Files
coder/coderd/x/chatd/chattool/chattool.go
T
Ethan 0ac23e3ee1 feat: add per-template Coder Agents access control (#27285)
Relates to CODAGT-713

Depends on #27284

This makes the per-template `agents_allowed` field authoritative in the API and chatd. It adds optional create and metadata update fields with the intended default and omission semantics, supports `agents-allowed:` template search, includes the value in telemetry, and makes `list_templates`, `read_template`, and `create_workspace` read the template row directly. Existing-workspace retries remain idempotent, and blocked same-organisation templates return an actionable message.

The experimental `/template-allowlist` routes remain temporarily because the shipped AI Settings page still calls them, but they no longer control chatd enforcement. #27514 moves that page to per-template metadata, #27515 removes the legacy storage, routes, SDK types, and utility, #27517 adds the CLI flags, and #27518 updates the platform controls documentation for the per-template model, directly addressing CRF-5 and CRF-6. The stack is intended to merge as a unit.
2026-08-06 14:14:37 +10:00

170 lines
4.6 KiB
Go

package chattool
import (
"context"
"encoding/json"
"unicode/utf8"
"charm.land/fantasy"
"github.com/google/uuid"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/codersdk"
)
const templateNotAvailableMessage = "template not available for chat workspaces; use list_templates to find allowed templates"
func marshalToolResponse(result any) fantasy.ToolResponse {
data, err := json.Marshal(result)
if err != nil {
return fantasy.NewTextResponse("{}")
}
return fantasy.NewTextResponse(string(data))
}
// toolResponse builds a fantasy.ToolResponse from a JSON-serializable
// result map. The map constraint ensures all tool results serialize
// to JSON objects so the frontend can safely parse them.
func toolResponse(result map[string]any) fantasy.ToolResponse {
return marshalToolResponse(result)
}
// buildToolResponse marshals a buildErrorResult into a tool response.
// Separate from toolResponse to keep the map[string]any constraint
// on the general helper while allowing typed error structs.
func buildToolResponse(r buildErrorResult) fantasy.ToolResponse {
return marshalToolResponse(r)
}
// responseErrorResult converts a codersdk.Response into a structured
// tool result. We return these via toolResponse rather than
// NewTextErrorResponse because the fantasy/chatprompt pipeline flattens
// IsError content into a single string and drops validation details.
func responseErrorResult(resp codersdk.Response) map[string]any {
message := resp.Message
if message == "" {
message = "request failed"
}
result := map[string]any{
"error": message,
}
if resp.Detail != "" {
result["detail"] = resp.Detail
}
if len(resp.Validations) > 0 {
result["validations"] = resp.Validations
}
return result
}
func latestWorkspaceBuildAndJob(
ctx context.Context,
db database.Store,
workspaceID uuid.UUID,
) (database.WorkspaceBuild, database.ProvisionerJob, error) {
build, err := db.GetLatestWorkspaceBuildByWorkspaceID(ctx, workspaceID)
if err != nil {
return database.WorkspaceBuild{}, database.ProvisionerJob{}, xerrors.Errorf("get latest build: %w", err)
}
job, err := db.GetProvisionerJobByID(ctx, build.JobID)
if err != nil {
return database.WorkspaceBuild{}, database.ProvisionerJob{}, xerrors.Errorf("get provisioner job: %w", err)
}
return build, job, nil
}
func publishBuildBinding(
ctx context.Context,
db database.Store,
logger slog.Logger,
chatID uuid.UUID,
workspaceID uuid.UUID,
buildID uuid.UUID,
onChatUpdated func(database.Chat),
) {
updatedChat, bindErr := db.UpdateChatWorkspaceBinding(ctx, database.UpdateChatWorkspaceBindingParams{
ID: chatID,
WorkspaceID: uuid.NullUUID{UUID: workspaceID, Valid: true},
BuildID: uuid.NullUUID{
UUID: buildID,
Valid: buildID != uuid.Nil,
},
AgentID: uuid.NullUUID{},
})
if bindErr != nil {
logger.Error(ctx, "failed to persist build ID on chat binding",
slog.F("chat_id", chatID),
slog.F("build_id", buildID),
slog.Error(bindErr),
)
return
}
if onChatUpdated != nil {
onChatUpdated(updatedChat)
}
}
func provisionerJobTerminal(status database.ProvisionerJobStatus) bool {
switch status {
case database.ProvisionerJobStatusSucceeded,
database.ProvisionerJobStatusFailed,
database.ProvisionerJobStatusCanceled:
return true
default:
return false
}
}
func truncateRunes(value string, maxLen int) string {
if maxLen <= 0 || value == "" {
return ""
}
if utf8.RuneCountInString(value) <= maxLen {
return value
}
runes := []rune(value)
if maxLen > len(runes) {
maxLen = len(runes)
}
return string(runes[:maxLen])
}
// buildErrorResult is a structured error response that preserves
// the build ID alongside the error message. This lets the frontend
// keep showing build logs when a build fails instead of losing
// them on the error transition.
type buildErrorResult struct {
Error string `json:"error"`
BuildID string `json:"build_id,omitempty"`
}
func newBuildError(msg string, buildID uuid.UUID) buildErrorResult {
r := buildErrorResult{Error: msg}
if buildID != uuid.Nil {
r.BuildID = buildID.String()
}
return r
}
// setBuildID adds the build_id field to a tool response map when
// the build ID is known (non-zero).
func setBuildID(result map[string]any, buildID uuid.UUID) {
if buildID != uuid.Nil {
result["build_id"] = buildID.String()
}
}
// setNoBuild marks the response with no_build: true when no build
// was triggered. The frontend uses this flag to suppress the
// build-log section for already-running workspaces.
func setNoBuild(result map[string]any, buildID uuid.UUID) {
if buildID == uuid.Nil {
result["no_build"] = true
}
}