From f4747c9456e89b9408cd19d949565eae2bf5483a Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Fri, 11 Sep 2026 16:24:59 -0700 Subject: [PATCH 1/3] feat: deprecate the oauth2 experiment in favor of CODER_OAUTH2_PROVIDER_ENABLE The oauth2 experiment no longer does anything. ReadExperiments drops it from the result and logs one warning per process naming the flag, so an admin whose OAuth2 clients start getting 404s after upgrading sees why. The constant stays known for one release so the warning can be specific; the next release removes it. --- coderd/apidoc/docs.go | 4 +- coderd/apidoc/swagger.json | 4 +- coderd/coderd.go | 28 ++++++++++++- coderd/experiments_internal_test.go | 65 +++++++++++++++++++++++++++++ coderd/experiments_test.go | 20 +++++++++ codersdk/deployment.go | 2 +- 6 files changed, 116 insertions(+), 7 deletions(-) create mode 100644 coderd/experiments_internal_test.go diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 57dee3f6d16..3812ad78908 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -23353,7 +23353,7 @@ const docTemplate = `{ "ExperimentMCPToolSearch": "Defers MCP tool schemas behind a searchable catalog in agent chats.", "ExperimentNATSPubsub": "Enables embedded NATS pubsub.", "ExperimentNotifications": "Sends notifications via SMTP and webhooks following certain events.", - "ExperimentOAuth2": "Enables OAuth2 provider functionality.", + "ExperimentOAuth2": "Deprecated: has no effect; use CODER_OAUTH2_PROVIDER_ENABLE.", "ExperimentWorkspaceBuildUpdates": "Enables publishing workspace build updates to the all builds pubsub channel.", "ExperimentWorkspaceCapableLicensing": "Counts only users holding the workspace-create permission toward the license seat limit.", "ExperimentWorkspaceUsage": "Enables the new workspace usage tracking." @@ -23363,7 +23363,7 @@ const docTemplate = `{ "This should not be taken out of experiments until we have redesigned the feature.", "Sends notifications via SMTP and webhooks following certain events.", "Enables the new workspace usage tracking.", - "Enables OAuth2 provider functionality.", + "Deprecated: has no effect; use CODER_OAUTH2_PROVIDER_ENABLE.", "Enables the MCP HTTP server functionality.", "Defers MCP tool schemas behind a searchable catalog in agent chats.", "Enables publishing workspace build updates to the all builds pubsub channel.", diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index a8184b49458..245a1aea546 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -21253,7 +21253,7 @@ "ExperimentMCPToolSearch": "Defers MCP tool schemas behind a searchable catalog in agent chats.", "ExperimentNATSPubsub": "Enables embedded NATS pubsub.", "ExperimentNotifications": "Sends notifications via SMTP and webhooks following certain events.", - "ExperimentOAuth2": "Enables OAuth2 provider functionality.", + "ExperimentOAuth2": "Deprecated: has no effect; use CODER_OAUTH2_PROVIDER_ENABLE.", "ExperimentWorkspaceBuildUpdates": "Enables publishing workspace build updates to the all builds pubsub channel.", "ExperimentWorkspaceCapableLicensing": "Counts only users holding the workspace-create permission toward the license seat limit.", "ExperimentWorkspaceUsage": "Enables the new workspace usage tracking." @@ -21263,7 +21263,7 @@ "This should not be taken out of experiments until we have redesigned the feature.", "Sends notifications via SMTP and webhooks following certain events.", "Enables the new workspace usage tracking.", - "Enables OAuth2 provider functionality.", + "Deprecated: has no effect; use CODER_OAUTH2_PROVIDER_ENABLE.", "Enables the MCP HTTP server functionality.", "Defers MCP tool schemas behind a searchable catalog in agent chats.", "Enables publishing workspace build updates to the all builds pubsub channel.", diff --git a/coderd/coderd.go b/coderd/coderd.go index 9f228fefe7b..84f9a517b38 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -2578,15 +2578,39 @@ func (api *API) DERPMap() *tailcfg.DERPMap { return api.BaseDERPMap } +// oauth2ExperimentDeprecatedMessage is logged when the retired oauth2 +// experiment is still configured. The OAuth2 provider is controlled by +// CODER_OAUTH2_PROVIDER_ENABLE, so the experiment value does nothing. +const oauth2ExperimentDeprecatedMessage = `CODER_EXPERIMENTS contains "oauth2", which is deprecated and has no effect. The OAuth2 provider is now generally available and disabled by default. Set CODER_OAUTH2_PROVIDER_ENABLE=true to enable it. The "oauth2" experiment value will be removed in the next release.` + +// warnOAuth2ExperimentDeprecated limits the deprecation warning to once per +// process. coder server reads the experiment list several times during +// startup, and every read would otherwise repeat the line. +var warnOAuth2ExperimentDeprecated sync.Once + // nolint:revive func ReadExperiments(log slog.Logger, raw []string) codersdk.Experiments { + return parseExperiments(log, raw, &warnOAuth2ExperimentDeprecated) +} + +// parseExperiments takes the warning guard as a parameter so tests can check +// the once-only behavior with their own sync.Once instead of resetting the +// package-level one. +func parseExperiments(log slog.Logger, raw []string, warnOAuth2Once *sync.Once) codersdk.Experiments { exps := make([]codersdk.Experiment, 0, len(raw)) for _, v := range raw { - switch v { + ex := codersdk.Experiment(strings.ToLower(v)) + switch ex { case "*": exps = append(exps, codersdk.ExperimentsSafe...) + case codersdk.ExperimentOAuth2: + // Recognized but inert for one release so the warning can be + // specific. Deliberately not appended: nothing may observe the + // experiment as enabled. + warnOAuth2Once.Do(func() { + log.Warn(context.Background(), oauth2ExperimentDeprecatedMessage) + }) default: - ex := codersdk.Experiment(strings.ToLower(v)) if !slice.Contains(codersdk.ExperimentsKnown, ex) { log.Warn(context.Background(), "ignoring unknown experiment", slog.F("experiment", ex)) } else if !slice.Contains(codersdk.ExperimentsSafe, ex) { diff --git a/coderd/experiments_internal_test.go b/coderd/experiments_internal_test.go new file mode 100644 index 00000000000..f78c6cd314d --- /dev/null +++ b/coderd/experiments_internal_test.go @@ -0,0 +1,65 @@ +package coderd + +import ( + "context" + "sync" + "testing" + + "github.com/stretchr/testify/require" + + "cdr.dev/slog/v3" + "github.com/coder/coder/v2/codersdk" +) + +// logRecorder keeps every entry so a test can count log lines. +type logRecorder struct { + mu sync.Mutex + entries []slog.SinkEntry +} + +func (s *logRecorder) LogEntry(_ context.Context, e slog.SinkEntry) { + s.mu.Lock() + defer s.mu.Unlock() + s.entries = append(s.entries, e) +} + +func (*logRecorder) Sync() {} + +func (s *logRecorder) messages(level slog.Level) []string { + s.mu.Lock() + defer s.mu.Unlock() + var out []string + for _, e := range s.entries { + if e.Level == level { + out = append(out, e.Message) + } + } + return out +} + +func TestReadExperimentsDeprecatedOAuth2(t *testing.T) { + t.Parallel() + + rec := &logRecorder{} + log := slog.Make(rec) + var once sync.Once + raw := []string{string(codersdk.ExperimentOAuth2), string(codersdk.ExperimentMCPServerHTTP)} + + got := parseExperiments(log, raw, &once) + require.Equal(t, codersdk.Experiments{codersdk.ExperimentMCPServerHTTP}, got, + "the oauth2 experiment must be dropped, not passed through") + require.Equal(t, []string{oauth2ExperimentDeprecatedMessage, "🐉 HERE BE DRAGONS: opting into hidden experiment"}, + rec.messages(slog.LevelWarn)) + + // A second read in the same process returns the same slice and does not + // repeat the deprecation warning. Upper-case input is matched too. + got = parseExperiments(log, []string{"OAuth2", string(codersdk.ExperimentMCPServerHTTP)}, &once) + require.Equal(t, codersdk.Experiments{codersdk.ExperimentMCPServerHTTP}, got) + var deprecations int + for _, m := range rec.messages(slog.LevelWarn) { + if m == oauth2ExperimentDeprecatedMessage { + deprecations++ + } + } + require.Equal(t, 1, deprecations, "deprecation warning must be logged once per process") +} diff --git a/coderd/experiments_test.go b/coderd/experiments_test.go index 8f5944609ab..fac9fbf9e46 100644 --- a/coderd/experiments_test.go +++ b/coderd/experiments_test.go @@ -99,6 +99,26 @@ func Test_Experiments(t *testing.T) { require.False(t, experiments.Enabled("herebedragons")) }) + t.Run("deprecated oauth2 experiment is dropped", func(t *testing.T) { + t.Parallel() + cfg := coderdtest.DeploymentValues(t) + cfg.Experiments = []string{string(codersdk.ExperimentOAuth2), string(codersdk.ExperimentMCPServerHTTP)} + client := coderdtest.New(t, &coderdtest.Options{ + DeploymentValues: cfg, + }) + _ = coderdtest.CreateFirstUser(t, client) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + experiments, err := client.Experiments(ctx) + require.NoError(t, err) + // The provider is controlled by CODER_OAUTH2_PROVIDER_ENABLE, so the + // experiment must never be reported as enabled. + require.ElementsMatch(t, []codersdk.Experiment{codersdk.ExperimentMCPServerHTTP}, experiments) + require.False(t, experiments.Enabled(codersdk.ExperimentOAuth2)) + }) + t.Run("Unauthorized", func(t *testing.T) { t.Parallel() cfg := coderdtest.DeploymentValues(t) diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 7a0a90de921..746e7e35dc0 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -5192,7 +5192,7 @@ const ( ExperimentAutoFillParameters Experiment = "auto-fill-parameters" // This should not be taken out of experiments until we have redesigned the feature. ExperimentNotifications Experiment = "notifications" // Sends notifications via SMTP and webhooks following certain events. ExperimentWorkspaceUsage Experiment = "workspace-usage" // Enables the new workspace usage tracking. - ExperimentOAuth2 Experiment = "oauth2" // Enables OAuth2 provider functionality. + ExperimentOAuth2 Experiment = "oauth2" // Deprecated: has no effect; use CODER_OAUTH2_PROVIDER_ENABLE. ExperimentMCPServerHTTP Experiment = "mcp-server-http" // Enables the MCP HTTP server functionality. ExperimentMCPToolSearch Experiment = "mcp-tool-search" // Defers MCP tool schemas behind a searchable catalog in agent chats. ExperimentWorkspaceBuildUpdates Experiment = "workspace-build-updates" // Enables publishing workspace build updates to the all builds pubsub channel. From 9190c04d46174caef4d67abb211313306cb4b287 Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Sat, 12 Sep 2026 09:50:05 -0700 Subject: [PATCH 2/3] fix(coderd): soften the oauth2 experiment removal wording The deprecation warning promised removal in the next release, which no ticket backed. Removal is now tracked in PLAT-635, so the message says a future release and the code comment names the ticket. Co-Authored-By: Claude Fable 5.1 --- coderd/coderd.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/coderd/coderd.go b/coderd/coderd.go index 84f9a517b38..62c9b99b3c6 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -2581,7 +2581,7 @@ func (api *API) DERPMap() *tailcfg.DERPMap { // oauth2ExperimentDeprecatedMessage is logged when the retired oauth2 // experiment is still configured. The OAuth2 provider is controlled by // CODER_OAUTH2_PROVIDER_ENABLE, so the experiment value does nothing. -const oauth2ExperimentDeprecatedMessage = `CODER_EXPERIMENTS contains "oauth2", which is deprecated and has no effect. The OAuth2 provider is now generally available and disabled by default. Set CODER_OAUTH2_PROVIDER_ENABLE=true to enable it. The "oauth2" experiment value will be removed in the next release.` +const oauth2ExperimentDeprecatedMessage = `CODER_EXPERIMENTS contains "oauth2", which is deprecated and has no effect. The OAuth2 provider is now generally available and disabled by default. Set CODER_OAUTH2_PROVIDER_ENABLE=true to enable it. The "oauth2" experiment value will be removed in a future release.` // warnOAuth2ExperimentDeprecated limits the deprecation warning to once per // process. coder server reads the experiment list several times during @@ -2604,9 +2604,9 @@ func parseExperiments(log slog.Logger, raw []string, warnOAuth2Once *sync.Once) case "*": exps = append(exps, codersdk.ExperimentsSafe...) case codersdk.ExperimentOAuth2: - // Recognized but inert for one release so the warning can be - // specific. Deliberately not appended: nothing may observe the - // experiment as enabled. + // Recognized but inert so the warning can be specific. PLAT-635 + // removes the constant and this branch. Deliberately not + // appended: nothing may observe the experiment as enabled. warnOAuth2Once.Do(func() { log.Warn(context.Background(), oauth2ExperimentDeprecatedMessage) }) From ee405a9a23e48a579dcdb7f9eebf9bfca4c0a071 Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Sat, 12 Sep 2026 09:56:44 -0700 Subject: [PATCH 3/3] fix(coderd): address review notes on the oauth2 experiment deprecation Trim the doc comment on the message constant to its purpose so it cannot drift from the literal. Assert the deprecation warning by count instead of an exact warn slice, which coupled the test to the safety classification of an unrelated experiment, and check explicitly that no unknown-experiment warning leaks. Co-Authored-By: Claude Fable 5.1 --- coderd/coderd.go | 5 ++--- coderd/experiments_internal_test.go | 27 +++++++++++++++++---------- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/coderd/coderd.go b/coderd/coderd.go index 62c9b99b3c6..c427519a579 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -2578,9 +2578,8 @@ func (api *API) DERPMap() *tailcfg.DERPMap { return api.BaseDERPMap } -// oauth2ExperimentDeprecatedMessage is logged when the retired oauth2 -// experiment is still configured. The OAuth2 provider is controlled by -// CODER_OAUTH2_PROVIDER_ENABLE, so the experiment value does nothing. +// oauth2ExperimentDeprecatedMessage is logged when a retired oauth2 +// experiment value is still configured. const oauth2ExperimentDeprecatedMessage = `CODER_EXPERIMENTS contains "oauth2", which is deprecated and has no effect. The OAuth2 provider is now generally available and disabled by default. Set CODER_OAUTH2_PROVIDER_ENABLE=true to enable it. The "oauth2" experiment value will be removed in a future release.` // warnOAuth2ExperimentDeprecated limits the deprecation warning to once per diff --git a/coderd/experiments_internal_test.go b/coderd/experiments_internal_test.go index f78c6cd314d..a7918757504 100644 --- a/coderd/experiments_internal_test.go +++ b/coderd/experiments_internal_test.go @@ -37,6 +37,17 @@ func (s *logRecorder) messages(level slog.Level) []string { return out } +// count returns how many entries at level carry exactly msg. +func (s *logRecorder) count(level slog.Level, msg string) int { + var n int + for _, m := range s.messages(level) { + if m == msg { + n++ + } + } + return n +} + func TestReadExperimentsDeprecatedOAuth2(t *testing.T) { t.Parallel() @@ -48,18 +59,14 @@ func TestReadExperimentsDeprecatedOAuth2(t *testing.T) { got := parseExperiments(log, raw, &once) require.Equal(t, codersdk.Experiments{codersdk.ExperimentMCPServerHTTP}, got, "the oauth2 experiment must be dropped, not passed through") - require.Equal(t, []string{oauth2ExperimentDeprecatedMessage, "🐉 HERE BE DRAGONS: opting into hidden experiment"}, - rec.messages(slog.LevelWarn)) + require.Equal(t, 1, rec.count(slog.LevelWarn, oauth2ExperimentDeprecatedMessage)) + require.NotContains(t, rec.messages(slog.LevelWarn), "ignoring unknown experiment", + "oauth2 must be matched before the unknown-experiment branch") - // A second read in the same process returns the same slice and does not + // A second read in the same process returns the same values and does not // repeat the deprecation warning. Upper-case input is matched too. got = parseExperiments(log, []string{"OAuth2", string(codersdk.ExperimentMCPServerHTTP)}, &once) require.Equal(t, codersdk.Experiments{codersdk.ExperimentMCPServerHTTP}, got) - var deprecations int - for _, m := range rec.messages(slog.LevelWarn) { - if m == oauth2ExperimentDeprecatedMessage { - deprecations++ - } - } - require.Equal(t, 1, deprecations, "deprecation warning must be logged once per process") + require.Equal(t, 1, rec.count(slog.LevelWarn, oauth2ExperimentDeprecatedMessage), + "deprecation warning must be logged once per process") }