chore: remove legacy chat template allowlist (#27515)

Relates to CODAGT-713

Depends on #27514

Removes the legacy deployment-wide allowlist now that the API and frontend use per-template `agents_allowed`: the experimental `/template-allowlist` routes, SDK methods and generated types, site config queries, frontend bindings, and the now-unused `xjson` utility.

Migration `000563` deletes the obsolete `agents_template_allowlist` value. It's irreversible for deployments that configured an allowlist, which I think is fine, since `000562` already drops `agents_allowed` on the way down, and this release ships `000548` and `000555` with the same property.

Two side effects of the model change worth writing down, both from #27514 rather than here. The value used to need `ActionRead` on `ResourceDeploymentConfig` to read and deployment config update to write. `AgentsAllowed` is now a plain field on the template response, readable by anyone who can read the template, and it's set with a template update, so org admins manage it themselves. That's the delegation we wanted, and it's tracked in the audit log.

The rest of the stack adds `--agents-allowed` to the CLI and updates the platform controls docs.
This commit is contained in:
Ethan
2026-08-06 14:35:37 +10:00
committed by GitHub
parent ac6c9ae45c
commit d2f9280138
17 changed files with 6 additions and 540 deletions
-2
View File
@@ -1398,8 +1398,6 @@ func New(options *Options) *API {
r.Put("/debug-retention-days", api.putChatDebugRetentionDays)
r.Get("/auto-archive-days", api.getChatAutoArchiveDays)
r.Put("/auto-archive-days", api.putChatAutoArchiveDays)
r.Get("/template-allowlist", api.getChatTemplateAllowlist)
r.Put("/template-allowlist", api.putChatTemplateAllowlist)
})
// TODO(cian): place under /api/experimental/chats/config
r.Route("/providers", func(r chi.Router) {
-18
View File
@@ -3555,17 +3555,6 @@ func (q *querier) GetChatSystemPromptConfig(ctx context.Context) (database.GetCh
return q.db.GetChatSystemPromptConfig(ctx)
}
// GetChatTemplateAllowlist requires deployment-config read permission,
// unlike the peer getters (GetChatDesktopEnabled, etc.) which only
// check actor presence. The allowlist is admin-configuration that
// should not be readable by non-admin users via the HTTP API.
func (q *querier) GetChatTemplateAllowlist(ctx context.Context) (string, error) {
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceDeploymentConfig); err != nil {
return "", err
}
return q.db.GetChatTemplateAllowlist(ctx)
}
func (q *querier) GetChatTitleGenerationModelOverride(ctx context.Context) (string, error) {
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceDeploymentConfig); err != nil {
return "", err
@@ -8914,13 +8903,6 @@ func (q *querier) UpsertChatSystemPrompt(ctx context.Context, value string) erro
return q.db.UpsertChatSystemPrompt(ctx, value)
}
func (q *querier) UpsertChatTemplateAllowlist(ctx context.Context, templateAllowlist string) error {
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil {
return err
}
return q.db.UpsertChatTemplateAllowlist(ctx, templateAllowlist)
}
func (q *querier) UpsertChatTitleGenerationModelOverride(ctx context.Context, value string) error {
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil {
return err
-8
View File
@@ -1158,10 +1158,6 @@ func (s *MethodTestSuite) TestChats() {
dbm.EXPECT().GetChatPlanModeInstructions(gomock.Any()).Return("", nil).AnyTimes()
check.Args().Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate)
}))
s.Run("GetChatTemplateAllowlist", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
dbm.EXPECT().GetChatTemplateAllowlist(gomock.Any()).Return("", nil).AnyTimes()
check.Args().Asserts(rbac.ResourceDeploymentConfig, policy.ActionRead)
}))
s.Run("GetChatWorkspaceTTL", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
dbm.EXPECT().GetChatWorkspaceTTL(gomock.Any()).Return("1h", nil).AnyTimes()
check.Args().Asserts()
@@ -1594,10 +1590,6 @@ func (s *MethodTestSuite) TestChats() {
dbm.EXPECT().UpsertChatPlanModeInstructions(gomock.Any(), "").Return(nil).AnyTimes()
check.Args("").Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate)
}))
s.Run("UpsertChatTemplateAllowlist", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
dbm.EXPECT().UpsertChatTemplateAllowlist(gomock.Any(), "").Return(nil).AnyTimes()
check.Args("").Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate)
}))
s.Run("UpsertChatWorkspaceTTL", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
dbm.EXPECT().UpsertChatWorkspaceTTL(gomock.Any(), "1h").Return(nil).AnyTimes()
check.Args("1h").Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate)
-16
View File
@@ -1769,14 +1769,6 @@ func (m queryMetricsStore) GetChatSystemPromptConfig(ctx context.Context) (datab
return r0, r1
}
func (m queryMetricsStore) GetChatTemplateAllowlist(ctx context.Context) (string, error) {
start := time.Now()
r0, r1 := m.s.GetChatTemplateAllowlist(ctx)
m.queryLatencies.WithLabelValues("GetChatTemplateAllowlist").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetChatTemplateAllowlist").Inc()
return r0, r1
}
func (m queryMetricsStore) GetChatTitleGenerationModelOverride(ctx context.Context) (string, error) {
start := time.Now()
r0, r1 := m.s.GetChatTitleGenerationModelOverride(ctx)
@@ -6305,14 +6297,6 @@ func (m queryMetricsStore) UpsertChatSystemPrompt(ctx context.Context, value str
return r0
}
func (m queryMetricsStore) UpsertChatTemplateAllowlist(ctx context.Context, templateAllowlist string) error {
start := time.Now()
r0 := m.s.UpsertChatTemplateAllowlist(ctx, templateAllowlist)
m.queryLatencies.WithLabelValues("UpsertChatTemplateAllowlist").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpsertChatTemplateAllowlist").Inc()
return r0
}
func (m queryMetricsStore) UpsertChatTitleGenerationModelOverride(ctx context.Context, value string) error {
start := time.Now()
r0 := m.s.UpsertChatTitleGenerationModelOverride(ctx, value)
-29
View File
@@ -3270,21 +3270,6 @@ func (mr *MockStoreMockRecorder) GetChatSystemPromptConfig(ctx any) *gomock.Call
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatSystemPromptConfig", reflect.TypeOf((*MockStore)(nil).GetChatSystemPromptConfig), ctx)
}
// GetChatTemplateAllowlist mocks base method.
func (m *MockStore) GetChatTemplateAllowlist(ctx context.Context) (string, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetChatTemplateAllowlist", ctx)
ret0, _ := ret[0].(string)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetChatTemplateAllowlist indicates an expected call of GetChatTemplateAllowlist.
func (mr *MockStoreMockRecorder) GetChatTemplateAllowlist(ctx any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatTemplateAllowlist", reflect.TypeOf((*MockStore)(nil).GetChatTemplateAllowlist), ctx)
}
// GetChatTitleGenerationModelOverride mocks base method.
func (m *MockStore) GetChatTitleGenerationModelOverride(ctx context.Context) (string, error) {
m.ctrl.T.Helper()
@@ -11804,20 +11789,6 @@ func (mr *MockStoreMockRecorder) UpsertChatSystemPrompt(ctx, value any) *gomock.
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertChatSystemPrompt", reflect.TypeOf((*MockStore)(nil).UpsertChatSystemPrompt), ctx, value)
}
// UpsertChatTemplateAllowlist mocks base method.
func (m *MockStore) UpsertChatTemplateAllowlist(ctx context.Context, templateAllowlist string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpsertChatTemplateAllowlist", ctx, templateAllowlist)
ret0, _ := ret[0].(error)
return ret0
}
// UpsertChatTemplateAllowlist indicates an expected call of UpsertChatTemplateAllowlist.
func (mr *MockStoreMockRecorder) UpsertChatTemplateAllowlist(ctx, templateAllowlist any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertChatTemplateAllowlist", reflect.TypeOf((*MockStore)(nil).UpsertChatTemplateAllowlist), ctx, templateAllowlist)
}
// UpsertChatTitleGenerationModelOverride mocks base method.
func (m *MockStore) UpsertChatTitleGenerationModelOverride(ctx context.Context, value string) error {
m.ctrl.T.Helper()
@@ -0,0 +1,2 @@
-- No-op. The up migration deleted the only copy of the deployment-wide
-- allowlist, so there is nothing left to restore.
@@ -0,0 +1,4 @@
-- 000563 copied this value into templates.agents_allowed. Deleting the row
-- makes the original list unrecoverable.
DELETE FROM site_configs
WHERE key = 'agents_template_allowlist';
-4
View File
@@ -507,9 +507,6 @@ type sqlcQuerier interface {
// non-empty custom prompt implied opting out before the explicit toggle
// existed.
GetChatSystemPromptConfig(ctx context.Context) (GetChatSystemPromptConfigRow, error)
// GetChatTemplateAllowlist returns the JSON-encoded template allowlist.
// Returns an empty string when no allowlist has been configured (all templates allowed).
GetChatTemplateAllowlist(ctx context.Context) (string, error)
GetChatTitleGenerationModelOverride(ctx context.Context) (string, error)
// Returns the concatenated text of each user-visible user prompt in a
// chat, newest first. Used by the composer to populate the up/down
@@ -1650,7 +1647,6 @@ type sqlcQuerier interface {
UpsertChatPlanModeInstructions(ctx context.Context, value string) error
UpsertChatRetentionDays(ctx context.Context, retentionDays int32) error
UpsertChatSystemPrompt(ctx context.Context, value string) error
UpsertChatTemplateAllowlist(ctx context.Context, templateAllowlist string) error
UpsertChatTitleGenerationModelOverride(ctx context.Context, value string) error
UpsertChatWorkspaceTTL(ctx context.Context, workspaceTtl string) error
// The default proxy is implied and not actually stored in the database.
-24
View File
@@ -24825,20 +24825,6 @@ func (q *sqlQuerier) GetChatSystemPromptConfig(ctx context.Context) (GetChatSyst
return i, err
}
const getChatTemplateAllowlist = `-- name: GetChatTemplateAllowlist :one
SELECT
COALESCE((SELECT value FROM site_configs WHERE key = 'agents_template_allowlist'), '') :: text AS template_allowlist
`
// GetChatTemplateAllowlist returns the JSON-encoded template allowlist.
// Returns an empty string when no allowlist has been configured (all templates allowed).
func (q *sqlQuerier) GetChatTemplateAllowlist(ctx context.Context) (string, error) {
row := q.db.QueryRowContext(ctx, getChatTemplateAllowlist)
var template_allowlist string
err := row.Scan(&template_allowlist)
return template_allowlist, err
}
const getChatTitleGenerationModelOverride = `-- name: GetChatTitleGenerationModelOverride :one
SELECT
COALESCE((SELECT value FROM site_configs WHERE key = 'agents_chat_title_generation_model_override'), '') :: text AS model_config_id
@@ -25265,16 +25251,6 @@ func (q *sqlQuerier) UpsertChatSystemPrompt(ctx context.Context, value string) e
return err
}
const upsertChatTemplateAllowlist = `-- name: UpsertChatTemplateAllowlist :exec
INSERT INTO site_configs (key, value) VALUES ('agents_template_allowlist', $1)
ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'agents_template_allowlist'
`
func (q *sqlQuerier) UpsertChatTemplateAllowlist(ctx context.Context, templateAllowlist string) error {
_, err := q.db.ExecContext(ctx, upsertChatTemplateAllowlist, templateAllowlist)
return err
}
const upsertChatTitleGenerationModelOverride = `-- name: UpsertChatTitleGenerationModelOverride :exec
INSERT INTO site_configs (key, value) VALUES ('agents_chat_title_generation_model_override', $1)
ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'agents_chat_title_generation_model_override'
-10
View File
@@ -313,12 +313,6 @@ SET value = CASE
END
WHERE site_configs.key = 'agents_chat_personal_model_overrides_enabled';
-- GetChatTemplateAllowlist returns the JSON-encoded template allowlist.
-- Returns an empty string when no allowlist has been configured (all templates allowed).
-- name: GetChatTemplateAllowlist :one
SELECT
COALESCE((SELECT value FROM site_configs WHERE key = 'agents_template_allowlist'), '') :: text AS template_allowlist;
-- GetChatIncludeDefaultSystemPrompt preserves the legacy default
-- for deployments created before the explicit include-default toggle.
-- When the toggle is unset, a non-empty custom prompt implies false;
@@ -360,10 +354,6 @@ SELECT
'0s'
)::text AS workspace_ttl;
-- name: UpsertChatTemplateAllowlist :exec
INSERT INTO site_configs (key, value) VALUES ('agents_template_allowlist', @template_allowlist)
ON CONFLICT (key) DO UPDATE SET value = @template_allowlist WHERE site_configs.key = 'agents_template_allowlist';
-- name: UpsertChatWorkspaceTTL :exec
INSERT INTO site_configs (key, value)
VALUES ('agents_workspace_ttl', @workspace_ttl::text)
-135
View File
@@ -42,7 +42,6 @@ import (
"github.com/coder/coder/v2/coderd/searchquery"
"github.com/coder/coder/v2/coderd/tracing"
"github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/coderd/util/xjson"
"github.com/coder/coder/v2/coderd/workspaceapps"
"github.com/coder/coder/v2/coderd/wsbuilder"
"github.com/coder/coder/v2/coderd/x/agenthooks/dispatch"
@@ -5593,140 +5592,6 @@ func (api *API) putChatAutoArchiveDays(rw http.ResponseWriter, r *http.Request)
rw.WriteHeader(http.StatusNoContent)
}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
//
//nolint:revive // get-return: revive assumes get* must be a getter, but this is an HTTP handler.
func (api *API) getChatTemplateAllowlist(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if !api.Authorize(r, policy.ActionRead, rbac.ResourceDeploymentConfig) {
httpapi.ResourceNotFound(rw)
return
}
raw, err := api.Database.GetChatTemplateAllowlist(ctx)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching chat template allowlist.",
Detail: err.Error(),
})
return
}
parsed, parseErr := xjson.ParseUUIDList(raw)
if parseErr != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Stored template allowlist is corrupt.",
Detail: parseErr.Error(),
})
return
}
ids := make([]string, len(parsed))
for i, id := range parsed {
ids[i] = id.String()
}
resp := codersdk.ChatTemplateAllowlist{
TemplateIDs: ids,
}
httpapi.Write(ctx, rw, http.StatusOK, resp)
}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
func (api *API) putChatTemplateAllowlist(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) {
httpapi.ResourceNotFound(rw)
return
}
var req codersdk.ChatTemplateAllowlist
if !httpapi.Read(ctx, rw, r, &req) {
return
}
// Validate all entries are valid UUIDs and deduplicate.
seen := make(map[string]struct{}, len(req.TemplateIDs))
deduped := make([]string, 0, len(req.TemplateIDs))
for _, id := range req.TemplateIDs {
parsed, err := uuid.Parse(id)
if err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid template ID in allowlist.",
Detail: fmt.Sprintf("%q is not a valid UUID.", id),
})
return
}
// Canonicalize to lowercase so deduplication is
// case-insensitive and stored values are consistent.
canonical := parsed.String()
if _, ok := seen[canonical]; !ok {
seen[canonical] = struct{}{}
deduped = append(deduped, canonical)
}
}
// Convert to UUIDs for the database query.
parsedUUIDs := make([]uuid.UUID, len(deduped))
for i, s := range deduped {
// Already validated above, safe to ignore error.
parsedUUIDs[i], _ = uuid.Parse(s)
}
raw, err := json.Marshal(deduped)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error encoding template allowlist.",
Detail: err.Error(),
})
return
}
err = api.Database.InTx(func(tx database.Store) error {
// Verify all IDs refer to existing, non-deprecated templates
// in a single query.
if len(parsedUUIDs) > 0 {
found, err := tx.GetTemplatesWithFilter(ctx, database.GetTemplatesWithFilterParams{
IDs: parsedUUIDs,
Deprecated: sql.NullBool{
Bool: false,
Valid: true,
},
})
if err != nil {
return xerrors.Errorf("fetch templates: %w", err)
}
if len(found) != len(parsedUUIDs) {
foundSet := make(map[uuid.UUID]struct{}, len(found))
for _, t := range found {
foundSet[t.ID] = struct{}{}
}
var missing []string
for _, id := range parsedUUIDs {
if _, ok := foundSet[id]; !ok {
missing = append(missing, id.String())
}
}
return xerrors.Errorf("templates not found or deprecated: %s", strings.Join(missing, ", "))
}
}
return tx.UpsertChatTemplateAllowlist(ctx, string(raw))
}, nil)
if err != nil {
// If the error mentions "not found or deprecated", it's a
// validation failure, not an internal error.
if strings.Contains(err.Error(), "not found or deprecated") {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "One or more templates not found or deprecated.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error updating chat template allowlist.",
Detail: err.Error(),
})
return
}
rw.WriteHeader(http.StatusNoContent)
}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
//
//nolint:revive // get-return: revive assumes get* must be a getter, but this is an HTTP handler.
-128
View File
@@ -14968,134 +14968,6 @@ func TestUserChatCompactionThresholds(t *testing.T) {
})
}
//nolint:tparallel // Subtests share a single coderdtest instance and run sequentially.
func TestChatTemplateAllowlist(t *testing.T) {
t.Parallel()
// Shared setup: one coderdtest instance with two real templates.
// Subtests that need valid template IDs use these.
client, store := newChatClientWithDatabase(t)
admin := coderdtest.CreateFirstUser(t, client.Client)
tmpl1 := dbgen.Template(t, store, database.Template{
OrganizationID: admin.OrganizationID,
CreatedBy: admin.UserID,
})
tmpl2 := dbgen.Template(t, store, database.Template{
OrganizationID: admin.OrganizationID,
CreatedBy: admin.UserID,
})
deprecatedTmpl := dbgen.Template(t, store, database.Template{
OrganizationID: admin.OrganizationID,
CreatedBy: admin.UserID,
})
//nolint:gocritic // Owner context needed to deprecate the template in test setup.
ownerRoles, err := rbac.RoleIdentifiers{rbac.RoleOwner()}.Expand()
require.NoError(t, err)
err = store.UpdateTemplateAccessControlByID(dbauthz.As(context.Background(), rbac.Subject{
ID: "owner",
Roles: rbac.Roles(ownerRoles),
Scope: rbac.ExpandableScope(rbac.ScopeAll),
}), database.UpdateTemplateAccessControlByIDParams{
ID: deprecatedTmpl.ID,
Deprecated: "this template is deprecated",
})
require.NoError(t, err, "deprecate template")
//nolint:paralleltest // Sequential: subtests share a single coderdtest instance.
t.Run("ReturnsEmptyWhenUnset", func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
resp, err := client.GetChatTemplateAllowlist(ctx)
require.NoError(t, err)
require.Empty(t, resp.TemplateIDs)
})
//nolint:paralleltest // Sequential: subtests share a single coderdtest instance.
t.Run("AdminCanSet", func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
ids := []string{tmpl1.ID.String(), tmpl2.ID.String()}
err := client.UpdateChatTemplateAllowlist(ctx, codersdk.ChatTemplateAllowlist{TemplateIDs: ids})
require.NoError(t, err)
resp, err := client.GetChatTemplateAllowlist(ctx)
require.NoError(t, err)
require.ElementsMatch(t, ids, resp.TemplateIDs)
})
//nolint:paralleltest // Sequential: subtests share a single coderdtest instance.
t.Run("AdminCanClear", func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
err := client.UpdateChatTemplateAllowlist(ctx, codersdk.ChatTemplateAllowlist{TemplateIDs: []string{}})
require.NoError(t, err)
resp, err := client.GetChatTemplateAllowlist(ctx)
require.NoError(t, err)
require.Empty(t, resp.TemplateIDs)
})
//nolint:paralleltest // Sequential: subtests share a single coderdtest instance.
t.Run("NonAdminReadFails", func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, admin.OrganizationID)
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
_, err := memberClient.GetChatTemplateAllowlist(ctx)
requireSDKError(t, err, http.StatusNotFound)
})
//nolint:paralleltest // Sequential: subtests share a single coderdtest instance.
t.Run("NonAdminWriteFails", func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, admin.OrganizationID)
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
// Uses a random UUID — hits 404 before template validation.
err := memberClient.UpdateChatTemplateAllowlist(ctx, codersdk.ChatTemplateAllowlist{TemplateIDs: []string{uuid.NewString()}})
requireSDKError(t, err, http.StatusNotFound)
})
//nolint:paralleltest // Sequential: subtests share a single coderdtest instance.
t.Run("UnauthenticatedFails", func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
anonClient := codersdk.NewExperimentalClient(codersdk.New(client.URL))
// Uses a random UUID — hits 401 before template validation.
err := anonClient.UpdateChatTemplateAllowlist(ctx, codersdk.ChatTemplateAllowlist{TemplateIDs: []string{uuid.NewString()}})
requireSDKError(t, err, http.StatusUnauthorized)
})
//nolint:paralleltest // Sequential: subtests share a single coderdtest instance.
t.Run("InvalidUUIDRejected", func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
err := client.UpdateChatTemplateAllowlist(ctx, codersdk.ChatTemplateAllowlist{TemplateIDs: []string{"not-a-uuid"}})
requireSDKError(t, err, http.StatusBadRequest)
})
//nolint:paralleltest // Sequential: subtests share a single coderdtest instance.
t.Run("NonexistentTemplateRejected", func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
err := client.UpdateChatTemplateAllowlist(ctx, codersdk.ChatTemplateAllowlist{TemplateIDs: []string{uuid.NewString()}})
requireSDKError(t, err, http.StatusBadRequest)
})
//nolint:paralleltest // Sequential: subtests share a single coderdtest instance.
t.Run("DeprecatedTemplateRejected", func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
err := client.UpdateChatTemplateAllowlist(ctx, codersdk.ChatTemplateAllowlist{
TemplateIDs: []string{deprecatedTmpl.ID.String()},
})
requireSDKError(t, err, http.StatusBadRequest)
})
//nolint:paralleltest // Sequential: subtests share a single coderdtest instance.
t.Run("DeduplicatesIDs", func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
id := tmpl1.ID.String()
err := client.UpdateChatTemplateAllowlist(ctx, codersdk.ChatTemplateAllowlist{
TemplateIDs: []string{id, id, id},
})
require.NoError(t, err)
resp, err := client.GetChatTemplateAllowlist(ctx)
require.NoError(t, err)
require.Len(t, resp.TemplateIDs, 1)
require.Equal(t, id, resp.TemplateIDs[0])
})
}
func TestGetChatsByWorkspace(t *testing.T) {
t.Parallel()
-35
View File
@@ -1,35 +0,0 @@
package xjson
import (
"encoding/json"
"strings"
"github.com/google/uuid"
"golang.org/x/xerrors"
)
// ParseUUIDList parses a JSON-encoded array of UUID strings
// (e.g. `["uuid1","uuid2"]`) and returns the corresponding
// slice of uuid.UUID values. An empty input (including
// whitespace-only) returns an empty (non-nil) slice.
func ParseUUIDList(raw string) ([]uuid.UUID, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return []uuid.UUID{}, nil
}
var strs []string
if err := json.Unmarshal([]byte(raw), &strs); err != nil {
return nil, xerrors.Errorf("unmarshal uuid list: %w", err)
}
ids := make([]uuid.UUID, 0, len(strs))
for _, s := range strs {
id, err := uuid.Parse(s)
if err != nil {
return nil, xerrors.Errorf("parse uuid %q: %w", s, err)
}
ids = append(ids, id)
}
return ids, nil
}
-70
View File
@@ -1,70 +0,0 @@
package xjson_test
import (
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/util/xjson"
)
func TestParseUUIDList(t *testing.T) {
t.Parallel()
a := uuid.MustParse("c7c6686d-a93c-4df2-bef9-5f837e9a33d5")
b := uuid.MustParse("8f3b3e0b-2c3f-46a5-a365-fd5b62bd8818")
tests := []struct {
name string
input string
want []uuid.UUID
wantErr string
}{
{
name: "EmptyString",
input: "",
want: []uuid.UUID{},
},
{
name: "JSONNull",
input: "null",
want: []uuid.UUID{},
},
{
name: "WhitespaceOnly",
input: " \n\t ",
want: []uuid.UUID{},
},
{
name: "ValidUUIDs",
input: `["c7c6686d-a93c-4df2-bef9-5f837e9a33d5","8f3b3e0b-2c3f-46a5-a365-fd5b62bd8818"]`,
want: []uuid.UUID{a, b},
},
{
name: "InvalidJSON",
input: "not json at all",
wantErr: "unmarshal uuid list",
},
{
name: "InvalidUUID",
input: `["not-a-uuid"]`,
wantErr: "parse uuid",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := xjson.ParseUUIDList(tt.input)
if tt.wantErr != "" {
require.Error(t, err)
require.Contains(t, err.Error(), tt.wantErr)
return
}
require.NoError(t, err)
require.NotNil(t, got)
require.Equal(t, tt.want, got)
})
}
}
-34
View File
@@ -1205,13 +1205,6 @@ func ParseChatWorkspaceTTL(s string) (time.Duration, error) {
return d, nil
}
// ChatTemplateAllowlist is the request and response body for the
// chat template allowlist configuration endpoint. An empty list
// means all templates are allowed.
type ChatTemplateAllowlist struct {
TemplateIDs []string `json:"template_ids"`
}
// ChatProviderConfigSource describes how a provider entry is sourced.
type ChatProviderConfigSource string
@@ -2574,33 +2567,6 @@ func (c *ExperimentalClient) UpdateChatAutoArchiveDays(ctx context.Context, req
return nil
}
// GetChatTemplateAllowlist returns the deployment-wide chat template allowlist.
func (c *ExperimentalClient) GetChatTemplateAllowlist(ctx context.Context) (ChatTemplateAllowlist, error) {
res, err := c.Request(ctx, http.MethodGet, "/api/experimental/chats/config/template-allowlist", nil)
if err != nil {
return ChatTemplateAllowlist{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return ChatTemplateAllowlist{}, ReadBodyAsError(res)
}
var resp ChatTemplateAllowlist
return resp, ReadBodyAsJSON(res, &resp)
}
// UpdateChatTemplateAllowlist updates the deployment-wide chat template allowlist.
func (c *ExperimentalClient) UpdateChatTemplateAllowlist(ctx context.Context, req ChatTemplateAllowlist) error {
res, err := c.Request(ctx, http.MethodPut, "/api/experimental/chats/config/template-allowlist", req)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode != http.StatusNoContent {
return ReadBodyAsError(res)
}
return nil
}
// UpdateUserChatCustomPrompt updates the user's custom chat prompt.
func (c *ExperimentalClient) UpdateUserChatCustomPrompt(ctx context.Context, req UserChatCustomPrompt) (UserChatCustomPrompt, error) {
res, err := c.Request(ctx, http.MethodPut, "/api/experimental/chats/config/user-prompt", req)
-17
View File
@@ -3695,14 +3695,6 @@ class ExperimentalApiMethods {
return response.data;
};
getChatTemplateAllowlist =
async (): Promise<TypesGen.ChatTemplateAllowlist> => {
const response = await this.axios.get<TypesGen.ChatTemplateAllowlist>(
"/api/experimental/chats/config/template-allowlist",
);
return response.data;
};
updateChatWorkspaceTTL = async (
req: TypesGen.UpdateChatWorkspaceTTLRequest,
): Promise<void> => {
@@ -3759,15 +3751,6 @@ class ExperimentalApiMethods {
);
};
updateChatTemplateAllowlist = async (
req: TypesGen.ChatTemplateAllowlist,
): Promise<void> => {
await this.axios.put(
"/api/experimental/chats/config/template-allowlist",
req,
);
};
getUserChatCustomPrompt =
async (): Promise<TypesGen.UserChatCustomPrompt> => {
const response = await this.axios.get<TypesGen.UserChatCustomPrompt>(
-10
View File
@@ -3433,16 +3433,6 @@ export interface ChatSystemPromptResponse {
readonly default_system_prompt: string;
}
// From codersdk/chats.go
/**
* ChatTemplateAllowlist is the request and response body for the
* chat template allowlist configuration endpoint. An empty list
* means all templates are allowed.
*/
export interface ChatTemplateAllowlist {
readonly template_ids: readonly string[];
}
// From codersdk/chats.go
export interface ChatTextPart {
readonly type: "text";