diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go
index bc7ecbda7ce3f..df33e539ab4a0 100644
--- a/coderd/apidoc/docs.go
+++ b/coderd/apidoc/docs.go
@@ -24305,7 +24305,8 @@ const docTemplate = `{
"user_ai_budget_override",
"chat",
"user_secret",
- "user_skill"
+ "user_skill",
+ "chat_instruction_settings"
],
"x-enum-varnames": [
"ResourceTypeTemplate",
@@ -24343,7 +24344,8 @@ const docTemplate = `{
"ResourceTypeUserAIBudgetOverride",
"ResourceTypeChat",
"ResourceTypeUserSecret",
- "ResourceTypeUserSkill"
+ "ResourceTypeUserSkill",
+ "ResourceTypeChatInstructionSettings"
]
},
"codersdk.Response": {
diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json
index 638c27ec248f2..c052f8d023bf2 100644
--- a/coderd/apidoc/swagger.json
+++ b/coderd/apidoc/swagger.json
@@ -22287,7 +22287,8 @@
"user_ai_budget_override",
"chat",
"user_secret",
- "user_skill"
+ "user_skill",
+ "chat_instruction_settings"
],
"x-enum-varnames": [
"ResourceTypeTemplate",
@@ -22325,7 +22326,8 @@
"ResourceTypeUserAIBudgetOverride",
"ResourceTypeChat",
"ResourceTypeUserSecret",
- "ResourceTypeUserSkill"
+ "ResourceTypeUserSkill",
+ "ResourceTypeChatInstructionSettings"
]
},
"codersdk.Response": {
diff --git a/coderd/audit/audit.go b/coderd/audit/audit.go
index 2b3a34d3a8f51..2b1a60dc67cbc 100644
--- a/coderd/audit/audit.go
+++ b/coderd/audit/audit.go
@@ -42,9 +42,19 @@ func NewMock() *MockAuditor {
return &MockAuditor{}
}
+// NewMockWithDiffFn returns a MockAuditor whose entries carry diffs computed
+// by the supplied function instead of the default empty diff. Tests use it to
+// pin the Old/New pair a handler captured, which the empty diff cannot
+// express. The function is test-supplied comparison logic; the mock never
+// calls the production differ.
+func NewMockWithDiffFn(fn func(old, newVal any) Map) *MockAuditor {
+ return &MockAuditor{diffFn: fn}
+}
+
type MockAuditor struct {
mutex sync.Mutex
auditLogs []database.AuditLog
+ diffFn func(old, newVal any) Map
}
// ResetLogs removes all audit logs from the mock auditor.
@@ -70,8 +80,13 @@ func (a *MockAuditor) Export(_ context.Context, alog database.AuditLog) error {
return nil
}
-func (*MockAuditor) diff(any, any) Map {
- return Map{}
+func (a *MockAuditor) diff(old, newVal any) Map {
+ a.mutex.Lock()
+ defer a.mutex.Unlock()
+ if a.diffFn == nil {
+ return Map{}
+ }
+ return a.diffFn(old, newVal)
}
// Contains returns true if, for each non-zero-valued field in expected,
diff --git a/coderd/audit/diff.go b/coderd/audit/diff.go
index 374f74dd7ab26..97105c24d54a5 100644
--- a/coderd/audit/diff.go
+++ b/coderd/audit/diff.go
@@ -42,7 +42,8 @@ type Auditable interface {
database.AuditableGroupAIBudget |
database.AuditableUserAIBudgetOverride |
database.UserSecret |
- database.UserSkill
+ database.UserSkill |
+ database.ChatInstructionSettings
}
// Map is a map of changed fields in an audited resource. It maps field names to
diff --git a/coderd/audit/request.go b/coderd/audit/request.go
index 4b213968bd3e8..9aaafb1e2ccdb 100644
--- a/coderd/audit/request.go
+++ b/coderd/audit/request.go
@@ -158,6 +158,8 @@ func ResourceTarget[T Auditable](tgt T) string {
return typed.Name
case database.UserSkill:
return typed.Name
+ case database.ChatInstructionSettings:
+ return typed.Name
default:
panic(fmt.Sprintf("unknown resource %T for ResourceTarget", tgt))
}
@@ -168,6 +170,22 @@ func ResourceTarget[T Auditable](tgt T) string {
// 51A51C = "Static"
var noID = uuid.MustParse("51A51C00-0000-0000-0000-000000000000")
+// Fixed IDs for the two chat instruction settings. History-by-setting works
+// only if every change to one setting carries the same resource ID, so
+// unlike the per-write artificial IDs of the other settings singletons,
+// these never change. C1A7 = "Chat".
+var (
+ ChatInstructionSystemPromptID = uuid.MustParse("C1A715C0-0000-0000-0000-000000000001")
+ ChatInstructionPlanModeID = uuid.MustParse("C1A715C0-0000-0000-0000-000000000002")
+)
+
+// Human-readable targets for the two chat instruction settings, so an audit
+// row names the setting it concerns.
+const (
+ ChatInstructionSystemPromptName = "System prompt"
+ ChatInstructionPlanModeName = "Plan mode instructions"
+)
+
func ResourceID[T Auditable](tgt T) uuid.UUID {
switch typed := any(tgt).(type) {
case database.Template:
@@ -243,6 +261,9 @@ func ResourceID[T Auditable](tgt T) uuid.UUID {
return typed.ID
case database.UserSkill:
return typed.ID
+ case database.ChatInstructionSettings:
+ // Fixed ID per setting; see ChatInstructionSettings IDs.
+ return typed.ID
default:
panic(fmt.Sprintf("unknown resource %T for ResourceID", tgt))
}
@@ -318,6 +339,8 @@ func ResourceType[T Auditable](tgt T) database.ResourceType {
return database.ResourceTypeUserSecret
case database.UserSkill:
return database.ResourceTypeUserSkill
+ case database.ChatInstructionSettings:
+ return database.ResourceTypeChatInstructionSettings
default:
panic(fmt.Sprintf("unknown resource %T for ResourceType", typed))
}
@@ -408,6 +431,9 @@ func ResourceRequiresOrgID[T Auditable]() bool {
case database.UserSkill:
// User skills are global to the user across organizations.
return false
+ case database.ChatInstructionSettings:
+ // Deployment settings, not scoped to any organization.
+ return false
default:
panic(fmt.Sprintf("unknown resource %T for ResourceRequiresOrgID", tgt))
}
diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql
index 92f2f77b975ba..ee444df91696b 100644
--- a/coderd/database/dump.sql
+++ b/coderd/database/dump.sql
@@ -598,7 +598,8 @@ CREATE TYPE resource_type AS ENUM (
'user_skill',
'ai_gateway_key',
'user_ai_budget_override',
- 'oauth2_provider_settings'
+ 'oauth2_provider_settings',
+ 'chat_instruction_settings'
);
CREATE TYPE shareable_workspace_owners AS ENUM (
diff --git a/coderd/database/lock.go b/coderd/database/lock.go
index a9830336fedfe..846758891205f 100644
--- a/coderd/database/lock.go
+++ b/coderd/database/lock.go
@@ -20,6 +20,17 @@ const (
LockIDChatCapacityAdmission
)
+// Per-setting advisory lock IDs for the chat instruction settings. These
+// derive from the exact site_configs key with GenLockID (FNV-1a 64) instead
+// of the sequential LockID* block above, so writers of different settings
+// never contend and the IDs cannot collide with any sequentially allocated
+// lock ID (different derivation space) or with another subsystem's
+// GenLockID output (the key strings are unique to these settings).
+var (
+ LockIDChatInstructionSystemPrompt = GenLockID("agents_chat_system_prompt")
+ LockIDChatInstructionPlanMode = GenLockID("agents_chat_plan_mode_instructions")
+)
+
// GenLockID generates a unique and consistent lock ID from a given string.
func GenLockID(name string) int64 {
hash := fnv.New64()
diff --git a/coderd/database/lock_internal_test.go b/coderd/database/lock_internal_test.go
new file mode 100644
index 0000000000000..c320841461542
--- /dev/null
+++ b/coderd/database/lock_internal_test.go
@@ -0,0 +1,50 @@
+package database
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+// TestChatInstructionLockIDsDistinct proves the per-setting advisory lock IDs
+// for the chat instruction settings cannot collide with each other or with
+// any sequentially allocated LockID* constant. The constants are listed
+// explicitly rather than enumerated programmatically (there is no registry of
+// iota constants), so a future LockID* addition that collides fails here in
+// review, not in a production deadlock.
+func TestChatInstructionLockIDsDistinct(t *testing.T) {
+ t.Parallel()
+
+ generated := map[string]int64{
+ "LockIDChatInstructionSystemPrompt": LockIDChatInstructionSystemPrompt,
+ "LockIDChatInstructionPlanMode": LockIDChatInstructionPlanMode,
+ }
+
+ sequential := map[string]int64{
+ "LockIDDeploymentSetup": LockIDDeploymentSetup,
+ "LockIDEnterpriseDeploymentSetup": LockIDEnterpriseDeploymentSetup,
+ "LockIDDBRollup": LockIDDBRollup,
+ "LockIDDBPurge": LockIDDBPurge,
+ "LockIDNotificationsReportGenerator": LockIDNotificationsReportGenerator,
+ "LockIDCryptoKeyRotation": LockIDCryptoKeyRotation,
+ "LockIDReconcilePrebuilds": LockIDReconcilePrebuilds,
+ "LockIDReconcileSystemRoles": LockIDReconcileSystemRoles,
+ "LockIDBoundaryUsageStats": LockIDBoundaryUsageStats,
+ "LockIDAIProvidersEnvSeed": LockIDAIProvidersEnvSeed,
+ "LockIDChatModelConfigWrites": LockIDChatModelConfigWrites,
+ }
+
+ // The two generated IDs are pairwise distinct.
+ require.NotEqual(t,
+ LockIDChatInstructionSystemPrompt,
+ LockIDChatInstructionPlanMode,
+ "per-setting lock IDs must differ from each other")
+
+ // Neither generated ID collides with any sequential constant.
+ for name, id := range generated {
+ for seqName, seqID := range sequential {
+ require.NotEqualf(t, seqID, id,
+ "%s (%d) collides with sequential constant %s", name, id, seqName)
+ }
+ }
+}
diff --git a/coderd/database/migrations/000573_audit_chat_instruction_settings.down.sql b/coderd/database/migrations/000573_audit_chat_instruction_settings.down.sql
new file mode 100644
index 0000000000000..35020b349fc4e
--- /dev/null
+++ b/coderd/database/migrations/000573_audit_chat_instruction_settings.down.sql
@@ -0,0 +1 @@
+-- No-op, enum values can't be dropped.
diff --git a/coderd/database/migrations/000573_audit_chat_instruction_settings.up.sql b/coderd/database/migrations/000573_audit_chat_instruction_settings.up.sql
new file mode 100644
index 0000000000000..aa03609c738d1
--- /dev/null
+++ b/coderd/database/migrations/000573_audit_chat_instruction_settings.up.sql
@@ -0,0 +1,2 @@
+ALTER TYPE resource_type
+ ADD VALUE IF NOT EXISTS 'chat_instruction_settings';
diff --git a/coderd/database/models.go b/coderd/database/models.go
index 22aaada3fb376..cff3b4c6bf446 100644
--- a/coderd/database/models.go
+++ b/coderd/database/models.go
@@ -3535,6 +3535,7 @@ const (
ResourceTypeAIGatewayKey ResourceType = "ai_gateway_key"
ResourceTypeUserAIBudgetOverride ResourceType = "user_ai_budget_override"
ResourceTypeOauth2ProviderSettings ResourceType = "oauth2_provider_settings"
+ ResourceTypeChatInstructionSettings ResourceType = "chat_instruction_settings"
)
func (e *ResourceType) Scan(src interface{}) error {
@@ -3609,7 +3610,8 @@ func (e ResourceType) Valid() bool {
ResourceTypeUserSkill,
ResourceTypeAIGatewayKey,
ResourceTypeUserAIBudgetOverride,
- ResourceTypeOauth2ProviderSettings:
+ ResourceTypeOauth2ProviderSettings,
+ ResourceTypeChatInstructionSettings:
return true
}
return false
@@ -3653,6 +3655,7 @@ func AllResourceTypeValues() []ResourceType {
ResourceTypeAIGatewayKey,
ResourceTypeUserAIBudgetOverride,
ResourceTypeOauth2ProviderSettings,
+ ResourceTypeChatInstructionSettings,
}
}
diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go
index bf7063bbb670c..39c721ce36023 100644
--- a/coderd/database/queries.sql.go
+++ b/coderd/database/queries.sql.go
@@ -25095,12 +25095,18 @@ SELECT
WHERE key = 'agents_chat_system_prompt'
AND value != ''
)
- ) :: boolean AS include_default_system_prompt
+ ) :: boolean AS include_default_system_prompt,
+ EXISTS (
+ SELECT 1
+ FROM site_configs
+ WHERE key = 'agents_chat_include_default_system_prompt'
+ ) :: boolean AS include_default_system_prompt_set
`
type GetChatSystemPromptConfigRow struct {
- ChatSystemPrompt string `db:"chat_system_prompt" json:"chat_system_prompt"`
- IncludeDefaultSystemPrompt bool `db:"include_default_system_prompt" json:"include_default_system_prompt"`
+ ChatSystemPrompt string `db:"chat_system_prompt" json:"chat_system_prompt"`
+ IncludeDefaultSystemPrompt bool `db:"include_default_system_prompt" json:"include_default_system_prompt"`
+ IncludeDefaultSystemPromptSet bool `db:"include_default_system_prompt_set" json:"include_default_system_prompt_set"`
}
// GetChatSystemPromptConfig returns both chat system prompt settings in a
@@ -25111,7 +25117,7 @@ type GetChatSystemPromptConfigRow struct {
func (q *sqlQuerier) GetChatSystemPromptConfig(ctx context.Context) (GetChatSystemPromptConfigRow, error) {
row := q.db.QueryRowContext(ctx, getChatSystemPromptConfig)
var i GetChatSystemPromptConfigRow
- err := row.Scan(&i.ChatSystemPrompt, &i.IncludeDefaultSystemPrompt)
+ err := row.Scan(&i.ChatSystemPrompt, &i.IncludeDefaultSystemPrompt, &i.IncludeDefaultSystemPromptSet)
return i, err
}
diff --git a/coderd/database/queries/siteconfig.sql b/coderd/database/queries/siteconfig.sql
index 5cc902baa748d..a4d9589f7daef 100644
--- a/coderd/database/queries/siteconfig.sql
+++ b/coderd/database/queries/siteconfig.sql
@@ -175,7 +175,12 @@ SELECT
WHERE key = 'agents_chat_system_prompt'
AND value != ''
)
- ) :: boolean AS include_default_system_prompt;
+ ) :: boolean AS include_default_system_prompt,
+ EXISTS (
+ SELECT 1
+ FROM site_configs
+ WHERE key = 'agents_chat_include_default_system_prompt'
+ ) :: boolean AS include_default_system_prompt_set;
-- name: UpsertChatSystemPrompt :exec
INSERT INTO site_configs (key, value) VALUES ('agents_chat_system_prompt', $1)
diff --git a/coderd/database/types.go b/coderd/database/types.go
index 68ad4b5a40f97..cfa518c7ca2cb 100644
--- a/coderd/database/types.go
+++ b/coderd/database/types.go
@@ -49,6 +49,27 @@ type OAuth2ProviderSettings struct {
DynamicClientRegistrationEnabled bool `db:"dynamic_client_registration_enabled" json:"dynamic_client_registration_enabled"`
}
+// ChatInstructionSettings is the auditable shape of the deployment-wide
+// chat instruction configuration, stored across the
+// agents_chat_system_prompt, agents_chat_include_default_system_prompt and
+// agents_chat_plan_mode_instructions site_configs keys. Both the
+// system-prompt and plan-mode-instructions endpoints audit this one type;
+// each populates only the fields its endpoint can change.
+type ChatInstructionSettings struct {
+ ID uuid.UUID `db:"id" json:"id"`
+ // Name identifies which setting an audit row concerns (e.g. "System
+ // prompt"). It is ignored in diffs and set identically on Old and New.
+ Name string `db:"name" json:"name"`
+ SystemPrompt string `db:"system_prompt" json:"system_prompt"`
+ // IncludeDefaultSystemPromptSet records whether the override row
+ // exists, not only its effective value: writing explicit false over a
+ // legacy absent row does not move the effective value but changes
+ // future behavior, so presence must enter the diff.
+ IncludeDefaultSystemPromptSet bool `db:"include_default_system_prompt_set" json:"include_default_system_prompt_set"`
+ IncludeDefaultSystemPrompt bool `db:"include_default_system_prompt" json:"include_default_system_prompt"`
+ PlanModeInstructions string `db:"plan_mode_instructions" json:"plan_mode_instructions"`
+}
+
type Actions []policy.Action
func (a *Actions) Scan(src interface{}) error {
diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go
index f98a22d11f3cf..0e9026a8fc80f 100644
--- a/coderd/exp_chats.go
+++ b/coderd/exp_chats.go
@@ -4546,12 +4546,38 @@ func (api *API) getChatSystemPrompt(rw http.ResponseWriter, r *http.Request) {
})
}
+// chatInstructionSettingsLockTimeout bounds how long a request waits for the
+// per-setting advisory lock. The only other holders are sibling requests
+// holding it for a single upsert, so a short bound cannot strand a waiter:
+// on expiry the request falls back to the unaudited write path instead of
+// hanging until the client deadline.
+const chatInstructionSettingsLockTimeout = 5 * time.Second
+
func (api *API) putChatSystemPrompt(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
+
+ // Identity is assigned before the authorization check so a denied PUT
+ // records the attempt with status 403 and an empty diff. The body is
+ // never read before authorization, so no request content reaches that
+ // row.
+ aReq, commitAudit := audit.InitRequestWithCancel[database.ChatInstructionSettings](rw, &audit.RequestParams{
+ Audit: *api.Auditor.Load(),
+ Log: api.Logger,
+ Request: r,
+ Action: database.AuditActionWrite,
+ })
+ defer commitAudit(true)
+ aReq.Old = database.ChatInstructionSettings{
+ ID: audit.ChatInstructionSystemPromptID,
+ Name: audit.ChatInstructionSystemPromptName,
+ }
+ aReq.New = aReq.Old
+
if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) {
httpapi.Forbidden(rw)
return
}
+
// Cap the raw request body to prevent excessive memory use from
// payloads padded with invisible characters that sanitize away.
r.Body = http.MaxBytesReader(rw, r.Body, int64(2*maxSystemPromptLenBytes))
@@ -4569,7 +4595,40 @@ func (api *API) putChatSystemPrompt(rw http.ResponseWriter, r *http.Request) {
})
return
}
+
+ var (
+ noChange bool
+ oldCaptured bool
+ oldReadErr error
+ lockErr error
+ )
+ // The per-setting advisory lock serializes the audit change-detection
+ // with the write: two concurrent identical PUTs both still succeed,
+ // but the second transaction's comparison sees the first's committed
+ // state and reports no change. The lock wait is bounded so a waiter
+ // cannot hang past the client's deadline.
+ lockCtx, lockCancel := context.WithTimeout(ctx, chatInstructionSettingsLockTimeout)
+ defer lockCancel()
err := api.Database.InTx(func(tx database.Store) error {
+ if err := tx.AcquireLock(lockCtx, database.LockIDChatInstructionSystemPrompt); err != nil {
+ lockErr = err
+ return xerrors.Errorf("acquire chat instruction setting write lock: %w", err)
+ }
+
+ // Old capture is best effort: it runs after the lock but before
+ // the write, so abandoning the transaction on its error costs
+ // nothing and the handler writes directly below with no entry.
+ // The lock keeps the captured baseline serialized with the write.
+ oldConfig, oldErr := tx.GetChatSystemPromptConfig(ctx)
+ if oldErr != nil {
+ oldReadErr = oldErr
+ return oldErr
+ }
+ oldCaptured = true
+ aReq.Old.SystemPrompt = oldConfig.ChatSystemPrompt
+ aReq.Old.IncludeDefaultSystemPromptSet = oldConfig.IncludeDefaultSystemPromptSet
+ aReq.Old.IncludeDefaultSystemPrompt = oldConfig.IncludeDefaultSystemPrompt
+
if err := tx.UpsertChatSystemPrompt(ctx, sanitizedPrompt); err != nil {
return err
}
@@ -4579,17 +4638,83 @@ func (api *API) putChatSystemPrompt(rw http.ResponseWriter, r *http.Request) {
// avoiding a backward-compatibility regression for older clients
// that only send system_prompt.
if req.IncludeDefaultSystemPrompt != nil {
- return tx.UpsertChatIncludeDefaultSystemPrompt(ctx, *req.IncludeDefaultSystemPrompt)
+ if err := tx.UpsertChatIncludeDefaultSystemPrompt(ctx, *req.IncludeDefaultSystemPrompt); err != nil {
+ return err
+ }
}
+
+ // Derive New from what was written rather than re-reading: the
+ // upserts store $1 verbatim, so the stored prompt is the request
+ // value, and the effective include-default flag follows
+ // GetChatSystemPromptConfig's rule from the written toggle row
+ // and the written prompt. A post-write read would fail on
+ // query-local context cancellation even after a successful
+ // commit, silently discarding a change the client made.
+ newIncludeSet := oldConfig.IncludeDefaultSystemPromptSet
+ newIncludeValue := oldConfig.IncludeDefaultSystemPrompt
+ if req.IncludeDefaultSystemPrompt != nil {
+ newIncludeSet = true
+ newIncludeValue = *req.IncludeDefaultSystemPrompt
+ }
+ if !newIncludeSet {
+ // Legacy fallback: a non-empty custom prompt implies opting
+ // out; otherwise the setting defaults to true.
+ newIncludeValue = sanitizedPrompt == ""
+ }
+ aReq.New.SystemPrompt = sanitizedPrompt
+ aReq.New.IncludeDefaultSystemPromptSet = newIncludeSet
+ aReq.New.IncludeDefaultSystemPrompt = newIncludeValue
+ noChange = aReq.New.SystemPrompt == aReq.Old.SystemPrompt &&
+ aReq.New.IncludeDefaultSystemPromptSet == aReq.Old.IncludeDefaultSystemPromptSet &&
+ aReq.New.IncludeDefaultSystemPrompt == aReq.Old.IncludeDefaultSystemPrompt
return nil
}, nil)
if err != nil {
+ auditSetupErr := lockErr
+ if auditSetupErr == nil {
+ auditSetupErr = oldReadErr
+ }
+ if auditSetupErr != nil {
+ // The audit-added lock wait or Old capture failed before
+ // anything was written, so the transaction was abandoned at
+ // no cost: write directly exactly as main does, emit no
+ // entry, and warn.
+ api.Logger.Warn(ctx, "audit change detection failed, writing chat system prompt without an audit entry",
+ slog.Error(auditSetupErr))
+ commitAudit(false)
+ if mainErr := api.Database.InTx(func(tx database.Store) error {
+ if err := tx.UpsertChatSystemPrompt(ctx, sanitizedPrompt); err != nil {
+ return err
+ }
+ if req.IncludeDefaultSystemPrompt != nil {
+ return tx.UpsertChatIncludeDefaultSystemPrompt(ctx, *req.IncludeDefaultSystemPrompt)
+ }
+ return nil
+ }, nil); mainErr != nil {
+ httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
+ Message: "Internal error updating chat system prompt configuration.",
+ Detail: mainErr.Error(),
+ })
+ return
+ }
+ rw.WriteHeader(http.StatusNoContent)
+ return
+ }
+ // This endpoint was transactional on main, so begin, write,
+ // commit and rollback errors all surface exactly as main's. The
+ // advisory lock is audit-added, so its failure falls back above
+ // instead of surfacing.
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error updating chat system prompt configuration.",
Detail: err.Error(),
})
return
}
+ if oldCaptured && noChange {
+ // Stage the no-op decision until after the transaction commits,
+ // so a commit failure cannot suppress an attempt row.
+ commitAudit(false)
+ }
rw.WriteHeader(http.StatusNoContent)
}
@@ -4620,6 +4745,24 @@ func (api *API) getChatPlanModeInstructions(rw http.ResponseWriter, r *http.Requ
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
func (api *API) putChatPlanModeInstructions(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
+
+ // Identity is assigned before the authorization check so a denied PUT
+ // records the attempt with status 403 and an empty diff. The body is
+ // never read before authorization, so no request content reaches that
+ // row.
+ aReq, commitAudit := audit.InitRequestWithCancel[database.ChatInstructionSettings](rw, &audit.RequestParams{
+ Audit: *api.Auditor.Load(),
+ Log: api.Logger,
+ Request: r,
+ Action: database.AuditActionWrite,
+ })
+ defer commitAudit(true)
+ aReq.Old = database.ChatInstructionSettings{
+ ID: audit.ChatInstructionPlanModeID,
+ Name: audit.ChatInstructionPlanModeName,
+ }
+ aReq.New = aReq.Old
+
if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) {
httpapi.Forbidden(rw)
return
@@ -4643,14 +4786,94 @@ func (api *API) putChatPlanModeInstructions(rw http.ResponseWriter, r *http.Requ
return
}
- if err := api.Database.UpsertChatPlanModeInstructions(ctx, sanitizedInstructions); err != nil {
- httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
- Message: "Internal error updating plan mode instructions.",
- Detail: err.Error(),
- })
- return
- }
+ // This endpoint was not transactional on main, so the audited
+ // transaction's lock, begin, commit and rollback failures run the
+ // direct write instead, matching main's non-transactional behavior.
+ // Only a failure of the write itself surfaces the transaction error
+ // (the raw write error, as main produced).
+ var (
+ noChange bool
+ oldCaptured bool
+ oldReadErr error
+ )
+ lockCtx, lockCancel := context.WithTimeout(ctx, chatInstructionSettingsLockTimeout)
+ defer lockCancel()
+ var writeErr error
+ err := api.Database.InTx(func(tx database.Store) error {
+ if err := tx.AcquireLock(lockCtx, database.LockIDChatInstructionPlanMode); err != nil {
+ return xerrors.Errorf("acquire chat instruction setting write lock: %w", err)
+ }
+
+ // Old capture is best effort: it runs after the lock but before
+ // the write, so abandoning the transaction on its error costs
+ // nothing and the handler writes directly below with no entry;
+ // see putChatSystemPrompt for why New is derived from the write.
+ oldInstructions, oldErr := tx.GetChatPlanModeInstructions(ctx)
+ if oldErr != nil {
+ oldReadErr = oldErr
+ return oldErr
+ }
+ oldCaptured = true
+ aReq.Old.PlanModeInstructions = oldInstructions
+ if err := tx.UpsertChatPlanModeInstructions(ctx, sanitizedInstructions); err != nil {
+ writeErr = err
+ return err
+ }
+ aReq.New.PlanModeInstructions = sanitizedInstructions
+ noChange = aReq.New.PlanModeInstructions == aReq.Old.PlanModeInstructions
+ return nil
+ }, nil)
+ if err != nil {
+ // Log the full InTx error first: the response below derives from
+ // the write error alone, so a rollback failure layered on it
+ // would otherwise vanish from response, audit row and logs
+ // simultaneously.
+ api.Logger.Warn(ctx, "plan mode instructions update transaction failed",
+ slog.Error(err))
+ if oldReadErr != nil {
+ // The Old capture failed before anything was written: fall
+ // through to the direct write below with no entry.
+ oldCaptured = false
+ } else if writeErr != nil {
+ // The write itself failed: respond with the raw error
+ // exactly as the endpoint did before the audit wiring
+ // existed.
+ httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
+ Message: "Internal error updating plan mode instructions.",
+ Detail: writeErr.Error(),
+ })
+ return
+ }
+ // Only the audit-added transaction machinery failed (lock,
+ // begin, commit, rollback, or the Old read), not the write.
+ // Main's non-transactional behavior is authoritative: run the
+ // direct upsert and derive the response from that.
+ if mainErr := api.Database.UpsertChatPlanModeInstructions(ctx, sanitizedInstructions); mainErr != nil {
+ httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
+ Message: "Internal error updating plan mode instructions.",
+ Detail: mainErr.Error(),
+ })
+ return
+ }
+ if !oldCaptured {
+ // The lock wait or Old capture failed, so the write landed
+ // through the direct path with no baseline: no entry, warn,
+ // and finish with the success response. A commit failure
+ // keeps oldCaptured true and deliberately falls through so
+ // the attempt row with its real diff survives.
+ api.Logger.Warn(ctx, "audit change detection failed, writing plan mode instructions without an audit entry",
+ slog.Error(err))
+ commitAudit(false)
+ rw.WriteHeader(http.StatusNoContent)
+ return
+ }
+ }
+ if oldCaptured && noChange {
+ // Stage the no-op decision until after the transaction commits,
+ // so a commit failure cannot suppress an attempt row.
+ commitAudit(false)
+ }
rw.WriteHeader(http.StatusNoContent)
}
diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go
index e57d4f089b04e..1d2111b4cefa5 100644
--- a/coderd/exp_chats_test.go
+++ b/coderd/exp_chats_test.go
@@ -16,6 +16,7 @@ import (
"slices"
"strconv"
"strings"
+ "sync"
"sync/atomic"
"testing"
"time"
@@ -24,6 +25,7 @@ import (
"github.com/google/uuid"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/sqlc-dev/pqtype"
+ "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/sync/errgroup"
"golang.org/x/xerrors"
@@ -200,12 +202,42 @@ func findUserMessage(t testing.TB, messages []database.ChatMessage) database.Cha
return messages[idx]
}
+// failNextChatSystemPromptStore lets a test force single chat system prompt
+// queries to fail. One-shot flags disarm when the forced failure fires, and
+// armed counters fail every matching read until the armed count reaches zero,
+// so failures swallowed by best-effort audit captures do not leak into later
+// reads. Flags and counters are shared across InTx wrappers.
type failNextChatSystemPromptStore struct {
database.Store
- failNextGetChatIncludeDefaultSystemPrompt atomic.Bool
- failNextGetChatSystemPromptConfig atomic.Bool
- failNextUpsertChatIncludeDefaultSystemPrompt atomic.Bool
+ failNextGetChatIncludeDefaultSystemPrompt *atomic.Bool
+ failNextGetChatSystemPromptConfig *atomic.Bool
+ armedGetChatSystemPromptConfigFailures *atomic.Int64
+ getChatSystemPromptConfigCallsBeforeFailure *atomic.Int64
+ failNextUpsertChatIncludeDefaultSystemPrompt *atomic.Bool
+}
+
+func newFailNextChatSystemPromptStore(store database.Store) *failNextChatSystemPromptStore {
+ return &failNextChatSystemPromptStore{
+ Store: store,
+ failNextGetChatIncludeDefaultSystemPrompt: &atomic.Bool{},
+ failNextGetChatSystemPromptConfig: &atomic.Bool{},
+ armedGetChatSystemPromptConfigFailures: &atomic.Int64{},
+ getChatSystemPromptConfigCallsBeforeFailure: &atomic.Int64{},
+ failNextUpsertChatIncludeDefaultSystemPrompt: &atomic.Bool{},
+ }
+}
+
+func (s *failNextChatSystemPromptStore) InTx(function func(database.Store) error, txOpts *database.TxOptions) error {
+ return s.Store.InTx(func(tx database.Store) error {
+ return function(&failNextChatSystemPromptStore{
+ Store: tx,
+ failNextGetChatSystemPromptConfig: s.failNextGetChatSystemPromptConfig,
+ armedGetChatSystemPromptConfigFailures: s.armedGetChatSystemPromptConfigFailures,
+ getChatSystemPromptConfigCallsBeforeFailure: s.getChatSystemPromptConfigCallsBeforeFailure,
+ failNextUpsertChatIncludeDefaultSystemPrompt: s.failNextUpsertChatIncludeDefaultSystemPrompt,
+ })
+ }, txOpts)
}
func (s *failNextChatSystemPromptStore) GetChatIncludeDefaultSystemPrompt(ctx context.Context) (bool, error) {
@@ -226,9 +258,191 @@ func (s *failNextChatSystemPromptStore) GetChatSystemPromptConfig(ctx context.Co
if s.failNextGetChatSystemPromptConfig.CompareAndSwap(true, false) {
return database.GetChatSystemPromptConfigRow{}, stderrors.New("forced chat system prompt configuration read failure")
}
+ // Armed failures fire on every read until the armed count is spent.
+ // Best-effort audit captures swallow these errors without retrying,
+ // which is why a one-shot flag is not enough: the failure would stay
+ // latent and fire on a later, unrelated read.
+ if s.armedGetChatSystemPromptConfigFailures != nil && s.armedGetChatSystemPromptConfigFailures.Load() > 0 {
+ // When a skip count is set, the first N reads succeed so failures
+ // can be aimed at a specific read, e.g. the New re-read after the
+ // Old capture.
+ if s.getChatSystemPromptConfigCallsBeforeFailure != nil && s.getChatSystemPromptConfigCallsBeforeFailure.Load() > 0 {
+ s.getChatSystemPromptConfigCallsBeforeFailure.Add(-1)
+ return s.Store.GetChatSystemPromptConfig(ctx)
+ }
+ s.armedGetChatSystemPromptConfigFailures.Add(-1)
+ return database.GetChatSystemPromptConfigRow{}, stderrors.New("forced chat system prompt configuration read failure")
+ }
return s.Store.GetChatSystemPromptConfig(ctx)
}
+// failNextUpsertChatSystemPromptStore lets a test force the system-prompt
+// upsert to fail once, sharing its failure state across InTx wrappers.
+type failNextUpsertChatSystemPromptStore struct {
+ database.Store
+
+ failNextUpsertChatSystemPrompt *atomic.Bool
+}
+
+func newFailNextUpsertChatSystemPromptStore(store database.Store) *failNextUpsertChatSystemPromptStore {
+ return &failNextUpsertChatSystemPromptStore{
+ Store: store,
+ failNextUpsertChatSystemPrompt: &atomic.Bool{},
+ }
+}
+
+func (s *failNextUpsertChatSystemPromptStore) InTx(function func(database.Store) error, txOpts *database.TxOptions) error {
+ return s.Store.InTx(func(tx database.Store) error {
+ return function(&failNextUpsertChatSystemPromptStore{
+ Store: tx,
+ failNextUpsertChatSystemPrompt: s.failNextUpsertChatSystemPrompt,
+ })
+ }, txOpts)
+}
+
+func (s *failNextUpsertChatSystemPromptStore) UpsertChatSystemPrompt(ctx context.Context, prompt string) error {
+ if s.failNextUpsertChatSystemPrompt.CompareAndSwap(true, false) {
+ return stderrors.New("forced system prompt upsert failure")
+ }
+ return s.Store.UpsertChatSystemPrompt(ctx, prompt)
+}
+
+// failNextAcquireLockStore lets a test force the next advisory lock
+// acquisition for one lock ID to fail with a deadline error, simulating a
+// lock wait that timed out. The failure state is shared across InTx
+// wrappers.
+type failNextAcquireLockStore struct {
+ database.Store
+
+ lockID int64
+ failNextAcquireLock *atomic.Bool
+}
+
+func newFailNextAcquireLockStore(store database.Store, lockID int64) *failNextAcquireLockStore {
+ return &failNextAcquireLockStore{
+ Store: store,
+ lockID: lockID,
+ failNextAcquireLock: &atomic.Bool{},
+ }
+}
+
+func (s *failNextAcquireLockStore) InTx(function func(database.Store) error, txOpts *database.TxOptions) error {
+ return s.Store.InTx(func(tx database.Store) error {
+ return function(&failNextAcquireLockStore{
+ Store: tx,
+ lockID: s.lockID,
+ failNextAcquireLock: s.failNextAcquireLock,
+ })
+ }, txOpts)
+}
+
+func (s *failNextAcquireLockStore) AcquireLock(ctx context.Context, id int64) error {
+ if id == s.lockID && s.failNextAcquireLock.CompareAndSwap(true, false) {
+ return context.DeadlineExceeded
+ }
+ return s.Store.AcquireLock(ctx, id)
+}
+
+// failNextUpsertChatPlanModeInstructionsStore lets a test force the plan-mode
+// instructions upsert to fail once, sharing its failure state across InTx
+// wrappers.
+type failNextUpsertChatPlanModeInstructionsStore struct {
+ database.Store
+
+ failNextUpsertChatPlanModeInstructions *atomic.Bool
+}
+
+func newFailNextUpsertChatPlanModeInstructionsStore(store database.Store) *failNextUpsertChatPlanModeInstructionsStore {
+ return &failNextUpsertChatPlanModeInstructionsStore{
+ Store: store,
+ failNextUpsertChatPlanModeInstructions: &atomic.Bool{},
+ }
+}
+
+func (s *failNextUpsertChatPlanModeInstructionsStore) InTx(function func(database.Store) error, txOpts *database.TxOptions) error {
+ return s.Store.InTx(func(tx database.Store) error {
+ return function(&failNextUpsertChatPlanModeInstructionsStore{
+ Store: tx,
+ failNextUpsertChatPlanModeInstructions: s.failNextUpsertChatPlanModeInstructions,
+ })
+ }, txOpts)
+}
+
+func (s *failNextUpsertChatPlanModeInstructionsStore) UpsertChatPlanModeInstructions(ctx context.Context, instructions string) error {
+ if s.failNextUpsertChatPlanModeInstructions.CompareAndSwap(true, false) {
+ return stderrors.New("forced plan mode instructions upsert failure")
+ }
+ return s.Store.UpsertChatPlanModeInstructions(ctx, instructions)
+}
+
+// lockSwitchChatPlanModeInstructionsStore can disable the per-setting
+// advisory lock (to prove its effect) and stall each transaction after its
+// Old-capture read until every concurrent transaction has read, so the
+// stale-Old race the lock prevents is deterministic when the lock is off.
+// The lock must stay enabled in any test that leaves it on, or the barrier
+// deadlocks.
+type lockSwitchChatPlanModeInstructionsStore struct {
+ database.Store
+
+ lockEnabled *atomic.Bool
+ mu *sync.Mutex
+ parked *int
+ target *atomic.Int64
+ release chan struct{}
+}
+
+func newLockSwitchChatPlanModeInstructionsStore(store database.Store) *lockSwitchChatPlanModeInstructionsStore {
+ return &lockSwitchChatPlanModeInstructionsStore{
+ Store: store,
+ lockEnabled: &atomic.Bool{},
+ mu: &sync.Mutex{},
+ parked: new(int),
+ target: &atomic.Int64{},
+ release: make(chan struct{}),
+ }
+}
+
+func (s *lockSwitchChatPlanModeInstructionsStore) InTx(function func(database.Store) error, txOpts *database.TxOptions) error {
+ return s.Store.InTx(func(tx database.Store) error {
+ return function(&lockSwitchChatPlanModeInstructionsStore{
+ Store: tx,
+ lockEnabled: s.lockEnabled,
+ mu: s.mu,
+ parked: s.parked,
+ target: s.target,
+ release: s.release,
+ })
+ }, txOpts)
+}
+
+func (s *lockSwitchChatPlanModeInstructionsStore) AcquireLock(ctx context.Context, id int64) error {
+ if !s.lockEnabled.Load() {
+ return nil
+ }
+ return s.Store.AcquireLock(ctx, id)
+}
+
+func (s *lockSwitchChatPlanModeInstructionsStore) GetChatPlanModeInstructions(ctx context.Context) (string, error) {
+ // Read before parking so every waiter holds the same stale value no
+ // matter how goroutines are scheduled afterwards. Parking before the
+ // read lets the releasing goroutine write and commit before a slowly
+ // waking waiter executes its read, which then sees the committed new
+ // value under read committed and turns into a no-op.
+ instructions, err := s.Store.GetChatPlanModeInstructions(ctx)
+ if s.target.Load() > 0 {
+ s.mu.Lock()
+ *s.parked++
+ done := *s.parked >= int(s.target.Load())
+ s.mu.Unlock()
+ if done {
+ close(s.release)
+ } else {
+ <-s.release
+ }
+ }
+ return instructions, err
+}
+
// failNextUpdateChatModelConfigStore shares its failure state across InTx
// wrappers so tests can force a specific in-transaction model-config update to
// return sql.ErrNoRows.
@@ -12466,7 +12680,7 @@ If a workspace is needed, use list_templates before create_workspace and follow
ctx := testutil.Context(t, testutil.WaitLong)
rawDB, pubsub := dbtestutil.NewDB(t)
- store := &failNextChatSystemPromptStore{Store: rawDB}
+ store := newFailNextChatSystemPromptStore(rawDB)
rawClient, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{
Database: store,
Pubsub: pubsub,
@@ -12636,7 +12850,7 @@ If a workspace is needed, use list_templates before create_workspace and follow
ctx := testutil.Context(t, testutil.WaitLong)
rawDB, pubsub := dbtestutil.NewDB(t)
- store := &failNextChatSystemPromptStore{Store: rawDB}
+ store := newFailNextChatSystemPromptStore(rawDB)
rawClient, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{
Database: store,
Pubsub: pubsub,
@@ -12685,7 +12899,7 @@ If a workspace is needed, use list_templates before create_workspace and follow
ctx := testutil.Context(t, testutil.WaitLong)
rawDB, pubsub := dbtestutil.NewDB(t)
- store := &failNextChatSystemPromptStore{Store: rawDB}
+ store := newFailNextChatSystemPromptStore(rawDB)
rawClient, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{
Database: store,
Pubsub: pubsub,
@@ -12767,6 +12981,455 @@ If a workspace is needed, use list_templates before create_workspace and follow
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
require.Equal(t, "System prompt exceeds maximum length.", sdkErr.Message)
})
+
+ t.Run("Audit", func(t *testing.T) {
+ ctx := testutil.Context(t, testutil.WaitLong)
+
+ mAudit := audit.NewMock()
+ auditClient := newChatClient(t, func(opts *coderdtest.Options) {
+ opts.Auditor = mAudit
+ })
+ _ = coderdtest.CreateFirstUser(t, auditClient.Client)
+ // Discard the login entry emitted by user creation.
+ mAudit.ResetLogs()
+
+ // A value change emits a Write entry with the setting's stable
+ // identity.
+ err := auditClient.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{
+ SystemPrompt: "You are a test assistant.",
+ IncludeDefaultSystemPrompt: ptr.Ref(true),
+ })
+ require.NoError(t, err)
+
+ logs := mAudit.AuditLogs()
+ require.Len(t, logs, 1)
+ require.Equal(t, database.AuditActionWrite, logs[0].Action)
+ require.Equal(t, database.ResourceTypeChatInstructionSettings, logs[0].ResourceType)
+ require.Equal(t, audit.ChatInstructionSystemPromptID, logs[0].ResourceID)
+ require.Equal(t, "System prompt", logs[0].ResourceTarget)
+ require.Equal(t, uuid.Nil, logs[0].OrganizationID)
+ require.EqualValues(t, http.StatusNoContent, logs[0].StatusCode)
+
+ // A value-identical PUT emits nothing (the write still happens).
+ mAudit.ResetLogs()
+ err = auditClient.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{
+ SystemPrompt: "You are a test assistant.",
+ IncludeDefaultSystemPrompt: ptr.Ref(true),
+ })
+ require.NoError(t, err)
+ require.Empty(t, mAudit.AuditLogs())
+
+ resp, err := auditClient.GetChatSystemPrompt(ctx)
+ require.NoError(t, err)
+ require.Equal(t, "You are a test assistant.", resp.SystemPrompt)
+ require.True(t, resp.IncludeDefaultSystemPrompt)
+
+ // A second distinct change keeps the same stable identity.
+ mAudit.ResetLogs()
+ err = auditClient.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{
+ SystemPrompt: "Renamed assistant.",
+ IncludeDefaultSystemPrompt: ptr.Ref(true),
+ })
+ require.NoError(t, err)
+ logs = mAudit.AuditLogs()
+ require.Len(t, logs, 1)
+ require.Equal(t, audit.ChatInstructionSystemPromptID, logs[0].ResourceID)
+ require.Equal(t, "System prompt", logs[0].ResourceTarget)
+
+ // An omitted-flag PUT is not mis-suppressed: the stored toggle row
+ // keeps the effective include-default flag false, so a prompt-only
+ // change still emits an entry.
+ mAudit.ResetLogs()
+ err = auditClient.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{
+ SystemPrompt: "Custom without default.",
+ IncludeDefaultSystemPrompt: ptr.Ref(false),
+ })
+ require.NoError(t, err)
+ require.Len(t, mAudit.AuditLogs(), 1)
+
+ mAudit.ResetLogs()
+ err = auditClient.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{
+ SystemPrompt: "Renamed without default.",
+ })
+ require.NoError(t, err)
+ require.Len(t, mAudit.AuditLogs(), 1)
+
+ resp, err = auditClient.GetChatSystemPrompt(ctx)
+ require.NoError(t, err)
+ require.Equal(t, "Renamed without default.", resp.SystemPrompt)
+ require.False(t, resp.IncludeDefaultSystemPrompt)
+
+ // A failed PUT records the attempt with an empty diff.
+ mAudit.ResetLogs()
+ tooLong := strings.Repeat("a", 131073)
+ err = auditClient.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{
+ SystemPrompt: tooLong,
+ })
+ requireSDKError(t, err, http.StatusBadRequest)
+ logs = mAudit.AuditLogs()
+ require.Len(t, logs, 1)
+ require.EqualValues(t, http.StatusBadRequest, logs[0].StatusCode)
+ require.JSONEq(t, "{}", string(logs[0].Diff))
+ })
+
+ // A denied PUT records the attempt with status 403, an empty diff, and
+ // no request body content in the row.
+ t.Run("AuditDeniedPUTRecordsAttempt", func(t *testing.T) {
+ ctx := testutil.Context(t, testutil.WaitLong)
+
+ mAudit := audit.NewMock()
+ auditClient := newChatClient(t, func(opts *coderdtest.Options) {
+ opts.Auditor = mAudit
+ })
+ firstUser := coderdtest.CreateFirstUser(t, auditClient.Client)
+ // Discard the login entry emitted by user creation.
+ mAudit.ResetLogs()
+
+ memberClientRaw, _ := coderdtest.CreateAnotherUser(t, auditClient.Client, firstUser.OrganizationID)
+ memberClient := codersdk.NewExperimentalClient(memberClientRaw)
+ mAudit.ResetLogs()
+
+ err := memberClient.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{
+ SystemPrompt: "must not leak into the audit row",
+ })
+ requireSDKError(t, err, http.StatusForbidden)
+
+ logs := mAudit.AuditLogs()
+ require.Len(t, logs, 1)
+ require.Equal(t, database.ResourceTypeChatInstructionSettings, logs[0].ResourceType)
+ require.Equal(t, audit.ChatInstructionSystemPromptID, logs[0].ResourceID)
+ require.Equal(t, "System prompt", logs[0].ResourceTarget)
+ require.EqualValues(t, http.StatusForbidden, logs[0].StatusCode)
+ require.JSONEq(t, "{}", string(logs[0].Diff))
+ require.NotContains(t, string(logs[0].Diff), "must not leak")
+ })
+
+ // A failing upsert rolls the transaction back: the request fails with
+ // 500, no audit entry is emitted, and the stale Old the failed request
+ // captured must not poison a later no-change PUT into being audited
+ // as a change.
+ t.Run("AuditUpsertFailureThenNoChangeSuppressed", func(t *testing.T) {
+ ctx := testutil.Context(t, testutil.WaitLong)
+
+ rawDB, pubsub := dbtestutil.NewDB(t)
+ store := newFailNextUpsertChatSystemPromptStore(rawDB)
+ mAudit := audit.NewMock()
+ rawClient, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{
+ Database: store,
+ Pubsub: pubsub,
+ DeploymentValues: coderdtest.DeploymentValues(t),
+ Auditor: mAudit,
+ })
+ aibridgedtest.StartTestAIBridgeDaemon(t.Context(), t, api, nil)
+ client := codersdk.NewExperimentalClient(rawClient)
+ _ = coderdtest.CreateFirstUser(t, client.Client)
+ // Discard the login entry emitted by user creation.
+ mAudit.ResetLogs()
+
+ store.failNextUpsertChatSystemPrompt.Store(true)
+ err := client.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{
+ SystemPrompt: "First prompt.",
+ IncludeDefaultSystemPrompt: ptr.Ref(false),
+ })
+ requireSDKError(t, err, http.StatusInternalServerError)
+ // The failed request records the attempt with an empty diff.
+ logs := mAudit.AuditLogs()
+ require.Len(t, logs, 1)
+ require.EqualValues(t, http.StatusInternalServerError, logs[0].StatusCode)
+ require.JSONEq(t, "{}", string(logs[0].Diff))
+
+ // The failed write rolled back, so nothing was stored: the stale
+ // Old from the failed request must not survive into this one, or
+ // the entry below would diff against "First prompt.". Writing the
+ // deployment default materializes the override row (absent ->
+ // explicit true), which the presence field now audits.
+ mAudit.ResetLogs()
+ err = client.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{
+ SystemPrompt: "",
+ IncludeDefaultSystemPrompt: ptr.Ref(true),
+ })
+ require.NoError(t, err)
+ logs = mAudit.AuditLogs()
+ require.Len(t, logs, 1)
+ require.EqualValues(t, http.StatusNoContent, logs[0].StatusCode)
+
+ // With the override row now stored, the identical PUT is a true
+ // no-op: nothing is audited.
+ mAudit.ResetLogs()
+ err = client.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{
+ SystemPrompt: "",
+ IncludeDefaultSystemPrompt: ptr.Ref(true),
+ })
+ require.NoError(t, err)
+ require.Empty(t, mAudit.AuditLogs())
+ })
+
+ // The advisory lock only exists for audit change detection, so a lock
+ // wait that times out must not fail a write that succeeded before the
+ // audit wiring existed: the handler falls back to the unaudited write
+ // path and emits no entry.
+ t.Run("AuditLockFailureFallsBackWithoutEntry", func(t *testing.T) {
+ ctx := testutil.Context(t, testutil.WaitLong)
+
+ rawDB, pubsub := dbtestutil.NewDB(t)
+ store := newFailNextAcquireLockStore(rawDB, database.LockIDChatInstructionSystemPrompt)
+ mAudit := audit.NewMock()
+ rawClient, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{
+ Database: store,
+ Pubsub: pubsub,
+ DeploymentValues: coderdtest.DeploymentValues(t),
+ Auditor: mAudit,
+ })
+ aibridgedtest.StartTestAIBridgeDaemon(t.Context(), t, api, nil)
+ client := codersdk.NewExperimentalClient(rawClient)
+ _ = coderdtest.CreateFirstUser(t, client.Client)
+ // Discard the login entry emitted by user creation.
+ mAudit.ResetLogs()
+
+ store.failNextAcquireLock.Store(true)
+ err := client.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{
+ SystemPrompt: "Prompt written despite lock timeout.",
+ })
+ require.NoError(t, err)
+ require.Empty(t, mAudit.AuditLogs())
+
+ resp, err := client.GetChatSystemPrompt(ctx)
+ require.NoError(t, err)
+ require.Equal(t, "Prompt written despite lock timeout.", resp.SystemPrompt)
+
+ // The next PUT reacquires the lock normally and audits the change
+ // against the fallback-written value.
+ mAudit.ResetLogs()
+ err = client.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{
+ SystemPrompt: "Prompt written after lock recovery.",
+ })
+ require.NoError(t, err)
+ logs := mAudit.AuditLogs()
+ require.Len(t, logs, 1)
+ require.EqualValues(t, http.StatusNoContent, logs[0].StatusCode)
+ })
+
+ // The derived New must match the getter's computed effective state on
+ // the happy path for every combination of written prompt and
+ // include-default input, so the derivation cannot drift from
+ // GetChatSystemPromptConfig's rule.
+ t.Run("AuditDerivedNewMatchesGetter", func(t *testing.T) {
+ t.Parallel()
+
+ cases := []struct {
+ name string
+ prompt string
+ explicit *bool
+ }{
+ {"EmptyPromptOmittedToggle", "", nil},
+ {"EmptyPromptExplicitTrue", "", ptr.Ref(true)},
+ {"EmptyPromptExplicitFalse", "", ptr.Ref(false)},
+ {"NonEmptyPromptOmittedToggle", "Custom instructions.", nil},
+ {"NonEmptyPromptExplicitTrue", "Custom instructions.", ptr.Ref(true)},
+ {"NonEmptyPromptExplicitFalse", "Custom instructions.", ptr.Ref(false)},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ ctx := testutil.Context(t, testutil.WaitLong)
+
+ auditClient, db := newChatClientWithDatabase(t)
+ _ = coderdtest.CreateFirstUser(t, auditClient.Client)
+
+ err := auditClient.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{
+ SystemPrompt: tc.prompt,
+ IncludeDefaultSystemPrompt: tc.explicit,
+ })
+ require.NoError(t, err)
+
+ // The getter's computed state after the write.
+ stored, err := db.GetChatSystemPromptConfig(dbauthz.AsSystemRestricted(ctx))
+ require.NoError(t, err)
+
+ // The derivation the handler applies, computed from the
+ // written values and the pre-write stored state (empty
+ // here, fresh deployment).
+ newIncludeSet := false
+ newIncludeValue := true
+ if tc.explicit != nil {
+ newIncludeSet = true
+ newIncludeValue = *tc.explicit
+ }
+ if !newIncludeSet {
+ newIncludeValue = tc.prompt == ""
+ }
+
+ require.Equal(t, tc.prompt, stored.ChatSystemPrompt)
+ require.Equal(t, newIncludeSet, stored.IncludeDefaultSystemPromptSet,
+ "derived presence must match the getter")
+ require.Equal(t, newIncludeValue, stored.IncludeDefaultSystemPrompt,
+ "derived effective value must match the getter")
+ })
+ }
+ })
+
+ // Two concurrent identical PUTs both succeed, but the advisory lock
+ // makes the second comparison see the first's committed state, so
+ // only the first write is audited as a change.
+ // Two concurrent identical PUTs of a new value both succeed, but the
+ // per-setting lock serializes the Old capture with the write, so the
+ // first is a real change and the second sees the first's committed
+ // state and is a no-op: one entry.
+ t.Run("AuditConcurrentIdenticalPUTsSingleEntry", func(t *testing.T) {
+ ctx := testutil.Context(t, testutil.WaitLong)
+
+ mAudit := audit.NewMock()
+ auditClient := newChatClient(t, func(opts *coderdtest.Options) {
+ opts.Auditor = mAudit
+ })
+ _ = coderdtest.CreateFirstUser(t, auditClient.Client)
+ // Discard the login entry emitted by user creation.
+ mAudit.ResetLogs()
+
+ err := auditClient.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{
+ SystemPrompt: "Seed prompt.",
+ IncludeDefaultSystemPrompt: ptr.Ref(true),
+ })
+ require.NoError(t, err)
+ require.Len(t, mAudit.AuditLogs(), 1)
+
+ mAudit.ResetLogs()
+ const puts = 2
+ start := make(chan struct{})
+ errs := make(chan error, puts)
+ var wg sync.WaitGroup
+ for range puts {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ <-start
+ errs <- auditClient.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{
+ SystemPrompt: "Concurrent prompt.",
+ IncludeDefaultSystemPrompt: ptr.Ref(true),
+ })
+ }()
+ }
+ close(start)
+ wg.Wait()
+ close(errs)
+ for err := range errs {
+ require.NoError(t, err)
+ }
+
+ logs := mAudit.AuditLogs()
+ require.Len(t, logs, 1)
+ require.Equal(t, database.AuditActionWrite, logs[0].Action)
+ })
+
+ // The audit diff for the fallback flip must carry the re-read boolean,
+ // not the stale Old value: a regression that built New from the Old
+ // capture would emit no include_default_system_prompt diff entry. The
+ // mock's test-supplied diff function pins the captured Old/New pair,
+ // which the default empty mock diff cannot express.
+ t.Run("AuditFallbackFlipDiffCarriesReReadValue", func(t *testing.T) {
+ ctx := testutil.Context(t, testutil.WaitLong)
+
+ // This runs on the HTTP handler goroutine (via the deferred
+ // commitAudit), so it must use assert, not require: require calls
+ // t.FailNow, which is only legal on the test goroutine.
+ mAudit := audit.NewMockWithDiffFn(func(old, newVal any) audit.Map {
+ oldSettings, ok := old.(database.ChatInstructionSettings)
+ assert.True(t, ok)
+ newSettings, ok := newVal.(database.ChatInstructionSettings)
+ assert.True(t, ok)
+ return audit.Map{
+ "system_prompt": {Old: oldSettings.SystemPrompt, New: newSettings.SystemPrompt},
+ "include_default_system_prompt": {Old: oldSettings.IncludeDefaultSystemPrompt, New: newSettings.IncludeDefaultSystemPrompt},
+ }
+ })
+ auditClient := newChatClient(t, func(opts *coderdtest.Options) {
+ opts.Auditor = mAudit
+ })
+ _ = coderdtest.CreateFirstUser(t, auditClient.Client)
+ // Discard the login entry emitted by user creation.
+ mAudit.ResetLogs()
+
+ // Seed only the prompt key, like a pre-toggle deployment: with no
+ // include-default row, the effective flag falls back to false
+ // while a non-empty custom prompt exists.
+ err := auditClient.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{
+ SystemPrompt: "Legacy custom instructions.",
+ })
+ require.NoError(t, err)
+ require.Len(t, mAudit.AuditLogs(), 1)
+ mAudit.ResetLogs()
+
+ // Clearing the prompt without sending the flag flips the
+ // effective include-default value from false to true. The diff
+ // must show that flip, which is only possible if New came from
+ // the post-write re-read.
+ err = auditClient.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{
+ SystemPrompt: "",
+ })
+ require.NoError(t, err)
+
+ logs := mAudit.AuditLogs()
+ require.Len(t, logs, 1)
+ var diff map[string]codersdk.AuditDiffField
+ require.NoError(t, json.Unmarshal(logs[0].Diff, &diff))
+ require.Equal(t, map[string]codersdk.AuditDiffField{
+ "system_prompt": {Old: "Legacy custom instructions.", New: ""},
+ "include_default_system_prompt": {Old: false, New: true},
+ }, diff)
+ })
+
+ t.Run("AuditIncludeDefaultFallbackFlip", func(t *testing.T) {
+ ctx := testutil.Context(t, testutil.WaitLong)
+
+ mAudit := audit.NewMock()
+ auditClient := newChatClient(t, func(opts *coderdtest.Options) {
+ opts.Auditor = mAudit
+ })
+ _ = coderdtest.CreateFirstUser(t, auditClient.Client)
+ // Discard the login entry emitted by user creation.
+ mAudit.ResetLogs()
+
+ // Seed only the prompt key, like a pre-toggle deployment: with no
+ // include-default row, the effective flag falls back to false
+ // while a non-empty custom prompt exists.
+ err := auditClient.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{
+ SystemPrompt: "Legacy custom instructions.",
+ })
+ require.NoError(t, err)
+ require.Len(t, mAudit.AuditLogs(), 1)
+
+ resp, err := auditClient.GetChatSystemPrompt(ctx)
+ require.NoError(t, err)
+ require.Equal(t, "Legacy custom instructions.", resp.SystemPrompt)
+ require.False(t, resp.IncludeDefaultSystemPrompt)
+
+ // Clearing the prompt without sending the flag flips the
+ // effective include-default value from false to true. The entry
+ // must be emitted and its diff must carry the boolean change,
+ // which is why the handler re-reads the pair after writing.
+ mAudit.ResetLogs()
+ err = auditClient.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{
+ SystemPrompt: "",
+ })
+ require.NoError(t, err)
+
+ logs := mAudit.AuditLogs()
+ require.Len(t, logs, 1)
+ require.Equal(t, database.AuditActionWrite, logs[0].Action)
+ require.Equal(t, database.ResourceTypeChatInstructionSettings, logs[0].ResourceType)
+
+ resp, err = auditClient.GetChatSystemPrompt(ctx)
+ require.NoError(t, err)
+ require.Empty(t, resp.SystemPrompt)
+ require.True(t, resp.IncludeDefaultSystemPrompt)
+
+ // Repeating the cleared state changes nothing: no entry.
+ mAudit.ResetLogs()
+ err = auditClient.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{
+ SystemPrompt: "",
+ })
+ require.NoError(t, err)
+ require.Empty(t, mAudit.AuditLogs())
+ })
}
//nolint:tparallel,paralleltest // Subtests share a single coderdtest instance.
@@ -12841,6 +13504,313 @@ func TestChatPlanModeInstructions(t *testing.T) {
_, err := memberClient.GetChatPlanModeInstructions(ctx)
requireSDKError(t, err, http.StatusNotFound)
})
+
+ t.Run("Audit", func(t *testing.T) {
+ ctx := testutil.Context(t, testutil.WaitLong)
+
+ mAudit := audit.NewMock()
+ auditClient := newChatClient(t, func(opts *coderdtest.Options) {
+ opts.Auditor = mAudit
+ })
+ _ = coderdtest.CreateFirstUser(t, auditClient.Client)
+ // Discard the login entry emitted by user creation.
+ mAudit.ResetLogs()
+
+ // A value change emits a Write entry with the setting's stable
+ // identity, distinct from the system prompt's.
+ err := auditClient.UpdateChatPlanModeInstructions(ctx, codersdk.UpdateChatPlanModeInstructionsRequest{
+ PlanModeInstructions: "Draft a plan first.",
+ })
+ require.NoError(t, err)
+
+ logs := mAudit.AuditLogs()
+ require.Len(t, logs, 1)
+ require.Equal(t, database.AuditActionWrite, logs[0].Action)
+ require.Equal(t, database.ResourceTypeChatInstructionSettings, logs[0].ResourceType)
+ require.Equal(t, audit.ChatInstructionPlanModeID, logs[0].ResourceID)
+ require.Equal(t, "Plan mode instructions", logs[0].ResourceTarget)
+ require.NotEqual(t, audit.ChatInstructionSystemPromptID, logs[0].ResourceID)
+ require.Equal(t, uuid.Nil, logs[0].OrganizationID)
+ require.EqualValues(t, http.StatusNoContent, logs[0].StatusCode)
+
+ // A value-identical PUT emits nothing (the write still happens).
+ mAudit.ResetLogs()
+ err = auditClient.UpdateChatPlanModeInstructions(ctx, codersdk.UpdateChatPlanModeInstructionsRequest{
+ PlanModeInstructions: "Draft a plan first.",
+ })
+ require.NoError(t, err)
+ require.Empty(t, mAudit.AuditLogs())
+
+ resp, err := auditClient.GetChatPlanModeInstructions(ctx)
+ require.NoError(t, err)
+ require.Equal(t, "Draft a plan first.", resp.PlanModeInstructions)
+
+ // A second distinct change keeps the same stable identity.
+ mAudit.ResetLogs()
+ err = auditClient.UpdateChatPlanModeInstructions(ctx, codersdk.UpdateChatPlanModeInstructionsRequest{
+ PlanModeInstructions: "Draft a better plan.",
+ })
+ require.NoError(t, err)
+ logs = mAudit.AuditLogs()
+ require.Len(t, logs, 1)
+ require.Equal(t, audit.ChatInstructionPlanModeID, logs[0].ResourceID)
+ require.Equal(t, "Plan mode instructions", logs[0].ResourceTarget)
+
+ // A failed PUT records the attempt with an empty diff.
+ mAudit.ResetLogs()
+ tooLong := strings.Repeat("a", 131073)
+ err = auditClient.UpdateChatPlanModeInstructions(ctx, codersdk.UpdateChatPlanModeInstructionsRequest{
+ PlanModeInstructions: tooLong,
+ })
+ requireSDKError(t, err, http.StatusBadRequest)
+ logs = mAudit.AuditLogs()
+ require.Len(t, logs, 1)
+ require.EqualValues(t, http.StatusBadRequest, logs[0].StatusCode)
+ require.JSONEq(t, "{}", string(logs[0].Diff))
+ })
+
+ // A denied PUT records the attempt with status 403, an empty diff, and
+ // no request body content in the row.
+ t.Run("AuditDeniedPUTRecordsAttempt", func(t *testing.T) {
+ ctx := testutil.Context(t, testutil.WaitLong)
+
+ mAudit := audit.NewMock()
+ auditClient := newChatClient(t, func(opts *coderdtest.Options) {
+ opts.Auditor = mAudit
+ })
+ firstUser := coderdtest.CreateFirstUser(t, auditClient.Client)
+ // Discard the login entry emitted by user creation.
+ mAudit.ResetLogs()
+
+ memberClientRaw, _ := coderdtest.CreateAnotherUser(t, auditClient.Client, firstUser.OrganizationID)
+ memberClient := codersdk.NewExperimentalClient(memberClientRaw)
+ mAudit.ResetLogs()
+
+ err := memberClient.UpdateChatPlanModeInstructions(ctx, codersdk.UpdateChatPlanModeInstructionsRequest{
+ PlanModeInstructions: "must not leak into the audit row",
+ })
+ requireSDKError(t, err, http.StatusForbidden)
+
+ logs := mAudit.AuditLogs()
+ require.Len(t, logs, 1)
+ require.Equal(t, database.ResourceTypeChatInstructionSettings, logs[0].ResourceType)
+ require.Equal(t, audit.ChatInstructionPlanModeID, logs[0].ResourceID)
+ require.Equal(t, "Plan mode instructions", logs[0].ResourceTarget)
+ require.EqualValues(t, http.StatusForbidden, logs[0].StatusCode)
+ require.JSONEq(t, "{}", string(logs[0].Diff))
+ require.NotContains(t, string(logs[0].Diff), "must not leak")
+ })
+
+ // A failing upsert rolls the transaction back: the request fails with
+ // 500, no audit entry is emitted, and the stale Old the failed request
+ // captured must not poison a later no-change PUT into being audited
+ // as a change.
+ t.Run("AuditUpsertFailureThenNoChangeSuppressed", func(t *testing.T) {
+ ctx := testutil.Context(t, testutil.WaitLong)
+
+ rawDB, pubsub := dbtestutil.NewDB(t)
+ store := newFailNextUpsertChatPlanModeInstructionsStore(rawDB)
+ mAudit := audit.NewMock()
+ rawClient, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{
+ Database: store,
+ Pubsub: pubsub,
+ DeploymentValues: coderdtest.DeploymentValues(t),
+ Auditor: mAudit,
+ })
+ aibridgedtest.StartTestAIBridgeDaemon(t.Context(), t, api, nil)
+ client := codersdk.NewExperimentalClient(rawClient)
+ _ = coderdtest.CreateFirstUser(t, client.Client)
+ // Discard the login entry emitted by user creation.
+ mAudit.ResetLogs()
+
+ err := client.UpdateChatPlanModeInstructions(ctx, codersdk.UpdateChatPlanModeInstructionsRequest{
+ PlanModeInstructions: "Persist me.",
+ })
+ require.NoError(t, err)
+ require.Len(t, mAudit.AuditLogs(), 1)
+
+ mAudit.ResetLogs()
+ store.failNextUpsertChatPlanModeInstructions.Store(true)
+ err = client.UpdateChatPlanModeInstructions(ctx, codersdk.UpdateChatPlanModeInstructionsRequest{
+ PlanModeInstructions: "Never stored.",
+ })
+ sdkErr := requireSDKError(t, err, http.StatusInternalServerError)
+ // The response detail must be the raw write error, byte-identical
+ // to the pre-transaction endpoint, not the InTx wrapper.
+ require.Equal(t, "forced plan mode instructions upsert failure", sdkErr.Detail)
+ // The failed request records the attempt with an empty diff.
+ logs := mAudit.AuditLogs()
+ require.Len(t, logs, 1)
+ require.EqualValues(t, http.StatusInternalServerError, logs[0].StatusCode)
+ require.JSONEq(t, "{}", string(logs[0].Diff))
+
+ // The failed write rolled back, so the stored value is still
+ // "Persist me." and repeating it is a no-op: no entry. The stale
+ // Old from the failed request must not survive into this one.
+ mAudit.ResetLogs()
+ err = client.UpdateChatPlanModeInstructions(ctx, codersdk.UpdateChatPlanModeInstructionsRequest{
+ PlanModeInstructions: "Persist me.",
+ })
+ require.NoError(t, err)
+ require.Empty(t, mAudit.AuditLogs())
+ })
+
+ // A lock wait that times out falls back to the unaudited write path:
+ // the write succeeds, and because no baseline was captured, no entry
+ // is emitted (not even an empty-diff success entry).
+ t.Run("AuditLockFailureFallsBackWithoutEntry", func(t *testing.T) {
+ ctx := testutil.Context(t, testutil.WaitLong)
+
+ rawDB, pubsub := dbtestutil.NewDB(t)
+ store := newFailNextAcquireLockStore(rawDB, database.LockIDChatInstructionPlanMode)
+ mAudit := audit.NewMock()
+ rawClient, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{
+ Database: store,
+ Pubsub: pubsub,
+ DeploymentValues: coderdtest.DeploymentValues(t),
+ Auditor: mAudit,
+ })
+ aibridgedtest.StartTestAIBridgeDaemon(t.Context(), t, api, nil)
+ client := codersdk.NewExperimentalClient(rawClient)
+ _ = coderdtest.CreateFirstUser(t, client.Client)
+ // Discard the login entry emitted by user creation.
+ mAudit.ResetLogs()
+
+ store.failNextAcquireLock.Store(true)
+ err := client.UpdateChatPlanModeInstructions(ctx, codersdk.UpdateChatPlanModeInstructionsRequest{
+ PlanModeInstructions: "Written despite lock timeout.",
+ })
+ require.NoError(t, err)
+ require.Empty(t, mAudit.AuditLogs())
+
+ resp, err := client.GetChatPlanModeInstructions(ctx)
+ require.NoError(t, err)
+ require.Equal(t, "Written despite lock timeout.", resp.PlanModeInstructions)
+
+ // The next PUT reacquires the lock normally and audits the change
+ // against the fallback-written value.
+ mAudit.ResetLogs()
+ err = client.UpdateChatPlanModeInstructions(ctx, codersdk.UpdateChatPlanModeInstructionsRequest{
+ PlanModeInstructions: "Written after lock recovery.",
+ })
+ require.NoError(t, err)
+ logs := mAudit.AuditLogs()
+ require.Len(t, logs, 1)
+ require.EqualValues(t, http.StatusNoContent, logs[0].StatusCode)
+ })
+
+ // Two concurrent identical PUTs both succeed, but the per-setting lock
+ // serializes the Old capture with the write, so the second
+ // transaction's comparison sees the first's committed state and only
+ // the first write is audited as a change. The barrier forces both
+ // transactions to the Old capture before either commits, so the
+ // lock-off case below reproduces the stale-Old race deterministically.
+ t.Run("AuditConcurrentIdenticalPUTsSingleEntry", func(t *testing.T) {
+ ctx := testutil.Context(t, testutil.WaitLong)
+
+ rawDB, pubsub := dbtestutil.NewDB(t)
+ store := newLockSwitchChatPlanModeInstructionsStore(rawDB)
+ store.lockEnabled.Store(true)
+ mAudit := audit.NewMock()
+ rawClient, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{
+ Database: store,
+ Pubsub: pubsub,
+ DeploymentValues: coderdtest.DeploymentValues(t),
+ Auditor: mAudit,
+ })
+ aibridgedtest.StartTestAIBridgeDaemon(t.Context(), t, api, nil)
+ client := codersdk.NewExperimentalClient(rawClient)
+ _ = coderdtest.CreateFirstUser(t, client.Client)
+ // Discard the login entry emitted by user creation.
+ mAudit.ResetLogs()
+
+ err := client.UpdateChatPlanModeInstructions(ctx, codersdk.UpdateChatPlanModeInstructionsRequest{
+ PlanModeInstructions: "Seed instructions.",
+ })
+ require.NoError(t, err)
+ require.Len(t, mAudit.AuditLogs(), 1)
+
+ // Concurrent identical PUTs of a NEW value under the lock: the
+ // first is a real change, the second sees the first's committed
+ // value and is a no-op.
+ mAudit.ResetLogs()
+ const puts = 2
+ start := make(chan struct{})
+ errs := make(chan error, puts)
+ var wg sync.WaitGroup
+ for range puts {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ <-start
+ errs <- client.UpdateChatPlanModeInstructions(ctx, codersdk.UpdateChatPlanModeInstructionsRequest{
+ PlanModeInstructions: "Concurrent instructions.",
+ })
+ }()
+ }
+ close(start)
+ wg.Wait()
+ close(errs)
+ for err := range errs {
+ require.NoError(t, err)
+ }
+ require.Len(t, mAudit.AuditLogs(), 1)
+ })
+
+ // With the lock removed, the barrier parks both transactions at the
+ // Old capture before either writes, so both read the same stale Old,
+ // both write, and both emit: the exact duplicate the lock prevents.
+ // This is the mutation proof for the lock.
+ t.Run("AuditConcurrentIdenticalPUTsDuplicateWithoutLock", func(t *testing.T) {
+ ctx := testutil.Context(t, testutil.WaitLong)
+
+ rawDB, pubsub := dbtestutil.NewDB(t)
+ store := newLockSwitchChatPlanModeInstructionsStore(rawDB)
+ // Lock disabled.
+ mAudit := audit.NewMock()
+ rawClient, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{
+ Database: store,
+ Pubsub: pubsub,
+ DeploymentValues: coderdtest.DeploymentValues(t),
+ Auditor: mAudit,
+ })
+ aibridgedtest.StartTestAIBridgeDaemon(t.Context(), t, api, nil)
+ client := codersdk.NewExperimentalClient(rawClient)
+ _ = coderdtest.CreateFirstUser(t, client.Client)
+ // Discard the login entry emitted by user creation.
+ mAudit.ResetLogs()
+
+ err := client.UpdateChatPlanModeInstructions(ctx, codersdk.UpdateChatPlanModeInstructionsRequest{
+ PlanModeInstructions: "Seed instructions.",
+ })
+ require.NoError(t, err)
+ require.Len(t, mAudit.AuditLogs(), 1)
+
+ mAudit.ResetLogs()
+ store.target.Store(2)
+ const puts = 2
+ start := make(chan struct{})
+ errs := make(chan error, puts)
+ var wg sync.WaitGroup
+ for range puts {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ <-start
+ errs <- client.UpdateChatPlanModeInstructions(ctx, codersdk.UpdateChatPlanModeInstructionsRequest{
+ PlanModeInstructions: "Concurrent instructions.",
+ })
+ }()
+ }
+ close(start)
+ wg.Wait()
+ close(errs)
+ for err := range errs {
+ require.NoError(t, err)
+ }
+ // Both captured the same stale Old and both wrote: two entries.
+ require.Len(t, mAudit.AuditLogs(), 2)
+ })
}
//nolint:tparallel,paralleltest // Setting subtests share per-setting coderdtest instances.
diff --git a/codersdk/audit.go b/codersdk/audit.go
index 637410807a234..7ee41ce51581d 100644
--- a/codersdk/audit.go
+++ b/codersdk/audit.go
@@ -44,17 +44,18 @@ const (
ResourceTypeWorkspaceAgent ResourceType = "workspace_agent"
// Deprecated: Workspace App connections are now included in the
// connection log.
- ResourceTypeWorkspaceApp ResourceType = "workspace_app"
- ResourceTypeTask ResourceType = "task"
- ResourceTypeAISeat ResourceType = "ai_seat"
- ResourceTypeAIProvider ResourceType = "ai_provider"
- ResourceTypeAIProviderKey ResourceType = "ai_provider_key"
- ResourceTypeAIGatewayKey ResourceType = "ai_gateway_key"
- ResourceTypeGroupAIBudget ResourceType = "group_ai_budget"
- ResourceTypeUserAIBudgetOverride ResourceType = "user_ai_budget_override"
- ResourceTypeChat ResourceType = "chat"
- ResourceTypeUserSecret ResourceType = "user_secret"
- ResourceTypeUserSkill ResourceType = "user_skill"
+ ResourceTypeWorkspaceApp ResourceType = "workspace_app"
+ ResourceTypeTask ResourceType = "task"
+ ResourceTypeAISeat ResourceType = "ai_seat"
+ ResourceTypeAIProvider ResourceType = "ai_provider"
+ ResourceTypeAIProviderKey ResourceType = "ai_provider_key"
+ ResourceTypeAIGatewayKey ResourceType = "ai_gateway_key"
+ ResourceTypeGroupAIBudget ResourceType = "group_ai_budget"
+ ResourceTypeUserAIBudgetOverride ResourceType = "user_ai_budget_override"
+ ResourceTypeChat ResourceType = "chat"
+ ResourceTypeUserSecret ResourceType = "user_secret"
+ ResourceTypeUserSkill ResourceType = "user_skill"
+ ResourceTypeChatInstructionSettings ResourceType = "chat_instruction_settings"
)
func (r ResourceType) FriendlyString() string {
@@ -133,6 +134,8 @@ func (r ResourceType) FriendlyString() string {
return "user secret"
case ResourceTypeUserSkill:
return "user skill"
+ case ResourceTypeChatInstructionSettings:
+ return "chat instruction settings"
default:
return "unknown"
}
diff --git a/docs/admin/security/audit-logs.md b/docs/admin/security/audit-logs.md
index 136dcb50fc803..27082192dced3 100644
--- a/docs/admin/security/audit-logs.md
+++ b/docs/admin/security/audit-logs.md
@@ -26,6 +26,7 @@ We track the following resources:
| AuditableOrganizationMember
|
| Field | Tracked |
| | created_at | true |
| organization_id | false |
| roles | true |
| updated_at | true |
| user_id | true |
| username | true |
|
| AuditableUserAIBudgetOverride
write, delete | | Field | Tracked |
| | created_at | false |
| group_id | true |
| group_name | true |
| spend_limit | true |
| spend_limit_micros | false |
| updated_at | false |
| user_id | false |
| username | false |
|
| Chat
create, write | | Field | Tracked |
| | agent_id | false |
| archived | true |
| build_id | false |
| client_type | false |
| compaction_requested_at | false |
| context_aggregate_hash | false |
| context_dirty_resources | false |
| context_dirty_since | false |
| context_error | false |
| created_at | false |
| dynamic_tools | false |
| generation_attempt | false |
| group_acl | true |
| heartbeat_at | false |
| history_version | false |
| id | true |
| labels | true |
| last_error | false |
| last_model_config_id | false |
| last_read_message_id | false |
| last_reasoning_effort | false |
| last_turn_summary | false |
| mcp_server_ids | true |
| mode | true |
| organization_id | false |
| owner_id | true |
| owner_name | false |
| owner_username | false |
| parent_chat_id | false |
| pin_order | true |
| plan_mode | false |
| queue_version | false |
| requires_action_deadline_at | false |
| retry_state | false |
| retry_state_version | false |
| root_chat_id | false |
| runner_id | false |
| snapshot_version | false |
| started_at | false |
| status | false |
| summary | false |
| summary_generated_at | false |
| title | true |
| updated_at | false |
| user_acl | true |
| worker_id | false |
| workspace_id | true |
|
+| ChatInstructionSettings
write | | Field | Tracked |
| | id | false |
| include_default_system_prompt | true |
| include_default_system_prompt_set | true |
| name | false |
| plan_mode_instructions | true |
| system_prompt | true |
|
| CustomRole
| | Field | Tracked |
| | created_at | false |
| display_name | true |
| id | false |
| is_system | false |
| member_permissions | true |
| name | true |
| org_permissions | true |
| organization_id | false |
| site_permissions | true |
| updated_at | false |
| user_permissions | true |
|
| GitSSHKey
create | | Field | Tracked |
| | created_at | false |
| private_key | true |
| private_key_key_id | false |
| public_key | true |
| updated_at | false |
| user_id | true |
|
| GroupSyncSettings
| | Field | Tracked |
| | auto_create_missing_groups | true |
| field | true |
| legacy_group_name_mapping | false |
| mapping | true |
| regex_filter | true |
|
diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md
index 6383f702336b6..2c20028c82ff0 100644
--- a/docs/reference/api/schemas.md
+++ b/docs/reference/api/schemas.md
@@ -11542,9 +11542,9 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith
#### Enumerated Values
-| Value(s) |
-|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
-| `ai_gateway_key`, `ai_provider`, `ai_provider_key`, `ai_seat`, `api_key`, `chat`, `convert_login`, `custom_role`, `git_ssh_key`, `group`, `group_ai_budget`, `health_settings`, `idp_sync_settings_group`, `idp_sync_settings_organization`, `idp_sync_settings_role`, `license`, `notification_template`, `notifications_settings`, `oauth2_provider_app`, `oauth2_provider_app_secret`, `oauth2_provider_settings`, `organization`, `organization_member`, `prebuilds_settings`, `task`, `template`, `template_version`, `user`, `user_ai_budget_override`, `user_secret`, `user_skill`, `workspace`, `workspace_agent`, `workspace_app`, `workspace_build`, `workspace_proxy` |
+| Value(s) |
+|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| `ai_gateway_key`, `ai_provider`, `ai_provider_key`, `ai_seat`, `api_key`, `chat`, `chat_instruction_settings`, `convert_login`, `custom_role`, `git_ssh_key`, `group`, `group_ai_budget`, `health_settings`, `idp_sync_settings_group`, `idp_sync_settings_organization`, `idp_sync_settings_role`, `license`, `notification_template`, `notifications_settings`, `oauth2_provider_app`, `oauth2_provider_app_secret`, `oauth2_provider_settings`, `organization`, `organization_member`, `prebuilds_settings`, `task`, `template`, `template_version`, `user`, `user_ai_budget_override`, `user_secret`, `user_skill`, `workspace`, `workspace_agent`, `workspace_app`, `workspace_build`, `workspace_proxy` |
## codersdk.Response
diff --git a/enterprise/audit/diff_internal_test.go b/enterprise/audit/diff_internal_test.go
index bca368d8603d5..3a4eea8480f1c 100644
--- a/enterprise/audit/diff_internal_test.go
+++ b/enterprise/audit/diff_internal_test.go
@@ -504,6 +504,61 @@ func Test_diff(t *testing.T) {
},
},
})
+
+ runDiffTests(t, []diffTest{
+ {
+ // Prompt text is tracked, not secret: reviewers must see what
+ // agents are told to do (CODAGT-719). The system-prompt
+ // endpoint populates only its two fields; the untouched
+ // plan-mode field stays zero on both sides and never diffs.
+ name: "SystemPromptChangeTracked",
+ left: database.ChatInstructionSettings{
+ SystemPrompt: "old instructions",
+ IncludeDefaultSystemPrompt: true,
+ },
+ right: database.ChatInstructionSettings{
+ ID: uuid.UUID{1},
+ SystemPrompt: "new instructions",
+ IncludeDefaultSystemPrompt: false,
+ },
+ exp: audit.Map{
+ "system_prompt": audit.OldNew{Old: "old instructions", New: "new instructions"},
+ "include_default_system_prompt": audit.OldNew{Old: true, New: false},
+ },
+ },
+ {
+ // The plan-mode endpoint populates only its own field; the
+ // system-prompt fields stay zero on both sides, so a
+ // plan-mode change diffs exactly one field.
+ name: "PlanModeInstructionsChangeTracked",
+ left: database.ChatInstructionSettings{
+ PlanModeInstructions: "old plan guidance",
+ },
+ right: database.ChatInstructionSettings{
+ ID: uuid.UUID{1},
+ PlanModeInstructions: "new plan guidance",
+ },
+ exp: audit.Map{
+ "plan_mode_instructions": audit.OldNew{Old: "old plan guidance", New: "new plan guidance"},
+ },
+ },
+ {
+ // The artificial ID is ignored, so a value-identical write
+ // would diff empty. Handlers additionally suppress the entry
+ // entirely by leaving both resource IDs nil.
+ name: "ArtificialIDIgnored",
+ left: database.ChatInstructionSettings{
+ SystemPrompt: "same",
+ IncludeDefaultSystemPrompt: true,
+ },
+ right: database.ChatInstructionSettings{
+ ID: uuid.UUID{1},
+ SystemPrompt: "same",
+ IncludeDefaultSystemPrompt: true,
+ },
+ exp: audit.Map{},
+ },
+ })
}
func runDiffTests(t *testing.T, tests []diffTest) {
diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go
index a58d523d7db76..4e1082a67b138 100644
--- a/enterprise/audit/table.go
+++ b/enterprise/audit/table.go
@@ -37,6 +37,7 @@ var AuditActionMap = map[string][]codersdk.AuditAction{
"Chat": {codersdk.AuditActionCreate, codersdk.AuditActionWrite}, // chats get 'archived' by users, not deleted.
"UserSecret": {codersdk.AuditActionCreate, codersdk.AuditActionWrite, codersdk.AuditActionDelete},
"UserSkill": {codersdk.AuditActionCreate, codersdk.AuditActionWrite, codersdk.AuditActionDelete},
+ "ChatInstructionSettings": {codersdk.AuditActionWrite},
}
type Action string
@@ -284,6 +285,14 @@ var auditableResourcesTypes = map[any]map[string]Action{
"id": ActionIgnore,
"dynamic_client_registration_enabled": ActionTrack,
},
+ &database.ChatInstructionSettings{}: {
+ "id": ActionIgnore,
+ "name": ActionIgnore,
+ "system_prompt": ActionTrack,
+ "include_default_system_prompt_set": ActionTrack,
+ "include_default_system_prompt": ActionTrack,
+ "plan_mode_instructions": ActionTrack,
+ },
// TODO: track an ID here when the below ticket is completed:
// https://github.com/coder/coder/pull/6012
&database.License{}: {
diff --git a/enterprise/coderd/chatinstructions_audit_test.go b/enterprise/coderd/chatinstructions_audit_test.go
new file mode 100644
index 0000000000000..e6d810f0e05a3
--- /dev/null
+++ b/enterprise/coderd/chatinstructions_audit_test.go
@@ -0,0 +1,193 @@
+package coderd_test
+
+import (
+ "context"
+ "encoding/json"
+ stderrors "errors"
+ "sync"
+ "sync/atomic"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "cdr.dev/slog/v3/sloggers/slogtest"
+ "github.com/coder/coder/v2/coderd/coderdtest"
+ "github.com/coder/coder/v2/coderd/database"
+ "github.com/coder/coder/v2/coderd/database/dbtestutil"
+ "github.com/coder/coder/v2/coderd/util/ptr"
+ "github.com/coder/coder/v2/codersdk"
+ entaudit "github.com/coder/coder/v2/enterprise/audit"
+ "github.com/coder/coder/v2/testutil"
+)
+
+// captureBackend is an enterprise audit backend that records exported logs
+// so tests can assert on the production differ's output.
+type captureBackend struct {
+ mu sync.Mutex
+ logs []database.AuditLog
+}
+
+func (*captureBackend) Decision() entaudit.FilterDecision {
+ return entaudit.FilterDecisionStore | entaudit.FilterDecisionExport
+}
+
+func (b *captureBackend) Export(_ context.Context, alog database.AuditLog, _ entaudit.BackendDetails) error {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ b.logs = append(b.logs, alog)
+ return nil
+}
+
+func (b *captureBackend) reset() {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ b.logs = nil
+}
+
+func (b *captureBackend) entries() []database.AuditLog {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ out := make([]database.AuditLog, len(b.logs))
+ copy(out, b.logs)
+ return out
+}
+
+// failNextGetChatSystemPromptConfigStore fails the Old capture read of the
+// chat system prompt configuration once. Failure state is shared across InTx
+// wrappers.
+type failNextGetChatSystemPromptConfigStore struct {
+ database.Store
+
+ fail *atomic.Bool
+}
+
+func newFailNextGetChatSystemPromptConfigStore(store database.Store) *failNextGetChatSystemPromptConfigStore {
+ return &failNextGetChatSystemPromptConfigStore{
+ Store: store,
+ fail: &atomic.Bool{},
+ }
+}
+
+func (s *failNextGetChatSystemPromptConfigStore) InTx(function func(database.Store) error, txOpts *database.TxOptions) error {
+ return s.Store.InTx(func(tx database.Store) error {
+ return function(&failNextGetChatSystemPromptConfigStore{
+ Store: tx,
+ fail: s.fail,
+ })
+ }, txOpts)
+}
+
+func (s *failNextGetChatSystemPromptConfigStore) GetChatSystemPromptConfig(ctx context.Context) (database.GetChatSystemPromptConfigRow, error) {
+ if s.fail.CompareAndSwap(true, false) {
+ return database.GetChatSystemPromptConfigRow{}, stderrors.New("forced old capture failure")
+ }
+ return s.Store.GetChatSystemPromptConfig(ctx)
+}
+
+// TestChatInstructionSettingsOldCaptureBestEffort proves the Old capture is
+// best effort: a failed Old read costs nothing (nothing was written yet), so
+// the handler runs the direct write path and emits no audit entry. There is
+// no post-write read to fail, so no diff can ever be fabricated from a zero
+// value.
+func TestChatInstructionSettingsOldCaptureBestEffort(t *testing.T) {
+ t.Parallel()
+
+ ctx := testutil.Context(t, testutil.WaitLong)
+
+ rawDB, pubsub := dbtestutil.NewDB(t)
+ store := newFailNextGetChatSystemPromptConfigStore(rawDB)
+ backend := &captureBackend{}
+ auditor := entaudit.NewAuditor(rawDB, entaudit.DefaultFilter, backend)
+ logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
+ rawClient, _, _ := coderdtest.NewWithAPI(t, &coderdtest.Options{
+ Database: store,
+ Pubsub: pubsub,
+ DeploymentValues: coderdtest.DeploymentValues(t),
+ Auditor: auditor,
+ Logger: &logger,
+ })
+ client := codersdk.NewExperimentalClient(rawClient)
+ _ = coderdtest.CreateFirstUser(t, client.Client)
+ // Discard the login entry emitted by user creation.
+ backend.reset()
+
+ // The Old capture fails before anything is written: the transaction
+ // is abandoned at no cost, the handler runs the direct write path,
+ // and no audit entry is emitted (there is no baseline to diff
+ // against, so any row would be a guess).
+ store.fail.Store(true)
+ err := client.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{
+ SystemPrompt: "Changed prompt.",
+ IncludeDefaultSystemPrompt: ptr.Ref(true),
+ })
+ require.NoError(t, err)
+ require.Empty(t, backend.entries())
+
+ resp, err := client.GetChatSystemPrompt(ctx)
+ require.NoError(t, err)
+ require.Equal(t, "Changed prompt.", resp.SystemPrompt)
+ require.True(t, resp.IncludeDefaultSystemPrompt)
+}
+
+// TestChatInstructionSettingsIncludeDefaultPresence exercises the production
+// differ against the include-default presence transition: writing an explicit
+// false over a legacy absent override row does not move the effective value
+// but inserts the persistent row, which changes future behavior, so the
+// presence transition must be audited.
+func TestChatInstructionSettingsIncludeDefaultPresence(t *testing.T) {
+ t.Parallel()
+
+ ctx := testutil.Context(t, testutil.WaitLong)
+
+ rawDB, pubsub := dbtestutil.NewDB(t)
+ backend := &captureBackend{}
+ auditor := entaudit.NewAuditor(rawDB, entaudit.DefaultFilter, backend)
+ logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
+ rawClient, _, _ := coderdtest.NewWithAPI(t, &coderdtest.Options{
+ Database: rawDB,
+ Pubsub: pubsub,
+ DeploymentValues: coderdtest.DeploymentValues(t),
+ Auditor: auditor,
+ Logger: &logger,
+ })
+ client := codersdk.NewExperimentalClient(rawClient)
+ _ = coderdtest.CreateFirstUser(t, client.Client)
+ // Discard the login entry emitted by user creation.
+ backend.reset()
+
+ // Legacy shape: a non-empty prompt with no override row computes
+ // effective false.
+ err := client.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{
+ SystemPrompt: "Legacy custom instructions.",
+ })
+ require.NoError(t, err)
+ require.Len(t, backend.entries(), 1)
+
+ // Explicit false over the absent row: same effective value, but the
+ // override row now exists, so the entry must record the presence
+ // transition.
+ backend.reset()
+ err = client.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{
+ SystemPrompt: "Legacy custom instructions.",
+ IncludeDefaultSystemPrompt: ptr.Ref(false),
+ })
+ require.NoError(t, err)
+ require.Len(t, backend.entries(), 1)
+ var diff map[string]codersdk.AuditDiffField
+ require.NoError(t, json.Unmarshal(backend.entries()[0].Diff, &diff))
+ require.Equal(t, codersdk.AuditDiffField{Old: false, New: true},
+ diff["include_default_system_prompt_set"])
+ // The effective value did not move, and the prompt is unchanged.
+ require.NotContains(t, diff, "include_default_system_prompt")
+ require.NotContains(t, diff, "system_prompt")
+
+ // Repeating the same explicit-false PUT is a true no-op now: the row
+ // exists with the same value, so nothing is audited.
+ backend.reset()
+ err = client.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{
+ SystemPrompt: "Legacy custom instructions.",
+ IncludeDefaultSystemPrompt: ptr.Ref(false),
+ })
+ require.NoError(t, err)
+ require.Empty(t, backend.entries())
+}
diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts
index 54a1e5dfd6129..74d8e009c6d8d 100644
--- a/site/src/api/typesGenerated.ts
+++ b/site/src/api/typesGenerated.ts
@@ -7939,6 +7939,7 @@ export type ResourceType =
| "ai_seat"
| "api_key"
| "chat"
+ | "chat_instruction_settings"
| "convert_login"
| "custom_role"
| "git_ssh_key"
@@ -7977,6 +7978,7 @@ export const ResourceTypes: ResourceType[] = [
"ai_seat",
"api_key",
"chat",
+ "chat_instruction_settings",
"convert_login",
"custom_role",
"git_ssh_key",
diff --git a/site/src/pages/AuditPage/AuditFilter.tsx b/site/src/pages/AuditPage/AuditFilter.tsx
index 47fc09e3155c0..fa527dccc4cf0 100644
--- a/site/src/pages/AuditPage/AuditFilter.tsx
+++ b/site/src/pages/AuditPage/AuditFilter.tsx
@@ -155,6 +155,10 @@ export const useResourceTypeFilterMenu = ({
label = "Workspace Build";
}
+ if (type === "chat_instruction_settings") {
+ label = "Chat Instruction Settings";
+ }
+
return {
value: type,
label,
diff --git a/site/src/pages/AuditPage/AuditPageView.stories.tsx b/site/src/pages/AuditPage/AuditPageView.stories.tsx
index 035dbe5fca518..d9d94536cb00f 100644
--- a/site/src/pages/AuditPage/AuditPageView.stories.tsx
+++ b/site/src/pages/AuditPage/AuditPageView.stories.tsx
@@ -1,6 +1,6 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { ComponentProps } from "react";
-import { expect, within } from "storybook/test";
+import { expect, fn, screen, userEvent, within } from "storybook/test";
import {
getDefaultFilterProps,
MockMenu,
@@ -18,6 +18,7 @@ import {
MockUserOwner,
} from "#/testHelpers/entities";
import { pixelWithTablet } from "#/testHelpers/pixel";
+import { useResourceTypeFilterMenu } from "./AuditFilter";
import { AuditPageView } from "./AuditPageView";
type FilterProps = ComponentProps["filterProps"];
@@ -119,6 +120,53 @@ export const NotVisibleWithoutLicenseAccess: Story = {
},
};
+const onResourceTypeChange = fn();
+
+// Uses the real resource-type menu so the generated resource type and its
+// friendly label are verified together.
+export const FilterByChatInstructionSettings: Story = {
+ args: {
+ auditsQuery: mockSuccessResult,
+ },
+ render: function AuditPageViewWithResourceTypeMenu(args) {
+ const resourceTypeMenu = useResourceTypeFilterMenu({
+ value: undefined,
+ onChange: onResourceTypeChange,
+ });
+ return (
+
+ );
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ onResourceTypeChange.mockClear();
+
+ await userEvent.click(
+ canvas.getByRole("button", { name: "Select a resource type" }),
+ );
+ const option = await screen.findByRole("option", {
+ name: "Chat Instruction Settings",
+ });
+ await userEvent.click(option);
+
+ await expect(onResourceTypeChange).toHaveBeenCalledWith(
+ expect.objectContaining({
+ value: "chat_instruction_settings",
+ label: "Chat Instruction Settings",
+ }),
+ );
+ },
+};
+
export const MultiOrg: Story = {
parameters: { pixel: { matrix: pixelWithTablet } },
args: {