From fe13bb2a20dfbcf5deb75857787b73cf72a1e801 Mon Sep 17 00:00:00 2001
From: Ethan
Date: Tue, 26 May 2026 13:16:32 +1000
Subject: [PATCH 001/249] fix(coderd/x/chatd): seed afterMessageID test
directly (#25665)
This fixes the flaky `TestSubscribeAfterMessageID` by seeding its chat
and messages directly, so the test no longer creates pending work that a
chat worker can pick up. The assertion now covers only the
`afterMessageID` subscription behavior, independent of chat processing
lifecycle timing.
Closes DEVEX-326
Closes https://github.com/coder/internal/issues/1489
---
coderd/x/chatd/chatd_test.go | 31 ++++++++++++++++++++++---------
1 file changed, 22 insertions(+), 9 deletions(-)
diff --git a/coderd/x/chatd/chatd_test.go b/coderd/x/chatd/chatd_test.go
index 678ad842bdcbb..d8be67eaaef79 100644
--- a/coderd/x/chatd/chatd_test.go
+++ b/coderd/x/chatd/chatd_test.go
@@ -4782,18 +4782,30 @@ func TestSubscribeAfterMessageID(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
user, org, model := seedChatDependencies(t, db)
- // Create a chat. This inserts one initial "user" message.
- chat, err := replica.CreateChat(ctx, chatd.CreateOptions{
- OrganizationID: org.ID,
- OwnerID: user.ID,
- Title: "after-id-test",
- ModelConfigID: model.ID,
- InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("first")},
+ chat := dbgen.Chat(t, db, database.Chat{
+ OrganizationID: org.ID,
+ OwnerID: user.ID,
+ LastModelConfigID: model.ID,
+ Title: "after-id-test",
+ Status: database.ChatStatusWaiting,
+ })
+
+ // Seed all messages directly so this subscription test is independent
+ // of chat processing lifecycle behavior.
+ firstContent, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{
+ codersdk.ChatMessageText("first"),
})
require.NoError(t, err)
- // Insert two more messages so we have three total visible
- // messages (the initial user message plus these two).
+ _ = dbgen.ChatMessage(t, db, database.ChatMessage{
+ ChatID: chat.ID,
+ CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true},
+ ModelConfigID: uuid.NullUUID{UUID: model.ID, Valid: true},
+ Role: database.ChatMessageRoleUser,
+ ContentVersion: chatprompt.CurrentContentVersion,
+ Content: firstContent,
+ })
+
secondContent, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{
codersdk.ChatMessageText("second"),
})
@@ -4814,6 +4826,7 @@ func TestSubscribeAfterMessageID(t *testing.T) {
_ = dbgen.ChatMessage(t, db, database.ChatMessage{
ChatID: chat.ID,
+ CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true},
ModelConfigID: uuid.NullUUID{UUID: model.ID, Valid: true},
Role: database.ChatMessageRoleUser,
ContentVersion: chatprompt.CurrentContentVersion,
From 4f1043a50aa6b1d653d3ff2795bab4ef903d5963 Mon Sep 17 00:00:00 2001
From: Ethan
Date: Tue, 26 May 2026 14:19:36 +1000
Subject: [PATCH 002/249] feat(scaletest): add chat scaletest command (#25553)
Adds `coder exp scaletest chat`, a harness for creating Coder Agents
chat load.
Start the mock LLM separately, prepare the scaletest workspaces you want
to target, then run the chat scaletest against the existing
`scaletest-*` fleet selected by the shared workspace targeting flags:
```sh
coder exp scaletest llm-mock --address 127.0.0.1:18080
coder exp scaletest chat --llm-mock-url http://127.0.0.1:18080/v1 --chats-per-workspace 10 --turns 1
coder exp scaletest chat --llm-mock-url http://127.0.0.1:18080/v1 --template docker --target-workspaces 0:10 --chats-per-workspace 1 --turns 10 --turn-start-delay 30s
```
This is the same pattern used by the `workspace-traffic` load generator.
Keeping the fake LLM as a separate process is intentional so it can be
scaled independently from the Coder deployment, which will likely be
necessary as we scale up and up.
This PR is the starting point: it provides the command, mock
provider/model bootstrap, existing workspace selection, chat streaming,
follow-up turns, metrics, and cleanup. Follow-up PRs will add multi-step
turns via tool calls. I'm still a bit iffy on the mechanism I have for
that. It'll likely involve having the runner send some magic strings
that the mock will recognise.
Relates to CODAGT-307
Relates to GRU-48
Relates to https://github.com/coder/scaletest/issues/124
Generated by Mux, but reviewed by a human
---
cli/exp_scaletest.go | 5 +-
cli/exp_scaletest_chat.go | 254 +++++++++++++++++
scaletest/chat/client.go | 54 ++++
scaletest/chat/config.go | 78 ++++++
scaletest/chat/metrics.go | 137 +++++++++
scaletest/chat/provider.go | 148 ++++++++++
scaletest/chat/run.go | 413 ++++++++++++++++++++++++++++
scaletest/chat/run_internal_test.go | 391 ++++++++++++++++++++++++++
8 files changed, 1478 insertions(+), 2 deletions(-)
create mode 100644 cli/exp_scaletest_chat.go
create mode 100644 scaletest/chat/client.go
create mode 100644 scaletest/chat/config.go
create mode 100644 scaletest/chat/metrics.go
create mode 100644 scaletest/chat/provider.go
create mode 100644 scaletest/chat/run.go
create mode 100644 scaletest/chat/run_internal_test.go
diff --git a/cli/exp_scaletest.go b/cli/exp_scaletest.go
index 06af372e151df..a4d5b14d65a49 100644
--- a/cli/exp_scaletest.go
+++ b/cli/exp_scaletest.go
@@ -70,6 +70,7 @@ func (r *RootCmd) scaletestCmd() *serpent.Command {
r.scaletestSMTP(),
r.scaletestPrebuilds(),
r.scaletestBridge(),
+ r.scaletestChat(),
r.scaletestLLMMock(),
},
}
@@ -404,13 +405,13 @@ func (f *workspaceTargetFlags) attach(opts *serpent.OptionSet) {
Flag: "template",
FlagShorthand: "t",
Env: "CODER_SCALETEST_TEMPLATE",
- Description: "Name or ID of the template. Traffic generation will be limited to workspaces created from this template.",
+ Description: "Name or ID of the template. Only workspaces created from this template are targeted.",
Value: serpent.StringOf(&f.template),
},
serpent.Option{
Flag: "target-workspaces",
Env: "CODER_SCALETEST_TARGET_WORKSPACES",
- Description: "Target a specific range of workspaces in the format [START]:[END] (exclusive). Example: 0:10 will target the 10 first alphabetically sorted workspaces (0-9).",
+ Description: "Target a specific range of matching workspaces in the format [START]:[END] (exclusive). Example: 0:10 targets the first 10 matching workspaces returned by the workspace query.",
Value: serpent.StringOf(&f.targetWorkspaces),
},
serpent.Option{
diff --git a/cli/exp_scaletest_chat.go b/cli/exp_scaletest_chat.go
new file mode 100644
index 0000000000000..bbde5f67abe07
--- /dev/null
+++ b/cli/exp_scaletest_chat.go
@@ -0,0 +1,254 @@
+//go:build !slim
+
+package cli
+
+import (
+ "fmt"
+ "sync"
+ "time"
+
+ "github.com/prometheus/client_golang/prometheus"
+ "github.com/prometheus/client_golang/prometheus/promhttp"
+ "golang.org/x/xerrors"
+
+ "cdr.dev/slog/v3"
+ "cdr.dev/slog/v3/sloggers/sloghuman"
+ "github.com/coder/coder/v2/codersdk"
+ "github.com/coder/coder/v2/scaletest/chat"
+ "github.com/coder/coder/v2/scaletest/harness"
+ "github.com/coder/coder/v2/scaletest/loadtestutil"
+ "github.com/coder/serpent"
+)
+
+func (r *RootCmd) scaletestChat() *serpent.Command {
+ var (
+ chatsPerWorkspace int64
+ prompt string
+ turns int64
+ turnStartDelay time.Duration
+ llmMockURL string
+ targetFlags = &workspaceTargetFlags{}
+ tracingFlags = &scaletestTracingFlags{}
+ prometheusFlags = &scaletestPrometheusFlags{}
+ timeoutStrategy = &timeoutFlags{}
+ cleanupStrategy = newScaletestCleanupStrategy()
+ output = &scaletestOutputFlags{}
+ )
+
+ cmd := &serpent.Command{
+ Use: "chat",
+ Short: "Generate Coder Agents load.",
+ Handler: func(inv *serpent.Invocation) error {
+ baseCtx := inv.Context()
+ ctx, stop := inv.SignalNotifyContext(baseCtx, StopSignals...)
+ defer stop()
+
+ outputs, err := output.parse()
+ if err != nil {
+ return xerrors.Errorf("could not parse --output flags: %w", err)
+ }
+ switch {
+ case turns < 1:
+ return xerrors.Errorf("--turns must be at least 1")
+ case chatsPerWorkspace < 1:
+ return xerrors.Errorf("--chats-per-workspace must be at least 1")
+ }
+
+ client, err := r.InitClient(inv)
+ if err != nil {
+ return err
+ }
+ me, err := RequireAdmin(ctx, client)
+ if err != nil {
+ return err
+ }
+ client.HTTPClient.Transport = &codersdk.HeaderTransport{
+ Transport: client.HTTPClient.Transport,
+ Header: BypassHeader,
+ }
+
+ workspaces, err := targetFlags.getTargetedWorkspaces(ctx, client, me.OrganizationIDs, inv.Stdout)
+ if err != nil {
+ return err
+ }
+
+ logger := slog.Make(sloghuman.Sink(inv.Stderr)).Leveled(slog.LevelDebug)
+ modelConfigID, err := chat.EnsureScaletestModelConfig(ctx, codersdk.NewExperimentalClient(client), logger, llmMockURL)
+ if err != nil {
+ return err
+ }
+
+ // Start metrics and tracing before creating runners.
+ reg := prometheus.NewRegistry()
+ metrics := chat.NewMetrics(reg)
+
+ prometheusSrvClose := ServeHandler(baseCtx, logger, promhttp.HandlerFor(reg, promhttp.HandlerOpts{}), prometheusFlags.Address, "prometheus")
+
+ tracerProvider, closeTracing, tracingEnabled, err := tracingFlags.provider(baseCtx)
+ if err != nil {
+ prometheusSrvClose()
+ return xerrors.Errorf("create tracer provider: %w", err)
+ }
+ defer func() {
+ if tracingEnabled {
+ _, _ = fmt.Fprintln(inv.Stderr, "Uploading traces...")
+ }
+ if err := closeTracing(baseCtx); err != nil {
+ _, _ = fmt.Fprintf(inv.Stderr, "Error uploading traces: %+v\n", err)
+ }
+ _, _ = fmt.Fprintf(inv.Stderr, "Waiting %s for prometheus metrics to be scraped\n", prometheusFlags.Wait)
+ <-time.After(prometheusFlags.Wait)
+ prometheusSrvClose()
+ }()
+
+ tracer := tracerProvider.Tracer(scaletestTracerName)
+
+ var turnStartReadyWaitGroup *sync.WaitGroup
+ var startTurnsChan chan struct{}
+ if turnStartDelay > 0 && turns > 1 {
+ turnStartReadyWaitGroup = &sync.WaitGroup{}
+ startTurnsChan = make(chan struct{})
+ }
+
+ chatHarness := harness.NewTestHarness(
+ timeoutStrategy.wrapStrategy(harness.ConcurrentExecutionStrategy{}),
+ cleanupStrategy.toStrategy(),
+ )
+ for workspaceIndex, targetWorkspace := range workspaces {
+ for chatIndex := int64(0); chatIndex < chatsPerWorkspace; chatIndex++ {
+ if turnStartReadyWaitGroup != nil {
+ turnStartReadyWaitGroup.Add(1)
+ }
+
+ cfg := chat.Config{
+ OrganizationID: targetWorkspace.OrganizationID,
+ WorkspaceID: targetWorkspace.ID,
+ Prompt: prompt,
+ ModelConfigID: modelConfigID,
+ Turns: int(turns),
+ TurnStartDelay: turnStartDelay,
+ TurnStartReadyWaitGroup: turnStartReadyWaitGroup,
+ StartTurnsChan: startTurnsChan,
+ Metrics: metrics,
+ }
+ if err := cfg.Validate(); err != nil {
+ return xerrors.Errorf("validate config for workspace %d chat %d: %w", workspaceIndex, chatIndex, err)
+ }
+
+ runnerClient, err := loadtestutil.DupClientCopyingHeaders(client, BypassHeader)
+ if err != nil {
+ return xerrors.Errorf("duplicate client for workspace %d chat %d: %w", workspaceIndex, chatIndex, err)
+ }
+ var runner harness.Runnable = chat.NewRunner(runnerClient, cfg)
+ if tracingEnabled {
+ runner = &runnableTraceWrapper{
+ tracer: tracer,
+ runner: runner,
+ spanName: fmt.Sprintf("chat/workspace-%d-chat-%d", workspaceIndex, chatIndex),
+ }
+ }
+ chatHarness.AddRun("chat", fmt.Sprintf("workspace-%d-chat-%d", workspaceIndex, chatIndex), runner)
+ }
+ }
+
+ // Run the chat harness in the background so the CLI can release the
+ // follow-up turns after every runner finishes its initial turn.
+ totalChats := int64(len(workspaces)) * chatsPerWorkspace
+ _, _ = fmt.Fprintf(inv.Stderr, "Starting chat scale test with %d chats across %d workspaces...\n", totalChats, len(workspaces))
+ testCtx, testCancel := timeoutStrategy.toContext(ctx)
+ defer testCancel()
+ testDone := make(chan error, 1)
+ go func() {
+ testDone <- chatHarness.Run(testCtx)
+ }()
+
+ if turnStartReadyWaitGroup != nil {
+ initialTurnsDone := make(chan struct{})
+ go func() {
+ turnStartReadyWaitGroup.Wait()
+ close(initialTurnsDone)
+ }()
+
+ select {
+ case <-testCtx.Done():
+ return testCtx.Err()
+ case <-initialTurnsDone:
+ }
+
+ _, _ = fmt.Fprintf(inv.Stderr, "All %d initial turns completed, waiting %s before starting the follow-up turns...\n", totalChats, turnStartDelay)
+ select {
+ case <-testCtx.Done():
+ return testCtx.Err()
+ case <-time.After(turnStartDelay):
+ }
+
+ close(startTurnsChan)
+ }
+
+ if err := <-testDone; err != nil {
+ return xerrors.Errorf("run harness: %w", err)
+ }
+
+ results := chatHarness.Results()
+ for _, o := range outputs {
+ if err := o.write(results, inv.Stdout); err != nil {
+ return xerrors.Errorf("write output %q to %q: %w", o.format, o.path, err)
+ }
+ }
+
+ _, _ = fmt.Fprintln(inv.Stderr, "\nCleaning up (archiving chats)...")
+ cleanupCtx, cleanupCancel := cleanupStrategy.toContext(ctx)
+ defer cleanupCancel()
+ if err := chatHarness.Cleanup(cleanupCtx); err != nil {
+ return xerrors.Errorf("cleanup chats: %w", err)
+ }
+
+ if results.TotalFail > 0 {
+ return xerrors.Errorf("scale test failed: %d/%d runs failed", results.TotalFail, results.TotalRuns)
+ }
+
+ _, _ = fmt.Fprintf(inv.Stderr, "Scale test passed: %d/%d runs succeeded\n", results.TotalPass, results.TotalRuns)
+ return nil
+ },
+ }
+
+ cmd.Options = serpent.OptionSet{
+ {
+ Flag: "chats-per-workspace",
+ Description: "Number of chats to run against each targeted workspace. Required and must be greater than 0.",
+ Value: serpent.Int64Of(&chatsPerWorkspace),
+ Required: true,
+ },
+ {
+ Flag: "prompt",
+ Description: "Text prompt to send on every turn in each chat.",
+ Default: "Reply with one short sentence.",
+ Value: serpent.StringOf(&prompt),
+ },
+ {
+ Flag: "turns",
+ Description: "Number of user to assistant exchanges per chat conversation.",
+ Default: "10",
+ Value: serpent.Int64Of(&turns),
+ },
+ {
+ Flag: "turn-start-delay",
+ Description: "Delay between every chat completing its initial turn and starting the follow-up turns. Use this to separate initial-turn load from follow-up-turn load.",
+ Default: "0s",
+ Value: serpent.DurationOf(&turnStartDelay),
+ },
+ {
+ Flag: "llm-mock-url",
+ Description: "URL of the mock LLM server (e.g. http://127.0.0.1:8080/v1). Creates or updates the Scaletest LLM Mock openai-compat provider and model config to point at this URL.",
+ Value: serpent.StringOf(&llmMockURL),
+ Required: true,
+ },
+ }
+ targetFlags.attach(&cmd.Options)
+ output.attach(&cmd.Options)
+ tracingFlags.attach(&cmd.Options)
+ prometheusFlags.attach(&cmd.Options)
+ timeoutStrategy.attach(&cmd.Options)
+ cleanupStrategy.attach(&cmd.Options)
+ return cmd
+}
diff --git a/scaletest/chat/client.go b/scaletest/chat/client.go
new file mode 100644
index 0000000000000..552bbd87e1982
--- /dev/null
+++ b/scaletest/chat/client.go
@@ -0,0 +1,54 @@
+package chat
+
+import (
+ "context"
+ "io"
+
+ "github.com/google/uuid"
+
+ "cdr.dev/slog/v3"
+ "github.com/coder/coder/v2/codersdk"
+)
+
+type chatClient interface {
+ SetLogger(logger slog.Logger)
+ SetLogBodies(logBodies bool)
+ CreateChat(ctx context.Context, req codersdk.CreateChatRequest) (codersdk.Chat, error)
+ StreamChat(ctx context.Context, chatID uuid.UUID, opts *codersdk.StreamChatOptions) (<-chan codersdk.ChatStreamEvent, io.Closer, error)
+ CreateChatMessage(ctx context.Context, chatID uuid.UUID, req codersdk.CreateChatMessageRequest) (codersdk.CreateChatMessageResponse, error)
+ UpdateChat(ctx context.Context, chatID uuid.UUID, req codersdk.UpdateChatRequest) error
+}
+
+type sdkChatClient struct {
+ client *codersdk.ExperimentalClient
+}
+
+func newChatClient(client *codersdk.Client) chatClient {
+ return &sdkChatClient{client: codersdk.NewExperimentalClient(client)}
+}
+
+func (c *sdkChatClient) SetLogger(logger slog.Logger) {
+ c.client.SetLogger(logger)
+}
+
+func (c *sdkChatClient) SetLogBodies(logBodies bool) {
+ c.client.SetLogBodies(logBodies)
+}
+
+func (c *sdkChatClient) CreateChat(ctx context.Context, req codersdk.CreateChatRequest) (codersdk.Chat, error) {
+ return c.client.CreateChat(ctx, req)
+}
+
+func (c *sdkChatClient) StreamChat(ctx context.Context, chatID uuid.UUID, opts *codersdk.StreamChatOptions) (<-chan codersdk.ChatStreamEvent, io.Closer, error) {
+ return c.client.StreamChat(ctx, chatID, opts)
+}
+
+func (c *sdkChatClient) CreateChatMessage(ctx context.Context, chatID uuid.UUID, req codersdk.CreateChatMessageRequest) (codersdk.CreateChatMessageResponse, error) {
+ return c.client.CreateChatMessage(ctx, chatID, req)
+}
+
+func (c *sdkChatClient) UpdateChat(ctx context.Context, chatID uuid.UUID, req codersdk.UpdateChatRequest) error {
+ return c.client.UpdateChat(ctx, chatID, req)
+}
+
+var _ chatClient = (*sdkChatClient)(nil)
diff --git a/scaletest/chat/config.go b/scaletest/chat/config.go
new file mode 100644
index 0000000000000..5b6b36baa2ea9
--- /dev/null
+++ b/scaletest/chat/config.go
@@ -0,0 +1,78 @@
+package chat
+
+import (
+ "sync"
+ "time"
+
+ "github.com/google/uuid"
+ "golang.org/x/xerrors"
+)
+
+// Config describes a single chat runner within a scaletest invocation.
+type Config struct {
+ // OrganizationID is the organization that owns the target workspace.
+ OrganizationID uuid.UUID `json:"organization_id"`
+
+ // WorkspaceID is the pre-existing workspace to use for this chat run.
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+
+ // Prompt is the text content sent on every turn.
+ Prompt string `json:"prompt"`
+
+ // ModelConfigID is the scaletest mock LLM model config.
+ ModelConfigID uuid.UUID `json:"model_config_id"`
+
+ // Turns is the total number of user to assistant exchanges per chat.
+ // Must be at least 1.
+ Turns int `json:"turns"`
+
+ // TurnStartDelay is the shared delay between every runner completing
+ // its initial turn and the release of the follow-up turns. Set
+ // to 0 to send all turns without an inter-phase pause.
+ TurnStartDelay time.Duration `json:"turn_start_delay"`
+
+ // TurnStartReadyWaitGroup coordinates the gap between the initial turn
+ // finishing and the follow-up turns. Each runner signals exactly
+ // once after its first turn reaches a terminal status, or when it
+ // knows it will never reach that point.
+ TurnStartReadyWaitGroup *sync.WaitGroup `json:"-"`
+
+ // StartTurnsChan blocks follow-up turns until the CLI layer releases them.
+ StartTurnsChan chan struct{} `json:"-"`
+
+ Metrics *Metrics `json:"-"`
+}
+
+func (c Config) Validate() error {
+ if c.OrganizationID == uuid.Nil {
+ return xerrors.Errorf("validate organization_id: must not be empty")
+ }
+ if c.WorkspaceID == uuid.Nil {
+ return xerrors.Errorf("validate workspace_id: must not be empty")
+ }
+ if c.Prompt == "" {
+ return xerrors.Errorf("validate prompt: must not be empty")
+ }
+ if c.ModelConfigID == uuid.Nil {
+ return xerrors.Errorf("validate model_config_id: must not be empty")
+ }
+ if c.Turns < 1 {
+ return xerrors.Errorf("validate turns: must be at least 1")
+ }
+ if c.TurnStartDelay < 0 {
+ return xerrors.Errorf("validate turn_start_delay: must not be negative")
+ }
+ if c.TurnStartDelay > 0 && c.Turns > 1 {
+ if c.TurnStartReadyWaitGroup == nil {
+ return xerrors.Errorf("validate turn_start_ready_wait_group: must not be nil when turn start delay is enabled for more than one turn")
+ }
+ if c.StartTurnsChan == nil {
+ return xerrors.Errorf("validate start_turns_chan: must not be nil when turn start delay is enabled for more than one turn")
+ }
+ }
+ if c.Metrics == nil {
+ return xerrors.Errorf("validate metrics: must not be nil")
+ }
+
+ return nil
+}
diff --git a/scaletest/chat/metrics.go b/scaletest/chat/metrics.go
new file mode 100644
index 0000000000000..829931cd81c0c
--- /dev/null
+++ b/scaletest/chat/metrics.go
@@ -0,0 +1,137 @@
+package chat
+
+import "github.com/prometheus/client_golang/prometheus"
+
+const (
+ metricLabelPhase = "phase"
+ metricLabelStatus = "status"
+ metricLabelStage = "stage"
+
+ phaseInitial = "initial"
+ phaseFollowUp = "follow_up"
+
+ failureStageCreateChat = "create_chat"
+ failureStageCreateMessage = "create_message"
+ failureStageStreamOpen = "stream_open"
+ failureStageStreamEndedEarly = "stream_ended_early"
+ failureStageStatusError = "status_error"
+)
+
+var (
+ chatRequestLatencyBuckets = prometheus.ExponentialBucketsRange(0.05, 120, 18)
+ chatProcessingLatencyBuckets = prometheus.ExponentialBucketsRange(0.1, 300, 18)
+)
+
+// Metrics holds the Prometheus metrics emitted by the chat scaletest.
+type Metrics struct {
+ ChatCreateLatencySeconds prometheus.Histogram
+ ChatMessageLatencySeconds *prometheus.HistogramVec
+ ChatConversationDurationSeconds prometheus.Histogram
+ ChatTimeToRunningSeconds *prometheus.HistogramVec
+ ChatTimeToFirstOutputSeconds *prometheus.HistogramVec
+ ChatTimeToTerminalStatusSeconds *prometheus.HistogramVec
+ ChatStageFailuresTotal *prometheus.CounterVec
+ ChatTerminalStatusTotal *prometheus.CounterVec
+ ChatTurnsCompletedTotal prometheus.Counter
+ ChatRetryEventsTotal prometheus.Counter
+ ActiveChatStreams prometheus.Gauge
+}
+
+func NewMetrics(reg prometheus.Registerer) *Metrics {
+ if reg == nil {
+ reg = prometheus.DefaultRegisterer
+ }
+
+ phaseLabelNames := []string{metricLabelPhase}
+ terminalStatusLabelNames := []string{metricLabelStatus}
+ failureStageLabelNames := []string{metricLabelStage}
+
+ m := &Metrics{
+ ChatCreateLatencySeconds: prometheus.NewHistogram(prometheus.HistogramOpts{
+ Namespace: "coderd",
+ Subsystem: "scaletest",
+ Name: "chat_create_latency_seconds",
+ Help: "Time in seconds to create a chat and enqueue the initial turn.",
+ Buckets: chatRequestLatencyBuckets,
+ }),
+ ChatMessageLatencySeconds: prometheus.NewHistogramVec(prometheus.HistogramOpts{
+ Namespace: "coderd",
+ Subsystem: "scaletest",
+ Name: "chat_message_latency_seconds",
+ Help: "Time in seconds to add a follow-up message to an existing chat.",
+ Buckets: chatRequestLatencyBuckets,
+ }, phaseLabelNames),
+ ChatConversationDurationSeconds: prometheus.NewHistogram(prometheus.HistogramOpts{
+ Namespace: "coderd",
+ Subsystem: "scaletest",
+ Name: "chat_conversation_duration_seconds",
+ Help: "Time in seconds from chat creation start until the conversation finishes or errors.",
+ Buckets: chatProcessingLatencyBuckets,
+ }),
+ ChatTimeToRunningSeconds: prometheus.NewHistogramVec(prometheus.HistogramOpts{
+ Namespace: "coderd",
+ Subsystem: "scaletest",
+ Name: "chat_time_to_running_seconds",
+ Help: "Time in seconds from the start of a chat turn until the chat enters running status.",
+ Buckets: chatProcessingLatencyBuckets,
+ }, phaseLabelNames),
+ ChatTimeToFirstOutputSeconds: prometheus.NewHistogramVec(prometheus.HistogramOpts{
+ Namespace: "coderd",
+ Subsystem: "scaletest",
+ Name: "chat_time_to_first_output_seconds",
+ Help: "Time in seconds from the start of a chat turn until the first output is received.",
+ Buckets: chatProcessingLatencyBuckets,
+ }, phaseLabelNames),
+ ChatTimeToTerminalStatusSeconds: prometheus.NewHistogramVec(prometheus.HistogramOpts{
+ Namespace: "coderd",
+ Subsystem: "scaletest",
+ Name: "chat_time_to_terminal_status_seconds",
+ Help: "Time in seconds from the start of a chat turn until a terminal status is received.",
+ Buckets: chatProcessingLatencyBuckets,
+ }, phaseLabelNames),
+ ChatStageFailuresTotal: prometheus.NewCounterVec(prometheus.CounterOpts{
+ Namespace: "coderd",
+ Subsystem: "scaletest",
+ Name: "chat_stage_failures_total",
+ Help: "Total number of terminal stage-specific chat runner failures.",
+ }, failureStageLabelNames),
+ ChatTerminalStatusTotal: prometheus.NewCounterVec(prometheus.CounterOpts{
+ Namespace: "coderd",
+ Subsystem: "scaletest",
+ Name: "chat_terminal_status_total",
+ Help: "Total number of terminal chat statuses observed.",
+ }, terminalStatusLabelNames),
+ ChatTurnsCompletedTotal: prometheus.NewCounter(prometheus.CounterOpts{
+ Namespace: "coderd",
+ Subsystem: "scaletest",
+ Name: "chat_turns_completed_total",
+ Help: "Total number of chat turns completed successfully.",
+ }),
+ ChatRetryEventsTotal: prometheus.NewCounter(prometheus.CounterOpts{
+ Namespace: "coderd",
+ Subsystem: "scaletest",
+ Name: "chat_retry_events_total",
+ Help: "Total number of chat retry events observed.",
+ }),
+ ActiveChatStreams: prometheus.NewGauge(prometheus.GaugeOpts{
+ Namespace: "coderd",
+ Subsystem: "scaletest",
+ Name: "active_chat_streams",
+ Help: "Current number of active chat streams.",
+ }),
+ }
+
+ reg.MustRegister(m.ChatCreateLatencySeconds)
+ reg.MustRegister(m.ChatMessageLatencySeconds)
+ reg.MustRegister(m.ChatConversationDurationSeconds)
+ reg.MustRegister(m.ChatTimeToRunningSeconds)
+ reg.MustRegister(m.ChatTimeToFirstOutputSeconds)
+ reg.MustRegister(m.ChatTimeToTerminalStatusSeconds)
+ reg.MustRegister(m.ChatStageFailuresTotal)
+ reg.MustRegister(m.ChatTerminalStatusTotal)
+ reg.MustRegister(m.ChatTurnsCompletedTotal)
+ reg.MustRegister(m.ChatRetryEventsTotal)
+ reg.MustRegister(m.ActiveChatStreams)
+
+ return m
+}
diff --git a/scaletest/chat/provider.go b/scaletest/chat/provider.go
new file mode 100644
index 0000000000000..ba946d7db2720
--- /dev/null
+++ b/scaletest/chat/provider.go
@@ -0,0 +1,148 @@
+package chat
+
+import (
+ "context"
+ "net/http"
+
+ "github.com/google/uuid"
+ "golang.org/x/xerrors"
+
+ "cdr.dev/slog/v3"
+ "github.com/coder/coder/v2/codersdk"
+)
+
+const (
+ scaletestProviderType = "openai-compat"
+ scaletestProviderDisplayName = "Scaletest LLM Mock"
+ scaletestModelName = "scaletest-model"
+ scaletestModelDisplayName = "Scaletest Model"
+)
+
+type scaletestProviderAction string
+
+const (
+ scaletestProviderActionCreated scaletestProviderAction = "created"
+ scaletestProviderActionUpdated scaletestProviderAction = "updated"
+ scaletestProviderActionReused scaletestProviderAction = "reused"
+)
+
+// EnsureScaletestModelConfig bootstraps the shared chat provider and model
+// config used by chat scaletests.
+func EnsureScaletestModelConfig(ctx context.Context, client *codersdk.ExperimentalClient, logger slog.Logger, llmMockURL string) (uuid.UUID, error) {
+ logger.Info(ctx, "bootstrapping mock LLM provider", slog.F("llm_mock_url", llmMockURL))
+
+ provider, providerAction, err := ensureScaletestProvider(ctx, client, llmMockURL)
+ if err != nil {
+ return uuid.Nil, err
+ }
+
+ switch providerAction {
+ case scaletestProviderActionCreated:
+ logger.Info(ctx, "created mock LLM provider",
+ slog.F("provider_type", scaletestProviderType),
+ slog.F("llm_mock_url", llmMockURL),
+ )
+ case scaletestProviderActionUpdated:
+ logger.Info(ctx, "updated mock LLM provider",
+ slog.F("provider_type", scaletestProviderType),
+ slog.F("provider_id", provider.ID),
+ slog.F("llm_mock_url", llmMockURL),
+ )
+ case scaletestProviderActionReused:
+ logger.Info(ctx, "reusing mock LLM provider",
+ slog.F("provider_type", scaletestProviderType),
+ slog.F("provider_id", provider.ID),
+ )
+ }
+
+ modelConfigs, err := client.ListChatModelConfigs(ctx)
+ if err != nil {
+ return uuid.Nil, xerrors.Errorf("list chat model configs: %w", err)
+ }
+
+ for i := range modelConfigs {
+ if modelConfigs[i].Provider != provider.Provider || modelConfigs[i].Model != scaletestModelName {
+ continue
+ }
+ if !modelConfigs[i].Enabled {
+ return uuid.Nil, xerrors.Errorf("existing scaletest chat model config %s is disabled; re-enable or delete it before running scaletests", modelConfigs[i].ID)
+ }
+ modelConfigID := modelConfigs[i].ID
+ logger.Info(ctx, "reusing scaletest model config", slog.F("model_config_id", modelConfigID))
+ return modelConfigID, nil
+ }
+
+ enabled := true
+ isDefault := false
+ contextLimit := int64(4096)
+ created, err := client.CreateChatModelConfig(ctx, codersdk.CreateChatModelConfigRequest{
+ Provider: provider.Provider,
+ Model: scaletestModelName,
+ DisplayName: scaletestModelDisplayName,
+ Enabled: &enabled,
+ IsDefault: &isDefault,
+ ContextLimit: &contextLimit,
+ })
+ if err != nil {
+ return uuid.Nil, xerrors.Errorf("create scaletest chat model config: %w", err)
+ }
+ logger.Info(ctx, "created scaletest model config", slog.F("model_config_id", created.ID))
+ return created.ID, nil
+}
+
+func ensureScaletestProvider(ctx context.Context, client *codersdk.ExperimentalClient, llmMockURL string) (codersdk.ChatProviderConfig, scaletestProviderAction, error) {
+ enabled := true
+ mockProviderToken := uuid.NewString()
+ created, err := client.CreateChatProvider(ctx, codersdk.CreateChatProviderConfigRequest{
+ Provider: scaletestProviderType,
+ DisplayName: scaletestProviderDisplayName,
+ APIKey: mockProviderToken,
+ BaseURL: llmMockURL,
+ Enabled: &enabled,
+ })
+ if err == nil {
+ return created, scaletestProviderActionCreated, nil
+ }
+
+ var sdkErr *codersdk.Error
+ if !xerrors.As(err, &sdkErr) || sdkErr.StatusCode() != http.StatusConflict {
+ return codersdk.ChatProviderConfig{}, "", xerrors.Errorf("create scaletest chat provider: %w", err)
+ }
+
+ providers, err := client.ListChatProviders(ctx)
+ if err != nil {
+ return codersdk.ChatProviderConfig{}, "", xerrors.Errorf("list chat providers: %w", err)
+ }
+
+ var existing *codersdk.ChatProviderConfig
+ for i := range providers {
+ if providers[i].Provider == scaletestProviderType {
+ existing = &providers[i]
+ break
+ }
+ }
+ if existing == nil {
+ return codersdk.ChatProviderConfig{}, "", xerrors.Errorf("find existing %s provider after conflict: not found", scaletestProviderType)
+ }
+ if existing.DisplayName != scaletestProviderDisplayName {
+ return codersdk.ChatProviderConfig{}, "", xerrors.Errorf("refusing to overwrite existing %s provider %s with display name %q", scaletestProviderType, existing.ID, existing.DisplayName)
+ }
+
+ if !existing.Enabled {
+ return codersdk.ChatProviderConfig{}, "", xerrors.Errorf("existing scaletest chat provider %s is disabled; re-enable or delete it before running scaletests", existing.ID)
+ }
+ if existing.BaseURL == llmMockURL {
+ return *existing, scaletestProviderActionReused, nil
+ }
+
+ updated, err := client.UpdateChatProvider(ctx, existing.ID, codersdk.UpdateChatProviderConfigRequest{
+ DisplayName: scaletestProviderDisplayName,
+ APIKey: &mockProviderToken,
+ BaseURL: &llmMockURL,
+ Enabled: &enabled,
+ })
+ if err != nil {
+ return codersdk.ChatProviderConfig{}, "", xerrors.Errorf("update scaletest chat provider: %w", err)
+ }
+ return updated, scaletestProviderActionUpdated, nil
+}
diff --git a/scaletest/chat/run.go b/scaletest/chat/run.go
new file mode 100644
index 0000000000000..b2e591fab6b78
--- /dev/null
+++ b/scaletest/chat/run.go
@@ -0,0 +1,413 @@
+package chat
+
+import (
+ "context"
+ "io"
+ "sync"
+ "time"
+
+ "github.com/google/uuid"
+ "go.opentelemetry.io/otel/attribute"
+ "golang.org/x/xerrors"
+
+ "cdr.dev/slog/v3"
+ "cdr.dev/slog/v3/sloggers/sloghuman"
+ "github.com/coder/coder/v2/coderd/tracing"
+ "github.com/coder/coder/v2/codersdk"
+ "github.com/coder/coder/v2/scaletest/harness"
+ "github.com/coder/coder/v2/scaletest/loadtestutil"
+)
+
+// Runner executes a single chat conversation as part of a scaletest run.
+type Runner struct {
+ client chatClient
+ cfg Config
+
+ chatID uuid.UUID
+ result runnerResult
+
+ conversationStart time.Time
+ turnStartTime time.Time
+ currentPhase string
+ lastStreamError string
+ lastStatus codersdk.ChatStatus
+ sawTurnRunning bool
+ sawTurnFirstOutput bool
+ markTurnStartReady func()
+}
+
+type runnerResult struct {
+ finalStatus string
+ failureStage string
+ totalDuration time.Duration
+ sawFirstOutput bool
+ retryCount int
+ eventCount int
+ turnsCompleted int
+}
+
+var (
+ _ harness.Runnable = &Runner{}
+ _ harness.Cleanable = &Runner{}
+ _ harness.Collectable = &Runner{}
+)
+
+func NewRunner(client *codersdk.Client, cfg Config) *Runner {
+ return &Runner{
+ client: newChatClient(client),
+ cfg: cfg,
+ }
+}
+
+func (r *Runner) Run(ctx context.Context, id string, logs io.Writer) error {
+ ctx, span := tracing.StartSpan(ctx)
+ defer span.End()
+
+ logs = loadtestutil.NewSyncWriter(logs)
+ logger := slog.Make(sloghuman.Sink(logs)).Leveled(slog.LevelDebug).Named(id)
+ r.client.SetLogger(logger)
+ r.client.SetLogBodies(true)
+
+ span.SetAttributes(
+ attribute.String("chat.runner_id", id),
+ attribute.String("chat.workspace_id", r.cfg.WorkspaceID.String()),
+ attribute.Int("chat.turns_requested", r.cfg.Turns),
+ attribute.Int64("chat.turn_start_delay_ms", r.cfg.TurnStartDelay.Milliseconds()),
+ )
+ span.SetAttributes(attribute.String("chat.model_config_id", r.cfg.ModelConfigID.String()))
+
+ markTurnStartReady := func() {}
+ if r.cfg.TurnStartReadyWaitGroup != nil {
+ markTurnStartReady = sync.OnceFunc(r.cfg.TurnStartReadyWaitGroup.Done)
+ }
+ r.markTurnStartReady = markTurnStartReady
+ defer r.markTurnStartReady()
+
+ defer func() {
+ if !r.conversationStart.IsZero() {
+ r.result.totalDuration = time.Since(r.conversationStart)
+ r.cfg.Metrics.ChatConversationDurationSeconds.Observe(r.result.totalDuration.Seconds())
+ }
+ span.SetAttributes(
+ attribute.String("chat.final_status", r.result.finalStatus),
+ attribute.String("chat.failure_stage", r.result.failureStage),
+ attribute.Int("chat.retry_count", r.result.retryCount),
+ attribute.Int("chat.turns_completed", r.result.turnsCompleted),
+ attribute.Bool("chat.saw_first_output", r.result.sawFirstOutput),
+ )
+ if r.result.totalDuration > 0 {
+ span.SetAttributes(attribute.Float64("chat.total_duration_seconds", r.result.totalDuration.Seconds()))
+ }
+ }()
+
+ workspaceID := r.cfg.WorkspaceID
+ modelConfigID := r.cfg.ModelConfigID
+ logger = logger.With(slog.F("workspace_id", workspaceID))
+ logger.Info(ctx, "starting chat runner")
+
+ r.resetConversation(time.Now(), markTurnStartReady)
+
+ createStartedAt := time.Now()
+ chat, err := r.client.CreateChat(ctx, codersdk.CreateChatRequest{
+ OrganizationID: r.cfg.OrganizationID,
+ WorkspaceID: &workspaceID,
+ ModelConfigID: &modelConfigID,
+ Content: []codersdk.ChatInputPart{{
+ Type: codersdk.ChatInputPartTypeText,
+ Text: r.cfg.Prompt,
+ }},
+ })
+ if err != nil {
+ r.result.failureStage = failureStageCreateChat
+ r.cfg.Metrics.ChatStageFailuresTotal.WithLabelValues(r.result.failureStage).Inc()
+ return xerrors.Errorf("create chat: %w", err)
+ }
+ r.cfg.Metrics.ChatCreateLatencySeconds.Observe(time.Since(createStartedAt).Seconds())
+
+ r.chatID = chat.ID
+ span.SetAttributes(attribute.String("chat.chat_id", chat.ID.String()))
+ logger = logger.With(slog.F("chat_id", chat.ID))
+ logger.Info(ctx, "created chat session", slog.F("duration", time.Since(createStartedAt)))
+
+ // CreateChat already queues the first prompt for processing on the
+ // server, so the initial turn is in flight as soon as CreateChat
+ // returns. Open the stream immediately and let the conversation loop
+ // drive the gate at the natural phase boundary (after the first turn
+ // reaches a terminal Waiting status), rather than fencing here on a
+ // turn that has already started running.
+ events, closer, err := r.client.StreamChat(ctx, chat.ID, nil)
+ if err != nil {
+ r.result.failureStage = failureStageStreamOpen
+ r.cfg.Metrics.ChatStageFailuresTotal.WithLabelValues(r.result.failureStage).Inc()
+ return xerrors.Errorf("stream chat: %w", err)
+ }
+
+ r.cfg.Metrics.ActiveChatStreams.Inc()
+ defer func() {
+ r.cfg.Metrics.ActiveChatStreams.Dec()
+ _ = closer.Close()
+ }()
+
+ logger.Info(ctx, "streaming chat events")
+
+ return r.runConversation(ctx, chat.ID, logger, events)
+}
+
+func (r *Runner) resetConversation(conversationStart time.Time, markTurnStartReady func()) {
+ if markTurnStartReady == nil {
+ markTurnStartReady = func() {}
+ }
+
+ r.result = runnerResult{}
+ r.conversationStart = conversationStart
+ r.turnStartTime = conversationStart
+ r.currentPhase = phaseInitial
+ r.lastStreamError = ""
+ r.lastStatus = ""
+ r.sawTurnRunning = false
+ r.sawTurnFirstOutput = false
+ r.markTurnStartReady = markTurnStartReady
+}
+
+func (r *Runner) runConversation(ctx context.Context, chatID uuid.UUID, logger slog.Logger, events <-chan codersdk.ChatStreamEvent) error {
+ r.chatID = chatID
+
+ for event := range events {
+ r.result.eventCount++
+
+ switch event.Type {
+ case codersdk.ChatStreamEventTypeStatus:
+ if event.Status == nil {
+ continue
+ }
+ done, err := r.handleStatusEvent(ctx, chatID, logger, event.Status.Status)
+ if err != nil {
+ return err
+ }
+ if done {
+ return nil
+ }
+ case codersdk.ChatStreamEventTypeMessagePart:
+ r.handleMessagePartEvent(ctx, logger)
+ case codersdk.ChatStreamEventTypeMessage:
+ // StreamChat replays persisted rows as message events, not
+ // message_part deltas, when a turn finished server-side before
+ // the stream attached. Route assistant rows through the same
+ // first-output path; skip user rows so persisted prompts do not
+ // count as model output.
+ if event.Message == nil || event.Message.Role != codersdk.ChatMessageRoleAssistant {
+ continue
+ }
+ r.handleMessagePartEvent(ctx, logger)
+ case codersdk.ChatStreamEventTypeRetry:
+ r.handleRetryEvent(ctx, logger, event.Retry)
+ case codersdk.ChatStreamEventTypeError:
+ r.handleErrorEvent(ctx, logger, event.Error)
+ }
+ }
+
+ if ctx.Err() != nil {
+ return ctx.Err()
+ }
+
+ r.result.failureStage = failureStageStreamEndedEarly
+ r.cfg.Metrics.ChatStageFailuresTotal.WithLabelValues(r.result.failureStage).Inc()
+ if r.lastStreamError != "" {
+ return xerrors.Errorf("chat %s stream ended before completing %d of %d turns: %s", chatID, r.result.turnsCompleted, r.cfg.Turns, r.lastStreamError)
+ }
+ return xerrors.Errorf("chat %s stream ended before completing %d of %d turns", chatID, r.result.turnsCompleted, r.cfg.Turns)
+}
+
+func (r *Runner) handleStatusEvent(ctx context.Context, chatID uuid.UUID, logger slog.Logger, status codersdk.ChatStatus) (bool, error) {
+ if status == r.lastStatus {
+ return false, nil
+ }
+ if status == codersdk.ChatStatusWaiting &&
+ !r.sawTurnFirstOutput &&
+ (r.sawTurnRunning || r.result.turnsCompleted > 0) {
+ return false, nil
+ }
+ r.lastStatus = status
+
+ switch status {
+ case codersdk.ChatStatusRunning:
+ r.sawTurnRunning = true
+ r.cfg.Metrics.ChatTimeToRunningSeconds.WithLabelValues(r.currentPhase).Observe(time.Since(r.turnStartTime).Seconds())
+ logger.Info(ctx, "chat reached running status",
+ slog.F("phase", r.currentPhase),
+ )
+ return false, nil
+ case codersdk.ChatStatusWaiting:
+ r.result.turnsCompleted++
+ turnDuration := time.Since(r.turnStartTime)
+ r.cfg.Metrics.ChatTimeToTerminalStatusSeconds.WithLabelValues(r.currentPhase).Observe(turnDuration.Seconds())
+ r.cfg.Metrics.ChatTerminalStatusTotal.WithLabelValues(string(codersdk.ChatStatusWaiting)).Inc()
+ r.cfg.Metrics.ChatTurnsCompletedTotal.Inc()
+ logger.Info(ctx, "chat completed turn",
+ slog.F("turn", r.result.turnsCompleted),
+ slog.F("turns", r.cfg.Turns),
+ slog.F("duration", turnDuration),
+ )
+ if r.result.turnsCompleted >= r.cfg.Turns {
+ r.result.finalStatus = string(codersdk.ChatStatusWaiting)
+ conversationDuration := time.Since(r.conversationStart)
+ logger.Info(ctx, "chat reached terminal status",
+ slog.F("status", codersdk.ChatStatusWaiting),
+ slog.F("duration", conversationDuration),
+ slog.F("turns_completed", r.result.turnsCompleted),
+ )
+ return true, nil
+ }
+
+ // After the very first turn completes, mark this runner ready
+ // for the CLI-coordinated turn-start gate. The inter-phase
+ // delay measures the gap between every chat actually finishing its
+ // initial turn and the start of the follow-up turns, not the gap
+ // between CreateChat returning and the next turn.
+ if r.result.turnsCompleted == 1 {
+ r.markTurnStartReady()
+ if r.cfg.StartTurnsChan != nil {
+ logger.Info(ctx, "chat waiting for turn start release",
+ slog.F("turn_start_delay", r.cfg.TurnStartDelay),
+ )
+ select {
+ case <-ctx.Done():
+ return false, ctx.Err()
+ case <-r.cfg.StartTurnsChan:
+ }
+ }
+ }
+
+ nextTurn := r.result.turnsCompleted + 1
+ r.currentPhase = phaseFollowUp
+ r.turnStartTime = time.Now()
+ r.lastStreamError = ""
+ r.lastStatus = ""
+ r.sawTurnRunning = false
+ r.sawTurnFirstOutput = false
+ if err := r.sendNextTurn(ctx, chatID, logger, nextTurn, r.currentPhase); err != nil {
+ r.result.failureStage = failureStageCreateMessage
+ r.cfg.Metrics.ChatStageFailuresTotal.WithLabelValues(r.result.failureStage).Inc()
+ return false, err
+ }
+ return false, nil
+ case codersdk.ChatStatusError:
+ r.result.finalStatus = string(codersdk.ChatStatusError)
+ r.result.failureStage = failureStageStatusError
+ turnDuration := time.Since(r.turnStartTime)
+ r.cfg.Metrics.ChatTimeToTerminalStatusSeconds.WithLabelValues(r.currentPhase).Observe(turnDuration.Seconds())
+ r.cfg.Metrics.ChatTerminalStatusTotal.WithLabelValues(string(codersdk.ChatStatusError)).Inc()
+ r.cfg.Metrics.ChatStageFailuresTotal.WithLabelValues(r.result.failureStage).Inc()
+
+ errMessage := r.lastStreamError
+ if errMessage == "" {
+ errMessage = "chat reached error status"
+ }
+ logger.Error(ctx, "chat reached terminal status",
+ slog.F("status", codersdk.ChatStatusError),
+ slog.F("turns_completed", r.result.turnsCompleted),
+ slog.F("turns", r.cfg.Turns),
+ slog.F("error", errMessage),
+ )
+ return false, xerrors.Errorf("chat %s reached error status: %s", chatID, errMessage)
+ default:
+ return false, nil
+ }
+}
+
+func (r *Runner) sendNextTurn(ctx context.Context, chatID uuid.UUID, logger slog.Logger, nextTurn int, phase string) error {
+ messageStartedAt := time.Now()
+ modelConfigID := r.cfg.ModelConfigID
+ _, err := r.client.CreateChatMessage(ctx, chatID, codersdk.CreateChatMessageRequest{
+ Content: []codersdk.ChatInputPart{{
+ Type: codersdk.ChatInputPartTypeText,
+ Text: r.cfg.Prompt,
+ }},
+ ModelConfigID: &modelConfigID,
+ })
+ if err != nil {
+ return xerrors.Errorf("create chat message for turn %d: %w", nextTurn, err)
+ }
+
+ r.cfg.Metrics.ChatMessageLatencySeconds.WithLabelValues(phase).Observe(time.Since(messageStartedAt).Seconds())
+ logger.Info(ctx, "chat sent message",
+ slog.F("turn", nextTurn),
+ slog.F("turns", r.cfg.Turns),
+ )
+ return nil
+}
+
+func (r *Runner) handleMessagePartEvent(ctx context.Context, logger slog.Logger) {
+ if r.sawTurnFirstOutput {
+ return
+ }
+ r.sawTurnFirstOutput = true
+ r.result.sawFirstOutput = true
+ firstOutputDuration := time.Since(r.turnStartTime)
+ r.cfg.Metrics.ChatTimeToFirstOutputSeconds.WithLabelValues(r.currentPhase).Observe(firstOutputDuration.Seconds())
+ logger.Info(ctx, "chat received first output",
+ slog.F("phase", r.currentPhase),
+ slog.F("duration", firstOutputDuration),
+ )
+}
+
+func (r *Runner) handleRetryEvent(ctx context.Context, logger slog.Logger, retry *codersdk.ChatStreamRetry) {
+ r.result.retryCount++
+ r.cfg.Metrics.ChatRetryEventsTotal.Inc()
+ if retry != nil {
+ logger.Warn(ctx, "chat retry event",
+ slog.F("attempt", retry.Attempt),
+ slog.F("delay_ms", retry.DelayMs),
+ slog.F("error", retry.Error),
+ )
+ return
+ }
+ logger.Warn(ctx, "chat retry event")
+}
+
+func (r *Runner) handleErrorEvent(ctx context.Context, logger slog.Logger, eventErr *codersdk.ChatError) {
+ if eventErr != nil && eventErr.Message != "" {
+ r.lastStreamError = eventErr.Message
+ logger.Warn(ctx, "chat stream error",
+ slog.F("error", r.lastStreamError),
+ )
+ return
+ }
+ logger.Warn(ctx, "chat stream error event")
+}
+
+func (r *Runner) Cleanup(ctx context.Context, id string, logs io.Writer) error {
+ if r.chatID == uuid.Nil {
+ return nil
+ }
+
+ logs = loadtestutil.NewSyncWriter(logs)
+ logger := slog.Make(sloghuman.Sink(logs)).Leveled(slog.LevelDebug).Named(id).With(slog.F("chat_id", r.chatID))
+ r.client.SetLogger(logger)
+ r.client.SetLogBodies(true)
+
+ archived := true
+ logger.Info(ctx, "archiving chat session")
+ if err := r.client.UpdateChat(ctx, r.chatID, codersdk.UpdateChatRequest{Archived: &archived}); err != nil {
+ logger.Error(ctx, "failed to archive chat", slog.Error(err))
+ return xerrors.Errorf("archive chat: %w", err)
+ }
+ logger.Info(ctx, "archived chat session")
+ return nil
+}
+
+func (r *Runner) GetMetrics() map[string]any {
+ return map[string]any{
+ "workspace_id": r.cfg.WorkspaceID.String(),
+ "turn_start_delay_ms": r.cfg.TurnStartDelay.Milliseconds(),
+ "chat_id": r.chatID.String(),
+ "final_status": r.result.finalStatus,
+ "failure_stage": r.result.failureStage,
+ "total_duration_seconds": r.result.totalDuration.Seconds(),
+ "saw_first_output": r.result.sawFirstOutput,
+ "retry_count": r.result.retryCount,
+ "event_count": r.result.eventCount,
+ "turns_requested": r.cfg.Turns,
+ "turns_completed": r.result.turnsCompleted,
+ }
+}
diff --git a/scaletest/chat/run_internal_test.go b/scaletest/chat/run_internal_test.go
new file mode 100644
index 0000000000000..2d93737fae4c5
--- /dev/null
+++ b/scaletest/chat/run_internal_test.go
@@ -0,0 +1,391 @@
+package chat
+
+import (
+ "bytes"
+ "context"
+ "io"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/prometheus/client_golang/prometheus"
+ "github.com/stretchr/testify/require"
+ "golang.org/x/xerrors"
+
+ "cdr.dev/slog/v3"
+ "cdr.dev/slog/v3/sloggers/sloghuman"
+ "github.com/coder/coder/v2/codersdk"
+)
+
+func TestRunnerRunConversation(t *testing.T) {
+ t.Parallel()
+
+ chatID := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
+ noopMarkTurnStartReady := func() {}
+
+ t.Run("OneTurnHappyPath", func(t *testing.T) {
+ t.Parallel()
+
+ runner := newTestRunner(t, newRunConfig(t))
+ events := make(chan codersdk.ChatStreamEvent, 3)
+ events <- statusEvent(chatID, codersdk.ChatStatusRunning)
+ events <- messagePartEvent(chatID)
+ events <- statusEvent(chatID, codersdk.ChatStatusWaiting)
+ close(events)
+
+ err := runTestConversation(t, runner, chatID, events, noopMarkTurnStartReady)
+ require.NoError(t, err)
+ result := runner.result
+ require.Equal(t, string(codersdk.ChatStatusWaiting), result.finalStatus)
+ require.Empty(t, result.failureStage)
+ require.True(t, result.sawFirstOutput)
+ require.Equal(t, 1, result.turnsCompleted)
+ require.Equal(t, 3, result.eventCount)
+ })
+
+ t.Run("DuplicateWaitingDoesNotAdvanceTurn", func(t *testing.T) {
+ t.Parallel()
+
+ cfg := newRunConfig(t)
+ cfg.Turns = 2
+
+ events := make(chan codersdk.ChatStreamEvent, 7)
+ events <- statusEvent(chatID, codersdk.ChatStatusRunning)
+ events <- messagePartEvent(chatID)
+ events <- statusEvent(chatID, codersdk.ChatStatusWaiting)
+ events <- statusEvent(chatID, codersdk.ChatStatusWaiting)
+
+ var sendCount atomic.Int64
+ runner := newTestRunnerWithChatMessage(t, cfg, chatID, func() {
+ sendCount.Add(1)
+ events <- statusEvent(chatID, codersdk.ChatStatusRunning)
+ events <- messagePartEvent(chatID)
+ events <- statusEvent(chatID, codersdk.ChatStatusWaiting)
+ close(events)
+ })
+
+ err := runTestConversation(t, runner, chatID, events, noopMarkTurnStartReady)
+ require.NoError(t, err)
+ result := runner.result
+ require.Equal(t, int64(1), sendCount.Load())
+ require.Equal(t, 2, result.turnsCompleted)
+ require.Equal(t, 7, result.eventCount)
+ require.Equal(t, string(codersdk.ChatStatusWaiting), result.finalStatus)
+ })
+
+ t.Run("StaleWaitingAfterNextTurnRunningDoesNotAdvanceTurn", func(t *testing.T) {
+ t.Parallel()
+
+ cfg := newRunConfig(t)
+ cfg.Turns = 2
+
+ events := make(chan codersdk.ChatStreamEvent, 7)
+ events <- statusEvent(chatID, codersdk.ChatStatusRunning)
+ events <- messagePartEvent(chatID)
+ events <- statusEvent(chatID, codersdk.ChatStatusWaiting)
+
+ var sendCount atomic.Int64
+ runner := newTestRunnerWithChatMessage(t, cfg, chatID, func() {
+ sendCount.Add(1)
+ events <- statusEvent(chatID, codersdk.ChatStatusRunning)
+ events <- statusEvent(chatID, codersdk.ChatStatusWaiting)
+ events <- messagePartEvent(chatID)
+ events <- statusEvent(chatID, codersdk.ChatStatusWaiting)
+ close(events)
+ })
+
+ err := runTestConversation(t, runner, chatID, events, noopMarkTurnStartReady)
+ require.NoError(t, err)
+ result := runner.result
+ require.Equal(t, int64(1), sendCount.Load())
+ require.Equal(t, 2, result.turnsCompleted)
+ require.Equal(t, 7, result.eventCount)
+ require.Equal(t, string(codersdk.ChatStatusWaiting), result.finalStatus)
+ })
+
+ t.Run("FirstTurnGatesFollowUpStorm", func(t *testing.T) {
+ t.Parallel()
+
+ // Reproduces the contract that the turn-start gate is checked
+ // after the first turn finishes, not before it begins. The runner
+ // must mark itself ready, wait for the release channel, and only
+ // then send turn 2.
+ cfg := newRunConfig(t)
+ cfg.Turns = 2
+ readyWG := &sync.WaitGroup{}
+ readyWG.Add(1)
+ releaseChan := make(chan struct{})
+ cfg.TurnStartReadyWaitGroup = readyWG
+ cfg.StartTurnsChan = releaseChan
+
+ events := make(chan codersdk.ChatStreamEvent, 4)
+ events <- statusEvent(chatID, codersdk.ChatStatusRunning)
+ events <- messagePartEvent(chatID)
+ events <- statusEvent(chatID, codersdk.ChatStatusWaiting)
+
+ ready := make(chan struct{})
+ go func() {
+ readyWG.Wait()
+ close(ready)
+ }()
+
+ errCh := make(chan error, 1)
+ var sendCount atomic.Int64
+ runner := newTestRunnerWithChatMessage(t, cfg, chatID, func() {
+ sendCount.Add(1)
+ events <- statusEvent(chatID, codersdk.ChatStatusRunning)
+ events <- messagePartEvent(chatID)
+ events <- statusEvent(chatID, codersdk.ChatStatusWaiting)
+ close(events)
+ })
+
+ runner.resetConversation(time.Now(), sync.OnceFunc(readyWG.Done))
+
+ go func() {
+ runErr := runner.runConversation(context.Background(), chatID, testLogger(), events)
+ errCh <- runErr
+ }()
+
+ select {
+ case <-ready:
+ case <-time.After(2 * time.Second):
+ t.Fatal("runner did not mark turn-start gate ready after first turn")
+ }
+
+ require.Equal(t, int64(0), sendCount.Load(), "next turn was sent before turn-start release")
+
+ close(releaseChan)
+
+ select {
+ case err := <-errCh:
+ require.NoError(t, err)
+ case <-time.After(2 * time.Second):
+ t.Fatal("runner did not finish after turn-start release")
+ }
+ require.Equal(t, int64(1), sendCount.Load())
+ })
+
+ t.Run("FirstOutputFromAssistantMessageEvent", func(t *testing.T) {
+ t.Parallel()
+
+ // Snapshot race: when a turn finishes before stream attach,
+ // StreamChat replays rows as message events, never as
+ // message_part deltas; the assistant row must record first output.
+ runner := newTestRunner(t, newRunConfig(t))
+ events := make(chan codersdk.ChatStreamEvent, 3)
+ events <- messageEvent(chatID, codersdk.ChatMessageRoleUser)
+ events <- messageEvent(chatID, codersdk.ChatMessageRoleAssistant)
+ events <- statusEvent(chatID, codersdk.ChatStatusWaiting)
+ close(events)
+
+ err := runTestConversation(t, runner, chatID, events, noopMarkTurnStartReady)
+ require.NoError(t, err)
+ result := runner.result
+ require.True(t, result.sawFirstOutput, "first output not recorded from assistant message event")
+ require.Equal(t, 1, result.turnsCompleted)
+ require.Equal(t, string(codersdk.ChatStatusWaiting), result.finalStatus)
+ })
+
+ t.Run("ImmediateWaitingCountsNextTurn", func(t *testing.T) {
+ t.Parallel()
+
+ cfg := newRunConfig(t)
+ cfg.Turns = 2
+
+ events := make(chan codersdk.ChatStreamEvent, 3)
+ events <- statusEvent(chatID, codersdk.ChatStatusWaiting)
+
+ var sendCount atomic.Int64
+ runner := newTestRunnerWithChatMessage(t, cfg, chatID, func() {
+ sendCount.Add(1)
+ events <- statusEvent(chatID, codersdk.ChatStatusRunning)
+ events <- messagePartEvent(chatID)
+ events <- statusEvent(chatID, codersdk.ChatStatusWaiting)
+ close(events)
+ })
+
+ err := runTestConversation(t, runner, chatID, events, noopMarkTurnStartReady)
+ require.NoError(t, err)
+ result := runner.result
+ require.Equal(t, int64(1), sendCount.Load())
+ require.Equal(t, 2, result.turnsCompleted)
+ require.Equal(t, string(codersdk.ChatStatusWaiting), result.finalStatus)
+ })
+}
+
+func runTestConversation(t *testing.T, runner *Runner, chatID uuid.UUID, events <-chan codersdk.ChatStreamEvent, markTurnStartReady func()) error {
+ t.Helper()
+ runner.resetConversation(time.Now(), markTurnStartReady)
+ return runner.runConversation(context.Background(), chatID, testLogger(), events)
+}
+
+func TestRunnerCleanup(t *testing.T) {
+ t.Parallel()
+
+ chatID := uuid.MustParse("22222222-2222-2222-2222-222222222222")
+
+ t.Run("ArchivesChat", func(t *testing.T) {
+ t.Parallel()
+
+ runner, archived := newTestRunnerWithChatArchive(t, chatID, nil)
+
+ logs := bytes.NewBuffer(nil)
+ err := runner.Cleanup(context.Background(), "runner-1", logs)
+ require.NoError(t, err)
+ require.True(t, archived())
+ require.Contains(t, logs.String(), "archived chat")
+ })
+
+ t.Run("ArchiveErrorIsReturned", func(t *testing.T) {
+ t.Parallel()
+
+ runner, archived := newTestRunnerWithChatArchive(t, chatID, xerrors.New("boom"))
+
+ err := runner.Cleanup(context.Background(), "runner-1", bytes.NewBuffer(nil))
+ require.Error(t, err)
+ require.ErrorContains(t, err, "archive chat")
+ require.True(t, archived())
+ })
+}
+
+func testLogger() slog.Logger {
+ return slog.Make(sloghuman.Sink(io.Discard)).Leveled(slog.LevelDebug)
+}
+
+func newRunConfig(t *testing.T) Config {
+ t.Helper()
+ reg := prometheus.NewRegistry()
+ return Config{
+ OrganizationID: uuid.MustParse("22222222-2222-2222-2222-222222222222"),
+ WorkspaceID: uuid.MustParse("11111111-1111-1111-1111-111111111111"),
+ ModelConfigID: uuid.MustParse("33333333-3333-3333-3333-333333333333"),
+ Prompt: "Reply with one short sentence.",
+ Turns: 1,
+ Metrics: NewMetrics(reg),
+ }
+}
+
+type fakeChatClient struct {
+ createChatFunc func(context.Context, codersdk.CreateChatRequest) (codersdk.Chat, error)
+ streamChatFunc func(context.Context, uuid.UUID, *codersdk.StreamChatOptions) (<-chan codersdk.ChatStreamEvent, io.Closer, error)
+ createChatMessageFunc func(context.Context, uuid.UUID, codersdk.CreateChatMessageRequest) (codersdk.CreateChatMessageResponse, error)
+ updateChatFunc func(context.Context, uuid.UUID, codersdk.UpdateChatRequest) error
+}
+
+func newFakeChatClient(t *testing.T) *fakeChatClient {
+ t.Helper()
+ return &fakeChatClient{}
+}
+
+func (*fakeChatClient) SetLogger(logger slog.Logger) {}
+
+func (*fakeChatClient) SetLogBodies(logBodies bool) {}
+
+func (f *fakeChatClient) CreateChat(ctx context.Context, req codersdk.CreateChatRequest) (codersdk.Chat, error) {
+ if f.createChatFunc == nil {
+ return codersdk.Chat{}, xerrors.New("unexpected CreateChat call")
+ }
+ return f.createChatFunc(ctx, req)
+}
+
+func (f *fakeChatClient) StreamChat(ctx context.Context, chatID uuid.UUID, opts *codersdk.StreamChatOptions) (<-chan codersdk.ChatStreamEvent, io.Closer, error) {
+ if f.streamChatFunc == nil {
+ return nil, nil, xerrors.New("unexpected StreamChat call")
+ }
+ return f.streamChatFunc(ctx, chatID, opts)
+}
+
+func (f *fakeChatClient) CreateChatMessage(ctx context.Context, chatID uuid.UUID, req codersdk.CreateChatMessageRequest) (codersdk.CreateChatMessageResponse, error) {
+ if f.createChatMessageFunc == nil {
+ return codersdk.CreateChatMessageResponse{}, xerrors.New("unexpected CreateChatMessage call")
+ }
+ return f.createChatMessageFunc(ctx, chatID, req)
+}
+
+func (f *fakeChatClient) UpdateChat(ctx context.Context, chatID uuid.UUID, req codersdk.UpdateChatRequest) error {
+ if f.updateChatFunc == nil {
+ return xerrors.New("unexpected UpdateChat call")
+ }
+ return f.updateChatFunc(ctx, chatID, req)
+}
+
+var _ chatClient = (*fakeChatClient)(nil)
+
+func newTestRunner(t *testing.T, cfg Config) *Runner {
+ t.Helper()
+ return &Runner{client: newFakeChatClient(t), cfg: cfg}
+}
+
+func newTestRunnerWithChatArchive(t *testing.T, chatID uuid.UUID, updateErr error) (*Runner, func() bool) {
+ t.Helper()
+
+ var archived atomic.Bool
+ client := newFakeChatClient(t)
+ client.updateChatFunc = func(ctx context.Context, gotChatID uuid.UUID, req codersdk.UpdateChatRequest) error {
+ if gotChatID != chatID {
+ return xerrors.Errorf("unexpected chat archive ID: %s", gotChatID)
+ }
+ if req.Archived == nil || !*req.Archived {
+ return xerrors.Errorf("unexpected archived value: %v", req.Archived)
+ }
+ archived.Store(true)
+ return updateErr
+ }
+ runner := &Runner{client: client, cfg: Config{}, chatID: chatID}
+ return runner, archived.Load
+}
+
+func newTestRunnerWithChatMessage(t *testing.T, cfg Config, chatID uuid.UUID, onMessage func()) *Runner {
+ t.Helper()
+
+ client := newFakeChatClient(t)
+ client.createChatMessageFunc = func(ctx context.Context, gotChatID uuid.UUID, req codersdk.CreateChatMessageRequest) (codersdk.CreateChatMessageResponse, error) {
+ if gotChatID != chatID {
+ return codersdk.CreateChatMessageResponse{}, xerrors.Errorf("unexpected chat message ID: %s", gotChatID)
+ }
+ if err := validatePromptParts(req.Content, cfg.Prompt); err != nil {
+ return codersdk.CreateChatMessageResponse{}, err
+ }
+ if req.ModelConfigID == nil || *req.ModelConfigID != cfg.ModelConfigID {
+ return codersdk.CreateChatMessageResponse{}, xerrors.Errorf("unexpected chat message model config ID: %v", req.ModelConfigID)
+ }
+
+ if onMessage != nil {
+ onMessage()
+ }
+ return codersdk.CreateChatMessageResponse{Queued: true}, nil
+ }
+ return &Runner{client: client, cfg: cfg}
+}
+
+func validatePromptParts(parts []codersdk.ChatInputPart, prompt string) error {
+ if len(parts) != 1 || parts[0].Type != codersdk.ChatInputPartTypeText || parts[0].Text != prompt {
+ return xerrors.Errorf("unexpected chat message content: %#v", parts)
+ }
+ return nil
+}
+
+func statusEvent(chatID uuid.UUID, status codersdk.ChatStatus) codersdk.ChatStreamEvent {
+ return codersdk.ChatStreamEvent{
+ Type: codersdk.ChatStreamEventTypeStatus,
+ ChatID: chatID,
+ Status: &codersdk.ChatStreamStatus{Status: status},
+ }
+}
+
+func messagePartEvent(chatID uuid.UUID) codersdk.ChatStreamEvent {
+ return codersdk.ChatStreamEvent{
+ Type: codersdk.ChatStreamEventTypeMessagePart,
+ ChatID: chatID,
+ }
+}
+
+func messageEvent(chatID uuid.UUID, role codersdk.ChatMessageRole) codersdk.ChatStreamEvent {
+ return codersdk.ChatStreamEvent{
+ Type: codersdk.ChatStreamEventTypeMessage,
+ ChatID: chatID,
+ Message: &codersdk.ChatMessage{Role: role},
+ }
+}
From c801dcbbc8c4d44068855e05c5952ad0158446cc Mon Sep 17 00:00:00 2001
From: Danny Kopping
Date: Tue, 26 May 2026 10:04:26 +0200
Subject: [PATCH 003/249] fix: strip route prefix when passing request to
aibridged handler (#25671)
We weren't stripping the API base (`/api/v2/aibridge`), leading to 404s
when using the in-memory transport.
Signed-off-by: Danny Kopping
---
coderd/aibridged.go | 4 ++--
enterprise/coderd/aibridge.go | 2 +-
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/coderd/aibridged.go b/coderd/aibridged.go
index 30439752ccd9b..f448be39d07ed 100644
--- a/coderd/aibridged.go
+++ b/coderd/aibridged.go
@@ -42,9 +42,9 @@ func (api *API) RegisterInMemoryAIBridgedHTTPHandler(srv http.Handler) {
panic("aibridged cannot be nil")
}
- api.aibridgedHandler = srv
+ api.aibridgedHandler = http.StripPrefix("/api/v2/aibridge", srv)
- factory := aibridged.NewTransportFactory(srv)
+ factory := aibridged.NewTransportFactory(api.aibridgedHandler)
var asInterface agplaibridge.TransportFactory = factory
api.AIBridgeTransportFactory.Store(&asInterface)
}
diff --git a/enterprise/coderd/aibridge.go b/enterprise/coderd/aibridge.go
index f08fd5b5363ef..9773dba352dad 100644
--- a/enterprise/coderd/aibridge.go
+++ b/enterprise/coderd/aibridge.go
@@ -86,7 +86,7 @@ func aibridgeHandler(api *API, middlewares ...func(http.Handler) http.Handler) f
return
}
- http.StripPrefix("/api/v2/aibridge", api.AGPL.GetAIBridgedHandler()).ServeHTTP(rw, r)
+ api.AGPL.GetAIBridgedHandler().ServeHTTP(rw, r)
})
})
}
From 32ed9f1f39d263b2ba730d8ebbbff1397c1595f2 Mon Sep 17 00:00:00 2001
From: Mathias Fredriksson
Date: Tue, 26 May 2026 11:11:47 +0300
Subject: [PATCH 004/249] fix: use old_text/new_text in edit_files tool schema
(#25658)
Models frequently confuse the search and replace fields in the
edit_files tool (CODAGT-312). Rename the model-facing JSON fields
to old_text/new_text so the intent is unambiguous.
Backend: custom UnmarshalJSON on editFileEdit falls back to
deprecated search/replace when old_text/new_text are empty. The
workspace agent API is unchanged; toSDKFiles maps old_text/new_text
back to search/replace for agent/agentfiles.
Frontend: normalizeEdit in parseEditFilesArgs accepts both
old_text/new_text and search/replace, normalizing to the internal
{ search, replace } representation so streaming diff rendering
works with either field naming convention.
---
coderd/x/chatd/chattool/editfiles.go | 78 ++++++++-
coderd/x/chatd/chattool/editfiles_test.go | 148 ++++++++++++++++++
.../ChatElements/tools/utils.test.ts | 51 ++++++
.../components/ChatElements/tools/utils.ts | 39 +++--
4 files changed, 297 insertions(+), 19 deletions(-)
diff --git a/coderd/x/chatd/chattool/editfiles.go b/coderd/x/chatd/chattool/editfiles.go
index 51518c1c9c678..1c1c584c406ac 100644
--- a/coderd/x/chatd/chattool/editfiles.go
+++ b/coderd/x/chatd/chattool/editfiles.go
@@ -2,6 +2,7 @@ package chattool
import (
"context"
+ "encoding/json"
"strings"
"charm.land/fantasy"
@@ -15,19 +16,80 @@ type EditFilesOptions struct {
IsPlanTurn bool
}
+// EditFilesArgs is the tool input schema, auto-generated by the
+// fantasy framework from these struct tags.
type EditFilesArgs struct {
- Files []workspacesdk.FileEdits `json:"files"`
+ Files []editFileEdits `json:"files"`
+}
+
+type editFileEdits struct {
+ Path string `json:"path"`
+ Edits []editFileEdit `json:"edits"`
+}
+
+// editFileEdit uses "old_text"/"new_text" instead of "search"/"replace"
+// because models confused the direction (CODAGT-312). Deprecated
+// "search"/"replace" accepted via UnmarshalJSON; toSDKFiles maps back
+// to "search"/"replace" for agent/agentfiles.
+type editFileEdit struct {
+ OldText string `json:"old_text"`
+ NewText string `json:"new_text"`
+ ReplaceAll bool `json:"replace_all,omitempty"`
+}
+
+// UnmarshalJSON falls back to deprecated "search"/"replace" when
+// "old_text"/"new_text" are empty.
+func (e *editFileEdit) UnmarshalJSON(data []byte) error {
+ var raw struct {
+ OldText string `json:"old_text"`
+ Search string `json:"search"`
+ NewText string `json:"new_text"`
+ Replace string `json:"replace"`
+ ReplaceAll bool `json:"replace_all"`
+ }
+ if err := json.Unmarshal(data, &raw); err != nil {
+ return err
+ }
+ e.OldText = raw.OldText
+ if e.OldText == "" {
+ e.OldText = raw.Search
+ }
+ e.NewText = raw.NewText
+ if e.NewText == "" {
+ e.NewText = raw.Replace
+ }
+ e.ReplaceAll = raw.ReplaceAll
+ return nil
+}
+
+func (a EditFilesArgs) toSDKFiles() []workspacesdk.FileEdits {
+ files := make([]workspacesdk.FileEdits, len(a.Files))
+ for i, f := range a.Files {
+ edits := make([]workspacesdk.FileEdit, len(f.Edits))
+ for j, e := range f.Edits {
+ edits[j] = workspacesdk.FileEdit{
+ Search: e.OldText,
+ Replace: e.NewText,
+ ReplaceAll: e.ReplaceAll,
+ }
+ }
+ files[i] = workspacesdk.FileEdits{
+ Path: f.Path,
+ Edits: edits,
+ }
+ }
+ return files
}
func EditFiles(options EditFilesOptions) fantasy.AgentTool {
return fantasy.NewAgentTool(
"edit_files",
- "Perform search-and-replace edits on one or more files. Matching"+
- " is fuzzy (tolerates whitespace and indentation differences) and"+
- " preserves the file's existing indentation and line endings."+
- " Errors if search matches zero locations, or more than one unless"+
- " replace_all is set. All edits in a batch are validated before any"+
- " file is written.",
+ "Perform edits on one or more files by replacing old_text with"+
+ " new_text. Matching is fuzzy (tolerates whitespace and indentation"+
+ " differences) and preserves the file's existing indentation and"+
+ " line endings. Errors if old_text matches zero locations, or more"+
+ " than one unless replace_all is set. All edits in a batch are"+
+ " validated before any file is written.",
func(ctx context.Context, args EditFilesArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) {
var planPath string
if options.IsPlanTurn && len(args.Files) > 0 {
@@ -101,7 +163,7 @@ func executeEditFilesTool(
}
resp, err := conn.EditFiles(ctx, workspacesdk.FileEditRequest{
- Files: args.Files,
+ Files: args.toSDKFiles(),
IncludeDiff: true,
})
if err != nil {
diff --git a/coderd/x/chatd/chattool/editfiles_test.go b/coderd/x/chatd/chattool/editfiles_test.go
index d025d1ca4bb52..2cafdfe7968ed 100644
--- a/coderd/x/chatd/chattool/editfiles_test.go
+++ b/coderd/x/chatd/chattool/editfiles_test.go
@@ -20,6 +20,44 @@ import (
func TestEditFiles(t *testing.T) {
t.Parallel()
+ // Verify the generated tool schema exposes old_text/new_text
+ // (not the deprecated search/replace) so the rename is
+ // auditable without running a separate program.
+ t.Run("SchemaUsesOldTextNewText", func(t *testing.T) {
+ t.Parallel()
+ tool := chattool.EditFiles(chattool.EditFilesOptions{})
+ info := tool.Info()
+
+ // Dig into: files -> items -> properties -> edits -> items -> properties
+ filesSchema := info.Parameters["files"]
+ require.NotNil(t, filesSchema, "missing files parameter")
+ filesMap, ok := filesSchema.(map[string]any)
+ require.True(t, ok)
+ items, ok := filesMap["items"].(map[string]any)
+ require.True(t, ok)
+ props, ok := items["properties"].(map[string]any)
+ require.True(t, ok)
+ editsSchema, ok := props["edits"].(map[string]any)
+ require.True(t, ok)
+ editItems, ok := editsSchema["items"].(map[string]any)
+ require.True(t, ok)
+ editProps, ok := editItems["properties"].(map[string]any)
+ require.True(t, ok)
+
+ assert.Contains(t, editProps, "old_text", "schema should expose old_text")
+ assert.Contains(t, editProps, "new_text", "schema should expose new_text")
+ assert.Contains(t, editProps, "replace_all", "schema should expose replace_all")
+ assert.NotContains(t, editProps, "search", "schema should not expose deprecated search")
+ assert.NotContains(t, editProps, "replace", "schema should not expose deprecated replace")
+
+ // Verify required fields.
+ editRequired, ok := editItems["required"].([]string)
+ require.True(t, ok)
+ assert.Contains(t, editRequired, "old_text")
+ assert.Contains(t, editRequired, "new_text")
+ assert.NotContains(t, editRequired, "replace_all", "replace_all should be optional")
+ })
+
t.Run("PlanTurnRejectsNonPlanPath", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
@@ -470,6 +508,116 @@ func TestEditFiles(t *testing.T) {
})
}
+func TestEditFiles_OldTextNewTextFieldsPreferred(t *testing.T) {
+ t.Parallel()
+
+ ctrl := gomock.NewController(t)
+ mockConn := agentconnmock.NewMockAgentConn(ctrl)
+ targetPath := "/home/coder/main.go"
+
+ // The agent API should map old_text->Search and new_text->Replace.
+ mockConn.EXPECT().
+ EditFiles(gomock.Any(), workspacesdk.FileEditRequest{
+ Files: []workspacesdk.FileEdits{{
+ Path: targetPath,
+ Edits: []workspacesdk.FileEdit{{
+ Search: "old content",
+ Replace: "new content",
+ }},
+ }},
+ IncludeDiff: true,
+ }).
+ Return(workspacesdk.FileEditResponse{}, nil)
+
+ tool := chattool.EditFiles(chattool.EditFilesOptions{
+ GetWorkspaceConn: func(context.Context) (workspacesdk.AgentConn, error) {
+ return mockConn, nil
+ },
+ })
+
+ resp, err := tool.Run(context.Background(), fantasy.ToolCall{
+ ID: "call-1",
+ Name: "edit_files",
+ Input: `{"files":[{"path":"` + targetPath + `","edits":[{"old_text":"old content","new_text":"new content"}]}]}`,
+ })
+ require.NoError(t, err)
+ assert.False(t, resp.IsError)
+}
+
+func TestEditFiles_DeprecatedSearchReplaceFieldsStillWork(t *testing.T) {
+ t.Parallel()
+
+ ctrl := gomock.NewController(t)
+ mockConn := agentconnmock.NewMockAgentConn(ctrl)
+ targetPath := "/home/coder/main.go"
+
+ // Agents with cached schemas may still send "search"/"replace".
+ // Also exercises replace_all through the new unmarshal+convert path.
+ mockConn.EXPECT().
+ EditFiles(gomock.Any(), workspacesdk.FileEditRequest{
+ Files: []workspacesdk.FileEdits{{
+ Path: targetPath,
+ Edits: []workspacesdk.FileEdit{{
+ Search: "old",
+ Replace: "replacement",
+ ReplaceAll: true,
+ }},
+ }},
+ IncludeDiff: true,
+ }).
+ Return(workspacesdk.FileEditResponse{}, nil)
+
+ tool := chattool.EditFiles(chattool.EditFilesOptions{
+ GetWorkspaceConn: func(context.Context) (workspacesdk.AgentConn, error) {
+ return mockConn, nil
+ },
+ })
+
+ resp, err := tool.Run(context.Background(), fantasy.ToolCall{
+ ID: "call-1",
+ Name: "edit_files",
+ Input: `{"files":[{"path":"` + targetPath + `","edits":[{"search":"old","replace":"replacement","replace_all":true}]}]}`,
+ })
+ require.NoError(t, err)
+ assert.False(t, resp.IsError)
+}
+
+func TestEditFiles_NewFieldNamesTakePrecedenceOverOld(t *testing.T) {
+ t.Parallel()
+
+ ctrl := gomock.NewController(t)
+ mockConn := agentconnmock.NewMockAgentConn(ctrl)
+ targetPath := "/home/coder/main.go"
+
+ // If both old and new field names are present, new names win.
+ mockConn.EXPECT().
+ EditFiles(gomock.Any(), workspacesdk.FileEditRequest{
+ Files: []workspacesdk.FileEdits{{
+ Path: targetPath,
+ Edits: []workspacesdk.FileEdit{{
+ Search: "from-oldText",
+ Replace: "from-newText",
+ }},
+ }},
+ IncludeDiff: true,
+ }).
+ Return(workspacesdk.FileEditResponse{}, nil)
+
+ tool := chattool.EditFiles(chattool.EditFilesOptions{
+ GetWorkspaceConn: func(context.Context) (workspacesdk.AgentConn, error) {
+ return mockConn, nil
+ },
+ })
+
+ resp, err := tool.Run(context.Background(), fantasy.ToolCall{
+ ID: "call-1",
+ Name: "edit_files",
+ Input: `{"files":[{"path":"` + targetPath + `","edits":[{"old_text":"from-oldText","search":"from-search","new_text":"from-newText","replace":"from-replace"}]}]}`,
+ })
+ require.NoError(t, err)
+ assert.False(t, resp.IsError)
+}
+
func TestEditFiles_ToolResponseCarriesFileResults(t *testing.T) {
t.Parallel()
diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/utils.test.ts b/site/src/pages/AgentsPage/components/ChatElements/tools/utils.test.ts
index 2d7dd802281b3..b0dd5384d84f5 100644
--- a/site/src/pages/AgentsPage/components/ChatElements/tools/utils.test.ts
+++ b/site/src/pages/AgentsPage/components/ChatElements/tools/utils.test.ts
@@ -763,6 +763,57 @@ describe("parseEditFilesArgs", () => {
expect(parsed[0].edits[0].replace).toBe("");
});
+ it("accepts old_text/new_text field names", () => {
+ const args = {
+ files: [
+ {
+ path: "a.ts",
+ edits: [{ old_text: "before", new_text: "after" }],
+ },
+ ],
+ };
+ const result = parseEditFilesArgs(args);
+ expect(result).toHaveLength(1);
+ expect(result[0].edits).toHaveLength(1);
+ expect(result[0].edits[0]).toEqual({ search: "before", replace: "after" });
+ });
+
+ it("prefers old_text/new_text over search/replace when both present", () => {
+ const args = {
+ files: [
+ {
+ path: "a.ts",
+ edits: [
+ {
+ old_text: "from-old-text",
+ new_text: "from-new-text",
+ search: "from-search",
+ replace: "from-replace",
+ },
+ ],
+ },
+ ],
+ };
+ const result = parseEditFilesArgs(args);
+ expect(result[0].edits[0]).toEqual({
+ search: "from-old-text",
+ replace: "from-new-text",
+ });
+ });
+
+ it("preserves deletion via old_text/new_text (empty new_text)", () => {
+ const args = {
+ files: [
+ {
+ path: "a.ts",
+ edits: [{ old_text: "remove me", new_text: "" }],
+ },
+ ],
+ };
+ const result = parseEditFilesArgs(args);
+ expect(result[0].edits[0]).toEqual({ search: "remove me", replace: "" });
+ });
+
// During streaming the model may emit a file entry before any
// edit is complete. Every edit has a missing replace, so all are
// filtered out. The file entry survives with an empty edits
diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/utils.ts b/site/src/pages/AgentsPage/components/ChatElements/tools/utils.ts
index 8c74d5874ae75..8e5595f15f3fa 100644
--- a/site/src/pages/AgentsPage/components/ChatElements/tools/utils.ts
+++ b/site/src/pages/AgentsPage/components/ChatElements/tools/utils.ts
@@ -12,12 +12,29 @@ export interface EditFilesFileEntry {
edits: Array<{ search: string; replace: string }>;
}
-const searchReplaceSchema = Yup.object({
- search: Yup.string().required(),
- replace: Yup.string().defined(),
-}).required();
-
-type SearchReplace = Yup.InferType;
+// Validates that the edit has at least the shape of an object with
+// string-typed text fields. Accepts both current field names
+// (old_text/new_text) and deprecated names (search/replace).
+const normalizeEdit = (
+ e: unknown,
+): { search: string; replace: string } | null => {
+ if (typeof e !== "object" || e === null) return null;
+ const raw = e as Record;
+ const search =
+ typeof raw.old_text === "string"
+ ? raw.old_text
+ : typeof raw.search === "string"
+ ? raw.search
+ : null;
+ const replace =
+ typeof raw.new_text === "string"
+ ? raw.new_text
+ : typeof raw.replace === "string"
+ ? raw.replace
+ : null;
+ if (!search || replace === null) return null;
+ return { search, replace };
+};
const fileEntrySchema = Yup.object({
path: Yup.string().required(),
@@ -688,15 +705,15 @@ export const parseEditFilesArgs = (args: unknown): EditFilesFileEntry[] => {
.filter((f): f is FileEntry => isValid(fileEntrySchema, f))
.map((f) => ({
path: f.path,
- edits: f.edits.filter((e): e is SearchReplace =>
- isValid(searchReplaceSchema, e),
- ),
+ edits: f.edits
+ .map(normalizeEdit)
+ .filter((e): e is { search: string; replace: string } => e !== null),
}));
};
/**
- * Builds a synthetic unified diff from search/replace edit pairs
- * for a single file. Each edit becomes a separate
+ * Builds a synthetic unified diff from edit pairs (normalized to
+ * search/replace) for a single file. Each edit becomes a separate
* `Diff.createPatch` call; the patches are concatenated and
* parsed into a single FileDiffMetadata.
*/
From d8dc782da5663c0bea7f355088dbf48faae4b507 Mon Sep 17 00:00:00 2001
From: Danny Kopping
Date: Tue, 26 May 2026 11:20:52 +0200
Subject: [PATCH 005/249] docs: require substantive, concise comments
describing behaviour (#25674)
*Disclaimer: implemented by a Coder Agent using Claude Opus 4.6*
Adds a clause under **Writing Comments and Avoiding Unnecessary
Changes** in `AGENTS.md` specifying that comments must be:
- **Substantive** and **concise**
- Describe the **behaviour** of the code
- NOT describe the reasoning the agent used to produce the change
Includes concrete anti-pattern examples (`// Added per PR feedback`, `//
Refactored for clarity`) to make the expectation clear.
---
AGENTS.md | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/AGENTS.md b/AGENTS.md
index 4517ffe21c2b6..4dcbc114d4663 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -164,6 +164,11 @@ See [Modern Go](.claude/docs/GO.md) for comment formatting and the rule to
avoid unrelated edits. Preserve existing comments that explain non-obvious
behavior unless the task directly requires changing them.
+Comments MUST be **substantive** and **concise**. Describe the **behaviour**
+of the code, not the reasoning the agent used to produce the change. Do not
+leave comments like `// Added per PR feedback` or `// Refactored for
+clarity`. Instead, explain what the code does and why the behaviour matters.
+
### No Emdash or Endash
Do not use emdash (U+2014), endash (U+2013), or ` -- ` as punctuation
From 51836e681e79e648b3f3a8e6a92753cbc7306317 Mon Sep 17 00:00:00 2001
From: Thomas Kosiewski
Date: Tue, 26 May 2026 14:52:21 +0200
Subject: [PATCH 006/249] refactor: build dogfood image as base + mise oci
layers (#25448)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Splits the dogfood image into two artifacts:
- `ghcr.io/coder/oss-dogfood-base:-`: Ubuntu base with
apt packages, chrome, rustup, brew, gh, and the mise binary. The
base-sha is a cache key over `Dockerfile.base` and `files/`, so commits
that don't touch those inputs reuse the previous build.
- `codercom/oss-dogfood:-` and rolling tags
(`:22.04`, `:26.04`, `:latest`, `:`): produced by `mise oci
build` on top of the base, with one content-addressed OCI layer per mise
tool. The rolling tag scheme is unchanged, so the workspace template
doesn't need updating.
Single-tool version bumps now invalidate only that tool's OCI layer, so
workspaces re-pull just what changed instead of the entire 5-6 GB image
on every recreate.
Also:
- Drops the build-time `pnpm dlx playwright@1.47.0 install --with-deps
chromium` step (~400 MB) and the equivalent `playwright-driver.browsers`
install from `flake.nix`. `@playwright/mcp` (used by the claude-code and
codex MCP servers in `dogfood/coder/main.tf`) does NOT auto-install
browsers, so the existing `install-deps` `coder_script` now runs two
installs on workspace start: `pnpm exec playwright install chromium` for
the site's pinned `@playwright/test`, and `npx
--package=@playwright/mcp@latest playwright-core install --no-shell
chromium` so the MCP servers find their matching browser revision.
Browser revisions coexist under
`~/.cache/ms-playwright/chromium-/`, which lives on the home volume
so both downloads happen once per workspace recreate and persist across
restarts. Net effect: same MCP behavior as before, +~1-2 min on first
workspace start. Nix devshell users running site e2e tests locally now
need `pnpm exec playwright install` once (instead of getting browsers
via nixpkgs).
- Bumps the pinned mise binary to v2026.5.12 (matching main after
#25521) and adds top-level `min_version = "2026.5.12"` to `mise.toml` so
every consumer (devs, CI, the embedded mise inside the dogfood image,
mise oci builds) fails fast on an older mise.
- Adds bison, flex, libicu-dev, libreadline-dev, uuid-dev, and
zlib1g-dev to both Ubuntu base images for source-build use cases (e.g.,
building Postgres from source).
- Replaces skopeo with crane as the registry client `mise oci push`
shells out to: crane is added to `mise.toml`, the workflow drops its
`apt-get install skopeo` and forces `--tool crane`, and the local
wrapper image stops bundling skopeo. One source of truth for tool
versions, no apt drift, smaller wrapper image, and workspace users get a
registry client on PATH for free via mise oci's tool layers.
- Removes `nix.hash`/`mise.hash` and their Makefile rules. The registry
digest already captures every effective change since CI rebuilds when
any baked-in input moves; the per-file `filesha1()` entries in
`pull_triggers` are redundant.
Supersedes #25400 (the `mise.hash` pull trigger landed there in
`2b612abe7b`; this PR removes it as part of the broader simplification).
> [!NOTE]
> `mise oci build` is experimental and requires `MISE_EXPERIMENTAL=1`
(set at job level in the workflow). The local-only
`scripts/dogfood/mise-oci-wrapper.sh` builds a tiny
`coderdev/mise-oci-wrapper:` Debian image with curl-installed
mise on first invocation (cached by version tag thereafter); we don't
reuse `jdxcode/mise:latest` because that tag lags upstream GitHub
releases by days and would defeat the `min_version` enforcement above.
> [!NOTE]
> `compute-base-sha.sh` and `compute-final-sha.sh` are cache keys, not
strict content addresses: the base Dockerfile still pulls dynamic
resources at build time (gh/buildx `releases/latest`, chrome
`stable_current_amd64.deb`, apt mirror state). Two runs with identical
checked-in files can produce slightly different bytes, which is
acceptable here because the cache-hit savings on irrelevant commits
outweigh that drift.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Signed-off-by: Thomas Kosiewski
Co-authored-by: Claude Opus 4.7 (1M context)
---
.dockerignore | 14 +-
.github/workflows/dogfood.yaml | 285 ++++++++++++------
.gitignore | 9 +
Makefile | 6 -
dogfood/coder/Makefile | 97 +++---
dogfood/coder/main.tf | 42 ++-
dogfood/coder/mise.hash | 2 -
dogfood/coder/nix.hash | 2 -
.../{Dockerfile => Dockerfile.base} | 67 ++--
.../{Dockerfile => Dockerfile.base} | 67 ++--
flake.nix | 14 -
mise.lock | 50 +++
mise.toml | 21 ++
scripts/dogfood/compute-base-sha.sh | 43 +++
scripts/dogfood/compute-final-sha.sh | 20 ++
scripts/dogfood/mise-oci-wrapper.sh | 109 +++++++
scripts/dogfood_test_image.sh | 14 +-
scripts/update-flake.sh | 2 -
18 files changed, 606 insertions(+), 258 deletions(-)
delete mode 100644 dogfood/coder/mise.hash
delete mode 100644 dogfood/coder/nix.hash
rename dogfood/coder/ubuntu-22.04/{Dockerfile => Dockerfile.base} (79%)
rename dogfood/coder/ubuntu-26.04/{Dockerfile => Dockerfile.base} (80%)
create mode 100755 scripts/dogfood/compute-base-sha.sh
create mode 100755 scripts/dogfood/compute-final-sha.sh
create mode 100755 scripts/dogfood/mise-oci-wrapper.sh
diff --git a/.dockerignore b/.dockerignore
index 9b4d2a599782b..9a9bc82b8716e 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -1,9 +1,9 @@
# This file controls what docker/BuildKit may send to the daemon when
# the build context is the repository root. Today only the dogfood
-# images at dogfood/coder/ubuntu-{22,26}.04/Dockerfile use the repo
-# root as context; other docker builds in this repo (scripts/Dockerfile,
-# scripts/Dockerfile.base, scripts/ironbank/Dockerfile) cd into a
-# temporary directory and have their own contexts.
+# base images at dogfood/coder/ubuntu-{22,26}.04/Dockerfile.base use the
+# repo root as context; other docker builds in this repo
+# (scripts/Dockerfile, scripts/Dockerfile.base, scripts/ironbank/Dockerfile)
+# cd into a temporary directory and have their own contexts.
#
# We use an allowlist so the context stays small and predictable, and
# new top-level files added to the repo do not silently inflate every
@@ -14,15 +14,15 @@
# file under a directory requires re-including the directory itself.
**
-# Re-allow paths the dogfood Dockerfiles consume.
-!mise.toml
-!mise.lock
+# Re-allow paths the dogfood Dockerfile.base files consume.
!dogfood
!dogfood/coder
!dogfood/coder/ubuntu-22.04
+!dogfood/coder/ubuntu-22.04/Dockerfile.base
!dogfood/coder/ubuntu-22.04/configure-chrome-flags.sh
!dogfood/coder/ubuntu-22.04/files
!dogfood/coder/ubuntu-22.04/files/**
!dogfood/coder/ubuntu-26.04
+!dogfood/coder/ubuntu-26.04/Dockerfile.base
!dogfood/coder/ubuntu-26.04/files
!dogfood/coder/ubuntu-26.04/files/**
diff --git a/.github/workflows/dogfood.yaml b/.github/workflows/dogfood.yaml
index 2bd0e6d29f42f..9dcd853f79294 100644
--- a/.github/workflows/dogfood.yaml
+++ b/.github/workflows/dogfood.yaml
@@ -8,21 +8,24 @@ on:
#
# Effects vary by event:
#
- # PRs: `build_image` builds the image variants but never pushes
- # (each `depot/build-push-action` step's `push:` and the
- # `Push Nix image` step are gated on `github.ref ==
- # 'refs/heads/main'`). `test_image` rebuilds the Ubuntu images
- # from Depot cache with `load: true` and runs `make gen`, `fmt`,
- # `lint`, and a Linux build inside each image to validate that
- # the baked-in tooling works. `deploy_template` runs
- # `terraform init` + `validate` only; the apply step and
- # SHA/title gathering are gated on main.
+ # PRs: `build_image` builds the base and runs `mise oci build`,
+ # loads the result into the local Docker daemon, and runs
+ # `make gen`, `fmt`, `lint`, and a Linux build inside the image
+ # to validate the baked-in tooling. Only the base image is pushed
+ # (to ghcr.io so the mise oci step can pull --from a real
+ # registry); the Docker Hub push is gated on
+ # `github.ref == 'refs/heads/main'`. Fork PRs skip the entire
+ # base+mise-oci pipeline since GITHUB_TOKEN is read-only for
+ # packages; the nix matrix entry still runs.
+ # `deploy_template` runs `terraform init` + `validate` only; the
+ # apply step and SHA/title gathering are gated on main.
#
# Pushes to main: `build_image` retags rolling tags on
# `codercom/oss-dogfood` (`:latest`, `:22.04`, `:26.04`),
# `codercom/oss-dogfood-vscode-coder` (`:latest`), and
# `codercom/oss-dogfood-nix` (`:latest`), plus a per-branch tag on
- # each. `test_image` validates tooling as above.
+ # each. The image-tooling validation runs as above before any
+ # push, so a broken image never reaches Docker Hub.
# `deploy_template` runs `terraform apply` and creates new
# `coderd_template` versions on dev.coder.com whose `name` is the
# commit short SHA. Content is unchanged when neither `dogfood/**`
@@ -37,6 +40,8 @@ on:
- "flake.nix"
- "mise.toml"
- "mise.lock"
+ - "scripts/dogfood/**"
+ - "scripts/dogfood_test_image.sh"
pull_request:
paths:
- "dogfood/**"
@@ -45,6 +50,8 @@ on:
- "flake.nix"
- "mise.toml"
- "mise.lock"
+ - "scripts/dogfood/**"
+ - "scripts/dogfood_test_image.sh"
workflow_dispatch:
permissions:
@@ -58,7 +65,16 @@ jobs:
image-version: ["22.04", "26.04", "nix"]
if: github.actor != 'dependabot[bot]' # Skip Dependabot PRs
- runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-4' || 'ubuntu-latest' }}
+ runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
+ permissions:
+ contents: read
+ packages: write # push the dogfood base image to ghcr.io/coder/oss-dogfood-base
+ env:
+ # MISE_EXPERIMENTAL opts into the experimental `oci` subcommand.
+ # Trust is set via a config file (see the Install mise step
+ # below) rather than MISE_TRUSTED_CONFIG_PATHS so the workspace
+ # template can keep parity with the same file-based approach.
+ MISE_EXPERIMENTAL: "1"
steps:
- name: Harden Runner
uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2.17.0
@@ -119,6 +135,58 @@ jobs:
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
if: matrix.image-version != 'nix'
+ - name: Install mise
+ if: matrix.image-version != 'nix'
+ # MISE_VERSION + MISE_SHA256 match dogfood/coder/ubuntu-*/Dockerfile.base
+ # so the mise binary baking the image is the same one a workspace
+ # ships with. `min_version` in mise.toml catches downgrades.
+ # Write trust config to ~/.config/mise/conf.d/ instead of using
+ # MISE_TRUSTED_CONFIG_PATHS so the same file-based approach
+ # works in workspaces (where the user owns the file).
+ env:
+ MISE_VERSION: v2026.5.12
+ MISE_SHA256: a238972a3162d710b85b28c324372e96ca4e4b486c81fe78695000d9fbc77c48
+ WORKSPACE: ${{ github.workspace }}
+ run: |
+ set -euo pipefail
+ curl --silent --show-error --location --fail \
+ "https://github.com/jdx/mise/releases/download/${MISE_VERSION}/mise-${MISE_VERSION}-linux-x64" \
+ --output /tmp/mise
+ echo "${MISE_SHA256} /tmp/mise" | sha256sum -c
+ sudo install -m 0755 /tmp/mise /usr/local/bin/mise
+ rm /tmp/mise
+ mise --version
+ mkdir -p "$HOME/.config/mise/conf.d"
+ cat > "$HOME/.config/mise/conf.d/00-ci-trust.toml" <> "$GITHUB_OUTPUT"
+ echo "final_sha=${final_sha}" >> "$GITHUB_OUTPUT"
+
+ - name: Login to GHCR
+ # Fork PRs get a read-only GITHUB_TOKEN that cannot push to
+ # ghcr.io. Skip the entire GHCR-dependent pipeline (base push +
+ # mise oci build) for fork PRs; the nix matrix entry still runs.
+ if: matrix.image-version != 'nix' && !github.event.pull_request.head.repo.fork
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
- name: Login to DockerHub
if: github.ref == 'refs/heads/main'
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
@@ -126,48 +194,122 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
- - name: Build and push Ubuntu 22.04 image
+ - name: Build base image
uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.17.0
+ if: matrix.image-version != 'nix' && !github.event.pull_request.head.repo.fork
with:
project: b4q6ltmpzh
token: ${{ secrets.DEPOT_TOKEN }}
buildx-fallback: true
- # Context is the repo root so the Dockerfile can COPY the
- # project mise.toml that the image installs from. The
- # github_token secret raises aqua's GitHub API quota during
- # `mise install`.
+ # Context is the repo root so Dockerfile.base can COPY the
+ # distro-specific files/ tree and configure-chrome-flags.sh.
context: "{{defaultContext}}"
- file: dogfood/coder/ubuntu-22.04/Dockerfile
- secrets: |
- github_token=${{ secrets.GITHUB_TOKEN }}
+ file: dogfood/coder/ubuntu-${{ matrix.image-version }}/Dockerfile.base
pull: true
- save: true
- push: ${{ github.ref == 'refs/heads/main' }}
- # TODO: move the `latest` tag to 26.04 soon. we don't want to transition
- # it immediately because that would make workspaces switch to it
- # automatically without any grace period.
- tags: "codercom/oss-dogfood:${{ steps.docker-tag-name.outputs.tag }},codercom/oss-dogfood:22.04,codercom/oss-dogfood:latest"
- if: matrix.image-version == '22.04'
+ # Push to ghcr.io on every non-fork CI run so the downstream
+ # mise oci build can --from a real registry. The base-sha tag
+ # is a cache key (see scripts/dogfood/compute-base-sha.sh) so
+ # commits that don't change base inputs reuse the previous
+ # build.
+ push: true
+ tags: |
+ ghcr.io/coder/oss-dogfood-base:${{ matrix.image-version }}-${{ steps.shas.outputs.base_sha }}
+ ghcr.io/coder/oss-dogfood-base:${{ matrix.image-version }}-${{ steps.docker-tag-name.outputs.tag }}
+
+ - name: Install mise tools
+ if: matrix.image-version != 'nix' && !github.event.pull_request.head.repo.fork
+ # `mise oci build` packages already-installed tools into OCI
+ # layers; it does not install them. Run `mise install` first so
+ # the tools land in MISE_DATA_DIR on the runner.
+ # github_token raises aqua's API quota during tool installs.
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ # --locked refuses to resolve URLs at install time and forces
+ # the runner to consume what mise.lock already committed,
+ # so a forgotten lockfile entry fails CI instead of silently
+ # being added on next run.
+ mise install --yes --locked
+ # Put mise's shims dir on PATH for subsequent steps so
+ # `mise oci push --tool crane` can find crane (and any other
+ # mise-managed binary it shells out to).
+ echo "$HOME/.local/share/mise/shims" >> "$GITHUB_PATH"
+
+ - name: Build mise oci layer
+ if: matrix.image-version != 'nix' && !github.event.pull_request.head.repo.fork
+ env:
+ IMAGE_VERSION: ${{ matrix.image-version }}
+ BASE_SHA: ${{ steps.shas.outputs.base_sha }}
+ FINAL_SHA: ${{ steps.shas.outputs.final_sha }}
+ # --output makes the OCI layout location explicit so the later
+ # `mise oci push --image-dir` steps point at the right path even
+ # if mise oci's default ever changes (it's experimental).
+ run: |
+ mise oci build \
+ --from "ghcr.io/coder/oss-dogfood-base:${IMAGE_VERSION}-${BASE_SHA}" \
+ --tag "codercom/oss-dogfood:${FINAL_SHA}-${IMAGE_VERSION}" \
+ --output ./mise-oci
+
+ # Load the OCI layout into the local Docker daemon so the next
+ # step can `docker run` it. crane lacks a direct OCI-layout-to-
+ # daemon command, but its built-in registry server gives us a
+ # simple two-hop path with no extra dependencies.
+ - name: Load mise oci image into Docker daemon
+ if: matrix.image-version != 'nix' && !github.event.pull_request.head.repo.fork
+ env:
+ IMAGE_VERSION: ${{ matrix.image-version }}
+ run: |
+ set -euo pipefail
+ crane registry serve --address localhost:5000 &
+ reg_pid=$!
+ trap 'kill $reg_pid 2>/dev/null || true' EXIT
+ for _ in 1 2 3 4 5; do
+ curl -sf http://localhost:5000/v2/ >/dev/null && break
+ sleep 1
+ done
+ crane push ./mise-oci "localhost:5000/dogfood-test:${IMAGE_VERSION}"
+ docker pull "localhost:5000/dogfood-test:${IMAGE_VERSION}"
+ docker tag "localhost:5000/dogfood-test:${IMAGE_VERSION}" "dogfood-test:${IMAGE_VERSION}"
+
+ # Validate the dogfood image's tooling by running make gen, fmt,
+ # lint, and a fat build inside it. Failures here block the
+ # Docker Hub push below so broken images never reach workspaces.
+ - name: Test image tooling
+ if: matrix.image-version != 'nix' && !github.event.pull_request.head.repo.fork
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: ./scripts/dogfood_test_image.sh "dogfood-test:${{ matrix.image-version }}"
- - name: Build and push Ubuntu 26.04 image
- uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.17.0
- with:
- project: b4q6ltmpzh
- token: ${{ secrets.DEPOT_TOKEN }}
- buildx-fallback: true
- # Context is the repo root so the Dockerfile can COPY the
- # project mise.toml that the image installs from. The
- # github_token secret raises aqua's GitHub API quota during
- # `mise install`.
- context: "{{defaultContext}}"
- file: dogfood/coder/ubuntu-26.04/Dockerfile
- secrets: |
- github_token=${{ secrets.GITHUB_TOKEN }}
- pull: true
- save: true
- push: ${{ github.ref == 'refs/heads/main' }}
- tags: "codercom/oss-dogfood:${{ steps.docker-tag-name.outputs.tag }},codercom/oss-dogfood:26.04"
- if: matrix.image-version == '26.04'
+ - name: Push final Ubuntu 22.04 image
+ if: matrix.image-version == '22.04' && github.ref == 'refs/heads/main'
+ env:
+ FINAL_SHA: ${{ steps.shas.outputs.final_sha }}
+ DOCKER_TAG: ${{ steps.docker-tag-name.outputs.tag }}
+ # --image-dir points at the OCI layout written by the previous
+ # `mise oci build` step. Without it, `mise oci push` rebuilds
+ # from mise.toml and forgets the --from base. --tool crane
+ # forces the registry client mise oci shells out to, so we
+ # don't drift between the apt-shipped skopeo on whatever runner
+ # image we land on.
+ # TODO: move the `latest` tag to 26.04 soon. we don't want to
+ # transition it immediately because that would make workspaces
+ # switch to it automatically without any grace period.
+ run: |
+ set -euo pipefail
+ for tag in "${FINAL_SHA}-22.04" "$DOCKER_TAG" 22.04 latest; do
+ mise oci push --tool crane --image-dir ./mise-oci "codercom/oss-dogfood:$tag"
+ done
+
+ - name: Push final Ubuntu 26.04 image
+ if: matrix.image-version == '26.04' && github.ref == 'refs/heads/main'
+ env:
+ FINAL_SHA: ${{ steps.shas.outputs.final_sha }}
+ DOCKER_TAG: ${{ steps.docker-tag-name.outputs.tag }}
+ run: |
+ set -euo pipefail
+ for tag in "${FINAL_SHA}-26.04" "$DOCKER_TAG" 26.04; do
+ mise oci push --tool crane --image-dir ./mise-oci "codercom/oss-dogfood:$tag"
+ done
- name: Build and push vscode-coder image
uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.17.0
@@ -201,59 +343,8 @@ jobs:
env:
DOCKER_TAG: ${{ steps.docker-tag-name.outputs.tag }}
- # Validate that the Ubuntu dogfood images contain working tooling.
- # Failures here block template deployment (deploy_template).
- test_image:
- needs: build_image
- strategy:
- fail-fast: false
- matrix:
- image-version: ["22.04", "26.04"]
- runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
- steps:
- - name: Harden Runner
- uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2.17.0
- with:
- egress-policy: audit
-
- - name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- with:
- fetch-depth: 1
- persist-credentials: false
-
- - name: Set up Depot CLI
- uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.7.1
-
- - name: Set up Docker Buildx
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
-
- # Near-instant cache hit from build_image; loads into local daemon
- # without pushing to a registry.
- - name: Load dogfood image from Depot cache
- uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.17.0
- with:
- project: b4q6ltmpzh
- token: ${{ secrets.DEPOT_TOKEN }}
- buildx-fallback: true
- context: "{{defaultContext}}"
- file: dogfood/coder/ubuntu-${{ matrix.image-version }}/Dockerfile
- secrets: |
- github_token=${{ secrets.GITHUB_TOKEN }}
- pull: true
- load: true
- push: false
- tags: "dogfood-test:${{ matrix.image-version }}"
-
- - name: Test image tooling
- run: ./scripts/dogfood_test_image.sh "dogfood-test:${{ matrix.image-version }}"
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-
deploy_template:
- needs:
- - build_image
- - test_image
+ needs: build_image
runs-on: ubuntu-latest
permissions:
# Necessary for GCP authentication (https://github.com/google-github-actions/setup-gcloud#usage)
diff --git a/.gitignore b/.gitignore
index 65dd97caf70e5..28e8f26c27596 100644
--- a/.gitignore
+++ b/.gitignore
@@ -96,6 +96,15 @@ __debug_bin*
# Local agent configuration
AGENTS.local.md
+# mise local overrides
+mise.local.toml
+.mise.local.toml
+mise.*.local.toml
+.mise.*.local.toml
+
+# `mise oci build` writes its OCI image layout here by default.
+mise-oci/
+
/.env
# Ignore plans written by AI agents.
diff --git a/Makefile b/Makefile
index 5b8af71bb22bb..3d92ff40dfea3 100644
--- a/Makefile
+++ b/Makefile
@@ -1636,12 +1636,6 @@ else
endif
.PHONY: test-e2e
-dogfood/coder/nix.hash: flake.nix flake.lock
- sha256sum flake.nix flake.lock >./dogfood/coder/nix.hash
-
-dogfood/coder/mise.hash: mise.toml mise.lock
- sha256sum mise.toml mise.lock >./dogfood/coder/mise.hash
-
# Count the number of test databases created per test package.
count-test-databases:
PGPASSWORD=postgres psql -h localhost -U postgres -d coder_testing -P pager=off -c 'SELECT test_package, count(*) as count from test_databases GROUP BY test_package ORDER BY count DESC'
diff --git a/dogfood/coder/Makefile b/dogfood/coder/Makefile
index 48693019fc3c2..ab7000a795d74 100644
--- a/dogfood/coder/Makefile
+++ b/dogfood/coder/Makefile
@@ -3,62 +3,79 @@
# tag names.
build_tag ?= $(shell git rev-parse --abbrev-ref HEAD | sed "s/\\//-/")
-# The Dockerfiles consume the repo root as build context so they can
-# reach the project mise.toml. Each variant still tracks its own
-# files/ tree under dogfood/coder/ubuntu-/.
+# The base Dockerfile consumes the repo root as build context so it can
+# reach the distro-specific files/ tree and configure-chrome-flags.sh
+# under dogfood/coder/ubuntu-/.
REPO_ROOT := $(shell git rev-parse --show-toplevel)
-# Mise's aqua backend exhausts GitHub's unauthenticated API quota
-# quickly. Plumb a token through to the mise install layer when one
-# is available. Two equivalent ways to supply it:
-# GITHUB_TOKEN=ghp_... - taken straight from the environment
-# (matches GitHub Actions, where
-# secrets.GITHUB_TOKEN is auto-provided)
-# GITHUB_TOKEN_FILE=/path - read the token from a file
-# If neither is set the build still runs but may hit 403s.
-ifneq ($(GITHUB_TOKEN_FILE),)
-docker_secret_arg := --secret id=github_token,src="$(GITHUB_TOKEN_FILE)"
-else ifneq ($(GITHUB_TOKEN),)
-docker_secret_arg := --secret id=github_token,env=GITHUB_TOKEN
+# Pick a container runtime. On macOS we prefer Apple's `container` CLI
+# when present (it produces a Linux VM-backed amd64 image without
+# Docker Desktop); otherwise fall back to docker. Linux always uses
+# docker.
+OS := $(shell uname -s)
+ifeq ($(OS),Darwin)
+ CONTAINER_RUNTIME ?= $(shell command -v container >/dev/null 2>&1 && echo container || echo docker)
+else
+ CONTAINER_RUNTIME ?= docker
endif
+# Apple's `container` defaults to the host arch; the dogfood image is
+# amd64-only, so pin it.
+ifeq ($(CONTAINER_RUNTIME),container)
+ PLATFORM_ARG := --platform linux/amd64
+else
+ PLATFORM_ARG :=
+endif
+
+ifeq ($(OS),Linux)
+ # `mise oci build` packages already-installed tools; the install
+ # has to run first. The macOS wrapper does this inside the
+ # container; on Linux we chain it here.
+ MISE_OCI := mise install --yes && MISE_EXPERIMENTAL=1 mise oci
+else
+ MISE_OCI := CONTAINER_RUNTIME=$(CONTAINER_RUNTIME) $(REPO_ROOT)/scripts/dogfood/mise-oci-wrapper.sh
+endif
+
+.PHONY: build build-ubuntu-22.04 build-ubuntu-26.04 \
+ build-base-ubuntu-22.04 build-base-ubuntu-26.04 \
+ update-keys update-keys-ubuntu-22.04 update-keys-ubuntu-26.04
+
build: build-ubuntu-22.04 build-ubuntu-26.04
-.PHONY: build
-build-ubuntu-22.04:
- DOCKER_BUILDKIT=1 docker build \
- -f dogfood/coder/ubuntu-22.04/Dockerfile \
- -t "codercom/oss-dogfood:22.04-$(build_tag)" \
- $(docker_secret_arg) \
+# Caveat: `build-ubuntu-*` requires the base image to be pullable from a
+# registry that `mise oci`'s HTTPS client can reach (ghcr.io, a local
+# `registry:2` sidecar, etc.). `--from coderdev/oss-dogfood-base:*-local`
+# only resolves when a registry mirror is set up alongside; without it,
+# `mise oci build` fails because the wrapper container cannot see the
+# host's local image store. The `build-base-ubuntu-*` targets on their
+# own work end to end without any registry. See
+# scripts/dogfood/mise-oci-wrapper.sh for the full story.
+build-base-ubuntu-22.04:
+ $(CONTAINER_RUNTIME) build $(PLATFORM_ARG) \
+ -f "$(REPO_ROOT)/dogfood/coder/ubuntu-22.04/Dockerfile.base" \
+ -t "coderdev/oss-dogfood-base:22.04-local" \
"$(REPO_ROOT)"
-.PHONY: build-ubuntu-22.04
-build-ubuntu-26.04:
- DOCKER_BUILDKIT=1 docker build \
- -f dogfood/coder/ubuntu-26.04/Dockerfile \
- -t "codercom/oss-dogfood:26.04-$(build_tag)" \
- $(docker_secret_arg) \
+build-base-ubuntu-26.04:
+ $(CONTAINER_RUNTIME) build $(PLATFORM_ARG) \
+ -f "$(REPO_ROOT)/dogfood/coder/ubuntu-26.04/Dockerfile.base" \
+ -t "coderdev/oss-dogfood-base:26.04-local" \
"$(REPO_ROOT)"
-.PHONY: build-ubuntu-26.04
-
-push: push-ubuntu-22.04 push-ubuntu-26.04
-.PHONY: push
-push-ubuntu-22.04: build-ubuntu-22.04
- docker push ${build_tag}
-.PHONY: push-ubuntu-22.04
+build-ubuntu-22.04: build-base-ubuntu-22.04
+ $(MISE_OCI) build \
+ --from "coderdev/oss-dogfood-base:22.04-local" \
+ --tag "codercom/oss-dogfood:22.04-$(build_tag)"
-push-ubuntu-26.04: build-ubuntu-26.04
- docker push ${build_tag}
-.PHONY: push-ubuntu-26.04
+build-ubuntu-26.04: build-base-ubuntu-26.04
+ $(MISE_OCI) build \
+ --from "coderdev/oss-dogfood-base:26.04-local" \
+ --tag "codercom/oss-dogfood:26.04-$(build_tag)"
update-keys: update-keys-ubuntu-22.04 update-keys-ubuntu-26.04
-.PHONY: update-keys
update-keys-ubuntu-22.04:
./ubuntu-22.04/update-keys.sh
-.PHONY: update-keys-ubuntu-22.04
update-keys-ubuntu-26.04:
./ubuntu-26.04/update-keys.sh
-.PHONY: update-keys-ubuntu-26.04
diff --git a/dogfood/coder/main.tf b/dogfood/coder/main.tf
index e013a8560c85d..81475418b9a18 100644
--- a/dogfood/coder/main.tf
+++ b/dogfood/coder/main.tf
@@ -687,10 +687,41 @@ resource "coder_script" "install-deps" {
coder exp sync want install-deps git-clone
coder exp sync start install-deps
+ # Seed a user-owned mise trust config on the persistent home
+ # volume. The image ships /etc/mise/conf.d/00-coder-trust.toml as
+ # a fallback, but mise's `trusted_config_paths` setting doesn't
+ # merge across config layers, so anything the user adds to their
+ # own file would otherwise replace the system fallback. Writing
+ # this file once (if-absent) seeds the defaults under
+ # ~/.config/mise/conf.d/ where the user can edit it and the edits
+ # survive workspace restart.
+ TRUST_FILE="$HOME/.config/mise/conf.d/00-coder-trust.toml"
+ if [ ! -f "$TRUST_FILE" ]; then
+ mkdir -p "$(dirname "$TRUST_FILE")"
+ cat > "$TRUST_FILE" <<'TRUST'
+ # mise trust paths for the dogfood workspace. Edit to add your own
+ # paths; this file lives on the persistent home volume so changes
+ # survive workspace restart. The install-deps coder_script only
+ # writes this file when it's absent.
+ [settings]
+ trusted_config_paths = [
+ "/home/coder/coder",
+ "/etc/mise",
+ ]
+ TRUST
+ fi
+
# Install playwright dependencies
# We want to use the playwright version from site/package.json
cd "${local.repo_dir}" && make clean
cd "${local.repo_dir}/site" && pnpm install
+
+ # Two playwright installs: site/'s @playwright/test and
+ # @playwright/mcp@0.0.75 bundle different playwright-core versions
+ # with different chromium revisions, and both are used at runtime
+ # (site tests + the claude-code/codex MCP servers below).
+ cd "${local.repo_dir}/site" && pnpm exec playwright install chromium
+ npx --yes --package=@playwright/mcp@0.0.75 playwright-core install --no-shell chromium
EOT
}
@@ -823,13 +854,10 @@ data "docker_registry_image" "dogfood" {
resource "docker_image" "dogfood" {
name = "${local.image_tags[data.coder_parameter.image_type.value]}@${data.docker_registry_image.dogfood.sha256_digest}"
+ # CI rebuilds and pushes when any baked-in input changes, so the
+ # digest captures every effective change on its own.
pull_triggers = [
data.docker_registry_image.dogfood.sha256_digest,
- sha1(join("", [for f in fileset(path.module, "files/*") : filesha1(f)])),
- filesha1("ubuntu-22.04/Dockerfile"),
- filesha1("ubuntu-26.04/Dockerfile"),
- filesha1("nix.hash"),
- filesha1("mise.hash"),
]
keep_locally = true
}
@@ -969,7 +997,7 @@ module "claude-code" {
"mcpServers": {
"playwright": {
"command": "npx",
- "args": ["--", "@playwright/mcp@latest", "--headless", "--isolated", "--no-sandbox"]
+ "args": ["--", "@playwright/mcp@0.0.75", "--headless", "--isolated", "--no-sandbox"]
}
}
}
@@ -1000,7 +1028,7 @@ module "codex" {
mcp = <<-EOT
[mcp_servers.playwright]
command = "npx"
- args = ["--", "@playwright/mcp@latest", "--headless", "--isolated", "--no-sandbox"]
+ args = ["--", "@playwright/mcp@0.0.75", "--headless", "--isolated", "--no-sandbox"]
type = "stdio"
EOT
}
diff --git a/dogfood/coder/mise.hash b/dogfood/coder/mise.hash
deleted file mode 100644
index f8ccac7148cc8..0000000000000
--- a/dogfood/coder/mise.hash
+++ /dev/null
@@ -1,2 +0,0 @@
-b5226f4cb3256b5f67df1344f46968f7275b1b8309380506d25782168bab5622 mise.toml
-b5cf72024409932659abde978440fca1d01a75bb11f1476e2410f7d4b83aa9c0 mise.lock
diff --git a/dogfood/coder/nix.hash b/dogfood/coder/nix.hash
deleted file mode 100644
index a25b9709f4d78..0000000000000
--- a/dogfood/coder/nix.hash
+++ /dev/null
@@ -1,2 +0,0 @@
-f09cd2cbbcdf00f5e855c6ddecab6008d11d871dc4ca5e1bc90aa14d4e3a2cfd flake.nix
-0d2489a26d149dade9c57ba33acfdb309b38100ac253ed0c67a2eca04a187e37 flake.lock
diff --git a/dogfood/coder/ubuntu-22.04/Dockerfile b/dogfood/coder/ubuntu-22.04/Dockerfile.base
similarity index 79%
rename from dogfood/coder/ubuntu-22.04/Dockerfile
rename to dogfood/coder/ubuntu-22.04/Dockerfile.base
index e40f5f868a0b6..24d5e3afb5afd 100644
--- a/dogfood/coder/ubuntu-22.04/Dockerfile
+++ b/dogfood/coder/ubuntu-22.04/Dockerfile.base
@@ -38,6 +38,7 @@ RUN sed -i 's|http://archive.ubuntu.com/ubuntu/|http://mirrors.edge.kernel.org/u
bat \
bats \
bind9-dnsutils \
+ bison \
build-essential \
ca-certificates \
containerd.io \
@@ -50,6 +51,7 @@ RUN sed -i 's|http://archive.ubuntu.com/ubuntu/|http://mirrors.edge.kernel.org/u
fd-find \
file \
fish \
+ flex \
gettext-base \
git \
gnupg \
@@ -66,6 +68,8 @@ RUN sed -i 's|http://archive.ubuntu.com/ubuntu/|http://mirrors.edge.kernel.org/u
language-pack-en \
less \
libgbm-dev \
+ libicu-dev \
+ libreadline-dev \
libssl-dev \
lsb-release \
lsof \
@@ -93,10 +97,12 @@ RUN sed -i 's|http://archive.ubuntu.com/ubuntu/|http://mirrors.edge.kernel.org/u
tmux \
traceroute \
unzip \
+ uuid-dev \
vim \
wget \
xauth \
zip \
+ zlib1g-dev \
zsh \
zstd && \
# Delete package cache to avoid consuming space in layer
@@ -183,45 +189,28 @@ RUN install --directory --owner=coder --group=coder --mode=0755 "${MISE_INSTALL_
test -x /usr/local/bin/mise && \
sudo --login --user=coder /bin/bash -lc 'set -euo pipefail && mise_bin="$(readlink --canonicalize /usr/local/bin/mise)" && test -w "$(dirname "$mise_bin")" && /usr/local/bin/mise --version && /usr/local/bin/mise self-update --help >/dev/null && /usr/local/bin/mise upgrade --help >/dev/null'
-# Trusted paths skip mise's per-config trust prompt for the baked-in
-# system config and the coder repo when cloned at the canonical
-# /home/coder/coder location. Other repos a user clones still get
-# the one-time `mise trust` prompt; pre-trusting all of /home/coder
-# would let any mise.toml under the home dir auto-run [hooks]/[tasks].
-ENV MISE_DATA_DIR=/home/coder/.local/share/mise \
- MISE_TRUSTED_CONFIG_PATHS=/home/coder/coder:/etc/mise
+ENV MISE_DATA_DIR=/home/coder/.local/share/mise
-# Bake the project manifest in as mise's system config and ship
-# the lockfile alongside it so mise verifies download checksums
-# during install. We do NOT override MISE_GLOBAL_CONFIG_FILE; that
-# would re-target `mise use --global` away from the user's
-# ~/.config/mise/config.toml (on the home volume) into this
-# image-only path, breaking the workflow.
-#
-# We pre-create /etc/mise as 0755 because COPY's implicitly-created
-# parent dirs inherit the --chmod, which would leave /etc/mise
-# without the `x` bit and unreachable to the coder user. We also
-# chown to coder so mise can write the temp lockfile it uses for
-# atomic rename when updating /etc/mise/mise.lock during installs.
-RUN install --directory --owner=coder --group=coder --mode=0755 /etc/mise
-COPY --chown=coder:coder --chmod=0644 mise.toml /etc/mise/config.toml
-COPY --chown=coder:coder --chmod=0644 mise.lock /etc/mise/mise.lock
+# Bake a system fallback for trusted_config_paths so the canonical
+# /home/coder/coder repo and the mise-oci-synthesized /etc/mise/config.toml
+# are trusted without a per-config prompt. The workspace template
+# (dogfood/coder/main.tf install-deps coder_script) seeds a matching
+# user-owned ~/.config/mise/conf.d/00-coder-trust.toml on workspace
+# start, which the user can edit to add their own paths; that file
+# lives on the persistent home volume and overrides this fallback.
+RUN install --directory --mode=0755 /etc/mise /etc/mise/conf.d
+COPY --chmod=0644 <<'EOF' /etc/mise/conf.d/00-coder-trust.toml
+[settings]
+trusted_config_paths = [
+ "/home/coder/coder",
+ "/etc/mise",
+]
+EOF
-# Pre-install tools into /opt/mise/data so they survive the home
-# volume's copy-on-first-mount. MISE_SHARED_INSTALL_DIRS (set below)
-# exposes them at runtime; MISE_DATA_DIR stays on the home volume.
-# github_token authenticates aqua's API calls (optional secret).
+# Reserve the mount_point declared in mise.toml [oci]. The path is
+# duplicated below in MISE_SHARED_INSTALL_DIRS and PATH; if it ever
+# changes, update all three plus mise.toml.
RUN install --directory --owner=coder --group=coder --mode=0755 /opt/mise /opt/mise/data
-RUN --mount=type=secret,id=github_token,required=false \
- gh_token="$(cat /run/secrets/github_token 2>/dev/null || true)" && \
- sudo --user=coder env \
- "MISE_DATA_DIR=/opt/mise/data" \
- "MISE_TRUSTED_CONFIG_PATHS=$MISE_TRUSTED_CONFIG_PATHS" \
- "GITHUB_TOKEN=$gh_token" \
- /usr/local/bin/mise install --yes && \
- PATH="/opt/mise/data/shims:$PATH" MISE_DATA_DIR=/opt/mise/data pnpm dlx playwright@1.47.0 install --with-deps chromium && \
- rm -rf /opt/mise/data/cache /opt/mise/data/downloads && \
- apt-get clean && rm -rf /var/lib/apt/lists/*
# Install Homebrew as the coder user so the supported Linux prefix remains
# writable after the image build.
@@ -229,10 +218,12 @@ RUN sudo --login --user=coder env NONINTERACTIVE=1 CI=1 /bin/bash -lc 'set -euo
test -x /home/linuxbrew/.linuxbrew/bin/brew && \
sudo --login --user=coder /bin/bash -lc '/home/linuxbrew/.linuxbrew/bin/brew --version'
-# Adjust OpenSSH config
+# Adjust OpenSSH config and drop the apt lists / cache that survived
+# the package installs above. No later step in this image needs apt.
RUN echo "PermitUserEnvironment yes" >>/etc/ssh/sshd_config && \
echo "X11Forwarding yes" >>/etc/ssh/sshd_config && \
- echo "X11UseLocalhost no" >>/etc/ssh/sshd_config
+ echo "X11UseLocalhost no" >>/etc/ssh/sshd_config && \
+ apt-get clean && rm -rf /var/lib/apt/lists/*
USER coder
diff --git a/dogfood/coder/ubuntu-26.04/Dockerfile b/dogfood/coder/ubuntu-26.04/Dockerfile.base
similarity index 80%
rename from dogfood/coder/ubuntu-26.04/Dockerfile
rename to dogfood/coder/ubuntu-26.04/Dockerfile.base
index 47f66f0174d1c..84be3b5024f5e 100644
--- a/dogfood/coder/ubuntu-26.04/Dockerfile
+++ b/dogfood/coder/ubuntu-26.04/Dockerfile.base
@@ -37,6 +37,7 @@ RUN sed -i 's|http://archive.ubuntu.com/ubuntu/|http://mirrors.edge.kernel.org/u
bat \
bats \
bind9-dnsutils \
+ bison \
build-essential \
ca-certificates \
containerd.io \
@@ -49,6 +50,7 @@ RUN sed -i 's|http://archive.ubuntu.com/ubuntu/|http://mirrors.edge.kernel.org/u
fd-find \
file \
fish \
+ flex \
gettext-base \
git \
gnupg \
@@ -65,6 +67,8 @@ RUN sed -i 's|http://archive.ubuntu.com/ubuntu/|http://mirrors.edge.kernel.org/u
language-pack-en \
less \
libgbm-dev \
+ libicu-dev \
+ libreadline-dev \
libssl-dev \
lsb-release \
lsof \
@@ -92,10 +96,12 @@ RUN sed -i 's|http://archive.ubuntu.com/ubuntu/|http://mirrors.edge.kernel.org/u
tmux \
traceroute \
unzip \
+ uuid-dev \
vim \
wget \
xauth \
zip \
+ zlib1g-dev \
zsh \
zstd && \
# Keep Docker's engine, CLI, runtime, and plugins on the versions selected by
@@ -193,45 +199,28 @@ RUN install --directory --owner=coder --group=coder --mode=0755 "${MISE_INSTALL_
test -x /usr/local/bin/mise && \
sudo --login --user=coder /bin/bash -lc 'set -euo pipefail && mise_bin="$(readlink --canonicalize /usr/local/bin/mise)" && test -w "$(dirname "$mise_bin")" && /usr/local/bin/mise --version && /usr/local/bin/mise self-update --help >/dev/null && /usr/local/bin/mise upgrade --help >/dev/null'
-# Trusted paths skip mise's per-config trust prompt for the baked-in
-# system config and the coder repo when cloned at the canonical
-# /home/coder/coder location. Other repos a user clones still get
-# the one-time `mise trust` prompt; pre-trusting all of /home/coder
-# would let any mise.toml under the home dir auto-run [hooks]/[tasks].
-ENV MISE_DATA_DIR=/home/coder/.local/share/mise \
- MISE_TRUSTED_CONFIG_PATHS=/home/coder/coder:/etc/mise
+ENV MISE_DATA_DIR=/home/coder/.local/share/mise
-# Bake the project manifest in as mise's system config and ship
-# the lockfile alongside it so mise verifies download checksums
-# during install. We do NOT override MISE_GLOBAL_CONFIG_FILE; that
-# would re-target `mise use --global` away from the user's
-# ~/.config/mise/config.toml (on the home volume) into this
-# image-only path, breaking the workflow.
-#
-# We pre-create /etc/mise as 0755 because COPY's implicitly-created
-# parent dirs inherit the --chmod, which would leave /etc/mise
-# without the `x` bit and unreachable to the coder user. We also
-# chown to coder so mise can write the temp lockfile it uses for
-# atomic rename when updating /etc/mise/mise.lock during installs.
-RUN install --directory --owner=coder --group=coder --mode=0755 /etc/mise
-COPY --chown=coder:coder --chmod=0644 mise.toml /etc/mise/config.toml
-COPY --chown=coder:coder --chmod=0644 mise.lock /etc/mise/mise.lock
+# Bake a system fallback for trusted_config_paths so the canonical
+# /home/coder/coder repo and the mise-oci-synthesized /etc/mise/config.toml
+# are trusted without a per-config prompt. The workspace template
+# (dogfood/coder/main.tf install-deps coder_script) seeds a matching
+# user-owned ~/.config/mise/conf.d/00-coder-trust.toml on workspace
+# start, which the user can edit to add their own paths; that file
+# lives on the persistent home volume and overrides this fallback.
+RUN install --directory --mode=0755 /etc/mise /etc/mise/conf.d
+COPY --chmod=0644 <<'EOF' /etc/mise/conf.d/00-coder-trust.toml
+[settings]
+trusted_config_paths = [
+ "/home/coder/coder",
+ "/etc/mise",
+]
+EOF
-# Pre-install tools into /opt/mise/data so they survive the home
-# volume's copy-on-first-mount. MISE_SHARED_INSTALL_DIRS (set below)
-# exposes them at runtime; MISE_DATA_DIR stays on the home volume.
-# github_token authenticates aqua's API calls (optional secret).
+# Reserve the mount_point declared in mise.toml [oci]. The path is
+# duplicated below in MISE_SHARED_INSTALL_DIRS and PATH; if it ever
+# changes, update all three plus mise.toml.
RUN install --directory --owner=coder --group=coder --mode=0755 /opt/mise /opt/mise/data
-RUN --mount=type=secret,id=github_token,required=false \
- gh_token="$(cat /run/secrets/github_token 2>/dev/null || true)" && \
- sudo --user=coder env \
- "MISE_DATA_DIR=/opt/mise/data" \
- "MISE_TRUSTED_CONFIG_PATHS=$MISE_TRUSTED_CONFIG_PATHS" \
- "GITHUB_TOKEN=$gh_token" \
- /usr/local/bin/mise install --yes && \
- PATH="/opt/mise/data/shims:$PATH" MISE_DATA_DIR=/opt/mise/data pnpm dlx playwright@1.47.0 install --with-deps chromium && \
- rm -rf /opt/mise/data/cache /opt/mise/data/downloads && \
- apt-get clean && rm -rf /var/lib/apt/lists/*
# Install Homebrew as the coder user so the supported Linux prefix remains
# writable after the image build.
@@ -239,10 +228,12 @@ RUN sudo --login --user=coder env NONINTERACTIVE=1 CI=1 /bin/bash -lc 'set -euo
test -x /home/linuxbrew/.linuxbrew/bin/brew && \
sudo --login --user=coder /bin/bash -lc '/home/linuxbrew/.linuxbrew/bin/brew --version'
-# Adjust OpenSSH config
+# Adjust OpenSSH config and drop the apt lists / cache that survived
+# the package installs above. No later step in this image needs apt.
RUN echo "PermitUserEnvironment yes" >>/etc/ssh/sshd_config && \
echo "X11Forwarding yes" >>/etc/ssh/sshd_config && \
- echo "X11UseLocalhost no" >>/etc/ssh/sshd_config
+ echo "X11UseLocalhost no" >>/etc/ssh/sshd_config && \
+ apt-get clean && rm -rf /var/lib/apt/lists/*
USER coder
diff --git a/flake.nix b/flake.nix
index 204d91e579f0e..2465f94fac361 100644
--- a/flake.nix
+++ b/flake.nix
@@ -198,7 +198,6 @@
pango
pixman
pkg-config
- playwright-driver.browsers
pnpm
postgresql_16
proto_gen_go_1_30
@@ -278,16 +277,6 @@
'';
};
in
- # "Keep in mind that you need to use the same version of playwright in your node playwright project as in your nixpkgs, or else playwright will try to use browsers versions that aren't installed!"
- # - https://nixos.wiki/wiki/Playwright
- assert pkgs.lib.assertMsg
- (
- (pkgs.lib.importJSON ./site/package.json).devDependencies."@playwright/test"
- == pkgs.playwright-driver.version
- )
- "There is a mismatch between the playwright versions in the ./nix.flake (${pkgs.playwright-driver.version}) and the ./site/package.json (${
- (pkgs.lib.importJSON ./site/package.json).devDependencies."@playwright/test"
- }) file. Please make sure that they use the exact same version.";
rec {
inherit formatter;
@@ -301,9 +290,6 @@
{
buildInputs = devShellPackages;
- PLAYWRIGHT_BROWSERS_PATH = pkgs.playwright-driver.browsers;
- PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS = true;
-
LOCALE_ARCHIVE =
with pkgs;
lib.optionalDrvAttr stdenv.isLinux "${glibcLocales}/lib/locale/locale-archive";
diff --git a/mise.lock b/mise.lock
index c63a3cf0140b0..7f96bd9e3cc5f 100644
--- a/mise.lock
+++ b/mise.lock
@@ -288,6 +288,54 @@ url = "https://github.com/sigstore/cosign/releases/download/v2.4.3/cosign-window
checksum = "sha256:a2ac24e197111c9430cb2a98f10a641164381afb83df036504868e4ea5720800"
url = "https://github.com/sigstore/cosign/releases/download/v2.4.3/cosign-windows-amd64.exe"
+[[tools.crane]]
+version = "0.21.6"
+backend = "aqua:google/go-containerregistry"
+
+[tools.crane."platforms.linux-arm64"]
+checksum = "sha256:6f61571ca0c2a5da27c2927fcb143255ccb2b74b8977dfcb44645b372ab0f951"
+url = "https://github.com/google/go-containerregistry/releases/download/v0.21.6/go-containerregistry_Linux_arm64.tar.gz"
+
+[tools.crane."platforms.linux-arm64-musl"]
+checksum = "sha256:6f61571ca0c2a5da27c2927fcb143255ccb2b74b8977dfcb44645b372ab0f951"
+url = "https://github.com/google/go-containerregistry/releases/download/v0.21.6/go-containerregistry_Linux_arm64.tar.gz"
+
+[tools.crane."platforms.linux-x64"]
+checksum = "sha256:7ebbdcd05b652345c1f5105f8475e518534b90d66f3bdb50017be63f426ea435"
+url = "https://github.com/google/go-containerregistry/releases/download/v0.21.6/go-containerregistry_Linux_x86_64.tar.gz"
+
+[tools.crane."platforms.linux-x64-baseline"]
+checksum = "sha256:7ebbdcd05b652345c1f5105f8475e518534b90d66f3bdb50017be63f426ea435"
+url = "https://github.com/google/go-containerregistry/releases/download/v0.21.6/go-containerregistry_Linux_x86_64.tar.gz"
+
+[tools.crane."platforms.linux-x64-musl"]
+checksum = "sha256:7ebbdcd05b652345c1f5105f8475e518534b90d66f3bdb50017be63f426ea435"
+url = "https://github.com/google/go-containerregistry/releases/download/v0.21.6/go-containerregistry_Linux_x86_64.tar.gz"
+
+[tools.crane."platforms.linux-x64-musl-baseline"]
+checksum = "sha256:7ebbdcd05b652345c1f5105f8475e518534b90d66f3bdb50017be63f426ea435"
+url = "https://github.com/google/go-containerregistry/releases/download/v0.21.6/go-containerregistry_Linux_x86_64.tar.gz"
+
+[tools.crane."platforms.macos-arm64"]
+checksum = "sha256:a124f297d1e63e8b6c63c2463e43565290d2fd074c1dadb5ca73d737bc7b2484"
+url = "https://github.com/google/go-containerregistry/releases/download/v0.21.6/go-containerregistry_Darwin_arm64.tar.gz"
+
+[tools.crane."platforms.macos-x64"]
+checksum = "sha256:f1e653737a1d6e8a412734d0ac25009e04eccec98853be2eb59b8c744dede834"
+url = "https://github.com/google/go-containerregistry/releases/download/v0.21.6/go-containerregistry_Darwin_x86_64.tar.gz"
+
+[tools.crane."platforms.macos-x64-baseline"]
+checksum = "sha256:f1e653737a1d6e8a412734d0ac25009e04eccec98853be2eb59b8c744dede834"
+url = "https://github.com/google/go-containerregistry/releases/download/v0.21.6/go-containerregistry_Darwin_x86_64.tar.gz"
+
+[tools.crane."platforms.windows-x64"]
+checksum = "sha256:fb78f814f68ab47266458f319ca7e642a303453ea25c8993a14eb9850c56e870"
+url = "https://github.com/google/go-containerregistry/releases/download/v0.21.6/go-containerregistry_Windows_x86_64.tar.gz"
+
+[tools.crane."platforms.windows-x64-baseline"]
+checksum = "sha256:fb78f814f68ab47266458f319ca7e642a303453ea25c8993a14eb9850c56e870"
+url = "https://github.com/google/go-containerregistry/releases/download/v0.21.6/go-containerregistry_Windows_x86_64.tar.gz"
+
[[tools.doctl]]
version = "1.158.0"
backend = "aqua:digitalocean/doctl"
@@ -734,6 +782,7 @@ url = "https://github.com/protocolbuffers/protobuf/releases/download/v23.4/proto
url = "https://github.com/protocolbuffers/protobuf/releases/download/v23.4/protoc-23.4-linux-aarch_64.zip"
[tools.protoc."platforms.linux-x64"]
+checksum = "blake3:b1d1a517cb9c8c3cbfc98c708f93e6d3bd8b3ce0e2db1ad8c1491ae8a4067ad2"
url = "https://github.com/protocolbuffers/protobuf/releases/download/v23.4/protoc-23.4-linux-x86_64.zip"
[tools.protoc."platforms.linux-x64-baseline"]
@@ -771,6 +820,7 @@ url = "https://github.com/protocolbuffers/protobuf-go/releases/download/v1.30.0/
url = "https://github.com/protocolbuffers/protobuf-go/releases/download/v1.30.0/protoc-gen-go.v1.30.0.linux.arm64.tar.gz"
[tools.protoc-gen-go."platforms.linux-x64"]
+checksum = "blake3:127ed3a8005b199a8451c258ea8fe8ae0f68dd01b4e52c21c881eb7f1d69a333"
url = "https://github.com/protocolbuffers/protobuf-go/releases/download/v1.30.0/protoc-gen-go.v1.30.0.linux.amd64.tar.gz"
[tools.protoc-gen-go."platforms.linux-x64-baseline"]
diff --git a/mise.toml b/mise.toml
index a09c86bc9479e..c7366deecd4f5 100644
--- a/mise.toml
+++ b/mise.toml
@@ -1,3 +1,7 @@
+# Keep in lockstep with MISE_VERSION in dogfood/coder/ubuntu-*/Dockerfile.base,
+# .github/workflows/dogfood.yaml, and scripts/dogfood/mise-oci-wrapper.sh.
+min_version = "2026.5.12"
+
[settings]
lockfile = true
@@ -28,6 +32,10 @@ protoc-gen-go = "1.30.0"
# Infrastructure, release, and lint CLIs.
"aqua:ahmetb/kubectx/kubens" = "0.9.4"
cosign = "2.4.3"
+# crane is the registry client `mise oci push` shells out to. Sourced
+# here so it travels with the rest of the mise toolset (one source of
+# truth, deterministic version, no apt drift across CI / wrapper).
+crane = "0.21.6"
golangci-lint = "1.64.8"
helm = "3.21.0"
kubectx = "0.9.4"
@@ -61,3 +69,16 @@ lazygit = "0.61.1"
[tools."go:github.com/coder/sqlc/cmd/sqlc"]
version = "337309bfb9524f38466a5090e310040fc7af0203"
install_env = { CGO_ENABLED = "1" }
+
+# Consumed by `mise oci build` to produce the dogfood image on top of
+# ghcr.io/coder/oss-dogfood-base. The `from` and `--tag` fields are
+# overridden by CLI args at build time per distro; `mount_point`,
+# `user`, and `workdir` always apply.
+#
+# mount_point MUST match the path the base image reserves and exposes
+# via `MISE_SHARED_INSTALL_DIRS`. Both Dockerfile.base files hardcode
+# /opt/mise/data in their `install --directory`, ENV, and PATH lines.
+[oci]
+mount_point = "/opt/mise/data"
+user = "coder"
+workdir = "/home/coder"
diff --git a/scripts/dogfood/compute-base-sha.sh b/scripts/dogfood/compute-base-sha.sh
new file mode 100755
index 0000000000000..cf0659da5d46e
--- /dev/null
+++ b/scripts/dogfood/compute-base-sha.sh
@@ -0,0 +1,43 @@
+#!/usr/bin/env bash
+# Deterministic 12-char content hash of base-image inputs for a distro.
+# Used as a cache key for the ghcr.io/coder/oss-dogfood-base tag so
+# commits that don't touch the base inputs reuse the previous build.
+#
+# This is NOT a strict content address: the base Dockerfile still
+# pulls dynamic resources at build time (gh/buildx releases/latest,
+# chrome stable_current_amd64.deb, apt mirror state, sh.rustup.rs).
+# Two runs with identical checked-in files can still produce slightly
+# different bytes. That's acceptable here because the dynamic drift
+# is small and the cache-hit savings (no full base rebuild for a
+# typo-fix commit, doc change, mise.toml bump, etc.) is large.
+set -euo pipefail
+
+# 12 hex chars matches docker/OCI short-digest displays.
+HASH_LEN=12
+
+distro="${1:?usage: $0 <22.04|26.04>}"
+
+repo_root="$(git rev-parse --show-toplevel)"
+cd "$repo_root"
+
+paths=(
+ "dogfood/coder/ubuntu-${distro}/Dockerfile.base"
+ "dogfood/coder/ubuntu-${distro}/files"
+)
+if [ "$distro" = "22.04" ]; then
+ paths+=("dogfood/coder/ubuntu-${distro}/configure-chrome-flags.sh")
+fi
+
+# Skip editor turds; .swp / ~-files / dotfiles are noise for a build
+# hash. Include symlinks too: `COPY dogfood/coder/ubuntu-*/files /`
+# bakes their target paths into the image, so swapping a symlink
+# changes base content and must invalidate the cache key.
+find "${paths[@]}" \( -type f -o -type l \) \
+ ! -name '.*' \
+ ! -name '*.swp' \
+ ! -name '*~' \
+ -print0 |
+ LC_ALL=C sort -z |
+ xargs -0 sha256sum |
+ sha256sum |
+ cut -c"1-$HASH_LEN"
diff --git a/scripts/dogfood/compute-final-sha.sh b/scripts/dogfood/compute-final-sha.sh
new file mode 100755
index 0000000000000..d843399dd4f4b
--- /dev/null
+++ b/scripts/dogfood/compute-final-sha.sh
@@ -0,0 +1,20 @@
+#!/usr/bin/env bash
+# Deterministic 12-char content hash of (base inputs + mise inputs) for
+# a distro. Used as the primary tag for the dogfood image produced by
+# `mise oci build`, so re-running CI on an unchanged commit reuses the
+# previous tag. Same cache-key (not strict content address) semantics
+# as `compute-base-sha.sh`.
+set -euo pipefail
+
+# 12 hex chars; see comment in compute-base-sha.sh.
+HASH_LEN=12
+
+distro="${1:?usage: $0 <22.04|26.04>}"
+
+repo_root="$(git rev-parse --show-toplevel)"
+cd "$repo_root"
+
+base_sha="$("$repo_root/scripts/dogfood/compute-base-sha.sh" "$distro")"
+mise_hash="$(sha256sum mise.toml mise.lock | sha256sum | cut -c"1-$HASH_LEN")"
+
+printf '%s\n' "$base_sha-$mise_hash" | sha256sum | cut -c"1-$HASH_LEN"
diff --git a/scripts/dogfood/mise-oci-wrapper.sh b/scripts/dogfood/mise-oci-wrapper.sh
new file mode 100755
index 0000000000000..c5f0698ba7449
--- /dev/null
+++ b/scripts/dogfood/mise-oci-wrapper.sh
@@ -0,0 +1,109 @@
+#!/usr/bin/env bash
+# Local-only helper: runs `mise oci ...` inside a Linux container so
+# macOS and Windows developers don't need a local Linux VM or a host
+# install of mise. CI runs `mise oci` directly on its Linux runner; it
+# does not use this script.
+#
+# Builds a small Debian-based wrapper image with the mise binary on
+# first invocation, then reuses it. Pinning to the same `MISE_VERSION`
+# baked into `Dockerfile.base` avoids depending on jdxcode/mise Docker
+# Hub publication cadence, which lags upstream GitHub releases by days.
+#
+# `oci build --from ` requires to be a registry-resolvable
+# reference; the host's local Docker daemon images are not visible
+# inside the wrapper. See the Makefile comment.
+#
+# Honors CONTAINER_RUNTIME=docker (default) or CONTAINER_RUNTIME=container
+# (Apple's `container` CLI on macOS).
+set -euo pipefail
+
+# Keep MISE_VERSION + MISE_SHA256 in lockstep with the same vars in
+# .github/workflows/dogfood.yaml and dogfood/coder/ubuntu-*/Dockerfile.base.
+# A `min_version` check in mise.toml catches downgrades.
+MISE_VERSION="v2026.5.12"
+MISE_SHA256="a238972a3162d710b85b28c324372e96ca4e4b486c81fe78695000d9fbc77c48"
+# Bump the -rN suffix when the Dockerfile heredoc below changes
+# (mise version, apt packages, trust config, etc.) so cached wrapper
+# images get rebuilt automatically.
+WRAPPER_REVISION="r2"
+RUNTIME="${CONTAINER_RUNTIME:-docker}"
+WRAPPER_IMAGE="coderdev/mise-oci-wrapper:$MISE_VERSION-$WRAPPER_REVISION"
+
+# Mount the repo root rather than $PWD: `make -C dogfood/coder` invokes
+# the wrapper from dogfood/coder/, but the project mise.toml/mise.lock
+# `mise oci build` consumes live at the repo root.
+REPO_ROOT="$(git rev-parse --show-toplevel)"
+
+platform_arg=()
+if [ "$RUNTIME" = "container" ]; then
+ platform_arg=(--platform linux/amd64)
+fi
+
+# Build the wrapper image on first invocation. The tag includes the
+# mise version so a bump automatically invalidates the cache; the old
+# image becomes orphaned and the user can prune it manually.
+if ! "$RUNTIME" image inspect "$WRAPPER_IMAGE" >/dev/null 2>&1; then
+ echo "[$0] Building $WRAPPER_IMAGE (first-time setup)..." >&2
+ build_dir="$(mktemp -d)"
+ trap 'rm -rf "$build_dir"' EXIT
+ cat >"$build_dir/Dockerfile" < /etc/mise/conf.d/00-trust.toml
+DOCKERFILE
+ "$RUNTIME" build ${platform_arg[@]+"${platform_arg[@]}"} -t "$WRAPPER_IMAGE" "$build_dir"
+ rm -rf "$build_dir"
+ trap - EXIT
+fi
+
+token_arg=()
+if [ -n "${GITHUB_TOKEN:-}" ]; then
+ token_arg=(-e "GITHUB_TOKEN=$GITHUB_TOKEN")
+fi
+
+# Mount ~/.docker when present so crane can find registry creds.
+# Apple `container` CLI users without Docker Desktop won't have it;
+# local builds don't push, so the skip is fine.
+docker_config_arg=()
+if [ -d "$HOME/.docker" ]; then
+ docker_config_arg=(-v "$HOME/.docker:/root/.docker:ro")
+fi
+
+# `oci build` needs all mise tools installed so it can package them
+# into layers. `oci push` needs crane on PATH (mise oci shells out to
+# it). Both end up running `mise install` first; build installs every
+# tool, push only crane. The `export PATH=...` exposes mise's shims
+# dir so `which crane` succeeds when mise oci spawns it as a child.
+# Single quotes are intentional: $HOME and $@ expand inside the
+# container's `sh -c`, not in this script.
+# shellcheck disable=SC2016
+inner_cmd='mise oci "$@"'
+case "${1:-}" in
+build)
+ # shellcheck disable=SC2016
+ inner_cmd='mise install --yes && export PATH="$HOME/.local/share/mise/shims:$PATH" && mise oci "$@"'
+ ;;
+push)
+ # shellcheck disable=SC2016
+ inner_cmd='mise install --yes crane && export PATH="$HOME/.local/share/mise/shims:$PATH" && mise oci "$@"'
+ ;;
+esac
+
+exec "$RUNTIME" run --rm ${platform_arg[@]+"${platform_arg[@]}"} \
+ -v "$REPO_ROOT":/src -w /src \
+ ${docker_config_arg[@]+"${docker_config_arg[@]}"} \
+ -e MISE_EXPERIMENTAL=1 \
+ ${token_arg[@]+"${token_arg[@]}"} \
+ --entrypoint /bin/sh \
+ "$WRAPPER_IMAGE" \
+ -c "$inner_cmd" -- "$@"
diff --git a/scripts/dogfood_test_image.sh b/scripts/dogfood_test_image.sh
index 08360d7511fef..b7547937a391e 100755
--- a/scripts/dogfood_test_image.sh
+++ b/scripts/dogfood_test_image.sh
@@ -50,17 +50,21 @@ else
fi
# Helper: run a make target inside the image.
-# Caches are persisted in named Docker volumes so that subsequent steps (and
-# repeated local runs) reuse downloaded modules and compiled artifacts.
+#
+# Mounts /home/coder/ as a single named volume to mirror the dogfood
+# workspace template (dogfood/coder/main.tf), so caches (Go modules,
+# Go build, pnpm store, mise data, etc.) persist the same way they do
+# in real workspaces. Per-cache subpath volumes would come up
+# root-owned on first mount because Docker creates non-existent
+# subpaths root-owned; the home-level volume inherits coder:coder
+# from the image's existing /home/coder (`useradd --create-home`).
run_make() {
docker run --rm \
+ --volume coder-dogfood-home:/home/coder \
--volume "$(pwd)":/home/coder/coder \
--env GIT_CONFIG_COUNT=1 \
--env GIT_CONFIG_KEY_0=safe.directory \
--env GIT_CONFIG_VALUE_0=/home/coder/coder \
- --volume coder-dogfood-gomod:/home/coder/go/pkg/mod \
- --volume coder-dogfood-gobuild:/home/coder/.cache/go-build \
- --volume coder-dogfood-pnpm:/home/coder/.local/share/pnpm/store \
--workdir /home/coder/coder \
--network=host \
--env GITHUB_TOKEN \
diff --git a/scripts/update-flake.sh b/scripts/update-flake.sh
index 7007b6b001a5d..f89dd179df75f 100755
--- a/scripts/update-flake.sh
+++ b/scripts/update-flake.sh
@@ -37,6 +37,4 @@ echo "protoc-gen-go version: $PROTOC_GEN_GO_REV"
PROTOC_GEN_GO_SHA256=$(nix-prefetch-git https://github.com/protocolbuffers/protobuf-go --rev "$PROTOC_GEN_GO_REV" | jq -r .hash)
sed -i "s#\(sha256 = \"\)[^\"]*#\1${PROTOC_GEN_GO_SHA256}#" ./flake.nix
-make dogfood/coder/nix.hash
-
echo "Flake updated successfully!"
From dfd7ca3b98749d39396d078d8a4d00e3da3b805e Mon Sep 17 00:00:00 2001
From: Atif Ali
Date: Tue, 26 May 2026 18:29:10 +0500
Subject: [PATCH 007/249] docs: improve discoverability of automatic port
forwarding via Coder Desktop (#25675)
---
docs/admin/networking/port-forwarding.md | 22 ++++++++++++++-----
.../desktop/desktop-connect-sync.md | 8 ++++++-
docs/user-guides/desktop/index.md | 7 +++++-
docs/user-guides/workspace-access/index.md | 15 ++++++++++++-
.../workspace-access/port-forwarding.md | 8 +++++--
5 files changed, 49 insertions(+), 11 deletions(-)
diff --git a/docs/admin/networking/port-forwarding.md b/docs/admin/networking/port-forwarding.md
index 3c4e9777d0960..f5678403adb94 100644
--- a/docs/admin/networking/port-forwarding.md
+++ b/docs/admin/networking/port-forwarding.md
@@ -4,18 +4,28 @@ Port forwarding lets developers securely access processes on their Coder
workspace from a local machine. A common use case is testing web applications in
a browser.
-There are three ways to forward ports in Coder:
+There are four ways to forward ports in Coder:
-- The `coder port-forward` command
-- Dashboard
-- SSH
+| Method | Details |
+|:---------------------------------------|:---------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| [Coder Desktop](#coder-desktop) | Automatic port forwarding via VPN tunnel. All workspace ports are available at `workspace.coder:PORT` with no manual setup. Supports peer-to-peer connections. |
+| [CLI](#the-coder-port-forward-command) | Forwards specific TCP or UDP ports from the workspace to local ports. Supports peer-to-peer connections. |
+| [Dashboard](#dashboard) | Proxies traffic through the Coder control plane. |
+| [SSH](#ssh) | Forwards ports over an SSH connection. |
-The `coder port-forward` command is generally more performant than:
+Coder Desktop and `coder port-forward` are generally more performant than:
1. The Dashboard which proxies traffic through the Coder control plane versus
- peer-to-peer which is possible with the Coder CLI
+ peer-to-peer which is possible with the Coder CLI and Coder Desktop
1. `sshd` which does double encryption of traffic with both Wireguard and SSH
+## Coder Desktop
+
+[Coder Desktop](../../user-guides/desktop/index.md) provides automatic port forwarding to every service running in your workspace.
+Once Coder Connect is enabled, any port your application listens on is instantly accessible at `.coder:PORT` from your local machine, with no additional commands or configuration.
+
+This is the simplest option for most users. See the [Coder Desktop documentation](../../user-guides/desktop/index.md) for installation and setup.
+
## The `coder port-forward` command
This command can be used to forward TCP or UDP ports from the remote workspace
diff --git a/docs/user-guides/desktop/desktop-connect-sync.md b/docs/user-guides/desktop/desktop-connect-sync.md
index f6a45a598477f..5ea445c672d9e 100644
--- a/docs/user-guides/desktop/desktop-connect-sync.md
+++ b/docs/user-guides/desktop/desktop-connect-sync.md
@@ -19,7 +19,13 @@ You can also connect to the SSH server in your workspace using any SSH client, s
ssh your-workspace.coder
```
-Any services listening on ports in your workspace will be available on the same hostname. For example, you can access a web server on port `8080` by visiting `http://your-workspace.coder:8080` in your browser.
+### Automatic port forwarding
+
+Any services listening on ports in your workspace are automatically available on the same hostname, with no manual port forwarding required. For example, you can access a web server on port `8080` by visiting `http://your-workspace.coder:8080` in your browser.
+
+This works for all TCP ports. Start a service in your workspace and access it immediately from your local machine at `http://your-workspace.coder:PORT`.
+
+For other port forwarding methods (CLI, dashboard, SSH), see [Workspace Ports](../workspace-access/port-forwarding.md).
> [!NOTE]
> For Coder versions v2.21.3 and earlier: the Coder IDE extensions for VSCode and JetBrains create their own tunnel and do not utilize the Coder Connect tunnel to connect to workspaces.
diff --git a/docs/user-guides/desktop/index.md b/docs/user-guides/desktop/index.md
index 12bd664f173ce..bbcb657df637f 100644
--- a/docs/user-guides/desktop/index.md
+++ b/docs/user-guides/desktop/index.md
@@ -1,6 +1,9 @@
# Coder Desktop
-Coder Desktop provides seamless access to your remote workspaces through a native application. Connect to workspace services using simple hostnames like `myworkspace.coder`, launch applications with one click, and synchronize files between local and remote environments—all without installing a CLI or configuring manual port forwarding.
+Coder Desktop provides seamless access to your remote workspaces through a native application. Connect to workspace services using simple hostnames like `myworkspace.coder`, launch applications with one click, and synchronize files between local and remote environments, all without installing a CLI or configuring manual port forwarding.
+
+> [!TIP]
+> Coder Desktop provides **automatic port forwarding** to every service running in your workspace. Any port your application listens on is instantly accessible at `workspace-name.coder:PORT` with no manual setup required. For a comparison of all port forwarding methods, see [Workspace Ports](../workspace-access/port-forwarding.md).
## What You'll Need
@@ -21,6 +24,7 @@ Coder Desktop provides seamless access to your remote workspaces through a nativ
**Coder Connect**, the primary component of Coder Desktop, creates a secure tunnel to your Coder deployment, allowing you to:
- **Access workspaces directly**: Connect via `workspace-name.coder` hostnames
+- **Automatic port forwarding**: All workspace ports are available at `workspace-name.coder:PORT` with no configuration
- **Use any application**: SSH clients, browsers, IDEs work seamlessly
- **Sync files**: Bidirectional sync between local and remote directories
- **Work offline**: Edit files locally, sync when reconnected
@@ -196,3 +200,4 @@ If you encounter issues not covered here:
## Next Steps
- [Using Coder Connect and File Sync](./desktop-connect-sync.md)
+- [Compare port forwarding methods](../workspace-access/port-forwarding.md)
diff --git a/docs/user-guides/workspace-access/index.md b/docs/user-guides/workspace-access/index.md
index 05dca3beea407..da72459cbbd66 100644
--- a/docs/user-guides/workspace-access/index.md
+++ b/docs/user-guides/workspace-access/index.md
@@ -155,12 +155,25 @@ of tools for extending the capability of your workspace. If you have a request
for a new IDE or tool, please file an issue in our
[Modules repo](https://github.com/coder/registry/issues).
+## Coder Desktop
+
+[Coder Desktop](../desktop/index.md) is a native application that provides seamless access to your workspaces via a VPN tunnel. With Coder Desktop, you get:
+
+- **Automatic port forwarding**: All workspace ports are available at `workspace-name.coder:PORT` with no manual setup
+- **SSH access**: Connect with `ssh workspace-name.coder` using any SSH client
+- **File sync**: Bidirectional file synchronization between local and remote directories
+
+Coder Desktop is the recommended way to access workspace services for developers who want a seamless, native experience.
+
## Ports and Port forwarding
-You can manage listening ports on your workspace page through with the listening
+You can manage listening ports on your workspace page through the listening
ports window in the dashboard. These ports are often used to run internal
services or preview environments.
+> [!TIP]
+> For automatic access to all ports without manual configuration, use [Coder Desktop](../desktop/index.md).
+
You can also [share ports](./port-forwarding.md#sharing-ports) with other users,
or [port-forward](./port-forwarding.md#the-coder-port-forward-command) through
the CLI with `coder port forward`. Read more in the
diff --git a/docs/user-guides/workspace-access/port-forwarding.md b/docs/user-guides/workspace-access/port-forwarding.md
index 3bcfb1e2b5196..26843bcb936f0 100644
--- a/docs/user-guides/workspace-access/port-forwarding.md
+++ b/docs/user-guides/workspace-access/port-forwarding.md
@@ -17,8 +17,12 @@ There are multiple ways to forward ports in Coder:
## Coder Desktop
-[Coder Desktop](../desktop/index.md) provides seamless access to your remote workspaces, eliminating the need to install a CLI or manually configure port forwarding.
-Access all your ports at `.coder:PORT`.
+> [!TIP]
+> Coder Desktop is the recommended way to access workspace ports. It provides automatic port forwarding with no manual setup.
+
+[Coder Desktop](../desktop/index.md) creates a VPN tunnel that automatically forwards every port in your workspace. Any service listening on a port is instantly accessible at `.coder:PORT` from your local machine, with no additional commands or configuration.
+
+This is the simplest option for most developers: install Coder Desktop, enable Coder Connect, and all ports just work. Connections are peer-to-peer for the best performance.
## The `coder port-forward` command
From 282ab7de34041c8ce6ed418b1f95a3c72bf7302e Mon Sep 17 00:00:00 2001
From: Danny Kopping
Date: Tue, 26 May 2026 15:57:01 +0200
Subject: [PATCH 008/249] refactor: load AI providers from the database at
startup (#25672)
Replace the env-based `BuildProviders` with a DB-backed loader. The database is now the single source of truth for runtime provider configuration; env config arrives via `SeedAIProvidersFromEnv` (run at boot) and `BuildProviders` reads it back as `aibridge.Provider` instances. `cli/server.go` and `enterprise/cli/server.go` both call the same path, so aibridged and aibridgeproxyd see the same provider set.
Per-provider `DumpDir` is replaced by a top-level `CODER_AI_GATEWAY_DUMP_DIR` base; each provider's effective dump path is `/`.
---
cli/aibridged.go | 311 ++++++++++--------
cli/aibridged_internal_test.go | 236 +++++++++++--
cli/server.go | 46 +--
cli/server_aibridge_internal_test.go | 53 ++-
cli/testdata/coder_server_--help.golden | 6 +
cli/testdata/server-config.yaml.golden | 5 +
coderd/ai_providers_migrate.go | 5 +
coderd/apidoc/docs.go | 8 +-
coderd/apidoc/swagger.json | 8 +-
coderd/database/dbauthz/dbauthz.go | 1 +
codersdk/deployment.go | 17 +-
docs/ai-coder/ai-gateway/setup.md | 53 ++-
docs/reference/api/general.md | 2 +-
docs/reference/api/schemas.md | 11 +-
docs/reference/cli/server.md | 10 +
.../cli/aibridgeproxyd_internal_test.go | 36 +-
enterprise/cli/server.go | 2 +-
.../cli/testdata/coder_server_--help.golden | 6 +
site/src/api/typesGenerated.ts | 10 +-
19 files changed, 569 insertions(+), 257 deletions(-)
diff --git a/cli/aibridged.go b/cli/aibridged.go
index 42bcc6d3d5314..8bb21a8cbfe8c 100644
--- a/cli/aibridged.go
+++ b/cli/aibridged.go
@@ -5,15 +5,21 @@ package cli
import (
"context"
+ "github.com/google/uuid"
"github.com/prometheus/client_golang/prometheus"
"golang.org/x/xerrors"
+ "cdr.dev/slog/v3"
"github.com/coder/coder/v2/aibridge"
"github.com/coder/coder/v2/aibridge/config"
"github.com/coder/coder/v2/aibridge/keypool"
"github.com/coder/coder/v2/coderd"
"github.com/coder/coder/v2/coderd/aibridged"
+ "github.com/coder/coder/v2/coderd/database"
+ "github.com/coder/coder/v2/coderd/database/db2sdk"
+ "github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/tracing"
+ "github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/quartz"
)
@@ -44,183 +50,200 @@ func newAIBridgeDaemon(coderAPI *coderd.API, providers []aibridge.Provider) (*ai
return srv, nil
}
-// BuildProviders constructs the list of AI providers from config.
-// It merges legacy single-provider env vars and indexed provider configs:
-// 1. Legacy providers (from CODER_AI_GATEWAY_OPENAI_KEY, etc.) are added first.
-// If a legacy name conflicts with an indexed provider, startup fails with
-// a clear error asking the admin to remove one or the other.
-// 2. Indexed providers (from CODER_AI_GATEWAY_PROVIDER__*) are added next.
-func BuildProviders(cfg codersdk.AIBridgeConfig) ([]aibridge.Provider, error) {
- var cbConfig *config.CircuitBreaker
- if cfg.CircuitBreakerEnabled.Value() {
- cbConfig = &config.CircuitBreaker{
- FailureThreshold: uint32(cfg.CircuitBreakerFailureThreshold.Value()), //nolint:gosec // Validated by serpent.Validate in deployment options.
- Interval: cfg.CircuitBreakerInterval.Value(),
- Timeout: cfg.CircuitBreakerTimeout.Value(),
- MaxRequests: uint32(cfg.CircuitBreakerMaxRequests.Value()), //nolint:gosec // Validated by serpent.Validate in deployment options.
+// BuildProviders loads every enabled ai_providers row, attaches its
+// keys, and constructs the equivalent [aibridge.Provider] instances.
+// The database is the single source of truth for runtime provider
+// configuration.
+//
+// Per-provider construction errors are logged and the offending row is
+// excluded from the returned snapshot; only a failure of the DB query
+// itself is propagated. This keeps a single misconfigured row from
+// taking the whole daemon down.
+func BuildProviders(ctx context.Context, db database.Store, cfg codersdk.AIBridgeConfig, logger slog.Logger) ([]aibridge.Provider, error) {
+ //nolint:gocritic // AsAIBridged has a minimal permission set for this purpose.
+ authCtx := dbauthz.AsAIBridged(ctx)
+
+ var rows []database.AIProvider
+ keysByProvider := make(map[uuid.UUID][]database.AIProviderKey)
+
+ // Wrap both queries in a read-only transaction so the provider list
+ // and the key list are consistent with each other.
+ err := db.InTx(func(tx database.Store) error {
+ var err error
+ rows, err = tx.GetAIProviders(authCtx, database.GetAIProvidersParams{
+ IncludeDisabled: false,
+ })
+ if err != nil {
+ return xerrors.Errorf("load ai providers: %w", err)
}
- }
- var providers []aibridge.Provider
- usedNames := make(map[string]struct{})
+ if len(rows) == 0 {
+ return nil
+ }
- // Collect names from indexed providers so we can detect conflicts
- // with legacy providers.
- for _, p := range cfg.Providers {
- name := p.Name
- if name == "" {
- name = p.Type
+ // Load keys only for the enabled providers to avoid materializing
+ // secrets for disabled rows.
+ ids := make([]uuid.UUID, len(rows))
+ for i, r := range rows {
+ ids[i] = r.ID
+ }
+ keyRows, err := tx.GetAIProviderKeysByProviderIDs(authCtx, ids)
+ if err != nil {
+ return xerrors.Errorf("load ai provider keys: %w", err)
+ }
+ for _, k := range keyRows {
+ keysByProvider[k.ProviderID] = append(keysByProvider[k.ProviderID], k)
}
- usedNames[name] = struct{}{}
+ return nil
+ }, &database.TxOptions{ReadOnly: true, TxIdentifier: "build_ai_providers"})
+ if err != nil {
+ return nil, err
}
- // Add legacy OpenAI provider if configured.
- if cfg.LegacyOpenAI.Key.String() != "" {
- if _, conflict := usedNames[aibridge.ProviderOpenAI]; conflict {
- return nil, xerrors.Errorf("legacy CODER_AI_GATEWAY_OPENAI_KEY (or CODER_AIBRIDGE_OPENAI_KEY) conflicts with indexed provider named %q; remove one or the other", aibridge.ProviderOpenAI)
+ out := make([]aibridge.Provider, 0, len(rows))
+ for _, row := range rows {
+ prov, err := buildAIProviderFromRow(row, keysByProvider[row.ID], cfg)
+ if err != nil {
+ logger.Error(ctx, "skipping misconfigured ai provider",
+ slog.F("provider_id", row.ID),
+ slog.F("provider_name", row.Name),
+ slog.F("provider_type", string(row.Type)),
+ slog.Error(err),
+ )
+ continue
}
- providers = append(providers, aibridge.NewOpenAIProvider(aibridge.OpenAIConfig{
- Name: aibridge.ProviderOpenAI,
- BaseURL: cfg.LegacyOpenAI.BaseURL.String(),
- Key: cfg.LegacyOpenAI.Key.String(),
- CircuitBreaker: cbConfig,
- SendActorHeaders: cfg.SendActorHeaders.Value(),
- }))
- usedNames[aibridge.ProviderOpenAI] = struct{}{}
+ out = append(out, prov)
+ }
+
+ if len(rows) > 0 && len(out) == 0 {
+ logger.Warn(ctx, "all enabled ai providers failed to build; daemon will start with zero providers")
}
- // Add legacy Anthropic provider if configured. Bedrock credentials
- // alone are sufficient, an Anthropic API key is not required when
- // using AWS Bedrock.
- if cfg.LegacyAnthropic.Key.String() != "" || getBedrockConfig(cfg.LegacyBedrock) != nil {
- if _, conflict := usedNames[aibridge.ProviderAnthropic]; conflict {
- return nil, xerrors.Errorf("legacy CODER_AI_GATEWAY_ANTHROPIC_KEY (or CODER_AIBRIDGE_ANTHROPIC_KEY) conflicts with indexed provider named %q; remove one or the other", aibridge.ProviderAnthropic)
+ return out, nil
+}
+
+// buildAIProviderFromRow decodes the settings blob and constructs the
+// appropriate [aibridge.Provider] for a single ai_providers row.
+func buildAIProviderFromRow(
+ row database.AIProvider,
+ keys []database.AIProviderKey,
+ cfg codersdk.AIBridgeConfig,
+) (aibridge.Provider, error) {
+ settings, err := db2sdk.AIProviderSettings(row.Settings)
+ if err != nil {
+ return nil, xerrors.Errorf("decode settings: %w", err)
+ }
+
+ cbCfg := circuitBreakerConfig(cfg)
+ sendActorHeaders := cfg.SendActorHeaders.Value()
+ dumpDir := cfg.APIDumpDir.Value()
+
+ switch row.Type {
+ case database.AiProviderTypeOpenai:
+ if len(keys) == 0 && !cfg.AllowBYOK.Value() {
+ return nil, xerrors.New("openai provider has no api keys configured and BYOK is not enabled")
}
var pool *keypool.Pool
- if key := cfg.LegacyAnthropic.Key.String(); key != "" {
+ if len(keys) > 0 {
var err error
- pool, err = keypool.New([]string{key}, quartz.NewReal())
+ pool, err = buildAIProviderKeyPool(keys)
if err != nil {
- return nil, xerrors.Errorf("create legacy anthropic key pool: %w", err)
+ return nil, xerrors.Errorf("openai key pool: %w", err)
}
}
- providers = append(providers, aibridge.NewAnthropicProvider(aibridge.AnthropicConfig{
- Name: aibridge.ProviderAnthropic,
- BaseURL: cfg.LegacyAnthropic.BaseURL.String(),
+ return aibridge.NewOpenAIProvider(aibridge.OpenAIConfig{
+ Name: row.Name,
+ BaseURL: row.BaseUrl,
KeyPool: pool,
- CircuitBreaker: cbConfig,
- SendActorHeaders: cfg.SendActorHeaders.Value(),
- }, getBedrockConfig(cfg.LegacyBedrock)))
- usedNames[aibridge.ProviderAnthropic] = struct{}{}
- }
-
- // Add indexed providers.
- for _, p := range cfg.Providers {
- name := p.Name
- if name == "" {
- name = p.Type
+ APIDumpDir: dumpDir,
+ CircuitBreaker: cbCfg,
+ SendActorHeaders: sendActorHeaders,
+ }), nil
+
+ case database.AiProviderTypeAnthropic:
+ bedrock := bedrockConfigFromRow(row, settings)
+ // Bedrock-backed Anthropic authenticates via AWS credentials in
+ // the settings blob, not the api_keys table. A bearer-token
+ // Anthropic without any key cannot make upstream calls.
+ if bedrock == nil && len(keys) == 0 && !cfg.AllowBYOK.Value() {
+ return nil, xerrors.New("anthropic provider has no api keys, no bedrock credentials, and BYOK is not enabled")
}
- switch p.Type {
- case aibridge.ProviderOpenAI:
- var pool *keypool.Pool
- if len(p.Keys) > 0 {
- var err error
- pool, err = keypool.New(p.Keys, quartz.NewReal())
- if err != nil {
- return nil, xerrors.Errorf("create openai key pool for provider %q: %w", name, err)
- }
- }
- providers = append(providers, aibridge.NewOpenAIProvider(aibridge.OpenAIConfig{
- Name: name,
- BaseURL: p.BaseURL,
- KeyPool: pool,
- APIDumpDir: p.DumpDir,
- CircuitBreaker: cbConfig,
- SendActorHeaders: cfg.SendActorHeaders.Value(),
- }))
- case aibridge.ProviderAnthropic:
- var pool *keypool.Pool
- if len(p.Keys) > 0 {
- var err error
- pool, err = keypool.New(p.Keys, quartz.NewReal())
- if err != nil {
- return nil, xerrors.Errorf("create anthropic key pool for provider %q: %w", name, err)
- }
+ var pool *keypool.Pool
+ if len(keys) > 0 {
+ var err error
+ pool, err = buildAIProviderKeyPool(keys)
+ if err != nil {
+ return nil, xerrors.Errorf("anthropic key pool: %w", err)
}
- providers = append(providers, aibridge.NewAnthropicProvider(aibridge.AnthropicConfig{
- Name: name,
- BaseURL: p.BaseURL,
- KeyPool: pool,
- APIDumpDir: p.DumpDir,
- CircuitBreaker: cbConfig,
- SendActorHeaders: cfg.SendActorHeaders.Value(),
- }, bedrockConfigFromProvider(p)))
- case aibridge.ProviderCopilot:
- providers = append(providers, aibridge.NewCopilotProvider(aibridge.CopilotConfig{
- Name: name,
- BaseURL: p.BaseURL,
- APIDumpDir: p.DumpDir,
- CircuitBreaker: cbConfig,
- }))
- default:
- return nil, xerrors.Errorf("unknown provider type %q for provider %q", p.Type, name)
}
+ return aibridge.NewAnthropicProvider(aibridge.AnthropicConfig{
+ Name: row.Name,
+ BaseURL: row.BaseUrl,
+ KeyPool: pool,
+ APIDumpDir: dumpDir,
+ CircuitBreaker: cbCfg,
+ SendActorHeaders: sendActorHeaders,
+ }, bedrock), nil
+
+ case database.AiProviderTypeCopilot:
+ // Copilot is always BYOK; the per-user token is supplied on each
+ // request via the Authorization header, so no keypool is built.
+ return aibridge.NewCopilotProvider(aibridge.CopilotConfig{
+ Name: row.Name,
+ BaseURL: row.BaseUrl,
+ APIDumpDir: dumpDir,
+ CircuitBreaker: cbCfg,
+ }), nil
+
+ default:
+ return nil, xerrors.Errorf("unsupported provider type: %q", row.Type)
}
-
- return providers, nil
}
-// bedrockConfigFromProvider converts Bedrock fields from an indexed
-// AIProviderConfig into an aibridge AWSBedrockConfig.
-// Returns nil if no Bedrock fields are set.
-func bedrockConfigFromProvider(p codersdk.AIProviderConfig) *aibridge.AWSBedrockConfig {
- // Currently, only the first key pair is used, if any.
- // TODO(ssncferreira): pass a keypool.Pool instead.
- var accessKey, accessKeySecret string
- if len(p.BedrockAccessKeys) > 0 {
- accessKey = p.BedrockAccessKeys[0]
+// buildAIProviderKeyPool builds a [keypool.Pool]. Callers must check
+// len(keys) > 0 first; keypool.New rejects empty input.
+func buildAIProviderKeyPool(keys []database.AIProviderKey) (*keypool.Pool, error) {
+ raw := make([]string, 0, len(keys))
+ for _, k := range keys {
+ raw = append(raw, k.APIKey)
}
- if len(p.BedrockAccessKeySecrets) > 0 {
- accessKeySecret = p.BedrockAccessKeySecrets[0]
+ return keypool.New(raw, quartz.NewReal())
+}
+
+// bedrockConfigFromRow returns nil when the settings have no Bedrock
+// discriminator or when the Bedrock fields are not actually configured.
+// The provider row's BaseUrl is the generic upstream endpoint and is
+// always non-empty, so it cannot serve as a Bedrock detection signal;
+// gate on the settings blob alone via [codersdk.AIProviderBedrockSettings.IsConfigured].
+func bedrockConfigFromRow(row database.AIProvider, settings codersdk.AIProviderSettings) *aibridge.AWSBedrockConfig {
+ if settings.Bedrock == nil {
+ return nil
}
- settings := codersdk.NewAIProviderBedrockSettings(
- p.BedrockRegion, accessKey, accessKeySecret,
- p.BedrockModel, p.BedrockSmallFastModel,
- )
- if !codersdk.IsBedrockConfigured(p.BedrockBaseURL, settings) {
+ bedrockSettings := *settings.Bedrock
+ if !bedrockSettings.IsConfigured() {
return nil
}
+ accessKey := ptr.NilToEmpty(bedrockSettings.AccessKey)
+ accessKeySecret := ptr.NilToEmpty(bedrockSettings.AccessKeySecret)
return &aibridge.AWSBedrockConfig{
- BaseURL: p.BedrockBaseURL,
- Region: p.BedrockRegion,
+ BaseURL: row.BaseUrl,
+ Region: bedrockSettings.Region,
AccessKey: accessKey,
AccessKeySecret: accessKeySecret,
- Model: p.BedrockModel,
- SmallFastModel: p.BedrockSmallFastModel,
+ Model: bedrockSettings.Model,
+ SmallFastModel: bedrockSettings.SmallFastModel,
}
}
-func getBedrockConfig(cfg codersdk.AIBridgeBedrockConfig) *aibridge.AWSBedrockConfig {
- // codersdk.IsBedrockConfigured decides what counts as Bedrock; when
- // it returns false, the AWS SDK default credential chain (env vars,
- // shared config, IAM roles, etc.) is left to resolve credentials.
- settings := codersdk.NewAIProviderBedrockSettings(
- cfg.Region.String(),
- cfg.AccessKey.String(),
- cfg.AccessKeySecret.String(),
- cfg.Model.String(),
- cfg.SmallFastModel.String(),
- )
- if !codersdk.IsBedrockConfigured(cfg.BaseURL.String(), settings) {
+// circuitBreakerConfig returns nil when the breaker is disabled.
+func circuitBreakerConfig(cfg codersdk.AIBridgeConfig) *config.CircuitBreaker {
+ if !cfg.CircuitBreakerEnabled.Value() {
return nil
}
-
- return &aibridge.AWSBedrockConfig{
- BaseURL: cfg.BaseURL.String(),
- Region: cfg.Region.String(),
- AccessKey: cfg.AccessKey.String(),
- AccessKeySecret: cfg.AccessKeySecret.String(),
- Model: cfg.Model.String(),
- SmallFastModel: cfg.SmallFastModel.String(),
+ return &config.CircuitBreaker{
+ FailureThreshold: uint32(cfg.CircuitBreakerFailureThreshold.Value()), //nolint:gosec // Validated by serpent.Validate in deployment options.
+ Interval: cfg.CircuitBreakerInterval.Value(),
+ Timeout: cfg.CircuitBreakerTimeout.Value(),
+ MaxRequests: uint32(cfg.CircuitBreakerMaxRequests.Value()), //nolint:gosec // Validated by serpent.Validate in deployment options.
}
}
diff --git a/cli/aibridged_internal_test.go b/cli/aibridged_internal_test.go
index 2c2651cc11476..1f9f51267864e 100644
--- a/cli/aibridged_internal_test.go
+++ b/cli/aibridged_internal_test.go
@@ -3,23 +3,47 @@
package cli
import (
+ "database/sql"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
+ "cdr.dev/slog/v3/sloggers/slogtest"
"github.com/coder/coder/v2/aibridge"
+ "github.com/coder/coder/v2/coderd"
agplaibridge "github.com/coder/coder/v2/coderd/aibridge"
+ "github.com/coder/coder/v2/coderd/database"
+ "github.com/coder/coder/v2/coderd/database/dbgen"
+ "github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/codersdk"
+ "github.com/coder/coder/v2/testutil"
"github.com/coder/serpent"
)
+// buildFromEnv exercises the same env-config-in/providers-out path that
+// production uses on boot: SeedAIProvidersFromEnv writes the env-derived
+// rows to the database, and BuildProviders reads them back as runtime
+// [aibridge.Provider] instances. This keeps the existing TestBuildProviders
+// table intact while reflecting the post-refactor flow where the database
+// is the single source of truth.
+func buildFromEnv(t *testing.T, cfg codersdk.AIBridgeConfig) ([]aibridge.Provider, error) {
+ t.Helper()
+ db, _ := dbtestutil.NewDB(t)
+ ctx := testutil.Context(t, testutil.WaitShort)
+ logger := slogtest.Make(t, nil)
+ if err := coderd.SeedAIProvidersFromEnv(ctx, db, cfg, logger); err != nil {
+ return nil, err
+ }
+ return BuildProviders(ctx, db, cfg, logger)
+}
+
func TestBuildProviders(t *testing.T) {
t.Parallel()
t.Run("EmptyConfig", func(t *testing.T) {
t.Parallel()
- providers, err := BuildProviders(codersdk.AIBridgeConfig{})
+ providers, err := buildFromEnv(t, codersdk.AIBridgeConfig{})
require.NoError(t, err)
assert.Empty(t, providers)
})
@@ -30,7 +54,7 @@ func TestBuildProviders(t *testing.T) {
cfg.LegacyOpenAI.Key = serpent.String("sk-openai")
cfg.LegacyAnthropic.Key = serpent.String("sk-anthropic")
- providers, err := BuildProviders(cfg)
+ providers, err := buildFromEnv(t, cfg)
require.NoError(t, err)
names := providerNames(providers)
@@ -44,28 +68,29 @@ func TestBuildProviders(t *testing.T) {
cfg := codersdk.AIBridgeConfig{
Providers: []codersdk.AIProviderConfig{
{
- Type: aibridge.ProviderAnthropic,
- Name: "anthropic-zdr",
- Keys: []string{"sk-zdr"},
- DumpDir: "/tmp/anthropic-dump",
+ Type: aibridge.ProviderAnthropic,
+ Name: "anthropic-zdr",
+ Keys: []string{"sk-zdr"},
},
{
Type: aibridge.ProviderOpenAI,
Name: "openai-azure",
Keys: []string{"sk-azure"},
BaseURL: "https://azure.openai.com",
- DumpDir: "/tmp/openai-dump",
},
},
}
- providers, err := BuildProviders(cfg)
+ providers, err := buildFromEnv(t, cfg)
require.NoError(t, err)
+ require.Len(t, providers, 2)
- names := providerNames(providers)
- assert.Equal(t, []string{"anthropic-zdr", "openai-azure"}, names)
- assert.Equal(t, "/tmp/anthropic-dump", providers[0].APIDumpDir())
- assert.Equal(t, "/tmp/openai-dump", providers[1].APIDumpDir())
+ byName := make(map[string]aibridge.Provider, len(providers))
+ for _, p := range providers {
+ byName[p.Name()] = p
+ }
+ require.Contains(t, byName, "anthropic-zdr")
+ require.Contains(t, byName, "openai-azure")
})
t.Run("LegacyOpenAIConflictsWithIndexed", func(t *testing.T) {
@@ -77,9 +102,9 @@ func TestBuildProviders(t *testing.T) {
}
cfg.LegacyOpenAI.Key = serpent.String("sk-legacy")
- _, err := BuildProviders(cfg)
+ _, err := buildFromEnv(t, cfg)
require.Error(t, err)
- assert.Contains(t, err.Error(), "conflicts with indexed provider")
+ assert.Contains(t, err.Error(), "conflicts with the legacy env var")
})
t.Run("LegacyAnthropicConflictsWithIndexed", func(t *testing.T) {
@@ -91,9 +116,9 @@ func TestBuildProviders(t *testing.T) {
}
cfg.LegacyAnthropic.Key = serpent.String("sk-legacy")
- _, err := BuildProviders(cfg)
+ _, err := buildFromEnv(t, cfg)
require.Error(t, err)
- assert.Contains(t, err.Error(), "conflicts with indexed provider")
+ assert.Contains(t, err.Error(), "conflicts with the legacy env var")
})
t.Run("MixedLegacyAndIndexed", func(t *testing.T) {
@@ -106,7 +131,7 @@ func TestBuildProviders(t *testing.T) {
cfg.LegacyOpenAI.Key = serpent.String("sk-openai")
cfg.LegacyAnthropic.Key = serpent.String("sk-anthropic")
- providers, err := BuildProviders(cfg)
+ providers, err := buildFromEnv(t, cfg)
require.NoError(t, err)
names := providerNames(providers)
@@ -123,7 +148,7 @@ func TestBuildProviders(t *testing.T) {
cfg.LegacyBedrock.AccessKey = serpent.String("AKID")
cfg.LegacyBedrock.AccessKeySecret = serpent.String("secret")
- providers, err := BuildProviders(cfg)
+ providers, err := buildFromEnv(t, cfg)
require.NoError(t, err)
names := providerNames(providers)
@@ -139,7 +164,7 @@ func TestBuildProviders(t *testing.T) {
cfg.LegacyBedrock.AccessKey = serpent.String("AKID")
cfg.LegacyBedrock.AccessKeySecret = serpent.String("secret")
- providers, err := BuildProviders(cfg)
+ providers, err := buildFromEnv(t, cfg)
require.NoError(t, err)
require.Len(t, providers, 1)
@@ -150,15 +175,18 @@ func TestBuildProviders(t *testing.T) {
t.Run("UnknownType", func(t *testing.T) {
t.Parallel()
+ // Unknown provider types are dropped by the seed step (logged
+ // and skipped) so one misconfigured row cannot stop the daemon
+ // from starting. The end state is "no providers", not an error.
cfg := codersdk.AIBridgeConfig{
Providers: []codersdk.AIProviderConfig{
{Type: "gemini", Name: "gemini-pro"},
},
}
- _, err := BuildProviders(cfg)
- require.Error(t, err)
- assert.Contains(t, err.Error(), "unknown provider type")
+ providers, err := buildFromEnv(t, cfg)
+ require.NoError(t, err)
+ assert.Empty(t, providers)
})
t.Run("CopilotVariants", func(t *testing.T) {
@@ -167,22 +195,25 @@ func TestBuildProviders(t *testing.T) {
// Copilot API hosts via an explicit BASE_URL.
cfg := codersdk.AIBridgeConfig{
Providers: []codersdk.AIProviderConfig{
- {Type: aibridge.ProviderCopilot, Name: aibridge.ProviderCopilot, DumpDir: "/tmp/copilot-dump"},
+ {Type: aibridge.ProviderCopilot, Name: aibridge.ProviderCopilot},
{Type: aibridge.ProviderCopilot, Name: agplaibridge.ProviderCopilotBusiness, BaseURL: "https://" + agplaibridge.HostCopilotBusiness},
{Type: aibridge.ProviderCopilot, Name: agplaibridge.ProviderCopilotEnterprise, BaseURL: "https://" + agplaibridge.HostCopilotEnterprise},
},
}
- providers, err := BuildProviders(cfg)
+ providers, err := buildFromEnv(t, cfg)
require.NoError(t, err)
require.Len(t, providers, 3)
- assert.Equal(t, aibridge.ProviderCopilot, providers[0].Name())
- assert.Equal(t, "/tmp/copilot-dump", providers[0].APIDumpDir())
- assert.Equal(t, agplaibridge.ProviderCopilotBusiness, providers[1].Name())
- assert.Equal(t, "https://"+agplaibridge.HostCopilotBusiness, providers[1].BaseURL())
- assert.Equal(t, agplaibridge.ProviderCopilotEnterprise, providers[2].Name())
- assert.Equal(t, "https://"+agplaibridge.HostCopilotEnterprise, providers[2].BaseURL())
+ byName := make(map[string]aibridge.Provider, len(providers))
+ for _, p := range providers {
+ byName[p.Name()] = p
+ }
+ require.Contains(t, byName, aibridge.ProviderCopilot)
+ require.Contains(t, byName, agplaibridge.ProviderCopilotBusiness)
+ require.Contains(t, byName, agplaibridge.ProviderCopilotEnterprise)
+ assert.Equal(t, "https://"+agplaibridge.HostCopilotBusiness, byName[agplaibridge.ProviderCopilotBusiness].BaseURL())
+ assert.Equal(t, "https://"+agplaibridge.HostCopilotEnterprise, byName[agplaibridge.ProviderCopilotEnterprise].BaseURL())
})
t.Run("ChatGPTProvider", func(t *testing.T) {
@@ -191,17 +222,158 @@ func TestBuildProviders(t *testing.T) {
// base URL. Admins configure it as an indexed openai provider.
cfg := codersdk.AIBridgeConfig{
Providers: []codersdk.AIProviderConfig{
- {Type: aibridge.ProviderOpenAI, Name: agplaibridge.ProviderChatGPT, BaseURL: agplaibridge.BaseURLChatGPT},
+ {Type: aibridge.ProviderOpenAI, Name: agplaibridge.ProviderChatGPT, Keys: []string{"sk-chatgpt"}, BaseURL: agplaibridge.BaseURLChatGPT},
},
}
- providers, err := BuildProviders(cfg)
+ providers, err := buildFromEnv(t, cfg)
require.NoError(t, err)
require.Len(t, providers, 1)
assert.Equal(t, agplaibridge.ProviderChatGPT, providers[0].Name())
assert.Equal(t, agplaibridge.BaseURLChatGPT, providers[0].BaseURL())
})
+
+ t.Run("NativeAnthropicDefaultBaseURL", func(t *testing.T) {
+ t.Parallel()
+ row := database.AIProvider{
+ Type: database.AiProviderTypeAnthropic,
+ Name: aibridge.ProviderAnthropic,
+ BaseUrl: "https://api.anthropic.com/",
+ }
+ assert.Nil(t, bedrockConfigFromRow(row, codersdk.AIProviderSettings{}))
+ })
+
+ t.Run("NativeAnthropicCustomBaseURL", func(t *testing.T) {
+ t.Parallel()
+ row := database.AIProvider{
+ Type: database.AiProviderTypeAnthropic,
+ Name: "anthropic-proxy",
+ BaseUrl: "https://internal-proxy.example.com/anthropic/",
+ }
+ assert.Nil(t, bedrockConfigFromRow(row, codersdk.AIProviderSettings{}))
+ })
+
+ t.Run("BedrockSettingsPresent", func(t *testing.T) {
+ t.Parallel()
+ accessKey := "AKID"
+ secret := "secret"
+ model := "anthropic.claude-3-5-sonnet-20241022-v2:0"
+ smallModel := "anthropic.claude-3-5-haiku-20241022-v1:0"
+ row := database.AIProvider{
+ Type: database.AiProviderTypeAnthropic,
+ Name: "anthropic-bedrock",
+ BaseUrl: "https://bedrock-runtime.us-west-2.amazonaws.com/",
+ }
+ settings := codersdk.AIProviderSettings{
+ Bedrock: &codersdk.AIProviderBedrockSettings{
+ Region: "us-west-2",
+ AccessKey: &accessKey,
+ AccessKeySecret: &secret,
+ Model: model,
+ SmallFastModel: smallModel,
+ },
+ }
+ got := bedrockConfigFromRow(row, settings)
+ require.NotNil(t, got)
+ assert.Equal(t, row.BaseUrl, got.BaseURL)
+ assert.Equal(t, "us-west-2", got.Region)
+ assert.Equal(t, accessKey, got.AccessKey)
+ assert.Equal(t, secret, got.AccessKeySecret)
+ assert.Equal(t, model, got.Model)
+ assert.Equal(t, smallModel, got.SmallFastModel)
+ })
+
+ t.Run("BedrockSettingsEmpty", func(t *testing.T) {
+ t.Parallel()
+ // A non-nil but zero-valued Bedrock settings blob should not
+ // produce a Bedrock config; the provider's generic BaseUrl is
+ // not a Bedrock detection signal.
+ row := database.AIProvider{
+ Type: database.AiProviderTypeAnthropic,
+ Name: "anthropic-empty-bedrock",
+ BaseUrl: "https://api.anthropic.com/",
+ }
+ settings := codersdk.AIProviderSettings{
+ Bedrock: &codersdk.AIProviderBedrockSettings{},
+ }
+ assert.Nil(t, bedrockConfigFromRow(row, settings))
+ })
+}
+
+// TestBuildProvidersSkipsBadRows exercises the skip-and-continue path
+// directly: rows whose settings blob is malformed or whose type is not
+// supported by the runtime builder are logged and excluded from the
+// returned snapshot without surfacing a top-level error. The seed path
+// filters most of these out before insert, so we bypass it and insert
+// rows straight into the database via dbgen.
+func TestBuildProvidersSkipsBadRows(t *testing.T) {
+ t.Parallel()
+
+ t.Run("CorruptSettings", func(t *testing.T) {
+ t.Parallel()
+ db, _ := dbtestutil.NewDB(t)
+ ctx := testutil.Context(t, testutil.WaitShort)
+ logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
+
+ dbgen.AIProvider(t, db, database.AIProvider{
+ Type: database.AiProviderTypeAnthropic,
+ Name: "anthropic-broken",
+ BaseUrl: "https://api.anthropic.com/",
+ Settings: sql.NullString{String: "not-json", Valid: true},
+ })
+
+ providers, err := BuildProviders(ctx, db, codersdk.AIBridgeConfig{}, logger)
+ require.NoError(t, err)
+ assert.Empty(t, providers)
+ })
+
+ t.Run("UnsupportedType", func(t *testing.T) {
+ t.Parallel()
+ db, _ := dbtestutil.NewDB(t)
+ ctx := testutil.Context(t, testutil.WaitShort)
+ logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
+
+ // Azure is a valid DB-level provider type but has no runtime
+ // builder yet; it must hit the default branch and be skipped.
+ dbgen.AIProvider(t, db, database.AIProvider{
+ Type: database.AiProviderTypeAzure,
+ Name: "azure-openai",
+ BaseUrl: "https://example.openai.azure.com/",
+ })
+
+ providers, err := BuildProviders(ctx, db, codersdk.AIBridgeConfig{}, logger)
+ require.NoError(t, err)
+ assert.Empty(t, providers)
+ })
+
+ t.Run("BadRowDoesNotBlockGoodRow", func(t *testing.T) {
+ t.Parallel()
+ db, _ := dbtestutil.NewDB(t)
+ ctx := testutil.Context(t, testutil.WaitShort)
+ logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
+
+ dbgen.AIProvider(t, db, database.AIProvider{
+ Type: database.AiProviderTypeAnthropic,
+ Name: "anthropic-broken",
+ BaseUrl: "https://api.anthropic.com/",
+ Settings: sql.NullString{String: "{not valid json", Valid: true},
+ })
+ good := dbgen.AIProvider(t, db, database.AIProvider{
+ Type: database.AiProviderTypeOpenai,
+ Name: "openai-good",
+ BaseUrl: "https://api.openai.com/",
+ })
+ dbgen.AIProviderKey(t, db, database.AIProviderKey{
+ ProviderID: good.ID,
+ APIKey: "sk-good",
+ })
+
+ providers, err := BuildProviders(ctx, db, codersdk.AIBridgeConfig{}, logger)
+ require.NoError(t, err)
+ require.Len(t, providers, 1)
+ assert.Equal(t, "openai-good", providers[0].Name())
+ })
}
func providerNames(providers []aibridge.Provider) []string {
diff --git a/cli/server.go b/cli/server.go
index 1b2350d931bb3..02e6b16564a0d 100644
--- a/cli/server.go
+++ b/cli/server.go
@@ -899,6 +899,32 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
if err != nil {
return xerrors.Errorf("remove secrets from deployment values: %w", err)
}
+
+ // AI provider DB initialization runs synchronously here so
+ // authorized reads complete before any background goroutine
+ // starts. Otherwise a mid-startup cancellation can interrupt
+ // them and fail startup. Seeding must also happen before
+ // newAPI so the aibridgeproxyd in the enterprise closure
+ // observes env-configured providers.
+ //
+ // This is a once-off operation; once completed, all providers
+ // will be sourced from the database.
+ if err := coderd.SeedAIProvidersFromEnv(
+ ctx,
+ options.Database,
+ vals.AI.BridgeConfig,
+ logger.Named("aibridge.envseed"),
+ ); err != nil {
+ return xerrors.Errorf("seed ai providers from env: %w", err)
+ }
+ var aibridgeProviders []aibridge.Provider
+ if vals.AI.BridgeConfig.Enabled.Value() {
+ aibridgeProviders, err = BuildProviders(ctx, options.Database, vals.AI.BridgeConfig, logger.Named("aibridge.providers"))
+ if err != nil {
+ return xerrors.Errorf("build AI providers: %w", err)
+ }
+ }
+
telemetryReporter, err := telemetry.New(telemetry.Options{
Disabled: !vals.Telemetry.Enable.Value(),
BuiltinPostgres: builtinPostgres,
@@ -1006,18 +1032,6 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
notificationReportGenerator := reports.NewReportGenerator(ctx, logger.Named("notifications.report_generator"), options.Database, options.NotificationsEnqueuer, quartz.NewReal())
defer notificationReportGenerator.Close()
- // Seed providers before newAPI so the aibridgeproxyd inside
- // the enterprise closure observes env-configured providers
- // at init.
- if err := coderd.SeedAIProvidersFromEnv(
- ctx,
- options.Database,
- vals.AI.BridgeConfig,
- logger.Named("aibridge.envseed"),
- ); err != nil {
- return xerrors.Errorf("seed ai providers from env: %w", err)
- }
-
// We use a separate coderAPICloser so the Enterprise API
// can have its own close functions. This is cleaner
// than abstracting the Coder API itself.
@@ -1034,11 +1048,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
// unconditionally when the bridge feature is enabled by config so
// chatd can use it regardless of license entitlement.
if vals.AI.BridgeConfig.Enabled.Value() {
- providers, err := BuildProviders(vals.AI.BridgeConfig)
- if err != nil {
- return xerrors.Errorf("build AI providers: %w", err)
- }
- aibridgeDaemon, err := newAIBridgeDaemon(coderAPI, providers)
+ aibridgeDaemon, err := newAIBridgeDaemon(coderAPI, aibridgeProviders)
if err != nil {
return xerrors.Errorf("create aibridged: %w", err)
}
@@ -3114,8 +3124,6 @@ func readAIProvidersForPrefix(logger slog.Logger, environ []string, prefix strin
}
case "BASE_URL":
provider.BaseURL = v.Value
- case "DUMP_DIR":
- provider.DumpDir = v.Value
case "BEDROCK_BASE_URL":
provider.BedrockBaseURL = v.Value
case "BEDROCK_REGION":
diff --git a/cli/server_aibridge_internal_test.go b/cli/server_aibridge_internal_test.go
index 8afed4c749ad2..1797f1c7edf94 100644
--- a/cli/server_aibridge_internal_test.go
+++ b/cli/server_aibridge_internal_test.go
@@ -10,8 +10,10 @@ import (
"cdr.dev/slog/v3"
"cdr.dev/slog/v3/sloggers/slogtest"
"github.com/coder/coder/v2/aibridge"
+ "github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
+ "github.com/coder/serpent"
)
func TestReadAIProvidersFromEnv(t *testing.T) {
@@ -34,7 +36,6 @@ func TestReadAIProvidersFromEnv(t *testing.T) {
"CODER_AIBRIDGE_PROVIDER_0_NAME=anthropic-zdr",
"CODER_AIBRIDGE_PROVIDER_0_KEY=sk-ant-xxx",
"CODER_AIBRIDGE_PROVIDER_0_BASE_URL=https://api.anthropic.com/",
- "CODER_AIBRIDGE_PROVIDER_0_DUMP_DIR=/tmp/aibridge-dump",
},
expected: []codersdk.AIProviderConfig{
{
@@ -42,7 +43,6 @@ func TestReadAIProvidersFromEnv(t *testing.T) {
Name: "anthropic-zdr",
Keys: []string{"sk-ant-xxx"},
BaseURL: "https://api.anthropic.com/",
- DumpDir: "/tmp/aibridge-dump",
},
},
},
@@ -537,3 +537,52 @@ func TestValidateLegacyAIBridgeConfig(t *testing.T) {
})
}
}
+
+func TestBuildAIProviderFromRowSetsAPIDumpDir(t *testing.T) {
+ t.Parallel()
+
+ const dumpDir = "/tmp/coder-aibridge-dumps"
+
+ tests := []struct {
+ name string
+ row database.AIProvider
+ }{
+ {
+ name: "OpenAI",
+ row: database.AIProvider{
+ Type: database.AiProviderTypeOpenai,
+ Name: "openai",
+ BaseUrl: "https://api.openai.com/",
+ },
+ },
+ {
+ name: "Anthropic",
+ row: database.AIProvider{
+ Type: database.AiProviderTypeAnthropic,
+ Name: "anthropic",
+ BaseUrl: "https://api.anthropic.com/",
+ },
+ },
+ {
+ name: "Copilot",
+ row: database.AIProvider{
+ Type: database.AiProviderTypeCopilot,
+ Name: "copilot",
+ BaseUrl: "https://api.githubcopilot.com/",
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ provider, err := buildAIProviderFromRow(tt.row, nil, codersdk.AIBridgeConfig{
+ AllowBYOK: serpent.Bool(true),
+ APIDumpDir: serpent.String(dumpDir),
+ })
+ require.NoError(t, err)
+ assert.Equal(t, dumpDir, provider.APIDumpDir())
+ })
+ }
+}
diff --git a/cli/testdata/coder_server_--help.golden b/cli/testdata/coder_server_--help.golden
index 32225cedc7b47..4bcf9efda9e07 100644
--- a/cli/testdata/coder_server_--help.golden
+++ b/cli/testdata/coder_server_--help.golden
@@ -113,6 +113,12 @@ AI GATEWAY OPTIONS:
with AI budgets. "highest" selects the group with the largest spend
limit, and is currently the only supported value.
+ --ai-gateway-dump-dir string, $CODER_AI_GATEWAY_DUMP_DIR
+ Base directory for dumping AI Bridge request/response pairs to disk
+ for debugging. When set, each provider writes under a subdirectory
+ named after the provider. Sensitive headers are redacted. Leave empty
+ to disable.
+
--ai-gateway-allow-byok bool, $CODER_AI_GATEWAY_ALLOW_BYOK (default: true)
Allow users to provide their own LLM API keys or subscriptions. When
disabled, only centralized key authentication is permitted.
diff --git a/cli/testdata/server-config.yaml.golden b/cli/testdata/server-config.yaml.golden
index c9f6725210a3d..3e73b7ca3b66a 100644
--- a/cli/testdata/server-config.yaml.golden
+++ b/cli/testdata/server-config.yaml.golden
@@ -920,6 +920,11 @@ ai_gateway:
# X-Ai-Bridge-Actor-Metadata-Username (their username).
# (default: false, type: bool)
send_actor_headers: false
+ # Base directory for dumping AI Bridge request/response pairs to disk for
+ # debugging. When set, each provider writes under a subdirectory named after the
+ # provider. Sensitive headers are redacted. Leave empty to disable.
+ # (default: , type: string)
+ api_dump_dir: ""
# Allow users to provide their own LLM API keys or subscriptions. When disabled,
# only centralized key authentication is permitted.
# (default: true, type: bool)
diff --git a/coderd/ai_providers_migrate.go b/coderd/ai_providers_migrate.go
index c54cd12e2815c..98cfba2226469 100644
--- a/coderd/ai_providers_migrate.go
+++ b/coderd/ai_providers_migrate.go
@@ -292,6 +292,11 @@ func providersFromEnv(ctx context.Context, cfg codersdk.AIBridgeConfig, logger s
Type: database.AiProviderTypeAnthropic,
}
if hasLegacyBedrock {
+ if hasAnthropicKey {
+ logger.Warn(ctx, "ignoring legacy Anthropic API key because Bedrock credentials are configured; Bedrock authenticates via access keys or credential chain",
+ slog.F("provider", aibridge.ProviderAnthropic),
+ )
+ }
// Bedrock-only deployments use CODER_AIBRIDGE_BEDROCK_BASE_URL
// for custom VPC, FIPS, or proxy endpoints.
dp.BaseURL = cfg.LegacyBedrock.BaseURL.String()
diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go
index dce2daec4cbb0..ae5715bd78e4b 100644
--- a/coderd/apidoc/docs.go
+++ b/coderd/apidoc/docs.go
@@ -14450,6 +14450,10 @@ const docTemplate = `{
}
]
},
+ "api_dump_dir": {
+ "description": "APIDumpDir is the base directory under which each provider's\nrequest/response dumps are written, in a subdirectory named after\nthe provider. Empty disables dumping.",
+ "type": "string"
+ },
"bedrock": {
"description": "Deprecated: Use Providers with indexed CODER_AI_GATEWAY_PROVIDER_\u003cN\u003e_* env vars instead.",
"allOf": [
@@ -15062,10 +15066,6 @@ const docTemplate = `{
"bedrock_small_fast_model": {
"type": "string"
},
- "dump_dir": {
- "description": "DumpDir is the directory path for dumping API requests and responses.",
- "type": "string"
- },
"name": {
"description": "Name is the unique instance identifier used for routing.\nDefaults to Type if not provided.",
"type": "string"
diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json
index 88839dc5ac538..21ee879158ea4 100644
--- a/coderd/apidoc/swagger.json
+++ b/coderd/apidoc/swagger.json
@@ -12854,6 +12854,10 @@
}
]
},
+ "api_dump_dir": {
+ "description": "APIDumpDir is the base directory under which each provider's\nrequest/response dumps are written, in a subdirectory named after\nthe provider. Empty disables dumping.",
+ "type": "string"
+ },
"bedrock": {
"description": "Deprecated: Use Providers with indexed CODER_AI_GATEWAY_PROVIDER_\u003cN\u003e_* env vars instead.",
"allOf": [
@@ -13466,10 +13470,6 @@
"bedrock_small_fast_model": {
"type": "string"
},
- "dump_dir": {
- "description": "DumpDir is the directory path for dumping API requests and responses.",
- "type": "string"
- },
"name": {
"description": "Name is the unique instance identifier used for routing.\nDefaults to Type if not provided.",
"type": "string"
diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go
index 086a35d229f78..74aa3f9f468a9 100644
--- a/coderd/database/dbauthz/dbauthz.go
+++ b/coderd/database/dbauthz/dbauthz.go
@@ -627,6 +627,7 @@ var (
rbac.ResourceAibridgeInterception.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete},
rbac.ResourceAiModelPrice.Type: {policy.ActionUpdate}, // Required for the startup price seeder.
rbac.ResourceAiSeat.Type: {policy.ActionCreate}, // Required for UpsertAISeatState.
+ rbac.ResourceAIProvider.Type: {policy.ActionRead}, // Required to load the provider snapshot (and per-provider keys) at startup.
}),
User: []rbac.Permission{},
ByOrgID: map[string]rbac.OrgPermissions{},
diff --git a/codersdk/deployment.go b/codersdk/deployment.go
index 97dcd6e27d72e..dab861590fad7 100644
--- a/codersdk/deployment.go
+++ b/codersdk/deployment.go
@@ -1863,6 +1863,16 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
Group: &deploymentGroupAIGateway,
YAML: "structured_logging",
}
+ aiGatewayAPIDumpDir := serpent.Option{
+ Name: "AI Gateway API Dump Directory",
+ Description: "Base directory for dumping AI Bridge request/response pairs to disk for debugging. When set, each provider writes under a subdirectory named after the provider. Sensitive headers are redacted. Leave empty to disable.",
+ Flag: "ai-gateway-dump-dir",
+ Env: "CODER_AI_GATEWAY_DUMP_DIR",
+ Value: &c.AI.BridgeConfig.APIDumpDir,
+ Default: "",
+ Group: &deploymentGroupAIGateway,
+ YAML: "api_dump_dir",
+ }
aiGatewaySendActorHeaders := serpent.Option{
Name: "AI Gateway Send Actor Headers",
Description: "Once enabled, extra headers will be added to upstream requests to identify the user (actor) making requests to AI Gateway. " +
@@ -4275,6 +4285,7 @@ Write out the current server config as YAML to stdout.`,
UseInstead: serpent.OptionSet{aiGatewaySendActorHeaders},
},
aiGatewaySendActorHeaders,
+ aiGatewayAPIDumpDir,
{
Name: "AI Bridge Allow BYOK",
Description: "Deprecated: use --ai-gateway-allow-byok or CODER_AI_GATEWAY_ALLOW_BYOK instead. Allow users to provide their own LLM API keys or subscriptions. When disabled, only centralized key authentication is permitted.",
@@ -4632,6 +4643,10 @@ type AIBridgeConfig struct {
CircuitBreakerInterval serpent.Duration `json:"circuit_breaker_interval" typescript:",notnull"`
CircuitBreakerTimeout serpent.Duration `json:"circuit_breaker_timeout" typescript:",notnull"`
CircuitBreakerMaxRequests serpent.Int64 `json:"circuit_breaker_max_requests" typescript:",notnull"`
+ // APIDumpDir is the base directory under which each provider's
+ // request/response dumps are written, in a subdirectory named after
+ // the provider. Empty disables dumping.
+ APIDumpDir serpent.String `json:"api_dump_dir" typescript:",notnull"`
}
type AIBridgeOpenAIConfig struct {
@@ -4669,8 +4684,6 @@ type AIProviderConfig struct {
Keys []string `json:"-"`
// BaseURL is the base URL of the upstream provider API.
BaseURL string `json:"base_url"`
- // DumpDir is the directory path for dumping API requests and responses.
- DumpDir string `json:"dump_dir,omitempty"`
// Bedrock fields (only applicable when Type == "anthropic").
BedrockBaseURL string `json:"-"`
diff --git a/docs/ai-coder/ai-gateway/setup.md b/docs/ai-coder/ai-gateway/setup.md
index dd95afdaac2d8..27aed7c0506dc 100644
--- a/docs/ai-coder/ai-gateway/setup.md
+++ b/docs/ai-coder/ai-gateway/setup.md
@@ -218,20 +218,12 @@ requests to `/api/v2/aibridge//` to target a specific instance:
**Supported keys per provider:**
-| Key | Required | Description |
-|------------|----------|-------------------------------------------------------|
-| `TYPE` | Yes | Provider type: `openai`, `anthropic`, or `copilot` |
-| `NAME` | No | Unique instance name for routing. Defaults to `TYPE` |
-| `KEY` | No | API key for upstream authentication (alias: `KEYS`) |
-| `BASE_URL` | No | Base URL of the upstream API |
-| `DUMP_DIR` | No | Directory for provider API request and response dumps |
-
-> [!WARNING]
-> `DUMP_DIR` is not intended for regular use. Setting this option
-> results in a high number of writes. Dump files contain raw request and
-> response data, which may include proprietary or sensitive information
-> (prompts, completions, tool inputs). Enable only briefly for diagnostic
-> purposes and protect the target directory.
+| Key | Required | Description |
+|------------|----------|------------------------------------------------------|
+| `TYPE` | Yes | Provider type: `openai`, `anthropic`, or `copilot` |
+| `NAME` | No | Unique instance name for routing. Defaults to `TYPE` |
+| `KEY` | No | API key for upstream authentication (alias: `KEYS`) |
+| `BASE_URL` | No | Base URL of the upstream API |
For `anthropic` providers using AWS Bedrock, the following keys are also
available: `BEDROCK_BASE_URL`, `BEDROCK_REGION`,
@@ -251,6 +243,39 @@ available: `BEDROCK_BASE_URL`, `BEDROCK_REGION`,
> will produce a startup error. Remove one or the other to resolve the
> conflict.
+## API Dumps
+
+AI Gateway can dump provider request and response pairs to disk for debugging.
+Configure the dump directory with `--ai-gateway-dump-dir` or
+`CODER_AI_GATEWAY_DUMP_DIR`:
+
+```sh
+coder server --ai-gateway-dump-dir=/var/lib/coder/ai-gateway-dumps
+```
+
+Or in YAML:
+
+```yaml
+ai_gateway:
+ api_dump_dir: /var/lib/coder/ai-gateway-dumps
+```
+
+This top-level setting replaces the previous per-provider `DUMP_DIR` field.
+For each provider, AI Gateway writes dumps under `/`, where
+`` is the configured dump directory and `` is the provider
+instance name used in the route. For example, a provider named `anthropic-corp`
+with `/var/lib/coder/ai-gateway-dumps` configured writes to
+`/var/lib/coder/ai-gateway-dumps/anthropic-corp`.
+
+Sensitive headers are redacted before dumps are written. Leave the value empty
+to disable dumping.
+
+> [!WARNING]
+> API dumps are intended for short diagnostic sessions only. Dump files contain
+> raw request and response data, which may include proprietary or sensitive
+> information such as prompts, completions, and tool inputs. Protect the target
+> directory and disable dumping when diagnostics are complete.
+
## Data Retention
AI Gateway records prompts, token usage, tool invocations, and model reasoning for auditing and
diff --git a/docs/reference/api/general.md b/docs/reference/api/general.md
index 57e564aee31bb..02dbfe4135c47 100644
--- a/docs/reference/api/general.md
+++ b/docs/reference/api/general.md
@@ -185,6 +185,7 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \
"base_url": "string",
"key": "string"
},
+ "api_dump_dir": "string",
"bedrock": {
"access_key": "string",
"access_key_secret": "string",
@@ -213,7 +214,6 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \
"bedrock_model": "string",
"bedrock_region": "string",
"bedrock_small_fast_model": "string",
- "dump_dir": "string",
"name": "string",
"type": "string"
}
diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md
index c2cffd13d441b..aaee58512c263 100644
--- a/docs/reference/api/schemas.md
+++ b/docs/reference/api/schemas.md
@@ -442,6 +442,7 @@
"base_url": "string",
"key": "string"
},
+ "api_dump_dir": "string",
"bedrock": {
"access_key": "string",
"access_key_secret": "string",
@@ -470,7 +471,6 @@
"bedrock_model": "string",
"bedrock_region": "string",
"bedrock_small_fast_model": "string",
- "dump_dir": "string",
"name": "string",
"type": "string"
}
@@ -488,6 +488,7 @@
|-------------------------------------|----------------------------------------------------------------------|----------|--------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `allow_byok` | boolean | false | | |
| `anthropic` | [codersdk.AIBridgeAnthropicConfig](#codersdkaibridgeanthropicconfig) | false | | Deprecated: Use Providers with indexed CODER_AI_GATEWAY_PROVIDER__* env vars instead. |
+| `api_dump_dir` | string | false | | Api dump dir is the base directory under which each provider's request/response dumps are written, in a subdirectory named after the provider. Empty disables dumping. |
| `bedrock` | [codersdk.AIBridgeBedrockConfig](#codersdkaibridgebedrockconfig) | false | | Deprecated: Use Providers with indexed CODER_AI_GATEWAY_PROVIDER__* env vars instead. |
| `budget_period` | string | false | | |
| `budget_policy` | string | false | | Budget settings for AI Governance cost controls. |
@@ -1245,6 +1246,7 @@
"base_url": "string",
"key": "string"
},
+ "api_dump_dir": "string",
"bedrock": {
"access_key": "string",
"access_key_secret": "string",
@@ -1273,7 +1275,6 @@
"bedrock_model": "string",
"bedrock_region": "string",
"bedrock_small_fast_model": "string",
- "dump_dir": "string",
"name": "string",
"type": "string"
}
@@ -1344,7 +1345,6 @@
"bedrock_model": "string",
"bedrock_region": "string",
"bedrock_small_fast_model": "string",
- "dump_dir": "string",
"name": "string",
"type": "string"
}
@@ -1358,7 +1358,6 @@
| `bedrock_model` | string | false | | |
| `bedrock_region` | string | false | | |
| `bedrock_small_fast_model` | string | false | | |
-| `dump_dir` | string | false | | Dump dir is the directory path for dumping API requests and responses. |
| `name` | string | false | | Name is the unique instance identifier used for routing. Defaults to Type if not provided. |
| `type` | string | false | | Type is the provider type: "openai", "anthropic", or "copilot". |
@@ -5706,6 +5705,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
"base_url": "string",
"key": "string"
},
+ "api_dump_dir": "string",
"bedrock": {
"access_key": "string",
"access_key_secret": "string",
@@ -5734,7 +5734,6 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
"bedrock_model": "string",
"bedrock_region": "string",
"bedrock_small_fast_model": "string",
- "dump_dir": "string",
"name": "string",
"type": "string"
}
@@ -6305,6 +6304,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
"base_url": "string",
"key": "string"
},
+ "api_dump_dir": "string",
"bedrock": {
"access_key": "string",
"access_key_secret": "string",
@@ -6333,7 +6333,6 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
"bedrock_model": "string",
"bedrock_region": "string",
"bedrock_small_fast_model": "string",
- "dump_dir": "string",
"name": "string",
"type": "string"
}
diff --git a/docs/reference/cli/server.md b/docs/reference/cli/server.md
index 79c63a5c9cb6a..de694faa7940f 100644
--- a/docs/reference/cli/server.md
+++ b/docs/reference/cli/server.md
@@ -1889,6 +1889,16 @@ Emit structured logs for AI Gateway interception records. Use this for exporting
Once enabled, extra headers will be added to upstream requests to identify the user (actor) making requests to AI Gateway. This is only needed if you are using a proxy between AI Gateway and an upstream AI provider. This will send X-Ai-Bridge-Actor-Id (the ID of the user making the request) and X-Ai-Bridge-Actor-Metadata-Username (their username).
+### --ai-gateway-dump-dir
+
+| | |
+|-------------|-----------------------------------------|
+| Type | string |
+| Environment | $CODER_AI_GATEWAY_DUMP_DIR |
+| YAML | ai_gateway.api_dump_dir |
+
+Base directory for dumping AI Bridge request/response pairs to disk for debugging. When set, each provider writes under a subdirectory named after the provider. Sensitive headers are redacted. Leave empty to disable.
+
### --ai-gateway-allow-byok
| | |
diff --git a/enterprise/cli/aibridgeproxyd_internal_test.go b/enterprise/cli/aibridgeproxyd_internal_test.go
index f95d3414fd53a..f7b25a549c8fa 100644
--- a/enterprise/cli/aibridgeproxyd_internal_test.go
+++ b/enterprise/cli/aibridgeproxyd_internal_test.go
@@ -6,11 +6,8 @@ import (
"testing"
"github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
"github.com/coder/coder/v2/aibridge"
- agplcli "github.com/coder/coder/v2/cli"
- "github.com/coder/coder/v2/codersdk"
)
func TestDomainsFromProviders(t *testing.T) {
@@ -19,14 +16,11 @@ func TestDomainsFromProviders(t *testing.T) {
t.Run("ExtractsHostnames", func(t *testing.T) {
t.Parallel()
- providers, err := agplcli.BuildProviders(codersdk.AIBridgeConfig{
- Providers: []codersdk.AIProviderConfig{
- {Type: aibridge.ProviderOpenAI, Name: "openai", Keys: []string{"k"}},
- {Type: aibridge.ProviderAnthropic, Name: "anthropic", Keys: []string{"k"}},
- {Type: aibridge.ProviderOpenAI, Name: "custom", Keys: []string{"k"}, BaseURL: "https://custom-llm.example.com:8443/api"},
- },
- })
- require.NoError(t, err)
+ providers := []aibridge.Provider{
+ aibridge.NewOpenAIProvider(aibridge.OpenAIConfig{Name: "openai", BaseURL: "https://api.openai.com/v1/"}),
+ aibridge.NewAnthropicProvider(aibridge.AnthropicConfig{Name: "anthropic", BaseURL: "https://api.anthropic.com/"}, nil),
+ aibridge.NewOpenAIProvider(aibridge.OpenAIConfig{Name: "custom", BaseURL: "https://custom-llm.example.com:8443/api"}),
+ }
domains, mapping := domainsFromProviders(providers)
@@ -43,13 +37,10 @@ func TestDomainsFromProviders(t *testing.T) {
t.Run("DeduplicatesSameHost", func(t *testing.T) {
t.Parallel()
- providers, err := agplcli.BuildProviders(codersdk.AIBridgeConfig{
- Providers: []codersdk.AIProviderConfig{
- {Type: aibridge.ProviderOpenAI, Name: "first", Keys: []string{"k"}, BaseURL: "https://api.example.com/v1"},
- {Type: aibridge.ProviderOpenAI, Name: "second", Keys: []string{"k"}, BaseURL: "https://api.example.com/v2"},
- },
- })
- require.NoError(t, err)
+ providers := []aibridge.Provider{
+ aibridge.NewOpenAIProvider(aibridge.OpenAIConfig{Name: "first", BaseURL: "https://api.example.com/v1"}),
+ aibridge.NewOpenAIProvider(aibridge.OpenAIConfig{Name: "second", BaseURL: "https://api.example.com/v2"}),
+ }
domains, mapping := domainsFromProviders(providers)
@@ -68,12 +59,9 @@ func TestDomainsFromProviders(t *testing.T) {
t.Run("CaseInsensitive", func(t *testing.T) {
t.Parallel()
- providers, err := agplcli.BuildProviders(codersdk.AIBridgeConfig{
- Providers: []codersdk.AIProviderConfig{
- {Type: aibridge.ProviderOpenAI, Name: "provider", Keys: []string{"k"}, BaseURL: "https://API.Example.COM/v1"},
- },
- })
- require.NoError(t, err)
+ providers := []aibridge.Provider{
+ aibridge.NewOpenAIProvider(aibridge.OpenAIConfig{Name: "provider", BaseURL: "https://API.Example.COM/v1"}),
+ }
domains, mapping := domainsFromProviders(providers)
diff --git a/enterprise/cli/server.go b/enterprise/cli/server.go
index 0ffc730a37a1a..adba4f92187ce 100644
--- a/enterprise/cli/server.go
+++ b/enterprise/cli/server.go
@@ -167,7 +167,7 @@ func (r *RootCmd) Server(_ func()) *serpent.Command {
// in-memory roundtripper regardless of license); only the proxy
// daemon remains enterprise-gated by config.
if options.DeploymentValues.AI.BridgeProxyConfig.Enabled.Value() {
- providers, err := agplcli.BuildProviders(options.DeploymentValues.AI.BridgeConfig)
+ providers, err := agplcli.BuildProviders(ctx, options.Database, options.DeploymentValues.AI.BridgeConfig, options.Logger.Named("aibridge.providers"))
if err != nil {
return nil, nil, xerrors.Errorf("build AI providers: %w", err)
}
diff --git a/enterprise/cli/testdata/coder_server_--help.golden b/enterprise/cli/testdata/coder_server_--help.golden
index 1eab828120938..a9062a426f3d5 100644
--- a/enterprise/cli/testdata/coder_server_--help.golden
+++ b/enterprise/cli/testdata/coder_server_--help.golden
@@ -114,6 +114,12 @@ AI GATEWAY OPTIONS:
with AI budgets. "highest" selects the group with the largest spend
limit, and is currently the only supported value.
+ --ai-gateway-dump-dir string, $CODER_AI_GATEWAY_DUMP_DIR
+ Base directory for dumping AI Bridge request/response pairs to disk
+ for debugging. When set, each provider writes under a subdirectory
+ named after the provider. Sensitive headers are redacted. Leave empty
+ to disable.
+
--ai-gateway-allow-byok bool, $CODER_AI_GATEWAY_ALLOW_BYOK (default: true)
Allow users to provide their own LLM API keys or subscriptions. When
disabled, only centralized key authentication is permitted.
diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts
index af46758f9fd89..29a28130a15ab 100644
--- a/site/src/api/typesGenerated.ts
+++ b/site/src/api/typesGenerated.ts
@@ -82,6 +82,12 @@ export interface AIBridgeConfig {
readonly circuit_breaker_interval: number;
readonly circuit_breaker_timeout: number;
readonly circuit_breaker_max_requests: number;
+ /**
+ * APIDumpDir is the base directory under which each provider's
+ * request/response dumps are written, in a subdirectory named after
+ * the provider. Empty disables dumping.
+ */
+ readonly api_dump_dir: string;
}
// From codersdk/aibridge.go
@@ -384,10 +390,6 @@ export interface AIProviderConfig {
* BaseURL is the base URL of the upstream provider API.
*/
readonly base_url: string;
- /**
- * DumpDir is the directory path for dumping API requests and responses.
- */
- readonly dump_dir?: string;
readonly bedrock_region?: string;
readonly bedrock_model?: string;
readonly bedrock_small_fast_model?: string;
From 8a2f28fa6a2ea8bf755dd7836cf331eead9628d2 Mon Sep 17 00:00:00 2001
From: Thomas Kosiewski
Date: Tue, 26 May 2026 15:57:48 +0200
Subject: [PATCH 009/249] fix(dogfood/coder): fix install-deps heredoc and
/opt/mise ownership (#25678)
Co-authored-by: Claude Opus 4.7 (1M context)
Signed-off-by: Thomas Kosiewski
---
dogfood/coder/main.tf | 17 ++++++++++++++++-
dogfood/coder/ubuntu-22.04/Dockerfile.base | 7 ++++++-
dogfood/coder/ubuntu-26.04/Dockerfile.base | 7 ++++++-
3 files changed, 28 insertions(+), 3 deletions(-)
diff --git a/dogfood/coder/main.tf b/dogfood/coder/main.tf
index 81475418b9a18..0a5a20c0f7a49 100644
--- a/dogfood/coder/main.tf
+++ b/dogfood/coder/main.tf
@@ -679,11 +679,26 @@ resource "coder_script" "install-deps" {
display_name = "Installing Dependencies"
run_on_start = true
start_blocks_login = false
- script = <
Date: Wed, 27 May 2026 00:01:10 +1000
Subject: [PATCH 010/249] fix: resolve `stopWorkspaceIfRunning` common path
(#25639)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Closes #24333
This was a common setup in `updateWorkspace()` but was not appropriately
ported to `changeWorkspaceVersion()`. Some tests have been added also to
ensure this works 🙂 Simple smooth and easy.
---
site/src/api/api.test.ts | 97 ++++++++++++++++++++++++++++++++++++++++
site/src/api/api.ts | 36 +++++++++------
2 files changed, 120 insertions(+), 13 deletions(-)
diff --git a/site/src/api/api.test.ts b/site/src/api/api.test.ts
index 5d3abc999db4b..99eb768384782 100644
--- a/site/src/api/api.test.ts
+++ b/site/src/api/api.test.ts
@@ -1,4 +1,5 @@
import {
+ MockProvisionerJob,
MockStoppedWorkspace,
MockTemplate,
MockTemplateVersion2,
@@ -275,6 +276,102 @@ describe("api.ts", () => {
});
});
+ describe("changeWorkspaceVersion", () => {
+ it("stops workspace before changing version if running", async () => {
+ vi.spyOn(API, "stopWorkspace").mockResolvedValueOnce({
+ ...MockWorkspaceBuild,
+ transition: "stop",
+ });
+ vi.spyOn(API, "waitForBuild").mockResolvedValueOnce({
+ ...MockProvisionerJob,
+ status: "succeeded",
+ });
+ vi.spyOn(API, "getWorkspaceBuildParameters").mockResolvedValueOnce([]);
+ vi.spyOn(API, "getTemplateVersionRichParameters").mockResolvedValueOnce(
+ [],
+ );
+ vi.spyOn(API, "postWorkspaceBuild").mockResolvedValueOnce({
+ ...MockWorkspaceBuild,
+ template_version_id: MockTemplateVersion2.id,
+ transition: "start",
+ });
+
+ await API.changeWorkspaceVersion(MockWorkspace, MockTemplateVersion2.id);
+
+ expect(API.stopWorkspace).toHaveBeenCalledWith(MockWorkspace.id);
+ expect(API.postWorkspaceBuild).toHaveBeenCalledWith(MockWorkspace.id, {
+ transition: "start",
+ template_version_id: MockTemplateVersion2.id,
+ rich_parameter_values: [],
+ });
+ });
+
+ it("does not stop workspace if already stopped", async () => {
+ vi.spyOn(API, "stopWorkspace");
+ vi.spyOn(API, "getWorkspaceBuildParameters").mockResolvedValueOnce([]);
+ vi.spyOn(API, "getTemplateVersionRichParameters").mockResolvedValueOnce(
+ [],
+ );
+ vi.spyOn(API, "postWorkspaceBuild").mockResolvedValueOnce({
+ ...MockWorkspaceBuild,
+ template_version_id: MockTemplateVersion2.id,
+ transition: "start",
+ });
+
+ await API.changeWorkspaceVersion(
+ MockStoppedWorkspace,
+ MockTemplateVersion2.id,
+ );
+
+ expect(API.stopWorkspace).not.toHaveBeenCalled();
+ });
+
+ it("rejects if stop is canceled", async () => {
+ vi.spyOn(API, "stopWorkspace").mockResolvedValueOnce({
+ ...MockWorkspaceBuild,
+ transition: "stop",
+ });
+ vi.spyOn(API, "waitForBuild").mockResolvedValueOnce({
+ ...MockProvisionerJob,
+ status: "canceled",
+ });
+ vi.spyOn(API, "getWorkspaceBuildParameters").mockResolvedValueOnce([]);
+ vi.spyOn(API, "getTemplateVersionRichParameters").mockResolvedValueOnce(
+ [],
+ );
+ vi.spyOn(API, "postWorkspaceBuild");
+
+ await expect(
+ API.changeWorkspaceVersion(MockWorkspace, MockTemplateVersion2.id),
+ ).rejects.toThrow("Workspace stop was canceled");
+ expect(API.postWorkspaceBuild).not.toHaveBeenCalled();
+ });
+
+ it("throws MissingBuildParameters for missing params", async () => {
+ vi.spyOn(API, "getWorkspaceBuildParameters").mockResolvedValueOnce([]);
+ vi.spyOn(API, "getTemplateVersionRichParameters").mockResolvedValueOnce([
+ MockTemplateVersionParameter1,
+ { ...MockTemplateVersionParameter2, mutable: false },
+ ]);
+
+ let error = new Error();
+ try {
+ await API.changeWorkspaceVersion(
+ MockStoppedWorkspace,
+ MockTemplateVersion2.id,
+ );
+ } catch (e) {
+ error = e as Error;
+ }
+
+ expect(error).toBeInstanceOf(MissingBuildParameters);
+ expect((error as MissingBuildParameters).parameters).toEqual([
+ MockTemplateVersionParameter1,
+ { ...MockTemplateVersionParameter2, mutable: false },
+ ]);
+ });
+ });
+
describe("chat configuration endpoints", () => {
it.each<[string, () => Promise, unknown]>([
[
diff --git a/site/src/api/api.ts b/site/src/api/api.ts
index 99d3e44e384ff..4fef6cbc169de 100644
--- a/site/src/api/api.ts
+++ b/site/src/api/api.ts
@@ -2498,6 +2498,24 @@ class ApiMethods {
}));
};
+ /**
+ * Stops a workspace if it is currently running and waits for the stop
+ * to complete. Throws if the stop build is canceled.
+ */
+ private stopWorkspaceIfRunning = async (
+ workspace: TypesGen.Workspace,
+ ): Promise => {
+ // Workspace is already in a state where it's "stopped".
+ if (workspace.latest_build.status !== "running") return;
+
+ const stopBuild = await this.stopWorkspace(workspace.id);
+ const awaitedStopBuild = await this.waitForBuild(stopBuild);
+
+ if (awaitedStopBuild?.status === "canceled") {
+ throw new Error("Workspace stop was canceled.");
+ }
+ };
+
/** Steps to change the workspace version
* - Get the latest template to access the latest active version
* - Get the current build parameters
@@ -2505,6 +2523,7 @@ class ApiMethods {
* - Update the build parameters and check if there are missed parameters for
* the new version
* - If there are missing parameters raise an error
+ * - Stop the workspace if it is already running
* - Create a build with the version and updated build parameters
*/
changeWorkspaceVersion = async (
@@ -2539,6 +2558,8 @@ class ApiMethods {
throw new MissingBuildParameters(missingParameters, templateVersionId);
}
+ await this.stopWorkspaceIfRunning(workspace);
+
return this.postWorkspaceBuild(workspace.id, {
transition: "start",
template_version_id: templateVersionId,
@@ -2553,7 +2574,7 @@ class ApiMethods {
* - Update the build parameters and check if there are missed parameters for
* the newest version
* - If there are missing parameters raise an error
- * - Stop the workspace with the current template version if it is already running
+ * - Stop the workspace if it is already running
* - Create a build with the latest version and updated build parameters
*/
updateWorkspace = async (
@@ -2585,18 +2606,7 @@ class ApiMethods {
}
}
- // Stop the workspace if it is already running.
- if (workspace.latest_build.status === "running") {
- const stopBuild = await this.stopWorkspace(workspace.id);
- const awaitedStopBuild = await this.waitForBuild(stopBuild);
- // If the stop is canceled halfway through, we bail.
- // This is the same behaviour as restartWorkspace.
- if (awaitedStopBuild?.status === "canceled") {
- return Promise.reject(
- new Error("Workspace stop was canceled, not proceeding with update."),
- );
- }
- }
+ await this.stopWorkspaceIfRunning(workspace);
try {
return await this.postWorkspaceBuild(workspace.id, {
From 90d8f56cc27b2fafc8a4b551b1cb6612a785ca6f Mon Sep 17 00:00:00 2001
From: Danny Kopping
Date: Tue, 26 May 2026 17:08:45 +0200
Subject: [PATCH 011/249] fix(site): rename "AI Bridge" to "AI Gateway" in
paywall card (#25677)
*Disclaimer: implemented by a Coder Agent using Claude Opus 4.6*
## Summary
Renames "AI Bridge" to "AI Gateway" in the paywall card shown on the AI
Governance settings page when the feature is not entitled.
## Changes
In `PaywallAIGovernance.tsx`:
- Title: `AI Bridge` -> `AI Gateway`
- Description text: `AI Bridge provides...` / `AI Bridge requires...` ->
`AI Gateway provides...` / `AI Gateway requires...`
- Docs link label: `AI Bridge Docs` -> `AI Gateway Docs`
---
site/src/components/Paywall/PaywallAIGovernance.tsx | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/site/src/components/Paywall/PaywallAIGovernance.tsx b/site/src/components/Paywall/PaywallAIGovernance.tsx
index 0b8b7f9bec400..9aff0257f48d5 100644
--- a/site/src/components/Paywall/PaywallAIGovernance.tsx
+++ b/site/src/components/Paywall/PaywallAIGovernance.tsx
@@ -19,13 +19,13 @@ const PaywallAIGovernance = () => {
- AI Bridge
+ AI GatewayAI Governance
- AI Bridge provides auditable visibility into user prompts and LLM tool
- calls from developer tools within Coder Workspaces. AI Bridge requires
- a Premium license with AI Governance add-on.
+ AI Gateway provides auditable visibility into user prompts and LLM
+ tool calls from developer tools within Coder Workspaces. AI Gateway
+ requires a Premium license with AI Governance add-on.
Learn about AI Governance
@@ -49,7 +49,7 @@ const PaywallAIGovernance = () => {
rel="noreferrer"
className="text-content-link"
>
- AI Bridge Docs
+ AI Gateway Docs
From e2f01e5531388f63c217b77a83bb931a7a55f52d Mon Sep 17 00:00:00 2001
From: Ethan
Date: Wed, 27 May 2026 01:09:14 +1000
Subject: [PATCH 012/249] chore: prettify agents usage indicator (#25428)
Restyles the Agents page usage trigger to match the new quota meter
presentation by Tracy.
The trigger now shows one compact row per section with the existing
severity colors, the same spend icon used in settings, a server icon for
workspace quota, and right-aligned counters. The related stories were
updated to reflect the new trigger layout.
**Before:**
**After:**
Relates to CODAGT-197
---
.../ChatsSidebar/chats/UserSidebarFooter.tsx | 6 +-
.../components/UsageIndicator.stories.tsx | 66 ++++++++++++++++---
.../AgentsPage/components/UsageIndicator.tsx | 57 +++++++++++-----
3 files changed, 100 insertions(+), 29 deletions(-)
diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/chats/UserSidebarFooter.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/chats/UserSidebarFooter.tsx
index 2eb0ae1970ea4..53533f5dbc9c0 100644
--- a/site/src/pages/AgentsPage/components/ChatsSidebar/chats/UserSidebarFooter.tsx
+++ b/site/src/pages/AgentsPage/components/ChatsSidebar/chats/UserSidebarFooter.tsx
@@ -16,11 +16,13 @@ export const UserSidebarFooter: FC = () => {
return (
-
+ {/* This footer is resizable, so child sizing must follow its container width instead of the viewport. */}
+
diff --git a/site/src/pages/AgentsPage/components/UsageIndicator.stories.tsx b/site/src/pages/AgentsPage/components/UsageIndicator.stories.tsx
index 6f024a07fb92f..dbdb6c4bf4a4b 100644
--- a/site/src/pages/AgentsPage/components/UsageIndicator.stories.tsx
+++ b/site/src/pages/AgentsPage/components/UsageIndicator.stories.tsx
@@ -1,4 +1,4 @@
-import type { Meta, StoryObj } from "@storybook/react-vite";
+import type { Decorator, Meta, StoryObj } from "@storybook/react-vite";
import type { FC } from "react";
import { useQueryClient } from "react-query";
import { expect, userEvent, within } from "storybook/test";
@@ -57,11 +57,22 @@ const withUnavailableWorkspaceCount = (Story: FC) => {
return ;
};
-const withUsageIndicatorFrame = (Story: FC) => (
-
-
-
-);
+// Mirrors the sidebar footer wrapper: a fixed-width container with
+// container-type set so the trigger inside reacts to the wrapper's width
+// instead of the viewport's.
+const withUsageIndicatorFrame = (
+ widthClassName = "w-[320px]",
+ frameTestId?: string,
+): Decorator => {
+ return (Story) => (
+
+ );
+};
diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx
index e854718a7bdb8..10ea071eda794 100644
--- a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx
+++ b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx
@@ -27,7 +27,7 @@ import {
import { ListTemplatesTool } from "./ListTemplatesTool";
import { ProcessOutputTool } from "./ProcessOutputTool";
import { ProposePlanTool } from "./ProposePlanTool";
-import { ReadFileTool } from "./ReadFileTool";
+import { getReadFileToolData, ReadFileTool } from "./ReadFileTool";
import { ReadSkillTool } from "./ReadSkillTool";
import { ReadTemplateTool } from "./ReadTemplateTool";
import { StartWorkspaceTool } from "./StartWorkspaceTool";
@@ -315,22 +315,12 @@ const ReadFileRenderer: FC = ({
args,
result,
isError,
-}) => {
- const parsedArgs = parseArgs(args);
- const path = parsedArgs ? asString(parsedArgs.path).trim() : "";
- const rec = asRecord(result);
- const content = rec ? asString(rec.content).trim() : "";
-
- return (
-
- );
-};
+}) => (
+
+);
const ReadSkillRenderer: FC = ({
status,
From 8ae732000cdfc89df0245d63bc9320be04104518 Mon Sep 17 00:00:00 2001
From: Jake Howell
Date: Wed, 27 May 2026 02:01:53 +1000
Subject: [PATCH 014/249] feat(site): add UI primitives for the AI settings
stack (#25579)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
> 🤖 This PR was modified by Coder Agents on behalf of Jake Howell.
Linear: [DEVEX-355](https://linear.app/coder/issue/DEVEX-355)
First PR in a 5-PR stack splitting #25328. Adds the small UI primitives
the AI settings stack depends on.
- `FormField` accepts a `description` prop and renders a required
marker. `aria-describedby` is composed from the description, helper, and
error IDs.
- `PageHeader` title, subtitle, and caption forward `className` and
other intrinsic `h1`/`h2`/`span` props to their root elements.
- `AvatarData` gains an opt-in `truncate` prop that clips overflowing
title and subtitle with an ellipsis. Off by default so existing
consumers passing non-text nodes (icons, badges) do not clip silently.
- Bundles the Vercel provider icon (`vercel.svg`) and registers it in
`icons.json` and `externalImages.ts`.
No new pages or routes here; later PRs in the stack consume these
primitives.
Stack
1. **jakehwll/DEVEX-355/01-primitives, primitives (this PR)**
2. jakehwll/DEVEX-355/02-api, API client and query layer
3. jakehwll/DEVEX-355/03-components, provider form components
4. jakehwll/DEVEX-355/04-pages, pages and routes
5. jakehwll/DEVEX-355/05-section, section reshuffle
Replaces #25328 once the stack lands.
---
.../components/Avatar/AvatarData.stories.tsx | 10 ++
site/src/components/Avatar/AvatarData.tsx | 27 ++-
.../FormField/FormField.stories.tsx | 160 ++++++++++++++++++
site/src/components/FormField/FormField.tsx | 31 +++-
site/src/components/PageHeader/PageHeader.tsx | 50 ++++--
.../StarterTemplatePageView.tsx | 2 +-
.../pages/TemplatePage/TemplatePageHeader.tsx | 6 +-
site/src/theme/externalImages.ts | 1 +
site/src/theme/icons.json | 1 +
site/static/icon/vercel.svg | 3 +
10 files changed, 269 insertions(+), 22 deletions(-)
create mode 100644 site/src/components/FormField/FormField.stories.tsx
create mode 100644 site/static/icon/vercel.svg
diff --git a/site/src/components/Avatar/AvatarData.stories.tsx b/site/src/components/Avatar/AvatarData.stories.tsx
index 22f8cb45d7699..62185254c41cf 100644
--- a/site/src/components/Avatar/AvatarData.stories.tsx
+++ b/site/src/components/Avatar/AvatarData.stories.tsx
@@ -20,3 +20,13 @@ export const WithImage: Story = {
src: "https://avatars.githubusercontent.com/u/95932066?s=200&v=4",
},
};
+
+export const WithLongTitle: Story = {
+ args: {
+ truncate: true,
+ title: "a-workspace-with-an-unreasonably-long-name-that-should-be-clipped",
+ subtitle:
+ "and-an-even-longer-organization-or-template-subtitle-that-truncates",
+ },
+ decorators: [(Story) =>
{Story()}
],
+};
diff --git a/site/src/components/Avatar/AvatarData.tsx b/site/src/components/Avatar/AvatarData.tsx
index 7e2515c9340b6..698e7df608cd0 100644
--- a/site/src/components/Avatar/AvatarData.tsx
+++ b/site/src/components/Avatar/AvatarData.tsx
@@ -1,5 +1,6 @@
import type { FC, ReactNode } from "react";
import { Avatar } from "#/components/Avatar/Avatar";
+import { cn } from "#/utils/cn";
interface AvatarDataProps {
title: ReactNode;
@@ -15,6 +16,13 @@ interface AvatarDataProps {
* from the title prop if it is a string.
*/
imgFallbackText?: string;
+
+ /**
+ * When true, the title and subtitle clip with an ellipsis if they overflow
+ * the available width. Off by default because callers that pass non-text
+ * nodes (icons, badges) as `title` would otherwise clip silently.
+ */
+ truncate?: boolean;
}
export const AvatarData: FC = ({
@@ -23,6 +31,7 @@ export const AvatarData: FC = ({
src,
imgFallbackText,
avatar,
+ truncate = false,
}) => {
if (!avatar) {
avatar = (
@@ -38,12 +47,24 @@ export const AvatarData: FC = ({
The notable new feature in this release is support for AVIF
images (both encoder and decoder). There's a demo site set up that
demonstrates the difference between HDR AVIF and SDR JPEG images. Note
that that demo is only really interesting if viewed on an HDR capable
screen (e.g. Apple Retina).
Security fixes
There are some notable security fixes in this release.
Security fixes in Go
This release upgrades from Go 1.26.1 to 126.3, which brings a set of
security fixes. Some relevant for Hugo are:
XSS in html/template (CVE-2026-39826 & CVE-2026-39823): Two
separate vulnerabilities where escaper bypasses in html/template could
lead to Cross-Site Scripting (XSS).
html/template: Fixes an issue where JS template literal contexts
were incorrectly tracked across template branches, which could lead to
improper content escaping.
Security fixes and hardening in Hugo
The following changes either fix a concrete issue or reduce the
default attack surface of hugo builds.
Disallow text/html content files by
default (e41a064).
A new security.allowContent policy gates which content
media types may be used for pages under /content.
text/html is denied by default; sites that rely on
hand-authored or adapter-emitted HTML content can opt back in with
security.allowContent = ['.*'].
Re-check security.http.urls on every redirect
hop in resources.GetRemote (86fbb0f).
Reject symlinked entries in
resources.Get (f8b5fa0).
We will update this section later with links to CVEs where
applicable.
All changes
hugolib: Fix Page.GitInfo for modules with go.mod in a repo
subdirectory df542191 @bep#14942
Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore ` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore ` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore ` will
remove the ignore condition of the specified dependency and ignore
conditions
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
go.mod | 2 +-
go.sum | 4 ++--
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/go.mod b/go.mod
index 54bfc271e9f80..c84156c8c05c0 100644
--- a/go.mod
+++ b/go.mod
@@ -228,7 +228,7 @@ require (
go.uber.org/goleak v1.3.1-0.20240429205332-517bace7cc29
go.uber.org/mock v0.6.0
go4.org/netipx v0.0.0-20230728180743-ad4cb58a6516
- golang.org/x/crypto v0.51.0
+ golang.org/x/crypto v0.52.0
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f
golang.org/x/mod v0.36.0
golang.org/x/net v0.55.0
diff --git a/go.sum b/go.sum
index 16ebc5d33b684..314cc3fa1f7f2 100644
--- a/go.sum
+++ b/go.sum
@@ -1378,8 +1378,8 @@ golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDf
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
-golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
-golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
+golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
+golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
From c96c1ab563272b741dfcffe16c5b832dada85e13 Mon Sep 17 00:00:00 2001
From: Danielle Maywood
Date: Tue, 26 May 2026 20:00:37 +0100
Subject: [PATCH 025/249] feat: add agents sidebar filters (#25402)
---
site/src/api/queries/chats.test.ts | 99 ++++-
site/src/api/queries/chats.ts | 133 ++++--
site/src/pages/AgentsPage/AgentsPage.test.ts | 44 ++
site/src/pages/AgentsPage/AgentsPage.tsx | 64 ++-
.../AgentsPage/AgentsPageView.stories.tsx | 34 +-
site/src/pages/AgentsPage/AgentsPageView.tsx | 13 +-
.../ChatsSidebar/ChatsSidebar.stories.tsx | 147 +-----
.../ChatsSidebar/ChatsSidebar.test.tsx | 111 ++++-
.../components/ChatsSidebar/ChatsSidebar.tsx | 13 +-
.../ChatsSidebar/chats/ChatsPanel.tsx | 257 ++++++-----
.../filters/FilterDropdown.stories.tsx | 33 --
.../ChatsSidebar/filters/FilterDropdown.tsx | 55 ---
.../filters/FilterPopover.stories.tsx | 96 ++++
.../ChatsSidebar/filters/FilterPopover.tsx | 419 ++++++++++++++++++
.../components/ChatsSidebar/locationSearch.ts | 2 +
.../ChatsSidebar/settings/SettingsNavItem.tsx | 4 +-
.../ChatsSidebar/tree/ChatTreeNode.tsx | 4 +-
.../hooks/useArchivedFilterParam.test.ts | 67 ---
.../hooks/useArchivedFilterParam.ts | 31 --
.../utils/agentSidebarFilters.test.ts | 122 +++++
.../AgentsPage/utils/agentSidebarFilters.ts | 126 ++++++
21 files changed, 1322 insertions(+), 552 deletions(-)
delete mode 100644 site/src/pages/AgentsPage/components/ChatsSidebar/filters/FilterDropdown.stories.tsx
delete mode 100644 site/src/pages/AgentsPage/components/ChatsSidebar/filters/FilterDropdown.tsx
create mode 100644 site/src/pages/AgentsPage/components/ChatsSidebar/filters/FilterPopover.stories.tsx
create mode 100644 site/src/pages/AgentsPage/components/ChatsSidebar/filters/FilterPopover.tsx
create mode 100644 site/src/pages/AgentsPage/components/ChatsSidebar/locationSearch.ts
delete mode 100644 site/src/pages/AgentsPage/hooks/useArchivedFilterParam.test.ts
delete mode 100644 site/src/pages/AgentsPage/hooks/useArchivedFilterParam.ts
create mode 100644 site/src/pages/AgentsPage/utils/agentSidebarFilters.test.ts
create mode 100644 site/src/pages/AgentsPage/utils/agentSidebarFilters.ts
diff --git a/site/src/api/queries/chats.test.ts b/site/src/api/queries/chats.test.ts
index 79a182472ba8a..8b17d17e06393 100644
--- a/site/src/api/queries/chats.test.ts
+++ b/site/src/api/queries/chats.test.ts
@@ -28,12 +28,14 @@ import {
deleteChatQueuedMessage,
editChatMessage,
infiniteChats,
+ infiniteChatsKey,
interruptChat,
invalidateChatListQueries,
mergeWatchedChatIntoCaches,
mergeWatchedChatSummary,
paginatedChatCostUsers,
pinChat,
+ prependToInfiniteChatsCache,
promoteChatQueuedMessage,
proposeChatTitle,
regenerateChatTitle,
@@ -73,9 +75,9 @@ vi.mock("#/api/api", () => ({
},
}));
-// The infinite query key used by useInfiniteQuery(infiniteChats())
-// is [...chatsKey, undefined] = ["chats", undefined].
-const infiniteChatsTestKey = [...chatsKey, undefined];
+type InfiniteChatsTestOptions = Parameters[0];
+
+const infiniteChatsTestKey = infiniteChatsKey();
type InfiniteData = {
pages: TypesGen.Chat[][];
@@ -86,8 +88,9 @@ type InfiniteData = {
const seedInfiniteChats = (
queryClient: QueryClient,
chats: TypesGen.Chat[],
+ opts?: InfiniteChatsTestOptions,
) => {
- queryClient.setQueryData(infiniteChatsTestKey, {
+ queryClient.setQueryData(infiniteChatsKey(opts), {
pages: [chats],
pageParams: [0],
});
@@ -96,8 +99,9 @@ const seedInfiniteChats = (
/** Read chats back from the infinite query cache. */
const readInfiniteChats = (
queryClient: QueryClient,
+ opts?: InfiniteChatsTestOptions,
): TypesGen.Chat[] | undefined => {
- const data = queryClient.getQueryData(infiniteChatsTestKey);
+ const data = queryClient.getQueryData(infiniteChatsKey(opts));
return data?.pages.flat();
};
@@ -191,7 +195,7 @@ describe("invalidateChatListQueries", () => {
// Sidebar queries.
queryClient.setQueryData(chatsKey, [makeChat(chatId)]);
- queryClient.setQueryData([...chatsKey, { archived: false }], {
+ queryClient.setQueryData(infiniteChatsKey({ archived: false }), {
pages: [[makeChat(chatId)]],
pageParams: [0],
});
@@ -212,7 +216,7 @@ describe("invalidateChatListQueries", () => {
"flat chats should be invalidated",
).toBe(true);
expect(
- queryClient.getQueryState([...chatsKey, { archived: false }])
+ queryClient.getQueryState(infiniteChatsKey({ archived: false }))
?.isInvalidated,
"infinite chats should be invalidated",
).toBe(true);
@@ -240,7 +244,7 @@ describe("invalidateChatListQueries", () => {
it("invalidates the infinite query with undefined opts", async () => {
const queryClient = createTestQueryClient();
- queryClient.setQueryData([...chatsKey, undefined], {
+ queryClient.setQueryData(infiniteChatsKey(), {
pages: [[makeChat("chat-1")]],
pageParams: [0],
});
@@ -248,7 +252,7 @@ describe("invalidateChatListQueries", () => {
await invalidateChatListQueries(queryClient);
expect(
- queryClient.getQueryState([...chatsKey, undefined])?.isInvalidated,
+ queryClient.getQueryState(infiniteChatsKey())?.isInvalidated,
"infinite chats with undefined opts should be invalidated",
).toBe(true);
});
@@ -273,6 +277,21 @@ describe("invalidateChatListQueries", () => {
"other chat's chatMessagesKey should NOT be invalidated",
).not.toBe(true);
});
+
+ it("prepends new root chats to filtered list caches", () => {
+ const queryClient = createTestQueryClient();
+ const activeChat = makeChat("active-created", { archived: false });
+
+ seedInfiniteChats(queryClient, [makeChat("active-existing")], {
+ archived: false,
+ });
+
+ prependToInfiniteChatsCache(queryClient, activeChat);
+
+ expect(readInfiniteChats(queryClient, { archived: false })?.[0]).toEqual(
+ activeChat,
+ );
+ });
});
describe("updateChatPlanMode optimistic update", () => {
@@ -374,7 +393,7 @@ describe("archiveChat optimistic update", () => {
// Verify the optimistic update took effect.
expect(readInfiniteChats(queryClient)?.[0].archived).toBe(true);
- // Simulate an error — the onError handler invalidates the
+ // Simulate an error, the onError handler invalidates the
// cache so a re-fetch restores the correct state.
mutation.onError(new Error("server error"), chatId, context);
@@ -542,7 +561,7 @@ describe("pinChat optimistic update", () => {
makeChat(chatId),
makeChat("chat-pinned-2", { pin_order: 2 }),
]);
- queryClient.setQueryData([...chatsKey, { archived: true }], {
+ queryClient.setQueryData(infiniteChatsKey({ archived: true }), {
pages: [[makeChat("chat-pinned-archived", { pin_order: 4 })]],
pageParams: [0],
});
@@ -787,7 +806,7 @@ describe("chat cost query factories", () => {
describe("mutation invalidation scope", () => {
// These tests assert the CORRECT (narrow) invalidation behaviour.
// Each mutation should only invalidate the queries it actually
- // needs to refresh — not the entire ["chats"] prefix tree. The
+ // needs to refresh, not the entire ["chats"] prefix tree. The
// WebSocket stream already delivers real-time updates for
// messages, status changes, and sidebar ordering, so broad
// prefix invalidation causes a burst of redundant HTTP requests
@@ -797,7 +816,7 @@ describe("mutation invalidation scope", () => {
* observed on the /agents/:id detail page. */
const seedAllActiveQueries = (queryClient: QueryClient, chatId: string) => {
// Infinite sidebar list: ["chats", { archived: false }]
- queryClient.setQueryData([...chatsKey, { archived: false }], {
+ queryClient.setQueryData(infiniteChatsKey({ archived: false }), {
pages: [[makeChat(chatId)]],
pageParams: [0],
});
@@ -1205,7 +1224,7 @@ describe("mutation invalidation scope", () => {
const queryClient = createTestQueryClient();
const chatId = "chat-1";
- // Page 0 (newest): IDs 10–6. Page 1 (older): IDs 5–1.
+ // Page 0 (newest): IDs 10 to 6. Page 1 (older): IDs 5 to 1.
const page0 = [10, 9, 8, 7, 6].map((id) => makeMsg(chatId, id));
const page1 = [5, 4, 3, 2, 1].map((id) => makeMsg(chatId, id));
const optimisticMessage = buildOptimisticMessage(requireMessage(page0, 7));
@@ -1374,7 +1393,10 @@ describe("mutation invalidation scope", () => {
for (const { label, key } of [
{ label: "flat chats", key: chatsKey },
- { label: "infinite chats", key: [...chatsKey, { archived: false }] },
+ {
+ label: "infinite chats",
+ key: infiniteChatsKey({ archived: false }),
+ },
{ label: "chat detail", key: chatKey(chatId) },
{ label: "messages", key: chatMessagesKey(chatId) },
...unrelatedKeys(chatId),
@@ -1404,7 +1426,7 @@ describe("mutation invalidation scope", () => {
"flat chats should be invalidated",
).toBe(true);
expect(
- queryClient.getQueryState([...chatsKey, { archived: false }])
+ queryClient.getQueryState(infiniteChatsKey({ archived: false }))
?.isInvalidated,
"infinite chats should be invalidated",
).toBe(true);
@@ -1520,6 +1542,39 @@ describe("infiniteChats", () => {
});
});
+ it("builds q from archived, prStatuses, and chatStatus", async () => {
+ vi.mocked(API.experimental.getChats).mockResolvedValue([]);
+ const { queryFn } = infiniteChats({
+ archived: true,
+ prStatuses: ["draft", "open", "merged"],
+ chatStatus: "unread",
+ });
+
+ await queryFn({ pageParam: 0 });
+
+ expect(API.experimental.getChats).toHaveBeenCalledWith({
+ limit: PAGE_LIMIT,
+ offset: 0,
+ q: "archived:true pr_status:draft,open,merged has_unread:true",
+ });
+ });
+
+ it("builds q for read chat status", async () => {
+ vi.mocked(API.experimental.getChats).mockResolvedValue([]);
+ const { queryFn } = infiniteChats({
+ archived: false,
+ chatStatus: "read",
+ });
+
+ await queryFn({ pageParam: 0 });
+
+ expect(API.experimental.getChats).toHaveBeenCalledWith({
+ limit: PAGE_LIMIT,
+ offset: 0,
+ q: "archived:false has_unread:false",
+ });
+ });
+
it("throws when pageParam is not a number", () => {
const { queryFn } = infiniteChats();
expect(() => queryFn({ pageParam: "bad" })).toThrow(
@@ -1548,7 +1603,7 @@ describe("diff_status_change invalidation scope", () => {
// These tests verify the CORRECT invalidation pattern for
// diff_status_change WebSocket events. The handler should
// invalidate only the individual chat detail and diff-contents
- // queries — NOT the chat list (sidebar) or messages.
+ // queries, NOT the chat list (sidebar) or messages.
it("exact chatKey invalidation does not cascade to messages or diff-contents", async () => {
const queryClient = createTestQueryClient();
@@ -1560,7 +1615,7 @@ describe("diff_status_change invalidation scope", () => {
queryClient.setQueryData(chatDiffContentsKey(chatId), { files: [] });
queryClient.setQueryData(chatsKey, [makeChat(chatId)]);
- // This is what the fixed handler does — exact: true.
+ // This is what the fixed handler does, exact: true.
await queryClient.invalidateQueries({
queryKey: chatKey(chatId),
exact: true,
@@ -1599,7 +1654,7 @@ describe("diff_status_change invalidation scope", () => {
queryClient.setQueryData(chatMessagesKey(chatId), []);
queryClient.setQueryData(chatDiffContentsKey(chatId), { files: [] });
- // This is what the OLD (broken) handler did — no exact: true.
+ // This is what the OLD (broken) handler did, no exact: true.
await queryClient.invalidateQueries({
queryKey: chatKey(chatId),
});
@@ -1715,7 +1770,7 @@ describe("cancelChatListRefetches", () => {
seedInfiniteChats(queryClient, [makeChat(chatId, { title: "original" })]);
- // Start an in-flight refetch (no fetchMeta — simulates a
+ // Start an in-flight refetch (no fetchMeta, simulates a
// regular invalidation or window-focus refetch).
const fetchDone = queryClient.prefetchQuery({
queryKey: infiniteChatsTestKey,
@@ -1778,7 +1833,7 @@ describe("cancelChatListRefetches", () => {
await cancelChatListRefetches(queryClient);
await fetchDone;
- // The fetch was NOT cancelled — the new data landed.
+ // The fetch was NOT cancelled, the new data landed.
const title = readInfiniteChats(queryClient)?.find(
(c) => c.id === chatId,
)?.title;
@@ -1825,7 +1880,7 @@ describe("cancelChatListRefetches", () => {
const queryClient = createTestQueryClient();
const chatId = "chat-1";
- // Do NOT seed the cache — simulate the very first fetch
+ // Do NOT seed the cache, simulate the very first fetch
// where no data exists yet.
const fetchDone = queryClient.prefetchQuery({
queryKey: infiniteChatsTestKey,
diff --git a/site/src/api/queries/chats.ts b/site/src/api/queries/chats.ts
index 88e6c15797874..0da5ec219761f 100644
--- a/site/src/api/queries/chats.ts
+++ b/site/src/api/queries/chats.ts
@@ -27,6 +27,51 @@ export const chatPromptsKey = (chatId: string) =>
export const chatACLKey = (chatId: string) => ["chats", chatId, "acl"] as const;
+export type ChatListPRStatusFilter = "draft" | "open" | "merged" | "closed";
+export type ChatListStatusFilter = "read" | "unread";
+
+type InfiniteChatsFilters = Readonly<{
+ archived?: boolean;
+ prStatuses?: readonly ChatListPRStatusFilter[];
+ chatStatus?: ChatListStatusFilter;
+}>;
+
+export const infiniteChatsKey = (filters?: {
+ archived?: boolean;
+ prStatuses?: readonly ChatListPRStatusFilter[];
+ chatStatus?: ChatListStatusFilter;
+}) => [...chatsKey, filters] as const;
+
+export const CHAT_LIST_PR_STATUS_ORDER = [
+ "draft",
+ "open",
+ "merged",
+ "closed",
+] as const satisfies readonly ChatListPRStatusFilter[];
+
+const chatListPRStatusSet = new Set(
+ CHAT_LIST_PR_STATUS_ORDER,
+);
+
+type InfiniteChatsCacheData = InfiniteData;
+
+/** Shared ordering keeps URL serialization stable. */
+export const canonicalizeChatListPRStatuses = (
+ prStatuses: Iterable,
+): readonly ChatListPRStatusFilter[] => {
+ const selected = new Set();
+ for (const prStatus of prStatuses) {
+ if (
+ typeof prStatus === "string" &&
+ chatListPRStatusSet.has(prStatus as ChatListPRStatusFilter)
+ ) {
+ selected.add(prStatus as ChatListPRStatusFilter);
+ }
+ }
+
+ return CHAT_LIST_PR_STATUS_ORDER.filter((status) => selected.has(status));
+};
+
export const chatsByWorkspaceKeyPrefix = [...chatsKey, "by-workspace"] as const;
export const chatsByWorkspace = (workspaceIds: string[]) => {
@@ -48,17 +93,16 @@ export const updateInfiniteChatsCache = (
updater: (chats: TypesGen.Chat[]) => TypesGen.Chat[],
) => {
// Update ALL infinite chat queries regardless of their filter opts.
- queryClient.setQueriesData<{
- pages: TypesGen.Chat[][];
- pageParams: unknown[];
- }>({ queryKey: chatsKey, predicate: isChatListQuery }, (prev) => {
- if (!prev) return prev;
- if (!prev.pages) return prev;
- const nextPages = prev.pages.map((page) => updater(page));
- // Only return a new reference if something actually changed.
- const changed = nextPages.some((page, i) => page !== prev.pages[i]);
- return changed ? { ...prev, pages: nextPages } : prev;
- });
+ queryClient.setQueriesData(
+ { queryKey: chatsKey, predicate: isChatListQuery },
+ (prev) => {
+ if (!prev?.pages) return prev;
+ const nextPages = prev.pages.map((page) => updater(page));
+ // Only return a new reference if something actually changed.
+ const changed = nextPages.some((page, i) => page !== prev.pages[i]);
+ return changed ? { ...prev, pages: nextPages } : prev;
+ },
+ );
};
/**
@@ -72,22 +116,22 @@ export const prependToInfiniteChatsCache = (
queryClient: QueryClient,
chat: TypesGen.Chat,
) => {
- queryClient.setQueriesData<{
- pages: TypesGen.Chat[][];
- pageParams: unknown[];
- }>({ queryKey: chatsKey, predicate: isChatListQuery }, (prev) => {
- if (!prev?.pages) return prev;
- // Check across ALL pages to avoid duplicates.
- const exists = prev.pages.some((page) =>
- page.some((c) => c.id === chat.id),
- );
- if (exists) return prev;
- // Only prepend to the first page.
- const nextPages = prev.pages.map((page, i) =>
- i === 0 ? [chat, ...page] : page,
- );
- return { ...prev, pages: nextPages };
- });
+ queryClient.setQueriesData(
+ { queryKey: chatsKey, predicate: isChatListQuery },
+ (prev) => {
+ if (!prev?.pages) return prev;
+ // Check across ALL pages to avoid duplicates.
+ const exists = prev.pages.some((page) =>
+ page.some((c) => c.id === chat.id),
+ );
+ if (exists) return prev;
+ // Only prepend to the first page.
+ const nextPages = prev.pages.map((page, i) =>
+ i === 0 ? [chat, ...page] : page,
+ );
+ return { ...prev, pages: nextPages };
+ },
+ );
};
/**
@@ -97,10 +141,10 @@ export const prependToInfiniteChatsCache = (
export const readInfiniteChatsCache = (
queryClient: QueryClient,
): TypesGen.Chat[] | undefined => {
- const queries = queryClient.getQueriesData<{
- pages: TypesGen.Chat[][];
- pageParams: unknown[];
- }>({ queryKey: chatsKey, predicate: isChatListQuery });
+ const queries = queryClient.getQueriesData({
+ queryKey: chatsKey,
+ predicate: isChatListQuery,
+ });
for (const [, data] of queries) {
if (data?.pages) {
return data.pages.flat();
@@ -504,21 +548,28 @@ const toChatPlanModePayload = (
return planMode ?? CLEAR_PLAN_MODE_WIRE_VALUE;
};
-export const infiniteChats = (opts?: { q?: string; archived?: boolean }) => {
- const limit = DEFAULT_CHAT_PAGE_LIMIT;
-
- // Build the search query string including the archived filter.
+const getInfiniteChatsQueryString = (
+ filters: InfiniteChatsFilters | undefined,
+): string | undefined => {
const qParts: string[] = [];
- if (opts?.q) {
- qParts.push(opts.q);
+ if (filters?.archived !== undefined) {
+ qParts.push(`archived:${filters.archived}`);
+ }
+ if (filters?.prStatuses?.length) {
+ qParts.push(`pr_status:${filters.prStatuses.join(",")}`);
}
- if (opts?.archived !== undefined) {
- qParts.push(`archived:${opts.archived}`);
+ if (filters?.chatStatus) {
+ qParts.push(`has_unread:${filters.chatStatus === "unread"}`);
}
- const q = qParts.length > 0 ? qParts.join(" ") : undefined;
+ return qParts.length > 0 ? qParts.join(" ") : undefined;
+};
+
+export const infiniteChats = (filters?: InfiniteChatsFilters) => {
+ const limit = DEFAULT_CHAT_PAGE_LIMIT;
+ const q = getInfiniteChatsQueryString(filters);
return {
- queryKey: [...chatsKey, opts],
+ queryKey: infiniteChatsKey(filters),
getNextPageParam: (lastPage: TypesGen.Chat[], pages: TypesGen.Chat[][]) => {
if (lastPage.length < limit) {
return undefined;
diff --git a/site/src/pages/AgentsPage/AgentsPage.test.ts b/site/src/pages/AgentsPage/AgentsPage.test.ts
index a47bb5f5628ff..90cc1431102bb 100644
--- a/site/src/pages/AgentsPage/AgentsPage.test.ts
+++ b/site/src/pages/AgentsPage/AgentsPage.test.ts
@@ -1,5 +1,7 @@
import { act, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import type * as TypesGen from "#/api/typesGenerated";
+import { shouldInvalidateFilteredChatList } from "./AgentsPage";
import {
emptyInputStorageKey,
useEmptyStateDraft,
@@ -885,3 +887,45 @@ describe("useFileAttachments processResizes", () => {
unmount();
});
});
+
+const chatForFilterInvalidation = (
+ overrides: Partial = {},
+): TypesGen.Chat =>
+ ({
+ id: "chat-1",
+ archived: false,
+ parent_chat_id: null,
+ ...overrides,
+ }) as TypesGen.Chat;
+
+describe(shouldInvalidateFilteredChatList.name, () => {
+ it.each<{
+ name: string;
+ updatedChat: TypesGen.Chat;
+ eventKind: TypesGen.ChatWatchEventKind;
+ expected: boolean;
+ }>([
+ {
+ name: "invalidates root chats for membership events",
+ updatedChat: chatForFilterInvalidation(),
+ eventKind: "diff_status_change",
+ expected: true,
+ },
+ {
+ name: "ignores non-membership events",
+ updatedChat: chatForFilterInvalidation(),
+ eventKind: "title_change",
+ expected: false,
+ },
+ {
+ name: "excludes child chats",
+ updatedChat: chatForFilterInvalidation({ parent_chat_id: "parent-1" }),
+ eventKind: "diff_status_change",
+ expected: false,
+ },
+ ])("$name", ({ updatedChat, eventKind, expected }) => {
+ expect(shouldInvalidateFilteredChatList(updatedChat, eventKind)).toBe(
+ expected,
+ );
+ });
+});
diff --git a/site/src/pages/AgentsPage/AgentsPage.tsx b/site/src/pages/AgentsPage/AgentsPage.tsx
index 951161453cb2a..6faaa1507e940 100644
--- a/site/src/pages/AgentsPage/AgentsPage.tsx
+++ b/site/src/pages/AgentsPage/AgentsPage.tsx
@@ -5,7 +5,12 @@ import {
useQuery,
useQueryClient,
} from "react-query";
-import { useLocation, useNavigate, useParams } from "react-router";
+import {
+ useLocation,
+ useNavigate,
+ useParams,
+ useSearchParams,
+} from "react-router";
import { toast } from "sonner";
import { API, watchChats } from "#/api/api";
import { getErrorMessage } from "#/api/errors";
@@ -51,7 +56,7 @@ import { AgentsPageView } from "./AgentsPageView";
import { emptyInputStorageKey } from "./components/AgentCreateForm";
import { useAgentsPageKeybindings } from "./hooks/useAgentsPageKeybindings";
import { useAgentsPWA } from "./hooks/useAgentsPWA";
-import { useArchivedFilterParam } from "./hooks/useArchivedFilterParam";
+import { getAgentSidebarFilters } from "./utils/agentSidebarFilters";
import {
archiveChatAndDeleteWorkspace,
resolveArchiveAndDeleteAction,
@@ -67,18 +72,33 @@ import {
export type { AgentsOutletContext } from "./AgentsPageView";
+const FILTER_MEMBERSHIP_EVENT_KINDS = new Set([
+ "diff_status_change",
+ "status_change",
+]);
+
+export const shouldInvalidateFilteredChatList = (
+ chat: TypesGen.Chat,
+ eventKind: TypesGen.ChatWatchEventKind,
+): boolean =>
+ !chat.parent_chat_id && FILTER_MEMBERSHIP_EVENT_KINDS.has(eventKind);
+
const AgentsPage: FC = () => {
useAgentsPWA();
const queryClient = useQueryClient();
const navigate = useNavigate();
const location = useLocation();
+ const [searchParams, setSearchParams] = useSearchParams();
const { agentId } = useParams();
const { permissions, user } = useAuthenticated();
const { organizations } = useDashboard();
const organizationName = getDefaultOrganizationName(organizations);
const isAgentsAdmin = permissions.editDeploymentConfig;
- const [archivedFilter, setArchivedFilter] = useArchivedFilterParam();
+ const [sidebarFilters, setSidebarFilters] = getAgentSidebarFilters(
+ searchParams,
+ setSearchParams,
+ );
const [isSearchDialogOpen, setIsSearchDialogOpen] = useState(false);
// The global CSS sets scrollbar-gutter: stable on to prevent
@@ -124,8 +144,17 @@ const AgentsPage: FC = () => {
};
}, []);
+ const archivedFilter = sidebarFilters.archiveStatus === "archived";
+ const chatStatusFilter =
+ sidebarFilters.chatStatuses.length === 1
+ ? sidebarFilters.chatStatuses[0]
+ : undefined;
const chatsQuery = useInfiniteQuery(
- infiniteChats({ archived: archivedFilter === "archived" }),
+ infiniteChats({
+ archived: archivedFilter,
+ prStatuses: sidebarFilters.prStatuses,
+ chatStatus: chatStatusFilter,
+ }),
);
// Model queries are kept here for the sidebar, which displays
// model info alongside each chat. Child routes that need models
@@ -368,8 +397,9 @@ const AgentsPage: FC = () => {
queryFn: () => API.getWorkspaceBuilds(workspaceId),
}),
() =>
- readInfiniteChatsCache(queryClient)?.find((c) => c.id === chatId)
- ?.created_at,
+ readInfiniteChatsCache(queryClient)?.find(
+ (chat) => chat.id === chatId,
+ )?.created_at,
);
if (action === "proceed") {
archiveAndDeleteMutation.mutate(
@@ -511,6 +541,7 @@ const AgentsPage: FC = () => {
});
return changed ? next : chats;
});
+ void invalidateChatListQueries(queryClient);
}, [agentId, queryClient]);
useEffect(() => {
return createReconnectingWebSocket({
@@ -524,13 +555,9 @@ const AgentsPage: FC = () => {
}
const chatEvent = event.parsedMessage;
const updatedChat = chatEvent.chat;
- // Read the previous status from the infinite chat list
- // cache before we write the update below. The per-chat
- // query cache (chatKey) only exists for chats the user
- // has opened, so reading from the list cache ensures
- // prevStatus is available for background agents too.
+ // The old membership is only available before the cache write below.
const prevStatus = readInfiniteChatsCache(queryClient)?.find(
- (c) => c.id === updatedChat.id,
+ (chat) => chat.id === updatedChat.id,
)?.status;
// Only play the chime for top-level chats, not sub-agents.
if (!updatedChat.parent_chat_id) {
@@ -592,11 +619,6 @@ const AgentsPage: FC = () => {
});
}
- // For "created" events, use a cross-page existence
- // check and prepend only to the first page.
- // updateInfiniteChatsCache runs the updater per
- // page, so a naive prepend would duplicate the
- // chat into every loaded page.
if (chatEvent.kind === "created") {
if (updatedChat.parent_chat_id) {
// Child chat: add to its parent's children
@@ -609,12 +631,16 @@ const AgentsPage: FC = () => {
);
} else {
prependToInfiniteChatsCache(queryClient, updatedChat);
+ void invalidateChatListQueries(queryClient);
}
} else {
mergeWatchedChatIntoCaches(queryClient, updatedChat, {
eventKind: chatEvent.kind,
activeChatId: activeChatIDRef.current,
});
+ if (shouldInvalidateFilteredChatList(updatedChat, chatEvent.kind)) {
+ void invalidateChatListQueries(queryClient);
+ }
}
});
return ws;
@@ -684,8 +710,8 @@ const AgentsPage: FC = () => {
hasNextPage={chatsQuery.hasNextPage}
onLoadMore={() => void chatsQuery.fetchNextPage()}
isFetchingNextPage={chatsQuery.isFetchingNextPage}
- archivedFilter={archivedFilter}
- onArchivedFilterChange={setArchivedFilter}
+ sidebarFilters={sidebarFilters}
+ onSidebarFiltersChange={setSidebarFilters}
/>
= {
regeneratingTitleChatIds: [],
onToggleSidebarCollapsed: fn(),
isAgentsAdmin: false,
- archivedFilter: "active",
- onArchivedFilterChange: fn(),
+ sidebarFilters: defaultSidebarFilters,
+ onSidebarFiltersChange: fn(),
hasNextPage: false,
onLoadMore: fn(),
isFetchingNextPage: false,
@@ -429,28 +437,6 @@ type Story = StoryObj;
export const EmptyState: Story = {};
-export const ArchivedEmptyState: Story = {
- args: {
- archivedFilter: "archived",
- chatList: [],
- },
- parameters: {
- reactRouter: reactRouterParameters({
- location: {
- path: "/agents",
- searchParams: { archived: "archived" },
- },
- routing: agentsRouting,
- }),
- },
- play: async () => {
- await expect(await screen.findByText("No archived agents")).toBeVisible();
- await expect(
- screen.getByRole("button", { name: /back to active/i }),
- ).toBeVisible();
- },
-};
-
export const WithChatList: Story = {
args: {
chatList: [
diff --git a/site/src/pages/AgentsPage/AgentsPageView.tsx b/site/src/pages/AgentsPage/AgentsPageView.tsx
index 830c821b323f5..817c0f7c29c57 100644
--- a/site/src/pages/AgentsPage/AgentsPageView.tsx
+++ b/site/src/pages/AgentsPage/AgentsPageView.tsx
@@ -10,6 +10,7 @@ import {
sidebarViewFromPath,
} from "./components/ChatsSidebar/ChatsSidebar";
import { ResizableChatsSidebarFrame } from "./components/ChatsSidebar/ResizableChatsSidebarFrame";
+import type { AgentSidebarFilters } from "./utils/agentSidebarFilters";
import type { ChatDetailError } from "./utils/usageLimitMessage";
export interface AgentsOutletContext {
@@ -75,8 +76,8 @@ interface AgentsPageViewProps {
hasNextPage: boolean | undefined;
onLoadMore: () => void;
isFetchingNextPage: boolean;
- archivedFilter: "active" | "archived";
- onArchivedFilterChange: (filter: "active" | "archived") => void;
+ sidebarFilters: AgentSidebarFilters;
+ onSidebarFiltersChange: (filters: AgentSidebarFilters) => void;
}
export const AgentsPageView: FC = ({
@@ -115,8 +116,8 @@ export const AgentsPageView: FC = ({
hasNextPage,
onLoadMore,
isFetchingNextPage,
- archivedFilter,
- onArchivedFilterChange,
+ sidebarFilters,
+ onSidebarFiltersChange,
}) => {
const location = useLocation();
const sidebarView = sidebarViewFromPath(location.pathname);
@@ -203,8 +204,8 @@ export const AgentsPageView: FC = ({
hasNextPage={hasNextPage}
onLoadMore={onLoadMore}
isFetchingNextPage={isFetchingNextPage}
- archivedFilter={archivedFilter}
- onArchivedFilterChange={onArchivedFilterChange}
+ sidebarFilters={sidebarFilters}
+ onSidebarFiltersChange={onSidebarFiltersChange}
onCollapse={onCollapseSidebar}
isPersonalModelOverridesEnabled={isPersonalModelOverridesEnabled}
isAdmin={isAgentsAdmin}
diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.stories.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.stories.tsx
index 1577f5e4e629d..5294ba61c0a9d 100644
--- a/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.stories.tsx
+++ b/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.stories.tsx
@@ -1,7 +1,6 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { ComponentProps } from "react";
import { useEffect, useState } from "react";
-import { useLocation } from "react-router";
import { expect, fn, userEvent, waitFor, within } from "storybook/test";
import { reactRouterParameters } from "storybook-addon-remix-react-router";
import { userChatProviderConfigsKey } from "#/api/queries/chats";
@@ -13,24 +12,10 @@ import {
withDashboardProvider,
} from "#/testHelpers/storybook";
import { useAgentsPageKeybindings } from "../../hooks/useAgentsPageKeybindings";
+import type { AgentSidebarFilters } from "../../utils/agentSidebarFilters";
import type { ModelSelectorOption } from "../ChatElements";
import { ChatsSidebar } from "./ChatsSidebar";
-// Probe element used by the archived-filter preservation story to surface the
-// search string of whatever child route the sidebar's NavLink ends up at.
-const ChildSearchProbe = () => {
- const location = useLocation();
- return
{location.search}
;
-};
-
-// Probe element used by the settings-link preservation story to surface the
-// state.from value passed when navigating to settings.
-const SettingsStateProbe = () => {
- const location = useLocation();
- const from = (location.state as { from?: string })?.from ?? "";
- return
);
};
@@ -277,15 +311,8 @@ const UsageProgress: FC<{
ariaLabel: string;
percent: number;
severity?: UsageSeverity;
- size?: "default" | "compact";
className?: string;
-}> = ({
- ariaLabel,
- percent,
- severity = "normal",
- size = "default",
- className,
-}) => {
+}> = ({ ariaLabel, percent, severity = "normal", className }) => {
const clampedPercent = clampPercent(percent);
return (
@@ -296,8 +323,7 @@ const UsageProgress: FC<{
aria-valuemax={100}
aria-valuenow={Math.round(clampedPercent)}
className={cn(
- size === "compact" ? "h-1" : "h-1.5",
- "overflow-hidden rounded-full bg-surface-tertiary",
+ "h-1.5 overflow-hidden rounded-full bg-surface-tertiary",
className,
)}
>
@@ -353,6 +379,17 @@ function getProgressClassName(severity: UsageSeverity): string {
}
}
+function getRingStrokeClassName(severity: UsageSeverity): string {
+ switch (severity) {
+ case "exceeded":
+ return "stroke-content-destructive";
+ case "warning":
+ return "stroke-content-warning";
+ case "normal":
+ return "stroke-content-secondary";
+ }
+}
+
function getTextClassName(severity: UsageSeverity = "normal"): string {
switch (severity) {
case "exceeded":
From e91bec85748189657d1e7e006d44c8aa4973622d Mon Sep 17 00:00:00 2001
From: Ethan
Date: Wed, 27 May 2026 17:33:14 +1000
Subject: [PATCH 037/249] fix(cli): close aibridge daemon before WebSocket
shutdown wait (#25719)
> [!WARNING]
> The investigation and solution in this PR were done with
[Mux](https://mux.coder.com/). I've reviewed the investigation
methodology, evidence and solution, and it all appears sound.
## Summary
PR #25570 (`refactor: move aibridged out of enterprise to AGPL`, merged
2026-05-22) added an in-memory aibridge DRPC server in
`coderd/aibridged.go` that does `api.WebsocketWaitGroup.Add(1)` and only
releases `Done()` when its client session is closed. PR #25575 then
flipped `CODER_AI_GATEWAY_ENABLED` to default to `true`, so every
`cli.Server()` invocation now spins up that goroutine.
In `cli/server.go`, the only call to `aibridgeDaemon.Close()` was a
`defer` scheduled at function return. During graceful shutdown the code
first calls `coderAPICloser.Close()`, which waits on
`api.WebsocketWaitGroup`. That wait sits for the full 10s timeout in
`coderd/coderd.go` (`websocket shutdown timed out after 10 seconds`),
then returns, then the function unwinds, and only then does the deferred
`aibridgeDaemon.Close()` fire and let the goroutine call `Done()`.
The 10s tax was previously latent (aibridged was enterprise-only and
opt-in). After the two May 22 PRs it hit every `cli.Server()` test. On
Linux/macOS CI it just makes the suite slower; on the Depot Windows
runner, the ramdisk reservation leaves only ~17 GiB of headroom and the
~10s shutdown tails of multiple concurrent package binaries overlap into
an OOM, presenting as `test-go-pg (windows-2022)` jobs that die silently
at the ~600s watchdog with an empty `steps` array.
See Slack:
https://codercom.slack.com/archives/C05AE94121Z/p1779807717764189
## Fix
Close `aibridgeDaemon` explicitly during graceful shutdown, **before**
`coderAPICloser.Close()` waits on the WebSocket wait group. This matches
the existing ordered-shutdown pattern used for `tunnel` and
`notificationsManager`. The deferred `aibridgeDaemon.Close()` is
retained as a safety net for early-return paths, and is safe to
double-call because `aibridged.Server.Close()` is already idempotent via
`shutdownOnce` in `coderd/aibridged/aibridged.go`.
## Regression test
`TestServer_AIGatewayShutdownOrdering` boots a real `coder server` with
`--ai-gateway-enabled=true`, cancels its context, and asserts graceful
shutdown finishes in under 8s. With the fix the test runs in ~0.1s;
without the fix it fails deterministically at ~10.0s. The flag is passed
explicitly so the test continues to guard the ordering even if the
deployment default is ever flipped back.
## Evidence this fixes the OOM
On Linux the patched `cli` test package drops from 114 s back to its
pre-regression 30 s wall time at the same single-process peak RSS (~7.6
GiB), and the `websocket shutdown timed out after 10 seconds` log line
disappears from every server-test run. Since the Windows OOM is the sum
of multiple concurrent 10 s shutdown tails overlapping past the runner's
~17 GiB headroom, removing those tails returns the concurrent-RSS budget
to its pre-regression level. The Windows OOM was intermittent (a handful
of hits across many runs since May 22), so a single green `test-go-pg
(windows-2022)` job on this PR is not by itself proof; confirmation will
come from watching Windows runs on `main` over the next several days and
seeing the ~600 s silent-kill fingerprint stop recurring.
Relates to ENG-2771
---
cli/server.go | 9 ++++++++-
cli/server_test.go | 47 ++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 55 insertions(+), 1 deletion(-)
diff --git a/cli/server.go b/cli/server.go
index c8ff2357f599f..3fed22aeb5aec 100644
--- a/cli/server.go
+++ b/cli/server.go
@@ -63,6 +63,7 @@ import (
"github.com/coder/coder/v2/cli/cliutil"
"github.com/coder/coder/v2/cli/config"
"github.com/coder/coder/v2/coderd"
+ "github.com/coder/coder/v2/coderd/aibridged"
"github.com/coder/coder/v2/coderd/autobuild"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/awsiamrds"
@@ -1014,6 +1015,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
if err != nil {
return xerrors.Errorf("create coder API: %w", err)
}
+ var aibridgeDaemon *aibridged.Server
// Both seed (writes) and build (reads) of AI providers need
// options.Database to be dbcrypt-wrapped, which only happens
@@ -1044,7 +1046,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
if err != nil {
return xerrors.Errorf("build AI providers: %w", err)
}
- aibridgeDaemon, err := newAIBridgeDaemon(coderAPI, aibridgeProviders)
+ aibridgeDaemon, err = newAIBridgeDaemon(coderAPI, aibridgeProviders)
if err != nil {
return xerrors.Errorf("create aibridged: %w", err)
}
@@ -1310,6 +1312,11 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
}
wg.Wait()
+ // The in-memory aibridge server participates in the websocket
+ // wait group, so close its client before waiting for that group.
+ if aibridgeDaemon != nil {
+ _ = aibridgeDaemon.Close()
+ }
cliui.Info(inv.Stdout, "Waiting for WebSocket connections to close..."+"\n")
_ = coderAPICloser.Close()
cliui.Info(inv.Stdout, "Done waiting for WebSocket connections"+"\n")
diff --git a/cli/server_test.go b/cli/server_test.go
index 5215eeb08ca2b..89e0ba7048c8d 100644
--- a/cli/server_test.go
+++ b/cli/server_test.go
@@ -2184,6 +2184,53 @@ func TestServer_InterruptShutdown(t *testing.T) {
require.NoError(t, err)
}
+// TestServer_AIGatewayShutdownOrdering is a regression test for a shutdown
+// ordering bug. The in-memory AI Gateway daemon registers itself with the
+// API WebsocketWaitGroup, so it must be closed before coderAPICloser.Close()
+// waits on that group. If it isn't, API.Close() blocks for the full 10s
+// WebsocketWaitGroup timeout, logs "websocket shutdown timed out after 10
+// seconds", and keeps heavy server-test state live for an extra 10s. On
+// Windows test-go-pg this extra shutdown tail overlapped across concurrent
+// package binaries and OOMed the runner.
+func TestServer_AIGatewayShutdownOrdering(t *testing.T) {
+ t.Parallel()
+
+ ctx, cancel := context.WithCancel(testutil.Context(t, testutil.WaitLong))
+ defer cancel()
+
+ inv, cfg := clitest.New(t,
+ "server",
+ dbArg(t),
+ "--http-address", ":0",
+ "--access-url", "http://example.com",
+ "--cache-dir", t.TempDir(),
+ // Explicit so the test catches the regression even if the
+ // default for ai-gateway-enabled is ever flipped back to false.
+ "--ai-gateway-enabled=true",
+ )
+
+ serverErr := make(chan error, 1)
+ go func() {
+ serverErr <- inv.WithContext(ctx).Run()
+ }()
+
+ // Wait for the server to come up so the in-memory AI Gateway daemon
+ // is registered with the API and the WebsocketWaitGroup is nonzero.
+ _ = waitAccessURL(t, cfg)
+
+ // The WebsocketWaitGroup timeout in coderd.API.Close() is hard coded
+ // to 10s, so any value comfortably below 10s catches the regression
+ // while leaving headroom for slow CI runners.
+ shutdownStart := time.Now()
+ cancel()
+ if err := <-serverErr; err != nil {
+ require.ErrorIs(t, err, context.Canceled)
+ }
+ require.Less(t, time.Since(shutdownStart), 8*time.Second,
+ "graceful shutdown took too long; the in-memory AI Gateway daemon is "+
+ "likely not being closed before coderAPICloser.Close()")
+}
+
func TestServer_GracefulShutdown(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
From bfa17c315ec2e5b6f90f7a00fc228ff41a5abc70 Mon Sep 17 00:00:00 2001
From: Thomas Kosiewski
Date: Wed, 27 May 2026 09:54:09 +0200
Subject: [PATCH 038/249] fix(dogfood/coder): persist mise user installs
(#25720)
---
dogfood/coder/main.tf | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/dogfood/coder/main.tf b/dogfood/coder/main.tf
index 176b99392d2a1..aad65c886f1b7 100644
--- a/dogfood/coder/main.tf
+++ b/dogfood/coder/main.tf
@@ -518,6 +518,11 @@ resource "coder_agent" "dev" {
# (non-MISE keys flow through). Move this back to `[oci.env]`
# once upstream mise fixes that.
MISE_CONFIG_DIR : "/home/coder/.config/mise",
+ # Keep user-installed mise tools on the persistent home volume.
+ # The image still exposes baked tools from /opt/mise/data via
+ # MISE_SHARED_INSTALL_DIRS, but /opt itself is image-resident
+ # and is recreated with the container on workspace restart.
+ MISE_DATA_DIR : "/home/coder/.local/share/mise",
},
data.coder_parameter.enable_ai_gateway.value ? {
ANTHROPIC_BASE_URL : "https://dev.coder.com/api/v2/aibridge/anthropic",
From 9c10ec2ca7d632a5984026e9292727b8ade01f15 Mon Sep 17 00:00:00 2001
From: Jake Howell
Date: Wed, 27 May 2026 18:36:55 +1000
Subject: [PATCH 039/249] fix: resolve mui `` regression
(#25716)
---
site/src/components/Table/Table.tsx | 5 ++---
site/src/components/Timeline/TimelineDateRow.tsx | 4 ++--
site/src/pages/AuditPage/AuditLogRow/AuditLogRow.tsx | 6 +++---
3 files changed, 7 insertions(+), 8 deletions(-)
diff --git a/site/src/components/Table/Table.tsx b/site/src/components/Table/Table.tsx
index 5cc4b2095a70b..85af9eda5fa72 100644
--- a/site/src/components/Table/Table.tsx
+++ b/site/src/components/Table/Table.tsx
@@ -99,9 +99,8 @@ export const TableRow: React.FC = ({
return (
+
+ {showThinkingIcon && }
+ {shimmer ? (
+
+ {text}
+
+ ) : (
+
+ {text}
+
+ )}
+
);
};
diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx
index 37e8a013febf9..41013e45ecbcd 100644
--- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx
+++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx
@@ -1,9 +1,4 @@
-import {
- BrainIcon,
- ChevronLeftIcon,
- ChevronRightIcon,
- PencilIcon,
-} from "lucide-react";
+import { ChevronLeftIcon, ChevronRightIcon, PencilIcon } from "lucide-react";
import {
type FC,
Fragment,
@@ -44,6 +39,7 @@ import {
} from "../ChatElements/tools/ReadFileTool";
import type { SubagentVariant } from "../ChatElements/tools/subagentDescriptor";
import { ToolCollapsible } from "../ChatElements/tools/ToolCollapsible";
+import { ToolIcon } from "../ChatElements/tools/ToolIcon";
import { ImageLightbox } from "../ImageLightbox";
import { TextPreviewDialog } from "../TextPreviewDialog";
import {
@@ -166,7 +162,7 @@ const ReasoningDisclosure = memo<{
onExpandedChange={(open) => setManualToggle(open)}
header={
<>
-
+
{isStreaming ? (
{title}
diff --git a/site/src/pages/AgentsPage/components/ChatConversation/StreamingOutput.tsx b/site/src/pages/AgentsPage/components/ChatConversation/StreamingOutput.tsx
index e95dadffea64e..af8534d960210 100644
--- a/site/src/pages/AgentsPage/components/ChatConversation/StreamingOutput.tsx
+++ b/site/src/pages/AgentsPage/components/ChatConversation/StreamingOutput.tsx
@@ -1,4 +1,3 @@
-import { BrainIcon } from "lucide-react";
import type { FC } from "react";
import type { UrlTransform } from "streamdown";
import type * as TypesGen from "#/api/typesGenerated";
@@ -10,6 +9,7 @@ import {
} from "../ChatElements";
import { TranscriptRow } from "../ChatElements/TranscriptRow";
import type { SubagentVariant } from "../ChatElements/tools/subagentDescriptor";
+import { ToolIcon } from "../ChatElements/tools/ToolIcon";
import { ChatStatusCallout } from "./ChatStatusCallout";
import { BlockList } from "./ConversationTimeline";
import type { LiveStatusModel } from "./liveStatusModel";
@@ -37,7 +37,7 @@ const hasTextOrReasoningBlock = (blocks: readonly RenderBlock[]): boolean =>
const StreamingThinkingPlaceholder: FC = () => (
-
+
Thinking
diff --git a/site/src/pages/AgentsPage/components/ChatElements/Conversation.stories.tsx b/site/src/pages/AgentsPage/components/ChatElements/Conversation.stories.tsx
index e0038879ebe8b..17ab70c005673 100644
--- a/site/src/pages/AgentsPage/components/ChatElements/Conversation.stories.tsx
+++ b/site/src/pages/AgentsPage/components/ChatElements/Conversation.stories.tsx
@@ -2,7 +2,6 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
import { Conversation, ConversationItem } from "./Conversation";
import { Message, MessageContent } from "./Message";
import { Shimmer } from "./Shimmer";
-import { Thinking } from "./Thinking";
const meta: Meta = {
title: "pages/AgentsPage/ChatElements/Conversation",
@@ -37,10 +36,6 @@ export const ConversationWithMessages: Story = {
-
- Inspecting auth state and recent command output before
- suggesting a fix.
-
The remote command failed because external auth needs to be
refreshed.
diff --git a/site/src/pages/AgentsPage/components/ChatElements/Thinking.tsx b/site/src/pages/AgentsPage/components/ChatElements/Thinking.tsx
deleted file mode 100644
index 1bf736a8e8c54..0000000000000
--- a/site/src/pages/AgentsPage/components/ChatElements/Thinking.tsx
+++ /dev/null
@@ -1,21 +0,0 @@
-import { BrainIcon } from "lucide-react";
-import type { ComponentPropsWithRef } from "react";
-import { cn } from "#/utils/cn";
-
-type ThinkingProps = ComponentPropsWithRef<"div">;
-
-export const Thinking = ({ className, ref, ...props }: ThinkingProps) => {
- return (
-
-
-
{props.children}
-
- );
-};
diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ToolIcon.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ToolIcon.tsx
index 4e79adae938c0..dd8cb1cd0488f 100644
--- a/site/src/pages/AgentsPage/components/ChatElements/tools/ToolIcon.tsx
+++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ToolIcon.tsx
@@ -9,6 +9,7 @@ import {
PowerIcon,
RouteIcon,
ServerIcon,
+ SparklesIcon,
TerminalIcon,
WrenchIcon,
} from "lucide-react";
@@ -116,6 +117,8 @@ export const ToolIcon: React.FC<{
return ;
case "chat_summarized":
return ;
+ case "thinking":
+ return ;
case "propose_plan":
return ;
case "ask_user_question":
From e32fdc813b98fb52df48701773bcb1d26e82b307 Mon Sep 17 00:00:00 2001
From: Nick Vigilante
Date: Thu, 28 May 2026 10:03:21 -0400
Subject: [PATCH 076/249] ci: rerun docs preview job on subsequent pushes
(#25456)
Fixes DOCS-174: the docs-preview workflow only fired on `pull_request:
opened`. Subsequent pushes left the preview comment stale.
## Changes
- Add `synchronize` and `reopened` to trigger types so subsequent pushes
retrigger the workflow.
- Add a workflow-level `concurrency` group keyed by PR number with
`cancel-in-progress: true` so rapid successive pushes don't race the
comment-upsert lookup.
- Replace always-create comment logic with an upsert: find the existing
comment containing `` and PATCH it; fall through to
create only when none exists or the PATCH itself fails (comment was
deleted between find and update).
- Filter the upsert lookup to comments authored by `github-actions[bot]`
so a human comment containing the marker is never silently overwritten.
- Decouple the `gh api` lookup from the `head -n 1` pipe so API failures
(network, auth, rate-limit) propagate immediately instead of being
swallowed by `|| true`.
- Delete the stale preview comment when a `synchronize` push drops all
Markdown changes (e.g. a follow-up push that removes the file an earlier
push had previewed but still touches `docs/`). The previous preview
comment would otherwise point at a deleted page.
- Extract the marker and the comment-selector jq into a single
`DOCS_PREVIEW_MARKER` variable and a `list_docs_preview_comments` shell
function so the stale-cleanup and upsert branches share one source of
truth.
## Out of scope
Vercel ISR cache invalidation for feature branch previews requires a
coder.com change (the `algolia-docs-sync` endpoint only accepts `main`
and `release/*` refs). Tracked separately in DOCS-174 out-of-scope
notes.
Pulls that fully revert their `docs/` changes in a follow-up push won't
fire this workflow at all (GitHub's `paths` filter requires a path match
in the diff), so a stale preview comment can survive on that specific
edge. Removing the `paths` filter to handle it would run the workflow on
every PR push, which is disproportionate. Acknowledged in
[CRF-12](https://github.com/coder/coder/pull/25456#discussion_r3313738550).
Implementation notes
**Marker and selector deduplication**: The marker string and jq selector
previously appeared at three sites (comment body, stale-cleanup API
call, upsert API call). They're now consolidated into
`DOCS_PREVIEW_MARKER` plus a `list_docs_preview_comments` shell function
so a future marker change updates one place.
**Comment body construction**: The double-quoted multi-line string form
with escaped backticks (`` \` ``) for the inline-code spans is
shellcheck-clean. An earlier draft used `printf -v comment_body` with a
single-quoted format string containing backticks, which triggered
SC2016; the printf-three-pieces workaround that replaced it has since
been simplified to the direct double-quoted form.
**Upsert logic**: `gh api --paginate` fetches all PR comments, jq
filters to `github-actions[bot]`-authored comments containing the
marker, and the workflow PATCHes the first match. If the PATCH fails
(404 because the comment was deleted between find and update), the
script falls through to `gh pr comment` to create a new one. Self-heals
on the next push if both paths somehow fail.
**Stale-cleanup logic**: Same selector as upsert, but in the early-exit
branch when no Markdown files exist in this push. `DELETE` failures are
logged and execution continues (the next push will re-attempt or post a
fresh comment), so a transient API failure won't fail the CI job.
> Generated by Coder Agents on behalf of @nickvigilante
---
.github/workflows/docs-preview.yaml | 101 +++++++++++++++++++++++++---
1 file changed, 93 insertions(+), 8 deletions(-)
diff --git a/.github/workflows/docs-preview.yaml b/.github/workflows/docs-preview.yaml
index c585a61acd814..8f00114e653e2 100644
--- a/.github/workflows/docs-preview.yaml
+++ b/.github/workflows/docs-preview.yaml
@@ -1,5 +1,5 @@
# This workflow posts a docs preview link as a PR comment whenever a
-# pull request that touches files under docs/ is opened. The preview
+# pull request that touches docs/ is opened or updated. The preview
# is served by coder.com's branch-preview feature at /docs/@.
#
# The link deep-links to the first added/modified/renamed Markdown file
@@ -7,8 +7,12 @@
# Branch names are URL-encoded so that names containing slashes or
# other special characters produce working links.
#
-# If the PR only deletes Markdown files (or only changes non-Markdown
-# files such as images or manifest.json), no comment is posted.
+# On subsequent pushes (synchronize) the existing comment is updated
+# rather than creating a duplicate. If a previous push had a Markdown
+# file but the current push has none, the stale comment is deleted so
+# readers don't follow a dead deep-link. If the PR only deletes
+# Markdown files (or only changes non-Markdown files such as images or
+# manifest.json), no comment is posted.
name: docs-preview
@@ -16,9 +20,15 @@ on:
pull_request:
types:
- opened
+ - synchronize
+ - reopened
paths:
- "docs/**"
+concurrency:
+ group: docs-preview-${{ github.event.pull_request.number }}
+ cancel-in-progress: true
+
permissions:
contents: read
@@ -35,6 +45,22 @@ jobs:
PR_NUMBER: ${{ github.event.pull_request.number }}
REPO: ${{ github.repository }}
run: |
+ # Marker embedded in the comment body so we can find this
+ # workflow's own comments later. Keep this in one place so
+ # later refactors don't drift between the body construction
+ # and the jq selectors used to find existing comments.
+ DOCS_PREVIEW_MARKER=''
+
+ # Returns IDs of github-actions[bot] comments on the PR whose
+ # body contains DOCS_PREVIEW_MARKER. Used by both the stale-
+ # comment-cleanup branch (when this push has no Markdown
+ # changes) and the upsert branch below.
+ list_docs_preview_comments() {
+ gh api --paginate \
+ "repos/${REPO}/issues/${PR_NUMBER}/comments" \
+ --jq ".[] | select(.user.login == \"github-actions[bot]\") | select(.body | contains(\"${DOCS_PREVIEW_MARKER}\")) | .id"
+ }
+
# Fetch the list of non-deleted files from the PR. This is
# intentionally not piped into grep so that a gh-api failure
# (network, auth, rate-limit) propagates immediately instead
@@ -51,7 +77,38 @@ jobs:
| head -n 1) || true
if [ -z "$first_doc" ]; then
- echo "No added/modified Markdown files under docs/, skipping preview comment."
+ echo "No added/modified Markdown files under docs/ on this push."
+
+ # Now that the workflow fires on synchronize, this branch
+ # is reachable on pushes that drop all Markdown while still
+ # touching docs/ (e.g. a push that removes the file an
+ # earlier push had previewed but adds a new image). The
+ # previous preview comment now points at a deleted page;
+ # delete it so readers don't follow a dead deep-link.
+ #
+ # Intentionally decoupled from head so that a gh-api failure
+ # propagates here instead of being swallowed by `|| true`. In
+ # this branch the workflow has no preview link to post anyway
+ # (no Markdown in the push), so a transient list failure is a
+ # cosmetic miss; log and exit cleanly rather than red-checking
+ # every docs-touching PR during a comments-endpoint hiccup.
+ # The next push will retry the cleanup. The upsert path below
+ # uses strict propagation by contrast, because silent failure
+ # there would create duplicate comments.
+ stale_comment_ids=$(list_docs_preview_comments) || {
+ echo "Could not list preview comments; skipping cleanup."
+ exit 0
+ }
+ stale_id=$(printf '%s\n' "$stale_comment_ids" | head -n 1) || true
+
+ if [ -n "$stale_id" ]; then
+ if gh api --method DELETE \
+ "repos/${REPO}/issues/comments/${stale_id}"; then
+ echo "Deleted stale docs preview comment (id=${stale_id})."
+ else
+ echo "Failed to delete stale docs preview comment (id=${stale_id}); leaving in place."
+ fi
+ fi
exit 0
fi
@@ -97,9 +154,37 @@ jobs:
url="${url}/${page_path}"
fi
- gh pr comment "${PR_NUMBER}" \
- --repo "${REPO}" \
- --body "## Docs preview
+ # The literal backticks around ${first_doc} are escaped so
+ # they survive the double-quoted string as Markdown inline
+ # code; ${url} and ${first_doc} expand normally.
+ comment_body="## Docs preview
[:book: View docs preview](${url}) for \`${first_doc}\`
- "
+ ${DOCS_PREVIEW_MARKER}"
+
+ # Upsert: update the existing docs-preview comment if one
+ # exists, otherwise create a new one. This prevents duplicate
+ # preview comments on every push to the PR.
+ #
+ # Intentionally not piped into head so that a gh-api failure
+ # (network, auth, rate-limit) propagates immediately instead
+ # of being swallowed by `|| true`.
+ all_comment_ids=$(list_docs_preview_comments)
+ existing_id=$(printf '%s\n' "$all_comment_ids" | head -n 1) || true
+
+ if [ -n "$existing_id" ]; then
+ if ! gh api --method PATCH \
+ "repos/${REPO}/issues/comments/${existing_id}" \
+ --field body="$comment_body"; then
+ echo "PATCH failed (comment may have been deleted); creating a new comment."
+ existing_id=""
+ else
+ echo "Updated existing docs preview comment (id=${existing_id})."
+ fi
+ fi
+ if [ -z "$existing_id" ]; then
+ gh pr comment "${PR_NUMBER}" \
+ --repo "${REPO}" \
+ --body "$comment_body"
+ echo "Created new docs preview comment."
+ fi
From ea280c5a906032c7501920f3ce4211dd14d393c1 Mon Sep 17 00:00:00 2001
From: Nick Vigilante
Date: Thu, 28 May 2026 10:48:53 -0400
Subject: [PATCH 077/249] docs(docs/install): strengthen Linux-only requirement
on Docker install page (#25742)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Closes DOCS-68.
Promotes the existing "Linux only" guidance on `docs/install/docker.md`
from an easy-to-miss bullet point to a prominent `[!IMPORTANT]` callout,
and briefly states *why* the page is Linux-only so macOS readers do not
waste time on the `getent` / `--group-add` snippets.
## Why this re-scope vs. the original ticket
The original DOCS-68 scope was "add a macOS `getent` alternative". On
inspection, that framing has three problems:
1. The Requirements section already says "A Linux machine. For macOS
devices, start Coder using the standalone binary," so macOS users are
already redirected. The signal just lives in a bullet that is easy to
overlook.
2. The `--group-add $DOCKER_GROUP` mechanism that drives the `getent`
call is Linux-specific. macOS Docker runtimes (Docker Desktop, Colima,
Rancher Desktop, Podman) use a VM and forward the socket differently;
the flag does not translate cleanly to any of them.
3. Defining a canonical macOS Docker path is the scope of
[DEVREL-22](https://linear.app/codercom/issue/DEVREL-22) (recommend
Colima / Rancher / Podman alternatives in the Quick Start guide).
DOCS-68 should not pre-empt that work.
This PR narrows the fix to making the existing macOS guidance
unmissable. A real macOS Docker install path can come as a separate
follow-up once DEVREL-22 lands and the recommended runtime is settled.
Decision log
* **(A) Close DOCS-68 as absorbed by DEVREL-22.** Rejected — the install
page still has a discoverability problem that DEVREL-22 (Quick Start)
will not fix.
* **(B) Re-scope DOCS-68 to a narrow today-fix (this PR).** Selected.
* **(C) Defer DOCS-68 until DEVREL-22 lands.** Rejected — the install
page is shipping the weaker guidance every day until then.
> [!NOTE]
> This is a docs-only change. No product code was modified.
---
*Generated by Coder Agents on behalf of @nickvigilante.*
---
docs/install/docker.md | 9 +++++++--
1 file changed, 7 insertions(+), 2 deletions(-)
diff --git a/docs/install/docker.md b/docs/install/docker.md
index 63bc5cd7b9474..31a7628c7a915 100644
--- a/docs/install/docker.md
+++ b/docs/install/docker.md
@@ -8,11 +8,16 @@ You can install and run Coder using the official Docker images published on
- Docker. See the
[official installation documentation](https://docs.docker.com/install/).
-- A Linux machine. For macOS devices, start Coder using the
- [standalone binary](./cli.md).
+- A Linux host.
- 2 CPU cores and 4 GB memory free on your machine.
+> [!IMPORTANT]
+> This guide is for **Linux** hosts only. The `getent` and `--group-add`
+> Docker socket patterns used below are Linux-specific and do not translate
+> cleanly to macOS Docker runtimes. For macOS, install Coder using the
+> [standalone binary](./cli.md) instead.
+
## Install Coder via `docker compose`
From 6df1536256475730d2fdba72083d3142c3089a25 Mon Sep 17 00:00:00 2001
From: Cian Johnston
Date: Thu, 28 May 2026 15:50:52 +0100
Subject: [PATCH 078/249] fix: add missing_key error kind for missing chat
api_key_id (#25783)
Refs CODAGT-486
- `codersdk/chats.go`: New `ChatErrorKindMissingKey` constant and
`AllChatErrorKinds` entry
- `coderd/x/chatd/chaterror/message.go`: `terminalMessage` and
`retryMessage` cases
- `coderd/x/chatd/model_routing_aibridge.go`: Pre-classify error with
`WithClassification`
- `coderd/x/chatd/model_routing_internal_test.go`: Classification
assertion on production path (CRF-2)
- `chatStatusHelpers.ts`: Frontend title "Chat interrupted"
- `LiveStreamTail.stories.tsx`: Storybook story with `detail` assertion
- `docs/ai-coder/ai-gateway/clients/coder-agents.md`: Troubleshooting
entry
- Tests: classification round-trip, terminal message, metrics kind
enumeration
> Generated with [Coder Agents](https://coder.com/agents) on behalf of
@johnstcn
---
coderd/apidoc/docs.go | 6 ++--
coderd/apidoc/swagger.json | 6 ++--
coderd/x/chatd/chaterror/classify_test.go | 22 ++++++++++++
coderd/x/chatd/chaterror/message.go | 6 ++++
coderd/x/chatd/chaterror/message_test.go | 7 ++++
coderd/x/chatd/chatloop/metrics_test.go | 1 +
coderd/x/chatd/model_routing_aibridge.go | 11 +++++-
coderd/x/chatd/model_routing_internal_test.go | 7 ++++
codersdk/chats.go | 2 ++
.../ai-gateway/clients/coder-agents.md | 7 ++++
docs/reference/api/chats.md | 14 ++++----
docs/reference/api/schemas.md | 6 ++--
site/src/api/typesGenerated.ts | 2 ++
.../LiveStreamTail.stories.tsx | 35 +++++++++++++++++++
.../ChatConversation/chatStatusHelpers.ts | 2 ++
15 files changed, 119 insertions(+), 15 deletions(-)
diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go
index 65f2c1927c28e..15e45d9f469d5 100644
--- a/coderd/apidoc/docs.go
+++ b/coderd/apidoc/docs.go
@@ -16554,7 +16554,8 @@ const docTemplate = `{
"startup_timeout",
"auth",
"config",
- "usage_limit"
+ "usage_limit",
+ "missing_key"
],
"x-enum-varnames": [
"ChatErrorKindGeneric",
@@ -16564,7 +16565,8 @@ const docTemplate = `{
"ChatErrorKindStartupTimeout",
"ChatErrorKindAuth",
"ChatErrorKindConfig",
- "ChatErrorKindUsageLimit"
+ "ChatErrorKindUsageLimit",
+ "ChatErrorKindMissingKey"
]
},
"codersdk.ChatFileMetadata": {
diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json
index 33ffe3e4b4fd0..b65c8a95fbdff 100644
--- a/coderd/apidoc/swagger.json
+++ b/coderd/apidoc/swagger.json
@@ -14904,7 +14904,8 @@
"startup_timeout",
"auth",
"config",
- "usage_limit"
+ "usage_limit",
+ "missing_key"
],
"x-enum-varnames": [
"ChatErrorKindGeneric",
@@ -14914,7 +14915,8 @@
"ChatErrorKindStartupTimeout",
"ChatErrorKindAuth",
"ChatErrorKindConfig",
- "ChatErrorKindUsageLimit"
+ "ChatErrorKindUsageLimit",
+ "ChatErrorKindMissingKey"
]
},
"codersdk.ChatFileMetadata": {
diff --git a/coderd/x/chatd/chaterror/classify_test.go b/coderd/x/chatd/chaterror/classify_test.go
index 457704bd5fe35..8e1a9783c31b8 100644
--- a/coderd/x/chatd/chaterror/classify_test.go
+++ b/coderd/x/chatd/chaterror/classify_test.go
@@ -1158,6 +1158,28 @@ func TestClassify_ChainBrokenSurvivesWithClassification(t *testing.T) {
" can detect it after re-classification")
}
+func TestClassify_MissingKeyPreClassified(t *testing.T) {
+ t.Parallel()
+
+ raw := xerrors.New("AI Gateway routing requires the active turn API key ID")
+ wrapped := chaterror.WithClassification(raw, chaterror.ClassifiedError{
+ Kind: codersdk.ChatErrorKindMissingKey,
+ Retryable: false,
+ Detail: "If this error persists after resending, please report it as a bug.",
+ })
+
+ classified := chaterror.Classify(wrapped)
+ require.Equal(t, codersdk.ChatErrorKindMissingKey, classified.Kind)
+ require.False(t, classified.Retryable)
+ require.Equal(t, "If this error persists after resending, please report it as a bug.", classified.Detail)
+ require.Equal(t,
+ "This conversation was started with an API key that is no longer available."+
+ " Send your message again to continue.",
+ classified.Message,
+ "Message should be filled by terminalMessage when not set explicitly",
+ )
+}
+
func testProviderError(
message string,
statusCode int,
diff --git a/coderd/x/chatd/chaterror/message.go b/coderd/x/chatd/chaterror/message.go
index a551078349ce5..5257420061675 100644
--- a/coderd/x/chatd/chaterror/message.go
+++ b/coderd/x/chatd/chaterror/message.go
@@ -61,6 +61,10 @@ func terminalMessage(classified ClassifiedError) string {
subject,
)
+ case codersdk.ChatErrorKindMissingKey:
+ return "This conversation was started with an API key that is no longer available." +
+ " Send your message again to continue."
+
default:
if !classified.Retryable && classified.StatusCode == 0 {
return "The chat request failed unexpectedly."
@@ -102,6 +106,8 @@ func retryMessage(classified ClassifiedError) string {
return fmt.Sprintf(
"%s rejected the model configuration.", subject,
)
+ case codersdk.ChatErrorKindMissingKey:
+ return "The API key for this conversation is no longer available."
default:
return fmt.Sprintf(
"%s returned an unexpected error.", subject,
diff --git a/coderd/x/chatd/chaterror/message_test.go b/coderd/x/chatd/chaterror/message_test.go
index 87cb375cbc022..94bf14bd13500 100644
--- a/coderd/x/chatd/chaterror/message_test.go
+++ b/coderd/x/chatd/chaterror/message_test.go
@@ -90,6 +90,13 @@ func TestTerminalMessage(t *testing.T) {
retryable: false,
want: "The usage quota for the AI provider has been exceeded. Check the billing and quota settings for the provider account.",
},
+ {
+ name: "MissingKey",
+ kind: codersdk.ChatErrorKindMissingKey,
+ provider: "",
+ retryable: false,
+ want: "This conversation was started with an API key that is no longer available. Send your message again to continue.",
+ },
}
for _, tt := range tests {
diff --git a/coderd/x/chatd/chatloop/metrics_test.go b/coderd/x/chatd/chatloop/metrics_test.go
index 7aa3885750378..c0c86deacc410 100644
--- a/coderd/x/chatd/chatloop/metrics_test.go
+++ b/coderd/x/chatd/chatloop/metrics_test.go
@@ -296,6 +296,7 @@ func TestRecordStreamRetry(t *testing.T) {
{name: "startup_timeout", kind: codersdk.ChatErrorKindStartupTimeout},
{name: "auth", kind: codersdk.ChatErrorKindAuth},
{name: "config", kind: codersdk.ChatErrorKindConfig},
+ {name: "missing_key", kind: codersdk.ChatErrorKindMissingKey},
{name: "generic", kind: codersdk.ChatErrorKindGeneric},
{name: "chain_broken", kind: codersdk.ChatErrorKindGeneric, chainBroken: true},
}
diff --git a/coderd/x/chatd/model_routing_aibridge.go b/coderd/x/chatd/model_routing_aibridge.go
index 5db1a16e535d3..07e8fd66b0f16 100644
--- a/coderd/x/chatd/model_routing_aibridge.go
+++ b/coderd/x/chatd/model_routing_aibridge.go
@@ -16,7 +16,9 @@ import (
"github.com/coder/coder/v2/coderd/aibridge"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/x/chatd/chatdebug"
+ "github.com/coder/coder/v2/coderd/x/chatd/chaterror"
"github.com/coder/coder/v2/coderd/x/chatd/chatprovider"
+ "github.com/coder/coder/v2/codersdk"
)
const (
@@ -98,7 +100,14 @@ func (p *Server) newAIGatewayModel(
return nil, xerrors.New("AI Gateway routing requires an AI provider name")
}
if opts.ActiveAPIKeyID == "" {
- return nil, xerrors.New("AI Gateway routing requires the active turn API key ID")
+ return nil, chaterror.WithClassification(
+ xerrors.New("AI Gateway routing requires the active turn API key ID"),
+ chaterror.ClassifiedError{
+ Kind: codersdk.ChatErrorKindMissingKey,
+ Retryable: false,
+ Detail: "If this error persists after resending, please report it as a bug.",
+ },
+ )
}
factoryPtr := p.aibridgeTransportFactory
diff --git a/coderd/x/chatd/model_routing_internal_test.go b/coderd/x/chatd/model_routing_internal_test.go
index 786365d9fb368..0d2f31720431f 100644
--- a/coderd/x/chatd/model_routing_internal_test.go
+++ b/coderd/x/chatd/model_routing_internal_test.go
@@ -20,8 +20,10 @@ import (
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/coderd/database/dbmock"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
+ "github.com/coder/coder/v2/coderd/x/chatd/chaterror"
"github.com/coder/coder/v2/coderd/x/chatd/chatprovider"
"github.com/coder/coder/v2/coderd/x/chatd/chattool"
+ "github.com/coder/coder/v2/codersdk"
)
type aibridgeTestFactory struct {
@@ -530,6 +532,11 @@ func TestAIBridgeRoutingFailClosed(t *testing.T) {
}
_, err := server.newModel(t.Context(), aibridgeTestRequest(chat, "gpt-4"), aibridgeTestRoute(aiProvider), modelBuildOptions{})
require.ErrorContains(t, err, "active turn API key ID")
+
+ classified := chaterror.Classify(err)
+ require.Equal(t, codersdk.ChatErrorKindMissingKey, classified.Kind,
+ "production path must return a pre-classified missing_key error")
+ require.False(t, classified.Retryable)
})
t.Run("StaticModel", func(t *testing.T) {
diff --git a/codersdk/chats.go b/codersdk/chats.go
index 665ace7aa8122..c6deeb35aa00f 100644
--- a/codersdk/chats.go
+++ b/codersdk/chats.go
@@ -1533,6 +1533,7 @@ const (
ChatErrorKindAuth ChatErrorKind = "auth"
ChatErrorKindConfig ChatErrorKind = "config"
ChatErrorKindUsageLimit ChatErrorKind = "usage_limit"
+ ChatErrorKindMissingKey ChatErrorKind = "missing_key"
)
// AllChatErrorKinds contains every ChatErrorKind value.
@@ -1546,6 +1547,7 @@ var AllChatErrorKinds = []ChatErrorKind{
ChatErrorKindAuth,
ChatErrorKindConfig,
ChatErrorKindUsageLimit,
+ ChatErrorKindMissingKey,
}
// ChatError represents a terminal chat error in persisted chat state or the
diff --git a/docs/ai-coder/ai-gateway/clients/coder-agents.md b/docs/ai-coder/ai-gateway/clients/coder-agents.md
index f5187cce58379..de0fcad927cf2 100644
--- a/docs/ai-coder/ai-gateway/clients/coder-agents.md
+++ b/docs/ai-coder/ai-gateway/clients/coder-agents.md
@@ -164,6 +164,13 @@ key is a valid Coder token.
one [model](../../agents/models.md#add-a-model) to the provider after
saving the Base URL. Providers without an enabled model are hidden from
developers.
+- **"Chat interrupted" error when resuming a conversation.**
+ This occurs when the API key that was used to start a chat turn is no
+ longer available. Common causes: upgrading from a version before
+ `api_key_id` tracking was introduced, or deleting an API key while a
+ chat is active. The error is self-healing: send your message again and
+ the new message will use your current API key. If the error persists
+ after resending, this indicates a bug. Please report it.
## Known limitations
diff --git a/docs/reference/api/chats.md b/docs/reference/api/chats.md
index 758f6641f5942..691b2a2bc5b32 100644
--- a/docs/reference/api/chats.md
+++ b/docs/reference/api/chats.md
@@ -292,13 +292,13 @@ Status Code **200**
#### Enumerated Values
-| Property | Value(s) |
-|---------------|--------------------------------------------------------------------------------------------------------------|
-| `client_type` | `api`, `ui` |
-| `kind` | `auth`, `config`, `generic`, `overloaded`, `rate_limit`, `startup_timeout`, `timeout`, `usage_limit` |
-| `type` | `context-file`, `file`, `file-reference`, `reasoning`, `skill`, `source`, `text`, `tool-call`, `tool-result` |
-| `plan_mode` | `plan` |
-| `status` | `completed`, `error`, `paused`, `pending`, `requires_action`, `running`, `waiting` |
+| Property | Value(s) |
+|---------------|---------------------------------------------------------------------------------------------------------------------|
+| `client_type` | `api`, `ui` |
+| `kind` | `auth`, `config`, `generic`, `missing_key`, `overloaded`, `rate_limit`, `startup_timeout`, `timeout`, `usage_limit` |
+| `type` | `context-file`, `file`, `file-reference`, `reasoning`, `skill`, `source`, `text`, `tool-call`, `tool-result` |
+| `plan_mode` | `plan` |
+| `status` | `completed`, `error`, `paused`, `pending`, `requires_action`, `running`, `waiting` |
To perform this operation, you must be authenticated. [Learn more](authentication.md).
diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md
index 912d53f8cef47..34a7b57b9d3ff 100644
--- a/docs/reference/api/schemas.md
+++ b/docs/reference/api/schemas.md
@@ -2732,9 +2732,9 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
#### Enumerated Values
-| Value(s) |
-|------------------------------------------------------------------------------------------------------|
-| `auth`, `config`, `generic`, `overloaded`, `rate_limit`, `startup_timeout`, `timeout`, `usage_limit` |
+| Value(s) |
+|---------------------------------------------------------------------------------------------------------------------|
+| `auth`, `config`, `generic`, `missing_key`, `overloaded`, `rate_limit`, `startup_timeout`, `timeout`, `usage_limit` |
## codersdk.ChatFileMetadata
diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts
index 2c0df043ff4dc..28412b9b0902e 100644
--- a/site/src/api/typesGenerated.ts
+++ b/site/src/api/typesGenerated.ts
@@ -1959,6 +1959,7 @@ export type ChatErrorKind =
| "auth"
| "config"
| "generic"
+ | "missing_key"
| "overloaded"
| "rate_limit"
| "startup_timeout"
@@ -1969,6 +1970,7 @@ export const ChatErrorKinds: ChatErrorKind[] = [
"auth",
"config",
"generic",
+ "missing_key",
"overloaded",
"rate_limit",
"startup_timeout",
diff --git a/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.stories.tsx
index a4ddf3bf6fc66..08fd59d1bbfd3 100644
--- a/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.stories.tsx
+++ b/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.stories.tsx
@@ -193,6 +193,41 @@ export const TerminalTimeoutErrorUnknownProvider: Story = {
},
};
+/** Missing API key shows the "Chat interrupted" terminal error. */
+export const TerminalMissingKeyError: Story = {
+ args: {
+ ...defaultArgs,
+ liveStatus: buildLiveStatus({
+ streamError: {
+ kind: "missing_key",
+ message:
+ "This conversation was started with an API key that is no longer available. Send your message again to continue.",
+ retryable: false,
+ detail:
+ "If this error persists after resending, please report it as a bug.",
+ },
+ }),
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ expect(
+ canvas.getByRole("heading", { name: /chat interrupted/i }),
+ ).toBeVisible();
+ expect(
+ canvas.getByText(
+ /this conversation was started with an api key that is no longer available/i,
+ ),
+ ).toBeVisible();
+ expect(
+ canvas.getByText(/if this error persists after resending/i),
+ ).toBeVisible();
+ // Guard against the generic fallback.
+ expect(
+ canvas.queryByText(/the chat request failed unexpectedly/i),
+ ).not.toBeInTheDocument();
+ },
+};
+
/** Retrying a transport timeout shows attempt + countdown. */
export const RetryingTimeoutAnthropic: Story = {
args: {
diff --git a/site/src/pages/AgentsPage/components/ChatConversation/chatStatusHelpers.ts b/site/src/pages/AgentsPage/components/ChatConversation/chatStatusHelpers.ts
index c718e2a00761f..243296ea2ce26 100644
--- a/site/src/pages/AgentsPage/components/ChatConversation/chatStatusHelpers.ts
+++ b/site/src/pages/AgentsPage/components/ChatConversation/chatStatusHelpers.ts
@@ -42,6 +42,8 @@ export const getErrorTitle = (
return "Configuration error";
case "usage_limit":
return "Usage limit reached";
+ case "missing_key":
+ return "Chat interrupted";
default:
return mode === "retry" ? "Retrying request" : "Request failed";
}
From 4591212482d0457dc632222959b4aa32eb0220d2 Mon Sep 17 00:00:00 2001
From: Steven Masley
Date: Thu, 28 May 2026 10:00:37 -0500
Subject: [PATCH 079/249] feat: implement SCIM handler for SCIM 2.0 compliance
(#25572)
Rewrites the SCIM 2.0 user provisioning handler to be RFC 7644
compliant. Verified against an external IdP Okta.
Behavior is OPT IN
---
coderd/apidoc/docs.go | 141 +-
coderd/apidoc/swagger.json | 141 +-
coderd/database/dbauthz/dbauthz.go | 32 +-
coderd/database/dbauthz/dbauthz_test.go | 2 +-
coderd/database/modelqueries.go | 2 +
coderd/database/queries.sql.go | 58 +-
coderd/database/queries/users.sql | 12 +
coderd/rbac/authz.go | 1 +
codersdk/deployment.go | 13 +
docs/reference/api/enterprise.md | 28 +-
docs/reference/api/general.md | 1 +
docs/reference/api/schemas.md | 105 +-
enterprise/cli/server.go | 1 +
enterprise/coderd/coderd.go | 45 +-
.../coderd/coderdenttest/coderdenttest.go | 2 +
.../{scim.go => legacyscim/legacyscim.go} | 177 ++-
.../coderd/{scim => legacyscim}/scimtypes.go | 2 +-
enterprise/coderd/scim/expression.go | 39 +
enterprise/coderd/scim/scim.go | 138 ++
enterprise/coderd/scim/users.go | 588 ++++++++
enterprise/coderd/scim/users_internal_test.go | 760 ++++++++++
enterprise/coderd/scim_test.go | 1306 ++++++++---------
enterprise/coderd/scimroutes.go | 74 +
go.mod | 4 +
go.sum | 8 +
site/src/api/typesGenerated.ts | 1 +
26 files changed, 2627 insertions(+), 1054 deletions(-)
rename enterprise/coderd/{scim.go => legacyscim/legacyscim.go} (65%)
rename enterprise/coderd/{scim => legacyscim}/scimtypes.go (99%)
create mode 100644 enterprise/coderd/scim/expression.go
create mode 100644 enterprise/coderd/scim/scim.go
create mode 100644 enterprise/coderd/scim/users.go
create mode 100644 enterprise/coderd/scim/users_internal_test.go
create mode 100644 enterprise/coderd/scimroutes.go
diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go
index 15e45d9f469d5..22616628d9a37 100644
--- a/coderd/apidoc/docs.go
+++ b/coderd/apidoc/docs.go
@@ -13961,7 +13961,7 @@ const docTemplate = `{
"in": "body",
"required": true,
"schema": {
- "$ref": "#/definitions/coderd.SCIMUser"
+ "$ref": "#/definitions/legacyscim.SCIMUser"
}
}
],
@@ -13969,7 +13969,7 @@ const docTemplate = `{
"200": {
"description": "OK",
"schema": {
- "$ref": "#/definitions/coderd.SCIMUser"
+ "$ref": "#/definitions/legacyscim.SCIMUser"
}
}
},
@@ -14035,7 +14035,7 @@ const docTemplate = `{
"in": "body",
"required": true,
"schema": {
- "$ref": "#/definitions/coderd.SCIMUser"
+ "$ref": "#/definitions/legacyscim.SCIMUser"
}
}
],
@@ -14077,7 +14077,7 @@ const docTemplate = `{
"in": "body",
"required": true,
"schema": {
- "$ref": "#/definitions/coderd.SCIMUser"
+ "$ref": "#/definitions/legacyscim.SCIMUser"
}
}
],
@@ -14288,71 +14288,6 @@ const docTemplate = `{
"ReinitializeReasonPrebuildClaimed"
]
},
- "coderd.SCIMUser": {
- "type": "object",
- "properties": {
- "active": {
- "description": "Active is a ptr to prevent the empty value from being interpreted as false.",
- "type": "boolean"
- },
- "emails": {
- "type": "array",
- "items": {
- "type": "object",
- "properties": {
- "display": {
- "type": "string"
- },
- "primary": {
- "type": "boolean"
- },
- "type": {
- "type": "string"
- },
- "value": {
- "type": "string",
- "format": "email"
- }
- }
- }
- },
- "groups": {
- "type": "array",
- "items": {}
- },
- "id": {
- "type": "string"
- },
- "meta": {
- "type": "object",
- "properties": {
- "resourceType": {
- "type": "string"
- }
- }
- },
- "name": {
- "type": "object",
- "properties": {
- "familyName": {
- "type": "string"
- },
- "givenName": {
- "type": "string"
- }
- }
- },
- "schemas": {
- "type": "array",
- "items": {
- "type": "string"
- }
- },
- "userName": {
- "type": "string"
- }
- }
- },
"coderd.cspViolation": {
"type": "object",
"properties": {
@@ -18791,6 +18726,9 @@ const docTemplate = `{
"scim_api_key": {
"type": "string"
},
+ "scim_use_legacy": {
+ "type": "boolean"
+ },
"session_lifetime": {
"$ref": "#/definitions/codersdk.SessionLifetime"
},
@@ -27408,6 +27346,71 @@ const docTemplate = `{
"key.NodePublic": {
"type": "object"
},
+ "legacyscim.SCIMUser": {
+ "type": "object",
+ "properties": {
+ "active": {
+ "description": "Active is a ptr to prevent the empty value from being interpreted as false.",
+ "type": "boolean"
+ },
+ "emails": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "display": {
+ "type": "string"
+ },
+ "primary": {
+ "type": "boolean"
+ },
+ "type": {
+ "type": "string"
+ },
+ "value": {
+ "type": "string",
+ "format": "email"
+ }
+ }
+ }
+ },
+ "groups": {
+ "type": "array",
+ "items": {}
+ },
+ "id": {
+ "type": "string"
+ },
+ "meta": {
+ "type": "object",
+ "properties": {
+ "resourceType": {
+ "type": "string"
+ }
+ }
+ },
+ "name": {
+ "type": "object",
+ "properties": {
+ "familyName": {
+ "type": "string"
+ },
+ "givenName": {
+ "type": "string"
+ }
+ }
+ },
+ "schemas": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "userName": {
+ "type": "string"
+ }
+ }
+ },
"netcheck.Report": {
"type": "object",
"properties": {
diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json
index b65c8a95fbdff..b32ea11968ae1 100644
--- a/coderd/apidoc/swagger.json
+++ b/coderd/apidoc/swagger.json
@@ -12389,7 +12389,7 @@
"in": "body",
"required": true,
"schema": {
- "$ref": "#/definitions/coderd.SCIMUser"
+ "$ref": "#/definitions/legacyscim.SCIMUser"
}
}
],
@@ -12397,7 +12397,7 @@
"200": {
"description": "OK",
"schema": {
- "$ref": "#/definitions/coderd.SCIMUser"
+ "$ref": "#/definitions/legacyscim.SCIMUser"
}
}
},
@@ -12455,7 +12455,7 @@
"in": "body",
"required": true,
"schema": {
- "$ref": "#/definitions/coderd.SCIMUser"
+ "$ref": "#/definitions/legacyscim.SCIMUser"
}
}
],
@@ -12493,7 +12493,7 @@
"in": "body",
"required": true,
"schema": {
- "$ref": "#/definitions/coderd.SCIMUser"
+ "$ref": "#/definitions/legacyscim.SCIMUser"
}
}
],
@@ -12692,71 +12692,6 @@
"enum": ["prebuild_claimed"],
"x-enum-varnames": ["ReinitializeReasonPrebuildClaimed"]
},
- "coderd.SCIMUser": {
- "type": "object",
- "properties": {
- "active": {
- "description": "Active is a ptr to prevent the empty value from being interpreted as false.",
- "type": "boolean"
- },
- "emails": {
- "type": "array",
- "items": {
- "type": "object",
- "properties": {
- "display": {
- "type": "string"
- },
- "primary": {
- "type": "boolean"
- },
- "type": {
- "type": "string"
- },
- "value": {
- "type": "string",
- "format": "email"
- }
- }
- }
- },
- "groups": {
- "type": "array",
- "items": {}
- },
- "id": {
- "type": "string"
- },
- "meta": {
- "type": "object",
- "properties": {
- "resourceType": {
- "type": "string"
- }
- }
- },
- "name": {
- "type": "object",
- "properties": {
- "familyName": {
- "type": "string"
- },
- "givenName": {
- "type": "string"
- }
- }
- },
- "schemas": {
- "type": "array",
- "items": {
- "type": "string"
- }
- },
- "userName": {
- "type": "string"
- }
- }
- },
"coderd.cspViolation": {
"type": "object",
"properties": {
@@ -17062,6 +16997,9 @@
"scim_api_key": {
"type": "string"
},
+ "scim_use_legacy": {
+ "type": "boolean"
+ },
"session_lifetime": {
"$ref": "#/definitions/codersdk.SessionLifetime"
},
@@ -25273,6 +25211,71 @@
"key.NodePublic": {
"type": "object"
},
+ "legacyscim.SCIMUser": {
+ "type": "object",
+ "properties": {
+ "active": {
+ "description": "Active is a ptr to prevent the empty value from being interpreted as false.",
+ "type": "boolean"
+ },
+ "emails": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "display": {
+ "type": "string"
+ },
+ "primary": {
+ "type": "boolean"
+ },
+ "type": {
+ "type": "string"
+ },
+ "value": {
+ "type": "string",
+ "format": "email"
+ }
+ }
+ }
+ },
+ "groups": {
+ "type": "array",
+ "items": {}
+ },
+ "id": {
+ "type": "string"
+ },
+ "meta": {
+ "type": "object",
+ "properties": {
+ "resourceType": {
+ "type": "string"
+ }
+ }
+ },
+ "name": {
+ "type": "object",
+ "properties": {
+ "familyName": {
+ "type": "string"
+ },
+ "givenName": {
+ "type": "string"
+ }
+ }
+ },
+ "schemas": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "userName": {
+ "type": "string"
+ }
+ }
+ },
"netcheck.Report": {
"type": "object",
"properties": {
diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go
index 74aa3f9f468a9..3e41260d82631 100644
--- a/coderd/database/dbauthz/dbauthz.go
+++ b/coderd/database/dbauthz/dbauthz.go
@@ -742,6 +742,29 @@ var (
}),
Scope: rbac.ScopeAll,
}.WithCachedASTValue()
+
+ subjectSCIM = rbac.Subject{
+ Type: rbac.SubjectTypeSCIMProvisioner,
+ FriendlyName: "SCIM Provisioner",
+ ID: uuid.Nil.String(),
+ Roles: rbac.Roles([]rbac.Role{
+ {
+ Identifier: rbac.RoleIdentifier{Name: "scim"},
+ DisplayName: "SCIM",
+ Site: rbac.Permissions(map[string][]policy.Action{
+ rbac.ResourceSystem.Type: {policy.ActionRead}, // Required for idp config reads, this should be fixed
+ rbac.ResourceAssignRole.Type: rbac.ResourceAssignRole.AvailableActions(),
+ rbac.ResourceAssignOrgRole.Type: rbac.ResourceAssignOrgRole.AvailableActions(),
+ rbac.ResourceUser.Type: {policy.ActionCreate, policy.ActionUpdate, policy.ActionRead, policy.ActionUpdatePersonal},
+ rbac.ResourceOrganization.Type: {policy.ActionRead},
+ rbac.ResourceOrganizationMember.Type: {policy.ActionRead, policy.ActionCreate, policy.ActionUpdate},
+ }),
+ User: []rbac.Permission{},
+ ByOrgID: map[string]rbac.OrgPermissions{},
+ },
+ }),
+ Scope: rbac.ScopeAll,
+ }.WithCachedASTValue()
)
// AsProvisionerd returns a context with an actor that has permissions required
@@ -872,6 +895,12 @@ func AsAIProviderMetadataReader(ctx context.Context) context.Context {
return As(ctx, subjectAIProviderMetadataReader)
}
+// AsSCIMProvisioner returns a context with an actor that has permissions required for
+// handling the /scim/v2 routes and provisioning users via SCIM.
+func AsSCIMProvisioner(ctx context.Context) context.Context {
+ return As(ctx, subjectSCIM)
+}
+
var AsRemoveActor = rbac.Subject{
ID: "remove-actor",
}
@@ -4659,7 +4688,8 @@ func (q *querier) GetUserCodeDiffDisplayMode(ctx context.Context, userID uuid.UU
}
func (q *querier) GetUserCount(ctx context.Context, includeSystem bool) (int64, error) {
- if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil {
+ // If you can read every user, then you can read the count of users.
+ if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceUser); err != nil {
return 0, err
}
return q.db.GetUserCount(ctx, includeSystem)
diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go
index 2cff1d6c8ceee..3296d5cebe6d4 100644
--- a/coderd/database/dbauthz/dbauthz_test.go
+++ b/coderd/database/dbauthz/dbauthz_test.go
@@ -4578,7 +4578,7 @@ func (s *MethodTestSuite) TestSystemFunctions() {
}))
s.Run("GetUserCount", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
dbm.EXPECT().GetUserCount(gomock.Any(), false).Return(int64(0), nil).AnyTimes()
- check.Args(false).Asserts(rbac.ResourceSystem, policy.ActionRead).Returns(int64(0))
+ check.Args(false).Asserts(rbac.ResourceUser, policy.ActionRead).Returns(int64(0))
}))
s.Run("GetTemplates", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
dbm.EXPECT().GetTemplates(gomock.Any()).Return([]database.Template{}, nil).AnyTimes()
diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go
index 73f973e15ca7a..972a104201ea6 100644
--- a/coderd/database/modelqueries.go
+++ b/coderd/database/modelqueries.go
@@ -413,6 +413,8 @@ func (q *sqlQuerier) GetAuthorizedUsers(ctx context.Context, arg GetUsersParams,
arg.AfterID,
arg.Search,
arg.Name,
+ arg.ExactUsername,
+ arg.ExactEmail,
pq.Array(arg.Status),
pq.Array(arg.RbacRole),
arg.LastSeenBefore,
diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go
index 2ba4b923def2e..1c04d4906be23 100644
--- a/coderd/database/queries.sql.go
+++ b/coderd/database/queries.sql.go
@@ -28052,65 +28052,77 @@ WHERE
name ILIKE concat('%', $3, '%')
ELSE true
END
+ -- Filter by exact username
+ AND CASE
+ WHEN $4 :: text != '' THEN
+ lower(username) = lower($4)
+ ELSE true
+ END
+ -- Filter by exact email
+ AND CASE
+ WHEN $5 :: text != '' THEN
+ lower(email) = lower($5)
+ ELSE true
+ END
-- Filter by status
AND CASE
-- @status needs to be a text because it can be empty, If it was
-- user_status enum, it would not.
- WHEN cardinality($4 :: user_status[]) > 0 THEN
- status = ANY($4 :: user_status[])
+ WHEN cardinality($6 :: user_status[]) > 0 THEN
+ status = ANY($6 :: user_status[])
ELSE true
END
-- Filter by rbac_roles
AND CASE
-- @rbac_role allows filtering by rbac roles. If 'member' is included, show everyone, as
-- everyone is a member.
- WHEN cardinality($5 :: text[]) > 0 AND 'member' != ANY($5 :: text[]) THEN
- rbac_roles && $5 :: text[]
+ WHEN cardinality($7 :: text[]) > 0 AND 'member' != ANY($7 :: text[]) THEN
+ rbac_roles && $7 :: text[]
ELSE true
END
-- Filter by last_seen
AND CASE
- WHEN $6 :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
- last_seen_at <= $6
+ WHEN $8 :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
+ last_seen_at <= $8
ELSE true
END
AND CASE
- WHEN $7 :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
- last_seen_at >= $7
+ WHEN $9 :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
+ last_seen_at >= $9
ELSE true
END
-- Filter by created_at
AND CASE
- WHEN $8 :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
- created_at <= $8
+ WHEN $10 :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
+ created_at <= $10
ELSE true
END
AND CASE
- WHEN $9 :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
- created_at >= $9
+ WHEN $11 :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
+ created_at >= $11
ELSE true
END
-- Filter by system type
AND CASE
- WHEN $10::bool THEN TRUE
+ WHEN $12::bool THEN TRUE
ELSE is_system = false
END
-- Filter by github.com user ID
AND CASE
- WHEN $11 :: bigint != 0 THEN
- github_com_user_id = $11
+ WHEN $13 :: bigint != 0 THEN
+ github_com_user_id = $13
ELSE true
END
-- Filter by login_type
AND CASE
- WHEN cardinality($12 :: login_type[]) > 0 THEN
- login_type = ANY($12 :: login_type[])
+ WHEN cardinality($14 :: login_type[]) > 0 THEN
+ login_type = ANY($14 :: login_type[])
ELSE true
END
-- Filter by service account.
AND CASE
- WHEN $13 :: boolean IS NOT NULL THEN
- is_service_account = $13 :: boolean
+ WHEN $15 :: boolean IS NOT NULL THEN
+ is_service_account = $15 :: boolean
ELSE true
END
-- End of filters
@@ -28119,16 +28131,18 @@ WHERE
-- @authorize_filter
ORDER BY
-- Deterministic and consistent ordering of all users. This is to ensure consistent pagination.
- LOWER(username) ASC OFFSET $14
+ LOWER(username) ASC OFFSET $16
LIMIT
-- A null limit means "no limit", so 0 means return all
- NULLIF($15 :: int, 0)
+ NULLIF($17 :: int, 0)
`
type GetUsersParams struct {
AfterID uuid.UUID `db:"after_id" json:"after_id"`
Search string `db:"search" json:"search"`
Name string `db:"name" json:"name"`
+ ExactUsername string `db:"exact_username" json:"exact_username"`
+ ExactEmail string `db:"exact_email" json:"exact_email"`
Status []UserStatus `db:"status" json:"status"`
RbacRole []string `db:"rbac_role" json:"rbac_role"`
LastSeenBefore time.Time `db:"last_seen_before" json:"last_seen_before"`
@@ -28173,6 +28187,8 @@ func (q *sqlQuerier) GetUsers(ctx context.Context, arg GetUsersParams) ([]GetUse
arg.AfterID,
arg.Search,
arg.Name,
+ arg.ExactUsername,
+ arg.ExactEmail,
pq.Array(arg.Status),
pq.Array(arg.RbacRole),
arg.LastSeenBefore,
diff --git a/coderd/database/queries/users.sql b/coderd/database/queries/users.sql
index 03f403e145c91..7bbd2dd0c97fe 100644
--- a/coderd/database/queries/users.sql
+++ b/coderd/database/queries/users.sql
@@ -486,6 +486,18 @@ WHERE
name ILIKE concat('%', @name, '%')
ELSE true
END
+ -- Filter by exact username
+ AND CASE
+ WHEN @exact_username :: text != '' THEN
+ lower(username) = lower(@exact_username)
+ ELSE true
+ END
+ -- Filter by exact email
+ AND CASE
+ WHEN @exact_email :: text != '' THEN
+ lower(email) = lower(@exact_email)
+ ELSE true
+ END
-- Filter by status
AND CASE
-- @status needs to be a text because it can be empty, If it was
diff --git a/coderd/rbac/authz.go b/coderd/rbac/authz.go
index 5d40e59cc6fac..4b253bc10d262 100644
--- a/coderd/rbac/authz.go
+++ b/coderd/rbac/authz.go
@@ -85,6 +85,7 @@ const (
SubjectTypeWorkspaceBuilder SubjectType = "workspace_builder"
SubjectTypeChatd SubjectType = "chatd"
SubjectTypeAIProviderMetadataReader SubjectType = "ai_provider_metadata_reader"
+ SubjectTypeSCIMProvisioner SubjectType = "scim_provisioner"
)
const (
diff --git a/codersdk/deployment.go b/codersdk/deployment.go
index b4939ec022986..3fb36c587f7b2 100644
--- a/codersdk/deployment.go
+++ b/codersdk/deployment.go
@@ -638,6 +638,7 @@ type DeploymentValues struct {
AgentFallbackTroubleshootingURL serpent.URL `json:"agent_fallback_troubleshooting_url,omitempty" typescript:",notnull"`
BrowserOnly serpent.Bool `json:"browser_only,omitempty" typescript:",notnull"`
SCIMAPIKey serpent.String `json:"scim_api_key,omitempty" typescript:",notnull"`
+ UseLegacySCIM serpent.Bool `json:"scim_use_legacy,omitempty" typescript:",notnull"`
ExternalTokenEncryptionKeys serpent.StringArray `json:"external_token_encryption_keys,omitempty" typescript:",notnull"`
Provisioner ProvisionerConfig `json:"provisioner,omitempty" typescript:",notnull"`
RateLimit RateLimitConfig `json:"rate_limit,omitempty" typescript:",notnull"`
@@ -3447,6 +3448,18 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
Annotations: serpent.Annotations{}.Mark(annotationEnterpriseKey, "true").Mark(annotationSecretKey, "true"),
Value: &c.SCIMAPIKey,
},
+ {
+ Name: "SCIM Use Legacy",
+ // The legacy SCIM is a weird mix of SCIM 1.0 and SCIM 2.0
+ Description: "Use the legacy SCIM implementation instead of the SCIM 2.0 handler. This is provided for backward compatibility for existing users.",
+ Flag: "scim-use-legacy",
+ Env: "CODER_SCIM_USE_LEGACY",
+ Hidden: true,
+ // TODO: When SCIM 2.0 has been tested more, flip this to false to default to the new scim
+ Default: "true",
+ Annotations: serpent.Annotations{}.Mark(annotationEnterpriseKey, "true"),
+ Value: &c.UseLegacySCIM,
+ },
{
Name: "External Token Encryption Keys",
Description: "Encrypt OIDC and Git authentication tokens with AES-256-GCM in the database. The value must be a comma-separated list of base64-encoded keys. Each key, when base64-decoded, must be exactly 32 bytes in length. The first key will be used to encrypt new values. Subsequent keys will be used as a fallback when decrypting. During normal operation it is recommended to only set one key unless you are in the process of rotating keys with the `coder server dbcrypt rotate` command.",
diff --git a/docs/reference/api/enterprise.md b/docs/reference/api/enterprise.md
index def5c219a2e24..eca72025c0e0d 100644
--- a/docs/reference/api/enterprise.md
+++ b/docs/reference/api/enterprise.md
@@ -4520,9 +4520,9 @@ curl -X POST http://coder-server:8080/scim/v2/Users \
### Parameters
-| Name | In | Type | Required | Description |
-|--------|------|----------------------------------------------|----------|-------------|
-| `body` | body | [coderd.SCIMUser](schemas.md#coderdscimuser) | true | New user |
+| Name | In | Type | Required | Description |
+|--------|------|------------------------------------------------------|----------|-------------|
+| `body` | body | [legacyscim.SCIMUser](schemas.md#legacyscimscimuser) | true | New user |
### Example responses
@@ -4559,9 +4559,9 @@ curl -X POST http://coder-server:8080/scim/v2/Users \
### Responses
-| Status | Meaning | Description | Schema |
-|--------|---------------------------------------------------------|-------------|----------------------------------------------|
-| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [coderd.SCIMUser](schemas.md#coderdscimuser) |
+| Status | Meaning | Description | Schema |
+|--------|---------------------------------------------------------|-------------|------------------------------------------------------|
+| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [legacyscim.SCIMUser](schemas.md#legacyscimscimuser) |
To perform this operation, you must be authenticated. [Learn more](authentication.md).
@@ -4638,10 +4638,10 @@ curl -X PUT http://coder-server:8080/scim/v2/Users/{id} \
### Parameters
-| Name | In | Type | Required | Description |
-|--------|------|----------------------------------------------|----------|----------------------|
-| `id` | path | string(uuid) | true | User ID |
-| `body` | body | [coderd.SCIMUser](schemas.md#coderdscimuser) | true | Replace user request |
+| Name | In | Type | Required | Description |
+|--------|------|------------------------------------------------------|----------|----------------------|
+| `id` | path | string(uuid) | true | User ID |
+| `body` | body | [legacyscim.SCIMUser](schemas.md#legacyscimscimuser) | true | Replace user request |
### Example responses
@@ -4730,10 +4730,10 @@ curl -X PATCH http://coder-server:8080/scim/v2/Users/{id} \
### Parameters
-| Name | In | Type | Required | Description |
-|--------|------|----------------------------------------------|----------|---------------------|
-| `id` | path | string(uuid) | true | User ID |
-| `body` | body | [coderd.SCIMUser](schemas.md#coderdscimuser) | true | Update user request |
+| Name | In | Type | Required | Description |
+|--------|------|------------------------------------------------------|----------|---------------------|
+| `id` | path | string(uuid) | true | User ID |
+| `body` | body | [legacyscim.SCIMUser](schemas.md#legacyscimscimuser) | true | Update user request |
### Example responses
diff --git a/docs/reference/api/general.md b/docs/reference/api/general.md
index 02dbfe4135c47..98812f55aebf4 100644
--- a/docs/reference/api/general.md
+++ b/docs/reference/api/general.md
@@ -538,6 +538,7 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \
"workspace_agent_logs": 0
},
"scim_api_key": "string",
+ "scim_use_legacy": true,
"session_lifetime": {
"default_duration": 0,
"default_token_lifetime": 0,
diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md
index 34a7b57b9d3ff..8ab5372f65c66 100644
--- a/docs/reference/api/schemas.md
+++ b/docs/reference/api/schemas.md
@@ -220,57 +220,6 @@
|--------------------|
| `prebuild_claimed` |
-## coderd.SCIMUser
-
-```json
-{
- "active": true,
- "emails": [
- {
- "display": "string",
- "primary": true,
- "type": "string",
- "value": "user@example.com"
- }
- ],
- "groups": [
- null
- ],
- "id": "string",
- "meta": {
- "resourceType": "string"
- },
- "name": {
- "familyName": "string",
- "givenName": "string"
- },
- "schemas": [
- "string"
- ],
- "userName": "string"
-}
-```
-
-### Properties
-
-| Name | Type | Required | Restrictions | Description |
-|------------------|--------------------|----------|--------------|-----------------------------------------------------------------------------|
-| `active` | boolean | false | | Active is a ptr to prevent the empty value from being interpreted as false. |
-| `emails` | array of object | false | | |
-| `» display` | string | false | | |
-| `» primary` | boolean | false | | |
-| `» type` | string | false | | |
-| `» value` | string | false | | |
-| `groups` | array of undefined | false | | |
-| `id` | string | false | | |
-| `meta` | object | false | | |
-| `» resourceType` | string | false | | |
-| `name` | object | false | | |
-| `» familyName` | string | false | | |
-| `» givenName` | string | false | | |
-| `schemas` | array of string | false | | |
-| `userName` | string | false | | |
-
## coderd.cspViolation
```json
@@ -6058,6 +6007,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
"workspace_agent_logs": 0
},
"scim_api_key": "string",
+ "scim_use_legacy": true,
"session_lifetime": {
"default_duration": 0,
"default_token_lifetime": 0,
@@ -6657,6 +6607,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
"workspace_agent_logs": 0
},
"scim_api_key": "string",
+ "scim_use_legacy": true,
"session_lifetime": {
"default_duration": 0,
"default_token_lifetime": 0,
@@ -6817,6 +6768,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
| `redirect_to_access_url` | boolean | false | | |
| `retention` | [codersdk.RetentionConfig](#codersdkretentionconfig) | false | | |
| `scim_api_key` | string | false | | |
+| `scim_use_legacy` | boolean | false | | |
| `session_lifetime` | [codersdk.SessionLifetime](#codersdksessionlifetime) | false | | |
| `ssh_keygen_algorithm` | string | false | | |
| `stats_collection` | [codersdk.StatsCollectionConfig](#codersdkstatscollectionconfig) | false | | |
@@ -17915,6 +17867,57 @@ Zero means unspecified. There might be a limit, but the client need not try to r
None
+## legacyscim.SCIMUser
+
+```json
+{
+ "active": true,
+ "emails": [
+ {
+ "display": "string",
+ "primary": true,
+ "type": "string",
+ "value": "user@example.com"
+ }
+ ],
+ "groups": [
+ null
+ ],
+ "id": "string",
+ "meta": {
+ "resourceType": "string"
+ },
+ "name": {
+ "familyName": "string",
+ "givenName": "string"
+ },
+ "schemas": [
+ "string"
+ ],
+ "userName": "string"
+}
+```
+
+### Properties
+
+| Name | Type | Required | Restrictions | Description |
+|------------------|--------------------|----------|--------------|-----------------------------------------------------------------------------|
+| `active` | boolean | false | | Active is a ptr to prevent the empty value from being interpreted as false. |
+| `emails` | array of object | false | | |
+| `» display` | string | false | | |
+| `» primary` | boolean | false | | |
+| `» type` | string | false | | |
+| `» value` | string | false | | |
+| `groups` | array of undefined | false | | |
+| `id` | string | false | | |
+| `meta` | object | false | | |
+| `» resourceType` | string | false | | |
+| `name` | object | false | | |
+| `» familyName` | string | false | | |
+| `» givenName` | string | false | | |
+| `schemas` | array of string | false | | |
+| `userName` | string | false | | |
+
## netcheck.Report
```json
diff --git a/enterprise/cli/server.go b/enterprise/cli/server.go
index c77a03a0b4e51..37febd028b752 100644
--- a/enterprise/cli/server.go
+++ b/enterprise/cli/server.go
@@ -95,6 +95,7 @@ func (r *RootCmd) Server(_ func()) *serpent.Command {
ConnectionLogging: true,
BrowserOnly: options.DeploymentValues.BrowserOnly.Value(),
SCIMAPIKey: []byte(options.DeploymentValues.SCIMAPIKey.Value()),
+ UseLegacySCIM: options.DeploymentValues.UseLegacySCIM.Value(),
RBAC: true,
DERPServerRelayAddress: options.DeploymentValues.DERP.Server.RelayURL.String(),
DERPServerRegionID: int(options.DeploymentValues.DERP.Server.RegionID.Value()),
diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go
index 82fdd4d27f405..2a55c6218f00f 100644
--- a/enterprise/coderd/coderd.go
+++ b/enterprise/coderd/coderd.go
@@ -622,40 +622,12 @@ func New(ctx context.Context, options *Options) (_ *API, err error) {
})
})
- if len(options.SCIMAPIKey) != 0 {
- api.AGPL.RootHandler.Route("/scim/v2", func(r chi.Router) {
- r.Use(
- api.RequireFeatureMW(codersdk.FeatureSCIM),
- )
- r.Get("/ServiceProviderConfig", api.scimServiceProviderConfig)
- r.Post("/Users", api.scimPostUser)
- r.Route("/Users", func(r chi.Router) {
- r.Get("/", api.scimGetUsers)
- r.Post("/", api.scimPostUser)
- r.Get("/{id}", api.scimGetUser)
- r.Patch("/{id}", api.scimPatchUser)
- r.Put("/{id}", api.scimPutUser)
- })
- r.NotFound(func(w http.ResponseWriter, r *http.Request) {
- u := r.URL.String()
- httpapi.Write(r.Context(), w, http.StatusNotFound, codersdk.Response{
- Message: fmt.Sprintf("SCIM endpoint %s not found", u),
- Detail: "This endpoint is not implemented. If it is correct and required, please contact support.",
- })
- })
- })
- } else {
- // Show a helpful 404 error. Because this is not under the /api/v2 routes,
- // the frontend is the fallback. A html page is not a helpful error for
- // a SCIM provider. This JSON has a call to action that __may__ resolve
- // the issue.
- // Using Mount to cover all subroute possibilities.
- api.AGPL.RootHandler.Mount("/scim/v2", http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- httpapi.Write(r.Context(), w, http.StatusNotFound, codersdk.Response{
- Message: "SCIM is disabled, please contact your administrator if you believe this is an error",
- Detail: "SCIM endpoints are disabled if no SCIM is configured. Configure 'CODER_SCIM_AUTH_HEADER' to enable.",
- })
- })))
+ var mountScimError error
+ api.AGPL.RootHandler.Route("/scim", func(r chi.Router) {
+ mountScimError = api.mountScimRoute(options, r)
+ })
+ if mountScimError != nil {
+ return nil, xerrors.Errorf("mount scim routes: %w", mountScimError)
}
// We always want to run the replica manager even if we don't have DERP
@@ -754,6 +726,11 @@ type Options struct {
// Whether to block non-browser connections.
BrowserOnly bool
SCIMAPIKey []byte
+ // UseLegacySCIM opts into the legacy SCIM handler implementation
+ // (imulab/go-scim based). This is provided for backward compatibility
+ // during the transition to the new elimity-com/scim implementation.
+ // It will be removed in a future release.
+ UseLegacySCIM bool
ExternalTokenEncryption []dbcrypt.Cipher
diff --git a/enterprise/coderd/coderdenttest/coderdenttest.go b/enterprise/coderd/coderdenttest/coderdenttest.go
index a7efd1b3023c5..1115ba12118c7 100644
--- a/enterprise/coderd/coderdenttest/coderdenttest.go
+++ b/enterprise/coderd/coderdenttest/coderdenttest.go
@@ -67,6 +67,7 @@ type Options struct {
BrowserOnly bool
EntitlementsUpdateInterval time.Duration
SCIMAPIKey []byte
+ UseLegacySCIM bool
UserWorkspaceQuota int
ProxyHealthInterval time.Duration
LicenseOptions *LicenseOptions
@@ -108,6 +109,7 @@ func NewWithAPI(t *testing.T, options *Options) (
AuditLogging: options.AuditLogging,
BrowserOnly: options.BrowserOnly,
SCIMAPIKey: options.SCIMAPIKey,
+ UseLegacySCIM: options.UseLegacySCIM,
DERPServerRelayAddress: serverURL.String(),
DERPServerRegionID: int(oop.DeploymentValues.DERP.Server.RegionID.Value()),
ReplicaSyncUpdateInterval: options.ReplicaSyncUpdateInterval,
diff --git a/enterprise/coderd/scim.go b/enterprise/coderd/legacyscim/legacyscim.go
similarity index 65%
rename from enterprise/coderd/scim.go
rename to enterprise/coderd/legacyscim/legacyscim.go
index 5d0b248abdc65..942a78dd839d2 100644
--- a/enterprise/coderd/scim.go
+++ b/enterprise/coderd/legacyscim/legacyscim.go
@@ -1,4 +1,14 @@
-package coderd
+// Package legacyscim preserves the old imulab/go-scim based SCIM handler.
+// It was added in May 2026 to keep an opt-out path available during the
+// rollout of the new SCIM 2.0 implementation in
+// enterprise/coderd/scim. Once that implementation has run in production
+// for a while and the CODER_SCIM_USE_LEGACY default is flipped, remove
+// this package in its entirety.
+//
+// Enabled via the UseLegacySCIM option.
+//
+// Deprecated: Use the enterprise/coderd/scim package instead.
+package legacyscim
import (
"bytes"
@@ -6,6 +16,8 @@ import (
"database/sql"
"encoding/json"
"net/http"
+ "net/url"
+ "sync/atomic"
"time"
"github.com/go-chi/chi/v5"
@@ -16,17 +28,64 @@ import (
"github.com/imulab/go-scim/pkg/v2/spec"
"golang.org/x/xerrors"
+ "cdr.dev/slog/v3"
agpl "github.com/coder/coder/v2/coderd"
"github.com/coder/coder/v2/coderd/audit"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/httpapi"
+ "github.com/coder/coder/v2/coderd/idpsync"
"github.com/coder/coder/v2/codersdk"
- "github.com/coder/coder/v2/enterprise/coderd/scim"
)
-func (api *API) scimVerifyAuthHeader(r *http.Request) bool {
+// LegacyServer is the old SCIM handler implementation, kept for backward
+// compatibility. It uses the imulab/go-scim library and custom JSON handling.
+type LegacyServer struct {
+ Logger slog.Logger
+ Database database.Store
+ IDPSync idpsync.IDPSync
+ AGPL *agpl.API
+ AccessURL *url.URL
+ SCIMAPIKey []byte
+ Auditor *atomic.Pointer[audit.Auditor]
+}
+
+// Handler returns an http.Handler that serves the legacy SCIM endpoints.
+// It should be mounted at /scim/v2.
+func (s *LegacyServer) Handler() http.Handler {
+ r := chi.NewRouter()
+ r.Get("/ServiceProviderConfig", s.scimServiceProviderConfig)
+ r.Post("/Users", s.scimPostUser)
+ r.Route("/Users", func(r chi.Router) {
+ r.Get("/", s.scimGetUsers)
+ r.Post("/", s.scimPostUser)
+ r.Get("/{id}", s.scimGetUser)
+ r.Patch("/{id}", s.scimPatchUser)
+ r.Put("/{id}", s.scimPutUser)
+ })
+ r.NotFound(func(w http.ResponseWriter, r *http.Request) {
+ u := r.URL.String()
+ httpapi.Write(r.Context(), w, http.StatusNotFound, codersdk.Response{
+ Message: "SCIM endpoint not found: " + u,
+ Detail: "This endpoint is not implemented. If it is correct and required, please contact support.",
+ })
+ })
+ return r
+}
+
+// AuthMiddleware verifies the SCIM Bearer token.
+func (s *LegacyServer) AuthMiddleware(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
+ if !s.scimVerifyAuthHeader(r) {
+ scimUnauthorized(rw)
+ return
+ }
+ next.ServeHTTP(rw, r)
+ })
+}
+
+func (s *LegacyServer) scimVerifyAuthHeader(r *http.Request) bool {
bearer := []byte("bearer ")
hdr := []byte(r.Header.Get("Authorization"))
@@ -35,11 +94,11 @@ func (api *API) scimVerifyAuthHeader(r *http.Request) bool {
hdr = hdr[len(bearer):]
}
- return len(api.SCIMAPIKey) != 0 && subtle.ConstantTimeCompare(hdr, api.SCIMAPIKey) == 1
+ return len(s.SCIMAPIKey) != 0 && subtle.ConstantTimeCompare(hdr, s.SCIMAPIKey) == 1
}
func scimUnauthorized(rw http.ResponseWriter) {
- _ = handlerutil.WriteError(rw, scim.NewHTTPError(http.StatusUnauthorized, "invalidAuthorization", xerrors.New("invalid authorization")))
+ _ = handlerutil.WriteError(rw, NewHTTPError(http.StatusUnauthorized, "invalidAuthorization", xerrors.New("invalid authorization")))
}
// scimServiceProviderConfig returns a static SCIM service provider configuration.
@@ -50,7 +109,7 @@ func scimUnauthorized(rw http.ResponseWriter) {
// @Tags Enterprise
// @Success 200
// @Router /scim/v2/ServiceProviderConfig [get]
-func (api *API) scimServiceProviderConfig(rw http.ResponseWriter, _ *http.Request) {
+func (s *LegacyServer) scimServiceProviderConfig(rw http.ResponseWriter, _ *http.Request) {
// No auth needed to query this endpoint.
rw.Header().Set("Content-Type", spec.ApplicationScimJson)
@@ -60,35 +119,35 @@ func (api *API) scimServiceProviderConfig(rw http.ResponseWriter, _ *http.Reques
// Increment this time if you make any changes to the provider config.
providerUpdated := time.Date(2024, 10, 25, 17, 0, 0, 0, time.UTC)
var location string
- locURL, err := api.AccessURL.Parse("/scim/v2/ServiceProviderConfig")
+ locURL, err := s.AccessURL.Parse("/scim/v2/ServiceProviderConfig")
if err == nil {
location = locURL.String()
}
enc := json.NewEncoder(rw)
enc.SetEscapeHTML(true)
- _ = enc.Encode(scim.ServiceProviderConfig{
+ _ = enc.Encode(ServiceProviderConfig{
Schemas: []string{"urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"},
DocURI: "https://coder.com/docs/admin/users/oidc-auth#scim",
- Patch: scim.Supported{
+ Patch: Supported{
Supported: true,
},
- Bulk: scim.BulkSupported{
+ Bulk: BulkSupported{
Supported: false,
},
- Filter: scim.FilterSupported{
+ Filter: FilterSupported{
Supported: false,
},
- ChangePassword: scim.Supported{
+ ChangePassword: Supported{
Supported: false,
},
- Sort: scim.Supported{
+ Sort: Supported{
Supported: false,
},
- ETag: scim.Supported{
+ ETag: Supported{
Supported: false,
},
- AuthSchemes: []scim.AuthenticationScheme{
+ AuthSchemes: []AuthenticationScheme{
{
Type: "oauthbearertoken",
Name: "HTTP Header Authentication",
@@ -96,7 +155,7 @@ func (api *API) scimServiceProviderConfig(rw http.ResponseWriter, _ *http.Reques
DocURI: "https://coder.com/docs/admin/users/oidc-auth#scim",
},
},
- Meta: scim.ServiceProviderMeta{
+ Meta: ServiceProviderMeta{
Created: providerUpdated,
LastModified: providerUpdated,
Location: location,
@@ -118,8 +177,8 @@ func (api *API) scimServiceProviderConfig(rw http.ResponseWriter, _ *http.Reques
// @Router /scim/v2/Users [get]
//
//nolint:revive
-func (api *API) scimGetUsers(rw http.ResponseWriter, r *http.Request) {
- if !api.scimVerifyAuthHeader(r) {
+func (s *LegacyServer) scimGetUsers(rw http.ResponseWriter, r *http.Request) {
+ if !s.scimVerifyAuthHeader(r) {
scimUnauthorized(rw)
return
}
@@ -146,13 +205,13 @@ func (api *API) scimGetUsers(rw http.ResponseWriter, r *http.Request) {
// @Router /scim/v2/Users/{id} [get]
//
//nolint:revive
-func (api *API) scimGetUser(rw http.ResponseWriter, r *http.Request) {
- if !api.scimVerifyAuthHeader(r) {
+func (s *LegacyServer) scimGetUser(rw http.ResponseWriter, r *http.Request) {
+ if !s.scimVerifyAuthHeader(r) {
scimUnauthorized(rw)
return
}
- _ = handlerutil.WriteError(rw, scim.NewHTTPError(http.StatusNotFound, spec.ErrNotFound.Type, xerrors.New("endpoint will always return 404")))
+ _ = handlerutil.WriteError(rw, NewHTTPError(http.StatusNotFound, spec.ErrNotFound.Type, xerrors.New("endpoint will always return 404")))
}
// We currently use our own struct instead of using the SCIM package. This was
@@ -193,20 +252,20 @@ var SCIMAuditAdditionalFields = map[string]string{
// @Security Authorization
// @Produce json
// @Tags Enterprise
-// @Param request body coderd.SCIMUser true "New user"
-// @Success 200 {object} coderd.SCIMUser
+// @Param request body legacyscim.SCIMUser true "New user"
+// @Success 200 {object} legacyscim.SCIMUser
// @Router /scim/v2/Users [post]
-func (api *API) scimPostUser(rw http.ResponseWriter, r *http.Request) {
+func (s *LegacyServer) scimPostUser(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
- if !api.scimVerifyAuthHeader(r) {
+ if !s.scimVerifyAuthHeader(r) {
scimUnauthorized(rw)
return
}
- auditor := *api.AGPL.Auditor.Load()
+ auditor := *s.Auditor.Load()
aReq, commitAudit := audit.InitRequest[database.User](rw, &audit.RequestParams{
Audit: auditor,
- Log: api.Logger,
+ Log: s.Logger,
Request: r,
Action: database.AuditActionCreate,
AdditionalFields: SCIMAuditAdditionalFields,
@@ -216,12 +275,12 @@ func (api *API) scimPostUser(rw http.ResponseWriter, r *http.Request) {
var sUser SCIMUser
err := json.NewDecoder(r.Body).Decode(&sUser)
if err != nil {
- _ = handlerutil.WriteError(rw, scim.NewHTTPError(http.StatusBadRequest, "invalidRequest", err))
+ _ = handlerutil.WriteError(rw, NewHTTPError(http.StatusBadRequest, "invalidRequest", err))
return
}
if sUser.Active == nil {
- _ = handlerutil.WriteError(rw, scim.NewHTTPError(http.StatusBadRequest, "invalidRequest", xerrors.New("active field is required")))
+ _ = handlerutil.WriteError(rw, NewHTTPError(http.StatusBadRequest, "invalidRequest", xerrors.New("active field is required")))
return
}
@@ -234,12 +293,12 @@ func (api *API) scimPostUser(rw http.ResponseWriter, r *http.Request) {
}
if email == "" {
- _ = handlerutil.WriteError(rw, scim.NewHTTPError(http.StatusBadRequest, "invalidEmail", xerrors.New("no primary email provided")))
+ _ = handlerutil.WriteError(rw, NewHTTPError(http.StatusBadRequest, "invalidEmail", xerrors.New("no primary email provided")))
return
}
//nolint:gocritic
- dbUser, err := api.Database.GetUserByEmailOrUsername(dbauthz.AsSystemRestricted(ctx), database.GetUserByEmailOrUsernameParams{
+ dbUser, err := s.Database.GetUserByEmailOrUsername(dbauthz.AsSystemRestricted(ctx), database.GetUserByEmailOrUsernameParams{
Email: email,
Username: sUser.UserName,
})
@@ -253,7 +312,7 @@ func (api *API) scimPostUser(rw http.ResponseWriter, r *http.Request) {
if *sUser.Active && dbUser.Status == database.UserStatusSuspended {
//nolint:gocritic
- newUser, err := api.Database.UpdateUserStatus(dbauthz.AsSystemRestricted(r.Context()), database.UpdateUserStatusParams{
+ newUser, err := s.Database.UpdateUserStatus(dbauthz.AsSystemRestricted(r.Context()), database.UpdateUserStatusParams{
ID: dbUser.ID,
// The user will get transitioned to Active after logging in.
Status: database.UserStatusDormant,
@@ -295,23 +354,23 @@ func (api *API) scimPostUser(rw http.ResponseWriter, r *http.Request) {
// This is to preserve single org deployment behavior.
organizations := []uuid.UUID{}
//nolint:gocritic // SCIM operations are a system user
- orgSync, err := api.IDPSync.OrganizationSyncSettings(dbauthz.AsSystemRestricted(ctx), api.Database)
+ orgSync, err := s.IDPSync.OrganizationSyncSettings(dbauthz.AsSystemRestricted(ctx), s.Database)
if err != nil {
- _ = handlerutil.WriteError(rw, scim.NewHTTPError(http.StatusInternalServerError, "internalError", xerrors.Errorf("failed to get organization sync settings: %w", err)))
+ _ = handlerutil.WriteError(rw, NewHTTPError(http.StatusInternalServerError, "internalError", xerrors.Errorf("failed to get organization sync settings: %w", err)))
return
}
if orgSync.AssignDefault {
//nolint:gocritic // SCIM operations are a system user
- defaultOrganization, err := api.Database.GetDefaultOrganization(dbauthz.AsSystemRestricted(ctx))
+ defaultOrganization, err := s.Database.GetDefaultOrganization(dbauthz.AsSystemRestricted(ctx))
if err != nil {
- _ = handlerutil.WriteError(rw, scim.NewHTTPError(http.StatusInternalServerError, "internalError", xerrors.Errorf("failed to get default organization: %w", err)))
+ _ = handlerutil.WriteError(rw, NewHTTPError(http.StatusInternalServerError, "internalError", xerrors.Errorf("failed to get default organization: %w", err)))
return
}
organizations = append(organizations, defaultOrganization.ID)
}
//nolint:gocritic // needed for SCIM
- dbUser, err = api.AGPL.CreateUser(dbauthz.AsSystemRestricted(ctx), api.Database, agpl.CreateUserRequest{
+ dbUser, err = s.AGPL.CreateUser(dbauthz.AsSystemRestricted(ctx), s.Database, agpl.CreateUserRequest{
CreateUserRequestWithOrgs: codersdk.CreateUserRequestWithOrgs{
Username: sUser.UserName,
Email: email,
@@ -322,7 +381,7 @@ func (api *API) scimPostUser(rw http.ResponseWriter, r *http.Request) {
SkipNotifications: true,
})
if err != nil {
- _ = handlerutil.WriteError(rw, scim.NewHTTPError(http.StatusInternalServerError, "internalError", xerrors.Errorf("failed to create user: %w", err)))
+ _ = handlerutil.WriteError(rw, NewHTTPError(http.StatusInternalServerError, "internalError", xerrors.Errorf("failed to create user: %w", err)))
return
}
aReq.New = dbUser
@@ -342,20 +401,20 @@ func (api *API) scimPostUser(rw http.ResponseWriter, r *http.Request) {
// @Produce application/scim+json
// @Tags Enterprise
// @Param id path string true "User ID" format(uuid)
-// @Param request body coderd.SCIMUser true "Update user request"
+// @Param request body legacyscim.SCIMUser true "Update user request"
// @Success 200 {object} codersdk.User
// @Router /scim/v2/Users/{id} [patch]
-func (api *API) scimPatchUser(rw http.ResponseWriter, r *http.Request) {
+func (s *LegacyServer) scimPatchUser(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
- if !api.scimVerifyAuthHeader(r) {
+ if !s.scimVerifyAuthHeader(r) {
scimUnauthorized(rw)
return
}
- auditor := *api.AGPL.Auditor.Load()
+ auditor := *s.Auditor.Load()
aReq, commitAudit := audit.InitRequestWithCancel[database.User](rw, &audit.RequestParams{
Audit: auditor,
- Log: api.Logger,
+ Log: s.Logger,
Request: r,
Action: database.AuditActionWrite,
})
@@ -367,19 +426,19 @@ func (api *API) scimPatchUser(rw http.ResponseWriter, r *http.Request) {
var sUser SCIMUser
err := json.NewDecoder(r.Body).Decode(&sUser)
if err != nil {
- _ = handlerutil.WriteError(rw, scim.NewHTTPError(http.StatusBadRequest, "invalidRequest", err))
+ _ = handlerutil.WriteError(rw, NewHTTPError(http.StatusBadRequest, "invalidRequest", err))
return
}
sUser.ID = id
uid, err := uuid.Parse(id)
if err != nil {
- _ = handlerutil.WriteError(rw, scim.NewHTTPError(http.StatusBadRequest, "invalidId", xerrors.Errorf("id must be a uuid: %w", err)))
+ _ = handlerutil.WriteError(rw, NewHTTPError(http.StatusBadRequest, "invalidId", xerrors.Errorf("id must be a uuid: %w", err)))
return
}
//nolint:gocritic // needed for SCIM
- dbUser, err := api.Database.GetUserByID(dbauthz.AsSystemRestricted(ctx), uid)
+ dbUser, err := s.Database.GetUserByID(dbauthz.AsSystemRestricted(ctx), uid)
if err != nil {
_ = handlerutil.WriteError(rw, err) // internal error
return
@@ -388,14 +447,14 @@ func (api *API) scimPatchUser(rw http.ResponseWriter, r *http.Request) {
aReq.UserID = dbUser.ID
if sUser.Active == nil {
- _ = handlerutil.WriteError(rw, scim.NewHTTPError(http.StatusBadRequest, "invalidRequest", xerrors.New("active field is required")))
+ _ = handlerutil.WriteError(rw, NewHTTPError(http.StatusBadRequest, "invalidRequest", xerrors.New("active field is required")))
return
}
newStatus := scimUserStatus(dbUser, *sUser.Active)
if dbUser.Status != newStatus {
//nolint:gocritic // needed for SCIM
- userNew, err := api.Database.UpdateUserStatus(dbauthz.AsSystemRestricted(r.Context()), database.UpdateUserStatusParams{
+ userNew, err := s.Database.UpdateUserStatus(dbauthz.AsSystemRestricted(r.Context()), database.UpdateUserStatusParams{
ID: dbUser.ID,
Status: newStatus,
UpdatedAt: dbtime.Now(),
@@ -426,20 +485,20 @@ func (api *API) scimPatchUser(rw http.ResponseWriter, r *http.Request) {
// @Produce application/scim+json
// @Tags Enterprise
// @Param id path string true "User ID" format(uuid)
-// @Param request body coderd.SCIMUser true "Replace user request"
+// @Param request body legacyscim.SCIMUser true "Replace user request"
// @Success 200 {object} codersdk.User
// @Router /scim/v2/Users/{id} [put]
-func (api *API) scimPutUser(rw http.ResponseWriter, r *http.Request) {
+func (s *LegacyServer) scimPutUser(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
- if !api.scimVerifyAuthHeader(r) {
+ if !s.scimVerifyAuthHeader(r) {
scimUnauthorized(rw)
return
}
- auditor := *api.AGPL.Auditor.Load()
+ auditor := *s.Auditor.Load()
aReq, commitAudit := audit.InitRequestWithCancel[database.User](rw, &audit.RequestParams{
Audit: auditor,
- Log: api.Logger,
+ Log: s.Logger,
Request: r,
Action: database.AuditActionWrite,
})
@@ -451,23 +510,23 @@ func (api *API) scimPutUser(rw http.ResponseWriter, r *http.Request) {
var sUser SCIMUser
err := json.NewDecoder(r.Body).Decode(&sUser)
if err != nil {
- _ = handlerutil.WriteError(rw, scim.NewHTTPError(http.StatusBadRequest, "invalidRequest", err))
+ _ = handlerutil.WriteError(rw, NewHTTPError(http.StatusBadRequest, "invalidRequest", err))
return
}
sUser.ID = id
if sUser.Active == nil {
- _ = handlerutil.WriteError(rw, scim.NewHTTPError(http.StatusBadRequest, "invalidRequest", xerrors.New("active field is required")))
+ _ = handlerutil.WriteError(rw, NewHTTPError(http.StatusBadRequest, "invalidRequest", xerrors.New("active field is required")))
return
}
uid, err := uuid.Parse(id)
if err != nil {
- _ = handlerutil.WriteError(rw, scim.NewHTTPError(http.StatusBadRequest, "invalidId", xerrors.Errorf("id must be a uuid: %w", err)))
+ _ = handlerutil.WriteError(rw, NewHTTPError(http.StatusBadRequest, "invalidId", xerrors.Errorf("id must be a uuid: %w", err)))
return
}
//nolint:gocritic // needed for SCIM
- dbUser, err := api.Database.GetUserByID(dbauthz.AsSystemRestricted(ctx), uid)
+ dbUser, err := s.Database.GetUserByID(dbauthz.AsSystemRestricted(ctx), uid)
if err != nil {
_ = handlerutil.WriteError(rw, err) // internal error
return
@@ -484,14 +543,14 @@ func (api *API) scimPutUser(rw http.ResponseWriter, r *http.Request) {
// TODO: Currently ignoring a lot of the SCIM fields. Coder's SCIM implementation
// is very basic and only supports active status changes.
if immutabilityViolation(dbUser.Username, sUser.UserName) {
- _ = handlerutil.WriteError(rw, scim.NewHTTPError(http.StatusBadRequest, "mutability", xerrors.Errorf("username is currently an immutable field, and cannot be changed. Current: %s, New: %s", dbUser.Username, sUser.UserName)))
+ _ = handlerutil.WriteError(rw, NewHTTPError(http.StatusBadRequest, "mutability", xerrors.Errorf("username is currently an immutable field, and cannot be changed. Current: %s, New: %s", dbUser.Username, sUser.UserName)))
return
}
newStatus := scimUserStatus(dbUser, *sUser.Active)
if dbUser.Status != newStatus {
//nolint:gocritic // needed for SCIM
- userNew, err := api.Database.UpdateUserStatus(dbauthz.AsSystemRestricted(r.Context()), database.UpdateUserStatusParams{
+ userNew, err := s.Database.UpdateUserStatus(dbauthz.AsSystemRestricted(r.Context()), database.UpdateUserStatusParams{
ID: dbUser.ID,
Status: newStatus,
UpdatedAt: dbtime.Now(),
diff --git a/enterprise/coderd/scim/scimtypes.go b/enterprise/coderd/legacyscim/scimtypes.go
similarity index 99%
rename from enterprise/coderd/scim/scimtypes.go
rename to enterprise/coderd/legacyscim/scimtypes.go
index 39e022aa24e05..c96044befbc30 100644
--- a/enterprise/coderd/scim/scimtypes.go
+++ b/enterprise/coderd/legacyscim/scimtypes.go
@@ -1,4 +1,4 @@
-package scim
+package legacyscim
import (
"encoding/json"
diff --git a/enterprise/coderd/scim/expression.go b/enterprise/coderd/scim/expression.go
new file mode 100644
index 0000000000000..516f6d325f1a9
--- /dev/null
+++ b/enterprise/coderd/scim/expression.go
@@ -0,0 +1,39 @@
+package scim
+
+import (
+ "github.com/scim2/filter-parser/v2"
+ "golang.org/x/xerrors"
+
+ "github.com/coder/coder/v2/coderd/database"
+)
+
+// userQuery only supports queries of a singular attribute expression.
+// Everything else is rejected. Okta just uses username.
+// Eg: username eq "alice"
+func userQuery(expr filter.Expression) (database.GetUsersParams, error) {
+ if expr == nil {
+ return database.GetUsersParams{}, nil
+ }
+
+ attrExpr, ok := expr.(*filter.AttributeExpression)
+ if !ok {
+ return database.GetUsersParams{}, xerrors.Errorf("expected attribute expression")
+ }
+
+ attrValue, ok := attrExpr.CompareValue.(string)
+ if !ok {
+ return database.GetUsersParams{}, xerrors.Errorf("expected string compare value")
+ }
+
+ var getUsers database.GetUsersParams
+ switch attrExpr.AttributePath.AttributeName {
+ case "userName":
+ getUsers.ExactUsername = attrValue
+ case "email":
+ getUsers.ExactEmail = attrValue
+ default:
+ return database.GetUsersParams{}, xerrors.Errorf("unsupported filter attribute: %s", attrExpr.AttributePath.AttributeName)
+ }
+
+ return getUsers, nil
+}
diff --git a/enterprise/coderd/scim/scim.go b/enterprise/coderd/scim/scim.go
new file mode 100644
index 0000000000000..2ef19c1b19207
--- /dev/null
+++ b/enterprise/coderd/scim/scim.go
@@ -0,0 +1,138 @@
+package scim
+
+import (
+ "bytes"
+ "crypto/subtle"
+ "encoding/json"
+ "net/http"
+ "sync/atomic"
+
+ "github.com/elimity-com/scim"
+ scimErrors "github.com/elimity-com/scim/errors"
+ "github.com/elimity-com/scim/optional"
+ "github.com/elimity-com/scim/schema"
+
+ "cdr.dev/slog/v3"
+ agpl "github.com/coder/coder/v2/coderd"
+ "github.com/coder/coder/v2/coderd/audit"
+ "github.com/coder/coder/v2/coderd/database"
+ "github.com/coder/coder/v2/coderd/database/dbauthz"
+ "github.com/coder/coder/v2/coderd/idpsync"
+)
+
+// Handler wraps the elimity-com/scim library's Server to implement
+// SCIM 2.0 endpoints. The library auto-serves /Schemas, /ResourceTypes,
+// and /ServiceProviderConfig from schema definitions.
+type Handler struct {
+ opts *Options
+ srv *scim.Server
+}
+
+// Options holds all the dependencies needed by SCIM resource handlers.
+type Options struct {
+ DB database.Store
+ Auditor *atomic.Pointer[audit.Auditor]
+ IDPSync idpsync.IDPSync
+ Logger slog.Logger
+
+ // AGPL is needed for CreateUser.
+ AGPL *agpl.API
+
+ // SCIMAPIKey is the bearer token used to authenticate SCIM requests.
+ SCIMAPIKey []byte
+}
+
+func New(opts *Options) (*Handler, error) {
+ userHandler := &ResourceUser{
+ store: opts.DB,
+ opts: opts,
+ }
+
+ args := &scim.ServerArgs{
+ ServiceProviderConfig: &scim.ServiceProviderConfig{
+ DocumentationURI: optional.NewString("https://coder.com/docs/admin/users/oidc-auth#scim"),
+ AuthenticationSchemes: []scim.AuthenticationScheme{
+ {
+ Type: scim.AuthenticationTypeOauthBearerToken,
+ Name: "HTTP Header Authentication",
+ Description: "Authentication scheme using the Authorization header with the shared token",
+ // TODO: Add documentation links for these specific docs once they exist.
+ SpecURI: optional.String{},
+ DocumentationURI: optional.String{},
+ Primary: true,
+ },
+ },
+ MaxResults: 0,
+ // SupportFiltering is set to false, as all filtering operations are not
+ // supported. A minimal filtering syntax is supported because Okta seems to
+ // ignore this field and attempt to filter anyway.
+ SupportFiltering: false,
+ SupportPatch: true,
+ },
+ ResourceTypes: []scim.ResourceType{
+ {
+ ID: optional.NewString("User"),
+ Name: "User",
+ Description: optional.NewString("User Account"),
+ Endpoint: "/Users",
+ Schema: schema.CoreUserSchema(),
+ Handler: userHandler,
+ SchemaExtensions: nil,
+ },
+ },
+ }
+
+ srv, err := scim.NewServer(args)
+ if err != nil {
+ return nil, err
+ }
+
+ return &Handler{
+ opts: opts,
+ srv: &srv,
+ }, nil
+}
+
+func (s *Handler) authMiddleware(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
+ if !s.verifyAuthHeader(r) {
+ scimUnauthorized(rw)
+ return
+ }
+
+ // All authenticated requests are treated as coming from the SCIM provisioner
+ //nolint:gocritic // auth header authenticates as this identity
+ ctx := dbauthz.AsSCIMProvisioner(r.Context())
+ r = r.WithContext(ctx)
+
+ next.ServeHTTP(rw, r)
+ })
+}
+
+func (s *Handler) Handler() http.Handler {
+ return s.authMiddleware(s.srv)
+}
+
+func (s *Handler) verifyAuthHeader(r *http.Request) bool {
+ bearer := []byte("bearer ")
+ hdr := []byte(r.Header.Get("Authorization"))
+
+ // Case-insensitive comparison of the "Bearer " prefix.
+ if len(hdr) >= len(bearer) && subtle.ConstantTimeCompare(bytes.ToLower(hdr[:len(bearer)]), bearer) == 1 {
+ hdr = hdr[len(bearer):]
+ }
+
+ return len(s.opts.SCIMAPIKey) != 0 && subtle.ConstantTimeCompare(hdr, s.opts.SCIMAPIKey) == 1
+}
+
+func scimUnauthorized(rw http.ResponseWriter) {
+ rw.Header().Set("Content-Type", "application/scim+json")
+ rw.WriteHeader(http.StatusUnauthorized)
+ // scim error spec:
+ // https://datatracker.ietf.org/doc/html/rfc7644#section-3.12
+ _ = json.NewEncoder(rw).Encode(scimErrors.ScimError{
+ ScimType: "", // No scimType exists for unauthorized errors.
+ Detail: "invalid authorization",
+ Status: http.StatusUnauthorized,
+ })
+}
diff --git a/enterprise/coderd/scim/users.go b/enterprise/coderd/scim/users.go
new file mode 100644
index 0000000000000..57d7436b71889
--- /dev/null
+++ b/enterprise/coderd/scim/users.go
@@ -0,0 +1,588 @@
+package scim
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "strconv"
+ "strings"
+
+ "github.com/elimity-com/scim"
+ scimErrors "github.com/elimity-com/scim/errors"
+ "github.com/elimity-com/scim/optional"
+ "github.com/google/uuid"
+ "golang.org/x/xerrors"
+
+ agpl "github.com/coder/coder/v2/coderd"
+ "github.com/coder/coder/v2/coderd/audit"
+ "github.com/coder/coder/v2/coderd/database"
+ "github.com/coder/coder/v2/coderd/database/dbauthz"
+ "github.com/coder/coder/v2/coderd/database/dbtime"
+ "github.com/coder/coder/v2/coderd/util/ptr"
+ "github.com/coder/coder/v2/codersdk"
+)
+
+var _ scim.ResourceHandler = (*ResourceUser)(nil)
+
+// auditUser emits an audit log for a SCIM operation. This uses
+// BackgroundAudit instead of InitRequest because the elimity-com/scim
+// library owns the http.ResponseWriter and does not expose it to
+// resource handlers.
+func (ru *ResourceUser) auditUser(ctx context.Context, r *http.Request, action database.AuditAction, old, changed database.User) {
+ raw, _ := json.Marshal(map[string]string{
+ "automatic_actor": "coder",
+ "automatic_subsystem": "scim",
+ })
+ auditor := *ru.opts.Auditor.Load()
+
+ // This is a best effort
+ // TODO: Check X-Forwarded-For and others for proxied requests
+ ip := r.RemoteAddr
+
+ audit.BackgroundAudit(ctx, &audit.BackgroundAuditParams[database.User]{
+ Audit: auditor,
+ Log: ru.opts.Logger,
+ UserID: uuid.Nil, // SCIM provisioner, not a real user
+ Action: action,
+ Old: old,
+ New: changed,
+ IP: ip,
+ UserAgent: r.UserAgent(),
+ AdditionalFields: raw,
+ Status: http.StatusOK,
+ })
+}
+
+type ResourceUser struct {
+ store database.Store
+ opts *Options
+}
+
+// Create implements scim.ResourceHandler. Creates a new Coder user from
+// SCIM attributes, or returns the existing user if a duplicate is found.
+func (ru *ResourceUser) Create(r *http.Request, attributes scim.ResourceAttributes) (scim.Resource, error) {
+ ctx := r.Context()
+
+ // Extract fields from the SCIM attributes.
+ // Do our best to match what the OIDC signup flow also does.
+ username, _ := attributeAsString(attributes, "userName")
+ email := primaryEmail(attributes)
+ if email == "" {
+ // email is required
+ return scim.Resource{}, scimErrors.ScimErrorBadRequest("no primary email provided")
+ }
+
+ // This comes from userOIDC
+ // TODO: Ideally this code would be shared between the two places.
+ usernameValidErr := codersdk.NameValid(username)
+ if usernameValidErr != nil {
+ if username == "" {
+ username = email
+ }
+ username = codersdk.UsernameFrom(username)
+ }
+
+ // TODO: OIDC has optional configuration like `EmailDomain` to reject emails outside a specific domain.
+ // We should consider whether we want to support that for SCIM as well, and if so, apply that validation here.
+
+ active := true
+ if a, ok := attribute(attributes, "active"); ok {
+ v, err := booleanValue(a)
+ if err != nil {
+ return scim.Resource{}, scimErrors.ScimErrorBadRequest(
+ fmt.Sprintf("invalid boolean value for 'active' field: %v", a))
+ }
+ active = v
+ }
+
+ // Check for existing user by email or username.
+ dbUser, err := ru.store.GetUserByEmailOrUsername(ctx, database.GetUserByEmailOrUsernameParams{
+ Email: email,
+ Username: username,
+ })
+ if err == nil {
+ // SCIM spec says to return a StatusConflict if the user already exists.
+ // However, Coder never deletes a user. So suspended **is** deleted.
+ // If the user is not suspended, we return a conflict.
+ if dbUser.Status != database.UserStatusSuspended {
+ return scim.Resource{}, scimErrors.ScimError{
+ ScimType: scimErrors.ScimTypeUniqueness,
+ Detail: fmt.Sprintf("user already exists with email %q or username %q", email, username),
+ Status: http.StatusConflict,
+ }
+ }
+
+ // If the user is suspended, then they might be deleted on the SCIM side.
+ // We can just update their status and return the user as they exist.
+ status := scimUserStatus(dbUser, &active)
+ dbUser, err = ru.updateUserStatus(ctx, r, dbUser, status)
+ if err != nil {
+ return scim.Resource{}, err
+ }
+ return userResource(dbUser), nil
+ }
+
+ if !xerrors.Is(err, sql.ErrNoRows) {
+ // Internal DB errors should be returned.
+ // ErrNoRows is expected if the user does not exist.
+ return scim.Resource{}, err
+ }
+
+ // OIDC login runs org, group, and role sync. SCIM does not have (or not yet) these
+ // claims. We only need to sync the default organization if that is enabled.
+ //
+ // When the user eventually logs in via OIDC, the regular sync will run.
+ // However, since org sync can be disabled. We need to assign the default org if
+ // that is how we are configured.
+ organizations := []uuid.UUID{}
+ orgSync, err := ru.opts.IDPSync.OrganizationSyncSettings(ctx, ru.store)
+ if err != nil {
+ return scim.Resource{}, xerrors.Errorf("get organization sync settings: %w", err)
+ }
+ if orgSync.AssignDefault {
+ // Technically, we could just always assign this. When they eventually log in,
+ // the org would be removed if necessary. But to avoid confusion of the user
+ // being in the org before they log in, we apply some intelligence to this guess
+ // of "Do they belong in the default org".
+ defaultOrganization, err := ru.store.GetDefaultOrganization(ctx)
+ if err != nil {
+ return scim.Resource{}, xerrors.Errorf("get default organization: %w", err)
+ }
+ organizations = append(organizations, defaultOrganization.ID)
+ }
+
+ // CreateUser does InsertOrganizationMember internally, and InsertUser
+ // implicitly assigns the member role at site scope. The SCIM provisioner
+ // role cannot assign either, so escalate to a system context for this
+ // specific call, matching the legacy SCIM handler.
+ //nolint:gocritic // SCIM bearer token authenticates as the SCIM provisioner; user creation needs broader rights to assign default roles.
+ dbUser, err = ru.opts.AGPL.CreateUser(dbauthz.AsSystemRestricted(ctx), ru.store, agpl.CreateUserRequest{
+ CreateUserRequestWithOrgs: codersdk.CreateUserRequestWithOrgs{
+ Username: username,
+ Email: email,
+ OrganizationIDs: organizations,
+ },
+ LoginType: database.LoginTypeOIDC,
+ // Do not send notifications to user admins; SCIM may call this
+ // sequentially for many users.
+ // TODO: Maybe we should spam them anyway?
+ SkipNotifications: true,
+ })
+ if err != nil {
+ return scim.Resource{}, xerrors.Errorf("create user: %w", err)
+ }
+
+ ru.auditUser(ctx, r, database.AuditActionCreate, database.User{}, dbUser)
+ return userResource(dbUser), nil
+}
+
+// Get implements scim.ResourceHandler. Returns a single user by ID.
+func (ru *ResourceUser) Get(r *http.Request, idStr string) (scim.Resource, error) {
+ ctx := r.Context()
+ usr, err := ru.user(ctx, idStr)
+ if err != nil {
+ return scim.Resource{}, err
+ }
+
+ return userResource(usr), nil
+}
+
+// GetAll implements scim.ResourceHandler. Returns a paginated list of users.
+func (ru *ResourceUser) GetAll(r *http.Request, params scim.ListRequestParams) (scim.Page, error) {
+ ctx := r.Context()
+
+ var qry database.GetUsersParams
+ if params.FilterValidator != nil {
+ var err error
+ qry, err = userQuery(params.FilterValidator.GetFilter())
+ if err != nil {
+ return scim.Page{}, scimErrors.ScimErrorBadRequest(fmt.Sprintf("invalid filter: %v", err))
+ }
+ }
+
+ qry.LimitOpt = int32(params.Count) //nolint:gosec
+ qry.OffsetOpt = int32(params.StartIndex - 1) //nolint:gosec
+
+ if qry.LimitOpt < 0 {
+ qry.LimitOpt = 100
+ }
+
+ users, err := ru.store.GetUsers(ctx, qry)
+ if err != nil {
+ return scim.Page{}, err
+ }
+
+ totalCount := int64(len(users))
+ if len(users) == int(qry.LimitOpt) {
+ // If the limit is not reached, that is the count
+ // TODO: If there is a query and the limit is reached, this is inaccurate.
+ totalCount, err = ru.store.GetUserCount(ctx, false)
+ if err != nil {
+ return scim.Page{}, err
+ }
+ }
+
+ resources := make([]scim.Resource, 0, len(users))
+ for _, u := range users {
+ resources = append(resources, userResourceFromGetUsersRow(u))
+ }
+
+ return scim.Page{
+ TotalResults: int(totalCount),
+ Resources: resources,
+ }, nil
+}
+
+// Replace implements scim.ResourceHandler (PUT). Replaces user attributes.
+// Currently only supports changing the active status per existing behavior.
+func (ru *ResourceUser) Replace(r *http.Request, idStr string, attributes scim.ResourceAttributes) (scim.Resource, error) {
+ ctx := r.Context()
+
+ dbUser, err := ru.user(ctx, idStr)
+ if err != nil {
+ return scim.Resource{}, err
+ }
+
+ // All of our fields except for active are immutable.
+ if !attributeEqual(dbUser.Username, attributes, "userName") {
+ return scim.Resource{}, scimErrors.ScimErrorBadRequest(fmt.Sprintf("changing the 'userName' field is not supported (current value: %q)", dbUser.Username))
+ }
+
+ // TODO: Check if the primary email has changed. If it has, should we do something?
+
+ activeInterface, ok := attribute(attributes, "active")
+ if !ok {
+ return scim.Resource{}, scimErrors.ScimErrorBadRequest("missing required 'active' field")
+ }
+
+ active, err := booleanValue(activeInterface)
+ if err != nil {
+ return scim.Resource{}, scimErrors.ScimErrorBadRequest(fmt.Sprintf("invalid boolean value for 'active' field: %v", activeInterface))
+ }
+
+ newStatus := scimUserStatus(dbUser, &active)
+ dbUser, err = ru.updateUserStatus(ctx, r, dbUser, newStatus)
+ if err != nil {
+ return scim.Resource{}, err
+ }
+
+ return userResource(dbUser), nil
+}
+
+// Delete implements scim.ResourceHandler. Suspends the user (Coder does
+// not hard-delete users).
+func (ru *ResourceUser) Delete(r *http.Request, idStr string) error {
+ ctx := r.Context()
+
+ dbUser, err := ru.user(ctx, idStr)
+ if err != nil {
+ return err
+ }
+
+ _, err = ru.updateUserStatus(ctx, r, dbUser, database.UserStatusSuspended)
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
+
+// Patch implements scim.ResourceHandler. Updates user attributes based on
+// SCIM PatchOp operations. Currently, supports changing the active status.
+func (ru *ResourceUser) Patch(r *http.Request, idStr string, operations []scim.PatchOperation) (scim.Resource, error) {
+ ctx := r.Context()
+
+ uid, err := uuid.Parse(idStr)
+ if err != nil {
+ return scim.Resource{}, badUUID(idStr, err)
+ }
+
+ dbUser, err := ru.store.GetUserByID(ctx, uid)
+ if err != nil {
+ if xerrors.Is(err, sql.ErrNoRows) {
+ return scim.Resource{}, scimErrors.ScimErrorResourceNotFound(idStr)
+ }
+ return scim.Resource{}, err
+ }
+
+ // Process operations. Currently, we only handle the "active" attribute.
+ var activeSet *bool
+ for _, op := range operations {
+ switch op.Op {
+ case "add":
+ // TODO: Currently we do not support the adding of attributes.
+ case "remove":
+ // TODO: If the path is unspecified, we should fail with the status code 400.
+ // Today, we only accept the 'active' field and silently drop the rest.
+ if op.Path != nil && strings.EqualFold(op.Path.String(), "active") {
+ activeSet = ptr.Ref(false)
+ }
+ case "replace":
+ // TODO: Honor mutability rules of fields like `userName` and `email`.
+ // Should scim be able to change those fields?
+
+ // SCIM PATCH replace can come in two forms:
+ // 1. Path set: {"op":"replace","path":"active","value":false}
+ // 2. No path, value is a map: {"op":"replace","value":{"active":false}}
+ if op.Path != nil && strings.EqualFold(op.Path.String(), "active") {
+ v, err := booleanValue(op.Value)
+ if err != nil {
+ return scim.Resource{}, scimErrors.ScimErrorBadRequest(fmt.Sprintf("invalid boolean value for 'active' field: %v", op.Value))
+ }
+ activeSet = &v
+ } else if m, ok := op.Value.(map[string]interface{}); ok {
+ if actV, ok := attribute(m, "active"); ok {
+ v, err := booleanValue(actV)
+ if err != nil {
+ return scim.Resource{}, scimErrors.ScimErrorBadRequest(fmt.Sprintf("invalid boolean value for 'active' field: %v", actV))
+ }
+ activeSet = &v
+ }
+ }
+ default:
+ }
+ }
+
+ newStatus := scimUserStatus(dbUser, activeSet)
+ dbUser, err = ru.updateUserStatus(ctx, r, dbUser, newStatus)
+ if err != nil {
+ return scim.Resource{}, err
+ }
+
+ return userResource(dbUser), nil
+}
+
+func (ru *ResourceUser) user(ctx context.Context, idStr string) (database.User, error) {
+ id, err := uuid.Parse(idStr)
+ if err != nil {
+ return database.User{}, badUUID(idStr, err)
+ }
+
+ usr, err := ru.store.GetUserByID(ctx, id)
+ if err != nil {
+ if xerrors.Is(err, sql.ErrNoRows) {
+ return database.User{}, scimErrors.ScimErrorResourceNotFound(idStr)
+ }
+ return database.User{}, err
+ }
+
+ return usr, nil
+}
+
+// updateUserStatus is a no-op if the status did not change.
+func (ru *ResourceUser) updateUserStatus(ctx context.Context, r *http.Request, u database.User, status database.UserStatus) (database.User, error) {
+ if u.Status == status {
+ return u, nil
+ }
+ newUser, err := ru.store.UpdateUserStatus(ctx, database.UpdateUserStatusParams{
+ ID: u.ID, Status: status, UpdatedAt: dbtime.Now(), UserIsSeen: false,
+ })
+ if err != nil {
+ return database.User{}, err
+ }
+ ru.auditUser(ctx, r, database.AuditActionWrite, u, newUser)
+ return newUser, nil
+}
+
+// scimUserStatus maps the SCIM "active" boolean to Coder's internal user status.
+// It preserves the active/dormant distinction: active users stay active,
+// dormant or suspended users become dormant when re-activated (they become
+// active after their next login).
+//
+//nolint:revive // active is not a control flag
+func scimUserStatus(user database.User, active *bool) database.UserStatus {
+ if active == nil {
+ return user.Status
+ }
+
+ if !(*active) {
+ // SCIM "active: false" means the user should be suspended
+ return database.UserStatusSuspended
+ }
+
+ switch user.Status {
+ case database.UserStatusActive:
+ // Active users stay active
+ return database.UserStatusActive
+ case database.UserStatusDormant, database.UserStatusSuspended:
+ // Dormant or suspended users become dormant when re-activated
+ // The user can then become active by doing something in the product.
+ return database.UserStatusDormant
+ default:
+ return database.UserStatusDormant
+ }
+}
+
+// userResource converts a database.User into a SCIM Resource.
+func userResource(u database.User) scim.Resource {
+ return scim.Resource{
+ ID: u.ID.String(),
+ ExternalID: optional.String{},
+ Attributes: scim.ResourceAttributes{
+ "userName": u.Username,
+ "name": map[string]interface{}{
+ "formatted": u.Name,
+ },
+ "emails": []map[string]interface{}{
+ {
+ "primary": true,
+ "value": u.Email,
+ },
+ },
+ "active": u.Status == database.UserStatusActive ||
+ u.Status == database.UserStatusDormant,
+ },
+ Meta: scim.Meta{
+ Created: &u.CreatedAt,
+ LastModified: &u.UpdatedAt,
+ },
+ }
+}
+
+// userResourceFromGetUsersRow converts a database.GetUsersRow into a SCIM Resource.
+func userResourceFromGetUsersRow(u database.GetUsersRow) scim.Resource {
+ return scim.Resource{
+ ID: u.ID.String(),
+ ExternalID: optional.String{},
+ Attributes: scim.ResourceAttributes{
+ "userName": u.Username,
+ "name": map[string]interface{}{
+ "formatted": u.Name,
+ },
+ "emails": []map[string]interface{}{
+ {
+ "primary": true,
+ "value": u.Email,
+ },
+ },
+ "active": u.Status == database.UserStatusActive ||
+ u.Status == database.UserStatusDormant,
+ },
+ Meta: scim.Meta{
+ Created: &u.CreatedAt,
+ LastModified: &u.UpdatedAt,
+ },
+ }
+}
+
+func attributeAsBool(attrs scim.ResourceAttributes, key string) (value bool, exists bool) {
+ val, ok := attribute(attrs, key)
+ if !ok {
+ return false, false
+ }
+
+ switch v := val.(type) {
+ case string:
+ pv, err := strconv.ParseBool(v)
+ return pv, err == nil
+ case bool:
+ return v, true
+ default:
+ return false, false
+ }
+}
+
+func attributeAsString(attrs scim.ResourceAttributes, key string) (string, bool) {
+ val, ok := attribute(attrs, key)
+ if !ok {
+ return "", false
+ }
+
+ switch v := val.(type) {
+ case string:
+ return v, true
+ case bool:
+ return strconv.FormatBool(v), true
+ default:
+ return "", false
+ }
+}
+
+func attribute(attrs scim.ResourceAttributes, key string) (interface{}, bool) {
+ // attribute names are case-insensitive per SCIM spec
+ val, ok := attrs[key]
+ if ok {
+ return val, true
+ }
+
+ // This is terrible, but we need to iterate the map to find the key in a case-insensitive way.
+ // The scim Spec says attribute names are case-insensitive.
+ for k, v := range attrs {
+ if k == key {
+ return v, true
+ }
+ if len(k) == len(key) && strings.EqualFold(k, key) {
+ return v, true
+ }
+ }
+
+ return nil, false
+}
+
+// badUUID returns a 404 not-found error for non-UUID identifiers.
+// SCIM clients may send arbitrary strings as IDs; returning 404
+// (rather than 400) signals that no resource matches.
+func badUUID(idStr string, _ error) scimErrors.ScimError {
+ return scimErrors.ScimError{
+ Detail: fmt.Sprintf("%q is not a valid uuid; resource not found", idStr),
+ Status: http.StatusNotFound,
+ }
+}
+
+func booleanValue(v interface{}) (bool, error) {
+ switch b := v.(type) {
+ case bool:
+ return b, nil
+ case string:
+ return strconv.ParseBool(b)
+ default:
+ return false, xerrors.Errorf("expected boolean or string value, got %T", v)
+ }
+}
+
+func attributeEqual[T comparable](existing T, attrs scim.ResourceAttributes, key string) bool {
+ found, ok := attribute(attrs, key)
+ if !ok {
+ return true // No change if the attribute is not present in the request
+ }
+
+ sameType, ok := found.(T)
+ if !ok {
+ return false // Type mismatch, consider it a change
+ }
+
+ return existing == sameType
+}
+
+// primaryEmail extracts the primary email from SCIM resource attributes.
+func primaryEmail(attributes scim.ResourceAttributes) string {
+ emailsRaw, ok := attribute(attributes, "emails")
+ if !ok {
+ return ""
+ }
+
+ emails, ok := emailsRaw.([]interface{})
+ if !ok {
+ return ""
+ }
+
+ var fallback string
+ for _, e := range emails {
+ emailMap, ok := e.(map[string]interface{})
+ if !ok {
+ continue
+ }
+ val, ok := attributeAsString(emailMap, "value")
+ if !ok {
+ continue
+ }
+ if primary, _ := attributeAsBool(emailMap, "primary"); primary {
+ return val
+ }
+ fallback = val
+ }
+
+ return fallback
+}
diff --git a/enterprise/coderd/scim/users_internal_test.go b/enterprise/coderd/scim/users_internal_test.go
new file mode 100644
index 0000000000000..b95e0a361f0ab
--- /dev/null
+++ b/enterprise/coderd/scim/users_internal_test.go
@@ -0,0 +1,760 @@
+package scim
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "sync/atomic"
+ "testing"
+
+ "github.com/elimity-com/scim"
+ scimErrors "github.com/elimity-com/scim/errors"
+ "github.com/google/uuid"
+ filter "github.com/scim2/filter-parser/v2"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "go.uber.org/mock/gomock"
+
+ "cdr.dev/slog/v3"
+ "cdr.dev/slog/v3/sloggers/slogtest"
+ "github.com/coder/coder/v2/coderd/audit"
+ "github.com/coder/coder/v2/coderd/database"
+ "github.com/coder/coder/v2/coderd/database/dbauthz"
+ "github.com/coder/coder/v2/coderd/database/dbgen"
+ "github.com/coder/coder/v2/coderd/database/dbmock"
+ "github.com/coder/coder/v2/coderd/database/dbtestutil"
+)
+
+// setupSCIM creates a ResourceUser backed by a real database for testing.
+// The returned mock auditor can be inspected for emitted audit logs.
+func setupSCIM(t *testing.T) (*ResourceUser, database.Store, *audit.MockAuditor) {
+ t.Helper()
+
+ db, _ := dbtestutil.NewDB(t)
+ mockAudit := audit.NewMock()
+ auditorPtr := atomic.Pointer[audit.Auditor]{}
+ var a audit.Auditor = mockAudit
+ auditorPtr.Store(&a)
+
+ ru := &ResourceUser{
+ store: db,
+ opts: &Options{
+ DB: db,
+ Auditor: &auditorPtr,
+ Logger: slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug),
+ },
+ }
+ return ru, db, mockAudit
+}
+
+// scimRequest builds an *http.Request with scim provisioner context,
+// simulating the auth context that the SCIM middleware normally sets.
+func scimRequest(t *testing.T) *http.Request {
+ t.Helper()
+ ctx := dbauthz.AsSCIMProvisioner(context.Background())
+ return httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx)
+}
+
+// seedUser creates a user in the database for testing.
+func seedUser(t *testing.T, db database.Store, opts database.User) database.User {
+ t.Helper()
+ return dbgen.User(t, db, opts)
+}
+
+// setupSCIMMock creates a ResourceUser backed by a gomock store for tests
+// that only need to verify call patterns (e.g. audit emission) without
+// real SQL.
+func setupSCIMMock(t *testing.T) (*ResourceUser, *dbmock.MockStore, *audit.MockAuditor) {
+ t.Helper()
+
+ ctrl := gomock.NewController(t)
+ mockStore := dbmock.NewMockStore(ctrl)
+ mockAudit := audit.NewMock()
+ auditorPtr := atomic.Pointer[audit.Auditor]{}
+ var a audit.Auditor = mockAudit
+ auditorPtr.Store(&a)
+
+ ru := &ResourceUser{
+ store: mockStore,
+ opts: &Options{
+ DB: mockStore,
+ Auditor: &auditorPtr,
+ Logger: slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug),
+ },
+ }
+ return ru, mockStore, mockAudit
+}
+
+// --- Pure function tests (no DB) ---
+
+func TestScimUserStatus(t *testing.T) {
+ t.Parallel()
+
+ boolPtr := func(b bool) *bool { return &b }
+
+ tests := []struct {
+ name string
+ status database.UserStatus
+ active *bool
+ expected database.UserStatus
+ }{
+ {"active+true=active", database.UserStatusActive, boolPtr(true), database.UserStatusActive},
+ {"active+false=suspended", database.UserStatusActive, boolPtr(false), database.UserStatusSuspended},
+ {"suspended+true=dormant", database.UserStatusSuspended, boolPtr(true), database.UserStatusDormant},
+ {"suspended+false=suspended", database.UserStatusSuspended, boolPtr(false), database.UserStatusSuspended},
+ {"dormant+true=dormant", database.UserStatusDormant, boolPtr(true), database.UserStatusDormant},
+ {"dormant+false=suspended", database.UserStatusDormant, boolPtr(false), database.UserStatusSuspended},
+ {"active+nil=active", database.UserStatusActive, nil, database.UserStatusActive},
+ {"suspended+nil=suspended", database.UserStatusSuspended, nil, database.UserStatusSuspended},
+ {"dormant+nil=dormant", database.UserStatusDormant, nil, database.UserStatusDormant},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ user := database.User{Status: tt.status}
+ got := scimUserStatus(user, tt.active)
+ assert.Equal(t, tt.expected, got)
+ })
+ }
+}
+
+func TestPrimaryEmail(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ attrs scim.ResourceAttributes
+ expected string
+ }{
+ {
+ name: "primary email",
+ attrs: scim.ResourceAttributes{
+ "emails": []interface{}{
+ map[string]interface{}{"value": "a@b.com", "primary": true},
+ },
+ },
+ expected: "a@b.com",
+ },
+ {
+ name: "fallback to first when no primary",
+ attrs: scim.ResourceAttributes{
+ "emails": []interface{}{
+ map[string]interface{}{"value": "first@b.com"},
+ },
+ },
+ expected: "first@b.com",
+ },
+ {
+ name: "picks primary over first",
+ attrs: scim.ResourceAttributes{
+ "emails": []interface{}{
+ map[string]interface{}{"value": "first@b.com"},
+ map[string]interface{}{"value": "primary@b.com", "primary": true},
+ },
+ },
+ expected: "primary@b.com",
+ },
+ {
+ name: "polluted",
+ attrs: scim.ResourceAttributes{
+ "emails": []interface{}{
+ // Try and cause a panic
+ "not-a-map",
+ true,
+ 7,
+ map[int]interface{}{
+ 1: "bad",
+ },
+ map[string]interface{}{
+ "value": 123, // value is not a string
+ },
+ map[string]interface{}{},
+ map[string]interface{}{"value": "first@b.com"},
+ map[string]interface{}{"value": "primary@b.com", "primary": true},
+ },
+ },
+ expected: "primary@b.com",
+ },
+ {
+ name: "no emails key",
+ attrs: scim.ResourceAttributes{},
+ expected: "",
+ },
+ {
+ name: "empty emails",
+ attrs: scim.ResourceAttributes{"emails": []interface{}{}},
+ expected: "",
+ },
+ {
+ name: "wrong type",
+ attrs: scim.ResourceAttributes{"emails": "not-a-list"},
+ expected: "",
+ },
+ {
+ name: "case-insensitive top-level key",
+ attrs: scim.ResourceAttributes{
+ "Emails": []interface{}{
+ map[string]interface{}{"value": "a@b.com", "primary": true},
+ },
+ },
+ expected: "a@b.com",
+ },
+ {
+ name: "case-insensitive inner keys",
+ attrs: scim.ResourceAttributes{
+ "emails": []interface{}{
+ map[string]interface{}{"Value": "a@b.com", "Primary": true},
+ },
+ },
+ expected: "a@b.com",
+ },
+ {
+ name: "all caps keys",
+ attrs: scim.ResourceAttributes{
+ "EMAILS": []interface{}{
+ map[string]interface{}{"VALUE": "a@b.com", "PRIMARY": true},
+ },
+ },
+ expected: "a@b.com",
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ got := primaryEmail(tt.attrs)
+ assert.Equal(t, tt.expected, got)
+ })
+ }
+}
+
+func TestBooleanValue(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ input interface{}
+ want bool
+ wantErr bool
+ }{
+ {"bool true", true, true, false},
+ {"bool false", false, false, false},
+ {"string true", "true", true, false},
+ {"string false", "false", false, false},
+ {"string True", "True", true, false},
+ {"int", 42, false, true},
+ {"nil", nil, false, true},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ got, err := booleanValue(tt.input)
+ if tt.wantErr {
+ require.Error(t, err)
+ } else {
+ require.NoError(t, err)
+ assert.Equal(t, tt.want, got)
+ }
+ })
+ }
+}
+
+func TestAttribute(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ attrs scim.ResourceAttributes
+ key string
+ wantVal interface{}
+ wantOK bool
+ }{
+ {"exact match", scim.ResourceAttributes{"active": true}, "active", true, true},
+ {"capital first", scim.ResourceAttributes{"active": true}, "Active", true, true},
+ {"all caps", scim.ResourceAttributes{"active": true}, "ACTIVE", true, true},
+ {"camelCase key", scim.ResourceAttributes{"userName": "alice"}, "username", "alice", true},
+ {"camelCase swapped", scim.ResourceAttributes{"username": "alice"}, "userName", "alice", true},
+ {"missing key", scim.ResourceAttributes{"active": true}, "missing", nil, false},
+ {"empty map", scim.ResourceAttributes{}, "active", nil, false},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ val, ok := attribute(tt.attrs, tt.key)
+ assert.Equal(t, tt.wantOK, ok)
+ assert.Equal(t, tt.wantVal, val)
+ })
+ }
+}
+
+func TestAttributeAsBool(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ attrs scim.ResourceAttributes
+ key string
+ want bool
+ wantOK bool
+ }{
+ {"exact key bool", scim.ResourceAttributes{"active": true}, "active", true, true},
+ {"mixed case bool", scim.ResourceAttributes{"active": false}, "Active", false, true},
+ {"all caps bool", scim.ResourceAttributes{"active": true}, "ACTIVE", true, true},
+ {"mixed case string true", scim.ResourceAttributes{"active": "true"}, "Active", true, true},
+ {"mixed case string false", scim.ResourceAttributes{"active": "false"}, "ACTIVE", false, true},
+ {"missing key", scim.ResourceAttributes{}, "active", false, false},
+ {"non-convertible", scim.ResourceAttributes{"active": 42}, "active", false, false},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ got, ok := attributeAsBool(tt.attrs, tt.key)
+ assert.Equal(t, tt.wantOK, ok)
+ assert.Equal(t, tt.want, got)
+ })
+ }
+}
+
+func TestAttributeAsString(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ attrs scim.ResourceAttributes
+ key string
+ want string
+ wantOK bool
+ }{
+ {"exact key string", scim.ResourceAttributes{"userName": "alice"}, "userName", "alice", true},
+ {"mixed case string", scim.ResourceAttributes{"userName": "alice"}, "UserName", "alice", true},
+ {"lower case lookup", scim.ResourceAttributes{"userName": "alice"}, "username", "alice", true},
+ {"bool to string", scim.ResourceAttributes{"active": true}, "active", "true", true},
+ {"mixed case bool to string", scim.ResourceAttributes{"active": false}, "Active", "false", true},
+ {"missing key", scim.ResourceAttributes{}, "userName", "", false},
+ {"non-convertible", scim.ResourceAttributes{"count": 42}, "count", "", false},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ got, ok := attributeAsString(tt.attrs, tt.key)
+ assert.Equal(t, tt.wantOK, ok)
+ assert.Equal(t, tt.want, got)
+ })
+ }
+}
+
+func TestAttributeEqual(t *testing.T) {
+ t.Parallel()
+
+ t.Run("exact match same value", func(t *testing.T) {
+ t.Parallel()
+ attrs := scim.ResourceAttributes{"userName": "alice"}
+ assert.True(t, attributeEqual("alice", attrs, "userName"))
+ })
+
+ t.Run("mixed case same value", func(t *testing.T) {
+ t.Parallel()
+ attrs := scim.ResourceAttributes{"userName": "alice"}
+ assert.True(t, attributeEqual("alice", attrs, "UserName"))
+ })
+
+ t.Run("mixed case different value", func(t *testing.T) {
+ t.Parallel()
+ attrs := scim.ResourceAttributes{"userName": "bob"}
+ assert.False(t, attributeEqual("alice", attrs, "USERNAME"))
+ })
+
+ t.Run("missing key means no change", func(t *testing.T) {
+ t.Parallel()
+ attrs := scim.ResourceAttributes{}
+ assert.True(t, attributeEqual("alice", attrs, "userName"))
+ })
+
+ t.Run("type mismatch", func(t *testing.T) {
+ t.Parallel()
+ attrs := scim.ResourceAttributes{"userName": 42}
+ assert.False(t, attributeEqual("alice", attrs, "userName"))
+ })
+}
+
+// --- Handler tests (with DB) ---
+
+func TestResourceUser_CaseInsensitive(t *testing.T) {
+ t.Parallel()
+
+ ru, db, _ := setupSCIM(t)
+
+ // Seed an active user.
+ user := seedUser(t, db, database.User{
+ Status: database.UserStatusActive,
+ LoginType: database.LoginTypeOIDC,
+ })
+
+ r := scimRequest(t)
+
+ // Replace with "Active" (capital A) instead of "active".
+ res, err := ru.Replace(r, user.ID.String(), scim.ResourceAttributes{
+ "userName": user.Username,
+ "Active": false,
+ })
+ require.NoError(t, err)
+ assert.Equal(t, false, res.Attributes["active"])
+
+ // Confirm suspended via Get.
+ res, err = ru.Get(r, user.ID.String())
+ require.NoError(t, err)
+ assert.Equal(t, false, res.Attributes["active"])
+
+ // Patch back with map-style replace using "Active" key.
+ res, err = ru.Patch(r, user.ID.String(), []scim.PatchOperation{
+ {Op: "replace", Value: map[string]interface{}{"Active": true}},
+ })
+ require.NoError(t, err)
+ assert.Equal(t, true, res.Attributes["active"])
+
+ // Confirm reactivated via Get.
+ res, err = ru.Get(r, user.ID.String())
+ require.NoError(t, err)
+ assert.Equal(t, true, res.Attributes["active"])
+}
+
+func TestResourceUser_Create(t *testing.T) {
+ t.Parallel()
+
+ // Coder does not hard-delete users. A SCIM Delete suspends the user, so
+ // when an IdP later re-creates the same user, the handler should match
+ // them by email/username and reactivate the existing row instead of
+ // returning 409 Conflict. See commit b3e6e0aa06.
+
+ t.Run("duplicate-active-conflict", func(t *testing.T) {
+ t.Parallel()
+ ru, db, _ := setupSCIM(t)
+
+ existing := seedUser(t, db, database.User{
+ Status: database.UserStatusActive,
+ LoginType: database.LoginTypeOIDC,
+ })
+
+ _, err := ru.Create(scimRequest(t), scim.ResourceAttributes{
+ "userName": existing.Username,
+ "emails": []interface{}{
+ map[string]interface{}{"value": existing.Email, "primary": true},
+ },
+ "active": true,
+ })
+ require.Error(t, err)
+ var scimErr scimErrors.ScimError
+ require.ErrorAs(t, err, &scimErr)
+ assert.Equal(t, http.StatusConflict, scimErr.Status)
+ })
+
+ t.Run("suspended-user-reactivates", func(t *testing.T) {
+ t.Parallel()
+ ru, db, mockAudit := setupSCIM(t)
+
+ existing := seedUser(t, db, database.User{
+ Status: database.UserStatusSuspended,
+ LoginType: database.LoginTypeOIDC,
+ })
+
+ res, err := ru.Create(scimRequest(t), scim.ResourceAttributes{
+ "userName": existing.Username,
+ "emails": []interface{}{
+ map[string]interface{}{"value": existing.Email, "primary": true},
+ },
+ "active": true,
+ })
+ require.NoError(t, err)
+ assert.Equal(t, existing.ID.String(), res.ID, "response should reference the existing user, not a new one")
+
+ // The SCIM response must reflect the post-update state so the IdP
+ // sees active=true after the recreate.
+ assert.Equal(t, true, res.Attributes["active"], "response should report the reactivated state")
+
+ // Suspended + active=true reactivates to Dormant (not Active) per scimUserStatus.
+ got, err := db.GetUserByID(dbauthz.AsSCIMProvisioner(context.Background()), existing.ID)
+ require.NoError(t, err)
+ assert.Equal(t, database.UserStatusDormant, got.Status, "suspended user should be marked dormant on recreate")
+
+ // Reactivation should emit one audit log for the status change.
+ assert.Len(t, mockAudit.AuditLogs(), 1)
+ })
+
+ t.Run("suspended-user-stays-suspended-when-active-false", func(t *testing.T) {
+ t.Parallel()
+ ru, db, mockAudit := setupSCIM(t)
+
+ existing := seedUser(t, db, database.User{
+ Status: database.UserStatusSuspended,
+ LoginType: database.LoginTypeOIDC,
+ })
+
+ res, err := ru.Create(scimRequest(t), scim.ResourceAttributes{
+ "userName": existing.Username,
+ "emails": []interface{}{
+ map[string]interface{}{"value": existing.Email, "primary": true},
+ },
+ "active": false,
+ })
+ require.NoError(t, err)
+ assert.Equal(t, existing.ID.String(), res.ID)
+ assert.Equal(t, false, res.Attributes["active"])
+
+ got, err := db.GetUserByID(dbauthz.AsSCIMProvisioner(context.Background()), existing.ID)
+ require.NoError(t, err)
+ assert.Equal(t, database.UserStatusSuspended, got.Status)
+
+ // No status change → no audit log.
+ assert.Empty(t, mockAudit.AuditLogs())
+ })
+}
+
+func TestResourceUser_Lifecycle(t *testing.T) {
+ t.Parallel()
+
+ ru, db, _ := setupSCIM(t)
+
+ // Seed an active user.
+ user := seedUser(t, db, database.User{
+ Status: database.UserStatusActive,
+ LoginType: database.LoginTypeOIDC,
+ })
+
+ r := scimRequest(t)
+
+ // Step 1: Get the user. Verify fields match.
+ res, err := ru.Get(r, user.ID.String())
+ require.NoError(t, err)
+ assert.Equal(t, user.ID.String(), res.ID)
+ assert.Equal(t, user.Username, res.Attributes["userName"])
+ assert.Equal(t, true, res.Attributes["active"])
+
+ // Step 2: Replace with active=false → suspended.
+ res, err = ru.Replace(r, user.ID.String(), scim.ResourceAttributes{
+ "userName": user.Username,
+ "active": false,
+ })
+ require.NoError(t, err)
+ assert.Equal(t, false, res.Attributes["active"])
+
+ // Step 3: Get → confirm inactive.
+ res, err = ru.Get(r, user.ID.String())
+ require.NoError(t, err)
+ assert.Equal(t, false, res.Attributes["active"])
+
+ // Step 4: Patch active=true → dormant (shown as active in SCIM).
+ res, err = ru.Patch(r, user.ID.String(), []scim.PatchOperation{
+ {Op: "replace", Path: mustPath("active"), Value: true},
+ })
+ require.NoError(t, err)
+ assert.Equal(t, true, res.Attributes["active"])
+
+ // Step 5: Get → confirm active again.
+ res, err = ru.Get(r, user.ID.String())
+ require.NoError(t, err)
+ assert.Equal(t, true, res.Attributes["active"])
+
+ // Step 6: Delete → suspended.
+ err = ru.Delete(r, user.ID.String())
+ require.NoError(t, err)
+
+ // Step 7: Get → confirm inactive after delete.
+ res, err = ru.Get(r, user.ID.String())
+ require.NoError(t, err)
+ assert.Equal(t, false, res.Attributes["active"])
+}
+
+func TestResourceUser_GetAll(t *testing.T) {
+ t.Parallel()
+
+ ru, db, _ := setupSCIM(t)
+
+ // Seed 3 users.
+ for i := 0; i < 3; i++ {
+ seedUser(t, db, database.User{
+ LoginType: database.LoginTypeOIDC,
+ })
+ }
+
+ r := scimRequest(t)
+
+ // Get all with large count.
+ page, err := ru.GetAll(r, scim.ListRequestParams{Count: 100, StartIndex: 1})
+ require.NoError(t, err)
+ assert.GreaterOrEqual(t, page.TotalResults, 3)
+ assert.GreaterOrEqual(t, len(page.Resources), 3)
+
+ // Paginate: startIndex=2, count=1.
+ page, err = ru.GetAll(r, scim.ListRequestParams{Count: 1, StartIndex: 2})
+ require.NoError(t, err)
+ assert.Len(t, page.Resources, 1)
+ assert.GreaterOrEqual(t, page.TotalResults, 3)
+}
+
+func TestResourceUser_Errors(t *testing.T) {
+ t.Parallel()
+
+ ru, _, _ := setupSCIM(t)
+ r := scimRequest(t)
+ missingUUID := uuid.New().String()
+
+ tests := []struct {
+ name string
+ run func() error
+ wantStatus int
+ }{
+ {
+ name: "Get/non-UUID",
+ run: func() error { _, err := ru.Get(r, "not-a-uuid"); return err },
+ wantStatus: http.StatusNotFound,
+ },
+ {
+ name: "Get/missing",
+ run: func() error { _, err := ru.Get(r, missingUUID); return err },
+ wantStatus: http.StatusNotFound,
+ },
+ {
+ name: "Replace/non-UUID",
+ run: func() error { _, err := ru.Replace(r, "bad", scim.ResourceAttributes{}); return err },
+ wantStatus: http.StatusNotFound,
+ },
+ {
+ name: "Replace/missing",
+ run: func() error { _, err := ru.Replace(r, missingUUID, scim.ResourceAttributes{}); return err },
+ wantStatus: http.StatusNotFound,
+ },
+ {
+ name: "Replace/immutable-userName",
+ run: func() error {
+ // Need a real user for this test.
+ user := seedUser(t, ru.store, database.User{LoginType: database.LoginTypeOIDC})
+ _, err := ru.Replace(r, user.ID.String(), scim.ResourceAttributes{
+ "userName": "different-name",
+ })
+ return err
+ },
+ wantStatus: http.StatusBadRequest,
+ },
+ {
+ name: "Patch/non-UUID",
+ run: func() error { _, err := ru.Patch(r, "bad", nil); return err },
+ wantStatus: http.StatusNotFound,
+ },
+ {
+ name: "Patch/missing",
+ run: func() error { _, err := ru.Patch(r, missingUUID, nil); return err },
+ wantStatus: http.StatusNotFound,
+ },
+ {
+ name: "Delete/non-UUID",
+ run: func() error { return ru.Delete(r, "bad") },
+ wantStatus: http.StatusNotFound,
+ },
+ {
+ name: "Delete/missing",
+ run: func() error { return ru.Delete(r, missingUUID) },
+ wantStatus: http.StatusNotFound,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ err := tt.run()
+ require.Error(t, err)
+ var scimErr scimErrors.ScimError
+ require.ErrorAs(t, err, &scimErr)
+ assert.Equal(t, tt.wantStatus, scimErr.Status)
+ })
+ }
+}
+
+func TestResourceUser_AuditLogs(t *testing.T) {
+ t.Parallel()
+
+ // These tests use dbmock instead of a real database because they only
+ // verify audit emission logic (does an audit log fire when status
+ // changes?), not SQL correctness. The handlers call just GetUserByID
+ // and UpdateUserStatus, both trivially mockable.
+
+ makeUser := func(status database.UserStatus) (database.User, database.User) {
+ id := uuid.New()
+ user := database.User{
+ ID: id,
+ Username: "testuser",
+ Status: status,
+ LoginType: database.LoginTypeOIDC,
+ }
+ suspended := user
+ suspended.Status = database.UserStatusSuspended
+ return user, suspended
+ }
+
+ t.Run("Replace/status-change-emits-audit", func(t *testing.T) {
+ t.Parallel()
+ ru, mockStore, mockAudit := setupSCIMMock(t)
+ activeUser, suspendedUser := makeUser(database.UserStatusActive)
+
+ mockStore.EXPECT().GetUserByID(gomock.Any(), activeUser.ID).Return(activeUser, nil)
+ mockStore.EXPECT().UpdateUserStatus(gomock.Any(), gomock.Any()).Return(suspendedUser, nil)
+
+ _, err := ru.Replace(scimRequest(t), activeUser.ID.String(), scim.ResourceAttributes{
+ "userName": activeUser.Username,
+ "active": false,
+ })
+ require.NoError(t, err)
+ assert.Len(t, mockAudit.AuditLogs(), 1)
+ })
+
+ t.Run("Replace/no-change-skips-audit", func(t *testing.T) {
+ t.Parallel()
+ ru, mockStore, mockAudit := setupSCIMMock(t)
+ activeUser, _ := makeUser(database.UserStatusActive)
+
+ mockStore.EXPECT().GetUserByID(gomock.Any(), activeUser.ID).Return(activeUser, nil)
+ // No UpdateUserStatus expected: active=true on an already active user is a no-op.
+
+ _, err := ru.Replace(scimRequest(t), activeUser.ID.String(), scim.ResourceAttributes{
+ "userName": activeUser.Username,
+ "active": true,
+ })
+ require.NoError(t, err)
+ assert.Empty(t, mockAudit.AuditLogs())
+ })
+
+ t.Run("Delete/active-user-emits-audit", func(t *testing.T) {
+ t.Parallel()
+ ru, mockStore, mockAudit := setupSCIMMock(t)
+ activeUser, suspendedUser := makeUser(database.UserStatusActive)
+
+ mockStore.EXPECT().GetUserByID(gomock.Any(), activeUser.ID).Return(activeUser, nil)
+ mockStore.EXPECT().UpdateUserStatus(gomock.Any(), gomock.Any()).Return(suspendedUser, nil)
+
+ err := ru.Delete(scimRequest(t), activeUser.ID.String())
+ require.NoError(t, err)
+ assert.Len(t, mockAudit.AuditLogs(), 1)
+ })
+
+ t.Run("Delete/suspended-user-skips-audit", func(t *testing.T) {
+ t.Parallel()
+ ru, mockStore, mockAudit := setupSCIMMock(t)
+ _, suspendedUser := makeUser(database.UserStatusSuspended)
+
+ mockStore.EXPECT().GetUserByID(gomock.Any(), suspendedUser.ID).Return(suspendedUser, nil)
+ // No UpdateUserStatus expected: already suspended.
+
+ err := ru.Delete(scimRequest(t), suspendedUser.ID.String())
+ require.NoError(t, err)
+ assert.Empty(t, mockAudit.AuditLogs())
+ })
+}
+
+// mustPath parses a SCIM attribute path string into a *filter.Path
+// for use in PatchOperation test data.
+func mustPath(attr string) *filter.Path {
+ p, err := filter.ParsePath([]byte(attr))
+ if err != nil {
+ panic(fmt.Sprintf("mustPath(%q): %v", attr, err))
+ }
+ return &p
+}
diff --git a/enterprise/coderd/scim_test.go b/enterprise/coderd/scim_test.go
index e33c49e2a4834..0aeb61d8e0221 100644
--- a/enterprise/coderd/scim_test.go
+++ b/enterprise/coderd/scim_test.go
@@ -4,13 +4,10 @@ import (
"context"
"encoding/json"
"fmt"
- "io"
"net/http"
"net/http/httptest"
"testing"
- "github.com/golang-jwt/jwt/v4"
- "github.com/google/uuid"
"github.com/imulab/go-scim/pkg/v2/handlerutil"
"github.com/imulab/go-scim/pkg/v2/spec"
"github.com/stretchr/testify/assert"
@@ -19,25 +16,22 @@ import (
"github.com/coder/coder/v2/coderd/audit"
"github.com/coder/coder/v2/coderd/coderdtest"
- "github.com/coder/coder/v2/coderd/coderdtest/oidctest"
- "github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/notifications/notificationstest"
"github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/cryptorand"
- "github.com/coder/coder/v2/enterprise/coderd"
"github.com/coder/coder/v2/enterprise/coderd/coderdenttest"
+ "github.com/coder/coder/v2/enterprise/coderd/legacyscim"
"github.com/coder/coder/v2/enterprise/coderd/license"
- "github.com/coder/coder/v2/enterprise/coderd/scim"
"github.com/coder/coder/v2/testutil"
)
//nolint:revive
-func makeScimUser(t testing.TB) coderd.SCIMUser {
+func makeScimUser(t testing.TB) legacyscim.SCIMUser {
rstr, err := cryptorand.String(10)
require.NoError(t, err)
- return coderd.SCIMUser{
+ return legacyscim.SCIMUser{
UserName: rstr,
Name: struct {
GivenName string `json:"givenName"`
@@ -64,807 +58,651 @@ func setScimAuth(key []byte) func(*http.Request) {
}
}
-func setScimAuthBearer(key []byte) func(*http.Request) {
- return func(r *http.Request) {
- // Do strange casing to ensure it's case-insensitive
- r.Header.Set("Authorization", "beAreR "+string(key))
- }
-}
-
+// TestLegacyScim tests the legacy SCIM handler (imulab/go-scim based).
+// This is a reduced set of integration tests verifying HTTP routing, auth,
+// and core CRUD. Detailed handler logic is covered by the unit tests in
+// enterprise/coderd/scim/scimusers_test.go.
+//
//nolint:gocritic // SCIM authenticates via a special header and bypasses internal RBAC.
-func TestScim(t *testing.T) {
+func TestLegacyScim(t *testing.T) {
t.Parallel()
- t.Run("postUser", func(t *testing.T) {
+ t.Run("disabled", func(t *testing.T) {
t.Parallel()
-
- t.Run("disabled", func(t *testing.T) {
- t.Parallel()
-
- ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
- defer cancel()
-
- client, _ := coderdenttest.New(t, &coderdenttest.Options{
- SCIMAPIKey: []byte("hi"),
- LicenseOptions: &coderdenttest.LicenseOptions{
- AccountID: "coolin",
- Features: license.Features{
- codersdk.FeatureSCIM: 0,
- },
- },
- })
-
- res, err := client.Request(ctx, "POST", "/scim/v2/Users", struct{}{})
- require.NoError(t, err)
- defer res.Body.Close()
- assert.Equal(t, http.StatusForbidden, res.StatusCode)
- })
-
- t.Run("noAuth", func(t *testing.T) {
- t.Parallel()
-
- ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
- defer cancel()
-
- client, _ := coderdenttest.New(t, &coderdenttest.Options{
- SCIMAPIKey: []byte("hi"),
- LicenseOptions: &coderdenttest.LicenseOptions{
- AccountID: "coolin",
- Features: license.Features{
- codersdk.FeatureSCIM: 1,
- },
- },
- })
-
- res, err := client.Request(ctx, "POST", "/scim/v2/Users", struct{}{})
- require.NoError(t, err)
- defer res.Body.Close()
- assert.Equal(t, http.StatusUnauthorized, res.StatusCode)
- })
-
- t.Run("OK", func(t *testing.T) {
- t.Parallel()
-
- ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
- defer cancel()
-
- // given
- scimAPIKey := []byte("hi")
- mockAudit := audit.NewMock()
- notifyEnq := ¬ificationstest.FakeEnqueuer{}
- client, _ := coderdenttest.New(t, &coderdenttest.Options{
- Options: &coderdtest.Options{
- Auditor: mockAudit,
- NotificationsEnqueuer: notifyEnq,
- },
- SCIMAPIKey: scimAPIKey,
- AuditLogging: true,
- LicenseOptions: &coderdenttest.LicenseOptions{
- AccountID: "coolin",
- Features: license.Features{
- codersdk.FeatureSCIM: 1,
- codersdk.FeatureAuditLog: 1,
- },
- },
- })
- mockAudit.ResetLogs()
-
- // verify scim is enabled
- res, err := client.Request(ctx, http.MethodGet, "/scim/v2/ServiceProviderConfig", nil)
- require.NoError(t, err)
- defer res.Body.Close()
- require.Equal(t, http.StatusOK, res.StatusCode)
-
- // when
- sUser := makeScimUser(t)
- res, err = client.Request(ctx, http.MethodPost, "/scim/v2/Users", sUser, setScimAuth(scimAPIKey))
- require.NoError(t, err)
- defer res.Body.Close()
- require.Equal(t, http.StatusOK, res.StatusCode)
-
- // then
- // Expect audit logs
- aLogs := mockAudit.AuditLogs()
- require.Len(t, aLogs, 1)
- af := map[string]string{}
- err = json.Unmarshal([]byte(aLogs[0].AdditionalFields), &af)
- require.NoError(t, err)
- assert.Equal(t, coderd.SCIMAuditAdditionalFields, af)
- assert.Equal(t, database.AuditActionCreate, aLogs[0].Action)
-
- // Expect users exposed over API
- userRes, err := client.Users(ctx, codersdk.UsersRequest{Search: sUser.Emails[0].Value})
- require.NoError(t, err)
- require.Len(t, userRes.Users, 1)
- assert.Equal(t, sUser.Emails[0].Value, userRes.Users[0].Email)
- assert.Equal(t, sUser.UserName, userRes.Users[0].Username)
- assert.Len(t, userRes.Users[0].OrganizationIDs, 1)
-
- // Expect zero notifications (SkipNotifications = true)
- require.Empty(t, notifyEnq.Sent())
+ ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
+ defer cancel()
+
+ client, _ := coderdenttest.New(t, &coderdenttest.Options{
+ SCIMAPIKey: []byte("hi"),
+ UseLegacySCIM: true,
+ LicenseOptions: &coderdenttest.LicenseOptions{
+ AccountID: "coolin",
+ Features: license.Features{codersdk.FeatureSCIM: 0},
+ },
})
- t.Run("OK_Bearer", func(t *testing.T) {
- t.Parallel()
-
- ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
- defer cancel()
-
- // given
- scimAPIKey := []byte("hi")
- mockAudit := audit.NewMock()
- notifyEnq := ¬ificationstest.FakeEnqueuer{}
- client, _ := coderdenttest.New(t, &coderdenttest.Options{
- Options: &coderdtest.Options{
- Auditor: mockAudit,
- NotificationsEnqueuer: notifyEnq,
- },
- SCIMAPIKey: scimAPIKey,
- AuditLogging: true,
- LicenseOptions: &coderdenttest.LicenseOptions{
- AccountID: "coolin",
- Features: license.Features{
- codersdk.FeatureSCIM: 1,
- codersdk.FeatureAuditLog: 1,
- },
- },
- })
- mockAudit.ResetLogs()
-
- // when
- sUser := makeScimUser(t)
- res, err := client.Request(ctx, "POST", "/scim/v2/Users", sUser, setScimAuthBearer(scimAPIKey))
- require.NoError(t, err)
- defer res.Body.Close()
- require.Equal(t, http.StatusOK, res.StatusCode)
-
- // then
- // Expect audit logs
- aLogs := mockAudit.AuditLogs()
- require.Len(t, aLogs, 1)
- af := map[string]string{}
- err = json.Unmarshal([]byte(aLogs[0].AdditionalFields), &af)
- require.NoError(t, err)
- assert.Equal(t, coderd.SCIMAuditAdditionalFields, af)
- assert.Equal(t, database.AuditActionCreate, aLogs[0].Action)
-
- // Expect users exposed over API
- userRes, err := client.Users(ctx, codersdk.UsersRequest{Search: sUser.Emails[0].Value})
- require.NoError(t, err)
- require.Len(t, userRes.Users, 1)
- assert.Equal(t, sUser.Emails[0].Value, userRes.Users[0].Email)
- assert.Equal(t, sUser.UserName, userRes.Users[0].Username)
- assert.Len(t, userRes.Users[0].OrganizationIDs, 1)
-
- // Expect zero notifications (SkipNotifications = true)
- require.Empty(t, notifyEnq.Sent())
- })
-
- t.Run("OKNoDefault", func(t *testing.T) {
- t.Parallel()
-
- ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
- defer cancel()
-
- // given
- scimAPIKey := []byte("hi")
- mockAudit := audit.NewMock()
- notifyEnq := ¬ificationstest.FakeEnqueuer{}
- dv := coderdtest.DeploymentValues(t)
- dv.OIDC.OrganizationAssignDefault = false
- client, _ := coderdenttest.New(t, &coderdenttest.Options{
- Options: &coderdtest.Options{
- Auditor: mockAudit,
- NotificationsEnqueuer: notifyEnq,
- DeploymentValues: dv,
- },
- SCIMAPIKey: scimAPIKey,
- AuditLogging: true,
- LicenseOptions: &coderdenttest.LicenseOptions{
- AccountID: "coolin",
- Features: license.Features{
- codersdk.FeatureSCIM: 1,
- codersdk.FeatureAuditLog: 1,
- },
- },
- })
- mockAudit.ResetLogs()
-
- // when
- sUser := makeScimUser(t)
- res, err := client.Request(ctx, "POST", "/scim/v2/Users", sUser, setScimAuth(scimAPIKey))
- require.NoError(t, err)
- defer res.Body.Close()
- require.Equal(t, http.StatusOK, res.StatusCode)
-
- // then
- // Expect audit logs
- aLogs := mockAudit.AuditLogs()
- require.Len(t, aLogs, 1)
- af := map[string]string{}
- err = json.Unmarshal([]byte(aLogs[0].AdditionalFields), &af)
- require.NoError(t, err)
- assert.Equal(t, coderd.SCIMAuditAdditionalFields, af)
- assert.Equal(t, database.AuditActionCreate, aLogs[0].Action)
-
- // Expect users exposed over API
- userRes, err := client.Users(ctx, codersdk.UsersRequest{Search: sUser.Emails[0].Value})
- require.NoError(t, err)
- require.Len(t, userRes.Users, 1)
- assert.Equal(t, sUser.Emails[0].Value, userRes.Users[0].Email)
- assert.Equal(t, sUser.UserName, userRes.Users[0].Username)
- assert.Len(t, userRes.Users[0].OrganizationIDs, 0)
+ res, err := client.Request(ctx, "POST", "/scim/v2/Users", struct{}{})
+ require.NoError(t, err)
+ defer res.Body.Close()
+ assert.Equal(t, http.StatusForbidden, res.StatusCode)
+ })
- // Expect zero notifications (SkipNotifications = true)
- require.Empty(t, notifyEnq.Sent())
+ t.Run("noAuth", func(t *testing.T) {
+ t.Parallel()
+ ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
+ defer cancel()
+
+ client, _ := coderdenttest.New(t, &coderdenttest.Options{
+ SCIMAPIKey: []byte("hi"),
+ UseLegacySCIM: true,
+ LicenseOptions: &coderdenttest.LicenseOptions{
+ AccountID: "coolin",
+ Features: license.Features{codersdk.FeatureSCIM: 1},
+ },
})
- t.Run("Duplicate", func(t *testing.T) {
- t.Parallel()
-
- ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
- defer cancel()
+ res, err := client.Request(ctx, "POST", "/scim/v2/Users", struct{}{})
+ require.NoError(t, err)
+ defer res.Body.Close()
+ assert.Equal(t, http.StatusUnauthorized, res.StatusCode)
+ })
- scimAPIKey := []byte("hi")
- client, _ := coderdenttest.New(t, &coderdenttest.Options{
- SCIMAPIKey: scimAPIKey,
- LicenseOptions: &coderdenttest.LicenseOptions{
- AccountID: "coolin",
- Features: license.Features{
- codersdk.FeatureSCIM: 1,
- },
+ t.Run("postUser", func(t *testing.T) {
+ t.Parallel()
+ ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
+ defer cancel()
+
+ scimAPIKey := []byte("hi")
+ mockAudit := audit.NewMock()
+ notifyEnq := ¬ificationstest.FakeEnqueuer{}
+ client, _ := coderdenttest.New(t, &coderdenttest.Options{
+ Options: &coderdtest.Options{
+ Auditor: mockAudit,
+ NotificationsEnqueuer: notifyEnq,
+ },
+ SCIMAPIKey: scimAPIKey,
+ UseLegacySCIM: true,
+ AuditLogging: true,
+ LicenseOptions: &coderdenttest.LicenseOptions{
+ AccountID: "coolin",
+ Features: license.Features{
+ codersdk.FeatureSCIM: 1,
+ codersdk.FeatureAuditLog: 1,
+ codersdk.FeatureMultipleOrganizations: 1,
},
- })
-
- sUser := makeScimUser(t)
- for i := 0; i < 3; i++ {
- res, err := client.Request(ctx, "POST", "/scim/v2/Users", sUser, setScimAuth(scimAPIKey))
- require.NoError(t, err)
- _ = res.Body.Close()
- assert.Equal(t, http.StatusOK, res.StatusCode)
- }
-
- userRes, err := client.Users(ctx, codersdk.UsersRequest{Search: sUser.Emails[0].Value})
- require.NoError(t, err)
- require.Len(t, userRes.Users, 1)
-
- assert.Equal(t, sUser.Emails[0].Value, userRes.Users[0].Email)
- assert.Equal(t, sUser.UserName, userRes.Users[0].Username)
+ },
})
- t.Run("Unsuspend", func(t *testing.T) {
- t.Parallel()
-
- ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
- defer cancel()
+ sUser := makeScimUser(t)
+ res, err := client.Request(ctx, "POST", "/scim/v2/Users", sUser, setScimAuth(scimAPIKey))
+ require.NoError(t, err)
+ defer res.Body.Close()
+ assert.Equal(t, http.StatusOK, res.StatusCode)
+
+ var createdUser legacyscim.SCIMUser
+ err = json.NewDecoder(res.Body).Decode(&createdUser)
+ require.NoError(t, err)
+ assert.NotEmpty(t, createdUser.ID)
+ assert.Equal(t, sUser.UserName, createdUser.UserName)
+
+ // Verify user exists.
+ userRes, err := client.Users(ctx, codersdk.UsersRequest{Search: createdUser.UserName})
+ require.NoError(t, err)
+ require.Len(t, userRes.Users, 1)
+ assert.Equal(t, codersdk.LoginTypeOIDC, userRes.Users[0].LoginType)
+
+ // Verify audit log.
+ require.True(t, len(mockAudit.AuditLogs()) > 0)
+
+ // Verify no user admin notification (SCIM skips notifications).
+ require.Empty(t, notifyEnq.Sent())
+ })
- scimAPIKey := []byte("hi")
- client, _ := coderdenttest.New(t, &coderdenttest.Options{
- SCIMAPIKey: scimAPIKey,
- LicenseOptions: &coderdenttest.LicenseOptions{
- AccountID: "coolin",
- Features: license.Features{
- codersdk.FeatureSCIM: 1,
- },
+ t.Run("Duplicate", func(t *testing.T) {
+ t.Parallel()
+ ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
+ defer cancel()
+
+ scimAPIKey := []byte("hi")
+ client, _ := coderdenttest.New(t, &coderdenttest.Options{
+ SCIMAPIKey: scimAPIKey,
+ UseLegacySCIM: true,
+ LicenseOptions: &coderdenttest.LicenseOptions{
+ AccountID: "coolin",
+ Features: license.Features{
+ codersdk.FeatureSCIM: 1,
+ codersdk.FeatureMultipleOrganizations: 1,
},
- })
-
- sUser := makeScimUser(t)
- res, err := client.Request(ctx, "POST", "/scim/v2/Users", sUser, setScimAuth(scimAPIKey))
- require.NoError(t, err)
- defer res.Body.Close()
- assert.Equal(t, http.StatusOK, res.StatusCode)
- err = json.NewDecoder(res.Body).Decode(&sUser)
- require.NoError(t, err)
-
- sUser.Active = ptr.Ref(false)
- res, err = client.Request(ctx, "PATCH", "/scim/v2/Users/"+sUser.ID, sUser, setScimAuth(scimAPIKey))
- require.NoError(t, err)
- _, _ = io.Copy(io.Discard, res.Body)
- _ = res.Body.Close()
- assert.Equal(t, http.StatusOK, res.StatusCode)
-
- sUser.Active = ptr.Ref(true)
- res, err = client.Request(ctx, "POST", "/scim/v2/Users", sUser, setScimAuth(scimAPIKey))
- require.NoError(t, err)
- _, _ = io.Copy(io.Discard, res.Body)
- _ = res.Body.Close()
- assert.Equal(t, http.StatusOK, res.StatusCode)
-
- userRes, err := client.Users(ctx, codersdk.UsersRequest{Search: sUser.Emails[0].Value})
- require.NoError(t, err)
- require.Len(t, userRes.Users, 1)
-
- assert.Equal(t, sUser.Emails[0].Value, userRes.Users[0].Email)
- assert.Equal(t, sUser.UserName, userRes.Users[0].Username)
- assert.Equal(t, codersdk.UserStatusDormant, userRes.Users[0].Status)
+ },
})
- t.Run("DomainStrips", func(t *testing.T) {
- t.Parallel()
+ sUser := makeScimUser(t)
- ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
- defer cancel()
-
- scimAPIKey := []byte("hi")
- client, _ := coderdenttest.New(t, &coderdenttest.Options{
- SCIMAPIKey: scimAPIKey,
- LicenseOptions: &coderdenttest.LicenseOptions{
- AccountID: "coolin",
- Features: license.Features{
- codersdk.FeatureSCIM: 1,
- },
- },
- })
-
- sUser := makeScimUser(t)
- sUser.UserName = sUser.UserName + "@coder.com"
+ // Create same user 3 times.
+ for i := 0; i < 3; i++ {
res, err := client.Request(ctx, "POST", "/scim/v2/Users", sUser, setScimAuth(scimAPIKey))
require.NoError(t, err)
- _, _ = io.Copy(io.Discard, res.Body)
_ = res.Body.Close()
assert.Equal(t, http.StatusOK, res.StatusCode)
+ }
- userRes, err := client.Users(ctx, codersdk.UsersRequest{Search: sUser.Emails[0].Value})
- require.NoError(t, err)
- require.Len(t, userRes.Users, 1)
-
- assert.Equal(t, sUser.Emails[0].Value, userRes.Users[0].Email)
- // Username should be the same as the given name. They all use the
- // same string before we modified it above.
- assert.Equal(t, sUser.Name.GivenName, userRes.Users[0].Username)
- })
+ // Only 1 user should exist.
+ userRes, err := client.Users(ctx, codersdk.UsersRequest{Search: sUser.UserName})
+ require.NoError(t, err)
+ require.Len(t, userRes.Users, 1)
})
t.Run("patchUser", func(t *testing.T) {
t.Parallel()
-
- t.Run("disabled", func(t *testing.T) {
- t.Parallel()
-
- ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
- defer cancel()
-
- client, _ := coderdenttest.New(t, &coderdenttest.Options{
- SCIMAPIKey: []byte("hi"),
- LicenseOptions: &coderdenttest.LicenseOptions{
- AccountID: "coolin",
- Features: license.Features{
- codersdk.FeatureSCIM: 0,
- },
+ ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
+ defer cancel()
+
+ scimAPIKey := []byte("hi")
+ mockAudit := audit.NewMock()
+ client, _ := coderdenttest.New(t, &coderdenttest.Options{
+ Options: &coderdtest.Options{Auditor: mockAudit},
+ SCIMAPIKey: scimAPIKey,
+ UseLegacySCIM: true,
+ AuditLogging: true,
+ LicenseOptions: &coderdenttest.LicenseOptions{
+ AccountID: "coolin",
+ Features: license.Features{
+ codersdk.FeatureSCIM: 1,
+ codersdk.FeatureAuditLog: 1,
+ codersdk.FeatureMultipleOrganizations: 1,
},
- })
-
- res, err := client.Request(ctx, "PATCH", "/scim/v2/Users/bob", struct{}{})
- require.NoError(t, err)
- _, _ = io.Copy(io.Discard, res.Body)
- _ = res.Body.Close()
- assert.Equal(t, http.StatusForbidden, res.StatusCode)
+ },
})
- t.Run("noAuth", func(t *testing.T) {
- t.Parallel()
-
- ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
- defer cancel()
+ // Create user first.
+ sUser := makeScimUser(t)
+ res, err := client.Request(ctx, "POST", "/scim/v2/Users", sUser, setScimAuth(scimAPIKey))
+ require.NoError(t, err)
+ defer res.Body.Close()
+ require.Equal(t, http.StatusOK, res.StatusCode)
+
+ var createdUser legacyscim.SCIMUser
+ err = json.NewDecoder(res.Body).Decode(&createdUser)
+ require.NoError(t, err)
+
+ // Suspend via PATCH.
+ mockAudit.ResetLogs()
+ sUser.Active = ptr.Ref(false)
+ res, err = client.Request(ctx, "PATCH", "/scim/v2/Users/"+createdUser.ID, sUser, setScimAuth(scimAPIKey))
+ require.NoError(t, err)
+ defer res.Body.Close()
+ assert.Equal(t, http.StatusOK, res.StatusCode)
+
+ // Verify suspended.
+ userRes, err := client.User(ctx, createdUser.ID)
+ require.NoError(t, err)
+ assert.Equal(t, codersdk.UserStatusSuspended, userRes.Status)
+ })
- client, _ := coderdenttest.New(t, &coderdenttest.Options{
- SCIMAPIKey: []byte("hi"),
- LicenseOptions: &coderdenttest.LicenseOptions{
- AccountID: "coolin",
- Features: license.Features{
- codersdk.FeatureSCIM: 1,
- },
+ t.Run("putUser", func(t *testing.T) {
+ t.Parallel()
+ ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
+ defer cancel()
+
+ scimAPIKey := []byte("hi")
+ mockAudit := audit.NewMock()
+ client, _ := coderdenttest.New(t, &coderdenttest.Options{
+ Options: &coderdtest.Options{Auditor: mockAudit},
+ SCIMAPIKey: scimAPIKey,
+ UseLegacySCIM: true,
+ AuditLogging: true,
+ LicenseOptions: &coderdenttest.LicenseOptions{
+ AccountID: "coolin",
+ Features: license.Features{
+ codersdk.FeatureSCIM: 1,
+ codersdk.FeatureAuditLog: 1,
+ codersdk.FeatureMultipleOrganizations: 1,
},
- })
-
- res, err := client.Request(ctx, "PATCH", "/scim/v2/Users/bob", struct{}{})
- require.NoError(t, err)
- _, _ = io.Copy(io.Discard, res.Body)
- _ = res.Body.Close()
- assert.Equal(t, http.StatusUnauthorized, res.StatusCode)
+ },
})
- t.Run("OK", func(t *testing.T) {
- t.Parallel()
-
- ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
- defer cancel()
-
- scimAPIKey := []byte("hi")
- mockAudit := audit.NewMock()
- client, _ := coderdenttest.New(t, &coderdenttest.Options{
- Options: &coderdtest.Options{Auditor: mockAudit},
- SCIMAPIKey: scimAPIKey,
- AuditLogging: true,
- LicenseOptions: &coderdenttest.LicenseOptions{
- AccountID: "coolin",
- Features: license.Features{
- codersdk.FeatureSCIM: 1,
- codersdk.FeatureAuditLog: 1,
- },
- },
- })
- mockAudit.ResetLogs()
+ // Create user first.
+ sUser := makeScimUser(t)
+ res, err := client.Request(ctx, "POST", "/scim/v2/Users", sUser, setScimAuth(scimAPIKey))
+ require.NoError(t, err)
+ defer res.Body.Close()
+ require.Equal(t, http.StatusOK, res.StatusCode)
+
+ var createdUser legacyscim.SCIMUser
+ err = json.NewDecoder(res.Body).Decode(&createdUser)
+ require.NoError(t, err)
+
+ // Suspend via PUT.
+ mockAudit.ResetLogs()
+ sUser.Active = ptr.Ref(false)
+ res, err = client.Request(ctx, "PUT", "/scim/v2/Users/"+createdUser.ID, sUser, setScimAuth(scimAPIKey))
+ require.NoError(t, err)
+ defer res.Body.Close()
+ assert.Equal(t, http.StatusOK, res.StatusCode)
+
+ // Verify suspended.
+ userRes, err := client.User(ctx, createdUser.ID)
+ require.NoError(t, err)
+ assert.Equal(t, codersdk.UserStatusSuspended, userRes.Status)
+ })
+}
- sUser := makeScimUser(t)
- res, err := client.Request(ctx, "POST", "/scim/v2/Users", sUser, setScimAuth(scimAPIKey))
- require.NoError(t, err)
- defer res.Body.Close()
- assert.Equal(t, http.StatusOK, res.StatusCode)
- mockAudit.ResetLogs()
+// scim2User is a minimal struct for decoding SCIM 2.0 user responses
+// returned by the elimity-com/scim library.
+type scim2User struct {
+ ID string `json:"id"`
+ UserName string `json:"userName"`
+ Active bool `json:"active"`
+}
- err = json.NewDecoder(res.Body).Decode(&sUser)
- require.NoError(t, err)
+// scim2UserBody is the request body for SCIM 2.0 POST/PUT calls.
+// Unlike the legacy handler, the elimity-com/scim library validates the
+// "schemas" attribute against the core User schema URI and rejects bodies
+// that omit it.
+type scim2UserBody struct {
+ Schemas []string `json:"schemas"`
+ UserName string `json:"userName"`
+ Name struct {
+ GivenName string `json:"givenName"`
+ FamilyName string `json:"familyName"`
+ } `json:"name"`
+ Emails []struct {
+ Primary bool `json:"primary"`
+ Value string `json:"value"`
+ } `json:"emails"`
+ Active *bool `json:"active,omitempty"`
+}
- sUser.Active = ptr.Ref(false)
+func makeScim2User(t testing.TB) scim2UserBody {
+ rstr, err := cryptorand.String(10)
+ require.NoError(t, err)
- res, err = client.Request(ctx, "PATCH", "/scim/v2/Users/"+sUser.ID, sUser, setScimAuth(scimAPIKey))
- require.NoError(t, err)
- _, _ = io.Copy(io.Discard, res.Body)
- _ = res.Body.Close()
- assert.Equal(t, http.StatusOK, res.StatusCode)
+ b := scim2UserBody{
+ Schemas: []string{"urn:ietf:params:scim:schemas:core:2.0:User"},
+ UserName: rstr,
+ Active: ptr.Ref(true),
+ }
+ b.Name.GivenName = rstr
+ b.Name.FamilyName = rstr
+ b.Emails = []struct {
+ Primary bool `json:"primary"`
+ Value string `json:"value"`
+ }{{Primary: true, Value: fmt.Sprintf("%s@coder.com", rstr)}}
+ return b
+}
- aLogs := mockAudit.AuditLogs()
- require.Len(t, aLogs, 1)
- assert.Equal(t, database.AuditActionWrite, aLogs[0].Action)
+// TestScim exercises the SCIM 2.0 handler through real HTTP routes. It
+// mirrors TestLegacyScim's structure (disabled/noAuth/post/patch/put) and
+// adds coverage for behavior unique to the v2 implementation: discovery
+// endpoints, 409 Conflict on duplicate active users, suspended-user
+// reactivation, GET by id, and DELETE.
+//
+//nolint:gocritic // SCIM authenticates via a special header and bypasses internal RBAC.
+func TestScim(t *testing.T) {
+ t.Parallel()
- userRes, err := client.Users(ctx, codersdk.UsersRequest{Search: sUser.Emails[0].Value})
- require.NoError(t, err)
- require.Len(t, userRes.Users, 1)
- assert.Equal(t, codersdk.UserStatusSuspended, userRes.Users[0].Status)
+ t.Run("disabled", func(t *testing.T) {
+ t.Parallel()
+ ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
+ defer cancel()
+
+ client, _ := coderdenttest.New(t, &coderdenttest.Options{
+ SCIMAPIKey: []byte("hi"),
+ LicenseOptions: &coderdenttest.LicenseOptions{
+ AccountID: "coolin",
+ Features: license.Features{codersdk.FeatureSCIM: 0},
+ },
})
- // Create a user via SCIM, which starts as dormant.
- // Log in as the user, making them active.
- // Then patch the user again and the user should still be active.
- t.Run("ActiveIsActive", func(t *testing.T) {
- t.Parallel()
-
- ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
- defer cancel()
-
- scimAPIKey := []byte("hi")
-
- mockAudit := audit.NewMock()
- fake := oidctest.NewFakeIDP(t, oidctest.WithServing())
- client, _ := coderdenttest.New(t, &coderdenttest.Options{
- Options: &coderdtest.Options{
- Auditor: mockAudit,
- OIDCConfig: fake.OIDCConfig(t, []string{}),
- },
- SCIMAPIKey: scimAPIKey,
- AuditLogging: true,
- LicenseOptions: &coderdenttest.LicenseOptions{
- AccountID: "coolin",
- Features: license.Features{
- codersdk.FeatureSCIM: 1,
- codersdk.FeatureAuditLog: 1,
- },
- },
- })
- mockAudit.ResetLogs()
-
- // User is dormant on create
- sUser := makeScimUser(t)
- res, err := client.Request(ctx, "POST", "/scim/v2/Users", sUser, setScimAuth(scimAPIKey))
- require.NoError(t, err)
- defer res.Body.Close()
- assert.Equal(t, http.StatusOK, res.StatusCode)
+ res, err := client.Request(ctx, "POST", "/scim/v2/Users", struct{}{})
+ require.NoError(t, err)
+ defer res.Body.Close()
+ assert.Equal(t, http.StatusForbidden, res.StatusCode)
+ })
- err = json.NewDecoder(res.Body).Decode(&sUser)
- require.NoError(t, err)
+ t.Run("noAuth", func(t *testing.T) {
+ t.Parallel()
+ ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
+ defer cancel()
+
+ client, _ := coderdenttest.New(t, &coderdenttest.Options{
+ SCIMAPIKey: []byte("hi"),
+ LicenseOptions: &coderdenttest.LicenseOptions{
+ AccountID: "coolin",
+ Features: license.Features{codersdk.FeatureSCIM: 1},
+ },
+ })
- // Check the audit log
- aLogs := mockAudit.AuditLogs()
- require.Len(t, aLogs, 1)
- assert.Equal(t, database.AuditActionCreate, aLogs[0].Action)
+ res, err := client.Request(ctx, "POST", "/scim/v2/Users", struct{}{})
+ require.NoError(t, err)
+ defer res.Body.Close()
+ assert.Equal(t, http.StatusUnauthorized, res.StatusCode)
+ })
- // Verify the user is dormant
- scimUser, err := client.User(ctx, sUser.UserName)
- require.NoError(t, err)
- require.Equal(t, codersdk.UserStatusDormant, scimUser.Status, "user starts as dormant")
-
- // Log in as the user, making them active
- //nolint:bodyclose
- scimUserClient, _ := fake.Login(t, client, jwt.MapClaims{
- "email": sUser.Emails[0].Value,
- "sub": uuid.NewString(),
- })
- scimUser, err = scimUserClient.User(ctx, codersdk.Me)
- require.NoError(t, err)
- require.Equal(t, codersdk.UserStatusActive, scimUser.Status, "user should now be active")
+ t.Run("discovery", func(t *testing.T) {
+ t.Parallel()
+ ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
+ defer cancel()
+
+ scimAPIKey := []byte("hi")
+ client, _ := coderdenttest.New(t, &coderdenttest.Options{
+ SCIMAPIKey: scimAPIKey,
+ LicenseOptions: &coderdenttest.LicenseOptions{
+ AccountID: "coolin",
+ Features: license.Features{codersdk.FeatureSCIM: 1},
+ },
+ })
- // Patch the user
- mockAudit.ResetLogs()
- res, err = client.Request(ctx, "PATCH", "/scim/v2/Users/"+sUser.ID, sUser, setScimAuth(scimAPIKey))
+ for _, path := range []string{
+ "/scim/v2/ServiceProviderConfig",
+ "/scim/v2/ResourceTypes",
+ "/scim/v2/Schemas",
+ } {
+ res, err := client.Request(ctx, "GET", path, nil, setScimAuth(scimAPIKey))
require.NoError(t, err)
- _, _ = io.Copy(io.Discard, res.Body)
_ = res.Body.Close()
- assert.Equal(t, http.StatusOK, res.StatusCode)
-
- // Should be no audit logs since there is no diff
- aLogs = mockAudit.AuditLogs()
- require.Len(t, aLogs, 0)
-
- // Verify the user is still active.
- scimUser, err = client.User(ctx, sUser.UserName)
- require.NoError(t, err)
- require.Equal(t, codersdk.UserStatusActive, scimUser.Status, "user is still active")
- })
+ assert.Equal(t, http.StatusOK, res.StatusCode, "discovery endpoint %s", path)
+ }
})
- t.Run("putUser", func(t *testing.T) {
+ t.Run("postUser", func(t *testing.T) {
t.Parallel()
-
- t.Run("disabled", func(t *testing.T) {
- t.Parallel()
-
- ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
- defer cancel()
-
- client, _ := coderdenttest.New(t, &coderdenttest.Options{
- SCIMAPIKey: []byte("hi"),
- LicenseOptions: &coderdenttest.LicenseOptions{
- AccountID: "coolin",
- Features: license.Features{
- codersdk.FeatureSCIM: 0,
- },
+ ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
+ defer cancel()
+
+ scimAPIKey := []byte("hi")
+ mockAudit := audit.NewMock()
+ notifyEnq := ¬ificationstest.FakeEnqueuer{}
+ client, _ := coderdenttest.New(t, &coderdenttest.Options{
+ Options: &coderdtest.Options{
+ Auditor: mockAudit,
+ NotificationsEnqueuer: notifyEnq,
+ },
+ SCIMAPIKey: scimAPIKey,
+ AuditLogging: true,
+ LicenseOptions: &coderdenttest.LicenseOptions{
+ AccountID: "coolin",
+ Features: license.Features{
+ codersdk.FeatureSCIM: 1,
+ codersdk.FeatureAuditLog: 1,
+ codersdk.FeatureMultipleOrganizations: 1,
},
- })
-
- res, err := client.Request(ctx, http.MethodPut, "/scim/v2/Users/bob", struct{}{})
- require.NoError(t, err)
- _, _ = io.Copy(io.Discard, res.Body)
- _ = res.Body.Close()
- assert.Equal(t, http.StatusForbidden, res.StatusCode)
+ },
})
- t.Run("noAuth", func(t *testing.T) {
- t.Parallel()
-
- ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
- defer cancel()
+ sUser := makeScim2User(t)
+ res, err := client.Request(ctx, "POST", "/scim/v2/Users", sUser, setScimAuth(scimAPIKey))
+ require.NoError(t, err)
+ defer res.Body.Close()
+ require.Equal(t, http.StatusCreated, res.StatusCode)
+
+ var created scim2User
+ require.NoError(t, json.NewDecoder(res.Body).Decode(&created))
+ assert.NotEmpty(t, created.ID)
+ assert.Equal(t, sUser.UserName, created.UserName)
+ assert.True(t, created.Active)
+
+ // Verify user exists.
+ userRes, err := client.Users(ctx, codersdk.UsersRequest{Search: created.UserName})
+ require.NoError(t, err)
+ require.Len(t, userRes.Users, 1)
+ assert.Equal(t, codersdk.LoginTypeOIDC, userRes.Users[0].LoginType)
+
+ // Verify audit log.
+ require.True(t, len(mockAudit.AuditLogs()) > 0)
+
+ // Verify no user admin notification (SCIM skips notifications).
+ require.Empty(t, notifyEnq.Sent())
+ })
- client, _ := coderdenttest.New(t, &coderdenttest.Options{
- SCIMAPIKey: []byte("hi"),
- LicenseOptions: &coderdenttest.LicenseOptions{
- AccountID: "coolin",
- Features: license.Features{
- codersdk.FeatureSCIM: 1,
- },
+ t.Run("postUserConflict", func(t *testing.T) {
+ // SCIM 2.0 returns 409 Conflict on duplicate active user, unlike the
+ // legacy handler which returned 200 with the existing user.
+ t.Parallel()
+ ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
+ defer cancel()
+
+ scimAPIKey := []byte("hi")
+ client, _ := coderdenttest.New(t, &coderdenttest.Options{
+ SCIMAPIKey: scimAPIKey,
+ LicenseOptions: &coderdenttest.LicenseOptions{
+ AccountID: "coolin",
+ Features: license.Features{
+ codersdk.FeatureSCIM: 1,
+ codersdk.FeatureMultipleOrganizations: 1,
},
- })
-
- res, err := client.Request(ctx, http.MethodPut, "/scim/v2/Users/bob", struct{}{})
- require.NoError(t, err)
- _, _ = io.Copy(io.Discard, res.Body)
- _ = res.Body.Close()
- assert.Equal(t, http.StatusUnauthorized, res.StatusCode)
+ },
})
- t.Run("MissingActiveField", func(t *testing.T) {
- t.Parallel()
-
- ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
- defer cancel()
-
- scimAPIKey := []byte("hi")
- mockAudit := audit.NewMock()
- client, _ := coderdenttest.New(t, &coderdenttest.Options{
- Options: &coderdtest.Options{Auditor: mockAudit},
- SCIMAPIKey: scimAPIKey,
- AuditLogging: true,
- LicenseOptions: &coderdenttest.LicenseOptions{
- AccountID: "coolin",
- Features: license.Features{
- codersdk.FeatureSCIM: 1,
- codersdk.FeatureAuditLog: 1,
- },
- },
- })
- mockAudit.ResetLogs()
-
- sUser := makeScimUser(t)
- res, err := client.Request(ctx, "POST", "/scim/v2/Users", sUser, setScimAuth(scimAPIKey))
- require.NoError(t, err)
- defer res.Body.Close()
- assert.Equal(t, http.StatusOK, res.StatusCode)
- mockAudit.ResetLogs()
-
- err = json.NewDecoder(res.Body).Decode(&sUser)
- require.NoError(t, err)
-
- sUser.Active = nil
+ sUser := makeScim2User(t)
+ res, err := client.Request(ctx, "POST", "/scim/v2/Users", sUser, setScimAuth(scimAPIKey))
+ require.NoError(t, err)
+ _ = res.Body.Close()
+ require.Equal(t, http.StatusCreated, res.StatusCode)
- res, err = client.Request(ctx, http.MethodPut, "/scim/v2/Users/"+sUser.ID, sUser, setScimAuth(scimAPIKey))
- require.NoError(t, err)
- defer res.Body.Close()
- assert.Equal(t, http.StatusBadRequest, res.StatusCode)
+ res, err = client.Request(ctx, "POST", "/scim/v2/Users", sUser, setScimAuth(scimAPIKey))
+ require.NoError(t, err)
+ _ = res.Body.Close()
+ assert.Equal(t, http.StatusConflict, res.StatusCode)
- data, err := io.ReadAll(res.Body)
- require.NoError(t, err)
- require.Contains(t, string(data), "active field is required")
- mockAudit.ResetLogs()
- })
+ userRes, err := client.Users(ctx, codersdk.UsersRequest{Search: sUser.UserName})
+ require.NoError(t, err)
+ require.Len(t, userRes.Users, 1)
+ })
- t.Run("ImmutabilityViolation", func(t *testing.T) {
- t.Parallel()
-
- ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
- defer cancel()
-
- scimAPIKey := []byte("hi")
- mockAudit := audit.NewMock()
- client, _ := coderdenttest.New(t, &coderdenttest.Options{
- Options: &coderdtest.Options{Auditor: mockAudit},
- SCIMAPIKey: scimAPIKey,
- AuditLogging: true,
- LicenseOptions: &coderdenttest.LicenseOptions{
- AccountID: "coolin",
- Features: license.Features{
- codersdk.FeatureSCIM: 1,
- codersdk.FeatureAuditLog: 1,
- },
+ t.Run("postUserReactivatesSuspended", func(t *testing.T) {
+ // When the SCIM client deletes a user (which only suspends in Coder),
+ // posting the same user again should reactivate the existing row.
+ t.Parallel()
+ ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
+ defer cancel()
+
+ scimAPIKey := []byte("hi")
+ client, _ := coderdenttest.New(t, &coderdenttest.Options{
+ SCIMAPIKey: scimAPIKey,
+ LicenseOptions: &coderdenttest.LicenseOptions{
+ AccountID: "coolin",
+ Features: license.Features{
+ codersdk.FeatureSCIM: 1,
+ codersdk.FeatureMultipleOrganizations: 1,
},
- })
- mockAudit.ResetLogs()
-
- sUser := makeScimUser(t)
- res, err := client.Request(ctx, "POST", "/scim/v2/Users", sUser, setScimAuth(scimAPIKey))
- require.NoError(t, err)
- defer res.Body.Close()
- assert.Equal(t, http.StatusOK, res.StatusCode)
- mockAudit.ResetLogs()
-
- err = json.NewDecoder(res.Body).Decode(&sUser)
- require.NoError(t, err)
-
- sUser.UserName += "changed"
-
- res, err = client.Request(ctx, http.MethodPut, "/scim/v2/Users/"+sUser.ID, sUser, setScimAuth(scimAPIKey))
- require.NoError(t, err)
- defer res.Body.Close()
- assert.Equal(t, http.StatusBadRequest, res.StatusCode)
- mockAudit.ResetLogs()
-
- data, err := io.ReadAll(res.Body)
- require.NoError(t, err)
- require.Contains(t, string(data), "mutability")
- require.NoError(t, err)
+ },
})
- t.Run("OK", func(t *testing.T) {
- t.Parallel()
-
- ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
- defer cancel()
-
- scimAPIKey := []byte("hi")
- mockAudit := audit.NewMock()
- client, _ := coderdenttest.New(t, &coderdenttest.Options{
- Options: &coderdtest.Options{Auditor: mockAudit},
- SCIMAPIKey: scimAPIKey,
- AuditLogging: true,
- LicenseOptions: &coderdenttest.LicenseOptions{
- AccountID: "coolin",
- Features: license.Features{
- codersdk.FeatureSCIM: 1,
- codersdk.FeatureAuditLog: 1,
- },
- },
- })
- mockAudit.ResetLogs()
-
- sUser := makeScimUser(t)
- res, err := client.Request(ctx, "POST", "/scim/v2/Users", sUser, setScimAuth(scimAPIKey))
- require.NoError(t, err)
- defer res.Body.Close()
- assert.Equal(t, http.StatusOK, res.StatusCode)
- mockAudit.ResetLogs()
-
- err = json.NewDecoder(res.Body).Decode(&sUser)
- require.NoError(t, err)
-
- sUser.Active = ptr.Ref(false)
-
- res, err = client.Request(ctx, http.MethodPatch, "/scim/v2/Users/"+sUser.ID, sUser, setScimAuth(scimAPIKey))
- require.NoError(t, err)
- _, _ = io.Copy(io.Discard, res.Body)
- _ = res.Body.Close()
- assert.Equal(t, http.StatusOK, res.StatusCode)
-
- aLogs := mockAudit.AuditLogs()
- require.Len(t, aLogs, 1)
- assert.Equal(t, database.AuditActionWrite, aLogs[0].Action)
+ sUser := makeScim2User(t)
+ res, err := client.Request(ctx, "POST", "/scim/v2/Users", sUser, setScimAuth(scimAPIKey))
+ require.NoError(t, err)
+ var created scim2User
+ require.NoError(t, json.NewDecoder(res.Body).Decode(&created))
+ _ = res.Body.Close()
+ require.Equal(t, http.StatusCreated, res.StatusCode)
+ require.NotEmpty(t, created.ID)
+
+ // Delete (suspends) the user.
+ res, err = client.Request(ctx, "DELETE", "/scim/v2/Users/"+created.ID, nil, setScimAuth(scimAPIKey))
+ require.NoError(t, err)
+ _ = res.Body.Close()
+ assert.Equal(t, http.StatusNoContent, res.StatusCode)
+
+ userRes, err := client.User(ctx, created.ID)
+ require.NoError(t, err)
+ assert.Equal(t, codersdk.UserStatusSuspended, userRes.Status)
+
+ // Re-create. The handler should reactivate the existing row.
+ res, err = client.Request(ctx, "POST", "/scim/v2/Users", sUser, setScimAuth(scimAPIKey))
+ require.NoError(t, err)
+ var recreated scim2User
+ require.NoError(t, json.NewDecoder(res.Body).Decode(&recreated))
+ _ = res.Body.Close()
+ require.Equal(t, http.StatusCreated, res.StatusCode)
+ assert.Equal(t, created.ID, recreated.ID, "recreate should reactivate the existing row, not create a new one")
+ assert.True(t, recreated.Active, "recreated user should be active in the SCIM response")
+
+ // The DB user moves from suspended → dormant on reactivate; the SCIM
+ // response reports both Active and Dormant as active=true.
+ userRes, err = client.User(ctx, created.ID)
+ require.NoError(t, err)
+ assert.Equal(t, codersdk.UserStatusDormant, userRes.Status)
+ })
- userRes, err := client.Users(ctx, codersdk.UsersRequest{Search: sUser.Emails[0].Value})
- require.NoError(t, err)
- require.Len(t, userRes.Users, 1)
- assert.Equal(t, codersdk.UserStatusSuspended, userRes.Users[0].Status)
+ t.Run("getUser", func(t *testing.T) {
+ t.Parallel()
+ ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
+ defer cancel()
+
+ scimAPIKey := []byte("hi")
+ client, _ := coderdenttest.New(t, &coderdenttest.Options{
+ SCIMAPIKey: scimAPIKey,
+ LicenseOptions: &coderdenttest.LicenseOptions{
+ AccountID: "coolin",
+ Features: license.Features{
+ codersdk.FeatureSCIM: 1,
+ codersdk.FeatureMultipleOrganizations: 1,
+ },
+ },
})
- // Create a user via SCIM, which starts as dormant.
- // Log in as the user, making them active.
- // Then patch the user again and the user should still be active.
- t.Run("ActiveIsActive", func(t *testing.T) {
- t.Parallel()
-
- ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
- defer cancel()
-
- scimAPIKey := []byte("hi")
+ sUser := makeScim2User(t)
+ res, err := client.Request(ctx, "POST", "/scim/v2/Users", sUser, setScimAuth(scimAPIKey))
+ require.NoError(t, err)
+ var created scim2User
+ require.NoError(t, json.NewDecoder(res.Body).Decode(&created))
+ _ = res.Body.Close()
+ require.Equal(t, http.StatusCreated, res.StatusCode)
+
+ res, err = client.Request(ctx, "GET", "/scim/v2/Users/"+created.ID, nil, setScimAuth(scimAPIKey))
+ require.NoError(t, err)
+ defer res.Body.Close()
+ require.Equal(t, http.StatusOK, res.StatusCode)
+
+ var got scim2User
+ require.NoError(t, json.NewDecoder(res.Body).Decode(&got))
+ assert.Equal(t, created.ID, got.ID)
+ assert.Equal(t, sUser.UserName, got.UserName)
+ })
- mockAudit := audit.NewMock()
- fake := oidctest.NewFakeIDP(t, oidctest.WithServing())
- client, _ := coderdenttest.New(t, &coderdenttest.Options{
- Options: &coderdtest.Options{
- Auditor: mockAudit,
- OIDCConfig: fake.OIDCConfig(t, []string{}),
- },
- SCIMAPIKey: scimAPIKey,
- AuditLogging: true,
- LicenseOptions: &coderdenttest.LicenseOptions{
- AccountID: "coolin",
- Features: license.Features{
- codersdk.FeatureSCIM: 1,
- codersdk.FeatureAuditLog: 1,
- },
+ t.Run("patchUser", func(t *testing.T) {
+ t.Parallel()
+ ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
+ defer cancel()
+
+ scimAPIKey := []byte("hi")
+ mockAudit := audit.NewMock()
+ client, _ := coderdenttest.New(t, &coderdenttest.Options{
+ Options: &coderdtest.Options{Auditor: mockAudit},
+ SCIMAPIKey: scimAPIKey,
+ AuditLogging: true,
+ LicenseOptions: &coderdenttest.LicenseOptions{
+ AccountID: "coolin",
+ Features: license.Features{
+ codersdk.FeatureSCIM: 1,
+ codersdk.FeatureAuditLog: 1,
+ codersdk.FeatureMultipleOrganizations: 1,
},
- })
- mockAudit.ResetLogs()
-
- // User is dormant on create
- sUser := makeScimUser(t)
- res, err := client.Request(ctx, http.MethodPost, "/scim/v2/Users", sUser, setScimAuth(scimAPIKey))
- require.NoError(t, err)
- defer res.Body.Close()
- assert.Equal(t, http.StatusOK, res.StatusCode)
-
- err = json.NewDecoder(res.Body).Decode(&sUser)
- require.NoError(t, err)
-
- // Check the audit log
- aLogs := mockAudit.AuditLogs()
- require.Len(t, aLogs, 1)
- assert.Equal(t, database.AuditActionCreate, aLogs[0].Action)
+ },
+ })
- // Verify the user is dormant
- scimUser, err := client.User(ctx, sUser.UserName)
- require.NoError(t, err)
- require.Equal(t, codersdk.UserStatusDormant, scimUser.Status, "user starts as dormant")
-
- // Log in as the user, making them active
- //nolint:bodyclose
- scimUserClient, _ := fake.Login(t, client, jwt.MapClaims{
- "email": sUser.Emails[0].Value,
- "sub": uuid.NewString(),
- })
- scimUser, err = scimUserClient.User(ctx, codersdk.Me)
- require.NoError(t, err)
- require.Equal(t, codersdk.UserStatusActive, scimUser.Status, "user should now be active")
+ sUser := makeScim2User(t)
+ res, err := client.Request(ctx, "POST", "/scim/v2/Users", sUser, setScimAuth(scimAPIKey))
+ require.NoError(t, err)
+ var created scim2User
+ require.NoError(t, json.NewDecoder(res.Body).Decode(&created))
+ _ = res.Body.Close()
+ require.Equal(t, http.StatusCreated, res.StatusCode)
+
+ // PATCH with replace op setting active=false.
+ mockAudit.ResetLogs()
+ patchBody := map[string]interface{}{
+ "schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
+ "Operations": []map[string]interface{}{
+ {"op": "replace", "path": "active", "value": false},
+ },
+ }
+ res, err = client.Request(ctx, "PATCH", "/scim/v2/Users/"+created.ID, patchBody, setScimAuth(scimAPIKey))
+ require.NoError(t, err)
+ _ = res.Body.Close()
+ assert.Equal(t, http.StatusOK, res.StatusCode)
+
+ userRes, err := client.User(ctx, created.ID)
+ require.NoError(t, err)
+ assert.Equal(t, codersdk.UserStatusSuspended, userRes.Status)
+ })
- // Patch the user
- mockAudit.ResetLogs()
- res, err = client.Request(ctx, http.MethodPut, "/scim/v2/Users/"+sUser.ID, sUser, setScimAuth(scimAPIKey))
- require.NoError(t, err)
- _, _ = io.Copy(io.Discard, res.Body)
- _ = res.Body.Close()
- assert.Equal(t, http.StatusOK, res.StatusCode)
+ t.Run("putUser", func(t *testing.T) {
+ t.Parallel()
+ ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
+ defer cancel()
+
+ scimAPIKey := []byte("hi")
+ client, _ := coderdenttest.New(t, &coderdenttest.Options{
+ SCIMAPIKey: scimAPIKey,
+ LicenseOptions: &coderdenttest.LicenseOptions{
+ AccountID: "coolin",
+ Features: license.Features{
+ codersdk.FeatureSCIM: 1,
+ codersdk.FeatureMultipleOrganizations: 1,
+ },
+ },
+ })
- // Should be no audit logs since there is no diff
- aLogs = mockAudit.AuditLogs()
- require.Len(t, aLogs, 0)
+ sUser := makeScim2User(t)
+ res, err := client.Request(ctx, "POST", "/scim/v2/Users", sUser, setScimAuth(scimAPIKey))
+ require.NoError(t, err)
+ var created scim2User
+ require.NoError(t, json.NewDecoder(res.Body).Decode(&created))
+ _ = res.Body.Close()
+ require.Equal(t, http.StatusCreated, res.StatusCode)
+
+ // PUT with active=false.
+ sUser.Active = ptr.Ref(false)
+ res, err = client.Request(ctx, "PUT", "/scim/v2/Users/"+created.ID, sUser, setScimAuth(scimAPIKey))
+ require.NoError(t, err)
+ _ = res.Body.Close()
+ assert.Equal(t, http.StatusOK, res.StatusCode)
+
+ userRes, err := client.User(ctx, created.ID)
+ require.NoError(t, err)
+ assert.Equal(t, codersdk.UserStatusSuspended, userRes.Status)
+ })
- // Verify the user is still active.
- scimUser, err = client.User(ctx, sUser.UserName)
- require.NoError(t, err)
- require.Equal(t, codersdk.UserStatusActive, scimUser.Status, "user is still active")
+ t.Run("deleteUser", func(t *testing.T) {
+ t.Parallel()
+ ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
+ defer cancel()
+
+ scimAPIKey := []byte("hi")
+ client, _ := coderdenttest.New(t, &coderdenttest.Options{
+ SCIMAPIKey: scimAPIKey,
+ LicenseOptions: &coderdenttest.LicenseOptions{
+ AccountID: "coolin",
+ Features: license.Features{
+ codersdk.FeatureSCIM: 1,
+ codersdk.FeatureMultipleOrganizations: 1,
+ },
+ },
})
+
+ sUser := makeScim2User(t)
+ res, err := client.Request(ctx, "POST", "/scim/v2/Users", sUser, setScimAuth(scimAPIKey))
+ require.NoError(t, err)
+ var created scim2User
+ require.NoError(t, json.NewDecoder(res.Body).Decode(&created))
+ _ = res.Body.Close()
+ require.Equal(t, http.StatusCreated, res.StatusCode)
+
+ res, err = client.Request(ctx, "DELETE", "/scim/v2/Users/"+created.ID, nil, setScimAuth(scimAPIKey))
+ require.NoError(t, err)
+ _ = res.Body.Close()
+ assert.Equal(t, http.StatusNoContent, res.StatusCode)
+
+ // Coder does not hard-delete users. The user should remain but be suspended.
+ userRes, err := client.User(ctx, created.ID)
+ require.NoError(t, err)
+ assert.Equal(t, codersdk.UserStatusSuspended, userRes.Status)
})
}
-func TestScimError(t *testing.T) {
+func TestLegacyScimError(t *testing.T) {
t.Parallel()
// Demonstrates that we cannot use the standard errors
@@ -876,7 +714,7 @@ func TestScimError(t *testing.T) {
// Our error wrapper works
rw = httptest.NewRecorder()
- _ = handlerutil.WriteError(rw, scim.NewHTTPError(http.StatusNotFound, spec.ErrNotFound.Type, xerrors.New("not found")))
+ _ = handlerutil.WriteError(rw, legacyscim.NewHTTPError(http.StatusNotFound, spec.ErrNotFound.Type, xerrors.New("not found")))
resp = rw.Result()
defer resp.Body.Close()
require.Equal(t, http.StatusNotFound, resp.StatusCode)
diff --git a/enterprise/coderd/scimroutes.go b/enterprise/coderd/scimroutes.go
new file mode 100644
index 0000000000000..891b760e2f412
--- /dev/null
+++ b/enterprise/coderd/scimroutes.go
@@ -0,0 +1,74 @@
+package coderd
+
+import (
+ "net/http"
+
+ "github.com/go-chi/chi/v5"
+ "github.com/go-chi/chi/v5/middleware"
+ "golang.org/x/xerrors"
+
+ "github.com/coder/coder/v2/coderd/httpapi"
+ "github.com/coder/coder/v2/codersdk"
+ "github.com/coder/coder/v2/enterprise/coderd/legacyscim"
+ "github.com/coder/coder/v2/enterprise/coderd/scim"
+)
+
+func (api *API) mountScimRoute(opt *Options, r chi.Router) error {
+ if len(opt.SCIMAPIKey) == 0 {
+ // Show a helpful 404 error. Because this is not under the /api/v2 routes,
+ // the frontend is the fallback. A html page is not a helpful error for
+ // a SCIM provider. This JSON has a call to action that __may__ resolve
+ // the issue.
+ //
+ // Using mount to cover all subroute possibilities
+ r.Mount("/", http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ httpapi.Write(r.Context(), w, http.StatusNotFound, codersdk.Response{
+ Message: "SCIM is disabled, please contact your administrator if you believe this is an error",
+ Detail: "SCIM endpoints are disabled if no SCIM is configured. Configure 'CODER_SCIM_AUTH_HEADER' to enable.",
+ })
+ })))
+ return nil
+ }
+
+ if opt.UseLegacySCIM {
+ // Legacy SCIM handler (imulab/go-scim based). Opt-in for
+ // backward compatibility during the transition period.
+ legacySrv := &legacyscim.LegacyServer{
+ Logger: opt.Logger,
+ Database: opt.Database,
+ IDPSync: opt.IDPSync,
+ AGPL: api.AGPL,
+ AccessURL: api.AccessURL,
+ SCIMAPIKey: opt.SCIMAPIKey,
+ Auditor: &api.AGPL.Auditor,
+ }
+ r.Mount("/v2", chi.Chain(
+ api.RequireFeatureMW(codersdk.FeatureSCIM),
+ legacySrv.AuthMiddleware,
+ ).Handler(legacySrv.Handler()))
+ return nil
+ }
+
+ // SCIM 2.0 handler (elimity-com/scim based).
+ scimSrv, err := scim.New(&scim.Options{
+ DB: opt.Database,
+ Auditor: &api.AGPL.Auditor,
+ IDPSync: opt.IDPSync,
+ Logger: opt.Logger,
+ AGPL: api.AGPL,
+ SCIMAPIKey: opt.SCIMAPIKey,
+ })
+ if err != nil {
+ return xerrors.Errorf("create scim server: %w", err)
+ }
+
+ // The elimity-com/scim library reads r.URL.Path and strips "/v2"
+ // internally. Chi's Route/Mount modifies its own routing context
+ // but not r.URL.Path, so we use http.StripPrefix to ensure the
+ // library sees paths like "/v2/Users" instead of "/scim/v2/Users".
+ r.Mount("/", chi.Chain(
+ api.RequireFeatureMW(codersdk.FeatureSCIM),
+ middleware.StripPrefix("/scim"),
+ ).Handler(scimSrv.Handler()))
+ return nil
+}
diff --git a/go.mod b/go.mod
index b3398ff9284ae..6a0d339038d7b 100644
--- a/go.mod
+++ b/go.mod
@@ -512,6 +512,7 @@ require (
github.com/danieljoos/wincred v1.2.3
github.com/dgraph-io/ristretto/v2 v2.4.0
github.com/elazarl/goproxy v1.8.0
+ github.com/elimity-com/scim v0.0.0-20260506142751-830e1caafcc3
github.com/fsnotify/fsnotify v1.10.1
github.com/go-git/go-git/v5 v5.19.1
github.com/invopop/jsonschema v0.14.0
@@ -519,6 +520,7 @@ require (
github.com/nats-io/nats-server/v2 v2.12.8
github.com/nats-io/nats.go v1.51.0
github.com/openai/openai-go/v3 v3.28.0
+ github.com/scim2/filter-parser/v2 v2.2.0
github.com/shopspring/decimal v1.4.0
github.com/smallstep/pkcs7 v0.2.1
github.com/sony/gobreaker/v2 v2.4.0
@@ -576,6 +578,8 @@ require (
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
github.com/daixiang0/gci v0.13.7 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
+ github.com/di-wu/parser v0.2.2 // indirect
+ github.com/di-wu/xsd-datetime v1.0.0 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect
github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect
diff --git a/go.sum b/go.sum
index f341026344c82..6c2e4ea778c5c 100644
--- a/go.sum
+++ b/go.sum
@@ -420,6 +420,10 @@ github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7c
github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA=
github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4=
github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU=
+github.com/di-wu/parser v0.2.2 h1:I9oHJ8spBXOeL7Wps0ffkFFFiXJf/pk7NX9lcAMqRMU=
+github.com/di-wu/parser v0.2.2/go.mod h1:SLp58pW6WamdmznrVRrw2NTyn4wAvT9rrEFynKX7nYo=
+github.com/di-wu/xsd-datetime v1.0.0 h1:vZoGNkbzpBNoc+JyfVLEbutNDNydYV8XwHeV7eUJoxI=
+github.com/di-wu/xsd-datetime v1.0.0/go.mod h1:i3iEhrP3WchwseOBeIdW/zxeoleXTOzx1WyDXgdmOww=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
@@ -450,6 +454,8 @@ github.com/elastic/go-windows v1.0.0 h1:qLURgZFkkrYyTTkvYpsZIgf83AUsdIHfvlJaqaZ7
github.com/elastic/go-windows v1.0.0/go.mod h1:TsU0Nrp7/y3+VwE82FoZF8gC/XFg/Elz6CcloAxnPgU=
github.com/elazarl/goproxy v1.8.0 h1:dt561rX7UAYMeFRLtzFx6uQGl2TpL1dr6uCG23nFQSY=
github.com/elazarl/goproxy v1.8.0/go.mod h1:b5xm6W48AUHNpRTCvlnd0YVh+JafCCtsLsJZvvNTz+E=
+github.com/elimity-com/scim v0.0.0-20260506142751-830e1caafcc3 h1:P+JJLBS2QNe5aWBpNoDWqmGwNv/DKP+WZpU/mPIS+28=
+github.com/elimity-com/scim v0.0.0-20260506142751-830e1caafcc3/go.mod h1:JkjcmqbLW+khwt2fmBPJFBhx2zGZ8XobRZ+O0VhlwWo=
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ=
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-smtp v0.21.2 h1:OLDgvZKuofk4em9fT5tFG5j4jE1/hXnX75UMvcrL4AA=
@@ -1083,6 +1089,8 @@ github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEV
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
github.com/satori/go.uuid v1.2.1-0.20181028125025-b2ce2384e17b h1:gQZ0qzfKHQIybLANtM3mBXNUtOfsCFXeTsnBqCsx1KM=
github.com/satori/go.uuid v1.2.1-0.20181028125025-b2ce2384e17b/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
+github.com/scim2/filter-parser/v2 v2.2.0 h1:QGadEcsmypxg8gYChRSM2j1edLyE/2j72j+hdmI4BJM=
+github.com/scim2/filter-parser/v2 v2.2.0/go.mod h1:jWnkDToqX/Y0ugz0P5VvpVEUKcWcyHHj+X+je9ce5JA=
github.com/secure-systems-lab/go-securesystemslib v0.10.0 h1:l+H5ErcW0PAehBNrBxoGv1jjNpGYdZ9RcheFkB2WI14=
github.com/secure-systems-lab/go-securesystemslib v0.10.0/go.mod h1:MRKONWmRoFzPNQ9USRF9i1mc7MvAVvF1LlW8X5VWDvk=
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts
index 28412b9b0902e..11d33b28a81d5 100644
--- a/site/src/api/typesGenerated.ts
+++ b/site/src/api/typesGenerated.ts
@@ -4090,6 +4090,7 @@ export interface DeploymentValues {
readonly agent_fallback_troubleshooting_url?: string;
readonly browser_only?: boolean;
readonly scim_api_key?: string;
+ readonly scim_use_legacy?: boolean;
readonly external_token_encryption_keys?: string;
readonly provisioner?: ProvisionerConfig;
readonly rate_limit?: RateLimitConfig;
From df929467f6213c752009bb445bf5d24339114ccd Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Thu, 28 May 2026 15:20:56 +0000
Subject: [PATCH 080/249] chore: bump github.com/open-policy-agent/opa from
1.11.0 to 1.17.0 (#25200)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Bumps
[github.com/open-policy-agent/opa](https://github.com/open-policy-agent/opa)
from 1.11.0 to 1.17.0.
Release notes
This release updates the version of Go used to build the OPA binaries
and images to 1.26.3;
addressing a
number of vulnerabilities.
v1.16.1
This is a patch release addressing a regression in the plugin manager
that may cause the service to hang on shutdown (#8590).
v1.16.0
[!WARNING]
A regression has been found in the plugin manager, which may cause
the service to hang on shutdown.
Users are advised to go directly to v1.16.1.
This release contains a mix of new features, performance
improvements, and bugfixes. Notably:
New uri.parse and uri.is_valid built-in
functions
Data API Request/Response Metadata
Prometheus metrics exported via OTLP
Formatter improvements
NOTE:
In v1.15.x, OPA was dropping logs for bundle downloads,
print() calls and other plugin-originated logs.
Users are advised to update, v1.16.0 fixes this bug in (#8544).
New uri.parse and uri.is_valid built-in
functions (#8263)
Two new built-in
functions have been added: uri.parse for parsing a
given URI, and uri.is_valid for verifying the structure of
a given URI.
uri.parse
Parses a URI and returns an object containing its components
according to RFC
3986. Empty components are omitted.
This OPA release introduces a new future.keywords.not
import
that fixes a long-standing semantic issue with negation in Rego.
Without the import, the compiler expands a negated composite
expression like
not f(g(input.x)) into a series of sub-expressions
evaluated before the
not:
__local0__ = input.x
g(__local0__, __local1__)
not f(__local1__)
If any sub-expression fails — for example, input.x is
undefined or g
produces an undefined result — the entire rule fails rather than the
not succeeding.
This is unintuitive: the user's intent is "the condition does not
hold," but
an undefined intermediate value causes a silent failure instead of the
expected
not result.
With import future.keywords.not, composite-expression
negation wraps the full compiler
expansion in an implicit body:
not { __local0__ = input.x; g(__local0__, __local1__);
f(__local1__) }
Now, if any sub-expression is undefined or fails, the body
is unsatisfiable
and the not expression succeeds; matching the intuition
that "the condition does not hold."
NOTE:
Users are recommended to import future.keywords.not
whenever the not keyword is used in a policy.
Rule annotations now support a labels field. Labels from
all successfully evaluated
rules are collected and included in each decision log entry as a
top-level rule_labels
diff --git a/site/src/pages/AuditPage/AuditLogRow/AuditLogDiff/auditUtils.test.ts b/site/src/pages/AuditPage/AuditLogRow/AuditLogDiff/auditUtils.test.ts
index 12c6cabb418ff..74cca5a2c69f1 100644
--- a/site/src/pages/AuditPage/AuditLogRow/AuditLogDiff/auditUtils.test.ts
+++ b/site/src/pages/AuditPage/AuditLogRow/AuditLogDiff/auditUtils.test.ts
@@ -1,4 +1,4 @@
-import { determineGroupDiff } from "./auditUtils";
+import { determineGroupDiff, formatAuditDiffValue } from "./auditUtils";
const auditDiffForNewGroup = {
id: {
@@ -120,3 +120,70 @@ describe("determineAuditDiff", () => {
expect(determineGroupDiff(AuditDiffForDeletedGroup)).toEqual(result);
});
});
+
+describe("formatAuditDiffValue", () => {
+ it.each([
+ { name: "string", value: "hello", expected: '"hello"' },
+ {
+ name: "string containing double quotes",
+ value: 'he said "hello"',
+ expected: '"he said \\"hello\\""',
+ },
+ {
+ name: "array of primitives",
+ value: ["admin", "auditor"],
+ expected: '["admin", "auditor"]',
+ },
+ { name: "boolean true", value: true, expected: "true" },
+ { name: "boolean false", value: false, expected: "false" },
+ { name: "number", value: 42, expected: "42" },
+ { name: "null", value: null, expected: "null" },
+ { name: "undefined", value: undefined, expected: "null" },
+ {
+ name: "invalid SQL time",
+ value: { Time: "0001-01-01T00:00:00Z", Valid: false },
+ expected: "null",
+ },
+ ])("preserves current behavior for $name", ({ value, expected }) => {
+ expect(formatAuditDiffValue(value)).toBe(expected);
+ });
+
+ it("preserves current behavior for valid SQL time objects", () => {
+ const value = { Time: "2024-10-22T09:03:23.961702Z", Valid: true };
+
+ expect(formatAuditDiffValue(value)).toBe(
+ new Date(value.Time).toLocaleString(),
+ );
+ });
+
+ it("formats plain objects as deterministic compact JSON", () => {
+ expect(
+ formatAuditDiffValue({
+ z: ["read"],
+ a: { permissions: ["read"] },
+ }),
+ ).toBe('{"a":{"permissions":["read"]},"z":["read"]}');
+ });
+
+ it("formats chat ACL objects as deterministic compact JSON", () => {
+ expect(
+ formatAuditDiffValue({
+ "user-2": { permissions: ["read"] },
+ "user-1": { permissions: ["read"] },
+ }),
+ ).toBe(
+ '{"user-1":{"permissions":["read"]},"user-2":{"permissions":["read"]}}',
+ );
+ });
+
+ it("formats arrays containing objects without object string coercion", () => {
+ expect(
+ formatAuditDiffValue([
+ { user_id: "user-2", permissions: ["read"] },
+ { permissions: ["read"], user_id: "user-1" },
+ ]),
+ ).toBe(
+ '[{"permissions":["read"],"user_id":"user-2"}, {"permissions":["read"],"user_id":"user-1"}]',
+ );
+ });
+});
diff --git a/site/src/pages/AuditPage/AuditLogRow/AuditLogDiff/auditUtils.ts b/site/src/pages/AuditPage/AuditLogRow/AuditLogDiff/auditUtils.ts
index 7e9033841ccf9..881463d46e5a3 100644
--- a/site/src/pages/AuditPage/AuditLogRow/AuditLogDiff/auditUtils.ts
+++ b/site/src/pages/AuditPage/AuditLogRow/AuditLogDiff/auditUtils.ts
@@ -25,27 +25,68 @@ export const determineGroupDiff = (auditLogDiff: AuditDiff): AuditDiff => {
};
/**
- *
- * @param auditLogDiff
- * @returns a diff with the 'mappings' as a JSON string. Otherwise, it is [Object object]
+ * Formats an audit diff value for display. Strings are quoted, nullish values
+ * become "null", SQL time objects are localized, arrays are recursed, and plain
+ * objects are serialized as compact JSON with sorted keys.
*/
-export const determineIdPSyncMappingDiff = (
- auditLogDiff: AuditDiff,
-): AuditDiff => {
- const old = auditLogDiff.mapping?.old as Record | undefined;
- const new_ = auditLogDiff.mapping?.new as
- | Record
- | undefined;
- if (!old || !new_) {
- return auditLogDiff;
+export const formatAuditDiffValue = (value: unknown): string => {
+ if (typeof value === "string") {
+ return JSON.stringify(value);
}
- return {
- ...auditLogDiff,
- mapping: {
- old: JSON.stringify(old),
- new: JSON.stringify(new_),
- secret: auditLogDiff.mapping?.secret,
- },
- };
+ if (isTimeObject(value)) {
+ if (!value.Valid) {
+ return "null";
+ }
+
+ return new Date(value.Time).toLocaleString();
+ }
+
+ if (Array.isArray(value)) {
+ const values = value.map((v) => formatAuditDiffValue(v));
+ return `[${values.join(", ")}]`;
+ }
+
+ if (value === null || value === undefined) {
+ return "null";
+ }
+
+ if (isPlainObject(value)) {
+ return JSON.stringify(sortObjectKeys(value));
+ }
+
+ return String(value);
+};
+
+const isTimeObject = (
+ value: unknown,
+): value is { Time: string; Valid: boolean } => {
+ return (
+ value !== null &&
+ typeof value === "object" &&
+ "Time" in value &&
+ typeof value.Time === "string" &&
+ "Valid" in value &&
+ typeof value.Valid === "boolean"
+ );
+};
+
+const isPlainObject = (value: unknown): value is Record => {
+ return Object.prototype.toString.call(value) === "[object Object]";
+};
+
+const sortObjectKeys = (value: unknown): unknown => {
+ if (Array.isArray(value)) {
+ return value.map(sortObjectKeys);
+ }
+
+ if (!isPlainObject(value)) {
+ return value;
+ }
+
+ const sorted: Record = {};
+ for (const key of Object.keys(value).sort()) {
+ sorted[key] = sortObjectKeys(value[key]);
+ }
+ return sorted;
};
diff --git a/site/src/pages/AuditPage/AuditLogRow/AuditLogRow.stories.tsx b/site/src/pages/AuditPage/AuditLogRow/AuditLogRow.stories.tsx
index 2d4fd6e28e0c1..d84ef1669716d 100644
--- a/site/src/pages/AuditPage/AuditLogRow/AuditLogRow.stories.tsx
+++ b/site/src/pages/AuditPage/AuditLogRow/AuditLogRow.stories.tsx
@@ -1,4 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
+import type { AuditLog } from "#/api/typesGenerated";
import { Table, TableBody } from "#/components/Table/Table";
import { chromatic } from "#/testHelpers/chromatic";
import {
@@ -187,3 +188,87 @@ export const WithConnectionType: Story = {
},
},
};
+
+const MockChatAuditLog: AuditLog = {
+ ...MockAuditLog,
+ resource_type: "chat",
+ resource_id: "c542b43f-4375-421a-a7e0-b39187e35131",
+ resource_target: "c542b43f",
+ resource_icon: "",
+ resource_link: "/agents/c542b43f-4375-421a-a7e0-b39187e35131",
+ description: "{user} updated chat {target}",
+ additional_fields: {},
+};
+
+export const WithChatACLDiff: Story = {
+ parameters: { chromatic },
+ args: {
+ auditLog: {
+ ...MockChatAuditLog,
+ id: "1d718c45-5dfb-4f24-9546-4f61fa8e3402",
+ action: "write",
+ description: "{user} updated sharing for chat {target}",
+ diff: {
+ user_acl: {
+ old: {},
+ new: {
+ "9a68e35d-bf3a-43bd-8e68-130df721cc71": {
+ permissions: ["read"],
+ },
+ },
+ secret: false,
+ },
+ group_acl: {
+ old: {},
+ new: {
+ "6d130d81-017e-44ff-8fca-3a38623dcb14": {
+ permissions: ["read"],
+ },
+ },
+ secret: false,
+ },
+ },
+ },
+ defaultIsDiffOpen: true,
+ },
+};
+
+export const WithArchivedChatDescription: Story = {
+ args: {
+ auditLog: {
+ ...MockChatAuditLog,
+ id: "57329396-084a-4074-9930-385a7eed858a",
+ action: "write",
+ description: "{user} archived chat {target}",
+ diff: {
+ archived: {
+ old: false,
+ new: true,
+ secret: false,
+ },
+ },
+ },
+ },
+};
+
+export const WithUpdatedChatSharingDescription: Story = {
+ args: {
+ auditLog: {
+ ...MockChatAuditLog,
+ id: "8f26cabf-8867-4d2f-942d-77e759a16c1c",
+ action: "write",
+ description: "{user} updated sharing for chat {target}",
+ diff: {
+ user_acl: {
+ old: {},
+ new: {
+ "9a68e35d-bf3a-43bd-8e68-130df721cc71": {
+ permissions: ["read"],
+ },
+ },
+ secret: false,
+ },
+ },
+ },
+ },
+};
diff --git a/site/src/pages/AuditPage/AuditLogRow/AuditLogRow.tsx b/site/src/pages/AuditPage/AuditLogRow/AuditLogRow.tsx
index ea0ccc37a8c3d..448aa393af467 100644
--- a/site/src/pages/AuditPage/AuditLogRow/AuditLogRow.tsx
+++ b/site/src/pages/AuditPage/AuditLogRow/AuditLogRow.tsx
@@ -22,10 +22,7 @@ import { cn } from "#/utils/cn";
import { buildReasonLabels } from "#/utils/workspace";
import { AuditLogDescription } from "./AuditLogDescription/AuditLogDescription";
import { AuditLogDiff } from "./AuditLogDiff/AuditLogDiff";
-import {
- determineGroupDiff,
- determineIdPSyncMappingDiff,
-} from "./AuditLogDiff/auditUtils";
+import { determineGroupDiff } from "./AuditLogDiff/auditUtils";
interface AuditLogRowProps {
auditLog: AuditLog;
@@ -53,14 +50,6 @@ export const AuditLogRow: FC = ({
auditDiff = determineGroupDiff(auditLog.diff);
}
- if (
- auditLog.resource_type === "idp_sync_settings_organization" ||
- auditLog.resource_type === "idp_sync_settings_group" ||
- auditLog.resource_type === "idp_sync_settings_role"
- ) {
- auditDiff = determineIdPSyncMappingDiff(auditLog.diff);
- }
-
const toggle = () => {
if (shouldDisplayDiff) {
setIsDiffOpen((v) => !v);
From c248dfb437432b6dac4ba38ff047a863a1a74624 Mon Sep 17 00:00:00 2001
From: Jon Ayers
Date: Thu, 28 May 2026 14:43:07 -0500
Subject: [PATCH 085/249] fix: escape agent log HTML (#25808)
---
.../resources/AgentLogs/AgentLogLine.test.tsx | 23 +++++++++++++++++++
.../resources/AgentLogs/AgentLogLine.tsx | 2 +-
2 files changed, 24 insertions(+), 1 deletion(-)
create mode 100644 site/src/modules/resources/AgentLogs/AgentLogLine.test.tsx
diff --git a/site/src/modules/resources/AgentLogs/AgentLogLine.test.tsx b/site/src/modules/resources/AgentLogs/AgentLogLine.test.tsx
new file mode 100644
index 0000000000000..80b1d900b048d
--- /dev/null
+++ b/site/src/modules/resources/AgentLogs/AgentLogLine.test.tsx
@@ -0,0 +1,23 @@
+import { screen } from "@testing-library/react";
+import type { Line } from "#/components/Logs/LogLine";
+import { renderComponent } from "#/testHelpers/renderHelpers";
+import { AgentLogLine } from "./AgentLogLine";
+
+const line: Line = {
+ id: 1,
+ level: "info",
+ output: 'safe xss',
+ sourceId: "source-id",
+ time: "2024-03-14T11:31:04.090715Z",
+};
+
+describe("AgentLogLine", () => {
+ it("renders log HTML as escaped text", () => {
+ renderComponent();
+
+ expect(screen.queryByTestId("agent-log-xss")).not.toBeInTheDocument();
+ expect(
+ screen.getByText(/safe xss<\/span>/),
+ ).toBeInTheDocument();
+ });
+});
diff --git a/site/src/modules/resources/AgentLogs/AgentLogLine.tsx b/site/src/modules/resources/AgentLogs/AgentLogLine.tsx
index 2fc68a63c230e..d7b3c50dbfe52 100644
--- a/site/src/modules/resources/AgentLogs/AgentLogLine.tsx
+++ b/site/src/modules/resources/AgentLogs/AgentLogLine.tsx
@@ -5,7 +5,7 @@ import { type Line, LogLine, LogLinePrefix } from "#/components/Logs/LogLine";
// Approximate height of a log line. Used to control virtualized list height.
export const AGENT_LOG_LINE_HEIGHT = 20;
-const convert = new AnsiToHTML();
+const convert = new AnsiToHTML({ escapeXML: true });
interface AgentLogLineProps {
line: Line;
From bb11946bd4c193a81450036b7d409ef3055aa0f2 Mon Sep 17 00:00:00 2001
From: Jon Ayers
Date: Thu, 28 May 2026 15:34:36 -0500
Subject: [PATCH 086/249] fix: require update permission to recreate
devcontainers (#25812)
- The httpmw upstream from this endpoint only checks for read perms to the
workspace agent. Recreating a dev container should require `update`
perms since it mutates state. This also matches the behavior of the
`DELETE` endpoint
---
coderd/workspaceagents.go | 5 ++++
coderd/workspaceagents_test.go | 45 ++++++++++++++++++++++++++++++++++
2 files changed, 50 insertions(+)
diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go
index 097d4ff41b7c7..074ca687b1e08 100644
--- a/coderd/workspaceagents.go
+++ b/coderd/workspaceagents.go
@@ -1096,6 +1096,11 @@ func (api *API) workspaceAgentRecreateDevcontainer(rw http.ResponseWriter, r *ht
ctx := r.Context()
waws := httpmw.WorkspaceAgentAndWorkspaceParam(r)
+ if !api.Authorize(r, policy.ActionUpdate, waws.WorkspaceTable) {
+ httpapi.Forbidden(rw)
+ return
+ }
+
devcontainer := chi.URLParam(r, "devcontainer")
if devcontainer == "" {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go
index 0fff131f5a4dc..9b36d11c275b0 100644
--- a/coderd/workspaceagents_test.go
+++ b/coderd/workspaceagents_test.go
@@ -1876,6 +1876,51 @@ func TestWorkspaceAgentRecreateDevcontainer(t *testing.T) {
})
}
+func TestWorkspaceAgentRecreateDevcontainerAuthorization(t *testing.T) {
+ t.Parallel()
+
+ for _, tc := range []struct {
+ name string
+ role func(uuid.UUID) rbac.RoleIdentifier
+ }{
+ {
+ name: "TemplateAdmin",
+ role: func(uuid.UUID) rbac.RoleIdentifier {
+ return rbac.RoleTemplateAdmin()
+ },
+ },
+ {
+ name: "OrgTemplateAdmin",
+ role: rbac.ScopedRoleOrgTemplateAdmin,
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ var (
+ ctx = testutil.Context(t, testutil.WaitMedium)
+ client, db = coderdtest.NewWithDatabase(t, nil)
+ admin = coderdtest.CreateFirstUser(t, client)
+ _, workspaceOwner = coderdtest.CreateAnotherUser(t, client, admin.OrganizationID)
+ templateAdminClient, _ = coderdtest.CreateAnotherUser(t, client, admin.OrganizationID, tc.role(admin.OrganizationID))
+ workspace = dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
+ OrganizationID: admin.OrganizationID,
+ OwnerID: workspaceOwner.ID,
+ }).WithAgent(func(agents []*proto.Agent) []*proto.Agent {
+ return agents
+ }).Do()
+ )
+
+ _, err := templateAdminClient.WorkspaceAgentRecreateDevcontainer(ctx, workspace.Agents[0].ID, uuid.NewString())
+ require.Error(t, err)
+
+ var sdkErr *codersdk.Error
+ require.ErrorAs(t, err, &sdkErr)
+ require.Equal(t, http.StatusForbidden, sdkErr.StatusCode())
+ })
+ }
+}
+
func TestWorkspaceAgentDeleteDevcontainer(t *testing.T) {
t.Parallel()
From 9d90b32d92c1c27208845fed605333101a30f49f Mon Sep 17 00:00:00 2001
From: George K
Date: Thu, 28 May 2026 14:02:23 -0700
Subject: [PATCH 087/249] fix(flake.nix): stop forcing musl biome binary in dev
shell (#25815)
The nix dev shell was forcing BIOME_BINARY to the musl package, but
pnpm install on glibc hosts only installs the glibc biome package by
default. That caused pnpm exec biome to fail in nix-shell even though
the default biome binary worked when BIOME_BINARY was unset.
---
flake.nix | 8 --------
1 file changed, 8 deletions(-)
diff --git a/flake.nix b/flake.nix
index d22dbcd72ce01..e47b07877744c 100644
--- a/flake.nix
+++ b/flake.nix
@@ -295,14 +295,6 @@
lib.optionalDrvAttr stdenv.isLinux "${glibcLocales}/lib/locale/locale-archive";
NODE_OPTIONS = "--max-old-space-size=8192";
- BIOME_BINARY =
- if pkgs.stdenv.isLinux then
- if pkgs.stdenv.hostPlatform.isAarch64 then
- "@biomejs/cli-linux-arm64-musl/biome"
- else
- "@biomejs/cli-linux-x64-musl/biome"
- else
- "";
GOPRIVATE = "coder.com,cdr.dev,go.coder.com,github.com/cdr,github.com/coder";
};
};
From dcb107684ea301b984c6948916a3f71e36b322a8 Mon Sep 17 00:00:00 2001
From: Nick Vigilante
Date: Thu, 28 May 2026 17:11:33 -0400
Subject: [PATCH 088/249] docs: fix stale redirect links in four docs pages
(#25738)
Four pages contained absolute `coder.com/docs` links that issued 308
redirects, creating unnecessary extra hops for readers. These were
identified via a SiteOne Crawler redirect-chain audit (DOCS-216).
| File | Old link | Final destination |
| -- | -- | -- |
| `admin/security/0001_user_apikeys_invalidation.md` |
`/docs/admin/audit-logs` | `/docs/admin/security/audit-logs` |
| `admin/templates/extending-templates/web-ides.md` |
`/docs/code-server/` (trailing slash) | `/docs/code-server` |
| `user-guides/workspace-access/index.md` | `/docs/code-server/latest` |
`/docs/code-server` |
| `install/cloud/azure-vm.md` | `/docs/coder-oss/latest/install` |
`/docs/install` |
Also quotes the `[install.sh]` bash associative array key in
`scripts/release/check_commit_metadata.sh` to fix a pre-existing shfmt
parse warning (shfmt misreads `.sh` inside unquoted `[...]` as a
floating-point expression).
---------
Co-authored-by: Claude Sonnet 4.6
---
docs/admin/templates/extending-templates/web-ides.md | 2 +-
docs/install/cloud/azure-vm.md | 2 +-
docs/user-guides/workspace-access/index.md | 2 +-
3 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/docs/admin/templates/extending-templates/web-ides.md b/docs/admin/templates/extending-templates/web-ides.md
index 4240dfe55205b..dae3fc593b6b2 100644
--- a/docs/admin/templates/extending-templates/web-ides.md
+++ b/docs/admin/templates/extending-templates/web-ides.md
@@ -55,7 +55,7 @@ resource "coder_agent" "main" {
For advanced use, we recommend installing code-server in your VM snapshot or
container image. Here's a Dockerfile which leverages some special
-[code-server features](https://coder.com/docs/code-server/):
+[code-server features](https://coder.com/docs/code-server):
```Dockerfile
FROM codercom/enterprise-base:ubuntu
diff --git a/docs/install/cloud/azure-vm.md b/docs/install/cloud/azure-vm.md
index 2ab41bc53a0b5..6cc21631056ba 100644
--- a/docs/install/cloud/azure-vm.md
+++ b/docs/install/cloud/azure-vm.md
@@ -56,7 +56,7 @@ as a system service.
For this instance, we will run Coder as a system service, however you can run
Coder a multitude of different ways. You can learn more about those
-[here](https://coder.com/docs/coder-oss/latest/install).
+[here](https://coder.com/docs/install).
In the Azure VM instance, run the following command to install Coder
diff --git a/docs/user-guides/workspace-access/index.md b/docs/user-guides/workspace-access/index.md
index da72459cbbd66..ee1bd9aa5c887 100644
--- a/docs/user-guides/workspace-access/index.md
+++ b/docs/user-guides/workspace-access/index.md
@@ -132,7 +132,7 @@ on connecting your JetBrains IDEs.
[code-server](https://github.com/coder/code-server) is our supported method of
running VS Code in the web browser.
Learn more about [what makes code-server different from VS Code web](./code-server.md) or visit the
-[documentation for code-server](https://coder.com/docs/code-server/latest).
+[documentation for code-server](https://coder.com/docs/code-server).

From a16de96611dede8a32263c39f4e09a8c676cec16 Mon Sep 17 00:00:00 2001
From: Spike Curtis
Date: Thu, 28 May 2026 17:38:09 -0400
Subject: [PATCH 089/249] chore: extract Expecter into its own package (#25806)
Relates to https://github.com/coder/internal/issues/1400
Extracts the code that matches command output from the code that sets up a PTY, so it can be used independently.
Subsequent PRs will actually refactor the tests to use this directly over an inmemory pipe.
---
pty/ptytest/ptytest.go | 474 +-----------------
testutil/expecter/expecter.go | 346 +++++++++++++
testutil/expecter/stdbuf.go | 119 +++++
.../expecter/stdbuf_internal_test.go | 2 +-
4 files changed, 488 insertions(+), 453 deletions(-)
create mode 100644 testutil/expecter/expecter.go
create mode 100644 testutil/expecter/stdbuf.go
rename pty/ptytest/ptytest_internal_test.go => testutil/expecter/stdbuf_internal_test.go (97%)
diff --git a/pty/ptytest/ptytest.go b/pty/ptytest/ptytest.go
index 7aaac5b2dcfae..43fff7c5d74eb 100644
--- a/pty/ptytest/ptytest.go
+++ b/pty/ptytest/ptytest.go
@@ -1,27 +1,14 @@
package ptytest
import (
- "bufio"
- "bytes"
- "context"
- "fmt"
- "io"
- "regexp"
"runtime"
- "slices"
- "strings"
"sync"
"testing"
- "time"
- "unicode/utf8"
- "github.com/acarl005/stripansi"
"github.com/stretchr/testify/require"
- "go.uber.org/atomic"
- "golang.org/x/xerrors"
"github.com/coder/coder/v2/pty"
- "github.com/coder/coder/v2/testutil"
+ "github.com/coder/coder/v2/testutil/expecter"
"github.com/coder/serpent"
)
@@ -31,10 +18,11 @@ func New(t *testing.T, opts ...pty.Option) *PTY {
ptty, err := newTestPTY(opts...)
require.NoError(t, err)
- e := newExpecter(t, ptty.Output(), "cmd")
+ e := expecter.New(t, ptty.Output(), "cmd")
r := &PTY{
- outExpecter: e,
- PTY: ptty,
+ t: t,
+ Expecter: *e,
+ PTY: ptty,
}
// Ensure pty is cleaned up at the end of test.
t.Cleanup(func() {
@@ -54,11 +42,12 @@ func Start(t *testing.T, cmd *pty.Cmd, opts ...pty.StartOption) (*PTYCmd, pty.Pr
_ = ps.Kill()
_ = ps.Wait()
})
- ex := newExpecter(t, ptty.OutputReader(), cmd.Args[0])
+ ex := expecter.New(t, ptty.OutputReader(), cmd.Args[0])
r := &PTYCmd{
- outExpecter: ex,
- PTYCmd: ptty,
+ Expecter: *ex,
+ PTYCmd: ptty,
+ t: t,
}
t.Cleanup(func() {
_ = r.Close()
@@ -66,322 +55,12 @@ func Start(t *testing.T, cmd *pty.Cmd, opts ...pty.StartOption) (*PTYCmd, pty.Pr
return r, ps
}
-func newExpecter(t *testing.T, r io.Reader, name string) outExpecter {
- // Use pipe for logging.
- logDone := make(chan struct{})
- logr, logw := io.Pipe()
-
- // Write to log and output buffer.
- copyDone := make(chan struct{})
- out := newStdbuf()
- w := io.MultiWriter(logw, out)
-
- ex := outExpecter{
- t: t,
- out: out,
- name: atomic.NewString(name),
-
- runeReader: bufio.NewReaderSize(out, utf8.UTFMax),
- }
-
- logClose := func(name string, c io.Closer) {
- ex.logf("closing %s", name)
- err := c.Close()
- ex.logf("closed %s: %v", name, err)
- }
- // Set the actual close function for the outExpecter.
- ex.close = func(reason string) error {
- ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
- defer cancel()
-
- ex.logf("closing expecter: %s", reason)
-
- // Caller needs to have closed the PTY so that copying can complete
- select {
- case <-ctx.Done():
- ex.fatalf("close", "copy did not close in time")
- case <-copyDone:
- }
-
- logClose("logw", logw)
- logClose("logr", logr)
- select {
- case <-ctx.Done():
- ex.fatalf("close", "log pipe did not close in time")
- case <-logDone:
- }
-
- ex.logf("closed expecter")
-
- return nil
- }
-
- go func() {
- defer close(copyDone)
- _, err := io.Copy(w, r)
- ex.logf("copy done: %v", err)
- ex.logf("closing out")
- err = out.closeErr(err)
- ex.logf("closed out: %v", err)
- }()
-
- // Log all output as part of test for easier debugging on errors.
- go func() {
- defer close(logDone)
- s := bufio.NewScanner(logr)
- for s.Scan() {
- ex.logf("%q", stripansi.Strip(s.Text()))
- }
- // Surface non-EOF scanner errors; otherwise they're invisible.
- if err := s.Err(); err != nil {
- ex.logf("log scanner stopped: %v", err)
- }
- }()
-
- return ex
-}
-
-type outExpecter struct {
- t *testing.T
- close func(reason string) error
- out *stdbuf
- name *atomic.String
-
- runeReader *bufio.Reader
-}
-
-// Deprecated: use ExpectMatchContext instead.
-// This uses a background context, so will not respect the test's context.
-func (e *outExpecter) ExpectMatch(str string) string {
- return e.expectMatchContextFunc(str, e.ExpectMatchContext)
-}
-
-func (e *outExpecter) ExpectRegexMatch(str string) string {
- return e.expectMatchContextFunc(str, e.ExpectRegexMatchContext)
-}
-
-func (e *outExpecter) expectMatchContextFunc(str string, fn func(ctx context.Context, str string) string) string {
- e.t.Helper()
-
- timeout, cancel := context.WithTimeout(context.Background(), testutil.WaitMedium)
- defer cancel()
-
- return fn(timeout, str)
-}
-
-// TODO(mafredri): Rename this to ExpectMatch when refactoring.
-func (e *outExpecter) ExpectMatchContext(ctx context.Context, str string) string {
- return e.expectMatcherFunc(ctx, str, strings.Contains)
-}
-
-func (e *outExpecter) ExpectRegexMatchContext(ctx context.Context, str string) string {
- return e.expectMatcherFunc(ctx, str, func(src, pattern string) bool {
- return regexp.MustCompile(pattern).MatchString(src)
- })
-}
-
-func (e *outExpecter) expectMatcherFunc(ctx context.Context, str string, fn func(src, pattern string) bool) string {
- e.t.Helper()
-
- var buffer bytes.Buffer
- err := e.doMatchWithDeadline(ctx, "ExpectMatchContext", func(rd *bufio.Reader) error {
- for {
- r, _, err := rd.ReadRune()
- if err != nil {
- return err
- }
- _, err = buffer.WriteRune(r)
- if err != nil {
- return err
- }
- if fn(buffer.String(), str) {
- return nil
- }
- }
- })
- if err != nil {
- e.fatalf("read error", "%v (wanted %q; got %q)", err, str, buffer.String())
- return ""
- }
- e.logf("matched %q = %q", str, buffer.String())
- return buffer.String()
-}
-
-// ExpectNoMatchBefore validates that `match` does not occur before `before`.
-func (e *outExpecter) ExpectNoMatchBefore(ctx context.Context, match, before string) string {
- e.t.Helper()
-
- var buffer bytes.Buffer
- err := e.doMatchWithDeadline(ctx, "ExpectNoMatchBefore", func(rd *bufio.Reader) error {
- for {
- r, _, err := rd.ReadRune()
- if err != nil {
- return err
- }
- _, err = buffer.WriteRune(r)
- if err != nil {
- return err
- }
-
- if strings.Contains(buffer.String(), match) {
- return xerrors.Errorf("found %q before %q", match, before)
- }
-
- if strings.Contains(buffer.String(), before) {
- return nil
- }
- }
- })
- if err != nil {
- e.fatalf("read error", "%v (wanted no %q before %q; got %q)", err, match, before, buffer.String())
- return ""
- }
- e.logf("matched %q = %q", before, stripansi.Strip(buffer.String()))
- return buffer.String()
-}
-
-func (e *outExpecter) Peek(ctx context.Context, n int) []byte {
- e.t.Helper()
-
- var out []byte
- err := e.doMatchWithDeadline(ctx, "Peek", func(rd *bufio.Reader) error {
- var err error
- out, err = rd.Peek(n)
- return err
- })
- if err != nil {
- e.fatalf("read error", "%v (wanted %d bytes; got %d: %q)", err, n, len(out), out)
- return nil
- }
- e.logf("peeked %d/%d bytes = %q", len(out), n, out)
- return slices.Clone(out)
-}
-
//nolint:govet // We don't care about conforming to ReadRune() (rune, int, error).
-func (e *outExpecter) ReadRune(ctx context.Context) rune {
- e.t.Helper()
-
- var r rune
- err := e.doMatchWithDeadline(ctx, "ReadRune", func(rd *bufio.Reader) error {
- var err error
- r, _, err = rd.ReadRune()
- return err
- })
- if err != nil {
- e.fatalf("read error", "%v (wanted rune; got %q)", err, r)
- return 0
- }
- e.logf("matched rune = %q", r)
- return r
-}
-
-func (e *outExpecter) ReadLine(ctx context.Context) string {
- e.t.Helper()
-
- var buffer bytes.Buffer
- err := e.doMatchWithDeadline(ctx, "ReadLine", func(rd *bufio.Reader) error {
- for {
- r, _, err := rd.ReadRune()
- if err != nil {
- return err
- }
- if r == '\n' {
- return nil
- }
- if r == '\r' {
- // Peek the next rune to see if it's an LF and then consume
- // it.
-
- // Unicode code points can be up to 4 bytes, but the
- // ones we're looking for are only 1 byte.
- b, _ := rd.Peek(1)
- if len(b) == 0 {
- return nil
- }
-
- r, _ = utf8.DecodeRune(b)
- if r == '\n' {
- _, _, err = rd.ReadRune()
- if err != nil {
- return err
- }
- }
-
- return nil
- }
-
- _, err = buffer.WriteRune(r)
- if err != nil {
- return err
- }
- }
- })
- if err != nil {
- e.fatalf("read error", "%v (wanted newline; got %q)", err, buffer.String())
- return ""
- }
- e.logf("matched newline = %q", buffer.String())
- return buffer.String()
-}
-
-func (e *outExpecter) ReadAll() []byte {
- e.t.Helper()
- return e.out.ReadAll()
-}
-
-func (e *outExpecter) doMatchWithDeadline(ctx context.Context, name string, fn func(*bufio.Reader) error) error {
- e.t.Helper()
-
- // A timeout is mandatory, caller can decide by passing a context
- // that times out.
- if _, ok := ctx.Deadline(); !ok {
- timeout := testutil.WaitMedium
- e.logf("%s ctx has no deadline, using %s", name, timeout)
- var cancel context.CancelFunc
- //nolint:gocritic // Rule guard doesn't detect that we're using testutil.Wait*.
- ctx, cancel = context.WithTimeout(ctx, timeout)
- defer cancel()
- }
-
- match := make(chan error, 1)
- go func() {
- defer close(match)
- match <- fn(e.runeReader)
- }()
- select {
- case err := <-match:
- return err
- case <-ctx.Done():
- // Ensure goroutine is cleaned up before test exit, do not call
- // (*outExpecter).close here to let the caller decide.
- _ = e.out.Close()
- <-match
-
- return xerrors.Errorf("match deadline exceeded: %w", ctx.Err())
- }
-}
-
-func (e *outExpecter) logf(format string, args ...interface{}) {
- e.t.Helper()
-
- // Match regular logger timestamp format, we seem to be logging in
- // UTC in other places as well, so match here.
- e.t.Logf("%s: %s: %s", time.Now().UTC().Format("2006-01-02 15:04:05.000"), e.name.Load(), fmt.Sprintf(format, args...))
-}
-
-func (e *outExpecter) fatalf(reason string, format string, args ...interface{}) {
- e.t.Helper()
-
- // Ensure the message is part of the normal log stream before
- // failing the test.
- e.logf("%s: %s", reason, fmt.Sprintf(format, args...))
-
- require.FailNowf(e.t, reason, format, args...)
-}
type PTY struct {
- outExpecter
+ expecter.Expecter
pty.PTY
+ t *testing.T
closeOnce sync.Once
closeErr error
}
@@ -391,11 +70,11 @@ func (p *PTY) Close() error {
p.closeOnce.Do(func() {
pErr := p.PTY.Close()
if pErr != nil {
- p.logf("PTY: Close failed: %v", pErr)
+ p.Logf("PTY: Close failed: %v", pErr)
}
- eErr := p.outExpecter.close("PTY close")
+ eErr := p.Expecter.Close("PTY close")
if eErr != nil {
- p.logf("PTY: close expecter failed: %v", eErr)
+ p.Logf("PTY: close expecter failed: %v", eErr)
}
if pErr != nil {
p.closeErr = pErr
@@ -418,7 +97,7 @@ func (p *PTY) Attach(inv *serpent.Invocation) *PTY {
func (p *PTY) Write(r rune) {
p.t.Helper()
- p.logf("stdin: %q", r)
+ p.Logf("stdin: %q", r)
_, err := p.Input().Write([]byte{byte(r)})
require.NoError(p.t, err, "write failed")
}
@@ -430,7 +109,7 @@ func (p *PTY) WriteLine(str string) {
if runtime.GOOS == "windows" {
newline = append(newline, '\n')
}
- p.logf("stdin: %q", str+string(newline))
+ p.Logf("stdin: %q", str+string(newline))
_, err := p.Input().Write(append([]byte(str), newline...))
require.NoError(p.t, err, "write line failed")
}
@@ -440,137 +119,28 @@ func (p *PTY) WriteLine(str string) {
//
// p := New(t).Named("myCmd")
func (p *PTY) Named(name string) *PTY {
- p.name.Store(name)
+ p.Rename(name)
return p
}
type PTYCmd struct {
- outExpecter
+ expecter.Expecter
pty.PTYCmd
+ t *testing.T
}
func (p *PTYCmd) Close() error {
p.t.Helper()
pErr := p.PTYCmd.Close()
if pErr != nil {
- p.logf("PTYCmd: Close failed: %v", pErr)
+ p.Logf("PTYCmd: Close failed: %v", pErr)
}
- eErr := p.outExpecter.close("PTYCmd close")
+ eErr := p.Expecter.Close("PTYCmd close")
if eErr != nil {
- p.logf("PTYCmd: close expecter failed: %v", eErr)
+ p.Logf("PTYCmd: close expecter failed: %v", eErr)
}
if pErr != nil {
return pErr
}
return eErr
}
-
-// stdbuf is like a buffered stdout, it buffers writes until read.
-type stdbuf struct {
- r io.Reader
-
- mu sync.Mutex // Protects following.
- b []byte
- more chan struct{}
- err error
-}
-
-func newStdbuf() *stdbuf {
- return &stdbuf{more: make(chan struct{}, 1)}
-}
-
-func (b *stdbuf) ReadAll() []byte {
- b.mu.Lock()
- defer b.mu.Unlock()
-
- if b.err != nil {
- return nil
- }
- p := append([]byte(nil), b.b...)
- b.b = b.b[len(b.b):]
- return p
-}
-
-func (b *stdbuf) Read(p []byte) (int, error) {
- if b.r == nil {
- return b.readOrWaitForMore(p)
- }
-
- n, err := b.r.Read(p)
- if xerrors.Is(err, io.EOF) {
- b.r = nil
- err = nil
- if n == 0 {
- return b.readOrWaitForMore(p)
- }
- }
- return n, err
-}
-
-func (b *stdbuf) readOrWaitForMore(p []byte) (int, error) {
- b.mu.Lock()
- defer b.mu.Unlock()
-
- // Deplete channel so that more check
- // is for future input into buffer.
- select {
- case <-b.more:
- default:
- }
-
- if len(b.b) == 0 {
- if b.err != nil {
- return 0, b.err
- }
-
- b.mu.Unlock()
- <-b.more
- b.mu.Lock()
- }
-
- b.r = bytes.NewReader(b.b)
- b.b = b.b[len(b.b):]
-
- return b.r.Read(p)
-}
-
-func (b *stdbuf) Write(p []byte) (int, error) {
- if len(p) == 0 {
- return 0, nil
- }
-
- b.mu.Lock()
- defer b.mu.Unlock()
-
- if b.err != nil {
- return 0, b.err
- }
-
- b.b = append(b.b, p...)
-
- select {
- case b.more <- struct{}{}:
- default:
- }
-
- return len(p), nil
-}
-
-func (b *stdbuf) Close() error {
- return b.closeErr(nil)
-}
-
-func (b *stdbuf) closeErr(err error) error {
- b.mu.Lock()
- defer b.mu.Unlock()
- if b.err != nil {
- return err
- }
- if err == nil {
- b.err = io.EOF
- } else {
- b.err = err
- }
- close(b.more)
- return err
-}
diff --git a/testutil/expecter/expecter.go b/testutil/expecter/expecter.go
new file mode 100644
index 0000000000000..5a370a9e64074
--- /dev/null
+++ b/testutil/expecter/expecter.go
@@ -0,0 +1,346 @@
+package expecter
+
+import (
+ "bufio"
+ "bytes"
+ "context"
+ "fmt"
+ "io"
+ "regexp"
+ "slices"
+ "strings"
+ "testing"
+ "time"
+ "unicode/utf8"
+
+ "github.com/acarl005/stripansi"
+ "github.com/stretchr/testify/require"
+ "go.uber.org/atomic"
+ "golang.org/x/xerrors"
+
+ "github.com/coder/coder/v2/testutil"
+)
+
+func New(t *testing.T, r io.Reader, name string) *Expecter {
+ // Use pipe for logging.
+ logDone := make(chan struct{})
+ logr, logw := io.Pipe()
+
+ // Write to log and output buffer.
+ copyDone := make(chan struct{})
+ out := newStdbuf()
+ w := io.MultiWriter(logw, out)
+
+ ex := &Expecter{
+ t: t,
+ out: out,
+ name: atomic.NewString(name),
+
+ runeReader: bufio.NewReaderSize(out, utf8.UTFMax),
+ logDone: logDone,
+ copyDone: copyDone,
+ logr: logr,
+ logw: logw,
+ }
+
+ go func() {
+ defer close(copyDone)
+ _, err := io.Copy(w, r)
+ ex.Logf("copy done: %v", err)
+ ex.Logf("closing out")
+ err = out.closeErr(err)
+ ex.Logf("closed out: %v", err)
+ }()
+
+ // Log all output as part of test for easier debugging on errors.
+ go func() {
+ defer close(logDone)
+ s := bufio.NewScanner(logr)
+ for s.Scan() {
+ ex.Logf("%q", stripansi.Strip(s.Text()))
+ }
+ // Surface non-EOF scanner errors; otherwise they're invisible.
+ if err := s.Err(); err != nil {
+ ex.Logf("log scanner stopped: %v", err)
+ }
+ }()
+
+ return ex
+}
+
+type Expecter struct {
+ t *testing.T
+ out *stdbuf
+ name *atomic.String
+
+ runeReader *bufio.Reader
+ copyDone, logDone chan struct{}
+ logr, logw io.Closer
+}
+
+// Rename the expecter. Make sure you set this before anything starts writing to the
+// stream, or it may not be named consistently.
+func (e *Expecter) Rename(name string) {
+ e.name.Store(name)
+}
+
+func (e *Expecter) Close(reason string) error {
+ ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
+ defer cancel()
+
+ e.Logf("closing expecter: %s", reason)
+
+ // Caller needs to have closed the stream so that copying can complete
+ select {
+ case <-ctx.Done():
+ e.fatalf("close", "copy did not close in time")
+ case <-e.copyDone:
+ }
+
+ e.logClose("logw", e.logw)
+ e.logClose("logr", e.logr)
+ select {
+ case <-ctx.Done():
+ e.fatalf("close", "log pipe did not close in time")
+ case <-e.logDone:
+ }
+
+ e.Logf("closed expecter")
+
+ return nil
+}
+
+func (e *Expecter) logClose(name string, c io.Closer) {
+ e.Logf("closing %s", name)
+ err := c.Close()
+ e.Logf("closed %s: %v", name, err)
+}
+
+// Deprecated: use ExpectMatchContext instead.
+// This uses a background context, so will not respect the test's context.
+func (e *Expecter) ExpectMatch(str string) string {
+ return e.expectMatchContextFunc(str, e.ExpectMatchContext)
+}
+
+func (e *Expecter) ExpectRegexMatch(str string) string {
+ return e.expectMatchContextFunc(str, e.ExpectRegexMatchContext)
+}
+
+func (e *Expecter) expectMatchContextFunc(str string, fn func(ctx context.Context, str string) string) string {
+ e.t.Helper()
+
+ timeout, cancel := context.WithTimeout(context.Background(), testutil.WaitMedium)
+ defer cancel()
+
+ return fn(timeout, str)
+}
+
+// TODO(mafredri): Rename this to ExpectMatch when refactoring.
+func (e *Expecter) ExpectMatchContext(ctx context.Context, str string) string {
+ return e.expectMatcherFunc(ctx, str, strings.Contains)
+}
+
+func (e *Expecter) ExpectRegexMatchContext(ctx context.Context, str string) string {
+ return e.expectMatcherFunc(ctx, str, func(src, pattern string) bool {
+ return regexp.MustCompile(pattern).MatchString(src)
+ })
+}
+
+func (e *Expecter) expectMatcherFunc(ctx context.Context, str string, fn func(src, pattern string) bool) string {
+ e.t.Helper()
+
+ var buffer bytes.Buffer
+ err := e.doMatchWithDeadline(ctx, "ExpectMatchContext", func(rd *bufio.Reader) error {
+ for {
+ r, _, err := rd.ReadRune()
+ if err != nil {
+ return err
+ }
+ _, err = buffer.WriteRune(r)
+ if err != nil {
+ return err
+ }
+ if fn(buffer.String(), str) {
+ return nil
+ }
+ }
+ })
+ if err != nil {
+ e.fatalf("read error", "%v (wanted %q; got %q)", err, str, buffer.String())
+ return ""
+ }
+ e.Logf("matched %q = %q", str, buffer.String())
+ return buffer.String()
+}
+
+// ExpectNoMatchBefore validates that `match` does not occur before `before`.
+func (e *Expecter) ExpectNoMatchBefore(ctx context.Context, match, before string) string {
+ e.t.Helper()
+
+ var buffer bytes.Buffer
+ err := e.doMatchWithDeadline(ctx, "ExpectNoMatchBefore", func(rd *bufio.Reader) error {
+ for {
+ r, _, err := rd.ReadRune()
+ if err != nil {
+ return err
+ }
+ _, err = buffer.WriteRune(r)
+ if err != nil {
+ return err
+ }
+
+ if strings.Contains(buffer.String(), match) {
+ return xerrors.Errorf("found %q before %q", match, before)
+ }
+
+ if strings.Contains(buffer.String(), before) {
+ return nil
+ }
+ }
+ })
+ if err != nil {
+ e.fatalf("read error", "%v (wanted no %q before %q; got %q)", err, match, before, buffer.String())
+ return ""
+ }
+ e.Logf("matched %q = %q", before, stripansi.Strip(buffer.String()))
+ return buffer.String()
+}
+
+func (e *Expecter) Peek(ctx context.Context, n int) []byte {
+ e.t.Helper()
+
+ var out []byte
+ err := e.doMatchWithDeadline(ctx, "Peek", func(rd *bufio.Reader) error {
+ var err error
+ out, err = rd.Peek(n)
+ return err
+ })
+ if err != nil {
+ e.fatalf("read error", "%v (wanted %d bytes; got %d: %q)", err, n, len(out), out)
+ return nil
+ }
+ e.Logf("peeked %d/%d bytes = %q", len(out), n, out)
+ return slices.Clone(out)
+}
+
+//nolint:govet // We don't care about conforming to ReadRune() (rune, int, error).
+func (e *Expecter) ReadRune(ctx context.Context) rune {
+ e.t.Helper()
+
+ var r rune
+ err := e.doMatchWithDeadline(ctx, "ReadRune", func(rd *bufio.Reader) error {
+ var err error
+ r, _, err = rd.ReadRune()
+ return err
+ })
+ if err != nil {
+ e.fatalf("read error", "%v (wanted rune; got %q)", err, r)
+ return 0
+ }
+ e.Logf("matched rune = %q", r)
+ return r
+}
+
+func (e *Expecter) ReadLine(ctx context.Context) string {
+ e.t.Helper()
+
+ var buffer bytes.Buffer
+ err := e.doMatchWithDeadline(ctx, "ReadLine", func(rd *bufio.Reader) error {
+ for {
+ r, _, err := rd.ReadRune()
+ if err != nil {
+ return err
+ }
+ if r == '\n' {
+ return nil
+ }
+ if r == '\r' {
+ // Peek the next rune to see if it's an LF and then consume
+ // it.
+
+ // Unicode code points can be up to 4 bytes, but the
+ // ones we're looking for are only 1 byte.
+ b, _ := rd.Peek(1)
+ if len(b) == 0 {
+ return nil
+ }
+
+ r, _ = utf8.DecodeRune(b)
+ if r == '\n' {
+ _, _, err = rd.ReadRune()
+ if err != nil {
+ return err
+ }
+ }
+
+ return nil
+ }
+
+ _, err = buffer.WriteRune(r)
+ if err != nil {
+ return err
+ }
+ }
+ })
+ if err != nil {
+ e.fatalf("read error", "%v (wanted newline; got %q)", err, buffer.String())
+ return ""
+ }
+ e.Logf("matched newline = %q", buffer.String())
+ return buffer.String()
+}
+
+func (e *Expecter) ReadAll() []byte {
+ e.t.Helper()
+ return e.out.ReadAll()
+}
+
+func (e *Expecter) doMatchWithDeadline(ctx context.Context, name string, fn func(*bufio.Reader) error) error {
+ e.t.Helper()
+
+ // A timeout is mandatory, caller can decide by passing a context
+ // that times out.
+ if _, ok := ctx.Deadline(); !ok {
+ timeout := testutil.WaitMedium
+ e.Logf("%s ctx has no deadline, using %s", name, timeout)
+ var cancel context.CancelFunc
+ //nolint:gocritic // Rule guard doesn't detect that we're using testutil.Wait*.
+ ctx, cancel = context.WithTimeout(ctx, timeout)
+ defer cancel()
+ }
+
+ match := make(chan error, 1)
+ go func() {
+ defer close(match)
+ match <- fn(e.runeReader)
+ }()
+ select {
+ case err := <-match:
+ return err
+ case <-ctx.Done():
+ // Ensure goroutine is cleaned up before test exit, do not call
+ // (*outExpecter).close here to let the caller decide.
+ _ = e.out.Close()
+ <-match
+
+ return xerrors.Errorf("match deadline exceeded: %w", ctx.Err())
+ }
+}
+
+func (e *Expecter) Logf(format string, args ...interface{}) {
+ e.t.Helper()
+
+ // Match regular logger timestamp format, we seem to be logging in
+ // UTC in other places as well, so match here.
+ e.t.Logf("%s: %s: %s", time.Now().UTC().Format("2006-01-02 15:04:05.000"), e.name.Load(), fmt.Sprintf(format, args...))
+}
+
+func (e *Expecter) fatalf(reason string, format string, args ...interface{}) {
+ e.t.Helper()
+
+ // Ensure the message is part of the normal log stream before
+ // failing the test.
+ e.Logf("%s: %s", reason, fmt.Sprintf(format, args...))
+
+ require.FailNowf(e.t, reason, format, args...)
+}
diff --git a/testutil/expecter/stdbuf.go b/testutil/expecter/stdbuf.go
new file mode 100644
index 0000000000000..092f401d1eb22
--- /dev/null
+++ b/testutil/expecter/stdbuf.go
@@ -0,0 +1,119 @@
+package expecter
+
+import (
+ "bytes"
+ "io"
+ "sync"
+
+ "golang.org/x/xerrors"
+)
+
+// stdbuf is like a buffered stdout, it buffers writes until read.
+type stdbuf struct {
+ r io.Reader
+
+ mu sync.Mutex // Protects following.
+ b []byte
+ more chan struct{}
+ err error
+}
+
+func newStdbuf() *stdbuf {
+ return &stdbuf{more: make(chan struct{}, 1)}
+}
+
+func (b *stdbuf) ReadAll() []byte {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+
+ if b.err != nil {
+ return nil
+ }
+ p := append([]byte(nil), b.b...)
+ b.b = b.b[len(b.b):]
+ return p
+}
+
+func (b *stdbuf) Read(p []byte) (int, error) {
+ if b.r == nil {
+ return b.readOrWaitForMore(p)
+ }
+
+ n, err := b.r.Read(p)
+ if xerrors.Is(err, io.EOF) {
+ b.r = nil
+ err = nil
+ if n == 0 {
+ return b.readOrWaitForMore(p)
+ }
+ }
+ return n, err
+}
+
+func (b *stdbuf) readOrWaitForMore(p []byte) (int, error) {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+
+ // Deplete channel so that more check
+ // is for future input into buffer.
+ select {
+ case <-b.more:
+ default:
+ }
+
+ if len(b.b) == 0 {
+ if b.err != nil {
+ return 0, b.err
+ }
+
+ b.mu.Unlock()
+ <-b.more
+ b.mu.Lock()
+ }
+
+ b.r = bytes.NewReader(b.b)
+ b.b = b.b[len(b.b):]
+
+ return b.r.Read(p)
+}
+
+func (b *stdbuf) Write(p []byte) (int, error) {
+ if len(p) == 0 {
+ return 0, nil
+ }
+
+ b.mu.Lock()
+ defer b.mu.Unlock()
+
+ if b.err != nil {
+ return 0, b.err
+ }
+
+ b.b = append(b.b, p...)
+
+ select {
+ case b.more <- struct{}{}:
+ default:
+ }
+
+ return len(p), nil
+}
+
+func (b *stdbuf) Close() error {
+ return b.closeErr(nil)
+}
+
+func (b *stdbuf) closeErr(err error) error {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ if b.err != nil {
+ return err
+ }
+ if err == nil {
+ b.err = io.EOF
+ } else {
+ b.err = err
+ }
+ close(b.more)
+ return err
+}
diff --git a/pty/ptytest/ptytest_internal_test.go b/testutil/expecter/stdbuf_internal_test.go
similarity index 97%
rename from pty/ptytest/ptytest_internal_test.go
rename to testutil/expecter/stdbuf_internal_test.go
index 29154178636f6..02365a8ff63e8 100644
--- a/pty/ptytest/ptytest_internal_test.go
+++ b/testutil/expecter/stdbuf_internal_test.go
@@ -1,4 +1,4 @@
-package ptytest
+package expecter
import (
"bytes"
From ee4126e913402ca1d450d82f1f35396b83691a2e Mon Sep 17 00:00:00 2001
From: Spike Curtis
Date: Thu, 28 May 2026 17:50:37 -0400
Subject: [PATCH 090/249] test: refactor CLI create tests not to use PTY
(#25807)
Part of https://github.com/coder/internal/issues/1400
Refactors CLI tests of the `create` command as the first batch of tests refactored to take a PTY out of the loop.
One interesting difference I noticed between PTY and a direct pipe to standard in is that on the PTY we write `\r` to enter some input, but the kernel actually sends `\n` (or maybe `\r\n`) to the process, at least on Unix. (On windows we sent `\r\n` into the PTY). This is reflected in the implementation of the `Writer` , otherwise mostly inspired by the PTYTest equivalents.
---
cli/create_test.go | 359 +++++++++++++++++-----------------
pty/ptytest/ptytest.go | 17 +-
testutil/expecter/expecter.go | 23 ++-
testutil/writer.go | 55 ++++++
4 files changed, 262 insertions(+), 192 deletions(-)
create mode 100644 testutil/writer.go
diff --git a/cli/create_test.go b/cli/create_test.go
index 670f7857911d0..043148d178d87 100644
--- a/cli/create_test.go
+++ b/cli/create_test.go
@@ -20,8 +20,8 @@ import (
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/provisioner/echo"
"github.com/coder/coder/v2/provisionersdk/proto"
- "github.com/coder/coder/v2/pty/ptytest"
"github.com/coder/coder/v2/testutil"
+ "github.com/coder/coder/v2/testutil/expecter"
)
func TestCreateDynamic(t *testing.T) {
@@ -74,14 +74,14 @@ func TestCreateDynamic(t *testing.T) {
}
inv, root := clitest.New(t, args...)
clitest.SetupConfig(t, member, root)
- pty := ptytest.New(t).Attach(inv)
+ stdout := expecter.NewAttachedToInvocation(t, inv)
doneChan := make(chan error)
go func() {
doneChan <- inv.Run()
}()
- pty.ExpectMatchContext(ctx, "has been created")
+ stdout.ExpectMatchContext(ctx, "has been created")
err := testutil.RequireReceive(ctx, t, doneChan)
require.NoError(t, err)
@@ -103,14 +103,14 @@ func TestCreateDynamic(t *testing.T) {
}
inv, root = clitest.New(t, args...)
clitest.SetupConfig(t, member, root)
- pty = ptytest.New(t).Attach(inv)
+ stdout = expecter.NewAttachedToInvocation(t, inv)
doneChan = make(chan error)
go func() {
doneChan <- inv.Run()
}()
- pty.ExpectMatchContext(ctx, "has been created")
+ stdout.ExpectMatchContext(ctx, "has been created")
err = testutil.RequireReceive(ctx, t, doneChan)
require.NoError(t, err)
@@ -129,7 +129,8 @@ func TestCreateDynamic(t *testing.T) {
// When enable_region=true, the region parameter becomes required and CLI should prompt.
t.Run("PromptForConditionalParam", func(t *testing.T) {
t.Parallel()
- ctx := testutil.Context(t, testutil.WaitLong)
+ ctx := testutil.Context(t, time.Hour)
+ logger := testutil.Logger(t)
template, _ := coderdtest.DynamicParameterTemplate(t, owner, first.OrganizationID, coderdtest.DynamicParameterTemplateParams{
MainTF: conditionalParamTF,
@@ -143,7 +144,8 @@ func TestCreateDynamic(t *testing.T) {
}
inv, root := clitest.New(t, args...)
clitest.SetupConfig(t, member, root)
- pty := ptytest.New(t).Attach(inv)
+ stdout := expecter.NewAttachedToInvocation(t, inv)
+ stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
doneChan := make(chan error)
go func() {
@@ -151,14 +153,14 @@ func TestCreateDynamic(t *testing.T) {
}()
// CLI should prompt for the region parameter since enable_region=true
- pty.ExpectMatchContext(ctx, "region")
- pty.WriteLine("eu-west")
+ stdout.ExpectMatchContext(ctx, "region")
+ stdin.WriteLine("eu-west")
// Confirm creation
- pty.ExpectMatchContext(ctx, "Confirm create?")
- pty.WriteLine("yes")
+ stdout.ExpectMatchContext(ctx, "Confirm create?")
+ stdin.WriteLine("yes")
- pty.ExpectMatchContext(ctx, "has been created")
+ stdout.ExpectMatchContext(ctx, "has been created")
err := <-doneChan
require.NoError(t, err)
@@ -305,14 +307,14 @@ func TestCreateDynamic(t *testing.T) {
"-y",
)
clitest.SetupConfig(t, member, root)
- pty := ptytest.New(t).Attach(inv)
+ stdout := expecter.NewAttachedToInvocation(t, inv)
doneChan := make(chan error)
go func() {
doneChan <- inv.Run()
}()
- pty.ExpectMatchContext(ctx, "has been created")
+ stdout.ExpectMatchContext(ctx, "has been created")
err = <-doneChan
require.NoError(t, err, "slider=8 should succeed when max_slider=10")
@@ -331,6 +333,8 @@ func TestCreate(t *testing.T) {
t.Parallel()
t.Run("Create", func(t *testing.T) {
t.Parallel()
+ logger := testutil.Logger(t)
+ ctx := testutil.Context(t, testutil.WaitMedium)
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
@@ -348,7 +352,8 @@ func TestCreate(t *testing.T) {
inv, root := clitest.New(t, args...)
clitest.SetupConfig(t, member, root)
doneChan := make(chan struct{})
- pty := ptytest.New(t).Attach(inv)
+ stdout := expecter.NewAttachedToInvocation(t, inv)
+ stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
go func() {
defer close(doneChan)
err := inv.Run()
@@ -363,9 +368,9 @@ func TestCreate(t *testing.T) {
{match: "Confirm create", write: "yes"},
}
for _, m := range matches {
- pty.ExpectMatch(m.match)
+ stdout.ExpectMatchContext(ctx, m.match)
if len(m.write) > 0 {
- pty.WriteLine(m.write)
+ stdin.WriteLine(m.write)
}
}
<-doneChan
@@ -385,6 +390,8 @@ func TestCreate(t *testing.T) {
t.Run("CreateForOtherUser", func(t *testing.T) {
t.Parallel()
+ logger := testutil.Logger(t)
+ ctx := testutil.Context(t, testutil.WaitMedium)
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, completeWithAgent())
@@ -403,7 +410,8 @@ func TestCreate(t *testing.T) {
//nolint:gocritic // Creating a workspace for another user requires owner permissions.
clitest.SetupConfig(t, client, root)
doneChan := make(chan struct{})
- pty := ptytest.New(t).Attach(inv)
+ stdout := expecter.NewAttachedToInvocation(t, inv)
+ stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
go func() {
defer close(doneChan)
err := inv.Run()
@@ -418,9 +426,9 @@ func TestCreate(t *testing.T) {
{match: "Confirm create", write: "yes"},
}
for _, m := range matches {
- pty.ExpectMatch(m.match)
+ stdout.ExpectMatchContext(ctx, m.match)
if len(m.write) > 0 {
- pty.WriteLine(m.write)
+ stdin.WriteLine(m.write)
}
}
<-doneChan
@@ -439,6 +447,8 @@ func TestCreate(t *testing.T) {
t.Run("CreateWithSpecificTemplateVersion", func(t *testing.T) {
t.Parallel()
+ logger := testutil.Logger(t)
+ ctx := testutil.Context(t, testutil.WaitMedium)
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
@@ -467,7 +477,8 @@ func TestCreate(t *testing.T) {
inv, root := clitest.New(t, args...)
clitest.SetupConfig(t, member, root)
doneChan := make(chan struct{})
- pty := ptytest.New(t).Attach(inv)
+ stdout := expecter.NewAttachedToInvocation(t, inv)
+ stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
go func() {
defer close(doneChan)
err := inv.Run()
@@ -482,9 +493,9 @@ func TestCreate(t *testing.T) {
{match: "Confirm create", write: "yes"},
}
for _, m := range matches {
- pty.ExpectMatch(m.match)
+ stdout.ExpectMatchContext(ctx, m.match)
if len(m.write) > 0 {
- pty.WriteLine(m.write)
+ stdin.WriteLine(m.write)
}
}
<-doneChan
@@ -506,6 +517,8 @@ func TestCreate(t *testing.T) {
t.Run("InheritStopAfterFromTemplate", func(t *testing.T) {
t.Parallel()
+ logger := testutil.Logger(t)
+ ctx := testutil.Context(t, testutil.WaitMedium)
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
@@ -522,7 +535,8 @@ func TestCreate(t *testing.T) {
}
inv, root := clitest.New(t, args...)
clitest.SetupConfig(t, member, root)
- pty := ptytest.New(t).Attach(inv)
+ stdout := expecter.NewAttachedToInvocation(t, inv)
+ stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
waiter := clitest.StartWithWaiter(t, inv)
matches := []struct {
match string
@@ -533,9 +547,9 @@ func TestCreate(t *testing.T) {
{match: "Confirm create", write: "yes"},
}
for _, m := range matches {
- pty.ExpectMatch(m.match)
+ stdout.ExpectMatchContext(ctx, m.match)
if len(m.write) > 0 {
- pty.WriteLine(m.write)
+ stdin.WriteLine(m.write)
}
}
waiter.RequireSuccess()
@@ -570,6 +584,8 @@ func TestCreate(t *testing.T) {
t.Run("FromNothing", func(t *testing.T) {
t.Parallel()
+ logger := testutil.Logger(t)
+ ctx := testutil.Context(t, testutil.WaitMedium)
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
@@ -579,7 +595,8 @@ func TestCreate(t *testing.T) {
inv, root := clitest.New(t, "create", "")
clitest.SetupConfig(t, member, root)
doneChan := make(chan struct{})
- pty := ptytest.New(t).Attach(inv)
+ stdout := expecter.NewAttachedToInvocation(t, inv)
+ stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
go func() {
defer close(doneChan)
err := inv.Run()
@@ -592,8 +609,8 @@ func TestCreate(t *testing.T) {
for i := 0; i < len(matches); i += 2 {
match := matches[i]
value := matches[i+1]
- pty.ExpectMatch(match)
- pty.WriteLine(value)
+ stdout.ExpectMatchContext(ctx, match)
+ stdin.WriteLine(value)
}
<-doneChan
@@ -621,14 +638,14 @@ func TestCreate(t *testing.T) {
)
clitest.SetupConfig(t, member, root)
doneChan := make(chan struct{})
- pty := ptytest.New(t).Attach(inv)
+ stdout := expecter.NewAttachedToInvocation(t, inv)
go func() {
defer close(doneChan)
err := inv.Run()
assert.NoError(t, err)
}()
- pty.ExpectMatchContext(ctx, "building in the background")
+ stdout.ExpectMatchContext(ctx, "building in the background")
_ = testutil.TryReceive(ctx, t, doneChan)
// Verify workspace was actually created.
@@ -658,14 +675,14 @@ func TestCreate(t *testing.T) {
)
clitest.SetupConfig(t, member, root)
doneChan := make(chan struct{})
- pty := ptytest.New(t).Attach(inv)
+ stdout := expecter.NewAttachedToInvocation(t, inv)
go func() {
defer close(doneChan)
err := inv.Run()
assert.NoError(t, err)
}()
- pty.ExpectMatchContext(ctx, "building in the background")
+ stdout.ExpectMatchContext(ctx, "building in the background")
_ = testutil.TryReceive(ctx, t, doneChan)
// Verify workspace was created and parameters were applied.
@@ -706,14 +723,14 @@ func TestCreate(t *testing.T) {
)
clitest.SetupConfig(t, member, root)
doneChan := make(chan struct{})
- pty := ptytest.New(t).Attach(inv)
+ stdout := expecter.NewAttachedToInvocation(t, inv)
go func() {
defer close(doneChan)
err := inv.Run()
assert.NoError(t, err)
}()
- pty.ExpectMatchContext(ctx, "building in the background")
+ stdout.ExpectMatchContext(ctx, "building in the background")
_ = testutil.TryReceive(ctx, t, doneChan)
ws, err := member.WorkspaceByOwnerAndName(ctx, codersdk.Me, "my-workspace", codersdk.WorkspaceOptions{})
@@ -801,7 +818,7 @@ func TestCreateWithRichParameters(t *testing.T) {
setup func() []string
// handlePty optionally runs after the command is started. It should handle
// all expected prompts from the pty.
- handlePty func(pty *ptytest.PTY)
+ handlePty func(ctx context.Context, stdout *expecter.Expecter, stdin *testutil.Writer)
// postRun runs after the command has finished but before the workspace is
// verified. It must return the workspace name to check (used for the copy
// workspace tests).
@@ -818,15 +835,15 @@ func TestCreateWithRichParameters(t *testing.T) {
}{
{
name: "ValuesFromPrompt",
- handlePty: func(pty *ptytest.PTY) {
+ handlePty: func(ctx context.Context, stdout *expecter.Expecter, stdin *testutil.Writer) {
// Enter the value for each parameter as prompted.
for _, param := range params {
- pty.ExpectMatch(param.name)
- pty.WriteLine(param.value)
+ stdout.ExpectMatchContext(ctx, param.name)
+ stdin.WriteLine(param.value)
}
// Confirm the creation.
- pty.ExpectMatch("Confirm create?")
- pty.WriteLine("yes")
+ stdout.ExpectMatchContext(ctx, "Confirm create?")
+ stdin.WriteLine("yes")
},
},
{
@@ -839,16 +856,16 @@ func TestCreateWithRichParameters(t *testing.T) {
}
return args
},
- handlePty: func(pty *ptytest.PTY) {
+ handlePty: func(ctx context.Context, stdout *expecter.Expecter, stdin *testutil.Writer) {
// Simply accept the defaults.
for _, param := range params {
- pty.ExpectMatch(param.name)
- pty.ExpectMatch(`Enter a value (default: "` + param.value + `")`)
- pty.WriteLine("")
+ stdout.ExpectMatchContext(ctx, param.name)
+ stdout.ExpectMatchContext(ctx, `Enter a value (default: "`+param.value+`")`)
+ stdin.WriteLine("")
}
// Confirm the creation.
- pty.ExpectMatch("Confirm create?")
- pty.WriteLine("yes")
+ stdout.ExpectMatchContext(ctx, "Confirm create?")
+ stdin.WriteLine("yes")
},
},
{
@@ -865,10 +882,10 @@ func TestCreateWithRichParameters(t *testing.T) {
return []string{"--rich-parameter-file", parameterFile.Name()}
},
- handlePty: func(pty *ptytest.PTY) {
+ handlePty: func(ctx context.Context, stdout *expecter.Expecter, stdin *testutil.Writer) {
// No prompts, we only need to confirm.
- pty.ExpectMatch("Confirm create?")
- pty.WriteLine("yes")
+ stdout.ExpectMatchContext(ctx, "Confirm create?")
+ stdin.WriteLine("yes")
},
},
{
@@ -881,10 +898,10 @@ func TestCreateWithRichParameters(t *testing.T) {
}
return args
},
- handlePty: func(pty *ptytest.PTY) {
+ handlePty: func(ctx context.Context, stdout *expecter.Expecter, stdin *testutil.Writer) {
// No prompts, we only need to confirm.
- pty.ExpectMatch("Confirm create?")
- pty.WriteLine("yes")
+ stdout.ExpectMatchContext(ctx, "Confirm create?")
+ stdin.WriteLine("yes")
},
},
{
@@ -920,9 +937,6 @@ func TestCreateWithRichParameters(t *testing.T) {
postRun: func(t *testing.T, tctx testContext) string {
inv, root := clitest.New(t, "create", "--copy-parameters-from", tctx.workspaceName, "other-workspace", "-y")
clitest.SetupConfig(t, tctx.member, root)
- pty := ptytest.New(t).Attach(inv)
- inv.Stdout = pty.Output()
- inv.Stderr = pty.Output()
err := inv.Run()
require.NoError(t, err, "failed to create a workspace based on the source workspace")
return "other-workspace"
@@ -952,9 +966,6 @@ func TestCreateWithRichParameters(t *testing.T) {
// Then create the copy. It should use the old template version.
inv, root := clitest.New(t, "create", "--copy-parameters-from", tctx.workspaceName, "other-workspace", "-y")
clitest.SetupConfig(t, tctx.member, root)
- pty := ptytest.New(t).Attach(inv)
- inv.Stdout = pty.Output()
- inv.Stderr = pty.Output()
err := inv.Run()
require.NoError(t, err, "failed to create a workspace based on the source workspace")
return "other-workspace"
@@ -962,16 +973,16 @@ func TestCreateWithRichParameters(t *testing.T) {
},
{
name: "ValuesFromTemplateDefaults",
- handlePty: func(pty *ptytest.PTY) {
+ handlePty: func(ctx context.Context, stdout *expecter.Expecter, stdin *testutil.Writer) {
// Simply accept the defaults.
for _, param := range params {
- pty.ExpectMatch(param.name)
- pty.ExpectMatch(`Enter a value (default: "` + param.value + `")`)
- pty.WriteLine("")
+ stdout.ExpectMatchContext(ctx, param.name)
+ stdout.ExpectMatchContext(ctx, `Enter a value (default: "`+param.value+`")`)
+ stdin.WriteLine("")
}
// Confirm the creation.
- pty.ExpectMatch("Confirm create?")
- pty.WriteLine("yes")
+ stdout.ExpectMatchContext(ctx, "Confirm create?")
+ stdin.WriteLine("yes")
},
withDefaults: true,
},
@@ -980,14 +991,14 @@ func TestCreateWithRichParameters(t *testing.T) {
setup: func() []string {
return []string{"--use-parameter-defaults"}
},
- handlePty: func(pty *ptytest.PTY) {
+ handlePty: func(ctx context.Context, stdout *expecter.Expecter, stdin *testutil.Writer) {
// Default values should get printed.
for _, param := range params {
- pty.ExpectMatch(fmt.Sprintf("%s: '%s'", param.name, param.value))
+ stdout.ExpectMatchContext(ctx, fmt.Sprintf("%s: '%s'", param.name, param.value))
}
// No prompts, we only need to confirm.
- pty.ExpectMatch("Confirm create?")
- pty.WriteLine("yes")
+ stdout.ExpectMatchContext(ctx, "Confirm create?")
+ stdin.WriteLine("yes")
},
withDefaults: true,
},
@@ -1001,14 +1012,14 @@ func TestCreateWithRichParameters(t *testing.T) {
}
return args
},
- handlePty: func(pty *ptytest.PTY) {
+ handlePty: func(ctx context.Context, stdout *expecter.Expecter, stdin *testutil.Writer) {
// Default values should get printed.
for _, param := range params {
- pty.ExpectMatch(fmt.Sprintf("%s: '%s'", param.name, param.value))
+ stdout.ExpectMatchContext(ctx, fmt.Sprintf("%s: '%s'", param.name, param.value))
}
// No prompts, we only need to confirm.
- pty.ExpectMatch("Confirm create?")
- pty.WriteLine("yes")
+ stdout.ExpectMatchContext(ctx, "Confirm create?")
+ stdin.WriteLine("yes")
},
},
{
@@ -1031,14 +1042,14 @@ cli_param: from file`)
"--parameter", "cli_param=from cli",
}
},
- handlePty: func(pty *ptytest.PTY) {
+ handlePty: func(ctx context.Context, stdout *expecter.Expecter, stdin *testutil.Writer) {
// Should get prompted for the input param since it has no default.
- pty.ExpectMatch("input_param")
- pty.WriteLine("from input")
+ stdout.ExpectMatchContext(ctx, "input_param")
+ stdin.WriteLine("from input")
// Confirm the creation.
- pty.ExpectMatch("Confirm create?")
- pty.WriteLine("yes")
+ stdout.ExpectMatchContext(ctx, "Confirm create?")
+ stdin.WriteLine("yes")
},
withDefaults: true,
inputParameters: []param{
@@ -1082,6 +1093,8 @@ cli_param: from file`)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
+ logger := testutil.Logger(t)
+ ctx := testutil.Context(t, testutil.WaitMedium)
parameters := params
if len(tt.inputParameters) > 0 {
@@ -1122,14 +1135,15 @@ cli_param: from file`)
inv, root := clitest.New(t, args...)
clitest.SetupConfig(t, member, root)
doneChan := make(chan error)
- pty := ptytest.New(t).Attach(inv)
+ stdout := expecter.NewAttachedToInvocation(t, inv)
+ stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
go func() {
doneChan <- inv.Run()
}()
// The test may do something with the pty.
if tt.handlePty != nil {
- tt.handlePty(pty)
+ tt.handlePty(ctx, stdout, stdin)
}
// Wait for the command to exit.
@@ -1235,6 +1249,7 @@ func TestCreateWithPreset(t *testing.T) {
// the CLI uses the specified preset instead of the default
t.Run("PresetFlag", func(t *testing.T) {
t.Parallel()
+ ctx := testutil.Context(t, testutil.WaitMedium)
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
@@ -1263,17 +1278,15 @@ func TestCreateWithPreset(t *testing.T) {
workspaceName := "my-workspace"
inv, root := clitest.New(t, "create", workspaceName, "--template", template.Name, "-y", "--preset", preset.Name)
clitest.SetupConfig(t, member, root)
- pty := ptytest.New(t).Attach(inv)
- inv.Stdout = pty.Output()
- inv.Stderr = pty.Output()
+ stdout := expecter.NewAttachedToInvocation(t, inv)
err := inv.Run()
require.NoError(t, err)
// Should: display the selected preset as well as its parameters
presetName := fmt.Sprintf("Preset '%s' applied:", preset.Name)
- pty.ExpectMatch(presetName)
- pty.ExpectMatch(fmt.Sprintf("%s: '%s'", firstParameterName, secondOptionalParameterValue))
- pty.ExpectMatch(fmt.Sprintf("%s: '%s'", thirdParameterName, thirdParameterValue))
+ stdout.ExpectMatchContext(ctx, presetName)
+ stdout.ExpectMatchContext(ctx, fmt.Sprintf("%s: '%s'", firstParameterName, secondOptionalParameterValue))
+ stdout.ExpectMatchContext(ctx, fmt.Sprintf("%s: '%s'", thirdParameterName, thirdParameterValue))
// Verify if the new workspace uses expected parameters.
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
@@ -1312,6 +1325,7 @@ func TestCreateWithPreset(t *testing.T) {
// the CLI automatically uses the default preset to create the workspace
t.Run("DefaultPreset", func(t *testing.T) {
t.Parallel()
+ ctx := testutil.Context(t, testutil.WaitMedium)
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
@@ -1340,22 +1354,17 @@ func TestCreateWithPreset(t *testing.T) {
workspaceName := "my-workspace"
inv, root := clitest.New(t, "create", workspaceName, "--template", template.Name, "-y")
clitest.SetupConfig(t, member, root)
- pty := ptytest.New(t).Attach(inv)
- inv.Stdout = pty.Output()
- inv.Stderr = pty.Output()
+ stdout := expecter.NewAttachedToInvocation(t, inv)
err := inv.Run()
require.NoError(t, err)
// Should: display the default preset as well as its parameters
presetName := fmt.Sprintf("Preset '%s' (default) applied:", defaultPreset.Name)
- pty.ExpectMatch(presetName)
- pty.ExpectMatch(fmt.Sprintf("%s: '%s'", firstParameterName, secondOptionalParameterValue))
- pty.ExpectMatch(fmt.Sprintf("%s: '%s'", thirdParameterName, thirdParameterValue))
+ stdout.ExpectMatchContext(ctx, presetName)
+ stdout.ExpectMatchContext(ctx, fmt.Sprintf("%s: '%s'", firstParameterName, secondOptionalParameterValue))
+ stdout.ExpectMatchContext(ctx, fmt.Sprintf("%s: '%s'", thirdParameterName, thirdParameterValue))
// Verify if the new workspace uses expected parameters.
- ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
- defer cancel()
-
tvPresets, err := client.TemplateVersionPresets(ctx, version.ID)
require.NoError(t, err)
require.Len(t, tvPresets, 2)
@@ -1389,12 +1398,14 @@ func TestCreateWithPreset(t *testing.T) {
// the CLI prompts the user to select a preset.
t.Run("NoDefaultPresetPromptUser", func(t *testing.T) {
t.Parallel()
+ logger := testutil.Logger(t)
+ ctx := testutil.Context(t, testutil.WaitMedium)
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
- // Given: a template and a template version with two presets
+ // Given: a template and a template version with a single, non-default preset.
preset := proto.Preset{
Name: "preset-test",
Description: "Preset Test.",
@@ -1414,7 +1425,8 @@ func TestCreateWithPreset(t *testing.T) {
"--parameter", fmt.Sprintf("%s=%s", thirdParameterName, thirdParameterValue))
clitest.SetupConfig(t, member, root)
doneChan := make(chan struct{})
- pty := ptytest.New(t).Attach(inv)
+ stdout := expecter.NewAttachedToInvocation(t, inv)
+ stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
go func() {
defer close(doneChan)
err := inv.Run()
@@ -1422,18 +1434,16 @@ func TestCreateWithPreset(t *testing.T) {
}()
// Should: prompt the user for the preset
- pty.ExpectMatch("Select a preset below:")
- pty.WriteLine("\n")
- pty.ExpectMatch("Preset 'preset-test' applied")
- pty.ExpectMatch("Confirm create?")
- pty.WriteLine("yes")
+ stdout.ExpectMatchContext(ctx, "Select a preset below:")
+ // We don't actually have to respond to the selector, since we hardcode the cliui.Select to return the
+ // first option in test scenarios (c.f. cliui/select.go)
+ stdout.ExpectMatchContext(ctx, "Preset 'preset-test' applied")
+ stdout.ExpectMatchContext(ctx, "Confirm create?")
+ stdin.WriteLine("yes")
<-doneChan
// Verify if the new workspace uses expected parameters.
- ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
- defer cancel()
-
tvPresets, err := client.TemplateVersionPresets(ctx, version.ID)
require.NoError(t, err)
require.Len(t, tvPresets, 1)
@@ -1460,6 +1470,7 @@ func TestCreateWithPreset(t *testing.T) {
// with workspace creation without applying any preset.
t.Run("TemplateVersionWithoutPresets", func(t *testing.T) {
t.Parallel()
+ ctx := testutil.Context(t, testutil.WaitMedium)
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
@@ -1476,17 +1487,12 @@ func TestCreateWithPreset(t *testing.T) {
"--parameter", fmt.Sprintf("%s=%s", firstParameterName, firstOptionalParameterValue),
"--parameter", fmt.Sprintf("%s=%s", thirdParameterName, thirdParameterValue))
clitest.SetupConfig(t, member, root)
- pty := ptytest.New(t).Attach(inv)
- inv.Stdout = pty.Output()
- inv.Stderr = pty.Output()
+ stdout := expecter.NewAttachedToInvocation(t, inv)
err := inv.Run()
require.NoError(t, err)
- pty.ExpectMatch("No preset applied.")
+ stdout.ExpectMatchContext(ctx, "No preset applied.")
// Verify if the new workspace uses expected parameters.
- ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
- defer cancel()
-
workspaces, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{
Name: workspaceName,
})
@@ -1509,6 +1515,7 @@ func TestCreateWithPreset(t *testing.T) {
// The workspace should be created without using any preset-defined parameters.
t.Run("PresetFlagNone", func(t *testing.T) {
t.Parallel()
+ ctx := testutil.Context(t, testutil.WaitMedium)
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
@@ -1533,17 +1540,12 @@ func TestCreateWithPreset(t *testing.T) {
"--parameter", fmt.Sprintf("%s=%s", firstParameterName, firstOptionalParameterValue),
"--parameter", fmt.Sprintf("%s=%s", thirdParameterName, thirdParameterValue))
clitest.SetupConfig(t, member, root)
- pty := ptytest.New(t).Attach(inv)
- inv.Stdout = pty.Output()
- inv.Stderr = pty.Output()
+ stdout := expecter.NewAttachedToInvocation(t, inv)
err := inv.Run()
require.NoError(t, err)
- pty.ExpectMatch("No preset applied.")
+ stdout.ExpectMatchContext(ctx, "No preset applied.")
// Verify that the new workspace doesn't use the preset parameters.
- ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
- defer cancel()
-
tvPresets, err := client.TemplateVersionPresets(ctx, version.ID)
require.NoError(t, err)
require.Len(t, tvPresets, 1)
@@ -1591,9 +1593,6 @@ func TestCreateWithPreset(t *testing.T) {
workspaceName := "my-workspace"
inv, root := clitest.New(t, "create", workspaceName, "--template", template.Name, "-y", "--preset", "invalid-preset")
clitest.SetupConfig(t, member, root)
- pty := ptytest.New(t).Attach(inv)
- inv.Stdout = pty.Output()
- inv.Stderr = pty.Output()
err := inv.Run()
// Should: fail with an error indicating the preset was not found
@@ -1610,6 +1609,7 @@ func TestCreateWithPreset(t *testing.T) {
// - and the value of parameter B from the parameter flag.
t.Run("PresetOverridesParameterFlagValues", func(t *testing.T) {
t.Parallel()
+ ctx := testutil.Context(t, testutil.WaitMedium)
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
@@ -1633,21 +1633,16 @@ func TestCreateWithPreset(t *testing.T) {
"--parameter", fmt.Sprintf("%s=%s", firstParameterName, firstOptionalParameterValue),
"--parameter", fmt.Sprintf("%s=%s", thirdParameterName, thirdParameterValue))
clitest.SetupConfig(t, member, root)
- pty := ptytest.New(t).Attach(inv)
- inv.Stdout = pty.Output()
- inv.Stderr = pty.Output()
+ stdout := expecter.NewAttachedToInvocation(t, inv)
err := inv.Run()
require.NoError(t, err)
// Should: display the selected preset as well as its parameter
presetName := fmt.Sprintf("Preset '%s' applied:", preset.Name)
- pty.ExpectMatch(presetName)
- pty.ExpectMatch(fmt.Sprintf("%s: '%s'", firstParameterName, secondOptionalParameterValue))
+ stdout.ExpectMatchContext(ctx, presetName)
+ stdout.ExpectMatchContext(ctx, fmt.Sprintf("%s: '%s'", firstParameterName, secondOptionalParameterValue))
// Verify if the new workspace uses expected parameters.
- ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
- defer cancel()
-
tvPresets, err := client.TemplateVersionPresets(ctx, version.ID)
require.NoError(t, err)
require.Len(t, tvPresets, 1)
@@ -1679,6 +1674,7 @@ func TestCreateWithPreset(t *testing.T) {
// - and the value of parameter B from the file.
t.Run("PresetOverridesParameterFileValues", func(t *testing.T) {
t.Parallel()
+ ctx := testutil.Context(t, testutil.WaitMedium)
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
@@ -1707,21 +1703,16 @@ func TestCreateWithPreset(t *testing.T) {
"--preset", preset.Name,
"--rich-parameter-file", parameterFile.Name())
clitest.SetupConfig(t, member, root)
- pty := ptytest.New(t).Attach(inv)
- inv.Stdout = pty.Output()
- inv.Stderr = pty.Output()
+ stdout := expecter.NewAttachedToInvocation(t, inv)
err := inv.Run()
require.NoError(t, err)
// Should: display the selected preset as well as its parameter
presetName := fmt.Sprintf("Preset '%s' applied:", preset.Name)
- pty.ExpectMatch(presetName)
- pty.ExpectMatch(fmt.Sprintf("%s: '%s'", firstParameterName, secondOptionalParameterValue))
+ stdout.ExpectMatchContext(ctx, presetName)
+ stdout.ExpectMatchContext(ctx, fmt.Sprintf("%s: '%s'", firstParameterName, secondOptionalParameterValue))
// Verify if the new workspace uses expected parameters.
- ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
- defer cancel()
-
tvPresets, err := client.TemplateVersionPresets(ctx, version.ID)
require.NoError(t, err)
require.Len(t, tvPresets, 1)
@@ -1748,7 +1739,8 @@ func TestCreateWithPreset(t *testing.T) {
// the CLI prompts the user for input to fill in the missing parameters.
t.Run("PromptsForMissingParametersWhenPresetIsIncomplete", func(t *testing.T) {
t.Parallel()
-
+ ctx := testutil.Context(t, testutil.WaitMedium)
+ logger := testutil.Logger(t)
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
@@ -1769,7 +1761,8 @@ func TestCreateWithPreset(t *testing.T) {
inv, root := clitest.New(t, "create", workspaceName, "--template", template.Name, "--preset", preset.Name)
clitest.SetupConfig(t, member, root)
doneChan := make(chan struct{})
- pty := ptytest.New(t).Attach(inv)
+ stdout := expecter.NewAttachedToInvocation(t, inv)
+ stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
go func() {
defer close(doneChan)
err := inv.Run()
@@ -1778,21 +1771,18 @@ func TestCreateWithPreset(t *testing.T) {
// Should: display the selected preset as well as its parameters
presetName := fmt.Sprintf("Preset '%s' applied:", preset.Name)
- pty.ExpectMatch(presetName)
- pty.ExpectMatch(fmt.Sprintf("%s: '%s'", firstParameterName, secondOptionalParameterValue))
+ stdout.ExpectMatchContext(ctx, presetName)
+ stdout.ExpectMatchContext(ctx, fmt.Sprintf("%s: '%s'", firstParameterName, secondOptionalParameterValue))
// Should: prompt for the missing parameter
- pty.ExpectMatch(thirdParameterDescription)
- pty.WriteLine(thirdParameterValue)
- pty.ExpectMatch("Confirm create?")
- pty.WriteLine("yes")
+ stdout.ExpectMatchContext(ctx, thirdParameterDescription)
+ stdin.WriteLine(thirdParameterValue)
+ stdout.ExpectMatchContext(ctx, "Confirm create?")
+ stdin.WriteLine("yes")
<-doneChan
// Verify if the new workspace uses expected parameters.
- ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
- defer cancel()
-
tvPresets, err := client.TemplateVersionPresets(ctx, version.ID)
require.NoError(t, err)
require.Len(t, tvPresets, 1)
@@ -1857,7 +1847,8 @@ func TestCreateValidateRichParameters(t *testing.T) {
t.Run("ValidateString", func(t *testing.T) {
t.Parallel()
-
+ logger := testutil.Logger(t)
+ ctx := testutil.Context(t, testutil.WaitMedium)
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
@@ -1869,7 +1860,8 @@ func TestCreateValidateRichParameters(t *testing.T) {
inv, root := clitest.New(t, "create", "my-workspace", "--template", template.Name)
clitest.SetupConfig(t, member, root)
doneChan := make(chan struct{})
- pty := ptytest.New(t).Attach(inv)
+ stdout := expecter.NewAttachedToInvocation(t, inv)
+ stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
go func() {
defer close(doneChan)
err := inv.Run()
@@ -1885,9 +1877,9 @@ func TestCreateValidateRichParameters(t *testing.T) {
for i := 0; i < len(matches); i += 2 {
match := matches[i]
value := matches[i+1]
- pty.ExpectMatch(match)
+ stdout.ExpectMatchContext(ctx, match)
if value != "" {
- pty.WriteLine(value)
+ stdin.WriteLine(value)
}
}
<-doneChan
@@ -1895,6 +1887,8 @@ func TestCreateValidateRichParameters(t *testing.T) {
t.Run("ValidateNumber", func(t *testing.T) {
t.Parallel()
+ logger := testutil.Logger(t)
+ ctx := testutil.Context(t, testutil.WaitMedium)
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
@@ -1907,7 +1901,8 @@ func TestCreateValidateRichParameters(t *testing.T) {
inv, root := clitest.New(t, "create", "my-workspace", "--template", template.Name)
clitest.SetupConfig(t, member, root)
doneChan := make(chan struct{})
- pty := ptytest.New(t).Attach(inv)
+ stdout := expecter.NewAttachedToInvocation(t, inv)
+ stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
go func() {
defer close(doneChan)
err := inv.Run()
@@ -1923,9 +1918,9 @@ func TestCreateValidateRichParameters(t *testing.T) {
for i := 0; i < len(matches); i += 2 {
match := matches[i]
value := matches[i+1]
- pty.ExpectMatch(match)
+ stdout.ExpectMatchContext(ctx, match)
if value != "" {
- pty.WriteLine(value)
+ stdin.WriteLine(value)
}
}
<-doneChan
@@ -1933,6 +1928,8 @@ func TestCreateValidateRichParameters(t *testing.T) {
t.Run("ValidateNumber_CustomError", func(t *testing.T) {
t.Parallel()
+ logger := testutil.Logger(t)
+ ctx := testutil.Context(t, testutil.WaitMedium)
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
@@ -1945,7 +1942,8 @@ func TestCreateValidateRichParameters(t *testing.T) {
inv, root := clitest.New(t, "create", "my-workspace", "--template", template.Name)
clitest.SetupConfig(t, member, root)
doneChan := make(chan struct{})
- pty := ptytest.New(t).Attach(inv)
+ stdout := expecter.NewAttachedToInvocation(t, inv)
+ stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
go func() {
defer close(doneChan)
err := inv.Run()
@@ -1961,9 +1959,9 @@ func TestCreateValidateRichParameters(t *testing.T) {
for i := 0; i < len(matches); i += 2 {
match := matches[i]
value := matches[i+1]
- pty.ExpectMatch(match)
+ stdout.ExpectMatchContext(ctx, match)
if value != "" {
- pty.WriteLine(value)
+ stdin.WriteLine(value)
}
}
<-doneChan
@@ -1971,6 +1969,8 @@ func TestCreateValidateRichParameters(t *testing.T) {
t.Run("ValidateBool", func(t *testing.T) {
t.Parallel()
+ logger := testutil.Logger(t)
+ ctx := testutil.Context(t, testutil.WaitMedium)
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
@@ -1983,7 +1983,8 @@ func TestCreateValidateRichParameters(t *testing.T) {
inv, root := clitest.New(t, "create", "my-workspace", "--template", template.Name)
clitest.SetupConfig(t, member, root)
doneChan := make(chan struct{})
- pty := ptytest.New(t).Attach(inv)
+ stdout := expecter.NewAttachedToInvocation(t, inv)
+ stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
go func() {
defer close(doneChan)
err := inv.Run()
@@ -1999,9 +2000,9 @@ func TestCreateValidateRichParameters(t *testing.T) {
for i := 0; i < len(matches); i += 2 {
match := matches[i]
value := matches[i+1]
- pty.ExpectMatch(match)
+ stdout.ExpectMatchContext(ctx, match)
if value != "" {
- pty.WriteLine(value)
+ stdin.WriteLine(value)
}
}
<-doneChan
@@ -2018,15 +2019,18 @@ func TestCreateValidateRichParameters(t *testing.T) {
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
t.Run("Prompt", func(t *testing.T) {
+ logger := testutil.Logger(t)
+ ctx := testutil.Context(t, testutil.WaitMedium)
inv, root := clitest.New(t, "create", "my-workspace-1", "--template", template.Name)
clitest.SetupConfig(t, member, root)
- pty := ptytest.New(t).Attach(inv)
+ stdout := expecter.NewAttachedToInvocation(t, inv)
+ stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
clitest.Start(t, inv)
- pty.ExpectMatch(listOfStringsParameterName)
- pty.ExpectMatch("aaa, bbb, ccc")
- pty.ExpectMatch("Confirm create?")
- pty.WriteLine("yes")
+ stdout.ExpectMatchContext(ctx, listOfStringsParameterName)
+ stdout.ExpectMatchContext(ctx, "aaa, bbb, ccc")
+ stdout.ExpectMatchContext(ctx, "Confirm create?")
+ stdin.WriteLine("yes")
})
t.Run("Default", func(t *testing.T) {
@@ -2049,6 +2053,8 @@ func TestCreateValidateRichParameters(t *testing.T) {
t.Run("ValidateListOfStrings_YAMLFile", func(t *testing.T) {
t.Parallel()
+ logger := testutil.Logger(t)
+ ctx := testutil.Context(t, testutil.WaitMedium)
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
@@ -2066,8 +2072,8 @@ func TestCreateValidateRichParameters(t *testing.T) {
- fff`)
inv, root := clitest.New(t, "create", "my-workspace", "--template", template.Name, "--rich-parameter-file", parameterFile.Name())
clitest.SetupConfig(t, member, root)
- pty := ptytest.New(t).Attach(inv)
-
+ stdout := expecter.NewAttachedToInvocation(t, inv)
+ stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
clitest.Start(t, inv)
matches := []string{
@@ -2076,9 +2082,9 @@ func TestCreateValidateRichParameters(t *testing.T) {
for i := 0; i < len(matches); i += 2 {
match := matches[i]
value := matches[i+1]
- pty.ExpectMatch(match)
+ stdout.ExpectMatchContext(ctx, match)
if value != "" {
- pty.WriteLine(value)
+ stdin.WriteLine(value)
}
}
})
@@ -2086,6 +2092,8 @@ func TestCreateValidateRichParameters(t *testing.T) {
func TestCreateWithGitAuth(t *testing.T) {
t.Parallel()
+ logger := testutil.Logger(t)
+ ctx := testutil.Context(t, testutil.WaitMedium)
echoResponses := &echo.Responses{
Parse: echo.ParseComplete,
ProvisionInit: echo.InitComplete,
@@ -2120,13 +2128,14 @@ func TestCreateWithGitAuth(t *testing.T) {
inv, root := clitest.New(t, "create", "my-workspace", "--template", template.Name)
clitest.SetupConfig(t, member, root)
- pty := ptytest.New(t).Attach(inv)
+ stdout := expecter.NewAttachedToInvocation(t, inv)
+ stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
clitest.Start(t, inv)
- pty.ExpectMatch("You must authenticate with GitHub to create a workspace")
+ stdout.ExpectMatchContext(ctx, "You must authenticate with GitHub to create a workspace")
resp := coderdtest.RequestExternalAuthCallback(t, "github", member)
_ = resp.Body.Close()
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
- pty.ExpectMatch("Confirm create?")
- pty.WriteLine("yes")
+ stdout.ExpectMatchContext(ctx, "Confirm create?")
+ stdin.WriteLine("yes")
}
diff --git a/pty/ptytest/ptytest.go b/pty/ptytest/ptytest.go
index 43fff7c5d74eb..191f4cf622069 100644
--- a/pty/ptytest/ptytest.go
+++ b/pty/ptytest/ptytest.go
@@ -72,15 +72,10 @@ func (p *PTY) Close() error {
if pErr != nil {
p.Logf("PTY: Close failed: %v", pErr)
}
- eErr := p.Expecter.Close("PTY close")
- if eErr != nil {
- p.Logf("PTY: close expecter failed: %v", eErr)
- }
+ p.Expecter.Close("PTY close")
if pErr != nil {
p.closeErr = pErr
- return
}
- p.closeErr = eErr
})
return p.closeErr
}
@@ -135,12 +130,6 @@ func (p *PTYCmd) Close() error {
if pErr != nil {
p.Logf("PTYCmd: Close failed: %v", pErr)
}
- eErr := p.Expecter.Close("PTYCmd close")
- if eErr != nil {
- p.Logf("PTYCmd: close expecter failed: %v", eErr)
- }
- if pErr != nil {
- return pErr
- }
- return eErr
+ p.Expecter.Close("PTYCmd close")
+ return pErr
}
diff --git a/testutil/expecter/expecter.go b/testutil/expecter/expecter.go
index 5a370a9e64074..333e9a18abbc2 100644
--- a/testutil/expecter/expecter.go
+++ b/testutil/expecter/expecter.go
@@ -19,6 +19,7 @@ import (
"golang.org/x/xerrors"
"github.com/coder/coder/v2/testutil"
+ "github.com/coder/serpent"
)
func New(t *testing.T, r io.Reader, name string) *Expecter {
@@ -68,6 +69,22 @@ func New(t *testing.T, r io.Reader, name string) *Expecter {
return ex
}
+func NewAttachedToInvocation(t *testing.T, invocation *serpent.Invocation) *Expecter {
+ r, w := io.Pipe()
+ invocation.Stdout = w
+ invocation.Stderr = w
+ e := New(t, r, "cmd")
+
+ t.Cleanup(func() {
+ // Serpent doesn't handle closing stdout after running the Invocation; normally the OS does that automatically when
+ // the process exits. Close it here at the end of the test to ensure we don't leak goroutines reading from the
+ // stdout/stderr.
+ _ = w.Close()
+ e.Close("test end")
+ })
+ return e
+}
+
type Expecter struct {
t *testing.T
out *stdbuf
@@ -84,7 +101,7 @@ func (e *Expecter) Rename(name string) {
e.name.Store(name)
}
-func (e *Expecter) Close(reason string) error {
+func (e *Expecter) Close(reason string) {
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
@@ -94,6 +111,7 @@ func (e *Expecter) Close(reason string) error {
select {
case <-ctx.Done():
e.fatalf("close", "copy did not close in time")
+ return
case <-e.copyDone:
}
@@ -102,12 +120,11 @@ func (e *Expecter) Close(reason string) error {
select {
case <-ctx.Done():
e.fatalf("close", "log pipe did not close in time")
+ return
case <-e.logDone:
}
e.Logf("closed expecter")
-
- return nil
}
func (e *Expecter) logClose(name string, c io.Closer) {
diff --git a/testutil/writer.go b/testutil/writer.go
new file mode 100644
index 0000000000000..4def987e6271c
--- /dev/null
+++ b/testutil/writer.go
@@ -0,0 +1,55 @@
+package testutil
+
+import (
+ "io"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "gvisor.dev/gvisor/pkg/context"
+
+ "cdr.dev/slog/v3"
+ "github.com/coder/serpent"
+)
+
+// Writer wraps an underlying io.Writer and provides friendlier methods to write to it, including logging.
+type Writer struct {
+ t *testing.T
+ w io.Writer
+ l slog.Logger
+}
+
+func NewWriterAttachedToInvocation(t *testing.T, logger slog.Logger, invocation *serpent.Invocation) *Writer {
+ r, w := io.Pipe()
+ invocation.Stdin = r
+ // Close the pipe at the end of the test to ensure any goroutine in the Invocation that reads from stdin won't leak.
+ t.Cleanup(func() {
+ _ = w.Close()
+ })
+ return &Writer{
+ t: t,
+ w: w,
+ l: logger,
+ }
+}
+
+func (w *Writer) Write(r rune) {
+ w.t.Helper()
+ _, err := w.w.Write([]byte{byte(r)})
+ if assert.NoError(w.t, err, "write failed") {
+ w.l.Debug(context.Background(), "wrote rune", slog.F("rune", r))
+ }
+}
+
+func (w *Writer) WriteLine(str string) {
+ w.t.Helper()
+
+ // Always write Windows style endings since our CLI prompt readers trim both out. Note this is *different* than what
+ // PTY-based tests do. On Unix-like operating systems we write a single carriage-return (\r) to delimit a line
+ // and the PTY translates it to a line feed (\n) for the CLI command to read. Here there is no translation.
+ newline := []byte{'\r', '\n'}
+
+ _, err := w.w.Write(append([]byte(str), newline...))
+ if assert.NoError(w.t, err, "write line failed") {
+ w.l.Debug(context.Background(), "wrote line", slog.F("line", str+string(newline)))
+ }
+}
From d3bedb4a9399d99602851eb9c776fd14665999aa Mon Sep 17 00:00:00 2001
From: TJ
Date: Thu, 28 May 2026 15:03:20 -0700
Subject: [PATCH 091/249] refactor(site): promote search to full-width sidebar
nav item (#25595)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Move the chat search button from a subtle icon next to the filter in the
Chats header to a prominent full-width nav item below New Agent. The
search bar shows a magnifying glass icon and "Search" label by default,
with the keyboard shortcut badge (`⌘ K` / `Ctrl K`) and background
appearing on hover/focus.
Also pull the Chats header and filter row out of the scroll area so the
scrollbar only covers the chat list content, and align the logo row with
the nav item content inset.
> 🤖 Generated by Coder Agents on behalf of @tracyjohnsonux
---
.../ChatsSidebar/ChatsSidebar.stories.tsx | 199 ++++++++++++++++--
.../ChatsSidebar/chats/ChatsPanel.tsx | 93 ++++----
.../ChatsSidebar/filters/FilterPopover.tsx | 2 +-
.../ChatsSidebar/settings/SettingsNavItem.tsx | 44 +++-
4 files changed, 263 insertions(+), 75 deletions(-)
diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.stories.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.stories.tsx
index 5294ba61c0a9d..a15c7cee8c4f3 100644
--- a/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.stories.tsx
+++ b/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.stories.tsx
@@ -1,6 +1,7 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { ComponentProps } from "react";
import { useEffect, useState } from "react";
+import { useLocation } from "react-router";
import { expect, fn, userEvent, waitFor, within } from "storybook/test";
import { reactRouterParameters } from "storybook-addon-remix-react-router";
import { userChatProviderConfigsKey } from "#/api/queries/chats";
@@ -12,10 +13,25 @@ import {
withDashboardProvider,
} from "#/testHelpers/storybook";
import { useAgentsPageKeybindings } from "../../hooks/useAgentsPageKeybindings";
-import type { AgentSidebarFilters } from "../../utils/agentSidebarFilters";
+import { DEFAULT_AGENT_SIDEBAR_FILTERS as defaultSidebarFilters } from "../../utils/agentSidebarFilters";
import type { ModelSelectorOption } from "../ChatElements";
import { ChatsSidebar } from "./ChatsSidebar";
+// Probe element used by the archived-filter preservation story to surface the
+// search string of whatever child route the sidebar's NavLink ends up at.
+const ChildSearchProbe = () => {
+ const location = useLocation();
+ return
{location.search}
;
+};
+
+// Probe element used by the settings-link preservation story to surface the
+// state.from value passed when navigating to settings.
+const SettingsStateProbe = () => {
+ const location = useLocation();
+ const from = (location.state as { from?: string })?.from ?? "";
+ return