From df33f01e989bb29e14d4e96f39e117ae987abbc1 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Tue, 14 Jul 2026 05:27:24 +0000 Subject: [PATCH 1/4] feat: add per-template Coder Agents access control --- coderd/apidoc/docs.go | 10 ++ coderd/apidoc/swagger.json | 10 ++ coderd/searchquery/search.go | 1 + coderd/searchquery/search_test.go | 14 ++ coderd/telemetry/telemetry.go | 2 + coderd/telemetry/telemetry_test.go | 18 ++ coderd/templates.go | 8 +- coderd/templates_meta_update.go | 2 + coderd/templates_meta_update_internal_test.go | 9 + coderd/templates_test.go | 34 ++++ coderd/x/chatd/chatd.go | 42 +---- coderd/x/chatd/chatd_test.go | 31 ++-- coderd/x/chatd/chattool/chattool.go | 14 -- coderd/x/chatd/chattool/createworkspace.go | 8 +- .../chattool/createworkspace_internal_test.go | 73 ++++++++ coderd/x/chatd/chattool/listtemplates.go | 23 +-- coderd/x/chatd/chattool/listtemplates_test.go | 157 ++++++------------ coderd/x/chatd/chattool/readtemplate.go | 10 +- codersdk/organizations.go | 3 + codersdk/templates.go | 2 + docs/reference/api/schemas.md | 7 + docs/reference/api/templatebuilder.md | 1 + docs/reference/api/templates.md | 10 ++ site/src/api/typesGenerated.ts | 6 + .../TemplateSettingsPage.test.tsx | 1 + site/src/testHelpers/entities.ts | 1 + 26 files changed, 292 insertions(+), 205 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 58160272203..3c8f7af53a5 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -19066,6 +19066,10 @@ const docTemplate = `{ "description": "ActivityBumpMillis allows optionally specifying the activity bump\nduration for all workspaces created from this template. Defaults to 1h\nbut can be set to 0 to disable activity bumping.", "type": "integer" }, + "agents_allowed": { + "description": "AgentsAllowed controls whether Coder Agents can use this template. It defaults to true.", + "type": "boolean" + }, "allow_user_autostart": { "description": "AllowUserAutostart allows users to set a schedule for autostarting their\nworkspace. By default this is true. This can only be disabled when using\nan enterprise license.", "type": "boolean" @@ -24612,6 +24616,9 @@ const docTemplate = `{ "activity_bump_ms": { "type": "integer" }, + "agents_allowed": { + "type": "boolean" + }, "allow_user_autostart": { "description": "AllowUserAutostart and AllowUserAutostop are enterprise-only. Their\nvalues are only used if your license is entitled to use the advanced\ntemplate scheduling feature.", "type": "boolean" @@ -26000,6 +26007,9 @@ const docTemplate = `{ "description": "ActivityBumpMillis allows optionally specifying the activity bump\nduration for all workspaces created from this template. Defaults to 1h\nbut can be set to 0 to disable activity bumping.", "type": "integer" }, + "agents_allowed": { + "type": "boolean" + }, "allow_user_autostart": { "type": "boolean" }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 1ec365ae8ec..e0834dcd94a 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -17243,6 +17243,10 @@ "description": "ActivityBumpMillis allows optionally specifying the activity bump\nduration for all workspaces created from this template. Defaults to 1h\nbut can be set to 0 to disable activity bumping.", "type": "integer" }, + "agents_allowed": { + "description": "AgentsAllowed controls whether Coder Agents can use this template. It defaults to true.", + "type": "boolean" + }, "allow_user_autostart": { "description": "AllowUserAutostart allows users to set a schedule for autostarting their\nworkspace. By default this is true. This can only be disabled when using\nan enterprise license.", "type": "boolean" @@ -22583,6 +22587,9 @@ "activity_bump_ms": { "type": "integer" }, + "agents_allowed": { + "type": "boolean" + }, "allow_user_autostart": { "description": "AllowUserAutostart and AllowUserAutostop are enterprise-only. Their\nvalues are only used if your license is entitled to use the advanced\ntemplate scheduling feature.", "type": "boolean" @@ -23896,6 +23903,9 @@ "description": "ActivityBumpMillis allows optionally specifying the activity bump\nduration for all workspaces created from this template. Defaults to 1h\nbut can be set to 0 to disable activity bumping.", "type": "integer" }, + "agents_allowed": { + "type": "boolean" + }, "allow_user_autostart": { "type": "boolean" }, diff --git a/coderd/searchquery/search.go b/coderd/searchquery/search.go index f8e7dd64b6a..4849c55a39e 100644 --- a/coderd/searchquery/search.go +++ b/coderd/searchquery/search.go @@ -347,6 +347,7 @@ func Templates(ctx context.Context, db database.Store, actorID uuid.UUID, query IDs: parser.UUIDs(values, []uuid.UUID{}, "ids"), Deprecated: parser.NullableBoolean(values, sql.NullBool{}, "deprecated"), HasAITask: parser.NullableBoolean(values, sql.NullBool{}, "has-ai-task"), + AgentsAllowed: parser.NullableBoolean(values, sql.NullBool{}, "agents-allowed"), AuthorID: parser.UUID(values, uuid.Nil, "author_id"), AuthorUsername: parser.String(values, "", "author"), HasExternalAgent: parser.NullableBoolean(values, sql.NullBool{}, "has_external_agent"), diff --git a/coderd/searchquery/search_test.go b/coderd/searchquery/search_test.go index 77299416ef0..67be2c8ec37 100644 --- a/coderd/searchquery/search_test.go +++ b/coderd/searchquery/search_test.go @@ -982,6 +982,20 @@ func TestSearchTemplates(t *testing.T) { }, }, }, + { + Name: "AgentsAllowedTrue", + Query: "agents-allowed:true", + Expected: database.GetTemplatesWithFilterParams{ + AgentsAllowed: sql.NullBool{Bool: true, Valid: true}, + }, + }, + { + Name: "AgentsAllowedFalse", + Query: "agents-allowed:false", + Expected: database.GetTemplatesWithFilterParams{ + AgentsAllowed: sql.NullBool{Bool: false, Valid: true}, + }, + }, { Name: "MyTemplates", Query: "author:me", diff --git a/coderd/telemetry/telemetry.go b/coderd/telemetry/telemetry.go index 6bff04bf262..28e69a248b3 100644 --- a/coderd/telemetry/telemetry.go +++ b/coderd/telemetry/telemetry.go @@ -1512,6 +1512,7 @@ func ConvertTemplate(dbTemplate database.Template) Template { AutostopRequirementWeeks: dbTemplate.AutostopRequirementWeeks, AutostartAllowedDays: codersdk.BitmapToWeekdays(dbTemplate.AutostartAllowedDays()), RequireActiveVersion: dbTemplate.RequireActiveVersion, + AgentsAllowed: dbTemplate.AgentsAllowed, Deprecated: dbTemplate.Deprecated != "", UseClassicParameterFlow: ptr.Ref(dbTemplate.UseClassicParameterFlow), } @@ -1855,6 +1856,7 @@ type Template struct { AutostopRequirementWeeks int64 `json:"autostop_requirement_weeks"` AutostartAllowedDays []string `json:"autostart_allowed_days"` RequireActiveVersion bool `json:"require_active_version"` + AgentsAllowed bool `json:"agents_allowed"` Deprecated bool `json:"deprecated"` UseClassicParameterFlow *bool `json:"use_classic_parameter_flow"` } diff --git a/coderd/telemetry/telemetry_test.go b/coderd/telemetry/telemetry_test.go index f3f65a20b8c..875882388f4 100644 --- a/coderd/telemetry/telemetry_test.go +++ b/coderd/telemetry/telemetry_test.go @@ -44,6 +44,24 @@ func TestMain(m *testing.M) { goleak.VerifyTestMain(m, testutil.GoleakOptions...) } +func TestConvertTemplateAgentsAllowed(t *testing.T) { + t.Parallel() + + for _, tt := range []struct { + name string + allowed bool + }{ + {name: "Allowed", allowed: true}, + {name: "Disallowed", allowed: false}, + } { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := telemetry.ConvertTemplate(database.Template{AgentsAllowed: tt.allowed}) + require.Equal(t, tt.allowed, got.AgentsAllowed) + }) + } +} + func TestTelemetry(t *testing.T) { t.Parallel() t.Run("Snapshot", func(t *testing.T) { diff --git a/coderd/templates.go b/coderd/templates.go index 28edf1d8eb5..c8a48a0b5e4 100644 --- a/coderd/templates.go +++ b/coderd/templates.go @@ -212,6 +212,7 @@ func (api *API) postTemplateByOrganization(rw http.ResponseWriter, r *http.Reque // Default is false as dynamic parameters are now the preferred approach. useClassicParameterFlow := ptr.NilToDefault(createTemplate.UseClassicParameterFlow, false) + agentsAllowed := ptr.NilToDefault(createTemplate.AgentsAllowed, true) // Make a temporary struct to represent the template. This is used for // auditing if any of the following checks fail. It will be overwritten when @@ -224,7 +225,7 @@ func (api *API) postTemplateByOrganization(rw http.ResponseWriter, r *http.Reque Icon: createTemplate.Icon, DisplayName: createTemplate.DisplayName, UseClassicParameterFlow: useClassicParameterFlow, - AgentsAllowed: true, + AgentsAllowed: agentsAllowed, } _, err := api.Database.GetTemplateByOrganizationAndName(ctx, database.GetTemplateByOrganizationAndNameParams{ @@ -448,7 +449,7 @@ func (api *API) postTemplateByOrganization(rw http.ResponseWriter, r *http.Reque MaxPortSharingLevel: maxPortShareLevel, UseClassicParameterFlow: useClassicParameterFlow, CorsBehavior: corsBehavior, - AgentsAllowed: true, + AgentsAllowed: agentsAllowed, }) if err != nil { return xerrors.Errorf("insert template: %s", err) @@ -780,7 +781,7 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { UseClassicParameterFlow: resolved.useClassicTemplateFlow, CorsBehavior: resolved.corsBehavior, DisableModuleCache: resolved.disableModuleCache, - AgentsAllowed: template.AgentsAllowed, + AgentsAllowed: resolved.agentsAllowed, }) if err != nil { return xerrors.Errorf("update template metadata: %w", err) @@ -1057,6 +1058,7 @@ func (api *API) convertTemplate( UseClassicParameterFlow: template.UseClassicParameterFlow, CORSBehavior: codersdk.CORSBehavior(template.CorsBehavior), DisableModuleCache: template.DisableModuleCache, + AgentsAllowed: template.AgentsAllowed, } } diff --git a/coderd/templates_meta_update.go b/coderd/templates_meta_update.go index f2398378377..2a16505629b 100644 --- a/coderd/templates_meta_update.go +++ b/coderd/templates_meta_update.go @@ -29,6 +29,7 @@ type templateMetaUpdate struct { allowUserAutostart bool allowUserAutostop bool allowUserCancelWorkspaceJobs bool + agentsAllowed bool requireActiveVersion bool deprecationMessage string useClassicTemplateFlow bool @@ -81,6 +82,7 @@ func resolveTemplateMetaUpdate( allowUserAutostart: ptr.NilToDefault(req.AllowUserAutostart, template.AllowUserAutostart), allowUserAutostop: ptr.NilToDefault(req.AllowUserAutostop, template.AllowUserAutostop), allowUserCancelWorkspaceJobs: ptr.NilToDefault(req.AllowUserCancelWorkspaceJobs, template.AllowUserCancelWorkspaceJobs), + agentsAllowed: ptr.NilToDefault(req.AgentsAllowed, template.AgentsAllowed), requireActiveVersion: ptr.NilToDefault(req.RequireActiveVersion, template.RequireActiveVersion), deprecationMessage: ptr.NilToDefault(req.DeprecationMessage, template.Deprecated), useClassicTemplateFlow: ptr.NilToDefault(req.UseClassicParameterFlow, template.UseClassicParameterFlow), diff --git a/coderd/templates_meta_update_internal_test.go b/coderd/templates_meta_update_internal_test.go index 91966365910..8afb3418cd2 100644 --- a/coderd/templates_meta_update_internal_test.go +++ b/coderd/templates_meta_update_internal_test.go @@ -28,6 +28,7 @@ func baselineTemplate() database.Template { AllowUserAutostart: false, AllowUserAutostop: false, AllowUserCancelWorkspaceJobs: false, + AgentsAllowed: true, RequireActiveVersion: true, DefaultTTL: int64(60 * 60 * 1000 * 1000 * 1000), // 1 hour in ns ActivityBump: int64(30 * 60 * 1000 * 1000 * 1000), // 30 minutes in ns @@ -81,6 +82,7 @@ func baselineResolved() templateMetaUpdate { allowUserAutostart: tpl.AllowUserAutostart, allowUserAutostop: tpl.AllowUserAutostop, allowUserCancelWorkspaceJobs: tpl.AllowUserCancelWorkspaceJobs, + agentsAllowed: tpl.AgentsAllowed, requireActiveVersion: tpl.RequireActiveVersion, deprecationMessage: tpl.Deprecated, useClassicTemplateFlow: tpl.UseClassicParameterFlow, @@ -225,6 +227,13 @@ func TestResolveTemplateMetaUpdate(t *testing.T) { r.allowUserCancelWorkspaceJobs = true }}, }, + { + name: "AgentsAllowed", + req: codersdk.UpdateTemplateMeta{AgentsAllowed: ptr.Ref(false)}, + expected: expected{override: func(r *templateMetaUpdate) { + r.agentsAllowed = false + }}, + }, { name: "FailureTTLMillis", req: codersdk.UpdateTemplateMeta{FailureTTLMillis: ptr.Ref(int64(3_600_000))}, diff --git a/coderd/templates_test.go b/coderd/templates_test.go index dbe82329b8d..5ca78a66a7a 100644 --- a/coderd/templates_test.go +++ b/coderd/templates_test.go @@ -121,6 +121,8 @@ func TestPostTemplateByOrganization(t *testing.T) { assert.Equal(t, expected.ActivityBumpMillis, got.ActivityBumpMillis) assert.Equal(t, expected.TimeTilAutostopNotifyMillis, got.TimeTilAutostopNotifyMillis) assert.Equal(t, expected.UseClassicParameterFlow, false) // Current default is false + assert.True(t, expected.AgentsAllowed) + assert.True(t, got.AgentsAllowed) require.Len(t, auditor.AuditLogs(), 3) assert.Equal(t, database.AuditActionCreate, auditor.AuditLogs()[0].Action) @@ -1007,6 +1009,7 @@ func TestPatchTemplateMeta(t *testing.T) { ActivityBumpMillis: ptr.Ref(3 * time.Hour.Milliseconds()), TimeTilAutostopNotifyMillis: ptr.Ref(5 * time.Minute.Milliseconds()), AllowUserCancelWorkspaceJobs: ptr.Ref(false), + AgentsAllowed: ptr.Ref(false), } // It is unfortunate we need to sleep, but the test can fail if the // updatedAt is too close together. @@ -1025,6 +1028,7 @@ func TestPatchTemplateMeta(t *testing.T) { assert.Equal(t, *req.ActivityBumpMillis, updated.ActivityBumpMillis) assert.Equal(t, *req.TimeTilAutostopNotifyMillis, updated.TimeTilAutostopNotifyMillis) assert.False(t, *req.AllowUserCancelWorkspaceJobs) + assert.False(t, updated.AgentsAllowed) // Extra paranoid: did it _really_ happen? updated, err = client.Template(ctx, template.ID) @@ -1038,11 +1042,35 @@ func TestPatchTemplateMeta(t *testing.T) { assert.Equal(t, *req.ActivityBumpMillis, updated.ActivityBumpMillis) assert.Equal(t, *req.TimeTilAutostopNotifyMillis, updated.TimeTilAutostopNotifyMillis) assert.False(t, *req.AllowUserCancelWorkspaceJobs) + assert.False(t, updated.AgentsAllowed) require.Len(t, auditor.AuditLogs(), 5) assert.Equal(t, database.AuditActionWrite, auditor.AuditLogs()[4].Action) }) + t.Run("AgentsAllowedAuthorization", func(t *testing.T) { + t.Parallel() + + ownerClient := coderdtest.New(t, nil) + owner := coderdtest.CreateFirstUser(t, ownerClient) + templateAdminClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.ScopedRoleOrgTemplateAdmin(owner.OrganizationID)) + memberClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID) + version := coderdtest.CreateTemplateVersion(t, templateAdminClient, owner.OrganizationID, nil) + template := coderdtest.CreateTemplate(t, templateAdminClient, owner.OrganizationID, version.ID) + ctx := testutil.Context(t, testutil.WaitLong) + + updated, err := templateAdminClient.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ + AgentsAllowed: ptr.Ref(false), + }) + require.NoError(t, err) + assert.False(t, updated.AgentsAllowed) + + _, err = memberClient.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ + AgentsAllowed: ptr.Ref(true), + }) + require.Error(t, err) + }) + t.Run("AlreadyExists", func(t *testing.T) { t.Parallel() @@ -1960,6 +1988,7 @@ func TestPatchTemplateMeta(t *testing.T) { ctr.Icon = "/icon/original.png" ctr.DefaultTTLMillis = ptr.Ref((24 * time.Hour).Milliseconds()) ctr.AllowUserCancelWorkspaceJobs = ptr.Ref(true) + ctr.AgentsAllowed = ptr.Ref(false) }) ctx := testutil.Context(t, testutil.WaitLong) @@ -1975,6 +2004,7 @@ func TestPatchTemplateMeta(t *testing.T) { assert.Equal(t, template.Icon, updated.Icon) assert.Equal(t, template.DefaultTTLMillis, updated.DefaultTTLMillis) assert.Equal(t, template.AllowUserCancelWorkspaceJobs, updated.AllowUserCancelWorkspaceJobs) + assert.Equal(t, template.AgentsAllowed, updated.AgentsAllowed) assert.Equal(t, template.RequireActiveVersion, updated.RequireActiveVersion) }) @@ -1991,9 +2021,11 @@ func TestPatchTemplateMeta(t *testing.T) { version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil) template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { ctr.AllowUserCancelWorkspaceJobs = ptr.Ref(true) + ctr.AgentsAllowed = ptr.Ref(false) ctr.DefaultTTLMillis = ptr.Ref((24 * time.Hour).Milliseconds()) }) require.True(t, template.AllowUserCancelWorkspaceJobs) + require.False(t, template.AgentsAllowed) require.Equal(t, (24 * time.Hour).Milliseconds(), template.DefaultTTLMillis) ctx := testutil.Context(t, testutil.WaitLong) @@ -2006,6 +2038,7 @@ func TestPatchTemplateMeta(t *testing.T) { }) require.NoError(t, err) assert.Equal(t, newTTL, updated.DefaultTTLMillis) + assert.False(t, updated.AgentsAllowed, "omitted agents field must not be overwritten") assert.True(t, updated.AllowUserCancelWorkspaceJobs, "omitted bool field must not be overwritten") // Conversely, sending only AllowUserCancelWorkspaceJobs must not zero @@ -2015,6 +2048,7 @@ func TestPatchTemplateMeta(t *testing.T) { }) require.NoError(t, err) assert.False(t, updated.AllowUserCancelWorkspaceJobs) + assert.False(t, updated.AgentsAllowed, "unrelated patch must preserve agents field") assert.Equal(t, newTTL, updated.DefaultTTLMillis, "omitted int64 field must not be overwritten") }) } diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index 05e0aff7503..9d37df09967 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -35,7 +35,6 @@ import ( coderdpubsub "github.com/coder/coder/v2/coderd/pubsub" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/util/ptr" - "github.com/coder/coder/v2/coderd/util/xjson" "github.com/coder/coder/v2/coderd/webpush" "github.com/coder/coder/v2/coderd/workspacestats" "github.com/coder/coder/v2/coderd/x/agenthooks/dispatch" @@ -208,36 +207,6 @@ type Server struct { chatHeartbeatInterval time.Duration } -// chatTemplateAllowlist returns the deployment-wide template -// allowlist as a set of permitted template IDs. The callback -// signature matches what the chat tools expect. When the -// allowlist is empty or cannot be loaded the function returns -// nil, which the tools interpret as "all templates allowed". -func (p *Server) chatTemplateAllowlist() map[uuid.UUID]bool { - //nolint:gocritic // AsChatd provides narrowly-scoped daemon - // access for reading deployment config. - ctx, cancel := context.WithTimeout(context.Background(), time.Second) - defer cancel() - //nolint:gocritic // AsChatd provides narrowly-scoped read - // access to deployment config (the template allowlist). - ctx = dbauthz.AsChatd(ctx) - raw, err := p.db.GetChatTemplateAllowlist(ctx) - if err != nil { - p.logger.Warn(ctx, "failed to load chat template allowlist", slog.Error(err)) - return nil - } - ids, err := xjson.ParseUUIDList(raw) - if err != nil { - p.logger.Warn(ctx, "failed to parse chat template allowlist", slog.Error(err)) - return nil - } - m := make(map[uuid.UUID]bool, len(ids)) - for _, id := range ids { - m[id] = true - } - return m -} - func (p *Server) loadAdvisorConfig(ctx context.Context, logger slog.Logger) codersdk.AdvisorConfig { cfg, err := p.configCache.AdvisorConfig(ctx) if err != nil { @@ -3867,14 +3836,12 @@ func (p *Server) appendRootChatTools( tools = append(tools, chattool.ListTemplates(p.db, opts.chat.OrganizationID, chattool.ListTemplatesOptions{ - OwnerID: opts.chat.OwnerID, - Logger: p.logger, - Clock: p.clock, - AllowedTemplateIDs: p.chatTemplateAllowlist, + OwnerID: opts.chat.OwnerID, + Logger: p.logger, + Clock: p.clock, }), chattool.ReadTemplate(p.db, opts.chat.OrganizationID, chattool.ReadTemplateOptions{ - OwnerID: opts.chat.OwnerID, - AllowedTemplateIDs: p.chatTemplateAllowlist, + OwnerID: opts.chat.OwnerID, }), chattool.CreateWorkspace(p.db, opts.chat.OrganizationID, opts.chat.ID, chattool.CreateWorkspaceOptions{ OwnerID: opts.chat.OwnerID, @@ -3884,7 +3851,6 @@ func (p *Server) appendRootChatTools( WorkspaceMu: opts.workspaceMu, OnChatUpdated: onChatUpdated, Logger: p.logger, - AllowedTemplateIDs: p.chatTemplateAllowlist, }), chattool.StartWorkspace(p.db, opts.chat.ID, chattool.StartWorkspaceOptions{ OwnerID: opts.chat.OwnerID, diff --git a/coderd/x/chatd/chatd_test.go b/coderd/x/chatd/chatd_test.go index cfb9c51f447..cf74a445d52 100644 --- a/coderd/x/chatd/chatd_test.go +++ b/coderd/x/chatd/chatd_test.go @@ -10929,11 +10929,11 @@ func TestMCPServerOAuth2TokenRefreshFailureGraceful(t *testing.T) { "original token should be preserved when refresh fails") } -func TestChatTemplateAllowlistEnforcement(t *testing.T) { +func TestChatTemplateAgentsAllowedEnforcement(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) - db, ps := dbtestutil.NewDB(t) + db, ps, sqlDB := dbtestutil.NewDBWithSQLDB(t) // Declare templates before the handler so the closure can // reference their IDs when building tool-call arguments. @@ -10991,18 +10991,15 @@ func TestChatTemplateAllowlistEnforcement(t *testing.T) { Name: "blocked-template", }) - // Set the allowlist to only tplAllowed. - allowlistJSON, err := json.Marshal([]string{tplAllowed.ID.String()}) - require.NoError(t, err) - err = db.UpsertChatTemplateAllowlist(dbauthz.AsSystemRestricted(ctx), string(allowlistJSON)) + // Block tplBlocked for Coder Agents. + _, err := sqlDB.ExecContext(ctx, `UPDATE templates SET agents_allowed = false WHERE id = $1`, tplBlocked.ID) require.NoError(t, err) server := newActiveTestServer(t, db, ps, func(cfg *chatd.Config) { cfg.AIBridgeTransportFactory = chatAIGatewayTransportFactoryPointer(chattest.NewMockAIBridgeTransport(t, openAIURL)) - // Provide a CreateWorkspace function so the tool reaches - // the allowlist check instead of bailing with "not - // configured". If the allowlist is enforced correctly - // this function will never be called. + // Provide a CreateWorkspace function so the tool reaches the template + // access check instead of returning "not configured". The blocked + // template must be rejected before this function is called. cfg.CreateWorkspace = func( _ context.Context, _ uuid.UUID, @@ -11016,10 +11013,10 @@ func TestChatTemplateAllowlistEnforcement(t *testing.T) { chat, err := server.CreateChat(ctx, chatd.CreateOptions{ OrganizationID: org.ID, OwnerID: user.ID, - Title: "allowlist-test", + Title: "template-access-test", ModelConfigID: model.ID, InitialUserContent: []codersdk.ChatMessagePart{ - codersdk.ChatMessageText("Test allowlist enforcement"), + codersdk.ChatMessageText("Test template access enforcement"), }, }) require.NoError(t, err) @@ -11076,15 +11073,15 @@ func TestChatTemplateAllowlistEnforcement(t *testing.T) { require.Contains(t, toolResults["list_templates"][0], tplAllowed.ID.String(), "allowed template should appear in list_templates result") require.NotContains(t, toolResults["list_templates"][0], tplBlocked.ID.String(), - "blocked template should NOT appear in list_templates result") + "blocked template should not appear in list_templates result") - // read_template: blocked ID → error, allowed ID → success. - require.Contains(t, toolResults["read_template"][0], "not found", - "read_template for blocked template should return not-found error") + // read_template: the blocked row fails and the allowed row succeeds. + require.Contains(t, toolResults["read_template"][0], "not available", + "read_template for blocked template should return an actionable error") require.Contains(t, toolResults["read_template"][1], tplAllowed.ID.String(), "read_template for allowed template should return template details") - // create_workspace: blocked ID → rejected. + // create_workspace: the blocked row is rejected. require.Contains(t, toolResults["create_workspace"][0], "not available", "create_workspace for blocked template should be rejected") } diff --git a/coderd/x/chatd/chattool/chattool.go b/coderd/x/chatd/chattool/chattool.go index 6f7adadcdfb..ef5240d1b61 100644 --- a/coderd/x/chatd/chattool/chattool.go +++ b/coderd/x/chatd/chattool/chattool.go @@ -165,17 +165,3 @@ func setNoBuild(result map[string]any, buildID uuid.UUID) { result["no_build"] = true } } - -// isTemplateAllowed checks whether a template ID is permitted by the -// configured allowlist. A nil function or an empty allowlist means -// all templates are allowed. -func isTemplateAllowed(getAllowlist func() map[uuid.UUID]bool, id uuid.UUID) bool { - if getAllowlist == nil { - return true - } - allowlist := getAllowlist() - if len(allowlist) == 0 { - return true - } - return allowlist[id] -} diff --git a/coderd/x/chatd/chattool/createworkspace.go b/coderd/x/chatd/chattool/createworkspace.go index 9b740fe378c..8984689bc03 100644 --- a/coderd/x/chatd/chattool/createworkspace.go +++ b/coderd/x/chatd/chattool/createworkspace.go @@ -71,7 +71,6 @@ type CreateWorkspaceOptions struct { WorkspaceMu *sync.Mutex OnChatUpdated func(database.Chat) Logger slog.Logger - AllowedTemplateIDs func() map[uuid.UUID]bool } type createWorkspaceArgs struct { @@ -117,10 +116,6 @@ func CreateWorkspace(db database.Store, organizationID, chatID uuid.UUID, option ), nil } - if !isTemplateAllowed(options.AllowedTemplateIDs, templateID) { - return fantasy.NewTextErrorResponse("template not available for chat workspaces; use list_templates to find allowed templates"), nil - } - // Serialize workspace creation to prevent parallel // tool calls from creating duplicate workspaces. if options.WorkspaceMu != nil { @@ -173,6 +168,9 @@ func CreateWorkspace(db database.Store, organizationID, chatID uuid.UUID, option "use list_templates to find templates in the correct organization", ), nil } + if !tmpl.AgentsAllowed { + return fantasy.NewTextErrorResponse("template not available for chat workspaces; use list_templates to find allowed templates"), nil + } hasExternalAgent, externalAgentErr := templateHasExternalAgent(ctx, db, tmpl) if externalAgentErr != nil { diff --git a/coderd/x/chatd/chattool/createworkspace_internal_test.go b/coderd/x/chatd/chattool/createworkspace_internal_test.go index 13f009d6686..c049caa8d9a 100644 --- a/coderd/x/chatd/chattool/createworkspace_internal_test.go +++ b/coderd/x/chatd/chattool/createworkspace_internal_test.go @@ -261,6 +261,7 @@ func TestCreateWorkspace_PrefersChatSuffixAgent(t *testing.T) { Return(database.Template{ ID: templateID, OrganizationID: orgID, + AgentsAllowed: true, }, nil) db.EXPECT(). @@ -362,6 +363,7 @@ func TestCreateWorkspace_ReturnsSelectionErrorImmediately(t *testing.T) { Return(database.Template{ ID: templateID, OrganizationID: orgID, + AgentsAllowed: true, }, nil) db.EXPECT(). GetChatWorkspaceTTL(gomock.Any()). @@ -472,6 +474,7 @@ func TestCreateWorkspace_PostCreationBuildFailure(t *testing.T) { Return(database.Template{ ID: templateID, OrganizationID: orgID, + AgentsAllowed: true, }, nil) db.EXPECT(). @@ -571,6 +574,7 @@ func TestCreateWorkspace_PostCreationQuotaFailure(t *testing.T) { Return(database.Template{ ID: templateID, OrganizationID: orgID, + AgentsAllowed: true, }, nil) db.EXPECT(). @@ -811,6 +815,7 @@ func TestCreateWorkspace_ResponderErrorPreservesStructuredFields(t *testing.T) { Return(database.Template{ ID: templateID, OrganizationID: orgID, + AgentsAllowed: true, }, nil) db.EXPECT(). @@ -988,6 +993,7 @@ func TestCreateWorkspace_GlobalTTL(t *testing.T) { Return(database.Template{ ID: templateID, OrganizationID: orgID, + AgentsAllowed: true, }, nil) db.EXPECT(). @@ -1091,6 +1097,7 @@ func TestCreateWorkspace_RejectsCrossOrgTemplate(t *testing.T) { Return(database.Template{ ID: templateID, OrganizationID: templateOrgID, + AgentsAllowed: true, Name: "wrong-org-template", }, nil) @@ -1117,6 +1124,68 @@ func TestCreateWorkspace_RejectsCrossOrgTemplate(t *testing.T) { require.Contains(t, resp.Content, "organization") } +func TestCreateWorkspace_ReturnsExistingWorkspaceBeforeTemplateValidation(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + db := newCreateWorkspaceMockStore(ctrl) + + chatID := uuid.New() + workspaceID := uuid.New() + jobID := uuid.New() + agentID := uuid.New() + now := time.Now().UTC() + + expectExistingWorkspaceLookup( + db, + chatID, + workspaceID, + jobID, + "existing-workspace", + database.ProvisionerJobStatusSucceeded, + database.WorkspaceTransitionStart, + ) + db.EXPECT(). + GetWorkspaceAgentsInLatestBuildByWorkspaceID(gomock.Any(), workspaceID). + Return([]database.WorkspaceAgent{{ + ID: agentID, + Name: "dev", + CreatedAt: now.Add(-time.Minute), + FirstConnectedAt: validNullTime(now.Add(-45 * time.Second)), + LastConnectedAt: validNullTime(now.Add(-5 * time.Second)), + }}, nil) + db.EXPECT(). + GetWorkspaceAgentLifecycleStateByID(gomock.Any(), agentID). + Return(database.GetWorkspaceAgentLifecycleStateByIDRow{ + LifecycleState: database.WorkspaceAgentLifecycleStateReady, + }, nil) + + tool := CreateWorkspace(db, uuid.New(), chatID, CreateWorkspaceOptions{ + OwnerID: uuid.New(), + CreateFn: func(context.Context, uuid.UUID, codersdk.CreateWorkspaceRequest) (codersdk.Workspace, error) { + t.Fatal("CreateFn should not be called when the chat already has a workspace") + return codersdk.Workspace{}, nil + }, + WorkspaceMu: &sync.Mutex{}, + AgentInactiveDisconnectTimeout: time.Minute, + Logger: slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}), + }) + + input := fmt.Sprintf(`{"template_id":%q}`, uuid.New().String()) + resp, err := tool.Run(context.Background(), fantasy.ToolCall{ + ID: "call-1", + Name: "create_workspace", + Input: input, + }) + require.NoError(t, err) + require.False(t, resp.IsError) + + var result map[string]any + require.NoError(t, json.Unmarshal([]byte(resp.Content), &result)) + require.Equal(t, "already_exists", result["status"]) + require.Equal(t, "existing-workspace", result["workspace_name"]) +} + func TestCreateWorkspace_BlocksExternalTemplate(t *testing.T) { t.Parallel() @@ -1145,6 +1214,7 @@ func TestCreateWorkspace_BlocksExternalTemplate(t *testing.T) { Return(database.Template{ ID: templateID, OrganizationID: orgID, + AgentsAllowed: true, ActiveVersionID: activeVersionID, }, nil) db.EXPECT(). @@ -1553,6 +1623,7 @@ func TestWaitForBuild_CanceledJob(t *testing.T) { Return(database.Template{ ID: templateID, OrganizationID: orgID, + AgentsAllowed: true, }, nil) db.EXPECT(). @@ -1770,6 +1841,7 @@ func TestCreateWorkspace_OnChatUpdatedFiresAfterBuild(t *testing.T) { Return(database.Template{ ID: templateID, OrganizationID: uuid.Nil, + AgentsAllowed: true, }, nil) db.EXPECT(). @@ -1913,6 +1985,7 @@ func setupCreateWorkspacePresetTest(t *testing.T) createWorkspacePresetTestSetup Return(database.Template{ ID: s.TemplateID, OrganizationID: s.OrgID, + AgentsAllowed: true, Name: "test-template", ActiveVersionID: uuid.New(), }, nil) diff --git a/coderd/x/chatd/chattool/listtemplates.go b/coderd/x/chatd/chattool/listtemplates.go index b13721f0281..a59b00d1fbd 100644 --- a/coderd/x/chatd/chattool/listtemplates.go +++ b/coderd/x/chatd/chattool/listtemplates.go @@ -4,7 +4,6 @@ import ( "cmp" "context" "database/sql" - "maps" "math" "slices" "strings" @@ -80,13 +79,11 @@ const ( ) // ListTemplatesOptions configures the list_templates tool. OwnerID is -// required; Clock defaults to a real clock when nil. AllowedTemplateIDs -// optionally restricts which templates can be returned. +// required; Clock defaults to a real clock when nil. type ListTemplatesOptions struct { - OwnerID uuid.UUID - Logger slog.Logger - Clock quartz.Clock - AllowedTemplateIDs func() map[uuid.UUID]bool + OwnerID uuid.UUID + Logger slog.Logger + Clock quartz.Clock } type listTemplatesArgs struct { @@ -140,14 +137,10 @@ func ListTemplates(db database.Store, organizationID uuid.UUID, options ListTemp Bool: false, Valid: true, }, - } - - var allowlist map[uuid.UUID]bool - if options.AllowedTemplateIDs != nil { - allowlist = options.AllowedTemplateIDs() - } - if len(allowlist) > 0 { - filterParams.IDs = slices.Collect(maps.Keys(allowlist)) + AgentsAllowed: sql.NullBool{ + Bool: true, + Valid: true, + }, } templates, err := db.GetTemplatesWithFilter(ctx, filterParams) if err != nil { diff --git a/coderd/x/chatd/chattool/listtemplates_test.go b/coderd/x/chatd/chattool/listtemplates_test.go index a9c3c7a9666..5d29beaa22c 100644 --- a/coderd/x/chatd/chattool/listtemplates_test.go +++ b/coderd/x/chatd/chattool/listtemplates_test.go @@ -714,10 +714,10 @@ func TestListTemplates_AmbiguousTopMatches(t *testing.T) { } //nolint:tparallel,paralleltest // Subtests share a single DB and run sequentially. -func TestTemplateAllowlistEnforcement(t *testing.T) { +func TestTemplateAgentsAllowedEnforcement(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) - db, _ := dbtestutil.NewDB(t) + db, _, sqlDB := dbtestutil.NewDBWithSQLDB(t) user := dbgen.User(t, db, database.User{}) org := dbgen.Organization(t, db, database.Organization{}) @@ -726,125 +726,66 @@ func TestTemplateAllowlistEnforcement(t *testing.T) { OrganizationID: org.ID, }) - t1 := dbgen.Template(t, db, database.Template{ + allowed := dbgen.Template(t, db, database.Template{ OrganizationID: org.ID, CreatedBy: user.ID, Name: "template-alpha", }) - t2 := dbgen.Template(t, db, database.Template{ + blocked := dbgen.Template(t, db, database.Template{ OrganizationID: org.ID, CreatedBy: user.ID, Name: "template-beta", }) + _, err := sqlDB.ExecContext(ctx, `UPDATE templates SET agents_allowed = false WHERE id = $1`, blocked.ID) + require.NoError(t, err) t.Run("ListTemplates", func(t *testing.T) { - t.Run("NoAllowlist", func(t *testing.T) { - tool := chattool.ListTemplates(db, uuid.Nil, chattool.ListTemplatesOptions{ - OwnerID: user.ID, - }) - - resp, err := tool.Run(ctx, fantasy.ToolCall{ID: "c1", Name: "list_templates", Input: "{}"}) - require.NoError(t, err) - var result map[string]any - require.NoError(t, json.Unmarshal([]byte(resp.Content), &result)) - templates := result["templates"].([]any) - require.Len(t, templates, 2) - }) - - t.Run("EmptyAllowlist", func(t *testing.T) { - tool := chattool.ListTemplates(db, uuid.Nil, chattool.ListTemplatesOptions{ - OwnerID: user.ID, - AllowedTemplateIDs: func() map[uuid.UUID]bool { return map[uuid.UUID]bool{} }, - }) - - resp, err := tool.Run(ctx, fantasy.ToolCall{ID: "c2", Name: "list_templates", Input: "{}"}) - require.NoError(t, err) - var result map[string]any - require.NoError(t, json.Unmarshal([]byte(resp.Content), &result)) - templates := result["templates"].([]any) - require.Len(t, templates, 2) - }) - - t.Run("OneMatch", func(t *testing.T) { - tool := chattool.ListTemplates(db, uuid.Nil, chattool.ListTemplatesOptions{ - OwnerID: user.ID, - AllowedTemplateIDs: func() map[uuid.UUID]bool { return map[uuid.UUID]bool{t1.ID: true} }, - }) - - resp, err := tool.Run(ctx, fantasy.ToolCall{ID: "c3", Name: "list_templates", Input: "{}"}) - require.NoError(t, err) - var result map[string]any - require.NoError(t, json.Unmarshal([]byte(resp.Content), &result)) - templates := result["templates"].([]any) - require.Len(t, templates, 1) - m := templates[0].(map[string]any) - require.Equal(t, t1.ID.String(), m["id"].(string)) - require.Equal(t, chattool.NextStepUseRecommended, result["next_step"]) - require.Equal(t, t1.ID.String(), result["recommended_template_id"]) + tool := chattool.ListTemplates(db, org.ID, chattool.ListTemplatesOptions{ + OwnerID: user.ID, }) - t.Run("NoMatches", func(t *testing.T) { - tool := chattool.ListTemplates(db, uuid.Nil, chattool.ListTemplatesOptions{ - OwnerID: user.ID, - AllowedTemplateIDs: func() map[uuid.UUID]bool { return map[uuid.UUID]bool{uuid.New(): true} }, - }) - - resp, err := tool.Run(ctx, fantasy.ToolCall{ID: "c4", Name: "list_templates", Input: "{}"}) - require.NoError(t, err) - var result map[string]any - require.NoError(t, json.Unmarshal([]byte(resp.Content), &result)) - templates := result["templates"].([]any) - require.Empty(t, templates) - require.Equal(t, chattool.NextStepNoTemplates, result["next_step"]) - _, ok := result["recommended_template_id"] - require.False(t, ok) - }) + resp, err := tool.Run(ctx, fantasy.ToolCall{ID: "c1", Name: "list_templates", Input: "{}"}) + require.NoError(t, err) + var result map[string]any + require.NoError(t, json.Unmarshal([]byte(resp.Content), &result)) + templates := result["templates"].([]any) + require.Len(t, templates, 1) + template := templates[0].(map[string]any) + require.Equal(t, allowed.ID.String(), template["id"]) + require.NotEqual(t, blocked.ID.String(), template["id"]) }) t.Run("ReadTemplate", func(t *testing.T) { t.Run("Allowed", func(t *testing.T) { tool := chattool.ReadTemplate(db, org.ID, chattool.ReadTemplateOptions{ - OwnerID: user.ID, - AllowedTemplateIDs: func() map[uuid.UUID]bool { return map[uuid.UUID]bool{t1.ID: true} }, + OwnerID: user.ID, }) - input := `{"template_id":"` + t1.ID.String() + `"}` - resp, err := tool.Run(ctx, fantasy.ToolCall{ID: "c5", Name: "read_template", Input: input}) + input := `{"template_id":"` + allowed.ID.String() + `"}` + resp, err := tool.Run(ctx, fantasy.ToolCall{ID: "c2", Name: "read_template", Input: input}) require.NoError(t, err) require.False(t, resp.IsError) var result map[string]any require.NoError(t, json.Unmarshal([]byte(resp.Content), &result)) - tmplInfo := result["template"].(map[string]any) - require.Equal(t, t1.ID.String(), tmplInfo["id"].(string)) - }) - - t.Run("Disallowed", func(t *testing.T) { - tool := chattool.ReadTemplate(db, org.ID, chattool.ReadTemplateOptions{ - OwnerID: user.ID, - AllowedTemplateIDs: func() map[uuid.UUID]bool { return map[uuid.UUID]bool{uuid.New(): true} }, - }) - input := `{"template_id":"` + t2.ID.String() + `"}` - resp, err := tool.Run(ctx, fantasy.ToolCall{ID: "c6", Name: "read_template", Input: input}) - require.NoError(t, err) - require.True(t, resp.IsError) - require.Contains(t, resp.Content, "not found") + template := result["template"].(map[string]any) + require.Equal(t, allowed.ID.String(), template["id"]) }) - t.Run("NoAllowlist", func(t *testing.T) { + t.Run("Blocked", func(t *testing.T) { tool := chattool.ReadTemplate(db, org.ID, chattool.ReadTemplateOptions{ OwnerID: user.ID, }) - input := `{"template_id":"` + t2.ID.String() + `"}` - resp, err := tool.Run(ctx, fantasy.ToolCall{ID: "c7", Name: "read_template", Input: input}) + input := `{"template_id":"` + blocked.ID.String() + `"}` + resp, err := tool.Run(ctx, fantasy.ToolCall{ID: "c3", Name: "read_template", Input: input}) require.NoError(t, err) - require.False(t, resp.IsError) + require.True(t, resp.IsError) + require.Equal(t, "template not available for chat workspaces; use list_templates to find allowed templates", resp.Content) }) }) + model := seedModelConfig(t, db) + t.Run("CreateWorkspace", func(t *testing.T) { t.Run("Allowed", func(t *testing.T) { - // CreateWorkspace requires a real chat row so the existing - // workspace lookup can fall through to creation. - model := seedModelConfig(t, db) chat, err := db.InsertChat(ctx, database.InsertChatParams{ OrganizationID: org.ID, OwnerID: user.ID, @@ -857,44 +798,46 @@ func TestTemplateAllowlistEnforcement(t *testing.T) { createCalled := false tool := chattool.CreateWorkspace(db, org.ID, chat.ID, chattool.CreateWorkspaceOptions{ - OwnerID: user.ID, - AllowedTemplateIDs: func() map[uuid.UUID]bool { return map[uuid.UUID]bool{t1.ID: true} }, - + OwnerID: user.ID, CreateFn: func(_ context.Context, _ uuid.UUID, _ codersdk.CreateWorkspaceRequest) (codersdk.Workspace, error) { createCalled = true return codersdk.Workspace{}, nil }, }) - input := `{"template_id":"` + t1.ID.String() + `"}` - resp, err := tool.Run(ctx, fantasy.ToolCall{ID: "c8a", Name: "create_workspace", Input: input}) + input := `{"template_id":"` + allowed.ID.String() + `"}` + _, err = tool.Run(ctx, fantasy.ToolCall{ID: "c4", Name: "create_workspace", Input: input}) require.NoError(t, err) - require.True(t, createCalled, "CreateFn should be called for allowed template") - // We don't assert resp.IsError here because CreateWorkspace - // does additional work (asOwner, workspace lookup) that - // depends on full RBAC setup. The key assertion is that - // the allowlist gate passed and CreateFn was invoked. - _ = resp + require.True(t, createCalled, "CreateFn should be called for an allowed template") }) - t.Run("Disallowed", func(t *testing.T) { + t.Run("Blocked", func(t *testing.T) { + chat, err := db.InsertChat(ctx, database.InsertChatParams{ + OrganizationID: org.ID, + OwnerID: user.ID, + LastModelConfigID: model.ID, + Title: "blocked-create", + Status: database.ChatStatusWaiting, + ClientType: database.ChatClientTypeApi, + }) + require.NoError(t, err) + var createCalled bool - tool := chattool.CreateWorkspace(db, org.ID, uuid.New(), chattool.CreateWorkspaceOptions{ - OwnerID: user.ID, - AllowedTemplateIDs: func() map[uuid.UUID]bool { return map[uuid.UUID]bool{t2.ID: true} }, + tool := chattool.CreateWorkspace(db, org.ID, chat.ID, chattool.CreateWorkspaceOptions{ + OwnerID: user.ID, CreateFn: func(_ context.Context, _ uuid.UUID, _ codersdk.CreateWorkspaceRequest) (codersdk.Workspace, error) { createCalled = true - t.Fatal("CreateFn should not be called for blocked template") + t.Fatal("CreateFn should not be called for a blocked template") return codersdk.Workspace{}, nil }, }) - input := `{"template_id":"` + t1.ID.String() + `"}` - resp, err := tool.Run(ctx, fantasy.ToolCall{ID: "c8", Name: "create_workspace", Input: input}) + input := `{"template_id":"` + blocked.ID.String() + `"}` + resp, err := tool.Run(ctx, fantasy.ToolCall{ID: "c5", Name: "create_workspace", Input: input}) require.NoError(t, err) require.True(t, resp.IsError) require.Contains(t, resp.Content, "template not available for chat workspaces") - require.False(t, createCalled, "CreateFn should not be called for blocked template") + require.False(t, createCalled, "CreateFn should not be called for a blocked template") }) }) } diff --git a/coderd/x/chatd/chattool/readtemplate.go b/coderd/x/chatd/chattool/readtemplate.go index ac592bff183..51ea98df8c5 100644 --- a/coderd/x/chatd/chattool/readtemplate.go +++ b/coderd/x/chatd/chattool/readtemplate.go @@ -18,8 +18,7 @@ const ReadTemplateReadmeMaxRunes = 8000 // ReadTemplateOptions configures the read_template tool. type ReadTemplateOptions struct { - OwnerID uuid.UUID - AllowedTemplateIDs func() map[uuid.UUID]bool + OwnerID uuid.UUID } type readTemplateArgs struct { @@ -50,10 +49,6 @@ func ReadTemplate(db database.Store, organizationID uuid.UUID, options ReadTempl ), nil } - if !isTemplateAllowed(options.AllowedTemplateIDs, templateID) { - return fantasy.NewTextErrorResponse("template not found"), nil - } - ctx, err = asOwner(ctx, db, options.OwnerID) if err != nil { return fantasy.NewTextErrorResponse(err.Error()), nil @@ -67,6 +62,9 @@ func ReadTemplate(db database.Store, organizationID uuid.UUID, options ReadTempl if template.OrganizationID != organizationID { return fantasy.NewTextErrorResponse("template not found"), nil } + if !template.AgentsAllowed { + return fantasy.NewTextErrorResponse("template not available for chat workspaces; use list_templates to find allowed templates"), nil + } params, err := db.GetTemplateVersionParameters(ctx, template.ActiveVersionID) if err != nil { diff --git a/codersdk/organizations.go b/codersdk/organizations.go index cc45e0b9155..593e1c9b8f5 100644 --- a/codersdk/organizations.go +++ b/codersdk/organizations.go @@ -229,6 +229,9 @@ type CreateTemplateRequest struct { // CORSBehavior allows optionally specifying the CORS behavior for all shared ports. CORSBehavior *CORSBehavior `json:"cors_behavior"` + + // AgentsAllowed controls whether Coder Agents can use this template. It defaults to true. + AgentsAllowed *bool `json:"agents_allowed,omitempty"` } // CreateWorkspaceRequest provides options for creating a new workspace. diff --git a/codersdk/templates.go b/codersdk/templates.go index 4a1d80f2702..83f6a5e1e22 100644 --- a/codersdk/templates.go +++ b/codersdk/templates.go @@ -68,6 +68,7 @@ type Template struct { CORSBehavior CORSBehavior `json:"cors_behavior"` UseClassicParameterFlow bool `json:"use_classic_parameter_flow"` + AgentsAllowed bool `json:"agents_allowed"` // DisableModuleCache disables the use of cached Terraform modules during // provisioning. @@ -282,6 +283,7 @@ type UpdateTemplateMeta struct { // DisableModuleCache disables the using of cached Terraform modules during // provisioning. It is recommended not to disable this. DisableModuleCache *bool `json:"disable_module_cache,omitempty"` + AgentsAllowed *bool `json:"agents_allowed,omitempty"` } type TemplateExample struct { diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index d327575fbfd..08a01c1e8fe 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -5058,6 +5058,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in ```json { "activity_bump_ms": 0, + "agents_allowed": true, "allow_user_autostart": true, "allow_user_autostop": true, "allow_user_cancel_workspace_jobs": true, @@ -5095,6 +5096,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | Name | Type | Required | Restrictions | Description | |---------------------------------------|--------------------------------------------------------------------------------|----------|--------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | `activity_bump_ms` | integer | false | | Activity bump ms allows optionally specifying the activity bump duration for all workspaces created from this template. Defaults to 1h but can be set to 0 to disable activity bumping. | +| `agents_allowed` | boolean | false | | Agents allowed controls whether Coder Agents can use this template. It defaults to true. | | `allow_user_autostart` | boolean | false | | Allow user autostart allows users to set a schedule for autostarting their workspace. By default this is true. This can only be disabled when using an enterprise license. | | `allow_user_autostop` | boolean | false | | Allow user autostop allows users to set a custom workspace TTL to use in place of the template's DefaultTTL field. By default this is true. If false, the DefaultTTL will always be used. This can only be disabled when using an enterprise license. | | `allow_user_cancel_workspace_jobs` | boolean | false | | Allow users to cancel in-progress workspace jobs. *bool as the default value is "true". | @@ -12319,6 +12321,7 @@ Only certain features set these fields: - FeatureManagedAgentLimit - FeatureAgen "active_user_count": 0, "active_version_id": "eae64611-bd53-4a80-bb77-df1e432c0fbc", "activity_bump_ms": 0, + "agents_allowed": true, "allow_user_autostart": true, "allow_user_autostop": true, "allow_user_cancel_workspace_jobs": true, @@ -12380,6 +12383,7 @@ Only certain features set these fields: - FeatureManagedAgentLimit - FeatureAgen | `active_user_count` | integer | false | | Active user count is set to -1 when loading. | | `active_version_id` | string | false | | | | `activity_bump_ms` | integer | false | | | +| `agents_allowed` | boolean | false | | | | `allow_user_autostart` | boolean | false | | Allow user autostart and AllowUserAutostop are enterprise-only. Their values are only used if your license is entitled to use the advanced template scheduling feature. | | `allow_user_autostop` | boolean | false | | | | `allow_user_cancel_workspace_jobs` | boolean | false | | | @@ -12783,6 +12787,7 @@ Restarts will only happen on weekdays in this list on weeks which line up with W "active_user_count": 0, "active_version_id": "eae64611-bd53-4a80-bb77-df1e432c0fbc", "activity_bump_ms": 0, + "agents_allowed": true, "allow_user_autostart": true, "allow_user_autostop": true, "allow_user_cancel_workspace_jobs": true, @@ -13977,6 +13982,7 @@ Restarts will only happen on weekdays in this list on weeks which line up with W ```json { "activity_bump_ms": 0, + "agents_allowed": true, "allow_user_autostart": true, "allow_user_autostop": true, "allow_user_cancel_workspace_jobs": true, @@ -14017,6 +14023,7 @@ Restarts will only happen on weekdays in this list on weeks which line up with W | Name | Type | Required | Restrictions | Description | |------------------------------------|--------------------------------------------------------------------------------|----------|--------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | `activity_bump_ms` | integer | false | | Activity bump ms allows optionally specifying the activity bump duration for all workspaces created from this template. Defaults to 1h but can be set to 0 to disable activity bumping. | +| `agents_allowed` | boolean | false | | | | `allow_user_autostart` | boolean | false | | | | `allow_user_autostop` | boolean | false | | | | `allow_user_cancel_workspace_jobs` | boolean | false | | | diff --git a/docs/reference/api/templatebuilder.md b/docs/reference/api/templatebuilder.md index 57dd44239d3..5172995b8f7 100644 --- a/docs/reference/api/templatebuilder.md +++ b/docs/reference/api/templatebuilder.md @@ -160,6 +160,7 @@ curl -X POST http://coder-server:8080/api/v2/templatebuilder/compose/template \ "active_user_count": 0, "active_version_id": "eae64611-bd53-4a80-bb77-df1e432c0fbc", "activity_bump_ms": 0, + "agents_allowed": true, "allow_user_autostart": true, "allow_user_autostop": true, "allow_user_cancel_workspace_jobs": true, diff --git a/docs/reference/api/templates.md b/docs/reference/api/templates.md index ade3daf8823..668b9ce8eea 100644 --- a/docs/reference/api/templates.md +++ b/docs/reference/api/templates.md @@ -33,6 +33,7 @@ To include deprecated templates, specify `deprecated:true` in the search query. "active_user_count": 0, "active_version_id": "eae64611-bd53-4a80-bb77-df1e432c0fbc", "activity_bump_ms": 0, + "agents_allowed": true, "allow_user_autostart": true, "allow_user_autostop": true, "allow_user_cancel_workspace_jobs": true, @@ -104,6 +105,7 @@ Status Code **200** | `» active_user_count` | integer | false | | Active user count is set to -1 when loading. | | `» active_version_id` | string(uuid) | false | | | | `» activity_bump_ms` | integer | false | | | +| `» agents_allowed` | boolean | false | | | | `» allow_user_autostart` | boolean | false | | Allow user autostart and AllowUserAutostop are enterprise-only. Their values are only used if your license is entitled to use the advanced template scheduling feature. | | `» allow_user_autostop` | boolean | false | | | | `» allow_user_cancel_workspace_jobs` | boolean | false | | | @@ -174,6 +176,7 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/templa ```json { "activity_bump_ms": 0, + "agents_allowed": true, "allow_user_autostart": true, "allow_user_autostop": true, "allow_user_cancel_workspace_jobs": true, @@ -222,6 +225,7 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/templa "active_user_count": 0, "active_version_id": "eae64611-bd53-4a80-bb77-df1e432c0fbc", "activity_bump_ms": 0, + "agents_allowed": true, "allow_user_autostart": true, "allow_user_autostop": true, "allow_user_cancel_workspace_jobs": true, @@ -375,6 +379,7 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/templat "active_user_count": 0, "active_version_id": "eae64611-bd53-4a80-bb77-df1e432c0fbc", "activity_bump_ms": 0, + "agents_allowed": true, "allow_user_autostart": true, "allow_user_autostop": true, "allow_user_cancel_workspace_jobs": true, @@ -798,6 +803,7 @@ To include deprecated templates, specify `deprecated:true` in the search query. "active_user_count": 0, "active_version_id": "eae64611-bd53-4a80-bb77-df1e432c0fbc", "activity_bump_ms": 0, + "agents_allowed": true, "allow_user_autostart": true, "allow_user_autostop": true, "allow_user_cancel_workspace_jobs": true, @@ -869,6 +875,7 @@ Status Code **200** | `» active_user_count` | integer | false | | Active user count is set to -1 when loading. | | `» active_version_id` | string(uuid) | false | | | | `» activity_bump_ms` | integer | false | | | +| `» agents_allowed` | boolean | false | | | | `» allow_user_autostart` | boolean | false | | Allow user autostart and AllowUserAutostop are enterprise-only. Their values are only used if your license is entitled to use the advanced template scheduling feature. | | `» allow_user_autostop` | boolean | false | | | | `» allow_user_cancel_workspace_jobs` | boolean | false | | | @@ -1004,6 +1011,7 @@ curl -X GET http://coder-server:8080/api/v2/templates/{template} \ "active_user_count": 0, "active_version_id": "eae64611-bd53-4a80-bb77-df1e432c0fbc", "activity_bump_ms": 0, + "agents_allowed": true, "allow_user_autostart": true, "allow_user_autostop": true, "allow_user_cancel_workspace_jobs": true, @@ -1129,6 +1137,7 @@ curl -X PATCH http://coder-server:8080/api/v2/templates/{template} \ ```json { "activity_bump_ms": 0, + "agents_allowed": true, "allow_user_autostart": true, "allow_user_autostop": true, "allow_user_cancel_workspace_jobs": true, @@ -1180,6 +1189,7 @@ curl -X PATCH http://coder-server:8080/api/v2/templates/{template} \ "active_user_count": 0, "active_version_id": "eae64611-bd53-4a80-bb77-df1e432c0fbc", "activity_bump_ms": 0, + "agents_allowed": true, "allow_user_autostart": true, "allow_user_autostop": true, "allow_user_cancel_workspace_jobs": true, diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 95bfe8039da..1e5abb7823a 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -4108,6 +4108,10 @@ export interface CreateTemplateRequest { * CORSBehavior allows optionally specifying the CORS behavior for all shared ports. */ readonly cors_behavior: CORSBehavior | null; + /** + * AgentsAllowed controls whether Coder Agents can use this template. It defaults to true. + */ + readonly agents_allowed?: boolean; } // From codersdk/templateversions.go @@ -8792,6 +8796,7 @@ export interface Template { readonly max_port_share_level: WorkspaceAgentPortShareLevel; readonly cors_behavior: CORSBehavior; readonly use_classic_parameter_flow: boolean; + readonly agents_allowed: boolean; /** * DisableModuleCache disables the use of cached Terraform modules during * provisioning. @@ -9778,6 +9783,7 @@ export interface UpdateTemplateMeta { * provisioning. It is recommended not to disable this. */ readonly disable_module_cache?: boolean; + readonly agents_allowed?: boolean; } // From codersdk/users.go diff --git a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.test.tsx b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.test.tsx index ce90944727a..33b952a2040 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.test.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.test.tsx @@ -17,6 +17,7 @@ const validFormValues: FormValues = { description: "A description", icon: "vscode.png", allow_user_cancel_workspace_jobs: false, + agents_allowed: true, allow_user_autostart: false, allow_user_autostop: false, autostop_requirement: { diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 2b3892a6b85..65a1ff3bbea 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -949,6 +949,7 @@ export const MockTemplate: TypesGen.Template = { created_by_name: "test_creator", icon: "/icon/code.svg", allow_user_cancel_workspace_jobs: true, + agents_allowed: true, failure_ttl_ms: 0, time_til_dormant_ms: 0, time_til_dormant_autodelete_ms: 0, From fa06b0822a69b1cc6c912356f43fcefe59c8ebdd Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Tue, 4 Aug 2026 13:13:06 +0000 Subject: [PATCH 2/4] review --- coderd/apidoc/docs.go | 3 +- coderd/apidoc/swagger.json | 3 +- coderd/templates_test.go | 23 ------ coderd/x/chatd/chatd_test.go | 10 +-- coderd/x/chatd/chattool/chattool.go | 2 + coderd/x/chatd/chattool/createworkspace.go | 2 +- coderd/x/chatd/chattool/listtemplates_test.go | 82 +++++++++++++++++-- coderd/x/chatd/chattool/readtemplate.go | 2 +- coderd/x/chatd/chattool/readtemplate_test.go | 6 ++ codersdk/organizations.go | 3 +- codersdk/templates.go | 4 +- docs/reference/api/schemas.md | 4 +- site/src/api/typesGenerated.ts | 7 +- 13 files changed, 108 insertions(+), 43 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 3c8f7af53a5..39e1a97f23e 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -19067,7 +19067,7 @@ const docTemplate = `{ "type": "integer" }, "agents_allowed": { - "description": "AgentsAllowed controls whether Coder Agents can use this template. It defaults to true.", + "description": "AgentsAllowed controls whether Coder Agents can create workspaces using\nthis template. Defaults to true.", "type": "boolean" }, "allow_user_autostart": { @@ -26008,6 +26008,7 @@ const docTemplate = `{ "type": "integer" }, "agents_allowed": { + "description": "AgentsAllowed controls whether Coder Agents can create workspaces using\nthis template. If omitted, the current value is preserved.", "type": "boolean" }, "allow_user_autostart": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index e0834dcd94a..e70e01bad93 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -17244,7 +17244,7 @@ "type": "integer" }, "agents_allowed": { - "description": "AgentsAllowed controls whether Coder Agents can use this template. It defaults to true.", + "description": "AgentsAllowed controls whether Coder Agents can create workspaces using\nthis template. Defaults to true.", "type": "boolean" }, "allow_user_autostart": { @@ -23904,6 +23904,7 @@ "type": "integer" }, "agents_allowed": { + "description": "AgentsAllowed controls whether Coder Agents can create workspaces using\nthis template. If omitted, the current value is preserved.", "type": "boolean" }, "allow_user_autostart": { diff --git a/coderd/templates_test.go b/coderd/templates_test.go index 5ca78a66a7a..2283a33d984 100644 --- a/coderd/templates_test.go +++ b/coderd/templates_test.go @@ -1048,29 +1048,6 @@ func TestPatchTemplateMeta(t *testing.T) { assert.Equal(t, database.AuditActionWrite, auditor.AuditLogs()[4].Action) }) - t.Run("AgentsAllowedAuthorization", func(t *testing.T) { - t.Parallel() - - ownerClient := coderdtest.New(t, nil) - owner := coderdtest.CreateFirstUser(t, ownerClient) - templateAdminClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.ScopedRoleOrgTemplateAdmin(owner.OrganizationID)) - memberClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID) - version := coderdtest.CreateTemplateVersion(t, templateAdminClient, owner.OrganizationID, nil) - template := coderdtest.CreateTemplate(t, templateAdminClient, owner.OrganizationID, version.ID) - ctx := testutil.Context(t, testutil.WaitLong) - - updated, err := templateAdminClient.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ - AgentsAllowed: ptr.Ref(false), - }) - require.NoError(t, err) - assert.False(t, updated.AgentsAllowed) - - _, err = memberClient.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ - AgentsAllowed: ptr.Ref(true), - }) - require.Error(t, err) - }) - t.Run("AlreadyExists", func(t *testing.T) { t.Parallel() diff --git a/coderd/x/chatd/chatd_test.go b/coderd/x/chatd/chatd_test.go index cf74a445d52..6eb9a29fcc3 100644 --- a/coderd/x/chatd/chatd_test.go +++ b/coderd/x/chatd/chatd_test.go @@ -10933,7 +10933,7 @@ func TestChatTemplateAgentsAllowedEnforcement(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) - db, ps, sqlDB := dbtestutil.NewDBWithSQLDB(t) + db, ps := dbtestutil.NewDB(t) // Declare templates before the handler so the closure can // reference their IDs when building tool-call arguments. @@ -10984,17 +10984,15 @@ func TestChatTemplateAgentsAllowedEnforcement(t *testing.T) { OrganizationID: org.ID, CreatedBy: user.ID, Name: "allowed-template", + AgentsAllowed: true, }) tplBlocked = dbgen.Template(t, db, database.Template{ OrganizationID: org.ID, CreatedBy: user.ID, Name: "blocked-template", + AgentsAllowed: false, }) - // Block tplBlocked for Coder Agents. - _, err := sqlDB.ExecContext(ctx, `UPDATE templates SET agents_allowed = false WHERE id = $1`, tplBlocked.ID) - require.NoError(t, err) - server := newActiveTestServer(t, db, ps, func(cfg *chatd.Config) { cfg.AIBridgeTransportFactory = chatAIGatewayTransportFactoryPointer(chattest.NewMockAIBridgeTransport(t, openAIURL)) // Provide a CreateWorkspace function so the tool reaches the template @@ -11142,6 +11140,7 @@ func TestChatAsksUserWhenListTemplatesRequiresSelection(t *testing.T) { Name: "code-2", DisplayName: "typescript-alpha", Description: "this is a long description", + AgentsAllowed: true, }) tplDocker = dbgen.Template(t, db, database.Template{ OrganizationID: org.ID, @@ -11149,6 +11148,7 @@ func TestChatAsksUserWhenListTemplatesRequiresSelection(t *testing.T) { Name: "docker", DisplayName: "Docker Containers", Description: "Provision Docker containers as Coder workspaces", + AgentsAllowed: true, }) server := newActiveTestServer(t, db, ps, func(cfg *chatd.Config) { diff --git a/coderd/x/chatd/chattool/chattool.go b/coderd/x/chatd/chattool/chattool.go index ef5240d1b61..dc99c384d79 100644 --- a/coderd/x/chatd/chattool/chattool.go +++ b/coderd/x/chatd/chattool/chattool.go @@ -14,6 +14,8 @@ import ( "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 { diff --git a/coderd/x/chatd/chattool/createworkspace.go b/coderd/x/chatd/chattool/createworkspace.go index 8984689bc03..3722f77e3cc 100644 --- a/coderd/x/chatd/chattool/createworkspace.go +++ b/coderd/x/chatd/chattool/createworkspace.go @@ -169,7 +169,7 @@ func CreateWorkspace(db database.Store, organizationID, chatID uuid.UUID, option ), nil } if !tmpl.AgentsAllowed { - return fantasy.NewTextErrorResponse("template not available for chat workspaces; use list_templates to find allowed templates"), nil + return fantasy.NewTextErrorResponse(templateNotAvailableMessage), nil } hasExternalAgent, externalAgentErr := templateHasExternalAgent(ctx, db, tmpl) diff --git a/coderd/x/chatd/chattool/listtemplates_test.go b/coderd/x/chatd/chattool/listtemplates_test.go index 5d29beaa22c..8c404217716 100644 --- a/coderd/x/chatd/chattool/listtemplates_test.go +++ b/coderd/x/chatd/chattool/listtemplates_test.go @@ -47,11 +47,15 @@ func TestListTemplates_OrganizationFilter(t *testing.T) { OrganizationID: orgA.ID, CreatedBy: user.ID, Name: "alpha", + + AgentsAllowed: true, }) tBeta := dbgen.Template(t, db, database.Template{ OrganizationID: orgB.ID, CreatedBy: user.ID, Name: "beta", + + AgentsAllowed: true, }) t.Run("ScopedToOrgA", func(t *testing.T) { @@ -149,18 +153,24 @@ func TestListTemplates_QueryMatchesDisplayNameAndDescription(t *testing.T) { CreatedBy: user.ID, Name: "tpl-42", DisplayName: "Data Science Lab", + + AgentsAllowed: true, }) descriptionTemplate := dbgen.Template(t, db, database.Template{ OrganizationID: org.ID, CreatedBy: user.ID, Name: "node-general", Description: "A JavaScript and TypeScript workspace.", + + AgentsAllowed: true, }) _ = dbgen.Template(t, db, database.Template{ OrganizationID: org.ID, CreatedBy: user.ID, Name: "unrelated", Description: "A plain Linux workspace.", + + AgentsAllowed: true, }) tool := chattool.ListTemplates(db, org.ID, chattool.ListTemplatesOptions{ @@ -211,22 +221,30 @@ func TestListTemplates_QueryScoreTiers(t *testing.T) { OrganizationID: org.ID, CreatedBy: user.ID, Name: "python", + + AgentsAllowed: true, }) prefix := dbgen.Template(t, db, database.Template{ OrganizationID: org.ID, CreatedBy: user.ID, Name: "python-alpha", + + AgentsAllowed: true, }) contains := dbgen.Template(t, db, database.Template{ OrganizationID: org.ID, CreatedBy: user.ID, Name: "go-python", + + AgentsAllowed: true, }) description := dbgen.Template(t, db, database.Template{ OrganizationID: org.ID, CreatedBy: user.ID, Name: "generic-dev", Description: "Python-capable general environment.", + + AgentsAllowed: true, }) tool := chattool.ListTemplates(db, org.ID, chattool.ListTemplatesOptions{ @@ -244,6 +262,8 @@ func TestListTemplates_QueryScoreTiers(t *testing.T) { OrganizationID: org.ID, CreatedBy: user.ID, Name: "python-gpu", + + AgentsAllowed: true, }) result = runListTemplates(ctx, t, tool, `{"query":"python gpu"}`) templates = listTemplateItems(t, result) @@ -255,6 +275,8 @@ func TestListTemplates_QueryScoreTiers(t *testing.T) { CreatedBy: user.ID, Name: "ml-tools", Description: "Includes machine-learning libraries.", + + AgentsAllowed: true, }) result = runListTemplates(ctx, t, tool, `{"query":"machine learning"}`) templates = listTemplateItems(t, result) @@ -279,6 +301,8 @@ func TestListTemplates_RanksAllCandidatesBeforePagination(t *testing.T) { OrganizationID: org.ID, CreatedBy: user.ID, Name: fmt.Sprintf("template-%02d", i), + + AgentsAllowed: true, }) if i == 10 { target = tpl @@ -329,12 +353,16 @@ func TestListTemplates_QueryRelevanceOutranksPersonalUsage(t *testing.T) { CreatedBy: user.ID, Name: "python-gpu", Description: "GPU workspace.", + + AgentsAllowed: true, }) used := dbgen.Template(t, db, database.Template{ OrganizationID: org.ID, CreatedBy: user.ID, Name: "generic-dev", Description: "Python-capable general environment.", + + AgentsAllowed: true, }) dbgen.Workspace(t, db, database.WorkspaceTable{ OwnerID: user.ID, @@ -370,11 +398,15 @@ func TestListTemplates_PersonalUsageBreaksEqualQueryScoreTie(t *testing.T) { OrganizationID: org.ID, CreatedBy: user.ID, Name: "python-alpha", + + AgentsAllowed: true, }) used := dbgen.Template(t, db, database.Template{ OrganizationID: org.ID, CreatedBy: user.ID, Name: "python-beta", + + AgentsAllowed: true, }) dbgen.Workspace(t, db, database.WorkspaceTable{ OwnerID: user.ID, @@ -410,11 +442,15 @@ func TestListTemplates_OrgPopularityFallback(t *testing.T) { OrganizationID: org.ID, CreatedBy: user.ID, Name: "popular-template", + + AgentsAllowed: true, }) lessPopular := dbgen.Template(t, db, database.Template{ OrganizationID: org.ID, CreatedBy: user.ID, Name: "less-popular-template", + + AgentsAllowed: true, }) for range 2 { otherUser := dbgen.User(t, db, database.User{}) @@ -458,11 +494,15 @@ func TestListTemplates_WeakOrgPopularityDoesNotRecommend(t *testing.T) { OrganizationID: org.ID, CreatedBy: user.ID, Name: "used-by-one", + + AgentsAllowed: true, }) unused := dbgen.Template(t, db, database.Template{ OrganizationID: org.ID, CreatedBy: user.ID, Name: "unused", + + AgentsAllowed: true, }) otherUser := dbgen.User(t, db, database.User{}) dbgen.Workspace(t, db, database.WorkspaceTable{ @@ -503,11 +543,15 @@ func TestListTemplates_StalePersonalUsageDoesNotRecommend(t *testing.T) { OrganizationID: org.ID, CreatedBy: user.ID, Name: "old-usage", + + AgentsAllowed: true, }) unused := dbgen.Template(t, db, database.Template{ OrganizationID: org.ID, CreatedBy: user.ID, Name: "unused", + + AgentsAllowed: true, }) dbgen.Workspace(t, db, database.WorkspaceTable{ OwnerID: user.ID, @@ -551,11 +595,15 @@ func TestListTemplates_StaleFrequentPersonalUsageDoesNotRecommend(t *testing.T) OrganizationID: org.ID, CreatedBy: user.ID, Name: "stale-usage", + + AgentsAllowed: true, }) unused := dbgen.Template(t, db, database.Template{ OrganizationID: org.ID, CreatedBy: user.ID, Name: "unused", + + AgentsAllowed: true, }) // Stale usage decays out of the personal signal despite its frequency. for range 2 { @@ -601,11 +649,15 @@ func TestListTemplates_RecentPersonalUsageRecommends(t *testing.T) { OrganizationID: org.ID, CreatedBy: user.ID, Name: "recent-usage", + + AgentsAllowed: true, }) unused := dbgen.Template(t, db, database.Template{ OrganizationID: org.ID, CreatedBy: user.ID, Name: "unused", + + AgentsAllowed: true, }) // Recent in-window usage is a confident signal. for range 2 { @@ -649,11 +701,15 @@ func TestListTemplates_DeletedRecentPersonalUsageShowsEvidence(t *testing.T) { OrganizationID: org.ID, CreatedBy: user.ID, Name: "deleted-usage", + + AgentsAllowed: true, }) unused := dbgen.Template(t, db, database.Template{ OrganizationID: org.ID, CreatedBy: user.ID, Name: "unused", + + AgentsAllowed: true, }) dbgen.Workspace(t, db, database.WorkspaceTable{ OwnerID: user.ID, @@ -695,11 +751,15 @@ func TestListTemplates_AmbiguousTopMatches(t *testing.T) { OrganizationID: org.ID, CreatedBy: user.ID, Name: "go-alpha", + + AgentsAllowed: true, }) _ = dbgen.Template(t, db, database.Template{ OrganizationID: org.ID, CreatedBy: user.ID, Name: "go-beta", + + AgentsAllowed: true, }) tool := chattool.ListTemplates(db, org.ID, chattool.ListTemplatesOptions{ @@ -717,7 +777,7 @@ func TestListTemplates_AmbiguousTopMatches(t *testing.T) { func TestTemplateAgentsAllowedEnforcement(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) - db, _, sqlDB := dbtestutil.NewDBWithSQLDB(t) + db, _ := dbtestutil.NewDB(t) user := dbgen.User(t, db, database.User{}) org := dbgen.Organization(t, db, database.Organization{}) @@ -730,14 +790,14 @@ func TestTemplateAgentsAllowedEnforcement(t *testing.T) { OrganizationID: org.ID, CreatedBy: user.ID, Name: "template-alpha", + AgentsAllowed: true, }) blocked := dbgen.Template(t, db, database.Template{ OrganizationID: org.ID, CreatedBy: user.ID, Name: "template-beta", + AgentsAllowed: false, }) - _, err := sqlDB.ExecContext(ctx, `UPDATE templates SET agents_allowed = false WHERE id = $1`, blocked.ID) - require.NoError(t, err) t.Run("ListTemplates", func(t *testing.T) { tool := chattool.ListTemplates(db, org.ID, chattool.ListTemplatesOptions{ @@ -836,7 +896,7 @@ func TestTemplateAgentsAllowedEnforcement(t *testing.T) { resp, err := tool.Run(ctx, fantasy.ToolCall{ID: "c5", Name: "create_workspace", Input: input}) require.NoError(t, err) require.True(t, resp.IsError) - require.Contains(t, resp.Content, "template not available for chat workspaces") + require.Equal(t, "template not available for chat workspaces; use list_templates to find allowed templates", resp.Content) require.False(t, createCalled, "CreateFn should not be called for a blocked template") }) }) @@ -874,6 +934,8 @@ func TestListTemplates_ReadmeExcerpt(t *testing.T) { CreatedBy: user.ID, Name: name, ActiveVersionID: tv.ID, + + AgentsAllowed: true, }) require.NoError(t, db.UpdateTemplateVersionByID(ctx, database.UpdateTemplateVersionByIDParams{ ID: tv.ID, @@ -898,6 +960,8 @@ func TestListTemplates_ReadmeExcerpt(t *testing.T) { CreatedBy: user.ID, Name: "missing-version", ActiveVersionID: uuid.New(), + + AgentsAllowed: true, }) // Run through a dbauthz-wrapped store so the tool executes under real RBAC as @@ -973,8 +1037,14 @@ func TestGetTemplateRankingSignalsByOwnerID(t *testing.T) { _ = dbgen.OrganizationMember(t, db, database.OrganizationMember{UserID: u, OrganizationID: org.ID}) } - used := dbgen.Template(t, db, database.Template{OrganizationID: org.ID, CreatedBy: user.ID, Name: "used"}) - unused := dbgen.Template(t, db, database.Template{OrganizationID: org.ID, CreatedBy: user.ID, Name: "unused"}) + used := dbgen.Template(t, db, database.Template{ + OrganizationID: org.ID, CreatedBy: user.ID, Name: "used", + AgentsAllowed: true, + }) + unused := dbgen.Template(t, db, database.Template{ + OrganizationID: org.ID, CreatedBy: user.ID, Name: "unused", + AgentsAllowed: true, + }) activeLastUsedAt := now.Add(-2 * 24 * time.Hour) deletedLastUsedAt := now.Add(-3 * 24 * time.Hour) diff --git a/coderd/x/chatd/chattool/readtemplate.go b/coderd/x/chatd/chattool/readtemplate.go index 51ea98df8c5..b52760865b5 100644 --- a/coderd/x/chatd/chattool/readtemplate.go +++ b/coderd/x/chatd/chattool/readtemplate.go @@ -63,7 +63,7 @@ func ReadTemplate(db database.Store, organizationID uuid.UUID, options ReadTempl return fantasy.NewTextErrorResponse("template not found"), nil } if !template.AgentsAllowed { - return fantasy.NewTextErrorResponse("template not available for chat workspaces; use list_templates to find allowed templates"), nil + return fantasy.NewTextErrorResponse(templateNotAvailableMessage), nil } params, err := db.GetTemplateVersionParameters(ctx, template.ActiveVersionID) diff --git a/coderd/x/chatd/chattool/readtemplate_test.go b/coderd/x/chatd/chattool/readtemplate_test.go index c9227409ff0..797787e5947 100644 --- a/coderd/x/chatd/chattool/readtemplate_test.go +++ b/coderd/x/chatd/chattool/readtemplate_test.go @@ -36,6 +36,8 @@ func TestReadTemplate_IncludesPresets(t *testing.T) { OrganizationID: org.ID, CreatedBy: user.ID, ActiveVersionID: tv.ID, + + AgentsAllowed: true, }) // Create a preset with parameters. @@ -161,6 +163,8 @@ func TestReadTemplate_NoPresets(t *testing.T) { OrganizationID: org.ID, CreatedBy: user.ID, ActiveVersionID: tv.ID, + + AgentsAllowed: true, }) ctx := testutil.Context(t, testutil.WaitShort) @@ -203,6 +207,8 @@ func TestReadTemplate_Readme(t *testing.T) { OrganizationID: org.ID, CreatedBy: user.ID, ActiveVersionID: activeVersionID, + + AgentsAllowed: true, }) ctx := testutil.Context(t, testutil.WaitShort) diff --git a/codersdk/organizations.go b/codersdk/organizations.go index 593e1c9b8f5..d50577be171 100644 --- a/codersdk/organizations.go +++ b/codersdk/organizations.go @@ -230,7 +230,8 @@ type CreateTemplateRequest struct { // CORSBehavior allows optionally specifying the CORS behavior for all shared ports. CORSBehavior *CORSBehavior `json:"cors_behavior"` - // AgentsAllowed controls whether Coder Agents can use this template. It defaults to true. + // AgentsAllowed controls whether Coder Agents can create workspaces using + // this template. Defaults to true. AgentsAllowed *bool `json:"agents_allowed,omitempty"` } diff --git a/codersdk/templates.go b/codersdk/templates.go index 83f6a5e1e22..3ae936577e7 100644 --- a/codersdk/templates.go +++ b/codersdk/templates.go @@ -283,7 +283,9 @@ type UpdateTemplateMeta struct { // DisableModuleCache disables the using of cached Terraform modules during // provisioning. It is recommended not to disable this. DisableModuleCache *bool `json:"disable_module_cache,omitempty"` - AgentsAllowed *bool `json:"agents_allowed,omitempty"` + // AgentsAllowed controls whether Coder Agents can create workspaces using + // this template. If omitted, the current value is preserved. + AgentsAllowed *bool `json:"agents_allowed,omitempty"` } type TemplateExample struct { diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 08a01c1e8fe..1dc9283ebea 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -5096,7 +5096,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | Name | Type | Required | Restrictions | Description | |---------------------------------------|--------------------------------------------------------------------------------|----------|--------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | `activity_bump_ms` | integer | false | | Activity bump ms allows optionally specifying the activity bump duration for all workspaces created from this template. Defaults to 1h but can be set to 0 to disable activity bumping. | -| `agents_allowed` | boolean | false | | Agents allowed controls whether Coder Agents can use this template. It defaults to true. | +| `agents_allowed` | boolean | false | | Agents allowed controls whether Coder Agents can create workspaces using this template. Defaults to true. | | `allow_user_autostart` | boolean | false | | Allow user autostart allows users to set a schedule for autostarting their workspace. By default this is true. This can only be disabled when using an enterprise license. | | `allow_user_autostop` | boolean | false | | Allow user autostop allows users to set a custom workspace TTL to use in place of the template's DefaultTTL field. By default this is true. If false, the DefaultTTL will always be used. This can only be disabled when using an enterprise license. | | `allow_user_cancel_workspace_jobs` | boolean | false | | Allow users to cancel in-progress workspace jobs. *bool as the default value is "true". | @@ -14023,7 +14023,7 @@ Restarts will only happen on weekdays in this list on weeks which line up with W | Name | Type | Required | Restrictions | Description | |------------------------------------|--------------------------------------------------------------------------------|----------|--------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | `activity_bump_ms` | integer | false | | Activity bump ms allows optionally specifying the activity bump duration for all workspaces created from this template. Defaults to 1h but can be set to 0 to disable activity bumping. | -| `agents_allowed` | boolean | false | | | +| `agents_allowed` | boolean | false | | Agents allowed controls whether Coder Agents can create workspaces using this template. If omitted, the current value is preserved. | | `allow_user_autostart` | boolean | false | | | | `allow_user_autostop` | boolean | false | | | | `allow_user_cancel_workspace_jobs` | boolean | false | | | diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 1e5abb7823a..0037abb3cad 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -4109,7 +4109,8 @@ export interface CreateTemplateRequest { */ readonly cors_behavior: CORSBehavior | null; /** - * AgentsAllowed controls whether Coder Agents can use this template. It defaults to true. + * AgentsAllowed controls whether Coder Agents can create workspaces using + * this template. Defaults to true. */ readonly agents_allowed?: boolean; } @@ -9783,6 +9784,10 @@ export interface UpdateTemplateMeta { * provisioning. It is recommended not to disable this. */ readonly disable_module_cache?: boolean; + /** + * AgentsAllowed controls whether Coder Agents can create workspaces using + * this template. If omitted, the current value is preserved. + */ readonly agents_allowed?: boolean; } From 9f4e86f15953ee812c8a6afbe3fac76873482c8c Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Wed, 5 Aug 2026 02:19:00 +0000 Subject: [PATCH 3/4] review --- coderd/x/chatd/chatd_test.go | 2 - coderd/x/chatd/chattool/listtemplates_test.go | 96 +++++++------------ coderd/x/chatd/chattool/readtemplate_test.go | 9 +- 3 files changed, 35 insertions(+), 72 deletions(-) diff --git a/coderd/x/chatd/chatd_test.go b/coderd/x/chatd/chatd_test.go index 6eb9a29fcc3..e8c004e5588 100644 --- a/coderd/x/chatd/chatd_test.go +++ b/coderd/x/chatd/chatd_test.go @@ -11073,13 +11073,11 @@ func TestChatTemplateAgentsAllowedEnforcement(t *testing.T) { require.NotContains(t, toolResults["list_templates"][0], tplBlocked.ID.String(), "blocked template should not appear in list_templates result") - // read_template: the blocked row fails and the allowed row succeeds. require.Contains(t, toolResults["read_template"][0], "not available", "read_template for blocked template should return an actionable error") require.Contains(t, toolResults["read_template"][1], tplAllowed.ID.String(), "read_template for allowed template should return template details") - // create_workspace: the blocked row is rejected. require.Contains(t, toolResults["create_workspace"][0], "not available", "create_workspace for blocked template should be rejected") } diff --git a/coderd/x/chatd/chattool/listtemplates_test.go b/coderd/x/chatd/chattool/listtemplates_test.go index 8c404217716..ec2786f4eef 100644 --- a/coderd/x/chatd/chattool/listtemplates_test.go +++ b/coderd/x/chatd/chattool/listtemplates_test.go @@ -47,15 +47,13 @@ func TestListTemplates_OrganizationFilter(t *testing.T) { OrganizationID: orgA.ID, CreatedBy: user.ID, Name: "alpha", - - AgentsAllowed: true, + AgentsAllowed: true, }) tBeta := dbgen.Template(t, db, database.Template{ OrganizationID: orgB.ID, CreatedBy: user.ID, Name: "beta", - - AgentsAllowed: true, + AgentsAllowed: true, }) t.Run("ScopedToOrgA", func(t *testing.T) { @@ -153,24 +151,21 @@ func TestListTemplates_QueryMatchesDisplayNameAndDescription(t *testing.T) { CreatedBy: user.ID, Name: "tpl-42", DisplayName: "Data Science Lab", - - AgentsAllowed: true, + AgentsAllowed: true, }) descriptionTemplate := dbgen.Template(t, db, database.Template{ OrganizationID: org.ID, CreatedBy: user.ID, Name: "node-general", Description: "A JavaScript and TypeScript workspace.", - - AgentsAllowed: true, + AgentsAllowed: true, }) _ = dbgen.Template(t, db, database.Template{ OrganizationID: org.ID, CreatedBy: user.ID, Name: "unrelated", Description: "A plain Linux workspace.", - - AgentsAllowed: true, + AgentsAllowed: true, }) tool := chattool.ListTemplates(db, org.ID, chattool.ListTemplatesOptions{ @@ -221,30 +216,26 @@ func TestListTemplates_QueryScoreTiers(t *testing.T) { OrganizationID: org.ID, CreatedBy: user.ID, Name: "python", - - AgentsAllowed: true, + AgentsAllowed: true, }) prefix := dbgen.Template(t, db, database.Template{ OrganizationID: org.ID, CreatedBy: user.ID, Name: "python-alpha", - - AgentsAllowed: true, + AgentsAllowed: true, }) contains := dbgen.Template(t, db, database.Template{ OrganizationID: org.ID, CreatedBy: user.ID, Name: "go-python", - - AgentsAllowed: true, + AgentsAllowed: true, }) description := dbgen.Template(t, db, database.Template{ OrganizationID: org.ID, CreatedBy: user.ID, Name: "generic-dev", Description: "Python-capable general environment.", - - AgentsAllowed: true, + AgentsAllowed: true, }) tool := chattool.ListTemplates(db, org.ID, chattool.ListTemplatesOptions{ @@ -262,8 +253,7 @@ func TestListTemplates_QueryScoreTiers(t *testing.T) { OrganizationID: org.ID, CreatedBy: user.ID, Name: "python-gpu", - - AgentsAllowed: true, + AgentsAllowed: true, }) result = runListTemplates(ctx, t, tool, `{"query":"python gpu"}`) templates = listTemplateItems(t, result) @@ -275,8 +265,7 @@ func TestListTemplates_QueryScoreTiers(t *testing.T) { CreatedBy: user.ID, Name: "ml-tools", Description: "Includes machine-learning libraries.", - - AgentsAllowed: true, + AgentsAllowed: true, }) result = runListTemplates(ctx, t, tool, `{"query":"machine learning"}`) templates = listTemplateItems(t, result) @@ -301,8 +290,7 @@ func TestListTemplates_RanksAllCandidatesBeforePagination(t *testing.T) { OrganizationID: org.ID, CreatedBy: user.ID, Name: fmt.Sprintf("template-%02d", i), - - AgentsAllowed: true, + AgentsAllowed: true, }) if i == 10 { target = tpl @@ -353,16 +341,14 @@ func TestListTemplates_QueryRelevanceOutranksPersonalUsage(t *testing.T) { CreatedBy: user.ID, Name: "python-gpu", Description: "GPU workspace.", - - AgentsAllowed: true, + AgentsAllowed: true, }) used := dbgen.Template(t, db, database.Template{ OrganizationID: org.ID, CreatedBy: user.ID, Name: "generic-dev", Description: "Python-capable general environment.", - - AgentsAllowed: true, + AgentsAllowed: true, }) dbgen.Workspace(t, db, database.WorkspaceTable{ OwnerID: user.ID, @@ -398,15 +384,13 @@ func TestListTemplates_PersonalUsageBreaksEqualQueryScoreTie(t *testing.T) { OrganizationID: org.ID, CreatedBy: user.ID, Name: "python-alpha", - - AgentsAllowed: true, + AgentsAllowed: true, }) used := dbgen.Template(t, db, database.Template{ OrganizationID: org.ID, CreatedBy: user.ID, Name: "python-beta", - - AgentsAllowed: true, + AgentsAllowed: true, }) dbgen.Workspace(t, db, database.WorkspaceTable{ OwnerID: user.ID, @@ -442,15 +426,13 @@ func TestListTemplates_OrgPopularityFallback(t *testing.T) { OrganizationID: org.ID, CreatedBy: user.ID, Name: "popular-template", - - AgentsAllowed: true, + AgentsAllowed: true, }) lessPopular := dbgen.Template(t, db, database.Template{ OrganizationID: org.ID, CreatedBy: user.ID, Name: "less-popular-template", - - AgentsAllowed: true, + AgentsAllowed: true, }) for range 2 { otherUser := dbgen.User(t, db, database.User{}) @@ -494,15 +476,13 @@ func TestListTemplates_WeakOrgPopularityDoesNotRecommend(t *testing.T) { OrganizationID: org.ID, CreatedBy: user.ID, Name: "used-by-one", - - AgentsAllowed: true, + AgentsAllowed: true, }) unused := dbgen.Template(t, db, database.Template{ OrganizationID: org.ID, CreatedBy: user.ID, Name: "unused", - - AgentsAllowed: true, + AgentsAllowed: true, }) otherUser := dbgen.User(t, db, database.User{}) dbgen.Workspace(t, db, database.WorkspaceTable{ @@ -543,15 +523,13 @@ func TestListTemplates_StalePersonalUsageDoesNotRecommend(t *testing.T) { OrganizationID: org.ID, CreatedBy: user.ID, Name: "old-usage", - - AgentsAllowed: true, + AgentsAllowed: true, }) unused := dbgen.Template(t, db, database.Template{ OrganizationID: org.ID, CreatedBy: user.ID, Name: "unused", - - AgentsAllowed: true, + AgentsAllowed: true, }) dbgen.Workspace(t, db, database.WorkspaceTable{ OwnerID: user.ID, @@ -595,15 +573,13 @@ func TestListTemplates_StaleFrequentPersonalUsageDoesNotRecommend(t *testing.T) OrganizationID: org.ID, CreatedBy: user.ID, Name: "stale-usage", - - AgentsAllowed: true, + AgentsAllowed: true, }) unused := dbgen.Template(t, db, database.Template{ OrganizationID: org.ID, CreatedBy: user.ID, Name: "unused", - - AgentsAllowed: true, + AgentsAllowed: true, }) // Stale usage decays out of the personal signal despite its frequency. for range 2 { @@ -649,15 +625,13 @@ func TestListTemplates_RecentPersonalUsageRecommends(t *testing.T) { OrganizationID: org.ID, CreatedBy: user.ID, Name: "recent-usage", - - AgentsAllowed: true, + AgentsAllowed: true, }) unused := dbgen.Template(t, db, database.Template{ OrganizationID: org.ID, CreatedBy: user.ID, Name: "unused", - - AgentsAllowed: true, + AgentsAllowed: true, }) // Recent in-window usage is a confident signal. for range 2 { @@ -701,15 +675,13 @@ func TestListTemplates_DeletedRecentPersonalUsageShowsEvidence(t *testing.T) { OrganizationID: org.ID, CreatedBy: user.ID, Name: "deleted-usage", - - AgentsAllowed: true, + AgentsAllowed: true, }) unused := dbgen.Template(t, db, database.Template{ OrganizationID: org.ID, CreatedBy: user.ID, Name: "unused", - - AgentsAllowed: true, + AgentsAllowed: true, }) dbgen.Workspace(t, db, database.WorkspaceTable{ OwnerID: user.ID, @@ -751,15 +723,13 @@ func TestListTemplates_AmbiguousTopMatches(t *testing.T) { OrganizationID: org.ID, CreatedBy: user.ID, Name: "go-alpha", - - AgentsAllowed: true, + AgentsAllowed: true, }) _ = dbgen.Template(t, db, database.Template{ OrganizationID: org.ID, CreatedBy: user.ID, Name: "go-beta", - - AgentsAllowed: true, + AgentsAllowed: true, }) tool := chattool.ListTemplates(db, org.ID, chattool.ListTemplatesOptions{ @@ -934,8 +904,7 @@ func TestListTemplates_ReadmeExcerpt(t *testing.T) { CreatedBy: user.ID, Name: name, ActiveVersionID: tv.ID, - - AgentsAllowed: true, + AgentsAllowed: true, }) require.NoError(t, db.UpdateTemplateVersionByID(ctx, database.UpdateTemplateVersionByIDParams{ ID: tv.ID, @@ -960,8 +929,7 @@ func TestListTemplates_ReadmeExcerpt(t *testing.T) { CreatedBy: user.ID, Name: "missing-version", ActiveVersionID: uuid.New(), - - AgentsAllowed: true, + AgentsAllowed: true, }) // Run through a dbauthz-wrapped store so the tool executes under real RBAC as diff --git a/coderd/x/chatd/chattool/readtemplate_test.go b/coderd/x/chatd/chattool/readtemplate_test.go index 797787e5947..22d40c8cd74 100644 --- a/coderd/x/chatd/chattool/readtemplate_test.go +++ b/coderd/x/chatd/chattool/readtemplate_test.go @@ -36,8 +36,7 @@ func TestReadTemplate_IncludesPresets(t *testing.T) { OrganizationID: org.ID, CreatedBy: user.ID, ActiveVersionID: tv.ID, - - AgentsAllowed: true, + AgentsAllowed: true, }) // Create a preset with parameters. @@ -163,8 +162,7 @@ func TestReadTemplate_NoPresets(t *testing.T) { OrganizationID: org.ID, CreatedBy: user.ID, ActiveVersionID: tv.ID, - - AgentsAllowed: true, + AgentsAllowed: true, }) ctx := testutil.Context(t, testutil.WaitShort) @@ -207,8 +205,7 @@ func TestReadTemplate_Readme(t *testing.T) { OrganizationID: org.ID, CreatedBy: user.ID, ActiveVersionID: activeVersionID, - - AgentsAllowed: true, + AgentsAllowed: true, }) ctx := testutil.Context(t, testutil.WaitShort) From 80be31ffd02531329bcf6e0ffc209fc516e14631 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Wed, 5 Aug 2026 05:11:13 +0000 Subject: [PATCH 4/4] review --- coderd/x/chatd/chatd_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/coderd/x/chatd/chatd_test.go b/coderd/x/chatd/chatd_test.go index e8c004e5588..53e85e65cab 100644 --- a/coderd/x/chatd/chatd_test.go +++ b/coderd/x/chatd/chatd_test.go @@ -11067,7 +11067,6 @@ func TestChatTemplateAgentsAllowedEnforcement(t *testing.T) { len(toolResults["create_workspace"]) >= 1 }, testutil.IntervalFast) - // list_templates: only the allowed template should appear. require.Contains(t, toolResults["list_templates"][0], tplAllowed.ID.String(), "allowed template should appear in list_templates result") require.NotContains(t, toolResults["list_templates"][0], tplBlocked.ID.String(),