From c457a62d412ffbd6a8e8a24df9ac853d7af54da1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 27 May 2026 14:47:49 -0400 Subject: [PATCH 001/138] ci: trigger CI on release branch creation (#25744) (#25752) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cherry-pick of https://github.com/coder/coder/pull/25744 Original PR: #25744 — ci: trigger CI on release branch creation Merge commit: 5991a2c8b088ae940c22d14d5e55ff6c2e710e06 Requested by: @f0ssel Co-authored-by: Garrett Delfosse --- .github/workflows/ci.yaml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f0a7ccd76c7..78d6aba61a9 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -6,6 +6,13 @@ on: - main - release/* + # GitHub Actions does not reliably trigger push-based CI when a new + # branch is created at a commit that already has a workflow run (e.g. + # from main). The create event fires separately and ensures CI runs + # on newly cut release branches. Non-release branch creations are + # filtered out by the changes job condition. + create: + pull_request: workflow_dispatch: @@ -21,6 +28,13 @@ concurrency: jobs: changes: runs-on: ubuntu-latest + # For create events, only run on release branches to avoid + # triggering CI for every feature branch creation. + if: | + github.event_name != 'create' || ( + github.event.ref_type == 'branch' && + startsWith(github.event.ref, 'release/') + ) outputs: docs-only: ${{ steps.filter.outputs.docs_count == steps.filter.outputs.all_count }} docs: ${{ steps.filter.outputs.docs }} From 75f51532f3673226cfeb850204ea9a5c0d0a861f Mon Sep 17 00:00:00 2001 From: "blinkagent[bot]" <237617714+blinkagent[bot]@users.noreply.github.com> Date: Wed, 27 May 2026 16:46:09 -0400 Subject: [PATCH 002/138] chore: update terraform to v1.15.5 (#25747) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cherry-pick of #25746 to `release/2.34`. Bumps bundled Terraform from `1.15.2` to `1.15.5`. Terraform 1.15.5 is built with Go 1.25.10 (vs Go 1.25.8 in 1.15.2), addressing Go stdlib CVEs flagged by security scanners. Files changed: - `.github/actions/setup-tf/action.yaml` - `scripts/Dockerfile.base` - `install.sh` - `flake.nix` (+ updated SRI hash for the linux_amd64 zip) - `mise.toml` - `mise.lock` (+ updated per-platform SHA256 checksums) - `provisioner/terraform/testdata/version.txt` - `provisioner/terraform/testdata/resources/ai-tasks-disabled/ai-tasks-disabled.tfplan.json` Release notes: https://github.com/hashicorp/terraform/releases/tag/v1.15.5 (cherry picked from commit bcc6cca0401de5b74797c569389ece700756858d — will be updated to the merged SHA from #25746) Created on behalf of @Shelnutt2 Co-authored-by: blink-so[bot] <211532188+blink-so[bot]@users.noreply.github.com> --- .github/actions/setup-tf/action.yaml | 2 +- flake.nix | 10 ++-- install.sh | 2 +- mise.lock | 46 +++++++++---------- mise.toml | 2 +- .../ai-tasks-disabled.tfplan.json | 4 +- provisioner/terraform/testdata/version.txt | 2 +- scripts/Dockerfile.base | 2 +- 8 files changed, 35 insertions(+), 35 deletions(-) diff --git a/.github/actions/setup-tf/action.yaml b/.github/actions/setup-tf/action.yaml index abcf9d7a22d..22c7253050f 100644 --- a/.github/actions/setup-tf/action.yaml +++ b/.github/actions/setup-tf/action.yaml @@ -7,5 +7,5 @@ runs: - name: Install Terraform uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2 with: - terraform_version: 1.15.2 + terraform_version: 1.15.5 terraform_wrapper: false diff --git a/flake.nix b/flake.nix index 2465f94fac3..d22dbcd72ce 100644 --- a/flake.nix +++ b/flake.nix @@ -111,13 +111,13 @@ # Keep Terraform aligned with provisioner/terraform/testdata/version.txt # so `make gen` remains deterministic in Nix shells. - terraform_1_15_2 = + terraform_1_15_5 = if pkgs.stdenv.isLinux && pkgs.stdenv.hostPlatform.isx86_64 then - pkgs.runCommand "terraform-1.15.2" { + pkgs.runCommand "terraform-1.15.5" { nativeBuildInputs = [ pkgs.unzip ]; src = pkgs.fetchurl { - url = "https://releases.hashicorp.com/terraform/1.15.2/terraform_1.15.2_linux_amd64.zip"; - hash = "sha256-xW/yvH5s6bOHmlA5KwPC6gdLR2iL9QP/lmyH+wGyqrg="; + url = "https://releases.hashicorp.com/terraform/1.15.5/terraform_1.15.5_linux_amd64.zip"; + hash = "sha256-cCshNq9nKMj/A3+EPdLbzit62IeGtzgdHXKu+iUPYBw="; }; } '' mkdir -p "$out/bin" @@ -208,7 +208,7 @@ # sqlc sqlc-custom syft - terraform_1_15_2 + terraform_1_15_5 typos which # Needed for many LD system libs! diff --git a/install.sh b/install.sh index cbb74248fce..daf4f598369 100755 --- a/install.sh +++ b/install.sh @@ -276,7 +276,7 @@ EOF main() { MAINLINE=1 STABLE=0 - TERRAFORM_VERSION="1.15.2" + TERRAFORM_VERSION="1.15.5" if [ "${TRACE-}" ]; then set -x diff --git a/mise.lock b/mise.lock index 7f96bd9e3cc..babc55e498d 100644 --- a/mise.lock +++ b/mise.lock @@ -896,49 +896,49 @@ checksum = "sha256:b8bfdedb261de2a69768097422a73bc72273ee92136ff676a20c3161e6588 url = "https://github.com/anchore/syft/releases/download/v1.20.0/syft_1.20.0_windows_amd64.zip" [[tools.terraform]] -version = "1.15.2" +version = "1.15.5" backend = "aqua:hashicorp/terraform" [tools.terraform."platforms.linux-arm64"] -checksum = "sha256:cf27657e96bbdc6116f4c16a0c801d36ae6410d7210183a520ac6b2198fb723e" -url = "https://releases.hashicorp.com/terraform/1.15.2/terraform_1.15.2_linux_arm64.zip" +checksum = "sha256:06e7b48de826146c6d9331ba35b13da12332d8392be30d1dd6b789ba4713fff0" +url = "https://releases.hashicorp.com/terraform/1.15.5/terraform_1.15.5_linux_arm64.zip" [tools.terraform."platforms.linux-arm64-musl"] -checksum = "sha256:cf27657e96bbdc6116f4c16a0c801d36ae6410d7210183a520ac6b2198fb723e" -url = "https://releases.hashicorp.com/terraform/1.15.2/terraform_1.15.2_linux_arm64.zip" +checksum = "sha256:06e7b48de826146c6d9331ba35b13da12332d8392be30d1dd6b789ba4713fff0" +url = "https://releases.hashicorp.com/terraform/1.15.5/terraform_1.15.5_linux_arm64.zip" [tools.terraform."platforms.linux-x64"] -checksum = "sha256:c56ff2bc7e6ce9b3879a50392b03c2ea074b47688bf503ff966c87fb01b2aab8" -url = "https://releases.hashicorp.com/terraform/1.15.2/terraform_1.15.2_linux_amd64.zip" +checksum = "sha256:702b2136af6728c8ff037f843dd2dbce2b7ad88786b7381d1d72aefa250f601c" +url = "https://releases.hashicorp.com/terraform/1.15.5/terraform_1.15.5_linux_amd64.zip" [tools.terraform."platforms.linux-x64-baseline"] -checksum = "sha256:c56ff2bc7e6ce9b3879a50392b03c2ea074b47688bf503ff966c87fb01b2aab8" -url = "https://releases.hashicorp.com/terraform/1.15.2/terraform_1.15.2_linux_amd64.zip" +checksum = "sha256:702b2136af6728c8ff037f843dd2dbce2b7ad88786b7381d1d72aefa250f601c" +url = "https://releases.hashicorp.com/terraform/1.15.5/terraform_1.15.5_linux_amd64.zip" [tools.terraform."platforms.linux-x64-musl"] -checksum = "sha256:c56ff2bc7e6ce9b3879a50392b03c2ea074b47688bf503ff966c87fb01b2aab8" -url = "https://releases.hashicorp.com/terraform/1.15.2/terraform_1.15.2_linux_amd64.zip" +checksum = "sha256:702b2136af6728c8ff037f843dd2dbce2b7ad88786b7381d1d72aefa250f601c" +url = "https://releases.hashicorp.com/terraform/1.15.5/terraform_1.15.5_linux_amd64.zip" [tools.terraform."platforms.linux-x64-musl-baseline"] -checksum = "sha256:c56ff2bc7e6ce9b3879a50392b03c2ea074b47688bf503ff966c87fb01b2aab8" -url = "https://releases.hashicorp.com/terraform/1.15.2/terraform_1.15.2_linux_amd64.zip" +checksum = "sha256:702b2136af6728c8ff037f843dd2dbce2b7ad88786b7381d1d72aefa250f601c" +url = "https://releases.hashicorp.com/terraform/1.15.5/terraform_1.15.5_linux_amd64.zip" [tools.terraform."platforms.macos-arm64"] -checksum = "sha256:4204bc3450418a7ce423e58451b053e5daed625ad6c6a15de98bc09345269f99" -url = "https://releases.hashicorp.com/terraform/1.15.2/terraform_1.15.2_darwin_arm64.zip" +checksum = "sha256:01137660510005b918bba82154866fbeac4393163d8277c2abe861dfb5842c3c" +url = "https://releases.hashicorp.com/terraform/1.15.5/terraform_1.15.5_darwin_arm64.zip" [tools.terraform."platforms.macos-x64"] -checksum = "sha256:2bb701bc2db93ed39613df4f4e033ec4c2de9eba1c036d9a2f62cffc988af066" -url = "https://releases.hashicorp.com/terraform/1.15.2/terraform_1.15.2_darwin_amd64.zip" +checksum = "sha256:3687d07c034b3e7deed5b072cd8ae2b34835bcb139baec3fc4f5fd534dabf5ed" +url = "https://releases.hashicorp.com/terraform/1.15.5/terraform_1.15.5_darwin_amd64.zip" [tools.terraform."platforms.macos-x64-baseline"] -checksum = "sha256:2bb701bc2db93ed39613df4f4e033ec4c2de9eba1c036d9a2f62cffc988af066" -url = "https://releases.hashicorp.com/terraform/1.15.2/terraform_1.15.2_darwin_amd64.zip" +checksum = "sha256:3687d07c034b3e7deed5b072cd8ae2b34835bcb139baec3fc4f5fd534dabf5ed" +url = "https://releases.hashicorp.com/terraform/1.15.5/terraform_1.15.5_darwin_amd64.zip" [tools.terraform."platforms.windows-x64"] -checksum = "sha256:a7e25570dd85f363581e96cac0b468257c45945ca8875d951413b6606c9b86d4" -url = "https://releases.hashicorp.com/terraform/1.15.2/terraform_1.15.2_windows_amd64.zip" +checksum = "sha256:2f652dd854af7b7fbb51301afc55b5ef1d3f6e287be7889d4cc3818df891cd38" +url = "https://releases.hashicorp.com/terraform/1.15.5/terraform_1.15.5_windows_amd64.zip" [tools.terraform."platforms.windows-x64-baseline"] -checksum = "sha256:a7e25570dd85f363581e96cac0b468257c45945ca8875d951413b6606c9b86d4" -url = "https://releases.hashicorp.com/terraform/1.15.2/terraform_1.15.2_windows_amd64.zip" +checksum = "sha256:2f652dd854af7b7fbb51301afc55b5ef1d3f6e287be7889d4cc3818df891cd38" +url = "https://releases.hashicorp.com/terraform/1.15.5/terraform_1.15.5_windows_amd64.zip" diff --git a/mise.toml b/mise.toml index c7366deecd4..b148fe41c6e 100644 --- a/mise.toml +++ b/mise.toml @@ -40,7 +40,7 @@ golangci-lint = "1.64.8" helm = "3.21.0" kubectx = "0.9.4" syft = "1.20.0" -terraform = "1.15.2" +terraform = "1.15.5" # Developer-environment niceties for the dogfood image. Non-dogfood # users who run `mise install` here will pull these too; they are diff --git a/provisioner/terraform/testdata/resources/ai-tasks-disabled/ai-tasks-disabled.tfplan.json b/provisioner/terraform/testdata/resources/ai-tasks-disabled/ai-tasks-disabled.tfplan.json index 455f32871c3..a3ce227430c 100644 --- a/provisioner/terraform/testdata/resources/ai-tasks-disabled/ai-tasks-disabled.tfplan.json +++ b/provisioner/terraform/testdata/resources/ai-tasks-disabled/ai-tasks-disabled.tfplan.json @@ -1,12 +1,12 @@ { "format_version": "1.2", - "terraform_version": "1.15.2", + "terraform_version": "1.15.5", "planned_values": { "root_module": {} }, "prior_state": { "format_version": "1.0", - "terraform_version": "1.15.2", + "terraform_version": "1.15.5", "values": { "root_module": { "resources": [ diff --git a/provisioner/terraform/testdata/version.txt b/provisioner/terraform/testdata/version.txt index 42cf0675c56..d32434904bc 100644 --- a/provisioner/terraform/testdata/version.txt +++ b/provisioner/terraform/testdata/version.txt @@ -1 +1 @@ -1.15.2 +1.15.5 diff --git a/scripts/Dockerfile.base b/scripts/Dockerfile.base index 337ee5a84fc..315c099d78b 100644 --- a/scripts/Dockerfile.base +++ b/scripts/Dockerfile.base @@ -27,7 +27,7 @@ RUN apk add --no-cache \ # Terraform was disabled in the edge repo due to a build issue. # https://gitlab.alpinelinux.org/alpine/aports/-/commit/f3e263d94cfac02d594bef83790c280e045eba35 # Using wget for now. Note that busybox unzip doesn't support streaming. -RUN ARCH="$(arch)"; if [ "${ARCH}" == "x86_64" ]; then ARCH="amd64"; elif [ "${ARCH}" == "aarch64" ]; then ARCH="arm64"; elif [ "${ARCH}" == "armv7l" ]; then ARCH="arm"; fi; wget -O /tmp/terraform.zip "https://releases.hashicorp.com/terraform/1.15.2/terraform_1.15.2_linux_${ARCH}.zip" && \ +RUN ARCH="$(arch)"; if [ "${ARCH}" == "x86_64" ]; then ARCH="amd64"; elif [ "${ARCH}" == "aarch64" ]; then ARCH="arm64"; elif [ "${ARCH}" == "armv7l" ]; then ARCH="arm"; fi; wget -O /tmp/terraform.zip "https://releases.hashicorp.com/terraform/1.15.5/terraform_1.15.5_linux_${ARCH}.zip" && \ busybox unzip /tmp/terraform.zip -d /usr/local/bin && \ rm -f /tmp/terraform.zip && \ chmod +x /usr/local/bin/terraform && \ From 570b193ed7563afad22899e36a44eeae1eafc15e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 28 May 2026 09:29:02 -0400 Subject: [PATCH 003/138] refactor(site): update BYOK link to use "View docs" on AI settings page (#25743) (#25764) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cherry-pick of https://github.com/coder/coder/pull/25743 Original PR: #25743 — refactor(site): update BYOK link to use "View docs" on AI settings page Merge commit: cfa343e45666c8f123d55e14baeb78b866ea667d Requested by: @dannykopping Co-authored-by: TJ --- .../ProvidersPage/ProvidersPageView.tsx | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/site/src/pages/AISettingsPage/ProvidersPage/ProvidersPageView.tsx b/site/src/pages/AISettingsPage/ProvidersPage/ProvidersPageView.tsx index d507d8fcd21..ec827418941 100644 --- a/site/src/pages/AISettingsPage/ProvidersPage/ProvidersPageView.tsx +++ b/site/src/pages/AISettingsPage/ProvidersPage/ProvidersPageView.tsx @@ -1,5 +1,5 @@ import { ChevronDownIcon, PlusIcon } from "lucide-react"; -import { Link, useNavigate } from "react-router"; +import { useNavigate } from "react-router"; import type { AIProvider } from "#/api/typesGenerated"; import { ErrorAlert } from "#/components/Alert/ErrorAlert"; import { Button } from "#/components/Button/Button"; @@ -9,6 +9,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "#/components/DropdownMenu/DropdownMenu"; +import { Link } from "#/components/Link/Link"; import { SettingsHeader, SettingsHeaderDescription, @@ -87,12 +88,8 @@ const ProvidersPageView: React.FC = ({ Bedrock. Providers configured here power Coder Agents, AI Gateway, and other capabilities such as APIs, CLI or IDEs that use LLMs. By default, users can supply their own keys for any provider.{" "} - - Manage deployment-wide BYOK + + View docs From eb8b062b1dd3162128007477311f5ffd56089c52 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 28 May 2026 09:29:30 -0400 Subject: [PATCH 004/138] fix: re-validate provider per request and classify reloads (#25766) (#25788) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cherry-pick of https://github.com/coder/coder/pull/25766 Original PR: #25766 — fix: re-validate provider per request and classify reloads Merge commit: a9f5ed76444357faa1b9c74cf5b4f074cb5e42ac Requested by: @dannykopping Co-authored-by: Danny Kopping --- enterprise/aibridgeproxyd/aibridgeproxyd.go | 95 ++- .../aibridgeproxyd/aibridgeproxyd_test.go | 704 ++++++++---------- enterprise/aibridgeproxyd/reload.go | 123 +-- .../aibridgeproxyd/reload_internal_test.go | 100 ++- enterprise/aibridgeproxyd/reload_test.go | 288 +++++-- enterprise/cli/aibridgeproxyd.go | 62 +- .../cli/aibridgeproxyd_internal_test.go | 105 +++ 7 files changed, 852 insertions(+), 625 deletions(-) create mode 100644 enterprise/cli/aibridgeproxyd_internal_test.go diff --git a/enterprise/aibridgeproxyd/aibridgeproxyd.go b/enterprise/aibridgeproxyd/aibridgeproxyd.go index 19d05ca5113..cfcb2071c41 100644 --- a/enterprise/aibridgeproxyd/aibridgeproxyd.go +++ b/enterprise/aibridgeproxyd/aibridgeproxyd.go @@ -31,18 +31,6 @@ import ( agplaibridge "github.com/coder/coder/v2/coderd/aibridge" ) -// ProviderRoute is the routing entry for a single AI provider: the -// instance name (the routing key) and the upstream base URL (the -// source of the MITM allowlist host). -type ProviderRoute struct { - Name string - BaseURL string -} - -// RefreshProvidersFunc returns the live provider set used by Reload to -// rebuild the proxy's routing snapshot. -type RefreshProvidersFunc func(ctx context.Context) ([]ProviderRoute, error) - // Known AI provider hosts. const ( HostAnthropic = "api.anthropic.com" @@ -161,7 +149,7 @@ type Server struct { // providerRouter keeps CONNECT matching and provider lookup in sync. type providerRouter struct { - mitmHosts []string // host:port allowlist for the goproxy condition. + mitmHosts []string // host:port set the goproxy condition matches against. nameByHost map[string]string // lowercase hostname -> provider name. } @@ -218,15 +206,8 @@ type Options struct { // CertStore is an optional certificate cache for MITM. If nil, a default // cache is created. Exposed for testing. CertStore goproxy.CertStorage - // DomainAllowlist seeds the boot-time MITM allowlist. Production - // callers should leave this empty and rely on RefreshProviders; - // tests use it to skip the refresh round-trip. - DomainAllowlist []string - // AIBridgeProviderFromHost seeds the boot-time host -> provider - // name mapping. Required iff DomainAllowlist is non-empty. - AIBridgeProviderFromHost func(host string) string // UpstreamProxy is the URL of an upstream HTTP proxy to chain tunneled - // (non-allowlisted) requests through. If empty, tunneled requests connect + // (non-provider-host) requests through. If empty, tunneled requests connect // directly to their destinations. // Format: http://[user:pass@]host:port or https://[user:pass@]host:port UpstreamProxy string @@ -249,7 +230,7 @@ type Options struct { // If nil, metrics will not be recorded. Metrics *Metrics // RefreshProviders, when set, is invoked by Server.Reload to fetch - // the live provider snapshot used to derive the MITM allowlist and + // the live provider snapshot used to derive the MITM host set and // host -> provider-name routing. Nil disables hot-reload. RefreshProviders RefreshProvidersFunc } @@ -296,14 +277,6 @@ func New(ctx context.Context, logger slog.Logger, opts Options) (*Server, error) allowedPorts = []string{"80", "443"} } - // Build the boot-time router from DomainAllowlist + the lookup fn. - // Both empty is fine: the server fails closed (no MITM until - // Reload populates the router from the database). - bootRouter, err := buildBootRouter(opts.DomainAllowlist, opts.AIBridgeProviderFromHost, allowedPorts) - if err != nil { - return nil, err - } - // Parse configured exceptions to the blocked IP ranges. allowedPrivateRanges := make([]net.IPNet, 0, len(opts.AllowedPrivateCIDRs)) for _, cidr := range opts.AllowedPrivateCIDRs { @@ -352,13 +325,13 @@ func New(ctx context.Context, logger slog.Logger, opts Options) (*Server, error) newDumper: opts.NewDumper, metrics: opts.Metrics, } - // Seed the boot-time router from the constructor inputs so the - // proxy can serve immediately. Reload may swap this snapshot at any - // point after construction. - srv.providerRouter.Store(bootRouter) + // Start with an empty router; the first Reload populates it from + // the configured provider source. The proxy fails closed (no MITM) + // until that happens. + srv.providerRouter.Store(emptyProviderRouter) - // Configure upstream proxy for tunneled (non-allowlisted) CONNECT requests. - // Allowlisted domains are MITM'd and forwarded to aibridge directly, + // Configure upstream proxy for tunneled (non-provider-host) CONNECT requests. + // Provider-host domains are MITM'd and forwarded to aibridge directly, // bypassing the upstream proxy. if opts.UpstreamProxy != "" { upstreamURL, err := url.Parse(opts.UpstreamProxy) @@ -443,7 +416,7 @@ func New(ctx context.Context, logger slog.Logger, opts Options) (*Server, error) // Reject CONNECT requests to non-standard ports. proxy.OnRequest().HandleConnectFunc(srv.portMiddleware(allowedPorts)) - // Apply MITM with authentication only to allowlisted hosts. The host + // Apply MITM with authentication only to provider hosts. The host // list is loaded from the atomic router on every CONNECT so a // Reload while inflight requests are in progress takes effect on // the next CONNECT without touching the already-MITM'd ones. @@ -452,9 +425,9 @@ func New(ctx context.Context, logger slog.Logger, opts Options) (*Server, error) srv.authMiddleware, ) - // Tunnel CONNECT requests for non-allowlisted domains directly to their destination. + // Tunnel CONNECT requests for non-provider-host domains directly to their destination. // goproxy calls handlers in registration order: this must come after the MITM handler - // so it only handles requests that weren't matched by the allowlist. + // so it only handles requests that weren't matched as provider hosts. proxy.OnRequest().HandleConnectFunc(srv.tunneledMiddleware) // Handle decrypted requests: route to aibridged for known AI providers, or tunnel to original destination. @@ -495,7 +468,6 @@ func New(ctx context.Context, logger slog.Logger, opts Options) (*Server, error) slog.F("listen_addr", listener.Addr().String()), slog.F("tls_listener_enabled", srv.tlsEnabled), slog.F("coder_access_url", coderAccessURL.String()), - slog.F("domain_allowlist", bootRouter.mitmHosts), slog.F("upstream_proxy", opts.UpstreamProxy), slog.F("allowed_private_cidrs", opts.AllowedPrivateCIDRs), slog.F("api_dump_enabled", opts.NewDumper != nil), @@ -810,7 +782,7 @@ func newProxyAuthRequiredResponse(req *http.Request) *http.Response { } } -// tunneledMiddleware is a CONNECT middleware that handles tunneled (non-allowlisted) +// tunneledMiddleware is a CONNECT middleware that handles tunneled (non-provider-host) // connections. These connections are not MITM'd and are tunneled directly to their // destination. This middleware records metrics for tunneled CONNECT sessions. func (s *Server) tunneledMiddleware(host string, _ *goproxy.ProxyCtx) (*goproxy.ConnectAction, string) { @@ -946,16 +918,28 @@ func (s *Server) handleRequest(req *http.Request, ctx *goproxy.ProxyCtx) (*http. return req, resp } - if reqCtx.Provider == "" { - // A concurrent Reload can remove the provider after CONNECT - // authentication. The request is MITM'd (decrypted), but without a - // mapping there is no known route to aibridge. Log and forward - // to the original destination as a fallback. - s.logger.Warn(s.ctx, "decrypted request has no provider mapping, passing through", + // Re-validate the CONNECT-time provider against the live router. + // A long-lived CONNECT tunnel can outlive a provider being disabled, + // removed, or renamed: the captured reqCtx.Provider is stale, but + // subsequent decrypted requests would still route to aibridged if we + // trusted it. Look up the provider for the current request's host + // and pass through if the mapping is gone or has changed. + host := req.URL.Hostname() + if host == "" { + host = req.Host + if h, _, splitErr := net.SplitHostPort(host); splitErr == nil { + host = h + } + } + liveProvider := s.loadProviderRouter().providerFromHost(host) + if liveProvider == "" || liveProvider != reqCtx.Provider { + s.logger.Warn(s.ctx, "provider mapping changed or removed since CONNECT, passing through", slog.F("connect_id", reqCtx.ConnectSessionID.String()), slog.F("host", req.Host), slog.F("method", req.Method), slog.F("path", originalPath), + slog.F("connect_provider", reqCtx.Provider), + slog.F("live_provider", liveProvider), ) return req, nil } @@ -1053,8 +1037,13 @@ func injectBYOKHeaderIfNeeded(header http.Header, coderToken string) { } // handleResponse handles responses received from aibridged. -// This is only called for MITM'd requests (allowlisted domains routed through aibridged). -// Tunneled requests (non-allowlisted domains) bypass this handler entirely. +// This is called for every MITM'd request, including the pass-through +// path where handleRequest re-validated the CONNECT-time provider and +// forwarded the request to the original upstream instead of aibridged. +// Pass-through responses are identified by reqCtx.RequestID == uuid.Nil +// (set only when handleRequest routes to aibridged) and are skipped here +// to avoid mislabeled logs and corrupting MITM metrics. +// Tunneled requests (non-provider-host domains) bypass this handler entirely. func (s *Server) handleResponse(resp *http.Response, ctx *goproxy.ProxyCtx) *http.Response { if resp == nil { return nil @@ -1077,6 +1066,14 @@ func (s *Server) handleResponse(resp *http.Response, ctx *goproxy.ProxyCtx) *htt slog.F("status", resp.StatusCode), ) + // Pass-through responses (handleRequest returned without routing to + // aibridged) come from the real upstream. The aibridged-specific log + // and metrics do not apply; the pass-through itself is already logged + // in handleRequest. + if requestID == uuid.Nil { + return resp + } + switch { case resp.StatusCode >= http.StatusInternalServerError: logger.Error(s.ctx, "received error response from aibridged") diff --git a/enterprise/aibridgeproxyd/aibridgeproxyd_test.go b/enterprise/aibridgeproxyd/aibridgeproxyd_test.go index 6b843d8b141..fbf77956a2b 100644 --- a/enterprise/aibridgeproxyd/aibridgeproxyd_test.go +++ b/enterprise/aibridgeproxyd/aibridgeproxyd_test.go @@ -3,6 +3,7 @@ package aibridgeproxyd_test import ( "bufio" "bytes" + "context" "crypto/rand" "crypto/rsa" "crypto/tls" @@ -145,20 +146,19 @@ func generateListenerCert(t *testing.T) (certFile, keyFile string) { } type testProxyConfig struct { - listenAddr string - tlsCertFile string - tlsKeyFile string - coderAccessURL string - allowedPorts []string - certStore *aibridgeproxyd.CertCache - domainAllowlist []string - aibridgeProviderFromHost func(string) string - upstreamProxy string - upstreamProxyCA string - allowedPrivateCIDRs []string - newDumper func(string, string) aibridgeproxyd.RoundTripDumper - metrics *aibridgeproxyd.Metrics - refreshProviders aibridgeproxyd.RefreshProvidersFunc + listenAddr string + tlsCertFile string + tlsKeyFile string + coderAccessURL string + allowedPorts []string + certStore *aibridgeproxyd.CertCache + providers []aibridgeproxyd.ReloadedProvider + upstreamProxy string + upstreamProxyCA string + allowedPrivateCIDRs []string + newDumper func(string, string) aibridgeproxyd.RoundTripDumper + metrics *aibridgeproxyd.Metrics + refreshProviders aibridgeproxyd.RefreshProvidersFunc } type testProxyOption func(*testProxyConfig) @@ -181,15 +181,41 @@ func withCertStore(store *aibridgeproxyd.CertCache) testProxyOption { } } -func withDomainAllowlist(domains ...string) testProxyOption { +// withProviders configures the proxy with the given classified provider +// set. The reload helper synthesizes a RefreshProvidersFunc and the +// router is populated synchronously during newTestProxy before the +// server begins serving. +func withProviders(providers ...aibridgeproxyd.ReloadedProvider) testProxyOption { return func(cfg *testProxyConfig) { - cfg.domainAllowlist = domains + cfg.providers = providers } } -func withAIBridgeProviderFromHost(fn func(string) string) testProxyOption { +// withProviderHosts is a convenience that builds enabled +// ReloadedProvider entries from each host, looking up the well-known +// provider name via testProviderFromHost and falling back to +// "test-provider" for hosts without a well-known mapping. Equivalent +// to passing each entry individually to withProviders. +func withProviderHosts(hosts ...string) testProxyOption { return func(cfg *testProxyConfig) { - cfg.aibridgeProviderFromHost = fn + providers := make([]aibridgeproxyd.ReloadedProvider, 0, len(hosts)) + for _, h := range hosts { + name := testProviderFromHost(h) + if name == "" { + name = "test-provider" + } + host, _, splitErr := net.SplitHostPort(h) + if splitErr != nil { + host = h + } + providers = append(providers, aibridgeproxyd.ReloadedProvider{ + Name: name, + Type: "openai", + Host: strings.ToLower(host), + Status: aibridgeproxyd.ProviderStatusEnabled, + }) + } + cfg.providers = providers } } @@ -264,39 +290,48 @@ func newTestProxy(t *testing.T, opts ...testProxyOption) *aibridgeproxyd.Server t.Helper() cfg := &testProxyConfig{ - listenAddr: "127.0.0.1:0", - coderAccessURL: "http://localhost:3000", - domainAllowlist: []string{"127.0.0.1", "localhost"}, + listenAddr: "127.0.0.1:0", + coderAccessURL: "http://localhost:3000", // Allow 127.0.0.1 by default so test servers, which always listen on // loopback, are reachable. Tests that verify IP blocking override this. allowedPrivateCIDRs: []string{"127.0.0.1/32"}, - aibridgeProviderFromHost: func(host string) string { - return "test-provider" + providers: []aibridgeproxyd.ReloadedProvider{ + {Name: "test-provider", Type: "openai", Host: "127.0.0.1", Status: aibridgeproxyd.ProviderStatusEnabled}, + {Name: "test-provider", Type: "openai", Host: "localhost", Status: aibridgeproxyd.ProviderStatusEnabled}, }, } for _, opt := range opts { opt(cfg) } + // If the test did not supply a RefreshProviders, synthesize one + // that returns the configured providers verbatim. This populates + // the router synchronously below, mirroring how production starts + // up after the first reload completes. + if cfg.refreshProviders == nil { + providers := cfg.providers + cfg.refreshProviders = func(context.Context) (aibridgeproxyd.ProviderReload, error) { + return aibridgeproxyd.ProviderReload{Providers: providers}, nil + } + } + mitmCertFile, mitmKeyFile := getSharedTestMITMCert(t) logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug) aibridgeOpts := aibridgeproxyd.Options{ - ListenAddr: cfg.listenAddr, - TLSCertFile: cfg.tlsCertFile, - TLSKeyFile: cfg.tlsKeyFile, - CoderAccessURL: cfg.coderAccessURL, - MITMCertFile: mitmCertFile, - MITMKeyFile: mitmKeyFile, - AllowedPorts: cfg.allowedPorts, - DomainAllowlist: cfg.domainAllowlist, - AIBridgeProviderFromHost: cfg.aibridgeProviderFromHost, - UpstreamProxy: cfg.upstreamProxy, - UpstreamProxyCA: cfg.upstreamProxyCA, - AllowedPrivateCIDRs: cfg.allowedPrivateCIDRs, - NewDumper: cfg.newDumper, - Metrics: cfg.metrics, - RefreshProviders: cfg.refreshProviders, + ListenAddr: cfg.listenAddr, + TLSCertFile: cfg.tlsCertFile, + TLSKeyFile: cfg.tlsKeyFile, + CoderAccessURL: cfg.coderAccessURL, + MITMCertFile: mitmCertFile, + MITMKeyFile: mitmKeyFile, + AllowedPorts: cfg.allowedPorts, + UpstreamProxy: cfg.upstreamProxy, + UpstreamProxyCA: cfg.upstreamProxyCA, + AllowedPrivateCIDRs: cfg.allowedPrivateCIDRs, + NewDumper: cfg.newDumper, + Metrics: cfg.metrics, + RefreshProviders: cfg.refreshProviders, } if cfg.certStore != nil { aibridgeOpts.CertStore = cfg.certStore @@ -306,6 +341,10 @@ func newTestProxy(t *testing.T, opts ...testProxyOption) *aibridgeproxyd.Server require.NoError(t, err) t.Cleanup(func() { _ = srv.Close() }) + // Populate the router before the server starts handling traffic. + // Production performs the first reload during boot via pubsub. + require.NoError(t, srv.Reload(t.Context())) + // Wait for the proxy server to be ready. proxyAddr := srv.Addr() require.NotEmpty(t, proxyAddr) @@ -444,10 +483,9 @@ func TestNew(t *testing.T) { logger := slogtest.Make(t, nil) _, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ - CoderAccessURL: "http://localhost:3000", - MITMCertFile: mitmCertFile, - MITMKeyFile: mitmKeyFile, - DomainAllowlist: []string{aibridgeproxyd.HostAnthropic, aibridgeproxyd.HostOpenAI}, + CoderAccessURL: "http://localhost:3000", + MITMCertFile: mitmCertFile, + MITMKeyFile: mitmKeyFile, }) require.Error(t, err) require.Contains(t, err.Error(), "listen address is required") @@ -460,11 +498,10 @@ func TestNew(t *testing.T) { logger := slogtest.Make(t, nil) _, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ - ListenAddr: "", - CoderAccessURL: "http://localhost:3000", - MITMCertFile: mitmCertFile, - MITMKeyFile: mitmKeyFile, - DomainAllowlist: []string{aibridgeproxyd.HostAnthropic, aibridgeproxyd.HostOpenAI}, + ListenAddr: "", + CoderAccessURL: "http://localhost:3000", + MITMCertFile: mitmCertFile, + MITMKeyFile: mitmKeyFile, }) require.Error(t, err) require.Contains(t, err.Error(), "listen address is required") @@ -477,12 +514,11 @@ func TestNew(t *testing.T) { logger := slogtest.Make(t, nil) _, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ - ListenAddr: "127.0.0.1:0", - TLSCertFile: "cert.pem", - CoderAccessURL: "http://localhost:3000", - MITMCertFile: mitmCertFile, - MITMKeyFile: mitmKeyFile, - DomainAllowlist: []string{aibridgeproxyd.HostAnthropic, aibridgeproxyd.HostOpenAI}, + ListenAddr: "127.0.0.1:0", + TLSCertFile: "cert.pem", + CoderAccessURL: "http://localhost:3000", + MITMCertFile: mitmCertFile, + MITMKeyFile: mitmKeyFile, }) require.Error(t, err) require.Contains(t, err.Error(), "tls cert file and tls key file must both be set") @@ -495,12 +531,11 @@ func TestNew(t *testing.T) { logger := slogtest.Make(t, nil) _, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ - ListenAddr: "127.0.0.1:0", - TLSKeyFile: "key.pem", - CoderAccessURL: "http://localhost:3000", - MITMCertFile: mitmCertFile, - MITMKeyFile: mitmKeyFile, - DomainAllowlist: []string{aibridgeproxyd.HostAnthropic, aibridgeproxyd.HostOpenAI}, + ListenAddr: "127.0.0.1:0", + TLSKeyFile: "key.pem", + CoderAccessURL: "http://localhost:3000", + MITMCertFile: mitmCertFile, + MITMKeyFile: mitmKeyFile, }) require.Error(t, err) require.Contains(t, err.Error(), "tls cert file and tls key file must both be set") @@ -513,14 +548,12 @@ func TestNew(t *testing.T) { logger := slogtest.Make(t, nil) _, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ - ListenAddr: "127.0.0.1:0", - TLSCertFile: "/nonexistent/cert.pem", - TLSKeyFile: "/nonexistent/key.pem", - CoderAccessURL: "http://localhost:3000", - MITMCertFile: mitmCertFile, - MITMKeyFile: mitmKeyFile, - DomainAllowlist: []string{aibridgeproxyd.HostAnthropic, aibridgeproxyd.HostOpenAI}, - AIBridgeProviderFromHost: testProviderFromHost, + ListenAddr: "127.0.0.1:0", + TLSCertFile: "/nonexistent/cert.pem", + TLSKeyFile: "/nonexistent/key.pem", + CoderAccessURL: "http://localhost:3000", + MITMCertFile: mitmCertFile, + MITMKeyFile: mitmKeyFile, }) require.Error(t, err) require.Contains(t, err.Error(), "load listener TLS certificate") @@ -533,10 +566,9 @@ func TestNew(t *testing.T) { logger := slogtest.Make(t, nil) _, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ - ListenAddr: "127.0.0.1:0", - MITMCertFile: mitmCertFile, - MITMKeyFile: mitmKeyFile, - DomainAllowlist: []string{aibridgeproxyd.HostAnthropic, aibridgeproxyd.HostOpenAI}, + ListenAddr: "127.0.0.1:0", + MITMCertFile: mitmCertFile, + MITMKeyFile: mitmKeyFile, }) require.Error(t, err) require.Contains(t, err.Error(), "coder access URL is required") @@ -549,11 +581,10 @@ func TestNew(t *testing.T) { logger := slogtest.Make(t, nil) _, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ - ListenAddr: "127.0.0.1:0", - CoderAccessURL: " ", - MITMCertFile: mitmCertFile, - MITMKeyFile: mitmKeyFile, - DomainAllowlist: []string{aibridgeproxyd.HostAnthropic, aibridgeproxyd.HostOpenAI}, + ListenAddr: "127.0.0.1:0", + CoderAccessURL: " ", + MITMCertFile: mitmCertFile, + MITMKeyFile: mitmKeyFile, }) require.Error(t, err) require.Contains(t, err.Error(), "coder access URL is required") @@ -566,11 +597,10 @@ func TestNew(t *testing.T) { logger := slogtest.Make(t, nil) _, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ - ListenAddr: "127.0.0.1:0", - CoderAccessURL: "://invalid", - MITMCertFile: mitmCertFile, - MITMKeyFile: mitmKeyFile, - DomainAllowlist: []string{aibridgeproxyd.HostAnthropic, aibridgeproxyd.HostOpenAI}, + ListenAddr: "127.0.0.1:0", + CoderAccessURL: "://invalid", + MITMCertFile: mitmCertFile, + MITMKeyFile: mitmKeyFile, }) require.Error(t, err) require.Contains(t, err.Error(), "invalid coder access URL") @@ -583,12 +613,10 @@ func TestNew(t *testing.T) { logger := slogtest.Make(t, nil) srv, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ - ListenAddr: "127.0.0.1:0", - CoderAccessURL: "http://localhost", - MITMCertFile: mitmCertFile, - MITMKeyFile: mitmKeyFile, - DomainAllowlist: []string{aibridgeproxyd.HostAnthropic}, - AIBridgeProviderFromHost: testProviderFromHost, + ListenAddr: "127.0.0.1:0", + CoderAccessURL: "http://localhost", + MITMCertFile: mitmCertFile, + MITMKeyFile: mitmKeyFile, }) require.NoError(t, err) require.Equal(t, "localhost", srv.CoderAccessURL().Hostname()) @@ -602,12 +630,10 @@ func TestNew(t *testing.T) { logger := slogtest.Make(t, nil) srv, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ - ListenAddr: "127.0.0.1:0", - CoderAccessURL: "https://localhost", - MITMCertFile: mitmCertFile, - MITMKeyFile: mitmKeyFile, - DomainAllowlist: []string{aibridgeproxyd.HostAnthropic}, - AIBridgeProviderFromHost: testProviderFromHost, + ListenAddr: "127.0.0.1:0", + CoderAccessURL: "https://localhost", + MITMCertFile: mitmCertFile, + MITMKeyFile: mitmKeyFile, }) require.NoError(t, err) require.Equal(t, "localhost", srv.CoderAccessURL().Hostname()) @@ -621,12 +647,10 @@ func TestNew(t *testing.T) { logger := slogtest.Make(t, nil) srv, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ - ListenAddr: "127.0.0.1:0", - CoderAccessURL: "http://localhost:3000", - MITMCertFile: mitmCertFile, - MITMKeyFile: mitmKeyFile, - DomainAllowlist: []string{aibridgeproxyd.HostAnthropic}, - AIBridgeProviderFromHost: testProviderFromHost, + ListenAddr: "127.0.0.1:0", + CoderAccessURL: "http://localhost:3000", + MITMCertFile: mitmCertFile, + MITMKeyFile: mitmKeyFile, }) require.NoError(t, err) require.Equal(t, "localhost", srv.CoderAccessURL().Hostname()) @@ -639,10 +663,9 @@ func TestNew(t *testing.T) { logger := slogtest.Make(t, nil) _, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ - ListenAddr: ":0", - CoderAccessURL: "http://localhost:3000", - MITMKeyFile: "key.pem", - DomainAllowlist: []string{aibridgeproxyd.HostAnthropic, aibridgeproxyd.HostOpenAI}, + ListenAddr: ":0", + CoderAccessURL: "http://localhost:3000", + MITMKeyFile: "key.pem", }) require.Error(t, err) require.Contains(t, err.Error(), "cert file and key file are required") @@ -654,10 +677,9 @@ func TestNew(t *testing.T) { logger := slogtest.Make(t, nil) _, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ - ListenAddr: ":0", - CoderAccessURL: "http://localhost:3000", - MITMCertFile: "cert.pem", - DomainAllowlist: []string{aibridgeproxyd.HostAnthropic, aibridgeproxyd.HostOpenAI}, + ListenAddr: ":0", + CoderAccessURL: "http://localhost:3000", + MITMCertFile: "cert.pem", }) require.Error(t, err) require.Contains(t, err.Error(), "cert file and key file are required") @@ -669,70 +691,33 @@ func TestNew(t *testing.T) { logger := slogtest.Make(t, nil) _, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ - ListenAddr: ":0", - CoderAccessURL: "http://localhost:3000", - MITMCertFile: "/nonexistent/cert.pem", - MITMKeyFile: "/nonexistent/key.pem", - DomainAllowlist: []string{aibridgeproxyd.HostAnthropic, aibridgeproxyd.HostOpenAI}, - AIBridgeProviderFromHost: testProviderFromHost, + ListenAddr: ":0", + CoderAccessURL: "http://localhost:3000", + MITMCertFile: "/nonexistent/cert.pem", + MITMKeyFile: "/nonexistent/key.pem", }) require.Error(t, err) require.Contains(t, err.Error(), "failed to load MITM certificate") }) - t.Run("MissingDomainAllowlist", func(t *testing.T) { - t.Parallel() - - mitmCertFile, mitmKeyFile := getSharedTestMITMCert(t) - logger := slogtest.Make(t, nil) - - srv, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ - ListenAddr: ":0", - CoderAccessURL: "http://localhost:3000", - MITMCertFile: mitmCertFile, - MITMKeyFile: mitmKeyFile, - AIBridgeProviderFromHost: testProviderFromHost, - }) - require.NoError(t, err) - t.Cleanup(func() { _ = srv.Close() }) - }) - - t.Run("EmptyDomainAllowlist", func(t *testing.T) { - t.Parallel() - - mitmCertFile, mitmKeyFile := getSharedTestMITMCert(t) - logger := slogtest.Make(t, nil) - - srv, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ - ListenAddr: ":0", - CoderAccessURL: "http://localhost:3000", - MITMCertFile: mitmCertFile, - MITMKeyFile: mitmKeyFile, - DomainAllowlist: []string{""}, - AIBridgeProviderFromHost: testProviderFromHost, - }) - require.NoError(t, err) - t.Cleanup(func() { _ = srv.Close() }) - }) - - t.Run("InvalidDomainAllowlist", func(t *testing.T) { + t.Run("InvalidUpstreamProxy", func(t *testing.T) { t.Parallel() mitmCertFile, mitmKeyFile := getSharedTestMITMCert(t) logger := slogtest.Make(t, nil) _, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ - ListenAddr: "127.0.0.1:0", - CoderAccessURL: "http://localhost:3000", - MITMCertFile: mitmCertFile, - MITMKeyFile: mitmKeyFile, - DomainAllowlist: []string{"[invalid:domain"}, + ListenAddr: "127.0.0.1:0", + CoderAccessURL: "http://localhost:3000", + MITMCertFile: mitmCertFile, + MITMKeyFile: mitmKeyFile, + UpstreamProxy: "://invalid-url", }) require.Error(t, err) - require.Contains(t, err.Error(), "invalid domain") + require.Contains(t, err.Error(), "invalid upstream proxy URL") }) - t.Run("DomainWithNonAllowedPort", func(t *testing.T) { + t.Run("UpstreamProxyCAFileNotFound", func(t *testing.T) { t.Parallel() mitmCertFile, mitmKeyFile := getSharedTestMITMCert(t) @@ -743,64 +728,8 @@ func TestNew(t *testing.T) { CoderAccessURL: "http://localhost:3000", MITMCertFile: mitmCertFile, MITMKeyFile: mitmKeyFile, - DomainAllowlist: []string{"api.anthropic.com:8443"}, - }) - require.Error(t, err) - require.Contains(t, err.Error(), "invalid port in domain") - }) - - t.Run("AllowlistWithoutProviderMapping", func(t *testing.T) { - t.Parallel() - - mitmCertFile, mitmKeyFile := getSharedTestMITMCert(t) - logger := slogtest.Make(t, nil) - - _, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ - ListenAddr: "127.0.0.1:0", - CoderAccessURL: "http://localhost:3000", - MITMCertFile: mitmCertFile, - MITMKeyFile: mitmKeyFile, - DomainAllowlist: []string{"unknown.example.com"}, - AIBridgeProviderFromHost: testProviderFromHost, - }) - require.Error(t, err) - require.Contains(t, err.Error(), `domain "unknown.example.com" is in allowlist but has no provider mapping`) - }) - - t.Run("InvalidUpstreamProxy", func(t *testing.T) { - t.Parallel() - - mitmCertFile, mitmKeyFile := getSharedTestMITMCert(t) - logger := slogtest.Make(t, nil) - - _, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ - ListenAddr: "127.0.0.1:0", - CoderAccessURL: "http://localhost:3000", - MITMCertFile: mitmCertFile, - MITMKeyFile: mitmKeyFile, - DomainAllowlist: []string{aibridgeproxyd.HostAnthropic, aibridgeproxyd.HostOpenAI}, - AIBridgeProviderFromHost: testProviderFromHost, - UpstreamProxy: "://invalid-url", - }) - require.Error(t, err) - require.Contains(t, err.Error(), "invalid upstream proxy URL") - }) - - t.Run("UpstreamProxyCAFileNotFound", func(t *testing.T) { - t.Parallel() - - mitmCertFile, mitmKeyFile := getSharedTestMITMCert(t) - logger := slogtest.Make(t, nil) - - _, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ - ListenAddr: "127.0.0.1:0", - CoderAccessURL: "http://localhost:3000", - MITMCertFile: mitmCertFile, - MITMKeyFile: mitmKeyFile, - DomainAllowlist: []string{aibridgeproxyd.HostAnthropic, aibridgeproxyd.HostOpenAI}, - AIBridgeProviderFromHost: testProviderFromHost, - UpstreamProxy: "https://proxy.example.com:8080", - UpstreamProxyCA: "/nonexistent/ca.pem", + UpstreamProxy: "https://proxy.example.com:8080", + UpstreamProxyCA: "/nonexistent/ca.pem", }) require.Error(t, err) require.Contains(t, err.Error(), "failed to read upstream proxy CA certificate") @@ -813,13 +742,11 @@ func TestNew(t *testing.T) { logger := slogtest.Make(t, nil) _, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ - ListenAddr: "127.0.0.1:0", - CoderAccessURL: "http://localhost:3000", - MITMCertFile: mitmCertFile, - MITMKeyFile: mitmKeyFile, - DomainAllowlist: []string{aibridgeproxyd.HostAnthropic, aibridgeproxyd.HostOpenAI}, - AIBridgeProviderFromHost: testProviderFromHost, - UpstreamProxy: "http://:@proxy.example.com:8080", + ListenAddr: "127.0.0.1:0", + CoderAccessURL: "http://localhost:3000", + MITMCertFile: mitmCertFile, + MITMKeyFile: mitmKeyFile, + UpstreamProxy: "http://:@proxy.example.com:8080", }) require.Error(t, err) require.Contains(t, err.Error(), "invalid credentials: both username and password are empty") @@ -832,13 +759,11 @@ func TestNew(t *testing.T) { logger := slogtest.Make(t, nil) _, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ - ListenAddr: "127.0.0.1:0", - CoderAccessURL: "http://localhost:3000", - MITMCertFile: mitmCertFile, - MITMKeyFile: mitmKeyFile, - DomainAllowlist: []string{aibridgeproxyd.HostAnthropic, aibridgeproxyd.HostOpenAI}, - AIBridgeProviderFromHost: testProviderFromHost, - AllowedPrivateCIDRs: []string{"not-a-cidr"}, + ListenAddr: "127.0.0.1:0", + CoderAccessURL: "http://localhost:3000", + MITMCertFile: mitmCertFile, + MITMKeyFile: mitmKeyFile, + AllowedPrivateCIDRs: []string{"not-a-cidr"}, }) require.Error(t, err) require.Contains(t, err.Error(), "invalid allowed private CIDR") @@ -851,12 +776,10 @@ func TestNew(t *testing.T) { logger := slogtest.Make(t, nil) srv, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ - ListenAddr: "127.0.0.1:0", - CoderAccessURL: "http://localhost:3000", - MITMCertFile: mitmCertFile, - MITMKeyFile: mitmKeyFile, - DomainAllowlist: []string{aibridgeproxyd.HostAnthropic, aibridgeproxyd.HostOpenAI}, - AIBridgeProviderFromHost: testProviderFromHost, + ListenAddr: "127.0.0.1:0", + CoderAccessURL: "http://localhost:3000", + MITMCertFile: mitmCertFile, + MITMKeyFile: mitmKeyFile, }) require.NoError(t, err) require.NotNil(t, srv) @@ -870,14 +793,12 @@ func TestNew(t *testing.T) { logger := slogtest.Make(t, nil) srv, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ - ListenAddr: "127.0.0.1:0", - TLSCertFile: listenerCertFile, - TLSKeyFile: listenerKeyFile, - CoderAccessURL: "http://localhost:3000", - MITMCertFile: mitmCertFile, - MITMKeyFile: mitmKeyFile, - DomainAllowlist: []string{aibridgeproxyd.HostAnthropic, aibridgeproxyd.HostOpenAI}, - AIBridgeProviderFromHost: testProviderFromHost, + ListenAddr: "127.0.0.1:0", + TLSCertFile: listenerCertFile, + TLSKeyFile: listenerKeyFile, + CoderAccessURL: "http://localhost:3000", + MITMCertFile: mitmCertFile, + MITMKeyFile: mitmKeyFile, }) require.NoError(t, err) require.NotNil(t, srv) @@ -890,13 +811,11 @@ func TestNew(t *testing.T) { logger := slogtest.Make(t, nil) srv, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ - ListenAddr: "127.0.0.1:0", - CoderAccessURL: "http://localhost:3000", - MITMCertFile: mitmCertFile, - MITMKeyFile: mitmKeyFile, - DomainAllowlist: []string{aibridgeproxyd.HostAnthropic, aibridgeproxyd.HostOpenAI}, - AIBridgeProviderFromHost: testProviderFromHost, - UpstreamProxy: "http://proxy.example.com:8080", + ListenAddr: "127.0.0.1:0", + CoderAccessURL: "http://localhost:3000", + MITMCertFile: mitmCertFile, + MITMKeyFile: mitmKeyFile, + UpstreamProxy: "http://proxy.example.com:8080", }) require.NoError(t, err) require.NotNil(t, srv) @@ -910,14 +829,12 @@ func TestNew(t *testing.T) { // Use the shared MITM certificate as the upstream proxy CA (it's a valid PEM cert) srv, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ - ListenAddr: "127.0.0.1:0", - CoderAccessURL: "http://localhost:3000", - MITMCertFile: mitmCertFile, - MITMKeyFile: mitmKeyFile, - DomainAllowlist: []string{aibridgeproxyd.HostAnthropic, aibridgeproxyd.HostOpenAI}, - AIBridgeProviderFromHost: testProviderFromHost, - UpstreamProxy: "https://proxy.example.com:8080", - UpstreamProxyCA: mitmCertFile, + ListenAddr: "127.0.0.1:0", + CoderAccessURL: "http://localhost:3000", + MITMCertFile: mitmCertFile, + MITMKeyFile: mitmKeyFile, + UpstreamProxy: "https://proxy.example.com:8080", + UpstreamProxyCA: mitmCertFile, }) require.NoError(t, err) require.NotNil(t, srv) @@ -930,13 +847,11 @@ func TestNew(t *testing.T) { logger := slogtest.Make(t, nil) srv, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ - ListenAddr: "127.0.0.1:0", - CoderAccessURL: "http://localhost:3000", - MITMCertFile: mitmCertFile, - MITMKeyFile: mitmKeyFile, - DomainAllowlist: []string{aibridgeproxyd.HostAnthropic, aibridgeproxyd.HostOpenAI}, - AIBridgeProviderFromHost: testProviderFromHost, - UpstreamProxy: "http://proxyuser:proxypass@proxy.example.com:8080", + ListenAddr: "127.0.0.1:0", + CoderAccessURL: "http://localhost:3000", + MITMCertFile: mitmCertFile, + MITMKeyFile: mitmKeyFile, + UpstreamProxy: "http://proxyuser:proxypass@proxy.example.com:8080", }) require.NoError(t, err) require.NotNil(t, srv) @@ -949,13 +864,11 @@ func TestNew(t *testing.T) { logger := slogtest.Make(t, nil) srv, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ - ListenAddr: "127.0.0.1:0", - CoderAccessURL: "http://localhost:3000", - MITMCertFile: mitmCertFile, - MITMKeyFile: mitmKeyFile, - DomainAllowlist: []string{aibridgeproxyd.HostAnthropic, aibridgeproxyd.HostOpenAI}, - AIBridgeProviderFromHost: testProviderFromHost, - UpstreamProxy: "http://proxyuser:@proxy.example.com:8080", + ListenAddr: "127.0.0.1:0", + CoderAccessURL: "http://localhost:3000", + MITMCertFile: mitmCertFile, + MITMKeyFile: mitmKeyFile, + UpstreamProxy: "http://proxyuser:@proxy.example.com:8080", }) require.NoError(t, err) require.NotNil(t, srv) @@ -969,13 +882,11 @@ func TestNew(t *testing.T) { // Username only (no colon) should also succeed (password is optional) srv, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ - ListenAddr: "127.0.0.1:0", - CoderAccessURL: "http://localhost:3000", - MITMCertFile: mitmCertFile, - MITMKeyFile: mitmKeyFile, - DomainAllowlist: []string{aibridgeproxyd.HostAnthropic, aibridgeproxyd.HostOpenAI}, - AIBridgeProviderFromHost: testProviderFromHost, - UpstreamProxy: "http://proxyuser@proxy.example.com:8080", + ListenAddr: "127.0.0.1:0", + CoderAccessURL: "http://localhost:3000", + MITMCertFile: mitmCertFile, + MITMKeyFile: mitmKeyFile, + UpstreamProxy: "http://proxyuser@proxy.example.com:8080", }) require.NoError(t, err) require.NotNil(t, srv) @@ -988,13 +899,11 @@ func TestNew(t *testing.T) { logger := slogtest.Make(t, nil) srv, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ - ListenAddr: "127.0.0.1:0", - CoderAccessURL: "http://localhost:3000", - MITMCertFile: mitmCertFile, - MITMKeyFile: mitmKeyFile, - DomainAllowlist: []string{aibridgeproxyd.HostAnthropic, aibridgeproxyd.HostOpenAI}, - AIBridgeProviderFromHost: testProviderFromHost, - UpstreamProxy: "http://:proxypass@proxy.example.com:8080", + ListenAddr: "127.0.0.1:0", + CoderAccessURL: "http://localhost:3000", + MITMCertFile: mitmCertFile, + MITMKeyFile: mitmKeyFile, + UpstreamProxy: "http://:proxypass@proxy.example.com:8080", }) require.NoError(t, err) require.NotNil(t, srv) @@ -1011,13 +920,11 @@ func TestNew(t *testing.T) { metrics := aibridgeproxyd.NewMetrics(reg) srv, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ - ListenAddr: "127.0.0.1:0", - CoderAccessURL: "http://localhost:3000", - MITMCertFile: mitmCertFile, - MITMKeyFile: mitmKeyFile, - DomainAllowlist: []string{aibridgeproxyd.HostAnthropic, aibridgeproxyd.HostOpenAI}, - AIBridgeProviderFromHost: testProviderFromHost, - Metrics: metrics, + ListenAddr: "127.0.0.1:0", + CoderAccessURL: "http://localhost:3000", + MITMCertFile: mitmCertFile, + MITMKeyFile: mitmKeyFile, + Metrics: metrics, }) require.NoError(t, err) require.NotNil(t, srv) @@ -1030,13 +937,11 @@ func TestNew(t *testing.T) { logger := slogtest.Make(t, nil) srv, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ - ListenAddr: "127.0.0.1:0", - CoderAccessURL: "http://localhost:3000", - MITMCertFile: mitmCertFile, - MITMKeyFile: mitmKeyFile, - DomainAllowlist: []string{aibridgeproxyd.HostAnthropic, aibridgeproxyd.HostOpenAI}, - AIBridgeProviderFromHost: testProviderFromHost, - AllowedPrivateCIDRs: []string{"127.0.0.1/32"}, + ListenAddr: "127.0.0.1:0", + CoderAccessURL: "http://localhost:3000", + MITMCertFile: mitmCertFile, + MITMKeyFile: mitmKeyFile, + AllowedPrivateCIDRs: []string{"127.0.0.1/32"}, }) require.NoError(t, err) require.NotNil(t, srv) @@ -1053,12 +958,10 @@ func TestClose(t *testing.T) { logger := slogtest.Make(t, nil) srv, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ - ListenAddr: "127.0.0.1:0", - CoderAccessURL: "http://localhost:3000", - MITMCertFile: mitmCertFile, - MITMKeyFile: mitmKeyFile, - DomainAllowlist: []string{aibridgeproxyd.HostAnthropic, aibridgeproxyd.HostOpenAI}, - AIBridgeProviderFromHost: testProviderFromHost, + ListenAddr: "127.0.0.1:0", + CoderAccessURL: "http://localhost:3000", + MITMCertFile: mitmCertFile, + MITMKeyFile: mitmKeyFile, }) require.NoError(t, err) @@ -1081,13 +984,11 @@ func TestClose(t *testing.T) { metrics := aibridgeproxyd.NewMetrics(reg) srv, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ - ListenAddr: "127.0.0.1:0", - CoderAccessURL: "http://localhost:3000", - MITMCertFile: mitmCertFile, - MITMKeyFile: mitmKeyFile, - DomainAllowlist: []string{aibridgeproxyd.HostAnthropic, aibridgeproxyd.HostOpenAI}, - AIBridgeProviderFromHost: testProviderFromHost, - Metrics: metrics, + ListenAddr: "127.0.0.1:0", + CoderAccessURL: "http://localhost:3000", + MITMCertFile: mitmCertFile, + MITMKeyFile: mitmKeyFile, + Metrics: metrics, }) require.NoError(t, err) @@ -1110,19 +1011,19 @@ func TestProxy_CertCaching(t *testing.T) { t.Parallel() tests := []struct { - name string - domainAllowlist []string - tunneled bool + name string + providerHosts []string + tunneled bool }{ { - name: "AllowlistedDomainCached", - domainAllowlist: nil, // will use targetURL.Hostname() - tunneled: false, + name: "ProviderHostCached", + providerHosts: nil, // will use targetURL.Hostname() + tunneled: false, }, { - name: "NonAllowlistedDomainNotCached", - domainAllowlist: []string{"other.example.com"}, - tunneled: true, + name: "NonProviderHostNotCached", + providerHosts: []string{"other.example.com"}, + tunneled: true, }, } @@ -1135,7 +1036,7 @@ func TestProxy_CertCaching(t *testing.T) { w.WriteHeader(http.StatusOK) }) - // Create a mock aibridged server for allowlisted (MITM'd) requests. + // Create a mock aibridged server for provider-host (MITM'd) requests. aibridgedServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })) @@ -1144,10 +1045,10 @@ func TestProxy_CertCaching(t *testing.T) { // Create a cert cache so we can inspect it after the request. certCache := aibridgeproxyd.NewCertCache() - // Configure domain allowlist. - domainAllowlist := tt.domainAllowlist - if domainAllowlist == nil { - domainAllowlist = []string{targetURL.Hostname()} + // Configure provider hosts. + providerHosts := tt.providerHosts + if providerHosts == nil { + providerHosts = []string{targetURL.Hostname()} } // Start the proxy server with the certificate cache. @@ -1155,7 +1056,7 @@ func TestProxy_CertCaching(t *testing.T) { withCoderAccessURL(aibridgedServer.URL), withAllowedPorts(targetURL.Port()), withCertStore(certCache), - withDomainAllowlist(domainAllowlist...), + withProviderHosts(providerHosts...), ) // Build the cert pool for the client to trust: @@ -1189,7 +1090,7 @@ func TestProxy_CertCaching(t *testing.T) { if tt.tunneled { // Certificate should NOT have been cached since request was tunneled. - require.Equal(t, 1, genCalls, "certificate should NOT have been cached for non-allowlisted domain") + require.Equal(t, 1, genCalls, "certificate should NOT have been cached for non-provider-host") } else { // Certificate should have been cached during MITM. require.Equal(t, 0, genCalls, "certificate should have been cached during request") @@ -1233,7 +1134,7 @@ func TestProxy_PortValidation(t *testing.T) { _, _ = w.Write([]byte("hello from target")) }) - // Create a mock aibridged server for allowlisted (MITM'd) requests. + // Create a mock aibridged server for provider-host (MITM'd) requests. aibridgedServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("hello from aibridged")) @@ -1244,7 +1145,7 @@ func TestProxy_PortValidation(t *testing.T) { srv := newTestProxy(t, withCoderAccessURL(aibridgedServer.URL), withAllowedPorts(tt.allowedPorts(targetURL)...), - withDomainAllowlist(targetURL.Hostname()), + withProviderHosts(targetURL.Hostname()), ) // Make a request through the proxy to the target server. @@ -1309,7 +1210,7 @@ func TestProxy_Authentication(t *testing.T) { _, _ = w.Write([]byte("hello from target")) }) - // Create a mock aibridged server for allowlisted (MITM'd) requests. + // Create a mock aibridged server for provider-host (MITM'd) requests. aibridgedServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("hello from aibridged")) @@ -1320,7 +1221,7 @@ func TestProxy_Authentication(t *testing.T) { srv := newTestProxy(t, withCoderAccessURL(aibridgedServer.URL), withAllowedPorts(targetURL.Port()), - withDomainAllowlist(targetURL.Hostname()), + withProviderHosts(targetURL.Hostname()), ) if tt.expectSuccess { @@ -1365,18 +1266,18 @@ func TestProxy_MITM(t *testing.T) { t.Parallel() tests := []struct { - name string - domainAllowlist []string - allowedPorts []string - buildTargetURL func(tunneledURL *url.URL) (string, error) - tunneled bool - expectedPath string - provider string + name string + providerHosts []string + allowedPorts []string + buildTargetURL func(tunneledURL *url.URL) (string, error) + tunneled bool + expectedPath string + provider string }{ { - name: "MitmdAnthropic", - domainAllowlist: []string{aibridgeproxyd.HostAnthropic}, - allowedPorts: []string{"443"}, + name: "MitmdAnthropic", + providerHosts: []string{aibridgeproxyd.HostAnthropic}, + allowedPorts: []string{"443"}, buildTargetURL: func(_ *url.URL) (string, error) { return "https://api.anthropic.com/v1/messages", nil }, @@ -1384,9 +1285,9 @@ func TestProxy_MITM(t *testing.T) { provider: "anthropic", }, { - name: "MitmdAnthropicNonDefaultPort", - domainAllowlist: []string{aibridgeproxyd.HostAnthropic}, - allowedPorts: []string{"8443"}, + name: "MitmdAnthropicNonDefaultPort", + providerHosts: []string{aibridgeproxyd.HostAnthropic}, + allowedPorts: []string{"8443"}, buildTargetURL: func(_ *url.URL) (string, error) { return "https://api.anthropic.com:8443/v1/messages", nil }, @@ -1394,9 +1295,9 @@ func TestProxy_MITM(t *testing.T) { provider: "anthropic", }, { - name: "MitmdOpenAI", - domainAllowlist: []string{aibridgeproxyd.HostOpenAI}, - allowedPorts: []string{"443"}, + name: "MitmdOpenAI", + providerHosts: []string{aibridgeproxyd.HostOpenAI}, + allowedPorts: []string{"443"}, buildTargetURL: func(_ *url.URL) (string, error) { return "https://api.openai.com/v1/chat/completions", nil }, @@ -1404,9 +1305,9 @@ func TestProxy_MITM(t *testing.T) { provider: "openai", }, { - name: "MitmdOpenAINonDefaultPort", - domainAllowlist: []string{aibridgeproxyd.HostOpenAI}, - allowedPorts: []string{"8443"}, + name: "MitmdOpenAINonDefaultPort", + providerHosts: []string{aibridgeproxyd.HostOpenAI}, + allowedPorts: []string{"8443"}, buildTargetURL: func(_ *url.URL) (string, error) { return "https://api.openai.com:8443/v1/chat/completions", nil }, @@ -1414,9 +1315,9 @@ func TestProxy_MITM(t *testing.T) { provider: "openai", }, { - name: "TunneledUnknownHost", - domainAllowlist: []string{aibridgeproxyd.HostAnthropic, aibridgeproxyd.HostOpenAI}, - allowedPorts: nil, // will use tunneledURL.Port() + name: "TunneledUnknownHost", + providerHosts: []string{aibridgeproxyd.HostAnthropic, aibridgeproxyd.HostOpenAI}, + allowedPorts: nil, // will use tunneledURL.Port() buildTargetURL: func(tunneledURL *url.URL) (string, error) { return url.JoinPath(tunneledURL.String(), "/some/path") }, @@ -1458,18 +1359,17 @@ func TestProxy_MITM(t *testing.T) { allowedPorts = []string{tunneledURL.Port()} } - // Configure domain allowlist. - domainAllowlist := tt.domainAllowlist - if domainAllowlist == nil { - domainAllowlist = []string{tunneledURL.Hostname()} + // Configure provider hosts. + providerHosts := tt.providerHosts + if providerHosts == nil { + providerHosts = []string{tunneledURL.Hostname()} } // Start the proxy server pointing to our mock aibridged. srv := newTestProxy(t, withCoderAccessURL(aibridgedServer.URL), withAllowedPorts(allowedPorts...), - withDomainAllowlist(domainAllowlist...), - withAIBridgeProviderFromHost(testProviderFromHost), + withProviderHosts(providerHosts...), withMetrics(metrics), ) @@ -1607,8 +1507,7 @@ func TestProxy_MITM_BYOKInjection(t *testing.T) { srv := newTestProxy(t, withCoderAccessURL(aibridgedServer.URL), - withDomainAllowlist(aibridgeproxyd.HostCopilot), - withAIBridgeProviderFromHost(testProviderFromHost), + withProviderHosts(aibridgeproxyd.HostCopilot), ) certPool := getProxyCertPool(t) @@ -1687,8 +1586,8 @@ func TestListenerTLS(t *testing.T) { withAllowedPorts(targetURL.Port()), ) if tt.tunneled { - // Use a domain allowlist that excludes the target server so requests are tunneled. - proxyOpts = append(proxyOpts, withDomainAllowlist(aibridgeproxyd.HostAnthropic, aibridgeproxyd.HostOpenAI)) + // Configure provider hosts that exclude the target server so requests are tunneled. + proxyOpts = append(proxyOpts, withProviderHosts(aibridgeproxyd.HostAnthropic, aibridgeproxyd.HostOpenAI)) } srv := newTestProxy(t, proxyOpts...) @@ -1791,14 +1690,10 @@ func TestServeCACert_CompoundPEM(t *testing.T) { logger := slogtest.Make(t, nil) srv, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ - ListenAddr: "127.0.0.1:0", - CoderAccessURL: "http://localhost:3000", - MITMCertFile: compoundCertFile, - MITMKeyFile: keyFile, - DomainAllowlist: []string{"127.0.0.1", "localhost"}, - AIBridgeProviderFromHost: func(host string) string { - return "test-provider" - }, + ListenAddr: "127.0.0.1:0", + CoderAccessURL: "http://localhost:3000", + MITMCertFile: compoundCertFile, + MITMKeyFile: keyFile, }) require.NoError(t, err) t.Cleanup(func() { _ = srv.Close() }) @@ -1849,8 +1744,8 @@ func TestUpstreamProxy(t *testing.T) { name string // tunneled determines whether the request should be tunneled through // the upstream proxy (true) or MITM'd by aiproxy (false). - // When true, the target domain is NOT in the allowlist. - // When false, the target domain IS in the allowlist. + // When true, the target domain has no configured provider. + // When false, the target domain has a configured provider. tunneled bool // upstreamProxyTLS determines whether the upstream proxy uses TLS. // When true, aiproxy must be configured with the upstream proxy's CA. @@ -1865,7 +1760,7 @@ func TestUpstreamProxy(t *testing.T) { upstreamProxyAuth string }{ { - name: "NonAllowlistedDomain_TunneledToHTTPUpstreamProxy", + name: "NonProviderHost_TunneledToHTTPUpstreamProxy", tunneled: true, upstreamProxyTLS: false, buildTargetURL: func(finalDestinationURL *url.URL) string { @@ -1873,7 +1768,7 @@ func TestUpstreamProxy(t *testing.T) { }, }, { - name: "NonAllowlistedDomain_TunneledToHTTPSUpstreamProxy", + name: "NonProviderHost_TunneledToHTTPSUpstreamProxy", tunneled: true, upstreamProxyTLS: true, buildTargetURL: func(finalDestinationURL *url.URL) string { @@ -1881,7 +1776,7 @@ func TestUpstreamProxy(t *testing.T) { }, }, { - name: "NonAllowlistedDomain_TunneledToHTTPUpstreamProxyWithAuth", + name: "NonProviderHost_TunneledToHTTPUpstreamProxyWithAuth", tunneled: true, upstreamProxyTLS: false, upstreamProxyAuth: "proxyuser:proxypass", @@ -1890,7 +1785,7 @@ func TestUpstreamProxy(t *testing.T) { }, }, { - name: "NonAllowlistedDomain_TunneledToHTTPUpstreamProxyWithUsernameOnly", + name: "NonProviderHost_TunneledToHTTPUpstreamProxyWithUsernameOnly", tunneled: true, upstreamProxyTLS: false, upstreamProxyAuth: "proxyuser", @@ -1899,7 +1794,7 @@ func TestUpstreamProxy(t *testing.T) { }, }, { - name: "NonAllowlistedDomain_TunneledToHTTPUpstreamProxyWithUsernameAndColon", + name: "NonProviderHost_TunneledToHTTPUpstreamProxyWithUsernameAndColon", tunneled: true, upstreamProxyTLS: false, upstreamProxyAuth: "proxyuser:", @@ -1908,7 +1803,7 @@ func TestUpstreamProxy(t *testing.T) { }, }, { - name: "NonAllowlistedDomain_TunneledToHTTPUpstreamProxyWithTokenAuth", + name: "NonProviderHost_TunneledToHTTPUpstreamProxyWithTokenAuth", tunneled: true, upstreamProxyTLS: false, upstreamProxyAuth: ":proxypass", @@ -1917,7 +1812,7 @@ func TestUpstreamProxy(t *testing.T) { }, }, { - name: "AllowlistedDomain_MITMByAIProxy", + name: "ProviderHost_MITMByAIProxy", tunneled: false, upstreamProxyTLS: false, buildTargetURL: func(_ *url.URL) string { @@ -2057,10 +1952,10 @@ func TestUpstreamProxy(t *testing.T) { parsedTargetURL, err := url.Parse(targetURL) require.NoError(t, err) - // Configure allowlist based on test case: - // - For tunneled requests, api.anthropic.com is in allowlist, but we target a different host. - // - For MITM, api.anthropic.com must be in the allowlist. - domainAllowlist := []string{aibridgeproxyd.HostAnthropic} + // Configure provider hosts based on test case: + // - For tunneled requests, api.anthropic.com has a configured provider, but we target a different host. + // - For MITM, api.anthropic.com must have a configured provider. + providerHosts := []string{aibridgeproxyd.HostAnthropic} // Build upstream proxy URL with optional auth credentials. upstreamProxyURLStr := upstreamProxy.URL @@ -2073,10 +1968,9 @@ func TestUpstreamProxy(t *testing.T) { // Create aiproxy with upstream proxy configured. proxyOpts := []testProxyOption{ withCoderAccessURL(aibridgeServer.URL), - withDomainAllowlist(domainAllowlist...), + withProviderHosts(providerHosts...), withUpstreamProxy(upstreamProxyURLStr), withAllowedPorts("80", "443", parsedTargetURL.Port()), - withAIBridgeProviderFromHost(testProviderFromHost), } if upstreamProxyCAFile != "" { proxyOpts = append(proxyOpts, withUpstreamProxyCA(upstreamProxyCAFile)) @@ -2114,7 +2008,7 @@ func TestUpstreamProxy(t *testing.T) { // Verify the request flow based on test case. if tt.tunneled { require.True(t, upstreamProxyCONNECTReceived, - "upstream proxy should receive CONNECT for non-allowlisted domain") + "upstream proxy should receive CONNECT for non-provider-host") require.Equal(t, finalDestinationURL.Host, upstreamProxyCONNECTHost, "upstream proxy should receive CONNECT to correct host") require.True(t, finalDestinationReceived, @@ -2124,12 +2018,12 @@ func TestUpstreamProxy(t *testing.T) { require.Equal(t, requestBody, finalDestinationBody, "final destination should receive the exact request body") require.False(t, aibridgeReceived, - "aibridge should NOT receive request for non-allowlisted domain") + "aibridge should NOT receive request for non-provider-host") require.Empty(t, aibridgeAuthz, "tunneled requests should not reach aibridge") } else { require.False(t, upstreamProxyCONNECTReceived, - "upstream proxy should NOT receive CONNECT for allowlisted domain") + "upstream proxy should NOT receive CONNECT for provider host") require.True(t, aibridgeReceived, "aibridge should receive the MITM'd request") require.Equal(t, tt.expectedAIBridgePath, aibridgePath, @@ -2141,7 +2035,7 @@ func TestUpstreamProxy(t *testing.T) { require.Equal(t, requestBody, aibridgeBody, "aibridge should receive the exact request body") require.False(t, finalDestinationReceived, - "final destination should NOT receive request for allowlisted domain") + "final destination should NOT receive request for provider host") } // Verify upstream proxy authentication if configured. @@ -2155,7 +2049,7 @@ func TestUpstreamProxy(t *testing.T) { } // TestProxy_MITM_CustomProvider verifies that a non-builtin provider -// (e.g. OpenRouter) whose domain is added to the allowlist is correctly +// (e.g. OpenRouter) whose domain is registered as a provider host is correctly // MITM'd and routed through the proxy to the bridge endpoint. func TestProxy_MITM_CustomProvider(t *testing.T) { t.Parallel() @@ -2177,16 +2071,16 @@ func TestProxy_MITM_CustomProvider(t *testing.T) { })) t.Cleanup(aibridgedServer.Close) - // Wire the custom domain and provider mapping directly, as the - // real daemon would after calling domainsFromProviders. + // Wire the custom domain and provider mapping directly via + // withProviders, equivalent to the snapshot the daemon's Reload + // builds from classified providers in production. srv := newTestProxy(t, withCoderAccessURL(aibridgedServer.URL), - withDomainAllowlist(openrouterDomain), - withAIBridgeProviderFromHost(func(host string) string { - if host == openrouterDomain { - return openrouterProvider - } - return "" + withProviders(aibridgeproxyd.ReloadedProvider{ + Name: openrouterProvider, + Type: "openai", + Host: openrouterDomain, + Status: aibridgeproxyd.ProviderStatusEnabled, }), ) @@ -2307,10 +2201,10 @@ func TestProxy_PrivateIPBlocking(t *testing.T) { // Build the CONNECT target using the configured hostname. connectTarget := fmt.Sprintf("%s:%s", tt.targetHostname, targetURL.Port()) - // Use a domain allowlist that excludes the target so CONNECT requests + // Configure provider hosts that exclude the target so CONNECT requests // go through the tunnel path rather than being MITM'd. opts := []testProxyOption{ - withDomainAllowlist(aibridgeproxyd.HostAnthropic), + withProviderHosts(aibridgeproxyd.HostAnthropic), withAllowedPorts(targetURL.Port()), } @@ -2395,8 +2289,7 @@ func TestProxy_APIDump(t *testing.T) { srv := newTestProxy(t, withCoderAccessURL(aibridgedServer.URL), withAllowedPorts("443"), - withDomainAllowlist(aibridgeproxyd.HostAnthropic), - withAIBridgeProviderFromHost(testProviderFromHost), + withProviderHosts(aibridgeproxyd.HostAnthropic), withNewDumper(func(provider, requestID string) aibridgeproxyd.RoundTripDumper { dumpedProvider = provider dumpedRequestID = requestID @@ -2443,8 +2336,7 @@ func TestProxy_APIDump_ErrorsDoNotAffectProxy(t *testing.T) { srv := newTestProxy(t, withCoderAccessURL(aibridgedServer.URL), withAllowedPorts("443"), - withDomainAllowlist(aibridgeproxyd.HostAnthropic), - withAIBridgeProviderFromHost(testProviderFromHost), + withProviderHosts(aibridgeproxyd.HostAnthropic), withNewDumper(func(_, _ string) aibridgeproxyd.RoundTripDumper { return &failingDumper{} }), diff --git a/enterprise/aibridgeproxyd/reload.go b/enterprise/aibridgeproxyd/reload.go index d235a9be3b0..686bab11306 100644 --- a/enterprise/aibridgeproxyd/reload.go +++ b/enterprise/aibridgeproxyd/reload.go @@ -3,7 +3,6 @@ package aibridgeproxyd import ( "context" "net/http" - "net/url" "slices" "strings" @@ -13,23 +12,72 @@ import ( "cdr.dev/slog/v3" ) +// ProviderStatus describes the lifecycle state of a configured AI +// provider for observability and routing purposes. +type ProviderStatus string + +const ( + // ProviderStatusEnabled means the provider is configured, valid, and + // included in the active routing snapshot. + ProviderStatusEnabled ProviderStatus = "enabled" + // ProviderStatusDisabled means the provider exists in configuration + // but is intentionally turned off by an operator. + ProviderStatusDisabled ProviderStatus = "disabled" + // ProviderStatusError means the provider exists in configuration but + // cannot be routed to because of a validation failure (missing or + // invalid base URL, duplicate host, etc.). + ProviderStatusError ProviderStatus = "error" +) + +// ReloadedProvider is one row from the provider configuration together +// with the outcome of evaluating it for routing. Host is populated only +// when Status == ProviderStatusEnabled; Err is populated only when +// Status == ProviderStatusError. +type ReloadedProvider struct { + Name string + Type string + Host string + Status ProviderStatus + Err error +} + +// ProviderReload is the result of a single refresh pass: every +// configured provider with its classification. +type ProviderReload struct { + Providers []ReloadedProvider +} + +// RefreshProvidersFunc returns the live provider classification used by +// Reload to rebuild the proxy's routing snapshot. +type RefreshProvidersFunc func(ctx context.Context) (ProviderReload, error) + // Reload refreshes proxy routing from the configured provider source. // A refresh failure leaves the previous snapshot in place. func (s *Server) Reload(ctx context.Context) error { if s.refreshProviders == nil { return nil } - providers, err := s.refreshProviders(ctx) + reload, err := s.refreshProviders(ctx) if err != nil { return xerrors.Errorf("refresh ai providers for proxy routing: %w", err) } - router, err := buildProviderRouter(ctx, s.logger, providers, s.allowedPorts) + router, err := buildProviderRouter(reload, s.allowedPorts) if err != nil { - return xerrors.Errorf("build provider router (provider_count=%d): %w", len(providers), err) + return xerrors.Errorf("build provider router (provider_count=%d): %w", len(reload.Providers), err) } s.providerRouter.Store(router) + for _, p := range reload.Providers { + if p.Status == ProviderStatusError { + s.logger.Warn(s.ctx, "provider excluded from routing", + slog.F("provider", p.Name), + slog.Error(p.Err), + ) + } + } s.logger.Debug(s.ctx, "aibridgeproxyd router reloaded", + slog.F("provider_count", len(reload.Providers)), slog.F("mitm_host_count", len(router.mitmHosts)), + slog.F("mitm_hosts", router.mitmHosts), ) return nil } @@ -42,7 +90,7 @@ func (s *Server) loadProviderRouter() *providerRouter { } // mitmHostsCondition returns a goproxy ReqConditionFunc that reads the -// allowlist from the atomic router on every match. Using a closure +// MITM host set from the atomic router on every match. Using a closure // instead of goproxy.ReqHostIs(...) lets Reload affect every later // CONNECT without re-registering handlers. func (s *Server) mitmHostsCondition() goproxy.ReqConditionFunc { @@ -54,35 +102,23 @@ func (s *Server) mitmHostsCondition() goproxy.ReqConditionFunc { } } -// buildProviderRouter constructs a router snapshot from a refreshed -// provider list. First provider wins on duplicate hostnames. -func buildProviderRouter(ctx context.Context, logger slog.Logger, providers []ProviderRoute, allowedPorts []string) (*providerRouter, error) { - nameByHost := make(map[string]string, len(providers)) - var domains []string - for _, p := range providers { - if p.BaseURL == "" { - logger.Warn(ctx, "skipping ai provider without base url", - slog.F("provider_name", p.Name), - ) - continue - } - u, err := url.Parse(p.BaseURL) - if err != nil { - logger.Warn(ctx, "skipping ai provider with invalid base url", - slog.F("provider_name", p.Name), - slog.F("base_url", p.BaseURL), - slog.Error(err), - ) +// buildProviderRouter constructs a router snapshot from a classified +// provider reload. Only providers with Status == ProviderStatusEnabled +// are included in the active routing tables; the refresh function is +// responsible for classifying disabled and errored rows. First entry +// wins on duplicate hostnames as a defense-in-depth measure even though +// the refresh function should mark duplicates as errors. +func buildProviderRouter(reload ProviderReload, allowedPorts []string) (*providerRouter, error) { + nameByHost := make(map[string]string, len(reload.Providers)) + domains := make([]string, 0, len(reload.Providers)) + for _, p := range reload.Providers { + if p.Status != ProviderStatusEnabled { continue } - if u.Hostname() == "" { - logger.Warn(ctx, "skipping ai provider base url without hostname", - slog.F("provider_name", p.Name), - slog.F("base_url", p.BaseURL), - ) + host := strings.ToLower(p.Host) + if host == "" { continue } - host := strings.ToLower(u.Hostname()) if _, exists := nameByHost[host]; exists { continue } @@ -95,30 +131,3 @@ func buildProviderRouter(ctx context.Context, logger slog.Logger, providers []Pr } return &providerRouter{mitmHosts: mitmHosts, nameByHost: nameByHost}, nil } - -// buildBootRouter seeds the providerRouter from the boot-time inputs. -// The lookup function is consulted only for hosts in the allowlist; a -// nil function with an empty allowlist is fine and yields an empty -// router (the proxy fails closed until Reload populates it). -func buildBootRouter(domainAllowlist []string, providerFromHost func(string) string, allowedPorts []string) (*providerRouter, error) { - mitmHosts, err := convertDomainsToHosts(domainAllowlist, allowedPorts) - if err != nil { - return nil, xerrors.Errorf("invalid domain allowlist: %w", err) - } - nameByHost := make(map[string]string, len(domainAllowlist)) - for _, domain := range domainAllowlist { - domain = strings.TrimSpace(strings.ToLower(domain)) - if domain == "" { - continue - } - var name string - if providerFromHost != nil { - name = providerFromHost(domain) - } - if name == "" { - return nil, xerrors.Errorf("domain %q is in allowlist but has no provider mapping", domain) - } - nameByHost[domain] = name - } - return &providerRouter{mitmHosts: mitmHosts, nameByHost: nameByHost}, nil -} diff --git a/enterprise/aibridgeproxyd/reload_internal_test.go b/enterprise/aibridgeproxyd/reload_internal_test.go index 7a8b0f9fae3..fb985445f3e 100644 --- a/enterprise/aibridgeproxyd/reload_internal_test.go +++ b/enterprise/aibridgeproxyd/reload_internal_test.go @@ -12,17 +12,26 @@ import ( "github.com/coder/coder/v2/testutil" ) +func enabledProvider(name, host string) ReloadedProvider { + return ReloadedProvider{ + Name: name, + Type: "openai", + Host: host, + Status: ProviderStatusEnabled, + } +} + func TestServerReloadSwapsProviderRouter(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitShort) - providers := []ProviderRoute{{Name: "old", BaseURL: "https://old.example.com/"}} + reload := ProviderReload{Providers: []ReloadedProvider{enabledProvider("old", "old.example.com")}} srv := &Server{ ctx: ctx, logger: slogtest.Make(t, nil), allowedPorts: []string{"443"}, - refreshProviders: func(context.Context) ([]ProviderRoute, error) { - return providers, nil + refreshProviders: func(context.Context) (ProviderReload, error) { + return reload, nil }, } srv.providerRouter.Store(emptyProviderRouter) @@ -31,7 +40,7 @@ func TestServerReloadSwapsProviderRouter(t *testing.T) { assert.Equal(t, "old", srv.loadProviderRouter().providerFromHost("old.example.com")) assert.Empty(t, srv.loadProviderRouter().providerFromHost("new.example.com")) - providers = []ProviderRoute{{Name: "new", BaseURL: "https://new.example.com/"}} + reload = ProviderReload{Providers: []ReloadedProvider{enabledProvider("new", "new.example.com")}} require.NoError(t, srv.Reload(ctx)) router := srv.loadProviderRouter() @@ -45,17 +54,17 @@ func TestServerReloadPreservesProviderRouterOnRefreshError(t *testing.T) { ctx := testutil.Context(t, testutil.WaitShort) refreshErr := xerrors.New("refresh failed") - providers := []ProviderRoute{{Name: "old", BaseURL: "https://old.example.com/"}} + reload := ProviderReload{Providers: []ReloadedProvider{enabledProvider("old", "old.example.com")}} failRefresh := false srv := &Server{ ctx: ctx, logger: slogtest.Make(t, nil), allowedPorts: []string{"443"}, - refreshProviders: func(context.Context) ([]ProviderRoute, error) { + refreshProviders: func(context.Context) (ProviderReload, error) { if failRefresh { - return nil, refreshErr + return ProviderReload{}, refreshErr } - return providers, nil + return reload, nil }, } srv.providerRouter.Store(emptyProviderRouter) @@ -73,75 +82,84 @@ func TestServerReloadPreservesProviderRouterOnRefreshError(t *testing.T) { assert.Equal(t, []string{"old.example.com:443"}, after.mitmHosts) } -// TestBuildProviderRouter covers the host-and-routing derivation that -// Reload feeds into the providerRouter. +// TestBuildProviderRouter covers the host-and-routing derivation from +// the classified provider reload. func TestBuildProviderRouter(t *testing.T) { t.Parallel() - t.Run("ExtractsHostnames", func(t *testing.T) { + t.Run("IncludesEnabledOnly", func(t *testing.T) { t.Parallel() - providers := []ProviderRoute{ - {Name: "openai", BaseURL: "https://api.openai.com/v1/"}, - {Name: "anthropic", BaseURL: "https://api.anthropic.com/"}, - {Name: "custom", BaseURL: "https://custom-llm.example.com:8443/api"}, - } - - router, err := buildProviderRouter(testutil.Context(t, testutil.WaitShort), slogtest.Make(t, nil), providers, []string{"443"}) + reload := ProviderReload{Providers: []ReloadedProvider{ + enabledProvider("openai", "api.openai.com"), + enabledProvider("anthropic", "api.anthropic.com"), + enabledProvider("custom", "custom-llm.example.com"), + // Host is populated on the non-enabled rows so the Status + // guard, not the empty-host guard, is what excludes them. + {Name: "off", Type: "openai", Host: "disabled.example.com", Status: ProviderStatusDisabled}, + {Name: "bad", Type: "openai", Host: "errored.example.com", Status: ProviderStatusError, Err: xerrors.New("nope")}, + }} + + router, err := buildProviderRouter(reload, []string{"443"}) require.NoError(t, err) assert.Equal(t, "openai", router.providerFromHost("api.openai.com")) assert.Equal(t, "anthropic", router.providerFromHost("api.anthropic.com")) assert.Equal(t, "custom", router.providerFromHost("custom-llm.example.com")) assert.Empty(t, router.providerFromHost("unknown.com")) + assert.Empty(t, router.providerFromHost("disabled.example.com"), + "disabled provider must not be routable even with a populated Host") + assert.Empty(t, router.providerFromHost("errored.example.com"), + "errored provider must not be routable even with a populated Host") assert.Contains(t, router.mitmHosts, "api.openai.com:443") assert.Contains(t, router.mitmHosts, "api.anthropic.com:443") + assert.Len(t, router.mitmHosts, 3) }) - t.Run("DeduplicatesSameHost", func(t *testing.T) { + t.Run("CaseInsensitive", func(t *testing.T) { t.Parallel() - providers := []ProviderRoute{ - {Name: "first", BaseURL: "https://api.example.com/v1"}, - {Name: "second", BaseURL: "https://api.example.com/v2"}, - } + reload := ProviderReload{Providers: []ReloadedProvider{ + {Name: "provider", Type: "openai", Host: "API.Example.COM", Status: ProviderStatusEnabled}, + }} - router, err := buildProviderRouter(testutil.Context(t, testutil.WaitShort), slogtest.Make(t, nil), providers, []string{"443"}) + router, err := buildProviderRouter(reload, []string{"443"}) require.NoError(t, err) - // First provider wins on duplicate host. - assert.Equal(t, "first", router.providerFromHost("api.example.com")) + assert.Equal(t, "provider", router.providerFromHost("API.Example.COM")) + assert.Equal(t, "provider", router.providerFromHost("api.example.com")) }) - t.Run("CaseInsensitive", func(t *testing.T) { + t.Run("DefensiveDeduplicatesSameHost", func(t *testing.T) { t.Parallel() - providers := []ProviderRoute{ - {Name: "provider", BaseURL: "https://API.Example.COM/v1"}, - } + // Refresh function should mark the duplicate as ProviderStatusError; + // buildProviderRouter is defensive and tolerates an enabled duplicate + // by giving the first entry the host (first wins). + reload := ProviderReload{Providers: []ReloadedProvider{ + enabledProvider("first", "api.example.com"), + enabledProvider("second", "api.example.com"), + }} - router, err := buildProviderRouter(testutil.Context(t, testutil.WaitShort), slogtest.Make(t, nil), providers, []string{"443"}) + router, err := buildProviderRouter(reload, []string{"443"}) require.NoError(t, err) - assert.Equal(t, "provider", router.providerFromHost("API.Example.COM")) - assert.Equal(t, "provider", router.providerFromHost("api.example.com")) + assert.Equal(t, "first", router.providerFromHost("api.example.com")) }) - t.Run("SkipsEmptyOrMalformedBaseURL", func(t *testing.T) { + t.Run("SkipsRowsWithEmptyHost", func(t *testing.T) { t.Parallel() - providers := []ProviderRoute{ - {Name: "no-url"}, - {Name: "scheme-only", BaseURL: "https://"}, - {Name: "good", BaseURL: "https://api.good.example.com/"}, - } + reload := ProviderReload{Providers: []ReloadedProvider{ + {Name: "no-host", Type: "openai", Status: ProviderStatusEnabled}, + enabledProvider("good", "api.good.example.com"), + }} - router, err := buildProviderRouter(testutil.Context(t, testutil.WaitShort), slogtest.Make(t, nil), providers, []string{"443"}) + router, err := buildProviderRouter(reload, []string{"443"}) require.NoError(t, err) assert.Equal(t, "good", router.providerFromHost("api.good.example.com")) - assert.Empty(t, router.providerFromHost("scheme-only")) assert.Equal(t, []string{"api.good.example.com:443"}, router.mitmHosts) }) } diff --git a/enterprise/aibridgeproxyd/reload_test.go b/enterprise/aibridgeproxyd/reload_test.go index d51aa5bc98c..e55d45a3725 100644 --- a/enterprise/aibridgeproxyd/reload_test.go +++ b/enterprise/aibridgeproxyd/reload_test.go @@ -5,6 +5,7 @@ import ( "io" "net/http" "net/http/httptest" + "net/url" "slices" "strings" "sync" @@ -55,16 +56,24 @@ func (r *aibridgedRecorder) reset() { r.path = "" } -// providerStore is a mutable [aibridgeproxyd.RefreshProvidersFunc] -// backing for integration tests. set / setErr mutate the snapshot -// returned by the next Reload, mimicking CRUD against the database. +// rawProvider is a (name, base URL) pair representing what the database +// holds before classification, mirroring the ai_providers row shape +// that the production refresh function classifies. +type rawProvider struct { + name string + baseURL string +} + +// providerStore is a mutable RefreshProvidersFunc backing for +// integration tests. set / setErr mutate the snapshot returned by the +// next Reload, mimicking CRUD against the database. type providerStore struct { mu sync.Mutex - providers []aibridgeproxyd.ProviderRoute + providers []rawProvider err error } -func (s *providerStore) set(providers []aibridgeproxyd.ProviderRoute) { +func (s *providerStore) set(providers []rawProvider) { s.mu.Lock() defer s.mu.Unlock() s.providers = providers @@ -77,20 +86,59 @@ func (s *providerStore) setErr(err error) { s.err = err } -func (s *providerStore) refresh(context.Context) ([]aibridgeproxyd.ProviderRoute, error) { +func (s *providerStore) refresh(context.Context) (aibridgeproxyd.ProviderReload, error) { s.mu.Lock() defer s.mu.Unlock() if s.err != nil { - return nil, s.err + return aibridgeproxyd.ProviderReload{}, s.err + } + providers := slices.Clone(s.providers) + reload := aibridgeproxyd.ProviderReload{ + Providers: make([]aibridgeproxyd.ReloadedProvider, 0, len(providers)), + } + seenHost := make(map[string]string, len(providers)) + for _, p := range providers { + reload.Providers = append(reload.Providers, classifyRaw(p, seenHost)) + } + return reload, nil +} + +// classifyRaw mirrors the production classifier in enterprise/cli so +// the reload tests exercise the same validation rules end-to-end. +func classifyRaw(p rawProvider, seenHost map[string]string) aibridgeproxyd.ReloadedProvider { + out := aibridgeproxyd.ReloadedProvider{Name: p.name, Type: "openai"} + if strings.TrimSpace(p.baseURL) == "" { + out.Status = aibridgeproxyd.ProviderStatusError + out.Err = xerrors.New("base url is empty") + return out + } + u, err := url.Parse(p.baseURL) + if err != nil { + out.Status = aibridgeproxyd.ProviderStatusError + out.Err = xerrors.Errorf("invalid base url %q: %w", p.baseURL, err) + return out + } + host := strings.ToLower(u.Hostname()) + if host == "" { + out.Status = aibridgeproxyd.ProviderStatusError + out.Err = xerrors.Errorf("base url %q has no hostname", p.baseURL) + return out } - // Return a copy so callers can't mutate our internal snapshot. - return slices.Clone(s.providers), nil + if claimedBy, taken := seenHost[host]; taken { + out.Status = aibridgeproxyd.ProviderStatusError + out.Err = xerrors.Errorf("hostname %q already claimed by provider %q", host, claimedBy) + return out + } + seenHost[host] = p.name + out.Host = host + out.Status = aibridgeproxyd.ProviderStatusEnabled + return out } -// newReloadTestHarness boots a proxy with an empty boot allowlist and a -// store-backed RefreshProviders. Production wiring is identical: the -// daemon constructs the proxy without a static allowlist and lets -// Reload populate the router from the database. +// newReloadTestHarness boots a proxy with an empty initial router and +// a store-backed RefreshProviders. Production wiring is identical: the +// daemon constructs the proxy without preconfigured provider hosts and +// lets Reload populate the router from the database. func newReloadTestHarness(t *testing.T) *reloadTestHarness { t.Helper() @@ -106,10 +154,6 @@ func newReloadTestHarness(t *testing.T) *reloadTestHarness { srv := newTestProxy(t, withCoderAccessURL(bridged.URL), withAllowedPorts("443"), - // Empty boot allowlist: the router must be populated by Reload, - // matching the production daemon's behavior. - withDomainAllowlist(), - withAIBridgeProviderFromHost(nil), withRefreshProviders(store.refresh), ) @@ -192,16 +236,128 @@ func (h *reloadTestHarness) expectNotRouted(t *testing.T, targetURL string) { "aibridged must not be reached for non-routed host %s", targetURL) } +// TestProxy_StaleTunnelStopsRoutingAfterProviderChange is the +// regression test for a bug where a long-lived CONNECT tunnel that was +// established while a provider was enabled kept routing decrypted +// requests to aibridged after the provider was disabled or renamed. The +// fix re-validates the CONNECT-time provider against the live router on +// every decrypted request and covers both shapes of stale mapping: +// +// - ProviderDisabled: liveProvider == "" (host no longer MITM'd). +// - ProviderRenamed: liveProvider != reqCtx.Provider (host MITM'd, but +// under a new provider name). +func TestProxy_StaleTunnelStopsRoutingAfterProviderChange(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + // applyChange mutates the store to simulate the provider change + // after the initial routed request succeeds. + applyChange func(*providerStore) + // changeDescription is appended to the second-request assertion + // message so a failure points at the exercised branch. + changeDescription string + }{ + { + name: "ProviderDisabled", + applyChange: func(s *providerStore) { s.set(nil) }, + changeDescription: "after alpha was disabled", + }, + { + name: "ProviderRenamed", + applyChange: func(s *providerStore) { + // Same host, new provider name: the live router still + // MITMs alpha.invalid, but as "alpha-v2". The stale + // CONNECT-time name "alpha" no longer matches. + s.set([]rawProvider{ + {name: "alpha-v2", baseURL: "https://alpha.invalid/v1"}, + }) + }, + changeDescription: "after alpha was renamed to alpha-v2", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + recorder := &aibridgedRecorder{} + bridged := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + recorder.record(r.URL.Path) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("aibridged")) + })) + t.Cleanup(bridged.Close) + + store := &providerStore{} + store.set([]rawProvider{ + {name: "alpha", baseURL: "https://alpha.invalid/v1"}, + }) + + // newTestProxy seeds the router from the store via the + // initial Reload, so the first CONNECT is MITM'd as alpha. + srv := newTestProxy(t, + withCoderAccessURL(bridged.URL), + withAllowedPorts("443"), + withRefreshProviders(store.refresh), + ) + + certPool := getProxyCertPool(t) + client := newProxyClient(t, srv, makeProxyAuthHeader("coder-token"), certPool, false) + // Keep-alives are required: the regression exists only when a + // subsequent request reuses the original CONNECT tunnel. A fresh + // CONNECT would correctly observe the post-reload router. + transport := client.Transport.(*http.Transport) + transport.DisableKeepAlives = false + transport.MaxConnsPerHost = 1 + transport.MaxIdleConnsPerHost = 1 + + sendThroughTunnel := func(path string) (status int, err error) { + ctx, cancel := context.WithTimeout(t.Context(), testutil.WaitShort) + defer cancel() + req, reqErr := http.NewRequestWithContext(ctx, http.MethodPost, "https://alpha.invalid"+path, strings.NewReader(`{}`)) + require.NoError(t, reqErr) + req.Header.Set("Content-Type", "application/json") + resp, err := client.Do(req) + if err != nil { + return 0, err + } + defer resp.Body.Close() + _, _ = io.Copy(io.Discard, resp.Body) + return resp.StatusCode, nil + } + + // First request: alpha is enabled, the proxy MITMs and routes to + // aibridged under the alpha namespace. + recorder.reset() + status, err := sendThroughTunnel("/v1/messages") + require.NoError(t, err) + require.Equal(t, http.StatusOK, status) + require.Equal(t, "/api/v2/aibridge/alpha/v1/messages", recorder.load(), + "first request must be routed to aibridged while alpha is enabled") + + // Apply the provider change and reload. The atomic router swap + // takes effect immediately, but the client's connection (and + // the proxy's hijacked tunnel) remain open. + tc.applyChange(store) + require.NoError(t, srv.Reload(t.Context())) + + // Second request on the same tunnel: aibridged must NOT see it. + // The connection is hijacked so the request reaches the proxy's + // handleRequest with the stale CONNECT-time provider; the fix + // re-validates against the live router and passes through to + // the original upstream (alpha.invalid, which fails DNS). + recorder.reset() + _, _ = sendThroughTunnel("/v1/should-not-route") + require.Empty(t, recorder.load(), + "%s, aibridged must not receive the request even on a reused tunnel", tc.changeDescription) + }) + } +} + // TestProxy_HotReloadRoutingCRUD drives the proxy through a CRUD-style // sequence of provider changes and asserts on routing after each -// Reload via real HTTPS requests. Each sub-test mutates the store and -// validates that: -// - newly created providers are MITM'd to aibridged with the right -// /api/v2/aibridge// -// - renamed providers route under the new name -// - providers whose BaseURL host changes route the new host and stop -// MITM'ing the old host -// - deleted providers stop being MITM'd; aibridged sees nothing +// Reload via real HTTPS requests. // // Hostnames are .invalid (RFC 2606) so a request that escapes the MITM // path fails fast via DNS rather than reaching a real upstream. @@ -210,30 +366,30 @@ func TestProxy_HotReloadRoutingCRUD(t *testing.T) { h := newReloadTestHarness(t) - // InitialEmptyRouter: no Reload has been called and the boot - // allowlist is empty, so any host falls through to the tunneled + // InitialEmptyRouter: no Reload has been called and no provider + // hosts are configured, so any host falls through to the tunneled // middleware. h.expectNotRouted(t, "https://alpha.invalid/v1/messages") // CreateProvider. - h.store.set([]aibridgeproxyd.ProviderRoute{ - {Name: "alpha", BaseURL: "https://alpha.invalid/v1"}, + h.store.set([]rawProvider{ + {name: "alpha", baseURL: "https://alpha.invalid/v1"}, }) require.NoError(t, h.srv.Reload(t.Context())) h.expectRoutedTo(t, "https://alpha.invalid/v1/messages", "/api/v2/aibridge/alpha/v1/messages") // UpdateProviderName: the same BaseURL with a new name must route // under the new name on the next Reload. - h.store.set([]aibridgeproxyd.ProviderRoute{ - {Name: "alpha-v2", BaseURL: "https://alpha.invalid/v1"}, + h.store.set([]rawProvider{ + {name: "alpha-v2", baseURL: "https://alpha.invalid/v1"}, }) require.NoError(t, h.srv.Reload(t.Context())) h.expectRoutedTo(t, "https://alpha.invalid/v1/messages", "/api/v2/aibridge/alpha-v2/v1/messages") // UpdateProviderBaseURLHost: moving the provider to a new host must // start MITM'ing the new host and stop MITM'ing the old one. - h.store.set([]aibridgeproxyd.ProviderRoute{ - {Name: "alpha-v2", BaseURL: "https://alpha-new.invalid/v1"}, + h.store.set([]rawProvider{ + {name: "alpha-v2", baseURL: "https://alpha-new.invalid/v1"}, }) require.NoError(t, h.srv.Reload(t.Context())) h.expectRoutedTo(t, "https://alpha-new.invalid/v1/messages", "/api/v2/aibridge/alpha-v2/v1/messages") @@ -241,9 +397,9 @@ func TestProxy_HotReloadRoutingCRUD(t *testing.T) { // AddSecondProvider: a second provider added in the same Reload must // route independently from the first. - h.store.set([]aibridgeproxyd.ProviderRoute{ - {Name: "alpha-v2", BaseURL: "https://alpha-new.invalid/v1"}, - {Name: "beta", BaseURL: "https://beta.invalid/v1"}, + h.store.set([]rawProvider{ + {name: "alpha-v2", baseURL: "https://alpha-new.invalid/v1"}, + {name: "beta", baseURL: "https://beta.invalid/v1"}, }) require.NoError(t, h.srv.Reload(t.Context())) h.expectRoutedTo(t, "https://alpha-new.invalid/v1/messages", "/api/v2/aibridge/alpha-v2/v1/messages") @@ -251,8 +407,8 @@ func TestProxy_HotReloadRoutingCRUD(t *testing.T) { // DeleteOneProvider: removing alpha must keep beta routed and stop // routing alpha. - h.store.set([]aibridgeproxyd.ProviderRoute{ - {Name: "beta", BaseURL: "https://beta.invalid/v1"}, + h.store.set([]rawProvider{ + {name: "beta", baseURL: "https://beta.invalid/v1"}, }) require.NoError(t, h.srv.Reload(t.Context())) h.expectRoutedTo(t, "https://beta.invalid/v1/chat/completions", "/api/v2/aibridge/beta/v1/chat/completions") @@ -268,8 +424,8 @@ func TestProxy_HotReloadRoutingCRUD(t *testing.T) { // RecreateAfterDelete: reintroducing a previously-deleted provider // must route again without restart, confirming the swap is // symmetric. - h.store.set([]aibridgeproxyd.ProviderRoute{ - {Name: "alpha", BaseURL: "https://alpha.invalid/v1"}, + h.store.set([]rawProvider{ + {name: "alpha", baseURL: "https://alpha.invalid/v1"}, }) require.NoError(t, h.srv.Reload(t.Context())) h.expectRoutedTo(t, "https://alpha.invalid/v1/messages", "/api/v2/aibridge/alpha/v1/messages") @@ -288,11 +444,11 @@ func TestProxy_HotReloadRoutingInvalidProviders(t *testing.T) { h := newReloadTestHarness(t) // One valid provider and one with an empty BaseURL. The empty - // entry must be silently dropped; the valid one must still - // route. - h.store.set([]aibridgeproxyd.ProviderRoute{ - {Name: "no-url"}, - {Name: "valid", BaseURL: "https://valid.invalid/v1"}, + // entry must be classified as error and excluded from routing; + // the valid one must still route. + h.store.set([]rawProvider{ + {name: "no-url"}, + {name: "valid", baseURL: "https://valid.invalid/v1"}, }) require.NoError(t, h.srv.Reload(t.Context())) @@ -304,12 +460,12 @@ func TestProxy_HotReloadRoutingInvalidProviders(t *testing.T) { h := newReloadTestHarness(t) // A BaseURL that fails url.Parse and one whose Hostname() is - // empty must both be dropped. Mixed with a valid entry, only - // the valid one routes. - h.store.set([]aibridgeproxyd.ProviderRoute{ - {Name: "malformed", BaseURL: "://not-a-url"}, - {Name: "no-host", BaseURL: "https://"}, - {Name: "valid", BaseURL: "https://valid.invalid/v1"}, + // empty must both be classified as error. Mixed with a valid + // entry, only the valid one routes. + h.store.set([]rawProvider{ + {name: "malformed", baseURL: "://not-a-url"}, + {name: "no-host", baseURL: "https://"}, + {name: "valid", baseURL: "https://valid.invalid/v1"}, }) require.NoError(t, h.srv.Reload(t.Context())) @@ -320,11 +476,11 @@ func TestProxy_HotReloadRoutingInvalidProviders(t *testing.T) { t.Parallel() h := newReloadTestHarness(t) - // Two providers with the same BaseURL host: the first one wins, - // matching buildProviderRouter's documented contract. - h.store.set([]aibridgeproxyd.ProviderRoute{ - {Name: "first", BaseURL: "https://shared.invalid/v1"}, - {Name: "second", BaseURL: "https://shared.invalid/v2"}, + // Two providers with the same BaseURL host: the second is + // classified as error and excluded; the first routes. + h.store.set([]rawProvider{ + {name: "first", baseURL: "https://shared.invalid/v1"}, + {name: "second", baseURL: "https://shared.invalid/v2"}, }) require.NoError(t, h.srv.Reload(t.Context())) @@ -337,10 +493,10 @@ func TestProxy_HotReloadRoutingInvalidProviders(t *testing.T) { h := newReloadTestHarness(t) // When every provider is invalid, the router contains no // entries and the proxy fails closed: no host is MITM'd. - h.store.set([]aibridgeproxyd.ProviderRoute{ - {Name: "no-url"}, - {Name: "malformed", BaseURL: "://not-a-url"}, - {Name: "no-host", BaseURL: "https://"}, + h.store.set([]rawProvider{ + {name: "no-url"}, + {name: "malformed", baseURL: "://not-a-url"}, + {name: "no-host", baseURL: "https://"}, }) require.NoError(t, h.srv.Reload(t.Context())) @@ -352,15 +508,15 @@ func TestProxy_HotReloadRoutingInvalidProviders(t *testing.T) { h := newReloadTestHarness(t) // Seed a valid snapshot so we have something to preserve. - h.store.set([]aibridgeproxyd.ProviderRoute{ - {Name: "alpha", BaseURL: "https://alpha.invalid/v1"}, + h.store.set([]rawProvider{ + {name: "alpha", baseURL: "https://alpha.invalid/v1"}, }) require.NoError(t, h.srv.Reload(t.Context())) h.expectRoutedTo(t, "https://alpha.invalid/v1/messages", "/api/v2/aibridge/alpha/v1/messages") // A refresh error must NOT clear the router: dropping the - // allowlist on every transient DB hiccup would amplify the - // fault into a denial of service. + // provider host set on every transient DB hiccup would + // amplify the fault into a denial of service. h.store.setErr(xerrors.New("simulated db failure")) err := h.srv.Reload(t.Context()) require.Error(t, err) @@ -369,8 +525,8 @@ func TestProxy_HotReloadRoutingInvalidProviders(t *testing.T) { // Recovery: once the store returns providers again, the next // Reload applies the new snapshot. - h.store.set([]aibridgeproxyd.ProviderRoute{ - {Name: "beta", BaseURL: "https://beta.invalid/v1"}, + h.store.set([]rawProvider{ + {name: "beta", baseURL: "https://beta.invalid/v1"}, }) require.NoError(t, h.srv.Reload(t.Context())) h.expectRoutedTo(t, "https://beta.invalid/v1/messages", "/api/v2/aibridge/beta/v1/messages") diff --git a/enterprise/cli/aibridgeproxyd.go b/enterprise/cli/aibridgeproxyd.go index 0f7ba976a5b..00cbefaee6a 100644 --- a/enterprise/cli/aibridgeproxyd.go +++ b/enterprise/cli/aibridgeproxyd.go @@ -5,7 +5,9 @@ package cli import ( "context" "io" + "net/url" "path/filepath" + "strings" "github.com/prometheus/client_golang/prometheus" "golang.org/x/xerrors" @@ -86,19 +88,67 @@ func newAIBridgeProxyDaemon(coderAPI *coderd.API) (io.Closer, error) { }, nil } +// refreshProxyProviders classifies every ai_providers row as enabled, +// disabled, or error so the proxy router and any observers see the full +// configured set. Disabled rows are excluded from routing; errored rows +// are excluded from routing and surface their failure reason for +// metrics and logs. func refreshProxyProviders(db database.Store) aibridgeproxyd.RefreshProvidersFunc { - return func(ctx context.Context) ([]aibridgeproxyd.ProviderRoute, error) { + return func(ctx context.Context) (aibridgeproxyd.ProviderReload, error) { //nolint:gocritic // AsAIProviderMetadataReader is the correct subject for routing-only access. rows, err := db.GetAIProviders(dbauthz.AsAIProviderMetadataReader(ctx), database.GetAIProvidersParams{ - IncludeDisabled: false, + IncludeDisabled: true, }) if err != nil { - return nil, xerrors.Errorf("load ai providers: %w", err) + return aibridgeproxyd.ProviderReload{}, xerrors.Errorf("load ai providers: %w", err) } - out := make([]aibridgeproxyd.ProviderRoute, 0, len(rows)) + reload := aibridgeproxyd.ProviderReload{ + Providers: make([]aibridgeproxyd.ReloadedProvider, 0, len(rows)), + } + seenHost := make(map[string]string, len(rows)) for _, row := range rows { - out = append(out, aibridgeproxyd.ProviderRoute{Name: row.Name, BaseURL: row.BaseUrl}) + reload.Providers = append(reload.Providers, classifyProviderRow(row, seenHost)) } - return out, nil + return reload, nil + } +} + +// classifyProviderRow evaluates a single ai_providers row for routing. +// seenHost is mutated to track the first provider that claimed each +// hostname so later duplicates can be flagged as errors. +func classifyProviderRow(row database.AIProvider, seenHost map[string]string) aibridgeproxyd.ReloadedProvider { + out := aibridgeproxyd.ReloadedProvider{ + Name: row.Name, + Type: string(row.Type), + } + if !row.Enabled { + out.Status = aibridgeproxyd.ProviderStatusDisabled + return out + } + if strings.TrimSpace(row.BaseUrl) == "" { + out.Status = aibridgeproxyd.ProviderStatusError + out.Err = xerrors.New("base url is empty") + return out + } + u, err := url.Parse(row.BaseUrl) + if err != nil { + out.Status = aibridgeproxyd.ProviderStatusError + out.Err = xerrors.Errorf("invalid base url %q: %w", row.BaseUrl, err) + return out + } + host := strings.ToLower(u.Hostname()) + if host == "" { + out.Status = aibridgeproxyd.ProviderStatusError + out.Err = xerrors.Errorf("base url %q has no hostname", row.BaseUrl) + return out + } + if claimedBy, taken := seenHost[host]; taken { + out.Status = aibridgeproxyd.ProviderStatusError + out.Err = xerrors.Errorf("hostname %q already claimed by provider %q", host, claimedBy) + return out } + seenHost[host] = row.Name + out.Host = host + out.Status = aibridgeproxyd.ProviderStatusEnabled + return out } diff --git a/enterprise/cli/aibridgeproxyd_internal_test.go b/enterprise/cli/aibridgeproxyd_internal_test.go new file mode 100644 index 00000000000..54c6c25f78f --- /dev/null +++ b/enterprise/cli/aibridgeproxyd_internal_test.go @@ -0,0 +1,105 @@ +//go:build !slim + +package cli + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/enterprise/aibridgeproxyd" +) + +// TestClassifyProviderRow covers every branch of the classifier so the +// disabled, error, and enabled paths are exercised through the +// production code instead of relying on classifyRaw, the test mirror in +// reload_test.go. +func TestClassifyProviderRow(t *testing.T) { + t.Parallel() + + enabledRow := func(name, baseURL string) database.AIProvider { + return database.AIProvider{ + Name: name, + Type: database.AiProviderTypeOpenai, + Enabled: true, + BaseUrl: baseURL, + } + } + + t.Run("Enabled", func(t *testing.T) { + t.Parallel() + + seen := map[string]string{} + got := classifyProviderRow(enabledRow("openai", "https://api.openai.com/v1"), seen) + assert.Equal(t, "openai", got.Name) + assert.Equal(t, string(database.AiProviderTypeOpenai), got.Type) + assert.Equal(t, aibridgeproxyd.ProviderStatusEnabled, got.Status) + assert.Equal(t, "api.openai.com", got.Host) + assert.NoError(t, got.Err) + assert.Equal(t, "openai", seen["api.openai.com"]) + }) + + t.Run("DisabledRow", func(t *testing.T) { + t.Parallel() + + seen := map[string]string{} + row := enabledRow("off", "https://api.off.example.com/v1") + row.Enabled = false + got := classifyProviderRow(row, seen) + assert.Equal(t, aibridgeproxyd.ProviderStatusDisabled, got.Status) + assert.Empty(t, got.Host, "disabled provider must not claim a host") + assert.NoError(t, got.Err) + assert.Empty(t, seen, "disabled provider must not occupy a host slot") + }) + + t.Run("EmptyBaseURL", func(t *testing.T) { + t.Parallel() + + seen := map[string]string{} + got := classifyProviderRow(enabledRow("no-url", " "), seen) + assert.Equal(t, aibridgeproxyd.ProviderStatusError, got.Status) + assert.Empty(t, got.Host) + assert.ErrorContains(t, got.Err, "base url is empty") + }) + + t.Run("MalformedBaseURL", func(t *testing.T) { + t.Parallel() + + seen := map[string]string{} + got := classifyProviderRow(enabledRow("bad", "://not-a-url"), seen) + assert.Equal(t, aibridgeproxyd.ProviderStatusError, got.Status) + assert.ErrorContains(t, got.Err, "invalid base url") + }) + + t.Run("BaseURLWithoutHostname", func(t *testing.T) { + t.Parallel() + + seen := map[string]string{} + got := classifyProviderRow(enabledRow("no-host", "https://"), seen) + assert.Equal(t, aibridgeproxyd.ProviderStatusError, got.Status) + assert.ErrorContains(t, got.Err, "no hostname") + }) + + t.Run("DuplicateHostnameFirstWins", func(t *testing.T) { + t.Parallel() + + seen := map[string]string{} + first := classifyProviderRow(enabledRow("first", "https://shared.example.com/v1"), seen) + assert.Equal(t, aibridgeproxyd.ProviderStatusEnabled, first.Status) + + second := classifyProviderRow(enabledRow("second", "https://shared.example.com/v2"), seen) + assert.Equal(t, aibridgeproxyd.ProviderStatusError, second.Status) + assert.ErrorContains(t, second.Err, "already claimed by provider \"first\"") + assert.Equal(t, "first", seen["shared.example.com"], "first wins must not be overwritten") + }) + + t.Run("HostnameLowercased", func(t *testing.T) { + t.Parallel() + + seen := map[string]string{} + got := classifyProviderRow(enabledRow("mixed", "https://API.Example.COM/v1"), seen) + assert.Equal(t, aibridgeproxyd.ProviderStatusEnabled, got.Status) + assert.Equal(t, "api.example.com", got.Host) + }) +} From 85d39b3dbe36fe47dfa13f7fb4420abbec14e0b9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 28 May 2026 11:29:14 -0400 Subject: [PATCH 005/138] fix(coderd/x/chatd/chatloop): use stream silence timeout (#25782) (#25786) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cherry-pick of https://github.com/coder/coder/pull/25782 Original PR: #25782 — fix(coderd/x/chatd/chatloop): use stream silence timeout Merge commit: 7e2f7198ddef4d19a1e716bc23b9188582ccbd08 Requested by: @ethanndickson Co-authored-by: Ethan --- coderd/x/chatd/chatloop/chatloop.go | 119 +++++---- .../chatloop/chatloop_run_internal_test.go | 247 ++++++++++++++---- 2 files changed, 265 insertions(+), 101 deletions(-) diff --git a/coderd/x/chatd/chatloop/chatloop.go b/coderd/x/chatd/chatloop/chatloop.go index c67e2ee6d00..7a81dc4d6e8 100644 --- a/coderd/x/chatd/chatloop/chatloop.go +++ b/coderd/x/chatd/chatloop/chatloop.go @@ -39,10 +39,11 @@ const ( // prevents infinite compaction loops when the model keeps // hitting the context limit after summarization. maxCompactionRetries = 3 - // defaultStartupTimeout bounds how long an individual - // model attempt may spend starting to respond before + // defaultStreamSilenceTimeout bounds how long an individual + // model attempt may go without receiving a stream part before // the attempt is canceled and retried. - defaultStartupTimeout = 60 * time.Second + defaultStreamSilenceTimeout = 10 * time.Minute + streamSilenceGuardTimerTag = "streamSilenceGuard" ) var ( @@ -53,8 +54,8 @@ var ( // the run should terminate cleanly after persistence. ErrStopAfterTool = xerrors.New("stop after tool") - errStartupTimeout = xerrors.New( - "chat response did not start before the startup timeout", + errStreamSilenceTimeout = xerrors.New( + "chat stream was silent for longer than the configured timeout", ) ) @@ -114,14 +115,14 @@ type RunOptions struct { Messages []fantasy.Message Tools []fantasy.AgentTool MaxSteps int - // StartupTimeout bounds how long each model attempt may - // spend opening the provider stream and waiting for its - // first stream part before the attempt is canceled and - // retried. Zero uses the production default. - StartupTimeout time.Duration - // Clock creates startup guard timers. In production use a - // real clock; tests can inject quartz.NewMock(t) to make - // startup timeout behavior deterministic. + // StreamSilenceTimeout bounds how long each model attempt + // may go without receiving a stream part before the + // attempt is canceled and retried. Zero uses the + // production default. + StreamSilenceTimeout time.Duration + // Clock creates stream silence guard timers. In production + // use a real clock; tests can inject quartz.NewMock(t) to + // make timeout behavior deterministic. Clock quartz.Clock ActiveTools []string @@ -364,8 +365,8 @@ func Run(ctx context.Context, opts RunOptions) error { if opts.MaxSteps <= 0 { opts.MaxSteps = 1 } - if opts.StartupTimeout <= 0 { - opts.StartupTimeout = defaultStartupTimeout + if opts.StreamSilenceTimeout <= 0 { + opts.StreamSilenceTimeout = defaultStreamSilenceTimeout } if opts.Clock == nil { opts.Clock = quartz.NewReal() @@ -468,7 +469,7 @@ func Run(ctx context.Context, opts RunOptions) error { provider, modelName, opts.Clock, - opts.StartupTimeout, + opts.StreamSilenceTimeout, func(attemptCtx context.Context) (fantasy.StreamResponse, error) { return opts.Model.Stream(attemptCtx, call) }, @@ -782,9 +783,9 @@ func prepareMessagesForRequest( return canonical, prompt, nil } -// guardedAttempt owns an attempt-scoped context and startup guard +// guardedAttempt owns an attempt-scoped context and silence guard // around a provider stream. release is idempotent and frees the -// attempt-scoped timer/context. finish canonicalizes startup timeout +// attempt-scoped timer/context. finish canonicalizes silence timeout // errors before the retry loop classifies them. type guardedAttempt struct { ctx context.Context @@ -793,47 +794,77 @@ type guardedAttempt struct { finish func(error) error } -// startupGuard arbitrates whether an attempt times out during -// stream startup. Exactly one outcome wins: the timer cancels -// the attempt, or the first-part path disarms the timer. -type startupGuard struct { - timer *quartz.Timer - cancel context.CancelCauseFunc - once sync.Once +// streamSilenceGuard arbitrates whether an attempt times out while +// waiting for the next stream part. Exactly one outcome wins: the +// timer cancels the attempt, or release disarms the timer. +type streamSilenceGuard struct { + mu sync.Mutex + timer *quartz.Timer + cancel context.CancelCauseFunc + timeout time.Duration + settled bool } -func newStartupGuard( +func newStreamSilenceGuard( clock quartz.Clock, timeout time.Duration, cancel context.CancelCauseFunc, -) *startupGuard { - guard := &startupGuard{cancel: cancel} - guard.timer = clock.AfterFunc(timeout, guard.onTimeout, "startupGuard") +) *streamSilenceGuard { + guard := &streamSilenceGuard{ + cancel: cancel, + timeout: timeout, + } + guard.timer = clock.AfterFunc( + timeout, + guard.onTimeout, + streamSilenceGuardTimerTag, + ) return guard } -func (g *startupGuard) onTimeout() { - g.once.Do(func() { - g.cancel(errStartupTimeout) - }) +func (g *streamSilenceGuard) settle() bool { + g.mu.Lock() + defer g.mu.Unlock() + if g.settled { + return false + } + g.settled = true + return true } -func (g *startupGuard) Disarm() { - g.once.Do(func() { - g.timer.Stop() - }) +func (g *streamSilenceGuard) onTimeout() { + if !g.settle() { + return + } + g.cancel(errStreamSilenceTimeout) +} + +func (g *streamSilenceGuard) Reset() { + g.mu.Lock() + defer g.mu.Unlock() + if g.settled { + return + } + g.timer.Reset(g.timeout, streamSilenceGuardTimerTag) +} + +func (g *streamSilenceGuard) Disarm() { + if !g.settle() { + return + } + g.timer.Stop() } -func classifyStartupTimeout( +func classifyStreamSilenceTimeout( attemptCtx context.Context, provider string, err error, ) error { - if !errors.Is(context.Cause(attemptCtx), errStartupTimeout) { + if !errors.Is(context.Cause(attemptCtx), errStreamSilenceTimeout) { return err } if err == nil { - err = errStartupTimeout + err = errStreamSilenceTimeout } return chaterror.WithClassification(err, chaterror.ClassifiedError{ Kind: codersdk.ChatErrorKindStartupTimeout, @@ -851,7 +882,7 @@ func guardedStream( metrics *Metrics, ) (guardedAttempt, error) { attemptCtx, cancelAttempt := context.WithCancelCause(parent) - guard := newStartupGuard(clock, timeout, cancelAttempt) + guard := newStreamSilenceGuard(clock, timeout, cancelAttempt) var releaseOnce sync.Once release := func() { releaseOnce.Do(func() { @@ -863,7 +894,7 @@ func guardedStream( streamStart := clock.Now() stream, err := openStream(attemptCtx) if err != nil { - err = classifyStartupTimeout(attemptCtx, provider, err) + err = classifyStreamSilenceTimeout(attemptCtx, provider, err) release() return guardedAttempt{}, err } @@ -877,7 +908,7 @@ func guardedStream( ctx: attemptCtx, stream: fantasy.StreamResponse(func(yield func(fantasy.StreamPart) bool) { for part := range stream { - guard.Disarm() + guard.Reset() recordTTFT() if !yield(part) { return @@ -886,7 +917,7 @@ func guardedStream( }), release: release, finish: func(err error) error { - return classifyStartupTimeout(attemptCtx, provider, err) + return classifyStreamSilenceTimeout(attemptCtx, provider, err) }, }, nil } diff --git a/coderd/x/chatd/chatloop/chatloop_run_internal_test.go b/coderd/x/chatd/chatloop/chatloop_run_internal_test.go index a7a2079d79e..64b1d8f97cb 100644 --- a/coderd/x/chatd/chatloop/chatloop_run_internal_test.go +++ b/coderd/x/chatd/chatloop/chatloop_run_internal_test.go @@ -581,13 +581,13 @@ func TestRun_OnRetryEnrichesProvider(t *testing.T) { ) } -func TestStartupGuard_DisarmAndFireRace(t *testing.T) { +func TestStreamSilenceGuard_DisarmAndFireRace(t *testing.T) { t.Parallel() for range 128 { var cancels atomic.Int32 - guard := newStartupGuard(quartz.NewReal(), time.Hour, func(err error) { - if errors.Is(err, errStartupTimeout) { + guard := newStreamSilenceGuard(quartz.NewReal(), time.Hour, func(err error) { + if errors.Is(err, errStreamSilenceTimeout) { cancels.Add(1) } }) @@ -618,17 +618,17 @@ func TestStartupGuard_DisarmAndFireRace(t *testing.T) { } } -func TestStartupGuard_DisarmPreservesPermanentError(t *testing.T) { +func TestStreamSilenceGuard_DisarmPreservesPermanentError(t *testing.T) { t.Parallel() attemptCtx, cancelAttempt := context.WithCancelCause(context.Background()) defer cancelAttempt(nil) - guard := newStartupGuard(quartz.NewReal(), time.Hour, cancelAttempt) + guard := newStreamSilenceGuard(quartz.NewReal(), time.Hour, cancelAttempt) guard.Disarm() guard.onTimeout() - classified := chaterror.Classify(classifyStartupTimeout( + classified := chaterror.Classify(classifyStreamSilenceTimeout( attemptCtx, "openai", xerrors.New("invalid model"), @@ -638,10 +638,10 @@ func TestStartupGuard_DisarmPreservesPermanentError(t *testing.T) { require.Nil(t, context.Cause(attemptCtx)) } -func TestRun_RetriesStartupTimeoutWhileOpeningStream(t *testing.T) { +func TestRun_RetriesSilenceTimeoutWhileOpeningStream(t *testing.T) { t.Parallel() - const startupTimeout = 5 * time.Millisecond + const silenceTimeout = 5 * time.Millisecond ctx, cancel := context.WithTimeout( context.Background(), @@ -650,7 +650,7 @@ func TestRun_RetriesStartupTimeoutWhileOpeningStream(t *testing.T) { defer cancel() mClock := quartz.NewMock(t) - trap := mClock.Trap().AfterFunc("startupGuard") + trap := mClock.Trap().AfterFunc(streamSilenceGuardTimerTag) defer trap.Close() attempts := 0 @@ -675,10 +675,10 @@ func TestRun_RetriesStartupTimeoutWhileOpeningStream(t *testing.T) { done := make(chan error, 1) go func() { done <- Run(context.Background(), RunOptions{ - Model: model, - MaxSteps: 1, - StartupTimeout: startupTimeout, - Clock: mClock, + Model: model, + MaxSteps: 1, + StreamSilenceTimeout: silenceTimeout, + Clock: mClock, PersistStep: func(_ context.Context, _ PersistedStep) error { return nil }, @@ -694,7 +694,7 @@ func TestRun_RetriesStartupTimeoutWhileOpeningStream(t *testing.T) { }() trap.MustWait(ctx).MustRelease(ctx) - mClock.Advance(startupTimeout).MustWait(ctx) + mClock.Advance(silenceTimeout).MustWait(ctx) trap.MustWait(ctx).MustRelease(ctx) require.NoError(t, awaitRunResult(ctx, t, done)) @@ -710,9 +710,9 @@ func TestRun_RetriesStartupTimeoutWhileOpeningStream(t *testing.T) { ) select { case cause := <-attemptCause: - require.ErrorIs(t, cause, errStartupTimeout) + require.ErrorIs(t, cause, errStreamSilenceTimeout) case <-ctx.Done(): - t.Fatal("timed out waiting for startup timeout cause") + t.Fatal("timed out waiting for silence timeout cause") } } @@ -728,7 +728,7 @@ func TestRun_HTTP2TransportErrorClassifiedAsRetryableTimeout(t *testing.T) { t.Run(provider, func(t *testing.T) { t.Parallel() - const startupTimeout = 5 * time.Millisecond + const silenceTimeout = 5 * time.Millisecond ctx, cancel := context.WithTimeout( context.Background(), @@ -737,7 +737,7 @@ func TestRun_HTTP2TransportErrorClassifiedAsRetryableTimeout(t *testing.T) { defer cancel() mClock := quartz.NewMock(t) - trap := mClock.Trap().AfterFunc("startupGuard") + trap := mClock.Trap().AfterFunc(streamSilenceGuardTimerTag) defer trap.Close() attempts := 0 @@ -763,10 +763,10 @@ func TestRun_HTTP2TransportErrorClassifiedAsRetryableTimeout(t *testing.T) { done := make(chan error, 1) go func() { done <- Run(context.Background(), RunOptions{ - Model: model, - MaxSteps: 1, - StartupTimeout: startupTimeout, - Clock: mClock, + Model: model, + MaxSteps: 1, + StreamSilenceTimeout: silenceTimeout, + Clock: mClock, PersistStep: func(_ context.Context, _ PersistedStep) error { return nil }, @@ -795,10 +795,10 @@ func TestRun_HTTP2TransportErrorClassifiedAsRetryableTimeout(t *testing.T) { } } -func TestRun_RetriesStartupTimeoutBeforeFirstPart(t *testing.T) { +func TestRun_RetriesSilenceTimeoutBeforeFirstPart(t *testing.T) { t.Parallel() - const startupTimeout = 5 * time.Millisecond + const silenceTimeout = 5 * time.Millisecond ctx, cancel := context.WithTimeout( context.Background(), @@ -807,7 +807,7 @@ func TestRun_RetriesStartupTimeoutBeforeFirstPart(t *testing.T) { defer cancel() mClock := quartz.NewMock(t) - trap := mClock.Trap().AfterFunc("startupGuard") + trap := mClock.Trap().AfterFunc(streamSilenceGuardTimerTag) defer trap.Close() attempts := 0 @@ -837,10 +837,10 @@ func TestRun_RetriesStartupTimeoutBeforeFirstPart(t *testing.T) { done := make(chan error, 1) go func() { done <- Run(context.Background(), RunOptions{ - Model: model, - MaxSteps: 1, - StartupTimeout: startupTimeout, - Clock: mClock, + Model: model, + MaxSteps: 1, + StreamSilenceTimeout: silenceTimeout, + Clock: mClock, PersistStep: func(_ context.Context, _ PersistedStep) error { return nil }, @@ -856,7 +856,7 @@ func TestRun_RetriesStartupTimeoutBeforeFirstPart(t *testing.T) { }() trap.MustWait(ctx).MustRelease(ctx) - mClock.Advance(startupTimeout).MustWait(ctx) + mClock.Advance(silenceTimeout).MustWait(ctx) trap.MustWait(ctx).MustRelease(ctx) require.NoError(t, awaitRunResult(ctx, t, done)) @@ -872,16 +872,16 @@ func TestRun_RetriesStartupTimeoutBeforeFirstPart(t *testing.T) { ) select { case cause := <-attemptCause: - require.ErrorIs(t, cause, errStartupTimeout) + require.ErrorIs(t, cause, errStreamSilenceTimeout) case <-ctx.Done(): - t.Fatal("timed out waiting for startup timeout cause") + t.Fatal("timed out waiting for silence timeout cause") } } -func TestRun_FirstPartDisarmsStartupTimeout(t *testing.T) { +func TestRun_StreamPartsResetSilenceTimeout(t *testing.T) { t.Parallel() - const startupTimeout = 5 * time.Millisecond + const silenceTimeout = 5 * time.Millisecond ctx, cancel := context.WithTimeout( context.Background(), @@ -890,12 +890,17 @@ func TestRun_FirstPartDisarmsStartupTimeout(t *testing.T) { defer cancel() mClock := quartz.NewMock(t) - trap := mClock.Trap().AfterFunc("startupGuard") + armTrap := mClock.Trap().AfterFunc(streamSilenceGuardTimerTag) + defer armTrap.Close() + resetTrap := mClock.Trap().TimerReset(streamSilenceGuardTimerTag) + defer resetTrap.Close() attempts := 0 retried := false firstPartYielded := make(chan struct{}, 1) - continueStream := make(chan struct{}) + secondPartYielded := make(chan struct{}, 1) + continueToSecond := make(chan struct{}) + continueToFinish := make(chan struct{}) model := &chattest.FakeModel{ ProviderName: "openai", StreamFn: func(ctx context.Context, _ fantasy.Call) (fantasy.StreamResponse, error) { @@ -910,7 +915,29 @@ func TestRun_FirstPartDisarmsStartupTimeout(t *testing.T) { } select { - case <-continueStream: + case <-continueToSecond: + case <-ctx.Done(): + _ = yield(fantasy.StreamPart{ + Type: fantasy.StreamPartTypeError, + Error: ctx.Err(), + }) + return + } + + if !yield(fantasy.StreamPart{ + Type: fantasy.StreamPartTypeTextDelta, + ID: "text-1", + Delta: "done", + }) { + return + } + select { + case secondPartYielded <- struct{}{}: + default: + } + + select { + case <-continueToFinish: case <-ctx.Done(): _ = yield(fantasy.StreamPart{ Type: fantasy.StreamPartTypeError, @@ -920,7 +947,6 @@ func TestRun_FirstPartDisarmsStartupTimeout(t *testing.T) { } parts := []fantasy.StreamPart{ - {Type: fantasy.StreamPartTypeTextDelta, ID: "text-1", Delta: "done"}, {Type: fantasy.StreamPartTypeTextEnd, ID: "text-1"}, {Type: fantasy.StreamPartTypeFinish, FinishReason: fantasy.FinishReasonStop}, } @@ -936,10 +962,10 @@ func TestRun_FirstPartDisarmsStartupTimeout(t *testing.T) { done := make(chan error, 1) go func() { done <- Run(context.Background(), RunOptions{ - Model: model, - MaxSteps: 1, - StartupTimeout: startupTimeout, - Clock: mClock, + Model: model, + MaxSteps: 1, + StreamSilenceTimeout: silenceTimeout, + Clock: mClock, PersistStep: func(_ context.Context, _ PersistedStep) error { return nil }, @@ -954,23 +980,130 @@ func TestRun_FirstPartDisarmsStartupTimeout(t *testing.T) { }) }() - trap.MustWait(ctx).MustRelease(ctx) - trap.Close() - + armTrap.MustWait(ctx).MustRelease(ctx) + resetTrap.MustWait(ctx).MustRelease(ctx) select { case <-firstPartYielded: case <-ctx.Done(): t.Fatal("timed out waiting for first stream part") } - mClock.Advance(startupTimeout).MustWait(ctx) - close(continueStream) + mClock.Advance(silenceTimeout / 2).MustWait(ctx) + close(continueToSecond) + resetTrap.MustWait(ctx).MustRelease(ctx) + select { + case <-secondPartYielded: + case <-ctx.Done(): + t.Fatal("timed out waiting for second stream part") + } + + mClock.Advance(silenceTimeout / 2).MustWait(ctx) + close(continueToFinish) + resetTrap.MustWait(ctx).MustRelease(ctx) + resetTrap.MustWait(ctx).MustRelease(ctx) require.NoError(t, awaitRunResult(ctx, t, done)) require.Equal(t, 1, attempts) require.False(t, retried) } +func TestRun_RetriesSilenceTimeoutBetweenParts(t *testing.T) { + t.Parallel() + + const silenceTimeout = 5 * time.Millisecond + + ctx, cancel := context.WithTimeout( + context.Background(), + testutil.WaitLong, + ) + defer cancel() + + mClock := quartz.NewMock(t) + armTrap := mClock.Trap().AfterFunc(streamSilenceGuardTimerTag) + defer armTrap.Close() + resetTrap := mClock.Trap().TimerReset(streamSilenceGuardTimerTag) + defer resetTrap.Close() + + attempts := 0 + firstPartYielded := make(chan struct{}, 1) + attemptCause := make(chan error, 1) + var retries []chatretry.ClassifiedError + model := &chattest.FakeModel{ + ProviderName: "openai", + StreamFn: func(ctx context.Context, _ fantasy.Call) (fantasy.StreamResponse, error) { + attempts++ + if attempts == 1 { + return iter.Seq[fantasy.StreamPart](func(yield func(fantasy.StreamPart) bool) { + if !yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeTextStart, ID: "text-1"}) { + return + } + select { + case firstPartYielded <- struct{}{}: + default: + } + + <-ctx.Done() + attemptCause <- context.Cause(ctx) + _ = yield(fantasy.StreamPart{ + Type: fantasy.StreamPartTypeError, + Error: ctx.Err(), + }) + }), nil + } + return streamFromParts([]fantasy.StreamPart{{ + Type: fantasy.StreamPartTypeFinish, + FinishReason: fantasy.FinishReasonStop, + }}), nil + }, + } + + done := make(chan error, 1) + go func() { + done <- Run(context.Background(), RunOptions{ + Model: model, + MaxSteps: 1, + StreamSilenceTimeout: silenceTimeout, + Clock: mClock, + PersistStep: func(_ context.Context, _ PersistedStep) error { + return nil + }, + OnRetry: func( + _ int, + _ error, + classified chatretry.ClassifiedError, + _ time.Duration, + ) { + retries = append(retries, classified) + }, + }) + }() + + armTrap.MustWait(ctx).MustRelease(ctx) + resetTrap.MustWait(ctx).MustRelease(ctx) + select { + case <-firstPartYielded: + case <-ctx.Done(): + t.Fatal("timed out waiting for first stream part") + } + + mClock.Advance(silenceTimeout).MustWait(ctx) + armTrap.MustWait(ctx).MustRelease(ctx) + resetTrap.MustWait(ctx).MustRelease(ctx) + + require.NoError(t, awaitRunResult(ctx, t, done)) + require.Equal(t, 2, attempts) + require.Len(t, retries, 1) + require.Equal(t, codersdk.ChatErrorKindStartupTimeout, retries[0].Kind) + require.True(t, retries[0].Retryable) + require.Equal(t, "openai", retries[0].Provider) + select { + case cause := <-attemptCause: + require.ErrorIs(t, cause, errStreamSilenceTimeout) + case <-ctx.Done(): + t.Fatal("timed out waiting for silence timeout cause") + } +} + func TestRun_PanicInPublishMessagePartReleasesAttempt(t *testing.T) { t.Parallel() @@ -1014,10 +1147,10 @@ func TestRun_PanicInPublishMessagePartReleasesAttempt(t *testing.T) { t.Fatal("expected Run to panic") } -func TestRun_RetriesStartupTimeoutWhenStreamClosesSilently(t *testing.T) { +func TestRun_RetriesSilenceTimeoutWhenStreamStaysSilent(t *testing.T) { t.Parallel() - const startupTimeout = 5 * time.Millisecond + const silenceTimeout = 5 * time.Millisecond ctx, cancel := context.WithTimeout( context.Background(), @@ -1026,7 +1159,7 @@ func TestRun_RetriesStartupTimeoutWhenStreamClosesSilently(t *testing.T) { defer cancel() mClock := quartz.NewMock(t) - trap := mClock.Trap().AfterFunc("startupGuard") + trap := mClock.Trap().AfterFunc(streamSilenceGuardTimerTag) defer trap.Close() attempts := 0 @@ -1052,10 +1185,10 @@ func TestRun_RetriesStartupTimeoutWhenStreamClosesSilently(t *testing.T) { done := make(chan error, 1) go func() { done <- Run(context.Background(), RunOptions{ - Model: model, - MaxSteps: 1, - StartupTimeout: startupTimeout, - Clock: mClock, + Model: model, + MaxSteps: 1, + StreamSilenceTimeout: silenceTimeout, + Clock: mClock, PersistStep: func(_ context.Context, _ PersistedStep) error { return nil }, @@ -1071,7 +1204,7 @@ func TestRun_RetriesStartupTimeoutWhenStreamClosesSilently(t *testing.T) { }() trap.MustWait(ctx).MustRelease(ctx) - mClock.Advance(startupTimeout).MustWait(ctx) + mClock.Advance(silenceTimeout).MustWait(ctx) trap.MustWait(ctx).MustRelease(ctx) require.NoError(t, awaitRunResult(ctx, t, done)) @@ -1087,9 +1220,9 @@ func TestRun_RetriesStartupTimeoutWhenStreamClosesSilently(t *testing.T) { ) select { case cause := <-attemptCause: - require.ErrorIs(t, cause, errStartupTimeout) + require.ErrorIs(t, cause, errStreamSilenceTimeout) case <-ctx.Done(): - t.Fatal("timed out waiting for startup timeout cause") + t.Fatal("timed out waiting for silence timeout cause") } } From 7e5e8eb9d2bf48babe40e3cddeca6797d23cce94 Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Thu, 28 May 2026 18:54:02 +0200 Subject: [PATCH 006/138] fix: add ai provider status and reload freshness metrics (#25770) (#25795) Add metrics for `aibridged` and `aibridgeproxyd`'s provider statuses. AI providers can be modified, and possibly misconfigured, at runtime. These metrics help operators understand the state of these provider definitions in case unexpected behaviour is observed. (cherry picked from commit 12520ee964270e561e0e1b75b765a4194c159c4a) --- cli/aibridged.go | 79 ++++++---- cli/aibridged_internal_test.go | 53 ++++++- cli/server.go | 2 +- coderd/aibridged/metrics.go | 94 ++++++++++++ coderd/aibridged/metrics_test.go | 84 +++++++++++ coderd/aibridged/provider.go | 28 ++++ docs/admin/integrations/prometheus.md | 6 + .../aibridgeproxyd/aibridgeproxyd_test.go | 25 ++-- enterprise/aibridgeproxyd/metrics.go | 33 +++++ .../aibridgeproxyd/metrics_internal_test.go | 135 ++++++++++++++++++ enterprise/aibridgeproxyd/reload.go | 80 ++++++----- .../aibridgeproxyd/reload_internal_test.go | 19 +-- enterprise/aibridgeproxyd/reload_test.go | 66 +++++++-- enterprise/cli/aibridgeproxyd.go | 18 +-- .../cli/aibridgeproxyd_internal_test.go | 18 +-- enterprise/coderd/aibridge_reload_test.go | 70 +++++++-- scripts/metricsdocgen/metrics | 18 +++ scripts/metricsdocgen/scanner/scanner.go | 1 + 18 files changed, 704 insertions(+), 125 deletions(-) create mode 100644 coderd/aibridged/metrics.go create mode 100644 coderd/aibridged/metrics_test.go create mode 100644 coderd/aibridged/provider.go create mode 100644 enterprise/aibridgeproxyd/metrics_internal_test.go diff --git a/cli/aibridged.go b/cli/aibridged.go index da28a74dee0..caf67082fc9 100644 --- a/cli/aibridged.go +++ b/cli/aibridged.go @@ -37,6 +37,7 @@ func newAIBridgeDaemon(coderAPI *coderd.API, providers []aibridge.Provider, cfg reg := prometheus.WrapRegistererWithPrefix("coder_aibridged_", coderAPI.PrometheusRegistry) metrics := aibridge.NewMetrics(reg) + providerMetrics := aibridged.NewMetrics(reg) tracer := coderAPI.TracerProvider.Tracer(tracing.TracerName) // Create pool for reusable stateful [aibridge.RequestBridge] instances (one per user). @@ -50,10 +51,11 @@ func newAIBridgeDaemon(coderAPI *coderd.API, providers []aibridge.Provider, cfg // derives from env config and serves as a fallback if the database // load fails inside the reloader. reloader := &poolDBReloader{ - pool: pool, - db: coderAPI.Database, - cfg: cfg, - logger: logger.Named("provider-loader"), + pool: pool, + db: coderAPI.Database, + cfg: cfg, + logger: logger.Named("provider-loader"), + metrics: providerMetrics, } unsubscribe, err := aibridged.SubscribeProviderReload(ctx, coderAPI.Pubsub, reloader, logger.Named("provider-reload")) if err != nil { @@ -78,14 +80,16 @@ func newAIBridgeDaemon(coderAPI *coderd.API, providers []aibridge.Provider, cfg // the live provider set from the database and forwarding it to the // pool. type poolDBReloader struct { - pool *aibridged.CachedBridgePool - db database.Store - cfg codersdk.AIBridgeConfig - logger slog.Logger + pool *aibridged.CachedBridgePool + db database.Store + cfg codersdk.AIBridgeConfig + logger slog.Logger + metrics *aibridged.Metrics } func (r *poolDBReloader) Reload(ctx context.Context) error { - providers, err := BuildProviders(ctx, r.db, r.cfg, r.logger) + r.metrics.RecordReloadAttempt() + providers, outcomes, err := BuildProviders(ctx, r.db, r.cfg, r.logger) if err != nil { // Keep the previous snapshot in place: dropping all providers // because the DB read failed would compound the visible failure @@ -93,19 +97,15 @@ func (r *poolDBReloader) Reload(ctx context.Context) error { return xerrors.Errorf("load ai providers from database: %w", err) } r.pool.ReplaceProviders(providers) + r.metrics.RecordReloadSuccess(outcomes) return nil } -// 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) { +// BuildProviders loads every ai_providers row (including disabled) +// and returns the active provider list plus per-row outcomes. Per-row +// build errors are logged and excluded from providers but recorded in +// outcomes; only DB query failures propagate. +func BuildProviders(ctx context.Context, db database.Store, cfg codersdk.AIBridgeConfig, logger slog.Logger) ([]aibridge.Provider, []aibridged.ProviderOutcome, error) { //nolint:gocritic // AsAIBridged has a minimal permission set for this purpose. authCtx := dbauthz.AsAIBridged(ctx) @@ -117,7 +117,7 @@ func BuildProviders(ctx context.Context, db database.Store, cfg codersdk.AIBridg err := db.InTx(func(tx database.Store) error { var err error rows, err = tx.GetAIProviders(authCtx, database.GetAIProvidersParams{ - IncludeDisabled: false, + IncludeDisabled: true, }) if err != nil { return xerrors.Errorf("load ai providers: %w", err) @@ -129,9 +129,15 @@ func BuildProviders(ctx context.Context, db database.Store, cfg codersdk.AIBridg // 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 + ids := make([]uuid.UUID, 0, len(rows)) + for _, r := range rows { + if !r.Enabled { + continue + } + ids = append(ids, r.ID) + } + if len(ids) == 0 { + return nil } keyRows, err := tx.GetAIProviderKeysByProviderIDs(authCtx, ids) if err != nil { @@ -143,13 +149,28 @@ func BuildProviders(ctx context.Context, db database.Store, cfg codersdk.AIBridg return nil }, &database.TxOptions{ReadOnly: true, TxIdentifier: "build_ai_providers"}) if err != nil { - return nil, err + return nil, nil, err } - out := make([]aibridge.Provider, 0, len(rows)) + providers := make([]aibridge.Provider, 0, len(rows)) + outcomes := make([]aibridged.ProviderOutcome, 0, len(rows)) + enabledCount := 0 for _, row := range rows { + outcome := aibridged.ProviderOutcome{ + Name: row.Name, + Type: string(row.Type), + } + if !row.Enabled { + outcome.Status = aibridged.ProviderStatusDisabled + outcomes = append(outcomes, outcome) + continue + } + enabledCount++ prov, err := buildAIProviderFromRow(row, keysByProvider[row.ID], cfg) if err != nil { + outcome.Status = aibridged.ProviderStatusError + outcome.Err = err + outcomes = append(outcomes, outcome) logger.Error(ctx, "skipping misconfigured ai provider", slog.F("provider_id", row.ID), slog.F("provider_name", row.Name), @@ -158,14 +179,16 @@ func BuildProviders(ctx context.Context, db database.Store, cfg codersdk.AIBridg ) continue } - out = append(out, prov) + outcome.Status = aibridged.ProviderStatusEnabled + outcomes = append(outcomes, outcome) + providers = append(providers, prov) } - if len(rows) > 0 && len(out) == 0 { + if enabledCount > 0 && len(providers) == 0 { logger.Warn(ctx, "all enabled ai providers failed to build; daemon will start with zero providers") } - return out, nil + return providers, outcomes, nil } // buildAIProviderFromRow decodes the settings blob and constructs the diff --git a/cli/aibridged_internal_test.go b/cli/aibridged_internal_test.go index 1f9f5126786..02269745207 100644 --- a/cli/aibridged_internal_test.go +++ b/cli/aibridged_internal_test.go @@ -13,6 +13,7 @@ import ( "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/aibridged" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/database/dbtestutil" @@ -35,7 +36,8 @@ func buildFromEnv(t *testing.T, cfg codersdk.AIBridgeConfig) ([]aibridge.Provide if err := coderd.SeedAIProvidersFromEnv(ctx, db, cfg, logger); err != nil { return nil, err } - return BuildProviders(ctx, db, cfg, logger) + providers, _, err := BuildProviders(ctx, db, cfg, logger) + return providers, err } func TestBuildProviders(t *testing.T) { @@ -323,28 +325,35 @@ func TestBuildProvidersSkipsBadRows(t *testing.T) { Settings: sql.NullString{String: "not-json", Valid: true}, }) - providers, err := BuildProviders(ctx, db, codersdk.AIBridgeConfig{}, logger) + providers, outcomes, err := BuildProviders(ctx, db, codersdk.AIBridgeConfig{}, logger) require.NoError(t, err) assert.Empty(t, providers) + require.Len(t, outcomes, 1) + assert.Equal(t, "anthropic-broken", outcomes[0].Name) + assert.Equal(t, aibridged.ProviderStatusError, outcomes[0].Status) + assert.Error(t, outcomes[0].Err) }) - t.Run("UnsupportedType", func(t *testing.T) { + t.Run("EnabledButNoKeys", 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. + // Azure routes through the OpenAI-family builder, which rejects + // rows without keys when BYOK is disabled. The row must be + // classified as error and excluded from the snapshot. 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) + providers, outcomes, err := BuildProviders(ctx, db, codersdk.AIBridgeConfig{}, logger) require.NoError(t, err) assert.Empty(t, providers) + require.Len(t, outcomes, 1) + assert.Equal(t, aibridged.ProviderStatusError, outcomes[0].Status) }) t.Run("BadRowDoesNotBlockGoodRow", func(t *testing.T) { @@ -369,10 +378,40 @@ func TestBuildProvidersSkipsBadRows(t *testing.T) { APIKey: "sk-good", }) - providers, err := BuildProviders(ctx, db, codersdk.AIBridgeConfig{}, logger) + providers, outcomes, err := BuildProviders(ctx, db, codersdk.AIBridgeConfig{}, logger) require.NoError(t, err) require.Len(t, providers, 1) assert.Equal(t, "openai-good", providers[0].Name()) + require.Len(t, outcomes, 2) + byName := map[string]aibridged.ProviderOutcome{} + for _, o := range outcomes { + byName[o.Name] = o + } + assert.Equal(t, aibridged.ProviderStatusError, byName["anthropic-broken"].Status) + assert.Equal(t, aibridged.ProviderStatusEnabled, byName["openai-good"].Status) + }) + + t.Run("DisabledRowClassifiedAsDisabled", func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitShort) + logger := slogtest.Make(t, nil) + + dbgen.AIProvider(t, db, database.AIProvider{ + Type: database.AiProviderTypeOpenai, + Name: "openai-off", + BaseUrl: "https://api.openai.com/", + }, func(p *database.InsertAIProviderParams) { + p.Enabled = false + }) + + providers, outcomes, err := BuildProviders(ctx, db, codersdk.AIBridgeConfig{}, logger) + require.NoError(t, err) + assert.Empty(t, providers, "disabled providers must not be in the active snapshot") + require.Len(t, outcomes, 1) + assert.Equal(t, "openai-off", outcomes[0].Name) + assert.Equal(t, aibridged.ProviderStatusDisabled, outcomes[0].Status) + assert.NoError(t, outcomes[0].Err) }) } diff --git a/cli/server.go b/cli/server.go index 0a15645e505..b2fa89fd3b0 100644 --- a/cli/server.go +++ b/cli/server.go @@ -1041,7 +1041,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() { - aibridgeProviders, err := BuildProviders(aibridgeInitCtx, options.Database, vals.AI.BridgeConfig, logger.Named("aibridge.providers")) + aibridgeProviders, _, err := BuildProviders(aibridgeInitCtx, options.Database, vals.AI.BridgeConfig, logger.Named("aibridge.providers")) if err != nil { return xerrors.Errorf("build AI providers: %w", err) } diff --git a/coderd/aibridged/metrics.go b/coderd/aibridged/metrics.go new file mode 100644 index 00000000000..b06a9c067cc --- /dev/null +++ b/coderd/aibridged/metrics.go @@ -0,0 +1,94 @@ +package aibridged + +import ( + "time" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" +) + +// Metrics is the prometheus surface for aibridged provider reloads. +type Metrics struct { + registerer prometheus.Registerer + + // ProviderInfo is one series per configured provider; value is + // always 1 and the status label carries the alertable signal. + // Labels: provider_name, provider_type, status. + ProviderInfo *prometheus.GaugeVec + + // ProvidersLastReloadTimestampSeconds is the unix timestamp of the + // last reload attempt, success or failure. + ProvidersLastReloadTimestampSeconds prometheus.Gauge + + // ProvidersLastReloadSuccessTimestampSeconds is the unix timestamp + // of the last reload that successfully refreshed the pool. A gap + // against ProvidersLastReloadTimestampSeconds means the loop is + // firing but the refresh function is failing. + ProvidersLastReloadSuccessTimestampSeconds prometheus.Gauge +} + +// NewMetrics registers the provider metrics against reg. +func NewMetrics(reg prometheus.Registerer) *Metrics { + factory := promauto.With(reg) + + return &Metrics{ + registerer: reg, + + ProviderInfo: factory.NewGaugeVec(prometheus.GaugeOpts{ + Name: "provider_info", + Help: "One series per configured AI provider. Value is always 1; the status label (enabled, disabled, error) carries the alertable signal.", + }, []string{"provider_name", "provider_type", "status"}), + + ProvidersLastReloadTimestampSeconds: factory.NewGauge(prometheus.GaugeOpts{ + Name: "providers_last_reload_timestamp_seconds", + Help: "Unix timestamp of the last provider reload attempt, success or failure.", + }), + + ProvidersLastReloadSuccessTimestampSeconds: factory.NewGauge(prometheus.GaugeOpts{ + Name: "providers_last_reload_success_timestamp_seconds", + Help: "Unix timestamp of the last provider reload that successfully refreshed the pool. A gap against coder_aibridged_providers_last_reload_timestamp_seconds means the loop is firing but the refresh function is failing.", + }), + } +} + +// Unregister removes the provider metrics from the registerer. +func (m *Metrics) Unregister() { + if m == nil { + return + } + m.registerer.Unregister(m.ProviderInfo) + m.registerer.Unregister(m.ProvidersLastReloadTimestampSeconds) + m.registerer.Unregister(m.ProvidersLastReloadSuccessTimestampSeconds) +} + +// RecordReloadAttempt stamps the attempt-time gauge at the start of a +// reload. A reload that hangs mid-flight is detected by watching the +// gap between this gauge and ProvidersLastReloadSuccessTimestampSeconds. +func (m *Metrics) RecordReloadAttempt() { + if m == nil { + return + } + m.ProvidersLastReloadTimestampSeconds.Set(float64(time.Now().Unix())) +} + +// RecordReloadSuccess rewrites the ProviderInfo GaugeVec from the +// outcomes and stamps the success-time gauge. Reset clears series for +// providers that have left the configuration so they don't linger as +// stale. +func (m *Metrics) RecordReloadSuccess(outcomes []ProviderOutcome) { + if m == nil { + return + } + WriteProviderInfoSnapshot(m.ProviderInfo, outcomes) + m.ProvidersLastReloadSuccessTimestampSeconds.Set(float64(time.Now().Unix())) +} + +// WriteProviderInfoSnapshot Resets info and writes one series per +// outcome. Both aibridged and aibridgeproxyd use this so the +// provider_info recording contract stays in one place. +func WriteProviderInfoSnapshot(info *prometheus.GaugeVec, outcomes []ProviderOutcome) { + info.Reset() + for _, o := range outcomes { + info.WithLabelValues(o.Name, o.Type, string(o.Status)).Set(1) + } +} diff --git a/coderd/aibridged/metrics_test.go b/coderd/aibridged/metrics_test.go new file mode 100644 index 00000000000..008c79dd340 --- /dev/null +++ b/coderd/aibridged/metrics_test.go @@ -0,0 +1,84 @@ +package aibridged_test + +import ( + "testing" + "time" + + "github.com/prometheus/client_golang/prometheus" + promtest "github.com/prometheus/client_golang/prometheus/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/coderd/aibridged" +) + +// TestMetricsRecordReloadSuccess covers the provider_info GaugeVec +// surface: every reload pass rewrites the series for the current +// outcomes and the Reset on each pass drops stale series. +func TestMetricsRecordReloadSuccess(t *testing.T) { + t.Parallel() + + reg := prometheus.NewRegistry() + m := aibridged.NewMetrics(reg) + + outcomes := []aibridged.ProviderOutcome{ + {Name: "alpha", Type: "openai", Status: aibridged.ProviderStatusEnabled}, + {Name: "beta", Type: "anthropic", Status: aibridged.ProviderStatusDisabled}, + {Name: "gamma", Type: "openai", Status: aibridged.ProviderStatusError, Err: xerrors.New("bad config")}, + } + + before := time.Now().Unix() + m.RecordReloadAttempt() + m.RecordReloadSuccess(outcomes) + after := time.Now().Unix() + + assert.Equal(t, 1.0, promtest.ToFloat64(m.ProviderInfo.WithLabelValues("alpha", "openai", "enabled"))) + assert.Equal(t, 1.0, promtest.ToFloat64(m.ProviderInfo.WithLabelValues("beta", "anthropic", "disabled"))) + assert.Equal(t, 1.0, promtest.ToFloat64(m.ProviderInfo.WithLabelValues("gamma", "openai", "error"))) + + attemptTS := int64(promtest.ToFloat64(m.ProvidersLastReloadTimestampSeconds)) + successTS := int64(promtest.ToFloat64(m.ProvidersLastReloadSuccessTimestampSeconds)) + assert.GreaterOrEqual(t, attemptTS, before) + assert.LessOrEqual(t, attemptTS, after) + assert.GreaterOrEqual(t, successTS, before) + assert.LessOrEqual(t, successTS, after) +} + +// TestMetricsResetsStaleProviderSeries verifies that providers removed +// from the outcome set between reloads do not leave behind stale +// series. +func TestMetricsResetsStaleProviderSeries(t *testing.T) { + t.Parallel() + + reg := prometheus.NewRegistry() + m := aibridged.NewMetrics(reg) + + m.RecordReloadSuccess([]aibridged.ProviderOutcome{ + {Name: "alpha", Type: "openai", Status: aibridged.ProviderStatusEnabled}, + {Name: "beta", Type: "anthropic", Status: aibridged.ProviderStatusEnabled}, + }) + require.Equal(t, 2, promtest.CollectAndCount(m.ProviderInfo)) + + m.RecordReloadSuccess([]aibridged.ProviderOutcome{ + {Name: "alpha", Type: "openai", Status: aibridged.ProviderStatusEnabled}, + }) + + assert.Equal(t, 1, promtest.CollectAndCount(m.ProviderInfo), + "beta should have been Reset out of the GaugeVec") + assert.Equal(t, 1.0, promtest.ToFloat64(m.ProviderInfo.WithLabelValues("alpha", "openai", "enabled"))) +} + +// TestMetricsNilSafe asserts the helpers tolerate a nil receiver so +// callers can pass `nil` to disable metric updates without guarding +// every call site. +func TestMetricsNilSafe(t *testing.T) { + t.Parallel() + + var m *aibridged.Metrics + require.NotPanics(t, func() { + m.RecordReloadAttempt() + m.RecordReloadSuccess(nil) + m.Unregister() + }) +} diff --git a/coderd/aibridged/provider.go b/coderd/aibridged/provider.go new file mode 100644 index 00000000000..6fb53e1a93e --- /dev/null +++ b/coderd/aibridged/provider.go @@ -0,0 +1,28 @@ +package aibridged + +// ProviderStatus is the lifecycle state of a configured AI provider. +type ProviderStatus string + +const ( + // ProviderStatusEnabled indicates the provider is configured and + // valid, and is included in the active pool snapshot. + ProviderStatusEnabled ProviderStatus = "enabled" + // ProviderStatusDisabled indicates the provider is configured but + // intentionally turned off by an operator. + ProviderStatusDisabled ProviderStatus = "disabled" + // ProviderStatusError indicates the provider is configured but + // cannot be constructed (missing keys, unsupported type, malformed + // settings). + ProviderStatusError ProviderStatus = "error" +) + +// ProviderOutcome classifies one ai_providers row, including disabled +// and errored rows the pool excludes. Err is populated only when +// Status == ProviderStatusError; the build error is already logged at +// the call site. +type ProviderOutcome struct { + Name string + Type string + Status ProviderStatus + Err error +} diff --git a/docs/admin/integrations/prometheus.md b/docs/admin/integrations/prometheus.md index 210f22d0405..acaf3e06418 100644 --- a/docs/admin/integrations/prometheus.md +++ b/docs/admin/integrations/prometheus.md @@ -120,11 +120,17 @@ deployment. They will always be available from the agent. | `coder_aibridged_non_injected_tool_selections_total` | counter | The number of times an AI model selected a tool to be invoked by the client. | `model` `name` `provider` | | `coder_aibridged_passthrough_total` | counter | The count of requests which were not intercepted but passed through to the upstream. | `method` `provider` `route` | | `coder_aibridged_prompts_total` | counter | The number of prompts issued by users (initiators). | `initiator_id` `model` `provider` | +| `coder_aibridged_provider_info` | gauge | One series per configured AI provider. Value is always 1; the status label (enabled, disabled, error) carries the alertable signal. | `provider_name` `provider_type` `status` | +| `coder_aibridged_providers_last_reload_success_timestamp_seconds` | gauge | Unix timestamp of the last provider reload that successfully refreshed the pool. A gap against coder_aibridged_providers_last_reload_timestamp_seconds means the loop is firing but the refresh function is failing. | | +| `coder_aibridged_providers_last_reload_timestamp_seconds` | gauge | Unix timestamp of the last provider reload attempt, success or failure. | | | `coder_aibridged_tokens_total` | counter | The number of tokens used by intercepted requests. | `initiator_id` `model` `provider` `type` | | `coder_aibridgeproxyd_connect_sessions_total` | counter | Total number of CONNECT sessions established. | `type` | | `coder_aibridgeproxyd_inflight_mitm_requests` | gauge | Number of MITM requests currently being processed. | `provider` | | `coder_aibridgeproxyd_mitm_requests_total` | counter | Total number of MITM requests handled by the proxy. | `provider` | | `coder_aibridgeproxyd_mitm_responses_total` | counter | Total number of MITM responses by HTTP status code class. | `code` `provider` | +| `coder_aibridgeproxyd_provider_info` | gauge | One series per configured AI provider. Value is always 1; the status label (enabled, disabled, error) carries the alertable signal. | `provider_name` `provider_type` `status` | +| `coder_aibridgeproxyd_providers_last_reload_success_timestamp_seconds` | gauge | Unix timestamp of the last provider reload that successfully refreshed the router. A gap against coder_aibridgeproxyd_providers_last_reload_timestamp_seconds means the loop is firing but the refresh function is failing. | | +| `coder_aibridgeproxyd_providers_last_reload_timestamp_seconds` | gauge | Unix timestamp of the last provider reload attempt, success or failure. | | | `coder_derp_server_accepts_total` | counter | Total DERP connections accepted. | | | `coder_derp_server_average_queue_duration_ms` | gauge | Average queue duration in milliseconds. | | | `coder_derp_server_bytes_received_total` | counter | Total bytes received. | | diff --git a/enterprise/aibridgeproxyd/aibridgeproxyd_test.go b/enterprise/aibridgeproxyd/aibridgeproxyd_test.go index fbf77956a2b..50224aa98cb 100644 --- a/enterprise/aibridgeproxyd/aibridgeproxyd_test.go +++ b/enterprise/aibridgeproxyd/aibridgeproxyd_test.go @@ -35,6 +35,7 @@ import ( "cdr.dev/slog/v3/sloggers/slogtest" "github.com/coder/coder/v2/aibridge" agplaibridge "github.com/coder/coder/v2/coderd/aibridge" + "github.com/coder/coder/v2/coderd/aibridged" "github.com/coder/coder/v2/enterprise/aibridgeproxyd" "github.com/coder/coder/v2/testutil" ) @@ -209,10 +210,12 @@ func withProviderHosts(hosts ...string) testProxyOption { host = h } providers = append(providers, aibridgeproxyd.ReloadedProvider{ - Name: name, - Type: "openai", - Host: strings.ToLower(host), - Status: aibridgeproxyd.ProviderStatusEnabled, + ProviderOutcome: aibridged.ProviderOutcome{ + Name: name, + Type: "openai", + Status: aibridged.ProviderStatusEnabled, + }, + Host: strings.ToLower(host), }) } cfg.providers = providers @@ -296,8 +299,8 @@ func newTestProxy(t *testing.T, opts ...testProxyOption) *aibridgeproxyd.Server // loopback, are reachable. Tests that verify IP blocking override this. allowedPrivateCIDRs: []string{"127.0.0.1/32"}, providers: []aibridgeproxyd.ReloadedProvider{ - {Name: "test-provider", Type: "openai", Host: "127.0.0.1", Status: aibridgeproxyd.ProviderStatusEnabled}, - {Name: "test-provider", Type: "openai", Host: "localhost", Status: aibridgeproxyd.ProviderStatusEnabled}, + {ProviderOutcome: aibridged.ProviderOutcome{Name: "test-provider", Type: "openai", Status: aibridged.ProviderStatusEnabled}, Host: "127.0.0.1"}, + {ProviderOutcome: aibridged.ProviderOutcome{Name: "test-provider", Type: "openai", Status: aibridged.ProviderStatusEnabled}, Host: "localhost"}, }, } for _, opt := range opts { @@ -2077,10 +2080,12 @@ func TestProxy_MITM_CustomProvider(t *testing.T) { srv := newTestProxy(t, withCoderAccessURL(aibridgedServer.URL), withProviders(aibridgeproxyd.ReloadedProvider{ - Name: openrouterProvider, - Type: "openai", - Host: openrouterDomain, - Status: aibridgeproxyd.ProviderStatusEnabled, + ProviderOutcome: aibridged.ProviderOutcome{ + Name: openrouterProvider, + Type: "openai", + Status: aibridged.ProviderStatusEnabled, + }, + Host: openrouterDomain, }), ) diff --git a/enterprise/aibridgeproxyd/metrics.go b/enterprise/aibridgeproxyd/metrics.go index 55a1fa41775..ccfd334aa70 100644 --- a/enterprise/aibridgeproxyd/metrics.go +++ b/enterprise/aibridgeproxyd/metrics.go @@ -30,6 +30,21 @@ type Metrics struct { // Labels: code (HTTP status code), provider // Cardinality is bounded: ~100 used status codes x few providers. MITMResponsesTotal *prometheus.CounterVec + + // ProviderInfo is one series per configured provider; value is + // always 1 and the status label carries the alertable signal. + // Labels: provider_name, provider_type, status. + ProviderInfo *prometheus.GaugeVec + + // ProvidersLastReloadTimestampSeconds is the unix timestamp of the + // last reload attempt, success or failure. + ProvidersLastReloadTimestampSeconds prometheus.Gauge + + // ProvidersLastReloadSuccessTimestampSeconds is the unix timestamp + // of the last reload that successfully refreshed the router. A gap + // against ProvidersLastReloadTimestampSeconds means the loop is + // firing but the refresh function is failing. + ProvidersLastReloadSuccessTimestampSeconds prometheus.Gauge } // NewMetrics creates and registers all metrics for aibridgeproxyd. @@ -58,6 +73,21 @@ func NewMetrics(reg prometheus.Registerer) *Metrics { Name: "mitm_responses_total", Help: "Total number of MITM responses by HTTP status code class.", }, []string{"code", "provider"}), + + ProviderInfo: factory.NewGaugeVec(prometheus.GaugeOpts{ + Name: "provider_info", + Help: "One series per configured AI provider. Value is always 1; the status label (enabled, disabled, error) carries the alertable signal.", + }, []string{"provider_name", "provider_type", "status"}), + + ProvidersLastReloadTimestampSeconds: factory.NewGauge(prometheus.GaugeOpts{ + Name: "providers_last_reload_timestamp_seconds", + Help: "Unix timestamp of the last provider reload attempt, success or failure.", + }), + + ProvidersLastReloadSuccessTimestampSeconds: factory.NewGauge(prometheus.GaugeOpts{ + Name: "providers_last_reload_success_timestamp_seconds", + Help: "Unix timestamp of the last provider reload that successfully refreshed the router. A gap against coder_aibridgeproxyd_providers_last_reload_timestamp_seconds means the loop is firing but the refresh function is failing.", + }), } } @@ -67,4 +97,7 @@ func (m *Metrics) Unregister() { m.registerer.Unregister(m.MITMRequestsTotal) m.registerer.Unregister(m.InflightMITMRequests) m.registerer.Unregister(m.MITMResponsesTotal) + m.registerer.Unregister(m.ProviderInfo) + m.registerer.Unregister(m.ProvidersLastReloadTimestampSeconds) + m.registerer.Unregister(m.ProvidersLastReloadSuccessTimestampSeconds) } diff --git a/enterprise/aibridgeproxyd/metrics_internal_test.go b/enterprise/aibridgeproxyd/metrics_internal_test.go new file mode 100644 index 00000000000..6ebefbd56be --- /dev/null +++ b/enterprise/aibridgeproxyd/metrics_internal_test.go @@ -0,0 +1,135 @@ +package aibridgeproxyd + +import ( + "context" + "testing" + "time" + + "github.com/prometheus/client_golang/prometheus" + promtest "github.com/prometheus/client_golang/prometheus/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/xerrors" + + "cdr.dev/slog/v3/sloggers/slogtest" + "github.com/coder/coder/v2/coderd/aibridged" + "github.com/coder/coder/v2/testutil" +) + +// TestReloadUpdatesProviderMetrics covers the provider_info GaugeVec +// surface: every reload pass rewrites the series for the current +// snapshot, including disabled and errored rows; the Reset on each +// reload drops series for providers that have left the configuration. +func TestReloadUpdatesProviderMetrics(t *testing.T) { + t.Parallel() + + reg := prometheus.NewRegistry() + metrics := NewMetrics(reg) + + reload := ProviderReload{Providers: []ReloadedProvider{ + {ProviderOutcome: aibridged.ProviderOutcome{Name: "alpha", Type: "openai", Status: aibridged.ProviderStatusEnabled}, Host: "alpha.example.com"}, + {ProviderOutcome: aibridged.ProviderOutcome{Name: "beta", Type: "anthropic", Status: aibridged.ProviderStatusDisabled}}, + {ProviderOutcome: aibridged.ProviderOutcome{Name: "gamma", Type: "openai", Status: aibridged.ProviderStatusError, Err: xerrors.New("bad config")}}, + }} + + ctx := testutil.Context(t, testutil.WaitShort) + srv := &Server{ + ctx: ctx, + logger: slogtest.Make(t, nil), + allowedPorts: []string{"443"}, + metrics: metrics, + refreshProviders: func(context.Context) (ProviderReload, error) { + return reload, nil + }, + } + srv.providerRouter.Store(emptyProviderRouter) + + before := time.Now().Unix() + require.NoError(t, srv.Reload(ctx)) + after := time.Now().Unix() + + assert.Equal(t, 1.0, promtest.ToFloat64(metrics.ProviderInfo.WithLabelValues("alpha", "openai", "enabled"))) + assert.Equal(t, 1.0, promtest.ToFloat64(metrics.ProviderInfo.WithLabelValues("beta", "anthropic", "disabled"))) + assert.Equal(t, 1.0, promtest.ToFloat64(metrics.ProviderInfo.WithLabelValues("gamma", "openai", "error"))) + + attemptTS := int64(promtest.ToFloat64(metrics.ProvidersLastReloadTimestampSeconds)) + successTS := int64(promtest.ToFloat64(metrics.ProvidersLastReloadSuccessTimestampSeconds)) + assert.GreaterOrEqual(t, attemptTS, before) + assert.LessOrEqual(t, attemptTS, after) + assert.GreaterOrEqual(t, successTS, before) + assert.LessOrEqual(t, successTS, after) +} + +// TestReloadResetsStaleProviderSeries verifies that providers removed +// between reloads do not leave behind stale series. Without Reset, a +// removed provider's last-seen value would persist for 5+ minutes and +// could fire alerts despite the provider no longer being configured. +func TestReloadResetsStaleProviderSeries(t *testing.T) { + t.Parallel() + + reg := prometheus.NewRegistry() + metrics := NewMetrics(reg) + + current := ProviderReload{Providers: []ReloadedProvider{ + {ProviderOutcome: aibridged.ProviderOutcome{Name: "alpha", Type: "openai", Status: aibridged.ProviderStatusEnabled}, Host: "alpha.example.com"}, + {ProviderOutcome: aibridged.ProviderOutcome{Name: "beta", Type: "anthropic", Status: aibridged.ProviderStatusEnabled}, Host: "beta.example.com"}, + }} + + ctx := testutil.Context(t, testutil.WaitShort) + srv := &Server{ + ctx: ctx, + logger: slogtest.Make(t, nil), + allowedPorts: []string{"443"}, + metrics: metrics, + refreshProviders: func(context.Context) (ProviderReload, error) { + return current, nil + }, + } + srv.providerRouter.Store(emptyProviderRouter) + + require.NoError(t, srv.Reload(ctx)) + require.Equal(t, 2, promtest.CollectAndCount(metrics.ProviderInfo)) + + current = ProviderReload{Providers: []ReloadedProvider{ + {ProviderOutcome: aibridged.ProviderOutcome{Name: "alpha", Type: "openai", Status: aibridged.ProviderStatusEnabled}, Host: "alpha.example.com"}, + }} + require.NoError(t, srv.Reload(ctx)) + + assert.Equal(t, 1, promtest.CollectAndCount(metrics.ProviderInfo), + "beta should have been Reset out of the GaugeVec") + assert.Equal(t, 1.0, promtest.ToFloat64(metrics.ProviderInfo.WithLabelValues("alpha", "openai", "enabled"))) +} + +// TestReloadAttemptTimestampUpdatesOnFailure asserts the attempt-time +// gauge advances even when the refresh function fails, while the +// success-time gauge does not. +func TestReloadAttemptTimestampUpdatesOnFailure(t *testing.T) { + t.Parallel() + + reg := prometheus.NewRegistry() + metrics := NewMetrics(reg) + refreshErr := xerrors.New("simulated failure") + + ctx := testutil.Context(t, testutil.WaitShort) + srv := &Server{ + ctx: ctx, + logger: slogtest.Make(t, nil), + allowedPorts: []string{"443"}, + metrics: metrics, + refreshProviders: func(context.Context) (ProviderReload, error) { + return ProviderReload{}, refreshErr + }, + } + srv.providerRouter.Store(emptyProviderRouter) + + before := time.Now().Unix() + err := srv.Reload(ctx) + require.ErrorIs(t, err, refreshErr) + after := time.Now().Unix() + + attemptTS := int64(promtest.ToFloat64(metrics.ProvidersLastReloadTimestampSeconds)) + successTS := int64(promtest.ToFloat64(metrics.ProvidersLastReloadSuccessTimestampSeconds)) + assert.GreaterOrEqual(t, attemptTS, before) + assert.LessOrEqual(t, attemptTS, after) + assert.Equal(t, int64(0), successTS, "success timestamp must not advance on failure") +} diff --git a/enterprise/aibridgeproxyd/reload.go b/enterprise/aibridgeproxyd/reload.go index 686bab11306..04b1f5438b0 100644 --- a/enterprise/aibridgeproxyd/reload.go +++ b/enterprise/aibridgeproxyd/reload.go @@ -5,40 +5,21 @@ import ( "net/http" "slices" "strings" + "time" "github.com/elazarl/goproxy" "golang.org/x/xerrors" "cdr.dev/slog/v3" + "github.com/coder/coder/v2/coderd/aibridged" ) -// ProviderStatus describes the lifecycle state of a configured AI -// provider for observability and routing purposes. -type ProviderStatus string - -const ( - // ProviderStatusEnabled means the provider is configured, valid, and - // included in the active routing snapshot. - ProviderStatusEnabled ProviderStatus = "enabled" - // ProviderStatusDisabled means the provider exists in configuration - // but is intentionally turned off by an operator. - ProviderStatusDisabled ProviderStatus = "disabled" - // ProviderStatusError means the provider exists in configuration but - // cannot be routed to because of a validation failure (missing or - // invalid base URL, duplicate host, etc.). - ProviderStatusError ProviderStatus = "error" -) - -// ReloadedProvider is one row from the provider configuration together -// with the outcome of evaluating it for routing. Host is populated only -// when Status == ProviderStatusEnabled; Err is populated only when -// Status == ProviderStatusError. +// ReloadedProvider is the classification of one ai_providers row. +// Host is the routable hostname; it's populated only when the embedded +// outcome's Status == aibridged.ProviderStatusEnabled. type ReloadedProvider struct { - Name string - Type string - Host string - Status ProviderStatus - Err error + aibridged.ProviderOutcome + Host string } // ProviderReload is the result of a single refresh pass: every @@ -47,8 +28,8 @@ type ProviderReload struct { Providers []ReloadedProvider } -// RefreshProvidersFunc returns the live provider classification used by -// Reload to rebuild the proxy's routing snapshot. +// RefreshProvidersFunc returns the live provider classification used +// by Reload to rebuild the proxy's routing snapshot. type RefreshProvidersFunc func(ctx context.Context) (ProviderReload, error) // Reload refreshes proxy routing from the configured provider source. @@ -57,6 +38,7 @@ func (s *Server) Reload(ctx context.Context) error { if s.refreshProviders == nil { return nil } + s.recordReloadAttempt() reload, err := s.refreshProviders(ctx) if err != nil { return xerrors.Errorf("refresh ai providers for proxy routing: %w", err) @@ -67,13 +49,14 @@ func (s *Server) Reload(ctx context.Context) error { } s.providerRouter.Store(router) for _, p := range reload.Providers { - if p.Status == ProviderStatusError { + if p.Status == aibridged.ProviderStatusError { s.logger.Warn(s.ctx, "provider excluded from routing", slog.F("provider", p.Name), slog.Error(p.Err), ) } } + s.recordReloadSuccess(reload) s.logger.Debug(s.ctx, "aibridgeproxyd router reloaded", slog.F("provider_count", len(reload.Providers)), slog.F("mitm_host_count", len(router.mitmHosts)), @@ -82,6 +65,32 @@ func (s *Server) Reload(ctx context.Context) error { return nil } +// recordReloadAttempt stamps the attempt-time gauge at the start of a +// Reload. A reload that hangs mid-flight is detected by watching the +// gap between this gauge and ProvidersLastReloadSuccessTimestampSeconds. +func (s *Server) recordReloadAttempt() { + if s.metrics == nil { + return + } + s.metrics.ProvidersLastReloadTimestampSeconds.Set(float64(time.Now().Unix())) +} + +// recordReloadSuccess rewrites the provider_info GaugeVec from the +// classified reload and stamps the success-time gauge. Reset clears +// series for providers that have left the configuration so they don't +// linger as stale. +func (s *Server) recordReloadSuccess(reload ProviderReload) { + if s.metrics == nil { + return + } + outcomes := make([]aibridged.ProviderOutcome, len(reload.Providers)) + for i, p := range reload.Providers { + outcomes[i] = p.ProviderOutcome + } + aibridged.WriteProviderInfoSnapshot(s.metrics.ProviderInfo, outcomes) + s.metrics.ProvidersLastReloadSuccessTimestampSeconds.Set(float64(time.Now().Unix())) +} + func (s *Server) loadProviderRouter() *providerRouter { if p := s.providerRouter.Load(); p != nil { return p @@ -103,16 +112,17 @@ func (s *Server) mitmHostsCondition() goproxy.ReqConditionFunc { } // buildProviderRouter constructs a router snapshot from a classified -// provider reload. Only providers with Status == ProviderStatusEnabled -// are included in the active routing tables; the refresh function is -// responsible for classifying disabled and errored rows. First entry -// wins on duplicate hostnames as a defense-in-depth measure even though -// the refresh function should mark duplicates as errors. +// provider reload. Only providers with Status == +// aibridged.ProviderStatusEnabled are included in the active routing +// tables; the refresh function is responsible for classifying disabled +// and errored rows. First entry wins on duplicate hostnames as a +// defense-in-depth measure even though the refresh function should +// mark duplicates as errors. func buildProviderRouter(reload ProviderReload, allowedPorts []string) (*providerRouter, error) { nameByHost := make(map[string]string, len(reload.Providers)) domains := make([]string, 0, len(reload.Providers)) for _, p := range reload.Providers { - if p.Status != ProviderStatusEnabled { + if p.Status != aibridged.ProviderStatusEnabled { continue } host := strings.ToLower(p.Host) diff --git a/enterprise/aibridgeproxyd/reload_internal_test.go b/enterprise/aibridgeproxyd/reload_internal_test.go index fb985445f3e..5ccba37ec7b 100644 --- a/enterprise/aibridgeproxyd/reload_internal_test.go +++ b/enterprise/aibridgeproxyd/reload_internal_test.go @@ -9,15 +9,18 @@ import ( "golang.org/x/xerrors" "cdr.dev/slog/v3/sloggers/slogtest" + "github.com/coder/coder/v2/coderd/aibridged" "github.com/coder/coder/v2/testutil" ) func enabledProvider(name, host string) ReloadedProvider { return ReloadedProvider{ - Name: name, - Type: "openai", - Host: host, - Status: ProviderStatusEnabled, + ProviderOutcome: aibridged.ProviderOutcome{ + Name: name, + Type: "openai", + Status: aibridged.ProviderStatusEnabled, + }, + Host: host, } } @@ -96,8 +99,8 @@ func TestBuildProviderRouter(t *testing.T) { enabledProvider("custom", "custom-llm.example.com"), // Host is populated on the non-enabled rows so the Status // guard, not the empty-host guard, is what excludes them. - {Name: "off", Type: "openai", Host: "disabled.example.com", Status: ProviderStatusDisabled}, - {Name: "bad", Type: "openai", Host: "errored.example.com", Status: ProviderStatusError, Err: xerrors.New("nope")}, + {ProviderOutcome: aibridged.ProviderOutcome{Name: "off", Type: "openai", Status: aibridged.ProviderStatusDisabled}, Host: "disabled.example.com"}, + {ProviderOutcome: aibridged.ProviderOutcome{Name: "bad", Type: "openai", Status: aibridged.ProviderStatusError, Err: xerrors.New("nope")}, Host: "errored.example.com"}, }} router, err := buildProviderRouter(reload, []string{"443"}) @@ -121,7 +124,7 @@ func TestBuildProviderRouter(t *testing.T) { t.Parallel() reload := ProviderReload{Providers: []ReloadedProvider{ - {Name: "provider", Type: "openai", Host: "API.Example.COM", Status: ProviderStatusEnabled}, + {ProviderOutcome: aibridged.ProviderOutcome{Name: "provider", Type: "openai", Status: aibridged.ProviderStatusEnabled}, Host: "API.Example.COM"}, }} router, err := buildProviderRouter(reload, []string{"443"}) @@ -152,7 +155,7 @@ func TestBuildProviderRouter(t *testing.T) { t.Parallel() reload := ProviderReload{Providers: []ReloadedProvider{ - {Name: "no-host", Type: "openai", Status: ProviderStatusEnabled}, + {ProviderOutcome: aibridged.ProviderOutcome{Name: "no-host", Type: "openai", Status: aibridged.ProviderStatusEnabled}}, enabledProvider("good", "api.good.example.com"), }} diff --git a/enterprise/aibridgeproxyd/reload_test.go b/enterprise/aibridgeproxyd/reload_test.go index e55d45a3725..bfc90338d42 100644 --- a/enterprise/aibridgeproxyd/reload_test.go +++ b/enterprise/aibridgeproxyd/reload_test.go @@ -11,10 +11,13 @@ import ( "sync" "testing" + "github.com/prometheus/client_golang/prometheus" + promtest "github.com/prometheus/client_golang/prometheus/testutil" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/xerrors" + "github.com/coder/coder/v2/coderd/aibridged" "github.com/coder/coder/v2/enterprise/aibridgeproxyd" "github.com/coder/coder/v2/testutil" ) @@ -28,6 +31,7 @@ type reloadTestHarness struct { client *http.Client bridged *httptest.Server recorder *aibridgedRecorder + metrics *aibridgeproxyd.Metrics } // aibridgedRecorder captures the path of the last request received by @@ -106,32 +110,34 @@ func (s *providerStore) refresh(context.Context) (aibridgeproxyd.ProviderReload, // classifyRaw mirrors the production classifier in enterprise/cli so // the reload tests exercise the same validation rules end-to-end. func classifyRaw(p rawProvider, seenHost map[string]string) aibridgeproxyd.ReloadedProvider { - out := aibridgeproxyd.ReloadedProvider{Name: p.name, Type: "openai"} + out := aibridgeproxyd.ReloadedProvider{ + ProviderOutcome: aibridged.ProviderOutcome{Name: p.name, Type: "openai"}, + } if strings.TrimSpace(p.baseURL) == "" { - out.Status = aibridgeproxyd.ProviderStatusError + out.Status = aibridged.ProviderStatusError out.Err = xerrors.New("base url is empty") return out } u, err := url.Parse(p.baseURL) if err != nil { - out.Status = aibridgeproxyd.ProviderStatusError + out.Status = aibridged.ProviderStatusError out.Err = xerrors.Errorf("invalid base url %q: %w", p.baseURL, err) return out } host := strings.ToLower(u.Hostname()) if host == "" { - out.Status = aibridgeproxyd.ProviderStatusError + out.Status = aibridged.ProviderStatusError out.Err = xerrors.Errorf("base url %q has no hostname", p.baseURL) return out } if claimedBy, taken := seenHost[host]; taken { - out.Status = aibridgeproxyd.ProviderStatusError + out.Status = aibridged.ProviderStatusError out.Err = xerrors.Errorf("hostname %q already claimed by provider %q", host, claimedBy) return out } seenHost[host] = p.name out.Host = host - out.Status = aibridgeproxyd.ProviderStatusEnabled + out.Status = aibridged.ProviderStatusEnabled return out } @@ -151,10 +157,12 @@ func newReloadTestHarness(t *testing.T) *reloadTestHarness { t.Cleanup(bridged.Close) store := &providerStore{} + metrics := aibridgeproxyd.NewMetrics(prometheus.NewRegistry()) srv := newTestProxy(t, withCoderAccessURL(bridged.URL), withAllowedPorts("443"), withRefreshProviders(store.refresh), + withMetrics(metrics), ) certPool := getProxyCertPool(t) @@ -169,6 +177,7 @@ func newReloadTestHarness(t *testing.T) *reloadTestHarness { return &reloadTestHarness{ srv: srv, store: store, + metrics: metrics, client: client, bridged: bridged, recorder: recorder, @@ -236,6 +245,25 @@ func (h *reloadTestHarness) expectNotRouted(t *testing.T, targetURL string) { "aibridged must not be reached for non-routed host %s", targetURL) } +// expectProviderStatus asserts the provider_info series for (name, +// status) is present with value 1. +func (h *reloadTestHarness) expectProviderStatus(t *testing.T, name, status string) { + t.Helper() + assert.Equal(t, 1.0, promtest.ToFloat64(h.metrics.ProviderInfo.WithLabelValues(name, "openai", status)), + "expected provider_info{provider_name=%q, status=%q} == 1", name, status) +} + +// expectProviderAbsent asserts no series exists for the provider name +// in any status. This verifies the GaugeVec.Reset on each reload +// clears stale entries. +func (h *reloadTestHarness) expectProviderAbsent(t *testing.T, name string) { + t.Helper() + for _, status := range []string{"enabled", "disabled", "error"} { + assert.Equal(t, 0.0, promtest.ToFloat64(h.metrics.ProviderInfo.WithLabelValues(name, "openai", status)), + "expected no provider_info series for %q, found status %q", name, status) + } +} + // TestProxy_StaleTunnelStopsRoutingAfterProviderChange is the // regression test for a bug where a long-lived CONNECT tunnel that was // established while a provider was enabled kept routing decrypted @@ -377,14 +405,18 @@ func TestProxy_HotReloadRoutingCRUD(t *testing.T) { }) require.NoError(t, h.srv.Reload(t.Context())) h.expectRoutedTo(t, "https://alpha.invalid/v1/messages", "/api/v2/aibridge/alpha/v1/messages") + h.expectProviderStatus(t, "alpha", "enabled") // UpdateProviderName: the same BaseURL with a new name must route - // under the new name on the next Reload. + // under the new name on the next Reload. The renamed provider must + // not leave a stale alpha series behind. h.store.set([]rawProvider{ {name: "alpha-v2", baseURL: "https://alpha.invalid/v1"}, }) require.NoError(t, h.srv.Reload(t.Context())) h.expectRoutedTo(t, "https://alpha.invalid/v1/messages", "/api/v2/aibridge/alpha-v2/v1/messages") + h.expectProviderStatus(t, "alpha-v2", "enabled") + h.expectProviderAbsent(t, "alpha") // UpdateProviderBaseURLHost: moving the provider to a new host must // start MITM'ing the new host and stop MITM'ing the old one. @@ -394,6 +426,7 @@ func TestProxy_HotReloadRoutingCRUD(t *testing.T) { require.NoError(t, h.srv.Reload(t.Context())) h.expectRoutedTo(t, "https://alpha-new.invalid/v1/messages", "/api/v2/aibridge/alpha-v2/v1/messages") h.expectNotRouted(t, "https://alpha.invalid/v1/messages") + h.expectProviderStatus(t, "alpha-v2", "enabled") // AddSecondProvider: a second provider added in the same Reload must // route independently from the first. @@ -404,15 +437,19 @@ func TestProxy_HotReloadRoutingCRUD(t *testing.T) { require.NoError(t, h.srv.Reload(t.Context())) h.expectRoutedTo(t, "https://alpha-new.invalid/v1/messages", "/api/v2/aibridge/alpha-v2/v1/messages") h.expectRoutedTo(t, "https://beta.invalid/v1/chat/completions", "/api/v2/aibridge/beta/v1/chat/completions") + h.expectProviderStatus(t, "alpha-v2", "enabled") + h.expectProviderStatus(t, "beta", "enabled") // DeleteOneProvider: removing alpha must keep beta routed and stop - // routing alpha. + // routing alpha. The deleted name disappears from provider_info. h.store.set([]rawProvider{ {name: "beta", baseURL: "https://beta.invalid/v1"}, }) require.NoError(t, h.srv.Reload(t.Context())) h.expectRoutedTo(t, "https://beta.invalid/v1/chat/completions", "/api/v2/aibridge/beta/v1/chat/completions") h.expectNotRouted(t, "https://alpha-new.invalid/v1/messages") + h.expectProviderStatus(t, "beta", "enabled") + h.expectProviderAbsent(t, "alpha-v2") // DeleteAllProviders: an empty Reload must collapse the router to // the fail-closed state with no host MITM'd. @@ -420,6 +457,7 @@ func TestProxy_HotReloadRoutingCRUD(t *testing.T) { require.NoError(t, h.srv.Reload(t.Context())) h.expectNotRouted(t, "https://beta.invalid/v1/chat/completions") h.expectNotRouted(t, "https://alpha-new.invalid/v1/messages") + h.expectProviderAbsent(t, "beta") // RecreateAfterDelete: reintroducing a previously-deleted provider // must route again without restart, confirming the swap is @@ -429,6 +467,11 @@ func TestProxy_HotReloadRoutingCRUD(t *testing.T) { }) require.NoError(t, h.srv.Reload(t.Context())) h.expectRoutedTo(t, "https://alpha.invalid/v1/messages", "/api/v2/aibridge/alpha/v1/messages") + h.expectProviderStatus(t, "alpha", "enabled") + + // Both timestamp gauges must have advanced through this sequence. + assert.Positive(t, promtest.ToFloat64(h.metrics.ProvidersLastReloadTimestampSeconds)) + assert.Positive(t, promtest.ToFloat64(h.metrics.ProvidersLastReloadSuccessTimestampSeconds)) } // TestProxy_HotReloadRoutingInvalidProviders covers the resilience @@ -453,6 +496,8 @@ func TestProxy_HotReloadRoutingInvalidProviders(t *testing.T) { require.NoError(t, h.srv.Reload(t.Context())) h.expectRoutedTo(t, "https://valid.invalid/v1/messages", "/api/v2/aibridge/valid/v1/messages") + h.expectProviderStatus(t, "no-url", "error") + h.expectProviderStatus(t, "valid", "enabled") }) t.Run("MalformedBaseURLSkipped", func(t *testing.T) { @@ -470,6 +515,9 @@ func TestProxy_HotReloadRoutingInvalidProviders(t *testing.T) { require.NoError(t, h.srv.Reload(t.Context())) h.expectRoutedTo(t, "https://valid.invalid/v1/messages", "/api/v2/aibridge/valid/v1/messages") + h.expectProviderStatus(t, "malformed", "error") + h.expectProviderStatus(t, "no-host", "error") + h.expectProviderStatus(t, "valid", "enabled") }) t.Run("DuplicateHostFirstWins", func(t *testing.T) { @@ -485,6 +533,8 @@ func TestProxy_HotReloadRoutingInvalidProviders(t *testing.T) { require.NoError(t, h.srv.Reload(t.Context())) h.expectRoutedTo(t, "https://shared.invalid/v1/messages", "/api/v2/aibridge/first/v1/messages") + h.expectProviderStatus(t, "first", "enabled") + h.expectProviderStatus(t, "second", "error") }) t.Run("AllInvalidYieldsEmptyRouter", func(t *testing.T) { diff --git a/enterprise/cli/aibridgeproxyd.go b/enterprise/cli/aibridgeproxyd.go index 00cbefaee6a..08641f5769c 100644 --- a/enterprise/cli/aibridgeproxyd.go +++ b/enterprise/cli/aibridgeproxyd.go @@ -118,37 +118,39 @@ func refreshProxyProviders(db database.Store) aibridgeproxyd.RefreshProvidersFun // hostname so later duplicates can be flagged as errors. func classifyProviderRow(row database.AIProvider, seenHost map[string]string) aibridgeproxyd.ReloadedProvider { out := aibridgeproxyd.ReloadedProvider{ - Name: row.Name, - Type: string(row.Type), + ProviderOutcome: aibridged.ProviderOutcome{ + Name: row.Name, + Type: string(row.Type), + }, } if !row.Enabled { - out.Status = aibridgeproxyd.ProviderStatusDisabled + out.Status = aibridged.ProviderStatusDisabled return out } if strings.TrimSpace(row.BaseUrl) == "" { - out.Status = aibridgeproxyd.ProviderStatusError + out.Status = aibridged.ProviderStatusError out.Err = xerrors.New("base url is empty") return out } u, err := url.Parse(row.BaseUrl) if err != nil { - out.Status = aibridgeproxyd.ProviderStatusError + out.Status = aibridged.ProviderStatusError out.Err = xerrors.Errorf("invalid base url %q: %w", row.BaseUrl, err) return out } host := strings.ToLower(u.Hostname()) if host == "" { - out.Status = aibridgeproxyd.ProviderStatusError + out.Status = aibridged.ProviderStatusError out.Err = xerrors.Errorf("base url %q has no hostname", row.BaseUrl) return out } if claimedBy, taken := seenHost[host]; taken { - out.Status = aibridgeproxyd.ProviderStatusError + out.Status = aibridged.ProviderStatusError out.Err = xerrors.Errorf("hostname %q already claimed by provider %q", host, claimedBy) return out } seenHost[host] = row.Name out.Host = host - out.Status = aibridgeproxyd.ProviderStatusEnabled + out.Status = aibridged.ProviderStatusEnabled return out } diff --git a/enterprise/cli/aibridgeproxyd_internal_test.go b/enterprise/cli/aibridgeproxyd_internal_test.go index 54c6c25f78f..2c8520878b6 100644 --- a/enterprise/cli/aibridgeproxyd_internal_test.go +++ b/enterprise/cli/aibridgeproxyd_internal_test.go @@ -7,8 +7,8 @@ import ( "github.com/stretchr/testify/assert" + "github.com/coder/coder/v2/coderd/aibridged" "github.com/coder/coder/v2/coderd/database" - "github.com/coder/coder/v2/enterprise/aibridgeproxyd" ) // TestClassifyProviderRow covers every branch of the classifier so the @@ -34,7 +34,7 @@ func TestClassifyProviderRow(t *testing.T) { got := classifyProviderRow(enabledRow("openai", "https://api.openai.com/v1"), seen) assert.Equal(t, "openai", got.Name) assert.Equal(t, string(database.AiProviderTypeOpenai), got.Type) - assert.Equal(t, aibridgeproxyd.ProviderStatusEnabled, got.Status) + assert.Equal(t, aibridged.ProviderStatusEnabled, got.Status) assert.Equal(t, "api.openai.com", got.Host) assert.NoError(t, got.Err) assert.Equal(t, "openai", seen["api.openai.com"]) @@ -47,7 +47,7 @@ func TestClassifyProviderRow(t *testing.T) { row := enabledRow("off", "https://api.off.example.com/v1") row.Enabled = false got := classifyProviderRow(row, seen) - assert.Equal(t, aibridgeproxyd.ProviderStatusDisabled, got.Status) + assert.Equal(t, aibridged.ProviderStatusDisabled, got.Status) assert.Empty(t, got.Host, "disabled provider must not claim a host") assert.NoError(t, got.Err) assert.Empty(t, seen, "disabled provider must not occupy a host slot") @@ -58,7 +58,7 @@ func TestClassifyProviderRow(t *testing.T) { seen := map[string]string{} got := classifyProviderRow(enabledRow("no-url", " "), seen) - assert.Equal(t, aibridgeproxyd.ProviderStatusError, got.Status) + assert.Equal(t, aibridged.ProviderStatusError, got.Status) assert.Empty(t, got.Host) assert.ErrorContains(t, got.Err, "base url is empty") }) @@ -68,7 +68,7 @@ func TestClassifyProviderRow(t *testing.T) { seen := map[string]string{} got := classifyProviderRow(enabledRow("bad", "://not-a-url"), seen) - assert.Equal(t, aibridgeproxyd.ProviderStatusError, got.Status) + assert.Equal(t, aibridged.ProviderStatusError, got.Status) assert.ErrorContains(t, got.Err, "invalid base url") }) @@ -77,7 +77,7 @@ func TestClassifyProviderRow(t *testing.T) { seen := map[string]string{} got := classifyProviderRow(enabledRow("no-host", "https://"), seen) - assert.Equal(t, aibridgeproxyd.ProviderStatusError, got.Status) + assert.Equal(t, aibridged.ProviderStatusError, got.Status) assert.ErrorContains(t, got.Err, "no hostname") }) @@ -86,10 +86,10 @@ func TestClassifyProviderRow(t *testing.T) { seen := map[string]string{} first := classifyProviderRow(enabledRow("first", "https://shared.example.com/v1"), seen) - assert.Equal(t, aibridgeproxyd.ProviderStatusEnabled, first.Status) + assert.Equal(t, aibridged.ProviderStatusEnabled, first.Status) second := classifyProviderRow(enabledRow("second", "https://shared.example.com/v2"), seen) - assert.Equal(t, aibridgeproxyd.ProviderStatusError, second.Status) + assert.Equal(t, aibridged.ProviderStatusError, second.Status) assert.ErrorContains(t, second.Err, "already claimed by provider \"first\"") assert.Equal(t, "first", seen["shared.example.com"], "first wins must not be overwritten") }) @@ -99,7 +99,7 @@ func TestClassifyProviderRow(t *testing.T) { seen := map[string]string{} got := classifyProviderRow(enabledRow("mixed", "https://API.Example.COM/v1"), seen) - assert.Equal(t, aibridgeproxyd.ProviderStatusEnabled, got.Status) + assert.Equal(t, aibridged.ProviderStatusEnabled, got.Status) assert.Equal(t, "api.example.com", got.Host) }) } diff --git a/enterprise/coderd/aibridge_reload_test.go b/enterprise/coderd/aibridge_reload_test.go index 6aee3afa91a..65f678df6eb 100644 --- a/enterprise/coderd/aibridge_reload_test.go +++ b/enterprise/coderd/aibridge_reload_test.go @@ -9,6 +9,8 @@ import ( "sync/atomic" "testing" + "github.com/prometheus/client_golang/prometheus" + promtest "github.com/prometheus/client_golang/prometheus/testutil" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.opentelemetry.io/otel" @@ -54,7 +56,7 @@ func newMockUpstream(t *testing.T, name string) *mockUpstream { // the supplied API and subscribes it to ai_providers change events. // This mirrors what cli/server.go does in production so /api/v2/aibridge // requests dispatch through the real pool and reloader. -func startTestAIBridgeDaemon(t *testing.T, api *coderd.API) { +func startTestAIBridgeDaemon(t *testing.T, api *coderd.API) *aibridged.Metrics { t.Helper() ctx := context.Background() @@ -62,14 +64,15 @@ func startTestAIBridgeDaemon(t *testing.T, api *coderd.API) { cfg := api.DeploymentValues.AI.BridgeConfig tracer := otel.Tracer("aibridge-reload-test") - providers, err := cli.BuildProviders(ctx, api.Database, cfg, logger) + providers, _, err := cli.BuildProviders(ctx, api.Database, cfg, logger) require.NoError(t, err) pool, err := aibridged.NewCachedBridgePool(aibridged.DefaultPoolOptions, providers, logger.Named("pool"), nil, tracer) require.NoError(t, err) t.Cleanup(func() { _ = pool.Shutdown(context.Background()) }) - reloader := &testPoolReloader{pool: pool, db: api.Database, cfg: cfg, logger: logger.Named("reloader")} + metrics := aibridged.NewMetrics(prometheus.NewRegistry()) + reloader := &testPoolReloader{pool: pool, db: api.Database, cfg: cfg, logger: logger.Named("reloader"), metrics: metrics} unsubscribe, err := aibridged.SubscribeProviderReload(ctx, api.Pubsub, reloader, logger.Named("subscriber")) require.NoError(t, err) t.Cleanup(unsubscribe) @@ -81,21 +84,25 @@ func startTestAIBridgeDaemon(t *testing.T, api *coderd.API) { t.Cleanup(func() { _ = srv.Close() }) api.RegisterInMemoryAIBridgedHTTPHandler(srv) + return metrics } type testPoolReloader struct { - pool *aibridged.CachedBridgePool - db database.Store - cfg codersdk.AIBridgeConfig - logger slog.Logger + pool *aibridged.CachedBridgePool + db database.Store + cfg codersdk.AIBridgeConfig + logger slog.Logger + metrics *aibridged.Metrics } func (r *testPoolReloader) Reload(ctx context.Context) error { - providers, err := cli.BuildProviders(ctx, r.db, r.cfg, r.logger) + defer r.metrics.RecordReloadAttempt() + providers, outcomes, err := cli.BuildProviders(ctx, r.db, r.cfg, r.logger) if err != nil { return err } r.pool.ReplaceProviders(providers) + r.metrics.RecordReloadSuccess(outcomes) return nil } @@ -124,7 +131,34 @@ func TestAIBridgeProviderHotReload(t *testing.T) { }, }) - startTestAIBridgeDaemon(t, api.AGPL) + metrics := startTestAIBridgeDaemon(t, api.AGPL) + + // requireProviderStatus polls until the provider_info series for + // (name, status) settles to value 1. Reloads happen via pubsub, so + // the assertion has to be eventual. + requireProviderStatus := func(t *testing.T, name, status string) { + t.Helper() + require.Eventuallyf(t, func() bool { + return promtest.ToFloat64(metrics.ProviderInfo.WithLabelValues(name, "openai", status)) == 1 + }, testutil.WaitShort, testutil.IntervalFast, + "expected provider_info{provider_name=%q, status=%q} == 1", name, status) + } + + // requireProviderAbsent polls until no series exists for the + // provider name in any status. After a delete the Reset on the + // next reload must clear all previous status labels for the name. + requireProviderAbsent := func(t *testing.T, name string) { + t.Helper() + require.Eventuallyf(t, func() bool { + for _, status := range []string{"enabled", "disabled", "error"} { + if promtest.ToFloat64(metrics.ProviderInfo.WithLabelValues(name, "openai", status)) != 0 { + return false + } + } + return true + }, testutil.WaitShort, testutil.IntervalFast, + "expected provider_info series for %q to be cleared after delete", name) + } ctx := testutil.Context(t, testutil.WaitLong) @@ -188,6 +222,7 @@ func TestAIBridgeProviderHotReload(t *testing.T) { require.NoError(t, err) require.Equal(t, "primary", created.Name) requireRoutesTo(t, "primary", upstreamA) + requireProviderStatus(t, "primary", "enabled") // 2. Update BaseURL: same name, now points at upstream B. newBaseURL := upstreamB.server.URL @@ -196,15 +231,17 @@ func TestAIBridgeProviderHotReload(t *testing.T) { }) require.NoError(t, err) requireRoutesTo(t, "primary", upstreamB) + requireProviderStatus(t, "primary", "enabled") // 3. Disable: the provider drops out of the snapshot, requests - // stop reaching any upstream. + // stop reaching any upstream. The metric flips to "disabled". disabled := false _, err = client.UpdateAIProvider(ctx, "primary", codersdk.UpdateAIProviderRequest{ Enabled: &disabled, }) require.NoError(t, err) requireRoutingGone(t, "primary") + requireProviderStatus(t, "primary", "disabled") // 4. Re-enable: routing comes back at the most recent BaseURL. enabled := true @@ -213,6 +250,7 @@ func TestAIBridgeProviderHotReload(t *testing.T) { }) require.NoError(t, err) requireRoutesTo(t, "primary", upstreamB) + requireProviderStatus(t, "primary", "enabled") // 5. Add a second provider; both names must route independently. _, err = client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ @@ -225,9 +263,19 @@ func TestAIBridgeProviderHotReload(t *testing.T) { require.NoError(t, err) requireRoutesTo(t, "primary", upstreamB) requireRoutesTo(t, "secondary", upstreamA) + requireProviderStatus(t, "primary", "enabled") + requireProviderStatus(t, "secondary", "enabled") - // 6. Delete primary: only secondary remains routable. + // 6. Delete primary: only secondary remains routable. The + // provider_info series for primary disappears entirely on the + // next reload's Reset. require.NoError(t, client.DeleteAIProvider(ctx, "primary")) requireRoutingGone(t, "primary") requireRoutesTo(t, "secondary", upstreamA) + requireProviderAbsent(t, "primary") + requireProviderStatus(t, "secondary", "enabled") + + // Both timestamp gauges must have advanced during this test. + assert.Positive(t, promtest.ToFloat64(metrics.ProvidersLastReloadTimestampSeconds)) + assert.Positive(t, promtest.ToFloat64(metrics.ProvidersLastReloadSuccessTimestampSeconds)) } diff --git a/scripts/metricsdocgen/metrics b/scripts/metricsdocgen/metrics index 653de992419..036ac496a16 100644 --- a/scripts/metricsdocgen/metrics +++ b/scripts/metricsdocgen/metrics @@ -208,3 +208,21 @@ coder_aibridgeproxyd_mitm_requests_total{provider=""} 0 # HELP coder_aibridgeproxyd_mitm_responses_total Total number of MITM responses by HTTP status code class. # TYPE coder_aibridgeproxyd_mitm_responses_total counter coder_aibridgeproxyd_mitm_responses_total{code="",provider=""} 0 +# HELP coder_aibridged_provider_info One series per configured AI provider. Value is always 1; the status label (enabled, disabled, error) carries the alertable signal. +# TYPE coder_aibridged_provider_info gauge +coder_aibridged_provider_info{provider_name="",provider_type="",status=""} 0 +# HELP coder_aibridged_providers_last_reload_timestamp_seconds Unix timestamp of the last provider reload attempt, success or failure. +# TYPE coder_aibridged_providers_last_reload_timestamp_seconds gauge +coder_aibridged_providers_last_reload_timestamp_seconds 0 +# HELP coder_aibridged_providers_last_reload_success_timestamp_seconds Unix timestamp of the last provider reload that successfully refreshed the pool. A gap against coder_aibridged_providers_last_reload_timestamp_seconds means the loop is firing but the refresh function is failing. +# TYPE coder_aibridged_providers_last_reload_success_timestamp_seconds gauge +coder_aibridged_providers_last_reload_success_timestamp_seconds 0 +# HELP coder_aibridgeproxyd_provider_info One series per configured AI provider. Value is always 1; the status label (enabled, disabled, error) carries the alertable signal. +# TYPE coder_aibridgeproxyd_provider_info gauge +coder_aibridgeproxyd_provider_info{provider_name="",provider_type="",status=""} 0 +# HELP coder_aibridgeproxyd_providers_last_reload_timestamp_seconds Unix timestamp of the last provider reload attempt, success or failure. +# TYPE coder_aibridgeproxyd_providers_last_reload_timestamp_seconds gauge +coder_aibridgeproxyd_providers_last_reload_timestamp_seconds 0 +# HELP coder_aibridgeproxyd_providers_last_reload_success_timestamp_seconds Unix timestamp of the last provider reload that successfully refreshed the router. A gap against coder_aibridgeproxyd_providers_last_reload_timestamp_seconds means the loop is firing but the refresh function is failing. +# TYPE coder_aibridgeproxyd_providers_last_reload_success_timestamp_seconds gauge +coder_aibridgeproxyd_providers_last_reload_success_timestamp_seconds 0 diff --git a/scripts/metricsdocgen/scanner/scanner.go b/scripts/metricsdocgen/scanner/scanner.go index eee4166e494..f7ab57f9d4a 100644 --- a/scripts/metricsdocgen/scanner/scanner.go +++ b/scripts/metricsdocgen/scanner/scanner.go @@ -40,6 +40,7 @@ var scanDirs = []string{ // // eliminate the need for this skip list. var skipPaths = []string{ + "coderd/aibridged/metrics.go", "enterprise/aibridgeproxyd/metrics.go", } From c350e98a6e79715667bba16998313c58c6a2fc1a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 29 May 2026 12:49:46 -0400 Subject: [PATCH 007/138] fix(site): update models settings page description text (#25830) (#25831) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cherry-pick of https://github.com/coder/coder/pull/25830 Original PR: #25830 — fix(site): update models settings page description text Merge commit: a801d996e7ee7a644eebf15d27265f4efa6fabc3 Requested by: @tracyjohnsonux Co-authored-by: TJ --- site/src/pages/AgentsPage/AgentSettingsModelsPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/pages/AgentsPage/AgentSettingsModelsPage.tsx b/site/src/pages/AgentsPage/AgentSettingsModelsPage.tsx index b7a8b36ba72..4b8c50fd96e 100644 --- a/site/src/pages/AgentsPage/AgentSettingsModelsPage.tsx +++ b/site/src/pages/AgentsPage/AgentSettingsModelsPage.tsx @@ -47,7 +47,7 @@ const AgentSettingsModelsPage: FC = () => { Date: Fri, 29 May 2026 12:53:24 -0400 Subject: [PATCH 008/138] fix(coderd/x/chatd): harden openai-compatible chat calls (#25737) (#25796) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cherry-pick of https://github.com/coder/coder/pull/25737 Original PR: #25737 — fix(coderd/x/chatd): harden openai-compatible chat calls Merge commit: f529577beec3d35ff6f8974bf20fe404269efcf3 Requested by: @ibetitsmike Co-authored-by: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> --- coderd/x/chatd/chatprovider/chatprovider.go | 1 + .../chatprovider/openai_compat_patches.go | 237 ++++++++++++++++++ .../openai_compat_patches_internal_test.go | 156 ++++++++++++ .../openai_compat_patches_test.go | 186 ++++++++++++++ coderd/x/chatd/quickgen_internal_test.go | 118 ++++++++- 5 files changed, 695 insertions(+), 3 deletions(-) create mode 100644 coderd/x/chatd/chatprovider/openai_compat_patches.go create mode 100644 coderd/x/chatd/chatprovider/openai_compat_patches_internal_test.go create mode 100644 coderd/x/chatd/chatprovider/openai_compat_patches_test.go diff --git a/coderd/x/chatd/chatprovider/chatprovider.go b/coderd/x/chatd/chatprovider/chatprovider.go index 768aa5774ec..fec0840b08e 100644 --- a/coderd/x/chatd/chatprovider/chatprovider.go +++ b/coderd/x/chatd/chatprovider/chatprovider.go @@ -1243,6 +1243,7 @@ func ModelFromConfig( } providerClient, err = fantasyopenai.New(options...) case fantasyopenaicompat.Name: + httpClient = withOpenAICompatRequestPatches(httpClient, baseURL, modelID) options := []fantasyopenaicompat.Option{ fantasyopenaicompat.WithAPIKey(apiKey), fantasyopenaicompat.WithUserAgent(userAgent), diff --git a/coderd/x/chatd/chatprovider/openai_compat_patches.go b/coderd/x/chatd/chatprovider/openai_compat_patches.go new file mode 100644 index 00000000000..beac165bb5f --- /dev/null +++ b/coderd/x/chatd/chatprovider/openai_compat_patches.go @@ -0,0 +1,237 @@ +package chatprovider + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/url" + "strings" +) + +// OpenAI-compatible providers share an API shape but differ in the exact JSON +// they accept. These patches adjust Fantasy's serialized request body at the +// transport boundary so higher-level generation code can stay provider agnostic. +// +// googleOpenAICompatDummyThoughtSignature is Google's documented last-resort +// bypass for callers that cannot preserve a real Gemini thought signature. +// See https://ai.google.dev/gemini-api/docs/thought-signatures. +const googleOpenAICompatDummyThoughtSignature = "skip_thought_signature_validator" + +func withOpenAICompatRequestPatches( + client *http.Client, + baseURL string, + modelID string, +) *http.Client { + if client == nil { + client = &http.Client{} + } else { + clone := *client + client = &clone + } + client.Transport = &openAICompatRequestPatchTransport{ + Base: client.Transport, + BaseURL: baseURL, + ModelID: modelID, + } + return client +} + +type openAICompatRequestPatchTransport struct { + Base http.RoundTripper + // BaseURL is the configured provider base URL, used to detect direct Gemini endpoints. + BaseURL string + // ModelID is the configured model ID, used to detect Gemini routes through Coder AI Bridge. + ModelID string +} + +func (t *openAICompatRequestPatchTransport) RoundTrip(req *http.Request) (*http.Response, error) { + base := t.base() + if !shouldPatchOpenAICompatRequest(req) { + return base.RoundTrip(req) + } + + body, err := io.ReadAll(req.Body) + closeErr := req.Body.Close() + if err != nil { + return nil, err + } + if closeErr != nil { + return nil, closeErr + } + + patched := patchOpenAICompatChatCompletionsBody(body, t.BaseURL, t.ModelID) + patchedReq := req.Clone(req.Context()) + patchedReq.Body = io.NopCloser(bytes.NewReader(patched)) + patchedReq.ContentLength = int64(len(patched)) + patchedReq.GetBody = func() (io.ReadCloser, error) { + return io.NopCloser(bytes.NewReader(patched)), nil + } + + return base.RoundTrip(patchedReq) +} + +func (t *openAICompatRequestPatchTransport) base() http.RoundTripper { + if t.Base != nil { + return t.Base + } + return http.DefaultTransport +} + +func shouldPatchOpenAICompatRequest(req *http.Request) bool { + return req != nil && + req.Method == http.MethodPost && + req.Body != nil && + strings.HasSuffix(req.URL.Path, "/chat/completions") +} + +func patchOpenAICompatChatCompletionsBody(body []byte, baseURL string, modelID string) []byte { + var payload map[string]any + if err := json.Unmarshal(body, &payload); err != nil { + return body + } + + changed := rewriteOpenAICompatSingleToolChoice(payload) + if shouldAddGoogleOpenAICompatThoughtSignatures(baseURL, modelID) { + changed = addGoogleOpenAICompatThoughtSignatures(payload) || changed + } + if !changed { + return body + } + + patched, err := json.Marshal(payload) + if err != nil { + return body + } + return patched +} + +// rewriteOpenAICompatSingleToolChoice replaces a single named tool choice with +// "required" because some compatible endpoints reject the named object form. +func rewriteOpenAICompatSingleToolChoice(payload map[string]any) bool { + tools, ok := payload["tools"].([]any) + if !ok || len(tools) != 1 { + return false + } + tool, ok := tools[0].(map[string]any) + if !ok { + return false + } + function, ok := tool["function"].(map[string]any) + if !ok { + return false + } + toolName, _ := function["name"].(string) + if toolName == "" { + return false + } + + toolChoice, ok := payload["tool_choice"].(map[string]any) + if !ok { + return false + } + if toolType, _ := toolChoice["type"].(string); toolType != "function" { + return false + } + choiceFunction, ok := toolChoice["function"].(map[string]any) + if !ok { + return false + } + choiceName, _ := choiceFunction["name"].(string) + if choiceName != toolName { + return false + } + + payload["tool_choice"] = "required" + return true +} + +// shouldAddGoogleOpenAICompatThoughtSignatures detects direct Gemini OpenAI +// endpoints and Coder AI Bridge Gemini routes. Other gateways, such as Vercel, +// keep their own provider-specific compatibility behavior. +func shouldAddGoogleOpenAICompatThoughtSignatures(baseURL string, modelID string) bool { + parsed, err := url.Parse(baseURL) + if err != nil { + return false + } + host := strings.ToLower(parsed.Hostname()) + path := strings.ToLower(parsed.EscapedPath()) + if host == "generativelanguage.googleapis.com" && strings.Contains(path, "/openai") { + return true + } + return host == "coder-aibridge" && isGeminiModelID(modelID) +} + +func isGeminiModelID(modelID string) bool { + modelID = strings.ToLower(strings.TrimSpace(modelID)) + return strings.HasPrefix(modelID, "gemini-") || strings.Contains(modelID, "/gemini-") +} + +// addGoogleOpenAICompatThoughtSignatures adds a dummy thought signature to the +// first tool call on each assistant tool-call message in the latest user turn. +// Gemini validates tool-call history with thought signatures, but +// OpenAI-compatible serialization can drop the original provider metadata. +func addGoogleOpenAICompatThoughtSignatures(payload map[string]any) bool { + messages, ok := payload["messages"].([]any) + if !ok { + return false + } + + currentTurnStart := -1 + for i, raw := range messages { + message, ok := raw.(map[string]any) + if !ok { + continue + } + if role, _ := message["role"].(string); role == "user" { + currentTurnStart = i + } + } + + if currentTurnStart == -1 { + return false + } + + changed := false + for _, raw := range messages[currentTurnStart+1:] { + message, ok := raw.(map[string]any) + if !ok || !isOpenAICompatAssistantRole(message["role"]) { + continue + } + toolCalls, ok := message["tool_calls"].([]any) + if !ok || len(toolCalls) == 0 { + continue + } + firstToolCall, ok := toolCalls[0].(map[string]any) + if !ok { + continue + } + if ensureGoogleOpenAICompatThoughtSignature(firstToolCall) { + changed = true + } + } + return changed +} + +func isOpenAICompatAssistantRole(role any) bool { + roleValue, _ := role.(string) + return roleValue == "assistant" || roleValue == "model" +} + +func ensureGoogleOpenAICompatThoughtSignature(toolCall map[string]any) bool { + extraContent, _ := toolCall["extra_content"].(map[string]any) + google, _ := extraContent["google"].(map[string]any) + if signature, _ := google["thought_signature"].(string); signature != "" { + return false + } + if extraContent == nil { + extraContent = map[string]any{} + toolCall["extra_content"] = extraContent + } + if google == nil { + google = map[string]any{} + extraContent["google"] = google + } + google["thought_signature"] = googleOpenAICompatDummyThoughtSignature + return true +} diff --git a/coderd/x/chatd/chatprovider/openai_compat_patches_internal_test.go b/coderd/x/chatd/chatprovider/openai_compat_patches_internal_test.go new file mode 100644 index 00000000000..eace6c4173d --- /dev/null +++ b/coderd/x/chatd/chatprovider/openai_compat_patches_internal_test.go @@ -0,0 +1,156 @@ +//nolint:testpackage // These tests cover unexported request-patch guards. +package chatprovider + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestPatchOpenAICompatChatCompletionsBody_Guards(t *testing.T) { + t.Parallel() + + t.Run("leaves multi tool specific choice unchanged", func(t *testing.T) { + t.Parallel() + + payload := map[string]any{ + "tools": []any{ + functionTool("first_tool"), + functionTool("second_tool"), + }, + "tool_choice": map[string]any{ + "type": "function", + "function": map[string]any{ + "name": "first_tool", + }, + }, + } + + patched := patchOpenAICompatChatCompletionsBody(mustJSON(t, payload), "http://example.com/v1", "test-model") + body := decodeJSONMap(t, patched) + toolChoice, ok := body["tool_choice"].(map[string]any) + require.True(t, ok) + function, ok := toolChoice["function"].(map[string]any) + require.True(t, ok) + require.Equal(t, "first_tool", function["name"]) + }) + + t.Run("leaves string tool choice unchanged", func(t *testing.T) { + t.Parallel() + + payload := map[string]any{ + "tools": []any{functionTool("first_tool")}, + "tool_choice": "auto", + } + + patched := patchOpenAICompatChatCompletionsBody(mustJSON(t, payload), "http://example.com/v1", "test-model") + body := decodeJSONMap(t, patched) + require.Equal(t, "auto", body["tool_choice"]) + }) + + t.Run("leaves Gemini assistant history without a user turn unchanged", func(t *testing.T) { + t.Parallel() + + payload := map[string]any{ + "messages": []any{ + map[string]any{ + "role": "assistant", + "tool_calls": []any{ + functionToolCall("call_without_user", "history_tool"), + }, + }, + }, + } + + patched := patchOpenAICompatChatCompletionsBody(mustJSON(t, payload), "https://generativelanguage.googleapis.com/v1beta/openai/", "gemini-3.5-flash") + body := decodeJSONMap(t, patched) + messages := body["messages"].([]any) + require.Empty(t, googleThoughtSignature(t, messages[0], 0)) + }) + + t.Run("preserves existing Gemini thought signature", func(t *testing.T) { + t.Parallel() + + payload := map[string]any{ + "messages": []any{ + map[string]any{"role": "user", "content": "current turn"}, + map[string]any{ + "role": "assistant", + "tool_calls": []any{ + map[string]any{ + "id": "call_with_signature", + "type": "function", + "function": map[string]any{ + "name": "signed_tool", + "arguments": `{}`, + }, + "extra_content": map[string]any{ + "google": map[string]any{ + "thought_signature": "real-signature", + }, + }, + }, + }, + }, + }, + } + + patched := patchOpenAICompatChatCompletionsBody(mustJSON(t, payload), "https://generativelanguage.googleapis.com/v1beta/openai/", "gemini-3.5-flash") + body := decodeJSONMap(t, patched) + messages := body["messages"].([]any) + require.Equal(t, "real-signature", googleThoughtSignature(t, messages[1], 0)) + }) +} + +func functionTool(name string) map[string]any { + return map[string]any{ + "type": "function", + "function": map[string]any{ + "name": name, + }, + } +} + +func functionToolCall(id string, name string) map[string]any { + return map[string]any{ + "id": id, + "type": "function", + "function": map[string]any{ + "name": name, + "arguments": `{}`, + }, + } +} + +func mustJSON(t *testing.T, payload map[string]any) []byte { + t.Helper() + + body, err := json.Marshal(payload) + require.NoError(t, err) + return body +} + +func decodeJSONMap(t *testing.T, body []byte) map[string]any { + t.Helper() + + var payload map[string]any + require.NoError(t, json.Unmarshal(body, &payload)) + return payload +} + +func googleThoughtSignature(t *testing.T, rawMessage any, toolCallIndex int) string { + t.Helper() + + message, ok := rawMessage.(map[string]any) + require.True(t, ok) + toolCalls, ok := message["tool_calls"].([]any) + require.True(t, ok) + require.Greater(t, len(toolCalls), toolCallIndex) + toolCall, ok := toolCalls[toolCallIndex].(map[string]any) + require.True(t, ok) + extraContent, _ := toolCall["extra_content"].(map[string]any) + google, _ := extraContent["google"].(map[string]any) + signature, _ := google["thought_signature"].(string) + return signature +} diff --git a/coderd/x/chatd/chatprovider/openai_compat_patches_test.go b/coderd/x/chatd/chatprovider/openai_compat_patches_test.go new file mode 100644 index 00000000000..c6042c0c635 --- /dev/null +++ b/coderd/x/chatd/chatprovider/openai_compat_patches_test.go @@ -0,0 +1,186 @@ +package chatprovider_test + +import ( + "encoding/json" + "io" + "net/http" + "strings" + "testing" + + "charm.land/fantasy" + fantasyopenaicompat "charm.land/fantasy/providers/openaicompat" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/x/chatd/chatprovider" +) + +const dummyThoughtSignature = "skip_thought_signature_validator" + +func TestModelFromConfig_GeminiOpenAICompatThoughtSignatures(t *testing.T) { + t.Parallel() + + t.Run("Gemini endpoint receives current turn thought signature", func(t *testing.T) { + t.Parallel() + + body := generateOpenAICompatRequest(t, "https://generativelanguage.googleapis.com/v1beta/openai/", "gemini-3.5-flash") + messages := body["messages"].([]any) + + require.Empty(t, thoughtSignature(t, messages[1], 0)) + require.Equal(t, dummyThoughtSignature, thoughtSignature(t, messages[4], 0)) + require.Empty(t, thoughtSignature(t, messages[4], 1)) + require.Equal(t, dummyThoughtSignature, thoughtSignature(t, messages[6], 0)) + }) + + t.Run("Coder AI Bridge Gemini route receives current turn thought signature", func(t *testing.T) { + t.Parallel() + + body := generateOpenAICompatRequest(t, "http://coder-aibridge/v1", "gemini-3.5-flash") + messages := body["messages"].([]any) + + require.Equal(t, dummyThoughtSignature, thoughtSignature(t, messages[4], 0)) + }) + + t.Run("Vercel OpenAI-compatible Gemini route is unchanged", func(t *testing.T) { + t.Parallel() + + body := generateOpenAICompatRequest(t, "https://gateway.vercel.ai/v1", "google/gemini-3.5-flash") + messages := body["messages"].([]any) + + require.Empty(t, thoughtSignature(t, messages[4], 0)) + }) +} + +func generateOpenAICompatRequest(t *testing.T, baseURL string, modelID string) map[string]any { + t.Helper() + + transport := &captureChatCompletionTransport{} + model, err := chatprovider.ModelFromConfig( + fantasyopenaicompat.Name, + modelID, + chatprovider.ProviderAPIKeys{ + ByProvider: map[string]string{ + fantasyopenaicompat.Name: "test-key", + }, + BaseURLByProvider: map[string]string{ + fantasyopenaicompat.Name: baseURL, + }, + }, + chatprovider.UserAgent(), + nil, + &http.Client{Transport: transport}, + ) + require.NoError(t, err) + + _, err = model.Generate(t.Context(), fantasy.Call{ + Prompt: geminiOpenAICompatToolPrompt(), + }) + require.NoError(t, err) + require.NotNil(t, transport.body) + return transport.body +} + +type captureChatCompletionTransport struct { + body map[string]any +} + +func (ct *captureChatCompletionTransport) RoundTrip(req *http.Request) (*http.Response, error) { + body, err := io.ReadAll(req.Body) + if err != nil { + return nil, err + } + _ = req.Body.Close() + if strings.HasSuffix(req.URL.Path, "/chat/completions") { + ct.body = map[string]any{} + if err := json.Unmarshal(body, &ct.body); err != nil { + return nil, err + } + } + + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + }, + Body: io.NopCloser(strings.NewReader(`{ + "id":"chatcmpl-test", + "object":"chat.completion", + "created":0, + "model":"gemini-3.5-flash", + "choices":[{"index":0,"message":{"role":"assistant","content":"done"},"finish_reason":"stop"}], + "usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2} + }`)), + }, nil +} + +func geminiOpenAICompatToolPrompt() []fantasy.Message { + return []fantasy.Message{ + { + Role: fantasy.MessageRoleUser, + Content: []fantasy.MessagePart{ + fantasy.TextPart{Text: "previous turn"}, + }, + }, + { + Role: fantasy.MessageRoleAssistant, + Content: []fantasy.MessagePart{ + fantasy.ToolCallPart{ToolCallID: "previous-call", ToolName: "previous_tool", Input: `{}`}, + }, + }, + { + Role: fantasy.MessageRoleTool, + Content: []fantasy.MessagePart{ + fantasy.ToolResultPart{ + ToolCallID: "previous-call", + Output: fantasy.ToolResultOutputContentText{Text: `{}`}, + }, + }, + }, + { + Role: fantasy.MessageRoleUser, + Content: []fantasy.MessagePart{ + fantasy.TextPart{Text: "current turn"}, + }, + }, + { + Role: fantasy.MessageRoleAssistant, + Content: []fantasy.MessagePart{ + fantasy.ToolCallPart{ToolCallID: "current-call-a", ToolName: "first_tool", Input: `{}`}, + fantasy.ToolCallPart{ToolCallID: "current-call-b", ToolName: "parallel_tool", Input: `{}`}, + }, + }, + { + Role: fantasy.MessageRoleTool, + Content: []fantasy.MessagePart{ + fantasy.ToolResultPart{ + ToolCallID: "current-call-a", + Output: fantasy.ToolResultOutputContentText{Text: `{}`}, + }, + }, + }, + { + Role: fantasy.MessageRoleAssistant, + Content: []fantasy.MessagePart{ + fantasy.ToolCallPart{ + ToolCallID: "current-call-c", + ToolName: "second_step_tool", + Input: `{}`, + }, + }, + }, + } +} + +func thoughtSignature(t *testing.T, rawMessage any, toolCallIndex int) string { + t.Helper() + message, ok := rawMessage.(map[string]any) + require.True(t, ok) + toolCalls, ok := message["tool_calls"].([]any) + require.True(t, ok) + require.Greater(t, len(toolCalls), toolCallIndex) + toolCall, ok := toolCalls[toolCallIndex].(map[string]any) + require.True(t, ok) + extraContent, _ := toolCall["extra_content"].(map[string]any) + google, _ := extraContent["google"].(map[string]any) + signature, _ := google["thought_signature"].(string) + return signature +} diff --git a/coderd/x/chatd/quickgen_internal_test.go b/coderd/x/chatd/quickgen_internal_test.go index 09fc8001abf..0e46ccc0f74 100644 --- a/coderd/x/chatd/quickgen_internal_test.go +++ b/coderd/x/chatd/quickgen_internal_test.go @@ -3,11 +3,14 @@ package chatd import ( "context" "encoding/json" + "net/http" + "net/http/httptest" "strings" "testing" "time" "charm.land/fantasy" + fantasyopenaicompat "charm.land/fantasy/providers/openaicompat" "github.com/sqlc-dev/pqtype" "github.com/stretchr/testify/require" @@ -667,6 +670,100 @@ func TestFallbackTurnStatusLabel(t *testing.T) { } } +func TestGenerateStructuredTitleWithUsage_OpenAICompatibleRequiredToolChoice(t *testing.T) { + t.Parallel() + + server, requests := newOpenAICompatStructuredOutputServer(t, "propose_title", `{"title":"Failed workspace logs"}`) + model := openAICompatTestModel(t, server.URL) + + title, _, err := generateStructuredTitleWithUsage( + t.Context(), + model, + titleGenerationPrompt, + "summarize failed workspace build logs", + ) + require.NoError(t, err) + require.Equal(t, "Failed workspace logs", title) + + body := testutil.TryReceive(t.Context(), t, requests) + require.Equal(t, "required", body["tool_choice"]) +} + +func newOpenAICompatStructuredOutputServer( + t *testing.T, + toolName string, + arguments string, +) (*httptest.Server, <-chan map[string]any) { + t.Helper() + + requests := make(chan map[string]any, 10) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + requests <- body + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "chatcmpl-structured-output", + "object": "chat.completion", + "created": time.Now().Unix(), + "model": "anthropic/claude-4-5-sonnet", + "choices": []map[string]any{ + { + "index": 0, + "message": map[string]any{ + "role": "assistant", + "content": "", + "tool_calls": []map[string]any{ + { + "id": "call_structured_output", + "type": "function", + "function": map[string]any{ + "name": toolName, + "arguments": arguments, + }, + }, + }, + }, + "finish_reason": "tool_calls", + }, + }, + "usage": map[string]any{ + "prompt_tokens": 10, + "completion_tokens": 5, + "total_tokens": 15, + }, + }) + })) + t.Cleanup(server.Close) + return server, requests +} + +func openAICompatTestModel(t *testing.T, baseURL string) fantasy.LanguageModel { + t.Helper() + + model, err := chatprovider.ModelFromConfig( + fantasyopenaicompat.Name, + "anthropic/claude-4-5-sonnet", + chatprovider.ProviderAPIKeys{ + ByProvider: map[string]string{ + fantasyopenaicompat.Name: "test-key", + }, + BaseURLByProvider: map[string]string{ + fantasyopenaicompat.Name: baseURL, + }, + }, + chatprovider.UserAgent(), + nil, + nil, + ) + require.NoError(t, err) + return model +} + func TestGenerateStructuredTurnStatusLabel(t *testing.T) { t.Parallel() @@ -682,11 +779,26 @@ func TestGenerateStructuredTurnStatusLabel(t *testing.T) { }, } - label, err := generateStructuredTurnStatusLabel(context.Background(), model, turnStatusLabelPrompt, "done") + label, err := generateStructuredTurnStatusLabel(t.Context(), model, turnStatusLabelPrompt, "done") require.NoError(t, err) require.Equal(t, "Submitted PR", label) }) + t.Run("sends required tool_choice to openai-compatible provider", func(t *testing.T) { + t.Parallel() + + server, requests := newOpenAICompatStructuredOutputServer(t, "propose_turn_status_label", `{"label":"Submitted PR"}`) + model := openAICompatTestModel(t, server.URL) + + label, err := generateStructuredTurnStatusLabel(t.Context(), model, turnStatusLabelPrompt, "done") + require.NoError(t, err) + require.Equal(t, "Submitted PR", label) + require.Len(t, requests, 1) + + body := testutil.TryReceive(t.Context(), t, requests) + require.Equal(t, "required", body["tool_choice"]) + }) + t.Run("rejects narrative label", func(t *testing.T) { t.Parallel() @@ -698,7 +810,7 @@ func TestGenerateStructuredTurnStatusLabel(t *testing.T) { }, } - _, err := generateStructuredTurnStatusLabel(context.Background(), model, turnStatusLabelPrompt, "done") + _, err := generateStructuredTurnStatusLabel(t.Context(), model, turnStatusLabelPrompt, "done") require.ErrorContains(t, err, "generated turn status label was invalid") }) @@ -706,7 +818,7 @@ func TestGenerateStructuredTurnStatusLabel(t *testing.T) { t.Parallel() model := &chattest.FakeModel{} - _, err := generateStructuredTurnStatusLabel(context.Background(), model, turnStatusLabelPrompt, " ") + _, err := generateStructuredTurnStatusLabel(t.Context(), model, turnStatusLabelPrompt, " ") require.ErrorContains(t, err, "turn status label input was empty") }) } From cccf436db2339d20d6d6985a8b091da412c8de59 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 29 May 2026 12:58:13 -0400 Subject: [PATCH 009/138] feat: serve 503 sentinel for disabled providers (#25794) (#25837) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cherry-pick of https://github.com/coder/coder/pull/25794 Original PR: #25794 — feat: serve 503 sentinel for disabled providers Merge commit: 5b10268827e8c3a8ac8e0ad563d042e66c7003c7 Requested by: @dannykopping Signed-off-by: Danny Kopping Co-authored-by: Danny Kopping Co-authored-by: Claude Opus 4.7 (1M context) --- aibridge/api.go | 8 +++ aibridge/bridge.go | 27 ++++++++ aibridge/bridge_test.go | 55 +++++++++++++++++ aibridge/internal/testutil/mockprovider.go | 2 + aibridge/provider/anthropic.go | 2 + aibridge/provider/copilot.go | 2 + aibridge/provider/disabled.go | 47 ++++++++++++++ aibridge/provider/openai.go | 2 + aibridge/provider/provider.go | 2 + cli/aibridged.go | 49 +++++++++++---- cli/aibridged_internal_test.go | 71 ++++++++++++++++------ cli/server_aibridge_internal_test.go | 10 +++ coderd/aibridged/pool.go | 7 ++- coderd/aibridged/provider.go | 6 +- enterprise/coderd/aibridge_reload_test.go | 18 +++++- 15 files changed, 270 insertions(+), 38 deletions(-) create mode 100644 aibridge/provider/disabled.go diff --git a/aibridge/api.go b/aibridge/api.go index 809d452fe90..34dce84ef88 100644 --- a/aibridge/api.go +++ b/aibridge/api.go @@ -57,6 +57,14 @@ func NewCopilotProvider(cfg config.Copilot) provider.Provider { return provider.NewCopilot(cfg) } +// NewDisabledProviderStub returns a Provider that reports Enabled() == +// false and has no-op implementations for all other methods. Use this +// instead of constructing a concrete provider for disabled rows so that +// adding a new provider type does not require updating a switch here. +func NewDisabledProviderStub(name, providerType string) provider.Provider { + return provider.NewDisabledStub(name, providerType) +} + func NewMetrics(reg prometheus.Registerer) *metrics.Metrics { return metrics.NewMetrics(reg) } diff --git a/aibridge/bridge.go b/aibridge/bridge.go index f604d0a38ab..79715af880d 100644 --- a/aibridge/bridge.go +++ b/aibridge/bridge.go @@ -30,6 +30,11 @@ import ( const ( // The duration after which an async recording will be aborted. recordingTimeout = time.Second * 5 + + // ErrorCodeProviderDisabled is the code written in the response + // body when a request targets a configured-but-disabled provider. + // Paired with HTTP 503. + ErrorCodeProviderDisabled = "provider_disabled" ) // RequestBridge is an [http.Handler] which is capable of masquerading as AI providers' APIs; @@ -96,6 +101,14 @@ func NewRequestBridge(ctx context.Context, providers []provider.Provider, rec re mux := http.NewServeMux() for _, prov := range providers { + // Disabled providers serve a 503 sentinel on every path under + // "//". Bound to the bare name (not RoutePrefix) so paths + // outside the provider's normal "/v1" subtree are also caught. + if !prov.Enabled() { + prefix := fmt.Sprintf("/%s/", prov.Name()) + mux.HandleFunc(prefix, disabledProviderHandler(prov.Name(), logger)) + continue + } // Create per-provider circuit breaker if configured cfg := prov.CircuitBreakerConfig() providerName := prov.Name() @@ -170,6 +183,20 @@ func NewRequestBridge(ctx context.Context, providers []provider.Provider, rec re }, nil } +// disabledProviderHandler returns 503 with a body containing +// [ErrorCodeProviderDisabled] and the provider name for every request +// targeting name. +func disabledProviderHandler(name string, logger slog.Logger) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + logger.Debug(r.Context(), "refusing request for disabled ai provider", + slog.F("provider", name), + slog.F("path", r.URL.Path), + slog.F("method", r.Method), + ) + http.Error(w, fmt.Sprintf("%s: AI provider %q is disabled", ErrorCodeProviderDisabled, name), http.StatusServiceUnavailable) + } +} + // newInterceptionProcessor returns an [http.HandlerFunc] which is capable of creating a new interceptor and processing a given request // using [Provider] p, recording all usage events using [Recorder] rec. // If cbs is non-nil, circuit breaker protection is applied per endpoint/model tuple. diff --git a/aibridge/bridge_test.go b/aibridge/bridge_test.go index f2657ab80f5..93beb82de9a 100644 --- a/aibridge/bridge_test.go +++ b/aibridge/bridge_test.go @@ -205,3 +205,58 @@ func TestPassthroughRoutesForProviders(t *testing.T) { }) } } + +// TestDisabledProviderHandler asserts that requests to a disabled +// provider return a 503 with an ErrorCodeProviderDisabled body and +// that a sibling enabled provider keeps routing normally. +func TestDisabledProviderHandler(t *testing.T) { + t.Parallel() + + logger := slogtest.Make(t, nil) + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("upstream-reached")) + })) + t.Cleanup(upstream.Close) + + enabled := aibridge.NewOpenAIProvider(config.OpenAI{Name: "enabled-openai", BaseURL: upstream.URL}) + disabled := aibridge.NewDisabledProviderStub("disabled-openai", "openai") + bridge, err := aibridge.NewRequestBridge( + t.Context(), + []provider.Provider{enabled, disabled}, + nil, nil, logger, nil, bridgeTestTracer, + ) + require.NoError(t, err) + + for _, tc := range []struct { + name string + path string + }{ + {name: "Bridged", path: "/disabled-openai/v1/chat/completions"}, + {name: "Passthrough", path: "/disabled-openai/v1/models"}, + {name: "Unknown", path: "/disabled-openai/anything/else"}, + } { + t.Run("DisabledProviderReturnsSentinel/"+tc.name, func(t *testing.T) { + t.Parallel() + + req := httptest.NewRequest(http.MethodPost, tc.path, nil) + resp := httptest.NewRecorder() + bridge.ServeHTTP(resp, req) + + assert.Equal(t, http.StatusServiceUnavailable, resp.Code) + assert.Contains(t, resp.Body.String(), aibridge.ErrorCodeProviderDisabled) + assert.Contains(t, resp.Body.String(), "disabled-openai") + }) + } + + t.Run("EnabledProviderUnaffected", func(t *testing.T) { + t.Parallel() + + req := httptest.NewRequest(http.MethodGet, "/enabled-openai/v1/models", nil) + resp := httptest.NewRecorder() + bridge.ServeHTTP(resp, req) + + assert.Equal(t, http.StatusOK, resp.Code) + assert.Equal(t, "upstream-reached", resp.Body.String()) + }) +} diff --git a/aibridge/internal/testutil/mockprovider.go b/aibridge/internal/testutil/mockprovider.go index 0fd85d28636..e5015cd870e 100644 --- a/aibridge/internal/testutil/mockprovider.go +++ b/aibridge/internal/testutil/mockprovider.go @@ -15,6 +15,7 @@ import ( type MockProvider struct { NameStr string URL string + Disabled bool Bridged []string Passthrough []string InterceptorFunc func(w http.ResponseWriter, r *http.Request, tracer trace.Tracer) (intercept.Interceptor, error) @@ -22,6 +23,7 @@ type MockProvider struct { func (m *MockProvider) Type() string { return m.NameStr } func (m *MockProvider) Name() string { return m.NameStr } +func (m *MockProvider) Enabled() bool { return !m.Disabled } func (m *MockProvider) BaseURL() string { return m.URL } func (m *MockProvider) RoutePrefix() string { return fmt.Sprintf("/%s", m.NameStr) } func (m *MockProvider) BridgedRoutes() []string { return m.Bridged } diff --git a/aibridge/provider/anthropic.go b/aibridge/provider/anthropic.go index eb50a3b2965..664183f82fd 100644 --- a/aibridge/provider/anthropic.go +++ b/aibridge/provider/anthropic.go @@ -95,6 +95,8 @@ func (p *Anthropic) Name() string { return p.cfg.Name } +func (*Anthropic) Enabled() bool { return true } + func (p *Anthropic) RoutePrefix() string { return fmt.Sprintf("/%s", p.Name()) } diff --git a/aibridge/provider/copilot.go b/aibridge/provider/copilot.go index 1186e8b253f..fd317aadaba 100644 --- a/aibridge/provider/copilot.go +++ b/aibridge/provider/copilot.go @@ -78,6 +78,8 @@ func (p *Copilot) Name() string { return p.cfg.Name } +func (*Copilot) Enabled() bool { return true } + func (p *Copilot) BaseURL() string { return p.cfg.BaseURL } diff --git a/aibridge/provider/disabled.go b/aibridge/provider/disabled.go new file mode 100644 index 00000000000..95384b4952e --- /dev/null +++ b/aibridge/provider/disabled.go @@ -0,0 +1,47 @@ +package provider + +import ( + "fmt" + "net/http" + + "go.opentelemetry.io/otel/trace" + + "cdr.dev/slog/v3" + "github.com/coder/coder/v2/aibridge/config" + "github.com/coder/coder/v2/aibridge/intercept" + "github.com/coder/coder/v2/aibridge/keypool" +) + +// DisabledStub is a Provider placeholder for a configured-but-disabled +// provider. Only Name and Enabled return meaningful values; all other +// methods return empty/nil so the stub never influences routing. +type DisabledStub struct { + name string + providerType string +} + +// NewDisabledStub returns a Provider stub that reports Enabled() == false. +// The type string is preserved so callers can distinguish provider families. +func NewDisabledStub(name, providerType string) *DisabledStub { + return &DisabledStub{name: name, providerType: providerType} +} + +func (d *DisabledStub) Type() string { return d.providerType } +func (d *DisabledStub) Name() string { return d.name } +func (*DisabledStub) Enabled() bool { return false } +func (*DisabledStub) BaseURL() string { return "" } +func (d *DisabledStub) RoutePrefix() string { + return fmt.Sprintf("/%s", d.name) +} +func (*DisabledStub) BridgedRoutes() []string { return nil } +func (*DisabledStub) PassthroughRoutes() []string { return nil } +func (*DisabledStub) AuthHeader() string { return "" } +func (*DisabledStub) KeyFailoverConfig(_ slog.Logger) keypool.KeyFailoverConfig { + return keypool.KeyFailoverConfig{} +} +func (*DisabledStub) CircuitBreakerConfig() *config.CircuitBreaker { return nil } +func (*DisabledStub) APIDumpDir() string { return "" } +func (*DisabledStub) CreateInterceptor(_ http.ResponseWriter, _ *http.Request, _ trace.Tracer) (intercept.Interceptor, error) { + //nolint:nilnil // disabled providers never reach the interceptor. + return nil, nil +} diff --git a/aibridge/provider/openai.go b/aibridge/provider/openai.go index 177ae03409e..7a78069df63 100644 --- a/aibridge/provider/openai.go +++ b/aibridge/provider/openai.go @@ -84,6 +84,8 @@ func (p *OpenAI) Name() string { return p.cfg.Name } +func (*OpenAI) Enabled() bool { return true } + func (p *OpenAI) RoutePrefix() string { // Route prefix includes version to match default OpenAI base URL. // More detailed explanation: https://github.com/coder/aibridge/pull/174#discussion_r2782320152 diff --git a/aibridge/provider/provider.go b/aibridge/provider/provider.go index 7520333b53b..6f21d7290de 100644 --- a/aibridge/provider/provider.go +++ b/aibridge/provider/provider.go @@ -53,6 +53,8 @@ type Provider interface { // Name returns the provider instance name. // Defaults to Type() when not explicitly configured. Name() string + // Enabled reports whether the provider should serve requests. + Enabled() bool // BaseURL defines the base URL endpoint for this provider's API. BaseURL() string diff --git a/cli/aibridged.go b/cli/aibridged.go index caf67082fc9..a890488a104 100644 --- a/cli/aibridged.go +++ b/cli/aibridged.go @@ -4,6 +4,7 @@ package cli import ( "context" + "slices" "github.com/google/uuid" "github.com/prometheus/client_golang/prometheus" @@ -101,10 +102,18 @@ func (r *poolDBReloader) Reload(ctx context.Context) error { return nil } -// BuildProviders loads every ai_providers row (including disabled) -// and returns the active provider list plus per-row outcomes. Per-row -// build errors are logged and excluded from providers but recorded in -// outcomes; only DB query failures propagate. +// BuildProviders loads all ai_providers rows (enabled and disabled), +// attaches keys to enabled rows, and constructs the equivalent +// [aibridge.Provider] instances. The database is the single source of +// truth for runtime provider configuration. +// +// Disabled rows produce a Provider stub with Enabled() == false so the +// bridge can answer requests targeting them with a 503 sentinel. +// +// 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, []aibridged.ProviderOutcome, error) { //nolint:gocritic // AsAIBridged has a minimal permission set for this purpose. authCtx := dbauthz.AsAIBridged(ctx) @@ -160,12 +169,9 @@ func BuildProviders(ctx context.Context, db database.Store, cfg codersdk.AIBridg Name: row.Name, Type: string(row.Type), } - if !row.Enabled { - outcome.Status = aibridged.ProviderStatusDisabled - outcomes = append(outcomes, outcome) - continue + if row.Enabled { + enabledCount++ } - enabledCount++ prov, err := buildAIProviderFromRow(row, keysByProvider[row.ID], cfg) if err != nil { outcome.Status = aibridged.ProviderStatusError @@ -179,13 +185,17 @@ func BuildProviders(ctx context.Context, db database.Store, cfg codersdk.AIBridg ) continue } - outcome.Status = aibridged.ProviderStatusEnabled + if row.Enabled { + outcome.Status = aibridged.ProviderStatusEnabled + } else { + outcome.Status = aibridged.ProviderStatusDisabled + } outcomes = append(outcomes, outcome) providers = append(providers, prov) } - if enabledCount > 0 && len(providers) == 0 { - logger.Warn(ctx, "all enabled ai providers failed to build; daemon will start with zero providers") + if enabledCount > 0 && !slices.ContainsFunc(providers, func(p aibridge.Provider) bool { return p.Enabled() }) { + logger.Warn(ctx, "all enabled ai providers failed to build; only disabled providers remain") } return providers, outcomes, nil @@ -193,11 +203,18 @@ func BuildProviders(ctx context.Context, db database.Store, cfg codersdk.AIBridg // buildAIProviderFromRow decodes the settings blob and constructs the // appropriate [aibridge.Provider] for a single ai_providers row. +// Disabled rows return a Provider stub carrying only Name and +// Disabled: true; settings decode, key loading, and credential checks +// are skipped because the provider will never call upstream. func buildAIProviderFromRow( row database.AIProvider, keys []database.AIProviderKey, cfg codersdk.AIBridgeConfig, ) (aibridge.Provider, error) { + if !row.Enabled { + return disabledProviderFromRow(row) + } + settings, err := db2sdk.AIProviderSettings(row.Settings) if err != nil { return nil, xerrors.Errorf("decode settings: %w", err) @@ -287,6 +304,14 @@ func buildAIProviderFromRow( } } +// disabledProviderFromRow builds a Provider stub for a disabled row. +// Using provider.DisabledStub rather than a concrete provider avoids +// duplicating the row.Type switch and ensures that a new AiProviderType +// value is automatically handled without requiring a matching case here. +func disabledProviderFromRow(row database.AIProvider) (aibridge.Provider, error) { + return aibridge.NewDisabledProviderStub(row.Name, string(row.Type)), nil +} + // 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) { diff --git a/cli/aibridged_internal_test.go b/cli/aibridged_internal_test.go index 02269745207..6b3e1eb7ac7 100644 --- a/cli/aibridged_internal_test.go +++ b/cli/aibridged_internal_test.go @@ -393,25 +393,60 @@ func TestBuildProvidersSkipsBadRows(t *testing.T) { t.Run("DisabledRowClassifiedAsDisabled", func(t *testing.T) { t.Parallel() - db, _ := dbtestutil.NewDB(t) - ctx := testutil.Context(t, testutil.WaitShort) - logger := slogtest.Make(t, nil) - dbgen.AIProvider(t, db, database.AIProvider{ - Type: database.AiProviderTypeOpenai, - Name: "openai-off", - BaseUrl: "https://api.openai.com/", - }, func(p *database.InsertAIProviderParams) { - p.Enabled = false - }) - - providers, outcomes, err := BuildProviders(ctx, db, codersdk.AIBridgeConfig{}, logger) - require.NoError(t, err) - assert.Empty(t, providers, "disabled providers must not be in the active snapshot") - require.Len(t, outcomes, 1) - assert.Equal(t, "openai-off", outcomes[0].Name) - assert.Equal(t, aibridged.ProviderStatusDisabled, outcomes[0].Status) - assert.NoError(t, outcomes[0].Err) + for _, tc := range []struct { + name string + row database.AIProvider + }{ + { + name: "OpenAI", + row: database.AIProvider{ + Type: database.AiProviderTypeOpenai, + Name: "openai-off", + BaseUrl: "https://api.openai.com/", + }, + }, + { + // Anthropic and Bedrock have stricter credential checks + // than the OpenAI family; the disabled short-circuit + // must reach them too. No keys, no bedrock settings. + name: "Anthropic", + row: database.AIProvider{ + Type: database.AiProviderTypeAnthropic, + Name: "anthropic-off", + BaseUrl: "https://api.anthropic.com/", + }, + }, + { + name: "Bedrock", + row: database.AIProvider{ + Type: database.AiProviderTypeBedrock, + Name: "bedrock-off", + BaseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com/", + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitShort) + logger := slogtest.Make(t, nil) + + dbgen.AIProvider(t, db, tc.row, func(p *database.InsertAIProviderParams) { + p.Enabled = false + }) + + providers, outcomes, err := BuildProviders(ctx, db, codersdk.AIBridgeConfig{}, logger) + require.NoError(t, err) + require.Len(t, providers, 1, "disabled providers stay in the snapshot so the bridge can serve a 503 sentinel") + assert.Equal(t, tc.row.Name, providers[0].Name()) + assert.False(t, providers[0].Enabled()) + require.Len(t, outcomes, 1) + assert.Equal(t, tc.row.Name, outcomes[0].Name) + assert.Equal(t, aibridged.ProviderStatusDisabled, outcomes[0].Status) + assert.NoError(t, outcomes[0].Err) + }) + } }) } diff --git a/cli/server_aibridge_internal_test.go b/cli/server_aibridge_internal_test.go index 6b712a63529..a91e5b51d22 100644 --- a/cli/server_aibridge_internal_test.go +++ b/cli/server_aibridge_internal_test.go @@ -588,6 +588,7 @@ func TestBuildAIProviderFromRowSetsAPIDumpDir(t *testing.T) { { name: "OpenAI", row: database.AIProvider{ + Enabled: true, Type: database.AiProviderTypeOpenai, Name: "openai", BaseUrl: "https://api.openai.com/", @@ -597,6 +598,7 @@ func TestBuildAIProviderFromRowSetsAPIDumpDir(t *testing.T) { { name: "Anthropic", row: database.AIProvider{ + Enabled: true, Type: database.AiProviderTypeAnthropic, Name: "anthropic", BaseUrl: "https://api.anthropic.com/", @@ -606,6 +608,7 @@ func TestBuildAIProviderFromRowSetsAPIDumpDir(t *testing.T) { { name: "Copilot", row: database.AIProvider{ + Enabled: true, Type: database.AiProviderTypeCopilot, Name: "copilot", BaseUrl: "https://api.githubcopilot.com/", @@ -615,6 +618,7 @@ func TestBuildAIProviderFromRowSetsAPIDumpDir(t *testing.T) { { name: "Azure", row: database.AIProvider{ + Enabled: true, Type: database.AiProviderTypeAzure, Name: "azure", BaseUrl: "https://example.openai.azure.com/", @@ -624,6 +628,7 @@ func TestBuildAIProviderFromRowSetsAPIDumpDir(t *testing.T) { { name: "Google", row: database.AIProvider{ + Enabled: true, Type: database.AiProviderTypeGoogle, Name: "google", BaseUrl: "https://generativelanguage.googleapis.com/v1beta/openai/", @@ -633,6 +638,7 @@ func TestBuildAIProviderFromRowSetsAPIDumpDir(t *testing.T) { { name: "OpenAICompat", row: database.AIProvider{ + Enabled: true, Type: database.AiProviderTypeOpenaiCompat, Name: "openai-compat", BaseUrl: "https://compat.example.com/v1/", @@ -642,6 +648,7 @@ func TestBuildAIProviderFromRowSetsAPIDumpDir(t *testing.T) { { name: "OpenRouter", row: database.AIProvider{ + Enabled: true, Type: database.AiProviderTypeOpenrouter, Name: "openrouter", BaseUrl: "https://openrouter.ai/api/v1/", @@ -651,6 +658,7 @@ func TestBuildAIProviderFromRowSetsAPIDumpDir(t *testing.T) { { name: "Vercel", row: database.AIProvider{ + Enabled: true, Type: database.AiProviderTypeVercel, Name: "vercel", BaseUrl: "https://api.v0.dev/v1/", @@ -660,6 +668,7 @@ func TestBuildAIProviderFromRowSetsAPIDumpDir(t *testing.T) { { name: "Bedrock", row: database.AIProvider{ + Enabled: true, Type: database.AiProviderTypeBedrock, Name: "bedrock", BaseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com/", @@ -694,6 +703,7 @@ func TestBuildAIProviderFromRowBedrockWithoutSettings(t *testing.T) { t.Parallel() _, err := buildAIProviderFromRow(database.AIProvider{ + Enabled: true, Type: database.AiProviderTypeBedrock, Name: "bedrock-no-settings", BaseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com/", diff --git a/coderd/aibridged/pool.go b/coderd/aibridged/pool.go index 3b7e60955c0..b86cefe00ab 100644 --- a/coderd/aibridged/pool.go +++ b/coderd/aibridged/pool.go @@ -30,7 +30,9 @@ const ( type Pooler interface { Acquire(ctx context.Context, req Request, clientFn ClientFunc, mcpBootstrapper MCPProxyBuilder) (http.Handler, error) // ReplaceProviders swaps the providers used to construct future - // RequestBridge instances and clears the cache. + // RequestBridge instances and clears the cache. Disabled providers + // must be included; the bridge serves a 503 sentinel on their + // routes. ReplaceProviders(providers []aibridge.Provider) Shutdown(ctx context.Context) error } @@ -53,7 +55,8 @@ var _ Pooler = &CachedBridgePool{} type CachedBridgePool struct { cache *ristretto.Cache[string, *aibridge.RequestBridge] - // providers is the live provider set used by new RequestBridge instances. + // providers is the live provider set used by new RequestBridge + // instances. Includes disabled providers. providers atomic.Pointer[[]aibridge.Provider] providerVersion atomic.Int64 logger slog.Logger diff --git a/coderd/aibridged/provider.go b/coderd/aibridged/provider.go index 6fb53e1a93e..9d2faa030b5 100644 --- a/coderd/aibridged/provider.go +++ b/coderd/aibridged/provider.go @@ -17,9 +17,9 @@ const ( ) // ProviderOutcome classifies one ai_providers row, including disabled -// and errored rows the pool excludes. Err is populated only when -// Status == ProviderStatusError; the build error is already logged at -// the call site. +// rows (which the pool keeps as 503 stubs) and errored rows (which the +// pool excludes). Err is populated only when Status == ProviderStatusError; +// the build error is already logged at the call site. type ProviderOutcome struct { Name string Type string diff --git a/enterprise/coderd/aibridge_reload_test.go b/enterprise/coderd/aibridge_reload_test.go index 65f678df6eb..e3370c8f7d2 100644 --- a/enterprise/coderd/aibridge_reload_test.go +++ b/enterprise/coderd/aibridge_reload_test.go @@ -211,6 +211,18 @@ func TestAIBridgeProviderHotReload(t *testing.T) { "expected provider %q to stop routing", providerName) } + // requireDisabledSentinel polls until the provider name yields a + // 503 with the provider_disabled body, indicating the disabled + // handler is wired up for the row. + requireDisabledSentinel := func(t *testing.T, providerName string) { + t.Helper() + require.Eventuallyf(t, func() bool { + status, _ := sendRequest(providerName) + return status == http.StatusServiceUnavailable + }, testutil.WaitShort, testutil.IntervalFast, + "expected provider %q to serve the disabled sentinel", providerName) + } + // 1. Create: provider points at upstream A. created, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ Type: codersdk.AIProviderTypeOpenAI, @@ -233,14 +245,14 @@ func TestAIBridgeProviderHotReload(t *testing.T) { requireRoutesTo(t, "primary", upstreamB) requireProviderStatus(t, "primary", "enabled") - // 3. Disable: the provider drops out of the snapshot, requests - // stop reaching any upstream. The metric flips to "disabled". + // 3. Disable: requests stop reaching upstream and the bridge + // answers with the 503 sentinel. The metric flips to "disabled". disabled := false _, err = client.UpdateAIProvider(ctx, "primary", codersdk.UpdateAIProviderRequest{ Enabled: &disabled, }) require.NoError(t, err) - requireRoutingGone(t, "primary") + requireDisabledSentinel(t, "primary") requireProviderStatus(t, "primary", "disabled") // 4. Re-enable: routing comes back at the most recent BaseURL. From 663f1ee8341bbc888afe0678060d3cf7f033bea5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 29 May 2026 12:59:39 -0400 Subject: [PATCH 010/138] fix: track credential hint across key failover attempts in aibridge (#25735) (#25847) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cherry-pick of https://github.com/coder/coder/pull/25735 Original PR: #25735 — fix: track credential hint across key failover attempts in aibridge Merge commit: 7b903cad739f434cb240752972b4b5b2a4b1a9aa Requested by: @ssncferreira Co-authored-by: Susana Ferreira --- aibridge/bridge.go | 34 +- .../intercept/chatcompletions/blocking.go | 7 +- .../chatcompletions/blocking_internal_test.go | 83 ++-- .../intercept/chatcompletions/streaming.go | 5 + .../streaming_internal_test.go | 83 ++-- aibridge/intercept/messages/blocking.go | 7 +- .../messages/blocking_internal_test.go | 84 ++-- aibridge/intercept/messages/streaming.go | 5 + .../messages/streaming_internal_test.go | 84 ++-- aibridge/intercept/responses/blocking.go | 7 +- .../responses/blocking_internal_test.go | 81 +-- aibridge/intercept/responses/streaming.go | 5 + .../responses/streaming_internal_test.go | 81 +-- aibridge/keypool/keymark.go | 5 +- aibridge/keypool/keypool.go | 7 + aibridge/provider/anthropic.go | 11 +- aibridge/provider/anthropic_internal_test.go | 4 +- aibridge/provider/openai.go | 10 +- aibridge/provider/openai_internal_test.go | 8 +- aibridge/recorder/types.go | 11 +- coderd/aibridged/proto/aibridged.pb.go | 467 +++++++++--------- coderd/aibridged/proto/aibridged.proto | 1 + coderd/aibridged/translator.go | 5 +- coderd/aibridgedserver/aibridgedserver.go | 5 +- .../aibridgedserver/aibridgedserver_test.go | 23 +- coderd/database/dbgen/dbgen.go | 5 +- coderd/database/querier_test.go | 62 ++- coderd/database/queries.sql.go | 18 +- coderd/database/queries/aibridge.sql | 9 +- 29 files changed, 736 insertions(+), 481 deletions(-) diff --git a/aibridge/bridge.go b/aibridge/bridge.go index 79715af880d..daf103fb101 100644 --- a/aibridge/bridge.go +++ b/aibridge/bridge.go @@ -20,6 +20,7 @@ import ( "cdr.dev/slog/v3" "github.com/coder/coder/v2/aibridge/circuitbreaker" aibcontext "github.com/coder/coder/v2/aibridge/context" + "github.com/coder/coder/v2/aibridge/intercept" "github.com/coder/coder/v2/aibridge/mcp" "github.com/coder/coder/v2/aibridge/metrics" "github.com/coder/coder/v2/aibridge/provider" @@ -275,11 +276,18 @@ func newInterceptionProcessor(p provider.Provider, cbs *circuitbreaker.ProviderC slog.F("user_agent", r.UserAgent()), slog.F("streaming", interceptor.Streaming()), slog.F("credential_kind", string(cred.Kind)), - slog.F("credential_hint", cred.Hint), - slog.F("credential_length", cred.Length), ) - log.Debug(ctx, "interception started") + // Log BYOK credentials. Centralized credentials are set by + // the key failover loop. + credLogFields := []slog.Field{} + if cred.Kind == intercept.CredentialKindBYOK { + credLogFields = append(credLogFields, + slog.F("credential_hint", cred.Hint), + slog.F("credential_length", cred.Length), + ) + } + log.Debug(ctx, "interception started", credLogFields...) if m != nil { m.InterceptionsInflight.WithLabelValues(p.Name(), interceptor.Model(), route).Add(1) defer func() { @@ -288,22 +296,30 @@ func newInterceptionProcessor(p provider.Provider, cbs *circuitbreaker.ProviderC } // Process request with circuit breaker protection if configured - if err := cbs.Execute(route, interceptor.Model(), w, func(rw http.ResponseWriter) error { + execErr := cbs.Execute(route, interceptor.Model(), w, func(rw http.ResponseWriter) error { return interceptor.ProcessRequest(rw, r) - }); err != nil { + }) + // For centralized, the hint now reflects the last attempted + // key from the failover loop. + credHint := interceptor.Credential().Hint + credLen := interceptor.Credential().Length + if execErr != nil { if m != nil { m.InterceptionCount.WithLabelValues(p.Name(), interceptor.Model(), metrics.InterceptionCountStatusFailed, route, r.Method, actor.ID, string(client)).Add(1) } - span.SetStatus(codes.Error, fmt.Sprintf("interception failed: %v", err)) - log.Warn(ctx, "interception failed", slog.Error(err)) + span.SetStatus(codes.Error, fmt.Sprintf("interception failed: %v", execErr)) + log.Warn(ctx, "interception failed", slog.Error(execErr), slog.F("credential_hint", credHint), slog.F("credential_length", credLen)) } else { if m != nil { m.InterceptionCount.WithLabelValues(p.Name(), interceptor.Model(), metrics.InterceptionCountStatusCompleted, route, r.Method, actor.ID, string(client)).Add(1) } - log.Debug(ctx, "interception ended") + log.Debug(ctx, "interception ended", slog.F("credential_hint", credHint), slog.F("credential_length", credLen)) } - _ = asyncRecorder.RecordInterceptionEnded(ctx, &recorder.InterceptionRecordEnded{ID: interceptor.ID().String()}) + _ = asyncRecorder.RecordInterceptionEnded(ctx, &recorder.InterceptionRecordEnded{ + ID: interceptor.ID().String(), + CredentialHint: credHint, + }) // Ensure all recording have completed before completing request. asyncRecorder.Wait() diff --git a/aibridge/intercept/chatcompletions/blocking.go b/aibridge/intercept/chatcompletions/blocking.go index 95d065ce5b3..fa1511f660d 100644 --- a/aibridge/intercept/chatcompletions/blocking.go +++ b/aibridge/intercept/chatcompletions/blocking.go @@ -291,15 +291,16 @@ func (i *BlockingInterception) newChatCompletionWithKey(ctx context.Context, svc // 401/403. Errors that aren't key-specific don't trigger // failover and are returned to the caller. func (i *BlockingInterception) newChatCompletionWithKeyFailover(ctx context.Context, svc openai.ChatCompletionService, opts []option.RequestOption) (*openai.ChatCompletion, error) { - // TODO(ssncferreira): update the interception's credential - // hint with the actually-used key (the successful key on - // success, the last tried key on failure) in the upstack PR. walker := i.cfg.KeyPool.Walker() for { key, keyPoolErr := walker.Next() if keyPoolErr != nil { return nil, keyPoolErr } + // Record the key in use so the hint reflects the last attempted key. + i.credential = intercept.NewCredentialInfo(intercept.CredentialKindCentralized, key.Value()) + i.logger.Debug(ctx, "using centralized api key", + slog.F("credential_hint", i.Credential().Hint), slog.F("credential_length", i.Credential().Length)) requestOpts := append([]option.RequestOption{}, opts...) requestOpts = append(requestOpts, diff --git a/aibridge/intercept/chatcompletions/blocking_internal_test.go b/aibridge/intercept/chatcompletions/blocking_internal_test.go index 3b3a917a54a..2b9afaadeac 100644 --- a/aibridge/intercept/chatcompletions/blocking_internal_test.go +++ b/aibridge/intercept/chatcompletions/blocking_internal_test.go @@ -72,31 +72,35 @@ func TestBlockingInterception_KeyFailover(t *testing.T) { expectedRetryAfter string // Expected key states after the request, by index in keys. expectedKeyStates []keypool.KeyState + // Expected credential hint after ProcessRequest: last + // attempted key for centralized, user key from initial request for BYOK. + expectedCredentialHint string }{ { // Given: 1 valid key returning 200. // Then: 1 request, 200 response, key remains valid. name: "single_valid_key", - keys: []string{"k0"}, + keys: []string{"k0-long-key"}, responses: map[string]upstreamResponse{ - "k0": {statusCode: http.StatusOK, body: successBody}, + "k0-long-key": {statusCode: http.StatusOK, body: successBody}, }, - expectedRequestCount: 1, - expectedStatusCode: http.StatusOK, - expectedKeyStates: []keypool.KeyState{keypool.KeyStateValid}, + expectedRequestCount: 1, + expectedStatusCode: http.StatusOK, + expectedKeyStates: []keypool.KeyState{keypool.KeyStateValid}, + expectedCredentialHint: utils.MaskSecret("k0-long-key"), }, { // Given: 2 keys; key-0 returns 429, key-1 returns 200. // Then: 2 requests, 200 response, key-0 temporary, key-1 valid. name: "failover_after_429", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": { + "k0-long-key": { statusCode: http.StatusTooManyRequests, headers: map[string]string{"Retry-After": "5"}, body: rateLimitBody, }, - "k1": {statusCode: http.StatusOK, body: successBody}, + "k1-long-key": {statusCode: http.StatusOK, body: successBody}, }, expectedRequestCount: 2, expectedStatusCode: http.StatusOK, @@ -104,15 +108,16 @@ func TestBlockingInterception_KeyFailover(t *testing.T) { keypool.KeyStateTemporary, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 2 keys; key-0 returns 401, key-1 returns 200. // Then: 2 requests, 200 response, key-0 permanent, key-1 valid. name: "failover_after_401", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": {statusCode: http.StatusUnauthorized, body: authErrorBody}, - "k1": {statusCode: http.StatusOK, body: successBody}, + "k0-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody}, + "k1-long-key": {statusCode: http.StatusOK, body: successBody}, }, expectedRequestCount: 2, expectedStatusCode: http.StatusOK, @@ -120,15 +125,16 @@ func TestBlockingInterception_KeyFailover(t *testing.T) { keypool.KeyStatePermanent, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 2 keys; key-0 returns 403, key-1 returns 200. // Then: 2 requests, 200 response, key-0 permanent, key-1 valid. name: "failover_after_403", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": {statusCode: http.StatusForbidden, body: authErrorBody}, - "k1": {statusCode: http.StatusOK, body: successBody}, + "k0-long-key": {statusCode: http.StatusForbidden, body: authErrorBody}, + "k1-long-key": {statusCode: http.StatusOK, body: successBody}, }, expectedRequestCount: 2, expectedStatusCode: http.StatusOK, @@ -136,25 +142,26 @@ func TestBlockingInterception_KeyFailover(t *testing.T) { keypool.KeyStatePermanent, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 3 keys; all return 429 with cooldowns 5s, 3s, 10s. // Then: 3 requests, 429 response with smallest Retry-After, // all keys temporary. name: "all_keys_rate_limited", - keys: []string{"k0", "k1", "k2"}, + keys: []string{"k0-long-key", "k1-long-key", "k2-long-key"}, responses: map[string]upstreamResponse{ - "k0": { + "k0-long-key": { statusCode: http.StatusTooManyRequests, headers: map[string]string{"Retry-After": "5"}, body: rateLimitBody, }, - "k1": { + "k1-long-key": { statusCode: http.StatusTooManyRequests, headers: map[string]string{"Retry-After": "3"}, body: rateLimitBody, }, - "k2": { + "k2-long-key": { statusCode: http.StatusTooManyRequests, headers: map[string]string{"Retry-After": "10"}, body: rateLimitBody, @@ -168,15 +175,16 @@ func TestBlockingInterception_KeyFailover(t *testing.T) { keypool.KeyStateTemporary, keypool.KeyStateTemporary, }, + expectedCredentialHint: utils.MaskSecret("k2-long-key"), }, { // Given: 2 keys; both return 401. // Then: 2 requests, 502 api_error response, both keys permanent. name: "all_keys_unauthorized", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": {statusCode: http.StatusUnauthorized, body: authErrorBody}, - "k1": {statusCode: http.StatusUnauthorized, body: authErrorBody}, + "k0-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody}, + "k1-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody}, }, expectedRequestCount: 2, expectedStatusCode: http.StatusBadGateway, @@ -184,14 +192,15 @@ func TestBlockingInterception_KeyFailover(t *testing.T) { keypool.KeyStatePermanent, keypool.KeyStatePermanent, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 2 keys; key-0 returns 500. // Then: 1 request, 500 response, both keys remain valid. name: "server_error_no_failover", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": {statusCode: http.StatusInternalServerError, body: serverErrorBody}, + "k0-long-key": {statusCode: http.StatusInternalServerError, body: serverErrorBody}, }, expectedRequestCount: 1, expectedStatusCode: http.StatusInternalServerError, @@ -199,6 +208,7 @@ func TestBlockingInterception_KeyFailover(t *testing.T) { keypool.KeyStateValid, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k0-long-key"), }, { // Given: BYOK with a single key returning 429. @@ -219,9 +229,10 @@ func TestBlockingInterception_KeyFailover(t *testing.T) { body: rateLimitBody, }, }, - expectedRequestCount: 1, - expectedStatusCode: http.StatusTooManyRequests, - expectedRetryAfter: "5", + expectedRequestCount: 1, + expectedStatusCode: http.StatusTooManyRequests, + expectedRetryAfter: "5", + expectedCredentialHint: utils.MaskSecret("user-byok"), }, } @@ -252,6 +263,7 @@ func TestBlockingInterception_KeyFailover(t *testing.T) { cfg := config.OpenAI{BaseURL: upstream.URL + "/"} var pool *keypool.Pool + credInfo := intercept.NewCredentialInfo(intercept.CredentialKindCentralized, "") if len(tc.keys) > 0 { var err error pool, err = keypool.New(tc.keys, quartz.NewMock(t)) @@ -259,6 +271,7 @@ func TestBlockingInterception_KeyFailover(t *testing.T) { cfg.KeyPool = pool } else if tc.byokKey != "" { cfg.Key = tc.byokKey + credInfo = intercept.NewCredentialInfo(intercept.CredentialKindBYOK, tc.byokKey) } interceptor := NewBlockingInterceptor( @@ -269,7 +282,7 @@ func TestBlockingInterception_KeyFailover(t *testing.T) { http.Header{}, "Authorization", otel.Tracer("blocking_test"), - intercept.NewCredentialInfo(intercept.CredentialKindCentralized, ""), + credInfo, ) interceptor.Setup(slog.Make(), &testutil.MockRecorder{}, nil) @@ -288,6 +301,7 @@ func TestBlockingInterception_KeyFailover(t *testing.T) { if pool != nil { assert.Equal(t, tc.expectedKeyStates, pool.PoolState(), "key states") } + assert.Equal(t, tc.expectedCredentialHint, interceptor.Credential().Hint, "credential hint") }) } } @@ -309,6 +323,9 @@ func TestBlockingInterception_AgenticLoopFailover(t *testing.T) { expectedSeenKeys []string expectedStatusCode int expectedKeyStates []keypool.KeyState + // Expected credential hint after ProcessRequest: hint of the + // last attempted key across all agentic-loop iterations. + expectedCredentialHint string }{ { // Given: 2 keys; both upstream calls succeed on key-0. @@ -319,12 +336,13 @@ func TestBlockingInterception_AgenticLoopFailover(t *testing.T) { {statusCode: http.StatusOK, body: textCompleteBody}, }, expectedRequestCount: 2, - expectedSeenKeys: []string{"k0", "k0"}, + expectedSeenKeys: []string{"k0-long-key", "k0-long-key"}, expectedStatusCode: http.StatusOK, expectedKeyStates: []keypool.KeyState{ keypool.KeyStateValid, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k0-long-key"), }, { // Given: 2 keys; key-0 succeeds initially, then 429s @@ -342,12 +360,13 @@ func TestBlockingInterception_AgenticLoopFailover(t *testing.T) { {statusCode: http.StatusOK, body: textCompleteBody}, }, expectedRequestCount: 3, - expectedSeenKeys: []string{"k0", "k0", "k1"}, + expectedSeenKeys: []string{"k0-long-key", "k0-long-key", "k1-long-key"}, expectedStatusCode: http.StatusOK, expectedKeyStates: []keypool.KeyState{ keypool.KeyStateTemporary, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 2 keys; key-0 succeeds initially, then both @@ -369,12 +388,13 @@ func TestBlockingInterception_AgenticLoopFailover(t *testing.T) { }, }, expectedRequestCount: 3, - expectedSeenKeys: []string{"k0", "k0", "k1"}, + expectedSeenKeys: []string{"k0-long-key", "k0-long-key", "k1-long-key"}, expectedStatusCode: http.StatusTooManyRequests, expectedKeyStates: []keypool.KeyState{ keypool.KeyStateTemporary, keypool.KeyStateTemporary, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, } @@ -409,7 +429,7 @@ func TestBlockingInterception_AgenticLoopFailover(t *testing.T) { })) t.Cleanup(upstream.Close) - pool, err := keypool.New([]string{"k0", "k1"}, quartz.NewMock(t)) + pool, err := keypool.New([]string{"k0-long-key", "k1-long-key"}, quartz.NewMock(t)) require.NoError(t, err) cfg := config.OpenAI{ @@ -459,6 +479,7 @@ func TestBlockingInterception_AgenticLoopFailover(t *testing.T) { defer seenKeysMu.Unlock() assert.Equal(t, tc.expectedSeenKeys, seenKeys, "seen keys") assert.Equal(t, tc.expectedKeyStates, pool.PoolState(), "key states") + assert.Equal(t, tc.expectedCredentialHint, interceptor.Credential().Hint, "credential hint") }) } } diff --git a/aibridge/intercept/chatcompletions/streaming.go b/aibridge/intercept/chatcompletions/streaming.go index 581ab49d034..e20a2a801d6 100644 --- a/aibridge/intercept/chatcompletions/streaming.go +++ b/aibridge/intercept/chatcompletions/streaming.go @@ -164,6 +164,11 @@ func (i *StreamingInterception) ProcessRequest(w http.ResponseWriter, r *http.Re break } currentKey = key + // Record the key in use so the hint reflects the last attempted key. + i.credential = intercept.NewCredentialInfo(intercept.CredentialKindCentralized, key.Value()) + logger.Debug(ctx, "using centralized api key", + slog.F("credential_hint", i.Credential().Hint), slog.F("credential_length", i.Credential().Length)) + opts = append(opts, option.WithAPIKey(key.Value()), // Disable SDK retries because the failover diff --git a/aibridge/intercept/chatcompletions/streaming_internal_test.go b/aibridge/intercept/chatcompletions/streaming_internal_test.go index 82c58f9bc10..9561c0948a9 100644 --- a/aibridge/intercept/chatcompletions/streaming_internal_test.go +++ b/aibridge/intercept/chatcompletions/streaming_internal_test.go @@ -144,36 +144,40 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { expectedRetryAfter string // Expected key states after the request, by index in keys. expectedKeyStates []keypool.KeyState + // Expected credential hint after ProcessRequest: last + // attempted key for centralized, user key from initial request for BYOK. + expectedCredentialHint string }{ { // Given: 1 valid key returning a successful stream. // Then: 1 request, 200 response, key remains valid. name: "single_valid_key", - keys: []string{"k0"}, + keys: []string{"k0-long-key"}, responses: map[string]upstreamResponse{ - "k0": { + "k0-long-key": { statusCode: http.StatusOK, headers: map[string]string{"Content-Type": "text/event-stream"}, body: streamingSuccessBody, }, }, - expectedRequestCount: 1, - expectedStatusCode: http.StatusOK, - expectedKeyStates: []keypool.KeyState{keypool.KeyStateValid}, + expectedRequestCount: 1, + expectedStatusCode: http.StatusOK, + expectedKeyStates: []keypool.KeyState{keypool.KeyStateValid}, + expectedCredentialHint: utils.MaskSecret("k0-long-key"), }, { // Given: 2 keys; key-0 returns 429 pre-stream, key-1 // streams successfully. // Then: 2 requests, 200 response, key-0 temporary, key-1 valid. name: "failover_after_429", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": { + "k0-long-key": { statusCode: http.StatusTooManyRequests, headers: map[string]string{"Retry-After": "5"}, body: rateLimitBody, }, - "k1": { + "k1-long-key": { statusCode: http.StatusOK, headers: map[string]string{"Content-Type": "text/event-stream"}, body: streamingSuccessBody, @@ -185,16 +189,17 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { keypool.KeyStateTemporary, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 2 keys; key-0 returns 401 pre-stream, key-1 // streams successfully. // Then: 2 requests, 200 response, key-0 permanent, key-1 valid. name: "failover_after_401", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": {statusCode: http.StatusUnauthorized, body: authErrorBody}, - "k1": { + "k0-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody}, + "k1-long-key": { statusCode: http.StatusOK, headers: map[string]string{"Content-Type": "text/event-stream"}, body: streamingSuccessBody, @@ -206,15 +211,16 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { keypool.KeyStatePermanent, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 2 keys; key-0 returns 403 pre-stream, key-1 streams. // Then: 2 requests, 200 response, key-0 permanent, key-1 valid. name: "failover_after_403", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": {statusCode: http.StatusForbidden, body: authErrorBody}, - "k1": { + "k0-long-key": {statusCode: http.StatusForbidden, body: authErrorBody}, + "k1-long-key": { statusCode: http.StatusOK, headers: map[string]string{"Content-Type": "text/event-stream"}, body: streamingSuccessBody, @@ -226,6 +232,7 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { keypool.KeyStatePermanent, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 3 keys; all return 429 pre-stream with @@ -233,19 +240,19 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { // Then: 3 requests, 429 response with smallest // Retry-After, all keys temporary. name: "all_keys_rate_limited", - keys: []string{"k0", "k1", "k2"}, + keys: []string{"k0-long-key", "k1-long-key", "k2-long-key"}, responses: map[string]upstreamResponse{ - "k0": { + "k0-long-key": { statusCode: http.StatusTooManyRequests, headers: map[string]string{"Retry-After": "5"}, body: rateLimitBody, }, - "k1": { + "k1-long-key": { statusCode: http.StatusTooManyRequests, headers: map[string]string{"Retry-After": "3"}, body: rateLimitBody, }, - "k2": { + "k2-long-key": { statusCode: http.StatusTooManyRequests, headers: map[string]string{"Retry-After": "10"}, body: rateLimitBody, @@ -259,15 +266,16 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { keypool.KeyStateTemporary, keypool.KeyStateTemporary, }, + expectedCredentialHint: utils.MaskSecret("k2-long-key"), }, { // Given: 2 keys; both return 401 pre-stream. // Then: 2 requests, 502 api_error response, both keys permanent. name: "all_keys_unauthorized", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": {statusCode: http.StatusUnauthorized, body: authErrorBody}, - "k1": {statusCode: http.StatusUnauthorized, body: authErrorBody}, + "k0-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody}, + "k1-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody}, }, expectedRequestCount: 2, expectedStatusCode: http.StatusBadGateway, @@ -275,14 +283,15 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { keypool.KeyStatePermanent, keypool.KeyStatePermanent, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 2 keys; key-0 returns 500 pre-stream. // Then: 1 request, 500 response, both keys remain valid. name: "server_error_no_failover", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": {statusCode: http.StatusInternalServerError, body: serverErrorBody}, + "k0-long-key": {statusCode: http.StatusInternalServerError, body: serverErrorBody}, }, expectedRequestCount: 1, expectedStatusCode: http.StatusInternalServerError, @@ -290,6 +299,7 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { keypool.KeyStateValid, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k0-long-key"), }, { // Given: BYOK with a single key returning 429. @@ -310,9 +320,10 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { body: rateLimitBody, }, }, - expectedRequestCount: 1, - expectedStatusCode: http.StatusTooManyRequests, - expectedRetryAfter: "5", + expectedRequestCount: 1, + expectedStatusCode: http.StatusTooManyRequests, + expectedRetryAfter: "5", + expectedCredentialHint: utils.MaskSecret("user-byok"), }, } @@ -342,6 +353,7 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { cfg := config.OpenAI{BaseURL: upstream.URL + "/"} var pool *keypool.Pool + credInfo := intercept.NewCredentialInfo(intercept.CredentialKindCentralized, "") if len(tc.keys) > 0 { var err error pool, err = keypool.New(tc.keys, quartz.NewMock(t)) @@ -349,6 +361,7 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { cfg.KeyPool = pool } else if tc.byokKey != "" { cfg.Key = tc.byokKey + credInfo = intercept.NewCredentialInfo(intercept.CredentialKindBYOK, tc.byokKey) } interceptor := NewStreamingInterceptor( @@ -359,7 +372,7 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { http.Header{}, "Authorization", otel.Tracer("streaming_test"), - intercept.NewCredentialInfo(intercept.CredentialKindCentralized, ""), + credInfo, ) interceptor.Setup(slog.Make(), &testutil.MockRecorder{}, nil) @@ -378,6 +391,7 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { if pool != nil { assert.Equal(t, tc.expectedKeyStates, pool.PoolState(), "key states") } + assert.Equal(t, tc.expectedCredentialHint, interceptor.Credential().Hint, "credential hint") }) } } @@ -435,6 +449,9 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) { // error (e.g. all keys exhausted). expectedErr bool expectedKeyStates []keypool.KeyState + // Expected credential hint after ProcessRequest: hint of the + // last attempted key across all agentic-loop iterations. + expectedCredentialHint string }{ { // Given: 2 keys; both upstream calls succeed on key-0. @@ -445,13 +462,14 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) { {statusCode: http.StatusOK, headers: sseHeaders, body: textStreamBody}, }, expectedRequestCount: 2, - expectedSeenKeys: []string{"k0", "k0"}, + expectedSeenKeys: []string{"k0-long-key", "k0-long-key"}, expectedBodyContains: "done", expectErrorAsSSEEvent: false, expectedKeyStates: []keypool.KeyState{ keypool.KeyStateValid, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k0-long-key"), }, { // Given: 2 keys; key-0 succeeds initially, then 429s @@ -469,13 +487,14 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) { {statusCode: http.StatusOK, headers: sseHeaders, body: textStreamBody}, }, expectedRequestCount: 3, - expectedSeenKeys: []string{"k0", "k0", "k1"}, + expectedSeenKeys: []string{"k0-long-key", "k0-long-key", "k1-long-key"}, expectedBodyContains: "done", expectErrorAsSSEEvent: false, expectedKeyStates: []keypool.KeyState{ keypool.KeyStateTemporary, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 2 keys; key-0 succeeds initially, then both @@ -497,7 +516,7 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) { }, }, expectedRequestCount: 3, - expectedSeenKeys: []string{"k0", "k0", "k1"}, + expectedSeenKeys: []string{"k0-long-key", "k0-long-key", "k1-long-key"}, expectedBodyContains: "all configured keys are rate-limited", expectErrorAsSSEEvent: true, expectedErr: true, @@ -505,6 +524,7 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) { keypool.KeyStateTemporary, keypool.KeyStateTemporary, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, } @@ -538,7 +558,7 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) { })) t.Cleanup(upstream.Close) - pool, err := keypool.New([]string{"k0", "k1"}, quartz.NewMock(t)) + pool, err := keypool.New([]string{"k0-long-key", "k1-long-key"}, quartz.NewMock(t)) require.NoError(t, err) cfg := config.OpenAI{ @@ -596,6 +616,7 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) { defer seenKeysMu.Unlock() assert.Equal(t, tc.expectedSeenKeys, seenKeys, "seen keys") assert.Equal(t, tc.expectedKeyStates, pool.PoolState(), "key states") + assert.Equal(t, tc.expectedCredentialHint, interceptor.Credential().Hint, "credential hint") }) } } diff --git a/aibridge/intercept/messages/blocking.go b/aibridge/intercept/messages/blocking.go index e91f80feb9e..bf74885b2b5 100644 --- a/aibridge/intercept/messages/blocking.go +++ b/aibridge/intercept/messages/blocking.go @@ -367,15 +367,16 @@ func (i *BlockingInterception) newMessageWithKey(ctx context.Context, svc anthro // Errors that aren't key-specific don't trigger failover and // are returned to the caller. func (i *BlockingInterception) newMessageWithKeyFailover(ctx context.Context, svc anthropic.MessageService) (*anthropic.Message, error) { - // TODO(ssncferreira): update the interception's credential - // hint with the actually-used key (the successful key on - // success, the last tried key on failure) in the upstack PR. walker := i.cfg.KeyPool.Walker() for { key, keyPoolErr := walker.Next() if keyPoolErr != nil { return nil, keyPoolErr } + // Record the key in use so the hint reflects the last attempted key. + i.credential = intercept.NewCredentialInfo(intercept.CredentialKindCentralized, key.Value()) + i.logger.Debug(ctx, "using centralized api key", + slog.F("credential_hint", i.Credential().Hint), slog.F("credential_length", i.Credential().Length)) msg, err := i.newMessageWithKey(ctx, svc, option.WithAPIKey(key.Value()), diff --git a/aibridge/intercept/messages/blocking_internal_test.go b/aibridge/intercept/messages/blocking_internal_test.go index 857d425fe38..9b3f0d447b4 100644 --- a/aibridge/intercept/messages/blocking_internal_test.go +++ b/aibridge/intercept/messages/blocking_internal_test.go @@ -19,6 +19,7 @@ import ( "github.com/coder/coder/v2/aibridge/internal/testutil" "github.com/coder/coder/v2/aibridge/keypool" "github.com/coder/coder/v2/aibridge/mcp" + "github.com/coder/coder/v2/aibridge/utils" "github.com/coder/quartz" ) @@ -54,31 +55,35 @@ func TestBlockingInterception_KeyFailover(t *testing.T) { expectedRetryAfter string // Expected key states after the request, by index in keys. expectedKeyStates []keypool.KeyState + // Expected credential hint after ProcessRequest: last + // attempted key for centralized, user key from initial request for BYOK. + expectedCredentialHint string }{ { // Given: 1 valid key returning 200. // Then: 1 request, 200 response, key remains valid. name: "single_valid_key", - keys: []string{"k0"}, + keys: []string{"k0-long-key"}, responses: map[string]upstreamResponse{ - "k0": {statusCode: http.StatusOK, body: successBody}, + "k0-long-key": {statusCode: http.StatusOK, body: successBody}, }, - expectedRequestCount: 1, - expectedStatusCode: http.StatusOK, - expectedKeyStates: []keypool.KeyState{keypool.KeyStateValid}, + expectedRequestCount: 1, + expectedStatusCode: http.StatusOK, + expectedKeyStates: []keypool.KeyState{keypool.KeyStateValid}, + expectedCredentialHint: utils.MaskSecret("k0-long-key"), }, { // Given: 2 keys; key-0 returns 429, key-1 returns 200. // Then: 2 requests, 200 response, key-0 temporary, key-1 valid. name: "failover_after_429", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": { + "k0-long-key": { statusCode: http.StatusTooManyRequests, headers: map[string]string{"Retry-After": "5"}, body: rateLimitBody, }, - "k1": {statusCode: http.StatusOK, body: successBody}, + "k1-long-key": {statusCode: http.StatusOK, body: successBody}, }, expectedRequestCount: 2, expectedStatusCode: http.StatusOK, @@ -86,15 +91,16 @@ func TestBlockingInterception_KeyFailover(t *testing.T) { keypool.KeyStateTemporary, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 2 keys; key-0 returns 401, key-1 returns 200. // Then: 2 requests, 200 response, key-0 permanent, key-1 valid. name: "failover_after_401", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": {statusCode: http.StatusUnauthorized, body: authErrorBody}, - "k1": {statusCode: http.StatusOK, body: successBody}, + "k0-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody}, + "k1-long-key": {statusCode: http.StatusOK, body: successBody}, }, expectedRequestCount: 2, expectedStatusCode: http.StatusOK, @@ -102,15 +108,16 @@ func TestBlockingInterception_KeyFailover(t *testing.T) { keypool.KeyStatePermanent, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 2 keys; key-0 returns 403, key-1 returns 200. // Then: 2 requests, 200 response, key-0 permanent, key-1 valid. name: "failover_after_403", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": {statusCode: http.StatusForbidden, body: authErrorBody}, - "k1": {statusCode: http.StatusOK, body: successBody}, + "k0-long-key": {statusCode: http.StatusForbidden, body: authErrorBody}, + "k1-long-key": {statusCode: http.StatusOK, body: successBody}, }, expectedRequestCount: 2, expectedStatusCode: http.StatusOK, @@ -118,25 +125,26 @@ func TestBlockingInterception_KeyFailover(t *testing.T) { keypool.KeyStatePermanent, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 3 keys; all return 429 with cooldowns 5s, 3s, 10s. // Then: 3 requests, 429 response with smallest Retry-After, // all keys temporary. name: "all_keys_rate_limited", - keys: []string{"k0", "k1", "k2"}, + keys: []string{"k0-long-key", "k1-long-key", "k2-long-key"}, responses: map[string]upstreamResponse{ - "k0": { + "k0-long-key": { statusCode: http.StatusTooManyRequests, headers: map[string]string{"Retry-After": "5"}, body: rateLimitBody, }, - "k1": { + "k1-long-key": { statusCode: http.StatusTooManyRequests, headers: map[string]string{"Retry-After": "3"}, body: rateLimitBody, }, - "k2": { + "k2-long-key": { statusCode: http.StatusTooManyRequests, headers: map[string]string{"Retry-After": "10"}, body: rateLimitBody, @@ -150,15 +158,16 @@ func TestBlockingInterception_KeyFailover(t *testing.T) { keypool.KeyStateTemporary, keypool.KeyStateTemporary, }, + expectedCredentialHint: utils.MaskSecret("k2-long-key"), }, { // Given: 2 keys; both return 401. // Then: 2 requests, 502 api_error response, both keys permanent. name: "all_keys_unauthorized", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": {statusCode: http.StatusUnauthorized, body: authErrorBody}, - "k1": {statusCode: http.StatusUnauthorized, body: authErrorBody}, + "k0-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody}, + "k1-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody}, }, expectedRequestCount: 2, expectedStatusCode: http.StatusBadGateway, @@ -166,14 +175,15 @@ func TestBlockingInterception_KeyFailover(t *testing.T) { keypool.KeyStatePermanent, keypool.KeyStatePermanent, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 2 keys; key-0 returns 500. // Then: 1 request, 500 response, both keys remain valid. name: "server_error_no_failover", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": {statusCode: http.StatusInternalServerError, body: serverErrorBody}, + "k0-long-key": {statusCode: http.StatusInternalServerError, body: serverErrorBody}, }, expectedRequestCount: 1, expectedStatusCode: http.StatusInternalServerError, @@ -181,6 +191,7 @@ func TestBlockingInterception_KeyFailover(t *testing.T) { keypool.KeyStateValid, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k0-long-key"), }, { // Given: BYOK with a single key returning 429. @@ -201,9 +212,10 @@ func TestBlockingInterception_KeyFailover(t *testing.T) { body: rateLimitBody, }, }, - expectedRequestCount: 1, - expectedStatusCode: http.StatusTooManyRequests, - expectedRetryAfter: "5", + expectedRequestCount: 1, + expectedStatusCode: http.StatusTooManyRequests, + expectedRetryAfter: "5", + expectedCredentialHint: utils.MaskSecret("user-byok"), }, } @@ -234,6 +246,7 @@ func TestBlockingInterception_KeyFailover(t *testing.T) { cfg := config.Anthropic{BaseURL: upstream.URL + "/"} var pool *keypool.Pool + credInfo := intercept.NewCredentialInfo(intercept.CredentialKindCentralized, "") if len(tc.keys) > 0 { var err error pool, err = keypool.New(tc.keys, quartz.NewMock(t)) @@ -241,6 +254,7 @@ func TestBlockingInterception_KeyFailover(t *testing.T) { cfg.KeyPool = pool } else if tc.byokKey != "" { cfg.Key = tc.byokKey + credInfo = intercept.NewCredentialInfo(intercept.CredentialKindBYOK, tc.byokKey) } payload, err := NewRequestPayload([]byte(requestBody)) @@ -255,7 +269,7 @@ func TestBlockingInterception_KeyFailover(t *testing.T) { http.Header{}, "X-Api-Key", otel.Tracer("blocking_test"), - intercept.NewCredentialInfo(intercept.CredentialKindCentralized, ""), + credInfo, ) interceptor.Setup(slog.Make(), &testutil.MockRecorder{}, nil) @@ -271,6 +285,7 @@ func TestBlockingInterception_KeyFailover(t *testing.T) { assert.Equal(t, tc.expectedRequestCount, requestCount.Load(), "upstream request count") assert.Equal(t, tc.expectedStatusCode, w.Code, "response status code") assert.Equal(t, tc.expectedRetryAfter, w.Header().Get("Retry-After"), "Retry-After header") + assert.Equal(t, tc.expectedCredentialHint, interceptor.Credential().Hint, "credential hint") if pool != nil { assert.Equal(t, tc.expectedKeyStates, pool.PoolState(), "key states") } @@ -296,6 +311,9 @@ func TestBlockingInterception_AgenticLoopFailover(t *testing.T) { expectedStatusCode int expectedRetryAfter string expectedKeyStates []keypool.KeyState + // Expected credential hint after ProcessRequest: hint of the + // last attempted key across all agentic-loop iterations. + expectedCredentialHint string }{ { // Given: 2 keys; both upstream calls succeed on key-0. @@ -306,12 +324,13 @@ func TestBlockingInterception_AgenticLoopFailover(t *testing.T) { {statusCode: http.StatusOK, body: successBody}, }, expectedRequestCount: 2, - expectedSeenKeys: []string{"k0", "k0"}, + expectedSeenKeys: []string{"k0-long-key", "k0-long-key"}, expectedStatusCode: http.StatusOK, expectedKeyStates: []keypool.KeyState{ keypool.KeyStateValid, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k0-long-key"), }, { // Given: 2 keys; key-0 succeeds initially, then 429s @@ -329,12 +348,13 @@ func TestBlockingInterception_AgenticLoopFailover(t *testing.T) { {statusCode: http.StatusOK, body: successBody}, }, expectedRequestCount: 3, - expectedSeenKeys: []string{"k0", "k0", "k1"}, + expectedSeenKeys: []string{"k0-long-key", "k0-long-key", "k1-long-key"}, expectedStatusCode: http.StatusOK, expectedKeyStates: []keypool.KeyState{ keypool.KeyStateTemporary, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 2 keys; key-0 succeeds initially, then both @@ -356,13 +376,14 @@ func TestBlockingInterception_AgenticLoopFailover(t *testing.T) { }, }, expectedRequestCount: 3, - expectedSeenKeys: []string{"k0", "k0", "k1"}, + expectedSeenKeys: []string{"k0-long-key", "k0-long-key", "k1-long-key"}, expectedStatusCode: http.StatusTooManyRequests, expectedRetryAfter: "3", expectedKeyStates: []keypool.KeyState{ keypool.KeyStateTemporary, keypool.KeyStateTemporary, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, } @@ -397,7 +418,7 @@ func TestBlockingInterception_AgenticLoopFailover(t *testing.T) { })) t.Cleanup(upstream.Close) - pool, err := keypool.New([]string{"k0", "k1"}, quartz.NewMock(t)) + pool, err := keypool.New([]string{"k0-long-key", "k1-long-key"}, quartz.NewMock(t)) require.NoError(t, err) cfg := config.Anthropic{ @@ -447,6 +468,7 @@ func TestBlockingInterception_AgenticLoopFailover(t *testing.T) { assert.Equal(t, tc.expectedRequestCount, requestCount.Load(), "upstream request count") assert.Equal(t, tc.expectedStatusCode, w.Code, "response status code") assert.Equal(t, tc.expectedRetryAfter, w.Header().Get("Retry-After"), "Retry-After header") + assert.Equal(t, tc.expectedCredentialHint, interceptor.Credential().Hint, "credential hint") seenKeysMu.Lock() defer seenKeysMu.Unlock() diff --git a/aibridge/intercept/messages/streaming.go b/aibridge/intercept/messages/streaming.go index 475f32c99c4..47c49528a97 100644 --- a/aibridge/intercept/messages/streaming.go +++ b/aibridge/intercept/messages/streaming.go @@ -195,6 +195,11 @@ newStream: break } currentKey = key + // Record the key in use so the hint reflects the last attempted key. + i.credential = intercept.NewCredentialInfo(intercept.CredentialKindCentralized, key.Value()) + logger.Debug(ctx, "using centralized api key", + slog.F("credential_hint", i.Credential().Hint), slog.F("credential_length", i.Credential().Length)) + streamOpts = append(streamOpts, option.WithAPIKey(key.Value()), // Disable SDK retries because the failover diff --git a/aibridge/intercept/messages/streaming_internal_test.go b/aibridge/intercept/messages/streaming_internal_test.go index 97f48d4cc3c..5fc7da00df6 100644 --- a/aibridge/intercept/messages/streaming_internal_test.go +++ b/aibridge/intercept/messages/streaming_internal_test.go @@ -21,6 +21,7 @@ import ( "github.com/coder/coder/v2/aibridge/internal/testutil" "github.com/coder/coder/v2/aibridge/keypool" "github.com/coder/coder/v2/aibridge/mcp" + "github.com/coder/coder/v2/aibridge/utils" "github.com/coder/quartz" ) @@ -60,36 +61,40 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { expectedRetryAfter string // Expected key states after the request, by index in keys. expectedKeyStates []keypool.KeyState + // Expected credential hint after ProcessRequest: last + // attempted key for centralized, user key from initial request for BYOK. + expectedCredentialHint string }{ { // Given: 1 valid key returning a successful stream. // Then: 1 request, 200 response, key remains valid. name: "single_valid_key", - keys: []string{"k0"}, + keys: []string{"k0-long-key"}, responses: map[string]upstreamResponse{ - "k0": { + "k0-long-key": { statusCode: http.StatusOK, headers: map[string]string{"Content-Type": "text/event-stream"}, body: streamingSuccessBody, }, }, - expectedRequestCount: 1, - expectedStatusCode: http.StatusOK, - expectedKeyStates: []keypool.KeyState{keypool.KeyStateValid}, + expectedRequestCount: 1, + expectedStatusCode: http.StatusOK, + expectedKeyStates: []keypool.KeyState{keypool.KeyStateValid}, + expectedCredentialHint: utils.MaskSecret("k0-long-key"), }, { // Given: 2 keys; key-0 returns 429 pre-stream, key-1 // streams successfully. // Then: 2 requests, 200 response, key-0 temporary, key-1 valid. name: "failover_after_429", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": { + "k0-long-key": { statusCode: http.StatusTooManyRequests, headers: map[string]string{"Retry-After": "5"}, body: rateLimitBody, }, - "k1": { + "k1-long-key": { statusCode: http.StatusOK, headers: map[string]string{"Content-Type": "text/event-stream"}, body: streamingSuccessBody, @@ -101,16 +106,17 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { keypool.KeyStateTemporary, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 2 keys; key-0 returns 401 pre-stream, key-1 // streams successfully. // Then: 2 requests, 200 response, key-0 permanent, key-1 valid. name: "failover_after_401", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": {statusCode: http.StatusUnauthorized, body: authErrorBody}, - "k1": { + "k0-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody}, + "k1-long-key": { statusCode: http.StatusOK, headers: map[string]string{"Content-Type": "text/event-stream"}, body: streamingSuccessBody, @@ -122,15 +128,16 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { keypool.KeyStatePermanent, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 2 keys; key-0 returns 403 pre-stream, key-1 streams. // Then: 2 requests, 200 response, key-0 permanent, key-1 valid. name: "failover_after_403", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": {statusCode: http.StatusForbidden, body: authErrorBody}, - "k1": { + "k0-long-key": {statusCode: http.StatusForbidden, body: authErrorBody}, + "k1-long-key": { statusCode: http.StatusOK, headers: map[string]string{"Content-Type": "text/event-stream"}, body: streamingSuccessBody, @@ -142,6 +149,7 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { keypool.KeyStatePermanent, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 3 keys; all return 429 pre-stream with @@ -149,19 +157,19 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { // Then: 3 requests, 429 response with smallest // Retry-After, all keys temporary. name: "all_keys_rate_limited", - keys: []string{"k0", "k1", "k2"}, + keys: []string{"k0-long-key", "k1-long-key", "k2-long-key"}, responses: map[string]upstreamResponse{ - "k0": { + "k0-long-key": { statusCode: http.StatusTooManyRequests, headers: map[string]string{"Retry-After": "5"}, body: rateLimitBody, }, - "k1": { + "k1-long-key": { statusCode: http.StatusTooManyRequests, headers: map[string]string{"Retry-After": "3"}, body: rateLimitBody, }, - "k2": { + "k2-long-key": { statusCode: http.StatusTooManyRequests, headers: map[string]string{"Retry-After": "10"}, body: rateLimitBody, @@ -175,15 +183,16 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { keypool.KeyStateTemporary, keypool.KeyStateTemporary, }, + expectedCredentialHint: utils.MaskSecret("k2-long-key"), }, { // Given: 2 keys; both return 401 pre-stream. // Then: 2 requests, 502 api_error response, both keys permanent. name: "all_keys_unauthorized", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": {statusCode: http.StatusUnauthorized, body: authErrorBody}, - "k1": {statusCode: http.StatusUnauthorized, body: authErrorBody}, + "k0-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody}, + "k1-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody}, }, expectedRequestCount: 2, expectedStatusCode: http.StatusBadGateway, @@ -191,14 +200,15 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { keypool.KeyStatePermanent, keypool.KeyStatePermanent, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 2 keys; key-0 returns 500 pre-stream. // Then: 1 request, 500 response, both keys remain valid. name: "server_error_no_failover", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": {statusCode: http.StatusInternalServerError, body: serverErrorBody}, + "k0-long-key": {statusCode: http.StatusInternalServerError, body: serverErrorBody}, }, expectedRequestCount: 1, expectedStatusCode: http.StatusInternalServerError, @@ -206,6 +216,7 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { keypool.KeyStateValid, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k0-long-key"), }, { // Given: BYOK with a single key returning 429. @@ -226,9 +237,10 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { body: rateLimitBody, }, }, - expectedRequestCount: 1, - expectedStatusCode: http.StatusTooManyRequests, - expectedRetryAfter: "5", + expectedRequestCount: 1, + expectedStatusCode: http.StatusTooManyRequests, + expectedRetryAfter: "5", + expectedCredentialHint: utils.MaskSecret("user-byok"), }, } @@ -258,6 +270,7 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { cfg := config.Anthropic{BaseURL: upstream.URL + "/"} var pool *keypool.Pool + credInfo := intercept.NewCredentialInfo(intercept.CredentialKindCentralized, "") if len(tc.keys) > 0 { var err error pool, err = keypool.New(tc.keys, quartz.NewMock(t)) @@ -265,6 +278,7 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { cfg.KeyPool = pool } else if tc.byokKey != "" { cfg.Key = tc.byokKey + credInfo = intercept.NewCredentialInfo(intercept.CredentialKindBYOK, tc.byokKey) } payload, err := NewRequestPayload([]byte(requestBody)) @@ -279,7 +293,7 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { http.Header{}, "X-Api-Key", otel.Tracer("streaming_test"), - intercept.NewCredentialInfo(intercept.CredentialKindCentralized, ""), + credInfo, ) interceptor.Setup(slog.Make(), &testutil.MockRecorder{}, nil) @@ -301,6 +315,7 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { if pool != nil { assert.Equal(t, tc.expectedKeyStates, pool.PoolState(), "key states") } + assert.Equal(t, tc.expectedCredentialHint, interceptor.Credential().Hint, "credential hint") }) } } @@ -387,6 +402,9 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) { // error (e.g. all keys exhausted). expectedErr bool expectedKeyStates []keypool.KeyState + // Expected credential hint after ProcessRequest: hint of the + // last attempted key across all agentic-loop iterations. + expectedCredentialHint string }{ { // Given: 2 keys; both upstream calls succeed on key-0. @@ -397,13 +415,14 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) { {statusCode: http.StatusOK, headers: sseHeaders, body: textStreamBody}, }, expectedRequestCount: 2, - expectedSeenKeys: []string{"k0", "k0"}, + expectedSeenKeys: []string{"k0-long-key", "k0-long-key"}, expectedBodyContains: "done", expectErrorAsSSEEvent: false, expectedKeyStates: []keypool.KeyState{ keypool.KeyStateValid, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k0-long-key"), }, { // Given: 2 keys; key-0 succeeds initially, then 429s @@ -421,13 +440,14 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) { {statusCode: http.StatusOK, headers: sseHeaders, body: textStreamBody}, }, expectedRequestCount: 3, - expectedSeenKeys: []string{"k0", "k0", "k1"}, + expectedSeenKeys: []string{"k0-long-key", "k0-long-key", "k1-long-key"}, expectedBodyContains: "done", expectErrorAsSSEEvent: false, expectedKeyStates: []keypool.KeyState{ keypool.KeyStateTemporary, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 2 keys; key-0 succeeds initially, then both @@ -453,7 +473,7 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) { }, }, expectedRequestCount: 3, - expectedSeenKeys: []string{"k0", "k0", "k1"}, + expectedSeenKeys: []string{"k0-long-key", "k0-long-key", "k1-long-key"}, expectedBodyContains: "all configured keys are rate-limited", expectErrorAsSSEEvent: true, expectedErr: true, @@ -461,6 +481,7 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) { keypool.KeyStateTemporary, keypool.KeyStateTemporary, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, } @@ -494,7 +515,7 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) { })) t.Cleanup(upstream.Close) - pool, err := keypool.New([]string{"k0", "k1"}, quartz.NewMock(t)) + pool, err := keypool.New([]string{"k0-long-key", "k1-long-key"}, quartz.NewMock(t)) require.NoError(t, err) cfg := config.Anthropic{ @@ -553,6 +574,7 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) { defer seenKeysMu.Unlock() assert.Equal(t, tc.expectedSeenKeys, seenKeys, "seen keys") assert.Equal(t, tc.expectedKeyStates, pool.PoolState(), "key states") + assert.Equal(t, tc.expectedCredentialHint, interceptor.Credential().Hint, "credential hint") }) } } diff --git a/aibridge/intercept/responses/blocking.go b/aibridge/intercept/responses/blocking.go index 9726b6f750e..892dc1e71d5 100644 --- a/aibridge/intercept/responses/blocking.go +++ b/aibridge/intercept/responses/blocking.go @@ -171,15 +171,16 @@ func (i *BlockingResponsesInterceptor) newResponseWithKey(ctx context.Context, s // Errors that aren't key-specific don't trigger failover and // are returned to the caller. func (i *BlockingResponsesInterceptor) newResponseWithKeyFailover(ctx context.Context, srv responses.ResponseService, opts []option.RequestOption) (*responses.Response, error) { - // TODO(ssncferreira): update the interception's credential - // hint with the actually-used key (the successful key on - // success, the last tried key on failure) in the upstack PR. walker := i.cfg.KeyPool.Walker() for { key, keyPoolErr := walker.Next() if keyPoolErr != nil { return nil, keyPoolErr } + // Record the key in use so the hint reflects the last attempted key. + i.credential = intercept.NewCredentialInfo(intercept.CredentialKindCentralized, key.Value()) + i.logger.Debug(ctx, "using centralized api key", + slog.F("credential_hint", i.Credential().Hint), slog.F("credential_length", i.Credential().Length)) requestOpts := append([]option.RequestOption{}, opts...) requestOpts = append(requestOpts, diff --git a/aibridge/intercept/responses/blocking_internal_test.go b/aibridge/intercept/responses/blocking_internal_test.go index 678c2ce0f3c..94acf0deefb 100644 --- a/aibridge/intercept/responses/blocking_internal_test.go +++ b/aibridge/intercept/responses/blocking_internal_test.go @@ -58,31 +58,35 @@ func TestBlockingResponsesInterceptor_KeyFailover(t *testing.T) { expectedRetryAfter string // Expected key states after the request, by index in keys. expectedKeyStates []keypool.KeyState + // Expected credential hint after ProcessRequest: last + // attempted key for centralized, user key from initial request for BYOK. + expectedCredentialHint string }{ { // Given: 1 valid key returning 200. // Then: 1 request, 200 response, key remains valid. name: "single_valid_key", - keys: []string{"k0"}, + keys: []string{"k0-long-key"}, responses: map[string]upstreamResponse{ - "k0": {statusCode: http.StatusOK, body: successBody}, + "k0-long-key": {statusCode: http.StatusOK, body: successBody}, }, - expectedRequestCount: 1, - expectedStatusCode: http.StatusOK, - expectedKeyStates: []keypool.KeyState{keypool.KeyStateValid}, + expectedRequestCount: 1, + expectedStatusCode: http.StatusOK, + expectedKeyStates: []keypool.KeyState{keypool.KeyStateValid}, + expectedCredentialHint: utils.MaskSecret("k0-long-key"), }, { // Given: 2 keys; key-0 returns 429, key-1 returns 200. // Then: 2 requests, 200 response, key-0 temporary, key-1 valid. name: "failover_after_429", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": { + "k0-long-key": { statusCode: http.StatusTooManyRequests, headers: map[string]string{"Retry-After": "5"}, body: rateLimitBody, }, - "k1": {statusCode: http.StatusOK, body: successBody}, + "k1-long-key": {statusCode: http.StatusOK, body: successBody}, }, expectedRequestCount: 2, expectedStatusCode: http.StatusOK, @@ -90,15 +94,16 @@ func TestBlockingResponsesInterceptor_KeyFailover(t *testing.T) { keypool.KeyStateTemporary, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 2 keys; key-0 returns 401, key-1 returns 200. // Then: 2 requests, 200 response, key-0 permanent, key-1 valid. name: "failover_after_401", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": {statusCode: http.StatusUnauthorized, body: authErrorBody}, - "k1": {statusCode: http.StatusOK, body: successBody}, + "k0-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody}, + "k1-long-key": {statusCode: http.StatusOK, body: successBody}, }, expectedRequestCount: 2, expectedStatusCode: http.StatusOK, @@ -106,15 +111,16 @@ func TestBlockingResponsesInterceptor_KeyFailover(t *testing.T) { keypool.KeyStatePermanent, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 2 keys; key-0 returns 403, key-1 returns 200. // Then: 2 requests, 200 response, key-0 permanent, key-1 valid. name: "failover_after_403", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": {statusCode: http.StatusForbidden, body: authErrorBody}, - "k1": {statusCode: http.StatusOK, body: successBody}, + "k0-long-key": {statusCode: http.StatusForbidden, body: authErrorBody}, + "k1-long-key": {statusCode: http.StatusOK, body: successBody}, }, expectedRequestCount: 2, expectedStatusCode: http.StatusOK, @@ -122,25 +128,26 @@ func TestBlockingResponsesInterceptor_KeyFailover(t *testing.T) { keypool.KeyStatePermanent, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 3 keys; all return 429 with cooldowns 5s, 3s, 10s. // Then: 3 requests, 429 response with smallest Retry-After, // all keys temporary. name: "all_keys_rate_limited", - keys: []string{"k0", "k1", "k2"}, + keys: []string{"k0-long-key", "k1-long-key", "k2-long-key"}, responses: map[string]upstreamResponse{ - "k0": { + "k0-long-key": { statusCode: http.StatusTooManyRequests, headers: map[string]string{"Retry-After": "5"}, body: rateLimitBody, }, - "k1": { + "k1-long-key": { statusCode: http.StatusTooManyRequests, headers: map[string]string{"Retry-After": "3"}, body: rateLimitBody, }, - "k2": { + "k2-long-key": { statusCode: http.StatusTooManyRequests, headers: map[string]string{"Retry-After": "10"}, body: rateLimitBody, @@ -154,15 +161,16 @@ func TestBlockingResponsesInterceptor_KeyFailover(t *testing.T) { keypool.KeyStateTemporary, keypool.KeyStateTemporary, }, + expectedCredentialHint: utils.MaskSecret("k2-long-key"), }, { // Given: 2 keys; both return 401. // Then: 2 requests, 502 api_error response, both keys permanent. name: "all_keys_unauthorized", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": {statusCode: http.StatusUnauthorized, body: authErrorBody}, - "k1": {statusCode: http.StatusUnauthorized, body: authErrorBody}, + "k0-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody}, + "k1-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody}, }, expectedRequestCount: 2, expectedStatusCode: http.StatusBadGateway, @@ -170,14 +178,15 @@ func TestBlockingResponsesInterceptor_KeyFailover(t *testing.T) { keypool.KeyStatePermanent, keypool.KeyStatePermanent, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 2 keys; key-0 returns 500. // Then: 1 request, 500 response, both keys remain valid. name: "server_error_no_failover", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": {statusCode: http.StatusInternalServerError, body: serverErrorBody}, + "k0-long-key": {statusCode: http.StatusInternalServerError, body: serverErrorBody}, }, expectedRequestCount: 1, expectedStatusCode: http.StatusInternalServerError, @@ -185,6 +194,7 @@ func TestBlockingResponsesInterceptor_KeyFailover(t *testing.T) { keypool.KeyStateValid, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k0-long-key"), }, { // Given: BYOK with a single key returning 429. @@ -204,8 +214,9 @@ func TestBlockingResponsesInterceptor_KeyFailover(t *testing.T) { body: rateLimitBody, }, }, - expectedRequestCount: 1, - expectedStatusCode: http.StatusTooManyRequests, + expectedRequestCount: 1, + expectedStatusCode: http.StatusTooManyRequests, + expectedCredentialHint: utils.MaskSecret("user-byok"), }, } @@ -235,6 +246,7 @@ func TestBlockingResponsesInterceptor_KeyFailover(t *testing.T) { t.Cleanup(upstream.Close) cfg := config.OpenAI{BaseURL: upstream.URL + "/"} + credInfo := intercept.NewCredentialInfo(intercept.CredentialKindCentralized, "") var pool *keypool.Pool if len(tc.keys) > 0 { var err error @@ -243,6 +255,7 @@ func TestBlockingResponsesInterceptor_KeyFailover(t *testing.T) { cfg.KeyPool = pool } else if tc.byokKey != "" { cfg.Key = tc.byokKey + credInfo = intercept.NewCredentialInfo(intercept.CredentialKindBYOK, tc.byokKey) } payload, err := NewRequestPayload([]byte(requestBody)) @@ -256,7 +269,7 @@ func TestBlockingResponsesInterceptor_KeyFailover(t *testing.T) { http.Header{}, "Authorization", otel.Tracer("blocking_test"), - intercept.NewCredentialInfo(intercept.CredentialKindCentralized, ""), + credInfo, ) interceptor.Setup(slog.Make(), &testutil.MockRecorder{}, nil) @@ -272,6 +285,7 @@ func TestBlockingResponsesInterceptor_KeyFailover(t *testing.T) { assert.Equal(t, tc.expectedRequestCount, requestCount.Load(), "upstream request count") assert.Equal(t, tc.expectedStatusCode, w.Code, "response status code") assert.Equal(t, tc.expectedRetryAfter, w.Header().Get("Retry-After"), "Retry-After header") + assert.Equal(t, tc.expectedCredentialHint, interceptor.Credential().Hint, "credential hint") if pool != nil { assert.Equal(t, tc.expectedKeyStates, pool.PoolState(), "key states") } @@ -296,6 +310,9 @@ func TestBlockingResponsesInterceptor_AgenticLoopFailover(t *testing.T) { expectedSeenKeys []string expectedStatusCode int expectedKeyStates []keypool.KeyState + // Expected credential hint after ProcessRequest: hint of the + // last attempted key across all agentic-loop iterations. + expectedCredentialHint string }{ { // Given: 2 keys; both upstream calls succeed on key-0. @@ -306,12 +323,13 @@ func TestBlockingResponsesInterceptor_AgenticLoopFailover(t *testing.T) { {statusCode: http.StatusOK, body: textCompleteBody}, }, expectedRequestCount: 2, - expectedSeenKeys: []string{"k0", "k0"}, + expectedSeenKeys: []string{"k0-long-key", "k0-long-key"}, expectedStatusCode: http.StatusOK, expectedKeyStates: []keypool.KeyState{ keypool.KeyStateValid, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k0-long-key"), }, { // Given: 2 keys; key-0 succeeds initially, then 429s @@ -329,12 +347,13 @@ func TestBlockingResponsesInterceptor_AgenticLoopFailover(t *testing.T) { {statusCode: http.StatusOK, body: textCompleteBody}, }, expectedRequestCount: 3, - expectedSeenKeys: []string{"k0", "k0", "k1"}, + expectedSeenKeys: []string{"k0-long-key", "k0-long-key", "k1-long-key"}, expectedStatusCode: http.StatusOK, expectedKeyStates: []keypool.KeyState{ keypool.KeyStateTemporary, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 2 keys; key-0 succeeds initially, then both @@ -356,12 +375,13 @@ func TestBlockingResponsesInterceptor_AgenticLoopFailover(t *testing.T) { }, }, expectedRequestCount: 3, - expectedSeenKeys: []string{"k0", "k0", "k1"}, + expectedSeenKeys: []string{"k0-long-key", "k0-long-key", "k1-long-key"}, expectedStatusCode: http.StatusTooManyRequests, expectedKeyStates: []keypool.KeyState{ keypool.KeyStateTemporary, keypool.KeyStateTemporary, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, } @@ -396,7 +416,7 @@ func TestBlockingResponsesInterceptor_AgenticLoopFailover(t *testing.T) { })) t.Cleanup(upstream.Close) - pool, err := keypool.New([]string{"k0", "k1"}, quartz.NewMock(t)) + pool, err := keypool.New([]string{"k0-long-key", "k1-long-key"}, quartz.NewMock(t)) require.NoError(t, err) cfg := config.OpenAI{ @@ -444,6 +464,7 @@ func TestBlockingResponsesInterceptor_AgenticLoopFailover(t *testing.T) { assert.Equal(t, tc.expectedRequestCount, requestCount.Load(), "upstream request count") assert.Equal(t, tc.expectedStatusCode, w.Code, "response status code") + assert.Equal(t, tc.expectedCredentialHint, interceptor.Credential().Hint, "credential hint") seenKeysMu.Lock() defer seenKeysMu.Unlock() diff --git a/aibridge/intercept/responses/streaming.go b/aibridge/intercept/responses/streaming.go index 2140c5e6c86..3b38b7a7e67 100644 --- a/aibridge/intercept/responses/streaming.go +++ b/aibridge/intercept/responses/streaming.go @@ -144,6 +144,11 @@ func (i *StreamingResponsesInterceptor) ProcessRequest(w http.ResponseWriter, r return xerrors.Errorf("key pool exhausted: %w", keyPoolErr) } currentKey = key + // Record the key in use so the hint reflects the last attempted key. + i.credential = intercept.NewCredentialInfo(intercept.CredentialKindCentralized, key.Value()) + i.logger.Debug(ctx, "using centralized api key", + slog.F("credential_hint", i.Credential().Hint), slog.F("credential_length", i.Credential().Length)) + opts = append(opts, option.WithAPIKey(key.Value()), // Disable SDK retries because the failover diff --git a/aibridge/intercept/responses/streaming_internal_test.go b/aibridge/intercept/responses/streaming_internal_test.go index 3226147cbd1..4f20d76c17a 100644 --- a/aibridge/intercept/responses/streaming_internal_test.go +++ b/aibridge/intercept/responses/streaming_internal_test.go @@ -51,36 +51,40 @@ func TestStreamingResponsesInterceptor_KeyFailover(t *testing.T) { expectedRetryAfter string // Expected key states after the request, by index in keys. expectedKeyStates []keypool.KeyState + // Expected credential hint after ProcessRequest: last + // attempted key for centralized, user key from initial request for BYOK. + expectedCredentialHint string }{ { // Given: 1 valid key returning a successful stream. // Then: 1 request, 200 response, key remains valid. name: "single_valid_key", - keys: []string{"k0"}, + keys: []string{"k0-long-key"}, responses: map[string]upstreamResponse{ - "k0": { + "k0-long-key": { statusCode: http.StatusOK, headers: map[string]string{"Content-Type": "text/event-stream"}, body: streamingSuccessBody, }, }, - expectedRequestCount: 1, - expectedStatusCode: http.StatusOK, - expectedKeyStates: []keypool.KeyState{keypool.KeyStateValid}, + expectedRequestCount: 1, + expectedStatusCode: http.StatusOK, + expectedKeyStates: []keypool.KeyState{keypool.KeyStateValid}, + expectedCredentialHint: utils.MaskSecret("k0-long-key"), }, { // Given: 2 keys; key-0 returns 429 pre-stream, key-1 // streams successfully. // Then: 2 requests, 200 response, key-0 temporary, key-1 valid. name: "failover_after_429", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": { + "k0-long-key": { statusCode: http.StatusTooManyRequests, headers: map[string]string{"Retry-After": "5"}, body: rateLimitBody, }, - "k1": { + "k1-long-key": { statusCode: http.StatusOK, headers: map[string]string{"Content-Type": "text/event-stream"}, body: streamingSuccessBody, @@ -92,16 +96,17 @@ func TestStreamingResponsesInterceptor_KeyFailover(t *testing.T) { keypool.KeyStateTemporary, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 2 keys; key-0 returns 401 pre-stream, key-1 // streams successfully. // Then: 2 requests, 200 response, key-0 permanent, key-1 valid. name: "failover_after_401", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": {statusCode: http.StatusUnauthorized, body: authErrorBody}, - "k1": { + "k0-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody}, + "k1-long-key": { statusCode: http.StatusOK, headers: map[string]string{"Content-Type": "text/event-stream"}, body: streamingSuccessBody, @@ -113,15 +118,16 @@ func TestStreamingResponsesInterceptor_KeyFailover(t *testing.T) { keypool.KeyStatePermanent, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 2 keys; key-0 returns 403 pre-stream, key-1 streams. // Then: 2 requests, 200 response, key-0 permanent, key-1 valid. name: "failover_after_403", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": {statusCode: http.StatusForbidden, body: authErrorBody}, - "k1": { + "k0-long-key": {statusCode: http.StatusForbidden, body: authErrorBody}, + "k1-long-key": { statusCode: http.StatusOK, headers: map[string]string{"Content-Type": "text/event-stream"}, body: streamingSuccessBody, @@ -133,6 +139,7 @@ func TestStreamingResponsesInterceptor_KeyFailover(t *testing.T) { keypool.KeyStatePermanent, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 3 keys; all return 429 pre-stream with @@ -140,19 +147,19 @@ func TestStreamingResponsesInterceptor_KeyFailover(t *testing.T) { // Then: 3 requests, 429 response with smallest // Retry-After, all keys temporary. name: "all_keys_rate_limited", - keys: []string{"k0", "k1", "k2"}, + keys: []string{"k0-long-key", "k1-long-key", "k2-long-key"}, responses: map[string]upstreamResponse{ - "k0": { + "k0-long-key": { statusCode: http.StatusTooManyRequests, headers: map[string]string{"Retry-After": "5"}, body: rateLimitBody, }, - "k1": { + "k1-long-key": { statusCode: http.StatusTooManyRequests, headers: map[string]string{"Retry-After": "3"}, body: rateLimitBody, }, - "k2": { + "k2-long-key": { statusCode: http.StatusTooManyRequests, headers: map[string]string{"Retry-After": "10"}, body: rateLimitBody, @@ -166,15 +173,16 @@ func TestStreamingResponsesInterceptor_KeyFailover(t *testing.T) { keypool.KeyStateTemporary, keypool.KeyStateTemporary, }, + expectedCredentialHint: utils.MaskSecret("k2-long-key"), }, { // Given: 2 keys; both return 401 pre-stream. // Then: 2 requests, 502 api_error response, both keys permanent. name: "all_keys_unauthorized", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": {statusCode: http.StatusUnauthorized, body: authErrorBody}, - "k1": {statusCode: http.StatusUnauthorized, body: authErrorBody}, + "k0-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody}, + "k1-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody}, }, expectedRequestCount: 2, expectedStatusCode: http.StatusBadGateway, @@ -182,14 +190,15 @@ func TestStreamingResponsesInterceptor_KeyFailover(t *testing.T) { keypool.KeyStatePermanent, keypool.KeyStatePermanent, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 2 keys; key-0 returns 500 pre-stream. // Then: 1 request, 500 response, both keys remain valid. name: "server_error_no_failover", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": {statusCode: http.StatusInternalServerError, body: serverErrorBody}, + "k0-long-key": {statusCode: http.StatusInternalServerError, body: serverErrorBody}, }, expectedRequestCount: 1, expectedStatusCode: http.StatusInternalServerError, @@ -197,6 +206,7 @@ func TestStreamingResponsesInterceptor_KeyFailover(t *testing.T) { keypool.KeyStateValid, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k0-long-key"), }, { // Given: BYOK with a single key returning 429. @@ -216,8 +226,9 @@ func TestStreamingResponsesInterceptor_KeyFailover(t *testing.T) { body: rateLimitBody, }, }, - expectedRequestCount: 1, - expectedStatusCode: http.StatusTooManyRequests, + expectedRequestCount: 1, + expectedStatusCode: http.StatusTooManyRequests, + expectedCredentialHint: utils.MaskSecret("user-byok"), }, } @@ -246,6 +257,7 @@ func TestStreamingResponsesInterceptor_KeyFailover(t *testing.T) { t.Cleanup(upstream.Close) cfg := config.OpenAI{BaseURL: upstream.URL + "/"} + credInfo := intercept.NewCredentialInfo(intercept.CredentialKindCentralized, "") var pool *keypool.Pool if len(tc.keys) > 0 { var err error @@ -254,6 +266,7 @@ func TestStreamingResponsesInterceptor_KeyFailover(t *testing.T) { cfg.KeyPool = pool } else if tc.byokKey != "" { cfg.Key = tc.byokKey + credInfo = intercept.NewCredentialInfo(intercept.CredentialKindBYOK, tc.byokKey) } payload, err := NewRequestPayload([]byte(streamingRequestBody)) @@ -267,7 +280,7 @@ func TestStreamingResponsesInterceptor_KeyFailover(t *testing.T) { http.Header{}, "Authorization", otel.Tracer("streaming_test"), - intercept.NewCredentialInfo(intercept.CredentialKindCentralized, ""), + credInfo, ) interceptor.Setup(slog.Make(), &testutil.MockRecorder{}, nil) @@ -283,6 +296,7 @@ func TestStreamingResponsesInterceptor_KeyFailover(t *testing.T) { assert.Equal(t, tc.expectedRequestCount, requestCount.Load(), "upstream request count") assert.Equal(t, tc.expectedStatusCode, w.Code, "response status code") assert.Equal(t, tc.expectedRetryAfter, w.Header().Get("Retry-After"), "Retry-After header") + assert.Equal(t, tc.expectedCredentialHint, interceptor.Credential().Hint, "credential hint") if pool != nil { assert.Equal(t, tc.expectedKeyStates, pool.PoolState(), "key states") } @@ -339,6 +353,9 @@ func TestStreamingResponsesInterceptor_AgenticLoopFailover(t *testing.T) { // error (e.g. all keys exhausted). expectedErr bool expectedKeyStates []keypool.KeyState + // Expected credential hint after ProcessRequest: hint of the + // last attempted key across all agentic-loop iterations. + expectedCredentialHint string }{ { // Given: 2 keys; both upstream calls succeed on key-0. @@ -349,12 +366,13 @@ func TestStreamingResponsesInterceptor_AgenticLoopFailover(t *testing.T) { {statusCode: http.StatusOK, headers: sseHeaders, body: textStreamBody}, }, expectedRequestCount: 2, - expectedSeenKeys: []string{"k0", "k0"}, + expectedSeenKeys: []string{"k0-long-key", "k0-long-key"}, expectedBodyContains: "done", expectedKeyStates: []keypool.KeyState{ keypool.KeyStateValid, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k0-long-key"), }, { // Given: 2 keys; key-0 succeeds initially, then 429s @@ -372,12 +390,13 @@ func TestStreamingResponsesInterceptor_AgenticLoopFailover(t *testing.T) { {statusCode: http.StatusOK, headers: sseHeaders, body: textStreamBody}, }, expectedRequestCount: 3, - expectedSeenKeys: []string{"k0", "k0", "k1"}, + expectedSeenKeys: []string{"k0-long-key", "k0-long-key", "k1-long-key"}, expectedBodyContains: "done", expectedKeyStates: []keypool.KeyState{ keypool.KeyStateTemporary, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 2 keys; key-0 succeeds initially, then both @@ -399,13 +418,14 @@ func TestStreamingResponsesInterceptor_AgenticLoopFailover(t *testing.T) { }, }, expectedRequestCount: 3, - expectedSeenKeys: []string{"k0", "k0", "k1"}, + expectedSeenKeys: []string{"k0-long-key", "k0-long-key", "k1-long-key"}, expectedBodyContains: "all configured keys are rate-limited", expectedErr: true, expectedKeyStates: []keypool.KeyState{ keypool.KeyStateTemporary, keypool.KeyStateTemporary, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, } @@ -439,7 +459,7 @@ func TestStreamingResponsesInterceptor_AgenticLoopFailover(t *testing.T) { })) t.Cleanup(upstream.Close) - pool, err := keypool.New([]string{"k0", "k1"}, quartz.NewMock(t)) + pool, err := keypool.New([]string{"k0-long-key", "k1-long-key"}, quartz.NewMock(t)) require.NoError(t, err) cfg := config.OpenAI{ @@ -489,6 +509,7 @@ func TestStreamingResponsesInterceptor_AgenticLoopFailover(t *testing.T) { assert.Equal(t, tc.expectedRequestCount, requestCount.Load(), "upstream request count") body := w.Body.String() assert.Contains(t, body, tc.expectedBodyContains, "response body") + assert.Equal(t, tc.expectedCredentialHint, interceptor.Credential().Hint, "credential hint") seenKeysMu.Lock() defer seenKeysMu.Unlock() diff --git a/aibridge/keypool/keymark.go b/aibridge/keypool/keymark.go index 9b00bb400ac..9dfedb3e444 100644 --- a/aibridge/keypool/keymark.go +++ b/aibridge/keypool/keymark.go @@ -5,7 +5,6 @@ import ( "net/http" "cdr.dev/slog/v3" - "github.com/coder/coder/v2/aibridge/utils" ) // MarkKeyOnStatus marks key based on a key-specific HTTP @@ -32,7 +31,7 @@ func MarkKeyOnStatus( if key.MarkTemporary(cooldown) { logger.Info(ctx, "key marked temporary", slog.F("provider", providerName), - slog.F("api_key_hint", utils.MaskSecret(key.Value())), + slog.F("api_key_hint", key.Hint()), slog.F("status", statusCode), slog.F("cooldown", cooldown)) } @@ -41,7 +40,7 @@ func MarkKeyOnStatus( if key.MarkPermanent() { logger.Warn(ctx, "key marked permanent", slog.F("provider", providerName), - slog.F("api_key_hint", utils.MaskSecret(key.Value())), + slog.F("api_key_hint", key.Hint()), slog.F("status", statusCode)) } return true diff --git a/aibridge/keypool/keypool.go b/aibridge/keypool/keypool.go index 55d1712a935..e28ae78325f 100644 --- a/aibridge/keypool/keypool.go +++ b/aibridge/keypool/keypool.go @@ -7,6 +7,7 @@ import ( "golang.org/x/xerrors" + "github.com/coder/coder/v2/aibridge/utils" "github.com/coder/quartz" ) @@ -116,6 +117,12 @@ func (k *Key) Value() string { return k.value } +// Hint returns a masked, identifiable fragment of the key, suitable +// for logs and persisted records. +func (k *Key) Hint() string { + return utils.MaskSecret(k.value) +} + // State returns the current state of the key, derived from its // permanent flag and cooldown deadline. func (k *Key) State() KeyState { diff --git a/aibridge/provider/anthropic.go b/aibridge/provider/anthropic.go index 664183f82fd..d053cce9032 100644 --- a/aibridge/provider/anthropic.go +++ b/aibridge/provider/anthropic.go @@ -170,15 +170,10 @@ func (p *Anthropic) CreateInterceptor(_ http.ResponseWriter, r *http.Request, tr authHeaderName = "Authorization" credKind = intercept.CredentialKindBYOK credSecret = token - } else if cfg.KeyPool != nil { - // Centralized: use the first key as a placeholder hint. - // TODO(ssncferreira): record the actually-used key in - // the interception record to reflect failover. - if key, keyPoolErr := cfg.KeyPool.Walker().Next(); keyPoolErr == nil { - credSecret = key.Value() - } } - + // Centralized leaves credSecret empty: the hint is set by the + // failover loop on each key attempt and persisted at + // end-of-interception. cred := intercept.NewCredentialInfo(credKind, credSecret) var interceptor intercept.Interceptor diff --git a/aibridge/provider/anthropic_internal_test.go b/aibridge/provider/anthropic_internal_test.go index b3d89556a89..815a83ba031 100644 --- a/aibridge/provider/anthropic_internal_test.go +++ b/aibridge/provider/anthropic_internal_test.go @@ -257,7 +257,9 @@ func TestAnthropic_CreateInterceptor_BYOK(t *testing.T) { setHeaders: map[string]string{}, wantXApiKey: "test-key", wantCredentialKind: intercept.CredentialKindCentralized, - wantCredentialHint: "t...y", + // Centralized hint is empty at CreateInterceptor; set + // by the key failover loop during ProcessRequest. + wantCredentialHint: "", }, { name: "Messages_BYOK_BearerToken_And_APIKey", diff --git a/aibridge/provider/openai.go b/aibridge/provider/openai.go index 7a78069df63..88020b7eb23 100644 --- a/aibridge/provider/openai.go +++ b/aibridge/provider/openai.go @@ -143,14 +143,10 @@ func (p *OpenAI) CreateInterceptor(_ http.ResponseWriter, r *http.Request, trace cfg.KeyPool = nil credKind = intercept.CredentialKindBYOK credSecret = token - } else if cfg.KeyPool != nil { - // Centralized: use the first key as a placeholder hint. - // TODO(ssncferreira): record the actually-used key in - // the interception record to reflect failover. - if key, keyPoolErr := cfg.KeyPool.Walker().Next(); keyPoolErr == nil { - credSecret = key.Value() - } } + // Centralized leaves credSecret empty: the hint is set by the + // failover loop on each key attempt and persisted at + // end-of-interception. cred := intercept.NewCredentialInfo(credKind, credSecret) path := strings.TrimPrefix(r.URL.Path, p.RoutePrefix()) diff --git a/aibridge/provider/openai_internal_test.go b/aibridge/provider/openai_internal_test.go index e1afcc872c4..1922d22c30d 100644 --- a/aibridge/provider/openai_internal_test.go +++ b/aibridge/provider/openai_internal_test.go @@ -229,7 +229,9 @@ func TestOpenAI_CreateInterceptor(t *testing.T) { setHeaders: map[string]string{}, wantAuthorization: "Bearer centralized-key", wantCredentialKind: intercept.CredentialKindCentralized, - wantCredentialHint: "ce...ey", + // Centralized hint is empty at CreateInterceptor; set + // by the key failover loop during ProcessRequest. + wantCredentialHint: "", }, { name: "Responses_BYOK", @@ -249,7 +251,9 @@ func TestOpenAI_CreateInterceptor(t *testing.T) { setHeaders: map[string]string{}, wantAuthorization: "Bearer centralized-key", wantCredentialKind: intercept.CredentialKindCentralized, - wantCredentialHint: "ce...ey", + // Centralized hint is empty at CreateInterceptor; set + // by the key failover loop during ProcessRequest. + wantCredentialHint: "", }, // X-Api-Key should not appear in production since clients use Authorization, // but ensure it is stripped if it does arrive. diff --git a/aibridge/recorder/types.go b/aibridge/recorder/types.go index cd541eebd4b..faa57139009 100644 --- a/aibridge/recorder/types.go +++ b/aibridge/recorder/types.go @@ -39,13 +39,20 @@ type InterceptionRecord struct { Client string UserAgent string CorrelatingToolCallID *string - CredentialKind string - CredentialHint string + // CredentialKind is always set: either BYOK or centralized. + CredentialKind string + // CredentialHint is only set for BYOK, where the key is known + // from the request. Centralized uses key failover, so the hint + // can only be determined at end-of-interception. + CredentialHint string } type InterceptionRecordEnded struct { ID string EndedAt time.Time + // CredentialHint is the hint observed at end-of-interception. + // Only applied to the DB row for centralized; ignored for BYOK. + CredentialHint string } type TokenUsageRecord struct { diff --git a/coderd/aibridged/proto/aibridged.pb.go b/coderd/aibridged/proto/aibridged.pb.go index c364aeda405..17fef851ea0 100644 --- a/coderd/aibridged/proto/aibridged.pb.go +++ b/coderd/aibridged/proto/aibridged.pb.go @@ -216,8 +216,9 @@ type RecordInterceptionEndedRequest struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` // UUID. - EndedAt *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=ended_at,json=endedAt,proto3" json:"ended_at,omitempty"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` // UUID. + EndedAt *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=ended_at,json=endedAt,proto3" json:"ended_at,omitempty"` + CredentialHint string `protobuf:"bytes,3,opt,name=credential_hint,json=credentialHint,proto3" json:"credential_hint,omitempty"` } func (x *RecordInterceptionEndedRequest) Reset() { @@ -266,6 +267,13 @@ func (x *RecordInterceptionEndedRequest) GetEndedAt() *timestamppb.Timestamp { return nil } +func (x *RecordInterceptionEndedRequest) GetCredentialHint() string { + if x != nil { + return x.CredentialHint + } + return "" +} + type RecordInterceptionEndedResponse struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -1295,249 +1303,252 @@ var file_coderd_aibridged_proto_aibridged_proto_rawDesc = []byte{ 0x42, 0x14, 0x0a, 0x12, 0x5f, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x5f, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x22, 0x1c, 0x0a, 0x1a, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, - 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x67, 0x0a, 0x1e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, - 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x65, 0x64, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x35, 0x0a, 0x08, 0x65, 0x6e, 0x64, 0x65, 0x64, 0x5f, - 0x61, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, - 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, - 0x74, 0x61, 0x6d, 0x70, 0x52, 0x07, 0x65, 0x6e, 0x64, 0x65, 0x64, 0x41, 0x74, 0x22, 0x21, 0x0a, - 0x1f, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, - 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x65, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, - 0x22, 0xe9, 0x03, 0x0a, 0x17, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6b, 0x65, 0x6e, - 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x27, 0x0a, 0x0f, - 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, - 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x15, 0x0a, 0x06, 0x6d, 0x73, 0x67, 0x5f, 0x69, 0x64, 0x18, - 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6d, 0x73, 0x67, 0x49, 0x64, 0x12, 0x21, 0x0a, 0x0c, - 0x69, 0x6e, 0x70, 0x75, 0x74, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x03, 0x20, 0x01, - 0x28, 0x03, 0x52, 0x0b, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x12, - 0x23, 0x0a, 0x0d, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, - 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x54, 0x6f, - 0x6b, 0x65, 0x6e, 0x73, 0x12, 0x48, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, - 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2c, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, + 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x90, 0x01, 0x0a, 0x1e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, + 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x65, 0x64, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x35, 0x0a, 0x08, 0x65, 0x6e, 0x64, 0x65, 0x64, + 0x5f, 0x61, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, + 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, + 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x07, 0x65, 0x6e, 0x64, 0x65, 0x64, 0x41, 0x74, 0x12, 0x27, + 0x0a, 0x0f, 0x63, 0x72, 0x65, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x61, 0x6c, 0x5f, 0x68, 0x69, 0x6e, + 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x63, 0x72, 0x65, 0x64, 0x65, 0x6e, 0x74, + 0x69, 0x61, 0x6c, 0x48, 0x69, 0x6e, 0x74, 0x22, 0x21, 0x0a, 0x1f, 0x52, 0x65, 0x63, 0x6f, 0x72, + 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, + 0x65, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0xe9, 0x03, 0x0a, 0x17, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, - 0x6e, 0x74, 0x72, 0x79, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x39, - 0x0a, 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x06, 0x20, 0x01, - 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, - 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, - 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x35, 0x0a, 0x17, 0x63, 0x61, 0x63, - 0x68, 0x65, 0x5f, 0x72, 0x65, 0x61, 0x64, 0x5f, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x5f, 0x74, 0x6f, - 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x07, 0x20, 0x01, 0x28, 0x03, 0x52, 0x14, 0x63, 0x61, 0x63, 0x68, - 0x65, 0x52, 0x65, 0x61, 0x64, 0x49, 0x6e, 0x70, 0x75, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, - 0x12, 0x37, 0x0a, 0x18, 0x63, 0x61, 0x63, 0x68, 0x65, 0x5f, 0x77, 0x72, 0x69, 0x74, 0x65, 0x5f, - 0x69, 0x6e, 0x70, 0x75, 0x74, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x08, 0x20, 0x01, - 0x28, 0x03, 0x52, 0x15, 0x63, 0x61, 0x63, 0x68, 0x65, 0x57, 0x72, 0x69, 0x74, 0x65, 0x49, 0x6e, - 0x70, 0x75, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x1a, 0x51, 0x0a, 0x0d, 0x4d, 0x65, 0x74, - 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, - 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x2a, 0x0a, 0x05, - 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x67, 0x6f, - 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x41, 0x6e, - 0x79, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x1a, 0x0a, 0x18, - 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55, 0x73, 0x61, 0x67, 0x65, - 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0xcb, 0x02, 0x0a, 0x18, 0x52, 0x65, 0x63, - 0x6f, 0x72, 0x64, 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x27, 0x0a, 0x0f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, - 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, - 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x15, - 0x0a, 0x06, 0x6d, 0x73, 0x67, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, - 0x6d, 0x73, 0x67, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x70, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x18, - 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x70, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x12, 0x49, 0x0a, - 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, - 0x2d, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x50, 0x72, - 0x6f, 0x6d, 0x70, 0x74, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x27, 0x0a, 0x0f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, + 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x0e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, + 0x15, 0x0a, 0x06, 0x6d, 0x73, 0x67, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x05, 0x6d, 0x73, 0x67, 0x49, 0x64, 0x12, 0x21, 0x0a, 0x0c, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x5f, + 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x69, 0x6e, + 0x70, 0x75, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x12, 0x23, 0x0a, 0x0d, 0x6f, 0x75, 0x74, + 0x70, 0x75, 0x74, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, + 0x52, 0x0c, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x12, 0x48, + 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, + 0x32, 0x2c, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, + 0x6f, 0x6b, 0x65, 0x6e, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x39, 0x0a, 0x0a, 0x63, 0x72, 0x65, 0x61, - 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, + 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, - 0x64, 0x41, 0x74, 0x1a, 0x51, 0x0a, 0x0d, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, + 0x64, 0x41, 0x74, 0x12, 0x35, 0x0a, 0x17, 0x63, 0x61, 0x63, 0x68, 0x65, 0x5f, 0x72, 0x65, 0x61, + 0x64, 0x5f, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x07, + 0x20, 0x01, 0x28, 0x03, 0x52, 0x14, 0x63, 0x61, 0x63, 0x68, 0x65, 0x52, 0x65, 0x61, 0x64, 0x49, + 0x6e, 0x70, 0x75, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x12, 0x37, 0x0a, 0x18, 0x63, 0x61, + 0x63, 0x68, 0x65, 0x5f, 0x77, 0x72, 0x69, 0x74, 0x65, 0x5f, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x5f, + 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x08, 0x20, 0x01, 0x28, 0x03, 0x52, 0x15, 0x63, 0x61, + 0x63, 0x68, 0x65, 0x57, 0x72, 0x69, 0x74, 0x65, 0x49, 0x6e, 0x70, 0x75, 0x74, 0x54, 0x6f, 0x6b, + 0x65, 0x6e, 0x73, 0x1a, 0x51, 0x0a, 0x0d, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x2a, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x41, 0x6e, 0x79, 0x52, 0x05, 0x76, 0x61, 0x6c, - 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x1b, 0x0a, 0x19, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, - 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, - 0x6e, 0x73, 0x65, 0x22, 0x8f, 0x04, 0x0a, 0x16, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, - 0x6f, 0x6c, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x27, - 0x0a, 0x0f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x69, - 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, - 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x15, 0x0a, 0x06, 0x6d, 0x73, 0x67, 0x5f, 0x69, - 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6d, 0x73, 0x67, 0x49, 0x64, 0x12, 0x22, - 0x0a, 0x0a, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x03, 0x20, 0x01, - 0x28, 0x09, 0x48, 0x00, 0x52, 0x09, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x55, 0x72, 0x6c, 0x88, - 0x01, 0x01, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x6f, 0x6f, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x04, 0x74, 0x6f, 0x6f, 0x6c, 0x12, 0x14, 0x0a, 0x05, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x18, - 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x12, 0x1a, 0x0a, 0x08, - 0x69, 0x6e, 0x6a, 0x65, 0x63, 0x74, 0x65, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, - 0x69, 0x6e, 0x6a, 0x65, 0x63, 0x74, 0x65, 0x64, 0x12, 0x2e, 0x0a, 0x10, 0x69, 0x6e, 0x76, 0x6f, - 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x07, 0x20, 0x01, - 0x28, 0x09, 0x48, 0x01, 0x52, 0x0f, 0x69, 0x6e, 0x76, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, - 0x45, 0x72, 0x72, 0x6f, 0x72, 0x88, 0x01, 0x01, 0x12, 0x47, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, - 0x64, 0x61, 0x74, 0x61, 0x18, 0x08, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2b, 0x2e, 0x70, 0x72, 0x6f, - 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6f, 0x6c, 0x55, 0x73, 0x61, - 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, - 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, - 0x61, 0x12, 0x39, 0x0a, 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, - 0x09, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, - 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, - 0x70, 0x52, 0x09, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x20, 0x0a, 0x0c, - 0x74, 0x6f, 0x6f, 0x6c, 0x5f, 0x63, 0x61, 0x6c, 0x6c, 0x5f, 0x69, 0x64, 0x18, 0x0a, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x0a, 0x74, 0x6f, 0x6f, 0x6c, 0x43, 0x61, 0x6c, 0x6c, 0x49, 0x64, 0x1a, 0x51, - 0x0a, 0x0d, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, - 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, - 0x79, 0x12, 0x2a, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, - 0x32, 0x14, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, - 0x75, 0x66, 0x2e, 0x41, 0x6e, 0x79, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, - 0x01, 0x42, 0x0d, 0x0a, 0x0b, 0x5f, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x5f, 0x75, 0x72, 0x6c, - 0x42, 0x13, 0x0a, 0x11, 0x5f, 0x69, 0x6e, 0x76, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, - 0x65, 0x72, 0x72, 0x6f, 0x72, 0x22, 0x19, 0x0a, 0x17, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, - 0x6f, 0x6f, 0x6c, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, - 0x22, 0xb8, 0x02, 0x0a, 0x19, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4d, 0x6f, 0x64, 0x65, 0x6c, - 0x54, 0x68, 0x6f, 0x75, 0x67, 0x68, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x27, - 0x0a, 0x0f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x69, - 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, - 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, - 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, - 0x74, 0x12, 0x4a, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x03, 0x20, - 0x03, 0x28, 0x0b, 0x32, 0x2e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, - 0x72, 0x64, 0x4d, 0x6f, 0x64, 0x65, 0x6c, 0x54, 0x68, 0x6f, 0x75, 0x67, 0x68, 0x74, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, - 0x74, 0x72, 0x79, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x39, 0x0a, - 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, - 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, - 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x63, - 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x1a, 0x51, 0x0a, 0x0d, 0x4d, 0x65, 0x74, 0x61, + 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x1a, 0x0a, 0x18, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, + 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x22, 0xcb, 0x02, 0x0a, 0x18, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x50, 0x72, 0x6f, + 0x6d, 0x70, 0x74, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, + 0x27, 0x0a, 0x0f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x5f, + 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, + 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x15, 0x0a, 0x06, 0x6d, 0x73, 0x67, 0x5f, + 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6d, 0x73, 0x67, 0x49, 0x64, 0x12, + 0x16, 0x0a, 0x06, 0x70, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x06, 0x70, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x12, 0x49, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, + 0x61, 0x74, 0x61, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2d, 0x2e, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x55, 0x73, + 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, + 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, + 0x74, 0x61, 0x12, 0x39, 0x0a, 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, + 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, + 0x6d, 0x70, 0x52, 0x09, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x1a, 0x51, 0x0a, + 0x0d, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, + 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, + 0x12, 0x2a, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, + 0x14, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, + 0x66, 0x2e, 0x41, 0x6e, 0x79, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, + 0x22, 0x1b, 0x0a, 0x19, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, + 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x8f, 0x04, + 0x0a, 0x16, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6f, 0x6c, 0x55, 0x73, 0x61, 0x67, + 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x27, 0x0a, 0x0f, 0x69, 0x6e, 0x74, 0x65, + 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x0e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x49, + 0x64, 0x12, 0x15, 0x0a, 0x06, 0x6d, 0x73, 0x67, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x05, 0x6d, 0x73, 0x67, 0x49, 0x64, 0x12, 0x22, 0x0a, 0x0a, 0x73, 0x65, 0x72, 0x76, + 0x65, 0x72, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x09, + 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x55, 0x72, 0x6c, 0x88, 0x01, 0x01, 0x12, 0x12, 0x0a, 0x04, + 0x74, 0x6f, 0x6f, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, 0x6f, 0x6f, 0x6c, + 0x12, 0x14, 0x0a, 0x05, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x05, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x69, 0x6e, 0x6a, 0x65, 0x63, 0x74, + 0x65, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x69, 0x6e, 0x6a, 0x65, 0x63, 0x74, + 0x65, 0x64, 0x12, 0x2e, 0x0a, 0x10, 0x69, 0x6e, 0x76, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, + 0x5f, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x48, 0x01, 0x52, 0x0f, + 0x69, 0x6e, 0x76, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x88, + 0x01, 0x01, 0x12, 0x47, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x08, + 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2b, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, + 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6f, 0x6c, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, + 0x79, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x39, 0x0a, 0x0a, 0x63, + 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x09, 0x20, 0x01, 0x28, 0x0b, 0x32, + 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, + 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x63, 0x72, 0x65, + 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x20, 0x0a, 0x0c, 0x74, 0x6f, 0x6f, 0x6c, 0x5f, 0x63, + 0x61, 0x6c, 0x6c, 0x5f, 0x69, 0x64, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x74, 0x6f, + 0x6f, 0x6c, 0x43, 0x61, 0x6c, 0x6c, 0x49, 0x64, 0x1a, 0x51, 0x0a, 0x0d, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x2a, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x41, 0x6e, 0x79, - 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x1c, 0x0a, 0x1a, 0x52, + 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x42, 0x0d, 0x0a, 0x0b, 0x5f, + 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x5f, 0x75, 0x72, 0x6c, 0x42, 0x13, 0x0a, 0x11, 0x5f, 0x69, + 0x6e, 0x76, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x22, + 0x19, 0x0a, 0x17, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6f, 0x6c, 0x55, 0x73, 0x61, + 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0xb8, 0x02, 0x0a, 0x19, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4d, 0x6f, 0x64, 0x65, 0x6c, 0x54, 0x68, 0x6f, 0x75, 0x67, 0x68, - 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x35, 0x0a, 0x1a, 0x47, 0x65, 0x74, - 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, - 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, - 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, - 0x22, 0xb2, 0x01, 0x0a, 0x1b, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, - 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, - 0x12, 0x40, 0x0a, 0x10, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x5f, 0x6d, 0x63, 0x70, 0x5f, 0x63, 0x6f, - 0x6e, 0x66, 0x69, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x70, 0x72, 0x6f, - 0x74, 0x6f, 0x2e, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, - 0x69, 0x67, 0x52, 0x0e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x4d, 0x63, 0x70, 0x43, 0x6f, 0x6e, 0x66, - 0x69, 0x67, 0x12, 0x51, 0x0a, 0x19, 0x65, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x5f, 0x61, - 0x75, 0x74, 0x68, 0x5f, 0x6d, 0x63, 0x70, 0x5f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x18, - 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x4d, 0x43, - 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x16, 0x65, - 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x41, 0x75, 0x74, 0x68, 0x4d, 0x63, 0x70, 0x43, 0x6f, - 0x6e, 0x66, 0x69, 0x67, 0x73, 0x22, 0x85, 0x01, 0x0a, 0x0f, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, - 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x12, 0x28, 0x0a, 0x10, 0x74, - 0x6f, 0x6f, 0x6c, 0x5f, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x5f, 0x72, 0x65, 0x67, 0x65, 0x78, 0x18, - 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x74, 0x6f, 0x6f, 0x6c, 0x41, 0x6c, 0x6c, 0x6f, 0x77, - 0x52, 0x65, 0x67, 0x65, 0x78, 0x12, 0x26, 0x0a, 0x0f, 0x74, 0x6f, 0x6f, 0x6c, 0x5f, 0x64, 0x65, - 0x6e, 0x79, 0x5f, 0x72, 0x65, 0x67, 0x65, 0x78, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, - 0x74, 0x6f, 0x6f, 0x6c, 0x44, 0x65, 0x6e, 0x79, 0x52, 0x65, 0x67, 0x65, 0x78, 0x22, 0x72, 0x0a, - 0x24, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x63, 0x63, - 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x31, - 0x0a, 0x15, 0x6d, 0x63, 0x70, 0x5f, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x5f, 0x63, 0x6f, 0x6e, - 0x66, 0x69, 0x67, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x12, 0x6d, - 0x63, 0x70, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x49, 0x64, - 0x73, 0x22, 0xda, 0x02, 0x0a, 0x25, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, - 0x65, 0x72, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x42, 0x61, - 0x74, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x63, 0x0a, 0x0d, 0x61, - 0x63, 0x63, 0x65, 0x73, 0x73, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x01, 0x20, 0x03, - 0x28, 0x0b, 0x32, 0x3e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x43, - 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, - 0x65, 0x6e, 0x73, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, - 0x2e, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x45, 0x6e, 0x74, - 0x72, 0x79, 0x52, 0x0c, 0x61, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, - 0x12, 0x50, 0x0a, 0x06, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, - 0x32, 0x38, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, - 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, - 0x73, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x45, - 0x72, 0x72, 0x6f, 0x72, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x06, 0x65, 0x72, 0x72, 0x6f, - 0x72, 0x73, 0x1a, 0x3f, 0x0a, 0x11, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, - 0x6e, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, - 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, - 0x02, 0x38, 0x01, 0x1a, 0x39, 0x0a, 0x0b, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x73, 0x45, 0x6e, 0x74, - 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x3e, - 0x0a, 0x13, 0x49, 0x73, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x15, 0x0a, 0x06, 0x6b, 0x65, 0x79, 0x5f, 0x69, - 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6b, 0x65, 0x79, 0x49, 0x64, 0x22, 0x6b, - 0x0a, 0x14, 0x49, 0x73, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x52, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x19, 0x0a, 0x08, 0x6f, 0x77, 0x6e, 0x65, 0x72, 0x5f, - 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6f, 0x77, 0x6e, 0x65, 0x72, 0x49, - 0x64, 0x12, 0x1c, 0x0a, 0x0a, 0x61, 0x70, 0x69, 0x5f, 0x6b, 0x65, 0x79, 0x5f, 0x69, 0x64, 0x18, - 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x61, 0x70, 0x69, 0x4b, 0x65, 0x79, 0x49, 0x64, 0x12, - 0x1a, 0x0a, 0x08, 0x75, 0x73, 0x65, 0x72, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x08, 0x75, 0x73, 0x65, 0x72, 0x6e, 0x61, 0x6d, 0x65, 0x32, 0xa9, 0x04, 0x0a, 0x08, - 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x12, 0x59, 0x0a, 0x12, 0x52, 0x65, 0x63, 0x6f, - 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x20, - 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, - 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, - 0x1a, 0x21, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, - 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, - 0x6e, 0x73, 0x65, 0x12, 0x68, 0x0a, 0x17, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, - 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x65, 0x64, 0x12, 0x25, - 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, - 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x65, 0x64, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, - 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, - 0x45, 0x6e, 0x64, 0x65, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x53, 0x0a, - 0x10, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55, 0x73, 0x61, 0x67, - 0x65, 0x12, 0x1e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, - 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x1a, 0x1f, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, - 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, - 0x73, 0x65, 0x12, 0x56, 0x0a, 0x11, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x50, 0x72, 0x6f, 0x6d, - 0x70, 0x74, 0x55, 0x73, 0x61, 0x67, 0x65, 0x12, 0x1f, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, - 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x55, 0x73, 0x61, 0x67, - 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, - 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x55, 0x73, 0x61, - 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x50, 0x0a, 0x0f, 0x52, 0x65, - 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6f, 0x6c, 0x55, 0x73, 0x61, 0x67, 0x65, 0x12, 0x1d, 0x2e, - 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6f, 0x6c, - 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1e, 0x2e, 0x70, - 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6f, 0x6c, 0x55, - 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x59, 0x0a, 0x12, - 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4d, 0x6f, 0x64, 0x65, 0x6c, 0x54, 0x68, 0x6f, 0x75, 0x67, - 0x68, 0x74, 0x12, 0x20, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, - 0x64, 0x4d, 0x6f, 0x64, 0x65, 0x6c, 0x54, 0x68, 0x6f, 0x75, 0x67, 0x68, 0x74, 0x52, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, - 0x6f, 0x72, 0x64, 0x4d, 0x6f, 0x64, 0x65, 0x6c, 0x54, 0x68, 0x6f, 0x75, 0x67, 0x68, 0x74, 0x52, - 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, 0xeb, 0x01, 0x0a, 0x0f, 0x4d, 0x43, 0x50, 0x43, - 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x75, 0x72, 0x61, 0x74, 0x6f, 0x72, 0x12, 0x5c, 0x0a, 0x13, 0x47, + 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x27, 0x0a, 0x0f, 0x69, 0x6e, 0x74, 0x65, + 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x0e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x49, + 0x64, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x12, 0x4a, 0x0a, 0x08, 0x6d, + 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2e, 0x2e, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4d, 0x6f, 0x64, 0x65, + 0x6c, 0x54, 0x68, 0x6f, 0x75, 0x67, 0x68, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, + 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x08, 0x6d, + 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x39, 0x0a, 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, + 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, + 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, + 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, + 0x41, 0x74, 0x1a, 0x51, 0x0a, 0x0d, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, + 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x2a, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x41, 0x6e, 0x79, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, + 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x1c, 0x0a, 0x1a, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4d, + 0x6f, 0x64, 0x65, 0x6c, 0x54, 0x68, 0x6f, 0x75, 0x67, 0x68, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x22, 0x35, 0x0a, 0x1a, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, + 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x22, 0xb2, 0x01, 0x0a, 0x1b, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, - 0x67, 0x73, 0x12, 0x21, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x43, - 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x22, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, - 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, - 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x7a, 0x0a, 0x1d, 0x47, 0x65, 0x74, - 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, - 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x42, 0x61, 0x74, 0x63, 0x68, 0x12, 0x2b, 0x2e, 0x70, 0x72, 0x6f, - 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, - 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x42, 0x61, 0x74, 0x63, 0x68, - 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2c, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, + 0x67, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x40, 0x0a, 0x10, 0x63, 0x6f, + 0x64, 0x65, 0x72, 0x5f, 0x6d, 0x63, 0x70, 0x5f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x4d, 0x43, 0x50, + 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0e, 0x63, 0x6f, + 0x64, 0x65, 0x72, 0x4d, 0x63, 0x70, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x51, 0x0a, 0x19, + 0x65, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x5f, 0x61, 0x75, 0x74, 0x68, 0x5f, 0x6d, 0x63, + 0x70, 0x5f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, + 0x16, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, + 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x16, 0x65, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, + 0x6c, 0x41, 0x75, 0x74, 0x68, 0x4d, 0x63, 0x70, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x22, + 0x85, 0x01, 0x0a, 0x0f, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, + 0x66, 0x69, 0x67, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x02, 0x69, 0x64, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x03, 0x75, 0x72, 0x6c, 0x12, 0x28, 0x0a, 0x10, 0x74, 0x6f, 0x6f, 0x6c, 0x5f, 0x61, 0x6c, + 0x6c, 0x6f, 0x77, 0x5f, 0x72, 0x65, 0x67, 0x65, 0x78, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x0e, 0x74, 0x6f, 0x6f, 0x6c, 0x41, 0x6c, 0x6c, 0x6f, 0x77, 0x52, 0x65, 0x67, 0x65, 0x78, 0x12, + 0x26, 0x0a, 0x0f, 0x74, 0x6f, 0x6f, 0x6c, 0x5f, 0x64, 0x65, 0x6e, 0x79, 0x5f, 0x72, 0x65, 0x67, + 0x65, 0x78, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x74, 0x6f, 0x6f, 0x6c, 0x44, 0x65, + 0x6e, 0x79, 0x52, 0x65, 0x67, 0x65, 0x78, 0x22, 0x72, 0x0a, 0x24, 0x47, 0x65, 0x74, 0x4d, 0x43, + 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, + 0x65, 0x6e, 0x73, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, + 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x31, 0x0a, 0x15, 0x6d, 0x63, 0x70, 0x5f, + 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x5f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x5f, 0x69, 0x64, + 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x12, 0x6d, 0x63, 0x70, 0x53, 0x65, 0x72, 0x76, + 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x49, 0x64, 0x73, 0x22, 0xda, 0x02, 0x0a, 0x25, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x73, - 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, 0x55, 0x0a, 0x0a, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, - 0x7a, 0x65, 0x72, 0x12, 0x47, 0x0a, 0x0c, 0x49, 0x73, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, - 0x7a, 0x65, 0x64, 0x12, 0x1a, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x49, 0x73, 0x41, 0x75, - 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, - 0x1b, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x49, 0x73, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, - 0x69, 0x7a, 0x65, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x32, 0x5a, 0x30, - 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, - 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x76, 0x32, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x64, - 0x2f, 0x61, 0x69, 0x62, 0x72, 0x69, 0x64, 0x67, 0x65, 0x64, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, - 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x63, 0x0a, 0x0d, 0x61, 0x63, 0x63, 0x65, 0x73, 0x73, 0x5f, + 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x3e, 0x2e, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, + 0x72, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x42, 0x61, 0x74, + 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x41, 0x63, 0x63, 0x65, 0x73, + 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x0c, 0x61, 0x63, + 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x12, 0x50, 0x0a, 0x06, 0x65, 0x72, + 0x72, 0x6f, 0x72, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x38, 0x2e, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, + 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x42, 0x61, 0x74, 0x63, 0x68, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x73, 0x45, + 0x6e, 0x74, 0x72, 0x79, 0x52, 0x06, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x73, 0x1a, 0x3f, 0x0a, 0x11, + 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x45, 0x6e, 0x74, 0x72, + 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, + 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x1a, 0x39, 0x0a, + 0x0b, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, + 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, + 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, + 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x3e, 0x0a, 0x13, 0x49, 0x73, 0x41, 0x75, + 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, + 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, + 0x79, 0x12, 0x15, 0x0a, 0x06, 0x6b, 0x65, 0x79, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x05, 0x6b, 0x65, 0x79, 0x49, 0x64, 0x22, 0x6b, 0x0a, 0x14, 0x49, 0x73, 0x41, 0x75, + 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, + 0x12, 0x19, 0x0a, 0x08, 0x6f, 0x77, 0x6e, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x07, 0x6f, 0x77, 0x6e, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1c, 0x0a, 0x0a, 0x61, + 0x70, 0x69, 0x5f, 0x6b, 0x65, 0x79, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x08, 0x61, 0x70, 0x69, 0x4b, 0x65, 0x79, 0x49, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x75, 0x73, 0x65, + 0x72, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x75, 0x73, 0x65, + 0x72, 0x6e, 0x61, 0x6d, 0x65, 0x32, 0xa9, 0x04, 0x0a, 0x08, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, + 0x65, 0x72, 0x12, 0x59, 0x0a, 0x12, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, + 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x20, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, + 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, + 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x68, 0x0a, + 0x17, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, + 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x65, 0x64, 0x12, 0x25, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, + 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x65, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, + 0x26, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, + 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x65, 0x64, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x53, 0x0a, 0x10, 0x52, 0x65, 0x63, 0x6f, 0x72, + 0x64, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55, 0x73, 0x61, 0x67, 0x65, 0x12, 0x1e, 0x2e, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55, + 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1f, 0x2e, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55, + 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x56, 0x0a, 0x11, + 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x55, 0x73, 0x61, 0x67, + 0x65, 0x12, 0x1f, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, + 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, + 0x64, 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x50, 0x0a, 0x0f, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, + 0x6f, 0x6c, 0x55, 0x73, 0x61, 0x67, 0x65, 0x12, 0x1d, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, + 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6f, 0x6c, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, + 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6f, 0x6c, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x59, 0x0a, 0x12, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, + 0x4d, 0x6f, 0x64, 0x65, 0x6c, 0x54, 0x68, 0x6f, 0x75, 0x67, 0x68, 0x74, 0x12, 0x20, 0x2e, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4d, 0x6f, 0x64, 0x65, 0x6c, + 0x54, 0x68, 0x6f, 0x75, 0x67, 0x68, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, + 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4d, 0x6f, 0x64, + 0x65, 0x6c, 0x54, 0x68, 0x6f, 0x75, 0x67, 0x68, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x32, 0xeb, 0x01, 0x0a, 0x0f, 0x4d, 0x43, 0x50, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x75, + 0x72, 0x61, 0x74, 0x6f, 0x72, 0x12, 0x5c, 0x0a, 0x13, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, + 0x65, 0x72, 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x12, 0x21, 0x2e, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, + 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, + 0x22, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, + 0x72, 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x12, 0x7a, 0x0a, 0x1d, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, + 0x76, 0x65, 0x72, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x42, + 0x61, 0x74, 0x63, 0x68, 0x12, 0x2b, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, + 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, + 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x1a, 0x2c, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, + 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, + 0x6e, 0x73, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, + 0x55, 0x0a, 0x0a, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x12, 0x47, 0x0a, + 0x0c, 0x49, 0x73, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x12, 0x1a, 0x2e, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x49, 0x73, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, + 0x65, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x2e, 0x49, 0x73, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x32, 0x5a, 0x30, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, + 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, + 0x2f, 0x76, 0x32, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x64, 0x2f, 0x61, 0x69, 0x62, 0x72, 0x69, + 0x64, 0x67, 0x65, 0x64, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x33, } var ( diff --git a/coderd/aibridged/proto/aibridged.proto b/coderd/aibridged/proto/aibridged.proto index cd614115168..08fceee676d 100644 --- a/coderd/aibridged/proto/aibridged.proto +++ b/coderd/aibridged/proto/aibridged.proto @@ -58,6 +58,7 @@ message RecordInterceptionResponse {} message RecordInterceptionEndedRequest { string id = 1; // UUID. google.protobuf.Timestamp ended_at = 2; + string credential_hint = 3; } message RecordInterceptionEndedResponse {} diff --git a/coderd/aibridged/translator.go b/coderd/aibridged/translator.go index 2769ef0d89e..6d251df0fee 100644 --- a/coderd/aibridged/translator.go +++ b/coderd/aibridged/translator.go @@ -45,8 +45,9 @@ func (t *recorderTranslation) RecordInterception(ctx context.Context, req *aibri func (t *recorderTranslation) RecordInterceptionEnded(ctx context.Context, req *aibridge.InterceptionRecordEnded) error { _, err := t.client.RecordInterceptionEnded(ctx, &proto.RecordInterceptionEndedRequest{ - Id: req.ID, - EndedAt: timestamppb.New(req.EndedAt), + Id: req.ID, + EndedAt: timestamppb.New(req.EndedAt), + CredentialHint: req.CredentialHint, }) return err } diff --git a/coderd/aibridgedserver/aibridgedserver.go b/coderd/aibridgedserver/aibridgedserver.go index c593b18f798..8dbaa10bfa4 100644 --- a/coderd/aibridgedserver/aibridgedserver.go +++ b/coderd/aibridgedserver/aibridgedserver.go @@ -222,8 +222,9 @@ func (s *Server) RecordInterceptionEnded(ctx context.Context, in *proto.RecordIn } _, err = s.store.UpdateAIBridgeInterceptionEnded(ctx, database.UpdateAIBridgeInterceptionEndedParams{ - ID: intcID, - EndedAt: in.EndedAt.AsTime(), + ID: intcID, + EndedAt: in.EndedAt.AsTime(), + CredentialHint: in.CredentialHint, }) if err != nil { return nil, xerrors.Errorf("end interception: %w", err) diff --git a/coderd/aibridgedserver/aibridgedserver_test.go b/coderd/aibridgedserver/aibridgedserver_test.go index eb2f413e1ed..9aeb082069c 100644 --- a/coderd/aibridgedserver/aibridgedserver_test.go +++ b/coderd/aibridgedserver/aibridgedserver_test.go @@ -944,23 +944,26 @@ func TestRecordInterceptionEnded(t *testing.T) { { name: "ok", request: &proto.RecordInterceptionEndedRequest{ - Id: uuid.UUID{1}.String(), - EndedAt: timestamppb.Now(), + Id: uuid.UUID{1}.String(), + EndedAt: timestamppb.Now(), + CredentialHint: "sk-a...efgh", }, setupMocks: func(t *testing.T, db *dbmock.MockStore, req *proto.RecordInterceptionEndedRequest) { interceptionID, err := uuid.Parse(req.GetId()) assert.NoError(t, err, "parse interception UUID") db.EXPECT().UpdateAIBridgeInterceptionEnded(gomock.Any(), database.UpdateAIBridgeInterceptionEndedParams{ - ID: interceptionID, - EndedAt: req.EndedAt.AsTime(), + ID: interceptionID, + EndedAt: req.EndedAt.AsTime(), + CredentialHint: req.CredentialHint, }).Return(database.AIBridgeInterception{ - ID: interceptionID, - InitiatorID: uuid.UUID{2}, - Provider: "prov", - Model: "mod", - StartedAt: time.Now(), - EndedAt: sql.NullTime{Time: req.EndedAt.AsTime(), Valid: true}, + ID: interceptionID, + InitiatorID: uuid.UUID{2}, + Provider: "prov", + Model: "mod", + StartedAt: time.Now(), + EndedAt: sql.NullTime{Time: req.EndedAt.AsTime(), Valid: true}, + CredentialHint: req.CredentialHint, }, nil) }, }, diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index 834bad62749..a1d4db40179 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -1969,8 +1969,9 @@ func AIBridgeInterception(t testing.TB, db database.Store, seed database.InsertA }) if endedAt != nil { interception, err = db.UpdateAIBridgeInterceptionEnded(genCtx, database.UpdateAIBridgeInterceptionEndedParams{ - ID: interception.ID, - EndedAt: *endedAt, + ID: interception.ID, + EndedAt: *endedAt, + CredentialHint: takeFirst(seed.CredentialHint, ""), }) require.NoError(t, err, "insert aibridge interception") } diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index f181e2e94bc..cefe6a866e2 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -9921,8 +9921,9 @@ func TestUpdateAIBridgeInterceptionEnded(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) got, err := db.UpdateAIBridgeInterceptionEnded(ctx, database.UpdateAIBridgeInterceptionEndedParams{ - ID: uuid.New(), - EndedAt: time.Now(), + ID: uuid.New(), + EndedAt: time.Now(), + CredentialHint: "sk-a...efgh", }) require.ErrorContains(t, err, "no rows in result set") require.EqualValues(t, database.AIBridgeInterception{}, got) @@ -9957,18 +9958,21 @@ func TestUpdateAIBridgeInterceptionEnded(t *testing.T) { endedAt := time.Now() // Mark first interception as done updated, err := db.UpdateAIBridgeInterceptionEnded(ctx, database.UpdateAIBridgeInterceptionEndedParams{ - ID: intc0.ID, - EndedAt: endedAt, + ID: intc0.ID, + EndedAt: endedAt, + CredentialHint: "sk-a...efgh", }) require.NoError(t, err) require.EqualValues(t, updated.ID, intc0.ID) require.True(t, updated.EndedAt.Valid) require.WithinDuration(t, endedAt, updated.EndedAt.Time, 5*time.Second) + require.Equal(t, "sk-a...efgh", updated.CredentialHint) // Updating first interception again should fail updated, err = db.UpdateAIBridgeInterceptionEnded(ctx, database.UpdateAIBridgeInterceptionEndedParams{ - ID: intc0.ID, - EndedAt: endedAt.Add(time.Hour), + ID: intc0.ID, + EndedAt: endedAt.Add(time.Hour), + CredentialHint: "sk-a...efgh", }) require.ErrorIs(t, err, sql.ErrNoRows) @@ -9979,6 +9983,52 @@ func TestUpdateAIBridgeInterceptionEnded(t *testing.T) { require.False(t, got.EndedAt.Valid) } }) + + t.Run("CentralizedHintUpdated", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + user := dbgen.User(t, db, database.User{}) + intc, err := db.InsertAIBridgeInterception(ctx, database.InsertAIBridgeInterceptionParams{ + ID: uuid.New(), + InitiatorID: user.ID, + Metadata: json.RawMessage("{}"), + CredentialKind: database.CredentialKindCentralized, + CredentialHint: "", + }) + require.NoError(t, err) + + updated, err := db.UpdateAIBridgeInterceptionEnded(ctx, database.UpdateAIBridgeInterceptionEndedParams{ + ID: intc.ID, + EndedAt: time.Now(), + CredentialHint: "sk-a...efgh", + }) + require.NoError(t, err) + require.Equal(t, "sk-a...efgh", updated.CredentialHint) + }) + + t.Run("BYOKHintPreserved", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + user := dbgen.User(t, db, database.User{}) + intc, err := db.InsertAIBridgeInterception(ctx, database.InsertAIBridgeInterceptionParams{ + ID: uuid.New(), + InitiatorID: user.ID, + Metadata: json.RawMessage("{}"), + CredentialKind: database.CredentialKindByok, + CredentialHint: "sk-u...byok", + }) + require.NoError(t, err) + + updated, err := db.UpdateAIBridgeInterceptionEnded(ctx, database.UpdateAIBridgeInterceptionEndedParams{ + ID: intc.ID, + EndedAt: time.Now(), + CredentialHint: "sk-a...efgh", + }) + require.NoError(t, err) + require.Equal(t, "sk-u...byok", updated.CredentialHint) + }) } func TestDeleteExpiredAPIKeys(t *testing.T) { diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 2ba4b923def..4a52bb85852 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -2389,20 +2389,28 @@ func (q *sqlQuerier) ListAIBridgeUserPromptsByInterceptionIDs(ctx context.Contex const updateAIBridgeInterceptionEnded = `-- name: UpdateAIBridgeInterceptionEnded :one UPDATE aibridge_interceptions - SET ended_at = $1::timestamptz + SET ended_at = $1::timestamptz, + -- BYOK records its hint at the start of the interception. + -- Centralized uses key failover, so its hint is only known + -- at end-of-interception. + credential_hint = CASE + WHEN credential_kind = 'centralized' THEN $2::text + ELSE credential_hint + END WHERE - id = $2::uuid + id = $3::uuid AND ended_at IS NULL RETURNING id, initiator_id, provider, model, started_at, metadata, ended_at, api_key_id, client, thread_parent_id, thread_root_id, client_session_id, session_id, provider_name, credential_kind, credential_hint ` type UpdateAIBridgeInterceptionEndedParams struct { - EndedAt time.Time `db:"ended_at" json:"ended_at"` - ID uuid.UUID `db:"id" json:"id"` + EndedAt time.Time `db:"ended_at" json:"ended_at"` + CredentialHint string `db:"credential_hint" json:"credential_hint"` + ID uuid.UUID `db:"id" json:"id"` } func (q *sqlQuerier) UpdateAIBridgeInterceptionEnded(ctx context.Context, arg UpdateAIBridgeInterceptionEndedParams) (AIBridgeInterception, error) { - row := q.db.QueryRowContext(ctx, updateAIBridgeInterceptionEnded, arg.EndedAt, arg.ID) + row := q.db.QueryRowContext(ctx, updateAIBridgeInterceptionEnded, arg.EndedAt, arg.CredentialHint, arg.ID) var i AIBridgeInterception err := row.Scan( &i.ID, diff --git a/coderd/database/queries/aibridge.sql b/coderd/database/queries/aibridge.sql index 7756c7086b4..a1b49d25cd4 100644 --- a/coderd/database/queries/aibridge.sql +++ b/coderd/database/queries/aibridge.sql @@ -8,7 +8,14 @@ RETURNING *; -- name: UpdateAIBridgeInterceptionEnded :one UPDATE aibridge_interceptions - SET ended_at = @ended_at::timestamptz + SET ended_at = @ended_at::timestamptz, + -- BYOK records its hint at the start of the interception. + -- Centralized uses key failover, so its hint is only known + -- at end-of-interception. + credential_hint = CASE + WHEN credential_kind = 'centralized' THEN @credential_hint::text + ELSE credential_hint + END WHERE id = @id::uuid AND ended_at IS NULL From 476ed480d1543d10ce32ced6065fa39670a3e5bd Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 29 May 2026 13:00:45 -0400 Subject: [PATCH 011/138] fix(coderd): block ai provider env key drift (#25849) (#25851) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cherry-pick of https://github.com/coder/coder/pull/25849 Original PR: #25849 — fix(coderd): block ai provider env key drift Merge commit: 110210d7c96dbd5c843bd7d817104d521ea38407 Requested by: @dannykopping Co-authored-by: Danny Kopping --- coderd/ai_providers_migrate.go | 50 +++++++++-- coderd/ai_providers_migrate_test.go | 132 +++++++++++++++++++++++----- 2 files changed, 156 insertions(+), 26 deletions(-) diff --git a/coderd/ai_providers_migrate.go b/coderd/ai_providers_migrate.go index 6bc99ad8408..055877ecce9 100644 --- a/coderd/ai_providers_migrate.go +++ b/coderd/ai_providers_migrate.go @@ -116,10 +116,21 @@ func SeedAIProvidersFromEnv( if err != nil { return xerrors.Errorf("decode existing settings for %q: %w", dp.Name, err) } + // Load existing bearer keys so the canonical hash + // includes credentials for comparison. + existingKeyRows, err := tx.GetAIProviderKeysByProviderID(sysCtx, existing.ID) + if err != nil { + return xerrors.Errorf("load existing keys for %q: %w", dp.Name, err) + } + existingKeys := make([]string, 0, len(existingKeyRows)) + for _, k := range existingKeyRows { + existingKeys = append(existingKeys, k.APIKey) + } existingDP := desiredAIProvider{ Type: existing.Type, BaseURL: existing.BaseUrl, Bedrock: existingSettings.Bedrock, + Keys: existingKeys, } existingHash := computeProviderHash(existingDP.canonical()) if existingHash == dp.Hash { @@ -196,18 +207,15 @@ func SeedAIProvidersFromEnv( // canonicalAIProvider is the shape we hash to detect drift between the // configured environment and the row stored in the database. The fields // we hash are exactly the operator-controllable inputs that affect -// runtime behavior. Credentials are intentionally NOT part of the hash -// so operators can rotate them via the API without forcing a server -// restart. This applies to both bearer API keys (stored in -// ai_provider_keys) and to Bedrock access key/secret pairs (stored in -// the settings blob because Bedrock authenticates via settings rather -// than a bearer token). +// runtime behavior, including credentials. +// // Model and SmallFastModel are excluded: they're tunables, and their // serpent defaults shift across releases. type canonicalAIProvider struct { Type string `json:"type"` BaseURL string `json:"base_url"` BedrockRegion string `json:"bedrock_region"` + KeysHash string `json:"keys_hash"` } // desiredAIProvider is a normalized provider description sourced from @@ -235,9 +243,39 @@ func (d desiredAIProvider) canonical() canonicalAIProvider { if d.Bedrock != nil { c.BedrockRegion = d.Bedrock.Region } + c.KeysHash = computeKeysHash(d.Keys, d.Bedrock) return c } +// computeKeysHash produces a deterministic hash over the bearer API +// keys and, for Bedrock providers, the access key and secret. +func computeKeysHash(bearerKeys []string, bedrock *codersdk.AIProviderBedrockSettings) string { + // Collect all credential material in a deterministic order. + // Bearer keys are sorted so reordering in env vars does not + // trigger a false-positive drift. + sorted := make([]string, len(bearerKeys)) + copy(sorted, bearerKeys) + slices.Sort(sorted) + + h := sha256.New() + for _, k := range sorted { + _, _ = h.Write([]byte(k)) + // Separator so "ab"+"c" != "a"+"bc". + _, _ = h.Write([]byte{0}) + } + if bedrock != nil { + if bedrock.AccessKey != nil { + _, _ = h.Write([]byte(*bedrock.AccessKey)) + } + _, _ = h.Write([]byte{0}) + if bedrock.AccessKeySecret != nil { + _, _ = h.Write([]byte(*bedrock.AccessKeySecret)) + } + _, _ = h.Write([]byte{0}) + } + return hex.EncodeToString(h.Sum(nil)) +} + func computeProviderHash(c canonicalAIProvider) string { // json.Marshal is deterministic for structs because field order is // fixed by the struct definition. diff --git a/coderd/ai_providers_migrate_test.go b/coderd/ai_providers_migrate_test.go index d4a07bfdc28..89165002b0d 100644 --- a/coderd/ai_providers_migrate_test.go +++ b/coderd/ai_providers_migrate_test.go @@ -91,21 +91,23 @@ func TestSeedAIProvidersFromEnv(t *testing.T) { } require.NoError(t, coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t))) - // Changing the API key alone does NOT count as drift: keys - // live in a separate table and operators rotate them via the - // API. Only changes to non-credential provider-level fields - // (base_url, type, Bedrock region/model) trip the drift check. + // Changing the API key counts as drift: keys are included + // in the canonical hash so operators notice when env-var + // credential changes are ignored by an existing provider. cfg.LegacyOpenAI.Key = serpent.String("sk-rotated") - require.NoError(t, coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t))) + err := coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t)) + require.Error(t, err) + require.Contains(t, err.Error(), "differs from the current environment configuration") - // Changing the base URL is real drift. + // Changing the base URL is also real drift. + cfg.LegacyOpenAI.Key = serpent.String("sk-original") cfg.LegacyOpenAI.BaseURL = serpent.String("https://api.openai.com/v2") - err := coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t)) + err = coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t)) require.Error(t, err) require.Contains(t, err.Error(), "differs from the current environment configuration") }) - t.Run("BedrockCredentialRotationIsNotDrift", func(t *testing.T) { + t.Run("BedrockCredentialChangeIsDrift", func(t *testing.T) { t.Parallel() db, _ := dbtestutil.NewDB(t) ctx := testutil.Context(t, testutil.WaitShort) @@ -120,17 +122,20 @@ func TestSeedAIProvidersFromEnv(t *testing.T) { } require.NoError(t, coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t))) - // Rotating the Bedrock access key and secret in env must NOT - // trip the drift check: they're credentials, equivalent to - // bearer API keys, and operators rotate them via the API. + // Rotating the Bedrock access key in env trips the drift + // check so operators know the change did not take effect. cfg.LegacyBedrock.AccessKey = serpent.String("AKIA-rotated") cfg.LegacyBedrock.AccessKeySecret = serpent.String("secret-rotated") - require.NoError(t, coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t))) + err := coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t)) + require.Error(t, err) + require.Contains(t, err.Error(), "differs from the current environment configuration") // Changing the Bedrock region (a non-credential field) is - // real drift. + // also real drift. + cfg.LegacyBedrock.AccessKey = serpent.String("AKIA-original") + cfg.LegacyBedrock.AccessKeySecret = serpent.String("secret-original") cfg.LegacyBedrock.Region = serpent.String("us-west-2") - err := coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t)) + err = coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t)) require.Error(t, err) require.Contains(t, err.Error(), "differs from the current environment configuration") }) @@ -293,6 +298,57 @@ func TestSeedAIProvidersFromEnv(t *testing.T) { require.Equal(t, "sk-ant-1", anKeys[0].APIKey) }) + t.Run("IndexedProvidersKeyDriftWithMultipleKeysAndProviders", func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitShort) + + cfg := codersdk.AIBridgeConfig{ + Providers: []codersdk.AIProviderConfig{ + { + Type: "openai", + Name: "primary-openai", + BaseURL: "https://api.openai.com/v1", + Keys: []string{"sk-openai-1", "sk-openai-2"}, + }, + { + Type: "anthropic", + Name: "primary-anthropic", + BaseURL: "https://api.anthropic.com/", + Keys: []string{"sk-ant-1", "sk-ant-2"}, + }, + }, + } + require.NoError(t, coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t))) + + // Reordering keys must not count as drift. The canonical hash + // sorts keys before hashing, so equivalent key sets remain + // stable across restarts. + cfg.Providers[0].Keys = []string{"sk-openai-2", "sk-openai-1"} + cfg.Providers[1].Keys = []string{"sk-ant-2", "sk-ant-1"} + require.NoError(t, coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t))) + + // Changing one key on one provider must block startup even + // when multiple providers are configured. + cfg.Providers[1].Keys = []string{"sk-ant-2", "sk-ant-rotated"} + err := coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t)) + require.Error(t, err) + require.Contains(t, err.Error(), "differs from the current environment configuration") + require.Contains(t, err.Error(), `"primary-anthropic"`) + + oa, err := db.GetAIProviderByName(ctx, "primary-openai") + require.NoError(t, err) + oaKeys, err := db.GetAIProviderKeysByProviderID(ctx, oa.ID) + require.NoError(t, err) + require.ElementsMatch(t, []string{"sk-openai-1", "sk-openai-2"}, []string{oaKeys[0].APIKey, oaKeys[1].APIKey}) + + an, err := db.GetAIProviderByName(ctx, "primary-anthropic") + require.NoError(t, err) + anKeys, err := db.GetAIProviderKeysByProviderID(ctx, an.ID) + require.NoError(t, err) + require.ElementsMatch(t, []string{"sk-ant-1", "sk-ant-2"}, []string{anKeys[0].APIKey, anKeys[1].APIKey}) + }) + t.Run("BedrockIndexedProviderHasNoKeys", func(t *testing.T) { t.Parallel() db, _ := dbtestutil.NewDB(t) @@ -424,7 +480,7 @@ func TestSeedAIProvidersFromEnv(t *testing.T) { require.Empty(t, all, "expected no active rows after soft-delete + re-seed") }) - t.Run("ExistingKeysArePreserved", func(t *testing.T) { + t.Run("ExistingKeysBlockOnDrift", func(t *testing.T) { t.Parallel() db, _ := dbtestutil.NewDB(t) ctx := testutil.Context(t, testutil.WaitShort) @@ -440,15 +496,17 @@ func TestSeedAIProvidersFromEnv(t *testing.T) { row, err := db.GetAIProviderByName(ctx, "openai") require.NoError(t, err) - // Operator rotates the env key. The seed must not duplicate - // keys on a row that already exists; the new key is only - // installed via the API/CRUD layer in this flow. + // Operator rotates the env key. The seed now blocks startup + // because the keys differ, alerting the operator. cfg.LegacyOpenAI.Key = serpent.String("sk-rotated") - require.NoError(t, coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t))) + err = coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t)) + require.Error(t, err) + require.Contains(t, err.Error(), "differs from the current environment configuration") + // The original key is still in the database. keys, err := db.GetAIProviderKeysByProviderID(ctx, row.ID) require.NoError(t, err) - require.Len(t, keys, 1, "env reseed must not duplicate keys on existing rows") + require.Len(t, keys, 1) require.Equal(t, "sk-original", keys[0].APIKey) }) @@ -482,6 +540,40 @@ func TestSeedAIProvidersFromEnv(t *testing.T) { require.Len(t, all, 1, "duplicate indexed entries with matching hash must produce a single row") }) + t.Run("IndexedDuplicateNameMatchingHashDedupesReorderedKeys", func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitShort) + + // Key order should not affect the canonical hash. Reordered + // duplicates under the same name should still dedupe. + cfg := codersdk.AIBridgeConfig{ + Providers: []codersdk.AIProviderConfig{ + { + Type: "openai", + Name: "shared", + BaseURL: "https://api.openai.com/v1", + Keys: []string{"sk-1", "sk-2"}, + }, + { + Type: "openai", + Name: "shared", + BaseURL: "https://api.openai.com/v1", + Keys: []string{"sk-2", "sk-1"}, + }, + }, + } + require.NoError(t, coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t))) + + all, err := db.GetAIProviders(ctx, database.GetAIProvidersParams{}) + require.NoError(t, err) + require.Len(t, all, 1) + keys, err := db.GetAIProviderKeysByProviderID(ctx, all[0].ID) + require.NoError(t, err) + require.Len(t, keys, 2) + require.ElementsMatch(t, []string{"sk-1", "sk-2"}, []string{keys[0].APIKey, keys[1].APIKey}) + }) + t.Run("IndexedDuplicateNameMismatchingHashFails", func(t *testing.T) { t.Parallel() db, _ := dbtestutil.NewDB(t) From 804bb3c0cfa660885d392164d2427487fe953679 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 29 May 2026 18:06:06 +0100 Subject: [PATCH 012/138] fix(coderd): enforce api_key_id on user messages at type level (#25729) (#25797) - Empty string is valid for `apiKeyID` in paths that genuinely lack a caller key (e.g. agent-initiated context injection in `workspaceAgentAddChatContext`). AI Gateway fail-closed check remains the runtime safety net. - Context injection paths (`persistInstructionFiles`, compaction) read the key from `aibridge.DelegatedAPIKeyIDFromContext(ctx)`, set upstream by `contextWithActiveTurnAPIKeyID`. - Subagent context copy branches on `copiedRole == database.ChatMessageRoleUser` to choose the right append function. > Generated by Coder Agents (cherry picked from commit b278be7361616516a2189ce86717556e5e696a86) --- coderd/workspaceagents.go | 4 +- coderd/x/chatd/chatd.go | 161 ++++++++++++------ coderd/x/chatd/chatd_internal_test.go | 76 ++++++++- coderd/x/chatd/subagent.go | 37 ++-- .../x/chatd/subagent_context_internal_test.go | 24 +++ 5 files changed, 239 insertions(+), 63 deletions(-) diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index b3074740b18..097d4ff41b7 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -2562,9 +2562,9 @@ func (api *API) workspaceAgentAddChatContext(rw http.ResponseWriter, r *http.Req if locked.OwnerID != workspace.OwnerID { return errChatDoesNotBelongToWorkspaceOwner } - if _, err := tx.InsertChatMessages(sysCtx, chatd.BuildSingleChatMessageInsertParams( + if _, err := tx.InsertChatMessages(sysCtx, chatd.BuildSingleUserChatMessageInsertParams( chat.ID, - database.ChatMessageRoleUser, + "", // Agent-initiated context injection has no caller API key. content, database.ChatMessageVisibilityBoth, locked.LastModelConfigID, diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index debee591e8f..013567e3e0a 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -1629,7 +1629,7 @@ func (p *Server) CreateChat(ctx context.Context, opts CreateOptions) (database.C return xerrors.Errorf("marshal initial user content: %w", err) } - msgParams := database.InsertChatMessagesParams{ //nolint:exhaustruct // Fields populated by appendChatMessage. + msgParams := database.InsertChatMessagesParams{ //nolint:exhaustruct // Fields populated by append[User]ChatMessage. ChatID: insertedChat.ID, } @@ -1673,13 +1673,15 @@ func (p *Server) CreateChat(ctx context.Context, opts CreateOptions) (database.C chatprompt.CurrentContentVersion, )) - appendChatMessage(&msgParams, newChatMessage( - database.ChatMessageRoleUser, + userMsg := newUserChatMessage( + opts.APIKeyID, userContent, database.ChatMessageVisibilityBoth, opts.ModelConfigID, chatprompt.CurrentContentVersion, - ).withCreatedBy(opts.OwnerID).withAPIKeyID(opts.APIKeyID)) + ) + userMsg = userMsg.withCreatedBy(opts.OwnerID) + appendUserChatMessage(&msgParams, userMsg) _, err = tx.InsertChatMessages(ctx, msgParams) if err != nil { @@ -2111,16 +2113,18 @@ func (p *Server) EditMessage( // InsertChatMessages CTE updates chats.last_model_config_id // when the new message's model differs, so the assistant turn // that follows picks up the new selection. - msgParams := database.InsertChatMessagesParams{ //nolint:exhaustruct // Fields populated by appendChatMessage. + msgParams := database.InsertChatMessagesParams{ //nolint:exhaustruct // Fields populated by appendUserChatMessage. ChatID: opts.ChatID, } - appendChatMessage(&msgParams, newChatMessage( - database.ChatMessageRoleUser, + editUserMsg := newUserChatMessage( + opts.APIKeyID, content, editedMsg.Visibility, messageModelConfigID, chatprompt.CurrentContentVersion, - ).withCreatedBy(opts.CreatedBy).withAPIKeyID(opts.APIKeyID)) + ) + editUserMsg = editUserMsg.withCreatedBy(opts.CreatedBy) + appendUserChatMessage(&msgParams, editUserMsg) newMessages, err := insertChatMessageWithStore(ctx, tx, msgParams) if err != nil { return xerrors.Errorf("insert replacement message: %w", err) @@ -3899,19 +3903,16 @@ func insertChatMessageWithStore( return messages, nil } -// chatMessage describes a single message to insert as part of a batch. -// Use newChatMessage to create one, then chain builder methods for -// optional fields. For nullable UUID fields (ModelConfigID, CreatedBy), -// use uuid.Nil to represent NULL — the SQL uses NULLIF to convert zero -// UUIDs to NULL. For nullable int64 fields, use 0 to represent NULL — -// the SQL uses NULLIF to convert zeros to NULL. +// chatMessage is the base message type for batch inserts. Use directly +// only for non-user messages; for user messages, use userChatMessage. +// For nullable UUID fields (ModelConfigID, CreatedBy), use uuid.Nil to +// represent NULL. For nullable int64 fields, use 0 to represent NULL. type chatMessage struct { role database.ChatMessageRole content pqtype.NullRawMessage visibility database.ChatMessageVisibility modelConfigID uuid.UUID createdBy uuid.UUID - apiKeyID string contentVersion int16 compressed bool inputTokens int64 @@ -3926,6 +3927,23 @@ type chatMessage struct { providerResponseID string } +// userChatMessage wraps chatMessage with a required apiKeyID so that +// omitting it for user messages is a compile error, not a silent data bug. +type userChatMessage struct { + chatMessage + apiKeyID string +} + +func (m userChatMessage) withCreatedBy(id uuid.UUID) userChatMessage { + m.chatMessage = m.chatMessage.withCreatedBy(id) + return m +} + +func (m userChatMessage) withCompressed() userChatMessage { + m.chatMessage = m.chatMessage.withCompressed() + return m +} + func newChatMessage( role database.ChatMessageRole, content pqtype.NullRawMessage, @@ -3942,13 +3960,29 @@ func newChatMessage( } } -func (m chatMessage) withCreatedBy(id uuid.UUID) chatMessage { - m.createdBy = id - return m +// newUserChatMessage creates a user message. apiKeyID is required so +// that forgetting it is a compile error rather than a silent data bug. +func newUserChatMessage( + apiKeyID string, + content pqtype.NullRawMessage, + visibility database.ChatMessageVisibility, + modelConfigID uuid.UUID, + contentVersion int16, +) userChatMessage { + return userChatMessage{ + chatMessage: newChatMessage( + database.ChatMessageRoleUser, + content, + visibility, + modelConfigID, + contentVersion, + ), + apiKeyID: apiKeyID, + } } -func (m chatMessage) withAPIKeyID(id string) chatMessage { - m.apiKeyID = id +func (m chatMessage) withCreatedBy(id uuid.UUID) chatMessage { + m.createdBy = id return m } @@ -3990,13 +4024,16 @@ func (m chatMessage) withProviderResponseID(id string) chatMessage { return m } -// appendChatMessage appends a single message to the batch insert params. -func appendChatMessage( +// appendMessageFields writes all chatMessage fields into the batch insert +// params. apiKeyID is explicit so non-user messages always get "" while +// user messages carry the caller's key for AI Gateway routing. +func appendMessageFields( params *database.InsertChatMessagesParams, msg chatMessage, + apiKeyID string, ) { params.CreatedBy = append(params.CreatedBy, msg.createdBy) - params.APIKeyID = append(params.APIKeyID, msg.apiKeyID) + params.APIKeyID = append(params.APIKeyID, apiKeyID) params.ModelConfigID = append(params.ModelConfigID, msg.modelConfigID) params.Role = append(params.Role, msg.role) params.Content = append(params.Content, string(msg.content.RawMessage)) @@ -4015,25 +4052,44 @@ func appendChatMessage( params.ProviderResponseID = append(params.ProviderResponseID, msg.providerResponseID) } -// BuildSingleChatMessageInsertParams creates batch insert params for one -// message using the shared chat message builder. -func BuildSingleChatMessageInsertParams( +// appendChatMessage appends a non-user message to the batch insert params. +func appendChatMessage( + params *database.InsertChatMessagesParams, + msg chatMessage, +) { + if msg.role == database.ChatMessageRoleUser { + panic("developer error: use appendUserChatMessage for user-role messages") + } + appendMessageFields(params, msg, "") +} + +// appendUserChatMessage inserts a user message with its apiKeyID preserved. +func appendUserChatMessage( + params *database.InsertChatMessagesParams, + msg userChatMessage, +) { + appendMessageFields(params, msg.chatMessage, msg.apiKeyID) +} + +// BuildSingleUserChatMessageInsertParams creates batch insert params for +// one user message, requiring an apiKeyID for AI Gateway attribution. +func BuildSingleUserChatMessageInsertParams( chatID uuid.UUID, - role database.ChatMessageRole, + apiKeyID string, content pqtype.NullRawMessage, visibility database.ChatMessageVisibility, modelConfigID uuid.UUID, contentVersion int16, createdBy uuid.UUID, ) database.InsertChatMessagesParams { - params := database.InsertChatMessagesParams{ //nolint:exhaustruct // Fields populated by appendChatMessage. + params := database.InsertChatMessagesParams{ //nolint:exhaustruct // Fields populated by appendUserChatMessage. ChatID: chatID, } - msg := newChatMessage(role, content, visibility, modelConfigID, contentVersion) + msg := newUserChatMessage(apiKeyID, content, visibility, modelConfigID, contentVersion) if createdBy != uuid.Nil { msg = msg.withCreatedBy(createdBy) } - appendChatMessage(¶ms, msg) + appendUserChatMessage(¶ms, msg) return params } @@ -4048,16 +4104,18 @@ func insertUserMessageAndSetPending( createdBy uuid.UUID, apiKeyID string, ) (database.ChatMessage, database.Chat, error) { - msgParams := database.InsertChatMessagesParams{ //nolint:exhaustruct // Fields populated by appendChatMessage. + msgParams := database.InsertChatMessagesParams{ //nolint:exhaustruct // Fields populated by appendUserChatMessage. ChatID: lockedChat.ID, } - appendChatMessage(&msgParams, newChatMessage( - database.ChatMessageRoleUser, + insertUserMsg := newUserChatMessage( + apiKeyID, content, database.ChatMessageVisibilityBoth, modelConfigID, chatprompt.CurrentContentVersion, - ).withCreatedBy(createdBy).withAPIKeyID(apiKeyID)) + ) + insertUserMsg = insertUserMsg.withCreatedBy(createdBy) + appendUserChatMessage(&msgParams, insertUserMsg) messages, err := insertChatMessageWithStore(ctx, store, msgParams) if err != nil { return database.ChatMessage{}, database.Chat{}, err @@ -5870,11 +5928,11 @@ func (p *Server) tryAutoPromoteQueuedMessage( return nil, nil, false, xerrors.New("popped queued message out of order") } - msgParams := database.InsertChatMessagesParams{ //nolint:exhaustruct // Fields populated by appendChatMessage. + msgParams := database.InsertChatMessagesParams{ //nolint:exhaustruct // Fields populated by appendUserChatMessage. ChatID: chat.ID, } - appendChatMessage(&msgParams, newChatMessage( - database.ChatMessageRoleUser, + queuedUserMsg := newUserChatMessage( + nextQueued.APIKeyID.String, pqtype.NullRawMessage{ RawMessage: nextQueued.Content, Valid: len(nextQueued.Content) > 0, @@ -5882,7 +5940,9 @@ func (p *Server) tryAutoPromoteQueuedMessage( database.ChatMessageVisibilityBoth, effectiveModelConfigID, chatprompt.CurrentContentVersion, - ).withCreatedBy(chat.OwnerID).withAPIKeyID(nextQueued.APIKeyID.String)) + ) + queuedUserMsg = queuedUserMsg.withCreatedBy(chat.OwnerID) + appendUserChatMessage(&msgParams, queuedUserMsg) msgs, err := insertChatMessageWithStore(ctx, tx, msgParams) if err != nil { return nil, nil, false, xerrors.Errorf("insert promoted message: %w", err) @@ -8457,18 +8517,21 @@ func (p *Server) persistChatContextSummary( var insertedMessages []database.ChatMessage txErr := p.db.InTx(func(tx database.Store) error { - summaryParams := database.InsertChatMessagesParams{ //nolint:exhaustruct // Fields populated by appendChatMessage. + summaryParams := database.InsertChatMessagesParams{ //nolint:exhaustruct // Fields populated by append[User]ChatMessage. ChatID: chatID, } // Hidden summary user message (not published to subscribers). - appendChatMessage(&summaryParams, newChatMessage( - database.ChatMessageRoleUser, + summaryAPIKeyID, _ := aibridge.DelegatedAPIKeyIDFromContext(ctx) + summaryUserMsg := newUserChatMessage( + summaryAPIKeyID, systemContent, database.ChatMessageVisibilityModel, modelConfigID, chatprompt.CurrentContentVersion, - ).withCompressed()) + ) + summaryUserMsg = summaryUserMsg.withCompressed() + appendUserChatMessage(&summaryParams, summaryUserMsg) // Assistant tool-call message. appendChatMessage(&summaryParams, newChatMessage( @@ -8999,11 +9062,12 @@ func (p *Server) persistInstructionFiles( if err != nil { return "", nil, nil } - msgParams := database.InsertChatMessagesParams{ //nolint:exhaustruct // Fields populated by appendChatMessage. + contextAPIKeyID, _ := aibridge.DelegatedAPIKeyIDFromContext(ctx) + msgParams := database.InsertChatMessagesParams{ //nolint:exhaustruct // Fields populated by appendUserChatMessage. ChatID: chat.ID, } - appendChatMessage(&msgParams, newChatMessage( - database.ChatMessageRoleUser, + appendUserChatMessage(&msgParams, newUserChatMessage( + contextAPIKeyID, content, database.ChatMessageVisibilityBoth, modelConfigID, @@ -9022,11 +9086,12 @@ func (p *Server) persistInstructionFiles( return "", nil, xerrors.Errorf("marshal context-file parts: %w", err) } - msgParams := database.InsertChatMessagesParams{ //nolint:exhaustruct // Fields populated by appendChatMessage. + contextAPIKeyID, _ := aibridge.DelegatedAPIKeyIDFromContext(ctx) + msgParams := database.InsertChatMessagesParams{ //nolint:exhaustruct // Fields populated by appendUserChatMessage. ChatID: chat.ID, } - appendChatMessage(&msgParams, newChatMessage( - database.ChatMessageRoleUser, + appendUserChatMessage(&msgParams, newUserChatMessage( + contextAPIKeyID, content, database.ChatMessageVisibilityBoth, modelConfigID, diff --git a/coderd/x/chatd/chatd_internal_test.go b/coderd/x/chatd/chatd_internal_test.go index b28c0cdb0c8..69aebbfe30e 100644 --- a/coderd/x/chatd/chatd_internal_test.go +++ b/coderd/x/chatd/chatd_internal_test.go @@ -19,9 +19,12 @@ import ( "cdr.dev/slog/v3" "cdr.dev/slog/v3/sloggers/slogtest" + "github.com/coder/coder/v2/coderd/aibridge" "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" dbpubsub "github.com/coder/coder/v2/coderd/database/pubsub" coderdpubsub "github.com/coder/coder/v2/coderd/pubsub" "github.com/coder/coder/v2/coderd/rbac" @@ -1339,6 +1342,8 @@ func TestPersistInstructionFilesIncludesAgentMetadata(t *testing.T) { t.Parallel() ctx := context.Background() + testAPIKeyID := uuid.NewString() + ctx = aibridge.WithDelegatedAPIKeyID(ctx, testAPIKeyID) ctrl := gomock.NewController(t) db := dbmock.NewMockStore(ctrl) @@ -1366,7 +1371,18 @@ func TestPersistInstructionFilesIncludesAgentMetadata(t *testing.T) { gomock.Any(), agentID, ).Return(workspaceAgent, nil).Times(1) - db.EXPECT().InsertChatMessages(gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes() + db.EXPECT().InsertChatMessages(gomock.Any(), gomock.Cond(func(x any) bool { + params, ok := x.(database.InsertChatMessagesParams) + if !ok { + return false + } + for i, role := range params.Role { + if role == database.ChatMessageRoleUser && params.APIKeyID[i] != testAPIKeyID { + return false + } + } + return true + })).Return(nil, nil).AnyTimes() db.EXPECT().UpdateChatLastInjectedContext(gomock.Any(), gomock.Cond(func(x any) bool { arg, ok := x.(database.UpdateChatLastInjectedContextParams) @@ -6616,3 +6632,61 @@ func TestPrimeWorkspaceMCPCache_ExitsOnContextCancel(t *testing.T) { _, ok := server.workspaceMCPToolsCache.Load(chat.ID) require.False(t, ok, "primer must not cache anything when canceled") } + +func TestPersistChatContextSummarySetsAPIKeyID(t *testing.T) { + t.Parallel() + + db, _ := dbtestutil.NewDB(t) + ctx := context.Background() + + user := dbgen.User(t, db, database.User{}) + org := dbgen.Organization(t, db, database.Organization{}) + modelConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{}) + chat := dbgen.Chat(t, db, database.Chat{ + OwnerID: user.ID, + OrganizationID: org.ID, + LastModelConfigID: modelConfig.ID, + }) + apiKey, _ := dbgen.APIKey(t, db, database.APIKey{ + UserID: user.ID, + }) + + ctx = aibridge.WithDelegatedAPIKeyID(ctx, apiKey.ID) + + server := &Server{db: db} + + err := server.persistChatContextSummary( + ctx, + chat.ID, + modelConfig.ID, + "tool-call-id-1", + chatloop.CompactionResult{ + SystemSummary: "summarized context", + SummaryReport: "context was summarized", + ThresholdPercent: 70, + UsagePercent: 85.0, + ContextTokens: 8500, + ContextLimit: 10000, + }, + ) + require.NoError(t, err) + + msgs, err := db.GetChatMessagesForPromptByChatID(ctx, chat.ID) + require.NoError(t, err) + + // GetChatMessagesForPromptByChatID uses a compaction boundary CTE + // that selects compressed=true, visibility='model'. Only the user + // summary qualifies; the assistant (visibility=user) and tool + // result (visibility=both) are excluded by the CTE filter. + require.NotEmpty(t, msgs) + + var foundUserSummary bool + for _, msg := range msgs { + if msg.Role == database.ChatMessageRoleUser { + foundUserSummary = true + require.True(t, msg.APIKeyID.Valid, "summary user message must have APIKeyID set") + require.Equal(t, apiKey.ID, msg.APIKeyID.String, "summary user message APIKeyID must match") + } + } + require.True(t, foundUserSummary, "expected to find compressed user summary message") +} diff --git a/coderd/x/chatd/subagent.go b/coderd/x/chatd/subagent.go index 8984ac86cd9..450397416b7 100644 --- a/coderd/x/chatd/subagent.go +++ b/coderd/x/chatd/subagent.go @@ -1090,16 +1090,18 @@ func (p *Server) createChildSubagentChatWithOptions( return xerrors.Errorf("update child injected context: %w", err) } - userParams := database.InsertChatMessagesParams{ //nolint:exhaustruct // Fields populated by appendChatMessage. + userParams := database.InsertChatMessagesParams{ //nolint:exhaustruct // Fields populated by appendUserChatMessage. ChatID: insertedChat.ID, } - appendChatMessage(&userParams, newChatMessage( - database.ChatMessageRoleUser, + childUserMsg := newUserChatMessage( + childAPIKeyID, userContent, database.ChatMessageVisibilityBoth, modelConfigID, chatprompt.CurrentContentVersion, - ).withCreatedBy(parent.OwnerID).withAPIKeyID(childAPIKeyID)) + ) + childUserMsg = childUserMsg.withCreatedBy(parent.OwnerID) + appendUserChatMessage(&userParams, childUserMsg) if _, err := tx.InsertChatMessages(ctx, userParams); err != nil { return xerrors.Errorf("insert initial child user message: %w", err) } @@ -1176,16 +1178,27 @@ func copyParentContextMessages( return nil, xerrors.Errorf("marshal filtered context parts: %w", err) } - msgParams := database.InsertChatMessagesParams{ //nolint:exhaustruct // Fields populated by appendChatMessage. + msgParams := database.InsertChatMessagesParams{ //nolint:exhaustruct // Fields populated by append[User]ChatMessage. ChatID: child.ID, } - appendChatMessage(&msgParams, newChatMessage( - copiedRole, - filteredContent, - copiedVisibility, - child.LastModelConfigID, - copiedVersion, - )) + if copiedRole == database.ChatMessageRoleUser { + copiedAPIKeyID, _ := aibridge.DelegatedAPIKeyIDFromContext(ctx) + appendUserChatMessage(&msgParams, newUserChatMessage( + copiedAPIKeyID, + filteredContent, + copiedVisibility, + child.LastModelConfigID, + copiedVersion, + )) + } else { + appendChatMessage(&msgParams, newChatMessage( + copiedRole, + filteredContent, + copiedVisibility, + child.LastModelConfigID, + copiedVersion, + )) + } if _, err := store.InsertChatMessages(ctx, msgParams); err != nil { return nil, xerrors.Errorf("insert context message: %w", err) } diff --git a/coderd/x/chatd/subagent_context_internal_test.go b/coderd/x/chatd/subagent_context_internal_test.go index dc60e3330f5..5ccab312d67 100644 --- a/coderd/x/chatd/subagent_context_internal_test.go +++ b/coderd/x/chatd/subagent_context_internal_test.go @@ -11,6 +11,7 @@ import ( "github.com/stretchr/testify/require" "cdr.dev/slog/v3" + "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" @@ -446,10 +447,33 @@ func TestCreateChildSubagentChatUpdatesInheritedLastInjectedContext(t *testing.T ctx := chatdTestContext(t) parentChat := createParentChatWithInheritedContext(ctx, t, db, server) + // Set a delegated API key so that copied user-role context messages + // are stamped with api_key_id, preserving AI Gateway routing. + apiKey, _ := dbgen.APIKey(t, db, database.APIKey{UserID: parentChat.OwnerID}) + ctx = aibridge.WithDelegatedAPIKeyID(ctx, apiKey.ID) + child, err := server.createChildSubagentChat(ctx, parentChat, "inspect bindings", "") require.NoError(t, err) assertChildInheritedContext(ctx, t, db, child.ID, "inspect bindings") + + // Verify that all user-role messages in the child chat carry + // api_key_id so activeTurnAPIKeyIDFromMessages resolves correctly. + childMessages, err := db.GetChatMessagesByChatID(ctx, database.GetChatMessagesByChatIDParams{ + ChatID: child.ID, + AfterID: 0, + }) + require.NoError(t, err) + var userMsgCount int + for _, msg := range childMessages { + if msg.Role != database.ChatMessageRoleUser { + continue + } + userMsgCount++ + require.True(t, msg.APIKeyID.Valid, "child user message (id=%d) should have api_key_id set", msg.ID) + require.Equal(t, apiKey.ID, msg.APIKeyID.String, "child user message (id=%d) api_key_id mismatch", msg.ID) + } + require.Greater(t, userMsgCount, 0, "expected at least one user-role message in child chat") } func TestSpawnComputerUseAgentInheritsContext(t *testing.T) { From 359a39f58a1197c7ac8dd3a90bdeab07ed3efad1 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 29 May 2026 18:07:19 +0100 Subject: [PATCH 013/138] fix: add missing_key error kind for missing chat api_key_id (#25783) (#25798) 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 (cherry picked from commit 6df1536256475730d2fdba72083d3142c3089a25) --- 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 65f2c1927c2..15e45d9f469 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 33ffe3e4b4f..b65c8a95fbd 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 457704bd5fe..8e1a9783c31 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 a551078349c..52574200616 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 87cb375cbc0..94bf14bd135 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 7aa38857503..c0c86deacc4 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 5db1a16e535..07e8fd66b0f 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 786365d9fb3..0d2f3172043 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 665ace7aa81..c6deeb35aa0 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 f5187cce583..de0fcad927c 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 758f6641f59..691b2a2bc5b 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 912d53f8cef..34a7b57b9d3 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 2c0df043ff4..28412b9b090 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 a4ddf3bf6fc..08fd59d1bbf 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 c718e2a0076..243296ea2ce 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 2d640eaf76daba529f2e0d30f0ef4d1c459b62c4 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 29 May 2026 21:54:20 +0100 Subject: [PATCH 014/138] feat: classify provider_disabled 503 as non-retryable (#25800) (#25860) (NOTE: Depends on https://github.com/coder/coder/pull/25837) Adds a new `provider_disabled` error classification in `chatd` with the corresponding plumbing to classify it as non-retryable. Also adds a story for how this particular error kind is displayed in the UI. (cherry picked from commit d0a51da0a93b642792b9db36be3653ddbaacac34) --- coderd/apidoc/docs.go | 6 +- coderd/apidoc/swagger.json | 6 +- coderd/x/chatd/chaterror/classify.go | 8 ++ coderd/x/chatd/chaterror/classify_test.go | 81 ++++++++++++++++++ coderd/x/chatd/chaterror/message.go | 82 ++++++++++--------- coderd/x/chatd/chaterror/signals.go | 3 + codersdk/chats.go | 20 +++-- docs/reference/api/chats.md | 14 ++-- docs/reference/api/schemas.md | 6 +- site/src/api/typesGenerated.ts | 2 + .../LiveStreamTail.stories.tsx | 34 ++++++++ .../ChatConversation/chatStatusHelpers.ts | 2 + .../AgentsPage/utils/usageLimitMessage.ts | 5 +- 13 files changed, 203 insertions(+), 66 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 15e45d9f469..01c7f0a0f8d 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -16555,7 +16555,8 @@ const docTemplate = `{ "auth", "config", "usage_limit", - "missing_key" + "missing_key", + "provider_disabled" ], "x-enum-varnames": [ "ChatErrorKindGeneric", @@ -16566,7 +16567,8 @@ const docTemplate = `{ "ChatErrorKindAuth", "ChatErrorKindConfig", "ChatErrorKindUsageLimit", - "ChatErrorKindMissingKey" + "ChatErrorKindMissingKey", + "ChatErrorKindProviderDisabled" ] }, "codersdk.ChatFileMetadata": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index b65c8a95fbd..2f0d65d16ef 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -14905,7 +14905,8 @@ "auth", "config", "usage_limit", - "missing_key" + "missing_key", + "provider_disabled" ], "x-enum-varnames": [ "ChatErrorKindGeneric", @@ -14916,7 +14917,8 @@ "ChatErrorKindAuth", "ChatErrorKindConfig", "ChatErrorKindUsageLimit", - "ChatErrorKindMissingKey" + "ChatErrorKindMissingKey", + "ChatErrorKindProviderDisabled" ] }, "codersdk.ChatFileMetadata": { diff --git a/coderd/x/chatd/chaterror/classify.go b/coderd/x/chatd/chaterror/classify.go index 73e50f083be..4bf28efd4ff 100644 --- a/coderd/x/chatd/chaterror/classify.go +++ b/coderd/x/chatd/chaterror/classify.go @@ -195,6 +195,7 @@ func Classify(err error) ClassifiedError { } retryableHTTP2StreamReset, hasHTTP2StreamReset := classifyHTTP2StreamReset(err) + providerDisabledMatch := containsAny(lower, providerDisabledPatterns...) deadline := errors.Is(err, context.DeadlineExceeded) || strings.Contains(lower, "context deadline exceeded") overloadedMatch := statusCode == 529 || containsAny(lower, overloadedPatterns...) usageLimitMatch := containsAny(lower, usageLimitPatterns...) @@ -221,6 +222,8 @@ func Classify(err error) ClassifiedError { // over whatever HTTP status code the provider happened to use. // Strong auth still stays above config because bad credentials are // the root cause when both signals appear. + // Provider-disabled must precede timeout because disabled providers + // return 503, which matches the timeout rule. rules := []struct { match bool kind codersdk.ChatErrorKind @@ -251,6 +254,11 @@ func Classify(err error) ClassifiedError { kind: codersdk.ChatErrorKindRateLimit, retryable: true, }, + { + match: providerDisabledMatch, + kind: codersdk.ChatErrorKindProviderDisabled, + retryable: false, + }, { match: timeoutMatch && !configMatch, kind: codersdk.ChatErrorKindTimeout, diff --git a/coderd/x/chatd/chaterror/classify_test.go b/coderd/x/chatd/chaterror/classify_test.go index 8e1a9783c31..0e2e008bb8d 100644 --- a/coderd/x/chatd/chaterror/classify_test.go +++ b/coderd/x/chatd/chaterror/classify_test.go @@ -2,6 +2,7 @@ package chaterror_test import ( "context" + "fmt" "io" "net/http" "strings" @@ -218,6 +219,85 @@ func TestClassify(t *testing.T) { StatusCode: 0, }, }, + // The next cases model the error that fantasy produces + // when aibridge's disabledProviderHandler returns a 503 + // plain-text sentinel. Fantasy sets Title from the HTTP + // status text and Message from the response body (including + // the trailing newline written by http.Error). + { + name: "ProviderDisabled503ClassifiesAsProviderDisabled", + err: &fantasy.ProviderError{ + Title: fantasy.ErrorTitleForStatusCode(http.StatusServiceUnavailable), + Message: fmt.Sprintf("%s: AI provider %q is disabled\n", codersdk.ChatErrorKindProviderDisabled, "openai"), + StatusCode: http.StatusServiceUnavailable, + }, + want: chaterror.ClassifiedError{ + Message: "The OpenAI provider has been disabled. Contact your Coder administrator.", + Detail: fmt.Sprintf("%s: AI provider %q is disabled", codersdk.ChatErrorKindProviderDisabled, "openai"), + Kind: codersdk.ChatErrorKindProviderDisabled, + Provider: "openai", + Retryable: false, + StatusCode: 503, + }, + }, + { + name: "ProviderDisabled503UnknownProvider", + err: &fantasy.ProviderError{ + Title: fantasy.ErrorTitleForStatusCode(http.StatusServiceUnavailable), + Message: fmt.Sprintf("%s: AI provider %q is disabled\n", codersdk.ChatErrorKindProviderDisabled, "mycustomprovider"), + StatusCode: http.StatusServiceUnavailable, + }, + want: chaterror.ClassifiedError{ + Message: "The AI provider has been disabled. Contact your Coder administrator.", + Detail: fmt.Sprintf("%s: AI provider %q is disabled", codersdk.ChatErrorKindProviderDisabled, "mycustomprovider"), + Kind: codersdk.ChatErrorKindProviderDisabled, + Provider: "", + Retryable: false, + StatusCode: 503, + }, + }, + { + name: "ProviderDisabledPlainErrorString", + err: xerrors.New(fmt.Sprintf("%s: AI provider %q is disabled", codersdk.ChatErrorKindProviderDisabled, "anthropic")), + want: chaterror.ClassifiedError{ + Message: "The Anthropic provider has been disabled. Contact your Coder administrator.", + Kind: codersdk.ChatErrorKindProviderDisabled, + Provider: "anthropic", + Retryable: false, + StatusCode: 0, + }, + }, + { + name: "ProviderDisabledBeatsTimeout503", + err: &fantasy.ProviderError{ + Title: fantasy.ErrorTitleForStatusCode(http.StatusServiceUnavailable), + Message: fmt.Sprintf("%s: AI provider %q is disabled\n", codersdk.ChatErrorKindProviderDisabled, "google"), + StatusCode: http.StatusServiceUnavailable, + }, + want: chaterror.ClassifiedError{ + Message: "The Google provider has been disabled. Contact your Coder administrator.", + Detail: fmt.Sprintf("%s: AI provider %q is disabled", codersdk.ChatErrorKindProviderDisabled, "google"), + Kind: codersdk.ChatErrorKindProviderDisabled, + Provider: "google", + Retryable: false, + StatusCode: 503, + }, + }, + { + name: "Generic503StillClassifiesAsTimeout", + err: &fantasy.ProviderError{ + Message: "service unavailable", + StatusCode: 503, + }, + want: chaterror.ClassifiedError{ + Message: "The AI provider is temporarily unavailable.", + Detail: "service unavailable", + Kind: codersdk.ChatErrorKindTimeout, + Provider: "", + Retryable: true, + StatusCode: 503, + }, + }, } for _, tt := range tests { @@ -363,6 +443,7 @@ func TestClassify_PatternCoverage(t *testing.T) { {name: "OperationInterruptedLiteral", err: "operation interrupted", wantKind: codersdk.ChatErrorKindGeneric, wantRetry: false}, {name: "Status408", err: "status 408", wantKind: codersdk.ChatErrorKindTimeout, wantRetry: true}, {name: "Status500", err: "status 500", wantKind: codersdk.ChatErrorKindGeneric, wantRetry: true}, + {name: "ProviderDisabledLiteral", err: "provider_disabled", wantKind: codersdk.ChatErrorKindProviderDisabled, wantRetry: false}, } for _, tt := range tests { diff --git a/coderd/x/chatd/chaterror/message.go b/coderd/x/chatd/chaterror/message.go index 52574200616..49a6518154b 100644 --- a/coderd/x/chatd/chaterror/message.go +++ b/coderd/x/chatd/chaterror/message.go @@ -4,6 +4,7 @@ import ( "fmt" "strings" + stringutil "github.com/coder/coder/v2/coderd/util/strings" "github.com/coder/coder/v2/codersdk" ) @@ -16,48 +17,33 @@ func terminalMessage(classified ClassifiedError) string { subject := providerSubject(classified.Provider) switch classified.Kind { case codersdk.ChatErrorKindOverloaded: - return fmt.Sprintf("%s is temporarily overloaded.", subject) + return stringutil.Capitalize(fmt.Sprintf("%s is temporarily overloaded.", subject)) case codersdk.ChatErrorKindRateLimit: - return fmt.Sprintf("%s is rate limiting requests.", subject) + return stringutil.Capitalize(fmt.Sprintf("%s is rate limiting requests.", subject)) case codersdk.ChatErrorKindTimeout: if !classified.Retryable && classified.StatusCode == 0 { return "The request timed out before it completed." } - return fmt.Sprintf("%s is temporarily unavailable.", subject) + return stringutil.Capitalize(fmt.Sprintf("%s is temporarily unavailable.", subject)) case codersdk.ChatErrorKindStartupTimeout: - return fmt.Sprintf( + return stringutil.Capitalize(fmt.Sprintf( "%s did not start responding in time.", subject, - ) + )) case codersdk.ChatErrorKindUsageLimit: - displayName := providerDisplayName(classified.Provider) - if displayName == "" { - displayName = "the AI provider" - } - return fmt.Sprintf( + return stringutil.Capitalize(fmt.Sprintf( "The usage quota for %s has been exceeded."+ " Check the billing and quota settings for the provider account.", - displayName, - ) + subject, + )) case codersdk.ChatErrorKindAuth: - displayName := providerDisplayName(classified.Provider) - if displayName == "" { - displayName = "the AI provider" - } return fmt.Sprintf( "Authentication with %s failed."+ " Check the API key and permissions.", - displayName, - ) - - case codersdk.ChatErrorKindConfig: - return fmt.Sprintf( - "%s rejected the model configuration."+ - " Check the selected model and provider settings.", subject, ) @@ -65,11 +51,25 @@ func terminalMessage(classified ClassifiedError) string { return "This conversation was started with an API key that is no longer available." + " Send your message again to continue." + case codersdk.ChatErrorKindConfig: + return stringutil.Capitalize(fmt.Sprintf( + "%s rejected the model configuration."+ + " Check the selected model and provider settings.", + subject, + )) + + case codersdk.ChatErrorKindProviderDisabled: + displayName := providerDisplayName(classified.Provider) + return fmt.Sprintf( + "The %s provider has been disabled."+ + " Contact your Coder administrator.", + displayName, + ) default: if !classified.Retryable && classified.StatusCode == 0 { return "The chat request failed unexpectedly." } - return fmt.Sprintf("%s returned an unexpected error.", subject) + return stringutil.Capitalize(fmt.Sprintf("%s returned an unexpected error.", subject)) } } @@ -85,41 +85,43 @@ func retryMessage(classified ClassifiedError) string { subject := providerSubject(classified.Provider) switch classified.Kind { case codersdk.ChatErrorKindOverloaded: - return fmt.Sprintf("%s is temporarily overloaded.", subject) + return stringutil.Capitalize(fmt.Sprintf("%s is temporarily overloaded.", subject)) case codersdk.ChatErrorKindRateLimit: - return fmt.Sprintf("%s is rate limiting requests.", subject) + return stringutil.Capitalize(fmt.Sprintf("%s is rate limiting requests.", subject)) case codersdk.ChatErrorKindTimeout: - return fmt.Sprintf("%s is temporarily unavailable.", subject) + return stringutil.Capitalize(fmt.Sprintf("%s is temporarily unavailable.", subject)) case codersdk.ChatErrorKindStartupTimeout: - return fmt.Sprintf( + return stringutil.Capitalize(fmt.Sprintf( "%s did not start responding in time.", subject, - ) + )) case codersdk.ChatErrorKindAuth: - displayName := providerDisplayName(classified.Provider) - if displayName == "" { - displayName = "the AI provider" - } return fmt.Sprintf( - "Authentication with %s failed.", displayName, + "Authentication with %s failed.", subject, ) case codersdk.ChatErrorKindConfig: - return fmt.Sprintf( + return stringutil.Capitalize(fmt.Sprintf( "%s rejected the model configuration.", subject, + )) + case codersdk.ChatErrorKindProviderDisabled: + displayName := providerDisplayName(classified.Provider) + return fmt.Sprintf( + "The %s provider has been disabled by an administrator.", + displayName, ) case codersdk.ChatErrorKindMissingKey: return "The API key for this conversation is no longer available." default: - return fmt.Sprintf( + return stringutil.Capitalize(fmt.Sprintf( "%s returned an unexpected error.", subject, - ) + )) } } func providerSubject(provider string) string { - if displayName := providerDisplayName(provider); displayName != "" { + if displayName := providerDisplayName(provider); displayName != "AI" && displayName != "" { return displayName } - return "The AI provider" + return "the AI provider" } func providerDisplayName(provider string) string { @@ -141,7 +143,7 @@ func providerDisplayName(provider string) string { case "vercel": return "Vercel AI Gateway" default: - return "" + return "AI" } } diff --git a/coderd/x/chatd/chaterror/signals.go b/coderd/x/chatd/chaterror/signals.go index ebe6ff939b8..8dad9191276 100644 --- a/coderd/x/chatd/chaterror/signals.go +++ b/coderd/x/chatd/chaterror/signals.go @@ -4,6 +4,8 @@ import ( "regexp" "strconv" "strings" + + "github.com/coder/coder/v2/aibridge" ) type providerHint struct { @@ -83,6 +85,7 @@ var ( } genericRetryablePatterns = []string{"server error", "internal server error"} interruptedPatterns = []string{"chat interrupted", "request interrupted", "operation interrupted"} + providerDisabledPatterns = []string{aibridge.ErrorCodeProviderDisabled} ) func extractStatusCode(lower string) int { diff --git a/codersdk/chats.go b/codersdk/chats.go index c6deeb35aa0..bcf235f590b 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -1525,15 +1525,16 @@ type ChatStreamStatus struct { type ChatErrorKind string const ( - ChatErrorKindGeneric ChatErrorKind = "generic" - ChatErrorKindOverloaded ChatErrorKind = "overloaded" - ChatErrorKindRateLimit ChatErrorKind = "rate_limit" - ChatErrorKindTimeout ChatErrorKind = "timeout" - ChatErrorKindStartupTimeout ChatErrorKind = "startup_timeout" - ChatErrorKindAuth ChatErrorKind = "auth" - ChatErrorKindConfig ChatErrorKind = "config" - ChatErrorKindUsageLimit ChatErrorKind = "usage_limit" - ChatErrorKindMissingKey ChatErrorKind = "missing_key" + ChatErrorKindGeneric ChatErrorKind = "generic" + ChatErrorKindOverloaded ChatErrorKind = "overloaded" + ChatErrorKindRateLimit ChatErrorKind = "rate_limit" + ChatErrorKindTimeout ChatErrorKind = "timeout" + ChatErrorKindStartupTimeout ChatErrorKind = "startup_timeout" + ChatErrorKindAuth ChatErrorKind = "auth" + ChatErrorKindConfig ChatErrorKind = "config" + ChatErrorKindUsageLimit ChatErrorKind = "usage_limit" + ChatErrorKindMissingKey ChatErrorKind = "missing_key" + ChatErrorKindProviderDisabled ChatErrorKind = "provider_disabled" ) // AllChatErrorKinds contains every ChatErrorKind value. @@ -1548,6 +1549,7 @@ var AllChatErrorKinds = []ChatErrorKind{ ChatErrorKindConfig, ChatErrorKindUsageLimit, ChatErrorKindMissingKey, + ChatErrorKindProviderDisabled, } // ChatError represents a terminal chat error in persisted chat state or the diff --git a/docs/reference/api/chats.md b/docs/reference/api/chats.md index 691b2a2bc5b..f475d8482d2 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`, `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` | +| Property | Value(s) | +|---------------|------------------------------------------------------------------------------------------------------------------------------------------| +| `client_type` | `api`, `ui` | +| `kind` | `auth`, `config`, `generic`, `missing_key`, `overloaded`, `provider_disabled`, `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 34a7b57b9d3..4d84894af9d 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`, `missing_key`, `overloaded`, `rate_limit`, `startup_timeout`, `timeout`, `usage_limit` | +| Value(s) | +|------------------------------------------------------------------------------------------------------------------------------------------| +| `auth`, `config`, `generic`, `missing_key`, `overloaded`, `provider_disabled`, `rate_limit`, `startup_timeout`, `timeout`, `usage_limit` | ## codersdk.ChatFileMetadata diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 28412b9b090..76b09598426 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1961,6 +1961,7 @@ export type ChatErrorKind = | "generic" | "missing_key" | "overloaded" + | "provider_disabled" | "rate_limit" | "startup_timeout" | "timeout" @@ -1972,6 +1973,7 @@ export const ChatErrorKinds: ChatErrorKind[] = [ "generic", "missing_key", "overloaded", + "provider_disabled", "rate_limit", "startup_timeout", "timeout", diff --git a/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.stories.tsx index 08fd59d1bbf..129b5ffa0fe 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.stories.tsx @@ -288,6 +288,40 @@ export const TerminalStartupTimeoutError: Story = { }, }; +/** Disabled provider errors render an admin-oriented message without retry. */ +export const TerminalProviderDisabledError: Story = { + args: { + ...defaultArgs, + liveStatus: buildLiveStatus({ + streamError: { + kind: "provider_disabled", + message: + "The OpenAI provider has been disabled. Contact your Coder administrator.", + provider: "openai", + retryable: false, + statusCode: 503, + }, + }), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect( + canvas.getByRole("heading", { name: /provider disabled/i }), + ).toBeVisible(); + expect( + canvas.getByText( + /the openai provider has been disabled.*contact your coder administrator/i, + ), + ).toBeVisible(); + expect(canvas.getByText(/^HTTP 503$/)).toBeVisible(); + // No retry or status link for administrative disablement. + expect(canvas.queryByText(/retrying/i)).not.toBeInTheDocument(); + expect( + canvas.queryByRole("link", { name: /status/i }), + ).not.toBeInTheDocument(); + }, +}; + /** Generic failures do not show usage or provider CTAs. */ export const GenericErrorDoesNotShowUsageAction: Story = { args: { diff --git a/site/src/pages/AgentsPage/components/ChatConversation/chatStatusHelpers.ts b/site/src/pages/AgentsPage/components/ChatConversation/chatStatusHelpers.ts index 243296ea2ce..d9ea6f6e594 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/chatStatusHelpers.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/chatStatusHelpers.ts @@ -44,6 +44,8 @@ export const getErrorTitle = ( return "Usage limit reached"; case "missing_key": return "Chat interrupted"; + case "provider_disabled": + return "Provider disabled"; default: return mode === "retry" ? "Retrying request" : "Request failed"; } diff --git a/site/src/pages/AgentsPage/utils/usageLimitMessage.ts b/site/src/pages/AgentsPage/utils/usageLimitMessage.ts index 1986da02273..1c7f10f00f4 100644 --- a/site/src/pages/AgentsPage/utils/usageLimitMessage.ts +++ b/site/src/pages/AgentsPage/utils/usageLimitMessage.ts @@ -11,9 +11,8 @@ type UsageLimitData = Partial< /** * Typed classification for errors surfaced in the agent detail view. * - "usage_limit": the user hit a spending cap (409 + valid usage data). - * - other kinds come from normalized stream/provider failures such as - * "generic", "overloaded", "rate_limit", "timeout", - * "startup_timeout", "auth", and "config". + * - other kinds come from normalized stream/provider failures. + * See ChatErrorKind for the full set. */ export type ChatDetailError = { message: string; From 295d2de5d7c24011fdf2399c1d476c1188887eb6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 29 May 2026 19:59:47 -0400 Subject: [PATCH 015/138] feat(site): add Opus 4.8 known model (#25839) (#25853) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cherry-pick of https://github.com/coder/coder/pull/25839 Original PR: #25839 — feat(site): add Opus 4.8 known model Merge commit: 9448624d2de386fd60c42e2a6677679877b6f997 Requested by: @ibetitsmike Co-authored-by: Thomas Kosiewski --- .../ChatModelAdminPanel.stories.tsx | 6 ++--- .../knownModels/anthropic.test.ts | 18 +++++++++++-- .../knownModels/anthropic.ts | 25 ++++++++++++++++--- .../applyKnownModelDefaults.test.ts | 4 +-- 4 files changed, 43 insertions(+), 10 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ChatModelAdminPanel.stories.tsx b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ChatModelAdminPanel.stories.tsx index ec422bca5ee..09a53863697 100644 --- a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ChatModelAdminPanel.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ChatModelAdminPanel.stories.tsx @@ -1374,9 +1374,9 @@ export const AnthropicKnownModelHappyPath: Story = { await openKnownModelPopover(body); const options = await body.findAllByRole("option"); - await userEvent.click(findOptionByText(options, "claude-opus-4-7")); + await userEvent.click(findOptionByText(options, "claude-opus-4-8")); - await expectModelIdentifierValue(body, "claude-opus-4-7"); + await expectModelIdentifierValue(body, "claude-opus-4-8"); await expect(body.getByLabelText(/Context limit/i)).toHaveValue("1000000"); await expandSection(body, "Advanced"); @@ -1858,7 +1858,7 @@ export const KnownModelAutoHidePopoverWhenNoMatches: Story = { name: /Model Identifier/i, }); await userEvent.click(input); - await expect(await body.findByText("Claude Opus 4.7")).toBeInTheDocument(); + await expect(await body.findByText("Claude Opus 4.8")).toBeInTheDocument(); await userEvent.clear(input); await userEvent.type(input, "claude-opus-4-5"); diff --git a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/knownModels/anthropic.test.ts b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/knownModels/anthropic.test.ts index 0ad417439c3..2e1762d758a 100644 --- a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/knownModels/anthropic.test.ts +++ b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/knownModels/anthropic.test.ts @@ -22,6 +22,7 @@ describe("anthropicKnownModels", () => { (knownModel) => knownModel.modelIdentifier, ), ).toEqual([ + "claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6", "claude-sonnet-4-6", @@ -31,7 +32,11 @@ describe("anthropicKnownModels", () => { }); it("declares Anthropic reasoning defaults by API support", () => { - for (const modelIdentifier of ["claude-opus-4-7", "claude-opus-4-6"]) { + for (const modelIdentifier of [ + "claude-opus-4-8", + "claude-opus-4-7", + "claude-opus-4-6", + ]) { const knownModel = requireAnthropicKnownModel(modelIdentifier); expect(knownModel.reasoningEffort).toBe("high"); @@ -54,6 +59,7 @@ describe("anthropicKnownModels", () => { expect( anthropicKnownModels.map((knownModel) => knownModel.modelIdentifier), ).toEqual([ + "claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6", "claude-sonnet-4-6", @@ -64,8 +70,16 @@ describe("anthropicKnownModels", () => { for (const knownModel of anthropicKnownModels) { expect(knownModel.provider).toBe("anthropic"); expect(knownModel.sourceMetadata.sourceName).toBe("models.dev"); - expect(knownModel.sourceMetadata.sourceRetrievedAt).toBe("2026-04-30"); + expect(knownModel.sourceMetadata.sourceRetrievedAt).not.toBe(""); expect(knownModel.sourceMetadata.lastUpdated).not.toBe(""); } + + expect( + requireAnthropicKnownModel("claude-opus-4-8").sourceMetadata, + ).toEqual({ + sourceName: "models.dev", + sourceRetrievedAt: "2026-05-29", + lastUpdated: "2026-05-28", + }); }); }); diff --git a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/knownModels/anthropic.ts b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/knownModels/anthropic.ts index 297ddabe2cc..985fb49f7e8 100644 --- a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/knownModels/anthropic.ts +++ b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/knownModels/anthropic.ts @@ -10,14 +10,33 @@ import type { KnownModel } from "./types"; // catalog and should be reviewed when the catalog is refreshed. // // Reasoning configuration is split per model based on Anthropic API support: -// models that support adaptive thinking (Opus 4.7, Opus 4.6, Sonnet 4.6) -// carry `reasoningEffort`, which Coder maps to `thinking.type: "adaptive"` -// with the `effort` parameter. Models that do not (Haiku 4.5, Sonnet 4.5) +// models that support adaptive thinking (Opus 4.8, Opus 4.7, Opus 4.6, +// Sonnet 4.6) carry `reasoningEffort`, which Coder maps to +// `thinking.type: "adaptive"` with the `effort` parameter. Models that do not +// (Haiku 4.5, Sonnet 4.5) // carry `thinkingBudgetTokens` instead, which Coder maps to the legacy // `thinking.type: "enabled"` path with `budget_tokens`. Setting `effort` on // the legacy path produces an "adaptive thinking is not supported on this // model" HTTP 400 from Anthropic. export const anthropicKnownModels = [ + { + provider: "anthropic", + modelIdentifier: "claude-opus-4-8", + displayName: "Claude Opus 4.8", + aliases: [], + contextLimit: 1_000_000, + maxOutputTokens: 128_000, + reasoningEffort: "high", + inputCost: 5, + outputCost: 25, + cacheReadCost: 0.5, + cacheWriteCost: 6.25, + sourceMetadata: { + sourceName: "models.dev", + sourceRetrievedAt: "2026-05-29", + lastUpdated: "2026-05-28", + }, + }, { provider: "anthropic", modelIdentifier: "claude-opus-4-7", diff --git a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/knownModels/applyKnownModelDefaults.test.ts b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/knownModels/applyKnownModelDefaults.test.ts index d325c533854..d537aa726c4 100644 --- a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/knownModels/applyKnownModelDefaults.test.ts +++ b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/knownModels/applyKnownModelDefaults.test.ts @@ -294,7 +294,7 @@ describe("applyKnownModelDefaults", () => { values: buildInitialModelFormValues(), initialValues: buildInitialModelFormValues(), provider: "anthropic", - knownModel: requireKnownModel("anthropic", "claude-opus-4-7"), + knownModel: requireKnownModel("anthropic", "claude-opus-4-8"), }); expect(getPath(result.values, "config.anthropic.effort")).toBe("high"); @@ -327,7 +327,7 @@ describe("applyKnownModelDefaults", () => { values: buildInitialModelFormValues(), initialValues: buildInitialModelFormValues(), provider: "anthropic", - knownModel: requireKnownModel("anthropic", "claude-opus-4-7"), + knownModel: requireKnownModel("anthropic", "claude-opus-4-8"), }); expect(getPath(result.values, "config.anthropic.sendReasoning")).toBe(""); From eb918f9ad5c088dbc52f84f5c9b0c2b4e5d03270 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 1 Jun 2026 10:06:34 -0400 Subject: [PATCH 016/138] chore: Style fixes and nits across the AI Governance docs (#25793) (#25897) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport of https://github.com/coder/coder/pull/25793 Original PR: #25793 — chore: Style fixes and nits across the AI Governance docs Merge commit: 61a9c4a61d8823c46a4a30401045face91c0f3ea Requested by: @nickvigilante Co-authored-by: Nick Vigilante Co-authored-by: Danny Kopping --- docs/ai-coder/agent-firewall/index.md | 8 ++-- docs/ai-coder/ai-governance.md | 8 ++-- docs/ai-coder/usage-data-reporting.md | 4 +- docs/manifest.json | 61 ++++++++++++++++++--------- 4 files changed, 49 insertions(+), 32 deletions(-) diff --git a/docs/ai-coder/agent-firewall/index.md b/docs/ai-coder/agent-firewall/index.md index d5d29210970..8fe51927565 100644 --- a/docs/ai-coder/agent-firewall/index.md +++ b/docs/ai-coder/agent-firewall/index.md @@ -48,8 +48,8 @@ In your Terraform module, enable Agent Firewall with minimal configuration: ```tf module "claude-code" { - source = "dev.registry.coder.com/coder/claude-code/coder" - version = "4.7.0" + source = "registry.coder.com/coder/claude-code/coder" + version = "5.2.0" enable_boundary = true } ``` @@ -59,7 +59,7 @@ Claude Code module, use the following minimal configuration: ```yaml allowlist: - - "domain=dev.coder.com" # Required - use your Coder deployment domain + - "domain=coder.example.com" # Required - use your Coder deployment domain - "domain=api.anthropic.com" # Required - API endpoint for Claude - "domain=statsig.anthropic.com" # Required - Feature flags and analytics - "domain=claude.ai" # Recommended - WebFetch/WebSearch features @@ -225,5 +225,5 @@ such as Grafana Loki. Example of an allowed request (assuming stderr): ```console -2026-01-16 00:11:40.564 [info] coderd.agentrpc: boundary_request owner=joe workspace_name=some-task-c88d agent_name=dev decision=allow workspace_id=f2bd4e9f-7e27-49fc-961e-be4d1c2aa987 http_method=GET http_url=https://dev.coder.com event_time=2026-01-16T00:11:39.388607657Z matched_rule=domain=dev.coder.com request_id=9f30d667-1fc9-47ba-b9e5-8eac46e0abef trace=478b2b45577307c4fd1bcfc64fad6ffb span=9ece4bc70c311edb +2026-01-16 00:11:40.564 [info] coderd.agentrpc: boundary_request owner=joe workspace_name=some-task-c88d agent_name=dev decision=allow workspace_id=f2bd4e9f-7e27-49fc-961e-be4d1c2aa987 http_method=GET http_url=https://coder.example.com event_time=2026-01-16T00:11:39.388607657Z matched_rule=domain=coder.example.com request_id=9f30d667-1fc9-47ba-b9e5-8eac46e0abef trace=478b2b45577307c4fd1bcfc64fad6ffb span=9ece4bc70c311edb ``` diff --git a/docs/ai-coder/ai-governance.md b/docs/ai-coder/ai-governance.md index 0c8f7b609a1..857af72eecd 100644 --- a/docs/ai-coder/ai-governance.md +++ b/docs/ai-coder/ai-governance.md @@ -45,12 +45,10 @@ being used across the organization. AI Gateway provides audit trails of prompts, token usage, and tool invocations, giving administrators insight into AI adoption patterns and potential issues. -### Restricting agent network and command access +### Restricting agent network access -AI agents can make arbitrary network requests, potentially accessing -unauthorized services or exfiltrating data. They can also execute destructive -commands within a workspace. Agent Firewall enforces process-level policies -that restrict which domains agents can reach and what actions they can perform, +AI agents can make arbitrary network requests, potentially accessing unauthorized services or exfiltrating data. +Agent Firewall enforces process-level policies that restrict which domains agents can reach and what actions they can perform, preventing unintended data exposure and destructive operations like `rm -rf`. ### Centralizing API key management diff --git a/docs/ai-coder/usage-data-reporting.md b/docs/ai-coder/usage-data-reporting.md index 9d8fe08bfae..21c1e42d47b 100644 --- a/docs/ai-coder/usage-data-reporting.md +++ b/docs/ai-coder/usage-data-reporting.md @@ -5,7 +5,7 @@ The [AI Governance Add-On](./ai-governance.md) requires reporting usage data to - number of agent workspace builds consumed - number of AI Governance seats consumed -No user-identifiable information or additional metrics are sent to Tallyman. This information is also shared with [Metronome](https://metronome.com), a Stripe product and Coder partner for usage-based and reporting. +No user-identifiable information or additional metrics are sent to Tallyman. This information is also shared with [Metronome](https://metronome.com), a Stripe product and Coder partner for usage-based billing and reporting. To send usage data, your Coder deployment must be able to make outbound HTTPS requests to `https://tallyman-prod.coder.com`. Usage data is sent approximately every 17 minutes and can be monitored via `coderd` logs. @@ -17,7 +17,7 @@ Example of a successful request (requires debug logging enabled [`CODER_LOG_FILT Example of a request payload: -```sh +```txt POST /api/v1/events/ingest HTTP/1.1 Host: tallyman-prod.coder.com Content-Type: application/json diff --git a/docs/manifest.json b/docs/manifest.json index cbbeceefbef..6f2fbf33ac2 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -1144,85 +1144,99 @@ { "title": "Setup", "description": "How to set up and configure AI Gateway", - "path": "./ai-coder/ai-gateway/setup.md" + "path": "./ai-coder/ai-gateway/setup.md", + "state": ["ai governance add-on"] }, { "title": "Authentication", "description": "Learn how to authenticate against AI Gateway", - "path": "./ai-coder/ai-gateway/auth.md" + "path": "./ai-coder/ai-gateway/auth.md", + "state": ["ai governance add-on"] }, { "title": "Client Configuration", "description": "How to configure your AI coding tools to use AI Gateway", "path": "./ai-coder/ai-gateway/clients/index.md", + "state": ["ai governance add-on"], "children": [ { "title": "Coder Agents", "description": "Route Coder Agents traffic through AI Gateway", - "path": "./ai-coder/ai-gateway/clients/coder-agents.md" + "path": "./ai-coder/ai-gateway/clients/coder-agents.md", + "state": ["ai governance add-on"] }, { "title": "Claude Code", "description": "Configure Claude Code to use AI Gateway", - "path": "./ai-coder/ai-gateway/clients/claude-code.md" + "path": "./ai-coder/ai-gateway/clients/claude-code.md", + "state": ["ai governance add-on"] }, { "title": "Codex", "description": "Configure Codex to use AI Gateway", - "path": "./ai-coder/ai-gateway/clients/codex.md" + "path": "./ai-coder/ai-gateway/clients/codex.md", + "state": ["ai governance add-on"] }, { "title": "Mux", "description": "Configure Mux to use AI Gateway", - "path": "./ai-coder/ai-gateway/clients/mux.md" + "path": "./ai-coder/ai-gateway/clients/mux.md", + "state": ["ai governance add-on"] }, { "title": "OpenCode", "description": "Configure OpenCode to use AI Gateway", - "path": "./ai-coder/ai-gateway/clients/opencode.md" + "path": "./ai-coder/ai-gateway/clients/opencode.md", + "state": ["ai governance add-on"] }, { "title": "Factory", "description": "Configure Factory to use AI Gateway", - "path": "./ai-coder/ai-gateway/clients/factory.md" + "path": "./ai-coder/ai-gateway/clients/factory.md", + "state": ["ai governance add-on"] }, { "title": "Cline", "description": "Configure Cline to use AI Gateway", - "path": "./ai-coder/ai-gateway/clients/cline.md" + "path": "./ai-coder/ai-gateway/clients/cline.md", + "state": ["ai governance add-on"] }, { "title": "Kilo Code", "description": "Configure Kilo Code to use AI Gateway", - "path": "./ai-coder/ai-gateway/clients/kilo-code.md" + "path": "./ai-coder/ai-gateway/clients/kilo-code.md", + "state": ["ai governance add-on"] }, { "title": "VS Code", "description": "Configure VS Code to use AI Gateway", - "path": "./ai-coder/ai-gateway/clients/vscode.md" + "path": "./ai-coder/ai-gateway/clients/vscode.md", + "state": ["ai governance add-on"] }, { "title": "JetBrains", "description": "Configure JetBrains IDEs to use AI Gateway", - "path": "./ai-coder/ai-gateway/clients/jetbrains.md" + "path": "./ai-coder/ai-gateway/clients/jetbrains.md", + "state": ["ai governance add-on"] }, { "title": "Zed", "description": "Configure Zed to use AI Gateway", - "path": "./ai-coder/ai-gateway/clients/zed.md" + "path": "./ai-coder/ai-gateway/clients/zed.md", + "state": ["ai governance add-on"] }, { "title": "GitHub Copilot", "description": "Configure GitHub Copilot to use AI Gateway via AI Gateway Proxy", - "path": "./ai-coder/ai-gateway/clients/copilot.md" + "path": "./ai-coder/ai-gateway/clients/copilot.md", + "state": ["ai governance add-on"] } ] }, { "title": "MCP Tools Injection", "description": "How to configure MCP servers for tools injection through AI Gateway", - "path": "./ai-coder/ai-gateway/mcp.md", - "state": ["early access"] + "path": "./ai-coder/ai-gateway/mcp.md" }, { "title": "AI Gateway Proxy", @@ -1233,31 +1247,36 @@ { "title": "Setup", "description": "How to set up and configure AI Gateway Proxy", - "path": "./ai-coder/ai-gateway/ai-gateway-proxy/setup.md" + "path": "./ai-coder/ai-gateway/ai-gateway-proxy/setup.md", + "state": ["ai governance add-on"] } ] }, { "title": "Auditing AI Sessions", "description": "How to audit AI sessions", - "path": "./ai-coder/ai-gateway/audit.md" + "path": "./ai-coder/ai-gateway/audit.md", + "state": ["ai governance add-on"] }, { "title": "Monitoring", "description": "How to monitor AI Gateway", - "path": "./ai-coder/ai-gateway/monitoring.md" + "path": "./ai-coder/ai-gateway/monitoring.md", + "state": ["ai governance add-on"] }, { "title": "Reference", "description": "Technical reference for AI Gateway", - "path": "./ai-coder/ai-gateway/reference.md" + "path": "./ai-coder/ai-gateway/reference.md", + "state": ["ai governance add-on"] } ] }, { "title": "Usage Data Reporting", "description": "Configure AI usage data reporting", - "path": "./ai-coder/usage-data-reporting.md" + "path": "./ai-coder/usage-data-reporting.md", + "state": ["ai governance add-on"] } ] }, From 32882aee95558a4a2f65b3c009c182ecad78fdf2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 1 Jun 2026 10:32:44 -0400 Subject: [PATCH 017/138] fix: recreate `ai_provider_type` instead of ADD VALUE (#25895) (#25904) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cherry-pick of https://github.com/coder/coder/pull/25895 Original PR: #25895 — fix: recreate `ai_provider_type` instead of ADD VALUE Merge commit: 85f56e4944bd84fbb1047441444eb217de978899 Requested by: @dannykopping Signed-off-by: Danny Kopping Co-authored-by: Danny Kopping Co-authored-by: Claude Opus 4.8 (1M context) --- .claude/docs/DATABASE.md | 35 ++++++++ ...499_ai_provider_type_chatd_values.down.sql | 7 +- ...00499_ai_provider_type_chatd_values.up.sql | 30 +++++-- coderd/database/migrations/migrate_test.go | 80 +++++++++++++++++++ 4 files changed, 143 insertions(+), 9 deletions(-) diff --git a/.claude/docs/DATABASE.md b/.claude/docs/DATABASE.md index 84f6125fa48..331d662d20f 100644 --- a/.claude/docs/DATABASE.md +++ b/.claude/docs/DATABASE.md @@ -41,6 +41,41 @@ dimension. - Avoid `ByX` names for grouped queries. +### Enum Changes Run in a Single Transaction + +All migrations run inside one transaction (`pgTxnDriver`). Postgres forbids +*using* an enum value added by `ALTER TYPE ... ADD VALUE` within the same +transaction that added it, so it fails with `unsafe use of new value`. + +Adding the value is fine; using it in the same batch is not. "Using it" +includes a later migration that casts to it (`col::my_enum`), inserts or +updates a row with it, or sets it as a column default. This only fails when a +row actually materializes the new value, so fresh databases and CI pass while +deployments with existing data break. + +**MUST DO**: If any migration uses a newly added enum value, recreate the type +instead of using `ADD VALUE`. A freshly created enum's values are usable +immediately in the same transaction. Precedent: `000144_user_status_dormant`. + +```sql +CREATE TYPE new_my_enum AS ENUM ('existing', 'value', 'new_value'); + +ALTER TABLE my_table + ALTER COLUMN col TYPE new_my_enum USING (col::text::new_my_enum); + +DROP TYPE my_enum; + +ALTER TYPE new_my_enum RENAME TO my_enum; +``` + +Recreating produces an identical schema, so `make gen` yields no `dump.sql` +diff and databases that already applied the migration see no drift. + +**Testing**: `migrations.Stepper` commits each migration separately, so tests +built on it cannot surface this. To catch it, seed a row using the new value, +then apply the affected migrations in a single transaction (see +`TestMigration000504AIProvidersBackfillEnumInSingleTxn`). + ## Handling Nullable Fields Use `sql.NullString`, `sql.NullBool`, etc. for optional database fields: diff --git a/coderd/database/migrations/000499_ai_provider_type_chatd_values.down.sql b/coderd/database/migrations/000499_ai_provider_type_chatd_values.down.sql index a8b89359aa5..ab84bd795f5 100644 --- a/coderd/database/migrations/000499_ai_provider_type_chatd_values.down.sql +++ b/coderd/database/migrations/000499_ai_provider_type_chatd_values.down.sql @@ -1,3 +1,4 @@ --- No-op: Postgres does not allow removing enum values safely. --- Matches the precedent in 000495_ai_providers.down.sql for ALTER --- TYPE resource_type / api_key_scope ADD VALUE. +-- No-op: the up recreates ai_provider_type with a wider value set, but the +-- down does not narrow it back. Narrowing would drop rows that already use the +-- new values, and 000495_ai_providers.down.sql drops the type wholesale when +-- migrating all the way down. diff --git a/coderd/database/migrations/000499_ai_provider_type_chatd_values.up.sql b/coderd/database/migrations/000499_ai_provider_type_chatd_values.up.sql index 104514d32c1..30df7758dde 100644 --- a/coderd/database/migrations/000499_ai_provider_type_chatd_values.up.sql +++ b/coderd/database/migrations/000499_ai_provider_type_chatd_values.up.sql @@ -7,9 +7,27 @@ -- OpenAI-compatible endpoints. Native gateway-side support for these -- providers comes later, at which point this enum already carries the -- right discriminator and no further migration is needed. -ALTER TYPE ai_provider_type ADD VALUE IF NOT EXISTS 'azure'; -ALTER TYPE ai_provider_type ADD VALUE IF NOT EXISTS 'bedrock'; -ALTER TYPE ai_provider_type ADD VALUE IF NOT EXISTS 'google'; -ALTER TYPE ai_provider_type ADD VALUE IF NOT EXISTS 'openai-compat'; -ALTER TYPE ai_provider_type ADD VALUE IF NOT EXISTS 'openrouter'; -ALTER TYPE ai_provider_type ADD VALUE IF NOT EXISTS 'vercel'; +-- +-- Recreate the type rather than using ALTER TYPE ... ADD VALUE. Postgres +-- forbids using a value added by ADD VALUE within the same transaction, and +-- all migrations run in one transaction. 000504 casts existing chat_providers +-- rows to these new values in that same transaction, so ADD VALUE fails with +-- "unsafe use of new value". A freshly created enum's values are usable +-- immediately, so the cast in 000504 succeeds. +CREATE TYPE new_ai_provider_type AS ENUM ( + 'openai', + 'anthropic', + 'azure', + 'bedrock', + 'google', + 'openai-compat', + 'openrouter', + 'vercel' +); + +ALTER TABLE ai_providers + ALTER COLUMN type TYPE new_ai_provider_type USING (type::text::new_ai_provider_type); + +DROP TYPE ai_provider_type; + +ALTER TYPE new_ai_provider_type RENAME TO ai_provider_type; diff --git a/coderd/database/migrations/migrate_test.go b/coderd/database/migrations/migrate_test.go index 0b3e0c240c9..f148860bc5f 100644 --- a/coderd/database/migrations/migrate_test.go +++ b/coderd/database/migrations/migrate_test.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" "slices" + "strings" "sync" "testing" "time" @@ -1502,6 +1503,85 @@ func TestMigration000504AIProvidersBackfillOverridesNameConflict(t *testing.T) { require.True(t, fresh.Enabled) } +// TestMigration000504AIProvidersBackfillEnumInSingleTxn reproduces the +// production migration path, where every pending migration runs inside a +// single transaction (see pgTxnDriver). Migration 000499 widens +// ai_provider_type with ALTER TYPE ... ADD VALUE, and 000504 casts existing +// chat_providers rows to that enum. Postgres forbids using an enum value +// added by ADD VALUE within the same transaction, so when a legacy provider +// uses one of the new values (for example openai-compat) the batch fails with +// "unsafe use of new value". The per-step Stepper used by the other tests +// commits each migration separately and cannot surface this. +func TestMigration000504AIProvidersBackfillEnumInSingleTxn(t *testing.T) { + t.Parallel() + + sqlDB := testSQLDB(t) + ctx := testutil.Context(t, testutil.WaitSuperLong) + + // Apply everything through 498 and commit, so chat_providers exists and is + // populated before the batch under test runs, matching a deployment that + // ran an earlier migration batch before this one. + applyMigrationsInTxn(ctx, t, sqlDB, 1, 498) + + now := time.Now().UTC().Truncate(time.Microsecond) + providerID := uuid.New() + + // A legacy provider whose type is one of the values added in 000499. + _, err := sqlDB.ExecContext(ctx, ` + INSERT INTO chat_providers (id, provider, display_name, api_key, enabled, base_url, created_at, updated_at) + VALUES ($1, 'openai-compat', 'OpenAI Compatible', '', TRUE, 'https://api.example.com/v1', $2, $2) + `, providerID, now) + require.NoError(t, err) + + // Apply 000499 through 000504 in a single transaction, as production does. + applyMigrationsInTxn(ctx, t, sqlDB, 499, 504) + + var typ string + err = sqlDB.QueryRowContext(ctx, + `SELECT type FROM ai_providers WHERE id = $1`, providerID, + ).Scan(&typ) + require.NoError(t, err) + require.Equal(t, "openai-compat", typ) +} + +// applyMigrationsInTxn executes the up SQL for every migration whose version is +// in [from, to] inside a single transaction, mirroring pgTxnDriver. The whole +// batch commits or rolls back together. +func applyMigrationsInTxn(ctx context.Context, t *testing.T, sqlDB *sql.DB, from, to int) { + t.Helper() + + entries, err := os.ReadDir(".") + require.NoError(t, err) + + var files []string + for _, entry := range entries { + name := entry.Name() + if !strings.HasSuffix(name, ".up.sql") { + continue + } + var version int + if _, err := fmt.Sscanf(name, "%06d_", &version); err != nil { + continue + } + if version >= from && version <= to { + files = append(files, name) + } + } + slices.Sort(files) + + tx, err := sqlDB.BeginTx(ctx, nil) + require.NoError(t, err) + defer tx.Rollback() + + for _, name := range files { + query, err := os.ReadFile(name) + require.NoError(t, err) + _, err = tx.ExecContext(ctx, string(query)) + require.NoErrorf(t, err, "apply migration %s", name) + } + require.NoError(t, tx.Commit()) +} + func TestMigration000498SoftDeleteStaleWorkspaceAgents(t *testing.T) { t.Parallel() From f7369502bf52e7ef1335be9b111e4891f62dbc91 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Tue, 2 Jun 2026 01:01:43 +1000 Subject: [PATCH 018/138] chore: disable release freezing on dev.coder.com (#25881) (#25912) (cherry picked from commit 9c111a2be2ed87490083a1224e86f04cb7bcc6ba) --- scripts/should_deploy.sh | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/scripts/should_deploy.sh b/scripts/should_deploy.sh index 6259f9e1096..003828b411a 100755 --- a/scripts/should_deploy.sh +++ b/scripts/should_deploy.sh @@ -17,6 +17,20 @@ deploy_branch=main # branch names. branch_name=$(git branch --show-current) +# Short circuit: we no longer deploy release branches to dogfood, and instead +# test them on the stable deployment. +# TODO: once we're happy with the new deployment process, we can remove this +# script and the related github workflow stuff. +if [[ "$branch_name" == "main" ]]; then + log "VERDICT: DEPLOY" + echo "DEPLOY" # stdout + exit 0 +else + log "VERDICT: NOOP" + echo "NOOP" # stdout + exit 0 +fi + if [[ "$branch_name" != "main" && ! "$branch_name" =~ ^release/[0-9]+\.[0-9]+$ ]]; then error "Current branch '$branch_name' is not a supported branch name for dogfood, must be 'main' or 'release/x.y'" fi From ea971d54f3e468ef63add827b9c0d9ff1f97101f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 1 Jun 2026 13:40:09 -0400 Subject: [PATCH 019/138] fix: deprecate ai provider seeding env config (#25854) (#25900) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cherry-pick of https://github.com/coder/coder/pull/25854 Original PR: #25854 — fix: deprecate ai provider seeding env config Merge commit: c8555e2163ff2ac8eff2e2e6751c59a9fe4ff2ca Requested by: @dannykopping Co-authored-by: Danny Kopping --- cli/server.go | 35 +++++++++- cli/server_aibridge_internal_test.go | 64 +++++++++++++++++++ cli/testdata/coder_server_--help.golden | 60 ++++++++++++----- cli/testdata/server-config.yaml.golden | 34 +++++++--- codersdk/deployment.go | 51 ++++++++------- docs/reference/cli/server.md | 20 +++--- .../cli/testdata/coder_server_--help.golden | 60 ++++++++++++----- 7 files changed, 242 insertions(+), 82 deletions(-) diff --git a/cli/server.go b/cli/server.go index b2fa89fd3b0..e8b8768eeac 100644 --- a/cli/server.go +++ b/cli/server.go @@ -2979,6 +2979,11 @@ func parseExternalAuthProvidersFromEnv(prefix string, environ []string) ([]coder return providers, nil } +const ( + aiGatewayProviderEnvPrefix = "CODER_AI_GATEWAY_PROVIDER_" + aiBridgeProviderEnvPrefix = "CODER_AIBRIDGE_PROVIDER_" +) + // ReadAIProvidersFromEnv parses CODER_AI_GATEWAY_PROVIDER__ // environment variables into a slice of AIProviderConfig. // Deprecated alias env vars with the CODER_AIBRIDGE_PROVIDER__ @@ -2986,16 +2991,22 @@ func parseExternalAuthProvidersFromEnv(prefix string, environ []string) ([]coder // // This follows the same indexed pattern as ReadExternalAuthProvidersFromEnv. func ReadAIProvidersFromEnv(logger slog.Logger, environ []string) ([]codersdk.AIProviderConfig, error) { - providers, err := readAIProvidersForPrefix(logger, environ, "CODER_AIBRIDGE_PROVIDER_") + providers, err := readAIProvidersForPrefix(logger, environ, aiBridgeProviderEnvPrefix) if err != nil { return nil, err } - gatewayProviders, err := readAIProvidersForPrefix(logger, environ, "CODER_AI_GATEWAY_PROVIDER_") + gatewayProviders, err := readAIProvidersForPrefix(logger, environ, aiGatewayProviderEnvPrefix) if err != nil { return nil, err } if len(providers) > 0 && len(gatewayProviders) > 0 { - return nil, xerrors.New("cannot mix CODER_AIBRIDGE_PROVIDER_* and CODER_AI_GATEWAY_PROVIDER_* environment variables, please consolidate onto CODER_AI_GATEWAY_PROVIDER_*") + return nil, xerrors.Errorf("cannot mix %s* and %s* environment variables, please consolidate onto %s*", aiBridgeProviderEnvPrefix, aiGatewayProviderEnvPrefix, aiGatewayProviderEnvPrefix) + } + var activePrefix string + if len(providers) > 0 { + activePrefix = aiBridgeProviderEnvPrefix + } else if len(gatewayProviders) > 0 { + activePrefix = aiGatewayProviderEnvPrefix } providers = append(providers, gatewayProviders...) @@ -3077,9 +3088,27 @@ func ReadAIProvidersFromEnv(logger slog.Logger, environ []string) ([]codersdk.AI names[p.Name] = i } + warnIfAIProvidersConfiguredFromEnv(context.Background(), logger, activePrefix, providers) + return providers, nil } +func warnIfAIProvidersConfiguredFromEnv(ctx context.Context, logger slog.Logger, prefix string, providers []codersdk.AIProviderConfig) { + if len(providers) == 0 { + return + } + + if prefix == "" { + return + } + + logger.Warn(ctx, + "ai provider environment variables are deprecated for provider management and only seed provider configuration at startup", + slog.F("env_prefix", prefix), + slog.F("replacement", "Manage AI Providers from the Coder UI or HTTP API."), + ) +} + // readAIProvidersForPrefix parses provider env vars under a single // indexed prefix (e.g. CODER_AI_GATEWAY_PROVIDER_) into a slice of // AIProviderConfig. Per-field syntax errors and unknown keys are diff --git a/cli/server_aibridge_internal_test.go b/cli/server_aibridge_internal_test.go index a91e5b51d22..fce45aa6740 100644 --- a/cli/server_aibridge_internal_test.go +++ b/cli/server_aibridge_internal_test.go @@ -1,6 +1,7 @@ package cli import ( + "context" "database/sql" "encoding/json" "fmt" @@ -575,6 +576,58 @@ func TestValidateLegacyAIBridgeConfig(t *testing.T) { } } +func TestWarnIfAIProvidersConfiguredFromEnv(t *testing.T) { + t.Parallel() + + t.Run("NoProviders", func(t *testing.T) { + t.Parallel() + + sink := testutil.NewFakeSink(t) + warnIfAIProvidersConfiguredFromEnv(context.Background(), sink.Logger(), aiGatewayProviderEnvPrefix, nil) + + require.Empty(t, sink.Entries()) + }) + + t.Run("EmptyPrefix", func(t *testing.T) { + t.Parallel() + + sink := testutil.NewFakeSink(t) + warnIfAIProvidersConfiguredFromEnv(context.Background(), sink.Logger(), "", []codersdk.AIProviderConfig{{Type: "openai", Name: "openai"}}) + + require.Empty(t, sink.Entries()) + }) + + t.Run("AIGatewayPrefix", func(t *testing.T) { + t.Parallel() + + sink := testutil.NewFakeSink(t) + warnIfAIProvidersConfiguredFromEnv(context.Background(), sink.Logger(), aiGatewayProviderEnvPrefix, []codersdk.AIProviderConfig{{Type: "openai", Name: "openai"}}) + + entries := sink.Entries(func(e slog.SinkEntry) bool { + return e.Message == "ai provider environment variables are deprecated for provider management and only seed provider configuration at startup" + }) + require.Len(t, entries, 1) + require.Len(t, entries[0].Fields, 2) + assertFieldValue(t, entries[0].Fields, "env_prefix", aiGatewayProviderEnvPrefix) + assertFieldValue(t, entries[0].Fields, "replacement", "Manage AI Providers from the Coder UI or HTTP API.") + }) + + t.Run("AIBridgePrefix", func(t *testing.T) { + t.Parallel() + + sink := testutil.NewFakeSink(t) + warnIfAIProvidersConfiguredFromEnv(context.Background(), sink.Logger(), aiBridgeProviderEnvPrefix, []codersdk.AIProviderConfig{{Type: "openai", Name: "openai"}}) + + entries := sink.Entries(func(e slog.SinkEntry) bool { + return e.Message == "ai provider environment variables are deprecated for provider management and only seed provider configuration at startup" + }) + require.Len(t, entries, 1) + require.Len(t, entries[0].Fields, 2) + assertFieldValue(t, entries[0].Fields, "env_prefix", aiBridgeProviderEnvPrefix) + assertFieldValue(t, entries[0].Fields, "replacement", "Manage AI Providers from the Coder UI or HTTP API.") + }) +} + func TestBuildAIProviderFromRowSetsAPIDumpDir(t *testing.T) { t.Parallel() @@ -721,3 +774,14 @@ func mustMarshalSettings(s codersdk.AIProviderSettings) sql.NullString { } return sql.NullString{String: string(data), Valid: true} } + +func assertFieldValue(t *testing.T, fields slog.Map, name string, expected interface{}) { + t.Helper() + for _, f := range fields { + if f.Name == name { + assert.Equal(t, expected, f.Value) + return + } + } + t.Errorf("field %q not found", name) +} diff --git a/cli/testdata/coder_server_--help.golden b/cli/testdata/coder_server_--help.golden index 4bcf9efda9e..53ccf77f7c8 100644 --- a/cli/testdata/coder_server_--help.golden +++ b/cli/testdata/coder_server_--help.golden @@ -124,35 +124,55 @@ AI GATEWAY OPTIONS: disabled, only centralized key authentication is permitted. --ai-gateway-anthropic-base-url string, $CODER_AI_GATEWAY_ANTHROPIC_BASE_URL (default: https://api.anthropic.com/) - The base URL of the Anthropic API. + Deprecated: manage AI Providers from the Coder UI or HTTP API. If set, + this option seeds provider configuration at startup only exactly once. + It will not be used in service runtime. The base URL of the Anthropic + API. --ai-gateway-anthropic-key string, $CODER_AI_GATEWAY_ANTHROPIC_KEY - The key to authenticate against the Anthropic API. + Deprecated: manage AI Providers from the Coder UI or HTTP API. If set, + this option seeds provider configuration at startup only exactly once. + It will not be used in service runtime. The key to authenticate + against the Anthropic API. --ai-gateway-bedrock-access-key string, $CODER_AI_GATEWAY_BEDROCK_ACCESS_KEY - The access key to authenticate against the AWS Bedrock API. + Deprecated: manage AI Providers from the Coder UI or HTTP API. If set, + this option seeds provider configuration at startup only exactly once. + It will not be used in service runtime. The access key to authenticate + against the AWS Bedrock API. --ai-gateway-bedrock-access-key-secret string, $CODER_AI_GATEWAY_BEDROCK_ACCESS_KEY_SECRET - The access key secret to use with the access key to authenticate - against the AWS Bedrock API. + Deprecated: manage AI Providers from the Coder UI or HTTP API. If set, + this option seeds provider configuration at startup only exactly once. + It will not be used in service runtime. The access key secret to use + with the access key to authenticate against the AWS Bedrock API. --ai-gateway-bedrock-base-url string, $CODER_AI_GATEWAY_BEDROCK_BASE_URL - The base URL to use for the AWS Bedrock API. Use this setting to - specify an exact URL to use. Takes precedence over - CODER_AI_GATEWAY_BEDROCK_REGION. + Deprecated: manage AI Providers from the Coder UI or HTTP API. If set, + this option seeds provider configuration at startup only exactly once. + It will not be used in service runtime. The base URL to use for the + AWS Bedrock API. Use this setting to specify an exact URL to use. + Takes precedence over CODER_AI_GATEWAY_BEDROCK_REGION. --ai-gateway-bedrock-model string, $CODER_AI_GATEWAY_BEDROCK_MODEL (default: global.anthropic.claude-sonnet-4-5-20250929-v1:0) - The model to use when making requests to the AWS Bedrock API. + Deprecated: manage AI Providers from the Coder UI or HTTP API. If set, + this option seeds provider configuration at startup only exactly once. + It will not be used in service runtime. The model to use when making + requests to the AWS Bedrock API. --ai-gateway-bedrock-region string, $CODER_AI_GATEWAY_BEDROCK_REGION - The AWS Bedrock API region to use. Constructs a base URL to use for - the AWS Bedrock API in the form of - 'https://bedrock-runtime..amazonaws.com'. + Deprecated: manage AI Providers from the Coder UI or HTTP API. If set, + this option seeds provider configuration at startup only exactly once. + It will not be used in service runtime. The AWS Bedrock API region to + use. Constructs a base URL to use for the AWS Bedrock API in the form + of 'https://bedrock-runtime..amazonaws.com'. --ai-gateway-bedrock-small-fastmodel string, $CODER_AI_GATEWAY_BEDROCK_SMALL_FAST_MODEL (default: global.anthropic.claude-haiku-4-5-20251001-v1:0) - The small fast model to use when making requests to the AWS Bedrock - API. Claude Code uses Haiku-class models to perform background tasks. - See + Deprecated: manage AI Providers from the Coder UI or HTTP API. If set, + this option seeds provider configuration at startup only exactly once. + It will not be used in service runtime. The small fast model to use + when making requests to the AWS Bedrock API. Claude Code uses + Haiku-class models to perform background tasks. See https://docs.claude.com/en/docs/claude-code/settings#environment-variables. --ai-gateway-circuit-breaker-enabled bool, $CODER_AI_GATEWAY_CIRCUIT_BREAKER_ENABLED (default: false) @@ -171,10 +191,16 @@ AI GATEWAY OPTIONS: to disable (unlimited). --ai-gateway-openai-base-url string, $CODER_AI_GATEWAY_OPENAI_BASE_URL (default: https://api.openai.com/v1/) - The base URL of the OpenAI API. + Deprecated: manage AI Providers from the Coder UI or HTTP API. If set, + this option seeds provider configuration at startup only exactly once. + It will not be used in service runtime. The base URL of the OpenAI + API. --ai-gateway-openai-key string, $CODER_AI_GATEWAY_OPENAI_KEY - The key to authenticate against the OpenAI API. + Deprecated: manage AI Providers from the Coder UI or HTTP API. If set, + this option seeds provider configuration at startup only exactly once. + It will not be used in service runtime. The key to authenticate + against the OpenAI API. --ai-gateway-rate-limit int, $CODER_AI_GATEWAY_RATE_LIMIT (default: 0) Maximum number of AI Gateway requests per second per replica. Set to 0 diff --git a/cli/testdata/server-config.yaml.golden b/cli/testdata/server-config.yaml.golden index 5bc02e0ae60..613b639553b 100644 --- a/cli/testdata/server-config.yaml.golden +++ b/cli/testdata/server-config.yaml.golden @@ -874,25 +874,41 @@ ai_gateway: # Whether to start an in-memory AI Gateway instance. # (default: true, type: bool) enabled: true - # The base URL of the OpenAI API. + # Deprecated: manage AI Providers from the Coder UI or HTTP API. If set, this + # option seeds provider configuration at startup only exactly once. It will not be + # used in service runtime. The base URL of the OpenAI API. # (default: https://api.openai.com/v1/, type: string) openai_base_url: https://api.openai.com/v1/ - # The base URL of the Anthropic API. + # Deprecated: manage AI Providers from the Coder UI or HTTP API. If set, this + # option seeds provider configuration at startup only exactly once. It will not be + # used in service runtime. The base URL of the Anthropic API. # (default: https://api.anthropic.com/, type: string) anthropic_base_url: https://api.anthropic.com/ - # The base URL to use for the AWS Bedrock API. Use this setting to specify an - # exact URL to use. Takes precedence over CODER_AI_GATEWAY_BEDROCK_REGION. + # Deprecated: manage AI Providers from the Coder UI or HTTP API. If set, this + # option seeds provider configuration at startup only exactly once. It will not be + # used in service runtime. The base URL to use for the AWS Bedrock API. Use this + # setting to specify an exact URL to use. Takes precedence over + # CODER_AI_GATEWAY_BEDROCK_REGION. # (default: , type: string) bedrock_base_url: "" - # The AWS Bedrock API region to use. Constructs a base URL to use for the AWS - # Bedrock API in the form of 'https://bedrock-runtime..amazonaws.com'. + # Deprecated: manage AI Providers from the Coder UI or HTTP API. If set, this + # option seeds provider configuration at startup only exactly once. It will not be + # used in service runtime. The AWS Bedrock API region to use. Constructs a base + # URL to use for the AWS Bedrock API in the form of + # 'https://bedrock-runtime..amazonaws.com'. # (default: , type: string) bedrock_region: "" - # The model to use when making requests to the AWS Bedrock API. + # Deprecated: manage AI Providers from the Coder UI or HTTP API. If set, this + # option seeds provider configuration at startup only exactly once. It will not be + # used in service runtime. The model to use when making requests to the AWS + # Bedrock API. # (default: global.anthropic.claude-sonnet-4-5-20250929-v1:0, type: string) bedrock_model: global.anthropic.claude-sonnet-4-5-20250929-v1:0 - # The small fast model to use when making requests to the AWS Bedrock API. Claude - # Code uses Haiku-class models to perform background tasks. See + # Deprecated: manage AI Providers from the Coder UI or HTTP API. If set, this + # option seeds provider configuration at startup only exactly once. It will not be + # used in service runtime. The small fast model to use when making requests to the + # AWS Bedrock API. Claude Code uses Haiku-class models to perform background + # tasks. See # https://docs.claude.com/en/docs/claude-code/settings#environment-variables. # (default: global.anthropic.claude-haiku-4-5-20251001-v1:0, type: string) bedrock_small_fast_model: global.anthropic.claude-haiku-4-5-20251001-v1:0 diff --git a/codersdk/deployment.go b/codersdk/deployment.go index b4939ec0229..3829615910d 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -1699,6 +1699,7 @@ func (c *DeploymentValues) Options() serpent.OptionSet { } // AI Gateway options + aiGatewayProviderSeedingDeprecated := "Deprecated: manage AI Providers from the Coder UI or HTTP API. If set, this option seeds provider configuration at startup only exactly once. It will not be used in service runtime. " aiGatewayEnabled := serpent.Option{ Name: "AI Gateway Enabled", Description: "Whether to start an in-memory AI Gateway instance.", @@ -1711,7 +1712,7 @@ func (c *DeploymentValues) Options() serpent.OptionSet { } aiGatewayOpenAIBaseURL := serpent.Option{ Name: "AI Gateway OpenAI Base URL", - Description: "The base URL of the OpenAI API.", + Description: aiGatewayProviderSeedingDeprecated + "The base URL of the OpenAI API.", Flag: "ai-gateway-openai-base-url", Env: "CODER_AI_GATEWAY_OPENAI_BASE_URL", Value: &c.AI.BridgeConfig.LegacyOpenAI.BaseURL, @@ -1721,7 +1722,7 @@ func (c *DeploymentValues) Options() serpent.OptionSet { } aiGatewayOpenAIKey := serpent.Option{ Name: "AI Gateway OpenAI Key", - Description: "The key to authenticate against the OpenAI API.", + Description: aiGatewayProviderSeedingDeprecated + "The key to authenticate against the OpenAI API.", Flag: "ai-gateway-openai-key", Env: "CODER_AI_GATEWAY_OPENAI_KEY", Value: &c.AI.BridgeConfig.LegacyOpenAI.Key, @@ -1731,7 +1732,7 @@ func (c *DeploymentValues) Options() serpent.OptionSet { } aiGatewayAnthropicBaseURL := serpent.Option{ Name: "AI Gateway Anthropic Base URL", - Description: "The base URL of the Anthropic API.", + Description: aiGatewayProviderSeedingDeprecated + "The base URL of the Anthropic API.", Flag: "ai-gateway-anthropic-base-url", Env: "CODER_AI_GATEWAY_ANTHROPIC_BASE_URL", Value: &c.AI.BridgeConfig.LegacyAnthropic.BaseURL, @@ -1741,7 +1742,7 @@ func (c *DeploymentValues) Options() serpent.OptionSet { } aiGatewayAnthropicKey := serpent.Option{ Name: "AI Gateway Anthropic Key", - Description: "The key to authenticate against the Anthropic API.", + Description: aiGatewayProviderSeedingDeprecated + "The key to authenticate against the Anthropic API.", Flag: "ai-gateway-anthropic-key", Env: "CODER_AI_GATEWAY_ANTHROPIC_KEY", Value: &c.AI.BridgeConfig.LegacyAnthropic.Key, @@ -1750,30 +1751,28 @@ func (c *DeploymentValues) Options() serpent.OptionSet { Annotations: serpent.Annotations{}.Mark(annotationSecretKey, "true"), } aiGatewayBedrockBaseURL := serpent.Option{ - Name: "AI Gateway Bedrock Base URL", - Description: "The base URL to use for the AWS Bedrock API. Use this setting to specify an exact URL to use. Takes precedence " + - "over CODER_AI_GATEWAY_BEDROCK_REGION.", - Flag: "ai-gateway-bedrock-base-url", - Env: "CODER_AI_GATEWAY_BEDROCK_BASE_URL", - Value: &c.AI.BridgeConfig.LegacyBedrock.BaseURL, - Default: "", - Group: &deploymentGroupAIGateway, - YAML: "bedrock_base_url", + Name: "AI Gateway Bedrock Base URL", + Description: aiGatewayProviderSeedingDeprecated + "The base URL to use for the AWS Bedrock API. Use this setting to specify an exact URL to use. Takes precedence over CODER_AI_GATEWAY_BEDROCK_REGION.", + Flag: "ai-gateway-bedrock-base-url", + Env: "CODER_AI_GATEWAY_BEDROCK_BASE_URL", + Value: &c.AI.BridgeConfig.LegacyBedrock.BaseURL, + Default: "", + Group: &deploymentGroupAIGateway, + YAML: "bedrock_base_url", } aiGatewayBedrockRegion := serpent.Option{ - Name: "AI Gateway Bedrock Region", - Description: "The AWS Bedrock API region to use. Constructs a base URL to use for the AWS Bedrock API in the form of " + - "'https://bedrock-runtime..amazonaws.com'.", - Flag: "ai-gateway-bedrock-region", - Env: "CODER_AI_GATEWAY_BEDROCK_REGION", - Value: &c.AI.BridgeConfig.LegacyBedrock.Region, - Default: "", - Group: &deploymentGroupAIGateway, - YAML: "bedrock_region", + Name: "AI Gateway Bedrock Region", + Description: aiGatewayProviderSeedingDeprecated + "The AWS Bedrock API region to use. Constructs a base URL to use for the AWS Bedrock API in the form of 'https://bedrock-runtime..amazonaws.com'.", + Flag: "ai-gateway-bedrock-region", + Env: "CODER_AI_GATEWAY_BEDROCK_REGION", + Value: &c.AI.BridgeConfig.LegacyBedrock.Region, + Default: "", + Group: &deploymentGroupAIGateway, + YAML: "bedrock_region", } aiGatewayBedrockAccessKey := serpent.Option{ Name: "AI Gateway Bedrock Access Key", - Description: "The access key to authenticate against the AWS Bedrock API.", + Description: aiGatewayProviderSeedingDeprecated + "The access key to authenticate against the AWS Bedrock API.", Flag: "ai-gateway-bedrock-access-key", Env: "CODER_AI_GATEWAY_BEDROCK_ACCESS_KEY", Value: &c.AI.BridgeConfig.LegacyBedrock.AccessKey, @@ -1783,7 +1782,7 @@ func (c *DeploymentValues) Options() serpent.OptionSet { } aiGatewayBedrockAccessKeySecret := serpent.Option{ Name: "AI Gateway Bedrock Access Key Secret", - Description: "The access key secret to use with the access key to authenticate against the AWS Bedrock API.", + Description: aiGatewayProviderSeedingDeprecated + "The access key secret to use with the access key to authenticate against the AWS Bedrock API.", Flag: "ai-gateway-bedrock-access-key-secret", Env: "CODER_AI_GATEWAY_BEDROCK_ACCESS_KEY_SECRET", Value: &c.AI.BridgeConfig.LegacyBedrock.AccessKeySecret, @@ -1793,7 +1792,7 @@ func (c *DeploymentValues) Options() serpent.OptionSet { } aiGatewayBedrockModel := serpent.Option{ Name: "AI Gateway Bedrock Model", - Description: "The model to use when making requests to the AWS Bedrock API.", + Description: aiGatewayProviderSeedingDeprecated + "The model to use when making requests to the AWS Bedrock API.", Flag: "ai-gateway-bedrock-model", Env: "CODER_AI_GATEWAY_BEDROCK_MODEL", Value: &c.AI.BridgeConfig.LegacyBedrock.Model, @@ -1803,7 +1802,7 @@ func (c *DeploymentValues) Options() serpent.OptionSet { } aiGatewayBedrockSmallFastModel := serpent.Option{ Name: "AI Gateway Bedrock Small Fast Model", - Description: "The small fast model to use when making requests to the AWS Bedrock API. Claude Code uses Haiku-class models to perform background tasks. See https://docs.claude.com/en/docs/claude-code/settings#environment-variables.", + Description: aiGatewayProviderSeedingDeprecated + "The small fast model to use when making requests to the AWS Bedrock API. Claude Code uses Haiku-class models to perform background tasks. See https://docs.claude.com/en/docs/claude-code/settings#environment-variables.", Flag: "ai-gateway-bedrock-small-fastmodel", Env: "CODER_AI_GATEWAY_BEDROCK_SMALL_FAST_MODEL", Value: &c.AI.BridgeConfig.LegacyBedrock.SmallFastModel, diff --git a/docs/reference/cli/server.md b/docs/reference/cli/server.md index de694faa794..2de88e4960f 100644 --- a/docs/reference/cli/server.md +++ b/docs/reference/cli/server.md @@ -1743,7 +1743,7 @@ Whether to start an in-memory AI Gateway instance. | YAML | ai_gateway.openai_base_url | | Default | https://api.openai.com/v1/ | -The base URL of the OpenAI API. +Deprecated: manage AI Providers from the Coder UI or HTTP API. If set, this option seeds provider configuration at startup only exactly once. It will not be used in service runtime. The base URL of the OpenAI API. ### --ai-gateway-openai-key @@ -1752,7 +1752,7 @@ The base URL of the OpenAI API. | Type | string | | Environment | $CODER_AI_GATEWAY_OPENAI_KEY | -The key to authenticate against the OpenAI API. +Deprecated: manage AI Providers from the Coder UI or HTTP API. If set, this option seeds provider configuration at startup only exactly once. It will not be used in service runtime. The key to authenticate against the OpenAI API. ### --ai-gateway-anthropic-base-url @@ -1763,7 +1763,7 @@ The key to authenticate against the OpenAI API. | YAML | ai_gateway.anthropic_base_url | | Default | https://api.anthropic.com/ | -The base URL of the Anthropic API. +Deprecated: manage AI Providers from the Coder UI or HTTP API. If set, this option seeds provider configuration at startup only exactly once. It will not be used in service runtime. The base URL of the Anthropic API. ### --ai-gateway-anthropic-key @@ -1772,7 +1772,7 @@ The base URL of the Anthropic API. | Type | string | | Environment | $CODER_AI_GATEWAY_ANTHROPIC_KEY | -The key to authenticate against the Anthropic API. +Deprecated: manage AI Providers from the Coder UI or HTTP API. If set, this option seeds provider configuration at startup only exactly once. It will not be used in service runtime. The key to authenticate against the Anthropic API. ### --ai-gateway-bedrock-base-url @@ -1782,7 +1782,7 @@ The key to authenticate against the Anthropic API. | Environment | $CODER_AI_GATEWAY_BEDROCK_BASE_URL | | YAML | ai_gateway.bedrock_base_url | -The base URL to use for the AWS Bedrock API. Use this setting to specify an exact URL to use. Takes precedence over CODER_AI_GATEWAY_BEDROCK_REGION. +Deprecated: manage AI Providers from the Coder UI or HTTP API. If set, this option seeds provider configuration at startup only exactly once. It will not be used in service runtime. The base URL to use for the AWS Bedrock API. Use this setting to specify an exact URL to use. Takes precedence over CODER_AI_GATEWAY_BEDROCK_REGION. ### --ai-gateway-bedrock-region @@ -1792,7 +1792,7 @@ The base URL to use for the AWS Bedrock API. Use this setting to specify an exac | Environment | $CODER_AI_GATEWAY_BEDROCK_REGION | | YAML | ai_gateway.bedrock_region | -The AWS Bedrock API region to use. Constructs a base URL to use for the AWS Bedrock API in the form of 'https://bedrock-runtime..amazonaws.com'. +Deprecated: manage AI Providers from the Coder UI or HTTP API. If set, this option seeds provider configuration at startup only exactly once. It will not be used in service runtime. The AWS Bedrock API region to use. Constructs a base URL to use for the AWS Bedrock API in the form of 'https://bedrock-runtime..amazonaws.com'. ### --ai-gateway-bedrock-access-key @@ -1801,7 +1801,7 @@ The AWS Bedrock API region to use. Constructs a base URL to use for the AWS Bedr | Type | string | | Environment | $CODER_AI_GATEWAY_BEDROCK_ACCESS_KEY | -The access key to authenticate against the AWS Bedrock API. +Deprecated: manage AI Providers from the Coder UI or HTTP API. If set, this option seeds provider configuration at startup only exactly once. It will not be used in service runtime. The access key to authenticate against the AWS Bedrock API. ### --ai-gateway-bedrock-access-key-secret @@ -1810,7 +1810,7 @@ The access key to authenticate against the AWS Bedrock API. | Type | string | | Environment | $CODER_AI_GATEWAY_BEDROCK_ACCESS_KEY_SECRET | -The access key secret to use with the access key to authenticate against the AWS Bedrock API. +Deprecated: manage AI Providers from the Coder UI or HTTP API. If set, this option seeds provider configuration at startup only exactly once. It will not be used in service runtime. The access key secret to use with the access key to authenticate against the AWS Bedrock API. ### --ai-gateway-bedrock-model @@ -1821,7 +1821,7 @@ The access key secret to use with the access key to authenticate against the AWS | YAML | ai_gateway.bedrock_model | | Default | global.anthropic.claude-sonnet-4-5-20250929-v1:0 | -The model to use when making requests to the AWS Bedrock API. +Deprecated: manage AI Providers from the Coder UI or HTTP API. If set, this option seeds provider configuration at startup only exactly once. It will not be used in service runtime. The model to use when making requests to the AWS Bedrock API. ### --ai-gateway-bedrock-small-fastmodel @@ -1832,7 +1832,7 @@ The model to use when making requests to the AWS Bedrock API. | YAML | ai_gateway.bedrock_small_fast_model | | Default | global.anthropic.claude-haiku-4-5-20251001-v1:0 | -The small fast model to use when making requests to the AWS Bedrock API. Claude Code uses Haiku-class models to perform background tasks. See https://docs.claude.com/en/docs/claude-code/settings#environment-variables. +Deprecated: manage AI Providers from the Coder UI or HTTP API. If set, this option seeds provider configuration at startup only exactly once. It will not be used in service runtime. The small fast model to use when making requests to the AWS Bedrock API. Claude Code uses Haiku-class models to perform background tasks. See https://docs.claude.com/en/docs/claude-code/settings#environment-variables. ### --ai-gateway-retention diff --git a/enterprise/cli/testdata/coder_server_--help.golden b/enterprise/cli/testdata/coder_server_--help.golden index a9062a426f3..addd3dc2562 100644 --- a/enterprise/cli/testdata/coder_server_--help.golden +++ b/enterprise/cli/testdata/coder_server_--help.golden @@ -125,35 +125,55 @@ AI GATEWAY OPTIONS: disabled, only centralized key authentication is permitted. --ai-gateway-anthropic-base-url string, $CODER_AI_GATEWAY_ANTHROPIC_BASE_URL (default: https://api.anthropic.com/) - The base URL of the Anthropic API. + Deprecated: manage AI Providers from the Coder UI or HTTP API. If set, + this option seeds provider configuration at startup only exactly once. + It will not be used in service runtime. The base URL of the Anthropic + API. --ai-gateway-anthropic-key string, $CODER_AI_GATEWAY_ANTHROPIC_KEY - The key to authenticate against the Anthropic API. + Deprecated: manage AI Providers from the Coder UI or HTTP API. If set, + this option seeds provider configuration at startup only exactly once. + It will not be used in service runtime. The key to authenticate + against the Anthropic API. --ai-gateway-bedrock-access-key string, $CODER_AI_GATEWAY_BEDROCK_ACCESS_KEY - The access key to authenticate against the AWS Bedrock API. + Deprecated: manage AI Providers from the Coder UI or HTTP API. If set, + this option seeds provider configuration at startup only exactly once. + It will not be used in service runtime. The access key to authenticate + against the AWS Bedrock API. --ai-gateway-bedrock-access-key-secret string, $CODER_AI_GATEWAY_BEDROCK_ACCESS_KEY_SECRET - The access key secret to use with the access key to authenticate - against the AWS Bedrock API. + Deprecated: manage AI Providers from the Coder UI or HTTP API. If set, + this option seeds provider configuration at startup only exactly once. + It will not be used in service runtime. The access key secret to use + with the access key to authenticate against the AWS Bedrock API. --ai-gateway-bedrock-base-url string, $CODER_AI_GATEWAY_BEDROCK_BASE_URL - The base URL to use for the AWS Bedrock API. Use this setting to - specify an exact URL to use. Takes precedence over - CODER_AI_GATEWAY_BEDROCK_REGION. + Deprecated: manage AI Providers from the Coder UI or HTTP API. If set, + this option seeds provider configuration at startup only exactly once. + It will not be used in service runtime. The base URL to use for the + AWS Bedrock API. Use this setting to specify an exact URL to use. + Takes precedence over CODER_AI_GATEWAY_BEDROCK_REGION. --ai-gateway-bedrock-model string, $CODER_AI_GATEWAY_BEDROCK_MODEL (default: global.anthropic.claude-sonnet-4-5-20250929-v1:0) - The model to use when making requests to the AWS Bedrock API. + Deprecated: manage AI Providers from the Coder UI or HTTP API. If set, + this option seeds provider configuration at startup only exactly once. + It will not be used in service runtime. The model to use when making + requests to the AWS Bedrock API. --ai-gateway-bedrock-region string, $CODER_AI_GATEWAY_BEDROCK_REGION - The AWS Bedrock API region to use. Constructs a base URL to use for - the AWS Bedrock API in the form of - 'https://bedrock-runtime..amazonaws.com'. + Deprecated: manage AI Providers from the Coder UI or HTTP API. If set, + this option seeds provider configuration at startup only exactly once. + It will not be used in service runtime. The AWS Bedrock API region to + use. Constructs a base URL to use for the AWS Bedrock API in the form + of 'https://bedrock-runtime..amazonaws.com'. --ai-gateway-bedrock-small-fastmodel string, $CODER_AI_GATEWAY_BEDROCK_SMALL_FAST_MODEL (default: global.anthropic.claude-haiku-4-5-20251001-v1:0) - The small fast model to use when making requests to the AWS Bedrock - API. Claude Code uses Haiku-class models to perform background tasks. - See + Deprecated: manage AI Providers from the Coder UI or HTTP API. If set, + this option seeds provider configuration at startup only exactly once. + It will not be used in service runtime. The small fast model to use + when making requests to the AWS Bedrock API. Claude Code uses + Haiku-class models to perform background tasks. See https://docs.claude.com/en/docs/claude-code/settings#environment-variables. --ai-gateway-circuit-breaker-enabled bool, $CODER_AI_GATEWAY_CIRCUIT_BREAKER_ENABLED (default: false) @@ -172,10 +192,16 @@ AI GATEWAY OPTIONS: to disable (unlimited). --ai-gateway-openai-base-url string, $CODER_AI_GATEWAY_OPENAI_BASE_URL (default: https://api.openai.com/v1/) - The base URL of the OpenAI API. + Deprecated: manage AI Providers from the Coder UI or HTTP API. If set, + this option seeds provider configuration at startup only exactly once. + It will not be used in service runtime. The base URL of the OpenAI + API. --ai-gateway-openai-key string, $CODER_AI_GATEWAY_OPENAI_KEY - The key to authenticate against the OpenAI API. + Deprecated: manage AI Providers from the Coder UI or HTTP API. If set, + this option seeds provider configuration at startup only exactly once. + It will not be used in service runtime. The key to authenticate + against the OpenAI API. --ai-gateway-rate-limit int, $CODER_AI_GATEWAY_RATE_LIMIT (default: 0) Maximum number of AI Gateway requests per second per replica. Set to 0 From ec2d20a7f1ab3b189e83f7482a09496ba50c78c1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 1 Jun 2026 13:40:25 -0400 Subject: [PATCH 020/138] feat: support adding GitHub Copilot AI provider via UI (#25888) (#25902) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cherry-pick of https://github.com/coder/coder/pull/25888 Original PR: #25888 — feat: support adding GitHub Copilot AI provider via UI Merge commit: a85462bd4973efd96e2d43761266ccc8c3ebc60f Requested by: @dannykopping Co-authored-by: Danny Kopping --- coderd/ai_providers.go | 16 +++++ coderd/ai_providers_test.go | 69 +++++++++++++++++++ codersdk/aiproviders.go | 12 +++- enterprise/aibridgeproxyd/aibridgeproxyd.go | 35 +++++++++- .../aibridgeproxyd_internal_test.go | 68 ++++++++++++++++++ site/src/api/typesGenerated.ts | 5 +- .../AddProviderPageView.stories.tsx | 23 +++++++ .../AddProviderPage/AddProviderPageView.tsx | 7 +- .../UpdateProviderPageView.stories.tsx | 16 +++++ .../UpdateProviderPageView.tsx | 12 ++-- .../components/ProviderForm.stories.tsx | 32 +++++++++ .../ProvidersPage/components/ProviderForm.tsx | 61 +++++++++++++--- .../components/ProviderIcon.stories.tsx | 6 ++ .../ProvidersPage/components/ProviderIcon.tsx | 4 ++ .../components/addableProviderTypes.ts | 1 + .../components/providerFormApiMap.test.ts | 63 +++++++++++++++++ .../components/providerFormApiMap.ts | 49 +++++++++---- site/src/testHelpers/entities.ts | 14 ++++ 18 files changed, 457 insertions(+), 36 deletions(-) create mode 100644 enterprise/aibridgeproxyd/aibridgeproxyd_internal_test.go diff --git a/coderd/ai_providers.go b/coderd/ai_providers.go index 78ca50ecc99..0637822592c 100644 --- a/coderd/ai_providers.go +++ b/coderd/ai_providers.go @@ -340,6 +340,10 @@ func (api *API) aiProvidersUpdate(rw http.ResponseWriter, r *http.Request) { return errBedrockRejectsAPIKeys } + if req.APIKeys != nil && old.Type == database.AiProviderTypeCopilot && len(*req.APIKeys) > 0 { + return errCopilotRejectsAPIKeys + } + displayName := old.DisplayName if req.DisplayName != nil { // Empty string clears the column. @@ -383,6 +387,12 @@ func (api *API) aiProvidersUpdate(rw http.ResponseWriter, r *http.Request) { }) return } + if errors.Is(err, errCopilotRejectsAPIKeys) { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Copilot providers do not accept api_keys; they authenticate via request-time GitHub OAuth tokens.", + }) + return + } if errors.Is(err, errAIProviderBedrockTypeMismatch) { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "Bedrock settings are only valid for type=anthropic or type=bedrock.", @@ -483,6 +493,12 @@ func (api *API) publishAIProvidersChanged(ctx context.Context) { // Bedrock-typed provider; the outer handler translates it into a 400. var errBedrockRejectsAPIKeys = xerrors.New("bedrock providers do not accept api_keys") +// errCopilotRejectsAPIKeys is the sentinel returned from inside the +// update transaction when a caller attempts to attach api_keys to a +// Copilot-typed provider; the outer handler translates it into a 400. +// Copilot authenticates via request-time GitHub OAuth tokens. +var errCopilotRejectsAPIKeys = xerrors.New("copilot providers do not accept api_keys") + // errAIProviderBedrockTypeMismatch is the sentinel returned from // inside the update transaction when the post-merge settings carry a // Bedrock block but the provider is not anthropic- or bedrock-typed; diff --git a/coderd/ai_providers_test.go b/coderd/ai_providers_test.go index c020f90c265..e4f4f27a06d 100644 --- a/coderd/ai_providers_test.go +++ b/coderd/ai_providers_test.go @@ -889,6 +889,75 @@ func TestAIProvidersKeyManagement(t *testing.T) { require.Contains(t, sdkErr.Message, "Bedrock providers do not accept api_keys") }) + t.Run("CopilotCreateWithoutKeys", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitLong) + + //nolint:gocritic // Owner role is the audience for this endpoint. + provider, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ + Type: codersdk.AIProviderTypeCopilot, + Name: "keys-copilot", + Enabled: true, + BaseURL: "https://api.business.githubcopilot.com", + }) + require.NoError(t, err) + require.Equal(t, codersdk.AIProviderTypeCopilot, provider.Type) + require.Empty(t, provider.APIKeys) + }) + + t.Run("CopilotRejectsCreateWithKeys", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitLong) + + //nolint:gocritic // Owner role is the audience for this endpoint. + _, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ + Type: codersdk.AIProviderTypeCopilot, + Name: "keys-copilot-create", + Enabled: true, + BaseURL: "https://api.business.githubcopilot.com", + APIKeys: []string{"sk-should-be-rejected"}, //nolint:gosec // test fixture, not a real credential + }) + require.Error(t, err) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) + require.Len(t, sdkErr.Validations, 1) + require.Equal(t, "api_keys", sdkErr.Validations[0].Field) + require.Contains(t, sdkErr.Validations[0].Detail, "type=copilot does not accept api_keys") + }) + + t.Run("CopilotRejectsUpdateWithKeys", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitLong) + + //nolint:gocritic // Owner role is the audience for this endpoint. + provider, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ + Type: codersdk.AIProviderTypeCopilot, + Name: "keys-copilot-update", + Enabled: true, + BaseURL: "https://api.business.githubcopilot.com", + }) + require.NoError(t, err) + + rejected := []codersdk.AIProviderKeyMutation{ + {APIKey: ptr.Ref("sk-copilot-no")}, //nolint:gosec // test fixture, not a real credential + } + _, err = client.UpdateAIProvider(ctx, provider.Name, codersdk.UpdateAIProviderRequest{ + APIKeys: &rejected, + }) + require.Error(t, err) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) + require.Contains(t, sdkErr.Message, "Copilot providers do not accept api_keys") + }) + t.Run("EmptyKeyRejected", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) diff --git a/codersdk/aiproviders.go b/codersdk/aiproviders.go index fe34b9c03c2..7b513340bca 100644 --- a/codersdk/aiproviders.go +++ b/codersdk/aiproviders.go @@ -188,8 +188,9 @@ type AIProviderKey struct { // CreateAIProviderRequest is the payload for creating a new AI // provider. Name and Type are required. APIKeys carries the plaintext -// keys for OpenAI/Anthropic providers; Bedrock providers authenticate -// via Settings and must omit APIKeys. +// keys for OpenAI/Anthropic providers; Bedrock and Copilot providers +// must omit APIKeys (Bedrock authenticates via Settings, Copilot via +// request-time GitHub OAuth tokens). type CreateAIProviderRequest struct { Type AIProviderType `json:"type"` Name string `json:"name"` @@ -209,6 +210,7 @@ func (req CreateAIProviderRequest) Validate() []ValidationError { AIProviderTypeAnthropic, AIProviderTypeAzure, AIProviderTypeBedrock, + AIProviderTypeCopilot, AIProviderTypeGoogle, AIProviderTypeOpenAICompat, AIProviderTypeOpenrouter, @@ -244,6 +246,12 @@ func (req CreateAIProviderRequest) Validate() []ValidationError { Detail: "type=bedrock does not accept api_keys", }) } + if req.Type == AIProviderTypeCopilot && len(req.APIKeys) > 0 { + validations = append(validations, ValidationError{ + Field: "api_keys", + Detail: "type=copilot does not accept api_keys", + }) + } return validations } diff --git a/enterprise/aibridgeproxyd/aibridgeproxyd.go b/enterprise/aibridgeproxyd/aibridgeproxyd.go index cfcb2071c41..438d7c46b7f 100644 --- a/enterprise/aibridgeproxyd/aibridgeproxyd.go +++ b/enterprise/aibridgeproxyd/aibridgeproxyd.go @@ -1076,9 +1076,11 @@ func (s *Server) handleResponse(resp *http.Response, ctx *goproxy.ProxyCtx) *htt switch { case resp.StatusCode >= http.StatusInternalServerError: - logger.Error(s.ctx, "received error response from aibridged") + logger.Error(s.ctx, "received error response from aibridged", + slog.F("response_body", s.readErrorBodyForLog(resp, logger))) case resp.StatusCode >= http.StatusBadRequest: - logger.Warn(s.ctx, "received error response from aibridged") + logger.Warn(s.ctx, "received error response from aibridged", + slog.F("response_body", s.readErrorBodyForLog(resp, logger))) default: logger.Debug(s.ctx, "received response from aibridged") } @@ -1101,6 +1103,35 @@ func (s *Server) handleResponse(resp *http.Response, ctx *goproxy.ProxyCtx) *htt return resp } +// maxLoggedErrorBodyBytes bounds how much of an aibridged error response +// body is rendered into a log line, so a large upstream error payload +// cannot blow up log volume. +const maxLoggedErrorBodyBytes = 16 << 10 // 16 KiB + +// readErrorBodyForLog reads resp.Body for diagnostic logging and restores +// it with an equivalent reader, so the proxy still forwards the body +// downstream and the response dumper can read it again. The returned +// string is truncated to maxLoggedErrorBodyBytes; the restored body is +// always complete. +func (s *Server) readErrorBodyForLog(resp *http.Response, logger slog.Logger) string { + if resp.Body == nil { + return "" + } + body, err := io.ReadAll(resp.Body) + _ = resp.Body.Close() + // Restore the full body even on a read error: the proxy and dumper + // downstream still expect a readable body, and a partial body is + // better than a nil one. + resp.Body = io.NopCloser(bytes.NewReader(body)) + if err != nil { + logger.Warn(s.ctx, "failed to read aibridged error response body", slog.Error(err)) + } + if len(body) > maxLoggedErrorBodyBytes { + return string(body[:maxLoggedErrorBodyBytes]) + "...(truncated)" + } + return string(body) +} + // Handler returns an HTTP handler for the AI Bridge Proxy's HTTP endpoints. // This is separate from the proxy server itself and is used by coderd to // serve endpoints like the CA certificate. diff --git a/enterprise/aibridgeproxyd/aibridgeproxyd_internal_test.go b/enterprise/aibridgeproxyd/aibridgeproxyd_internal_test.go new file mode 100644 index 00000000000..397ed1cf3ec --- /dev/null +++ b/enterprise/aibridgeproxyd/aibridgeproxyd_internal_test.go @@ -0,0 +1,68 @@ +package aibridgeproxyd + +import ( + "bytes" + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "cdr.dev/slog/v3/sloggers/slogtest" +) + +// TestReadErrorBodyForLog verifies that reading an aibridged error +// response body for logging leaves the body intact for downstream +// consumers (the proxy forwards it, and the response dumper reads it +// again), and that the logged rendering is capped. +func TestReadErrorBodyForLog(t *testing.T) { + t.Parallel() + + newResponse := func(body string) *http.Response { + return &http.Response{ + StatusCode: http.StatusBadRequest, + Body: io.NopCloser(strings.NewReader(body)), + } + } + + t.Run("ReturnsBodyAndRestores", func(t *testing.T) { + t.Parallel() + s := &Server{ctx: t.Context(), logger: slogtest.Make(t, nil)} + resp := newResponse(`{"error":"bad request"}`) + + got := s.readErrorBodyForLog(resp, s.logger) + require.Equal(t, `{"error":"bad request"}`, got) + + // The body must still be readable in full for the proxy and the + // response dumper. + restored, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, `{"error":"bad request"}`, string(restored)) + }) + + t.Run("TruncatesLargeBodyButRestoresFull", func(t *testing.T) { + t.Parallel() + s := &Server{ctx: t.Context(), logger: slogtest.Make(t, nil)} + full := bytes.Repeat([]byte("a"), maxLoggedErrorBodyBytes+512) + resp := newResponse(string(full)) + + got := s.readErrorBodyForLog(resp, s.logger) + require.Len(t, got, maxLoggedErrorBodyBytes+len("...(truncated)")) + require.True(t, strings.HasSuffix(got, "...(truncated)")) + + // Truncation only affects the log string; the restored body is + // the complete payload. + restored, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, full, restored) + }) + + t.Run("NilBody", func(t *testing.T) { + t.Parallel() + s := &Server{ctx: t.Context(), logger: slogtest.Make(t, nil)} + resp := &http.Response{StatusCode: http.StatusInternalServerError, Body: nil} + + require.Equal(t, "", s.readErrorBodyForLog(resp, s.logger)) + }) +} diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 76b09598426..01ccc251aca 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -3232,8 +3232,9 @@ export interface ConvertLoginRequest { /** * CreateAIProviderRequest is the payload for creating a new AI * provider. Name and Type are required. APIKeys carries the plaintext - * keys for OpenAI/Anthropic providers; Bedrock providers authenticate - * via Settings and must omit APIKeys. + * keys for OpenAI/Anthropic providers; Bedrock and Copilot providers + * must omit APIKeys (Bedrock authenticates via Settings, Copilot via + * request-time GitHub OAuth tokens). */ export interface CreateAIProviderRequest { readonly type: AIProviderType; diff --git a/site/src/pages/AISettingsPage/ProvidersPage/AddProviderPage/AddProviderPageView.stories.tsx b/site/src/pages/AISettingsPage/ProvidersPage/AddProviderPage/AddProviderPageView.stories.tsx index f7bbab97617..176f8fb9ed9 100644 --- a/site/src/pages/AISettingsPage/ProvidersPage/AddProviderPage/AddProviderPageView.stories.tsx +++ b/site/src/pages/AISettingsPage/ProvidersPage/AddProviderPage/AddProviderPageView.stories.tsx @@ -1,4 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; +import { within } from "storybook/test"; import { reactRouterParameters } from "storybook-addon-remix-react-router"; import { withToaster } from "#/testHelpers/storybook"; import { addableProviders } from "../components/addableProviderTypes"; @@ -26,16 +27,38 @@ export const AddAnthropic: Story = { args: { provider: addableProviders.find((p) => p.value === "anthropic")!, }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await canvas.findByText("Add an Anthropic provider"); + }, }; export const AddOpenAI: Story = { args: { provider: addableProviders.find((p) => p.value === "openai")!, }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await canvas.findByText("Add an OpenAI provider"); + }, }; export const AddBedrock: Story = { args: { provider: addableProviders.find((p) => p.value === "bedrock")!, }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await canvas.findByText("Add an AWS Bedrock provider"); + }, +}; + +export const AddCopilot: Story = { + args: { + provider: addableProviders.find((p) => p.value === "copilot")!, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await canvas.findByText("Add a GitHub Copilot provider"); + }, }; diff --git a/site/src/pages/AISettingsPage/ProvidersPage/AddProviderPage/AddProviderPageView.tsx b/site/src/pages/AISettingsPage/ProvidersPage/AddProviderPage/AddProviderPageView.tsx index 6f21156077e..dbdb31b36a4 100644 --- a/site/src/pages/AISettingsPage/ProvidersPage/AddProviderPage/AddProviderPageView.tsx +++ b/site/src/pages/AISettingsPage/ProvidersPage/AddProviderPage/AddProviderPageView.tsx @@ -16,6 +16,9 @@ interface AddProviderPageViewProps { provider: AddableProvider; } +const indefiniteArticle = (word: string): string => + /^[aeiou]/i.test(word) ? "an" : "a"; + const AddProviderPageView: React.FC = ({ provider, }) => { @@ -38,7 +41,9 @@ const AddProviderPageView: React.FC = ({ size="lg" src={getProviderIcon(provider.value)} /> - {`Add a ${provider.label} provider`} + {`Add ${indefiniteArticle( + provider.label, + )} ${provider.label} provider`}

Configure connection details and credentials. diff --git a/site/src/pages/AISettingsPage/ProvidersPage/UpdateProviderPage/UpdateProviderPageView.stories.tsx b/site/src/pages/AISettingsPage/ProvidersPage/UpdateProviderPage/UpdateProviderPageView.stories.tsx index 15713428f9d..4d212d2b14b 100644 --- a/site/src/pages/AISettingsPage/ProvidersPage/UpdateProviderPage/UpdateProviderPageView.stories.tsx +++ b/site/src/pages/AISettingsPage/ProvidersPage/UpdateProviderPage/UpdateProviderPageView.stories.tsx @@ -5,6 +5,7 @@ import type { AIProvider } from "#/api/typesGenerated"; import { MockAIProviderAnthropic, MockAIProviderBedrock, + MockAIProviderCopilot, MockAIProviderOpenAI, } from "#/testHelpers/entities"; import { withToaster } from "#/testHelpers/storybook"; @@ -53,6 +54,21 @@ export const Bedrock: Story = { }, }; +// Copilot has no stored credential, so the edit form renders no API key +// field and keeps the immutable name disabled. +export const Copilot: Story = { + parameters: { + reactRouter: routingFor(`/ai/settings/${MockAIProviderCopilot.name}`), + ...seed(MockAIProviderCopilot), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const name = await canvas.findByLabelText(/^name/i); + expect(name).toBeDisabled(); + expect(canvas.queryByLabelText(/api key/i)).not.toBeInTheDocument(); + }, +}; + // No seeded query: the page renders the loader while useQuery fetches. export const Loading: Story = { parameters: { diff --git a/site/src/pages/AISettingsPage/ProvidersPage/UpdateProviderPage/UpdateProviderPageView.tsx b/site/src/pages/AISettingsPage/ProvidersPage/UpdateProviderPage/UpdateProviderPageView.tsx index dbeb16003d9..9991c3b8f67 100644 --- a/site/src/pages/AISettingsPage/ProvidersPage/UpdateProviderPage/UpdateProviderPageView.tsx +++ b/site/src/pages/AISettingsPage/ProvidersPage/UpdateProviderPage/UpdateProviderPageView.tsx @@ -43,8 +43,12 @@ const UpdateProviderPageView: React.FC = () => { }); const provider = providerQuery.data; - const providerIsOpenAiAnthropic = - provider !== undefined && !isBedrockProvider(provider); + // Copilot has no stored credential, and Bedrock keeps its secrets in + // settings, so only the remaining types surface the api_keys UI. + const providerUsesApiKeys = + provider !== undefined && + !isBedrockProvider(provider) && + provider.type !== "copilot"; const updateMutation = useMutation( updateAIProviderMutation(queryClient, providerId ?? ""), @@ -109,8 +113,8 @@ const UpdateProviderPageView: React.FC = () => { } const openAiAnthropicSavedApiKey = - providerIsOpenAiAnthropic && provider.api_keys.length > 0; - const openAiAnthropicMaskedApiKey = providerIsOpenAiAnthropic + providerUsesApiKeys && provider.api_keys.length > 0; + const openAiAnthropicMaskedApiKey = providerUsesApiKeys ? provider.api_keys[0]?.masked : undefined; diff --git a/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.stories.tsx b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.stories.tsx index 8fda28fae26..181a786a328 100644 --- a/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.stories.tsx +++ b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.stories.tsx @@ -64,6 +64,38 @@ export const EditBedrockKeepCredentials: Story = { }, }; +export const AddCopilot: Story = { + args: { + // The real add flow passes only the type; the form fills name and + // endpoint from the copilot defaults. + initialValues: { type: "copilot" }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await canvas.findByLabelText(/endpoint/i); + expect(canvas.queryByLabelText(/api key/i)).not.toBeInTheDocument(); + }, +}; + +export const EditCopilot: Story = { + args: { + editing: true, + initialValues: { + type: "copilot", + name: "copilot", + displayName: "GitHub Copilot", + baseUrl: "https://api.business.githubcopilot.com", + enabled: true, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const name = await canvas.findByLabelText(/^name/i); + expect(name).toBeDisabled(); + expect(canvas.queryByLabelText(/api key/i)).not.toBeInTheDocument(); + }, +}; + export const EditProvider: Story = { args: { editing: true, diff --git a/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.tsx b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.tsx index 0a609de2acd..7468d39b861 100644 --- a/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.tsx +++ b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.tsx @@ -81,6 +81,10 @@ const providerDefaults: Partial< name: "azure", baseUrl: "https://YOUR-RESOURCE.openai.azure.com/openai/v1", }, + copilot: { + name: "copilot", + baseUrl: "https://api.business.githubcopilot.com", + }, google: { name: "google", baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai/", @@ -164,6 +168,20 @@ const makeBedrockSchema = (editing: boolean) => enabled: Yup.boolean(), }); +const makeCopilotSchema = (editing: boolean) => + Yup.object({ + type: Yup.string() + .oneOf(["copilot"] as const) + .required(), + name: makeNameSchema(editing), + displayName: makeDisplayNameSchema(editing), + baseUrl: Yup.string() + .url("Endpoint must be a valid URL") + .matches(HTTP_SCHEME_REGEX, "Endpoint must use http or https.") + .required("Endpoint is required"), + enabled: Yup.boolean(), + }); + const getProviderFormSchema = (editing: boolean) => Yup.lazy((value: { type?: AIProviderType } | undefined) => { switch (value?.type) { @@ -177,6 +195,8 @@ const getProviderFormSchema = (editing: boolean) => return makeOpenAiAnthropicSchema(editing); case "bedrock": return makeBedrockSchema(editing); + case "copilot": + return makeCopilotSchema(editing); default: return Yup.object({ type: Yup.string() @@ -185,6 +205,7 @@ const getProviderFormSchema = (editing: boolean) => "anthropic", "bedrock", "azure", + "copilot", "google", "openai-compat", "openrouter", @@ -319,18 +340,38 @@ export const ProviderForm: FC = ({ required field={getFieldHelpers("baseUrl")} label="Endpoint" - description="The base URL where the provider's API is hosted." + description={ + typeSelectValue === "copilot" ? ( + <> + The base URL for your Copilot tier:{" "} + https://api.individual.githubcopilot.com,{" "} + https://api.business.githubcopilot.com, or{" "} + https://api.enterprise.githubcopilot.com. + + ) : ( + "The base URL where the provider's API is hosted." + ) + } className="w-full" placeholder={baseUrlPlaceholder(form.values.type)} /> - handleCredentialFocus("apiKey")} - autoComplete="new-password" - placeholder={apiKeyPlaceholder(form.values.type)} - /> + {typeSelectValue === "copilot" ? ( +

+ Copilot authenticates with each user's GitHub OAuth token at + request time, so there is no API key to configure here. This + requires a GitHub external authentication provider to be + configured. +

+ ) : ( + handleCredentialFocus("apiKey")} + autoComplete="new-password" + placeholder={apiKeyPlaceholder(form.values.type)} + /> + )} )} @@ -409,7 +450,7 @@ export const ProviderForm: FC = ({ -
+
From 1c08896e4fb41dc65b9719b9f7891f13ee8cbf5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Banaszewski?= Date: Sat, 27 Jun 2026 14:32:40 +0200 Subject: [PATCH 078/138] fix(aibridge): support Bedrock Opus 4.8 adaptive thinking (#26691) (#26708) Bedrock rejects legacy `thinking.type=enabled` requests for Claude Opus 4.8 because the model requires adaptive thinking. The AI Bridge Bedrock shim only recognized Opus 4.7 as adaptive-only, so Opus 4.8 requests could fall through and produce Bedrock 400 responses. Add Opus 4.8 to the adaptive-only model detection and cover the regional Bedrock model ID form with a regression test.
Coder Agents disclosure This PR was generated by Coder Agents on behalf of @ericpaulsen.
(cherry picked from commit 96aecd83fa7d5f4add02d76c6acde480de21e138) Co-authored-by: Eric Paulsen --- aibridge/intercept/messages/base.go | 3 ++- aibridge/intercept/messages/base_internal_test.go | 6 ++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/aibridge/intercept/messages/base.go b/aibridge/intercept/messages/base.go index 1f1f49e7443..51818b66b63 100644 --- a/aibridge/intercept/messages/base.go +++ b/aibridge/intercept/messages/base.go @@ -436,7 +436,8 @@ func bedrockModelSupportsAdaptiveThinking(model string) bool { // // See https://docs.aws.amazon.com/bedrock/latest/userguide/model-card-anthropic-claude-opus-4-7.html func bedrockModelRequiresAdaptiveThinking(model string) bool { - return strings.Contains(model, "anthropic.claude-opus-4-7") + return strings.Contains(model, "anthropic.claude-opus-4-7") || + strings.Contains(model, "anthropic.claude-opus-4-8") } // filterBedrockBetaFlags removes unsupported beta flags from the Anthropic-Beta diff --git a/aibridge/intercept/messages/base_internal_test.go b/aibridge/intercept/messages/base_internal_test.go index ef130deca1c..8cfbfc5e9a7 100644 --- a/aibridge/intercept/messages/base_internal_test.go +++ b/aibridge/intercept/messages/base_internal_test.go @@ -852,6 +852,12 @@ func TestAugmentRequestForBedrock_AdaptiveThinking(t *testing.T) { requestBody: `{"max_tokens":10000,"thinking":{"type":"enabled","budget_tokens":8000}}`, expectThinkingType: "adaptive", }, + { + name: "opus_4_8_model_with_enabled_thinking_is_converted_to_adaptive_and_drops_budget", + bedrockModel: "eu.anthropic.claude-opus-4-8", + requestBody: `{"max_tokens":10000,"thinking":{"type":"enabled","budget_tokens":5000}}`, + expectThinkingType: "adaptive", + }, { // Opus 4.7 on Bedrock rejects output_config.format (structured // outputs) with a 400 even though it accepts output_config.effort. From e436efccf4752263f113e5692498d4fccd0e0497 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 27 Jun 2026 08:52:54 -0400 Subject: [PATCH 079/138] fix(enterprise/aibridgeproxyd): stop injecting default port into forwarded Host header (#26656) (#26663) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport of https://github.com/coder/coder/pull/26656 Original PR: #26656 — fix(enterprise/aibridgeproxyd): stop injecting default port into forwarded Host header Merge commit: c41d219478a1aca56e6c6a8a89e28d4e7a8a81c0 Requested by: @ssncferreira Co-authored-by: Susana Ferreira Co-authored-by: Garrett Delfosse --- enterprise/aibridgeproxyd/aibridgeproxyd.go | 6 ++- .../aibridgeproxyd/aibridgeproxyd_test.go | 45 +++++++++++++++++-- 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/enterprise/aibridgeproxyd/aibridgeproxyd.go b/enterprise/aibridgeproxyd/aibridgeproxyd.go index 241de97edb9..1079cb6bde7 100644 --- a/enterprise/aibridgeproxyd/aibridgeproxyd.go +++ b/enterprise/aibridgeproxyd/aibridgeproxyd.go @@ -127,6 +127,8 @@ type Server struct { listener net.Listener tlsEnabled bool coderAccessURL *url.URL + // coderAccessPort is the resolved port for the Coder access URL. + coderAccessPort string // refreshProviders fetches the live provider snapshot on Reload. // Nil disables hot-reload. refreshProviders RefreshProvidersFunc @@ -265,7 +267,6 @@ func New(ctx context.Context, logger slog.Logger, opts Options) (*Server, error) coderAccessPort = "80" } } - coderAccessURL.Host = net.JoinHostPort(coderAccessURL.Hostname(), coderAccessPort) // MITM cert and key are required to intercept and decrypt HTTPS traffic. if opts.MITMCertFile == "" || opts.MITMKeyFile == "" { @@ -325,6 +326,7 @@ func New(ctx context.Context, logger slog.Logger, opts Options) (*Server, error) proxy: proxy, tlsEnabled: opts.TLSCertFile != "", coderAccessURL: coderAccessURL, + coderAccessPort: coderAccessPort, refreshProviders: opts.RefreshProviders, allowedPorts: allowedPorts, caCert: certPEM, @@ -801,7 +803,7 @@ func (s *Server) isBlockedIP(ip net.IP, hostname string, port string) bool { // block connections to its own deployment. Hostname-based (not IP-based) // to handle dynamic IPs (DNS changes, load balancers, k8s rescheduling). // The port is normalized at startup to handle URLs without explicit ports. - if strings.EqualFold(hostname, s.coderAccessURL.Hostname()) && port == s.coderAccessURL.Port() { + if strings.EqualFold(hostname, s.coderAccessURL.Hostname()) && port == s.coderAccessPort { return false } diff --git a/enterprise/aibridgeproxyd/aibridgeproxyd_test.go b/enterprise/aibridgeproxyd/aibridgeproxyd_test.go index e63e66ebf54..e84f7bd747f 100644 --- a/enterprise/aibridgeproxyd/aibridgeproxyd_test.go +++ b/enterprise/aibridgeproxyd/aibridgeproxyd_test.go @@ -622,8 +622,7 @@ func TestNew(t *testing.T) { MITMKeyFile: mitmKeyFile, }) require.NoError(t, err) - require.Equal(t, "localhost", srv.CoderAccessURL().Hostname()) - require.Equal(t, "80", srv.CoderAccessURL().Port()) + require.Equal(t, "localhost", srv.CoderAccessURL().Host) }) t.Run("CoderAccessURLDefaultHTTPSPort", func(t *testing.T) { @@ -639,8 +638,7 @@ func TestNew(t *testing.T) { MITMKeyFile: mitmKeyFile, }) require.NoError(t, err) - require.Equal(t, "localhost", srv.CoderAccessURL().Hostname()) - require.Equal(t, "443", srv.CoderAccessURL().Port()) + require.Equal(t, "localhost", srv.CoderAccessURL().Host) }) t.Run("CoderAccessURLExplicitPort", func(t *testing.T) { @@ -949,6 +947,45 @@ func TestNew(t *testing.T) { require.NoError(t, err) require.NotNil(t, srv) }) + + t.Run("CoderAccessURLHostPreserved", func(t *testing.T) { + t.Parallel() + + mitmCertFile, mitmKeyFile := getSharedTestMITMCert(t) + logger := slogtest.Make(t, nil) + + srv, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ + ListenAddr: "127.0.0.1:0", + CoderAccessURL: "https://coder.example.com", + MITMCertFile: mitmCertFile, + MITMKeyFile: mitmKeyFile, + AllowedPrivateCIDRs: []string{"127.0.0.1/32"}, + }) + require.NoError(t, err) + t.Cleanup(func() { _ = srv.Close() }) + + require.Equal(t, "coder.example.com", srv.CoderAccessURL().Host, + "Host must not have :443 appended") + }) + + t.Run("CoderAccessURLExplicitPortPreserved", func(t *testing.T) { + t.Parallel() + + mitmCertFile, mitmKeyFile := getSharedTestMITMCert(t) + logger := slogtest.Make(t, nil) + + srv, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ + ListenAddr: "127.0.0.1:0", + CoderAccessURL: "https://coder.example.com:8443", + MITMCertFile: mitmCertFile, + MITMKeyFile: mitmKeyFile, + AllowedPrivateCIDRs: []string{"127.0.0.1/32"}, + }) + require.NoError(t, err) + t.Cleanup(func() { _ = srv.Close() }) + + require.Equal(t, "coder.example.com:8443", srv.CoderAccessURL().Host) + }) } func TestClose(t *testing.T) { From 19d9274d2a5825fe4729095d51552e8f76531a50 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 27 Jun 2026 08:55:58 -0400 Subject: [PATCH 080/138] fix(site): keep TemplateVersionEditor file tree in sync (#25068) (#26770) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport of https://github.com/coder/coder/pull/25068 Original PR: #25068 — fix(site): keep TemplateVersionEditor file tree in sync Merge commit: 7481e1a5a0e9544220e0da3ee31649c2e35c27c1 Requested by: @aslilac Co-authored-by: McKayla はな Co-authored-by: Garrett Delfosse --- .../TemplateVersionEditor.stories.tsx | 3 +- .../TemplateVersionEditor.tsx | 17 +++-- .../TemplateVersionEditorPage.test.tsx | 72 +++++++++++++++++++ .../TemplateVersionEditorPage.tsx | 22 +++--- 4 files changed, 94 insertions(+), 20 deletions(-) diff --git a/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditor.stories.tsx b/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditor.stories.tsx index b4a553e4ec8..9fb9a8c6e8e 100644 --- a/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditor.stories.tsx +++ b/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditor.stories.tsx @@ -31,7 +31,8 @@ const meta: Meta = { activePath: "main.tf", template: MockTemplate, templateVersion: MockTemplateVersion, - defaultFileTree: MockTemplateVersionFileTree, + fileTree: MockTemplateVersionFileTree, + onFileTreeChange: action("onFileTreeChange"), onPublish: action("onPublish"), onConfirmPublish: action("onConfirmPublish"), onCancelPublish: action("onCancelPublish"), diff --git a/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditor.tsx b/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditor.tsx index 29e8767b3e3..9c7e122ff20 100644 --- a/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditor.tsx +++ b/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditor.tsx @@ -78,7 +78,8 @@ type Tab = "logs" | "resources" | undefined; // Undefined is to hide the tab interface TemplateVersionEditorProps { template: Template; templateVersion: TemplateVersion; - defaultFileTree: FileTree; + fileTree: FileTree; + onFileTreeChange: (updater: (fileTree: FileTree) => FileTree) => void; buildLogs?: ProvisionerJobLog[]; resources?: WorkspaceResource[]; isBuilding: boolean; @@ -108,7 +109,8 @@ export const TemplateVersionEditor: FC = ({ canPublish, template, templateVersion, - defaultFileTree, + fileTree, + onFileTreeChange, onPreview, onPublish, onConfirmPublish, @@ -133,7 +135,6 @@ export const TemplateVersionEditor: FC = ({ const navigate = useNavigate(); const getLink = useLinks(); const [selectedTab, setSelectedTab] = useState(defaultTab); - const [fileTree, setFileTree] = useState(defaultFileTree); const [createFileOpen, setCreateFileOpen] = useState(false); const [deleteFileOpen, setDeleteFileOpen] = useState(); const [renameFileOpen, setRenameFileOpen] = useState(); @@ -351,7 +352,9 @@ export const TemplateVersionEditor: FC = ({ }} checkExists={(path) => existsFile(path, fileTree)} onConfirm={(path) => { - setFileTree((fileTree) => createFile(path, fileTree, "")); + onFileTreeChange((fileTree) => + createFile(path, fileTree, ""), + ); onActivePathChange(path); setCreateFileOpen(false); setDirty(true); @@ -362,7 +365,7 @@ export const TemplateVersionEditor: FC = ({ if (!deleteFileOpen) { throw new Error("delete file must be set"); } - setFileTree((fileTree) => + onFileTreeChange((fileTree) => removeFile(deleteFileOpen, fileTree), ); setDeleteFileOpen(undefined); @@ -387,7 +390,7 @@ export const TemplateVersionEditor: FC = ({ if (!renameFileOpen) { return; } - setFileTree((fileTree) => + onFileTreeChange((fileTree) => moveFile(renameFileOpen, newPath, fileTree), ); onActivePathChange(newPath); @@ -433,7 +436,7 @@ export const TemplateVersionEditor: FC = ({ if (!activePath) { return; } - setFileTree((fileTree) => + onFileTreeChange((fileTree) => updateFile(activePath, value, fileTree), ); setDirty(true); diff --git a/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditorPage.test.tsx b/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditorPage.test.tsx index 157ec23a259..2c660fcbaa1 100644 --- a/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditorPage.test.tsx +++ b/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditorPage.test.tsx @@ -295,6 +295,78 @@ test("Preserves the currently open file path when building a template version", expect(router.state.location.search).toBe("?path=myfile.tf"); }); +test("Creating a new file opens it in the editor", async () => { + const user = userEvent.setup(); + const { router } = renderWithAuth(, { + route: `/templates/${MockTemplate.name}/versions/${MockTemplateVersion.name}/edit`, + path: "/templates/:template/versions/:version/edit", + }); + + // Wait for the default entrypoint file to load. + const editor = await screen.findByTestId("monaco-editor"); + await waitFor(() => { + expect(editor).not.toHaveValue(""); + }); + + const createButton = await screen.findByRole("button", { + name: "Create File", + }); + await user.click(createButton); + + const dialog = await screen.findByTestId("dialog"); + const pathField = within(dialog).getByLabelText("File Path"); + await user.type(pathField, "newfile.tf"); + await user.click(within(dialog).getByRole("button", { name: "Create" })); + + // The new (empty) file should be opened in the editor and the URL path + // query parameter should reflect the new file. + await waitFor(() => { + expect(screen.getByTestId("monaco-editor")).toHaveValue(""); + }); + expect(router.state.location.search).toBe("?path=newfile.tf"); +}); + +test("Renaming a file does not throw and opens the new path", async () => { + const user = userEvent.setup(); + const { router } = renderWithAuth(, { + route: `/templates/${MockTemplate.name}/versions/${MockTemplateVersion.name}/edit`, + path: "/templates/:template/versions/:version/edit", + }); + + // Wait for the default entrypoint file to load and capture its content so + // we can confirm the same content is shown after renaming. + const editor = await screen.findByTestId("monaco-editor"); + await waitFor(() => { + expect(editor).not.toHaveValue(""); + }); + if (!(editor instanceof HTMLTextAreaElement)) { + throw new Error("editor is not a textarea"); + } + const originalContent = editor.value; + + // Open the file actions menu for the active file and click Rename. + const fileActions = await screen.findByRole("button", { + name: "File actions", + }); + await user.click(fileActions); + await user.click(await screen.findByRole("menuitem", { name: /rename/i })); + + const dialog = await screen.findByTestId("dialog"); + const pathField = within(dialog).getByLabelText("File Path"); + await user.clear(pathField); + await user.type(pathField, "renamed.tf"); + await user.click(within(dialog).getByRole("button", { name: "Rename" })); + + // The renamed file should still be open with its original content and + // the URL path query parameter should reflect the new name. Previously + // this path threw "File is not a text file" because the parent's stale + // file tree fell back to the old entrypoint name. + await waitFor(() => { + expect(screen.getByTestId("monaco-editor")).toHaveValue(originalContent); + }); + expect(router.state.location.search).toBe("?path=renamed.tf"); +}); + describe.each([ { testName: "Do not ask when template version has no errors", diff --git a/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditorPage.tsx b/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditorPage.tsx index d46f06b7bac..96281a1d9ed 100644 --- a/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditorPage.tsx +++ b/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditorPage.tsx @@ -72,7 +72,7 @@ const TemplateVersionEditorPage: FC = () => { const logs = useWatchVersionLogs(activeTemplateVersion, { onDone: activeTemplateVersionQuery.refetch, }); - const { fileTree, tarFile } = useFileTree(activeTemplateVersion); + const { fileTree, setFileTree, tarFile } = useFileTree(activeTemplateVersion); const { missingVariables, setIsMissingVariablesDialogOpen, @@ -138,7 +138,10 @@ const TemplateVersionEditorPage: FC = () => { onActivePathChange={onActivePathChange} template={templateQuery.data} templateVersion={activeTemplateVersion} - defaultFileTree={fileTree} + fileTree={fileTree} + onFileTreeChange={(updater) => { + setFileTree((current) => (current ? updater(current) : current)); + }} onPreview={async (newFileTree) => { if (!tarFile) { return; @@ -244,13 +247,8 @@ const useFileTree = (templateVersion: TemplateVersion | undefined) => { ...file(templateVersion?.job.file_id ?? ""), enabled: templateVersion !== undefined, }); - const [state, setState] = useState<{ - fileTree?: FileTree; - tarFile?: TarReader; - }>({ - fileTree: undefined, - tarFile: undefined, - }); + const [fileTree, setFileTree] = useState(undefined); + const [tarFile, setTarFile] = useState(undefined); useEffect(() => { let stale = false; @@ -262,8 +260,8 @@ const useFileTree = (templateVersion: TemplateVersion | undefined) => { if (stale) { return; } - const fileTree = createTemplateVersionFileTree(tarFile); - setState({ fileTree, tarFile }); + setFileTree(createTemplateVersionFileTree(tarFile)); + setTarFile(tarFile); } catch (error) { console.error(error); toast.error("Error on initializing the editor.", { @@ -281,7 +279,7 @@ const useFileTree = (templateVersion: TemplateVersion | undefined) => { }; }, [fileQuery.data]); - return state; + return { fileTree, setFileTree, tarFile }; }; const useMissingVariables = (templateVersion: TemplateVersion | undefined) => { From 98bca81318e9a2eecc4582ae9f12a1f4179e024c Mon Sep 17 00:00:00 2001 From: Rowan Smith Date: Tue, 30 Jun 2026 02:29:29 +1000 Subject: [PATCH 081/138] fix(coderd): enforce required external auth on workspace create (#26314) (#26792) backport of #26314 for 2.34 Required external auth (`optional = false`) was only enforced by client-side preflight checks, so creating a workspace via the REST API succeeded even when the owner had never authenticated, producing a broken workspace. `createWorkspace` now validates the workspace owner's external auth server-side and returns 403 before any row is inserted or prebuild is claimed. The owner (not the initiator) is checked because build-time token injection uses their links, so this also covers admin-on-behalf-of creates and prebuild claims. Use `optional = true` to allow pre-provisioning for unauthenticated users. Fixes PLAT-241. > This PR was generated by Coder Agents on behalf of @dylanhuff-at-coder. Co-authored-by: dylanhuff-at-coder --- coderd/templateversions.go | 35 +++-- coderd/workspacebuilds_test.go | 7 +- coderd/workspaces.go | 66 +++++++++ coderd/workspaces_test.go | 241 +++++++++++++++++++++++++++++++++ 4 files changed, 335 insertions(+), 14 deletions(-) diff --git a/coderd/templateversions.go b/coderd/templateversions.go index ef7f6e08996..682a7bb0b1b 100644 --- a/coderd/templateversions.go +++ b/coderd/templateversions.go @@ -31,6 +31,7 @@ import ( "github.com/coder/coder/v2/coderd/dynamicparameters" "github.com/coder/coder/v2/coderd/externalauth" "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/httpapi/httperror" "github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/coderd/provisionerdserver" "github.com/coder/coder/v2/coderd/rbac" @@ -337,14 +338,28 @@ func (api *API) templateVersionExternalAuth(rw http.ResponseWriter, r *http.Requ templateVersion = httpmw.TemplateVersionParam(r) ) + providers, err := api.templateVersionExternalAuthForUser(ctx, templateVersion, apiKey.UserID) + if err != nil { + httperror.WriteResponseError(ctx, rw, err) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, providers) +} + +// templateVersionExternalAuthForUser returns the external auth providers +// referenced by the template version, with Authenticated reporting whether +// the given user has a usable token for each provider. Failures are returned +// as httperror response errors suitable for writing directly to an API +// response. +func (api *API) templateVersionExternalAuthForUser(ctx context.Context, templateVersion database.TemplateVersion, userID uuid.UUID) ([]codersdk.TemplateVersionExternalAuth, error) { var rawProviders []database.ExternalAuthProvider err := json.Unmarshal(templateVersion.ExternalAuthProviders, &rawProviders) if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + return nil, httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{ Message: "Internal error reading auth config from database", Detail: err.Error(), }) - return } providers := make([]codersdk.TemplateVersionExternalAuth, 0) @@ -357,21 +372,19 @@ func (api *API) templateVersionExternalAuth(rw http.ResponseWriter, r *http.Requ } } if config == nil { - httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{ + return nil, httperror.NewResponseError(http.StatusNotFound, codersdk.Response{ Message: fmt.Sprintf("The template version references a Git auth provider %q that no longer exists.", rawProvider.ID), Detail: "You'll need to update the template version to use a different provider.", }) - return } // This is the URL that will redirect the user with a state token. redirectURL, err := api.AccessURL.Parse(fmt.Sprintf("/external-auth/%s", config.ID)) if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + return nil, httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{ Message: "Failed to parse access URL.", Detail: err.Error(), }) - return } provider := codersdk.TemplateVersionExternalAuth{ @@ -385,7 +398,7 @@ func (api *API) templateVersionExternalAuth(rw http.ResponseWriter, r *http.Requ authLink, err := api.Database.GetExternalAuthLink(ctx, database.GetExternalAuthLinkParams{ ProviderID: config.ID, - UserID: apiKey.UserID, + UserID: userID, }) // If there isn't an auth link, then the user just isn't authenticated. if errors.Is(err, sql.ErrNoRows) { @@ -393,27 +406,25 @@ func (api *API) templateVersionExternalAuth(rw http.ResponseWriter, r *http.Requ continue } if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + return nil, httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{ Message: "Internal error fetching external auth link.", Detail: err.Error(), }) - return } _, err = config.RefreshToken(ctx, api.Database, authLink) if err != nil && !externalauth.IsInvalidTokenError(err) { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + return nil, httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{ Message: "Failed to refresh external auth token.", Detail: err.Error(), }) - return } provider.Authenticated = err == nil providers = append(providers, provider) } - httpapi.Write(ctx, rw, http.StatusOK, providers) + return providers, nil } // @Summary Get template variables by template version diff --git a/coderd/workspacebuilds_test.go b/coderd/workspacebuilds_test.go index 800076eaffc..96a70a43dae 100644 --- a/coderd/workspacebuilds_test.go +++ b/coderd/workspacebuilds_test.go @@ -1334,7 +1334,10 @@ func TestWorkspaceDeleteSuspendedUser(t *testing.T) { template := coderdtest.CreateTemplate(t, client, first.OrganizationID, version.ID) workspace := coderdtest.CreateWorkspace(t, client, template.ID) coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) - require.Equal(t, 1, validateCalls) // Ensure the external link is working + // Ensure the external link is working. Workspace creation validates the + // owner's required external auth, and the build's token injection + // validates it again. + require.Equal(t, 2, validateCalls) // Suspend the user ctx := testutil.Context(t, testutil.WaitLong) @@ -1348,7 +1351,7 @@ func TestWorkspaceDeleteSuspendedUser(t *testing.T) { }) require.NoError(t, err) build = coderdtest.AwaitWorkspaceBuildJobCompleted(t, owner, build.ID) - require.Equal(t, 2, validateCalls) + require.Equal(t, 3, validateCalls) require.Equal(t, codersdk.WorkspaceStatusDeleted, build.Status) } diff --git a/coderd/workspaces.go b/coderd/workspaces.go index c5fd0cde3d7..5c8a3108a08 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -9,6 +9,7 @@ import ( "net/http" "slices" "strconv" + "strings" "time" "github.com/dustin/go-humanize" @@ -595,6 +596,27 @@ func createWorkspace( }) } + // Required external auth is otherwise only enforced by client-side preflight + // checks in the CLI and UI, so API-created workspaces must be validated here + // before any workspace row is inserted or prebuilt workspace is claimed. + templateVersionID := req.TemplateVersionID + if templateVersionID == uuid.Nil { + templateVersionID = template.ActiveVersionID + } + templateVersion, err := api.Database.GetTemplateVersionByID(ctx, templateVersionID) + if err != nil { + if httpapi.Is404Error(err) { + return codersdk.Workspace{}, httperror.ErrResourceNotFound + } + return codersdk.Workspace{}, httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching template version.", + Detail: err.Error(), + }) + } + if err := api.requireWorkspaceOwnerExternalAuth(ctx, templateVersion, owner.ID); err != nil { + return codersdk.Workspace{}, err + } + dbAutostartSchedule, err := validWorkspaceSchedule(req.AutostartSchedule) if err != nil { return codersdk.Workspace{}, httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{ @@ -892,6 +914,50 @@ func createWorkspace( return w, nil } +// requireWorkspaceOwnerExternalAuth returns a 403 response error when the +// workspace owner has not authenticated with every required (non-optional) +// external auth provider referenced by the template version. Token injection +// at build time uses the owner's external auth links, so the owner is the +// subject of the check even when another user initiates the build. +func (api *API) requireWorkspaceOwnerExternalAuth(ctx context.Context, templateVersion database.TemplateVersion, ownerID uuid.UUID) error { + //nolint:gocritic // System access is required to validate the workspace owner's external auth links because admins and API clients may create workspaces for other users. + providers, err := api.templateVersionExternalAuthForUser(dbauthz.AsSystemRestricted(ctx), templateVersion, ownerID) + if err != nil { + return err + } + + var ( + missingNames []string + validations []codersdk.ValidationError + ) + for _, provider := range providers { + if provider.Optional || provider.Authenticated { + continue + } + name := provider.DisplayName + if name == "" { + name = provider.ID + } + missingNames = append(missingNames, name) + validations = append(validations, codersdk.ValidationError{ + Field: "external_auth", + Detail: provider.ID, + }) + } + if len(missingNames) == 0 { + return nil + } + + return httperror.NewResponseError(http.StatusForbidden, codersdk.Response{ + Message: "External authentication is required to create a workspace with this template.", + Detail: fmt.Sprintf( + "The workspace owner must authenticate with the following external auth providers: %s.", + strings.Join(missingNames, ", "), + ), + Validations: validations, + }) +} + func requestTemplate(ctx context.Context, req codersdk.CreateWorkspaceRequest, db database.Store) (database.Template, error) { // If we were given a `TemplateVersionID`, we need to determine the `TemplateID` from it. templateID := req.TemplateID diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 78d1a45e87d..00524c0f564 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -8,6 +8,8 @@ import ( "fmt" "math" "net/http" + "net/http/httptest" + "regexp" "slices" "strings" "testing" @@ -31,6 +33,7 @@ import ( "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/database/dbtime" + "github.com/coder/coder/v2/coderd/externalauth" "github.com/coder/coder/v2/coderd/notifications" "github.com/coder/coder/v2/coderd/notifications/notificationstest" "github.com/coder/coder/v2/coderd/provisionerdserver" @@ -1466,6 +1469,244 @@ func TestPostWorkspacesByOrganization(t *testing.T) { }) } +func TestCreateWorkspaceExternalAuth(t *testing.T) { + t.Parallel() + + // The expected 403 message returned by createWorkspace when the workspace + // owner is missing required external auth. + const externalAuthRequiredMessage = "External authentication is required to create a workspace with this template." + + // externalAuthVersion returns echo responses for a template version whose + // graph references the given external auth providers. + externalAuthVersion := func(providers ...*proto.ExternalAuthProviderResource) *echo.Responses { + return &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionGraph: []*proto.Response{{ + Type: &proto.Response_Graph{ + Graph: &proto.GraphComplete{ + ExternalAuthProviders: providers, + }, + }, + }}, + } + } + + t.Run("RequiredAuthMissing", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + ExternalAuthConfigs: []*externalauth.Config{{ + InstrumentedOAuth2Config: &testutil.OAuth2Config{}, + ID: "github", + Regex: regexp.MustCompile(`github\.com`), + Type: codersdk.EnhancedExternalAuthProviderGitHub.String(), + DisplayName: "GitHub", + }}, + }) + first := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, first.OrganizationID, + externalAuthVersion(&proto.ExternalAuthProviderResource{Id: "github"})) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, first.OrganizationID, version.ID) + memberClient, member := coderdtest.CreateAnotherUser(t, client, first.OrganizationID) + + ctx := testutil.Context(t, testutil.WaitLong) + + req := codersdk.CreateWorkspaceRequest{ + TemplateID: template.ID, + Name: coderdtest.RandomUsername(t), + } + _, err := memberClient.CreateUserWorkspace(ctx, codersdk.Me, req) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusForbidden, apiErr.StatusCode()) + require.Equal(t, externalAuthRequiredMessage, apiErr.Message) + require.Equal(t, "The workspace owner must authenticate with the following external auth providers: GitHub.", apiErr.Detail) + require.Equal(t, []codersdk.ValidationError{{ + Field: "external_auth", + Detail: "github", + }}, apiErr.Validations) + + // The rejection must happen before any workspace row is inserted. + _, err = memberClient.WorkspaceByOwnerAndName(ctx, codersdk.Me, req.Name, codersdk.WorkspaceOptions{}) + apiErr = nil + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusNotFound, apiErr.StatusCode()) + + // Authenticating with the provider lifts the rejection. + resp := coderdtest.RequestExternalAuthCallback(t, "github", memberClient) + _ = resp.Body.Close() + require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) + + workspace, err := memberClient.CreateUserWorkspace(ctx, codersdk.Me, req) + require.NoError(t, err) + require.Equal(t, member.ID, workspace.OwnerID) + }) + + t.Run("OwnerVsInitiator", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + ExternalAuthConfigs: []*externalauth.Config{{ + InstrumentedOAuth2Config: &testutil.OAuth2Config{}, + ID: "github", + Regex: regexp.MustCompile(`github\.com`), + Type: codersdk.EnhancedExternalAuthProviderGitHub.String(), + DisplayName: "GitHub", + }}, + }) + first := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, first.OrganizationID, + externalAuthVersion(&proto.ExternalAuthProviderResource{Id: "github"})) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, first.OrganizationID, version.ID) + memberClient, member := coderdtest.CreateAnotherUser(t, client, first.OrganizationID) + + ctx := testutil.Context(t, testutil.WaitLong) + + // The initiating admin is authenticated with the provider, but the + // workspace owner (the member) is not. Token injection at build time + // uses the owner's links, so the owner's auth state is what matters. + resp := coderdtest.RequestExternalAuthCallback(t, "github", client) + _ = resp.Body.Close() + require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) + + req := codersdk.CreateWorkspaceRequest{ + TemplateID: template.ID, + Name: coderdtest.RandomUsername(t), + } + _, err := client.CreateUserWorkspace(ctx, member.Username, req) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusForbidden, apiErr.StatusCode()) + require.Equal(t, externalAuthRequiredMessage, apiErr.Message) + + // Once the owner authenticates, the same create succeeds. + resp = coderdtest.RequestExternalAuthCallback(t, "github", memberClient) + _ = resp.Body.Close() + require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) + + workspace, err := client.CreateUserWorkspace(ctx, member.Username, req) + require.NoError(t, err) + require.Equal(t, member.ID, workspace.OwnerID) + }) + + t.Run("OptionalProvider", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + ExternalAuthConfigs: []*externalauth.Config{{ + InstrumentedOAuth2Config: &testutil.OAuth2Config{}, + ID: "github", + Regex: regexp.MustCompile(`github\.com`), + Type: codersdk.EnhancedExternalAuthProviderGitHub.String(), + DisplayName: "GitHub", + }}, + }) + first := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, first.OrganizationID, + externalAuthVersion(&proto.ExternalAuthProviderResource{Id: "github", Optional: true})) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, first.OrganizationID, version.ID) + memberClient, member := coderdtest.CreateAnotherUser(t, client, first.OrganizationID) + + ctx := testutil.Context(t, testutil.WaitLong) + + // Optional providers must not block creation even when the owner has + // never authenticated with them. + workspace, err := memberClient.CreateUserWorkspace(ctx, codersdk.Me, codersdk.CreateWorkspaceRequest{ + TemplateID: template.ID, + Name: coderdtest.RandomUsername(t), + }) + require.NoError(t, err) + require.Equal(t, member.ID, workspace.OwnerID) + }) + + t.Run("InvalidToken", func(t *testing.T) { + t.Parallel() + // The validation endpoint always reports the token as revoked. The + // external auth callback stores the link without validating it, so the + // link row exists but RefreshToken classifies it as invalid. + validateSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + t.Cleanup(validateSrv.Close) + client := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + ExternalAuthConfigs: []*externalauth.Config{{ + InstrumentedOAuth2Config: &testutil.OAuth2Config{}, + ID: "github", + Regex: regexp.MustCompile(`github\.com`), + Type: codersdk.EnhancedExternalAuthProviderGitHub.String(), + DisplayName: "GitHub", + ValidateURL: validateSrv.URL, + }}, + }) + first := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, first.OrganizationID, + externalAuthVersion(&proto.ExternalAuthProviderResource{Id: "github"})) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, first.OrganizationID, version.ID) + memberClient, _ := coderdtest.CreateAnotherUser(t, client, first.OrganizationID) + + // Create the external auth link for the owner. + resp := coderdtest.RequestExternalAuthCallback(t, "github", memberClient) + _ = resp.Body.Close() + require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) + + ctx := testutil.Context(t, testutil.WaitLong) + + // A link that fails validation counts as unauthenticated. + _, err := memberClient.CreateUserWorkspace(ctx, codersdk.Me, codersdk.CreateWorkspaceRequest{ + TemplateID: template.ID, + Name: coderdtest.RandomUsername(t), + }) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusForbidden, apiErr.StatusCode()) + require.Equal(t, externalAuthRequiredMessage, apiErr.Message) + require.Equal(t, []codersdk.ValidationError{{ + Field: "external_auth", + Detail: "github", + }}, apiErr.Validations) + }) + + t.Run("DisplayNameFallback", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + ExternalAuthConfigs: []*externalauth.Config{{ + InstrumentedOAuth2Config: &testutil.OAuth2Config{}, + ID: "fallback-provider", + Regex: regexp.MustCompile(`fallback\.example\.com`), + Type: codersdk.EnhancedExternalAuthProviderGitHub.String(), + }}, + }) + first := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, first.OrganizationID, + externalAuthVersion(&proto.ExternalAuthProviderResource{Id: "fallback-provider"})) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, first.OrganizationID, version.ID) + memberClient, _ := coderdtest.CreateAnotherUser(t, client, first.OrganizationID) + + ctx := testutil.Context(t, testutil.WaitLong) + + // Without a DisplayName, the response falls back to the provider ID. + _, err := memberClient.CreateUserWorkspace(ctx, codersdk.Me, codersdk.CreateWorkspaceRequest{ + TemplateID: template.ID, + Name: coderdtest.RandomUsername(t), + }) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusForbidden, apiErr.StatusCode()) + require.Equal(t, externalAuthRequiredMessage, apiErr.Message) + require.Contains(t, apiErr.Detail, "fallback-provider") + require.Len(t, apiErr.Validations, 1) + require.Equal(t, "external_auth", apiErr.Validations[0].Field) + require.Equal(t, "fallback-provider", apiErr.Validations[0].Detail) + }) +} + func TestWorkspaceByOwnerAndName(t *testing.T) { t.Parallel() t.Run("NotFound", func(t *testing.T) { From 9513245d549582e71e2b3be5b699ecbf2a100aa8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 13:18:24 -0400 Subject: [PATCH 082/138] fix(site): add a custom copy/paste menu to the web terminal (#26015) (#26818) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport of https://github.com/coder/coder/pull/26015 Original PR: #26015 — fix(site): add a custom copy/paste menu to the web terminal Merge commit: 5ab25b3ff6affb9326e8af6f23142b1d7fcfcddb Requested by: @uzair-coder07 Co-authored-by: Jaayden Halko Co-authored-by: Garrett Delfosse --- site/src/hooks/useClipboard.test.tsx | 40 ++++++++++ site/src/hooks/useClipboard.ts | 26 +++++- .../modules/terminal/WorkspaceTerminal.tsx | 80 ++++++++++++++++--- .../TerminalPage/TerminalPage.stories.tsx | 47 +++++++++++ 4 files changed, 180 insertions(+), 13 deletions(-) diff --git a/site/src/hooks/useClipboard.test.tsx b/site/src/hooks/useClipboard.test.tsx index dde538614cc..a2a4969c915 100644 --- a/site/src/hooks/useClipboard.test.tsx +++ b/site/src/hooks/useClipboard.test.tsx @@ -157,6 +157,8 @@ describe.each(secureContextValues)("useClipboard - secure: %j", (isSecure) => { clipboard: mockClipboard, })); + vi.stubGlobal("isSecureContext", isSecure); + vi.spyOn(console, "error").mockImplementation((errorValue, ...rest) => { const canIgnore = errorValue instanceof Error && @@ -172,6 +174,7 @@ describe.each(secureContextValues)("useClipboard - secure: %j", (isSecure) => { vi.runAllTimers(); vi.useRealTimers(); vi.resetAllMocks(); + vi.unstubAllGlobals(); global.document.execCommand = originalExecCommand; // Still have to reset the mock clipboard state because the same mock values @@ -299,6 +302,43 @@ describe.each(secureContextValues)("useClipboard - secure: %j", (isSecure) => { expect(result.current.copyToClipboard).toBe(initialCopy); }); + it("Reads back text that was copied through the hook", async () => { + const textToCopy = "wolves"; + const { result } = renderUseClipboard(); + await assertClipboardUpdateLifecycle(result, textToCopy); + + const readText = await act(() => result.current.readFromClipboard()); + expect(readText).toEqual(textToCopy); + }); + + it("Surfaces read failures in secure contexts and falls back to the cached value otherwise", async () => { + const textToCopy = "otters"; + const { result } = renderUseClipboard(); + await assertClipboardUpdateLifecycle(result, textToCopy); + + if (isSecure) { + // A failed or denied read must surface instead of silently pasting a + // stale cached selection. + setSimulateFailure(true); + await expect( + act(async () => { + await result.current.readFromClipboard(); + }), + ).rejects.toThrow(); + } else { + // Insecure contexts cannot read the system clipboard, so paste falls + // back to the last value copied within Coder. + const readText = await act(() => result.current.readFromClipboard()); + expect(readText).toEqual(textToCopy); + } + }); + + it("Returns an empty string when nothing has been copied yet", async () => { + const { result } = renderUseClipboard(); + const readText = await act(() => result.current.readFromClipboard()); + expect(readText).toEqual(""); + }); + it("Always uses the most up-to-date onError prop", async () => { const initialOnError = vi.fn(); const { result, rerender } = renderUseClipboard({ diff --git a/site/src/hooks/useClipboard.ts b/site/src/hooks/useClipboard.ts index 11c2518ba80..fdbb2d9235b 100644 --- a/site/src/hooks/useClipboard.ts +++ b/site/src/hooks/useClipboard.ts @@ -13,6 +13,13 @@ export type UseClipboardInput = Readonly<{ export type UseClipboardResult = Readonly<{ copyToClipboard: (textToCopy: string) => Promise; + + /** + * Reads text from the clipboard. When the asynchronous Clipboard API is + * available (secure contexts), it returns the live clipboard contents. + */ + readFromClipboard: () => Promise; + error: Error | undefined; /** @@ -44,6 +51,7 @@ export const useClipboard = ( const [showCopiedSuccess, setShowCopiedSuccess] = useState(false); const [error, setError] = useState(); const timeoutIdRef = useRef(undefined); + const lastCopiedTextRef = useRef(""); useEffect(() => { return () => window.clearTimeout(timeoutIdRef.current); @@ -52,6 +60,7 @@ export const useClipboard = ( const copyToClipboard = useCallback( async (textToCopy: string) => { const markSuccess = () => { + lastCopiedTextRef.current = textToCopy; setShowCopiedSuccess(true); if (clearErrorOnSuccess) { setError(undefined); @@ -84,7 +93,22 @@ export const useClipboard = ( [onError, clearErrorOnSuccess], ); - return { showCopiedSuccess, error, copyToClipboard }; + const readFromClipboard = useCallback(async (): Promise => { + // Insecure (HTTP) contexts and older browsers cannot read the system + // clipboard, so fall back to the last value copied within Coder. In a + // secure context, surface read failures (such as a denied permission) + // instead of silently pasting a stale cached selection. + if ( + window.isSecureContext && + typeof navigator.clipboard?.readText === "function" + ) { + return await navigator.clipboard.readText(); + } + + return lastCopiedTextRef.current; + }, []); + + return { showCopiedSuccess, error, copyToClipboard, readFromClipboard }; }; /** diff --git a/site/src/modules/terminal/WorkspaceTerminal.tsx b/site/src/modules/terminal/WorkspaceTerminal.tsx index 3cad12fd462..c4d95d1c2f2 100644 --- a/site/src/modules/terminal/WorkspaceTerminal.tsx +++ b/site/src/modules/terminal/WorkspaceTerminal.tsx @@ -15,14 +15,22 @@ import { useRef, useState, } from "react"; +import { toast } from "sonner"; import { ExponentialBackoff, type Websocket, WebsocketBuilder, WebsocketEvent, } from "websocket-ts"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from "#/components/ContextMenu/ContextMenu"; import { useClipboard } from "#/hooks/useClipboard"; import { cn } from "#/utils/cn"; +import { isMac } from "#/utils/platform"; import { terminalWebsocketUrl } from "#/utils/terminal"; import type { ConnectionStatus } from "./types"; @@ -93,7 +101,36 @@ export const WorkspaceTerminal = ({ onStatusChange?.(status); }); const [terminal, setTerminal] = useState(); - const { copyToClipboard } = useClipboard(); + const { copyToClipboard, readFromClipboard } = useClipboard(); + + const [hasSelection, setHasSelection] = useState(false); + const handleContextMenuOpenChange = (open: boolean) => { + if (open) { + setHasSelection(Boolean(terminal?.hasSelection())); + } + }; + const copyTerminalSelection = () => { + const selection = terminal?.getSelection(); + if (selection) { + void copyToClipboard(selection); + } + }; + const pasteIntoTerminal = async () => { + if (!terminal) { + return; + } + try { + const text = await readFromClipboard(); + if (text) { + terminal.paste(text); + } + } catch (error) { + toast.error("Failed to paste from clipboard"); + console.error(error); + } finally { + terminal.focus(); + } + }; const [hasBeenVisible, setHasBeenVisible] = useState(false); if (isVisible && !hasBeenVisible) { @@ -193,7 +230,6 @@ export const WorkspaceTerminal = ({ }), ); - const isMac = navigator.platform.match("Mac"); const copySelection = () => { const selection = nextTerminal.getSelection(); if (selection) { @@ -219,7 +255,7 @@ export const WorkspaceTerminal = ({ // By default this usually launches the browser dev tools, but users // expect this keybinding to copy when in the context of the web terminal. if ( - (isMac ? event.metaKey : event.ctrlKey) && + (isMac() ? event.metaKey : event.ctrlKey) && event.shiftKey && event.key === "C" ) { @@ -514,15 +550,35 @@ export const WorkspaceTerminal = ({ background-color: hsl(var(--surface-quaternary)); } `} -
+ + +
+ + { + event.preventDefault(); + terminal?.focus(); + }} + > + + Copy + + void pasteIntoTerminal()}> + Paste + + + ); }; diff --git a/site/src/pages/TerminalPage/TerminalPage.stories.tsx b/site/src/pages/TerminalPage/TerminalPage.stories.tsx index 947ad728721..e15d192f8ab 100644 --- a/site/src/pages/TerminalPage/TerminalPage.stories.tsx +++ b/site/src/pages/TerminalPage/TerminalPage.stories.tsx @@ -1,4 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, waitFor, within } from "storybook/test"; import { reactRouterOutlet, reactRouterParameters, @@ -23,6 +24,7 @@ import { MockWorkspaceAgent, } from "#/testHelpers/entities"; import { withWebSocket } from "#/testHelpers/storybook"; +import { isMac } from "#/utils/platform"; import TerminalPage from "./TerminalPage"; const createWorkspaceWithAgent = (lifecycle: WorkspaceAgentLifecycle) => { @@ -170,6 +172,51 @@ export const Ready: Story = { }, }; +export const RightClickMenu: Story = { + decorators: [withWebSocket], + parameters: { + ...meta.parameters, + webSocket: [ + { + event: "message", + data: "$ echo hello", + }, + ], + queries: [...meta.parameters.queries, createWorkspaceWithAgent("ready")], + }, + play: async ({ canvasElement }) => { + if (isMac()) { + return; + } + + const terminal = await waitFor(() => { + const element = canvasElement.querySelector(".xterm"); + if (!element) { + throw new Error("terminal has not rendered yet"); + } + return element; + }); + + const rect = terminal.getBoundingClientRect(); + terminal.dispatchEvent( + new MouseEvent("contextmenu", { + bubbles: true, + cancelable: true, + clientX: rect.left + rect.width / 2, + clientY: rect.top + rect.height / 2, + }), + ); + + const body = within(document.body); + await expect( + await body.findByRole("menuitem", { name: "Copy" }), + ).toBeInTheDocument(); + await expect( + body.getByRole("menuitem", { name: "Paste" }), + ).toBeInTheDocument(); + }, +}; + export const StartError: Story = { decorators: [withWebSocket], parameters: { From 8266eb0fc0cf458a191cb36f016d83d181ad4b65 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 13:29:34 -0400 Subject: [PATCH 083/138] feat: add INSECURE oidc email fallback flag for IdP brokers (#26751) (#26820) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport of https://github.com/coder/coder/pull/26751 Original PR: #26751 — feat: add INSECURE oidc email fallback flag for IdP brokers Merge commit: ad355aeaa9a10d44c8b910236d6e293e321e9d2f Requested by: @uzair-coder07 > [!WARNING] > The automatic cherry-pick had conflicts. > Please resolve manually by cherry-picking the original merge commit: > > ``` > git fetch origin backport/26751-to-2.34 > git checkout backport/26751-to-2.34 > git reset --hard origin/release/2.34 > git cherry-pick -x -m1 ad355aeaa9a10d44c8b910236d6e293e321e9d2f > # resolve conflicts, then push > ``` --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Steven Masley --- cli/server.go | 1 + cli/testdata/server-config.yaml.golden | 7 + coderd/apidoc/docs.go | 4 + coderd/apidoc/swagger.json | 4 + coderd/userauth.go | 40 +++++- coderd/userauth_test.go | 170 +++++++++++++++++++++++++ codersdk/deployment.go | 21 +++ docs/reference/api/general.md | 1 + docs/reference/api/schemas.md | 4 + site/src/api/typesGenerated.ts | 8 ++ 10 files changed, 254 insertions(+), 6 deletions(-) diff --git a/cli/server.go b/cli/server.go index 31a26794ae2..a5510c9f2ea 100644 --- a/cli/server.go +++ b/cli/server.go @@ -278,6 +278,7 @@ func createOIDCConfig(ctx context.Context, logger slog.Logger, vals *codersdk.De IconURL: vals.OIDC.IconURL.String(), IgnoreEmailVerified: vals.OIDC.IgnoreEmailVerified.Value(), PKCEMethods: pkceSupport.CodeChallengeMethodsSupported, + EmailFallback: vals.OIDC.EmailFallback.Value(), }, nil } diff --git a/cli/testdata/server-config.yaml.golden b/cli/testdata/server-config.yaml.golden index ba48a47cad4..0135f214c56 100644 --- a/cli/testdata/server-config.yaml.golden +++ b/cli/testdata/server-config.yaml.golden @@ -434,6 +434,13 @@ oidc: # next login. # (default: true, type: bool) oidc-repair-links: true + # INSECURE: Allow OIDC logins to fall back to email-based matching when the + # linked_id (issuer+subject) does not match an existing user link. Required for + # IdP brokers that do not issue a stable 'sub' for the same user across + # connections. The existing user_link's linked_id is preserved on fallback. Only + # enable if you understand and accept the risk. + # (default: , type: bool) + dangerousOidcEmailFallback: false # Telemetry is critical to our ability to improve Coder. We strip all personal # information before sending data to our servers. Please only disable telemetry # when required by your organization's security policy. diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 5ce93c256ad..d6e71c20b87 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -20736,6 +20736,10 @@ const docTemplate = `{ "type": "string" } }, + "email_fallback": { + "description": "EmailFallback allows OIDC logins to fall back to email-based matching\nwhen the ` + "`" + `linked_id` + "`" + ` (issuer+subject) does not match an existing user\nlink. INSECURE: weakens the linked_id check. It exists for IdP\nbrokers that do not issue a stable ` + "`" + `sub` + "`" + ` for the same user across\nconnections.", + "type": "boolean" + }, "email_field": { "type": "string" }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index ec00fd231e1..41b65fd52b6 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -18935,6 +18935,10 @@ "type": "string" } }, + "email_fallback": { + "description": "EmailFallback allows OIDC logins to fall back to email-based matching\nwhen the `linked_id` (issuer+subject) does not match an existing user\nlink. INSECURE: weakens the linked_id check. It exists for IdP\nbrokers that do not issue a stable `sub` for the same user across\nconnections.", + "type": "boolean" + }, "email_field": { "type": "string" }, diff --git a/coderd/userauth.go b/coderd/userauth.go index c8f329f5cf4..1684da9f762 100644 --- a/coderd/userauth.go +++ b/coderd/userauth.go @@ -1037,7 +1037,7 @@ func (api *API) userOAuth2Github(rw http.ResponseWriter, r *http.Request) { }) return } - user, link, err := findLinkedUser(ctx, api.Database, githubLinkedID(ghUser), database.LoginTypeGithub, verifiedEmail.GetEmail()) + user, link, err := findLinkedUser(ctx, api.Database, githubLinkedID(ghUser), database.LoginTypeGithub, false, verifiedEmail.GetEmail()) if errors.Is(err, errLinkedIDAlreadyBound) { logger.Warn(ctx, "oauth2: blocked login, account already linked to different identity", slog.F("email", verifiedEmail.GetEmail()), @@ -1187,6 +1187,12 @@ type OIDCConfig struct { // SignupsDisabledText is the text do display on the static error page. SignupsDisabledText string PKCEMethods []promoauth.Oauth2PKCEChallengeMethod + // EmailFallback, when true, allows OIDC logins to fall back to + // email-based user matching when the linked_id (issuer+subject) does + // not match an existing user link. INSECURE: weakens the linked_id + // check. Used for IdP brokers that do not issue a stable `sub` for the + // same user across connections. + EmailFallback bool } // PKCESupported is to prevent nil pointer dereference. @@ -1458,7 +1464,7 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) { } ctx = slog.With(ctx, slog.F("email", email), slog.F("username", username), slog.F("name", name)) - user, link, err := findLinkedUser(ctx, api.Database, oidcLinkedID(idToken), database.LoginTypeOIDC, email) + user, link, err := findLinkedUser(ctx, api.Database, oidcLinkedID(idToken), database.LoginTypeOIDC, api.OIDCConfig.EmailFallback, email) if errors.Is(err, errLinkedIDAlreadyBound) { logger.Warn(ctx, "oauth2: blocked login, account already linked to different identity", slog.F("email", email), @@ -1526,6 +1532,7 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) { UserInfoClaims: supplementaryClaims, MergedClaims: mergedClaims, }, + AllowInsecureLinkedIDMismatch: api.OIDCConfig.EmailFallback, }).SetInitAuditRequest(func(params *audit.RequestParams) (*audit.Request[database.User], func()) { return audit.InitRequest[database.User](rw, params) }) @@ -1679,6 +1686,13 @@ type oauthLoginParams struct { // It is used to save the user's claims on login. UserClaims database.UserLinkClaims + // AllowInsecureLinkedIDMismatch, when true, allows the login to proceed + // when the existing user_link's linked_id differs from LinkedID. The + // existing linked_id is preserved (no overwrite). INSECURE: opt-in + // escape hatch for IdP brokers that emit different subjects for the + // same user across connections. + AllowInsecureLinkedIDMismatch bool + commitLock sync.Mutex initAuditRequest func(params *audit.RequestParams) *audit.Request[database.User] commits []func() @@ -1911,7 +1925,10 @@ func (api *API) oauthLogin(r *http.Request, params *oauthLoginParams) ([]*http.C // Defense-in-depth: if a concurrent transaction backfilled // linked_id between findLinkedUser and this point, reject the // login with a 403 instead of letting it bubble up as a 500. - if link.LinkedID != "" && link.LinkedID != params.LinkedID { + // The INSECURE AllowInsecureLinkedIDMismatch escape hatch + // preserves the existing linked_id and lets the login proceed; + // the warning was already emitted by the caller. + if link.LinkedID != "" && link.LinkedID != params.LinkedID && !params.AllowInsecureLinkedIDMismatch { return &idpsync.HTTPError{ Code: http.StatusForbidden, Msg: "Account already linked", @@ -2162,7 +2179,16 @@ var errLinkedIDAlreadyBound = xerrors.New("user account is already linked to a d // legacy links (empty linked_id) only. If the user found by email // already has a link with a different linked_id, errLinkedIDAlreadyBound // is returned to prevent account takeover via IdP email reuse. -func findLinkedUser(ctx context.Context, db database.Store, linkedID string, loginType database.LoginType, emails ...string) (database.User, database.UserLink, error) { +// +// When allowInsecureLinkedIDMismatch is true, the linked_id mismatch +// check is skipped and the email fallback resolves the login even when +// the existing link's linked_id differs from the current login's. The +// existing linked_id is left intact (no overwrite). This is an INSECURE +// opt-in for IdP brokers that do not issue a stable `sub` for the same +// user across connections. +// +//nolint:revive // allowInsecureLinkedIDMismatch is intentionally a control flag; it gates an INSECURE opt-in. +func findLinkedUser(ctx context.Context, db database.Store, linkedID string, loginType database.LoginType, allowInsecureLinkedIDMismatch bool, emails ...string) (database.User, database.UserLink, error) { var ( user database.User link database.UserLink @@ -2215,8 +2241,10 @@ func findLinkedUser(ctx context.Context, db database.Store, linkedID string, log // Block email fallback when an existing link has a different linked_id. // Prevents account takeover via IdP email reuse; first-time and legacy - // (empty linked_id) links pass through. - if err == nil && link.LinkedID != "" && link.LinkedID != linkedID { + // (empty linked_id) links pass through. The INSECURE + // allowInsecureLinkedIDMismatch escape hatch keeps the existing link + // (and its original linked_id) and lets the login proceed. + if err == nil && link.LinkedID != "" && link.LinkedID != linkedID && !allowInsecureLinkedIDMismatch { return database.User{}, database.UserLink{}, errLinkedIDAlreadyBound } diff --git a/coderd/userauth_test.go b/coderd/userauth_test.go index d9f5dabf49e..3833dc64233 100644 --- a/coderd/userauth_test.go +++ b/coderd/userauth_test.go @@ -2002,6 +2002,176 @@ func TestUserOIDC(t *testing.T) { "linked_id must not be modified when the login is blocked") }) + // Tests the INSECURE OIDC email fallback escape hatch. When the + // deployment flag is set, an OIDC login whose subject differs from an + // existing user_link's linked_id but whose email matches must be + // allowed through. The original linked_id is preserved (no overwrite), + // so the user can keep logging in with either subject. + t.Run("OIDCInsecureEmailFallbackAllowed", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + + fake := oidctest.NewFakeIDP(t, + oidctest.WithRefresh(func(_ string) error { + return xerrors.New("refreshing token should never occur") + }), + oidctest.WithServing(), + ) + cfg := fake.OIDCConfig(t, nil, func(cfg *coderd.OIDCConfig) { + cfg.AllowSignups = true + cfg.EmailFallback = true + }) + + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) + owner, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{ + OIDCConfig: cfg, + Logger: &logger, + }) + + // Seed a user whose link records the IdP's first connection. + user := dbgen.User(t, db, database.User{ + LoginType: database.LoginTypeOIDC, + }) + originalLinkedID := fake.IssuerURL().String() + "||" + "first-connection-sub" + dbgen.UserLink(t, db, database.UserLink{ + UserID: user.ID, + LoginType: database.LoginTypeOIDC, + LinkedID: originalLinkedID, + }) + + // Login with a different subject (the broker emitted a new `sub` + // for the same user) but the same email. With EmailFallback + // enabled the email match resolves the login despite the linked_id + // mismatch. + client, resp := fake.AttemptLogin(t, owner, jwt.MapClaims{ + "email": user.Email, + "sub": "second-connection-sub", + }) + require.Equal(t, http.StatusOK, resp.StatusCode, + "insecure email fallback must allow login with a mismatched subject") + + me, err := client.User(ctx, "me") + require.NoError(t, err) + require.Equal(t, user.ID, me.ID, + "should authenticate as the existing user") + + // The original linked_id must be preserved, not overwritten by the + // new subject. This keeps the next login from the original subject + // (which hits the primary linked_id path) working. + link, err := db.GetUserLinkByUserIDLoginType(dbauthz.AsSystemRestricted(context.Background()), database.GetUserLinkByUserIDLoginTypeParams{ + UserID: user.ID, + LoginType: database.LoginTypeOIDC, + }) + require.NoError(t, err) + require.Equal(t, originalLinkedID, link.LinkedID, + "linked_id must be preserved on insecure email fallback") + }) + + // Tests that with the INSECURE OIDC email fallback enabled, the + // original subject's login still resolves via the primary linked_id + // path after a fallback login from a different subject. The fallback + // path does not overwrite the link, so the original subject keeps + // matching directly. + t.Run("OIDCInsecureEmailFallbackPreservesOriginalLogin", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + + fake := oidctest.NewFakeIDP(t, + oidctest.WithRefresh(func(_ string) error { + return xerrors.New("refreshing token should never occur") + }), + oidctest.WithServing(), + ) + cfg := fake.OIDCConfig(t, nil, func(cfg *coderd.OIDCConfig) { + cfg.AllowSignups = true + cfg.EmailFallback = true + }) + + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) + owner, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{ + OIDCConfig: cfg, + Logger: &logger, + }) + + user := dbgen.User(t, db, database.User{ + LoginType: database.LoginTypeOIDC, + }) + const originalSub = "first-connection-sub" + originalLinkedID := fake.IssuerURL().String() + "||" + originalSub + dbgen.UserLink(t, db, database.UserLink{ + UserID: user.ID, + LoginType: database.LoginTypeOIDC, + LinkedID: originalLinkedID, + }) + + // Fallback login with a different subject. + _, resp := fake.AttemptLogin(t, owner, jwt.MapClaims{ + "email": user.Email, + "sub": "second-connection-sub", + }) + require.Equal(t, http.StatusOK, resp.StatusCode) + + // Subsequent login with the original subject must hit the primary + // linked_id match and succeed. + client, resp := fake.AttemptLogin(t, owner, jwt.MapClaims{ + "email": user.Email, + "sub": originalSub, + }) + require.Equal(t, http.StatusOK, resp.StatusCode) + + me, err := client.User(ctx, "me") + require.NoError(t, err) + require.Equal(t, user.ID, me.ID) + + link, err := db.GetUserLinkByUserIDLoginType(dbauthz.AsSystemRestricted(context.Background()), database.GetUserLinkByUserIDLoginTypeParams{ + UserID: user.ID, + LoginType: database.LoginTypeOIDC, + }) + require.NoError(t, err) + require.Equal(t, originalLinkedID, link.LinkedID, + "linked_id must stay anchored to the original subject") + }) + + // Tests that the INSECURE OIDC email fallback does NOT extend to + // signups: an attacker logging in with a brand-new email (no existing + // user) still goes through the normal signup gate. The escape hatch is + // only about resolving subject-mismatch on existing accounts. + t.Run("OIDCInsecureEmailFallbackDoesNotCreateUsers", func(t *testing.T) { + t.Parallel() + + fake := oidctest.NewFakeIDP(t, + oidctest.WithRefresh(func(_ string) error { + return xerrors.New("refreshing token should never occur") + }), + oidctest.WithServing(), + ) + cfg := fake.OIDCConfig(t, nil, func(cfg *coderd.OIDCConfig) { + cfg.AllowSignups = false + cfg.EmailFallback = true + }) + + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) + owner, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{ + OIDCConfig: cfg, + Logger: &logger, + }) + + // Seed an existing user so the deployment's user count is > 0; + // otherwise the first signup is always allowed. + dbgen.User(t, db, database.User{ + LoginType: database.LoginTypeOIDC, + }) + + // New email, no existing user. Signups are disabled, so the + // fallback flag must not let the login through. + _, resp := fake.AttemptLogin(t, owner, jwt.MapClaims{ + "email": "stranger@example.com", + "sub": "stranger-subject", + }) + require.Equal(t, http.StatusForbidden, resp.StatusCode, + "insecure email fallback must not bypass the signup gate") + }) + t.Run("OIDCConvert", func(t *testing.T) { t.Parallel() diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 7b55ab967eb..da03da6fd3a 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -957,6 +957,13 @@ type OIDCConfig struct { RedirectURL serpent.URL `json:"redirect_url" typescript:",notnull"` AutoRepairLinks serpent.Bool `json:"auto_repair_links" typescript:",notnull"` + + // EmailFallback allows OIDC logins to fall back to email-based matching + // when the `linked_id` (issuer+subject) does not match an existing user + // link. INSECURE: weakens the linked_id check. It exists for IdP + // brokers that do not issue a stable `sub` for the same user across + // connections. + EmailFallback serpent.Bool `json:"email_fallback" typescript:",notnull"` } type TelemetryConfig struct { @@ -3006,6 +3013,20 @@ func (c *DeploymentValues) Options() serpent.OptionSet { // as a flag as an escape hatch for now. Hidden: true, }, + { + Name: "OIDC Insecure Email Fallback (DANGEROUS)", + Description: "INSECURE: Allow OIDC logins to fall back to email-based matching when " + + "the linked_id (issuer+subject) does not match an existing user link. " + + "Required for IdP brokers that do not issue a stable 'sub' for the same user across connections. " + + "The existing user_link's linked_id is preserved on fallback. " + + "Only enable if you understand and accept the risk.", + Flag: "dangerous-oidc-email-fallback", + Env: "CODER_DANGEROUS_OIDC_EMAIL_FALLBACK", + YAML: "dangerousOidcEmailFallback", + Value: &c.OIDC.EmailFallback, + Group: &deploymentGroupOIDC, + Hidden: true, + }, // Telemetry settings telemetryEnable, { diff --git a/docs/reference/api/general.md b/docs/reference/api/general.md index 6518fbe0a76..a0b0421e63e 100644 --- a/docs/reference/api/general.md +++ b/docs/reference/api/general.md @@ -432,6 +432,7 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \ "email_domain": [ "string" ], + "email_fallback": true, "email_field": "string", "group_allow_list": [ "string" diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 8365a68eb1b..09919429dca 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -5952,6 +5952,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "email_domain": [ "string" ], + "email_fallback": true, "email_field": "string", "group_allow_list": [ "string" @@ -6552,6 +6553,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "email_domain": [ "string" ], + "email_fallback": true, "email_field": "string", "group_allow_list": [ "string" @@ -9030,6 +9032,7 @@ Only certain features set these fields: - FeatureManagedAgentLimit| "email_domain": [ "string" ], + "email_fallback": true, "email_field": "string", "group_allow_list": [ "string" @@ -9099,6 +9102,7 @@ Only certain features set these fields: - FeatureManagedAgentLimit| | `client_key_file` | string | false | | Client key file & ClientCertFile are used in place of ClientSecret for PKI auth. | | `client_secret` | string | false | | | | `email_domain` | array of string | false | | | +| `email_fallback` | boolean | false | | Email fallback allows OIDC logins to fall back to email-based matching when the `linked_id` (issuer+subject) does not match an existing user link. INSECURE: weakens the linked_id check. It exists for IdP brokers that do not issue a stable `sub` for the same user across connections. | | `email_field` | string | false | | | | `group_allow_list` | array of string | false | | | | `group_auto_create` | boolean | false | | | diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 7eb4793a93a..e87613eba74 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -6025,6 +6025,14 @@ export interface OIDCConfig { */ readonly redirect_url: string; readonly auto_repair_links: boolean; + /** + * EmailFallback allows OIDC logins to fall back to email-based matching + * when the `linked_id` (issuer+subject) does not match an existing user + * link. INSECURE: weakens the linked_id check. It exists for IdP + * brokers that do not issue a stable `sub` for the same user across + * connections. + */ + readonly email_fallback: boolean; } // From codersdk/parameters.go From 85d78373e9672c471f875dbb518c26873ec3512a Mon Sep 17 00:00:00 2001 From: Atif Ali Date: Tue, 30 Jun 2026 05:09:06 +0500 Subject: [PATCH 084/138] docs: add GitLab read_api scope change to ESR upgrade guide (backport #26829) (#26840) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport of #26829 to `release/2.34`. ## Problem The 2.34.0 release changed the default GitLab external auth scopes from `write_repository` to `write_repository` plus `read_api`. This was not mentioned in the upgrade guide or the release notes, which caused breakage for users upgrading from 2.29 to 2.34. ## Fix - Add the GitLab scope change to the "Changes to be Aware of" table, placed next to the related PKCE default change. - Update the "Validate external authentication" bullet in the upgrade checklist to explicitly call out adding `read_api` to GitLab OAuth applications. > 🤖 This PR was created with the help of Coder Agents, and needs a human review. 🧑💻 --- .../install/releases/esr-2.29-2.34-upgrade.md | 59 ++++++++++--------- 1 file changed, 31 insertions(+), 28 deletions(-) diff --git a/docs/install/releases/esr-2.29-2.34-upgrade.md b/docs/install/releases/esr-2.29-2.34-upgrade.md index 01380f01619..d7c22e0bfcb 100644 --- a/docs/install/releases/esr-2.29-2.34-upgrade.md +++ b/docs/install/releases/esr-2.29-2.34-upgrade.md @@ -172,33 +172,34 @@ The CLI and dashboard gained smaller but meaningful workflow improvements: The following changes introduced after 2.29 might break workflows, require manual updates, or change administrator expectations: -| Initial State (2.29 and before) | New State (2.30-2.34) | Change Required | -|-----------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| Terraform modules are downloaded during each workspace start. | Terraform modules are cached and pinned per template version. | Publish a new template version when upstream module changes should apply. Test templates that relied on fresh module downloads. See [speed up templates](../../tutorials/best-practices/speed-up-templates.md). | -| Integrations may use experimental AI Bridge endpoints under `/api/experimental/aibridge/*`. | Experimental AI Bridge endpoints were removed after AI Gateway graduated to stable routes. | Update clients to use `/api/v2/aibridge/*` routes. Review API consumers again because `/api/v2/aibridge/interceptions` is now deprecated in favor of `/api/v2/aibridge/sessions`. See the [AI Gateway API reference](../../reference/api/aibridge.md). | -| Unknown external OAuth providers did not default to PKCE. | Unknown external OAuth providers now default to PKCE. | If a provider does not support PKCE, set `CODER_EXTERNAL_AUTH__PKCE_METHODS=none`. See [external authentication](../../admin/external-auth/index.md). | -| `--secure-auth-cookie` defaulted independently from the access URL. | Secure auth cookies are enabled automatically when `CODER_ACCESS_URL` uses HTTPS. | Confirm reverse proxies send the correct scheme headers. To preserve old behavior, explicitly set `CODER_SECURE_AUTH_COOKIE=false`. | -| SFTP and SCP connections always landed in `$HOME`. | SFTP and SCP now respect the workspace agent `dir` setting. | Update scripts that relied on implicit `$HOME` paths. Prefer explicit absolute paths for file transfers. | -| `coder_agent` `dir` attribute accepted any path without warning. | `dir` is deprecated and emits a warning. Non-`$HOME`/`~` values also break [Coder Desktop file sync](../../user-guides/desktop/desktop-connect-sync.md). | Set `dir` to `$HOME` or omit it on `coder_agent` resources. The attribute still works in 2.34 but will be removed in a future release. | -| Pre-2.28 Tasks templates might still exist in older deployments. | The pre-2.28 Tasks template format is no longer supported as of 2.30. | Update Tasks templates to use `app_id` instead of the deprecated `sidebar_app` flow. See the [Tasks migration guide](../../ai-coder/tasks-migration.md). | -| Tasks is the primary AI coding workflow. | Coder Agents is the long-term replacement, and Tasks is supported through the 2.34 ESR window (into 2026). | Plan migration from the Tasks API to the Chats API and Coder Agents. See [Migrating from the Tasks API to the Chats API](../../ai-coder/agents/tasks-to-chats-migration.md). | -| AI Gateway injected MCP tools can be used for tool exposure. | Injected MCP tools are deprecated. | Move new integrations toward Coder Agents MCP server configuration or the MCP server flow. See [AI Gateway MCP](../../ai-coder/ai-gateway/mcp.md) and [MCP servers](../../ai-coder/agents/platform-controls/mcp-servers.md). | -| AI Bridge is opt-in via `CODER_AIBRIDGE_ENABLED` (default `false`). | The toggle is renamed to `CODER_AI_GATEWAY_ENABLED` and now defaults to `true`. | The in-memory AI Gateway now starts on every deployment. Set `CODER_AI_GATEWAY_ENABLED=false`, or the deprecated `CODER_AIBRIDGE_ENABLED` alias which still works, to keep the old behavior. | -| AI Gateway providers are configured with `CODER_AIBRIDGE_PROVIDER_*` or `CODER_AI_GATEWAY_PROVIDER_*` env vars. | Provider configuration is stored in the database. Env vars seed the database once on first startup, then are deprecated. | After upgrade, visit `/ai/settings` to verify seeded providers, then remove the env vars. Coderd fails to start if env vars drift from the seeded database row. See [AI Gateway providers](../../ai-coder/ai-gateway/providers.md). | -| Regular users can read their own AI Gateway interceptions. | Only owners and auditors can read AI Gateway interception data. | Update dashboards, scripts, or user workflows that expected self-service interception reads. This intentionally narrows the RBAC surface. | -| `coder groups list -o json` returns the old command output shape. | `coder groups list -o json` returns a flat structure matching other list commands. | Update scripts that parse this command output. | -| `coder tokens rm` deletes token records by default. | `coder tokens rm` expires tokens by default and keeps records for auditability. | Use `coder tokens rm --delete` only when the token record must be deleted. Update scripts that expect removed tokens to disappear from token history. | -| Deprecated Prometheus metrics are still emitted. | Deprecated Prometheus metrics were removed. | Update dashboards and alerts that use `coderd_api_workspace_latest_build_total` or `coderd_oauth2_external_requests_rate_limit_total`. Use the replacement metrics without the `_total` suffix. | -| Authenticated rate limits are effectively shared by client IP in some deployments. | Authenticated request rate limits are keyed by user. | Review monitoring and expectations for NATed users or shared proxies. Per-user limits now apply more consistently after API key precheck. | -| `coder login` can run while `CODER_SESSION_TOKEN` is set. | `coder login` errors when `CODER_SESSION_TOKEN` is set. | Unset `CODER_SESSION_TOKEN` in interactive login flows. Keep using the environment variable for non-interactive automation. | -| Workspace starts with new parameters can proceed without an explicit stop in some flows. | Workspace starts with new parameters stop the workspace before starting. | Expect downtime when applying new parameters. Update automation that assumes the workspace remains running. | -| `mode=auto` workspace links can silently create workspaces with prefilled parameters. | Users must confirm workspace auto-creation before provisioning starts. | Update Open in Coder buttons, runbooks, or internal flows that expect one-click workspace creation without a consent dialog. | -| Users with `--login-type none` are common for automation. | `--login-type none` is deprecated. | For Premium deployments, migrate automation to service accounts. For OSS deployments, use regular users with password, GitHub, or OIDC authentication. See [headless auth](../../admin/users/headless-auth.md). | -| Terminal commands can be executed from URL parameters without extra confirmation. | The dashboard requires confirmation before executing terminal commands from URLs. | Update runbooks or deep links that expected immediate terminal execution. This protects users from accidental command execution. | -| Agent SSH port forwarding is always available when the agent allows SSH. | Reverse and local port forwarding can be disabled per agent. | Review templates and IDE workflows before enabling `--block-reverse-port-forwarding` or `--block-local-port-forwarding`. See [port forwarding](../../admin/networking/port-forwarding.md). | -| `PATCH /api/v2/templates/{template}` accepts value fields for metadata updates. | Template metadata update fields are optional pointer fields in the SDK, and 304 responses were removed. | Update SDK consumers and direct API clients that patch template metadata. Send only fields that should change, including false or zero values explicitly. | -| External provisioner daemons use the 2.29 provisionerd protocol. | The provisionerd protocol changed for provisioner operations and file upload/download. | Update external provisioner daemons to the matching 2.34 protocol. The protocol reserves removed fields such as `stop_modules`, `exp_reuse_terraform_workspace`, and `user_secrets`, and adds `DownloadFile`. | -| Helm chart health probes and observability bind addresses use older chart defaults. | Readiness and liveness probes have `enabled` toggles and more fields, and Prometheus/pprof addresses are overridable. | Review custom Helm values for probe behavior and observability bindings. Prefer restricting pprof to a local address when exposing diagnostics. | +| Initial State (2.29 and before) | New State (2.30-2.34) | Change Required | +|-----------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Terraform modules are downloaded during each workspace start. | Terraform modules are cached and pinned per template version. | Publish a new template version when upstream module changes should apply. Test templates that relied on fresh module downloads. See [speed up templates](../../tutorials/best-practices/speed-up-templates.md). | +| Integrations may use experimental AI Bridge endpoints under `/api/experimental/aibridge/*`. | Experimental AI Bridge endpoints were removed after AI Gateway graduated to stable routes. | Update clients to use `/api/v2/aibridge/*` routes. Review API consumers again because `/api/v2/aibridge/interceptions` is now deprecated in favor of `/api/v2/aibridge/sessions`. See the [AI Gateway API reference](../../reference/api/aibridge.md). | +| Unknown external OAuth providers did not default to PKCE. | Unknown external OAuth providers now default to PKCE. | If a provider does not support PKCE, set `CODER_EXTERNAL_AUTH__PKCE_METHODS=none`. See [external authentication](../../admin/external-auth/index.md). | +| GitLab external auth required only the `write_repository` scope. | GitLab external auth now requires `write_repository` and `read_api` scopes by default. | Add the `read_api` scope to your GitLab OAuth application. Existing users must re-authenticate to obtain the new scope. If you set scopes explicitly via `CODER_EXTERNAL_AUTH__SCOPES`, add `read_api` there as well. See [external authentication](../../admin/external-auth/index.md). | +| `--secure-auth-cookie` defaulted independently from the access URL. | Secure auth cookies are enabled automatically when `CODER_ACCESS_URL` uses HTTPS. | Confirm reverse proxies send the correct scheme headers. To preserve old behavior, explicitly set `CODER_SECURE_AUTH_COOKIE=false`. | +| SFTP and SCP connections always landed in `$HOME`. | SFTP and SCP now respect the workspace agent `dir` setting. | Update scripts that relied on implicit `$HOME` paths. Prefer explicit absolute paths for file transfers. | +| `coder_agent` `dir` attribute accepted any path without warning. | `dir` is deprecated and emits a warning. Non-`$HOME`/`~` values also break [Coder Desktop file sync](../../user-guides/desktop/desktop-connect-sync.md). | Set `dir` to `$HOME` or omit it on `coder_agent` resources. The attribute still works in 2.34 but will be removed in a future release. | +| Pre-2.28 Tasks templates might still exist in older deployments. | The pre-2.28 Tasks template format is no longer supported as of 2.30. | Update Tasks templates to use `app_id` instead of the deprecated `sidebar_app` flow. See the [Tasks migration guide](../../ai-coder/tasks-migration.md). | +| Tasks is the primary AI coding workflow. | Coder Agents is the long-term replacement, and Tasks is supported through the 2.34 ESR window (into 2026). | Plan migration from the Tasks API to the Chats API and Coder Agents. See [Migrating from the Tasks API to the Chats API](../../ai-coder/agents/tasks-to-chats-migration.md). | +| AI Gateway injected MCP tools can be used for tool exposure. | Injected MCP tools are deprecated. | Move new integrations toward Coder Agents MCP server configuration or the MCP server flow. See [AI Gateway MCP](../../ai-coder/ai-gateway/mcp.md) and [MCP servers](../../ai-coder/agents/platform-controls/mcp-servers.md). | +| AI Bridge is opt-in via `CODER_AIBRIDGE_ENABLED` (default `false`). | The toggle is renamed to `CODER_AI_GATEWAY_ENABLED` and now defaults to `true`. | The in-memory AI Gateway now starts on every deployment. Set `CODER_AI_GATEWAY_ENABLED=false`, or the deprecated `CODER_AIBRIDGE_ENABLED` alias which still works, to keep the old behavior. | +| AI Gateway providers are configured with `CODER_AIBRIDGE_PROVIDER_*` or `CODER_AI_GATEWAY_PROVIDER_*` env vars. | Provider configuration is stored in the database. Env vars seed the database once on first startup, then are deprecated. | After upgrade, visit `/ai/settings` to verify seeded providers, then remove the env vars. Coderd fails to start if env vars drift from the seeded database row. See [AI Gateway providers](../../ai-coder/ai-gateway/providers.md). | +| Regular users can read their own AI Gateway interceptions. | Only owners and auditors can read AI Gateway interception data. | Update dashboards, scripts, or user workflows that expected self-service interception reads. This intentionally narrows the RBAC surface. | +| `coder groups list -o json` returns the old command output shape. | `coder groups list -o json` returns a flat structure matching other list commands. | Update scripts that parse this command output. | +| `coder tokens rm` deletes token records by default. | `coder tokens rm` expires tokens by default and keeps records for auditability. | Use `coder tokens rm --delete` only when the token record must be deleted. Update scripts that expect removed tokens to disappear from token history. | +| Deprecated Prometheus metrics are still emitted. | Deprecated Prometheus metrics were removed. | Update dashboards and alerts that use `coderd_api_workspace_latest_build_total` or `coderd_oauth2_external_requests_rate_limit_total`. Use the replacement metrics without the `_total` suffix. | +| Authenticated rate limits are effectively shared by client IP in some deployments. | Authenticated request rate limits are keyed by user. | Review monitoring and expectations for NATed users or shared proxies. Per-user limits now apply more consistently after API key precheck. | +| `coder login` can run while `CODER_SESSION_TOKEN` is set. | `coder login` errors when `CODER_SESSION_TOKEN` is set. | Unset `CODER_SESSION_TOKEN` in interactive login flows. Keep using the environment variable for non-interactive automation. | +| Workspace starts with new parameters can proceed without an explicit stop in some flows. | Workspace starts with new parameters stop the workspace before starting. | Expect downtime when applying new parameters. Update automation that assumes the workspace remains running. | +| `mode=auto` workspace links can silently create workspaces with prefilled parameters. | Users must confirm workspace auto-creation before provisioning starts. | Update Open in Coder buttons, runbooks, or internal flows that expect one-click workspace creation without a consent dialog. | +| Users with `--login-type none` are common for automation. | `--login-type none` is deprecated. | For Premium deployments, migrate automation to service accounts. For OSS deployments, use regular users with password, GitHub, or OIDC authentication. See [headless auth](../../admin/users/headless-auth.md). | +| Terminal commands can be executed from URL parameters without extra confirmation. | The dashboard requires confirmation before executing terminal commands from URLs. | Update runbooks or deep links that expected immediate terminal execution. This protects users from accidental command execution. | +| Agent SSH port forwarding is always available when the agent allows SSH. | Reverse and local port forwarding can be disabled per agent. | Review templates and IDE workflows before enabling `--block-reverse-port-forwarding` or `--block-local-port-forwarding`. See [port forwarding](../../admin/networking/port-forwarding.md). | +| `PATCH /api/v2/templates/{template}` accepts value fields for metadata updates. | Template metadata update fields are optional pointer fields in the SDK, and 304 responses were removed. | Update SDK consumers and direct API clients that patch template metadata. Send only fields that should change, including false or zero values explicitly. | +| External provisioner daemons use the 2.29 provisionerd protocol. | The provisionerd protocol changed for provisioner operations and file upload/download. | Update external provisioner daemons to the matching 2.34 protocol. The protocol reserves removed fields such as `stop_modules`, `exp_reuse_terraform_workspace`, and `user_secrets`, and adds `DownloadFile`. | +| Helm chart health probes and observability bind addresses use older chart defaults. | Readiness and liveness probes have `enabled` toggles and more fields, and Prometheus/pprof addresses are overridable. | Review custom Helm values for probe behavior and observability bindings. Prefer restricting pprof to a local address when exposing diagnostics. | ## Upgrading @@ -263,7 +264,9 @@ The Coder team recommends taking the following steps when performing the upgrade support window, but new automation should use Coder Agents and the Chats API. Update internal docs, templates, and API clients accordingly. - **Validate external authentication:** Test GitHub, GitLab, OIDC, and custom - external auth providers. Disable PKCE for providers that do not support it. + external auth providers. Add the `read_api` scope to GitLab OAuth applications + and have users re-authenticate. Disable PKCE for providers that do not support + it. - **Migrate headless automation to service accounts:** Replace users created with `--login-type none` where possible, and verify CI/CD tokens, template publish jobs, and workspace automation. From 210bb5eb3d367a2e0d70a3d1edcdb44e9b2e56ba Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 20:09:22 -0400 Subject: [PATCH 085/138] fix(examples): remove vscode-desktop module from quickstart to avoid duplicate VS Code Desktop (#26789) (#26834) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cherry-pick of https://github.com/coder/coder/pull/26789 Original PR: #26789 — fix(examples): remove vscode-desktop module from quickstart to avoid duplicate VS Code Desktop Merge commit: 84f995e01977518f5888923e3fa3e62467c9be2f Requested by: @matifali Co-authored-by: Ben Potter --- examples/examples.gen.json | 2 +- examples/templates/quickstart/README.md | 5 +++-- examples/templates/quickstart/main.tf | 13 ------------- 3 files changed, 4 insertions(+), 16 deletions(-) diff --git a/examples/examples.gen.json b/examples/examples.gen.json index dc19d9fcf3d..0ff50e42145 100644 --- a/examples/examples.gen.json +++ b/examples/examples.gen.json @@ -220,7 +220,7 @@ "docker", "quickstart" ], - "markdown": "\n# Coder Quickstart\n\nGet up and running with Coder in minutes. Choose your programming languages, pick your preferred editors, optionally clone a Git repository, and start coding.\n\n## How It Works\n\nWhen you create a workspace from this template, you select:\n\n1. **Languages** to pre-install (Python, Node.js, Go, Rust, Java, C/C++)\n2. **Editors** to connect (VS Code in the browser, VS Code Desktop, Cursor, JetBrains, Zed, Windsurf)\n3. **A Git repository** to clone (optional)\n\nCoder provisions a workspace with your selections and you can start developing immediately.\n\n## Prerequisites\n\nThe host running Coder must have a Docker daemon accessible to the `coder` user:\n\n```sh\n# Add coder user to Docker group\nsudo adduser coder docker\n\n# Restart Coder server\nsudo systemctl restart coder\n\n# Verify access\nsudo -u coder docker ps\n```\n\n## Architecture\n\nThis template provisions:\n\n- **Docker container** (ephemeral) running Ubuntu with the Coder agent\n- **Docker volume** (persistent) mounted at `/home/coder`\n\nFiles in your home directory persist across workspace restarts. Selected languages are installed on first start and cached for subsequent starts.\n\n## Presets\n\nSelect a preset to auto-fill languages and editors for common workflows:\n\n| Preset | Languages | Editors |\n|---------------------|---------------------|-------------------------------------|\n| **Web Development** | Python, Node.js | VS Code (Browser) |\n| **Backend (Go)** | Go | VS Code (Browser), JetBrains GoLand |\n| **Data Science** | Python | VS Code (Browser) |\n| **Full Stack** | Python, Node.js, Go | VS Code (Browser), Cursor |\n\n## IDE Notes\n\n- **VS Code (Browser)**: Opens directly in your browser with no local install required.\n- **VS Code Desktop, Cursor, Windsurf**: Require the desktop application installed on your local machine. Coder opens them via protocol handler.\n- **JetBrains IDEs**: Filtered by your language selection (e.g. PyCharm for Python, GoLand for Go). Requires JetBrains Toolbox or Gateway on your local machine.\n- **Zed**: Connects over SSH. Requires Zed installed on your local machine.\n" + "markdown": "\n# Coder Quickstart\n\nGet up and running with Coder in minutes. Choose your programming languages, pick your preferred editors, optionally clone a Git repository, and start coding.\n\n## How It Works\n\nWhen you create a workspace from this template, you select:\n\n1. **Languages** to pre-install (Python, Node.js, Go, Rust, Java, C/C++)\n2. **Editors** to connect (VS Code in the browser, Cursor, JetBrains, Zed, Windsurf)\n3. **A Git repository** to clone (optional)\n\nCoder provisions a workspace with your selections and you can start developing immediately.\n\n## Prerequisites\n\nThe host running Coder must have a Docker daemon accessible to the `coder` user:\n\n```sh\n# Add coder user to Docker group\nsudo adduser coder docker\n\n# Restart Coder server\nsudo systemctl restart coder\n\n# Verify access\nsudo -u coder docker ps\n```\n\n## Architecture\n\nThis template provisions:\n\n- **Docker container** (ephemeral) running Ubuntu with the Coder agent\n- **Docker volume** (persistent) mounted at `/home/coder`\n\nFiles in your home directory persist across workspace restarts. Selected languages are installed on first start and cached for subsequent starts.\n\n## Presets\n\nSelect a preset to auto-fill languages and editors for common workflows:\n\n| Preset | Languages | Editors |\n|---------------------|---------------------|-------------------------------------|\n| **Web Development** | Python, Node.js | VS Code (Browser) |\n| **Backend (Go)** | Go | VS Code (Browser), JetBrains GoLand |\n| **Data Science** | Python | VS Code (Browser) |\n| **Full Stack** | Python, Node.js, Go | VS Code (Browser), Cursor |\n\n## IDE Notes\n\n- **VS Code (Browser)**: Opens directly in your browser with no local install required.\n- **VS Code Desktop**: Available on every workspace by default (Coder enables the VS Code Desktop display app automatically), so it is not listed as a separate editor option.\n- **Cursor, Windsurf**: Require the desktop application installed on your local machine. Coder opens them via protocol handler.\n- **JetBrains IDEs**: Filtered by your language selection (e.g. PyCharm for Python, GoLand for Go). Requires JetBrains Toolbox or Gateway on your local machine.\n- **Zed**: Connects over SSH. Requires Zed installed on your local machine.\n" }, { "id": "scratch", diff --git a/examples/templates/quickstart/README.md b/examples/templates/quickstart/README.md index c7f3ebed835..640151b43a8 100644 --- a/examples/templates/quickstart/README.md +++ b/examples/templates/quickstart/README.md @@ -16,7 +16,7 @@ Get up and running with Coder in minutes. Choose your programming languages, pic When you create a workspace from this template, you select: 1. **Languages** to pre-install (Python, Node.js, Go, Rust, Java, C/C++) -2. **Editors** to connect (VS Code in the browser, VS Code Desktop, Cursor, JetBrains, Zed, Windsurf) +2. **Editors** to connect (VS Code in the browser, Cursor, JetBrains, Zed, Windsurf) 3. **A Git repository** to clone (optional) Coder provisions a workspace with your selections and you can start developing immediately. @@ -59,6 +59,7 @@ Select a preset to auto-fill languages and editors for common workflows: ## IDE Notes - **VS Code (Browser)**: Opens directly in your browser with no local install required. -- **VS Code Desktop, Cursor, Windsurf**: Require the desktop application installed on your local machine. Coder opens them via protocol handler. +- **VS Code Desktop**: Available on every workspace by default (Coder enables the VS Code Desktop display app automatically), so it is not listed as a separate editor option. +- **Cursor, Windsurf**: Require the desktop application installed on your local machine. Coder opens them via protocol handler. - **JetBrains IDEs**: Filtered by your language selection (e.g. PyCharm for Python, GoLand for Go). Requires JetBrains Toolbox or Gateway on your local machine. - **Zed**: Connects over SSH. Requires Zed installed on your local machine. diff --git a/examples/templates/quickstart/main.tf b/examples/templates/quickstart/main.tf index 3bb89b39cfa..362a8eb266f 100644 --- a/examples/templates/quickstart/main.tf +++ b/examples/templates/quickstart/main.tf @@ -87,11 +87,6 @@ data "coder_parameter" "ides" { value = "code-server" icon = "/icon/code.svg" } - option { - name = "VS Code Desktop" - value = "vscode-desktop" - icon = "/icon/code.svg" - } option { name = "Cursor" value = "cursor" @@ -283,14 +278,6 @@ module "code-server" { order = 1 } -module "vscode-desktop" { - count = data.coder_workspace.me.start_count * (contains(local.ides, "vscode-desktop") ? 1 : 0) - source = "registry.coder.com/coder/vscode-desktop/coder" - version = "~> 1.0" - agent_id = coder_agent.main.id - folder = "/home/coder" - order = 2 -} module "cursor" { count = data.coder_workspace.me.start_count * (contains(local.ides, "cursor") ? 1 : 0) From c3dd41e1e8c9d0a0860a9a81c3c050be55879dc2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 20:09:48 -0400 Subject: [PATCH 086/138] fix(site/src/pages/WorkspacesPage): show only parent agent apps in workspaces table (#26568) (#26812) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport of https://github.com/coder/coder/pull/26568 Original PR: #26568 — fix(site/src/pages/WorkspacesPage): show only parent agent apps in workspaces table Merge commit: 7e509e8fd03d229e735f0f7ad41b62d11b00144d Requested by: @uzair-coder07 Co-authored-by: uzair-coder07 --- .../WorkspacesPageView.stories.tsx | 62 +++++++++++++++++++ .../pages/WorkspacesPage/WorkspacesTable.tsx | 16 +++-- 2 files changed, 73 insertions(+), 5 deletions(-) diff --git a/site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx b/site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx index 8dbda6b01a1..dfda18d6e56 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx @@ -21,6 +21,8 @@ import { MockUserOwner, MockWorkspace, MockWorkspaceAgent, + MockWorkspaceApp, + MockWorkspaceSubAgent, mockApiError, } from "#/testHelpers/entities"; import { @@ -354,6 +356,66 @@ export const MultipleApps: Story = { }, }; +// The shortcuts row only renders apps from the parent agent (the agent without +// a `parent_id`). Apps from sub-agents, such as those created by devcontainers, +// are excluded so the row stays deterministic regardless of agent ordering. +export const ParentAgentApps: Story = { + args: { + workspaces: [ + { + ...MockWorkspace, + name: "parent-agent-apps", + latest_build: { + ...MockWorkspace.latest_build, + resources: [ + { + ...MockWorkspace.latest_build.resources[0], + agents: [ + // Sub-agent is listed first to prove ordering does + // not determine which apps are shown. + { + ...MockWorkspaceSubAgent, + display_apps: [], + apps: [ + { + ...MockWorkspaceApp, + id: "sub-agent-app", + slug: "sub-agent-app", + display_name: "Sub Agent App", + health: "healthy", + }, + ], + }, + { + ...MockWorkspaceAgent, + display_apps: [], + apps: [ + { + ...MockWorkspaceApp, + id: "parent-agent-app", + slug: "parent-agent-app", + display_name: "Parent Agent App", + health: "healthy", + }, + ], + }, + ], + }, + ], + }, + }, + ], + count: allWorkspaces.length, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await canvas.findByRole("link", { name: /Open Parent Agent App/i }); + expect( + canvas.queryByRole("link", { name: /Open Sub Agent App/i }), + ).not.toBeInTheDocument(); + }, +}; + export const ShowOrganizations: Story = { args: { workspaces: [ diff --git a/site/src/pages/WorkspacesPage/WorkspacesTable.tsx b/site/src/pages/WorkspacesPage/WorkspacesTable.tsx index bcf0571d90a..676b2204c92 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesTable.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesTable.tsx @@ -655,15 +655,21 @@ const WorkspaceApps: FC = ({ workspace }) => { * Coder is pretty flexible and allows an enormous variety of use cases, such * as having multiple resources with many agents, but they are not common. The * most common scenario is to have one single compute resource with one single - * agent containing all the apps. Lets test this getting the apps for the - * first resource, and first agent - they are sorted to return the compute - * resource first - and see what customers and ourselves, using dogfood, think - * about that. + * agent containing all the apps. We get the apps from the first compute + * resource (they are sorted to return the compute resource first). + * + * For multi-agent workspaces with sub-agents we show the apps from the parent + * agent (the one without a `parent_id`). Sub-agents, such as those created by + * devcontainers, are skipped so agent ordering does not determine which apps + * appear. + * + * When a workspace has multiple parent-level agents we show the apps from the + * first one only; aggregating apps across agents is tracked separately. */ const agent = workspace.latest_build.resources .filter((r) => !r.hide) .at(0) - ?.agents?.at(0); + ?.agents?.find((a) => a.parent_id === null); if (!agent) { return null; } From 92c345bab8b35311e830b0f9c8926ff0dfd631a7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 17:54:35 +0100 Subject: [PATCH 087/138] fix: correct gvisor replace directive to match module path (#26822) (conflicts) (#26921) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport of https://github.com/coder/coder/pull/26822 Original PR: #26822 — fix: correct gvisor replace directive to match module path Merge commit: f77d0065ed368df51a82172adf674b9b77d01ea8 Requested by: @denisra Co-authored-by: @denisra --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 3300efecbe4..3159617c0b7 100644 --- a/go.mod +++ b/go.mod @@ -45,7 +45,7 @@ replace github.com/tailscale/wireguard-go => github.com/coder/wireguard-go v0.0. // We use a fork to fix an integer overflow issue that causes occasional crashes in workspace agents. // See https://github.com/coder/coder/issues/20885 -replace gvisor.dev => github.com/coder/gvisor v0.0.0-20260313164934-7a658db7b714 +replace gvisor.dev/gvisor => github.com/coder/gvisor v0.0.0-20260313164934-7a658db7b714 // Switch to our fork that imports fixes from http://github.com/tailscale/ssh. // See: https://github.com/coder/coder/issues/3371 diff --git a/go.sum b/go.sum index 314cc3fa1f7..21d4f155e23 100644 --- a/go.sum +++ b/go.sum @@ -332,6 +332,8 @@ github.com/coder/go-scim/pkg/v2 v2.0.0-20230221055123-1d63c1222136 h1:0RgB61LcNs github.com/coder/go-scim/pkg/v2 v2.0.0-20230221055123-1d63c1222136/go.mod h1:VkD1P761nykiq75dz+4iFqIQIZka189tx1BQLOp0Skc= github.com/coder/guts v1.7.0 h1:TaZ/PR9wgN8dlbcckaWV1MxkkuEFZRwSRwBBEm8dYXs= github.com/coder/guts v1.7.0/go.mod h1:30SShdvpmsauNlsNjECRB5AppScjYk08rf2ZVpH3MFg= +github.com/coder/gvisor v0.0.0-20260313164934-7a658db7b714 h1:j7tyq3rv0ZXkbyy/BE5K3lYiIqEsV8sDTiTjpLLxjiw= +github.com/coder/gvisor v0.0.0-20260313164934-7a658db7b714/go.mod h1:sxc3Uvk/vHcd3tj7/DHVBoR5wvWT/MmRq2pj7HRJnwU= github.com/coder/paralleltestctx v0.0.2 h1:0akzA1oSV0LOl7loR8Mmoq/mu7qGDaFV8DpojotmXiE= github.com/coder/paralleltestctx v0.0.2/go.mod h1:q/wi6cmlBOhrJKjUtouTn4J9xZlRhK0MbgHvJNdGW3w= github.com/coder/pq v1.10.5-0.20250807075151-6ad9b0a25151 h1:YAxwg3lraGNRwoQ18H7R7n+wsCqNve7Brdvj0F1rDnU= @@ -1570,8 +1572,6 @@ gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= -gvisor.dev/gvisor v0.0.0-20240509041132-65b30f7869dc h1:DXLLFYv/k/xr0rWcwVEvWme1GR36Oc4kNMspg38JeiE= -gvisor.dev/gvisor v0.0.0-20240509041132-65b30f7869dc/go.mod h1:sxc3Uvk/vHcd3tj7/DHVBoR5wvWT/MmRq2pj7HRJnwU= howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM= howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= k8s.io/apimachinery v0.34.2 h1:zQ12Uk3eMHPxrsbUJgNF8bTauTVR2WgqJsTmwTE/NW4= From 94a997d7b055b8d1c88714211265602fccaa4b48 Mon Sep 17 00:00:00 2001 From: Callum Styan Date: Mon, 6 Jul 2026 23:55:45 -0700 Subject: [PATCH 088/138] =?UTF-8?q?fix(coderd):=20require=20deployment-wid?= =?UTF-8?q?e=20workspace=20read=20permissions=20for=20W=E2=80=A6=20(#27029?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit backport https://github.com/coder/coder/pull/26985 to release/2.34 --- coderd/workspaces.go | 14 +++++++++++++ coderd/workspaces_test.go | 43 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 5c8a3108a08..b812011edca 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -2254,6 +2254,20 @@ func (api *API) watchWorkspace( func (api *API) watchAllWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() + // This endpoint streams build events for every workspace in the deployment + // with no per-item filtering, so require deployment-wide read on workspaces. + // Without this, any authenticated user could enumerate all workspace names + // and observe build activity across organizations they do not belong to. + if !api.Authorize(r, policy.ActionRead, rbac.ResourceWorkspace.All()) { + httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{ + Message: "You are not authorized to watch all workspace builds.", + Detail: "This requires permission to read all workspaces, which is granted by the " + + "Owner or Template Admin role. Please contact an administrator about your " + + "permissions if you feel this is an error.", + }) + return + } + // Buffer enough updates to avoid blocking the pubsub callback while we're // accepting the WebSocket connection. Accepting the connection signals to // the client that the server is subscribed and ready to forward events. diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 00524c0f564..de7ae93ccf6 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -4079,6 +4079,49 @@ func TestWatchAllWorkspaceBuilds(t *testing.T) { require.Equal(t, workspace2.ID, update.WorkspaceID) } +func TestWatchAllWorkspaceBuildsAuthorization(t *testing.T) { + t.Parallel() + + // Enable the workspace build updates experiment. + client := coderdtest.New(t, &coderdtest.Options{ + DeploymentValues: coderdtest.DeploymentValues(t, func(dv *codersdk.DeploymentValues) { + dv.Experiments = []string{string(codersdk.ExperimentWorkspaceBuildUpdates)} + }), + }) + owner := coderdtest.CreateFirstUser(t, client) + + t.Run("MemberForbidden", func(t *testing.T) { + t.Parallel() + + // A plain member has no deployment-wide workspace read permission and + // must not be able to open the all-builds stream. + memberClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) + defer cancel() + + _, err := memberClient.WatchAllWorkspaceBuilds(ctx) + require.Error(t, err) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusForbidden, apiErr.StatusCode()) + }) + + t.Run("TemplateAdminAllowed", func(t *testing.T) { + t.Parallel() + + // Template admins have site-wide workspace read and may open the stream. + taClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin()) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) + defer cancel() + + decoder, err := taClient.WatchAllWorkspaceBuilds(ctx) + require.NoError(t, err) + defer decoder.Close() + }) +} + func mustLocation(t *testing.T, location string) *time.Location { t.Helper() loc, err := time.LoadLocation(location) From db643c8ac8b3a45524d29992371884a7bc3fef2f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 09:46:33 +0200 Subject: [PATCH 089/138] fix(site): redirect to new organization after create (#26890) (#26930) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport of https://github.com/coder/coder/pull/26890 Original PR: #26890 — fix(site): redirect to new organization after create Merge commit: 3e0875d236340612cf88bde5fb3d167fcbc080da Requested by: @aslilac Co-authored-by: McKayla はな --- .../CreateOrganizationPage.tsx | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/site/src/pages/OrganizationSettingsPage/CreateOrganizationPage.tsx b/site/src/pages/OrganizationSettingsPage/CreateOrganizationPage.tsx index 2da4aafba5b..51cdccf5ab5 100644 --- a/site/src/pages/OrganizationSettingsPage/CreateOrganizationPage.tsx +++ b/site/src/pages/OrganizationSettingsPage/CreateOrganizationPage.tsx @@ -27,14 +27,11 @@ const CreateOrganizationPage: FC = () => { error={error} isEntitled={feats.multiple_organizations} onSubmit={async (values) => { - await createOrganizationMutation.mutateAsync(values, { - onSuccess: () => { - toast.success( - `Organization "${values.name}" created successfully.`, - ); - navigate(`/organizations/${values.name}`); - }, - }); + await createOrganizationMutation.mutateAsync(values); + toast.success( + `Organization "${values.name}" created successfully.`, + ); + navigate(`/organizations/${values.name}`); }} /> From 660dc564f5217ea9d1489cc0816e661f7ec0d15d Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Tue, 14 Jul 2026 17:12:15 +1000 Subject: [PATCH 090/138] fix(site/src/pages/AISettingsPage): match Formik onSubmit args in Bedrock story (#27211) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > 🤖 This PR was written by Coder Agents on behalf of Jake Howell. Test-only fix for `release/2.34`. ### Problem The `AddBedrockWithoutStaticCredentials` interaction test in `ProviderForm.stories.tsx` asserts: ```ts expect(args.onSubmit).toHaveBeenCalledWith(expect.objectContaining({ type: "bedrock", ... })) ``` Formik invokes `onSubmit(values, formikHelpers)` with **two** arguments, so `toHaveBeenCalledWith` never matches and the storybook/Chromatic interaction test fails deterministically. This breaks storybook CI for **any** PR targeting `release/2.34` (e.g. the #27083 backport in #27144). `main` and `release/2.35` don't hit this because #25848 refactored the story there to forward only `values` to the spy; that refactor was never backported to 2.34. ### Fix Match the second argument with `expect.anything()` so the assertion reflects Formik's actual call signature. Test-only, minimal, no product code change. Verified on 2.34: `ProviderForm` storybook stories (13) pass; lint clean. --- .../ProvidersPage/components/ProviderForm.stories.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.stories.tsx b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.stories.tsx index 0994ecb7a6b..f09d049e6df 100644 --- a/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.stories.tsx +++ b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.stories.tsx @@ -93,6 +93,9 @@ export const AddBedrockWithoutStaticCredentials: Story = { accessKey: "", accessKeySecret: "", }), + // Formik invokes onSubmit as (values, formikHelpers), so the + // second argument must be matched for the assertion to pass. + expect.anything(), ), ); }, From e77a5be245131712405dca44dd5e69604fbdd3f5 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Jul 2026 12:47:45 +0200 Subject: [PATCH 091/138] fix: bump github.com/go-chi/chi/v5 from 5.2.4 to 5.3.1 (backport 2.34) (#27148) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Backport [go-chi/chi](https://github.com/go-chi/chi) v5.2.4 to v5.3.1 on `release/2.34`, aligning with the upgrade already merged on `main` via #27051. ## Security Advisories Addressed chi v5.3.0 and v5.3.1 resolve the following advisories: | Advisory | Description | Severity | |----------|-------------|----------| | [GHSA-9g5q-2w5x-hmxf](https://github.com/advisories/GHSA-9g5q-2w5x-hmxf) | IP spoofing via XFF in RemoteAddr resolution | — | | [GHSA-rjr7-jggh-pgcp](https://github.com/advisories/GHSA-rjr7-jggh-pgcp) | RealIP allows IP spoofing via unvalidated XFF | — | | [GHSA-3fxj-6jh8-hvhx](https://github.com/advisories/GHSA-3fxj-6jh8-hvhx) | IP spoofing in middleware.RealIP | Critical (9.3) | | [GHSA-mqqf-5wvp-8fh8](https://github.com/advisories/GHSA-mqqf-5wvp-8fh8) | Open redirect in RedirectSlashes (CVE-2025-69725) | — | | [GHSA-vrw8-fxc6-2r93](https://github.com/advisories/GHSA-vrw8-fxc6-2r93) | Host Header Injection in RedirectSlashes | — | ## Impact Assessment Coder does **not** use the directly affected middleware: - `middleware.RealIP` — Coder uses its own `httpmw.ExtractRealIP` - `StripSlashes` / `RedirectSlashes` / `CleanPath` — Coder uses its own `singleSlashMW` The primary motivation for this backport is keeping `release/2.34` aligned with `main` to prevent dependency drift and to satisfy IronBank scanning requirements. ## Changes - `go.mod` / `go.sum`: bump `github.com/go-chi/chi/v5` v5.2.4 to v5.3.1 ## Related - #27051 — original Dependabot upgrade on `main` - #27147 — same backport on `release/2.35` --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Marcin Tojek --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 3159617c0b7..aa7e7002e25 100644 --- a/go.mod +++ b/go.mod @@ -155,7 +155,7 @@ require ( github.com/fergusstrange/embedded-postgres v1.34.0 github.com/gen2brain/beeep v0.11.1 github.com/gliderlabs/ssh v0.3.8 - github.com/go-chi/chi/v5 v5.2.4 + github.com/go-chi/chi/v5 v5.3.1 github.com/go-chi/cors v1.2.1 github.com/go-chi/httprate v0.15.0 github.com/go-jose/go-jose/v4 v4.1.4 diff --git a/go.sum b/go.sum index 21d4f155e23..95741dbcf8d 100644 --- a/go.sum +++ b/go.sum @@ -504,8 +504,8 @@ github.com/github/fakeca v0.1.0 h1:Km/MVOFvclqxPM9dZBC4+QE564nU4gz4iZ0D9pMw28I= github.com/github/fakeca v0.1.0/go.mod h1:+bormgoGMMuamOscx7N91aOuUST7wdaJ2rNjeohylyo= github.com/go-chi/chi/v5 v5.0.8/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= github.com/go-chi/chi/v5 v5.2.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= -github.com/go-chi/chi/v5 v5.2.4 h1:WtFKPHwlywe8Srng8j2BhOD9312j9cGUxG1SP4V2cR4= -github.com/go-chi/chi/v5 v5.2.4/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8= +github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4= github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= github.com/go-chi/hostrouter v0.3.0 h1:75it1eO3FvkG8te1CvU6Kvr3WzAZNEBbo8xIrxUKLOQ= From e885cf3534d5f12e966a0b69328563454a7ddd7f Mon Sep 17 00:00:00 2001 From: Denis Afonso Date: Tue, 14 Jul 2026 13:36:54 +0100 Subject: [PATCH 092/138] fix: bump Go version from 1.26.4 to 1.26.5 (backport 2.34) (#27158) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Backport Go toolchain bump from 1.26.4 to 1.26.5 to `release/2.34`. Go 1.26.5 ([released 2026-07-07](https://go.dev/doc/devel/release#go1.26.5)) includes security fixes to the `crypto/tls` and `os` packages, addressing: - [CVE-2026-39822](https://nvd.nist.gov/vuln/detail/CVE-2026-39822) — `os` package - [CVE-2026-42505](https://nvd.nist.gov/vuln/detail/CVE-2026-42505) — `crypto/tls` package These were flagged by the IronBank scan of `coder/coder-enterprise/coder-service-2:2.34.5`. Main PR: #27157 ## Changes - `go.mod`: `go 1.26.4` to `go 1.26.5` (language version floor) - `mise.toml` / `mise.lock`: Go toolchain pin 1.26.4 to 1.26.5 (this is what actually compiles the scanned artifact) - `.github/actions/setup-go/action.yaml`: default Go version 1.26.4 to 1.26.5 - `scripts/should_deploy.sh`: remove ~62 lines of unreachable dead code below an unconditional `if/else` that exits in both branches; cherry-picked from `main` to clear a shellcheck SC2317 warning on the Ubuntu 26.04 CI runner ## Nix flake fix cherry-picks Two commits were cherry-picked from `main` to fix the `build_image (nix)` CI job on `release/2.34`: - `fccb238ec` (#26584): Upgrade nixpkgs from 24.11 to 25.05, pin `protobuf_23_4`. Fixes a build failure caused by Google removing `google-chrome-stable_138.0.7204.49-1_amd64.deb` from its CDN (Chrome 138 was end-of-lifed), which broke the nixpkgs 24.11 `google-chrome` derivation. - `ff71c1e82` (#26643): Build `go-migrate` with only the postgres driver. Fixes a Snowflake driver compilation failure under the updated nixpkgs. These are already on `main` and have been cherry-picked to `release/2.34` (via the base branch merge). Local validation confirmed the nix build succeeds with these patches applied. ## Note on `should_deploy.sh` The `should_deploy.sh` cleanup is behavior-preserving. The existing `if/else` already exits with `DEPLOY` (main) or `NOOP` (any other branch) in both branches, making all code below it unreachable. The old release-branch deploy logic was already disabled by the short-circuit added earlier. This commit removes the dead code and was cherry-picked from `main` to fix a shellcheck SC2317 (unreachable code) warning that started failing on the Ubuntu 26.04 runner. Linear: ENT-130 --------- Co-authored-by: Marcin Tojek Co-authored-by: George K --- .github/actions/setup-go/action.yaml | 2 +- flake.lock | 8 ++-- flake.nix | 55 +++++++++++++++++++++-- go.mod | 2 +- mise.lock | 46 +++++++++---------- mise.toml | 2 +- scripts/should_deploy.sh | 66 ++-------------------------- 7 files changed, 86 insertions(+), 95 deletions(-) diff --git a/.github/actions/setup-go/action.yaml b/.github/actions/setup-go/action.yaml index 69f4b2dfabf..3d503af54b4 100644 --- a/.github/actions/setup-go/action.yaml +++ b/.github/actions/setup-go/action.yaml @@ -4,7 +4,7 @@ description: | inputs: version: description: "The Go version to use." - default: "1.26.4" + default: "1.26.5" use-cache: description: "Whether to use the cache." default: "true" diff --git a/flake.lock b/flake.lock index dea5417e7e6..2da9acc52c2 100644 --- a/flake.lock +++ b/flake.lock @@ -44,16 +44,16 @@ }, "nixpkgs": { "locked": { - "lastModified": 1751274312, - "narHash": "sha256-/bVBlRpECLVzjV19t5KMdMFWSwKLtb5RyXdjz3LJT+g=", + "lastModified": 1767313136, + "narHash": "sha256-16KkgfdYqjaeRGBaYsNrhPRRENs0qzkQVUooNHtoy2w=", "owner": "nixos", "repo": "nixpkgs", - "rev": "50ab793786d9de88ee30ec4e4c24fb4236fc2674", + "rev": "ac62194c3917d5f474c1a844b6fd6da2db95077d", "type": "github" }, "original": { "owner": "nixos", - "ref": "nixos-24.11", + "ref": "nixos-25.05", "repo": "nixpkgs", "type": "github" } diff --git a/flake.nix b/flake.nix index d22dbcd72ce..ecb6421e00e 100644 --- a/flake.nix +++ b/flake.nix @@ -2,7 +2,7 @@ description = "Development environments on your infrastructure"; inputs = { - nixpkgs.url = "github:nixos/nixpkgs/nixos-24.11"; + nixpkgs.url = "github:nixos/nixpkgs/nixos-25.05"; nixpkgs-unstable.url = "github:nixos/nixpkgs/nixos-unstable"; nixpkgs-pinned.url = "github:nixos/nixpkgs/5deee6281831847857720668867729617629ef1f"; flake-utils.url = "github:numtide/flake-utils"; @@ -84,6 +84,47 @@ vendorHash = null; }; + # Keep protoc aligned with mise.toml so local Nix shells use + # the same codegen tool version as CI and release workflows, + # regardless of nixpkgs channel defaults. + protobuf_23_4 = + let + releases = { + x86_64-linux = { + platform = "linux-x86_64"; + hash = "sha256-BQLyhqye2GC2KaeWWhRSex8t0THkKD+iPC1/GEZyqpo="; + }; + aarch64-linux = { + platform = "linux-aarch_64"; + hash = "sha256-HHdQtuA4MFtaf8PQzaHr798Qak8wp4e/gm7S/EfDln0="; + }; + aarch64-darwin = { + platform = "osx-aarch_64"; + hash = "sha256-jHr66GJraBHntYl9FtlAwtv1Cx4TXtlYoB22VmvdpyY="; + }; + x86_64-darwin = { + platform = "osx-x86_64"; + hash = "sha256-B+X9zxsHCNM2fcXm640TXefkB9dTFskxVc/YqzYu7IA="; + }; + }; + target = releases.${system} or null; + in + if target != null then + pkgs.runCommand "protobuf-23.4" { + nativeBuildInputs = [ pkgs.unzip ]; + src = pkgs.fetchurl { + url = "https://github.com/protocolbuffers/protobuf/releases/download/v23.4/protoc-23.4-${target.platform}.zip"; + hash = target.hash; + }; + } '' + mkdir -p "$out" + cd "$out" + unzip "$src" + chmod +x "$out/bin/protoc" + '' + else + throw "protobuf 23.4 is not defined for ${system}"; + # Custom sqlc build from coder/sqlc fork to fix ambiguous column bug, see: # - https://github.com/coder/sqlc/pull/1 # - https://github.com/sqlc-dev/sqlc/pull/4159 @@ -145,6 +186,14 @@ xcbuild ]); + migrate = pkgs.go-migrate.overrideAttrs (_oldAttrs: { + # Coder only needs migrate for migration creation and local Postgres + # migrations. The nixpkgs default build includes every database + # driver and is broken by a bad Go and driver combination, so rebuild + # only with the driver we need. + tags = [ "postgres" ]; + }); + # The minimal set of packages to build Coder. devShellPackages = with pkgs; @@ -176,7 +225,7 @@ gnutar unstablePkgs.go_1_26 gofumpt - go-migrate + migrate (pinnedPkgs.golangci-lint) gopls gotestsum @@ -201,7 +250,7 @@ pnpm postgresql_16 proto_gen_go_1_30 - protobuf_23 + protobuf_23_4 ripgrep shellcheck (pinnedPkgs.shfmt) diff --git a/go.mod b/go.mod index aa7e7002e25..3b857381b29 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/coder/coder/v2 -go 1.26.4 +go 1.26.5 // Required until a v3 of chroma is created to lazily initialize all XML files. // None of our dependencies seem to use the registries anyways, so this diff --git a/mise.lock b/mise.lock index 5fb490c21b8..f10ba8b9601 100644 --- a/mise.lock +++ b/mise.lock @@ -385,52 +385,52 @@ checksum = "sha256:e1245a0a760a45b236e7a25bf118c1defc8447734bdeb4260ea3ec15d1797 url = "https://github.com/digitalocean/doctl/releases/download/v1.158.0/doctl-1.158.0-windows-amd64.zip" [[tools.go]] -version = "1.26.4" +version = "1.26.5" backend = "core:go" [tools.go."platforms.linux-arm64"] -checksum = "sha256:ef758ae7c6cf9267c9c0ef080b8965f453d89ab2d25d9eb22de4405925238768" -url = "https://dl.google.com/go/go1.26.4.linux-arm64.tar.gz" +checksum = "sha256:fe4789e92b1f33358680864bbe8704289e7bb5fc207d80623c308935bd696d49" +url = "https://dl.google.com/go/go1.26.5.linux-arm64.tar.gz" [tools.go."platforms.linux-arm64-musl"] -checksum = "sha256:ef758ae7c6cf9267c9c0ef080b8965f453d89ab2d25d9eb22de4405925238768" -url = "https://dl.google.com/go/go1.26.4.linux-arm64.tar.gz" +checksum = "sha256:fe4789e92b1f33358680864bbe8704289e7bb5fc207d80623c308935bd696d49" +url = "https://dl.google.com/go/go1.26.5.linux-arm64.tar.gz" [tools.go."platforms.linux-x64"] -checksum = "sha256:1153d3d50e0ac764b447adfe05c2bcf08e889d42a02e0fe0259bd47f6733ad7f" -url = "https://dl.google.com/go/go1.26.4.linux-amd64.tar.gz" +checksum = "sha256:5c2c3b16caefa1d968a94c1daca04a7ca301a496d9b086e17ad77bb81393f053" +url = "https://dl.google.com/go/go1.26.5.linux-amd64.tar.gz" [tools.go."platforms.linux-x64-baseline"] -checksum = "sha256:1153d3d50e0ac764b447adfe05c2bcf08e889d42a02e0fe0259bd47f6733ad7f" -url = "https://dl.google.com/go/go1.26.4.linux-amd64.tar.gz" +checksum = "sha256:5c2c3b16caefa1d968a94c1daca04a7ca301a496d9b086e17ad77bb81393f053" +url = "https://dl.google.com/go/go1.26.5.linux-amd64.tar.gz" [tools.go."platforms.linux-x64-musl"] -checksum = "sha256:1153d3d50e0ac764b447adfe05c2bcf08e889d42a02e0fe0259bd47f6733ad7f" -url = "https://dl.google.com/go/go1.26.4.linux-amd64.tar.gz" +checksum = "sha256:5c2c3b16caefa1d968a94c1daca04a7ca301a496d9b086e17ad77bb81393f053" +url = "https://dl.google.com/go/go1.26.5.linux-amd64.tar.gz" [tools.go."platforms.linux-x64-musl-baseline"] -checksum = "sha256:1153d3d50e0ac764b447adfe05c2bcf08e889d42a02e0fe0259bd47f6733ad7f" -url = "https://dl.google.com/go/go1.26.4.linux-amd64.tar.gz" +checksum = "sha256:5c2c3b16caefa1d968a94c1daca04a7ca301a496d9b086e17ad77bb81393f053" +url = "https://dl.google.com/go/go1.26.5.linux-amd64.tar.gz" [tools.go."platforms.macos-arm64"] -checksum = "sha256:b62ad2b6d7d2464f12a5bcad7ff47f19d08325773b5efd21610e445a05a9bf53" -url = "https://dl.google.com/go/go1.26.4.darwin-arm64.tar.gz" +checksum = "sha256:efb87ff28af9a188d0536ef5d42e63dd52ba8263cd7344a993cc48dd11dedb6a" +url = "https://dl.google.com/go/go1.26.5.darwin-arm64.tar.gz" [tools.go."platforms.macos-x64"] -checksum = "sha256:05dc9b5f9997744520aaebb3d5deaa7c755371aebbfb7f97c2511a9f3367538d" -url = "https://dl.google.com/go/go1.26.4.darwin-amd64.tar.gz" +checksum = "sha256:6231d8d3b8f5552ec6cbf6d685bdd5482e1e703214b120e89b3bf0d7bf1ef725" +url = "https://dl.google.com/go/go1.26.5.darwin-amd64.tar.gz" [tools.go."platforms.macos-x64-baseline"] -checksum = "sha256:05dc9b5f9997744520aaebb3d5deaa7c755371aebbfb7f97c2511a9f3367538d" -url = "https://dl.google.com/go/go1.26.4.darwin-amd64.tar.gz" +checksum = "sha256:6231d8d3b8f5552ec6cbf6d685bdd5482e1e703214b120e89b3bf0d7bf1ef725" +url = "https://dl.google.com/go/go1.26.5.darwin-amd64.tar.gz" [tools.go."platforms.windows-x64"] -checksum = "sha256:3ca8fb4630b07c419cbdd51f754e31363cfcfb83b3a5354d9e895c90be2cc345" -url = "https://dl.google.com/go/go1.26.4.windows-amd64.zip" +checksum = "sha256:97e6b2a833b6d89f9ff17d25419ac0a7e3b482a044e9ab18cdef834bd834fd38" +url = "https://dl.google.com/go/go1.26.5.windows-amd64.zip" [tools.go."platforms.windows-x64-baseline"] -checksum = "sha256:3ca8fb4630b07c419cbdd51f754e31363cfcfb83b3a5354d9e895c90be2cc345" -url = "https://dl.google.com/go/go1.26.4.windows-amd64.zip" +checksum = "sha256:97e6b2a833b6d89f9ff17d25419ac0a7e3b482a044e9ab18cdef834bd834fd38" +url = "https://dl.google.com/go/go1.26.5.windows-amd64.zip" [[tools."go:github.com/coder/sqlc/cmd/sqlc"]] version = "337309bfb9524f38466a5090e310040fc7af0203" diff --git a/mise.toml b/mise.toml index 51c4240547c..b3aa2540d94 100644 --- a/mise.toml +++ b/mise.toml @@ -8,7 +8,7 @@ lockfile = true [tools] # Languages and runtimes. bun = "1.2.15" -go = "1.26.4" +go = "1.26.5" node = "22.19.0" pnpm = "10.33.2" diff --git a/scripts/should_deploy.sh b/scripts/should_deploy.sh index 003828b411a..a23d3293d6c 100755 --- a/scripts/should_deploy.sh +++ b/scripts/should_deploy.sh @@ -1,7 +1,6 @@ #!/usr/bin/env bash -# This script determines if a commit in either the main branch or a -# `release/x.y` branch should be deployed to dogfood. +# This script determines if the current branch should be deployed to dogfood. # # To avoid masking unrelated failures, this script will return 0 in either case, # and will print `DEPLOY` or `NOOP` to stdout. @@ -11,73 +10,16 @@ set -euo pipefail source "$(dirname "${BASH_SOURCE[0]}")/lib.sh" cdroot -deploy_branch=main - -# Determine the current branch name and check that it is one of the supported -# branch names. branch_name=$(git branch --show-current) -# Short circuit: we no longer deploy release branches to dogfood, and instead -# test them on the stable deployment. +# We no longer deploy release branches to dogfood, and instead test them on the +# stable deployment. # TODO: once we're happy with the new deployment process, we can remove this -# script and the related github workflow stuff. +# script and the related GitHub workflow. if [[ "$branch_name" == "main" ]]; then log "VERDICT: DEPLOY" echo "DEPLOY" # stdout - exit 0 else log "VERDICT: NOOP" echo "NOOP" # stdout - exit 0 -fi - -if [[ "$branch_name" != "main" && ! "$branch_name" =~ ^release/[0-9]+\.[0-9]+$ ]]; then - error "Current branch '$branch_name' is not a supported branch name for dogfood, must be 'main' or 'release/x.y'" -fi -log "Current branch '$branch_name'" - -# Determine the remote name -remote=$(git remote -v | grep coder/coder | awk '{print $1}' | head -n1) -if [[ -z "${remote}" ]]; then - error "Could not find remote for coder/coder" -fi -log "Using remote '$remote'" - -# Step 1: List all release branches and sort them by major/minor so we can find -# the latest release branch. -release_branches=$( - git branch -r --format='%(refname:short)' | - grep -E "${remote}/release/[0-9]+\.[0-9]+$" | - sed "s|${remote}/||" | - sort -V -) - -# As a sanity check, release/2.26 should exist. -if ! echo "$release_branches" | grep "release/2.26" >/dev/null; then - error "Could not find existing release branches. Did you run 'git fetch -ap ${remote}'?" -fi - -latest_release_branch=$(echo "$release_branches" | tail -n 1) -latest_release_branch_version=${latest_release_branch#release/} -log "Latest release branch: $latest_release_branch" -log "Latest release branch version: $latest_release_branch_version" - -# Step 2: check if a matching tag `v.0` exists. If it does not, we will -# use the release branch as the deploy branch. -if ! git rev-parse "refs/tags/v${latest_release_branch_version}.0" >/dev/null 2>&1; then - log "Tag 'v${latest_release_branch_version}.0' does not exist, using release branch as deploy branch" - deploy_branch=$latest_release_branch -else - log "Matching tag 'v${latest_release_branch_version}.0' exists, using main as deploy branch" -fi -log "Deploy branch: $deploy_branch" - -# Finally, check if the current branch is the deploy branch. -log -if [[ "$branch_name" != "$deploy_branch" ]]; then - log "VERDICT: DO NOT DEPLOY" - echo "NOOP" # stdout -else - log "VERDICT: DEPLOY" - echo "DEPLOY" # stdout fi From 744439f729b42c61968d8e9b939c9ac1e124ed96 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Wed, 15 Jul 2026 12:59:03 +0200 Subject: [PATCH 093/138] fix(scripts/releaser): remove doubled "v" in release calendar latest release link (#27261) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem The interactive releaser (`scripts/releaser`) renders the "Latest Release" cell of the release calendar with a doubled version prefix, e.g. `[vv2.35.0](.../tag/v2.35.0)`. `version.String()` already returns a `v`-prefixed string (e.g. `v2.35.0`), but `updateCalendar` wrapped it in a `"[v%s]"` template, so the link label gained a second `v`. The tag URL was already correct because release tags carry the `v` prefix. ## Fix Drop the extra `v` from the label template (`"[v%s]"` → `"[%s]"`). The URL is unchanged. - Label before: `[vv2.35.0]` - Label after: `[v2.35.0]` ## Test Added `scripts/releaser/docs_test.go`: - `TestUpdateCalendarLatestReleaseVersionPrefix` asserts the `LatestRelease` cell for a matching row on both a patch and a minor release. It fails on the old code (`[vv2.35.x]`) and passes with the fix. - `TestUpdateCalendarNotReleasedRowName` covers the `Not Released` → `Mainline` promotion and the major.minor "Release name" link (patch omitted).
Investigation notes - Entry path: `scripts/release.sh` → `go run ./scripts/releaser` → `runRelease` → `promptAndUpdateDocs` → `updateReleaseDocs` → `updateCalendarFile` → `updateCalendar` (`scripts/releaser/docs.go`). - Root cause in `updateCalendar`: `fmt.Sprintf("[v%s](%s)", newVer.String(), ...)` combined with `version.String()` returning `v%d.%d.%d`. - Only the link label was affected; the `releaseTagURLFmt` URL was correct because tags are `v`-prefixed. - The standalone `scripts/update-release-calendar.sh` is a separate implementation and is not affected (it strips the `v` before re-adding one). - Companion PRs: #27260 (`main`, file `scripts/releaser/v1/docs.go`), #27259 (`release/2.35`).
--- This PR was generated by Coder Agents. --- scripts/releaser/docs.go | 2 +- scripts/releaser/docs_test.go | 85 +++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 scripts/releaser/docs_test.go diff --git a/scripts/releaser/docs.go b/scripts/releaser/docs.go index e605d365bfe..33fbfd79a04 100644 --- a/scripts/releaser/docs.go +++ b/scripts/releaser/docs.go @@ -171,7 +171,7 @@ func updateCalendar( for i, r := range rows { if r.Major == newVer.Major && r.Minor == newVer.Minor { rows[i].LatestRelease = fmt.Sprintf( - "[v%s](%s)", + "[%s](%s)", newVer.String(), fmt.Sprintf(releaseTagURLFmt, newVer.String()), ) diff --git a/scripts/releaser/docs_test.go b/scripts/releaser/docs_test.go new file mode 100644 index 00000000000..b682106f9f9 --- /dev/null +++ b/scripts/releaser/docs_test.go @@ -0,0 +1,85 @@ +package main + +import "testing" + +// TestUpdateCalendarLatestReleaseVersionPrefix checks the formatting of the +// "Latest Release" cell produced by updateCalendar. version.String() already +// includes a leading "v", so the link label must not add a second one, while +// the release tag URL keeps the "v" because tags are prefixed with it. +func TestUpdateCalendarLatestReleaseVersionPrefix(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + newVer version + channel string + want string + }{ + { + name: "patch release", + newVer: version{Major: 2, Minor: 35, Patch: 1}, + channel: "mainline", + want: "[v2.35.1](https://github.com/coder/coder/releases/tag/v2.35.1)", + }, + { + name: "minor release", + newVer: version{Major: 2, Minor: 35, Patch: 0}, + channel: "mainline", + want: "[v2.35.0](https://github.com/coder/coder/releases/tag/v2.35.0)", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + rows := []calendarRow{{ + ReleaseName: "2.35", + Major: 2, + Minor: 35, + ReleaseDate: "February 03, 2026", + Status: "Mainline", + LatestRelease: "N/A", + }} + + got := updateCalendar(rows, tt.newVer, tt.channel) + + var cell string + for _, r := range got { + if r.Major == tt.newVer.Major && r.Minor == tt.newVer.Minor { + cell = r.LatestRelease + break + } + } + + if cell != tt.want { + t.Fatalf("LatestRelease = %q, want %q", cell, tt.want) + } + }) + } +} + +// TestUpdateCalendarNotReleasedRowName checks that a "Not Released" row is +// promoted to "Mainline" with a major.minor "Release name" link (patch +// omitted) once its minor version is released. +func TestUpdateCalendarNotReleasedRowName(t *testing.T) { + t.Parallel() + + rows := []calendarRow{{ + ReleaseName: "2.36", + Major: 2, + Minor: 36, + Status: "Not Released", + LatestRelease: "N/A", + }} + + got := updateCalendar(rows, version{Major: 2, Minor: 36, Patch: 0}, "mainline") + + if got[0].Status != "Mainline" { + t.Errorf("Status = %q, want %q", got[0].Status, "Mainline") + } + const want = "[2.36](https://coder.com/changelog/coder-2-36)" + if got[0].ReleaseName != want { + t.Fatalf("ReleaseName = %q, want %q", got[0].ReleaseName, want) + } +} From eda3747cef5824d0c66d743334c3b9a4186f7b24 Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Thu, 16 Jul 2026 00:05:49 -0700 Subject: [PATCH 094/138] fix: resolve client IP from the rightmost untrusted X-Forwarded-For entry (backport 2.34) (#27236) Backports #26646 to release/2.34 (Stable/ESR). A client could spoof its X-Forwarded-For value by prepending a fake IP; `getRemoteAddress` took the leftmost comma-delimited token, so appending proxies (nginx, ALB, Cloudflare) never overrode the spoofed value. This fed `httpmw.RateLimit` (per-IP login throttling) and audit log `IPAddress` fields, enabling rate-limit bypass and audit falsification. This is the security fix tracked in PLAT-258 / coder/security-disclosures#9 (SEC-FC61DF2BF7). It already shipped in mainline (v2.35.0); this PR brings it to the Stable/ESR line. Clean cherry-pick, no conflicts. `coderd/httpmw/realip_test.go` covers the spoofing scenario. Co-authored-by: Jon Ayers --- coderd/httpmw/realip.go | 59 ++++++++++++++++++++--- coderd/httpmw/realip_test.go | 91 ++++++++++++++++++++++++++++++++++++ 2 files changed, 144 insertions(+), 6 deletions(-) diff --git a/coderd/httpmw/realip.go b/coderd/httpmw/realip.go index f428e15fcf4..b3e460b9e9d 100644 --- a/coderd/httpmw/realip.go +++ b/coderd/httpmw/realip.go @@ -70,7 +70,17 @@ func ExtractRealIPAddress(config *RealIPConfig, req *http.Request) (net.IP, erro } for _, trustedHeader := range config.TrustedHeaders { - addr := getRemoteAddress(req.Header.Get(trustedHeader)) + // X-Forwarded-For is a list-valued header. Per RFC 7230, multiple + // field lines with the same name are equivalent to a single + // comma-separated value. Join them so a client cannot hide a spoofed + // address in the first field line, which Header.Get would return on + // its own. Other forwarding headers carry a single edge-proxy value, + // so use Header.Get to preserve their first-value semantics. + value := req.Header.Get(trustedHeader) + if http.CanonicalHeaderKey(trustedHeader) == headerXForwardedFor { + value = strings.Join(req.Header.Values(trustedHeader), ",") + } + addr := extractForwardedAddress(config, value) if addr != nil { return addr, nil } @@ -101,6 +111,14 @@ func FilterUntrustedOriginHeaders(config *RealIPConfig, req *http.Request) { } for _, header := range config.TrustedHeaders { + // X-Forwarded-For is a list-valued header whose field lines are + // equivalent to a single comma-separated value (RFC 7230 section + // 3.2.2). Join them so later hops are not dropped when collapsing to a + // single line. Other forwarding headers carry a single value. + if http.CanonicalHeaderKey(header) == headerXForwardedFor { + req.Header.Set(header, strings.Join(req.Header.Values(header), ",")) + continue + } req.Header.Set(header, req.Header.Get(header)) } } @@ -185,12 +203,15 @@ func EnsureXForwardedForHeader(req *http.Request) error { return nil } -// getRemoteAddress extracts the IP address from the given string. If -// the string contains commas, it assumes that the first part is the -// original address. +// getRemoteAddress extracts a single IP address from the given string, +// stripping a port if present. If the string contains commas, only the +// portion before the first comma is parsed. This helper does not select the +// real client from a multi-hop X-Forwarded-For chain; use +// extractForwardedAddress for that, which accounts for client-supplied values. func getRemoteAddress(address string) net.IP { - // X-Forwarded-For may contain multiple addresses, in case the - // proxies are chained; the first value is the client address + // A value may contain a port and, for a raw X-Forwarded-For value, more + // than one comma-separated address. Parse only the part before the first + // comma. i := strings.IndexByte(address, ',') if i == -1 { i = len(address) @@ -206,6 +227,32 @@ func getRemoteAddress(address string) net.IP { return net.ParseIP(host) } +// extractForwardedAddress parses a comma-separated forwarding header value and +// returns the rightmost address that is not a trusted origin. Reverse proxies +// append the peer that connected to them, so when every trusted proxy hop is +// listed in TrustedOrigins, the rightmost untrusted address is the real client; +// any values a client prepends to spoof its address sit to the left of the +// addresses inserted by trusted proxies and are ignored. If every parsed address +// is a trusted origin, the leftmost address is returned. It returns nil when no +// address can be parsed. +func extractForwardedAddress(config *RealIPConfig, value string) net.IP { + parts := strings.Split(value, ",") + var leftmost net.IP + for i := len(parts) - 1; i >= 0; i-- { + ip := getRemoteAddress(strings.TrimSpace(parts[i])) + if ip == nil { + continue + } + // Iterating right-to-left, so the last assignment is the leftmost + // valid address, used as the fallback when all hops are trusted. + leftmost = ip + if !isContainedIn(config.TrustedOrigins, ip) { + return ip + } + } + return leftmost +} + // isContainedIn checks that the given address is contained in the given // network. func isContainedIn(networks []*net.IPNet, address net.IP) bool { diff --git a/coderd/httpmw/realip_test.go b/coderd/httpmw/realip_test.go index caa1fe98496..cce7445bf68 100644 --- a/coderd/httpmw/realip_test.go +++ b/coderd/httpmw/realip_test.go @@ -82,6 +82,72 @@ func TestExtractAddress(t *testing.T) { }, ExpectedRemoteAddr: "10.24.1.1", }, + { + // A chain of trusted proxies appends each hop. The rightmost + // untrusted address (the real client) wins, skipping the trusted + // inner-proxy hop. + Name: "picks-rightmost-untrusted", + Config: &httpmw.RealIPConfig{ + TrustedOrigins: []*net.IPNet{ + { + IP: net.ParseIP("10.0.0.0"), + Mask: net.CIDRMask(8, 32), + }, + }, + TrustedHeaders: []string{ + "X-Forwarded-For", + }, + }, + RemoteAddr: "10.0.0.1", + Header: http.Header{ + "X-Forwarded-For": []string{"1.2.3.4, 203.0.113.5, 10.0.0.2"}, + }, + ExpectedRemoteAddr: "203.0.113.5", + }, + { + // When every parsed hop is a trusted origin, there is no untrusted + // client to select, so the leftmost address is used. + Name: "all-trusted-falls-back-to-leftmost", + Config: &httpmw.RealIPConfig{ + TrustedOrigins: []*net.IPNet{ + { + IP: net.ParseIP("10.0.0.0"), + Mask: net.CIDRMask(8, 32), + }, + }, + TrustedHeaders: []string{ + "X-Forwarded-For", + }, + }, + RemoteAddr: "10.0.0.1", + Header: http.Header{ + "X-Forwarded-For": []string{"10.0.0.1, 10.0.0.2"}, + }, + ExpectedRemoteAddr: "10.0.0.1", + }, + { + // A proxy may append its hop as a separate header line. Per + // RFC 7230 section 3.2.2 these are equivalent to a single + // comma-joined value, so the spoofed first line must not be + // trusted on its own. + Name: "x-forwarded-for-set-multiple-times", + Config: &httpmw.RealIPConfig{ + TrustedOrigins: []*net.IPNet{ + { + IP: net.ParseIP("10.0.0.0"), + Mask: net.CIDRMask(8, 32), + }, + }, + TrustedHeaders: []string{ + "X-Forwarded-For", + }, + }, + RemoteAddr: "10.0.0.1", + Header: http.Header{ + "X-Forwarded-For": []string{"1.2.3.4", "203.0.113.5, 10.0.0.2"}, + }, + ExpectedRemoteAddr: "203.0.113.5", + }, { Name: "single-real-ip", Config: &httpmw.RealIPConfig{ @@ -456,6 +522,31 @@ func TestFilterUntrusted(t *testing.T) { }, ExpectedRemoteAddr: "1.2.3.4", }, + { + // For a trusted origin, multiple X-Forwarded-For field lines are + // joined into one comma-separated value rather than collapsed to + // the first line, so later hops are preserved. + Name: "trusted-origin-joins-multiple-x-forwarded-for", + Config: &httpmw.RealIPConfig{ + TrustedOrigins: []*net.IPNet{ + { + IP: net.ParseIP("10.0.0.0"), + Mask: net.CIDRMask(8, 32), + }, + }, + TrustedHeaders: []string{ + "X-Forwarded-For", + }, + }, + Header: http.Header{ + "X-Forwarded-For": []string{"1.2.3.4", "203.0.113.5, 10.0.0.2"}, + }, + RemoteAddr: "10.0.0.1", + ExpectedHeader: http.Header{ + "X-Forwarded-For": []string{"1.2.3.4,203.0.113.5, 10.0.0.2"}, + }, + ExpectedRemoteAddr: "10.0.0.1", + }, } for _, test := range tests { From ee17a3bdd315fe3e41c96f8b074c0d5664d938f6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 16 Jul 2026 09:06:48 +0200 Subject: [PATCH 095/138] fix(site): keep activity bump editable when allow_user_autostop is on (#27083) (#27144) 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. Backport of https://github.com/coder/coder/pull/27083 Original PR: #27083 — fix(site): keep activity bump editable when allow_user_autostop is on Merge commit: 1eea4a7e5b9d096597d45c2a4a8fd14629ff1258 Requested by: @rowansmithau
Cherry-pick conflict resolution The automatic cherry-pick of the merge commit (`-m1`) conflicted because its diff pulled in the **autostop reminder** feature (`AutostopReminderHelperText`, the "Autostop reminder (hours)" field, and `time_til_autostop_notify_ms`), which does not exist on `release/2.34`. Resolution: apply only PR #27083's actual change (the `allow_user_autostop` / activity-bump fix) and drop the unrelated autostop-reminder additions that the merge-commit diff dragged in. - `TTLHelperText.tsx`: applied the `ActivityBumpHelperText` `allowUserAutostop` change; did not add `AutostopReminderHelperText`. - `TemplateScheduleForm.tsx`: applied the activity-bump `disabled` and submit changes; did not add the autostop-reminder field. - `TemplateSchedulePage.test.tsx`: removed the three superseded activity-bump tests (auto-merged cleanly). - `TemplateSchedulePageView.stories.tsx`: added the three activity-bump stories; dropped the autostop-reminder stories. Verified on 2.34: `TemplateSchedulePage.test.tsx` (20 tests) and the `TemplateSchedulePageView` storybook interaction tests (6) pass; the changed files type-check and lint cleanly.
--------- Co-authored-by: Jake Howell Co-authored-by: Jake Howell --- .../TemplateSchedulePage/TTLHelperText.tsx | 14 ++- .../TemplateScheduleForm.tsx | 14 ++- .../TemplateSchedulePage.test.tsx | 60 ---------- .../TemplateSchedulePageView.stories.tsx | 113 ++++++++++++++++++ 4 files changed, 133 insertions(+), 68 deletions(-) diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TTLHelperText.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TTLHelperText.tsx index 90a1606d35b..f58e56a7408 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TTLHelperText.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TTLHelperText.tsx @@ -25,14 +25,20 @@ export const DefaultTTLHelperText = (props: { ttl?: number }) => { export const ActivityBumpHelperText = (props: { bump?: number; defaultTTL?: number; + allowUserAutostop?: boolean; }) => { - const { bump = 0, defaultTTL = 0 } = props; + const { bump = 0, defaultTTL = 0, allowUserAutostop = false } = props; - if (!defaultTTL) { + // Activity bump extends a workspace's scheduled stop time. If there is no + // default TTL AND users cannot set their own autostop, there is no stop + // time to bump, so the field has no effect. + if (!defaultTTL && !allowUserAutostop) { return ( - Activity bump only applies when a default TTL is configured. Set a - default TTL above to enable activity bumping. + Activity bump only applies when "Default autostop" is configured or + users are allowed to customize autostop. Set "Default autostop" above or + check "Allow users to customize autostop duration for workspaces" below + to enable activity bumping. ); } diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx index 2db8dafc2f9..d4eda81d32d 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx @@ -201,10 +201,12 @@ export const TemplateScheduleForm: FC = ({ default_ttl_ms: form.values.default_ttl_ms ? form.values.default_ttl_ms * MS_HOUR_CONVERSION : undefined, - // Activity bump has no effect without a default TTL, so - // discard any stale value when default autostop is off. + // Activity bump has no effect without a scheduled stop time, so + // discard any stale value when there is no default TTL AND users + // cannot customize autostop on their workspaces. activity_bump_ms: - form.values.default_ttl_ms && form.values.activity_bump_ms + (form.values.default_ttl_ms || form.values.allow_user_autostop) && + form.values.activity_bump_ms ? form.values.activity_bump_ms * MS_HOUR_CONVERSION : undefined, failure_ttl_ms: form.values.failure_ttl_ms, @@ -307,10 +309,14 @@ export const TemplateScheduleForm: FC = ({ ), })} - disabled={isSubmitting || !form.values.default_ttl_ms} + disabled={ + isSubmitting || + (!form.values.default_ttl_ms && !form.values.allow_user_autostop) + } fullWidth inputProps={{ min: 0, step: 1 }} label="Activity bump (hours)" diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.test.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.test.tsx index 21837080e62..d2edb82598f 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.test.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.test.tsx @@ -349,64 +349,4 @@ describe("TemplateSchedulePage", () => { const validate = () => getValidationSchema().validateSync(values); expect(validate).toThrowError(); }); - - it("disables activity bump field when default TTL is 0", async () => { - await renderTemplateSchedulePage(); - const user = userEvent.setup(); - - const defaultTtlField = await screen.findByLabelText( - "Default autostop (hours)", - ); - const activityBumpField = screen.getByLabelText("Activity bump (hours)"); - - // Activity bump should be enabled when default TTL is non-zero. - expect(activityBumpField).not.toBeDisabled(); - - // Set default TTL to 0. - await user.clear(defaultTtlField); - await user.type(defaultTtlField, "0"); - - // Activity bump should now be disabled. - expect(activityBumpField).toBeDisabled(); - }); - - it("shows helper text on activity bump when default TTL is 0", async () => { - await renderTemplateSchedulePage(); - const user = userEvent.setup(); - - const defaultTtlField = await screen.findByLabelText( - "Default autostop (hours)", - ); - - // Set default TTL to 0. - await user.clear(defaultTtlField); - await user.type(defaultTtlField, "0"); - - // Should show the explanatory helper text. - expect( - screen.getByText( - /activity bump only applies when a default TTL is configured/i, - ), - ).toBeInTheDocument(); - }); - - it("re-enables activity bump field when default TTL is set back to non-zero", async () => { - await renderTemplateSchedulePage(); - const user = userEvent.setup(); - - const defaultTtlField = await screen.findByLabelText( - "Default autostop (hours)", - ); - const activityBumpField = screen.getByLabelText("Activity bump (hours)"); - - // Set default TTL to 0. - await user.clear(defaultTtlField); - await user.type(defaultTtlField, "0"); - expect(activityBumpField).toBeDisabled(); - - // Set default TTL back to a non-zero value. - await user.clear(defaultTtlField); - await user.type(defaultTtlField, "8"); - expect(activityBumpField).not.toBeDisabled(); - }); }); diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePageView.stories.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePageView.stories.tsx index 5ba34480bfb..d3b4686ee5e 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePageView.stories.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePageView.stories.tsx @@ -53,6 +53,10 @@ export const SubmitClearsActivityBumpWhenDefaultTTLIsZero: Story = { // Start with a non-zero activity bump so we can verify // it gets discarded when default TTL is set to 0. activity_bump_ms: 3 * 60 * 60 * 1000, + // Disable user autostop so the guard applies (activity bump + // remains editable when either default TTL or user autostop is + // enabled). + allow_user_autostop: false, }, onSubmit: fn(), }, @@ -70,6 +74,13 @@ export const SubmitClearsActivityBumpWhenDefaultTTLIsZero: Story = { await expect(activityBumpField).toBeDisabled(); + // Helper text explains why the field is disabled. + await expect( + canvas.getByText( + /activity bump only applies when "default autostop" is configured or users are allowed to customize autostop/i, + ), + ).toBeInTheDocument(); + const submitButton = canvas.getByRole("button", { name: /save/i }); await user.click(submitButton); @@ -82,3 +93,105 @@ export const SubmitClearsActivityBumpWhenDefaultTTLIsZero: Story = { }); }, }; + +export const SubmitPreservesActivityBumpWhenAllowUserAutostopIsEnabled: Story = + { + args: { + ...defaultArgs, + template: { + ...MockTemplate, + activity_bump_ms: 3 * 60 * 60 * 1000, + // Users can customize autostop on their workspaces, so activity + // bump still has meaning even without a default TTL. + allow_user_autostop: true, + }, + onSubmit: fn(), + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + const user = userEvent.setup(); + + const defaultTtlField = await canvas.findByLabelText( + "Default autostop (hours)", + ); + const activityBumpField = canvas.getByLabelText("Activity bump (hours)"); + + await user.clear(defaultTtlField); + await user.type(defaultTtlField, "0"); + + // Activity bump stays enabled because allow_user_autostop is on. + await expect(activityBumpField).not.toBeDisabled(); + + const submitButton = canvas.getByRole("button", { name: /save/i }); + await user.click(submitButton); + + await waitFor(() => { + expect(args.onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + activity_bump_ms: 3 * 60 * 60 * 1000, + }), + ); + }); + }, + }; + +export const ReEnablesActivityBumpWhenDefaultTTLIsSetBack: Story = { + args: { + ...defaultArgs, + template: { + ...MockTemplate, + allow_user_autostop: false, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const user = userEvent.setup(); + + const defaultTtlField = await canvas.findByLabelText( + "Default autostop (hours)", + ); + const activityBumpField = canvas.getByLabelText("Activity bump (hours)"); + + // Guard fires: default TTL is 0 and user autostop is off. + await user.clear(defaultTtlField); + await user.type(defaultTtlField, "0"); + await expect(activityBumpField).toBeDisabled(); + + // Setting default TTL back to a non-zero value re-enables the field. + await user.clear(defaultTtlField); + await user.type(defaultTtlField, "8"); + await expect(activityBumpField).not.toBeDisabled(); + }, +}; + +export const ReEnablesActivityBumpWhenAllowUserAutostopIsToggledOn: Story = { + args: { + ...defaultArgs, + template: { + ...MockTemplate, + allow_user_autostop: false, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const user = userEvent.setup(); + + const defaultTtlField = await canvas.findByLabelText( + "Default autostop (hours)", + ); + const activityBumpField = canvas.getByLabelText("Activity bump (hours)"); + const allowUserAutostopCheckbox = canvas.getByRole("checkbox", { + name: /allow users to customize autostop/i, + }); + + // Guard fires: default TTL is 0 and user autostop is off. + await user.clear(defaultTtlField); + await user.type(defaultTtlField, "0"); + await expect(activityBumpField).toBeDisabled(); + + // Toggling user autostop back on re-enables the field without + // touching the default TTL. + await user.click(allowUserAutostopCheckbox); + await expect(activityBumpField).not.toBeDisabled(); + }, +}; From 2b9e328f9de142a1da001583dc89a6bb5c4c9f32 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 21 Jul 2026 12:02:03 -0500 Subject: [PATCH 096/138] docs(docs/ai-coder/ai-gateway): fix bmcp_ described as suffix instead of prefix (#27392) (#27393) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport of https://github.com/coder/coder/pull/27392 Original PR: #27392 — docs(docs/ai-coder/ai-gateway): fix bmcp_ described as suffix instead of prefix Merge commit: f55be09bfca83892bdd46b094dc9be2fd0eb3800 Requested by: @nickvigilante Co-authored-by: Nick Vigilante --- docs/ai-coder/ai-gateway/mcp.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ai-coder/ai-gateway/mcp.md b/docs/ai-coder/ai-gateway/mcp.md index 494f43832b5..b62ad603a66 100644 --- a/docs/ai-coder/ai-gateway/mcp.md +++ b/docs/ai-coder/ai-gateway/mcp.md @@ -64,7 +64,7 @@ AI Gateway marks automatically injected tools with a prefix `bmcp_` ("bridged MC ## Tool Injection -If a model decides to invoke a tool and it has a `bmcp_` suffix and AI Gateway has a connection with the related MCP server, it will invoke the tool. The tool result will be passed back to the upstream AI provider, and this will loop until the model has all of its required data. These inner loops are not relayed back to the client; all it sees is the result of this loop. See [Implementation Details](./reference.md#implementation-details). +If a model decides to invoke a tool and it has a `bmcp_` prefix and AI Gateway has a connection with the related MCP server, it will invoke the tool. The tool result will be passed back to the upstream AI provider, and this will loop until the model has all of its required data. These inner loops are not relayed back to the client; all it sees is the result of this loop. See [Implementation Details](./reference.md#implementation-details). In contrast, tools which are defined by the client (i.e. the [`Bash` tool](https://docs.claude.com/en/docs/claude-code/settings#tools-available-to-claude) defined by _Claude Code_) cannot be invoked by AI Gateway, and the tool call from the model will be relayed to the client, after which it will invoke the tool. From ffa50fb18dc8a98fa84f667e7b038ea4402a47f8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 09:27:00 +0200 Subject: [PATCH 097/138] fix(site): replace ansi-to-html in the log viewer (#27206) (#27242) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport of https://github.com/coder/coder/pull/27206 Original PR: #27206 — fix(site): replace ansi-to-html in the log viewer Merge commit: b377bec5f4690d33706eb150dcd3939080e80e12 Requested by: @aslilac Co-authored-by: McKayla はな --- site/package.json | 2 +- site/pnpm-lock.yaml | 27 +++++--------- .../resources/AgentLogs/AgentLogLine.test.tsx | 37 +++++++++++++++++-- .../resources/AgentLogs/AgentLogLine.tsx | 21 +++++------ .../resources/AgentLogs/AgentLogs.stories.tsx | 37 +++++++++++++++++++ 5 files changed, 90 insertions(+), 34 deletions(-) diff --git a/site/package.json b/site/package.json index d2cf5e5512d..832403a28aa 100644 --- a/site/package.json +++ b/site/package.json @@ -68,7 +68,6 @@ "@xterm/addon-web-links": "0.12.0", "@xterm/addon-webgl": "0.19.0", "@xterm/xterm": "5.5.0", - "ansi-to-html": "0.7.2", "axios": "1.15.2", "chroma-js": "2.6.0", "class-variance-authority": "0.7.1", @@ -80,6 +79,7 @@ "dayjs": "1.11.20", "diff": "8.0.4", "emoji-mart": "5.6.0", + "fancy-ansi": "0.1.3", "file-saver": "2.0.5", "formik": "2.4.9", "front-matter": "4.0.2", diff --git a/site/pnpm-lock.yaml b/site/pnpm-lock.yaml index be78e8c75d6..d96aecbb121 100644 --- a/site/pnpm-lock.yaml +++ b/site/pnpm-lock.yaml @@ -119,9 +119,6 @@ importers: '@xterm/xterm': specifier: 5.5.0 version: 5.5.0 - ansi-to-html: - specifier: 0.7.2 - version: 0.7.2 axios: specifier: 1.15.2 version: 1.15.2 @@ -155,6 +152,9 @@ importers: emoji-mart: specifier: 5.6.0 version: 5.6.0 + fancy-ansi: + specifier: 0.1.3 + version: 0.1.3 file-saver: specifier: 2.0.5 version: 2.0.5 @@ -2963,11 +2963,6 @@ packages: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==, tarball: https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz} engines: {node: '>=12'} - ansi-to-html@0.7.2: - resolution: {integrity: sha512-v6MqmEpNlxF+POuyhKkidusCHWWkaLcGRURzivcU3I9tv7k4JVhFcnukrM5Rlk2rUywdZuzYAZ+kbZqWCnfN3g==, tarball: https://registry.npmjs.org/ansi-to-html/-/ansi-to-html-0.7.2.tgz} - engines: {node: '>=8.0.0'} - hasBin: true - any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==, tarball: https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz} @@ -3722,9 +3717,6 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==, tarball: https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz} engines: {node: '>= 0.8'} - entities@2.2.0: - resolution: {integrity: sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==, tarball: https://registry.npmjs.org/entities/-/entities-2.2.0.tgz} - entities@6.0.1: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==, tarball: https://registry.npmjs.org/entities/-/entities-6.0.1.tgz} engines: {node: '>=0.12'} @@ -3813,6 +3805,9 @@ packages: extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==, tarball: https://registry.npmjs.org/extend/-/extend-3.0.2.tgz} + fancy-ansi@0.1.3: + resolution: {integrity: sha512-tRQVTo5jjdSIiydqgzIIEZpKddzSsfGLsSVt6vWdjVm7fbvDTiQkyoPu6Z3dIPlAM4OZk0jP5jmTCX4G8WGgBw==, tarball: https://registry.npmjs.org/fancy-ansi/-/fancy-ansi-0.1.3.tgz} + fast-equals@5.3.2: resolution: {integrity: sha512-6rxyATwPCkaFIL3JLqw8qXqMpIZ942pTX/tbQFkRsDGblS8tNGtlUauA/+mt6RUfqn/4MoEr+WDkYoIQbibWuQ==, tarball: https://registry.npmjs.org/fast-equals/-/fast-equals-5.3.2.tgz} engines: {node: '>=6.0.0'} @@ -9014,10 +9009,6 @@ snapshots: ansi-styles@6.2.3: {} - ansi-to-html@0.7.2: - dependencies: - entities: 2.2.0 - any-promise@1.3.0: {} anymatch@3.1.3: @@ -9773,8 +9764,6 @@ snapshots: encodeurl@2.0.0: {} - entities@2.2.0: {} - entities@6.0.1: {} error-ex@1.3.2: @@ -9905,6 +9894,10 @@ snapshots: extend@3.0.2: {} + fancy-ansi@0.1.3: + dependencies: + escape-html: 1.0.3 + fast-equals@5.3.2: {} fast-glob@3.3.3: diff --git a/site/src/modules/resources/AgentLogs/AgentLogLine.test.tsx b/site/src/modules/resources/AgentLogs/AgentLogLine.test.tsx index 80b1d900b04..7dcdc1a5de4 100644 --- a/site/src/modules/resources/AgentLogs/AgentLogLine.test.tsx +++ b/site/src/modules/resources/AgentLogs/AgentLogLine.test.tsx @@ -3,21 +3,50 @@ import type { Line } from "#/components/Logs/LogLine"; import { renderComponent } from "#/testHelpers/renderHelpers"; import { AgentLogLine } from "./AgentLogLine"; -const line: Line = { +const makeLine = (output: string): Line => ({ id: 1, level: "info", - output: 'safe xss', + output, sourceId: "source-id", time: "2024-03-14T11:31:04.090715Z", -}; +}); + +const renderLine = (output: string) => + renderComponent( + , + ); describe("AgentLogLine", () => { it("renders log HTML as escaped text", () => { - renderComponent(); + renderLine('safe xss'); expect(screen.queryByTestId("agent-log-xss")).not.toBeInTheDocument(); expect( screen.getByText(/safe xss<\/span>/), ).toBeInTheDocument(); }); + + it("renders ANSI color codes as styled markup", () => { + renderLine("\u001b[31mred\u001b[0m plain"); + + const colored = screen.getByText("red"); + expect(colored.tagName).toBe("SPAN"); + expect(colored.getAttribute("style")).toContain("--ansi-red"); + expect(screen.getByText(/plain/)).toBeInTheDocument(); + }); + + it("shows only the text after the last carriage return", () => { + renderLine("downloading... 50%\rdownloading... 100%"); + + expect(screen.getByText("downloading... 100%")).toBeInTheDocument(); + expect(screen.queryByText(/50%/)).not.toBeInTheDocument(); + }); + + it("renders a ReDoS payload without hanging", () => { + // Cure53 CDM-02-004: this pattern caused catastrophic backtracking in + // ansi-to-html. fancy-ansi parses it in linear time. + const start = performance.now(); + renderLine(`\u001b[${"1".repeat(50000)}`); + expect(performance.now() - start).toBeLessThan(1000); + }); }); diff --git a/site/src/modules/resources/AgentLogs/AgentLogLine.tsx b/site/src/modules/resources/AgentLogs/AgentLogLine.tsx index d7b3c50dbfe..46f6e24784e 100644 --- a/site/src/modules/resources/AgentLogs/AgentLogLine.tsx +++ b/site/src/modules/resources/AgentLogs/AgentLogLine.tsx @@ -1,12 +1,10 @@ -import AnsiToHTML from "ansi-to-html"; import dayjs from "dayjs"; +import { AnsiHtml } from "fancy-ansi/react"; import { type FC, type ReactNode, useMemo } from "react"; 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({ escapeXML: true }); - interface AgentLogLineProps { line: Line; style: React.CSSProperties; @@ -18,9 +16,13 @@ export const AgentLogLine: FC = ({ sourceIcon, style, }) => { - const output = useMemo(() => { - return convert.toHtml(line.output.split(/\r/g).pop() as string); - }, [line.output]); + // Only render the text after the last carriage return so progress-bar style + // output that redraws a single line shows its final state. + const lastCarriageReturn = line.output.lastIndexOf("\r"); + const output = + lastCarriageReturn === -1 + ? line.output + : line.output.slice(lastCarriageReturn + 1); const timestamp = useMemo(() => { return dayjs(line.time).format("HH:mm:ss.SSS"); }, [line.time]); @@ -29,12 +31,7 @@ export const AgentLogLine: FC = ({ {sourceIcon} {timestamp} - + ); }; diff --git a/site/src/modules/resources/AgentLogs/AgentLogs.stories.tsx b/site/src/modules/resources/AgentLogs/AgentLogs.stories.tsx index bd4f3364b7b..1cd1035a079 100644 --- a/site/src/modules/resources/AgentLogs/AgentLogs.stories.tsx +++ b/site/src/modules/resources/AgentLogs/AgentLogs.stories.tsx @@ -1,4 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { Line } from "#/components/Logs/LogLine"; import { AGENT_LOG_LINE_HEIGHT } from "./AgentLogLine"; import { AgentLogs } from "./AgentLogs"; import { MockLogs, MockSources } from "./mocks"; @@ -28,3 +29,39 @@ export const Overflowed: Story = { overflowed: true, }, }; + +const sourceId = MockSources[0].id; + +// Demonstrates how ANSI escape sequences and carriage-return progress-bar +// redraws are rendered in the log viewer. +const AnsiLogs: readonly Line[] = [ + { + id: 1, + level: "info", + output: + "\u001b[31mred\u001b[0m \u001b[32mgreen\u001b[0m \u001b[34mblue\u001b[0m \u001b[1mbold\u001b[0m \u001b[3mitalic\u001b[0m", + time: "2024-03-14T11:31:04.090715Z", + sourceId, + }, + { + id: 2, + level: "info", + output: "\u001b[43m\u001b[30m warning \u001b[0m background colors", + time: "2024-03-14T11:31:04.090715Z", + sourceId, + }, + { + id: 3, + level: "info", + output: "downloading... 50%\rdownloading... 100%", + time: "2024-03-14T11:31:04.090715Z", + sourceId, + }, +]; + +export const AnsiFormatting: Story = { + args: { + logs: AnsiLogs, + height: AnsiLogs.length * AGENT_LOG_LINE_HEIGHT, + }, +}; From 58e20abbd54f2aa7ee3c416a4f68236b5a1c4461 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?McKayla=20=E3=81=AF=E3=81=AA?= Date: Mon, 27 Jul 2026 01:28:37 -0600 Subject: [PATCH 098/138] fix(coderd): harden oauth2 redirect validation (#27274) (#27463) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport of https://github.com/coder/coder/pull/27274 Original PR: #27274 — fix(coderd): harden oauth2 redirect validation Merge commit: 2f879910af7d0017736ea35bfa0f34eef557dd66 Requested by: @aslilac --- coderd/externalauth.go | 12 +-------- coderd/httpapi/redirect.go | 31 +++++++++++++++++++++ coderd/httpapi/redirect_test.go | 48 +++++++++++++++++++++++++++++++++ coderd/httpmw/oauth2.go | 12 +-------- coderd/userauth.go | 4 +-- 5 files changed, 83 insertions(+), 24 deletions(-) create mode 100644 coderd/httpapi/redirect.go create mode 100644 coderd/httpapi/redirect_test.go diff --git a/coderd/externalauth.go b/coderd/externalauth.go index 29eb53e6797..51b7727c00d 100644 --- a/coderd/externalauth.go +++ b/coderd/externalauth.go @@ -5,7 +5,6 @@ import ( "errors" "fmt" "net/http" - "net/url" "github.com/sqlc-dev/pqtype" "golang.org/x/sync/errgroup" @@ -331,7 +330,7 @@ func (api *API) externalAuthCallback(externalAuthConfig *externalauth.Config) ht // FE know not to enter the authentication loop again, and instead display an error. redirect = fmt.Sprintf("/external-auth/%s?redirected=true", externalAuthConfig.ID) } - redirect = uriFromURL(redirect) + redirect = httpapi.SafeRedirectPath(redirect) http.Redirect(rw, r, redirect, http.StatusTemporaryRedirect) } } @@ -429,12 +428,3 @@ func ExternalAuthConfig(cfg *externalauth.Config) codersdk.ExternalAuthLinkProvi CodeChallengeMethodsSupported: slice.ToStrings(cfg.CodeChallengeMethodsSupported), } } - -func uriFromURL(u string) string { - uri, err := url.Parse(u) - if err != nil { - return "/" - } - - return uri.RequestURI() -} diff --git a/coderd/httpapi/redirect.go b/coderd/httpapi/redirect.go new file mode 100644 index 00000000000..6c1c49e39e3 --- /dev/null +++ b/coderd/httpapi/redirect.go @@ -0,0 +1,31 @@ +package httpapi + +import ( + "net/url" + "strings" +) + +// SafeRedirectPath reduces a redirect URL down to a safe, relative path. The +// scheme and host are dropped to prevent redirecting to another origin. Opaque +// URLs (e.g. `javascript:`, `data:`) are rejected outright and default to /. +func SafeRedirectPath(u string) string { + uri, err := url.Parse(u) + if err != nil || uri.Opaque != "" { + return "/" + } + + // A path with 2 or more leading slashes (e.g. "//evil.com") is interpreted as + // protocol-relative, so make sure there is exactly one. + path := "/" + strings.TrimLeft(uri.EscapedPath(), "/") + if uri.RawQuery != "" { + path += "?" + uri.RawQuery + } + // We're specifically checking Fragment instead of RawFragment here because + // RawFragment is only populated when the parser needs to preserve a + // non-default escaping, so it is empty for plain-alphanumeric fragments like + // "#wooble". EscapedFragment handles escaping correctly in either case. + if uri.Fragment != "" { + path += "#" + uri.EscapedFragment() + } + return path +} diff --git a/coderd/httpapi/redirect_test.go b/coderd/httpapi/redirect_test.go new file mode 100644 index 00000000000..7bb9b88d99c --- /dev/null +++ b/coderd/httpapi/redirect_test.go @@ -0,0 +1,48 @@ +package httpapi_test + +import ( + "testing" + + "github.com/coder/coder/v2/coderd/httpapi" +) + +func TestSafeRedirectPath(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + in string + want string + }{ + {"empty", "", "/"}, + {"simple path", "/foo/bar", "/foo/bar"}, + {"path with query", "/foo/bar?baz=qux", "/foo/bar?baz=qux"}, + {"path with fragment", "/foo/bar#wooble", "/foo/bar#wooble"}, + {"path with query+fragment", "/foo/bar?wibble=wobble#wooble", "/foo/bar?wibble=wobble#wooble"}, + {"no leading slash", "foo/bar", "/foo/bar"}, + {"malformed", "http://[::1]:namedport", "/"}, + // Ensure backslashes aren't a blindspot. + {"backslash after slash", `/\evil.example.com`, "/%5Cevil.example.com"}, + {"leading double backslash", `\\evil.example.com`, "/%5C%5Cevil.example.com"}, + {"backslash then slash", `\/evil.example.com`, "/%5C/evil.example.com"}, + {"mixed slash backslash", `/\/evil.example.com`, "/%5C/evil.example.com"}, + {"scheme with backslash", `https:/\evil.example.com`, "/%5Cevil.example.com"}, + // Cure53 CDM-02-009: triple-slash open redirect. + {"protocol relative triple slash", "///evil.example.com", "/evil.example.com"}, + {"protocol relative double slash", "//evil.example.com", "/"}, + {"absolute url with host", "http://evil.example.com/path", "/path"}, + {"absolute url with host and query", "https://evil.example.com/path?a=b", "/path?a=b"}, + // Cure53 CDM-02-009: javascript: scheme bypassing CSP. + {"javascript scheme", "javascript:alert(origin)", "/"}, + {"nested javascript scheme", "javascript:javascript:javascript:alert(origin)", "/"}, + {"data scheme", "data:text/html,", "/"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := httpapi.SafeRedirectPath(tt.in); got != tt.want { + t.Errorf("SafeRedirectPath(%q) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} diff --git a/coderd/httpmw/oauth2.go b/coderd/httpmw/oauth2.go index 5f12543887a..fe91b49a5ea 100644 --- a/coderd/httpmw/oauth2.go +++ b/coderd/httpmw/oauth2.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "net/http" - "net/url" "reflect" "slices" @@ -100,7 +99,7 @@ func ExtractOAuth2(config promoauth.OAuth2Config, client *http.Client, cookieCfg // the host of the AccessURL but ultimately as long as our redirect // url omits a host we're ensuring that we're routing to a path // local to the application. - redirect = uriFromURL(redirect) + redirect = httpapi.SafeRedirectPath(redirect) } if code == "" { @@ -415,12 +414,3 @@ func ExtractOAuth2ProviderAppSecret(db database.Store) func(http.Handler) http.H }) } } - -func uriFromURL(u string) string { - uri, err := url.Parse(u) - if err != nil { - return "/" - } - - return uri.RequestURI() -} diff --git a/coderd/userauth.go b/coderd/userauth.go index 1684da9f762..fd686124d64 100644 --- a/coderd/userauth.go +++ b/coderd/userauth.go @@ -1139,7 +1139,7 @@ func (api *API) userOAuth2Github(rw http.ResponseWriter, r *http.Request) { http.SetCookie(rw, cookie) } - redirect = uriFromURL(redirect) + redirect = httpapi.SafeRedirectPath(redirect) if api.GithubOAuth2Config.DeviceFlowEnabled { // In the device flow, the redirect is handled client-side. httpapi.Write(ctx, rw, http.StatusOK, codersdk.OAuth2DeviceFlowCallbackResponse{ @@ -1560,7 +1560,7 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) { redirect := state.Redirect // Strip the host if it exists on the URL to prevent // any nefarious redirects. - redirect = uriFromURL(redirect) + redirect = httpapi.SafeRedirectPath(redirect) http.Redirect(rw, r, redirect, http.StatusTemporaryRedirect) } From b7d2742adc3ca28c3619f807799dd121d7f49247 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:01:08 +0200 Subject: [PATCH 099/138] fix: remove 403 from key failover and cooldown on 401 (#27419) (#27520) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport of https://github.com/coder/coder/pull/27419 Original PR: #27419 — fix: remove 403 from key failover and cooldown on 401 Merge commit: dba45cede78dda87e10d2ad1fd666abdd244c914 Requested by: @ssncferreira Co-authored-by: Susana Ferreira --- .../chatcompletions/base_internal_test.go | 14 ++-- .../chatcompletions/blocking_internal_test.go | 25 ++++--- .../streaming_internal_test.go | 29 ++++---- aibridge/intercept/messages/base.go | 4 +- .../intercept/messages/base_internal_test.go | 21 ++++-- .../messages/blocking_internal_test.go | 25 ++++--- .../messages/streaming_internal_test.go | 29 ++++---- aibridge/intercept/openai_errors.go | 4 +- aibridge/intercept/openai_errors_test.go | 7 ++ .../intercept/responses/base_internal_test.go | 14 ++-- .../responses/blocking_internal_test.go | 25 ++++--- .../responses/streaming_internal_test.go | 29 ++++---- aibridge/keypool/failover.go | 2 +- aibridge/keypool/keymark.go | 33 +++++---- aibridge/keypool/keymark_test.go | 27 +++---- aibridge/keypool/keypool.go | 72 ++++++++++++++----- aibridge/keypool/keypool_test.go | 45 +++++++++++- aibridge/passthrough_internal_test.go | 49 ++++++------- docs/ai-coder/ai-gateway/providers.md | 18 ++--- 19 files changed, 272 insertions(+), 200 deletions(-) diff --git a/aibridge/intercept/chatcompletions/base_internal_test.go b/aibridge/intercept/chatcompletions/base_internal_test.go index 1af6054cfa1..5e618530709 100644 --- a/aibridge/intercept/chatcompletions/base_internal_test.go +++ b/aibridge/intercept/chatcompletions/base_internal_test.go @@ -111,18 +111,18 @@ func TestMarkKeyOnError(t *testing.T) { expectedState: keypool.KeyStateTemporary, }, { - // Auth failure: mark permanent. - name: "401_marks_permanent", + // Auth failure: temporary cooldown so the key recovers. + name: "401_marks_temporary", err: &openai.Error{StatusCode: http.StatusUnauthorized, Response: &http.Response{StatusCode: http.StatusUnauthorized}}, expectedReturn: true, - expectedState: keypool.KeyStatePermanent, + expectedState: keypool.KeyStateTemporary, }, { - // Auth forbidden: mark permanent. - name: "403_marks_permanent", + // Forbidden is per-request, not key-specific. + name: "403_does_not_mark", err: &openai.Error{StatusCode: http.StatusForbidden, Response: &http.Response{StatusCode: http.StatusForbidden}}, - expectedReturn: true, - expectedState: keypool.KeyStatePermanent, + expectedReturn: false, + expectedState: keypool.KeyStateValid, }, { // Server errors are not key-specific. diff --git a/aibridge/intercept/chatcompletions/blocking_internal_test.go b/aibridge/intercept/chatcompletions/blocking_internal_test.go index 2b9afaadeac..80c5cb18c01 100644 --- a/aibridge/intercept/chatcompletions/blocking_internal_test.go +++ b/aibridge/intercept/chatcompletions/blocking_internal_test.go @@ -112,7 +112,7 @@ func TestBlockingInterception_KeyFailover(t *testing.T) { }, { // Given: 2 keys; key-0 returns 401, key-1 returns 200. - // Then: 2 requests, 200 response, key-0 permanent, key-1 valid. + // Then: 2 requests, 200 response, key-0 temporary, key-1 valid. name: "failover_after_401", keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ @@ -122,27 +122,26 @@ func TestBlockingInterception_KeyFailover(t *testing.T) { expectedRequestCount: 2, expectedStatusCode: http.StatusOK, expectedKeyStates: []keypool.KeyState{ - keypool.KeyStatePermanent, + keypool.KeyStateTemporary, keypool.KeyStateValid, }, expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { - // Given: 2 keys; key-0 returns 403, key-1 returns 200. - // Then: 2 requests, 200 response, key-0 permanent, key-1 valid. - name: "failover_after_403", + // Given: 2 keys; key-0 returns 403. + // Then: 1 request, 403 response, no failover, keys remain valid. + name: "forbidden_no_failover", keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ "k0-long-key": {statusCode: http.StatusForbidden, body: authErrorBody}, - "k1-long-key": {statusCode: http.StatusOK, body: successBody}, }, - expectedRequestCount: 2, - expectedStatusCode: http.StatusOK, + expectedRequestCount: 1, + expectedStatusCode: http.StatusForbidden, expectedKeyStates: []keypool.KeyState{ - keypool.KeyStatePermanent, + keypool.KeyStateValid, keypool.KeyStateValid, }, - expectedCredentialHint: utils.MaskSecret("k1-long-key"), + expectedCredentialHint: utils.MaskSecret("k0-long-key"), }, { // Given: 3 keys; all return 429 with cooldowns 5s, 3s, 10s. @@ -179,7 +178,7 @@ func TestBlockingInterception_KeyFailover(t *testing.T) { }, { // Given: 2 keys; both return 401. - // Then: 2 requests, 502 api_error response, both keys permanent. + // Then: 2 requests, 502 api_error response, both keys temporary. name: "all_keys_unauthorized", keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ @@ -189,8 +188,8 @@ func TestBlockingInterception_KeyFailover(t *testing.T) { expectedRequestCount: 2, expectedStatusCode: http.StatusBadGateway, expectedKeyStates: []keypool.KeyState{ - keypool.KeyStatePermanent, - keypool.KeyStatePermanent, + keypool.KeyStateTemporary, + keypool.KeyStateTemporary, }, expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, diff --git a/aibridge/intercept/chatcompletions/streaming_internal_test.go b/aibridge/intercept/chatcompletions/streaming_internal_test.go index 9561c0948a9..9d9b854e9e7 100644 --- a/aibridge/intercept/chatcompletions/streaming_internal_test.go +++ b/aibridge/intercept/chatcompletions/streaming_internal_test.go @@ -194,7 +194,7 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { { // Given: 2 keys; key-0 returns 401 pre-stream, key-1 // streams successfully. - // Then: 2 requests, 200 response, key-0 permanent, key-1 valid. + // Then: 2 requests, 200 response, key-0 temporary, key-1 valid. name: "failover_after_401", keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ @@ -208,31 +208,26 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { expectedRequestCount: 2, expectedStatusCode: http.StatusOK, expectedKeyStates: []keypool.KeyState{ - keypool.KeyStatePermanent, + keypool.KeyStateTemporary, keypool.KeyStateValid, }, expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { - // Given: 2 keys; key-0 returns 403 pre-stream, key-1 streams. - // Then: 2 requests, 200 response, key-0 permanent, key-1 valid. - name: "failover_after_403", + // Given: 2 keys; key-0 returns 403 pre-stream. + // Then: 1 request, 403 response, no failover, keys remain valid. + name: "forbidden_no_failover", keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ "k0-long-key": {statusCode: http.StatusForbidden, body: authErrorBody}, - "k1-long-key": { - statusCode: http.StatusOK, - headers: map[string]string{"Content-Type": "text/event-stream"}, - body: streamingSuccessBody, - }, }, - expectedRequestCount: 2, - expectedStatusCode: http.StatusOK, + expectedRequestCount: 1, + expectedStatusCode: http.StatusForbidden, expectedKeyStates: []keypool.KeyState{ - keypool.KeyStatePermanent, + keypool.KeyStateValid, keypool.KeyStateValid, }, - expectedCredentialHint: utils.MaskSecret("k1-long-key"), + expectedCredentialHint: utils.MaskSecret("k0-long-key"), }, { // Given: 3 keys; all return 429 pre-stream with @@ -270,7 +265,7 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { }, { // Given: 2 keys; both return 401 pre-stream. - // Then: 2 requests, 502 api_error response, both keys permanent. + // Then: 2 requests, 502 api_error response, both keys temporary. name: "all_keys_unauthorized", keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ @@ -280,8 +275,8 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { expectedRequestCount: 2, expectedStatusCode: http.StatusBadGateway, expectedKeyStates: []keypool.KeyState{ - keypool.KeyStatePermanent, - keypool.KeyStatePermanent, + keypool.KeyStateTemporary, + keypool.KeyStateTemporary, }, expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, diff --git a/aibridge/intercept/messages/base.go b/aibridge/intercept/messages/base.go index 51818b66b63..83084e43e38 100644 --- a/aibridge/intercept/messages/base.go +++ b/aibridge/intercept/messages/base.go @@ -594,12 +594,12 @@ func (i *interceptionBase) markKeyOnError(ctx context.Context, key *keypool.Key, // a developer-facing ResponseError shaped for the Anthropic API. func ResponseErrorFromKeyPool(keyPoolErr *keypool.Error) *ResponseError { switch keyPoolErr.Kind { - case keypool.ErrorKindPermanent: + case keypool.ErrorKindPermanent, keypool.ErrorKindUnauthorized: return newResponseError( keyPoolErr.Error(), string(constant.ValueOf[constant.APIError]()), http.StatusBadGateway, - keyPoolErr.RetryAfter, + 0, ) case keypool.ErrorKindRateLimited: return newResponseError( diff --git a/aibridge/intercept/messages/base_internal_test.go b/aibridge/intercept/messages/base_internal_test.go index 8cfbfc5e9a7..6968eb4ec57 100644 --- a/aibridge/intercept/messages/base_internal_test.go +++ b/aibridge/intercept/messages/base_internal_test.go @@ -1096,6 +1096,13 @@ func TestResponseErrorFromKeyPool(t *testing.T) { keyPoolErr: &keypool.Error{Kind: keypool.ErrorKindPermanent}, expectedStatus: http.StatusBadGateway, }, + { + // Auth-failure exhaustion: 502, no Retry-After. + name: "unauthorized_returns_502_without_retry_after", + keyPoolErr: &keypool.Error{Kind: keypool.ErrorKindUnauthorized, RetryAfter: 60 * time.Second}, + expectedStatus: http.StatusBadGateway, + expectedRetryAfter: 0, + }, } for _, tc := range tests { @@ -1133,18 +1140,18 @@ func TestMarkKeyOnError(t *testing.T) { expectedState: keypool.KeyStateTemporary, }, { - // Auth failure: mark permanent. - name: "401_marks_permanent", + // Auth failure: temporary cooldown so the key recovers. + name: "401_marks_temporary", err: &anthropic.Error{StatusCode: http.StatusUnauthorized, Response: &http.Response{StatusCode: http.StatusUnauthorized}}, expectedReturn: true, - expectedState: keypool.KeyStatePermanent, + expectedState: keypool.KeyStateTemporary, }, { - // Auth forbidden: mark permanent. - name: "403_marks_permanent", + // Forbidden is per-request, not key-specific. + name: "403_does_not_mark", err: &anthropic.Error{StatusCode: http.StatusForbidden, Response: &http.Response{StatusCode: http.StatusForbidden}}, - expectedReturn: true, - expectedState: keypool.KeyStatePermanent, + expectedReturn: false, + expectedState: keypool.KeyStateValid, }, { // Server errors are not key-specific. diff --git a/aibridge/intercept/messages/blocking_internal_test.go b/aibridge/intercept/messages/blocking_internal_test.go index 9b3f0d447b4..74ad6021057 100644 --- a/aibridge/intercept/messages/blocking_internal_test.go +++ b/aibridge/intercept/messages/blocking_internal_test.go @@ -95,7 +95,7 @@ func TestBlockingInterception_KeyFailover(t *testing.T) { }, { // Given: 2 keys; key-0 returns 401, key-1 returns 200. - // Then: 2 requests, 200 response, key-0 permanent, key-1 valid. + // Then: 2 requests, 200 response, key-0 temporary, key-1 valid. name: "failover_after_401", keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ @@ -105,27 +105,26 @@ func TestBlockingInterception_KeyFailover(t *testing.T) { expectedRequestCount: 2, expectedStatusCode: http.StatusOK, expectedKeyStates: []keypool.KeyState{ - keypool.KeyStatePermanent, + keypool.KeyStateTemporary, keypool.KeyStateValid, }, expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { - // Given: 2 keys; key-0 returns 403, key-1 returns 200. - // Then: 2 requests, 200 response, key-0 permanent, key-1 valid. - name: "failover_after_403", + // Given: 2 keys; key-0 returns 403. + // Then: 1 request, 403 response, no failover, keys remain valid. + name: "forbidden_no_failover", keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ "k0-long-key": {statusCode: http.StatusForbidden, body: authErrorBody}, - "k1-long-key": {statusCode: http.StatusOK, body: successBody}, }, - expectedRequestCount: 2, - expectedStatusCode: http.StatusOK, + expectedRequestCount: 1, + expectedStatusCode: http.StatusForbidden, expectedKeyStates: []keypool.KeyState{ - keypool.KeyStatePermanent, + keypool.KeyStateValid, keypool.KeyStateValid, }, - expectedCredentialHint: utils.MaskSecret("k1-long-key"), + expectedCredentialHint: utils.MaskSecret("k0-long-key"), }, { // Given: 3 keys; all return 429 with cooldowns 5s, 3s, 10s. @@ -162,7 +161,7 @@ func TestBlockingInterception_KeyFailover(t *testing.T) { }, { // Given: 2 keys; both return 401. - // Then: 2 requests, 502 api_error response, both keys permanent. + // Then: 2 requests, 502 api_error response, both keys temporary. name: "all_keys_unauthorized", keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ @@ -172,8 +171,8 @@ func TestBlockingInterception_KeyFailover(t *testing.T) { expectedRequestCount: 2, expectedStatusCode: http.StatusBadGateway, expectedKeyStates: []keypool.KeyState{ - keypool.KeyStatePermanent, - keypool.KeyStatePermanent, + keypool.KeyStateTemporary, + keypool.KeyStateTemporary, }, expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, diff --git a/aibridge/intercept/messages/streaming_internal_test.go b/aibridge/intercept/messages/streaming_internal_test.go index 5fc7da00df6..4531492781a 100644 --- a/aibridge/intercept/messages/streaming_internal_test.go +++ b/aibridge/intercept/messages/streaming_internal_test.go @@ -111,7 +111,7 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { { // Given: 2 keys; key-0 returns 401 pre-stream, key-1 // streams successfully. - // Then: 2 requests, 200 response, key-0 permanent, key-1 valid. + // Then: 2 requests, 200 response, key-0 temporary, key-1 valid. name: "failover_after_401", keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ @@ -125,31 +125,26 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { expectedRequestCount: 2, expectedStatusCode: http.StatusOK, expectedKeyStates: []keypool.KeyState{ - keypool.KeyStatePermanent, + keypool.KeyStateTemporary, keypool.KeyStateValid, }, expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { - // Given: 2 keys; key-0 returns 403 pre-stream, key-1 streams. - // Then: 2 requests, 200 response, key-0 permanent, key-1 valid. - name: "failover_after_403", + // Given: 2 keys; key-0 returns 403 pre-stream. + // Then: 1 request, 403 response, no failover, keys remain valid. + name: "forbidden_no_failover", keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ "k0-long-key": {statusCode: http.StatusForbidden, body: authErrorBody}, - "k1-long-key": { - statusCode: http.StatusOK, - headers: map[string]string{"Content-Type": "text/event-stream"}, - body: streamingSuccessBody, - }, }, - expectedRequestCount: 2, - expectedStatusCode: http.StatusOK, + expectedRequestCount: 1, + expectedStatusCode: http.StatusForbidden, expectedKeyStates: []keypool.KeyState{ - keypool.KeyStatePermanent, + keypool.KeyStateValid, keypool.KeyStateValid, }, - expectedCredentialHint: utils.MaskSecret("k1-long-key"), + expectedCredentialHint: utils.MaskSecret("k0-long-key"), }, { // Given: 3 keys; all return 429 pre-stream with @@ -187,7 +182,7 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { }, { // Given: 2 keys; both return 401 pre-stream. - // Then: 2 requests, 502 api_error response, both keys permanent. + // Then: 2 requests, 502 api_error response, both keys temporary. name: "all_keys_unauthorized", keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ @@ -197,8 +192,8 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { expectedRequestCount: 2, expectedStatusCode: http.StatusBadGateway, expectedKeyStates: []keypool.KeyState{ - keypool.KeyStatePermanent, - keypool.KeyStatePermanent, + keypool.KeyStateTemporary, + keypool.KeyStateTemporary, }, expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, diff --git a/aibridge/intercept/openai_errors.go b/aibridge/intercept/openai_errors.go index faf2e19e3e0..ef0c88384c1 100644 --- a/aibridge/intercept/openai_errors.go +++ b/aibridge/intercept/openai_errors.go @@ -74,13 +74,13 @@ func (e *ResponseError) ToResponse() *http.Response { // a developer-facing ResponseError shaped for the OpenAI API. func ResponseErrorFromKeyPool(keyPoolErr *keypool.Error) *ResponseError { switch keyPoolErr.Kind { - case keypool.ErrorKindPermanent: + case keypool.ErrorKindPermanent, keypool.ErrorKindUnauthorized: return NewResponseError( keyPoolErr.Error(), OpenAIErrTypeAPI, OpenAIErrCodeServer, http.StatusBadGateway, - keyPoolErr.RetryAfter, + 0, ) case keypool.ErrorKindRateLimited: return NewResponseError( diff --git a/aibridge/intercept/openai_errors_test.go b/aibridge/intercept/openai_errors_test.go index 9b49c1e43ab..256100f5424 100644 --- a/aibridge/intercept/openai_errors_test.go +++ b/aibridge/intercept/openai_errors_test.go @@ -41,6 +41,13 @@ func TestResponseErrorFromKeyPool(t *testing.T) { keyPoolErr: &keypool.Error{Kind: keypool.ErrorKindPermanent}, expectedStatus: http.StatusBadGateway, }, + { + // Auth-failure exhaustion: 502, no Retry-After. + name: "unauthorized_returns_502_without_retry_after", + keyPoolErr: &keypool.Error{Kind: keypool.ErrorKindUnauthorized, RetryAfter: 60 * time.Second}, + expectedStatus: http.StatusBadGateway, + expectedRetryAfter: 0, + }, } for _, tc := range tests { diff --git a/aibridge/intercept/responses/base_internal_test.go b/aibridge/intercept/responses/base_internal_test.go index f2b92ea029f..a78549d4e3a 100644 --- a/aibridge/intercept/responses/base_internal_test.go +++ b/aibridge/intercept/responses/base_internal_test.go @@ -415,18 +415,18 @@ func TestMarkKeyOnError(t *testing.T) { expectedState: keypool.KeyStateTemporary, }, { - // Auth failure: mark permanent. - name: "401_marks_permanent", + // Auth failure: temporary cooldown so the key recovers. + name: "401_marks_temporary", err: &openai.Error{StatusCode: http.StatusUnauthorized, Response: &http.Response{StatusCode: http.StatusUnauthorized}}, expectedReturn: true, - expectedState: keypool.KeyStatePermanent, + expectedState: keypool.KeyStateTemporary, }, { - // Auth forbidden: mark permanent. - name: "403_marks_permanent", + // Forbidden is per-request, not key-specific. + name: "403_does_not_mark", err: &openai.Error{StatusCode: http.StatusForbidden, Response: &http.Response{StatusCode: http.StatusForbidden}}, - expectedReturn: true, - expectedState: keypool.KeyStatePermanent, + expectedReturn: false, + expectedState: keypool.KeyStateValid, }, { // Server errors are not key-specific. diff --git a/aibridge/intercept/responses/blocking_internal_test.go b/aibridge/intercept/responses/blocking_internal_test.go index 94acf0deefb..a6cb07b829a 100644 --- a/aibridge/intercept/responses/blocking_internal_test.go +++ b/aibridge/intercept/responses/blocking_internal_test.go @@ -98,7 +98,7 @@ func TestBlockingResponsesInterceptor_KeyFailover(t *testing.T) { }, { // Given: 2 keys; key-0 returns 401, key-1 returns 200. - // Then: 2 requests, 200 response, key-0 permanent, key-1 valid. + // Then: 2 requests, 200 response, key-0 temporary, key-1 valid. name: "failover_after_401", keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ @@ -108,27 +108,26 @@ func TestBlockingResponsesInterceptor_KeyFailover(t *testing.T) { expectedRequestCount: 2, expectedStatusCode: http.StatusOK, expectedKeyStates: []keypool.KeyState{ - keypool.KeyStatePermanent, + keypool.KeyStateTemporary, keypool.KeyStateValid, }, expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { - // Given: 2 keys; key-0 returns 403, key-1 returns 200. - // Then: 2 requests, 200 response, key-0 permanent, key-1 valid. - name: "failover_after_403", + // Given: 2 keys; key-0 returns 403. + // Then: 1 request, 403 response, no failover, keys remain valid. + name: "forbidden_no_failover", keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ "k0-long-key": {statusCode: http.StatusForbidden, body: authErrorBody}, - "k1-long-key": {statusCode: http.StatusOK, body: successBody}, }, - expectedRequestCount: 2, - expectedStatusCode: http.StatusOK, + expectedRequestCount: 1, + expectedStatusCode: http.StatusForbidden, expectedKeyStates: []keypool.KeyState{ - keypool.KeyStatePermanent, + keypool.KeyStateValid, keypool.KeyStateValid, }, - expectedCredentialHint: utils.MaskSecret("k1-long-key"), + expectedCredentialHint: utils.MaskSecret("k0-long-key"), }, { // Given: 3 keys; all return 429 with cooldowns 5s, 3s, 10s. @@ -165,7 +164,7 @@ func TestBlockingResponsesInterceptor_KeyFailover(t *testing.T) { }, { // Given: 2 keys; both return 401. - // Then: 2 requests, 502 api_error response, both keys permanent. + // Then: 2 requests, 502 api_error response, both keys temporary. name: "all_keys_unauthorized", keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ @@ -175,8 +174,8 @@ func TestBlockingResponsesInterceptor_KeyFailover(t *testing.T) { expectedRequestCount: 2, expectedStatusCode: http.StatusBadGateway, expectedKeyStates: []keypool.KeyState{ - keypool.KeyStatePermanent, - keypool.KeyStatePermanent, + keypool.KeyStateTemporary, + keypool.KeyStateTemporary, }, expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, diff --git a/aibridge/intercept/responses/streaming_internal_test.go b/aibridge/intercept/responses/streaming_internal_test.go index 4f20d76c17a..07938d6132d 100644 --- a/aibridge/intercept/responses/streaming_internal_test.go +++ b/aibridge/intercept/responses/streaming_internal_test.go @@ -101,7 +101,7 @@ func TestStreamingResponsesInterceptor_KeyFailover(t *testing.T) { { // Given: 2 keys; key-0 returns 401 pre-stream, key-1 // streams successfully. - // Then: 2 requests, 200 response, key-0 permanent, key-1 valid. + // Then: 2 requests, 200 response, key-0 temporary, key-1 valid. name: "failover_after_401", keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ @@ -115,31 +115,26 @@ func TestStreamingResponsesInterceptor_KeyFailover(t *testing.T) { expectedRequestCount: 2, expectedStatusCode: http.StatusOK, expectedKeyStates: []keypool.KeyState{ - keypool.KeyStatePermanent, + keypool.KeyStateTemporary, keypool.KeyStateValid, }, expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { - // Given: 2 keys; key-0 returns 403 pre-stream, key-1 streams. - // Then: 2 requests, 200 response, key-0 permanent, key-1 valid. - name: "failover_after_403", + // Given: 2 keys; key-0 returns 403 pre-stream. + // Then: 1 request, 403 response, no failover, keys remain valid. + name: "forbidden_no_failover", keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ "k0-long-key": {statusCode: http.StatusForbidden, body: authErrorBody}, - "k1-long-key": { - statusCode: http.StatusOK, - headers: map[string]string{"Content-Type": "text/event-stream"}, - body: streamingSuccessBody, - }, }, - expectedRequestCount: 2, - expectedStatusCode: http.StatusOK, + expectedRequestCount: 1, + expectedStatusCode: http.StatusForbidden, expectedKeyStates: []keypool.KeyState{ - keypool.KeyStatePermanent, + keypool.KeyStateValid, keypool.KeyStateValid, }, - expectedCredentialHint: utils.MaskSecret("k1-long-key"), + expectedCredentialHint: utils.MaskSecret("k0-long-key"), }, { // Given: 3 keys; all return 429 pre-stream with @@ -177,7 +172,7 @@ func TestStreamingResponsesInterceptor_KeyFailover(t *testing.T) { }, { // Given: 2 keys; both return 401 pre-stream. - // Then: 2 requests, 502 api_error response, both keys permanent. + // Then: 2 requests, 502 api_error response, both keys temporary. name: "all_keys_unauthorized", keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ @@ -187,8 +182,8 @@ func TestStreamingResponsesInterceptor_KeyFailover(t *testing.T) { expectedRequestCount: 2, expectedStatusCode: http.StatusBadGateway, expectedKeyStates: []keypool.KeyState{ - keypool.KeyStatePermanent, - keypool.KeyStatePermanent, + keypool.KeyStateTemporary, + keypool.KeyStateTemporary, }, expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, diff --git a/aibridge/keypool/failover.go b/aibridge/keypool/failover.go index 38dcd3b972e..ae6bcb97611 100644 --- a/aibridge/keypool/failover.go +++ b/aibridge/keypool/failover.go @@ -94,7 +94,7 @@ func (t *keyFailoverTransport) RoundTrip(req *http.Request) (*http.Response, err // Transport-level error, not a key issue. return resp, rtErr } - // MarkKeyOnStatus returns true on key-specific failures (e.g. 401/403/429). + // MarkKeyOnStatus returns true on key-specific failures (e.g. 401/429). if MarkKeyOnStatus(req.Context(), key, resp, t.config.Logger, t.config.ProviderName) { // Drain and retry with the next key. _, _ = io.Copy(io.Discard, resp.Body) diff --git a/aibridge/keypool/keymark.go b/aibridge/keypool/keymark.go index 9dfedb3e444..c7cd735f905 100644 --- a/aibridge/keypool/keymark.go +++ b/aibridge/keypool/keymark.go @@ -7,10 +7,10 @@ import ( "cdr.dev/slog/v3" ) -// MarkKeyOnStatus marks key based on a key-specific HTTP -// status code from resp (429 for temporary, 401 or 403 for -// permanent). Returns true if the status was a key-specific -// failover trigger so callers can retry with the next key. +// MarkKeyOnStatus marks key based on a key-specific HTTP status +// code from resp (429 or 401 for temporary). Returns true if the +// status was a key-specific failover trigger so callers can retry +// with the next key. func MarkKeyOnStatus( ctx context.Context, key *Key, @@ -23,12 +23,19 @@ func MarkKeyOnStatus( } statusCode := resp.StatusCode switch statusCode { - case http.StatusTooManyRequests: - cooldown := ParseRetryAfter(resp) - if cooldown <= 0 { - cooldown = defaultCooldown + // A 429 rate-limits the key for the provider-supplied cooldown. A 401 + // means the key was rejected, so it cools down for the default period + // and recovers on its own. + case http.StatusTooManyRequests, http.StatusUnauthorized: + cooldown := defaultCooldown + reason := cooldownUnauthorized + if statusCode == http.StatusTooManyRequests { + reason = cooldownRateLimited + if retryAfter := ParseRetryAfter(resp); retryAfter > 0 { + cooldown = retryAfter + } } - if key.MarkTemporary(cooldown) { + if key.applyCooldown(cooldown, reason) { logger.Info(ctx, "key marked temporary", slog.F("provider", providerName), slog.F("api_key_hint", key.Hint()), @@ -36,14 +43,6 @@ func MarkKeyOnStatus( slog.F("cooldown", cooldown)) } return true - case http.StatusUnauthorized, http.StatusForbidden: - if key.MarkPermanent() { - logger.Warn(ctx, "key marked permanent", - slog.F("provider", providerName), - slog.F("api_key_hint", key.Hint()), - slog.F("status", statusCode)) - } - return true default: logger.Debug(ctx, "status is not a key failover trigger", slog.F("provider", providerName), diff --git a/aibridge/keypool/keymark_test.go b/aibridge/keypool/keymark_test.go index 228e576aa0d..15ae2d7f37f 100644 --- a/aibridge/keypool/keymark_test.go +++ b/aibridge/keypool/keymark_test.go @@ -52,16 +52,11 @@ func TestMarkKeyOnStatus(t *testing.T) { expectedCooldown: 60 * time.Second, }, { - name: "401_marks_permanent", - statusCode: http.StatusUnauthorized, - expectedReturn: true, - expectedState: keypool.KeyStatePermanent, - }, - { - name: "403_marks_permanent", - statusCode: http.StatusForbidden, - expectedReturn: true, - expectedState: keypool.KeyStatePermanent, + name: "401_marks_temporary", + statusCode: http.StatusUnauthorized, + expectedReturn: true, + expectedState: keypool.KeyStateTemporary, + expectedCooldown: 60 * time.Second, }, { name: "200_does_not_mark", @@ -69,6 +64,14 @@ func TestMarkKeyOnStatus(t *testing.T) { expectedReturn: false, expectedState: keypool.KeyStateValid, }, + { + // 403 is a per-request authorization failure, so the key + // is not marked. + name: "403_does_not_mark", + statusCode: http.StatusForbidden, + expectedReturn: false, + expectedState: keypool.KeyStateValid, + }, { name: "500_does_not_mark", statusCode: http.StatusInternalServerError, @@ -106,9 +109,7 @@ func TestMarkKeyOnStatus(t *testing.T) { context.Background(), key, resp, - // 401 and 403 cases legitimately log at error - // level when marking a key permanent. - slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}), + slogtest.Make(t, nil), "test", ) diff --git a/aibridge/keypool/keypool.go b/aibridge/keypool/keypool.go index e28ae78325f..9bb14509d58 100644 --- a/aibridge/keypool/keypool.go +++ b/aibridge/keypool/keypool.go @@ -31,6 +31,9 @@ const ( // ErrorKindPermanent means every key is permanently marked // and no key can satisfy the request. ErrorKindPermanent + // ErrorKindUnauthorized means every unavailable key is in a + // cooldown triggered by an authentication failure. + ErrorKindUnauthorized ) // Error is returned when no key is available for the @@ -44,7 +47,9 @@ type Error struct { func (e *Error) Error() string { switch e.Kind { case ErrorKindPermanent: - return "all configured keys failed authentication" + return "all configured keys are permanently unavailable" + case ErrorKindUnauthorized: + return "all configured keys failed authentication. Contact your Administrator" case ErrorKindRateLimited: return fmt.Sprintf("all configured keys are rate-limited (retry after %s)", e.RetryAfter) default: @@ -70,11 +75,27 @@ const ( // with a zero or negative cooldown duration. const defaultCooldown = 60 * time.Second +// cooldownReason records why a key entered its current cooldown. It is +// meaningful only while the cooldown is active. +type cooldownReason string + +const ( + // cooldownRateLimited means the current cooldown was triggered by a + // rate-limit response (HTTP 429). + cooldownRateLimited cooldownReason = "rate_limited" + // cooldownUnauthorized means the current cooldown was triggered by an + // authentication failure (HTTP 401). + cooldownUnauthorized cooldownReason = "unauthorized" +) + // Key holds a key value and its runtime state. type Key struct { value string permanent bool cooldownUntil time.Time + // reason records why the current cooldown was applied. It is only + // meaningful while cooldownUntil is active. + reason cooldownReason mu sync.RWMutex clock quartz.Clock @@ -139,26 +160,31 @@ func (k *Key) State() KeyState { return KeyStateValid } -// stateAndCooldown returns the key's state and remaining -// cooldown as a single atomic snapshot. -func (k *Key) stateAndCooldown() (KeyState, time.Duration) { +// stateAndCooldown returns the key's state, remaining cooldown, and the +// reason for the current cooldown as a single atomic snapshot. +func (k *Key) stateAndCooldown() (KeyState, time.Duration, cooldownReason) { k.mu.RLock() defer k.mu.RUnlock() if k.permanent { - return KeyStatePermanent, 0 + return KeyStatePermanent, 0, k.reason } now := k.clock.Now() if now.Before(k.cooldownUntil) { - return KeyStateTemporary, k.cooldownUntil.Sub(now) + return KeyStateTemporary, k.cooldownUntil.Sub(now), k.reason } - return KeyStateValid, 0 + return KeyStateValid, 0, k.reason } -// MarkTemporary marks the key as temporarily unavailable with -// the specified cooldown duration. Returns true if this call -// transitions the key to temporary. +// MarkTemporary marks the key unavailable for the given cooldown. Returns +// true on the valid -> temporary transition. func (k *Key) MarkTemporary(cooldown time.Duration) bool { + return k.applyCooldown(cooldown, cooldownRateLimited) +} + +// applyCooldown marks the key unavailable for the given cooldown, recording +// reason as the cause. Returns true on the valid -> temporary transition. +func (k *Key) applyCooldown(cooldown time.Duration, reason cooldownReason) bool { k.mu.Lock() defer k.mu.Unlock() @@ -182,6 +208,7 @@ func (k *Key) MarkTemporary(cooldown time.Duration) bool { } k.cooldownUntil = newDeadline + k.reason = reason return !inCooldown } @@ -200,33 +227,42 @@ func (k *Key) MarkPermanent() bool { return true } -// keyPoolError returns an Error summarizing why no -// key is currently available. When at least one key is -// temporary, the smallest remaining cooldown is used as the -// retry-after. +// keyPoolError returns an Error summarizing why no key is currently +// available. When at least one key is temporary, the smallest remaining +// cooldown is used as the retry-after. A rate limit anywhere in the pool +// takes precedence, so the exhaustion is classified as unauthorized only +// when every cooldown was triggered by an auth failure. func (p *Pool) keyPoolError() *Error { var retryAfter time.Duration var hasCooldown bool + var isRateLimited bool for i := range p.keys { - state, cooldown := p.keys[i].stateAndCooldown() + state, cooldown, reason := p.keys[i].stateAndCooldown() switch state { // Recoverable now: a key's cooldown expired between the walker's // check and this scan. Return Retry-After: 0 to indicate that // an immediate retry will succeed. case KeyStateValid: return &Error{Kind: ErrorKindRateLimited} - // Recoverable later: track soonest remaining cooldown. + // Recoverable later: track soonest remaining cooldown and reason. case KeyStateTemporary: if !hasCooldown || cooldown < retryAfter { retryAfter = cooldown - hasCooldown = true + } + hasCooldown = true + if reason == cooldownRateLimited { + isRateLimited = true } // Permanent: keep walking to confirm error type. default: } } if hasCooldown { - return &Error{Kind: ErrorKindRateLimited, RetryAfter: retryAfter} + kind := ErrorKindUnauthorized + if isRateLimited { + kind = ErrorKindRateLimited + } + return &Error{Kind: kind, RetryAfter: retryAfter} } return &Error{Kind: ErrorKindPermanent} } diff --git a/aibridge/keypool/keypool_test.go b/aibridge/keypool/keypool_test.go index 2029dafd688..c8c3aa8d775 100644 --- a/aibridge/keypool/keypool_test.go +++ b/aibridge/keypool/keypool_test.go @@ -1,6 +1,8 @@ package keypool_test import ( + "context" + "net/http" "sync" "testing" "time" @@ -8,6 +10,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "cdr.dev/slog/v3/sloggers/slogtest" "github.com/coder/coder/v2/aibridge/keypool" "github.com/coder/quartz" ) @@ -279,6 +282,17 @@ func TestMarkPermanent(t *testing.T) { } } +// markNextByStatus walks to the next available key and cools it down via +// the given HTTP status (401 or 429), so the resulting cooldown carries the +// same reason the failover path records at runtime. +func markNextByStatus(t *testing.T, pool *keypool.Pool, walker *keypool.Walker, status int) { + t.Helper() + key, keyPoolErr := walker.Next() + require.Nil(t, keyPoolErr) + resp := &http.Response{StatusCode: status, Header: make(http.Header)} + keypool.MarkKeyOnStatus(context.Background(), key, resp, slogtest.Make(t, nil), "test") +} + func TestWalkerNext(t *testing.T) { t.Parallel() @@ -496,6 +510,35 @@ func TestWalkerNext(t *testing.T) { expectedValid: []string{}, expectedErr: &keypool.Error{Kind: keypool.ErrorKindRateLimited, RetryAfter: 60 * time.Second}, }, + { + // Given: key-0: temporary (401), key-1: temporary (401). + // Then: key-0: temporary, key-1: temporary. + // Every cooldown is an auth failure, so exhaustion is unauthorized. + name: "all_unauthorized_exhausted", + keys: []string{"key-0", "key-1"}, + setup: func(t *testing.T, pool *keypool.Pool) { + walker := pool.Walker() + markNextByStatus(t, pool, walker, http.StatusUnauthorized) + markNextByStatus(t, pool, walker, http.StatusUnauthorized) + }, + expectedValid: []string{}, + expectedErr: &keypool.Error{Kind: keypool.ErrorKindUnauthorized, RetryAfter: 60 * time.Second}, + }, + { + // Given: key-0: temporary (401), key-1: temporary (429). + // Then: key-0: temporary, key-1: temporary. + // A rate limit anywhere in the pool wins, so exhaustion is + // rate-limited despite the auth failure. + name: "mixed_unauthorized_and_rate_limited_exhausted", + keys: []string{"key-0", "key-1"}, + setup: func(t *testing.T, pool *keypool.Pool) { + walker := pool.Walker() + markNextByStatus(t, pool, walker, http.StatusUnauthorized) + markNextByStatus(t, pool, walker, http.StatusTooManyRequests) + }, + expectedValid: []string{}, + expectedErr: &keypool.Error{Kind: keypool.ErrorKindRateLimited, RetryAfter: 60 * time.Second}, + }, } for _, tc := range tests { @@ -631,7 +674,7 @@ func TestWalkerIndependence(t *testing.T) { require.Nil(t, keyPoolErr) assert.Equal(t, "key-1", key.Value()) - // Simulate 401: mark key-1 permanent. + // Mark key-1 permanent. key.MarkPermanent() // Third attempt: walker advances to key-2. diff --git a/aibridge/passthrough_internal_test.go b/aibridge/passthrough_internal_test.go index 0cfeb00f638..25fa8e5ae9f 100644 --- a/aibridge/passthrough_internal_test.go +++ b/aibridge/passthrough_internal_test.go @@ -419,7 +419,7 @@ func TestPassthrough_KeyFailover(t *testing.T) { }, { // Given: 2 keys; key-0 returns 401, key-1 returns 200. - // Then: 2 requests, 200 response, key-0 permanent, key-1 valid. + // Then: 2 requests, 200 response, key-0 temporary, key-1 valid. name: "failover_after_401", keys: []string{"k0", "k1"}, responses: map[string]upstreamResponse{ @@ -429,23 +429,7 @@ func TestPassthrough_KeyFailover(t *testing.T) { expectedRequestCount: 2, expectedStatusCode: http.StatusOK, expectedKeyStates: []keypool.KeyState{ - keypool.KeyStatePermanent, - keypool.KeyStateValid, - }, - }, - { - // Given: 2 keys; key-0 returns 403, key-1 returns 200. - // Then: 2 requests, 200 response, key-0 permanent, key-1 valid. - name: "failover_after_403", - keys: []string{"k0", "k1"}, - responses: map[string]upstreamResponse{ - "k0": {statusCode: http.StatusForbidden, body: authErrorBody}, - "k1": {statusCode: http.StatusOK, body: successBody}, - }, - expectedRequestCount: 2, - expectedStatusCode: http.StatusOK, - expectedKeyStates: []keypool.KeyState{ - keypool.KeyStatePermanent, + keypool.KeyStateTemporary, keypool.KeyStateValid, }, }, @@ -453,7 +437,7 @@ func TestPassthrough_KeyFailover(t *testing.T) { // Given: 3 keys; all return 429 with cooldowns 5s, 3s, 10s. // Then: 3 requests, 429 response with smallest Retry-After, // all keys temporary. - name: "all_keys_rate_limited", + name: "all_keys_temporary_blocked", keys: []string{"k0", "k1", "k2"}, responses: map[string]upstreamResponse{ "k0": { @@ -483,7 +467,8 @@ func TestPassthrough_KeyFailover(t *testing.T) { }, { // Given: 2 keys; both return 401. - // Then: 2 requests, 502 response, both keys permanent. + // Then: 2 requests, 502 auth-failure response with no Retry-After, + // both keys temporary and recovering after the cooldown. name: "all_keys_unauthorized", keys: []string{"k0", "k1"}, responses: map[string]upstreamResponse{ @@ -493,8 +478,23 @@ func TestPassthrough_KeyFailover(t *testing.T) { expectedRequestCount: 2, expectedStatusCode: http.StatusBadGateway, expectedKeyStates: []keypool.KeyState{ - keypool.KeyStatePermanent, - keypool.KeyStatePermanent, + keypool.KeyStateTemporary, + keypool.KeyStateTemporary, + }, + }, + { + // Given: 2 keys; key-0 returns 403. + // Then: 1 request, 403 surfaced as-is, both keys valid. + name: "forbidden_no_failover", + keys: []string{"k0", "k1"}, + responses: map[string]upstreamResponse{ + "k0": {statusCode: http.StatusForbidden, body: authErrorBody}, + }, + expectedRequestCount: 1, + expectedStatusCode: http.StatusForbidden, + expectedKeyStates: []keypool.KeyState{ + keypool.KeyStateValid, + keypool.KeyStateValid, }, }, { @@ -569,10 +569,7 @@ func TestPassthrough_KeyFailover(t *testing.T) { } p := prov.newProvider(upstream.URL, pool) - // IgnoreErrors: MarkKey logs at ERROR level when a - // key is marked permanent (401/403); slogtest would - // otherwise fail those scenarios. - logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) + logger := slogtest.Make(t, nil) handler := newPassthroughRouter(p, logger, nil, testTracer) req := httptest.NewRequest(http.MethodGet, "/v1/models", nil) diff --git a/docs/ai-coder/ai-gateway/providers.md b/docs/ai-coder/ai-gateway/providers.md index 084a3227db3..ca1ff1db5ed 100644 --- a/docs/ai-coder/ai-gateway/providers.md +++ b/docs/ai-coder/ai-gateway/providers.md @@ -183,18 +183,18 @@ a maximum of **5 keys**. ### Failover behavior Every request starts with the first key in the list. If a key is rate-limited -or returns an authentication error, AI Gateway automatically retries the request -with the next available key. - -> [!WARNING] -> A key that fails with an authentication error (`401 Unauthorized` or -> `403 Forbidden`) is permanently disabled and will not be used again until the -> server is restarted or the provider configuration is reloaded. +(`429 Too Many Requests`) or fails authentication (`401 Unauthorized`), AI +Gateway puts that key on a temporary cooldown and retries the request with the +next available key. Keys recover automatically when the cooldown elapses, so +failover stays transparent to end users. Any other response, including a +`403 Forbidden`, is returned to the caller unchanged. If all keys in the pool are exhausted, AI Gateway returns: -- `429 Too Many Requests` when at least one key is rate-limited, with a `Retry-After` header set to the shortest cooldown across all keys. -- `502 Bad Gateway` when every key has failed permanently. +- `429 Too Many Requests` when at least one key is rate-limited, with a `Retry-After` +header set to the shortest cooldown across all keys. +- `502 Bad Gateway` when every key is in an authentication-failure cooldown. +The keys still recover automatically once their cooldowns elapse, so no `Retry-After` is sent. ## Bring Your Own Key From d547bea24a75391eaf14e45c0faa56089c12ba81 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:07:02 -0700 Subject: [PATCH 100/138] fix(coderd): reject workspace proxy hostname prefixes (#27544) (#27616) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport of https://github.com/coder/coder/pull/27544 Original PR: #27544 — fix(coderd): reject workspace proxy hostname prefixes Merge commit: 8cc7f2bb0e0b33199393cc3773c7d691bd2fbf6b Requested by: @geokat Co-authored-by: George K Co-authored-by: Bobby Ho --- coderd/database/querier_test.go | 52 +++++++++++++++++++++++++++++ coderd/database/queries.sql.go | 2 +- coderd/database/queries/proxies.sql | 2 +- coderd/workspaceapps_test.go | 9 +++++ 4 files changed, 63 insertions(+), 2 deletions(-) diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 5e8571e93b2..9a453bc2f2f 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -1841,6 +1841,16 @@ func TestProxyByHostname(t *testing.T) { accessURL: "https://two.coder.com", wildcardHostname: "*--suffix.two.coder.com", }, + { + name: "three", + accessURL: "https://three.coder.com:8443", + wildcardHostname: "*.wildcard.three.coder.com", + }, + { + name: "four", + accessURL: "https://four.coder.com/", + wildcardHostname: "*.wildcard.four.coder.com", + }, } for _, p := range proxies { dbgen.WorkspaceProxy(t, db, database.WorkspaceProxy{ @@ -1871,6 +1881,34 @@ func TestProxyByHostname(t *testing.T) { allowWildcardHost: true, matchProxyName: "one", }, + { + name: "MatchAccessURLWithPort", + testHostname: "three.coder.com", + allowAccessURL: true, + allowWildcardHost: false, + matchProxyName: "three", + }, + { + name: "MatchAccessURLWithTrailingSlash", + testHostname: "four.coder.com", + allowAccessURL: true, + allowWildcardHost: false, + matchProxyName: "four", + }, + { + name: "RejectAccessURLPrefix", + testHostname: "one.coder", + allowAccessURL: true, + allowWildcardHost: false, + matchProxyName: "", + }, + { + name: "RejectAccessURLTLDPrefix", + testHostname: "one.coder.co", + allowAccessURL: true, + allowWildcardHost: false, + matchProxyName: "", + }, { name: "MatchWildcard", testHostname: "something.wildcard.one.coder.com", @@ -1878,6 +1916,13 @@ func TestProxyByHostname(t *testing.T) { allowWildcardHost: true, matchProxyName: "one", }, + { + name: "RejectWildcardHostnamePrefix", + testHostname: "something.wildcard.one.coder", + allowAccessURL: false, + allowWildcardHost: true, + matchProxyName: "", + }, { name: "MatchSuffix", testHostname: "something--suffix.two.coder.com", @@ -1885,6 +1930,13 @@ func TestProxyByHostname(t *testing.T) { allowWildcardHost: true, matchProxyName: "two", }, + { + name: "RejectSuffixHostnamePrefix", + testHostname: "something--suffix.two.coder", + allowAccessURL: false, + allowWildcardHost: true, + matchProxyName: "", + }, { name: "ValidateHostname/1", testHostname: ".*ne.coder.com", diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 2e2e8a919b4..f6fe41c0287 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -21702,7 +21702,7 @@ WHERE ( ( $2 :: bool = true AND - url SIMILAR TO '[^:]*://' || $1 :: text || '([:/]?%)*' + url SIMILAR TO '[^:]*://' || $1 :: text || '([:/]%)*' ) OR ( $3 :: bool = true AND diff --git a/coderd/database/queries/proxies.sql b/coderd/database/queries/proxies.sql index df59d3baf10..cac44de84d4 100644 --- a/coderd/database/queries/proxies.sql +++ b/coderd/database/queries/proxies.sql @@ -120,7 +120,7 @@ WHERE ( ( @allow_access_url :: bool = true AND - url SIMILAR TO '[^:]*://' || @hostname :: text || '([:/]?%)*' + url SIMILAR TO '[^:]*://' || @hostname :: text || '([:/]%)*' ) OR ( @allow_wildcard_hostname :: bool = true AND diff --git a/coderd/workspaceapps_test.go b/coderd/workspaceapps_test.go index 8db2858e01e..db6f77ab951 100644 --- a/coderd/workspaceapps_test.go +++ b/coderd/workspaceapps_test.go @@ -115,6 +115,15 @@ func TestWorkspaceApplicationAuth(t *testing.T) { redirectURI: "https://proxy.test.coder.com/path", expectRedirect: "https://proxy.test.coder.com/path", }, + { + name: "RejectProxyAccessURLPrefix", + accessURL: "https://test.coder.com", + appHostname: "*.test.coder.com", + proxyURL: "https://proxy.test.coder.com", + proxyAppHostname: "*.proxy.test.coder.com", + redirectURI: "https://proxy.test.coder/path", + expectRedirect: "", + }, { name: "ProxySubdomainOK", accessURL: "https://test.coder.com", From b080be4461d0bae9bf1f1578a08ff7eac4fbebca Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 5 Aug 2026 21:35:02 +0200 Subject: [PATCH 101/138] fix(coderd): show correct deletion time in dormancy notification (backport #26488) (#27895) Code-only backport of #26488 to `release/2.34`. The dormancy notification's "will be automatically deleted in X" sentence rendered the dormancy threshold (`time_til_dormant`) instead of the auto-delete duration (`time_til_dormant_autodelete`). A 30-day threshold rendered as "4 weeks" even when auto-delete was configured to fire much sooner or later, so users were told the wrong deletion date. Unlike #26488, this backport contains **no migration**. Adding migration 000527 to the 2.34 line would break the migration ordering for deployments that later upgrade. Instead, the stored notification body is left untouched and the existing `timeTilDormant` label is populated with the correct value: - When the template has auto-delete configured, the label carries the countdown derived from the workspace's `deleting_at`, which `UpdateWorkspaceDormantDeletingAt` already computes atomically from `time_til_dormant_autodelete`. - When auto-delete is disabled, `deleting_at` is unset and the sentence cannot be omitted without a body change, so the label falls back to generic wording: "...will be automatically deleted in line with your template's auto-deletion policy if it remains inactive." Upgrading to >= 2.35.1 later applies migration 000527 and the `timeTilDelete` rename as usual; this patch is fully superseded at that point.
Implementation notes - `coderd/autobuild/lifecycle_executor.go`: propagate `wsNew.DeletingAt` onto `ws` after the dormancy UPDATE and humanize it into the `timeTilDormant` label. - `coderd/workspaces.go` (`putWorkspaceDormant`): use `newWorkspace.DeletingAt` for the label; the template fetch that fed the wrong duration is removed. - The label key intentionally stays `timeTilDormant` because the 2.34 notification body (migration 000311) references it; renaming would require a data migration, which this backport deliberately avoids. - No feature flag: the change is a pure correctness fix with no schema or API surface. - Tests adapted from #26488: `TestNotifications/DormancyAutoDelete` (lifecycle executor) and `TestWorkspaceNotifications/Dormant/InitiatorNotOwnerWithAutoDelete` (API path), plus fallback-wording assertions in the existing no-auto-delete tests. Both use a 35-day auto-delete so `humanize.Time` deterministically renders "1 month from now".
--- *This PR was generated by Coder Agents on behalf of @ibetitsmike.* --- coderd/autobuild/lifecycle_executor.go | 8 +-- coderd/autobuild/lifecycle_executor_test.go | 77 +++++++++++++++++++++ coderd/notifications/dormancy.go | 16 +++++ coderd/workspaces.go | 17 +---- coderd/workspaces_test.go | 62 +++++++++++++++++ 5 files changed, 161 insertions(+), 19 deletions(-) create mode 100644 coderd/notifications/dormancy.go diff --git a/coderd/autobuild/lifecycle_executor.go b/coderd/autobuild/lifecycle_executor.go index 60954373c47..9e7e5dd071f 100644 --- a/coderd/autobuild/lifecycle_executor.go +++ b/coderd/autobuild/lifecycle_executor.go @@ -11,7 +11,6 @@ import ( "sync/atomic" "time" - "github.com/dustin/go-humanize" "github.com/google/uuid" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" @@ -382,8 +381,10 @@ func (e *Executor) runOnce(t time.Time) Stats { Old: wsOld.WorkspaceTable(), New: wsNew, } - // To keep the `ws` accurate without doing a sql fetch + // Keep `ws` accurate without a sql fetch. The UPDATE derives + // deleting_at from the template's time_til_dormant_autodelete. ws.DormantAt = wsNew.DormantAt + ws.DeletingAt = wsNew.DeletingAt shouldNotifyDormancy = true @@ -467,7 +468,6 @@ func (e *Executor) runOnce(t time.Time) Stats { } } if shouldNotifyDormancy { - dormantTime := dbtime.Now().Add(time.Duration(tmpl.TimeTilDormant)) _, err = e.notificationsEnqueuer.Enqueue( e.ctx, ws.OwnerID, @@ -475,7 +475,7 @@ func (e *Executor) runOnce(t time.Time) Stats { map[string]string{ "name": ws.Name, "reason": "inactivity exceeded the dormancy threshold", - "timeTilDormant": humanize.Time(dormantTime), + "timeTilDormant": notifications.DormantDeletionText(ws.DeletingAt), }, "lifecycle_executor", ws.ID, diff --git a/coderd/autobuild/lifecycle_executor_test.go b/coderd/autobuild/lifecycle_executor_test.go index 345647977d6..f3126dc2f71 100644 --- a/coderd/autobuild/lifecycle_executor_test.go +++ b/coderd/autobuild/lifecycle_executor_test.go @@ -1336,6 +1336,83 @@ func TestNotifications(t *testing.T) { require.Contains(t, sent[0].Targets, workspace.ID) require.Contains(t, sent[0].Targets, workspace.OrganizationID) require.Contains(t, sent[0].Targets, workspace.OwnerID) + + require.Equal(t, "line with your template's auto-deletion policy", sent[0].Labels["timeTilDormant"]) + require.Equal(t, workspace.Name, sent[0].Labels["name"]) + require.Equal(t, "inactivity exceeded the dormancy threshold", sent[0].Labels["reason"]) + }) + + t.Run("DormancyAutoDelete", func(t *testing.T) { + t.Parallel() + + var ( + ticker = make(chan time.Time) + statCh = make(chan autobuild.Stats) + notifyEnq = notificationstest.FakeEnqueuer{} + // 35 days keeps humanize.Time deterministically in its "1 month" bucket. + timeTilDormant = time.Minute + timeTilDormantAutoDelete = 35 * 24 * time.Hour + client, db = coderdtest.NewWithDatabase(t, &coderdtest.Options{ + AutobuildTicker: ticker, + AutobuildStats: statCh, + IncludeProvisionerDaemon: true, + NotificationsEnqueuer: ¬ifyEnq, + TemplateScheduleStore: schedule.MockTemplateScheduleStore{ + SetFn: func(ctx context.Context, db database.Store, template database.Template, options schedule.TemplateScheduleOptions) (database.Template, error) { + template.TimeTilDormant = int64(options.TimeTilDormant) + template.TimeTilDormantAutoDelete = int64(options.TimeTilDormantAutoDelete) + return schedule.NewAGPLTemplateScheduleStore().Set(ctx, db, template, options) + }, + GetFn: func(_ context.Context, _ database.Store, _ uuid.UUID) (schedule.TemplateScheduleOptions, error) { + return schedule.TemplateScheduleOptions{ + UserAutostartEnabled: false, + UserAutostopEnabled: true, + DefaultTTL: 0, + AutostopRequirement: schedule.TemplateAutostopRequirement{}, + TimeTilDormant: timeTilDormant, + TimeTilDormantAutoDelete: timeTilDormantAutoDelete, + }, nil + }, + }, + }) + admin = coderdtest.CreateFirstUser(t, client) + version = coderdtest.CreateTemplateVersion(t, client, admin.OrganizationID, nil) + ) + + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, admin.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { + ctr.TimeTilDormantMillis = ptr.Ref(timeTilDormant.Milliseconds()) + ctr.TimeTilDormantAutoDeleteMillis = ptr.Ref(timeTilDormantAutoDelete.Milliseconds()) + }) + userClient, _ := coderdtest.CreateAnotherUser(t, client, admin.OrganizationID) + workspace := coderdtest.CreateWorkspace(t, userClient, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, userClient, workspace.LatestBuild.ID) + + workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) + _ = coderdtest.AwaitWorkspaceBuildJobCompleted(t, userClient, workspace.LatestBuild.ID) + + p, err := coderdtest.GetProvisionerForTags(db, time.Now(), workspace.OrganizationID, nil) + require.NoError(t, err) + + notifyEnq.Clear() + tickTime := workspace.LastUsedAt.Add(timeTilDormant * 3) + coderdtest.UpdateProvisionerLastSeenAt(t, db, p.ID, tickTime) + ticker <- tickTime + _ = testutil.TryReceive(testutil.Context(t, testutil.WaitShort), t, statCh) + + workspace = coderdtest.MustWorkspace(t, client, workspace.ID) + require.NotNil(t, workspace.DormantAt) + + sent := notifyEnq.Sent() + require.Len(t, sent, 1) + require.Equal(t, sent[0].TemplateID, notifications.TemplateWorkspaceDormant) + require.Contains(t, sent[0].Labels, "timeTilDormant") + require.Contains(t, sent[0].Labels["timeTilDormant"], "1 month", + "timeTilDormant must humanize TimeTilDormantAutoDelete, got %q", + sent[0].Labels["timeTilDormant"]) + require.NotContains(t, sent[0].Labels["timeTilDormant"], "ago", + "timeTilDormant must be a future timestamp, got %q", + sent[0].Labels["timeTilDormant"]) }) } diff --git a/coderd/notifications/dormancy.go b/coderd/notifications/dormancy.go new file mode 100644 index 00000000000..236bcd130e9 --- /dev/null +++ b/coderd/notifications/dormancy.go @@ -0,0 +1,16 @@ +package notifications + +import ( + "database/sql" + + "github.com/dustin/go-humanize" +) + +// DormantDeletionText supplies the timeTilDormant label embedded in the stored +// TemplateWorkspaceDormant "will be automatically deleted in ..." sentence. +func DormantDeletionText(deletingAt sql.NullTime) string { + if deletingAt.Valid { + return humanize.Time(deletingAt.Time) + } + return "line with your template's auto-deletion policy" +} diff --git a/coderd/workspaces.go b/coderd/workspaces.go index b812011edca..2bbaaf5e65b 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -12,7 +12,6 @@ import ( "strings" "time" - "github.com/dustin/go-humanize" "github.com/go-chi/chi/v5" "github.com/google/uuid" "golang.org/x/sync/errgroup" @@ -1514,19 +1513,7 @@ func (api *API) putWorkspaceDormant(rw http.ResponseWriter, r *http.Request) { ) } - tmpl, tmplErr := api.Database.GetTemplateByID(ctx, newWorkspace.TemplateID) - if tmplErr != nil { - api.Logger.Warn( - ctx, - "failed to fetch the template of the workspace marked as dormant", - slog.Error(err), - slog.F("workspace_id", newWorkspace.ID), - slog.F("template_id", newWorkspace.TemplateID), - ) - } - - if initiatorErr == nil && tmplErr == nil { - dormantTime := dbtime.Time(now).Add(time.Duration(tmpl.TimeTilDormant)) + if initiatorErr == nil { _, err = api.NotificationsEnqueuer.Enqueue( // nolint:gocritic // Need notifier actor to enqueue notifications dbauthz.AsNotifier(ctx), @@ -1535,7 +1522,7 @@ func (api *API) putWorkspaceDormant(rw http.ResponseWriter, r *http.Request) { map[string]string{ "name": newWorkspace.Name, "reason": "a " + initiator.Username + " request", - "timeTilDormant": humanize.Time(dormantTime), + "timeTilDormant": notifications.DormantDeletionText(newWorkspace.DeletingAt), }, "api", newWorkspace.ID, diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index de7ae93ccf6..b2fdbd70508 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -5156,6 +5156,68 @@ func TestWorkspaceNotifications(t *testing.T) { require.Contains(t, sent[0].Targets, workspace.ID) require.Contains(t, sent[0].Targets, workspace.OrganizationID) require.Contains(t, sent[0].Targets, workspace.OwnerID) + require.Equal(t, "line with your template's auto-deletion policy", sent[0].Labels["timeTilDormant"]) + }) + + t.Run("InitiatorNotOwnerWithAutoDelete", func(t *testing.T) { + t.Parallel() + + // Given + var ( + notifyEnq = ¬ificationstest.FakeEnqueuer{} + // 35 days keeps humanize.Time deterministically in its "1 month" bucket. + timeTilDormantAutoDelete = 35 * 24 * time.Hour + client = coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + NotificationsEnqueuer: notifyEnq, + // The AGPL store ignores TimeTilDormantAutoDelete, so the mock + // writes it to the template row for deleting_at computation. + TemplateScheduleStore: schedule.MockTemplateScheduleStore{ + SetFn: func(ctx context.Context, db database.Store, template database.Template, options schedule.TemplateScheduleOptions) (database.Template, error) { + template.TimeTilDormantAutoDelete = int64(options.TimeTilDormantAutoDelete) + return schedule.NewAGPLTemplateScheduleStore().Set(ctx, db, template, options) + }, + GetFn: func(_ context.Context, _ database.Store, _ uuid.UUID) (schedule.TemplateScheduleOptions, error) { + return schedule.TemplateScheduleOptions{ + UserAutostartEnabled: false, + UserAutostopEnabled: true, + DefaultTTL: 0, + AutostopRequirement: schedule.TemplateAutostopRequirement{}, + TimeTilDormantAutoDelete: timeTilDormantAutoDelete, + }, nil + }, + }, + }) + user = coderdtest.CreateFirstUser(t, client) + memberClient, _ = coderdtest.CreateAnotherUser(t, client, user.OrganizationID, rbac.RoleOwner()) + version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { + ctr.TimeTilDormantAutoDeleteMillis = ptr.Ref[int64](timeTilDormantAutoDelete.Milliseconds()) + }) + workspace = coderdtest.CreateWorkspace(t, client, template.ID) + _ = coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) + ) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + t.Cleanup(cancel) + + // When + err := memberClient.UpdateWorkspaceDormancy(ctx, workspace.ID, codersdk.UpdateWorkspaceDormancy{ + Dormant: true, + }) + + // Then + require.NoError(t, err, "mark workspace as dormant") + sent := notifyEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateWorkspaceDormant)) + require.Len(t, sent, 1) + require.Contains(t, sent[0].Labels, "timeTilDormant") + require.Contains(t, sent[0].Labels["timeTilDormant"], "1 month", + "timeTilDormant must humanize the workspace's deleting_at, got %q", + sent[0].Labels["timeTilDormant"]) + require.NotContains(t, sent[0].Labels["timeTilDormant"], "ago", + "timeTilDormant must be a future timestamp, got %q", + sent[0].Labels["timeTilDormant"]) }) t.Run("InitiatorIsOwner", func(t *testing.T) { From 350070e97dbc252d2cdb62d85e80667f94888ff5 Mon Sep 17 00:00:00 2001 From: Atif Ali Date: Thu, 6 Aug 2026 02:01:48 +0500 Subject: [PATCH 102/138] fix(site): backport admin settings dropdown visibility fix to release/2.34 (#27851) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport of #27481 to `release/2.34`. ## Problem `canViewAnyOrganization` included `viewAnyMembers`, a permission every user now has because of workspace sharing. This meant the Admin settings dropdown (and the Organizations entry within it) showed up for every user, not just admins. ## Fix - Removed `permissions.viewAnyMembers` from `canViewAnyOrganization` in `site/src/modules/permissions/index.ts`. - Updated `site/e2e/tests/roles.spec.ts` to match, including a new regression test for org members with no roles. ## Note on scope Upstream #27481 also refactored `DeploymentDropdown`/`MobileMenu` into a shared `AdminSettings.tsx` component driven by a single permissions object. That refactor doesn't apply to `release/2.34`: this branch's `DeploymentDropdown` and `MobileMenu` already gate the Admin settings menu with equivalent per-permission checks, so only the actual permission fix and its test coverage are backported here. ## Validation - `pnpm exec biome check` on the two changed files: clean. - Confirmed pre-existing `tsc` errors in this branch are unrelated environment/dependency issues (reproduced identically on a clean `release/2.34` checkout). > 🤖 This PR was created with the help of Coder Agents, and needs a human review. 🧑💻 --------- Co-authored-by: Jeremy Ruppel --- site/e2e/tests/roles.spec.ts | 28 +++++++++++++------ .../dashboard/Navbar/DeploymentDropdown.tsx | 9 ++++-- .../modules/dashboard/Navbar/MobileMenu.tsx | 15 ++++++---- site/src/modules/permissions/index.ts | 3 +- 4 files changed, 36 insertions(+), 19 deletions(-) diff --git a/site/e2e/tests/roles.spec.ts b/site/e2e/tests/roles.spec.ts index a1d39c7c42a..973b815991d 100644 --- a/site/e2e/tests/roles.spec.ts +++ b/site/e2e/tests/roles.spec.ts @@ -61,25 +61,21 @@ test.describe("roles admin settings access", () => { await login(page, users.templateAdmin); await page.goto("/", { waitUntil: "domcontentloaded" }); - await hasAccessToAdminSettings(page, ["Deployment", "Organizations"]); + await hasAccessToAdminSettings(page, ["Deployment"]); }); test("user admin can see admin settings", async ({ page }) => { await login(page, users.userAdmin); await page.goto("/", { waitUntil: "domcontentloaded" }); - await hasAccessToAdminSettings(page, ["Deployment", "Organizations"]); + await hasAccessToAdminSettings(page, ["Deployment"]); }); test("auditor can see admin settings", async ({ page }) => { await login(page, users.auditor); await page.goto("/", { waitUntil: "domcontentloaded" }); - await hasAccessToAdminSettings(page, [ - "Deployment", - "Organizations", - "Audit Logs", - ]); + await hasAccessToAdminSettings(page, ["Deployment", "Audit Logs"]); }); test("owner can see admin settings", async ({ page }) => { @@ -88,7 +84,6 @@ test.describe("roles admin settings access", () => { await hasAccessToAdminSettings(page, [ "Deployment", - "Organizations", "Healthcheck", "Audit Logs", ]); @@ -103,6 +98,23 @@ test.describe("org-scoped roles admin settings access", () => { await setupApiCalls(page); }); + test("member cannot see admin settings", async ({ page }) => { + // The unlicensed member test above cannot catch all regressions here. + // Many admin settings are locked behind a license and wouldn't be shown. + const org = await createOrganization(); + const member = await createOrganizationMember({ + orgRoles: { + [org.id]: [], + }, + }); + + await login(page, member); + await page.goto("/", { waitUntil: "domcontentloaded" }); + + // None, "Admin settings" button should not be visible + await hasAccessToAdminSettings(page, []); + }); + test("org template admin can see admin settings", async ({ page }) => { const org = await createOrganization(); const orgTemplateAdmin = await createOrganizationMember({ diff --git a/site/src/modules/dashboard/Navbar/DeploymentDropdown.tsx b/site/src/modules/dashboard/Navbar/DeploymentDropdown.tsx index 6517feca268..fcda1e15a32 100644 --- a/site/src/modules/dashboard/Navbar/DeploymentDropdown.tsx +++ b/site/src/modules/dashboard/Navbar/DeploymentDropdown.tsx @@ -67,6 +67,7 @@ export const DeploymentDropdown: FC = ({ const DeploymentDropdownContent: FC = ({ canViewDeployment, + canViewOrganizations, canViewAuditLog, canViewConnectionLog, canViewAIBridge, @@ -80,9 +81,11 @@ const DeploymentDropdownContent: FC = ({ Deployment )} - - Organizations - + {canViewOrganizations && ( + + Organizations + + )} {canViewAISettings && ( AI diff --git a/site/src/modules/dashboard/Navbar/MobileMenu.tsx b/site/src/modules/dashboard/Navbar/MobileMenu.tsx index ff498e1ac2a..8a8877dba59 100644 --- a/site/src/modules/dashboard/Navbar/MobileMenu.tsx +++ b/site/src/modules/dashboard/Navbar/MobileMenu.tsx @@ -208,6 +208,7 @@ const ProxySettingsSub: FC = ({ proxyContextValue }) => { const AdminSettingsSub: FC = ({ canViewDeployment, + canViewOrganizations, canViewAuditLog, canViewConnectionLog, canViewHealth, @@ -239,12 +240,14 @@ const AdminSettingsSub: FC = ({ Deployment )} - - Organizations - + {canViewOrganizations && ( + + Organizations + + )} {canViewAuditLog && ( { return ( permissions !== undefined && - (permissions.viewAnyMembers || - permissions.editAnyGroups || + (permissions.editAnyGroups || permissions.assignAnyRoles || permissions.viewAnyIdpSyncSettings || permissions.editAnySettings) From 85bcd5d3a3d71d2e60be8aa83abee2596850c819 Mon Sep 17 00:00:00 2001 From: Jakub Domeracki Date: Thu, 6 Aug 2026 14:45:00 +0200 Subject: [PATCH 103/138] fix!: require org membership for user ACLs (#26852) (#27884) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport of https://github.com/coder/coder/pull/26852 Original PR: #26852 — fix!: require org membership for user ACLs Merge commit: 6b3341aad326cd23e9dfc473aa6592975620aa2d Requested by: @jdomeracki-coder Clean cherry-pick, no conflicts. --- _Opened by Coder Agents on behalf of @jdomeracki-coder._ Co-authored-by: Jon Ayers --- coderd/rbac/authz_internal_test.go | 107 ++++++++++++++++++++++++++++- coderd/rbac/policy.rego | 4 +- 2 files changed, 108 insertions(+), 3 deletions(-) diff --git a/coderd/rbac/authz_internal_test.go b/coderd/rbac/authz_internal_test.go index 3d933060177..163e5d01e09 100644 --- a/coderd/rbac/authz_internal_test.go +++ b/coderd/rbac/authz_internal_test.go @@ -312,19 +312,35 @@ func TestAuthorizeDomain(t *testing.T) { testAuthorize(t, "UserACLList", user, []authTestCase{ { - resource: ResourceWorkspace.WithOwner(unusedID.String()).InOrg(unusedID).WithACLUserList(map[string][]policy.Action{ + resource: ResourceWorkspace.WithOwner(unusedID.String()).InOrg(defOrg).WithACLUserList(map[string][]policy.Action{ user.ID: ResourceWorkspace.AvailableActions(), }), actions: ResourceWorkspace.AvailableActions(), allow: true, }, { - resource: ResourceWorkspace.WithOwner(unusedID.String()).InOrg(unusedID).WithACLUserList(map[string][]policy.Action{ + resource: ResourceWorkspace.WithOwner(unusedID.String()).InOrg(defOrg).WithACLUserList(map[string][]policy.Action{ user.ID: {policy.WildcardSymbol}, }), actions: ResourceWorkspace.AvailableActions(), allow: true, }, + { + // User ACLs only grant permissions in organizations where the + // subject is currently a member. + resource: ResourceWorkspace.WithOwner(unusedID.String()).InOrg(unusedID).WithACLUserList(map[string][]policy.Action{ + user.ID: ResourceWorkspace.AvailableActions(), + }), + actions: ResourceWorkspace.AvailableActions(), + allow: false, + }, + { + resource: ResourceWorkspace.WithOwner(unusedID.String()).InOrg(unusedID).WithACLUserList(map[string][]policy.Action{ + user.ID: {policy.WildcardSymbol}, + }), + actions: ResourceWorkspace.AvailableActions(), + allow: false, + }, { resource: ResourceWorkspace.WithOwner(unusedID.String()).InOrg(unusedID).WithACLUserList(map[string][]policy.Action{ user.ID: {policy.ActionRead, policy.ActionUpdate}, @@ -714,6 +730,93 @@ func TestAuthorizeDomain(t *testing.T) { })) } +// TestAuthorizeUserACLOrgMembership verifies that user ACL grants require org +// membership, while site-wide roles still authorize independent of ACLs. +func TestAuthorizeUserACLOrgMembership(t *testing.T) { + t.Parallel() + + orgID := uuid.New() + + // Site template-admin, not a member of orgID. + siteTemplateAdmin := Subject{ + ID: "site-template-admin", + Scope: must(ExpandScope(ScopeAll)), + Roles: Roles{ + must(RoleByName(RoleMember())), + must(RoleByName(RoleTemplateAdmin())), + }, + } + testAuthorize(t, "SiteTemplateAdminNotInOrg", siteTemplateAdmin, []authTestCase{ + { + // Authorized by the site role, no ACL needed. + resource: ResourceTemplate.InOrg(orgID), + actions: []policy.Action{policy.ActionUpdate}, + allow: true, + }, + { + // Redundant ACL entry; still authorized by the site role. + resource: ResourceTemplate.InOrg(orgID).WithACLUserList(map[string][]policy.Action{ + siteTemplateAdmin.ID: {policy.ActionUpdate}, + }), + actions: []policy.Action{policy.ActionUpdate}, + allow: true, + }, + }) + + // Site user-admin (no template perms), not a member of orgID. + siteUserAdmin := Subject{ + ID: "site-user-admin", + Scope: must(ExpandScope(ScopeAll)), + Roles: Roles{ + must(RoleByName(RoleMember())), + must(RoleByName(RoleUserAdmin())), + }, + } + testAuthorize(t, "SiteUserAdminNotInOrg", siteUserAdmin, []authTestCase{ + { + // No template role and no ACL entry: denied. + resource: ResourceTemplate.InOrg(orgID), + actions: []policy.Action{policy.ActionUpdate}, + allow: false, + }, + { + // An ACL grant must not authorize a non-member. + resource: ResourceTemplate.InOrg(orgID).WithACLUserList(map[string][]policy.Action{ + siteUserAdmin.ID: {policy.ActionUpdate}, + }), + actions: []policy.Action{policy.ActionUpdate}, + allow: false, + }, + }) + + // Same site user-admin, now also a member of orgID. + siteUserAdminOrgMember := Subject{ + ID: "site-user-admin-org-member", + Scope: must(ExpandScope(ScopeAll)), + Roles: Roles{ + must(RoleByName(RoleMember())), + must(RoleByName(RoleUserAdmin())), + orgMemberRole(orgID), + }, + } + testAuthorize(t, "SiteUserAdminOrgMember", siteUserAdminOrgMember, []authTestCase{ + { + // Org membership alone does not grant template update. + resource: ResourceTemplate.InOrg(orgID), + actions: []policy.Action{policy.ActionUpdate}, + allow: false, + }, + { + // As an org member, the ACL grant takes effect. + resource: ResourceTemplate.InOrg(orgID).WithACLUserList(map[string][]policy.Action{ + siteUserAdminOrgMember.ID: {policy.ActionUpdate}, + }), + actions: []policy.Action{policy.ActionUpdate}, + allow: true, + }, + }) +} + // TestAuthorizeLevels ensures level overrides are acting appropriately func TestAuthorizeLevels(t *testing.T) { t.Parallel() diff --git a/coderd/rbac/policy.rego b/coderd/rbac/policy.rego index e8844a22bdb..0a15955badd 100644 --- a/coderd/rbac/policy.rego +++ b/coderd/rbac/policy.rego @@ -330,7 +330,9 @@ object_is_included_in_scope_allow_list if { # ACL for users acl_allow if { - # TODO: Should you have to be a member of the org too? + # The subject must be a member of the object's organization for a + # user ACL grant to apply. + is_org_member perms := input.object.acl_user_list[input.subject.id] # Check if either the action or * is allowed From c15ae01435c4cdf1c4df9140d555874e5c681651 Mon Sep 17 00:00:00 2001 From: Jakub Domeracki Date: Thu, 6 Aug 2026 14:45:24 +0200 Subject: [PATCH 104/138] fix: redact env var values in agent debug manifest endpoint (#26904) (#27880) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport of https://github.com/coder/coder/pull/26904 Original PR: #26904 — fix: redact env var values in agent debug manifest endpoint Merge commit: b33ff2d8514fd798a4e1610e1ca86a7aa448a266 Requested by: @jdomeracki-coder
Conflict resolution notes The cherry-pick conflicted in `agent/agent.go` because the new `redactedManifestEnvValue` constant landed where `release/2.34` has the `HandleHTTPDebugLogs` handler. Resolved by keeping both: the existing handler and the new constant. The manifest env-redaction block applied cleanly. The `./agent/...` package builds successfully.
--- _Opened by Coder Agents on behalf of @jdomeracki-coder._ Co-authored-by: Jon Ayers --- agent/agent.go | 24 +++++++++++++++++++++++- agent/agent_test.go | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/agent/agent.go b/agent/agent.go index 33ad83c804d..c598de819e7 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -2148,8 +2148,26 @@ func (a *agent) HandleHTTPDebugManifest(w http.ResponseWriter, r *http.Request) return } + // Redact env values. This endpoint is unauthenticated on loopback, + // reachable by any process regardless of Unix user. Keys are preserved + // so operators can still see which variables are configured. + debugManifest := *sdkManifest + if len(sdkManifest.EnvironmentVariables) > 0 { + envs := make(map[string]string, len(sdkManifest.EnvironmentVariables)) + for k, v := range sdkManifest.EnvironmentVariables { + // Preserve empty values, which carry no secret, matching + // sanitizeEnv in support/support.go. + if v == "" { + envs[k] = v + continue + } + envs[k] = redactedManifestEnvValue + } + debugManifest.EnvironmentVariables = envs + } + w.WriteHeader(http.StatusOK) - if err := json.NewEncoder(w).Encode(sdkManifest); err != nil { + if err := json.NewEncoder(w).Encode(debugManifest); err != nil { a.logger.Error(a.hardCtx, "write debug manifest", slog.Error(err)) } } @@ -2174,6 +2192,10 @@ func (a *agent) HandleHTTPDebugLogs(w http.ResponseWriter, r *http.Request) { } } +// redactedManifestEnvValue matches the marker used by sanitizeEnv in +// support/support.go so a support bundle and this endpoint agree. +const redactedManifestEnvValue = "***REDACTED***" + func (a *agent) HTTPDebug() http.Handler { r := chi.NewRouter() diff --git a/agent/agent_test.go b/agent/agent_test.go index 1fe8ad2725b..7412338404f 100644 --- a/agent/agent_test.go +++ b/agent/agent_test.go @@ -3519,6 +3519,10 @@ func TestAgent_DebugServer(t *testing.T) { //nolint:dogsled conn, _, _, _, agnt := setupAgentWithSecrets(t, agentsdk.Manifest{ DERPMap: derpMap, + EnvironmentVariables: map[string]string{ + "AWS_SECRET_ACCESS_KEY": "env-value-should-be-redacted-67890", + "EMPTY_VAR": "", + }, }, []agentsdk.WorkspaceSecret{ {EnvName: "DEBUG_SECRET", Value: []byte("super-secret-value-12345")}, }, 0, func(c *agenttest.Client, o *agent.Options) { @@ -3647,6 +3651,34 @@ func TestAgent_DebugServer(t *testing.T) { require.NoError(t, json.Unmarshal(body, &v)) }) + t.Run("ManifestEnvVarValuesRedacted", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL+"/debug/manifest", nil) + require.NoError(t, err) + + res, err := srv.Client().Do(req) + require.NoError(t, err) + defer res.Body.Close() + require.Equal(t, http.StatusOK, res.StatusCode) + + body, err := io.ReadAll(res.Body) + require.NoError(t, err) + + require.NotContains(t, string(body), "env-value-should-be-redacted-67890") + + var v agentsdk.Manifest + require.NoError(t, json.Unmarshal(body, &v)) + + require.Contains(t, v.EnvironmentVariables, "AWS_SECRET_ACCESS_KEY") + require.Equal(t, "***REDACTED***", v.EnvironmentVariables["AWS_SECRET_ACCESS_KEY"]) + + // Empty values carry no secret and are preserved as empty. + require.Contains(t, v.EnvironmentVariables, "EMPTY_VAR") + require.Equal(t, "", v.EnvironmentVariables["EMPTY_VAR"]) + }) + t.Run("Logs", func(t *testing.T) { t.Parallel() From 5c059ba989eacaaa5301ab53c946f245d0cfd1b8 Mon Sep 17 00:00:00 2001 From: Jakub Domeracki Date: Thu, 6 Aug 2026 14:45:27 +0200 Subject: [PATCH 105/138] fix: prevent open redirect in proxy authentication flow (#26647) (#27881) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport of https://github.com/coder/coder/pull/26647 Original PR: #26647 — fix: prevent open redirect in proxy authentication flow Merge commit: 7e7a6b4f189532e9802270eb8286f94680e64403 Requested by: @jdomeracki-coder Clean cherry-pick, no conflicts. --- _Opened by Coder Agents on behalf of @jdomeracki-coder._ Co-authored-by: Jon Ayers --- coderd/workspaceapps/proxy.go | 48 +++-- coderd/workspaceapps/proxy_internal_test.go | 117 ++++++++++++ coderd/workspaceapps/proxy_test.go | 190 +++++++++++++++----- 3 files changed, 294 insertions(+), 61 deletions(-) create mode 100644 coderd/workspaceapps/proxy_internal_test.go diff --git a/coderd/workspaceapps/proxy.go b/coderd/workspaceapps/proxy.go index c90d4833fe9..dfd8bff0b78 100644 --- a/coderd/workspaceapps/proxy.go +++ b/coderd/workspaceapps/proxy.go @@ -165,6 +165,18 @@ func (s *Server) Attach(r chi.Router) { r.Get("/api/v2/workspaceagents/{workspaceagent}/pty", s.workspaceAgentPTY) } +// originLocalURL returns p as a relative URL rooted at the current origin, +// safe to use as a redirect Location. p (typically r.URL.Path) is +// attacker-controlled and already percent-decoded, so a leading "//" or "/\" +// run would otherwise be parsed by http.Redirect or a browser as a +// scheme-relative URL pointing at another host, turning a redirect back to the +// same path into an open redirect. Collapsing the leading slash and backslash +// run to a single "/" keeps the result same-origin, and url.URL.String() +// percent-encodes any control characters in the path. +func originLocalURL(p string) *url.URL { + return &url.URL{Path: "/" + strings.TrimLeft(p, `/\`)} +} + // handleAPIKeySmuggling is called by the proxy path and subdomain handlers to // process any "smuggled" API keys in the query parameters. // @@ -265,19 +277,17 @@ func (s *Server) handleAPIKeySmuggling(rw http.ResponseWriter, r *http.Request, HttpOnly: true, })) - // Strip the query parameter. - path := r.URL.Path - if path == "" { - path = "/" - } + // Strip the smuggled API key query parameter and redirect back to the same + // path. r.URL.Path is attacker-controlled and can smuggle a separate host + // (e.g. "//evil.com"); originLocalURL keeps the redirect on the current + // origin. + redirectURL := originLocalURL(r.URL.Path) + q := r.URL.Query() q.Del(SubdomainProxyAPIKeyParam) - rawQuery := q.Encode() - if rawQuery != "" { - path += "?" + q.Encode() - } + redirectURL.RawQuery = q.Encode() - http.Redirect(rw, r, path, http.StatusSeeOther) + http.Redirect(rw, r, redirectURL.String(), http.StatusSeeOther) return false } @@ -615,7 +625,14 @@ func (s *Server) proxyWorkspaceApp(rw http.ResponseWriter, r *http.Request, appT // Web applications typically request paths relative to the // root URL. This allows for routing behind a proxy or subpath. // See https://github.com/coder/code-server/issues/241 for examples. - http.Redirect(rw, r, r.URL.Path+"/", http.StatusTemporaryRedirect) + // + // r.URL.Path is attacker-controlled, so sanitize it before redirecting + // to avoid an off-origin "//host" Location (see originLocalURL). + redirectURL := originLocalURL(r.URL.Path) + if !strings.HasSuffix(redirectURL.Path, "/") { + redirectURL.Path += "/" + } + http.Redirect(rw, r, redirectURL.String(), http.StatusTemporaryRedirect) return } if path == "/" && r.URL.RawQuery == "" && appURL.RawQuery != "" { @@ -624,8 +641,13 @@ func (s *Server) proxyWorkspaceApp(rw http.ResponseWriter, r *http.Request, appT // query parameters for server-side requests, but sometimes // client-side applications require the query parameters to render // properly. With code-server, this is the "folder" param. - r.URL.RawQuery = appURL.RawQuery - http.Redirect(rw, r, r.URL.String(), http.StatusTemporaryRedirect) + // + // r.URL.Path is attacker-controlled, so build the Location from a + // sanitized same-origin path instead of r.URL directly (see + // originLocalURL). + redirectURL := originLocalURL(r.URL.Path) + redirectURL.RawQuery = appURL.RawQuery + http.Redirect(rw, r, redirectURL.String(), http.StatusTemporaryRedirect) return } diff --git a/coderd/workspaceapps/proxy_internal_test.go b/coderd/workspaceapps/proxy_internal_test.go new file mode 100644 index 00000000000..cde66026f3f --- /dev/null +++ b/coderd/workspaceapps/proxy_internal_test.go @@ -0,0 +1,117 @@ +package workspaceapps + +import ( + "net/url" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// Test_originLocalURL checks that originLocalURL produces a redirect target that +// stays on the current origin. +func Test_originLocalURL(t *testing.T) { + t.Parallel() + + t.Run("RejectsOffOrigin", func(t *testing.T) { + t.Parallel() + + // Each path models an already-percent-decoded r.URL.Path that tries to + // smuggle a separate host into the redirect. + cases := []struct { + name string + path string + }{ + {name: "DoubleSlash", path: "//evil.com/phish"}, + {name: "TripleSlash", path: "///evil.com/phish"}, + {name: "SlashBackslash", path: "/\\evil.com/phish"}, + {name: "SlashBackslashSlash", path: "/\\/evil.com/phish"}, + {name: "DoubleBackslash", path: "\\\\evil.com/phish"}, + {name: "SlashTab", path: "/\t/evil.com/phish"}, + {name: "SlashTabBackslash", path: "/\t\\evil.com/phish"}, + {name: "SlashNewline", path: "/\n/evil.com/phish"}, + {name: "SlashCarriageReturn", path: "/\r/evil.com/phish"}, + {name: "SlashTabDoubleSlash", path: "/\t//evil.com/phish"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + loc := originLocalURL(tc.path).String() + + // The Location must parse as a relative, same-origin reference. + require.Falsef(t, strings.HasPrefix(loc, "//"), + "path %q produced scheme-relative Location %q", tc.path, loc) + parsed, err := url.Parse(loc) + require.NoErrorf(t, err, "path %q produced unparseable Location %q", tc.path, loc) + require.Emptyf(t, parsed.Scheme, "path %q produced Location %q with a scheme", tc.path, loc) + require.Emptyf(t, parsed.Host, "path %q produced Location %q with a host", tc.path, loc) + + // It must also be free of raw bytes a browser would normalize back + // into an authority before resolving (a backslash becomes "/", and + // tab/newline/CR are stripped, either of which could re-form + // "//host"). url.URL.String() guarantees this by percent-encoding + // them; we assert it here rather than reproducing browser + // normalization in the code. + for _, raw := range []string{`\`, "\t", "\n", "\r"} { + require.NotContainsf(t, loc, raw, + "path %q produced Location %q containing a raw %q", tc.path, loc, raw) + } + }) + } + }) + + t.Run("EscapesControlCharacters", func(t *testing.T) { + t.Parallel() + + // A redirect built from a path containing a raw control character is an + // open redirect: http.Redirect emits it verbatim (url.Parse rejects the + // control byte and skips cleaning) and browsers strip tab/newline/CR + // before resolving, re-forming "//evil.com". originLocalURL percent-encodes + // each one. Assert every class is escaped so a future change that breaks + // encoding for only one class is caught. + cases := []struct { + name string + in string + want string + }{ + {name: "Tab", in: "/\t/evil.com", want: "/%09/evil.com"}, + {name: "Newline", in: "/\n/evil.com", want: "/%0A/evil.com"}, + {name: "CarriageReturn", in: "/\r/evil.com", want: "/%0D/evil.com"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + require.Equalf(t, tc.want, originLocalURL(tc.in).String(), + "originLocalURL(%q) must percent-encode the control character", tc.in) + }) + } + }) + + t.Run("PreservesLegitPaths", func(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + in string + want string + }{ + {name: "Empty", in: "", want: "/"}, + {name: "Root", in: "/", want: "/"}, + {name: "Simple", in: "/test", want: "/test"}, + {name: "Nested", in: "/app/sub/page", want: "/app/sub/page"}, + {name: "PathApp", in: "/@user/ws/apps/app", want: "/@user/ws/apps/app"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + require.Equalf(t, tc.want, originLocalURL(tc.in).String(), "originLocalURL(%q)", tc.in) + }) + } + }) +} diff --git a/coderd/workspaceapps/proxy_test.go b/coderd/workspaceapps/proxy_test.go index 8678614243c..cd180b32897 100644 --- a/coderd/workspaceapps/proxy_test.go +++ b/coderd/workspaceapps/proxy_test.go @@ -8,12 +8,15 @@ import ( "net/http" "net/http/httptest" "net/url" + "strings" "testing" + "time" "github.com/stretchr/testify/require" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/coderd/jwtutils" "github.com/coder/coder/v2/coderd/workspaceapps" "github.com/coder/coder/v2/coderd/workspaceapps/appurl" "github.com/coder/coder/v2/testutil" @@ -34,57 +37,148 @@ func (s *fakeSignedTokenProvider) Issue(_ context.Context, _ http.ResponseWriter return nil, "", false } -func TestHandleSubdomain_IgnoresUntrustedForwardedHost(t *testing.T) { +func TestHandleSubdomain(t *testing.T) { t.Parallel() - hostnamePattern := "*--apps.test.coder.com" - hostnameRegex, err := appurl.CompileHostnamePattern(hostnamePattern) - require.NoError(t, err) - - dashboardURL, err := url.Parse("https://dashboard.test.coder.com") - require.NoError(t, err) - - provider := &fakeSignedTokenProvider{} - srv := workspaceapps.NewServer(workspaceapps.ServerOptions{ - Logger: testutil.Logger(t), - DashboardURL: dashboardURL, - AccessURL: dashboardURL, - Hostname: hostnamePattern, - HostnameRegex: hostnameRegex, - RealIPConfig: &httpmw.RealIPConfig{ - TrustedOrigins: []*net.IPNet{{ - IP: net.ParseIP("10.0.0.1"), - Mask: net.CIDRMask(32, 32), - }}, - }, - SignedTokenProvider: provider, + t.Run("IgnoresUntrustedForwardedHost", func(t *testing.T) { + t.Parallel() + + hostnamePattern := "*--apps.test.coder.com" + hostnameRegex, err := appurl.CompileHostnamePattern(hostnamePattern) + require.NoError(t, err) + + dashboardURL, err := url.Parse("https://dashboard.test.coder.com") + require.NoError(t, err) + + provider := &fakeSignedTokenProvider{} + srv := workspaceapps.NewServer(workspaceapps.ServerOptions{ + Logger: testutil.Logger(t), + DashboardURL: dashboardURL, + AccessURL: dashboardURL, + Hostname: hostnamePattern, + HostnameRegex: hostnameRegex, + RealIPConfig: &httpmw.RealIPConfig{ + TrustedOrigins: []*net.IPNet{{ + IP: net.ParseIP("10.0.0.1"), + Mask: net.CIDRMask(32, 32), + }}, + }, + SignedTokenProvider: provider, + }) + + forgedHost := appurl.ApplicationURL{ + AppSlugOrPort: "app", + WorkspaceName: "workspace", + Username: "victim", + }.String() + "--apps.test.coder.com" + + nextCalled := false + next := http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) { + nextCalled = true + }) + + // Given: a request with a forged X-Forwarded-Host set to a valid + // app hostname, and an immediate peer outside the trusted proxy + // config. + req := httptest.NewRequest(http.MethodGet, "https://dashboard.test.coder.com/", nil) + req.Header.Set(httpapi.XForwardedHostHeader, forgedHost) + req.RemoteAddr = "17.18.19.20:1234" + + // When: HandleSubdomain runs. + srv.HandleSubdomain()(next).ServeHTTP(httptest.NewRecorder(), req) + + // Then: it ignores untrusted X-Forwarded-Host, so the received + // dashboard host is used, the request falls through to the next + // handler, and the signed app token provider is never called. + require.True(t, nextCalled) + require.Zero(t, provider.fromRequestCalls) + require.Zero(t, provider.issueCalls) }) - forgedHost := appurl.ApplicationURL{ - AppSlugOrPort: "app", - WorkspaceName: "workspace", - Username: "victim", - }.String() + "--apps.test.coder.com" - - nextCalled := false - next := http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) { - nextCalled = true + // After consuming a smuggled API key, the handler redirects to strip the key. + // A path that smuggles a separate host (e.g. "//evil.com") must redirect back + // to the current origin, not off-site. The full path-sanitization matrix lives + // in Test_originLocalURL. + t.Run("APIKeySmugglingStaysOnOrigin", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + + hostnamePattern := "*--apps.test.coder.com" + hostnameRegex, err := appurl.CompileHostnamePattern(hostnamePattern) + require.NoError(t, err) + + dashboardURL, err := url.Parse("https://dashboard.test.coder.com") + require.NoError(t, err) + + // StaticKey lets us mint a smuggled key the handler can decrypt. + // A256GCMKW needs a 32-byte key. + keycache := jwtutils.StaticKey{ID: "test", Key: generateSecret(t, 32)} + payload := workspaceapps.EncryptedAPIKeyPayload{APIKey: "fake-api-key"} + payload.Fill(time.Now()) + encryptedAPIKey, err := jwtutils.Encrypt(ctx, keycache, payload) + require.NoError(t, err) + + srv := workspaceapps.NewServer(workspaceapps.ServerOptions{ + Logger: testutil.Logger(t), + DashboardURL: dashboardURL, + AccessURL: dashboardURL, + Hostname: hostnamePattern, + HostnameRegex: hostnameRegex, + RealIPConfig: &httpmw.RealIPConfig{ + TrustedOrigins: []*net.IPNet{{ + IP: net.ParseIP("10.0.0.1"), + Mask: net.CIDRMask(32, 32), + }}, + }, + SignedTokenProvider: &fakeSignedTokenProvider{}, + APIKeyEncryptionKeycache: keycache, + }) + + host := appurl.ApplicationURL{ + AppSlugOrPort: "app", + WorkspaceName: "workspace", + Username: "user", + }.String() + "--apps.test.coder.com" + + // Set r.URL.Path directly: the slash-collapsing middleware writes to chi's + // RoutePath, not r.URL.Path, so the raw "//evil.com" survives to the handler. + // "x=1" rides along to confirm unrelated params are preserved. + req := httptest.NewRequest(http.MethodGet, "https://"+host+"/", nil) + req.Host = host + req.RemoteAddr = "10.0.0.1:1234" + req.URL.Path = "//evil.com/phish" + req.URL.RawQuery = url.Values{ + workspaceapps.SubdomainProxyAPIKeyParam: {encryptedAPIKey}, + "x": {"1"}, + }.Encode() + + rec := httptest.NewRecorder() + nextCalled := false + next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + nextCalled = true + }) + + srv.HandleSubdomain()(next).ServeHTTP(rec, req) + + res := rec.Result() + defer res.Body.Close() + + // The key is consumed and a redirect is issued instead of proxying. + require.Equal(t, http.StatusSeeOther, res.StatusCode) + require.False(t, nextCalled) + + loc := res.Header.Get("Location") + require.NotEmpty(t, loc) + require.False(t, strings.HasPrefix(loc, "//"), "redirect %q must stay same-origin", loc) + + parsed, err := url.Parse(loc) + require.NoError(t, err) + require.Empty(t, parsed.Scheme, "redirect %q must not carry a scheme", loc) + require.Empty(t, parsed.Host, "redirect %q must not carry a host", loc) + + // The smuggled key is stripped and unrelated query params survive. + require.Empty(t, parsed.Query().Get(workspaceapps.SubdomainProxyAPIKeyParam)) + require.Equal(t, "1", parsed.Query().Get("x")) }) - - // Given: a request with a forged X-Forwarded-Host set to a valid - // app hostname, and an immediate peer outside the trusted proxy - // config. - req := httptest.NewRequest(http.MethodGet, "https://dashboard.test.coder.com/", nil) - req.Header.Set(httpapi.XForwardedHostHeader, forgedHost) - req.RemoteAddr = "17.18.19.20:1234" - - // When: HandleSubdomain runs. - srv.HandleSubdomain()(next).ServeHTTP(httptest.NewRecorder(), req) - - // Then: it ignores untrusted X-Forwarded-Host, so the received - // dashboard host is used, the request falls through to the next - // handler, and the signed app token provider is never called. - require.True(t, nextCalled) - require.Zero(t, provider.fromRequestCalls) - require.Zero(t, provider.issueCalls) } From d70055a0fa9c2dbea85229b533a8533f6aafc44c Mon Sep 17 00:00:00 2001 From: Jakub Domeracki Date: Thu, 6 Aug 2026 14:45:33 +0200 Subject: [PATCH 106/138] fix: only return group member count for workspace acl (#26206) (#27882) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport of https://github.com/coder/coder/pull/26206 Original PR: #26206 — fix: only return group member count for workspace acl Merge commit: c7ddcce62c80a3128bed6728cf29dc6a29d1a105 Requested by: @jdomeracki-coder Clean cherry-pick, no conflicts. --- _Opened by Coder Agents on behalf of @jdomeracki-coder._ Co-authored-by: Jon Ayers --- cli/sharing.go | 15 +++---- cli/sharing_test.go | 42 +++++++++++++++++++ coderd/workspaces.go | 58 +++++++++++++------------ enterprise/cli/sharing_test.go | 7 +++- enterprise/coderd/workspaces_test.go | 63 ++++++++++++++++++++++++++++ 5 files changed, 150 insertions(+), 35 deletions(-) diff --git a/cli/sharing.go b/cli/sharing.go index 61428d3b372..61c0f75dd30 100644 --- a/cli/sharing.go +++ b/cli/sharing.go @@ -312,13 +312,14 @@ func workspaceACLToTable(ctx context.Context, acl *codersdk.WorkspaceACL) (strin continue } - for _, user := range group.Members { - outputRows = append(outputRows, workspaceShareRow{ - User: user.Username, - Group: group.Name, - Role: group.Role, - }) - } + // The ACL endpoint intentionally omits the group's member roster to + // avoid leaking member PII, so we display one row per group rather + // than one row per member. + outputRows = append(outputRows, workspaceShareRow{ + User: defaultGroupDisplay, + Group: group.Name, + Role: group.Role, + }) } out, err := formatter.Format(ctx, outputRows) if err != nil { diff --git a/cli/sharing_test.go b/cli/sharing_test.go index 26ad858d09f..fa8026554df 100644 --- a/cli/sharing_test.go +++ b/cli/sharing_test.go @@ -205,6 +205,48 @@ func TestSharingStatus(t *testing.T) { } assert.True(t, found, "expected to find username %s with role %s in the output: %s", toShareWithUser.Username, codersdk.WorkspaceRoleUse, out.String()) }) + + t.Run("ListSharedGroups", func(t *testing.T) { + t.Parallel() + + var ( + client, db = coderdtest.NewWithDatabase(t, nil) + orgOwner = coderdtest.CreateFirstUser(t, client) + workspaceOwnerClient, workspaceOwner = coderdtest.CreateAnotherUser(t, client, orgOwner.OrganizationID, rbac.ScopedRoleOrgAuditor(orgOwner.OrganizationID)) + workspace = dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OwnerID: workspaceOwner.ID, + OrganizationID: orgOwner.OrganizationID, + }).Do().Workspace + ctx = testutil.Context(t, testutil.WaitMedium) + ) + + // The Everyone group always exists for an organization and shares the + // organization's ID. The workspace ACL endpoint no longer returns the + // group's member roster, so the CLI must still list the group itself. + err := client.UpdateWorkspaceACL(ctx, workspace.ID, codersdk.UpdateWorkspaceACL{ + GroupRoles: map[string]codersdk.WorkspaceRole{ + orgOwner.OrganizationID.String(): codersdk.WorkspaceRoleUse, + }, + }) + require.NoError(t, err) + + inv, root := clitest.New(t, "sharing", "status", workspace.Name) + clitest.SetupConfig(t, workspaceOwnerClient, root) + + out := new(bytes.Buffer) + inv.Stdout = out + err = inv.WithContext(ctx).Run() + require.NoError(t, err) + + found := false + for _, line := range strings.Split(out.String(), "\n") { + if strings.Contains(line, database.EveryoneGroup) && strings.Contains(line, string(codersdk.WorkspaceRoleUse)) { + found = true + break + } + } + assert.True(t, found, "expected to find group %s with role %s in the output: %s", database.EveryoneGroup, codersdk.WorkspaceRoleUse, out.String()) + }) } func TestSharingRemove(t *testing.T) { diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 2bbaaf5e65b..337c0aa9447 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -2372,13 +2372,13 @@ func (api *API) workspaceACL(rw http.ResponseWriter, r *http.Request) { return } - // This is largely based on the template ACL implementation, and is far from - // ideal. Usually, when we use the System context it's because we need to - // run some query that won't actually be exposed to the user. That is not - // the case here. This data goes directly to an unauthorized user. We are - // just straight up breaking security promises. - // - // TODO: This needs to be fixed before GA. Currently in beta. + // Callers are authorized to read this workspace, not necessarily the + // users and groups on its ACL. We deliberately use the System context to + // look up that data, but only return minimal identity information that is + // safe to expose to anyone who can read the ACL: MinimalUser for ACL users + // (no email or other PII) and group identity plus a member count for ACL + // groups (no member roster). This mirrors the chat ACL and template + // available-ACL endpoints. // Fetch all of the users and their organization memberships userIDs := make([]uuid.UUID, 0, len(workspaceACL.Users)) @@ -2390,7 +2390,8 @@ func (api *API) workspaceACL(rw http.ResponseWriter, r *http.Request) { } userIDs = append(userIDs, id) } - // For context see https://github.com/coder/coder/pull/19375 + // ACL users are returned as MinimalUser, which contains no PII, so it is + // safe to fetch them under the System context. // nolint:gocritic dbUsers, err := api.Database.GetUsersByIDs(dbauthz.AsSystemRestricted(ctx), userIDs) if err != nil && !xerrors.Is(err, sql.ErrNoRows) { @@ -2422,7 +2423,8 @@ func (api *API) workspaceACL(rw http.ResponseWriter, r *http.Request) { // before making the DB call. dbGroups := make([]database.GetGroupsRow, 0) if len(groupIDs) > 0 { - // For context see https://github.com/coder/coder/pull/19375 + // Group identity must be visible to anyone who can read the ACL so + // that owners and shared users can see and manage entries. // nolint:gocritic dbGroups, err = api.Database.GetGroups(dbauthz.AsSystemRestricted(ctx), database.GetGroupsParams{GroupIds: groupIDs}) if err != nil && !xerrors.Is(err, sql.ErrNoRows) { @@ -2431,26 +2433,30 @@ func (api *API) workspaceACL(rw http.ResponseWriter, r *http.Request) { } } + // Fetch member counts for all groups in a single query to avoid an N+1 + // lookup. We intentionally do not populate the per-group member rosters: + // callers authorized to read the ACL are not necessarily authorized to + // read group membership, and the roster includes member PII. Only the + // total member count is returned (see Group.TotalMemberCount). + // nolint:gocritic + countRows, err := api.Database.GetGroupMembersCountByGroupIDs(dbauthz.AsSystemRestricted(ctx), database.GetGroupMembersCountByGroupIDsParams{ + GroupIds: groupIDs, + IncludeSystem: false, + }) + if err != nil && !xerrors.Is(err, sql.ErrNoRows) { + httpapi.InternalServerError(rw, err) + return + } + countByGroup := make(map[uuid.UUID]int64, len(countRows)) + for _, row := range countRows { + countByGroup[row.GroupID] = row.MemberCount + } + groups := make([]codersdk.WorkspaceGroup, 0, len(dbGroups)) for _, it := range dbGroups { - var members []database.GroupMember - // For context see https://github.com/coder/coder/pull/19375 - // nolint:gocritic - members, err = api.Database.GetGroupMembersByGroupID(dbauthz.AsSystemRestricted(ctx), database.GetGroupMembersByGroupIDParams{ - GroupID: it.Group.ID, - IncludeSystem: false, - }) - if err != nil { - httpapi.InternalServerError(rw, err) - return - } groups = append(groups, codersdk.WorkspaceGroup{ - Group: db2sdk.Group(database.GetGroupsRow{ - Group: it.Group, - OrganizationName: it.OrganizationName, - OrganizationDisplayName: it.OrganizationDisplayName, - }, members, len(members)), - Role: convertToWorkspaceRole(workspaceACL.Groups[it.Group.ID.String()].Permissions), + Group: db2sdk.Group(it, nil, int(countByGroup[it.Group.ID])), + Role: convertToWorkspaceRole(workspaceACL.Groups[it.Group.ID.String()].Permissions), }) } diff --git a/enterprise/cli/sharing_test.go b/enterprise/cli/sharing_test.go index 6e1e3c8dd4f..c972cd6cd9e 100644 --- a/enterprise/cli/sharing_test.go +++ b/enterprise/cli/sharing_test.go @@ -216,14 +216,17 @@ func TestSharingStatus(t *testing.T) { err = inv.WithContext(ctx).Run() require.NoError(t, err) + // The ACL endpoint omits group member rosters to avoid leaking member + // PII, so the output lists the group itself rather than its members. found := false for _, line := range strings.Split(out.String(), "\n") { - if strings.Contains(line, orgMember.Username) && strings.Contains(line, string(codersdk.WorkspaceRoleUse)) && strings.Contains(line, group.Name) { + if strings.Contains(line, group.Name) && strings.Contains(line, string(codersdk.WorkspaceRoleUse)) { found = true break } } - assert.True(t, found, "expected to find username %s with role %s in the output: %s", orgMember.Username, codersdk.WorkspaceRoleUse, out.String()) + assert.True(t, found, "expected to find group %s with role %s in the output: %s", group.Name, codersdk.WorkspaceRoleUse, out.String()) + assert.NotContains(t, out.String(), orgMember.Username, "group member roster must not be exposed in sharing status output") }) } diff --git a/enterprise/coderd/workspaces_test.go b/enterprise/coderd/workspaces_test.go index 84dcc41f81c..0d0c97bdf30 100644 --- a/enterprise/coderd/workspaces_test.go +++ b/enterprise/coderd/workspaces_test.go @@ -4477,6 +4477,69 @@ func TestUpdateWorkspaceACL(t *testing.T) { require.Equal(t, workspaceACL.Groups[0].Role, codersdk.WorkspaceRoleAdmin) }) + // A user who has merely been shared a workspace must not be able to + // enumerate the full roster and PII of groups on that workspace's ACL. + // The endpoint returns the group identity and total member count only. + t.Run("GroupMembersNotReturned", func(t *testing.T) { + t.Parallel() + + dv := coderdtest.DeploymentValues(t) + + adminClient, adminUser := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + IncludeProvisionerDaemon: true, + DeploymentValues: dv, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureTemplateRBAC: 1, + }, + }, + }) + orgID := adminUser.OrganizationID + client, _ := coderdtest.CreateAnotherUser(t, adminClient, orgID) + sharedClient, sharedUser := coderdtest.CreateAnotherUser(t, adminClient, orgID) + _, member := coderdtest.CreateAnotherUser(t, adminClient, orgID) + group := coderdtest.CreateGroup(t, adminClient, orgID, "bloob", member) + + tv := coderdtest.CreateTemplateVersion(t, adminClient, orgID, nil) + coderdtest.AwaitTemplateVersionJobCompleted(t, adminClient, tv.ID) + template := coderdtest.CreateTemplate(t, adminClient, orgID, tv.ID) + + ws := coderdtest.CreateWorkspace(t, client, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID) + + ctx := testutil.Context(t, testutil.WaitMedium) + err := client.UpdateWorkspaceACL(ctx, ws.ID, codersdk.UpdateWorkspaceACL{ + UserRoles: map[string]codersdk.WorkspaceRole{ + sharedUser.ID.String(): codersdk.WorkspaceRoleUse, + }, + GroupRoles: map[string]codersdk.WorkspaceRole{ + group.ID.String(): codersdk.WorkspaceRoleUse, + }, + }) + require.NoError(t, err) + + // The low-privilege shared user can read the ACL, but must not see + // the group's member roster (which would expose member emails and + // other PII). Only the total member count is returned. + workspaceACL, err := sharedClient.WorkspaceACL(ctx, ws.ID) + require.NoError(t, err) + require.Len(t, workspaceACL.Groups, 1) + require.Equal(t, group.ID, workspaceACL.Groups[0].ID) + require.Equal(t, codersdk.WorkspaceRoleUse, workspaceACL.Groups[0].Role) + require.Equal(t, 1, workspaceACL.Groups[0].TotalMemberCount) + require.Empty(t, workspaceACL.Groups[0].Members) + + // The workspace owner sees the same count-only shape; the roster is + // omitted for all callers. + workspaceACL, err = client.WorkspaceACL(ctx, ws.ID) + require.NoError(t, err) + require.Len(t, workspaceACL.Groups, 1) + require.Equal(t, 1, workspaceACL.Groups[0].TotalMemberCount) + require.Empty(t, workspaceACL.Groups[0].Members) + }) + t.Run("UnknownIDs", func(t *testing.T) { t.Parallel() From d6ea2bd16e66c2b6c0b90be9002a617c991c943b Mon Sep 17 00:00:00 2001 From: Jakub Domeracki Date: Thu, 6 Aug 2026 14:45:43 +0200 Subject: [PATCH 107/138] fix(coderd): scope provisioner module file downloads to the daemon's org (#26635) (#27883) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport of https://github.com/coder/coder/pull/26635 Original PR: #26635 — fix(coderd): scope provisioner module file downloads to the daemon's org Merge commit: 1961908ca786be534f2cbe5a089149ec04f72a19 Requested by: @jdomeracki-coder
Conflict resolution notes The cherry-pick conflicted in generated database files because the new `HasTemplateVersionsUsingCachedModuleFileInOrg` query sits adjacent to chatd methods (`HydrateAgentChatsContext`, `IncrementChatGenerationAttempt`) that do not exist on `release/2.34`. Resolved by keeping only the new query and its generated bindings in `querier.go`, `dbmetrics/querymetrics.go`, `dbmock/dbmock.go`, and `dbauthz/dbauthz.go`, then running `gofmt`. Affected packages (`./coderd/database/...`, `./coderd/provisionerdserver/...`) build successfully.
--- _Opened by Coder Agents on behalf of @jdomeracki-coder._ --------- Co-authored-by: Jon Ayers --- coderd/database/dbauthz/dbauthz.go | 10 ++ coderd/database/dbauthz/dbauthz_test.go | 5 + coderd/database/dbmetrics/querymetrics.go | 8 + coderd/database/dbmock/dbmock.go | 15 ++ coderd/database/querier.go | 5 + coderd/database/queries.sql.go | 27 ++++ .../templateversionterraformvalues.sql | 14 ++ .../provisionerdserver/provisionerdserver.go | 20 +++ .../provisionerdserver_test.go | 142 ++++++++++++++++++ 9 files changed, 246 insertions(+) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index a92b2231c56..eb1a1a634ce 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -5386,6 +5386,16 @@ func (q *querier) GetWorkspacesForWorkspaceMetrics(ctx context.Context) ([]datab return q.db.GetWorkspacesForWorkspaceMetrics(ctx) } +func (q *querier) HasTemplateVersionsUsingCachedModuleFileInOrg(ctx context.Context, arg database.HasTemplateVersionsUsingCachedModuleFileInOrgParams) (bool, error) { + // This query authorizes provisioner module-file downloads. The caller + // must be able to read files in the target organization; the actual + // tenant isolation comes from the organization_id filter in the query. + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceFile.InOrg(arg.OrganizationID)); err != nil { + return false, err + } + return q.db.HasTemplateVersionsUsingCachedModuleFileInOrg(ctx, arg) +} + func (q *querier) InsertAIBridgeInterception(ctx context.Context, arg database.InsertAIBridgeInterceptionParams) (database.AIBridgeInterception, error) { return insert(q.log, q.auth, rbac.ResourceAibridgeInterception.WithOwner(arg.InitiatorID.String()), q.db.InsertAIBridgeInterception)(ctx, arg) } diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 489ffd7459c..6e4f8c841dd 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -2442,6 +2442,11 @@ func (s *MethodTestSuite) TestTemplate() { dbm.EXPECT().GetTemplateVersionTerraformValues(gomock.Any(), tv.ID).Return(val, nil).AnyTimes() check.Args(tv.ID).Asserts(t, policy.ActionRead) })) + s.Run("HasTemplateVersionsUsingCachedModuleFileInOrg", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + arg := database.HasTemplateVersionsUsingCachedModuleFileInOrgParams{FileID: uuid.New(), OrganizationID: uuid.New()} + dbm.EXPECT().HasTemplateVersionsUsingCachedModuleFileInOrg(gomock.Any(), arg).Return(true, nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceFile.InOrg(arg.OrganizationID), policy.ActionRead).Returns(true) + })) s.Run("GetTemplateVersionVariables", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { t1 := testutil.Fake(s.T(), faker, database.Template{}) tv := testutil.Fake(s.T(), faker, database.TemplateVersion{TemplateID: uuid.NullUUID{UUID: t1.ID, Valid: true}}) diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 6613ece7f0c..ba4cbb220c1 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -3682,6 +3682,14 @@ func (m queryMetricsStore) GetWorkspacesForWorkspaceMetrics(ctx context.Context) return r0, r1 } +func (m queryMetricsStore) HasTemplateVersionsUsingCachedModuleFileInOrg(ctx context.Context, arg database.HasTemplateVersionsUsingCachedModuleFileInOrgParams) (bool, error) { + start := time.Now() + r0, r1 := m.s.HasTemplateVersionsUsingCachedModuleFileInOrg(ctx, arg) + m.queryLatencies.WithLabelValues("HasTemplateVersionsUsingCachedModuleFileInOrg").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "HasTemplateVersionsUsingCachedModuleFileInOrg").Inc() + return r0, r1 +} + func (m queryMetricsStore) InsertAIBridgeInterception(ctx context.Context, arg database.InsertAIBridgeInterceptionParams) (database.AIBridgeInterception, error) { start := time.Now() r0, r1 := m.s.InsertAIBridgeInterception(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 7a4c5da40dc..72340abacf6 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -6901,6 +6901,21 @@ func (mr *MockStoreMockRecorder) GetWorkspacesForWorkspaceMetrics(ctx any) *gomo return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWorkspacesForWorkspaceMetrics", reflect.TypeOf((*MockStore)(nil).GetWorkspacesForWorkspaceMetrics), ctx) } +// HasTemplateVersionsUsingCachedModuleFileInOrg mocks base method. +func (m *MockStore) HasTemplateVersionsUsingCachedModuleFileInOrg(ctx context.Context, arg database.HasTemplateVersionsUsingCachedModuleFileInOrgParams) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "HasTemplateVersionsUsingCachedModuleFileInOrg", ctx, arg) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// HasTemplateVersionsUsingCachedModuleFileInOrg indicates an expected call of HasTemplateVersionsUsingCachedModuleFileInOrg. +func (mr *MockStoreMockRecorder) HasTemplateVersionsUsingCachedModuleFileInOrg(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HasTemplateVersionsUsingCachedModuleFileInOrg", reflect.TypeOf((*MockStore)(nil).HasTemplateVersionsUsingCachedModuleFileInOrg), ctx, arg) +} + // InTx mocks base method. func (m *MockStore) InTx(arg0 func(database.Store) error, arg1 *database.TxOptions) error { m.ctrl.T.Helper() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 5cffdb76bda..3235daa91db 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -918,6 +918,11 @@ type sqlcQuerier interface { GetWorkspacesByTemplateID(ctx context.Context, templateID uuid.UUID) ([]WorkspaceTable, error) GetWorkspacesEligibleForTransition(ctx context.Context, now time.Time) ([]GetWorkspacesEligibleForTransitionRow, error) GetWorkspacesForWorkspaceMetrics(ctx context.Context) ([]GetWorkspacesForWorkspaceMetricsRow, error) + // Reports whether the given file is referenced as cached module files by any + // template version in the given organization. Used to authorize provisioner + // module-file downloads so a daemon cannot read another organization's cached + // Terraform module source. + HasTemplateVersionsUsingCachedModuleFileInOrg(ctx context.Context, arg HasTemplateVersionsUsingCachedModuleFileInOrgParams) (bool, error) InsertAIBridgeInterception(ctx context.Context, arg InsertAIBridgeInterceptionParams) (AIBridgeInterception, error) InsertAIBridgeModelThought(ctx context.Context, arg InsertAIBridgeModelThoughtParams) (AIBridgeModelThought, error) InsertAIBridgeTokenUsage(ctx context.Context, arg InsertAIBridgeTokenUsageParams) (AIBridgeTokenUsage, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index f6fe41c0287..c9103e4880d 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -26040,6 +26040,33 @@ func (q *sqlQuerier) GetTemplateVersionTerraformValues(ctx context.Context, temp return i, err } +const hasTemplateVersionsUsingCachedModuleFileInOrg = `-- name: HasTemplateVersionsUsingCachedModuleFileInOrg :one +SELECT EXISTS ( + SELECT 1 + FROM template_version_terraform_values tvtv + JOIN template_versions tv + ON tv.id = tvtv.template_version_id + WHERE tvtv.cached_module_files = $1::uuid + AND tv.organization_id = $2::uuid +) +` + +type HasTemplateVersionsUsingCachedModuleFileInOrgParams struct { + FileID uuid.UUID `db:"file_id" json:"file_id"` + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` +} + +// Reports whether the given file is referenced as cached module files by any +// template version in the given organization. Used to authorize provisioner +// module-file downloads so a daemon cannot read another organization's cached +// Terraform module source. +func (q *sqlQuerier) HasTemplateVersionsUsingCachedModuleFileInOrg(ctx context.Context, arg HasTemplateVersionsUsingCachedModuleFileInOrgParams) (bool, error) { + row := q.db.QueryRowContext(ctx, hasTemplateVersionsUsingCachedModuleFileInOrg, arg.FileID, arg.OrganizationID) + var exists bool + err := row.Scan(&exists) + return exists, err +} + const insertTemplateVersionTerraformValuesByJobID = `-- name: InsertTemplateVersionTerraformValuesByJobID :exec INSERT INTO template_version_terraform_values ( diff --git a/coderd/database/queries/templateversionterraformvalues.sql b/coderd/database/queries/templateversionterraformvalues.sql index 2ded4a26753..97b20a5103c 100644 --- a/coderd/database/queries/templateversionterraformvalues.sql +++ b/coderd/database/queries/templateversionterraformvalues.sql @@ -23,3 +23,17 @@ VALUES @updated_at, @provisionerd_version ); + +-- name: HasTemplateVersionsUsingCachedModuleFileInOrg :one +-- Reports whether the given file is referenced as cached module files by any +-- template version in the given organization. Used to authorize provisioner +-- module-file downloads so a daemon cannot read another organization's cached +-- Terraform module source. +SELECT EXISTS ( + SELECT 1 + FROM template_version_terraform_values tvtv + JOIN template_versions tv + ON tv.id = tvtv.template_version_id + WHERE tvtv.cached_module_files = @file_id::uuid + AND tv.organization_id = @organization_id::uuid +); diff --git a/coderd/provisionerdserver/provisionerdserver.go b/coderd/provisionerdserver/provisionerdserver.go index d233cb41dd9..9fb25812600 100644 --- a/coderd/provisionerdserver/provisionerdserver.go +++ b/coderd/provisionerdserver/provisionerdserver.go @@ -1584,6 +1584,26 @@ func (s *server) DownloadFile(request *proto.FileRequest, stream proto.DRPCProvi if file.CreatedBy != uuid.Nil || file.Mimetype != tarMimeType { return fail(xerrors.Errorf("file %s is not a modules file", fid)) } + // Ensure the requested module file belongs to a template version in + // this provisioner daemon's organization. Without this, any + // authenticated provisioner could download cached module archives + // (Terraform source) belonging to other organizations (ANT-2026-22440). + ok, err := s.Database.HasTemplateVersionsUsingCachedModuleFileInOrg(ctx, database.HasTemplateVersionsUsingCachedModuleFileInOrgParams{ + FileID: fid, + OrganizationID: s.OrganizationID, + }) + if err != nil { + return fail(xerrors.Errorf("authorize module file: %w", err)) + } + if !ok { + s.Logger.Warn(ctx, "module file download rejected: file not referenced by any template version in daemon org", + slog.F("file_id", fid), + slog.F("organization_id", s.OrganizationID), + ) + // Use the same error as the metadata check above so the handler + // does not confirm the existence of files in other organizations. + return fail(xerrors.Errorf("file %s is not a modules file", fid)) + } default: return fail(xerrors.Errorf("unsupported file upload type: %s", request.UploadType)) } diff --git a/coderd/provisionerdserver/provisionerdserver_test.go b/coderd/provisionerdserver/provisionerdserver_test.go index 0e7f175c67d..7b59c46a54a 100644 --- a/coderd/provisionerdserver/provisionerdserver_test.go +++ b/coderd/provisionerdserver/provisionerdserver_test.go @@ -2,6 +2,7 @@ package provisionerdserver_test import ( "context" + crand "crypto/rand" "database/sql" "encoding/json" "io" @@ -25,6 +26,8 @@ import ( "golang.org/x/xerrors" "google.golang.org/protobuf/types/known/timestamppb" "storj.io/drpc" + "storj.io/drpc/drpcmux" + "storj.io/drpc/drpcserver" "cdr.dev/slog/v3" "cdr.dev/slog/v3/sloggers/slogtest" @@ -52,6 +55,7 @@ import ( "github.com/coder/coder/v2/coderd/usage/usagetypes" "github.com/coder/coder/v2/coderd/wspubsub" "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/drpcsdk" "github.com/coder/coder/v2/provisionerd/proto" "github.com/coder/coder/v2/provisionersdk" sdkproto "github.com/coder/coder/v2/provisionersdk/proto" @@ -5420,3 +5424,141 @@ func newFakeUsageInserter() (*coderdtest.UsageInserter, *atomic.Pointer[usage.In poitr.Store(&inserter) return fake, poitr } + +// serveProvisionerDaemon serves the provisioner daemon server over an +// in-memory pipe and returns a connected client, mirroring how coderd serves +// in-memory provisioner daemons. This exercises the real DRPC streaming path +// instead of a hand-rolled mock stream. +func serveProvisionerDaemon(t *testing.T, srv proto.DRPCProvisionerDaemonServer) proto.DRPCProvisionerDaemonClient { + t.Helper() + clientPipe, serverPipe := drpcsdk.MemTransportPipe() + t.Cleanup(func() { + _ = clientPipe.Close() + _ = serverPipe.Close() + }) + mux := drpcmux.New() + require.NoError(t, proto.DRPCRegisterProvisionerDaemon(mux, srv)) + server := drpcserver.NewWithOptions(mux, drpcserver.Options{ + Manager: drpcsdk.DefaultDRPCOptions(nil), + }) + ctx, cancel := context.WithCancel(context.Background()) + closed := make(chan struct{}) + go func() { + defer close(closed) + _ = server.Serve(ctx, serverPipe) + }() + t.Cleanup(func() { + cancel() + <-closed + }) + return proto.NewDRPCProvisionerDaemonClient(clientPipe) +} + +// insertModuleFile inserts a system-created (CreatedBy=uuid.Nil) tar file and +// links it as the cached module files of a template version in the given +// organization, returning the file. +func insertModuleFile(t *testing.T, db database.Store, orgID uuid.UUID, data []byte) database.File { + t.Helper() + ctx := testutil.Context(t, testutil.WaitShort) + + user := dbgen.User(t, db, database.User{}) + template := dbgen.Template(t, db, database.Template{ + OrganizationID: orgID, + CreatedBy: user.ID, + }) + jobID := uuid.New() + version := dbgen.TemplateVersion(t, db, database.TemplateVersion{ + OrganizationID: orgID, + CreatedBy: user.ID, + TemplateID: uuid.NullUUID{UUID: template.ID, Valid: true}, + JobID: jobID, + }) + // Insert the file directly rather than via dbgen.File: the helper treats a + // zero CreatedBy as "unset" and replaces it with a random UUID, but module + // files must be system-created (CreatedBy=uuid.Nil) to match the handler's + // metadata check. + file, err := db.InsertFile(ctx, database.InsertFileParams{ + ID: uuid.New(), + Hash: uuid.NewString(), + CreatedAt: dbtime.Now(), + CreatedBy: uuid.Nil, + Mimetype: "application/x-tar", + Data: data, + }) + require.NoError(t, err) + err = db.InsertTemplateVersionTerraformValuesByJobID(ctx, database.InsertTemplateVersionTerraformValuesByJobIDParams{ + JobID: version.JobID, + CachedPlan: []byte("{}"), + CachedModuleFiles: uuid.NullUUID{UUID: file.ID, Valid: true}, + UpdatedAt: dbtime.Now(), + }) + require.NoError(t, err) + return file +} + +// TestDownloadFile verifies that a provisioner daemon cannot download cached +// module archives belonging to other organizations (ANT-2026-22440), while +// still being able to download module files from its own organization. +func TestDownloadFile(t *testing.T) { + t.Parallel() + + t.Run("RejectsOtherOrgModuleFile", func(t *testing.T) { + t.Parallel() + + // The server is scoped to the default organization (org A). + srv, db, _, daemon := setup(t, false, &overrides{ + externalAuthConfigs: []*externalauth.Config{{}}, + }) + ctx := testutil.Context(t, testutil.WaitMedium) + client := serveProvisionerDaemon(t, srv) + + // Create a module file belonging to a different organization (org B). + otherOrg := dbgen.Organization(t, db, database.Organization{}) + require.NotEqual(t, daemon.OrganizationID, otherOrg.ID) + + moduleData := make([]byte, sdkproto.ChunkSize*2) + // crand.Read never returns an error as of Go 1.24. + _, _ = crand.Read(moduleData) + file := insertModuleFile(t, db, otherOrg.ID, moduleData) + + stream, err := client.DownloadFile(ctx, &proto.FileRequest{ + FileId: file.ID.String(), + UploadType: sdkproto.DataUploadType_UPLOAD_TYPE_MODULE_FILES, + }) + require.NoError(t, err) + + // The handler must reject the cross-org download with an error rather + // than streaming the file's contents. + _, err = provisionersdk.HandleReceivingDataUpload(stream) + require.Error(t, err) + require.ErrorContains(t, err, "is not a modules file") + }) + + t.Run("AllowsSameOrgModuleFile", func(t *testing.T) { + t.Parallel() + + // The server is scoped to the default organization (org A). + srv, db, _, daemon := setup(t, false, &overrides{ + externalAuthConfigs: []*externalauth.Config{{}}, + }) + ctx := testutil.Context(t, testutil.WaitMedium) + client := serveProvisionerDaemon(t, srv) + + moduleData := make([]byte, sdkproto.ChunkSize*2+512) + // crand.Read never returns an error as of Go 1.24. + _, _ = crand.Read(moduleData) + file := insertModuleFile(t, db, daemon.OrganizationID, moduleData) + + stream, err := client.DownloadFile(ctx, &proto.FileRequest{ + FileId: file.ID.String(), + UploadType: sdkproto.DataUploadType_UPLOAD_TYPE_MODULE_FILES, + }) + require.NoError(t, err) + + builder, err := provisionersdk.HandleReceivingDataUpload(stream) + require.NoError(t, err) + data, err := builder.Complete() + require.NoError(t, err) + require.Equal(t, moduleData, data) + }) +} From 877fbeb17f0371bf81ca42d77b8576968893de11 Mon Sep 17 00:00:00 2001 From: Denis Afonso Date: Mon, 10 Aug 2026 07:46:11 +0100 Subject: [PATCH 108/138] fix: update google.golang.org/grpc to v1.82.1 (#27928) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Updates `google.golang.org/grpc` from `v1.81.1` → `v1.82.1` on `release/2.34`. ## Why `google.golang.org/grpc v1.81.1` is vulnerable to **GHSA-hrxh-6v49-42gf** (GO-2026-6061) — the relevant exposure is the HTTP/2 Rapid Reset-style DoS (the xDS RBAC auth-bypass half of the advisory isn't used by Coder). Flagged by the IronBank scan of Coder v2.35.3; `release/2.34` ships the same vulnerable version. Fixed in **v1.82.1** (matches `v2.36.0`; `main` is on v1.83.0). Companion to #27925 (the `release/2.35` backport). ## Change - `go.mod`: `google.golang.org/grpc v1.81.1` → `v1.82.1` (direct), plus the minimal indirect bumps grpc 1.82.1 requires on this branch: - `google.golang.org/genproto/googleapis/api` 20260319 → 20260414 - `GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp` v1.31.0 → v1.32.0 - `go.opentelemetry.io/contrib/detectors/gcp` v1.42.0 → v1.43.0 - `go.sum`: corresponding hashes (These three indirect bumps weren't needed on the 2.35 backport because that branch already had newer pins; on 2.34 they're pulled up by grpc 1.82.1's minimum-version requirements. All `// indirect`, all `go mod verify`-clean.) ## Validation (local; `golangci-lint` at the CI-pinned v1.64.8) - `go build ./...` — clean - `go vet ./tailnet/... ./codersdk/...` — clean - `golangci-lint run` (full repo) — 0 issues - `go run ./scripts/intxcheck ./...` — clean - `go mod tidy` stable; `go mod verify` OK DB-backed suites (`make test-postgres`) run in CI. Linear: ENT-144 --- go.mod | 8 ++++---- go.sum | 16 ++++++++-------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/go.mod b/go.mod index 3b857381b29..a5ff12aebde 100644 --- a/go.mod +++ b/go.mod @@ -240,7 +240,7 @@ require ( golang.org/x/tools v0.45.0 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da google.golang.org/api v0.280.0 - google.golang.org/grpc v1.81.1 + google.golang.org/grpc v1.82.1 google.golang.org/protobuf v1.36.11 gopkg.in/DataDog/dd-trace-go.v1 v1.74.0 gopkg.in/natefinch/lumberjack.v2 v2.2.1 @@ -478,7 +478,7 @@ require ( golang.zx2c4.com/wireguard/windows v0.5.3 // indirect google.golang.org/appengine v1.6.8 // indirect google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60 // indirect gopkg.in/ini.v1 v1.67.1 // indirect howett.net/plist v1.0.1 // indirect @@ -541,7 +541,7 @@ require ( github.com/DataDog/datadog-agent/comp/core/tagger/origindetection v0.64.2 // indirect github.com/DataDog/datadog-agent/pkg/version v0.64.2 // indirect github.com/DataDog/dd-trace-go/v2 v2.0.0 // indirect - github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 // indirect github.com/Masterminds/semver/v3 v3.4.0 // indirect @@ -640,7 +640,7 @@ require ( github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/zeebo/xxh3 v1.0.2 // indirect - go.opentelemetry.io/contrib/detectors/gcp v1.42.0 // indirect + go.opentelemetry.io/contrib/detectors/gcp v1.43.0 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 // indirect go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect diff --git a/go.sum b/go.sum index 95741dbcf8d..af26afa9a5c 100644 --- a/go.sum +++ b/go.sum @@ -81,8 +81,8 @@ github.com/DataDog/opentelemetry-mapping-go/pkg/otlp/attributes v0.26.0 h1:GlvoS github.com/DataDog/opentelemetry-mapping-go/pkg/otlp/attributes v0.26.0/go.mod h1:mYQmU7mbHH6DrCaS8N6GZcxwPoeNfyuopUoLQltwSzs= github.com/DataDog/sketches-go v1.4.7 h1:eHs5/0i2Sdf20Zkj0udVFWuCrXGRFig2Dcfm5rtcTxc= github.com/DataDog/sketches-go v1.4.7/go.mod h1:eAmQ/EBmtSO+nQp7IZMZVRPT4BQTmIc5RZQ+deGlTPM= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 h1:DHa2U07rk8syqvCge0QIGMCE1WxGj9njT44GH7zNJLQ= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0 h1:rIkQfkCOVKc1OiRCNcSDD8ml5RJlZbH/Xsq7lbpynwc= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0/go.mod h1:RD2SsorTmYhF6HkTmDw7KmPYQk8OBYwTkuasChwv7R4= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 h1:UnDZ/zFfG1JhH/DqxIZYU/1CUAlTUScoXD/LcM2Ykk8= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0/go.mod h1:IA1C1U7jO/ENqm/vhi7V9YYpBsp+IMyqNrEN94N7tVc= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0 h1:7t/qx5Ost0s0wbA/VDrByOooURhp+ikYwv20i9Y07TQ= @@ -1315,8 +1315,8 @@ go.opentelemetry.io/collector/semconv v0.123.0/go.mod h1:te6VQ4zZJO5Lp8dM2XIhDxD go.opentelemetry.io/contrib v1.0.0/go.mod h1:EH4yDYeNoaTqn/8yCWQmfNB78VHfGX2Jt2bvnvzBlGM= go.opentelemetry.io/contrib v1.19.0 h1:rnYI7OEPMWFeM4QCqWQ3InMJ0arWMR1i0Cx9A5hcjYM= go.opentelemetry.io/contrib v1.19.0/go.mod h1:gIzjwWFoGazJmtCaDgViqOSJPde2mCWzv60o0bWPcZs= -go.opentelemetry.io/contrib/detectors/gcp v1.42.0 h1:kpt2PEJuOuqYkPcktfJqWWDjTEd/FNgrxcniL7kQrXQ= -go.opentelemetry.io/contrib/detectors/gcp v1.42.0/go.mod h1:W9zQ439utxymRrXsUOzZbFX4JhLxXU4+ZnCt8GG7yA8= +go.opentelemetry.io/contrib/detectors/gcp v1.43.0 h1:62yY3dT7/ShwOxzA0RsKRgshBmfElKI4d/Myu2OxDFU= +go.opentelemetry.io/contrib/detectors/gcp v1.43.0/go.mod h1:RyaZMFY7yi1kAs45S6mbFGz8O8rqB0dTY14uzvG4LCs= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY= @@ -1536,12 +1536,12 @@ google.golang.org/genai v1.51.0 h1:IZGuUqgfx40INv3hLFGCbOSGp0qFqm7LVmDghzNIYqg= google.golang.org/genai v1.51.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk= google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I= -google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 h1:41r6JMbpzBMen0R/4TZeeAmGXSJC7DftGINUodzTkPI= -google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y= +google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 h1:yQugLulqltosq0B/f8l4w9VryjV+N/5gcW0jQ3N8Qec= +google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478/go.mod h1:C6ADNqOxbgdUUeRTU+LCHDPB9ttAMCTff6auwCVa4uc= google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60 h1:seT2EwLWM78plQ7wcDfuWBc/4FAEAXDDiaSol4ku4qo= google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= -google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= +google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= From 7bd778d8815624fd2b598d9432092162437c06b3 Mon Sep 17 00:00:00 2001 From: Denis Afonso Date: Mon, 10 Aug 2026 08:14:15 +0100 Subject: [PATCH 109/138] fix: update github.com/DataDog/dd-trace-go/v2 to v2.8.1 (#27940) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Updates `github.com/DataDog/dd-trace-go/v2` from `v2.0.0` → `v2.8.1` (indirect) on `release/2.34`. ## Why `github.com/DataDog/dd-trace-go/v2 v2.0.0` is vulnerable to **CVE-2026-50274**. It is an indirect dependency pulled in by the direct `gopkg.in/DataDog/dd-trace-go.v1 v1.74.0` shim that `coderd/tracing` uses. Flagged by the IronBank scan of Coder v2.35.3; `release/2.34` ships the same vulnerable version. Fixed in **v2.8.1** (matches `v2.36.0`/`main`). Companion to #27938 (the `release/2.35` backport). ## Change `go get github.com/DataDog/dd-trace-go/v2@v2.8.1 && go mod tidy`. This reproduces the DataDog dependency cluster as shipped on `v2.36.0`. Notable transitive effects: - **`go-libddwaf/v3` → `/v4`** (major-version module swap; v3 removed) - `datadog-agent/pkg/*` 0.64.2 → 0.77.0 (plus new `pkg/trace/{log,otel,stats,traceutil}`, `pkg/template`, `pkg/opentelemetry-mapping-go/otlp/attributes`) - `datadog-go/v5` 5.6.0→5.8.3, and bumps to `go-sqllexer`, `sketches-go`, `go-tuf`, `go-runtime-metrics-internal` - `go.opentelemetry.io/collector/{component,pdata,pdata/pprofile,featuregate}` 1.27 → 1.51; dropped `collector/semconv`, `appsec-internal-go`, `Masterminds/semver/v3`, `eapache/queue/v2`, `gogo/protobuf` Every changed module matches `v2.36.0`'s `go.mod` with one exception: `github.com/ebitengine/purego` resolves to `v0.10.0` here vs `v0.10.1` on `v2.36.0`. It is a patch-level grandchild of `go-libddwaf/v4`; MVS selects the minimal version this branch's graph requires (nothing on `release/2.34` demands v0.10.1, whereas 2.36's grpc-bumped graph does). Build and lint are clean with it. `go.mod` +35/−30, `go.sum` +112/−100. All new/changed entries are `// indirect`. ## Validation (local; `golangci-lint` at the CI-pinned v1.64.8) - `go build ./...` — clean - `go vet ./coderd/tracing/... ./codersdk/...` — clean - `go test ./coderd/tracing/...` — ok - `golangci-lint run` (full repo) — 0 issues - `go run ./scripts/intxcheck ./...` — clean - `go mod tidy` stable; `go mod verify` OK DB-backed suites (`make test-postgres`) run in CI. Linear: ENT-141 Co-authored-by: Marcin Tojek --- go.mod | 65 ++++++++++-------- go.sum | 212 ++++++++++++++++++++++++++++++--------------------------- 2 files changed, 147 insertions(+), 130 deletions(-) diff --git a/go.mod b/go.mod index a5ff12aebde..040ebefafb6 100644 --- a/go.mod +++ b/go.mod @@ -204,7 +204,7 @@ require ( github.com/prometheus/common v0.67.5 github.com/quasilyte/go-ruleguard/dsl v0.3.23 github.com/robfig/cron/v3 v3.0.1 - github.com/shirou/gopsutil/v4 v4.26.1 + github.com/shirou/gopsutil/v4 v4.26.2 github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 github.com/spf13/afero v1.15.0 github.com/spf13/pflag v1.0.10 @@ -257,21 +257,18 @@ require ( dario.cat/mergo v1.0.2 // indirect filippo.io/edwards25519 v1.1.1 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect - github.com/DataDog/appsec-internal-go v1.11.2 // indirect - github.com/DataDog/datadog-agent/pkg/obfuscate v0.64.2 // indirect - github.com/DataDog/datadog-agent/pkg/proto v0.64.2 // indirect - github.com/DataDog/datadog-agent/pkg/remoteconfig/state v0.64.2 // indirect - github.com/DataDog/datadog-agent/pkg/trace v0.64.2 // indirect - github.com/DataDog/datadog-agent/pkg/util/log v0.64.2 // indirect - github.com/DataDog/datadog-agent/pkg/util/scrubber v0.64.2 // indirect - github.com/DataDog/datadog-go/v5 v5.6.0 // indirect - github.com/DataDog/go-libddwaf/v3 v3.5.4 // indirect - github.com/DataDog/go-runtime-metrics-internal v0.0.4-0.20250319104955-81009b9bad14 // indirect - github.com/DataDog/go-sqllexer v0.1.3 // indirect - github.com/DataDog/go-tuf v1.1.0-0.5.2 // indirect + github.com/DataDog/datadog-agent/pkg/obfuscate v0.77.0 // indirect + github.com/DataDog/datadog-agent/pkg/proto v0.77.0 // indirect + github.com/DataDog/datadog-agent/pkg/remoteconfig/state v0.77.0 // indirect + github.com/DataDog/datadog-agent/pkg/trace v0.77.0 // indirect + github.com/DataDog/datadog-agent/pkg/util/log v0.77.0 // indirect + github.com/DataDog/datadog-agent/pkg/util/scrubber v0.77.0 // indirect + github.com/DataDog/datadog-go/v5 v5.8.3 // indirect + github.com/DataDog/go-runtime-metrics-internal v0.0.4-0.20260217080614-b0f4edc38a6d // indirect + github.com/DataDog/go-sqllexer v0.1.13 // indirect + github.com/DataDog/go-tuf v1.1.1-0.5.2 // indirect github.com/DataDog/gostackparse v0.7.0 // indirect - github.com/DataDog/opentelemetry-mapping-go/pkg/otlp/attributes v0.26.0 // indirect - github.com/DataDog/sketches-go v1.4.7 // indirect + github.com/DataDog/sketches-go v1.4.8 // indirect github.com/KyleBanks/depth v1.2.1 // indirect github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 // indirect github.com/ProtonMail/go-crypto v1.4.1 // indirect @@ -319,8 +316,7 @@ require ( github.com/docker/go-units v0.5.0 // indirect github.com/dop251/goja v0.0.0-20241024094426-79f3a7efcdbd // indirect github.com/dustin/go-humanize v1.0.1 - github.com/eapache/queue/v2 v2.0.0-20230407133247-75960ed334e4 // indirect - github.com/ebitengine/purego v0.10.0-alpha.5 // indirect + github.com/ebitengine/purego v0.10.0 // indirect github.com/elastic/go-windows v1.0.0 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/felixge/httpsnoop v1.0.4 // indirect @@ -342,7 +338,6 @@ require ( github.com/gobwas/pool v0.2.1 // indirect github.com/gobwas/ws v1.4.0 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect - github.com/gogo/protobuf v1.3.2 // indirect github.com/gohugoio/hashstructure v0.6.0 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/golang/protobuf v1.5.4 // indirect @@ -380,7 +375,7 @@ require ( github.com/kylelemons/godebug v1.1.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/lucasb-eyer/go-colorful v1.4.0 // indirect - github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect + github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88 // indirect github.com/mailru/easyjson v0.9.1 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-localereader v0.0.1 // indirect @@ -388,7 +383,7 @@ require ( github.com/mdlayher/genetlink v1.3.2 // indirect github.com/mdlayher/netlink v1.7.2 // indirect github.com/mdlayher/sdnotify v1.0.0 // indirect - github.com/mdlayher/socket v0.5.0 // indirect + github.com/mdlayher/socket v0.5.1 // indirect github.com/microcosm-cc/bluemonday v1.0.27 github.com/miekg/dns v1.1.72 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect @@ -410,7 +405,7 @@ require ( github.com/opencontainers/runc v1.2.8 // indirect github.com/outcaste-io/ristretto v0.2.3 // indirect github.com/pelletier/go-toml/v2 v2.3.1 // indirect - github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect + github.com/philhofer/fwd v1.2.0 // indirect github.com/pierrec/lz4/v4 v4.1.18 // indirect github.com/pion/transport/v2 v2.2.10 // indirect github.com/pion/transport/v3 v3.0.7 // indirect @@ -440,7 +435,7 @@ require ( github.com/tdewolff/parse/v2 v2.8.12 // indirect github.com/tidwall/match v1.2.0 // indirect github.com/tidwall/pretty v1.2.1 - github.com/tinylib/msgp v1.2.5 // indirect + github.com/tinylib/msgp v1.6.3 // indirect github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/tklauser/numcpus v0.11.0 // indirect github.com/u-root/uio v0.0.0-20240209044354-b3d14b93376a // indirect @@ -461,10 +456,9 @@ require ( github.com/zclconf/go-cty v1.18.1 github.com/zeebo/errs v1.4.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/collector/component v1.27.0 // indirect - go.opentelemetry.io/collector/pdata v1.27.0 // indirect - go.opentelemetry.io/collector/pdata/pprofile v0.121.0 // indirect - go.opentelemetry.io/collector/semconv v0.123.0 // indirect + go.opentelemetry.io/collector/component v1.51.1-0.20260205185216-81bc641f26c0 // indirect + go.opentelemetry.io/collector/pdata v1.51.1-0.20260205185216-81bc641f26c0 // indirect + go.opentelemetry.io/collector/pdata/pprofile v0.145.1-0.20260205185216-81bc641f26c0 // indirect go.opentelemetry.io/contrib v1.19.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 go.opentelemetry.io/otel/metric v1.43.0 // indirect @@ -491,12 +485,24 @@ require github.com/coder/clistat v1.2.1 require github.com/SherClockHolmes/webpush-go v1.4.0 require ( + github.com/DataDog/datadog-agent/pkg/opentelemetry-mapping-go/otlp/attributes v0.77.0 // indirect + github.com/DataDog/datadog-agent/pkg/template v0.77.0 // indirect + github.com/DataDog/datadog-agent/pkg/trace/log v0.77.0 // indirect + github.com/DataDog/datadog-agent/pkg/trace/otel v0.77.0 // indirect + github.com/DataDog/datadog-agent/pkg/trace/stats v0.77.0 // indirect + github.com/DataDog/datadog-agent/pkg/trace/traceutil v0.77.0 // indirect + github.com/DataDog/go-libddwaf/v4 v4.9.0 // indirect github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 // indirect github.com/charmbracelet/colorprofile v0.4.1 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e // indirect github.com/golang-jwt/jwt/v5 v5.3.0 // indirect + github.com/linkdata/deadlock v0.5.5 // indirect + github.com/minio/simdjson-go v0.4.5 // indirect + github.com/petermattis/goid v0.0.0-20260226131333-17d1149c6ac6 // indirect + github.com/trailofbits/go-mutexasserts v0.0.0-20250514102930-c1f3d2e37561 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + go.opentelemetry.io/collector/featuregate v1.51.1-0.20260205185216-81bc641f26c0 // indirect ) require ( @@ -538,13 +544,12 @@ require ( git.sr.ht/~jackmordaunt/go-toast v1.1.2 // indirect github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect - github.com/DataDog/datadog-agent/comp/core/tagger/origindetection v0.64.2 // indirect - github.com/DataDog/datadog-agent/pkg/version v0.64.2 // indirect - github.com/DataDog/dd-trace-go/v2 v2.0.0 // indirect + github.com/DataDog/datadog-agent/comp/core/tagger/origindetection v0.77.0 // indirect + github.com/DataDog/datadog-agent/pkg/version v0.77.0 // indirect + github.com/DataDog/dd-trace-go/v2 v2.8.1 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 // indirect - github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/alecthomas/chroma v0.10.0 // indirect github.com/aquasecurity/go-version v0.0.1 // indirect github.com/aquasecurity/iamgo v0.0.10 // indirect diff --git a/go.sum b/go.sum index af26afa9a5c..d0cb206516b 100644 --- a/go.sum +++ b/go.sum @@ -45,42 +45,50 @@ github.com/BurntSushi/locker v0.0.0-20171006230638-a6e239ea1c69 h1:+tu3HOoMXB7RX github.com/BurntSushi/locker v0.0.0-20171006230638-a6e239ea1c69/go.mod h1:L1AbZdiDllfyYH5l5OkAaZtk7VkWe89bPJFmnDBNHxg= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= -github.com/DataDog/appsec-internal-go v1.11.2 h1:Q00pPMQzqMIw7jT2ObaORIxBzSly+deS0Ely9OZ/Bj0= -github.com/DataDog/appsec-internal-go v1.11.2/go.mod h1:9YppRCpElfGX+emXOKruShFYsdPq7WEPq/Fen4tYYpk= -github.com/DataDog/datadog-agent/comp/core/tagger/origindetection v0.64.2 h1:wEW+nwoLKubvnLLaxMScYO+rEuHGXmvDsrSV9M3aWdU= -github.com/DataDog/datadog-agent/comp/core/tagger/origindetection v0.64.2/go.mod h1:lzCtnMSGZm/3RMk5RBRW/6IuK1TNbDXx1ttHTxN5Ykc= -github.com/DataDog/datadog-agent/pkg/obfuscate v0.64.2 h1:xyKB0aTD0S0wp17Egqr8gNUL8btuaKC2WK08NT0pCFQ= -github.com/DataDog/datadog-agent/pkg/obfuscate v0.64.2/go.mod h1:izbemZjqzBn9upkZj8SyT9igSGPMALaQYgswJ0408vY= -github.com/DataDog/datadog-agent/pkg/proto v0.64.2 h1:JGnb24mKLi+wEJg/bo5FPf1wli3ca2+owIkACl4mwl4= -github.com/DataDog/datadog-agent/pkg/proto v0.64.2/go.mod h1:q324yHcBN5hIeCU8eoinM7lP9c7MOA2FTj7oeWAl3Pc= -github.com/DataDog/datadog-agent/pkg/remoteconfig/state v0.64.2 h1:bCRz9YBvQTJNeE+eAPLEcuz4p/2aStxAO9lgf1HsivI= -github.com/DataDog/datadog-agent/pkg/remoteconfig/state v0.64.2/go.mod h1:1AAhFoEuoXs8jfpj7EiGW6lsqvCYgQc0B0pRpYAPEW4= -github.com/DataDog/datadog-agent/pkg/trace v0.64.2 h1:vuwxRGRVnlFYFUoSK5ZV0sHqskJwxknP5/lV+WfkSSw= -github.com/DataDog/datadog-agent/pkg/trace v0.64.2/go.mod h1:e0wLYMuXKwS/yorq1FqTDGR9WFj9RzwCMwUrli7mCAw= -github.com/DataDog/datadog-agent/pkg/util/log v0.64.2 h1:Sx+L6L2h/HN4UZwAFQMYt4eHkaLHe6THj6GUADLgkm0= -github.com/DataDog/datadog-agent/pkg/util/log v0.64.2/go.mod h1:XDJfRmc5FwFNLDFHtOKX8AW8W1N8Yk+V/wPwj98Zi6Q= -github.com/DataDog/datadog-agent/pkg/util/scrubber v0.64.2 h1:5jGvehYy2VVYJCMED3Dj6zIZds4g0O8PMf5uIMAwoAY= -github.com/DataDog/datadog-agent/pkg/util/scrubber v0.64.2/go.mod h1:uzxlZdxJ2yZZ9k+hDM4PyG3tYacoeneZuh+PVk+IVAw= -github.com/DataDog/datadog-agent/pkg/version v0.64.2 h1:clAPToUGyhFWJIfN6pBR808YigQsDP6hNcpEcu8qbtU= -github.com/DataDog/datadog-agent/pkg/version v0.64.2/go.mod h1:DgOVsfSRaNV4GZNl/qgoZjG3hJjoYUNWPPhbfTfTqtY= -github.com/DataDog/datadog-go/v5 v5.6.0 h1:2oCLxjF/4htd55piM75baflj/KoE6VYS7alEUqFvRDw= -github.com/DataDog/datadog-go/v5 v5.6.0/go.mod h1:K9kcYBlxkcPP8tvvjZZKs/m1edNAUFzBbdpTUKfCsuw= -github.com/DataDog/dd-trace-go/v2 v2.0.0 h1:cHMEzD0Wcgtu+Rec9d1GuVgpIN5f+4vCaNzuFHJ0v+Y= -github.com/DataDog/dd-trace-go/v2 v2.0.0/go.mod h1:WBtf7TA9bWr5uA8DjOyw1qlSKe3bw9gN5nc0Ta9dHFE= -github.com/DataDog/go-libddwaf/v3 v3.5.4 h1:cLV5lmGhrUBnHG50EUXdqPQAlJdVCp9n3aQ5bDWJEAg= -github.com/DataDog/go-libddwaf/v3 v3.5.4/go.mod h1:HoLUHdj0NybsPBth/UppTcg8/DKA4g+AXuk8cZ6nuoo= -github.com/DataDog/go-runtime-metrics-internal v0.0.4-0.20250319104955-81009b9bad14 h1:tc5aVw7OcMyfVmJnrY4IOeiV1RTSaBuJBqF14BXxzIo= -github.com/DataDog/go-runtime-metrics-internal v0.0.4-0.20250319104955-81009b9bad14/go.mod h1:quaQJ+wPN41xEC458FCpTwyROZm3MzmTZ8q8XOXQiPs= -github.com/DataDog/go-sqllexer v0.1.3 h1:Kl2T6QVndMEZqQSY8rkoltYP+LVNaA54N+EwAMc9N5w= -github.com/DataDog/go-sqllexer v0.1.3/go.mod h1:KwkYhpFEVIq+BfobkTC1vfqm4gTi65skV/DpDBXtexc= -github.com/DataDog/go-tuf v1.1.0-0.5.2 h1:4CagiIekonLSfL8GMHRHcHudo1fQnxELS9g4tiAupQ4= -github.com/DataDog/go-tuf v1.1.0-0.5.2/go.mod h1:zBcq6f654iVqmkk8n2Cx81E1JnNTMOAx1UEO/wZR+P0= +github.com/DataDog/datadog-agent/comp/core/tagger/origindetection v0.77.0 h1:Lu/HEo5svx/UwE7XWh8vOrEHCrVRsein9X1N0jGK5bo= +github.com/DataDog/datadog-agent/comp/core/tagger/origindetection v0.77.0/go.mod h1:+Ty3r23MjcmMSkr8JbFeqA3utgtc1wxsZ0KaQ9CzoWA= +github.com/DataDog/datadog-agent/pkg/obfuscate v0.77.0 h1:mrHaNnDAIOFAVYhCqDpkenUtbadswHN68ZlG5krv40o= +github.com/DataDog/datadog-agent/pkg/obfuscate v0.77.0/go.mod h1:E6RGAcEOr/d8wsV5/khYHvaHkijWex6dfNEvsBIgR7A= +github.com/DataDog/datadog-agent/pkg/opentelemetry-mapping-go/otlp/attributes v0.77.0 h1:g1d9d1CfG54WjXgvkysTFL9yjXexWeDbYssQaf1PG6c= +github.com/DataDog/datadog-agent/pkg/opentelemetry-mapping-go/otlp/attributes v0.77.0/go.mod h1:N/AB9VGpVwHxCcyX+7GLNYMfnTZvn65vp5cHb5Ed0ow= +github.com/DataDog/datadog-agent/pkg/proto v0.77.0 h1:21nDAKD+LdxZz2pMsLAQTZ+w9Z4JqecjKpt8xY1b7Ig= +github.com/DataDog/datadog-agent/pkg/proto v0.77.0/go.mod h1:g2QYJe1CheZdssiDQpSYWra9hORkh+S3WO8aOqDNLkg= +github.com/DataDog/datadog-agent/pkg/remoteconfig/state v0.77.0 h1:SxFGFN/Dd/uREaUTxuVTi0R7fRABzvUtu32YOXcjf6c= +github.com/DataDog/datadog-agent/pkg/remoteconfig/state v0.77.0/go.mod h1:TpW5ZwsQTrlRBPjtZH5/OFwpmOqxV/v2i9BiF4Xfcac= +github.com/DataDog/datadog-agent/pkg/template v0.77.0 h1:sUbTCoQyU9kXMc6/aDd4YTP2pe9PlNkgySM11ydMEbE= +github.com/DataDog/datadog-agent/pkg/template v0.77.0/go.mod h1:ZUjICHSlN0of0cmWrYk9Pof0DV0eqHSpTUK1NTnN26Y= +github.com/DataDog/datadog-agent/pkg/trace v0.77.0 h1:B7M6IW0sd60XnLfeP6HEneeR4lRnaNtI9bboU4R1cV0= +github.com/DataDog/datadog-agent/pkg/trace v0.77.0/go.mod h1:+7zMNPjHTDidiphECirrpq5jLK09S9kmLtGRv7di29Q= +github.com/DataDog/datadog-agent/pkg/trace/log v0.77.0 h1:2VY1byEA2XnYVg7+eLQSTgX2f76ZCf/AwpCBJDXCiDc= +github.com/DataDog/datadog-agent/pkg/trace/log v0.77.0/go.mod h1:thnxBOGfMU9uRlFUClXud6J7DdI8qWtElELSds5jqts= +github.com/DataDog/datadog-agent/pkg/trace/otel v0.77.0 h1:9zSto72E+wSETaKs47Yiq5D9du9H71IqWgbDJzGpzSs= +github.com/DataDog/datadog-agent/pkg/trace/otel v0.77.0/go.mod h1:IxBidgqUt8aBrKYq4VKynBHWYZYNoflk+0+m7w+lfbI= +github.com/DataDog/datadog-agent/pkg/trace/stats v0.77.0 h1:InA6JO5R8TFAUcKRxsdmIF1hJpVZqVO5Aqux+Iw/2V0= +github.com/DataDog/datadog-agent/pkg/trace/stats v0.77.0/go.mod h1:iZVotmInV8qaU6Q5h+tsKk4CBYupDcOgTzonzLlMi0k= +github.com/DataDog/datadog-agent/pkg/trace/traceutil v0.77.0 h1:b/2+uA/cG2xEV0LzgwnxloMFWe5sdJa3xtaQhSGN0+s= +github.com/DataDog/datadog-agent/pkg/trace/traceutil v0.77.0/go.mod h1:csT+8o3GOUjhKPs/GqWMb5Zh4iQpuZ/HZQ4Z5ls8Sak= +github.com/DataDog/datadog-agent/pkg/util/log v0.77.0 h1:YFa+8kIg2qQZca9zvowtwCPdHDhGMcTIF+PMIQsLSRs= +github.com/DataDog/datadog-agent/pkg/util/log v0.77.0/go.mod h1:DFK2U5RcB8/BcObgmVEZ4VxqXUi2t7y2svLTtJwQqeo= +github.com/DataDog/datadog-agent/pkg/util/scrubber v0.77.0 h1:dd0W9e39rv0R3DSgnaurVnQ43/jX/juqQPwLpGAJgFs= +github.com/DataDog/datadog-agent/pkg/util/scrubber v0.77.0/go.mod h1:nkhevws2pJvoXSGhjc8wuTbptNQ9ECRBjwVr4hSvoq0= +github.com/DataDog/datadog-agent/pkg/version v0.77.0 h1:fxpMWuoaRHS5vHzCNHftvJ6wdQrGhEmuozjjl8wZG5k= +github.com/DataDog/datadog-agent/pkg/version v0.77.0/go.mod h1:h9eJjfeTHlYYv+kzq6n3rQ07qXGirdCCacn1Ryu4TFQ= +github.com/DataDog/datadog-go/v5 v5.8.3 h1:s58CUJ9s8lezjhTNJO/SxkPBv2qZjS3ktpRSqGF5n0s= +github.com/DataDog/datadog-go/v5 v5.8.3/go.mod h1:K9kcYBlxkcPP8tvvjZZKs/m1edNAUFzBbdpTUKfCsuw= +github.com/DataDog/dd-trace-go/v2 v2.8.1 h1:O/lPXXcJof4hqfcBGsL6p/PiVa5xTfvYzv5iv/4S66U= +github.com/DataDog/dd-trace-go/v2 v2.8.1/go.mod h1:IVkBpsq66Cw/YIRM/Te3pl2F0M9n4zguAB2ReGczWeo= +github.com/DataDog/go-libddwaf/v4 v4.9.0 h1:a788e37iuH7sR9uIYHkulvTnp2FkXTiZ3yY/kuaHgZE= +github.com/DataDog/go-libddwaf/v4 v4.9.0/go.mod h1:/AZqP6zw3qGJK5mLrA0PkfK3UQDk1zCI2fUNCt4xftE= +github.com/DataDog/go-runtime-metrics-internal v0.0.4-0.20260217080614-b0f4edc38a6d h1:cH9Bm0tJ8FEQbA4FRi0iRm7Zr/5Lata/Or31c+Dth0E= +github.com/DataDog/go-runtime-metrics-internal v0.0.4-0.20260217080614-b0f4edc38a6d/go.mod h1:yDuvU+Ak1TKwgd4K8DNcpJmUrrK8ONLkBMGNAppmBRk= +github.com/DataDog/go-sqllexer v0.1.13 h1:HhT2G21y7SDZYQx9i1b+3Sy/CHhESHet/YKMSm06XcE= +github.com/DataDog/go-sqllexer v0.1.13/go.mod h1:vOw7Ia7z+z6nl3zGZlLIZe0vQlPtCPR906WIPBJadxc= +github.com/DataDog/go-tuf v1.1.1-0.5.2 h1:YWvghV4ZvrQsPcUw8IOUMSDpqc3W5ruOIC+KJxPknv0= +github.com/DataDog/go-tuf v1.1.1-0.5.2/go.mod h1:zBcq6f654iVqmkk8n2Cx81E1JnNTMOAx1UEO/wZR+P0= github.com/DataDog/gostackparse v0.7.0 h1:i7dLkXHvYzHV308hnkvVGDL3BR4FWl7IsXNPz/IGQh4= github.com/DataDog/gostackparse v0.7.0/go.mod h1:lTfqcJKqS9KnXQGnyQMCugq3u1FP6UZMfWR0aitKFMM= -github.com/DataDog/opentelemetry-mapping-go/pkg/otlp/attributes v0.26.0 h1:GlvoS6hJN0uANUC3fjx72rOgM4StAKYo2HtQGaasC7s= -github.com/DataDog/opentelemetry-mapping-go/pkg/otlp/attributes v0.26.0/go.mod h1:mYQmU7mbHH6DrCaS8N6GZcxwPoeNfyuopUoLQltwSzs= -github.com/DataDog/sketches-go v1.4.7 h1:eHs5/0i2Sdf20Zkj0udVFWuCrXGRFig2Dcfm5rtcTxc= -github.com/DataDog/sketches-go v1.4.7/go.mod h1:eAmQ/EBmtSO+nQp7IZMZVRPT4BQTmIc5RZQ+deGlTPM= +github.com/DataDog/sketches-go v1.4.8 h1:pFk9BNn+Rzv8IMIoPUttoOpOr3bJOqU3P6EP5wK+Lv8= +github.com/DataDog/sketches-go v1.4.8/go.mod h1:a/wjRUqzqtGS8qRHRPDCs4EAQfmvPDZGDlMIF5mxXOE= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0 h1:rIkQfkCOVKc1OiRCNcSDD8ml5RJlZbH/Xsq7lbpynwc= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0/go.mod h1:RD2SsorTmYhF6HkTmDw7KmPYQk8OBYwTkuasChwv7R4= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 h1:UnDZ/zFfG1JhH/DqxIZYU/1CUAlTUScoXD/LcM2Ykk8= @@ -95,6 +103,7 @@ github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.1 h1:IpUgup6ucCE4wB59wAP0Y2 github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.1/go.mod h1:KUwy/WLgv9kv2yeBZkPCgDokHzg0M6EdRc17thnbVFw= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= +github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Microsoft/go-winio v0.5.0/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84= @@ -440,10 +449,8 @@ github.com/dop251/goja v0.0.0-20241024094426-79f3a7efcdbd/go.mod h1:MxLav0peU43G github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/eapache/queue/v2 v2.0.0-20230407133247-75960ed334e4 h1:8EXxF+tCLqaVk8AOC29zl2mnhQjwyLxxOTuhUazWRsg= -github.com/eapache/queue/v2 v2.0.0-20230407133247-75960ed334e4/go.mod h1:I5sHm0Y0T1u5YjlyqC5GVArM7aNZRUYtTjmJ8mPJFds= -github.com/ebitengine/purego v0.10.0-alpha.5 h1:IUIZ1pu0wnpxrn7o6utj8AeoZBS2upI11kLcddBF414= -github.com/ebitengine/purego v0.10.0-alpha.5/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= +github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/elastic/go-sysinfo v1.15.1 h1:zBmTnFEXxIQ3iwcQuk7MzaUotmKRp3OabbbWM8TdzIQ= github.com/elastic/go-sysinfo v1.15.1/go.mod h1:jPSuTgXG+dhhh0GKIyI2Cso+w5lPJ5PvVqKlL8LV/Hk= github.com/elastic/go-windows v1.0.0 h1:qLURgZFkkrYyTTkvYpsZIgf83AUsdIHfvlJaqaZ7aSY= @@ -602,8 +609,6 @@ github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0= github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA= github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= -github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= -github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/gohugoio/gift v0.2.0 h1:vA31pP0rTVmBxBrhpY3WEt+4zM4g+1sDqYeemwsYeqc= github.com/gohugoio/gift v0.2.0/go.mod h1:1Mrm5CjF33KpD749Dwj+UAjWZ3LC6cBXGuTMa5XwoP4= github.com/gohugoio/go-i18n/v2 v2.1.3-0.20251018145728-cfcc22d823c6 h1:pxlAea9eRwuAnt/zKbGqlFO2ZszpIe24YpOVLf+N+4I= @@ -798,8 +803,6 @@ github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4 github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= github.com/kirsle/configdir v0.0.0-20170128060238-e45d2f54772f h1:dKccXx7xA56UNqOcFIbuqFjAWPVtP688j5QMgmo6OHU= github.com/kirsle/configdir v0.0.0-20170128060238-e45d2f54772f/go.mod h1:4rEELDSfUAlBSyUjPG0JnaNGjf13JySHFeRdD/3dLP0= -github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE= github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= @@ -848,10 +851,12 @@ github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNB github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss= github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg= +github.com/linkdata/deadlock v0.5.5 h1:d6O+rzEqasSfamGDA8u7bjtaq7hOX8Ha4Zn36Wxrkvo= +github.com/linkdata/deadlock v0.5.5/go.mod h1:tXb28stzAD3trzEEK0UJWC+rZKuobCoPktPYzebb1u0= github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 h1:PpXWgLPs+Fqr325bN2FD2ISlRRztXibcX6e8f5FR5Dc= -github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= +github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88 h1:PTw+yKnXcOFCR6+8hHTyWBeQ/P4Nb7dd4/0ohEcWQuM= +github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8= @@ -887,12 +892,14 @@ github.com/mdlayher/netlink v1.7.2 h1:/UtM3ofJap7Vl4QWCPDGXY8d3GIY2UGSDbK+QWmY8/ github.com/mdlayher/netlink v1.7.2/go.mod h1:xraEF7uJbxLhc5fpHL4cPe221LI2bdttWlU+ZGLfQSw= github.com/mdlayher/sdnotify v1.0.0 h1:Ma9XeLVN/l0qpyx1tNeMSeTjCPH6NtuD6/N9XdTlQ3c= github.com/mdlayher/sdnotify v1.0.0/go.mod h1:HQUmpM4XgYkhDLtd+Uad8ZFK1T9D5+pNxnXQjCeJlGE= -github.com/mdlayher/socket v0.5.0 h1:ilICZmJcQz70vrWVes1MFera4jGiWNocSkykwwoy3XI= -github.com/mdlayher/socket v0.5.0/go.mod h1:WkcBFfvyG8QENs5+hfQPl1X6Jpd2yeLIYgrGFmJiJxI= +github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos= +github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= +github.com/minio/simdjson-go v0.4.5 h1:r4IQwjRGmWCQ2VeMc7fGiilu1z5du0gJ/I/FsKwgo5A= +github.com/minio/simdjson-go v0.4.5/go.mod h1:eoNz0DcLQRyEDeaPr4Ru6JpjlZPzbA0IodxVJk8lO8E= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= @@ -971,10 +978,10 @@ github.com/olekukonko/tablewriter v1.1.4 h1:ORUMI3dXbMnRlRggJX3+q7OzQFDdvgbN9nVW github.com/olekukonko/tablewriter v1.1.4/go.mod h1:+kedxuyTtgoZLwif3P1Em4hARJs+mVnzKxmsCL/C5RY= github.com/open-policy-agent/opa v1.11.0 h1:eOd/jJrbavakiX477yT4LrXZfUWViAot/AsKsjsfe7o= github.com/open-policy-agent/opa v1.11.0/go.mod h1:QimuJO4T3KYxWzrmAymqlFvsIanCjKrGjmmC8GgAdgE= -github.com/open-telemetry/opentelemetry-collector-contrib/pkg/sampling v0.120.1 h1:lK/3zr73guK9apbXTcnDnYrC0YCQ25V3CIULYz3k2xU= -github.com/open-telemetry/opentelemetry-collector-contrib/pkg/sampling v0.120.1/go.mod h1:01TvyaK8x640crO2iFwW/6CFCZgNsOvOGH3B5J239m0= -github.com/open-telemetry/opentelemetry-collector-contrib/processor/probabilisticsamplerprocessor v0.120.1 h1:TCyOus9tym82PD1VYtthLKMVMlVyRwtDI4ck4SR2+Ok= -github.com/open-telemetry/opentelemetry-collector-contrib/processor/probabilisticsamplerprocessor v0.120.1/go.mod h1:Z/S1brD5gU2Ntht/bHxBVnGxXKTvZDr0dNv/riUzPmY= +github.com/open-telemetry/opentelemetry-collector-contrib/pkg/sampling v0.145.0 h1:7rdLY2Ewa1WVnjMfJTEKwQ5uPDHYeA1tqNPNROi957U= +github.com/open-telemetry/opentelemetry-collector-contrib/pkg/sampling v0.145.0/go.mod h1:jYlQAaJO4ZyJAW2jcKAbjN+nt5BRCyu49mlZv4Rui7U= +github.com/open-telemetry/opentelemetry-collector-contrib/processor/probabilisticsamplerprocessor v0.145.0 h1:12mxn+8YLeAjMZ1kLGulBcvHrdhRNUmxLVIDnaLkJbQ= +github.com/open-telemetry/opentelemetry-collector-contrib/processor/probabilisticsamplerprocessor v0.145.0/go.mod h1:V87HYJpfmvCeQ6Cjy3Q4xylxfCn2wVSS80wvv5ECc0s= github.com/openai/openai-go v1.12.0 h1:NBQCnXzqOTv5wsgNC36PrFEiskGfO5wccfCWDo9S1U0= github.com/openai/openai-go v1.12.0/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= @@ -1001,8 +1008,11 @@ github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7ol github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= -github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c h1:dAMKvw0MlJT1GshSTtih8C2gDs04w8dReiOGXrGLNoY= -github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= +github.com/petermattis/goid v0.0.0-20250813065127-a731cc31b4fe/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= +github.com/petermattis/goid v0.0.0-20260226131333-17d1149c6ac6 h1:rh2lKw/P/EqHa724vYH2+VVQ1YnW4u6EOXl0PMAovZE= +github.com/petermattis/goid v0.0.0-20260226131333-17d1149c6ac6/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= +github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= +github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/pierrec/lz4/v4 v4.1.18 h1:xaKrnTkyoqfh1YItXl56+6KJNVYWlEEPuAQW9xsplYQ= github.com/pierrec/lz4/v4 v4.1.18/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms= @@ -1079,8 +1089,8 @@ github.com/sergeymakinen/go-ico v1.0.0-beta.0 h1:m5qKH7uPKLdrygMWxbamVn+tl2HfiA3 github.com/sergeymakinen/go-ico v1.0.0-beta.0/go.mod h1:wQ47mTczswBO5F0NoDt7O0IXgnV4Xy3ojrroMQzyhUk= github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= -github.com/shirou/gopsutil/v4 v4.26.1 h1:TOkEyriIXk2HX9d4isZJtbjXbEjf5qyKPAzbzY0JWSo= -github.com/shirou/gopsutil/v4 v4.26.1/go.mod h1:medLI9/UNAb0dOI9Q3/7yWSqKkj00u+1tgY8nvv41pc= +github.com/shirou/gopsutil/v4 v4.26.2 h1:X8i6sicvUFih4BmYIGT1m2wwgw2VG9YgrDTi7cIRGUI= +github.com/shirou/gopsutil/v4 v4.26.2/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= @@ -1176,14 +1186,16 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= -github.com/tinylib/msgp v1.2.5 h1:WeQg1whrXRFiZusidTQqzETkRpGjFjcIhW6uqWH09po= -github.com/tinylib/msgp v1.2.5/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0= +github.com/tinylib/msgp v1.6.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s= +github.com/tinylib/msgp v1.6.3/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= github.com/tmaxmax/go-sse v0.11.0 h1:nogmJM6rJUoOLoAwEKeQe5XlVpt9l7N82SS1jI7lWFg= github.com/tmaxmax/go-sse v0.11.0/go.mod h1:u/2kZQR1tyngo1lKaNCj1mJmhXGZWS1Zs5yiSOD+Eg8= +github.com/trailofbits/go-mutexasserts v0.0.0-20250514102930-c1f3d2e37561 h1:qqa3P9AtNn6RMe90l/lxd3eJWnIRxjI4eb5Rx8xqCLA= +github.com/trailofbits/go-mutexasserts v0.0.0-20250514102930-c1f3d2e37561/go.mod h1:GA3+Mq3kt3tYAfM0WZCu7ofy+GW9PuGysHfhr+6JX7s= github.com/u-root/gobusybox/src v0.0.0-20240225013946-a274a8d5d83a h1:eg5FkNoQp76ZsswyGZ+TjYqA/rhKefxK8BW7XOlQsxo= github.com/u-root/gobusybox/src v0.0.0-20240225013946-a274a8d5d83a/go.mod h1:e/8TmrdreH0sZOw2DFKBaUV7bvDWRq6SeM9PzkuVM68= github.com/u-root/u-root v0.14.0 h1:Ka4T10EEML7dQ5XDvO9c3MBN8z4nuSnGjcd1jmU2ivg= @@ -1256,8 +1268,6 @@ github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCO github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg= github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M= github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM= -github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= @@ -1284,34 +1294,40 @@ go.nhat.io/otelsql v0.16.0 h1:MUKhNSl7Vk1FGyopy04FBDimyYogpRFs0DBB9frQal0= go.nhat.io/otelsql v0.16.0/go.mod h1:YB2ocf0Q8+kK4kxzXYUOHj7P2Km8tNmE2QlRS0frUtc= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/collector/component v1.27.0 h1:6wk0K23YT9lSprX8BH9x5w8ssAORE109ekH/ix2S614= -go.opentelemetry.io/collector/component v1.27.0/go.mod h1:fIyBHoa7vDyZL3Pcidgy45cx24tBe7iHWne097blGgo= -go.opentelemetry.io/collector/component/componentstatus v0.120.0 h1:hzKjI9+AIl8A/saAARb47JqabWsge0kMp8NSPNiCNOQ= -go.opentelemetry.io/collector/component/componentstatus v0.120.0/go.mod h1:kbuAEddxvcyjGLXGmys3nckAj4jTGC0IqDIEXAOr3Ag= -go.opentelemetry.io/collector/component/componenttest v0.120.0 h1:vKX85d3lpxj/RoiFQNvmIpX9lOS80FY5svzOYUyeYX0= -go.opentelemetry.io/collector/component/componenttest v0.120.0/go.mod h1:QDLboWF2akEqAGyvje8Hc7GfXcrZvQ5FhmlWvD5SkzY= -go.opentelemetry.io/collector/consumer v1.26.0 h1:0MwuzkWFLOm13qJvwW85QkoavnGpR4ZObqCs9g1XAvk= -go.opentelemetry.io/collector/consumer v1.26.0/go.mod h1:I/ZwlWM0sbFLhbStpDOeimjtMbWpMFSoGdVmzYxLGDg= -go.opentelemetry.io/collector/consumer/consumertest v0.120.0 h1:iPFmXygDsDOjqwdQ6YZcTmpiJeQDJX+nHvrjTPsUuv4= -go.opentelemetry.io/collector/consumer/consumertest v0.120.0/go.mod h1:HeSnmPfAEBnjsRR5UY1fDTLlSrYsMsUjufg1ihgnFJ0= -go.opentelemetry.io/collector/consumer/xconsumer v0.120.0 h1:dzM/3KkFfMBIvad+NVXDV+mA+qUpHyu5c70TFOjDg68= -go.opentelemetry.io/collector/consumer/xconsumer v0.120.0/go.mod h1:eOf7RX9CYC7bTZQFg0z2GHdATpQDxI0DP36F9gsvXOQ= -go.opentelemetry.io/collector/pdata v1.27.0 h1:66yI7FYkUDia74h48Fd2/KG2Vk8DxZnGw54wRXykCEU= -go.opentelemetry.io/collector/pdata v1.27.0/go.mod h1:18e8/xDZsqyj00h/5HM5GLdJgBzzG9Ei8g9SpNoiMtI= -go.opentelemetry.io/collector/pdata/pprofile v0.121.0 h1:DFBelDRsZYxEaSoxSRtseAazsHJfqfC/Yl64uPicl2g= -go.opentelemetry.io/collector/pdata/pprofile v0.121.0/go.mod h1:j/fjrd7ybJp/PXkba92QLzx7hykUVmU8x/WJvI2JWSg= -go.opentelemetry.io/collector/pdata/testdata v0.120.0 h1:Zp0LBOv3yzv/lbWHK1oht41OZ4WNbaXb70ENqRY7HnE= -go.opentelemetry.io/collector/pdata/testdata v0.120.0/go.mod h1:PfezW5Rzd13CWwrElTZRrjRTSgMGUOOGLfHeBjj+LwY= -go.opentelemetry.io/collector/pipeline v0.123.0 h1:LDcuCrwhCTx2yROJZqhNmq2v0CFkCkUEvxvvcRW0+2c= -go.opentelemetry.io/collector/pipeline v0.123.0/go.mod h1:TO02zju/K6E+oFIOdi372Wk0MXd+Szy72zcTsFQwXl4= -go.opentelemetry.io/collector/processor v0.120.0 h1:No+I65ybBLVy4jc7CxcsfduiBrm7Z6kGfTnekW3hx1A= -go.opentelemetry.io/collector/processor v0.120.0/go.mod h1:4zaJGLZCK8XKChkwlGC/gn0Dj4Yke04gQCu4LGbJGro= -go.opentelemetry.io/collector/processor/processortest v0.120.0 h1:R+VSVSU59W0/mPAcyt8/h1d0PfWN6JI2KY5KeMICXvo= -go.opentelemetry.io/collector/processor/processortest v0.120.0/go.mod h1:me+IVxPsj4IgK99I0pgKLX34XnJtcLwqtgTuVLhhYDI= -go.opentelemetry.io/collector/processor/xprocessor v0.120.0 h1:mBznj/1MtNqmu6UpcoXz6a63tU0931oWH2pVAt2+hzo= -go.opentelemetry.io/collector/processor/xprocessor v0.120.0/go.mod h1:Nsp0sDR3gE+GAhi9d0KbN0RhOP+BK8CGjBRn8+9d/SY= -go.opentelemetry.io/collector/semconv v0.123.0 h1:hFjhLU1SSmsZ67pXVCVbIaejonkYf5XD/6u4qCQQPtc= -go.opentelemetry.io/collector/semconv v0.123.0/go.mod h1:te6VQ4zZJO5Lp8dM2XIhDxDiL45mwX0YAQQWRQ0Qr9U= +go.opentelemetry.io/collector/component v1.51.1-0.20260205185216-81bc641f26c0 h1:ZSlXxE90IY0Cl53RTqzyEgRgRPLTeTNBdGhaTmvj9eY= +go.opentelemetry.io/collector/component v1.51.1-0.20260205185216-81bc641f26c0/go.mod h1:944C7vEIdk13Pn1fBbyaU8C1qKf2XC0jRBlc69NAsRY= +go.opentelemetry.io/collector/component/componentstatus v0.145.0 h1:EwUZfSaagdpRXnlrb0TqReJXXW2p9HWBU5YiIeXPCAE= +go.opentelemetry.io/collector/component/componentstatus v0.145.0/go.mod h1:OiYb8rT4FtSJPFSGCKYvOaajdueDUTJZncixGrmy5aM= +go.opentelemetry.io/collector/component/componenttest v0.145.1-0.20260205185216-81bc641f26c0 h1:+VCK6wX/WN170dcaWJweRAkxpmAEyVucfrUV13NwUlY= +go.opentelemetry.io/collector/component/componenttest v0.145.1-0.20260205185216-81bc641f26c0/go.mod h1:U2wUjKMGwgqM49/q8ORkzzYzSWY2m6zpG/e606eK1wc= +go.opentelemetry.io/collector/consumer v1.51.1-0.20260205185216-81bc641f26c0 h1:WNkJ1bKnRVAEJtBm1bwEkoLG2x7GyANc3/OnErZJ338= +go.opentelemetry.io/collector/consumer v1.51.1-0.20260205185216-81bc641f26c0/go.mod h1:Erk6qdfVj+24QTrGCpurcrF+qdUlHkb4dgMy5wJxLvY= +go.opentelemetry.io/collector/consumer/consumertest v0.145.1-0.20260205185216-81bc641f26c0 h1:FHyDIlTbqt0Y6tDI9EbI3hr9uWthwkeLY7uGF1jZYqQ= +go.opentelemetry.io/collector/consumer/consumertest v0.145.1-0.20260205185216-81bc641f26c0/go.mod h1:IFc/FeaIHQClb8KK0aVn0tFDNMc+/MmfQ+aBT1cJNeo= +go.opentelemetry.io/collector/consumer/xconsumer v0.145.1-0.20260205185216-81bc641f26c0 h1:zg2Jqfy7n7o/LEmLsXB4sFhxWtOEMFCKRyQUFLFUS9M= +go.opentelemetry.io/collector/consumer/xconsumer v0.145.1-0.20260205185216-81bc641f26c0/go.mod h1:SryDCLP2ZaFeZJtA2CSksJ0XvjH8k3LmlfXvy/kC7Wc= +go.opentelemetry.io/collector/featuregate v1.51.1-0.20260205185216-81bc641f26c0 h1:fOXhfT2xKqNhfalTXaT/Wic9EBRK8+9ZH0y8phReQS4= +go.opentelemetry.io/collector/featuregate v1.51.1-0.20260205185216-81bc641f26c0/go.mod h1:/1bclXgP91pISaEeNulRxzzmzMTm4I5Xih2SnI4HRSo= +go.opentelemetry.io/collector/internal/componentalias v0.145.1-0.20260205185216-81bc641f26c0 h1:s4/vCxeIxgQpuWmX1AK1DRbZmEdmNBq925EKES8ebiI= +go.opentelemetry.io/collector/internal/componentalias v0.145.1-0.20260205185216-81bc641f26c0/go.mod h1:Z0TtMbzaMp2qhj1dw4toya8toyQzqoTF46/WhJXplVw= +go.opentelemetry.io/collector/internal/testutil v0.145.0 h1:H/KL0GH3kGqSMKxZvnQ0B0CulfO9xdTg4DZf28uV7fY= +go.opentelemetry.io/collector/internal/testutil v0.145.0/go.mod h1:YAD9EAkwh/l5asZNbEBEUCqEjoL1OKMjAMoPjPqH76c= +go.opentelemetry.io/collector/pdata v1.51.1-0.20260205185216-81bc641f26c0 h1:8tgf9W3aW3vFabyVxPNHKsaoyUytudfVOQbqZI9xBHQ= +go.opentelemetry.io/collector/pdata v1.51.1-0.20260205185216-81bc641f26c0/go.mod h1:GoX1bjKDR++mgFKdT7Hynv9+mdgQ1DDXbjs7/Ww209Q= +go.opentelemetry.io/collector/pdata/pprofile v0.145.1-0.20260205185216-81bc641f26c0 h1:MJcnK8txYZlqHZyfZ1rVf66kt5/kEveIdR6KX//BY1Y= +go.opentelemetry.io/collector/pdata/pprofile v0.145.1-0.20260205185216-81bc641f26c0/go.mod h1:a60GC7wQPhLAixWzKbbP51QLwwc+J0Cmp4SurOlhGUk= +go.opentelemetry.io/collector/pdata/testdata v0.145.0 h1:iFsxsCMtE3lnAc/5kZbhZHpRv1OMmM+O5ry46xdQHbg= +go.opentelemetry.io/collector/pdata/testdata v0.145.0/go.mod h1:0y2ERArdzqmYdJHdKLKue+AUubSEGlwK49F+23+Mbic= +go.opentelemetry.io/collector/pipeline v1.51.1-0.20260205185216-81bc641f26c0 h1:1KP5gXGF9qN1mEzJupZDUQVIND35qe/0Hy6Cptvdk0s= +go.opentelemetry.io/collector/pipeline v1.51.1-0.20260205185216-81bc641f26c0/go.mod h1:xUrAqiebzYbrgxyoXSkk6/Y3oi5Sy3im2iCA51LwUAI= +go.opentelemetry.io/collector/processor v1.51.0 h1:PKpCzkLQmqaW08TOVh/zM0qx07Ihq+DR5J/OBkPiL9o= +go.opentelemetry.io/collector/processor v1.51.0/go.mod h1:rtIPFS+EFRAkG+CSwtjxs2IsIkuZStObvALeueD02XI= +go.opentelemetry.io/collector/processor/processorhelper v0.145.0 h1:vXdv6lHz20Tm3ZEsg0i6jPZJBQgy9kzk/PuqWhHWiiM= +go.opentelemetry.io/collector/processor/processorhelper v0.145.0/go.mod h1:3Ecpe5jHRHGf24EvJHeJ/ekK/a1DLByyq0CSUxjjURg= +go.opentelemetry.io/collector/processor/processortest v0.145.0 h1:RDGBmyZnHk7XVK/EdLt/8iPWj+QLStbbVi1nFTNR01s= +go.opentelemetry.io/collector/processor/processortest v0.145.0/go.mod h1:WAvxAzSojkdoZB915Z1lsVHCPDJBb2fepjJBjenrzjg= +go.opentelemetry.io/collector/processor/xprocessor v0.145.0 h1:DaIE7MxRlg0OL1o2P0GQZtmZeExAmVso3qWv8S0RLps= +go.opentelemetry.io/collector/processor/xprocessor v0.145.0/go.mod h1:kUwRyKBU/kjCmXodd+0z7CpvcP0A9G9/QL+MaJt4U2o= go.opentelemetry.io/contrib v1.0.0/go.mod h1:EH4yDYeNoaTqn/8yCWQmfNB78VHfGX2Jt2bvnvzBlGM= go.opentelemetry.io/contrib v1.19.0 h1:rnYI7OEPMWFeM4QCqWQ3InMJ0arWMR1i0Cx9A5hcjYM= go.opentelemetry.io/contrib v1.19.0/go.mod h1:gIzjwWFoGazJmtCaDgViqOSJPde2mCWzv60o0bWPcZs= @@ -1332,8 +1348,8 @@ go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJK go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0 h1:ZrPRak/kS4xI3AVXy8F7pipuDXmDsrO8Lg+yQjBLjw0= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0/go.mod h1:3y6kQCWztq6hyW8Z9YxQDDm0Je9AJoFar2G0yDcmhRk= -go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.37.0 h1:SNhVp/9q4Go/XHBkQ1/d5u9P/U+L1yaGPoi0x+mStaI= -go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.37.0/go.mod h1:tx8OOlGH6R4kLV67YaYO44GFXloEjGPZuMjEkaaqIp4= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.39.0 h1:8UPA4IbVZxpsD76ihGOQiFml99GPAEZLohDXvqHdi6U= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.39.0/go.mod h1:MZ1T/+51uIVKlRzGw1Fo46KEWThjlCBZKl2LzY5nv4g= go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= go.opentelemetry.io/otel/sdk v1.3.0/go.mod h1:rIo4suHNhQwBIPg9axF8V9CA72Wz2mKF1teNrup8yzs= @@ -1346,6 +1362,12 @@ go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09 go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= +go.opentelemetry.io/proto/slim/otlp v1.9.0 h1:fPVMv8tP3TrsqlkH1HWYUpbCY9cAIemx184VGkS6vlE= +go.opentelemetry.io/proto/slim/otlp v1.9.0/go.mod h1:xXdeJJ90Gqyll+orzUkY4bOd2HECo5JofeoLpymVqdI= +go.opentelemetry.io/proto/slim/otlp/collector/profiles/v1development v0.2.0 h1:o13nadWDNkH/quoDomDUClnQBpdQQ2Qqv0lQBjIXjE8= +go.opentelemetry.io/proto/slim/otlp/collector/profiles/v1development v0.2.0/go.mod h1:Gyb6Xe7FTi/6xBHwMmngGoHqL0w29Y4eW8TGFzpefGA= +go.opentelemetry.io/proto/slim/otlp/profiles/v1development v0.2.0 h1:EiUYvtwu6PMrMHVjcPfnsG3v+ajPkbUeH+IL93+QYyk= +go.opentelemetry.io/proto/slim/otlp/profiles/v1development v0.2.0/go.mod h1:mUUHKFiN2SST3AhJ8XhJxEoeVW12oqfXog0Bo8W3Ec4= go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= @@ -1370,7 +1392,6 @@ go4.org/netipx v0.0.0-20230728180743-ad4cb58a6516/go.mod h1:TQvodOM+hJTioNQJilmL golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200117160349-530e935923ad/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= @@ -1386,8 +1407,6 @@ golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJk golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo= golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA= -golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= @@ -1398,8 +1417,6 @@ golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= @@ -1416,8 +1433,6 @@ golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -1437,7 +1452,6 @@ golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -1504,8 +1518,6 @@ golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= @@ -1574,8 +1586,8 @@ gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM= howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= -k8s.io/apimachinery v0.34.2 h1:zQ12Uk3eMHPxrsbUJgNF8bTauTVR2WgqJsTmwTE/NW4= -k8s.io/apimachinery v0.34.2/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= +k8s.io/apimachinery v0.35.1 h1:yxO6gV555P1YV0SANtnTjXYfiivaTPvCTKX6w6qdDsU= +k8s.io/apimachinery v0.35.1/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= k8s.io/utils v0.0.0-20250820121507-0af2bda4dd1d h1:wAhiDyZ4Tdtt7e46e9M5ZSAJ/MnPGPs+Ki1gHw4w1R0= k8s.io/utils v0.0.0-20250820121507-0af2bda4dd1d/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= kernel.org/pub/linux/libs/security/libcap/cap v1.2.73 h1:Th2b8jljYqkyZKS3aD3N9VpYsQpHuXLgea+SZUIfODA= From 5d9e1ad43a9b3d66519055ebb6c58e0de29d4184 Mon Sep 17 00:00:00 2001 From: Nick Vigilante Date: Fri, 14 Aug 2026 13:33:28 -0400 Subject: [PATCH 110/138] fix(docs): fix Helm TLS/ingress value keys in admin/setup (backport to release/2.34) (#28169) Backport of #28087 to `release/2.34` (ESR). Cherry-picked `5b97d99a4867` from `main` via `git cherry-pick -x`. Docs-only change; applied cleanly with no conflicts. The `backport` label on the original merged PR did not produce a 2.34 backport, so this is created by hand. 2.34 is listed in `scripts/release_channels/esr_versions.txt`, so it is a valid backport target. - Original PR: #28087 - Tracking: DOCS-651 > This PR was created with AI assistance (Coder Agents). --- docs/admin/setup/index.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/admin/setup/index.md b/docs/admin/setup/index.md index ea36467cfa1..df68b1efcbc 100644 --- a/docs/admin/setup/index.md +++ b/docs/admin/setup/index.md @@ -94,12 +94,14 @@ working directory prior to step 1. ```yaml coder: tls: - secretName: + secretNames: - coder-tls # Alternatively, if you use an Ingress controller to terminate TLS, # set the following values: ingress: + enable: true + tls: enable: true secretName: coder-tls wildcardSecretName: coder-tls From f518fb21b36056ec4dcaec77326e6d3bb45a5d9f Mon Sep 17 00:00:00 2001 From: Nick Vigilante Date: Fri, 14 Aug 2026 13:33:45 -0400 Subject: [PATCH 111/138] fix(docs): fix P2/P3 typos and syntax errors from drift sweep (backport to release/2.34) (#28170) Backport of #28101 to `release/2.34` (ESR). Cherry-picked `1d189cc204f9` from `main` via `git cherry-pick -x`. Docs-only change; applied cleanly with no conflicts. The `backport` label on the original merged PR did not produce a 2.34 backport, so this is created by hand. 2.34 is listed in `scripts/release_channels/esr_versions.txt`, so it is a valid backport target. - Original PR: #28101 - Tracking: DOCS-651 > This PR was created with AI assistance (Coder Agents). --- docs/about/contributing/modules.md | 2 +- docs/admin/networking/port-forwarding.md | 2 +- docs/admin/templates/extending-templates/variables.md | 2 +- docs/admin/users/idp-sync.md | 4 ++-- docs/ai-coder/tasks-migration.md | 6 +++--- docs/user-guides/workspace-access/index.md | 2 +- 6 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/about/contributing/modules.md b/docs/about/contributing/modules.md index 05d06e9299f..835729c3065 100644 --- a/docs/about/contributing/modules.md +++ b/docs/about/contributing/modules.md @@ -342,7 +342,7 @@ Use the version bump script to update versions: ```bash git add . - git commit -m "feat(git-clone):add git-clone module" + git commit -m "feat(git-clone): add git-clone module" ``` 4. **Open a pull request**: diff --git a/docs/admin/networking/port-forwarding.md b/docs/admin/networking/port-forwarding.md index f5678403adb..5e8ac2f3340 100644 --- a/docs/admin/networking/port-forwarding.md +++ b/docs/admin/networking/port-forwarding.md @@ -78,7 +78,7 @@ where each segment of hostnames must not exceed 63 characters. If your app name, agent name, workspace name and username exceed 63 characters in the hostname, port forwarding via the dashboard will not work. -### From an coder_app resource +### From a coder_app resource One way to port forward is to configure a `coder_app` resource in the workspace's template. This approach shows a visual application icon in the diff --git a/docs/admin/templates/extending-templates/variables.md b/docs/admin/templates/extending-templates/variables.md index 3c1d02f0baf..7776b355a3c 100644 --- a/docs/admin/templates/extending-templates/variables.md +++ b/docs/admin/templates/extending-templates/variables.md @@ -51,7 +51,7 @@ and predictability. If you encounter a situation where you need to override template settings for variables, you can employ a straightforward solution: -1. Create a `terraform.tfvars` file in in the template directory: +1. Create a `terraform.tfvars` file in the template directory: ```tf coder_image = newimage:tag diff --git a/docs/admin/users/idp-sync.md b/docs/admin/users/idp-sync.md index 3c7ec708be3..824d66635fd 100644 --- a/docs/admin/users/idp-sync.md +++ b/docs/admin/users/idp-sync.md @@ -241,7 +241,7 @@ role sync at the organization level. 1. Confirm you have the [Coder CLI](../../install/index.md) installed and are logged in with a user who is an Owner or has an Organization Admin role. -1. To fetch the current group sync settings for an organization, run the +1. To fetch the current role sync settings for an organization, run the following: ```sh @@ -388,7 +388,7 @@ settings, a user's memberships will update when they log out and log back in. "cbdcf774-4123-4118-8cd9-b3f502c84dfb" ], "sales": [ - "d79144d9-b30a-555a-9af8-7dac83b2q4ec", + "d79144d9-b30a-555a-9af8-7dac83b2q4ec" ] }, "organization_assign_default": true diff --git a/docs/ai-coder/tasks-migration.md b/docs/ai-coder/tasks-migration.md index b833e6e6ff9..98d98bd3c87 100644 --- a/docs/ai-coder/tasks-migration.md +++ b/docs/ai-coder/tasks-migration.md @@ -76,7 +76,7 @@ Below is a minimal illustrative example of a Coder Tasks template pre-2.28.0. terraform { required_providers { coder = { - source = "coder/coder + source = "coder/coder" } } } @@ -132,8 +132,8 @@ Example (**not** a full template): terraform { required_providers { coder = { - source = "coder/coder - version = ">= 2.13.0 + source = "coder/coder" + version = ">= 2.13.0" } } } diff --git a/docs/user-guides/workspace-access/index.md b/docs/user-guides/workspace-access/index.md index da72459cbbd..27f4d4dab01 100644 --- a/docs/user-guides/workspace-access/index.md +++ b/docs/user-guides/workspace-access/index.md @@ -26,7 +26,7 @@ customization options, keyboard shortcuts, and troubleshooting guides. ## SSH -### Through with the CLI +### Through the CLI Coder will use the optimal path for an SSH connection (determined by your deployment's [networking configuration](../../admin/infrastructure/index.md)) From 2c9745adae14d168e7014d38cf62a17a4ea4eeb2 Mon Sep 17 00:00:00 2001 From: Nick Vigilante Date: Fri, 14 Aug 2026 13:33:59 -0400 Subject: [PATCH 112/138] fix(docs): correct nginx X-Forwarded-Proto and certbot instructions flavor (backport to release/2.34) (#28171) Backport of #28086 to `release/2.34` (ESR). Cherry-picked `b0e93b6e3b59` from `main` via `git cherry-pick -x`. Docs-only change; applied cleanly with no conflicts. The `backport` label on the original merged PR did not produce a 2.34 backport, so this is created by hand. 2.34 is listed in `scripts/release_channels/esr_versions.txt`, so it is a valid backport target. - Original PR: #28086 - Tracking: DOCS-651 > This PR was created with AI assistance (Coder Agents). --- docs/tutorials/reverse-proxy-nginx.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/tutorials/reverse-proxy-nginx.md b/docs/tutorials/reverse-proxy-nginx.md index afc48cd6ef7..18dfd8a7344 100644 --- a/docs/tutorials/reverse-proxy-nginx.md +++ b/docs/tutorials/reverse-proxy-nginx.md @@ -54,7 +54,7 @@ you're using `coder.example.com` as your subdomain. ## Install and configure LetsEncrypt Certbot 1. Install LetsEncrypt Certbot: Refer to the - [CertBot documentation](https://certbot.eff.org/instructions?ws=apache&os=ubuntufocal&tab=wildcard). + [CertBot documentation](https://certbot.eff.org/instructions?ws=nginx&os=ubuntufocal&tab=wildcard). Be sure to pick the wildcard tab and select your DNS provider for instructions to install the necessary DNS plugin. @@ -134,7 +134,7 @@ providers, refer to the proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; + proxy_set_header X-Forwarded-Proto $scheme; add_header Strict-Transport-Security "max-age=15552000; includeSubDomains" always; } } From 54c8074434df2b541c17716def50821feaf888d1 Mon Sep 17 00:00:00 2001 From: Nick Vigilante Date: Fri, 14 Aug 2026 13:34:19 -0400 Subject: [PATCH 113/138] fix(docs): remove invalid `--yes` flag from `coder template version promote` (backport to release/2.34) (#28172) Backport of #28084 to `release/2.34` (ESR). Cherry-picked `f3fd4c4a77f2` from `main` via `git cherry-pick -x`. Docs-only change; applied cleanly with no conflicts. The `backport` label on the original merged PR did not produce a 2.34 backport, so this is created by hand. 2.34 is listed in `scripts/release_channels/esr_versions.txt`, so it is a valid backport target. - Original PR: #28084 - Tracking: DOCS-651 > This PR was created with AI assistance (Coder Agents). --- docs/tutorials/testing-templates.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/tutorials/testing-templates.md b/docs/tutorials/testing-templates.md index 3e0de88bc92..fcaa89793a3 100644 --- a/docs/tutorials/testing-templates.md +++ b/docs/tutorials/testing-templates.md @@ -131,5 +131,5 @@ jobs: - name: Promote template version if: success() run: | - coder template version promote --template=$TEMPLATE_NAME --template-version=${{ steps.name.outputs.version_name }} --yes + coder template version promote --template=$TEMPLATE_NAME --template-version=${{ steps.name.outputs.version_name }} ``` From f930866cdcc9b7347b4cf72843201e384c3e3b9e Mon Sep 17 00:00:00 2001 From: Nick Vigilante Date: Fri, 14 Aug 2026 13:34:37 -0400 Subject: [PATCH 114/138] fix(docs): correct broken CLI commands and flags from drift sweep (backport to release/2.34) (#28173) Backport of #28098 to `release/2.34` (ESR). Cherry-picked `58de9ab8f87e` from `main` via `git cherry-pick -x`. Three of the five files applied cleanly. **Conflicts resolved manually:** `docs/admin/integrations/dx-data-cloud.md` and `docs/admin/users/index.md`. On `release/2.34` both blocks use a ` ```shell ` fence where `main` uses ` ```sh `; that fence difference is an unrelated cross-branch divergence. This backport keeps 2.34's existing `shell` fence (which also matches the neighboring code blocks in those files) and applies #28098's intended change: replacing the broken `coder users list > users.csv` with the `coder users list --output json | jq ... | @csv > users.csv` pipeline. The resulting commit touches the same five files with the same insertion/deletion counts as the original squash. The `backport` label on the original merged PR did not produce a 2.34 backport, so this is created by hand. 2.34 is listed in `scripts/release_channels/esr_versions.txt`, so it is a valid backport target. - Original PR: #28098 - Tracking: DOCS-651 > This PR was created with AI assistance (Coder Agents). --- docs/admin/infrastructure/scale-utility.md | 2 +- docs/admin/integrations/dx-data-cloud.md | 5 +++-- docs/admin/users/index.md | 5 +++-- docs/ai-coder/github-to-tasks.md | 5 +---- docs/user-guides/workspace-access/index.md | 2 +- 5 files changed, 9 insertions(+), 10 deletions(-) diff --git a/docs/admin/infrastructure/scale-utility.md b/docs/admin/infrastructure/scale-utility.md index 6945b54bf55..0761b23a0ff 100644 --- a/docs/admin/infrastructure/scale-utility.md +++ b/docs/admin/infrastructure/scale-utility.md @@ -130,7 +130,7 @@ wish to clean up all workspaces, you can run the following command: ```shell coder exp scaletest cleanup \ --cleanup-job-timeout 2h \ - --cleanup-timeout 15min + --cleanup-timeout 15m ``` This will delete all workspaces and users with the prefix `scaletest-`. diff --git a/docs/admin/integrations/dx-data-cloud.md b/docs/admin/integrations/dx-data-cloud.md index 3556370535f..51ae222f961 100644 --- a/docs/admin/integrations/dx-data-cloud.md +++ b/docs/admin/integrations/dx-data-cloud.md @@ -30,10 +30,11 @@ If your organization already uses the Coder-DX integration, you can find a list ### CLI -Use `users list` to export the list of users to a CSV file: +Use `users list` with `jq` to export the list of users to a CSV file: ```shell -coder users list > users.csv +coder users list --output json | \ + jq -r '["username","email","created_at","status"], (.[] | [.username, .email, .created_at, .status]) | @csv' > users.csv ``` Visit the [users list](../../reference/cli/users_list.md) documentation for more options. diff --git a/docs/admin/users/index.md b/docs/admin/users/index.md index b49ac359054..92639fa6ae0 100644 --- a/docs/admin/users/index.md +++ b/docs/admin/users/index.md @@ -230,10 +230,11 @@ You can use the Coder CLI or API to retrieve your list of users. ### CLI -Use `users list` to export the list of users to a CSV file: +Use `users list` with `jq` to export the list of users to a CSV file: ```shell -coder users list > users.csv +coder users list --output json | \ + jq -r '["username","email","created_at","status"], (.[] | [.username, .email, .created_at, .status]) | @csv' > users.csv ``` Visit the [users list](../../reference/cli/users_list.md) documentation for more options. diff --git a/docs/ai-coder/github-to-tasks.md b/docs/ai-coder/github-to-tasks.md index 408dd8c101c..16c002fffcd 100644 --- a/docs/ai-coder/github-to-tasks.md +++ b/docs/ai-coder/github-to-tasks.md @@ -99,9 +99,6 @@ You must also set `coder-template-name` as part of this. The GHA example has thi ```bash # List all templates in your organization coder templates list - -# List templates in a specific organization -coder templates list --org your-org-name ``` You can also choose to modify the other [input parameters](https://github.com/coder/create-task-action?tab=readme-ov-file#inputs) to better fit your desired workflow. @@ -229,7 +226,7 @@ Generate a new token with these permissions at `https:///deploym **Solution:** -1. Verify the template name using: `coder templates list --org your-org-name` +1. Verify the template name using: `coder templates list` 1. Update the `coder-template-name` input in your workflow file to match exactly, or input secret or variable saved in GitHub 1. Ensure the template exists in the organization specified by `coder-organization` diff --git a/docs/user-guides/workspace-access/index.md b/docs/user-guides/workspace-access/index.md index 27f4d4dab01..22aa1d71400 100644 --- a/docs/user-guides/workspace-access/index.md +++ b/docs/user-guides/workspace-access/index.md @@ -176,7 +176,7 @@ services or preview environments. 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 +the CLI with `coder port-forward`. Read more in the [docs on workspace ports](./port-forwarding.md). ![Open Ports window](../../images/networking/listeningports.png) From 23ed566702537802782ed2a1687a86a9094b71ae Mon Sep 17 00:00:00 2001 From: Nick Vigilante Date: Fri, 14 Aug 2026 13:34:53 -0400 Subject: [PATCH 115/138] fix(docs): fix prometheus metric name and slack webhook backtick (backport to release/2.34) (#28174) Backport of #28085 to `release/2.34` (ESR). Cherry-picked `3145cc8386f9` from `main` via `git cherry-pick -x`. `prometheus.md` applied cleanly. **Conflict resolved manually:** `docs/admin/monitoring/notifications/slack.md`. On `release/2.34` the fence is ` ```bash ` where `main` uses ` ```sh `; that fence difference is an unrelated cross-branch divergence. This backport keeps 2.34's existing `bash` fence and applies only #28085's intended change: removing the stray trailing backtick from the `CODER_NOTIFICATIONS_WEBHOOK_ENDPOINT` export. The `backport` label on the original merged PR did not produce a 2.34 backport, so this is created by hand. 2.34 is listed in `scripts/release_channels/esr_versions.txt`, so it is a valid backport target. - Original PR: #28085 - Tracking: DOCS-651 > This PR was created with AI assistance (Coder Agents). --- docs/admin/integrations/prometheus.md | 2 +- docs/admin/monitoring/notifications/slack.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/admin/integrations/prometheus.md b/docs/admin/integrations/prometheus.md index acaf3e06418..df99ed870df 100644 --- a/docs/admin/integrations/prometheus.md +++ b/docs/admin/integrations/prometheus.md @@ -329,7 +329,7 @@ The following metrics support native histograms: * `coderd_workspace_creation_duration_seconds` * `coderd_prebuilt_workspace_claim_duration_seconds` -* `coderd_template_coderd_template_workspace_build_duration_seconds` +* `coderd_template_workspace_build_duration_seconds` Native histograms are an **experimental** Prometheus feature that removes the need to predefine bucket boundaries and allows higher-resolution buckets that adapt to deployment characteristics. Whether a metric is exposed as classic or native depends entirely on the Prometheus server configuration (see [Prometheus docs](https://prometheus.io/docs/specs/native_histograms/) for details): diff --git a/docs/admin/monitoring/notifications/slack.md b/docs/admin/monitoring/notifications/slack.md index 394a63d7049..1ddaeac88bf 100644 --- a/docs/admin/monitoring/notifications/slack.md +++ b/docs/admin/monitoring/notifications/slack.md @@ -196,7 +196,7 @@ To enable webhook integration in Coder, define the POST webhook endpoint matching the deployed Slack bot: ```bash -export CODER_NOTIFICATIONS_WEBHOOK_ENDPOINT=http://localhost:6000/v1/webhook` +export CODER_NOTIFICATIONS_WEBHOOK_ENDPOINT=http://localhost:6000/v1/webhook ``` Finally, go to the **Notification Settings** in Coder and switch the notifier to From 904d4a730954b88de07578662c21c03fc8906f6e Mon Sep 17 00:00:00 2001 From: Jakub Domeracki Date: Tue, 25 Aug 2026 20:18:45 +0200 Subject: [PATCH 116/138] ci: use dedicated release App token to publish releases (backport 2.34) [ESR] (#28590) Backport of #28553 to `release/2.34`. Use a dedicated GitHub App token (`secrets.RELEASE_APP_ID` / `secrets.RELEASE_APP_PRIVATE_KEY`) for the `Publish release` step instead of the default `GITHUB_TOKEN`. `gh release create` targets a release-branch commit that modifies files under `.github/workflows/` relative to the default branch. The create-release API requires the authenticating token to be authorized to modify workflows; the default `GITHUB_TOKEN` is not, so it fails with `HTTP 403: Resource not accessible by integration`. The `coder-release-publisher` App token has code + workflows write. Requires `RELEASE_APP_ID` / `RELEASE_APP_PRIVATE_KEY` to be configured (already set on `coder/coder`). Refs coder/security-automation#297. --- .github/workflows/release.yaml | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index d7ef868576f..f45537c9e41 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -553,6 +553,18 @@ jobs: gcloud storage cp "./build/${detached_signature}" "gs://releases.coder.com/coder-cli/${version}/${cli_name}.asc" done + # Mint a short-lived installation token from the dedicated release + # GitHub App. The default GITHUB_TOKEN (github-actions[bot]) cannot be + # added to the "Auto-imported tag create protections" ruleset bypass + # list, so `gh release create` fails to create the tag with a 403. The + # App is added to that ruleset's bypass list as an Integration actor. + - name: Generate release App token + id: release_app_token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ secrets.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + - name: Publish release run: | set -euo pipefail @@ -591,7 +603,9 @@ jobs: --release-notes-file "$CODER_RELEASE_NOTES_FILE" \ "${files[@]}" env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Use the dedicated release App token (github-actions[bot] is blocked + # from creating tags by the tag-create protection ruleset). + GITHUB_TOKEN: ${{ steps.release_app_token.outputs.token }} CODER_GPG_RELEASE_KEY_BASE64: ${{ secrets.GPG_RELEASE_KEY_BASE64 }} VERSION: ${{ steps.version.outputs.version }} CREATED_LATEST_TAG: ${{ steps.build_docker.outputs.created_latest_tag }} From 5f4cc5e999e0132bbd118a0fdc03d518521779ca Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 26 Aug 2026 09:32:20 +0200 Subject: [PATCH 117/138] fix: prevent markdown injection in notifications (#28340) (#28606) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport of https://github.com/coder/coder/pull/28340 Original PR: #28340 — fix: prevent markdown injection in notifications Merge commit: 9e8075db0ffd0837d38e7133441893c7320fc388 Requested by: @BobbyHo --------- Co-authored-by: Bobby Ho Co-authored-by: Claude Opus 5 (1M context) --- coderd/notifications/dispatch/smtp.go | 73 +++- .../dispatch/smtp_internal_test.go | 100 ++++++ coderd/notifications/dispatch/smtp_test.go | 128 +++++++ coderd/notifications/notifications_test.go | 88 +++++ coderd/notifications/notifier.go | 10 +- ...plateWorkspaceResourceReplaced.json.golden | 2 +- coderd/notifications/types/escape.go | 58 ++++ coderd/notifications/types/escape_test.go | 132 +++++++ coderd/render/escape.go | 188 ++++++++++ coderd/render/escape_internal_test.go | 313 +++++++++++++++++ coderd/render/escape_sink_internal_test.go | 321 ++++++++++++++++++ coderd/render/markdown.go | 79 ++++- scripts/check_emdash.sh | 5 + 13 files changed, 1490 insertions(+), 7 deletions(-) create mode 100644 coderd/notifications/types/escape.go create mode 100644 coderd/notifications/types/escape_test.go create mode 100644 coderd/render/escape.go create mode 100644 coderd/render/escape_internal_test.go create mode 100644 coderd/render/escape_sink_internal_test.go diff --git a/coderd/notifications/dispatch/smtp.go b/coderd/notifications/dispatch/smtp.go index 5dfcc43851d..d5760cfc1ce 100644 --- a/coderd/notifications/dispatch/smtp.go +++ b/coderd/notifications/dispatch/smtp.go @@ -6,7 +6,9 @@ import ( "crypto/tls" "crypto/x509" _ "embed" + "encoding/base64" "fmt" + "mime" "mime/multipart" "mime/quotedprintable" "net" @@ -18,6 +20,7 @@ import ( "sync" "text/template" "time" + "unicode/utf8" "github.com/emersion/go-sasl" smtp "github.com/emersion/go-smtp" @@ -66,7 +69,7 @@ func (s *SMTPHandler) Dispatcher(payload types.MessagePayload, titleTmpl, bodyTm return nil, xerrors.Errorf("render subject: %w", err) } - htmlBody := markdown.HTMLFromMarkdown(bodyTmpl) + htmlBody := markdown.HTMLFromNotificationMarkdown(bodyTmpl) plainBody, err := markdown.PlaintextFromMarkdown(bodyTmpl) if err != nil { return nil, xerrors.Errorf("render plaintext body: %w", err) @@ -202,7 +205,7 @@ func (s *SMTPHandler) dispatch(subject, htmlBody, plainBody, to string) Delivery multipartWriter := multipart.NewWriter(multipartBuffer) _, _ = fmt.Fprintf(msg, "From: %s\r\n", headerFrom) _, _ = fmt.Fprintf(msg, "To: %s\r\n", strings.Join(recipients, ", ")) - _, _ = fmt.Fprintf(msg, "Subject: %s\r\n", subject) + _, _ = fmt.Fprintf(msg, "Subject: %s\r\n", encodeHeaderValue(subject)) _, _ = fmt.Fprintf(msg, "Message-Id: %s@%s\r\n", msgID, s.hostname()) _, _ = fmt.Fprintf(msg, "Date: %s\r\n", time.Now().Format(time.RFC1123Z)) _, _ = fmt.Fprintf(msg, "Content-Type: multipart/alternative; boundary=%s\r\n", multipartWriter.Boundary()) @@ -573,3 +576,69 @@ func (s *SMTPHandler) password() (string, error) { } return s.cfg.Auth.Password.String(), nil } + +const ( + encodedWordPrefix = "=?utf-8?b?" + encodedWordSuffix = "?=" + // RFC 2047 limits an encoded-word to 75 characters including its + // delimiters, and base64 expands three bytes to four characters. + encodedWordMaxBytes = (75 - len(encodedWordPrefix) - len(encodedWordSuffix)) / 4 * 3 + + // maxHeaderValueOctets is the longest value emitted unfolded. RFC 5322 caps + // a line at 998 octets; the rest of the budget covers the field name. + maxHeaderValueOctets = 900 +) + +// encodeHeaderValue prepares a rendered value for use as a header value. Line +// breaks become spaces so the value cannot terminate the header and inject +// another. +func encodeHeaderValue(value string) string { + if strings.ContainsAny(value, "\r\n") { + value = strings.Map(func(r rune) rune { + if r == '\r' || r == '\n' { + return ' ' + } + return r + }, value) + } + // A forged encoded-word is printable ASCII, which mime.WordEncoder passes + // through untouched for the recipient's client to decode. + if strings.Contains(value, "=?") { + return encodeWords(value) + } + // Length is measured on the encoded form: Q-encoding expands a non-ASCII + // rune to three characters per byte, so a short value can still exceed the + // line limit. WordEncoder separates words with a space rather than folding, + // so anything over the limit goes to encodeWords. + if encoded := mime.QEncoding.Encode("utf-8", value); len(encoded) <= maxHeaderValueOctets { + return encoded + } + return encodeWords(value) +} + +// encodeWords emits value as RFC 2047 base64 encoded-words, joined with CRLF +// and a space so they both concatenate per RFC 2047 and fold per RFC 5322. +func encodeWords(value string) string { + var words []string + for len(value) > 0 { + n := encodedWordMaxBytes + if n >= len(value) { + n = len(value) + } else { + // Each encoded-word must decode on its own, so a multi-byte rune + // cannot straddle two of them. + for n > 0 && !utf8.RuneStart(value[n]) { + n-- + } + if n == 0 { + // A rune wider than the budget: emit it whole rather than + // splitting it into something undecodable. + _, n = utf8.DecodeRuneInString(value) + } + } + words = append(words, encodedWordPrefix+ + base64.StdEncoding.EncodeToString([]byte(value[:n]))+encodedWordSuffix) + value = value[n:] + } + return strings.Join(words, "\r\n ") +} diff --git a/coderd/notifications/dispatch/smtp_internal_test.go b/coderd/notifications/dispatch/smtp_internal_test.go index 2e7dff8cbec..1284cb070e1 100644 --- a/coderd/notifications/dispatch/smtp_internal_test.go +++ b/coderd/notifications/dispatch/smtp_internal_test.go @@ -2,6 +2,7 @@ package dispatch import ( "html" + "mime" "strings" "testing" @@ -116,3 +117,102 @@ func TestValidateFromAddr(t *testing.T) { }) } } + +func TestEncodeHeaderValue(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + value string + want string + }{ + { + name: "ascii is unchanged", + value: `User account "bobby" suspended`, + want: `User account "bobby" suspended`, + }, + { + name: "crlf is folded", + value: "Subject\r\nBcc: attacker@example.com", + want: "Subject Bcc: attacker@example.com", + }, + { + name: "bare newline is folded", + value: "Subject\nBcc: attacker@example.com", + want: "Subject Bcc: attacker@example.com", + }, + { + name: "non-ascii is q-encoded", + value: "Konto gelöscht", + want: "=?utf-8?q?Konto_gel=C3=B6scht?=", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got := encodeHeaderValue(tc.value) + require.Equal(t, tc.want, got) + // The result must never be able to terminate its own header. + require.NotContains(t, got, "\r") + require.NotContains(t, got, "\n") + }) + } +} + +// TestEncodeHeaderValueEncodedWord covers a forged RFC 2047 encoded-word, which +// is printable ASCII and so passes mime.WordEncoder through to the client. +func TestEncodeHeaderValueEncodedWord(t *testing.T) { + t.Parallel() + + // Decodes to "URGENT: verify your account". + const forged = "=?utf-8?B?VVJHRU5UOiB2ZXJpZnkgeW91ciBhY2NvdW50?=" + got := encodeHeaderValue(forged + " shared a chat with you") + + // The forged word must not survive as something a client would decode. + require.NotContains(t, got, forged) + + // Decoded rather than compared: chunk boundaries are an implementation detail. + decoded, err := new(mime.WordDecoder).DecodeHeader(got) + require.NoError(t, err) + require.Equal(t, forged+" shared a chat with you", decoded) +} + +// TestEncodeHeaderValueFolds covers RFC 5322's 998-octet line limit, which +// mime.WordEncoder does not fold for. +func TestEncodeHeaderValueFolds(t *testing.T) { + t.Parallel() + + for name, value := range map[string]string{ + "non-ascii": strings.Repeat("é", 600), + "ascii": strings.Repeat("a b ", 400), + // A rune that does not divide evenly into the per-word budget must not + // be split across two encoded-words: each has to decode on its own. + "multibyte": strings.Repeat("日本語", 400), + // Under the raw byte limit and over it once Q-encoded, so these fail + // unless the gate measures the encoded form. + "200 accented runes": strings.Repeat("é", 200), + "300 cjk runes": strings.Repeat("日", 300), + "200 emoji": strings.Repeat("🎉", 200), + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := encodeHeaderValue(value) + for _, line := range strings.Split(got, "\r\n") { + require.LessOrEqual(t, len(line), 998, + "a header line exceeds RFC 5322's limit: %d octets", len(line)) + } + // A CRLF must begin a continuation, or this is injection not folding. + for _, after := range strings.Split(got, "\r\n")[1:] { + require.True(t, strings.HasPrefix(after, " "), + "a CRLF was not followed by folding whitespace: %q", got) + } + + decoded, err := new(mime.WordDecoder).DecodeHeader(got) + require.NoError(t, err) + require.Equal(t, value, decoded) + }) + } +} diff --git a/coderd/notifications/dispatch/smtp_test.go b/coderd/notifications/dispatch/smtp_test.go index 34aed0feed6..ad8ec69fc61 100644 --- a/coderd/notifications/dispatch/smtp_test.go +++ b/coderd/notifications/dispatch/smtp_test.go @@ -4,6 +4,7 @@ import ( "bytes" "fmt" "log" + "strings" "sync" "testing" @@ -636,3 +637,130 @@ func TestSMTPEnvelopeAndHeaders(t *testing.T) { }) } } + +// TestSMTPSubjectHeader: a rendered subject must not terminate the Subject +// header, and a non-ASCII one must be RFC 2047 encoded rather than raw 8-bit. +func TestSMTPSubjectHeader(t *testing.T) { + t.Parallel() + + const ( + hello = "localhost" + to = "bob@bob.com" + body = "This is the body" + ) + + tests := []struct { + name string + // title is the rendered title template handed to the dispatcher. + title string + // wantSubject, when set, is the exact Subject header value. + wantSubject string + // wantSubjectContains are substrings the single Subject line must hold, + // used where pinning exact output would test glamour, not the header. + wantSubjectContains []string + // wantAbsent must not appear anywhere in the transmitted message. + wantAbsent string + }{ + { + name: "plain subject", + title: "This is the subject", + wantSubject: "This is the subject", + }, + { + name: "newline cannot inject a header", + // PlaintextFromMarkdown keeps the paragraph break, so this reaches + // the header writer with newlines in it. + title: "Innocent subject\n\nBcc: attacker@example.com", + wantSubjectContains: []string{"Innocent subject", "Bcc: attacker@example.com"}, + wantAbsent: "\r\nBcc:", + }, + { + name: "non-ascii subject is encoded", + title: "Konto gelöscht", + wantSubject: "=?utf-8?q?Konto_gel=C3=B6scht?=", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) + + cfg := codersdk.NotificationsEmailConfig{ + Hello: serpent.String(hello), + From: serpent.String("system@coder.com"), + } + + backend := smtptest.NewBackend(smtptest.Config{AuthMechanisms: []string{}}) + srv, listen, err := smtptest.CreateMockSMTPServer(backend, false) + require.NoError(t, err) + t.Cleanup(func() { + assert.ErrorIs(t, srv.Shutdown(ctx), smtp.ErrServerClosed) + }) + + var hp serpent.HostPort + require.NoError(t, hp.Set(listen.Addr().String())) + cfg.Smarthost = serpent.String(hp.String()) + + handler := dispatch.NewSMTPHandler(cfg, logger.Named("smtp")) + + var wg sync.WaitGroup + wg.Go(func() { + assert.NoError(t, srv.Serve(listen)) + }) + + require.Eventually(t, func() bool { + cl, err := smtptest.PingClient(listen, false, false) + if err != nil { + return false + } + _ = cl.Close() + return true + }, testutil.WaitShort, testutil.IntervalFast) + + payload := types.MessagePayload{ + Version: "1.0", + UserEmail: to, + Labels: make(map[string]string), + } + + dispatchFn, err := handler.Dispatcher(payload, tc.title, body, helpers()) + require.NoError(t, err) + + retryable, err := dispatchFn(ctx, uuid.New()) + require.NoError(t, err) + require.False(t, retryable) + + msg := backend.LastMessage() + require.NotNil(t, msg) + + // Assertions are scoped to the header block, which a blank line ends. + headers, _, found := strings.Cut(msg.Contents, "\r\n\r\n") + require.True(t, found, "message has no header/body separator") + + // The header must occupy exactly one line, whatever the value held. + require.Equal(t, 1, strings.Count(headers, "Subject: "), + "exactly one Subject header must be present") + _, after, found := strings.Cut(headers, "Subject: ") + require.True(t, found, "no Subject header in %q", headers) + subject, _, found := strings.Cut(after, "\r\n") + require.True(t, found, "Subject header is not CRLF terminated") + + if tc.wantSubject != "" { + require.Equal(t, tc.wantSubject, subject) + } + for _, want := range tc.wantSubjectContains { + require.Contains(t, subject, want) + } + if tc.wantAbsent != "" { + require.NotContains(t, headers, tc.wantAbsent, + "a value must not be able to inject an additional header") + } + + require.NoError(t, srv.Shutdown(ctx)) + wg.Wait() + }) + } +} diff --git a/coderd/notifications/notifications_test.go b/coderd/notifications/notifications_test.go index a59e64be42f..b6ad65f9e30 100644 --- a/coderd/notifications/notifications_test.go +++ b/coderd/notifications/notifications_test.go @@ -45,6 +45,7 @@ import ( "github.com/coder/coder/v2/coderd/notifications/dispatch/smtptest" "github.com/coder/coder/v2/coderd/notifications/types" "github.com/coder/coder/v2/coderd/rbac" + markdown "github.com/coder/coder/v2/coderd/render" "github.com/coder/coder/v2/coderd/util/syncmap" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/testutil" @@ -2291,3 +2292,90 @@ func (n *acquireSignalingInterceptor) AcquireNotificationMessages(ctx context.Co n.acquiredChan <- struct{}{} return messages, err } + +// renderCapture records what the notifier renders, so a test can assert on what +// a dispatcher would receive. +type renderCapture struct { + mu sync.Mutex + title, body string + captured chan struct{} + once sync.Once +} + +func newRenderCapture() *renderCapture { + return &renderCapture{captured: make(chan struct{})} +} + +func (c *renderCapture) Dispatcher(_ types.MessagePayload, title, body string, _ template.FuncMap) (dispatch.DeliveryFunc, error) { + return func(_ context.Context, _ uuid.UUID) (bool, error) { + c.mu.Lock() + c.title, c.body = title, body + c.mu.Unlock() + c.once.Do(func() { close(c.captured) }) + return false, nil + }, nil +} + +func (c *renderCapture) wait(t *testing.T) (title, body string) { + t.Helper() + testutil.TryReceive(testutil.Context(t, testutil.WaitLong), t, c.captured) + c.mu.Lock() + defer c.mu.Unlock() + return c.title, c.body +} + +// TestNotificationMarkdownInjection is the end-to-end regression test for +// https://linear.app/codercom/issue/SEC-93. +func TestNotificationMarkdownInjection(t *testing.T) { + t.Parallel() + + const payload = "Eve\n## URGENT: SSO certificate expiring\n" + + "[Re-authenticate now](https://coder-sso.attacker.example/login)" + + ctx := dbauthz.AsNotifier(testutil.Context(t, testutil.WaitSuperLong)) + store, pubsub := dbtestutil.NewDB(t) + logger := testutil.Logger(t) + + method := database.NotificationMethodSmtp + cfg := defaultNotificationsConfig(method) + capture := newRenderCapture() + + mgr, err := notifications.NewManager(cfg, store, pubsub, defaultHelpers(), createMetrics(), logger.Named("manager")) + require.NoError(t, err) + mgr.WithHandlers(map[database.NotificationMethod]notifications.Handler{ + method: capture, + database.NotificationMethodInbox: &fakeHandler{}, + }) + t.Cleanup(func() { + assert.NoError(t, mgr.Stop(ctx)) + }) + + enq, err := notifications.NewStoreEnqueuer(cfg, store, defaultHelpers(), logger.Named("enqueuer"), quartz.NewReal()) + require.NoError(t, err) + user := createSampleUser(t, store) + + // WHEN: the notification interpolates an attacker-controlled display name + _, err = enq.Enqueue(ctx, user.ID, notifications.TemplateUserAccountSuspended, map[string]string{ + "suspended_account_name": "eve", + "suspended_account_user_name": payload, + "initiator": "admin", + "account_type": "user", + }, "test") + require.NoError(t, err) + + mgr.Run(ctx) + _, body := capture.wait(t) + + // THEN: the rendered Markdown carries no structure from the display name. + html := markdown.HTMLFromNotificationMarkdown(body) + plain, err := markdown.PlaintextFromMarkdown(body) + require.NoError(t, err) + + for _, tag := range []string{"
|` + + // leadingEmphasis characters carry inline meaning anywhere but also open a + // block construct in leading position: "* " starts a bullet list, and three + // or more of either character starts a thematic break. Escaping them only + // there costs emphasis that begins on a line boundary. + leadingEmphasis = `*_` + + // foldStart characters also carry meaning only at the start of a line, but + // glamour does not honor a backslash before them, so escaping would leave a + // literal backslash in the plaintext part. The preceding line break becomes + // a space instead, denying them the line-start position. + // + // ":" is here for the GFM delimiter row ":-- | --:" as well as definition + // lists. Escaping "|" does not reach that row: its pipes are mid-line. + foldStart = `=~:` + + // maxLeadingSpaces is the widest indentation a line may keep, since four + // spaces open an indented code block and a space cannot be escaped. + maxLeadingSpaces = 3 +) + +// EscapeMarkdown neutralizes Markdown structure in an untrusted value so that it +// renders as literal text through both HTMLFromNotificationMarkdown and +// PlaintextFromMarkdown. Line breaks are preserved so multi-line values keep +// their shape. Other control characters are dropped, being the carrier for SMTP +// header injection. +// +// Known residual: a template that wraps the value in a code span. CommonMark +// does not process escapes inside one, so the backslashes emitted here reach +// the reader. Nothing here can detect that, since the sink is decided after +// this runs. +func EscapeMarkdown(s string) string { + if s == "" { + return s + } + + lines := strings.Split(stripControl(s), "\n") + var b strings.Builder + b.Grow(len(s) + len(s)/8) + + for i, line := range lines { + if i > 0 { + // Joining a fold-start line to the previous one takes it out of + // leading position. + if opensFoldConstruct(line) { + _ = b.WriteByte(' ') + } else { + _ = b.WriteByte('\n') + } + } + // The first line has no preceding break to fold, so escaping is the only + // lever left there. + _, _ = b.WriteString(escapeLine(line, i == 0 && isLeadingFoldConstruct(line))) + } + return b.String() +} + +// stripControl keeps line breaks, turns the other whitespace controls into +// spaces and drops the rest. Carriage returns are folded rather than kept so a +// value cannot terminate an SMTP header. +func stripControl(s string) string { + if strings.IndexFunc(s, isStrippable) < 0 { + return s + } + var b strings.Builder + b.Grow(len(s)) + for _, r := range s { + switch { + case r == '\n': + _, _ = b.WriteRune(r) + case r == '\r' || r == '\t' || r == '\v' || r == '\f': + _, _ = b.WriteRune(' ') + case r < 0x20 || r == 0x7f: + // Dropped. + default: + _, _ = b.WriteRune(r) + } + } + return b.String() +} + +func isStrippable(r rune) bool { + return r != '\n' && (r < 0x20 || r == 0x7f) +} + +// escapeLine escapes one line's structural characters and truncates its +// indentation to maxLeadingSpaces. +// +// escapeFold additionally escapes a leading "=" or "~". Only EscapeMarkdown's +// first line passes it, and only when that line really is a fold construct. +func escapeLine(line string, escapeFold bool) string { + var b strings.Builder + b.Grow(len(line)) + + leading := true + spaces := 0 + // digitRun reports whether the line so far is nothing but indentation and + // digits, which is the only position where "." opens an ordered list. + digitRun := false + for i, r := range line { + switch { + case r < 0x80 && strings.ContainsRune(inlineCritical, r): + _ = b.WriteByte('\\') + _, _ = b.WriteRune(r) + case leading && r == ' ': + // Indentation keeps the next character in leading position. + if spaces < maxLeadingSpaces { + _, _ = b.WriteRune(r) + spaces++ + } + continue + case leading && r < 0x80 && strings.ContainsRune(blockStart+leadingEmphasis, r): + _ = b.WriteByte('\\') + _, _ = b.WriteRune(r) + case leading && escapeFold && (r == '=' || r == '~'): + _ = b.WriteByte('\\') + _, _ = b.WriteRune(r) + case digitRun && r == '.' && closesMarker(line, i): + // The "1." of an ordered list. Its sibling "1)" needs no case + // because ")" is inlineCritical and is always escaped. + _ = b.WriteByte('\\') + _, _ = b.WriteRune(r) + default: + _, _ = b.WriteRune(r) + } + digitRun = (leading || digitRun) && r >= '0' && r <= '9' + leading = false + } + return b.String() +} + +// closesMarker reports whether the single-byte list-marker delimiter at i is +// followed by a space or ends the line, as CommonMark requires of a marker. +// That requirement is what keeps a value such as "1.5" out of the escaped set. +// Tabs need no handling: stripControl has already folded them into spaces. +func closesMarker(line string, i int) bool { + return i+1 == len(line) || line[i+1] == ' ' +} + +// opensFoldConstruct reports whether a line's first non-space character is a +// foldStart character. Approximate on purpose: it only decides whether to drop +// a line break, which costs nothing. +func opensFoldConstruct(line string) bool { + t := strings.TrimLeft(line, " ") + if t == "" { + return false + } + return strings.ContainsRune(foldStart, rune(t[0])) +} + +// isLeadingFoldConstruct reports whether a line is itself a tilde fence opener +// or a Setext "=" underline, rather than merely starting with one of those +// characters. Exact, because it governs escaping and the backslash is visible: +// "=> next" must not acquire one, while "~~~" must, since an unterminated fence +// at the start of a title renders the Subject, and heading empty. +// +// Indentation is ignored: escapeLine truncates it to maxLeadingSpaces, which +// still leaves the line able to open a block. ":" is excluded because a +// definition list or table needs a preceding line that a first line lacks. +func isLeadingFoldConstruct(line string) bool { + t := strings.TrimLeft(line, " ") + if strings.HasPrefix(t, "~~~") { + return true + } + t = strings.TrimRight(t, " ") + return t != "" && strings.Trim(t, "=") == "" +} diff --git a/coderd/render/escape_internal_test.go b/coderd/render/escape_internal_test.go new file mode 100644 index 00000000000..8cf2078f92b --- /dev/null +++ b/coderd/render/escape_internal_test.go @@ -0,0 +1,313 @@ +package render + +import ( + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// asciiPunctuation is every ASCII punctuation character, the set CommonMark +// declares escapable. +const asciiPunctuation = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~" + +// TestEscapableSet pins the escapes each renderer honors, which is what +// EscapeMarkdown's character classes are derived from. +func TestEscapableSet(t *testing.T) { + t.Parallel() + + // Measured against gomarkdown and glamour as vendored today. + const ( + wantHTML = "!#$&()*+-.:<>[\\]^_`{|}~" + wantPlain = "!#()*+-.<>[\\]_`{|}" + ) + + var gotHTML, gotPlain strings.Builder + for _, r := range asciiPunctuation { + escaped := `X\` + string(r) + `Y` + + // HTML escapes markup characters, so compare the entity form. + wantLiteral := "X" + string(r) + "Y" + switch r { + case '<': + wantLiteral = "X<Y" + case '>': + wantLiteral = "X>Y" + case '&': + wantLiteral = "X&Y" + case '"': + wantLiteral = "X"Y" + } + html := strings.TrimSuffix(strings.TrimPrefix(HTMLFromMarkdown(escaped), "<p>"), "</p>") + if html == wantLiteral { + _, _ = gotHTML.WriteRune(r) + } + + plain, err := PlaintextFromMarkdown(escaped) + require.NoError(t, err) + if plain == "X"+string(r)+"Y" { + _, _ = gotPlain.WriteRune(r) + } + } + + require.Equal(t, wantHTML, gotHTML.String(), + "the set of characters gomarkdown honors as escapes has changed; re-derive EscapeMarkdown's character classes") + require.Equal(t, wantPlain, gotPlain.String(), + "the set of characters glamour honors as escapes has changed; re-derive EscapeMarkdown's character classes") + + // Every character EscapeMarkdown escapes must be honored by both renderers. + for _, r := range inlineCritical + blockStart + leadingEmphasis { + assert.Contains(t, wantHTML, string(r), "gomarkdown does not honor \\%s", string(r)) + assert.Contains(t, wantPlain, string(r), "glamour does not honor \\%s", string(r)) + } + // foldStart characters are folded precisely because they are not escapable. + for _, r := range foldStart { + assert.NotContains(t, wantPlain, string(r), + "glamour now honors \\%s, so it could be escaped instead of folded", string(r)) + } +} + +// TestEscapeMarkdownControlValues guards the label values body templates compare +// with `eq`. Escaping one silently changes template control flow. +func TestEscapeMarkdownControlValues(t *testing.T) { + t.Parallel() + + for _, v := range []string{ + "user_override", // migrations/000553, {{if eq .Labels.limit_source "user_override"}} + "service", // migrations/000568, {{if eq .Labels.account_type "service"}} + "0", // migrations/000480, {{if eq .Data.retention_days "0"}} + "autobuild", + "initiator", + "user-override", + "1.5", + "10.0.0.1", + "bobby-workspace", + } { + require.Equal(t, v, EscapeMarkdown(v), "escaping changed a control value") + } +} + +func TestEscapeMarkdown(t *testing.T) { + t.Parallel() + + // Emphasis and code tags are accepted residuals: no destination. + structuralTags := []string{ + "<a ", "<img ", "<h1", "<h2", "<h3", "<h4", "<h5", "<h6", + "<ul", "<ol", "<hr", "<blockquote", "<table", + } + + t.Run("NeutralisesStructure", func(t *testing.T) { + t.Parallel() + + type structureCase struct { + name string + value string + // inertRaw marks a value neutralized by something other than marker + // escaping. Leaving it unset on one fails the liveness check below. + inertRaw bool + } + + for _, tc := range []structureCase{ + {name: "DisclosurePayload", value: "Eve\n## URGENT: SSO certificate expiring\n[Re-authenticate now](https://coder-sso.attacker.example/login)"}, + {name: "InlineLink", value: "[Re-authenticate now](https://attacker.example/login)"}, + // A link reference definition is not recognized mid-paragraph. + {name: "ReferenceLink", value: "[Re-auth][1]\n\n[1]: https://attacker.example", inertRaw: true}, + {name: "Image", value: "![px](https://tracker.attacker.example/p.gif)"}, + {name: "AngleAutolink", value: "Eve <https://attacker.example>"}, + // Neutralized by autolinking being off. See + // TestEscapeMarkdownNoAutolink. + {name: "BareURL", value: "Eve https://attacker.example/login", inertRaw: true}, + {name: "Mailto", value: "Eve mailto:eve@attacker.example", inertRaw: true}, + {name: "ATXHeading", value: "Eve\n## URGENT"}, + {name: "SetextH1", value: "URGENT: re-auth required\n===\nx"}, + {name: "SetextH1Spaced", value: "Eve\n=== \n#### x"}, + {name: "SetextH1Repeated", value: "Eve\n===\n===\nx"}, + {name: "SetextH2", value: "Eve\n---\nx"}, + {name: "ThematicBreak", value: "Eve\n----\nx"}, + {name: "ThematicBreakStars", value: "Eve\n***\nnext"}, + {name: "ThematicBreakUnderscores", value: "Eve\n___\nnext"}, + {name: "ThematicBreakSpacedStars", value: "Eve\n* * *\nnext"}, + {name: "ThematicBreakSpacedUnderscores", value: "Eve\n_ _ _\nnext"}, + {name: "BulletList", value: "Eve\n- one\n- two"}, + {name: "BulletListStar", value: "Eve\n* one\n* two"}, + {name: "BulletListStarIndented", value: "Eve\n * one\n * two"}, + {name: "OrderedList", value: "Eve\n1. one\n2. two"}, + {name: "OrderedListMultiDigit", value: "Eve\n99. one\n100. two"}, + {name: "OrderedListParen", value: "Eve\n1) one\n2) two"}, + {name: "Blockquote", value: "Eve\n> quoted"}, + // Neutralized by the Tables extension being off; the escaper's own + // handling is covered by TestEscapeMarkdownColon. + {name: "Table", value: "a | b\n--- | ---\nc | d", inertRaw: true}, + // Neutralized by the safelink policy. See + // TestEscapeMarkdownNoAutolink/SafelinkRejectsUnsafeSchemes. + {name: "JavascriptScheme", value: "[click](javascript:alert(1))", inertRaw: true}, + // Asserts escaping does not re-enable the value's own backslashes. + {name: "EscapeForging", value: `Eve \[Re-auth\](https://attacker.example)`, inertRaw: true}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // Rendered twice, the second with line breaks doubled: only a + // blank line lets a block construct interrupt a paragraph. + rawProducedTag := false + for _, value := range []string{tc.value, strings.ReplaceAll(tc.value, "\n", "\n\n")} { + escaped := EscapeMarkdown(value) + html := HTMLFromNotificationMarkdown(suspendedBody(escaped)) + plain, err := PlaintextFromMarkdown(suspendedBody(escaped)) + require.NoError(t, err) + raw := HTMLFromNotificationMarkdown(suspendedBody(value)) + + for _, tag := range structuralTags { + assert.NotContains(t, html, tag, "value %q rendered HTML: %s", value, html) + rawProducedTag = rawProducedTag || strings.Contains(raw, tag) + } + // A backslash the value did not contain means a character the + // renderer does not honor was escaped. + if !strings.Contains(value, `\`) { + assert.NotContains(t, html, `\`, "value %q leaked a literal backslash into HTML", value) + assert.NotContains(t, plain, `\`, "value %q leaked a literal backslash into plaintext", value) + } + } + + // Without this, a row whose value can never reach a line-start + // position passes whether or not EscapeMarkdown runs. + if !tc.inertRaw { + assert.True(t, rawProducedTag, + "vacuous row: %q produces no structural tag even unescaped, so the assertions above guard nothing; fix the value or set inertRaw with a reason", + tc.value) + } + }) + } + }) + + t.Run("PreservesBenignValues", func(t *testing.T) { + t.Parallel() + + // Also why this change leaves the notification golden files untouched. + for _, value := range []string{ + "William Tables", + "bobby-workspace", + "Bobby's Template", + "O'Brien-Smith (Eng) 100%", + "autodeleted due to dormancy (autobuild)", + "José Müller 日本語", + // Documented multi-line custom notification, see + // docs/admin/monitoring/notifications/index.md. + "Test results:\n • ✅ success", + "Test results:\n • ❌ failed (3 tests failed)", + } { + t.Run(value, func(t *testing.T) { + t.Parallel() + + escaped := EscapeMarkdown(value) + require.Equal(t, HTMLFromNotificationMarkdown(suspendedBody(value)), HTMLFromNotificationMarkdown(suspendedBody(escaped))) + + wantPlain, err := PlaintextFromMarkdown(suspendedBody(value)) + require.NoError(t, err) + gotPlain, err := PlaintextFromMarkdown(suspendedBody(escaped)) + require.NoError(t, err) + require.Equal(t, wantPlain, gotPlain) + }) + } + }) + + t.Run("ControlCharacters", func(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + value string + want string + }{ + {"KeepsNewlines", "a\nb", "a\nb"}, + {"FoldsCarriageReturn", "a\r\nb", "a \nb"}, + {"FoldsTab", "a\tb", "a b"}, + {"FoldsVerticalTab", "a\vb", "a b"}, + {"DropsNul", "a\x00b", "ab"}, + {"DropsBell", "a\x07b", "ab"}, + {"DropsDelete", "a\x7fb", "ab"}, + {"Empty", "", ""}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tc.want, EscapeMarkdown(tc.value)) + }) + } + }) + + t.Run("AngleBracketsAreNeutralised", func(t *testing.T) { + t.Parallel() + + // "Ops <ops@example.com>" used to render as a mailto anchor; turning it + // into text is a deliberate behavior change. + html := HTMLFromNotificationMarkdown(suspendedBody(EscapeMarkdown("Ops <ops@example.com>"))) + require.NotContains(t, html, "<a ") + require.Contains(t, html, "<ops@example.com>") + }) + + t.Run("EmphasisIsNotEscaped", func(t *testing.T) { + t.Parallel() + + // Mid-line "*" and "_" carry no destination and escaping "_" corrupts + // control values. Backtick reaches the info-string sink, so it is escaped. + require.Equal(t, "Eve *_\\`~", EscapeMarkdown("Eve *_`~")) + + // Leading "*" and "_" open a list or thematic break. "~" is denied the + // line-start position by the fold, glamour not honoring "\~". + require.Equal(t, "\\*_\\`~", EscapeMarkdown("*_`~")) + require.Equal(t, "\\_*\\`~", EscapeMarkdown("_*`~")) + }) +} + +// TestEscapeMarkdownNoAutolink: a URL in an untrusted value must not become an +// anchor, while links in the trusted template markdown keep working. +func TestEscapeMarkdownNoAutolink(t *testing.T) { + t.Parallel() + + t.Run("UntrustedValueProducesNoAnchor", func(t *testing.T) { + t.Parallel() + + for _, value := range []string{ + "Eve https://attacker.example/login", + "Eve [Re-auth](https://attacker.example/login)", + "Eve <https://attacker.example>", + "Eve mailto:eve@attacker.example", + "Eve http://attacker.example", + } { + html := HTMLFromNotificationMarkdown("Account **" + EscapeMarkdown(value) + "** suspended.") + assert.NotContains(t, html, "<a ", "value %q produced an anchor", value) + } + }) + + t.Run("TrustedTemplateLinksStillRender", func(t *testing.T) { + t.Parallel() + + // Shapes taken from shipped notification body templates. + for _, markdown := range []string{ + "marked as [**dormant**](https://coder.com/docs/templates/schedule#dormancy-threshold-enterprise) because of x", + "See [the docs](https://coder.com/docs/admin/templates/troubleshooting).", + } { + html := HTMLFromNotificationMarkdown(markdown) + assert.Contains(t, html, `<a href="https://coder.com/docs/`, "trusted link did not render: %s", html) + } + }) + + t.Run("HTMLFromMarkdownStillAutolinks", func(t *testing.T) { + t.Parallel() + + // The shared renderer keeps Autolink, so the OIDC signups-disabled page + // still linkifies. Safelink does now apply; see TestHTMLFromMarkdownSafelink. + require.Contains(t, HTMLFromMarkdown("see https://coder.com/docs"), `<a href="https://coder.com/docs"`) + }) + + t.Run("SafelinkRejectsUnsafeSchemes", func(t *testing.T) { + t.Parallel() + + for _, dest := range []string{"javascript:alert(1)", "data:text/html;base64,PHNjcmlwdD4="} { + html := HTMLFromNotificationMarkdown(fmt.Sprintf("[click](%s)", dest)) + assert.NotContains(t, html, "<a ", "unsafe scheme %q was linked", dest) + } + }) +} diff --git a/coderd/render/escape_sink_internal_test.go b/coderd/render/escape_sink_internal_test.go new file mode 100644 index 00000000000..5080f9817bd --- /dev/null +++ b/coderd/render/escape_sink_internal_test.go @@ -0,0 +1,321 @@ +package render + +import ( + "strings" + "testing" + + "github.com/gomarkdown/markdown/parser" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + xhtml "golang.org/x/net/html" +) + +// permissive is the grammar notificationExtensions used to enable. Tests that +// must exercise the escaper rather than the allowlist render against it. +const permissive = parser.CommonExtensions | parser.HardLineBreak + +// suspendedBody mirrors the live TemplateUserAccountSuspended body: the +// untrusted value sits mid-paragraph with trusted text on both sides. +func suspendedBody(value string) string { + return "The account belongs to **" + value + "** and it was suspended by **rob**." +} + +// TestEscapeMarkdownFenceInfo covers the info-string sink that made backtick +// inlineCritical: a `"` closes the class attribute and a `>` closes the tag. +func TestEscapeMarkdownFenceInfo(t *testing.T) { + t.Parallel() + + const info = `"><a/href="https://attacker.example/login">Click here` + + // Each payload needs a line after the closing fence, or the template's + // trailing text lands on it and the fence stops being one. + for name, value := range map[string]string{ + "Anchor": "Eve\n\n```" + info + "\nhidden\n```\nmore", + "Image": "Eve\n\n```\"><img/src=x/onerror=alert(1)>\nhidden\n```\nmore", + "Tilde": "Eve\n\n~~~" + info + "\nhidden\n~~~\nmore", + "AtStart": "```" + info + "\nhidden\n```\nmore", + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + + html := HTMLFromNotificationMarkdown(suspendedBody(EscapeMarkdown(value))) + assert.NotContains(t, html, `class="language-`, + "an info string reached the class attribute: %s", html) + assert.NotContains(t, html, "<a/", "the info string produced an anchor: %s", html) + assert.NotContains(t, html, "<img/", "the info string produced an image: %s", html) + }) + } + + // Liveness: unescaped, the anchor payload must reach the sink. + raw := HTMLFromNotificationMarkdown(suspendedBody("Eve\n\n```" + info + "\nhidden\n```\nmore")) + require.Contains(t, raw, `class="language-`, + "vacuous test: the payload no longer reaches the info-string sink even unescaped") +} + +// TestEscapeMarkdownColon covers ":", rendered under CommonExtensions so the +// shipped allowlist, which kills these constructs anyway, cannot carry the test. +func TestEscapeMarkdownColon(t *testing.T) { + t.Parallel() + + for name, value := range map[string]string{ + "DefinitionList": "Term\n: definition", + "TableColonBoth": "a | b\n:-- | --:\nc | d", + "TableColonCentre": "a | b\n:-: | :-:\nc | d", + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + + escaped := EscapeMarkdown(value) + html := renderHTML(suspendedBody(escaped), permissive) + plain, err := PlaintextFromMarkdown(suspendedBody(escaped)) + require.NoError(t, err) + + for _, tag := range []string{"<table", "<dl", "<dt", "<dd"} { + assert.NotContains(t, html, tag, "value %q rendered %s", value, html) + } + assert.NotContains(t, plain, `\`, "folding ':' should not leak a backslash: %q", plain) + + // Liveness: unescaped, each value must produce one of those tags. + raw := renderHTML(suspendedBody(value), permissive) + assert.True(t, + strings.Contains(raw, "<table") || strings.Contains(raw, "<dl"), + "vacuous row: %q produces no table or definition list even unescaped", value) + }) + } +} + +// TestNotificationExtensionsDropUnusedGrammar pins the allowlist: each construct +// is openable from an untrusted value and used by no template. +func TestNotificationExtensionsDropUnusedGrammar(t *testing.T) { + t.Parallel() + + for name, tc := range map[string]struct{ markdown, tag string }{ + "Tables": {"a | b\n:-- | --:\nc | d", "<table"}, + "DefinitionLists": {"Term\n: definition", "<dl"}, + "MathJax": {"Eve $x^2$ end", `class="math`}, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + + require.Contains(t, renderHTML(tc.markdown, permissive), tc.tag, + "the construct is no longer reachable under CommonExtensions, so this test guards nothing") + assert.NotContains(t, HTMLFromNotificationMarkdown(tc.markdown), tc.tag, + "notificationExtensions still enables %s", name) + }) + } + + // What shipped templates do use must keep rendering. + for _, tc := range []struct{ markdown, want string }{ + {"see [the docs](https://coder.com/docs/x).", `<a href="https://coder.com/docs/x"`}, + {"Your workspace **foo** was suspended.", "<strong>foo</strong>"}, + {"Resources:\n\n- one\n- two\n", "<li>"}, + {"marked as [**dormant**](https://coder.com/docs/y) because", `<a href="https://coder.com/docs/y"`}, + } { + assert.Contains(t, HTMLFromNotificationMarkdown(tc.markdown), tc.want) + } +} + +// TestEscapeMarkdownIndentedCode covers the one construct with no escape: a +// space cannot be escaped, so the indent run is truncated instead. +func TestEscapeMarkdownIndentedCode(t *testing.T) { + t.Parallel() + + for name, value := range map[string]string{ + "FourSpaces": "Eve\n\n hidden", + "EightSpaces": "Eve\n\n hidden", + "SingleBreak": "Eve\n hidden", + "DeepInList": "Eve\n\n - hidden", + "OnlyIndented": " hidden", + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + + html := HTMLFromNotificationMarkdown(suspendedBody(EscapeMarkdown(value))) + assert.NotContains(t, html, "<pre", "value %q produced a code block: %s", value, html) + }) + } + + // Indentation up to the cap is preserved, so documented multi-line custom + // notification values keep their shape. + require.Equal(t, "Test results:\n • ok", EscapeMarkdown("Test results:\n • ok")) + require.Equal(t, "a\n b", EscapeMarkdown("a\n b")) + require.Equal(t, "a\n b", EscapeMarkdown("a\n b")) +} + +// TestEscapeMarkdownEmptyLinkDestination covers the panic html.Safelink +// introduced: parser.IsSafeURL slices a destination before bounds-checking it. +func TestEscapeMarkdownEmptyLinkDestination(t *testing.T) { + t.Parallel() + + for _, md := range []string{ + "[our docs]()", "![px]()", "[a]( )", "[](https://coder.com)", "[a](x)", + } { + assert.NotPanics(t, func() { _ = HTMLFromNotificationMarkdown(md) }, "markdown %q", md) + // Reachable outside notifications, via OIDCConfig.SignupsDisabledText. + assert.NotPanics(t, func() { _ = HTMLFromMarkdown(md) }, "markdown %q", md) + } + + // Destinations Safelink still permits must keep rendering. + for _, tc := range []struct{ md, want string }{ + {"[a](https://coder.com)", `<a href="https://coder.com"`}, + {"[a](/path)", `<a href="/path"`}, + {"[a](./p)", `<a href="./p"`}, + {"[a](mailto:x@y.z)", `<a href="mailto:x@y.z"`}, + } { + assert.Contains(t, HTMLFromNotificationMarkdown(tc.md), tc.want) + } + + // And the ones it rejects must stay rejected. + for _, md := range []string{"[a](javascript:alert(1))", "[a](data:text/html;base64,eA==)"} { + assert.NotContains(t, HTMLFromNotificationMarkdown(md), "<a ", "unsafe scheme linked: %s", md) + } + + // Safelink also drops these two, a silent loss for a template author rather + // than a security property. Pinned so renderHTML's comment cannot drift. + for _, md := range []string{"[a](#anchor)", "[a](docs/x.md)"} { + assert.NotContains(t, HTMLFromNotificationMarkdown(md), "<a ", + "destination %q now renders an anchor; update the comment on renderHTML", md) + } +} + +// TestEscapeMarkdownLeadingFoldConstruct covers a first line the fold cannot +// reach. Escaping costs a visible backslash, so the untouched cases matter too. +func TestEscapeMarkdownLeadingFoldConstruct(t *testing.T) { + t.Parallel() + + t.Run("TitleKeepsItsTrustedText", func(t *testing.T) { + t.Parallel() + + for _, value := range []string{"~~~", "~~~~", "~~~x", "~~~ ", " ~~~"} { + subject, err := PlaintextFromMarkdown(EscapeMarkdown(value) + " shared a chat with you") + require.NoError(t, err) + assert.Contains(t, subject, "shared a chat with you", + "value %q swallowed the trusted subject text", value) + } + + // The backtick spelling is closed by backtick being inlineCritical. + subject, err := PlaintextFromMarkdown(EscapeMarkdown("```") + " shared a chat with you") + require.NoError(t, err) + require.Equal(t, "``` shared a chat with you", subject) + }) + + t.Run("SetextCannotPromoteATrustedLine", func(t *testing.T) { + t.Parallel() + + for _, value := range []string{"===", "=", "===\nx", " === "} { + html := HTMLFromNotificationMarkdown( + "Trusted line\n" + EscapeMarkdown(value) + "\nTrusted trailer.") + assert.NotContains(t, html, "<h1", "value %q promoted a heading: %s", value, html) + } + }) + + t.Run("NonConstructsAreUntouched", func(t *testing.T) { + t.Parallel() + + // These begin with a fold character without being a construct; escaping + // them would put a backslash in front of an ordinary display name. + for _, value := range []string{ + "=> next", "~tilde name", "= x", "~~strike~~", "=?utf-8?q?x?=", + "~", "~~", "=== and more", "a\n===", + } { + plain, err := PlaintextFromMarkdown(EscapeMarkdown(value) + " end") + require.NoError(t, err) + assert.NotContains(t, plain, `\`, + "value %q was escaped when it is not a fold construct", value) + } + }) +} + +// TestRecoverToEscapedSource drives renderHTML's panic guard directly: safeURL +// closed the only input known to panic it. +func TestRecoverToEscapedSource(t *testing.T) { + t.Parallel() + + const src = `<script>alert(1)</script> & "quoted"` + + got := recoverToEscapedSource(src, func() string { panic("boom") }) + assert.Equal(t, xhtml.EscapeString(src), got) + // The point of escaping rather than returning the source: no markup escapes. + assert.NotContains(t, got, "<script>") + + assert.Equal(t, "rendered", + recoverToEscapedSource(src, func() string { return "rendered" })) +} + +// TestHTMLFromMarkdownSafelink pins the behavior change Safelink brought to the +// shared renderer, called by OIDCConfig.SignupsDisabledText. +func TestHTMLFromMarkdownSafelink(t *testing.T) { + t.Parallel() + + // Unsafe schemes stopped linking here, not just in notifications. + for _, md := range []string{"[a](javascript:alert(1))", "[a](data:text/html;base64,eA==)"} { + assert.NotContains(t, HTMLFromMarkdown(md), "<a ", "unsafe scheme linked: %s", md) + } + + // Fragment and bare relative destinations stopped linking too, a silent loss + // rather than a security property. See renderHTML. + for _, md := range []string{"[a](#anchor)", "[a](docs/x.md)"} { + assert.NotContains(t, HTMLFromMarkdown(md), "<a ", "destination %q now links", md) + } + + // What the signups-disabled text actually uses must keep working. + for _, tc := range []struct{ md, want string }{ + {"see https://coder.com/docs", `<a href="https://coder.com/docs"`}, + {"[docs](https://coder.com/docs)", `<a href="https://coder.com/docs"`}, + {"contact [us](mailto:support@coder.com)", `<a href="mailto:support@coder.com"`}, + {"**bold** and _italic_", "<strong>bold</strong>"}, + } { + assert.Contains(t, HTMLFromMarkdown(tc.md), tc.want) + } +} + +// TestEscapeMarkdownResiduals pins the one gap EscapeMarkdown cannot close. +func TestEscapeMarkdownResiduals(t *testing.T) { + t.Parallel() + + t.Run("CodeSpanSwallowsEscapes", func(t *testing.T) { + t.Parallel() + + // CommonMark does not process escapes inside a code span, and the + // workspace out-of-disk body wraps a value in one. + html := HTMLFromNotificationMarkdown("The volume `" + EscapeMarkdown("config[0]") + "` is full.") + require.Contains(t, html, `config\[0\]`, + "if this no longer leaks, the residual is closed and this test should become an assertion that it stays closed") + }) +} + +// TestEscapeMarkdownNoStrayBackslash asserts the no-stray-backslash invariant +// across every interpolation position a shipped template provides. +func TestEscapeMarkdownNoStrayBackslash(t *testing.T) { + t.Parallel() + + positions := map[string]func(string) string{ + "midline": suspendedBody, + "linestart": func(v string) string { return v + " shared a chat with you." }, + "afterblank": func(v string) string { return "Hi.\n\n" + v + "\n\nRegards." }, + "listitem": func(v string) string { return "Resources:\n\n- " + v + "\n" }, + "trailing": func(v string) string { return "The account belongs to **" + v + "**" }, + } + + for _, value := range []string{ + "William Tables", "bobby-workspace", "Bobby's Template", + "O'Brien-Smith (Eng) 100%", "José Müller 日本語", + "config[0]", "vol(1)", "1.5", "user_override", + } { + for name, pos := range positions { + t.Run(name+"/"+value, func(t *testing.T) { + t.Parallel() + + md := pos(EscapeMarkdown(value)) + html := HTMLFromNotificationMarkdown(md) + plain, err := PlaintextFromMarkdown(md) + require.NoError(t, err) + + assert.NotContains(t, html, `\`, "stray backslash in HTML: %s", html) + assert.NotContains(t, plain, `\`, "stray backslash in plaintext: %q", plain) + assert.False(t, strings.Contains(html, `class="language-`), + "benign value reached the info-string sink: %s", html) + }) + } + } +} diff --git a/coderd/render/markdown.go b/coderd/render/markdown.go index 75e6d8d1c18..2e7a9ac8ed3 100644 --- a/coderd/render/markdown.go +++ b/coderd/render/markdown.go @@ -9,6 +9,7 @@ import ( gomarkdown "github.com/gomarkdown/markdown" "github.com/gomarkdown/markdown/html" "github.com/gomarkdown/markdown/parser" + xhtml "golang.org/x/net/html" "golang.org/x/xerrors" ) @@ -100,11 +101,85 @@ func PlaintextFromMarkdown(markdown string) (string, error) { return strings.TrimSpace(output), nil } +// notificationExtensions is an allowlist. Shipped templates use only core +// CommonMark, so Tables, DefinitionLists, MathJax and Autolink are absent; each +// is openable from an untrusted label value. Adding one back means revisiting +// EscapeMarkdown. +const notificationExtensions = parser.NoIntraEmphasis | parser.HardLineBreak + func HTMLFromMarkdown(markdown string) string { - p := parser.NewWithExtensions(parser.CommonExtensions | parser.HardLineBreak) // Added HardLineBreak. + return renderHTML(markdown, parser.CommonExtensions|parser.HardLineBreak) // Added HardLineBreak. +} + +// HTMLFromNotificationMarkdown converts a rendered notification body to HTML. +// Unlike HTMLFromMarkdown it does not autolink bare URLs, because notification +// bodies interpolate attacker-controlled label values. +func HTMLFromNotificationMarkdown(markdown string) string { + return renderHTML(markdown, notificationExtensions) +} + +// longestURLPath is the longest relative-path prefix parser.IsSafeURL compares +// against. Derived so a dependency bump that adds a longer one stays correct. +var longestURLPath = func() int { + longest := 0 + for _, p := range parser.Paths { + if len(p) > longest { + longest = len(p) + } + } + return longest +}() + +// safeURL wraps parser.IsSafeURL, which slices a destination to each candidate +// prefix length before checking it is that long, and so panics on a short one +// with no spare capacity, as "[docs]()" produces. Padding the capacity keeps +// the slice in bounds; IsSafeURL's own guards still decide the result. +func safeURL(url []byte) bool { + if cap(url) < longestURLPath { + padded := make([]byte, len(url), longestURLPath) + copy(padded, url) + url = padded + } + return parser.IsSafeURL(url) +} + +// recoverToEscapedSource runs render and, if it panics, returns the source +// HTML-escaped instead: the notification still arrives, showing Markdown +// source, and no markup escapes. Kept separate from renderHTML so the recovery +// is testable, safeURL having closed the only input known to panic. +func recoverToEscapedSource(markdown string, render func() string) (out string) { + defer func() { + if r := recover(); r != nil { + out = xhtml.EscapeString(markdown) + } + }() + return render() +} + +// renderHTML converts Markdown to HTML. Input is untrusted, so a parser panic +// is recovered rather than taking down the dispatcher. +// +// Safelink silently drops fragment and bare relative destinations, so [a](#x) +// and [a](docs/x.md) render without an anchor. /path, ./path, mailto: and +// http(s):// still work. +func renderHTML(markdown string, extensions parser.Extensions) string { + return recoverToEscapedSource(markdown, func() string { + return renderHTMLUnsafe(markdown, extensions) + }) +} + +// renderHTMLUnsafe is renderHTML without the panic guard. +func renderHTMLUnsafe(markdown string, extensions parser.Extensions) string { + p := parser.NewWithExtensions(extensions) + p.IsSafeURLOverride = safeURL doc := p.Parse([]byte(markdown)) renderer := html.NewRenderer(html.RendererOptions{ - Flags: html.CommonFlags | html.SkipHTML, + // Safelink restricts generated hrefs to trusted schemes, which keeps + // javascript: and data: out of rendered output. + Flags: html.CommonFlags | html.SkipHTML | html.Safelink, }) + // Safelink routes every destination through parser.IsSafeURL, which panics + // on a short one. The hook lives on the renderer, not on its options. + renderer.IsSafeURLOverride = safeURL return string(bytes.TrimSpace(gomarkdown.Render(doc, renderer))) } diff --git a/scripts/check_emdash.sh b/scripts/check_emdash.sh index 410318c6f0d..28e059353ee 100755 --- a/scripts/check_emdash.sh +++ b/scripts/check_emdash.sh @@ -26,6 +26,11 @@ exclude_pathspecs=( # Generated CLI golden files embed serpent's emdash-bordered footer. ":(exclude)cli/testdata/*.golden" ":(exclude)enterprise/cli/testdata/*.golden" + # Generated notification golden files embed every stored notification + # template, and one carries an emdash from before this check existed + # (migration 000324). It lives in an applied migration, so it cannot be + # edited in place. + ":(exclude)coderd/notifications/testdata/rendered-templates/**/*.golden" ) scan_all_files() { From e70e9b16f7edfccea34d3ba875e54c43ff1250e5 Mon Sep 17 00:00:00 2001 From: Bobby Ho <bobby@coder.com> Date: Wed, 26 Aug 2026 12:32:11 -0700 Subject: [PATCH 118/138] fix(coderd/notifications): HTML-escape the email template values (#28397) (#28646) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport of https://github.com/coder/coder/pull/28397 Original PR: #28397 — fix(coderd/notifications): HTML-escape the email template values Merge commit: 2236710badb55e1fe60109aff32e4408513e1bb1 Requested by: @BobbyHo Opened manually because the Backport workflow run for `release/2.34` failed: https://github.com/coder/coder/actions/runs/32985803559/job/98231735640 ## Why the automatic backport failed Two separate things went wrong in that run, and only one of them is about conflicts. The cherry-pick hit nine `modify/delete` conflicts (below). That alone is not fatal — the same happened for `release/2.36` and `release/2.37`, where the job still pushed a placeholder branch and opened a PR for manual resolution. What actually failed the job was the push: ``` ! [remote rejected] backport/28397-to-2.34 -> backport/28397-to-2.34 (Unable to determine if workflow can be created or updated due to timeout; `workflows` scope may be required.) ``` This cherry-pick touches no files under `.github/workflows/`, so this is GitHub timing out while determining scope rather than a genuine permission gap. The push was retried by hand for this PR and succeeded unchanged. The net effect of the failure was that no branch and no PR were created for 2.34 at all, so this one is opened from scratch rather than fixed up in place. The `release/2.35` job (#28644) failed the same way. ## Manual resolution: 9 golden files dropped `release/2.34` already carries #28606 (the 2.34 backport of #28340), so the `notifier.go` hunk applies cleanly. What remains is a genuine `modify/delete` conflict on nine golden files: ``` TemplateAIBudgetLimitReachedUser.html.golden TemplateAIBudgetWarningUser.html.golden TemplateUserAccountActivatedServiceAccount.html.golden TemplateUserAccountCreatedServiceAccount.html.golden TemplateUserAccountCreatedWithoutAccountType.html.golden TemplateUserAccountDeletedServiceAccount.html.golden TemplateUserAccountSuspendedServiceAccount.html.golden TemplateWorkspaceAutostopReminder.html.golden TemplateWorkspaceDormant_NoAutoDelete.html.golden ``` These are fixtures for notification templates and test cases that postdate the 2.34 branch point — AI budgets, service accounts, the autostop reminder, and the no-auto-delete variant of the dormancy notice. `coderd/notifications/` on `release/2.34` contains no reference to `AIBudget`, `BudgetLimitReached`, `BudgetWarning`, `ServiceAccount`, `AccountType`, `AutostopReminder` or `NoAutoDelete`, so there is no template to render them and no test case that reads them. **All nine were removed rather than added**; carrying them over would leave orphan fixtures. Note that `TemplateWorkspaceDormant.html.golden` does exist on 2.34 and is updated normally by this PR. Only the `_NoAutoDelete` variant of that test case is dropped. This is the only deviation from the original PR. Verified with a diff-of-diffs: excluding those nine paths, this commit matches #28397 line for line. The only textual differences are blob hashes and hunk line offsets — `notifier.go` sits one line higher on this branch, so the hunk header reads `@@ -252` instead of `@@ -253`. The escaping changes to `html.gotmpl`, `notifier.go` and `smtp_internal_test.go` are fully intact. Net: 30 files, +226/−71 (upstream: 39 files, +244/−89 — the delta is exactly the nine goldens). The smtp golden directory holds 36 files before and after, so nothing was added or lost. ## Verification - `go vet ./coderd/notifications/...` — clean - `go test ./coderd/notifications/dispatch/...` — pass, including the three new `TestSMTPHTMLTemplateEscapes*` tests - `go test ./coderd/notifications/ -run TestNotificationTemplates_Golden` — pass across all affected goldens ## Related backports - #28603 — `release/2.37` - #28604 — `release/2.36` - #28644 — `release/2.35` - #28643 — `release/2.29` (still needs manual resolution) --- .../notifications/dispatch/smtp/html.gotmpl | 16 +- .../dispatch/smtp_internal_test.go | 160 +++++++++++++++++- coderd/notifications/notifier.go | 4 +- .../smtp/TemplateTaskCompleted.html.golden | 4 +- .../smtp/TemplateTaskFailed.html.golden | 4 +- .../smtp/TemplateTaskIdle.html.golden | 4 +- .../smtp/TemplateTaskPaused.html.golden | 4 +- .../smtp/TemplateTaskResumed.html.golden | 4 +- .../smtp/TemplateTaskWorking.html.golden | 4 +- .../smtp/TemplateTemplateDeleted.html.golden | 4 +- .../TemplateTemplateDeprecated.html.golden | 4 +- .../TemplateUserAccountActivated.html.golden | 4 +- .../TemplateUserAccountCreated.html.golden | 4 +- .../TemplateUserAccountDeleted.html.golden | 4 +- .../TemplateUserAccountSuspended.html.golden | 4 +- ...teUserRequestedOneTimePasscode.html.golden | 8 +- .../TemplateWorkspaceAutoUpdated.html.golden | 5 +- ...mplateWorkspaceAutobuildFailed.html.golden | 4 +- .../smtp/TemplateWorkspaceCreated.html.golden | 4 +- .../smtp/TemplateWorkspaceDeleted.html.golden | 4 +- ...kspaceDeleted_CustomAppearance.html.golden | 4 +- .../smtp/TemplateWorkspaceDormant.html.golden | 4 +- ...lateWorkspaceManualBuildFailed.html.golden | 4 +- ...mplateWorkspaceManuallyUpdated.html.golden | 5 +- ...lateWorkspaceMarkedForDeletion.html.golden | 4 +- .../TemplateWorkspaceOutOfDisk.html.golden | 5 +- ...spaceOutOfDisk_MultipleVolumes.html.golden | 5 +- .../TemplateWorkspaceOutOfMemory.html.golden | 5 +- .../TemplateYourAccountActivated.html.golden | 4 +- .../TemplateYourAccountSuspended.html.golden | 4 +- 30 files changed, 226 insertions(+), 71 deletions(-) diff --git a/coderd/notifications/dispatch/smtp/html.gotmpl b/coderd/notifications/dispatch/smtp/html.gotmpl index cecba560af2..2deb5505a4a 100644 --- a/coderd/notifications/dispatch/smtp/html.gotmpl +++ b/coderd/notifications/dispatch/smtp/html.gotmpl @@ -3,7 +3,7 @@ <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> - <title>{{ .Labels._subject }} + {{ .Labels._subject | html }}
@@ -11,23 +11,23 @@ {{ app_name | html }} Logo

- {{ .Labels._subject }} + {{ .Labels._subject | html }}

-

Hi {{ .UserName }},

+

Hi {{ .UserName | html }},

{{ .Labels._body }}
-

© {{ current_year }} Coder. All rights reserved - {{ base_url }}

-

Click here to manage your notification settings

-

Stop receiving emails like this

+

© {{ current_year | html }} Coder. All rights reserved - {{ base_url | html }}

+

Click here to manage your notification settings

+

Stop receiving emails like this

diff --git a/coderd/notifications/dispatch/smtp_internal_test.go b/coderd/notifications/dispatch/smtp_internal_test.go index 1284cb070e1..187191d7c87 100644 --- a/coderd/notifications/dispatch/smtp_internal_test.go +++ b/coderd/notifications/dispatch/smtp_internal_test.go @@ -10,8 +10,100 @@ import ( "github.com/coder/coder/v2/coderd/notifications/render" "github.com/coder/coder/v2/coderd/notifications/types" + markdown "github.com/coder/coder/v2/coderd/render" ) +// Benign values, so a test measures only what its own payload injected. +func templateHelpers() map[string]any { + return map[string]any{ + "base_url": func() string { return "https://coder.example.com" }, + "current_year": func() string { return "2026" }, + "logo_url": func() string { return "https://coder.example.com/logo.png" }, + "app_name": func() string { return "Coder" }, + } +} + +func TestSMTPHTMLTemplateEscapesUntrustedValues(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + title string + userName string + actions []types.TemplateAction + injected string + }{ + { + name: "EntityEncodedAnchorInSubject", + title: `Template "<a href="https://attacker.example/login">Re-authenticate now</a>" deleted`, + userName: "Bobby", + injected: `Re-authenticate now`, + }, + { + name: "EntityEncodedImageInSubject", + title: `Workspace "<img src=x onerror="alert(1)">" marked dormant`, + userName: "Bobby", + injected: ``, + }, + { + name: "RawHTMLInUserName", + title: "Account suspended", + userName: `Bobby `, + injected: ``, + }, + { + name: "RawHTMLInActionLabel", + title: "Account suspended", + userName: "Bobby", + actions: []types.TemplateAction{ + {Label: ``, URL: "https://coder.example.com/"}, + }, + injected: ``, + }, + { + name: "RawHTMLInActionURL", + title: "Account suspended", + userName: "Bobby", + actions: []types.TemplateAction{ + {Label: "Open Coder", URL: `https://coder.example.com/?x=`}, + }, + injected: ``, + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // Decodes the entities, so the title arrives as live markup. + subject, err := markdown.PlaintextFromMarkdown(tc.title) + require.NoError(t, err) + + // Actions are set as the template sees them. The enqueuer renders + // them into JSON first, which rejects a `"` of its own accord. + payload := types.MessagePayload{ + NotificationTemplateID: "00000000-0000-0000-0000-000000000000", + UserName: tc.userName, + Actions: tc.actions, + Labels: map[string]string{ + "_subject": subject, + "_body": "

Test body

", + }, + } + + got, err := render.GoTemplate(htmlTemplate, payload, templateHelpers()) + require.NoError(t, err) + + escaped := html.EscapeString(tc.injected) + require.NotEqual(t, tc.injected, escaped, + "case carries no HTML to escape, so it guards nothing") + + require.NotContains(t, got, tc.injected, + "untrusted markup reached the rendered email: %s", got) + require.Contains(t, got, escaped, + "the value must still be displayed, entity encoded: %s", got) + }) + } +} + func TestSMTPHTMLTemplateEscapesAppearanceHelpers(t *testing.T) { t.Parallel() @@ -28,12 +120,9 @@ func TestSMTPHTMLTemplateEscapesAppearanceHelpers(t *testing.T) { "_body": "

Test body

", }, } - helpers := map[string]any{ - "base_url": func() string { return "https://coder.example.com" }, - "current_year": func() string { return "2026" }, - "logo_url": func() string { return logoURL }, - "app_name": func() string { return appName }, - } + helpers := templateHelpers() + helpers["logo_url"] = func() string { return logoURL } + helpers["app_name"] = func() string { return appName } got, err := render.GoTemplate(htmlTemplate, payload, helpers) require.NoError(t, err) @@ -44,6 +133,65 @@ func TestSMTPHTMLTemplateEscapesAppearanceHelpers(t *testing.T) { require.False(t, strings.Contains(got, logoURL), "raw logo URL must not be rendered") } +// The template escapes every value it interpolates except _body, which is +// trusted rendered Markdown. The three values here cannot carry markup in +// production, so this test is the only thing that fails if their escaping is +// removed. +func TestSMTPHTMLTemplateEscapesTrustedValues(t *testing.T) { + t.Parallel() + + const injected = `a"onclick=alert(1)` + + for _, tc := range []struct { + name string + apply func(*types.MessagePayload, map[string]any) + }{ + { + // net/url preserves a quote in the query and --access-url is + // validated for its scheme only, so an operator can land this. + name: "BaseURL", + apply: func(_ *types.MessagePayload, h map[string]any) { + h["base_url"] = func() string { return "https://coder.example.com/?q=" + injected } + }, + }, + { + name: "CurrentYear", + apply: func(_ *types.MessagePayload, h map[string]any) { + h["current_year"] = func() string { return injected } + }, + }, + { + name: "NotificationTemplateID", + apply: func(p *types.MessagePayload, _ map[string]any) { + p.NotificationTemplateID = injected + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + payload := types.MessagePayload{ + NotificationTemplateID: "00000000-0000-0000-0000-000000000000", + UserName: "Test User", + Labels: map[string]string{ + "_subject": "Test notification", + "_body": "

Test body

", + }, + } + helpers := templateHelpers() + tc.apply(&payload, helpers) + + got, err := render.GoTemplate(htmlTemplate, payload, helpers) + require.NoError(t, err) + + require.NotContains(t, got, injected, + "raw value reached the rendered email: %s", got) + require.Contains(t, got, html.EscapeString(injected), + "the value must still be displayed, entity encoded: %s", got) + }) + } +} + func TestValidateFromAddr(t *testing.T) { t.Parallel() diff --git a/coderd/notifications/notifier.go b/coderd/notifications/notifier.go index 3bfb548e2dc..beaeb08ffc8 100644 --- a/coderd/notifications/notifier.go +++ b/coderd/notifications/notifier.go @@ -252,7 +252,9 @@ func (n *notifier) prepare(ctx context.Context, msg database.AcquireNotification // Label and data values are user-controlled while the templates around them // are not, so Markdown structure in a value is neutralized before it reaches // the template. The dispatcher still receives the unescaped payload, because - // the webhook contract surfaces enqueued values verbatim. + // the webhook contract surfaces enqueued values verbatim. smtp/html.gotmpl + // escapes at its own sinks, which it must: PlaintextFromMarkdown strips this + // escaping back out of _subject. escaped := payload.EscapedForMarkdown() var title, body string diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskCompleted.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskCompleted.html.golden index 769d5595dbc..b4a2d53763c 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskCompleted.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskCompleted.html.golden @@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8 - Task 'my-workspace' completed + Task 'my-workspace' completed

- Task 'my-workspace' completed + Task 'my-workspace' completed

Hi Bobby,

diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskFailed.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskFailed.html.golden index 5d0879bc82d..1a17d690186 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskFailed.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskFailed.html.golden @@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8 - Task 'my-workspace' failed + Task 'my-workspace' failed

- Task 'my-workspace' failed + Task 'my-workspace' failed

Hi Bobby,

diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskIdle.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskIdle.html.golden index 578e39e91a2..0c4fea1cf9d 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskIdle.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskIdle.html.golden @@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8 - Task 'my-workspace' is idle + Task 'my-workspace' is idle

- Task 'my-workspace' is idle + Task 'my-workspace' is idle

Hi Bobby,

diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskPaused.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskPaused.html.golden index 58a1f098f77..f22fc19c5b6 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskPaused.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskPaused.html.golden @@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8 - Task 'my-task' is paused + Task 'my-task' is paused

- Task 'my-task' is paused + Task 'my-task' is paused

Hi Bobby,

diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskResumed.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskResumed.html.golden index 81d2498b579..4d71d3c8ec9 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskResumed.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskResumed.html.golden @@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8 - Task 'my-task' has resumed + Task 'my-task' has resumed

- Task 'my-task' has resumed + Task 'my-task' has resumed

Hi Bobby,

diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskWorking.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskWorking.html.golden index 21356601f62..b0cc318c350 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskWorking.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskWorking.html.golden @@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8 - Task 'my-workspace' is working + Task 'my-workspace' is working

- Task 'my-workspace' is working + Task 'my-workspace' is working

Hi Bobby,

diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateTemplateDeleted.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateTemplateDeleted.html.golden index 75af5a264e6..3103a58b97a 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateTemplateDeleted.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateTemplateDeleted.html.golden @@ -27,7 +27,7 @@ Content-Type: text/html; charset=UTF-8 - Template "Bobby's Template" deleted + Template "Bobby's Template" deleted

- Template "Bobby's Template" deleted + Template "Bobby's Template" deleted

Hi Bobby,

diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateTemplateDeprecated.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateTemplateDeprecated.html.golden index 70c27eed186..6eea0d7a8db 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateTemplateDeprecated.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateTemplateDeprecated.html.golden @@ -35,7 +35,7 @@ Content-Type: text/html; charset=UTF-8 - Template 'alpha' has been deprecated + Template 'alpha' has been deprecated

- Template 'alpha' has been deprecated + Template 'alpha' has been deprecated

Hi Bobby,

diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserAccountActivated.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserAccountActivated.html.golden index 011ef84ebfb..ff4ee4976af 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserAccountActivated.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserAccountActivated.html.golden @@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8 - User account "bobby" activated + User account "bobby" activated

- User account "bobby" activated + User account "bobby" activated

Hi Bobby,

diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserAccountCreated.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserAccountCreated.html.golden index 6fc619e4129..fa16f497bb9 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserAccountCreated.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserAccountCreated.html.golden @@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8 - User account "bobby" created + User account "bobby" created

- User account "bobby" created + User account "bobby" created

Hi Bobby,

diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserAccountDeleted.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserAccountDeleted.html.golden index cfcb22beec1..e52d1b71ab8 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserAccountDeleted.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserAccountDeleted.html.golden @@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8 - User account "bobby" deleted + User account "bobby" deleted

- User account "bobby" deleted + User account "bobby" deleted

Hi Bobby,

diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserAccountSuspended.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserAccountSuspended.html.golden index 9664bc88924..99cddd3593b 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserAccountSuspended.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserAccountSuspended.html.golden @@ -30,7 +30,7 @@ Content-Type: text/html; charset=UTF-8 - User account "bobby" suspended + User account "bobby" suspended

- User account "bobby" suspended + User account "bobby" suspended

Hi Bobby,

diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserRequestedOneTimePasscode.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserRequestedOneTimePasscode.html.golden index 12e29c47ed0..819fc0d0e85 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserRequestedOneTimePasscode.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserRequestedOneTimePasscode.html.golden @@ -56,10 +56,10 @@ argin: 8px 0 32px; line-height: 1.5;">
=20 +2-4cdb-87f1-0486f1bea415&email=3Dbobby%2Fdrop-table%2Buser%40coder.com"= + style=3D"display: inline-block; padding: 13px 24px; background-color: #020= +617; color: #f8fafc; text-decoration: none; border-radius: 8px; margin: 0 4= +px;"> Reset password =20 diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceAutoUpdated.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceAutoUpdated.html.golden index 2304fbf01bd..378f13e534d 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceAutoUpdated.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceAutoUpdated.html.golden @@ -30,7 +30,8 @@ Content-Type: text/html; charset=UTF-8 - Workspace "bobby-workspace" updated automatically + Workspace "bobby-workspace" updated automatically</title= +> </head> <body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-= ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel= @@ -45,7 +46,7 @@ er Logo" style=3D"height: 40px;" /> </div> <h1 style=3D"text-align: center; font-size: 24px; font-weight: 400; m= argin: 8px 0 32px; line-height: 1.5;"> - Workspace "bobby-workspace" updated automatically + Workspace "bobby-workspace" updated automatically </h1> <div style=3D"line-height: 1.5;"> <p>Hi Bobby,</p> diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceAutobuildFailed.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceAutobuildFailed.html.golden index c132ffb47d9..7cb61a67011 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceAutobuildFailed.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceAutobuildFailed.html.golden @@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8 <meta charset=3D"UTF-8" /> <meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale= =3D1.0" /> - <title>Workspace "bobby-workspace" autobuild failed + Workspace "bobby-workspace" autobuild failed

- Workspace "bobby-workspace" autobuild failed + Workspace "bobby-workspace" autobuild failed

Hi Bobby,

diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceCreated.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceCreated.html.golden index 9fccba0b1f2..97f6e720a28 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceCreated.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceCreated.html.golden @@ -28,7 +28,7 @@ Content-Type: text/html; charset=UTF-8 - Workspace 'bobby-workspace' has been created + Workspace 'bobby-workspace' has been created

- Workspace 'bobby-workspace' has been created + Workspace 'bobby-workspace' has been created

Hi Bobby,

diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceDeleted.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceDeleted.html.golden index fcc9b57f17b..46ebe956cb2 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceDeleted.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceDeleted.html.golden @@ -31,7 +31,7 @@ Content-Type: text/html; charset=UTF-8 - Workspace "bobby-workspace" deleted + Workspace "bobby-workspace" deleted

- Workspace "bobby-workspace" deleted + Workspace "bobby-workspace" deleted

Hi Bobby,

diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceDeleted_CustomAppearance.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceDeleted_CustomAppearance.html.golden index 7c1f7192b1f..cf1e96a2eda 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceDeleted_CustomAppearance.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceDeleted_CustomAppearance.html.golden @@ -31,7 +31,7 @@ Content-Type: text/html; charset=UTF-8 - Workspace "bobby-workspace" deleted + Workspace "bobby-workspace" deleted

- Workspace "bobby-workspace" deleted + Workspace "bobby-workspace" deleted

Hi Bobby,

diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceDormant.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceDormant.html.golden index ee3021c18ce..56b6be012a6 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceDormant.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceDormant.html.golden @@ -34,7 +34,7 @@ Content-Type: text/html; charset=UTF-8 - Workspace "bobby-workspace" marked as dormant + Workspace "bobby-workspace" marked as dormant

- Workspace "bobby-workspace" marked as dormant + Workspace "bobby-workspace" marked as dormant

Hi Bobby,

diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceManualBuildFailed.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceManualBuildFailed.html.golden index 2f7bb2771c8..f1b67ebc39c 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceManualBuildFailed.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceManualBuildFailed.html.golden @@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8 - Workspace "bobby-workspace" manual build failed + Workspace "bobby-workspace" manual build failed

- Workspace "bobby-workspace" manual build failed + Workspace "bobby-workspace" manual build failed

Hi Bobby,

diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceManuallyUpdated.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceManuallyUpdated.html.golden index 0e70293b090..d971cd94c15 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceManuallyUpdated.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceManuallyUpdated.html.golden @@ -31,7 +31,8 @@ Content-Type: text/html; charset=UTF-8 - Workspace 'bobby-workspace' has been manually updated + Workspace 'bobby-workspace' has been manually updated</t= +itle> </head> <body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-= ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel= @@ -46,7 +47,7 @@ er Logo" style=3D"height: 40px;" /> </div> <h1 style=3D"text-align: center; font-size: 24px; font-weight: 400; m= argin: 8px 0 32px; line-height: 1.5;"> - Workspace 'bobby-workspace' has been manually updated + Workspace 'bobby-workspace' has been manually updated </h1> <div style=3D"line-height: 1.5;"> <p>Hi Bobby,</p> diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceMarkedForDeletion.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceMarkedForDeletion.html.golden index bbd73d07b27..d76fb4caf1b 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceMarkedForDeletion.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceMarkedForDeletion.html.golden @@ -30,7 +30,7 @@ Content-Type: text/html; charset=UTF-8 <meta charset=3D"UTF-8" /> <meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale= =3D1.0" /> - <title>Workspace "bobby-workspace" marked for deletion + Workspace "bobby-workspace" marked for deletion

- Workspace "bobby-workspace" marked for deletion + Workspace "bobby-workspace" marked for deletion

Hi Bobby,

diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceOutOfDisk.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceOutOfDisk.html.golden index 1e65a1eab12..15bffe4a810 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceOutOfDisk.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceOutOfDisk.html.golden @@ -27,7 +27,8 @@ Content-Type: text/html; charset=UTF-8 - Your workspace "bobby-workspace" is low on volume space + Your workspace "bobby-workspace" is low on volume space<= +/title> </head> <body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-= ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel= @@ -42,7 +43,7 @@ er Logo" style=3D"height: 40px;" /> </div> <h1 style=3D"text-align: center; font-size: 24px; font-weight: 400; m= argin: 8px 0 32px; line-height: 1.5;"> - Your workspace "bobby-workspace" is low on volume space + Your workspace "bobby-workspace" is low on volume space </h1> <div style=3D"line-height: 1.5;"> <p>Hi Bobby,</p> diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceOutOfDisk_MultipleVolumes.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceOutOfDisk_MultipleVolumes.html.golden index aad0c2190c2..1f134cb0739 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceOutOfDisk_MultipleVolumes.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceOutOfDisk_MultipleVolumes.html.golden @@ -31,7 +31,8 @@ Content-Type: text/html; charset=UTF-8 <meta charset=3D"UTF-8" /> <meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale= =3D1.0" /> - <title>Your workspace "bobby-workspace" is low on volume space + Your workspace "bobby-workspace" is low on volume space<= +/title> </head> <body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-= ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel= @@ -46,7 +47,7 @@ er Logo" style=3D"height: 40px;" /> </div> <h1 style=3D"text-align: center; font-size: 24px; font-weight: 400; m= argin: 8px 0 32px; line-height: 1.5;"> - Your workspace "bobby-workspace" is low on volume space + Your workspace "bobby-workspace" is low on volume space </h1> <div style=3D"line-height: 1.5;"> <p>Hi Bobby,</p> diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceOutOfMemory.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceOutOfMemory.html.golden index b75c2032003..44a0e92b249 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceOutOfMemory.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceOutOfMemory.html.golden @@ -28,7 +28,8 @@ Content-Type: text/html; charset=UTF-8 <meta charset=3D"UTF-8" /> <meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale= =3D1.0" /> - <title>Your workspace "bobby-workspace" is low on memory + Your workspace "bobby-workspace" is low on memory</title= +> </head> <body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-= ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel= @@ -43,7 +44,7 @@ er Logo" style=3D"height: 40px;" /> </div> <h1 style=3D"text-align: center; font-size: 24px; font-weight: 400; m= argin: 8px 0 32px; line-height: 1.5;"> - Your workspace "bobby-workspace" is low on memory + Your workspace "bobby-workspace" is low on memory </h1> <div style=3D"line-height: 1.5;"> <p>Hi Bobby,</p> diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateYourAccountActivated.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateYourAccountActivated.html.golden index b86fd4bf639..b2c0fb7c1af 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateYourAccountActivated.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateYourAccountActivated.html.golden @@ -27,7 +27,7 @@ Content-Type: text/html; charset=UTF-8 <meta charset=3D"UTF-8" /> <meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale= =3D1.0" /> - <title>Your account "bobby" has been activated + Your account "bobby" has been activated

- Your account "bobby" has been activated + Your account "bobby" has been activated

Hi Bobby,

diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateYourAccountSuspended.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateYourAccountSuspended.html.golden index 277195a2bd4..74dee947796 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateYourAccountSuspended.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateYourAccountSuspended.html.golden @@ -25,7 +25,7 @@ Content-Type: text/html; charset=UTF-8 - Your account "bobby" has been suspended + Your account "bobby" has been suspended

- Your account "bobby" has been suspended + Your account "bobby" has been suspended

Hi Bobby,

From 33694a927e2ea617bc2ef1b8bcf5f88a2917a8dd Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 26 Aug 2026 21:34:50 +0200 Subject: [PATCH 119/138] fix(coderd): reject agent requests from suspended owners (#28513) (#28652) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport of https://github.com/coder/coder/pull/28513 Original PR: #28513 — fix(coderd): reject agent requests from suspended owners Merge commit: 2f50b2d7408dff4275f81d18c32e2a517a16c897 Requested by: @hwang251 Co-authored-by: Hank Hwang Co-authored-by: Marcin Tojek --- coderd/httpmw/workspaceagent.go | 8 ++++++- coderd/httpmw/workspaceagent_test.go | 35 ++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/coderd/httpmw/workspaceagent.go b/coderd/httpmw/workspaceagent.go index 47867e17b2c..7c7a28c07f3 100644 --- a/coderd/httpmw/workspaceagent.go +++ b/coderd/httpmw/workspaceagent.go @@ -109,7 +109,7 @@ func ExtractWorkspaceAgentAndLatestBuild(opts ExtractWorkspaceAgentAndLatestBuil return } - subject, _, err := UserRBACSubject( + subject, userStatus, err := UserRBACSubject( ctx, opts.DB, row.WorkspaceTable.OwnerID, @@ -129,6 +129,12 @@ func ExtractWorkspaceAgentAndLatestBuild(opts ExtractWorkspaceAgentAndLatestBuil }) return } + if userStatus != database.UserStatusActive { + httpapi.Write(ctx, rw, http.StatusUnauthorized, codersdk.Response{ + Message: fmt.Sprintf("User is not active (status = %q). Contact an admin to reactivate your account.", userStatus), + }) + return + } ctx = context.WithValue(ctx, workspaceAgentContextKey{}, row.WorkspaceAgent) ctx = context.WithValue(ctx, latestBuildContextKey{}, row.WorkspaceBuild) diff --git a/coderd/httpmw/workspaceagent_test.go b/coderd/httpmw/workspaceagent_test.go index 378d75927cc..c18e7aab91d 100644 --- a/coderd/httpmw/workspaceagent_test.go +++ b/coderd/httpmw/workspaceagent_test.go @@ -1,7 +1,10 @@ package httpmw_test import ( + "context" "database/sql" + "encoding/json" + "io" "net/http" "net/http/httptest" "testing" @@ -61,6 +64,38 @@ func TestWorkspaceAgent(t *testing.T) { require.Equal(t, http.StatusOK, res.StatusCode) }) + t.Run("InactiveUser", func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + authToken := uuid.New() + req, rtr, workspace, _ := setup(t, db, authToken, httpmw.ExtractWorkspaceAgentAndLatestBuild( + httpmw.ExtractWorkspaceAgentAndLatestBuildConfig{ + DB: db, + Optional: false, + }), + ) + + _, err := db.UpdateUserStatus(context.Background(), database.UpdateUserStatusParams{ + ID: workspace.OwnerID, + Status: database.UserStatusSuspended, + UpdatedAt: dbtime.Now(), + }) + require.NoError(t, err) + + rw := httptest.NewRecorder() + req.Header.Set(codersdk.SessionTokenHeader, authToken.String()) + rtr.ServeHTTP(rw, req) + + res := rw.Result() + defer res.Body.Close() + require.Equal(t, http.StatusUnauthorized, res.StatusCode) + body, err := io.ReadAll(res.Body) + require.NoError(t, err) + var response codersdk.Response + require.NoError(t, json.Unmarshal(body, &response)) + require.Contains(t, response.Message, `User is not active (status = "suspended")`) + }) + t.Run("Latest", func(t *testing.T) { t.Parallel() db, _ := dbtestutil.NewDB(t) From ff4ac10948d8d7cefb514da17c6578969e79b2ec Mon Sep 17 00:00:00 2001 From: Susana Ferreira Date: Thu, 27 Aug 2026 17:18:59 +0100 Subject: [PATCH 120/138] fix: enable Copilot HTTP transport fallback (#28494) (#28721) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport of https://github.com/coder/coder/pull/28494 Original PR: #28494 — fix: enable Copilot HTTP transport fallback Merge commit: 849543d8de4cfb629fc6ca826673c3454b43ac4e Requested by: @ssncferreira > [!NOTE] > This pull request was generated by Coder Agents on behalf of @ssncferreira. --- aibridge/bridge.go | 20 ++ aibridge/bridge_internal_test.go | 39 ++++ aibridge/bridge_test.go | 63 +++++- aibridge/provider/copilot.go | 2 + enterprise/aibridgeproxyd/aibridgeproxyd.go | 71 ++++--- .../aibridgeproxyd/aibridgeproxyd_test.go | 184 +++++++++++++++--- enterprise/aibridgeproxyd/reload.go | 11 +- .../aibridgeproxyd/reload_internal_test.go | 24 +-- 8 files changed, 344 insertions(+), 70 deletions(-) create mode 100644 aibridge/bridge_internal_test.go diff --git a/aibridge/bridge.go b/aibridge/bridge.go index e7b9101a2d7..f736dd6c422 100644 --- a/aibridge/bridge.go +++ b/aibridge/bridge.go @@ -16,6 +16,7 @@ import ( "github.com/sony/gobreaker/v2" "go.opentelemetry.io/otel/codes" "go.opentelemetry.io/otel/trace" + "golang.org/x/net/http/httpguts" "golang.org/x/xerrors" "cdr.dev/slog/v3" @@ -227,6 +228,18 @@ func newInterceptionProcessor(p provider.Provider, cbs *circuitbreaker.ProviderC client := GuessClient(r) sessionID := GuessSessionID(client, r) + if isWebSocketUpgrade(r) { + route := strings.TrimPrefix(r.URL.Path, fmt.Sprintf("/%s", p.Name())) + logger.Debug(ctx, "rejecting unsupported WebSocket upgrade", + slog.F("provider", p.Name()), + slog.F("route", route), + slog.F("client", string(client)), + slog.F("client_session_id", sessionID), + ) + http.Error(w, "WebSocket transport is not supported, use HTTP", http.StatusNotImplemented) + return + } + interceptor, err := p.CreateInterceptor(w, r.WithContext(ctx), tracer) if err != nil { span.SetStatus(codes.Error, fmt.Sprintf("failed to create interceptor: %v", err)) @@ -346,6 +359,13 @@ func newInterceptionProcessor(p provider.Provider, cbs *circuitbreaker.ProviderC } } +// isWebSocketUpgrade reports whether r is a WebSocket opening handshake. +func isWebSocketUpgrade(r *http.Request) bool { + return r.Method == http.MethodGet && + httpguts.HeaderValuesContainsToken(r.Header.Values("Connection"), "upgrade") && + httpguts.HeaderValuesContainsToken(r.Header.Values("Upgrade"), "websocket") +} + // writeRequestBodyTooLarge writes a human-readable 413 response indicating that // the request body exceeded maxRequestBodyBytes. func writeRequestBodyTooLarge(w http.ResponseWriter) { diff --git a/aibridge/bridge_internal_test.go b/aibridge/bridge_internal_test.go new file mode 100644 index 00000000000..0b07ed98c3a --- /dev/null +++ b/aibridge/bridge_internal_test.go @@ -0,0 +1,39 @@ +package aibridge + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestIsWebSocketUpgrade(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + method string + connection string + upgrade string + want bool + }{ + {name: "websocket upgrade", method: http.MethodGet, connection: "keep-alive, Upgrade", upgrade: "WebSocket", want: true}, + {name: "non-GET request", method: http.MethodPost, connection: "Upgrade", upgrade: "websocket", want: false}, + {name: "missing connection upgrade", method: http.MethodGet, connection: "keep-alive", upgrade: "websocket", want: false}, + {name: "different upgrade protocol", method: http.MethodGet, connection: "Upgrade", upgrade: "h2c", want: false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + req, err := http.NewRequestWithContext(t.Context(), tc.method, "/", nil) + require.NoError(t, err) + req.Header.Set("Connection", tc.connection) + req.Header.Set("Upgrade", tc.upgrade) + + assert.Equal(t, tc.want, isWebSocketUpgrade(req)) + }) + } +} diff --git a/aibridge/bridge_test.go b/aibridge/bridge_test.go index 9ac7ea9ec3d..c8057b02ff3 100644 --- a/aibridge/bridge_test.go +++ b/aibridge/bridge_test.go @@ -12,10 +12,12 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/trace" "cdr.dev/slog/v3/sloggers/slogtest" "github.com/coder/coder/v2/aibridge" "github.com/coder/coder/v2/aibridge/config" + "github.com/coder/coder/v2/aibridge/intercept" "github.com/coder/coder/v2/aibridge/internal/testutil" "github.com/coder/coder/v2/aibridge/provider" ) @@ -123,11 +125,12 @@ func TestPassthroughRoutesForProviders(t *testing.T) { upstreamRespBody := "upstream response" tests := []struct { - name string - baseURLPath string - requestPath string - provider func(string) provider.Provider - expectPath string + name string + baseURLPath string + requestMethod string + requestPath string + provider func(string) provider.Provider + expectPath string }{ { name: "openAI_no_base_path", @@ -180,6 +183,23 @@ func TestPassthroughRoutesForProviders(t *testing.T) { }, expectPath: "/v1/models", }, + { + name: "copilot_ping", + requestPath: "/copilot/_ping", + provider: func(baseURL string) provider.Provider { + return aibridge.NewCopilotProvider(config.Copilot{BaseURL: baseURL}) + }, + expectPath: "/_ping", + }, + { + name: "copilot_auto", + requestMethod: http.MethodPost, + requestPath: "/copilot/auto", + provider: func(baseURL string) provider.Provider { + return aibridge.NewCopilotProvider(config.Copilot{BaseURL: baseURL}) + }, + expectPath: "/auto", + }, } for _, tc := range tests { @@ -200,7 +220,7 @@ func TestPassthroughRoutesForProviders(t *testing.T) { bridge, err := aibridge.NewRequestBridge(t.Context(), []provider.Provider{prov}, &rec, nil, logger, nil, bridgeTestTracer) require.NoError(t, err) - req := httptest.NewRequest("", tc.requestPath, nil) + req := httptest.NewRequest(tc.requestMethod, tc.requestPath, nil) resp := httptest.NewRecorder() bridge.ServeHTTP(resp, req) @@ -210,6 +230,37 @@ func TestPassthroughRoutesForProviders(t *testing.T) { } } +func TestWebSocketUpgradeRejected(t *testing.T) { + t.Parallel() + + interceptorCalled := false + prov := &testutil.MockProvider{ + NameStr: "test", + Bridged: []string{"/responses"}, + InterceptorFunc: func(http.ResponseWriter, *http.Request, trace.Tracer) (intercept.Interceptor, error) { + interceptorCalled = true + return nil, nil //nolint:nilnil // The interceptor must not be reached. + }, + } + bridge, err := aibridge.NewRequestBridge( + t.Context(), + []provider.Provider{prov}, + nil, nil, slogtest.Make(t, nil), nil, bridgeTestTracer, + ) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodGet, "/test/responses", nil) + req.Header.Set("Connection", "keep-alive, Upgrade") + req.Header.Set("Upgrade", "WebSocket") + resp := httptest.NewRecorder() + + bridge.ServeHTTP(resp, req) + + assert.Equal(t, http.StatusNotImplemented, resp.Code) + assert.Contains(t, resp.Body.String(), "WebSocket transport is not supported, use HTTP") + assert.False(t, interceptorCalled) +} + func TestRequestBodySizeLimit(t *testing.T) { t.Parallel() diff --git a/aibridge/provider/copilot.go b/aibridge/provider/copilot.go index fd317aadaba..7859342e81e 100644 --- a/aibridge/provider/copilot.go +++ b/aibridge/provider/copilot.go @@ -97,6 +97,8 @@ func (*Copilot) BridgedRoutes() []string { func (*Copilot) PassthroughRoutes() []string { return []string{ + "/_ping", + "/auto", "/models", "/models/", "/agents/", diff --git a/enterprise/aibridgeproxyd/aibridgeproxyd.go b/enterprise/aibridgeproxyd/aibridgeproxyd.go index 1079cb6bde7..a48a190deb5 100644 --- a/enterprise/aibridgeproxyd/aibridgeproxyd.go +++ b/enterprise/aibridgeproxyd/aibridgeproxyd.go @@ -28,6 +28,7 @@ import ( "golang.org/x/xerrors" "cdr.dev/slog/v3" + aibridgeconfig "github.com/coder/coder/v2/aibridge/config" agplaibridge "github.com/coder/coder/v2/coderd/aibridge" ) @@ -132,7 +133,7 @@ type Server struct { // refreshProviders fetches the live provider snapshot on Reload. // Nil disables hot-reload. refreshProviders RefreshProvidersFunc - // providerRouter holds the live (mitmHosts, nameByHost) pair. + // providerRouter holds the live routing snapshot. providerRouter atomic.Pointer[providerRouter] // allowedPorts is the port allowlist for CONNECT requests. Fixed at // construction; not reloadable. @@ -149,19 +150,26 @@ type Server struct { metrics *Metrics } +type routedProvider struct { + name string + providerType string +} + // providerRouter keeps CONNECT matching and provider lookup in sync. type providerRouter struct { - mitmHosts []string // host:port set the goproxy condition matches against. - nameByHost map[string]string // lowercase hostname -> provider name. + mitmHosts []string // host:port set the goproxy condition matches against. + providerByHost map[string]routedProvider // lowercase hostname -> provider. } // emptyProviderRouter is used before the first Reload (or when the // operator deconfigures every provider) so handlers can safely call // loadProviderRouter without a nil check. -var emptyProviderRouter = &providerRouter{nameByHost: map[string]string{}} +var emptyProviderRouter = &providerRouter{ + providerByHost: map[string]routedProvider{}, +} -func (r *providerRouter) providerFromHost(host string) string { - return r.nameByHost[strings.ToLower(host)] +func (r *providerRouter) providerFromHost(host string) routedProvider { + return r.providerByHost[strings.ToLower(host)] } // requestContext holds metadata propagated through the proxy request/response chain. @@ -651,13 +659,13 @@ func (s *Server) authMiddleware(host string, ctx *goproxy.ProxyCtx) (*goproxy.Co provider := s.loadProviderRouter().providerFromHost(ctx.Req.URL.Hostname()) // A concurrent Reload can swap the router between CONNECT matching // and provider lookup, so treat a missing mapping as a runtime miss. - if provider == "" { + if provider.name == "" { logger.Warn(s.ctx, "rejecting CONNECT request with no provider mapping") return goproxy.RejectConnect, host } logger = logger.With( - slog.F("provider", provider), + slog.F("provider", provider.name), ) proxyAuth := ctx.Req.Header.Get("Proxy-Authorization") @@ -681,7 +689,7 @@ func (s *Server) authMiddleware(host string, ctx *goproxy.ProxyCtx) (*goproxy.Co ctx.UserData = &requestContext{ ConnectSessionID: connectSessionID, CoderToken: coderToken, - Provider: provider, + Provider: provider.name, } logger.Debug(s.ctx, "request CONNECT authenticated") @@ -932,14 +940,14 @@ func (s *Server) handleRequest(req *http.Request, ctx *goproxy.ProxyCtx) (*http. } } liveProvider := s.loadProviderRouter().providerFromHost(host) - if liveProvider == "" || liveProvider != reqCtx.Provider { + if liveProvider.name == "" || liveProvider.name != reqCtx.Provider { s.logger.Warn(s.ctx, "provider mapping changed or removed since CONNECT, passing through", slog.F("connect_id", reqCtx.ConnectSessionID.String()), slog.F("host", req.Host), slog.F("method", req.Method), slog.F("path", originalPath), slog.F("connect_provider", reqCtx.Provider), - slog.F("live_provider", liveProvider), + slog.F("live_provider", liveProvider.name), ) return req, nil } @@ -988,7 +996,8 @@ func (s *Server) handleRequest(req *http.Request, ctx *goproxy.ProxyCtx) (*http. req.URL = aiBridgeParsedURL req.Host = aiBridgeParsedURL.Host - injectBYOKHeaderIfNeeded(req.Header, reqCtx.CoderToken) + // Prepare Coder authentication for centralized and BYOK requests. + prepareAIGatewayAuth(req.Header, reqCtx.CoderToken, liveProvider.providerType) // Set request ID header to correlate requests between aibridgeproxyd and aibridged. req.Header.Set(agplaibridge.HeaderCoderRequestID, reqCtx.RequestID.String()) @@ -1015,24 +1024,34 @@ func (s *Server) handleRequest(req *http.Request, ctx *goproxy.ProxyCtx) (*http. return req, nil } -// injectBYOKHeaderIfNeeded sets HeaderCoderToken when the -// Authorization header carries a bearer token that differs from the -// Coder token, indicating the client is using its own LLM -// credentials. Clients that can set custom headers -// do this themselves; this handles clients that cannot. -// -// In centralized mode, Authorization carries the Coder token -// itself, so aibridged discovers it via ExtractAuthToken -// without any extra header. -func injectBYOKHeaderIfNeeded(header http.Header, coderToken string) { - // Don’t overwrite the header if it’s already set. - if header.Get(agplaibridge.HeaderCoderToken) != "" { +// prepareAIGatewayAuth prepares the Coder authentication headers for AI +// Gateway. Copilot is always BYOK, while other providers may use centralized +// or BYOK authentication. +func prepareAIGatewayAuth(headers http.Header, coderToken, providerType string) { + // Copilot is always BYOK, even when a route does not include a provider + // credential (e.g., /_ping). Prevent the Coder token from being forwarded + // to Copilot as a provider credential. + if providerType == aibridgeconfig.ProviderCopilot { + headers.Set(agplaibridge.HeaderCoderToken, coderToken) + + if extractCoderTokenFromBearerAuth(headers.Get("Authorization")) == coderToken { + headers.Del("Authorization") + } + if strings.TrimSpace(headers.Get("X-Api-Key")) == coderToken { + headers.Del("X-Api-Key") + } + return + } + + // For other providers, only add the Coder token when a separate provider + // credential indicates BYOK. + if headers.Get(agplaibridge.HeaderCoderToken) != "" { return } - bearer := extractCoderTokenFromBearerAuth(header.Get("Authorization")) + bearer := extractCoderTokenFromBearerAuth(headers.Get("Authorization")) if bearer != "" && bearer != coderToken { - header.Set(agplaibridge.HeaderCoderToken, coderToken) + headers.Set(agplaibridge.HeaderCoderToken, coderToken) } } diff --git a/enterprise/aibridgeproxyd/aibridgeproxyd_test.go b/enterprise/aibridgeproxyd/aibridgeproxyd_test.go index e84f7bd747f..e433bd6692a 100644 --- a/enterprise/aibridgeproxyd/aibridgeproxyd_test.go +++ b/enterprise/aibridgeproxyd/aibridgeproxyd_test.go @@ -193,26 +193,21 @@ func withProviders(providers ...aibridgeproxyd.ReloadedProvider) testProxyOption } // withProviderHosts is a convenience that builds enabled -// ReloadedProvider entries from each host, looking up the well-known -// provider name via testProviderFromHost and falling back to -// "test-provider" for hosts without a well-known mapping. Equivalent -// to passing each entry individually to withProviders. +// ReloadedProvider entries from each host, looking up well-known providers +// via testProviderFromHost. Unknown hosts use a generic name and OpenAI type. func withProviderHosts(hosts ...string) testProxyOption { return func(cfg *testProxyConfig) { providers := make([]aibridgeproxyd.ReloadedProvider, 0, len(hosts)) for _, h := range hosts { - name := testProviderFromHost(h) - if name == "" { - name = "test-provider" - } + provider := testProviderFromHost(h) host, _, splitErr := net.SplitHostPort(h) if splitErr != nil { host = h } providers = append(providers, aibridgeproxyd.ReloadedProvider{ ProviderOutcome: aibridged.ProviderOutcome{ - Name: name, - Type: "openai", + Name: provider.name, + Type: provider.providerType, Status: aibridged.ProviderStatusEnabled, }, Host: strings.ToLower(host), @@ -222,24 +217,29 @@ func withProviderHosts(hosts ...string) testProxyOption { } } -// testProviderFromHost maps well-known AI provider hostnames to -// provider names for test use. Unknown hosts return "". -func testProviderFromHost(host string) string { +type testProvider struct { + name string + providerType string +} + +// testProviderFromHost maps well-known AI provider hostnames to providers for +// test use. Unknown hosts use a generic name and OpenAI type. +func testProviderFromHost(host string) testProvider { switch strings.ToLower(host) { case aibridgeproxyd.HostAnthropic: - return aibridge.ProviderAnthropic + return testProvider{name: aibridge.ProviderAnthropic, providerType: aibridge.ProviderAnthropic} case aibridgeproxyd.HostOpenAI: - return aibridge.ProviderOpenAI + return testProvider{name: aibridge.ProviderOpenAI, providerType: aibridge.ProviderOpenAI} case aibridgeproxyd.HostCopilot: - return aibridge.ProviderCopilot + return testProvider{name: aibridge.ProviderCopilot, providerType: aibridge.ProviderCopilot} case agplaibridge.HostCopilotBusiness: - return agplaibridge.ProviderCopilotBusiness + return testProvider{name: agplaibridge.ProviderCopilotBusiness, providerType: aibridge.ProviderCopilot} case agplaibridge.HostCopilotEnterprise: - return agplaibridge.ProviderCopilotEnterprise + return testProvider{name: agplaibridge.ProviderCopilotEnterprise, providerType: aibridge.ProviderCopilot} case agplaibridge.HostChatGPT: - return agplaibridge.ProviderChatGPT + return testProvider{name: agplaibridge.ProviderChatGPT, providerType: aibridge.ProviderOpenAI} default: - return "" + return testProvider{name: "test-provider", providerType: aibridge.ProviderOpenAI} } } @@ -1547,13 +1547,13 @@ func TestProxy_MITM_BYOKInjection(t *testing.T) { srv := newTestProxy(t, withCoderAccessURL(aibridgedServer.URL), - withProviderHosts(aibridgeproxyd.HostCopilot), + withProviderHosts(aibridgeproxyd.HostOpenAI), ) certPool := getProxyCertPool(t) client := newProxyClient(t, srv, makeProxyAuthHeader(coderToken), certPool, false) - req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, "https://"+aibridgeproxyd.HostCopilot+"/chat/completions", strings.NewReader(`{}`)) + req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, "https://"+aibridgeproxyd.HostOpenAI+"/chat/completions", strings.NewReader(`{}`)) require.NoError(t, err) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", tt.authzHeader) @@ -1577,6 +1577,146 @@ func TestProxy_MITM_BYOKInjection(t *testing.T) { } } +func TestProxy_MITM_CopilotAuth(t *testing.T) { + t.Parallel() + + const coderToken = "coder-token" + stringPtr := func(value string) *string { return &value } + tests := []struct { + name string + host string + providerType string + authorization string + apiKey string + coderToken string + expectCoderToken *string + expectAuthorization *string + expectAPIKey *string + }{ + { + name: "NoProviderCredential", + host: aibridgeproxyd.HostCopilot, + expectCoderToken: stringPtr(coderToken), + expectAuthorization: nil, + expectAPIKey: nil, + }, + { + name: "StripCoderBearer", + host: aibridgeproxyd.HostCopilot, + authorization: "Bearer " + coderToken, + expectCoderToken: stringPtr(coderToken), + expectAuthorization: nil, + expectAPIKey: nil, + }, + { + name: "StripCoderAPIKey", + host: aibridgeproxyd.HostCopilot, + apiKey: coderToken, + expectCoderToken: stringPtr(coderToken), + expectAuthorization: nil, + expectAPIKey: nil, + }, + { + name: "PreserveProviderBearer", + host: aibridgeproxyd.HostCopilot, + authorization: "Bearer copilot-token", + expectCoderToken: stringPtr(coderToken), + expectAuthorization: stringPtr("Bearer copilot-token"), + expectAPIKey: nil, + }, + { + name: "ReplaceClientCoderToken", + host: aibridgeproxyd.HostCopilot, + coderToken: "other-coder-token", + expectCoderToken: stringPtr(coderToken), + expectAuthorization: nil, + expectAPIKey: nil, + }, + { + name: "CustomCopilotProvider", + host: "copilot.example.com", + providerType: aibridge.ProviderCopilot, + expectCoderToken: stringPtr(coderToken), + expectAuthorization: nil, + expectAPIKey: nil, + }, + { + name: "NonCopilotProvider", + host: aibridgeproxyd.HostCopilot, + providerType: aibridge.ProviderOpenAI, + expectCoderToken: nil, + expectAuthorization: nil, + expectAPIKey: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var receivedCoderToken, receivedAuthorization, receivedAPIKey string + aibridgedServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedCoderToken = r.Header.Get(agplaibridge.HeaderCoderToken) + receivedAuthorization = r.Header.Get("Authorization") + receivedAPIKey = r.Header.Get("X-Api-Key") + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(aibridgedServer.Close) + + provider := testProviderFromHost(tt.host) + if tt.providerType != "" { + provider.providerType = tt.providerType + } + srv := newTestProxy(t, + withCoderAccessURL(aibridgedServer.URL), + withProviders(aibridgeproxyd.ReloadedProvider{ + ProviderOutcome: aibridged.ProviderOutcome{ + Name: provider.name, + Type: provider.providerType, + Status: aibridged.ProviderStatusEnabled, + }, + Host: tt.host, + }), + ) + + certPool := getProxyCertPool(t) + client := newProxyClient(t, srv, makeProxyAuthHeader(coderToken), certPool, false) + + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, "https://"+tt.host, nil) + require.NoError(t, err) + if tt.authorization != "" { + req.Header.Set("Authorization", tt.authorization) + } + if tt.apiKey != "" { + req.Header.Set("X-Api-Key", tt.apiKey) + } + if tt.coderToken != "" { + req.Header.Set(agplaibridge.HeaderCoderToken, tt.coderToken) + } + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusOK, resp.StatusCode) + if tt.expectAuthorization == nil { + require.Empty(t, receivedAuthorization) + } else { + require.Equal(t, *tt.expectAuthorization, receivedAuthorization) + } + if tt.expectAPIKey == nil { + require.Empty(t, receivedAPIKey) + } else { + require.Equal(t, *tt.expectAPIKey, receivedAPIKey) + } + if tt.expectCoderToken == nil { + require.Empty(t, receivedCoderToken) + } else { + require.Equal(t, *tt.expectCoderToken, receivedCoderToken) + } + }) + } +} + // TestListenerTLS verifies that the proxy works correctly when its listener is wrapped in TLS. // It tests both tunneled and MITM'd requests through an HTTPS proxy listener. func TestListenerTLS(t *testing.T) { diff --git a/enterprise/aibridgeproxyd/reload.go b/enterprise/aibridgeproxyd/reload.go index 04b1f5438b0..9dc54c5fff6 100644 --- a/enterprise/aibridgeproxyd/reload.go +++ b/enterprise/aibridgeproxyd/reload.go @@ -119,7 +119,7 @@ func (s *Server) mitmHostsCondition() goproxy.ReqConditionFunc { // defense-in-depth measure even though the refresh function should // mark duplicates as errors. func buildProviderRouter(reload ProviderReload, allowedPorts []string) (*providerRouter, error) { - nameByHost := make(map[string]string, len(reload.Providers)) + providerByHost := make(map[string]routedProvider, len(reload.Providers)) domains := make([]string, 0, len(reload.Providers)) for _, p := range reload.Providers { if p.Status != aibridged.ProviderStatusEnabled { @@ -129,15 +129,18 @@ func buildProviderRouter(reload ProviderReload, allowedPorts []string) (*provide if host == "" { continue } - if _, exists := nameByHost[host]; exists { + if _, exists := providerByHost[host]; exists { continue } - nameByHost[host] = p.Name + providerByHost[host] = routedProvider{name: p.Name, providerType: p.Type} domains = append(domains, host) } mitmHosts, err := convertDomainsToHosts(domains, allowedPorts) if err != nil { return nil, err } - return &providerRouter{mitmHosts: mitmHosts, nameByHost: nameByHost}, nil + return &providerRouter{ + mitmHosts: mitmHosts, + providerByHost: providerByHost, + }, nil } diff --git a/enterprise/aibridgeproxyd/reload_internal_test.go b/enterprise/aibridgeproxyd/reload_internal_test.go index 5ccba37ec7b..537392fc447 100644 --- a/enterprise/aibridgeproxyd/reload_internal_test.go +++ b/enterprise/aibridgeproxyd/reload_internal_test.go @@ -40,7 +40,7 @@ func TestServerReloadSwapsProviderRouter(t *testing.T) { srv.providerRouter.Store(emptyProviderRouter) require.NoError(t, srv.Reload(ctx)) - assert.Equal(t, "old", srv.loadProviderRouter().providerFromHost("old.example.com")) + assert.Equal(t, routedProvider{name: "old", providerType: "openai"}, srv.loadProviderRouter().providerFromHost("old.example.com")) assert.Empty(t, srv.loadProviderRouter().providerFromHost("new.example.com")) reload = ProviderReload{Providers: []ReloadedProvider{enabledProvider("new", "new.example.com")}} @@ -48,7 +48,7 @@ func TestServerReloadSwapsProviderRouter(t *testing.T) { router := srv.loadProviderRouter() assert.Empty(t, router.providerFromHost("old.example.com")) - assert.Equal(t, "new", router.providerFromHost("new.example.com")) + assert.Equal(t, routedProvider{name: "new", providerType: "openai"}, router.providerFromHost("new.example.com")) assert.Equal(t, []string{"new.example.com:443"}, router.mitmHosts) } @@ -74,14 +74,14 @@ func TestServerReloadPreservesProviderRouterOnRefreshError(t *testing.T) { require.NoError(t, srv.Reload(ctx)) before := srv.loadProviderRouter() - assert.Equal(t, "old", before.providerFromHost("old.example.com")) + assert.Equal(t, routedProvider{name: "old", providerType: "openai"}, before.providerFromHost("old.example.com")) failRefresh = true require.ErrorIs(t, srv.Reload(ctx), refreshErr) after := srv.loadProviderRouter() assert.Same(t, before, after) - assert.Equal(t, "old", after.providerFromHost("old.example.com")) + assert.Equal(t, routedProvider{name: "old", providerType: "openai"}, after.providerFromHost("old.example.com")) assert.Equal(t, []string{"old.example.com:443"}, after.mitmHosts) } @@ -95,7 +95,7 @@ func TestBuildProviderRouter(t *testing.T) { reload := ProviderReload{Providers: []ReloadedProvider{ enabledProvider("openai", "api.openai.com"), - enabledProvider("anthropic", "api.anthropic.com"), + {ProviderOutcome: aibridged.ProviderOutcome{Name: "anthropic", Type: "anthropic", Status: aibridged.ProviderStatusEnabled}, Host: "api.anthropic.com"}, enabledProvider("custom", "custom-llm.example.com"), // Host is populated on the non-enabled rows so the Status // guard, not the empty-host guard, is what excludes them. @@ -106,9 +106,9 @@ func TestBuildProviderRouter(t *testing.T) { router, err := buildProviderRouter(reload, []string{"443"}) require.NoError(t, err) - assert.Equal(t, "openai", router.providerFromHost("api.openai.com")) - assert.Equal(t, "anthropic", router.providerFromHost("api.anthropic.com")) - assert.Equal(t, "custom", router.providerFromHost("custom-llm.example.com")) + assert.Equal(t, routedProvider{name: "openai", providerType: "openai"}, router.providerFromHost("api.openai.com")) + assert.Equal(t, routedProvider{name: "anthropic", providerType: "anthropic"}, router.providerFromHost("api.anthropic.com")) + assert.Equal(t, routedProvider{name: "custom", providerType: "openai"}, router.providerFromHost("custom-llm.example.com")) assert.Empty(t, router.providerFromHost("unknown.com")) assert.Empty(t, router.providerFromHost("disabled.example.com"), "disabled provider must not be routable even with a populated Host") @@ -130,8 +130,8 @@ func TestBuildProviderRouter(t *testing.T) { router, err := buildProviderRouter(reload, []string{"443"}) require.NoError(t, err) - assert.Equal(t, "provider", router.providerFromHost("API.Example.COM")) - assert.Equal(t, "provider", router.providerFromHost("api.example.com")) + assert.Equal(t, routedProvider{name: "provider", providerType: "openai"}, router.providerFromHost("API.Example.COM")) + assert.Equal(t, routedProvider{name: "provider", providerType: "openai"}, router.providerFromHost("api.example.com")) }) t.Run("DefensiveDeduplicatesSameHost", func(t *testing.T) { @@ -148,7 +148,7 @@ func TestBuildProviderRouter(t *testing.T) { router, err := buildProviderRouter(reload, []string{"443"}) require.NoError(t, err) - assert.Equal(t, "first", router.providerFromHost("api.example.com")) + assert.Equal(t, routedProvider{name: "first", providerType: "openai"}, router.providerFromHost("api.example.com")) }) t.Run("SkipsRowsWithEmptyHost", func(t *testing.T) { @@ -162,7 +162,7 @@ func TestBuildProviderRouter(t *testing.T) { router, err := buildProviderRouter(reload, []string{"443"}) require.NoError(t, err) - assert.Equal(t, "good", router.providerFromHost("api.good.example.com")) + assert.Equal(t, routedProvider{name: "good", providerType: "openai"}, router.providerFromHost("api.good.example.com")) assert.Equal(t, []string{"api.good.example.com:443"}, router.mitmHosts) }) } From 16885a67ae846ceb3a4c739e30379e9c3f2ecfa9 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Mon, 31 Aug 2026 20:24:55 -0500 Subject: [PATCH 121/138] chore: purge identified terraform modules via dbpurge (#28802) (#28815) Cherry-pick of [#28802](https://github.com/coder/coder/pull/28802) (`e2a856d42b`), matching [#28810](https://github.com/coder/coder/pull/28810) for `release/2.37`. Deletes cached Terraform module archives ingested during the identified window and clears the template version references to them. Runs from `dbpurge` rather than a migration, because migrations cannot be backported: the version table records a single high-water mark, so a migration cherry-picked here would cause later upgrades to skip every migration in between. ## Conflict resolution The commit did not apply cleanly. This branch predates the chat search work on `main`, so the incoming hunks carried unrelated context that was dropped: - `dbpurge.go`: took only the module cache block, the `ranModuleCachePurge` latch, the window constants, the `identified_module_files` log field and metric, and the `identifiedModuleCachePurged` instance field. Dropped the `chat_messages.search_tsv` backfill and stale reindex, along with `staleDrained` and the `chatSearch*` fields, none of which exist on this branch. - `dbpurge_test.go`: took `TestDeleteIdentifiedModuleCacheFiles` and the `awaitDoTicks` helper it depends on. Dropped `TestBackfillChatMessagesSearchTsv`. In the two `TestMetrics` mock setups, added only the `DeleteCachedModuleFilesCreatedBetween` expectation. - Generated files (`querier.go`, `queries.sql.go`, `dbmetrics`, `dbmock`, and the `dbauthz` stub) were reset to the branch state and regenerated from `queries/files.sql`, rather than taking the diff from `main`. Taking `main`'s versions would have introduced methods for queries that do not exist on this branch. Additionally on this branch, `dbpurge.New` takes an auditor argument that `main` does not, so the test call was adjusted to pass `nopAuditorPtr(t)`. ## Testing `coderd/database/dbpurge` and `TestMethodTestSuite` in `coderd/database/dbauthz` pass against Postgres. `make gen` is clean and pre-commit hooks pass. --- Opened by Coder Agents on behalf of @Emyrk. --- coderd/database/dbauthz/dbauthz.go | 7 + coderd/database/dbauthz/dbauthz_test.go | 5 + coderd/database/dbmetrics/querymetrics.go | 8 + coderd/database/dbmock/dbmock.go | 15 ++ coderd/database/dbpurge/dbpurge.go | 43 +++++ .../database/dbpurge/dbpurge_internal_test.go | 14 ++ coderd/database/dbpurge/dbpurge_test.go | 176 ++++++++++++++++++ coderd/database/querier.go | 6 + coderd/database/queries.sql.go | 52 ++++++ coderd/database/queries/files.sql | 38 ++++ 10 files changed, 364 insertions(+) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index eb1a1a634ce..f338ba7db94 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -1959,6 +1959,13 @@ func (q *querier) DeleteApplicationConnectAPIKeysByUserID(ctx context.Context, u return q.db.DeleteApplicationConnectAPIKeysByUserID(ctx, userID) } +func (q *querier) DeleteCachedModuleFilesCreatedBetween(ctx context.Context, arg database.DeleteCachedModuleFilesCreatedBetweenParams) (int64, error) { + if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceSystem); err != nil { + return 0, err + } + return q.db.DeleteCachedModuleFilesCreatedBetween(ctx, arg) +} + func (q *querier) DeleteChatDebugDataAfterMessageID(ctx context.Context, arg database.DeleteChatDebugDataAfterMessageIDParams) (int64, error) { chat, err := q.db.GetChatByID(ctx, arg.ChatID) if err != nil { diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 6e4f8c841dd..8d06ce7cdcc 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -4880,6 +4880,11 @@ func (s *MethodTestSuite) TestSystemFunctions() { dbm.EXPECT().DeleteOldWorkspaceAgentLogs(gomock.Any(), t).Return(int64(0), nil).AnyTimes() check.Args(t).Asserts(rbac.ResourceSystem, policy.ActionDelete) })) + s.Run("DeleteCachedModuleFilesCreatedBetween", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + arg := database.DeleteCachedModuleFilesCreatedBetweenParams{} + dbm.EXPECT().DeleteCachedModuleFilesCreatedBetween(gomock.Any(), arg).Return(int64(0), nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceSystem, policy.ActionDelete) + })) s.Run("InsertWorkspaceAgentStats", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { arg := database.InsertWorkspaceAgentStatsParams{} dbm.EXPECT().InsertWorkspaceAgentStats(gomock.Any(), arg).Return(xerrors.New("any error")).AnyTimes() diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index ba4cbb220c1..3da8dd65830 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -458,6 +458,14 @@ func (m queryMetricsStore) DeleteApplicationConnectAPIKeysByUserID(ctx context.C return r0 } +func (m queryMetricsStore) DeleteCachedModuleFilesCreatedBetween(ctx context.Context, arg database.DeleteCachedModuleFilesCreatedBetweenParams) (int64, error) { + start := time.Now() + r0, r1 := m.s.DeleteCachedModuleFilesCreatedBetween(ctx, arg) + m.queryLatencies.WithLabelValues("DeleteCachedModuleFilesCreatedBetween").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "DeleteCachedModuleFilesCreatedBetween").Inc() + return r0, r1 +} + func (m queryMetricsStore) DeleteChatDebugDataAfterMessageID(ctx context.Context, arg database.DeleteChatDebugDataAfterMessageIDParams) (int64, error) { start := time.Now() r0, r1 := m.s.DeleteChatDebugDataAfterMessageID(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 72340abacf6..f1628b5290c 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -747,6 +747,21 @@ func (mr *MockStoreMockRecorder) DeleteApplicationConnectAPIKeysByUserID(ctx, us return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteApplicationConnectAPIKeysByUserID", reflect.TypeOf((*MockStore)(nil).DeleteApplicationConnectAPIKeysByUserID), ctx, userID) } +// DeleteCachedModuleFilesCreatedBetween mocks base method. +func (m *MockStore) DeleteCachedModuleFilesCreatedBetween(ctx context.Context, arg database.DeleteCachedModuleFilesCreatedBetweenParams) (int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteCachedModuleFilesCreatedBetween", ctx, arg) + ret0, _ := ret[0].(int64) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// DeleteCachedModuleFilesCreatedBetween indicates an expected call of DeleteCachedModuleFilesCreatedBetween. +func (mr *MockStoreMockRecorder) DeleteCachedModuleFilesCreatedBetween(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteCachedModuleFilesCreatedBetween", reflect.TypeOf((*MockStore)(nil).DeleteCachedModuleFilesCreatedBetween), ctx, arg) +} + // DeleteChatDebugDataAfterMessageID mocks base method. func (m *MockStore) DeleteChatDebugDataAfterMessageID(ctx context.Context, arg database.DeleteChatDebugDataAfterMessageIDParams) (int64, error) { m.ctrl.T.Helper() diff --git a/coderd/database/dbpurge/dbpurge.go b/coderd/database/dbpurge/dbpurge.go index c87bc5a8df9..1f3644633f5 100644 --- a/coderd/database/dbpurge/dbpurge.go +++ b/coderd/database/dbpurge/dbpurge.go @@ -64,6 +64,20 @@ const ( // tick will archive by default. const defaultChatAutoArchiveBatchSize int32 = 1000 +// Terraform module archives ingested during this window may contain the +// identified upstream module. +// +// This is a one-off cleanup, not a recurring purge. It runs once per coderd +// process because the window is fixed in the past: after a successful pass +// there is nothing left to match. It lives here rather than in a migration +// because migrations cannot be backported. The version table records a single +// high-water mark, so a migration cherry-picked onto a release branch would +// cause later upgrades to skip every migration in between. +var ( + identifiedModuleCacheStart = time.Date(2026, 8, 31, 8, 0, 0, 0, time.UTC) + identifiedModuleCacheEnd = time.Date(2026, 8, 31, 22, 0, 0, 0, time.UTC) +) + type Option func(*instance) // WithClock overrides the clock used by the purger. Defaults to @@ -211,6 +225,10 @@ func (i *instance) purgeTick(ctx context.Context, db database.Store, start time. // Populated inside the tx; dispatched post-commit. var archivedChats []database.AutoArchiveInactiveChatsRow + // Latched after a successful commit so the one-off module cache cleanup + // is attempted again if the transaction rolls back. + ranModuleCachePurge := false + // Start a transaction to grab advisory lock, we don't want to run // multiple purges at the same time (multiple replicas). err := db.InTx(func(tx database.Store) error { @@ -336,6 +354,20 @@ func (i *instance) purgeTick(ctx context.Context, db database.Store, start time. } } + // One-off cleanup of the identified Terraform module cache. Skipped + // once this process has completed a pass. + var purgedIdentifiedModuleFiles int64 + if !i.identifiedModuleCachePurged { + purgedIdentifiedModuleFiles, err = tx.DeleteCachedModuleFilesCreatedBetween(ctx, database.DeleteCachedModuleFilesCreatedBetweenParams{ + CreatedAtAfter: identifiedModuleCacheStart, + CreatedAtBefore: identifiedModuleCacheEnd, + }) + if err != nil { + return xerrors.Errorf("failed to delete identified module cache files: %w", err) + } + ranModuleCachePurge = true + } + i.logger.Debug(ctx, "purged old database entries", slog.F("workspace_agent_logs", purgedWorkspaceAgentLogs), slog.F("expired_api_keys", expiredAPIKeys), @@ -346,6 +378,7 @@ func (i *instance) purgeTick(ctx context.Context, db database.Store, start time. slog.F("chat_files", purgedChatFiles), slog.F("chat_debug_runs", purgedChatDebugRuns), slog.F("auto_archived_chats", len(archivedChats)), + slog.F("identified_module_files", purgedIdentifiedModuleFiles), slog.F("duration", i.clk.Since(start)), ) @@ -358,6 +391,7 @@ func (i *instance) purgeTick(ctx context.Context, db database.Store, start time. i.recordsPurged.WithLabelValues("chats").Add(float64(purgedChats)) i.recordsPurged.WithLabelValues("chat_debug_runs").Add(float64(purgedChatDebugRuns)) i.recordsPurged.WithLabelValues("chat_files").Add(float64(purgedChatFiles)) + i.recordsPurged.WithLabelValues("identified_module_files").Add(float64(purgedIdentifiedModuleFiles)) } // chatConfigErr is returned after the tx, so do not record this @@ -373,6 +407,10 @@ func (i *instance) purgeTick(ctx context.Context, db database.Store, start time. return err } + if ranModuleCachePurge { + i.identifiedModuleCachePurged = true + } + // Surface the deferred chat-config error so doTick records // the failed iteration metric. if chatConfigErr != nil { @@ -408,6 +446,11 @@ type instance struct { recordsPurged *prometheus.CounterVec chatAutoArchiveRecords prometheus.Counter chatAutoArchiveBatchSize int32 + + // identifiedModuleCachePurged latches once this process has completed a + // pass of the one-off module cache cleanup. The window is fixed in the + // past, so a completed pass leaves nothing to match on later ticks. + identifiedModuleCachePurged bool } func (i *instance) Close() error { diff --git a/coderd/database/dbpurge/dbpurge_internal_test.go b/coderd/database/dbpurge/dbpurge_internal_test.go index f49426e9560..a7fe08d1ebd 100644 --- a/coderd/database/dbpurge/dbpurge_internal_test.go +++ b/coderd/database/dbpurge/dbpurge_internal_test.go @@ -43,3 +43,17 @@ func TestDBPurgeAuthorization(t *testing.T) { err := inst.purgeTick(ctx, db, now) require.NoError(t, err) } + +// The behavior of the one-off module cache cleanup is covered by +// TestDeleteIdentifiedModuleCacheFiles, which supplies its own window. This +// guards the production constants themselves, which that test no longer reads. +func TestIdentifiedModuleCacheWindow(t *testing.T) { + t.Parallel() + + require.True(t, identifiedModuleCacheStart.Before(identifiedModuleCacheEnd), + "window start must precede window end") + require.Equal(t, time.UTC, identifiedModuleCacheStart.Location(), + "window bounds must be UTC so they do not shift with the host timezone") + require.Equal(t, time.UTC, identifiedModuleCacheEnd.Location(), + "window bounds must be UTC so they do not shift with the host timezone") +} diff --git a/coderd/database/dbpurge/dbpurge_test.go b/coderd/database/dbpurge/dbpurge_test.go index 4ebd645a7a2..966b065e6c8 100644 --- a/coderd/database/dbpurge/dbpurge_test.go +++ b/coderd/database/dbpurge/dbpurge_test.go @@ -4,6 +4,7 @@ import ( "bufio" "bytes" "context" + "crypto/sha256" "database/sql" "encoding/json" "fmt" @@ -259,6 +260,7 @@ func TestMetrics(t *testing.T) { mDB.EXPECT().ExpirePrebuildsAPIKeys(gomock.Any(), gomock.Any()).Return(nil).AnyTimes() mDB.EXPECT().DeleteOldTelemetryLocks(gomock.Any(), gomock.Any()).Return(nil).AnyTimes() mDB.EXPECT().DeleteOldAuditLogConnectionEvents(gomock.Any(), gomock.Any()).Return(nil).AnyTimes() + mDB.EXPECT().DeleteCachedModuleFilesCreatedBetween(gomock.Any(), gomock.AssignableToTypeOf(database.DeleteCachedModuleFilesCreatedBetweenParams{})).Return(int64(0), nil).AnyTimes() mDB.EXPECT().DeleteOldChatDebugRuns(gomock.Any(), gomock.AssignableToTypeOf(database.DeleteOldChatDebugRunsParams{})).Return(int64(0), nil).MinTimes(1) mDB.EXPECT().InTx(gomock.Any(), database.DefaultTXOptions().WithID("db_purge")). DoAndReturn(func(f func(database.Store) error, _ *database.TxOptions) error { @@ -355,6 +357,7 @@ func TestMetrics(t *testing.T) { mDB.EXPECT().ExpirePrebuildsAPIKeys(gomock.Any(), gomock.Any()).Return(nil).AnyTimes() mDB.EXPECT().DeleteOldTelemetryLocks(gomock.Any(), gomock.Any()).Return(nil).AnyTimes() mDB.EXPECT().DeleteOldAuditLogConnectionEvents(gomock.Any(), gomock.Any()).Return(nil).AnyTimes() + mDB.EXPECT().DeleteCachedModuleFilesCreatedBetween(gomock.Any(), gomock.AssignableToTypeOf(database.DeleteCachedModuleFilesCreatedBetweenParams{})).Return(int64(0), nil).AnyTimes() mDB.EXPECT().DeleteOldChats(gomock.Any(), gomock.AssignableToTypeOf(database.DeleteOldChatsParams{})).Return(int64(0), nil).MinTimes(1) mDB.EXPECT().DeleteOldChatFiles(gomock.Any(), gomock.AssignableToTypeOf(database.DeleteOldChatFilesParams{})).Return(int64(0), nil).MinTimes(1) mDB.EXPECT().InTx(gomock.Any(), database.DefaultTXOptions().WithID("db_purge")). @@ -3525,3 +3528,176 @@ func TestAutoArchiveInactiveChats(t *testing.T) { }) } } + +func awaitDoTicks(ctx context.Context, t *testing.T, clk *quartz.Mock, n int) func() { + t.Helper() + completed := make(chan struct{}) + advance := make(chan struct{}) + trapNow := clk.Trap().Now() + trapStop := clk.Trap().TickerStop() + trapReset := clk.Trap().TickerReset() + go func() { + defer close(completed) + defer trapReset.Close() + defer trapStop.Close() + defer trapNow.Close() + trapNow.MustWait(ctx).MustRelease(ctx) + trapReset.MustWait(ctx).MustRelease(ctx) + select { + case completed <- struct{}{}: + case <-ctx.Done(): + return + } + for i := 1; i < n; i++ { + select { + case <-advance: + case <-ctx.Done(): + return + } + d, w := clk.AdvanceNext() + if !assert.Equal(t, 10*time.Minute, d) { + return + } + w.MustWait(ctx) + trapStop.MustWait(ctx).MustRelease(ctx) + trapReset.MustWait(ctx).MustRelease(ctx) + select { + case completed <- struct{}{}: + case <-ctx.Done(): + return + } + } + }() + first := true + return func() { + t.Helper() + if !first { + testutil.RequireSend(ctx, t, advance, struct{}{}) + } + first = false + testutil.TryReceive(ctx, t, completed) + } +} + +//nolint:paralleltest // It uses LockIDDBPurge. +func TestDeleteIdentifiedModuleCacheFiles(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitShort) + clk := quartz.NewMock(t) + clk.Set(dbtime.Now()).MustWait(ctx) + + // The window under test is supplied explicitly rather than copied from the + // production constants, so revising the incident timestamps cannot silently + // invalidate these boundary assertions. + windowStart := time.Date(2026, 8, 31, 8, 0, 0, 0, time.UTC) + windowEnd := time.Date(2026, 8, 31, 22, 0, 0, 0, time.UTC) + inWindow := windowStart.Add(time.Minute) + + db, _ := dbtestutil.NewDB(t, dbtestutil.WithDumpOnFailure()) + org := dbgen.Organization(t, db, database.Organization{}) + user := dbgen.User(t, db, database.User{}) + _ = dbgen.OrganizationMember(t, db, database.OrganizationMember{UserID: user.ID, OrganizationID: org.ID}) + + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) + + mkFile := func(name string, createdBy uuid.UUID, mimetype string, createdAt time.Time) database.File { + file, err := db.InsertFile(ctx, database.InsertFileParams{ + ID: uuid.New(), + Hash: fmt.Sprintf("%x", sha256.Sum256([]byte(name))), + CreatedBy: createdBy, + CreatedAt: createdAt, + Mimetype: mimetype, + Data: []byte{}, + }) + require.NoError(t, err, "insert file %q", name) + return file + } + + // mkVersion creates a template version whose cached module files point at a + // file with the given properties. InsertFile is used directly because + // dbgen.File treats uuid.Nil as unset and substitutes a random creator, + // while uuid.Nil is exactly what identifies a provisionerd module archive. + mkVersion := func(name string, createdBy uuid.UUID, mimetype string, createdAt time.Time) (database.File, database.TemplateVersion) { + file := mkFile(name, createdBy, mimetype, createdAt) + tv := dbgen.TemplateVersion(t, db, database.TemplateVersion{ + Name: name, + OrganizationID: org.ID, + CreatedBy: user.ID, + }) + _ = dbgen.TemplateVersionTerraformValues(t, db, database.TemplateVersionTerraformValue{ + TemplateVersionID: tv.ID, + CachedModuleFiles: uuid.NullUUID{UUID: file.ID, Valid: true}, + }) + return file, tv + } + + // Identified: a provisionerd module archive cached inside the window. + identified, identifiedTV := mkVersion("identified", uuid.Nil, "application/x-tar", inWindow) + // The lower bound is inclusive. + atStart, atStartTV := mkVersion("at-start", uuid.Nil, "application/x-tar", windowStart) + // The upper bound is exclusive, so this archive is known good. + atEnd, atEndTV := mkVersion("at-end", uuid.Nil, "application/x-tar", windowEnd) + // Cached before and after the window. + before, beforeTV := mkVersion("before", uuid.Nil, "application/x-tar", windowStart.Add(-time.Hour)) + after, afterTV := mkVersion("after", uuid.Nil, "application/x-tar", windowEnd.Add(time.Hour)) + // A user-uploaded template tarball shares the mimetype but has a real + // creator, so it must survive even though it is inside the window. + userUpload, userUploadTV := mkVersion("user-upload", user.ID, "application/x-tar", inWindow) + + // An unreferenced archive inside the window. Only archives referenced by a + // template version are in scope. + orphan := mkFile("orphan", uuid.Nil, "application/x-tar", inWindow) + + // when dbpurge runs + tick := awaitDoTicks(ctx, t, clk, 2) + closer := dbpurge.New(ctx, logger, db, &codersdk.DeploymentValues{}, prometheus.NewRegistry(), nopAuditorPtr(t), dbpurge.WithClock(clk)) + defer closer.Close() + tick() // doTick() has now run. + + assertFileDeleted := func(id uuid.UUID, name string) { + t.Helper() + _, err := db.GetFileByID(ctx, id) + require.ErrorIs(t, err, sql.ErrNoRows, "%s should be deleted", name) + } + assertFileExists := func(id uuid.UUID, name string) { + t.Helper() + _, err := db.GetFileByID(ctx, id) + require.NoError(t, err, "%s should be retained", name) + } + // assertCacheRef checks the template version still exists and that its + // module cache reference was cleared only when the file was deleted. + assertCacheRef := func(tv database.TemplateVersion, wantFile uuid.UUID, wantValid bool, name string) { + t.Helper() + values, err := db.GetTemplateVersionTerraformValues(ctx, tv.ID) + require.NoError(t, err, "%s: terraform values row must be retained", name) + require.Equal(t, wantValid, values.CachedModuleFiles.Valid, "%s: cache reference validity", name) + if wantValid { + require.Equal(t, wantFile, values.CachedModuleFiles.UUID, "%s: cache reference target", name) + } + } + + // then the identified archives are deleted and their references cleared + assertFileDeleted(identified.ID, "archive inside the window") + assertCacheRef(identifiedTV, uuid.Nil, false, "archive inside the window") + assertFileDeleted(atStart.ID, "archive at the inclusive lower bound") + assertCacheRef(atStartTV, uuid.Nil, false, "archive at the inclusive lower bound") + + // and everything else is untouched + assertFileExists(atEnd.ID, "archive at the exclusive upper bound") + assertCacheRef(atEndTV, atEnd.ID, true, "archive at the exclusive upper bound") + assertFileExists(before.ID, "archive cached before the window") + assertCacheRef(beforeTV, before.ID, true, "archive cached before the window") + assertFileExists(after.ID, "archive cached after the window") + assertCacheRef(afterTV, after.ID, true, "archive cached after the window") + assertFileExists(userUpload.ID, "user-uploaded tarball") + assertCacheRef(userUploadTV, userUpload.ID, true, "user-uploaded tarball") + assertFileExists(orphan.ID, "unreferenced archive") + + // The cleanup is one-off, not a recurring purge. A second tick must not + // repeat it, so an archive inserted into the window after the first pass + // survives. This documents the latch: the window is fixed in the past and + // nothing can legitimately land in it again. + late, lateTV := mkVersion("late", uuid.Nil, "application/x-tar", inWindow) + tick() + assertFileExists(late.ID, "archive inserted after the one-off pass") + assertCacheRef(lateTV, late.ID, true, "archive inserted after the one-off pass") +} diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 3235daa91db..96ee7c6bbd4 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -124,6 +124,12 @@ type sqlcQuerier interface { // be recreated. DeleteAllWebpushSubscriptions(ctx context.Context) error DeleteApplicationConnectAPIKeysByUserID(ctx context.Context, userID uuid.UUID) error + // Deletes cached Terraform module archives ingested in the given time range and + // clears the template version references to them. created_by and mimetype + // identify a provisionerd-written module archive, matching the checks in + // provisionerdserver, so user-uploaded template tarballs are never removed. + // Only archives referenced by a template version are considered. + DeleteCachedModuleFilesCreatedBetween(ctx context.Context, arg DeleteCachedModuleFilesCreatedBetweenParams) (int64, error) // Deletes debug runs (and their cascaded steps) whose message IDs // exceed the cutoff. The started_before bound prevents retried // cleanup from deleting runs created by a replacement turn that diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index c9103e4880d..80487a694a9 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -12247,6 +12247,58 @@ func (q *sqlQuerier) UpdateExternalAuthLinkRefreshToken(ctx context.Context, arg return err } +const deleteCachedModuleFilesCreatedBetween = `-- name: DeleteCachedModuleFilesCreatedBetween :execrows +WITH doomed AS ( + SELECT + files.id + FROM + files + INNER JOIN + template_version_terraform_values + ON template_version_terraform_values.cached_module_files = files.id + WHERE + files.created_by = '00000000-0000-0000-0000-000000000000' + AND files.mimetype = 'application/x-tar' + AND files.created_at >= $1 + AND files.created_at < $2 +), cleared AS ( + -- The foreign key is NO ACTION, so references must be cleared before the + -- files rows can be deleted. Data-modifying CTEs always run to completion, + -- and the constraint is checked at the end of the statement. + UPDATE + template_version_terraform_values + SET + cached_module_files = NULL + WHERE + cached_module_files IN (SELECT id FROM doomed) + RETURNING 1 +) +DELETE FROM + files +USING + doomed +WHERE + files.id = doomed.id +` + +type DeleteCachedModuleFilesCreatedBetweenParams struct { + CreatedAtAfter time.Time `db:"created_at_after" json:"created_at_after"` + CreatedAtBefore time.Time `db:"created_at_before" json:"created_at_before"` +} + +// Deletes cached Terraform module archives ingested in the given time range and +// clears the template version references to them. created_by and mimetype +// identify a provisionerd-written module archive, matching the checks in +// provisionerdserver, so user-uploaded template tarballs are never removed. +// Only archives referenced by a template version are considered. +func (q *sqlQuerier) DeleteCachedModuleFilesCreatedBetween(ctx context.Context, arg DeleteCachedModuleFilesCreatedBetweenParams) (int64, error) { + result, err := q.db.ExecContext(ctx, deleteCachedModuleFilesCreatedBetween, arg.CreatedAtAfter, arg.CreatedAtBefore) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + const getFileByHashAndCreator = `-- name: GetFileByHashAndCreator :one SELECT hash, created_at, created_by, mimetype, data, id diff --git a/coderd/database/queries/files.sql b/coderd/database/queries/files.sql index cdf6e37ce08..cefc3a2d02b 100644 --- a/coderd/database/queries/files.sql +++ b/coderd/database/queries/files.sql @@ -55,3 +55,41 @@ WHERE AND provisioner_jobs.type = 'template_version_import' AND file_id = @file_id ; + +-- name: DeleteCachedModuleFilesCreatedBetween :execrows +-- Deletes cached Terraform module archives ingested in the given time range and +-- clears the template version references to them. created_by and mimetype +-- identify a provisionerd-written module archive, matching the checks in +-- provisionerdserver, so user-uploaded template tarballs are never removed. +-- Only archives referenced by a template version are considered. +WITH doomed AS ( + SELECT + files.id + FROM + files + INNER JOIN + template_version_terraform_values + ON template_version_terraform_values.cached_module_files = files.id + WHERE + files.created_by = '00000000-0000-0000-0000-000000000000' + AND files.mimetype = 'application/x-tar' + AND files.created_at >= @created_at_after + AND files.created_at < @created_at_before +), cleared AS ( + -- The foreign key is NO ACTION, so references must be cleared before the + -- files rows can be deleted. Data-modifying CTEs always run to completion, + -- and the constraint is checked at the end of the statement. + UPDATE + template_version_terraform_values + SET + cached_module_files = NULL + WHERE + cached_module_files IN (SELECT id FROM doomed) + RETURNING 1 +) +DELETE FROM + files +USING + doomed +WHERE + files.id = doomed.id; From c539ca5117e3788add0250bfc1c4236ae2a15254 Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Tue, 1 Sep 2026 15:08:37 +0700 Subject: [PATCH 122/138] fix(site): dispose Monaco diff models on unmount (#28503) (#28806) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > 🤖 This PR was written by Coder Agents on behalf of Jake Howell. Backport of #28503 to `release/2.34`. Refs [DEVEX-736](https://linear.app/codercom/issue/DEVEX-736/template-editor-crashes-becomes-unusable-when-opening-versions). This carries the Monaco diff-model lifecycle cleanup and Storybook regression coverage into 2.34, preventing template-version navigation from retaining models until the editor becomes unusable. The cherry-pick conflict was limited to theme imports. This keeps the release branch's existing `@emotion/react` integration while applying the original functional fix. Co-authored-by: Marcin Tojek --- .../SyntaxHighlighter.stories.tsx | 111 +++++++++++++ .../SyntaxHighlighter/SyntaxHighlighter.tsx | 146 ++++++++++++------ 2 files changed, 211 insertions(+), 46 deletions(-) create mode 100644 site/src/components/SyntaxHighlighter/SyntaxHighlighter.stories.tsx diff --git a/site/src/components/SyntaxHighlighter/SyntaxHighlighter.stories.tsx b/site/src/components/SyntaxHighlighter/SyntaxHighlighter.stories.tsx new file mode 100644 index 00000000000..8740b3af492 --- /dev/null +++ b/site/src/components/SyntaxHighlighter/SyntaxHighlighter.stories.tsx @@ -0,0 +1,111 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type * as Monaco from "monaco-editor"; +import * as monaco from "monaco-editor"; +import { useState } from "react"; +import { expect, userEvent, waitFor } from "storybook/test"; +import { withDashboardProvider } from "#/testHelpers/storybook"; +import { SyntaxHighlighter } from "./SyntaxHighlighter"; + +const original = `resource "coder_agent" "main" { + os = "linux" + arch = "amd64" +} +`; + +const modified = `resource "coder_agent" "main" { + os = "linux" + arch = "arm64" +} +`; + +// The diff editor's gutter menu and occurrence highlighter register delayed +// disposables whose teardown throws when editors unmount in Storybook tests. +// They are irrelevant to model disposal, so we turn them off in stories to keep +// the test runner clean without changing production behavior. +const stableTeardownOptions: Monaco.editor.IStandaloneDiffEditorConstructionOptions = + { + minimap: { enabled: false }, + renderSideBySide: true, + readOnly: true, + renderGutterMenu: false, + occurrencesHighlight: "off", + }; + +const meta: Meta = { + title: "components/SyntaxHighlighter", + component: SyntaxHighlighter, + decorators: [withDashboardProvider], + args: { + language: "hcl", + editorProps: { options: stableTeardownOptions }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Plain: Story = { + args: { + value: original, + }, +}; + +export const Diff: Story = { + args: { + value: modified, + compareWith: original, + }, +}; + +// Reproduces the leak from DEVEX-736: a single SyntaxHighlighter instance that +// stays mounted while a file switches between diff and plain across template +// versions. Each diff editor owns two Monaco models, and they must be disposed +// when the diff goes away. Before the fix the models were only disposed on full +// unmount, so toggling diff -> plain -> diff leaked two models per cycle. +const DiffToggle = () => { + const [showDiff, setShowDiff] = useState(true); + return ( +
+ + +
+ ); +}; + +export const DisposesModelsOnDiffToggle: Story = { + render: () => , + play: async ({ canvas }) => { + const toggle = canvas.getByRole("button", { name: "Toggle diff" }); + + // Wait for the diff editor to mount its original + modified models, then + // record the total as a baseline. Every full toggle cycle must return to + // this number; growth would mean abandoned models are being retained. + let baseline = 0; + await waitFor(() => { + baseline = monaco.editor.getModels().length; + expect(baseline).toBeGreaterThanOrEqual(2); + }); + + for (let cycle = 0; cycle < 3; cycle++) { + // Switch to plain: the diff editor unmounts and must dispose its models. + await userEvent.click(toggle); + await waitFor(() => + expect(monaco.editor.getModels().length).toBeLessThan(baseline), + ); + + // Switch back to diff: a new diff editor mounts and the total must land + // back on the baseline rather than climbing. + await userEvent.click(toggle); + await waitFor(() => + expect(monaco.editor.getModels().length).toBe(baseline), + ); + } + }, +}; diff --git a/site/src/components/SyntaxHighlighter/SyntaxHighlighter.tsx b/site/src/components/SyntaxHighlighter/SyntaxHighlighter.tsx index d66d7973951..60e24b69ec4 100644 --- a/site/src/components/SyntaxHighlighter/SyntaxHighlighter.tsx +++ b/site/src/components/SyntaxHighlighter/SyntaxHighlighter.tsx @@ -2,7 +2,13 @@ import { useTheme } from "@emotion/react"; import Editor, { DiffEditor, loader } from "@monaco-editor/react"; import type * as Monaco from "monaco-editor"; import * as monaco from "monaco-editor"; -import { type ComponentProps, type FC, useCallback } from "react"; +import { + type ComponentProps, + type FC, + useCallback, + useEffect, + useRef, +} from "react"; import { useCoderTheme } from "./coderTheme"; loader.config({ monaco }); @@ -38,40 +44,6 @@ export const SyntaxHighlighter: FC = ({ const theme = useTheme(); const coderTheme = useCoderTheme(); - // Auto-scroll to first diff when the diff editor mounts and diffs are computed. - const handleDiffEditorMount = useCallback( - ( - editor: Monaco.editor.IStandaloneDiffEditor, - monacoInstance: typeof Monaco, - ) => { - // Call any existing onMount handler from editorProps. - editorProps?.onMount?.(editor, monacoInstance); - - // Diffs may already be computed by the time onMount fires, - // so check immediately first. If not ready yet, fall back - // to waiting for the onDidUpdateDiff event. - const scrollToFirstDiff = () => { - editor.goToDiff("next"); - }; - - const changes = editor.getLineChanges(); - if (changes && changes.length > 0) { - scrollToFirstDiff(); - return; - } - - const disposable = editor.onDidUpdateDiff(() => { - const updatedChanges = editor.getLineChanges(); - if (!updatedChanges || updatedChanges.length === 0) { - return; - } - disposable.dispose(); - scrollToFirstDiff(); - }); - }, - [editorProps], - ); - const commonProps = { language, theme: coderTheme.name, @@ -99,20 +71,102 @@ export const SyntaxHighlighter: FC = ({ }} > {hasDiff ? ( - + ) : ( )}
); }; + +type DiffFileProps = CommonEditorProps & { + original: string; + modified: string; +}; + +// Renders the diff editor and owns its model cleanup. Scoping this to its own +// component means the cleanup effect runs whenever the diff editor unmounts, +// including when SyntaxHighlighter stays mounted but switches diff -> plain for +// a file that stopped changing between versions. +// +// keepCurrent{Original,Modified}Model stops @monaco-editor/react from disposing +// the models mid-teardown (which throws), so we dispose them ourselves after +// React has torn the editor down. Without this the models accumulate unbounded +// as users open template versions until the tab runs out of memory. +const DiffFile: FC = ({ + original, + modified, + onMount, + ...editorProps +}) => { + const diffModelsRef = useRef<{ + original: Monaco.editor.ITextModel; + modified: Monaco.editor.ITextModel; + } | null>(null); + + const handleMount = useCallback( + ( + editor: Monaco.editor.IStandaloneDiffEditor, + monacoInstance: typeof Monaco, + ) => { + onMount?.(editor, monacoInstance); + + const diffModel = editor.getModel(); + diffModelsRef.current = diffModel + ? { original: diffModel.original, modified: diffModel.modified } + : null; + + // Auto-scroll to the first diff. Diffs may already be computed by the + // time onMount fires, so check immediately and otherwise wait for the + // onDidUpdateDiff event. + const scrollToFirstDiff = () => { + editor.goToDiff("next"); + }; + + const changes = editor.getLineChanges(); + if (changes && changes.length > 0) { + scrollToFirstDiff(); + return; + } + + const disposable = editor.onDidUpdateDiff(() => { + const updatedChanges = editor.getLineChanges(); + if (!updatedChanges || updatedChanges.length === 0) { + return; + } + disposable.dispose(); + scrollToFirstDiff(); + }); + }, + [onMount], + ); + + useEffect(() => { + return () => { + const models = diffModelsRef.current; + if (!models) { + return; + } + diffModelsRef.current = null; + // Defer disposal until after React's commit finishes. @monaco-editor/ + // react disposes the diff widget in its own unmount cleanup; freeing + // the models in the same synchronous teardown makes the widget throw + // "TextModel got disposed before DiffEditorWidget model got reset". + queueMicrotask(() => { + models.original.dispose(); + models.modified.dispose(); + }); + }; + }, []); + + return ( + + ); +}; From b649e7dcbbdbb87152dd19748331db8593c19ba0 Mon Sep 17 00:00:00 2001 From: Jay Date: Tue, 1 Sep 2026 20:29:02 +0530 Subject: [PATCH 123/138] fix(site): allow Antigravity IDE token substitution (#28784) (#28841) --- site/src/modules/apps/apps.test.ts | 18 ++++++++++++++++++ site/src/modules/apps/apps.ts | 1 + 2 files changed, 19 insertions(+) diff --git a/site/src/modules/apps/apps.test.ts b/site/src/modules/apps/apps.test.ts index 146964af78b..6a9ccd0dcef 100644 --- a/site/src/modules/apps/apps.test.ts +++ b/site/src/modules/apps/apps.test.ts @@ -85,6 +85,24 @@ describe("getAppHref", () => { expect(href).toBe("vscode://example.com?token=user-session-token"); }); + it("replaces the session token for Antigravity IDE URLs", () => { + const externalApp = { + ...MockWorkspaceApp, + external: true, + url: `antigravity-ide://coder.coder-remote/open?token=${SESSION_TOKEN_PLACEHOLDER}`, + }; + const href = getAppHref(externalApp, { + host: "*.apps-host.tld", + path: "/path-base", + agent: MockWorkspaceAgent, + workspace: MockWorkspace, + token: "user-session-token", + }); + expect(href).toBe( + "antigravity-ide://coder.coder-remote/open?token=user-session-token", + ); + }); + it("doesn't return the URL with the session token replaced when using the HTTP protocol", () => { const externalApp = { ...MockWorkspaceApp, diff --git a/site/src/modules/apps/apps.ts b/site/src/modules/apps/apps.ts index d3f5b7c8e28..179358cbd17 100644 --- a/site/src/modules/apps/apps.ts +++ b/site/src/modules/apps/apps.ts @@ -26,6 +26,7 @@ const ALLOWED_EXTERNAL_APP_PROTOCOLS = [ "kiro:", "positron:", "antigravity:", + "antigravity-ide:", ]; type GetVSCodeHrefParams = { From ac2323f165f8e35b227092c1af68752fa0279e77 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:15:36 +0200 Subject: [PATCH 124/138] fix: normalize path before rate-limit bucket keying (#27273) (#27444) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport of https://github.com/coder/coder/pull/27273 Original PR: #27273 — fix: normalize path before rate-limit bucket keying Merge commit: de716f89dca0174d2f8f4b8ecd998b25a0a3d755 Requested by: @jdomeracki-coder Co-authored-by: Bobby Ho Co-authored-by: Bobby Ho --- coderd/coderd_test.go | 48 +++++++++++++++++++++++++++++++++ coderd/httpmw/ratelimit.go | 18 ++++++++++++- coderd/httpmw/ratelimit_test.go | 30 +++++++++++++++++++++ 3 files changed, 95 insertions(+), 1 deletion(-) diff --git a/coderd/coderd_test.go b/coderd/coderd_test.go index ccf9c8de8fd..2a7f3ad9bf3 100644 --- a/coderd/coderd_test.go +++ b/coderd/coderd_test.go @@ -523,3 +523,51 @@ func TestRateLimitByUser(t *testing.T) { "member should not be able to bypass rate limit") }) } + +// TestRateLimitPathNormalization is a regression test for CDM-02-003 +// (Cure53): a client could bypass a rate limit by inserting redundant +// slashes into the request path. Coder's router still routes the +// respelled path to the same handler as the canonical path, but the rate +// limiter previously keyed its bucket on the raw, un-normalized path, so +// the respelled request landed in a fresh bucket instead of the one +// already exhausted by the canonical path. +func TestRateLimitPathNormalization(t *testing.T) { + t.Parallel() + + const rateLimit = 2 + + client := coderdtest.New(t, &coderdtest.Options{ + LoginRateLimit: rateLimit, + }) + + ctx := testutil.Context(t, testutil.WaitLong) + + post := func(path string) int { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, + client.URL.String()+path, strings.NewReader(`{"password":"hunter2"}`)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + + resp, err := client.HTTPClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + return resp.StatusCode + } + + // Exhaust the limit against the canonical path. + for i := range rateLimit { + require.Equal(t, http.StatusOK, post("/api/v2/users/validate-password"), + "request %d against the canonical path should succeed", i+1) + } + + // The canonical path is now rate limited. + require.Equal(t, http.StatusTooManyRequests, post("/api/v2/users/validate-password"), + "canonical path should be rate limited after exhausting the limit") + + // Respelling the same endpoint with redundant slashes must not grant a + // fresh bucket: it's the same handler, so it must still be limited. + require.Equal(t, http.StatusTooManyRequests, post("/api/v2/users//validate-password"), + "double-slash variant must share the canonical path's rate-limit bucket") + require.Equal(t, http.StatusTooManyRequests, post("/api/v2/users///validate-password"), + "triple-slash variant must share the canonical path's rate-limit bucket") +} diff --git a/coderd/httpmw/ratelimit.go b/coderd/httpmw/ratelimit.go index e89a280530e..17af4be2421 100644 --- a/coderd/httpmw/ratelimit.go +++ b/coderd/httpmw/ratelimit.go @@ -3,6 +3,7 @@ package httpmw import ( "fmt" "net/http" + "path" "strconv" "sync/atomic" "time" @@ -85,7 +86,7 @@ func RateLimit(count int, window time.Duration) func(http.Handler) http.Handler "%q provided but user is not %v", codersdk.BypassRatelimitHeader, rbac.RoleOwner(), ) - }, httprate.KeyByEndpoint), + }, keyByNormalizedEndpoint), httprate.WithLimitHandler(func(w http.ResponseWriter, r *http.Request) { httpapi.Write(r.Context(), w, http.StatusTooManyRequests, codersdk.Response{ Message: fmt.Sprintf("You've been rate limited for sending more than %v requests in %v.", count, window), @@ -94,6 +95,21 @@ func RateLimit(count int, window time.Duration) func(http.Handler) http.Handler ) } +// keyByNormalizedEndpoint mirrors httprate.KeyByEndpoint, but cleans the +// request path first. chi's router tolerates redundant slashes (see +// singleSlashMW in coderd.go) and routes them to the same handler as the +// canonical path, but only normalizes its internal route-matching path, +// not r.URL.Path. Without normalizing here too, a client can respell a +// path, for example inserting an extra slash, to get a fresh rate-limit +// bucket for an endpoint it's already been throttled on. +func keyByNormalizedEndpoint(r *http.Request) (string, error) { + p := r.URL.Path + if p == "" { + p = "/" + } + return path.Clean(p), nil +} + // RateLimitByAuthToken returns a handler that limits requests based on the // authentication token in the request. // diff --git a/coderd/httpmw/ratelimit_test.go b/coderd/httpmw/ratelimit_test.go index 49e46ccf467..88441326c74 100644 --- a/coderd/httpmw/ratelimit_test.go +++ b/coderd/httpmw/ratelimit_test.go @@ -49,6 +49,36 @@ func TestRateLimit(t *testing.T) { } }) + t.Run("PathNormalizationBypass", func(t *testing.T) { + t.Parallel() + rtr := chi.NewRouter() + rtr.Use(httpmw.RateLimit(1, time.Second)) + // A wildcard route so that requests for both the canonical path and + // its redundant-slash variants reach the same handler, mirroring + // how chi's router resolves /api/v2/users//validate-password to the + // same handler as /api/v2/users/validate-password in production. + rtr.Post("/*", func(rw http.ResponseWriter, r *http.Request) { + rw.WriteHeader(http.StatusOK) + }) + + remoteAddr := randRemoteAddr() + paths := []string{ + "/api/v2/users/validate-password", + "/api/v2/users//validate-password", + "/api/v2/users///validate-password", + "/api/v2/users/validate-password", + } + for i, p := range paths { + req := httptest.NewRequest("POST", p, nil) + req.RemoteAddr = remoteAddr + rec := httptest.NewRecorder() + rtr.ServeHTTP(rec, req) + resp := rec.Result() + _ = resp.Body.Close() + require.Equal(t, i != 0, resp.StatusCode == http.StatusTooManyRequests, "request %d (%s)", i, p) + } + }) + t.Run("RandomIPs", func(t *testing.T) { t.Parallel() rtr := chi.NewRouter() From a6bab6204fb49bf4603815019bdaf407590f2986 Mon Sep 17 00:00:00 2001 From: George K Date: Thu, 3 Sep 2026 23:08:43 -0700 Subject: [PATCH 125/138] fix: prevent coderd crashes from unsupported Tailnet RPCs (#28429) (#28965) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport of https://github.com/coder/coder/pull/28429 Original PR: #28429 — fix: prevent coderd crashes from unsupported Tailnet RPCs Merge commit: 156c8b6e41249e35af5dfdc93e867cdcd6579c33 PR created manually because the backport CI job is timing out. (Resolved) conflicts: - coderd/agentapi/api.go - enterprise/coderd/aibridgeserve.go --- agent/agentsocket/server.go | 2 +- agent/agenttest/client.go | 1 + coderd/agentapi/api.go | 32 +++++++- coderd/aibridged.go | 2 +- coderd/coderd.go | 2 +- coderd/workspaceagentsrpc_test.go | 44 +++++++++++ codersdk/drpcsdk/server.go | 39 ++++++++++ codersdk/drpcsdk/server_internal_test.go | 85 +++++++++++++++++++++ codersdk/drpcsdk/server_test.go | 94 ++++++++++++++++++++++++ enterprise/coderd/provisionerdaemons.go | 2 +- provisionersdk/serve.go | 2 +- tailnet/service.go | 17 ++++- tailnet/service_test.go | 22 ++++++ 13 files changed, 334 insertions(+), 10 deletions(-) create mode 100644 codersdk/drpcsdk/server.go create mode 100644 codersdk/drpcsdk/server_internal_test.go create mode 100644 codersdk/drpcsdk/server_test.go diff --git a/agent/agentsocket/server.go b/agent/agentsocket/server.go index 380b792da1d..d8cad44347b 100644 --- a/agent/agentsocket/server.go +++ b/agent/agentsocket/server.go @@ -55,7 +55,7 @@ func NewServer(logger slog.Logger, opts ...Option) (*Server, error) { return nil, xerrors.Errorf("failed to register drpc service: %w", err) } - server.drpcServer = drpcserver.NewWithOptions(mux, drpcserver.Options{ + server.drpcServer = drpcsdk.NewServer(logger, mux, drpcserver.Options{ Manager: drpcsdk.DefaultDRPCOptions(nil), Log: func(err error) { if errors.Is(err, context.Canceled) || diff --git a/agent/agenttest/client.go b/agent/agenttest/client.go index 474469d7ff0..24f84050a81 100644 --- a/agent/agenttest/client.go +++ b/agent/agenttest/client.go @@ -77,6 +77,7 @@ func NewClientWithSecrets(t testing.TB, fakeAAPI := NewFakeAgentAPI(t, logger, mp, statsChan) err = agentproto.DRPCRegisterAgent(mux, fakeAAPI) require.NoError(t, err) + // Keep panics unrecovered in this test server so they fail tests loudly. server := drpcserver.NewWithOptions(mux, drpcserver.Options{ Manager: drpcsdk.DefaultDRPCOptions(nil), Log: func(err error) { diff --git a/coderd/agentapi/api.go b/coderd/agentapi/api.go index 32d65adee29..d00b67b0c3e 100644 --- a/coderd/agentapi/api.go +++ b/coderd/agentapi/api.go @@ -58,7 +58,7 @@ type API struct { *ConnLogAPI *SubAgentAPI *BoundaryLogsAPI - *tailnet.DRPCService + tailnetService *tailnet.DRPCService cachedWorkspaceFields *CachedWorkspaceFields @@ -67,6 +67,28 @@ type API struct { var _ agentproto.DRPCAgentServer = &API{} +// agentTailnetService exposes only Tailnet RPCs intended for workspace agents. +// Other current and future RPCs remain unavailable until explicitly forwarded. +type agentTailnetService struct { + tailnetproto.DRPCTailnetUnimplementedServer + + service *tailnet.DRPCService +} + +func (s *agentTailnetService) PostTelemetry(ctx context.Context, req *tailnetproto.TelemetryRequest) (*tailnetproto.TelemetryResponse, error) { + return s.service.PostTelemetry(ctx, req) +} + +func (s *agentTailnetService) StreamDERPMaps(req *tailnetproto.StreamDERPMapsRequest, stream tailnetproto.DRPCTailnet_StreamDERPMapsStream) error { + return s.service.StreamDERPMaps(req, stream) +} + +func (s *agentTailnetService) Coordinate(stream tailnetproto.DRPCTailnet_CoordinateStream) error { + return s.service.Coordinate(stream) +} + +var _ tailnetproto.DRPCTailnetServer = (*agentTailnetService)(nil) + type Options struct { AgentID uuid.UUID OwnerID uuid.UUID @@ -217,7 +239,7 @@ func New(opts Options, workspace database.Workspace, agent database.WorkspaceAge Log: opts.Log, } - api.DRPCService = &tailnet.DRPCService{ + api.tailnetService = &tailnet.DRPCService{ CoordPtr: opts.TailnetCoordinator, Logger: opts.Log, DerpMapUpdateFrequency: opts.DerpMapUpdateFrequency, @@ -258,12 +280,14 @@ func (a *API) Server(ctx context.Context) (*drpcserver.Server, error) { return nil, xerrors.Errorf("register agent API protocol in DRPC mux: %w", err) } - err = tailnetproto.DRPCRegisterTailnet(mux, a) + err = tailnetproto.DRPCRegisterTailnet(mux, &agentTailnetService{ + service: a.tailnetService, + }) if err != nil { return nil, xerrors.Errorf("register tailnet API protocol in DRPC mux: %w", err) } - return drpcserver.NewWithOptions(&tracing.DRPCHandler{Handler: mux}, + return drpcsdk.NewServer(a.opts.Log, &tracing.DRPCHandler{Handler: mux}, drpcserver.Options{ Manager: drpcsdk.DefaultDRPCOptions(nil), Log: func(err error) { diff --git a/coderd/aibridged.go b/coderd/aibridged.go index f448be39d07..5abb08fb699 100644 --- a/coderd/aibridged.go +++ b/coderd/aibridged.go @@ -84,7 +84,7 @@ func (api *API) CreateInMemoryAIBridgeServer(dialCtx context.Context) (client ai if err != nil { return nil, xerrors.Errorf("register key validator service: %w", err) } - server := drpcserver.NewWithOptions(&tracing.DRPCHandler{Handler: mux}, + server := drpcsdk.NewServer(api.Logger, &tracing.DRPCHandler{Handler: mux}, drpcserver.Options{ Manager: drpcsdk.DefaultDRPCOptions(nil), Log: func(err error) { diff --git a/coderd/coderd.go b/coderd/coderd.go index de6cbbdacf6..558a9981a87 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -2492,7 +2492,7 @@ func (api *API) CreateInMemoryTaggedProvisionerDaemon(dialCtx context.Context, n if err != nil { return nil, err } - server := drpcserver.NewWithOptions(&tracing.DRPCHandler{Handler: mux}, + server := drpcsdk.NewServer(logger, &tracing.DRPCHandler{Handler: mux}, drpcserver.Options{ Manager: drpcsdk.DefaultDRPCOptions(nil), Log: func(err error) { diff --git a/coderd/workspaceagentsrpc_test.go b/coderd/workspaceagentsrpc_test.go index 1595462d191..5d083c131f9 100644 --- a/coderd/workspaceagentsrpc_test.go +++ b/coderd/workspaceagentsrpc_test.go @@ -7,6 +7,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "storj.io/drpc/drpcerr" agentproto "github.com/coder/coder/v2/agent/proto" "github.com/coder/coder/v2/coderd/coderdtest" @@ -17,6 +18,8 @@ import ( "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/codersdk/agentsdk" "github.com/coder/coder/v2/provisionersdk/proto" + "github.com/coder/coder/v2/tailnet" + tailnetproto "github.com/coder/coder/v2/tailnet/proto" "github.com/coder/coder/v2/testutil" ) @@ -109,6 +112,47 @@ func TestWorkspaceAgentReportStats(t *testing.T) { } } +func TestWorkspaceAgentRPC_TailnetMethods(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client, db := coderdtest.NewWithDatabase(t, nil) + user := coderdtest.CreateFirstUser(t, client) + workspace := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OrganizationID: user.OrganizationID, + OwnerID: user.UserID, + }).WithAgent().Do() + + agentClient := agentsdk.New(client.URL, agentsdk.WithFixedToken(workspace.AgentToken)) + conn, err := agentClient.ConnectRPC(ctx) + require.NoError(t, err) + t.Cleanup(func() { + _ = conn.Close() + }) + + tailnetClient := tailnetproto.NewDRPCTailnetClient(conn) + _, err = tailnetClient.RefreshResumeToken(ctx, &tailnetproto.RefreshResumeTokenRequest{}) + require.Error(t, err) + require.EqualValues(t, drpcerr.Unimplemented, drpcerr.Code(err)) + + updates, err := tailnetClient.WorkspaceUpdates(ctx, &tailnetproto.WorkspaceUpdatesRequest{ + WorkspaceOwnerId: tailnet.UUIDToByteSlice(user.UserID), + }) + if err == nil { + _, err = updates.Recv() + } + require.Error(t, err) + require.EqualValues(t, drpcerr.Unimplemented, drpcerr.Code(err)) + + telemetry, err := tailnetClient.PostTelemetry(ctx, &tailnetproto.TelemetryRequest{}) + require.NoError(t, err) + require.NotNil(t, telemetry) + + agentAPI := agentproto.NewDRPCAgentClient(conn) + _, err = agentAPI.GetManifest(ctx, &agentproto.GetManifestRequest{}) + require.NoError(t, err) +} + func TestAgentAPI_LargeManifest(t *testing.T) { t.Parallel() diff --git a/codersdk/drpcsdk/server.go b/codersdk/drpcsdk/server.go new file mode 100644 index 00000000000..6fc9a505d93 --- /dev/null +++ b/codersdk/drpcsdk/server.go @@ -0,0 +1,39 @@ +package drpcsdk + +import ( + "runtime/debug" + + "storj.io/drpc" + "storj.io/drpc/drpcserver" + + "cdr.dev/slog/v3" +) + +// NewServer constructs a dRPC server that recovers panics from RPC handlers. +func NewServer(logger slog.Logger, handler drpc.Handler, options drpcserver.Options) *drpcserver.Server { + return drpcserver.NewWithOptions(&recoverHandler{ + logger: logger, + handler: handler, + }, options) +} + +type recoverHandler struct { + logger slog.Logger + handler drpc.Handler +} + +func (h *recoverHandler) HandleRPC(stream drpc.Stream, rpc string) (err error) { + defer func() { + if r := recover(); r != nil { + h.logger.Error(stream.Context(), + "panic serving dRPC request (recovered)", + slog.F("rpc", rpc), + slog.F("panic", r), + slog.F("stack", string(debug.Stack())), + ) + err = drpc.InternalError.New("panic serving dRPC request") + } + }() + + return h.handler.HandleRPC(stream, rpc) +} diff --git a/codersdk/drpcsdk/server_internal_test.go b/codersdk/drpcsdk/server_internal_test.go new file mode 100644 index 00000000000..66298af68f5 --- /dev/null +++ b/codersdk/drpcsdk/server_internal_test.go @@ -0,0 +1,85 @@ +package drpcsdk + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + "golang.org/x/xerrors" + "storj.io/drpc" + + "cdr.dev/slog/v3" + "github.com/coder/coder/v2/testutil" +) + +func TestRecoverHandler(t *testing.T) { + t.Parallel() + + t.Run("Panic", func(t *testing.T) { + t.Parallel() + + const panicValue = "sensitive panic details" + sink := testutil.NewFakeSink(t) + handler := &recoverHandler{ + logger: sink.Logger(), + handler: handlerFunc(func(drpc.Stream, string) error { + panic(panicValue) + }), + } + + err := handler.HandleRPC(contextStream{ctx: t.Context()}, "/test.Service/Panic") + require.Error(t, err) + require.True(t, drpc.InternalError.Has(err)) + require.NotContains(t, err.Error(), panicValue) + + entries := sink.Entries() + require.Len(t, entries, 1) + require.Equal(t, slog.LevelError, entries[0].Level) + require.Equal(t, "panic serving dRPC request (recovered)", entries[0].Message) + require.Equal(t, "/test.Service/Panic", fieldValue(entries[0].Fields, "rpc")) + require.Equal(t, panicValue, fieldValue(entries[0].Fields, "panic")) + stackValue := fieldValue(entries[0].Fields, "stack") + stack, ok := stackValue.(string) + require.True(t, ok, "stack field must be a string, got %T", stackValue) + require.Contains(t, stack, "goroutine ") + }) + + t.Run("Error", func(t *testing.T) { + t.Parallel() + + expected := xerrors.New("handler error") + handler := &recoverHandler{ + handler: handlerFunc(func(drpc.Stream, string) error { + return expected + }), + } + + err := handler.HandleRPC(contextStream{ctx: t.Context()}, "/test.Service/Error") + require.ErrorIs(t, err, expected) + }) +} + +type handlerFunc func(drpc.Stream, string) error + +func (f handlerFunc) HandleRPC(stream drpc.Stream, rpc string) error { + return f(stream, rpc) +} + +type contextStream struct { + ctx context.Context +} + +func (s contextStream) Context() context.Context { return s.ctx } +func (contextStream) MsgSend(drpc.Message, drpc.Encoding) error { return nil } +func (contextStream) MsgRecv(drpc.Message, drpc.Encoding) error { return nil } +func (contextStream) CloseSend() error { return nil } +func (contextStream) Close() error { return nil } + +func fieldValue(fields slog.Map, name string) any { + for _, field := range fields { + if field.Name == name { + return field.Value + } + } + return nil +} diff --git a/codersdk/drpcsdk/server_test.go b/codersdk/drpcsdk/server_test.go new file mode 100644 index 00000000000..e906daa82e0 --- /dev/null +++ b/codersdk/drpcsdk/server_test.go @@ -0,0 +1,94 @@ +package drpcsdk_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + "golang.org/x/xerrors" + "storj.io/drpc" + "storj.io/drpc/drpcserver" + + "github.com/coder/coder/v2/codersdk/drpcsdk" + "github.com/coder/coder/v2/testutil" +) + +func TestNewServerRecoversPanics(t *testing.T) { + t.Parallel() + + const ( + panicRPC = "/test.Service/Panic" + echoRPC = "/test.Service/Echo" + panicValue = "sensitive panic details" + ) + + ctx := testutil.Context(t, testutil.WaitShort) + serverCtx, cancel := context.WithCancel(ctx) + defer cancel() + + client, listener := drpcsdk.MemTransportPipe() + defer func() { + _ = client.Close() + _ = listener.Close() + }() + + handler := testHandlerFunc(func(stream drpc.Stream, rpc string) error { + switch rpc { + case panicRPC: + panic(panicValue) + case echoRPC: + var message string + if err := stream.MsgRecv(&message, stringEncoding{}); err != nil { + return err + } + return stream.MsgSend(&message, stringEncoding{}) + default: + return xerrors.Errorf("unexpected RPC %q", rpc) + } + }) + server := drpcsdk.NewServer(testutil.NewFakeSink(t).Logger(), handler, drpcserver.Options{ + Manager: drpcsdk.DefaultDRPCOptions(nil), + }) + serverDone := make(chan error, 1) + go func() { + serverDone <- server.Serve(serverCtx, listener) + }() + + request, response := "request", "" + err := client.Invoke(ctx, panicRPC, stringEncoding{}, &request, &response) + require.EqualError(t, err, "internal error: panic serving dRPC request") + require.NotContains(t, err.Error(), panicValue) + + request, response = "healthy", "" + err = client.Invoke(ctx, echoRPC, stringEncoding{}, &request, &response) + require.NoError(t, err) + require.Equal(t, request, response) + + cancel() + require.NoError(t, testutil.RequireReceive(ctx, t, serverDone)) +} + +type testHandlerFunc func(drpc.Stream, string) error + +func (f testHandlerFunc) HandleRPC(stream drpc.Stream, rpc string) error { + return f(stream, rpc) +} + +type stringEncoding struct{} + +func (stringEncoding) Marshal(message drpc.Message) ([]byte, error) { + value, ok := message.(*string) + if !ok { + return nil, xerrors.Errorf("marshal %T: expected *string", message) + } + return []byte(*value), nil +} + +func (stringEncoding) Unmarshal(data []byte, message drpc.Message) error { + value, ok := message.(*string) + if !ok { + return xerrors.Errorf("unmarshal %T: expected *string", message) + } + *value = string(data) + return nil +} diff --git a/enterprise/coderd/provisionerdaemons.go b/enterprise/coderd/provisionerdaemons.go index 17a00d22421..f0ace6c7c57 100644 --- a/enterprise/coderd/provisionerdaemons.go +++ b/enterprise/coderd/provisionerdaemons.go @@ -376,7 +376,7 @@ func (api *API) provisionerDaemonServe(rw http.ResponseWriter, r *http.Request) _ = conn.Close(websocket.StatusInternalError, httpapi.WebsocketCloseSprintf("drpc register provisioner daemon: %s", err)) return } - server := drpcserver.NewWithOptions(mux, drpcserver.Options{ + server := drpcsdk.NewServer(logger, mux, drpcserver.Options{ Manager: drpcsdk.DefaultDRPCOptions(nil), Log: func(err error) { if xerrors.Is(err, io.EOF) { diff --git a/provisionersdk/serve.go b/provisionersdk/serve.go index 4afcee96269..013626b22a4 100644 --- a/provisionersdk/serve.go +++ b/provisionersdk/serve.go @@ -92,7 +92,7 @@ func Serve(ctx context.Context, server Server, options *ServeOptions) error { if err != nil { return xerrors.Errorf("register provisioner: %w", err) } - srv := drpcserver.NewWithOptions(&tracing.DRPCHandler{Handler: mux}, drpcserver.Options{ + srv := drpcsdk.NewServer(options.Logger, &tracing.DRPCHandler{Handler: mux}, drpcserver.Options{ Manager: drpcsdk.DefaultDRPCOptions(nil), }) diff --git a/tailnet/service.go b/tailnet/service.go index 6ae02876a4d..9773530c76f 100644 --- a/tailnet/service.go +++ b/tailnet/service.go @@ -11,6 +11,7 @@ import ( "github.com/google/uuid" "github.com/hashicorp/yamux" "golang.org/x/xerrors" + "storj.io/drpc/drpcerr" "storj.io/drpc/drpcmux" "storj.io/drpc/drpcserver" "tailscale.com/tailcfg" @@ -92,7 +93,7 @@ func NewClientService(options ClientServiceOptions) ( if err != nil { return nil, xerrors.Errorf("register DRPC service: %w", err) } - server := drpcserver.NewWithOptions(mux, drpcserver.Options{ + server := drpcsdk.NewServer(options.Logger, mux, drpcserver.Options{ Manager: drpcsdk.DefaultDRPCOptions(nil), Log: func(err error) { if xerrors.Is(err, io.EOF) || @@ -185,6 +186,13 @@ func (s *DRPCService) StreamDERPMaps(_ *proto.StreamDERPMapsRequest, stream prot } func (s *DRPCService) RefreshResumeToken(ctx context.Context, _ *proto.RefreshResumeTokenRequest) (*proto.RefreshResumeTokenResponse, error) { + if s.ResumeTokenProvider == nil { + return nil, drpcerr.WithCode( + xerrors.New("resume tokens not supported on this connection"), + drpcerr.Unimplemented, + ) + } + streamID, ok := ctx.Value(streamIDContextKey{}).(StreamID) if !ok { return nil, xerrors.New("no Stream ID") @@ -219,6 +227,13 @@ func (s *DRPCService) Coordinate(stream proto.DRPCTailnet_CoordinateStream) erro } func (s *DRPCService) WorkspaceUpdates(req *proto.WorkspaceUpdatesRequest, stream proto.DRPCTailnet_WorkspaceUpdatesStream) error { + if s.WorkspaceUpdatesProvider == nil { + return drpcerr.WithCode( + xerrors.New("workspace updates not supported on this connection"), + drpcerr.Unimplemented, + ) + } + defer stream.Close() ctx := stream.Context() diff --git a/tailnet/service_test.go b/tailnet/service_test.go index 0c268b05edb..21a290ef358 100644 --- a/tailnet/service_test.go +++ b/tailnet/service_test.go @@ -13,6 +13,7 @@ import ( "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" "golang.org/x/xerrors" + "storj.io/drpc/drpcerr" "tailscale.com/tailcfg" "github.com/coder/coder/v2/tailnet" @@ -178,6 +179,27 @@ func TestClientService_ServeClient_V1(t *testing.T) { require.ErrorIs(t, err, tailnet.ErrUnsupportedVersion) } +func TestClientService_UnsupportedProviders(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + clientID := uuid.New() + _, client := createUpdateService(t, ctx, clientID, nil) + + _, err := client.RefreshResumeToken(ctx, &proto.RefreshResumeTokenRequest{}) + require.ErrorContains(t, err, "resume tokens not supported on this connection") + require.EqualValues(t, drpcerr.Unimplemented, drpcerr.Code(err)) + + updates, err := client.WorkspaceUpdates(ctx, &proto.WorkspaceUpdatesRequest{ + WorkspaceOwnerId: tailnet.UUIDToByteSlice(clientID), + }) + if err == nil { + _, err = updates.Recv() + } + require.ErrorContains(t, err, "workspace updates not supported on this connection") + require.EqualValues(t, drpcerr.Unimplemented, drpcerr.Code(err)) +} + func TestNetworkTelemetryBatcher(t *testing.T) { t.Parallel() From dede330c3fbe56999cad1b8de3cd2e1a7e5dcdb6 Mon Sep 17 00:00:00 2001 From: dylanhuff-at-coder Date: Wed, 16 Sep 2026 12:38:12 -0400 Subject: [PATCH 126/138] fix(coderd/agentapi): reject missing RPC messages (#29150) (#29366) Backport of #29150 to `release/2.34`. Reject missing lifecycle, startup, and metadata sub-messages with `InvalidArgument` instead of panicking. Validate the entire metadata batch before modifying or queuing values.
Backport context Cherry-picks `9c58283d8e7a036a45820f76da7f05ffcf81d611` without changes. The automatic backport failed while pushing the branch.
Generated by Coder Agents on behalf of @dylanhuff-at-coder. --- coderd/agentapi/lifecycle.go | 10 ++++ coderd/agentapi/metadata.go | 12 ++++ coderd/agentapi/validation_test.go | 90 ++++++++++++++++++++++++++++++ 3 files changed, 112 insertions(+) create mode 100644 coderd/agentapi/validation_test.go diff --git a/coderd/agentapi/lifecycle.go b/coderd/agentapi/lifecycle.go index 5003a16f04d..d9b1aa296a1 100644 --- a/coderd/agentapi/lifecycle.go +++ b/coderd/agentapi/lifecycle.go @@ -10,7 +10,9 @@ import ( "github.com/google/uuid" "golang.org/x/mod/semver" "golang.org/x/xerrors" + "google.golang.org/grpc/codes" "google.golang.org/protobuf/types/known/timestamppb" + "storj.io/drpc/drpcerr" "cdr.dev/slog/v3" agentproto "github.com/coder/coder/v2/agent/proto" @@ -45,6 +47,10 @@ func (a *LifecycleAPI) now() time.Time { } func (a *LifecycleAPI) UpdateLifecycle(ctx context.Context, req *agentproto.UpdateLifecycleRequest) (*agentproto.Lifecycle, error) { + if req.GetLifecycle() == nil { + return nil, drpcerr.WithCode(xerrors.New("lifecycle is required"), uint64(codes.InvalidArgument)) + } + workspaceAgent, err := a.AgentFn(ctx) if err != nil { return nil, err @@ -146,6 +152,10 @@ func (a *LifecycleAPI) UpdateLifecycle(ctx context.Context, req *agentproto.Upda } func (a *LifecycleAPI) UpdateStartup(ctx context.Context, req *agentproto.UpdateStartupRequest) (*agentproto.Startup, error) { + if req.GetStartup() == nil { + return nil, drpcerr.WithCode(xerrors.New("startup is required"), uint64(codes.InvalidArgument)) + } + apiVersion, ok := ctx.Value(contextKeyAPIVersion{}).(string) if !ok { return nil, xerrors.Errorf("internal error; api version unspecified") diff --git a/coderd/agentapi/metadata.go b/coderd/agentapi/metadata.go index 12efe362abb..8baee8eb3f2 100644 --- a/coderd/agentapi/metadata.go +++ b/coderd/agentapi/metadata.go @@ -8,6 +8,8 @@ import ( "github.com/google/uuid" "golang.org/x/xerrors" + "google.golang.org/grpc/codes" + "storj.io/drpc/drpcerr" "cdr.dev/slog/v3" agentproto "github.com/coder/coder/v2/agent/proto" @@ -34,6 +36,16 @@ func (a *MetadataAPI) now() time.Time { } func (a *MetadataAPI) BatchUpdateMetadata(ctx context.Context, req *agentproto.BatchUpdateMetadataRequest) (*agentproto.BatchUpdateMetadataResponse, error) { + if req == nil { + return nil, drpcerr.WithCode(xerrors.New("metadata request is required"), uint64(codes.InvalidArgument)) + } + // Validate the entire batch before trimming values or discarding excess keys. + for i, md := range req.Metadata { + if md.GetResult() == nil { + return nil, drpcerr.WithCode(xerrors.Errorf("metadata result at index %d is required", i), uint64(codes.InvalidArgument)) + } + } + const ( // maxAllKeysLen is the maximum length of all metadata keys. This is // 6144 to stay below the Postgres NOTIFY limit of 8000 bytes, with some diff --git a/coderd/agentapi/validation_test.go b/coderd/agentapi/validation_test.go new file mode 100644 index 00000000000..2a32556b219 --- /dev/null +++ b/coderd/agentapi/validation_test.go @@ -0,0 +1,90 @@ +package agentapi_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "storj.io/drpc/drpcerr" + + agentproto "github.com/coder/coder/v2/agent/proto" + "github.com/coder/coder/v2/coderd/agentapi" + "github.com/coder/coder/v2/codersdk/drpcsdk" + "github.com/coder/coder/v2/testutil" +) + +func TestUpdateLifecycleMissingMessage(t *testing.T) { + t.Parallel() + + resp, err := (&agentapi.LifecycleAPI{}).UpdateLifecycle(context.Background(), &agentproto.UpdateLifecycleRequest{}) + require.ErrorContains(t, err, "lifecycle is required") + require.EqualValues(t, codes.InvalidArgument, drpcerr.Code(err)) + require.Nil(t, resp) +} + +func TestUpdateStartupMissingMessage(t *testing.T) { + t.Parallel() + + resp, err := (&agentapi.LifecycleAPI{}).UpdateStartup(context.Background(), &agentproto.UpdateStartupRequest{}) + require.ErrorContains(t, err, "startup is required") + require.EqualValues(t, codes.InvalidArgument, drpcerr.Code(err)) + require.Nil(t, resp) +} + +func TestBatchUpdateMetadataMissingMessage(t *testing.T) { + t.Parallel() + + for _, tt := range []struct { + name string + req *agentproto.BatchUpdateMetadataRequest + }{ + {name: "NilRequest"}, + {name: "MixedBatch", req: &agentproto.BatchUpdateMetadataRequest{Metadata: []*agentproto.Metadata{ + {Key: "valid", Result: &agentproto.WorkspaceAgentMetadata_Result{Value: "value"}}, + {Key: "invalid"}, + }}}, + } { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + resp, err := (&agentapi.MetadataAPI{}).BatchUpdateMetadata(context.Background(), tt.req) + require.Error(t, err) + require.EqualValues(t, codes.InvalidArgument, drpcerr.Code(err)) + require.Nil(t, resp) + }) + } +} + +func TestAgentRPCMissingMessage(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + serverCtx, cancel := context.WithCancel(agentapi.WithAPIVersion(ctx, "2.0")) + defer cancel() + conn, listener := drpcsdk.MemTransportPipe() + defer conn.Close() + defer listener.Close() + + api := &agentapi.API{ + LifecycleAPI: &agentapi.LifecycleAPI{}, + StatsAPI: &agentapi.StatsAPI{}, + } + server, err := api.Server(serverCtx) + require.NoError(t, err) + done := make(chan error, 1) + go func() { + done <- server.Serve(serverCtx, listener) + }() + client := agentproto.NewDRPCAgentClient(conn) + + _, err = client.UpdateLifecycle(ctx, &agentproto.UpdateLifecycleRequest{}) + require.ErrorContains(t, err, "lifecycle is required") + require.EqualValues(t, codes.InvalidArgument, drpcerr.Code(err)) + stats, err := client.UpdateStats(ctx, &agentproto.UpdateStatsRequest{}) + require.NoError(t, err) + require.NotNil(t, stats.ReportInterval) + + cancel() + require.NoError(t, testutil.RequireReceive(ctx, t, done)) +} From a6200b24fef4b4c04634fb0e3ad8ca0bf45b1e1f Mon Sep 17 00:00:00 2001 From: "J. Scott Miller" Date: Wed, 16 Sep 2026 11:39:19 -0500 Subject: [PATCH 127/138] perf(coderd): build the workspace build fan-out maps once per batch (#28074) (#29369) Backport of https://github.com/coder/coder/pull/28074 Original PR: #28074 - perf(coderd): build the workspace build fan-out maps once per batch Merge commit: 62f4afbb6067ec14ee27768f3b97c84327ecdf08 Requested by: @jscottmiller Opened manually because the backport workflow's push was rejected by GitHub's workflow-scope check (run 34996286869). Cherry-pick applied cleanly; `go build ./coderd/...` and `go test ./coderd -run TestConvertWorkspaceBuild` pass on this branch. Addresses PLAT-386 / #27205. --- Created with Coder Agents on behalf of @jscottmiller. --- coderd/workspacebuilds.go | 204 +++++++++------- coderd/workspacebuilds_internal_test.go | 299 ++++++++++++++++++++++++ coderd/workspaces.go | 9 +- 3 files changed, 415 insertions(+), 97 deletions(-) create mode 100644 coderd/workspacebuilds_internal_test.go diff --git a/coderd/workspacebuilds.go b/coderd/workspacebuilds.go index 8ccb6417b8e..a1c9ed4a64d 100644 --- a/coderd/workspacebuilds.go +++ b/coderd/workspacebuilds.go @@ -1,6 +1,7 @@ package coderd import ( + "cmp" "context" "database/sql" "encoding/json" @@ -81,15 +82,17 @@ func (api *API) workspaceBuild(rw http.ResponseWriter, r *http.Request) { workspaceBuild, workspace, data.jobs[0], - data.resources, - data.metadata, - data.agents, - data.apps, - data.appStatuses, - data.scripts, - data.logSources, + newWorkspaceBuildIndex( + data.resources, + data.metadata, + data.agents, + data.apps, + data.appStatuses, + data.scripts, + data.logSources, + nil, + ), data.templateVersions[0], - nil, ) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ @@ -291,15 +294,17 @@ func (api *API) workspaceBuildByBuildNumber(rw http.ResponseWriter, r *http.Requ workspaceBuild, workspace, data.jobs[0], - data.resources, - data.metadata, - data.agents, - data.apps, - data.appStatuses, - data.scripts, - data.logSources, + newWorkspaceBuildIndex( + data.resources, + data.metadata, + data.agents, + data.apps, + data.appStatuses, + data.scripts, + data.logSources, + data.provisionerDaemons, + ), data.templateVersions[0], - data.provisionerDaemons, ) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ @@ -537,15 +542,8 @@ func (api *API) postWorkspaceBuildsInternal( *workspaceBuild, workspace, queuePos, - []database.WorkspaceResource{}, - []database.WorkspaceResourceMetadatum{}, - []database.WorkspaceAgent{}, - []database.WorkspaceApp{}, - []database.WorkspaceAppStatus{}, - []database.GetWorkspaceAgentScriptsByAgentIDsRow{}, - []database.WorkspaceAgentLogSource{}, + newWorkspaceBuildIndex(nil, nil, nil, nil, nil, nil, nil, provisionerDaemons), database.TemplateVersion{}, - provisionerDaemons, ) if err != nil { return codersdk.WorkspaceBuild{}, httperror.NewResponseError( @@ -1120,6 +1118,78 @@ func (api *API) workspaceBuildsData(ctx context.Context, workspaceBuilds []datab }, nil } +// workspaceBuildIndex groups build rows by the ID they are looked up with. +// Conversion reorders rows within a bucket in place, so an index must be read +// by a single goroutine. +type workspaceBuildIndex struct { + resourcesByJobID map[uuid.UUID][]database.WorkspaceResource + metadataByResourceID map[uuid.UUID][]database.WorkspaceResourceMetadatum + agentsByResourceID map[uuid.UUID][]database.WorkspaceAgent + appsByAgentID map[uuid.UUID][]database.WorkspaceApp + statusesByAgentID map[uuid.UUID][]database.WorkspaceAppStatus + scriptsByAgentID map[uuid.UUID][]database.GetWorkspaceAgentScriptsByAgentIDsRow + logSourcesByAgentID map[uuid.UUID][]database.WorkspaceAgentLogSource + daemonsByJobID map[uuid.UUID][]database.ProvisionerDaemon +} + +// newWorkspaceBuildIndex makes one pass over each slice, copying rows into +// per-ID buckets. The input slices are neither modified nor aliased. +func newWorkspaceBuildIndex( + workspaceResources []database.WorkspaceResource, + resourceMetadata []database.WorkspaceResourceMetadatum, + resourceAgents []database.WorkspaceAgent, + agentApps []database.WorkspaceApp, + agentAppStatuses []database.WorkspaceAppStatus, + agentScripts []database.GetWorkspaceAgentScriptsByAgentIDsRow, + agentLogSources []database.WorkspaceAgentLogSource, + provisionerDaemons []database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow, +) *workspaceBuildIndex { + index := &workspaceBuildIndex{ + resourcesByJobID: make(map[uuid.UUID][]database.WorkspaceResource), + metadataByResourceID: make(map[uuid.UUID][]database.WorkspaceResourceMetadatum), + agentsByResourceID: make(map[uuid.UUID][]database.WorkspaceAgent), + appsByAgentID: make(map[uuid.UUID][]database.WorkspaceApp), + statusesByAgentID: make(map[uuid.UUID][]database.WorkspaceAppStatus), + scriptsByAgentID: make(map[uuid.UUID][]database.GetWorkspaceAgentScriptsByAgentIDsRow), + logSourcesByAgentID: make(map[uuid.UUID][]database.WorkspaceAgentLogSource), + daemonsByJobID: make(map[uuid.UUID][]database.ProvisionerDaemon), + } + for _, resource := range workspaceResources { + index.resourcesByJobID[resource.JobID] = append(index.resourcesByJobID[resource.JobID], resource) + } + for _, metadata := range resourceMetadata { + index.metadataByResourceID[metadata.WorkspaceResourceID] = append(index.metadataByResourceID[metadata.WorkspaceResourceID], metadata) + } + for _, agent := range resourceAgents { + index.agentsByResourceID[agent.ResourceID] = append(index.agentsByResourceID[agent.ResourceID], agent) + } + for _, app := range agentApps { + index.appsByAgentID[app.AgentID] = append(index.appsByAgentID[app.AgentID], app) + } + for _, status := range agentAppStatuses { + index.statusesByAgentID[status.AgentID] = append(index.statusesByAgentID[status.AgentID], status) + } + for _, script := range agentScripts { + index.scriptsByAgentID[script.WorkspaceAgentID] = append(index.scriptsByAgentID[script.WorkspaceAgentID], script) + } + for _, logSource := range agentLogSources { + index.logSourcesByAgentID[logSource.WorkspaceAgentID] = append(index.logSourcesByAgentID[logSource.WorkspaceAgentID], logSource) + } + for _, daemon := range provisionerDaemons { + index.daemonsByJobID[daemon.JobID] = append(index.daemonsByJobID[daemon.JobID], daemon.ProvisionerDaemon) + } + // Agents within a resource are ordered by display order, then name. + for _, agents := range index.agentsByResourceID { + slices.SortFunc(agents, func(a, b database.WorkspaceAgent) int { + return cmp.Or( + cmp.Compare(a.DisplayOrder, b.DisplayOrder), + cmp.Compare(a.Name, b.Name), + ) + }) + } + return index +} + func (api *API) convertWorkspaceBuilds( workspaceBuilds []database.WorkspaceBuild, workspaces []database.Workspace, @@ -1147,6 +1217,17 @@ func (api *API) convertWorkspaceBuilds( templateVersionByID[templateVersion.ID] = templateVersion } + index := newWorkspaceBuildIndex( + workspaceResources, + resourceMetadata, + resourceAgents, + agentApps, + agentAppStatuses, + agentScripts, + agentLogSources, + provisionerDaemons, + ) + // Should never be nil for API consistency apiBuilds := []codersdk.WorkspaceBuild{} for _, build := range workspaceBuilds { @@ -1167,15 +1248,8 @@ func (api *API) convertWorkspaceBuilds( build, workspace, job, - workspaceResources, - resourceMetadata, - resourceAgents, - agentApps, - agentAppStatuses, - agentScripts, - agentLogSources, + index, templateVersion, - provisionerDaemons, ) if err != nil { return nil, xerrors.Errorf("converting workspace build: %w", err) @@ -1191,64 +1265,16 @@ func (api *API) convertWorkspaceBuild( build database.WorkspaceBuild, workspace database.Workspace, job database.GetProvisionerJobsByIDsWithQueuePositionRow, - workspaceResources []database.WorkspaceResource, - resourceMetadata []database.WorkspaceResourceMetadatum, - resourceAgents []database.WorkspaceAgent, - agentApps []database.WorkspaceApp, - agentAppStatuses []database.WorkspaceAppStatus, - agentScripts []database.GetWorkspaceAgentScriptsByAgentIDsRow, - agentLogSources []database.WorkspaceAgentLogSource, + index *workspaceBuildIndex, templateVersion database.TemplateVersion, - provisionerDaemons []database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow, ) (codersdk.WorkspaceBuild, error) { - resourcesByJobID := map[uuid.UUID][]database.WorkspaceResource{} - for _, resource := range workspaceResources { - resourcesByJobID[resource.JobID] = append(resourcesByJobID[resource.JobID], resource) - } - metadataByResourceID := map[uuid.UUID][]database.WorkspaceResourceMetadatum{} - for _, metadata := range resourceMetadata { - metadataByResourceID[metadata.WorkspaceResourceID] = append(metadataByResourceID[metadata.WorkspaceResourceID], metadata) - } - agentsByResourceID := map[uuid.UUID][]database.WorkspaceAgent{} - for _, agent := range resourceAgents { - agentsByResourceID[agent.ResourceID] = append(agentsByResourceID[agent.ResourceID], agent) - } - appsByAgentID := map[uuid.UUID][]database.WorkspaceApp{} - for _, app := range agentApps { - appsByAgentID[app.AgentID] = append(appsByAgentID[app.AgentID], app) - } - scriptsByAgentID := map[uuid.UUID][]database.GetWorkspaceAgentScriptsByAgentIDsRow{} - for _, script := range agentScripts { - scriptsByAgentID[script.WorkspaceAgentID] = append(scriptsByAgentID[script.WorkspaceAgentID], script) - } - logSourcesByAgentID := map[uuid.UUID][]database.WorkspaceAgentLogSource{} - for _, logSource := range agentLogSources { - logSourcesByAgentID[logSource.WorkspaceAgentID] = append(logSourcesByAgentID[logSource.WorkspaceAgentID], logSource) - } - provisionerDaemonsForThisWorkspaceBuild := []database.ProvisionerDaemon{} - for _, provisionerDaemon := range provisionerDaemons { - if provisionerDaemon.JobID != job.ProvisionerJob.ID { - continue - } - provisionerDaemonsForThisWorkspaceBuild = append(provisionerDaemonsForThisWorkspaceBuild, provisionerDaemon.ProvisionerDaemon) - } - matchedProvisioners := db2sdk.MatchedProvisioners(provisionerDaemonsForThisWorkspaceBuild, job.ProvisionerJob.CreatedAt, provisionerdserver.StaleInterval) - statusesByAgentID := map[uuid.UUID][]database.WorkspaceAppStatus{} - for _, status := range agentAppStatuses { - statusesByAgentID[status.AgentID] = append(statusesByAgentID[status.AgentID], status) - } + matchedProvisioners := db2sdk.MatchedProvisioners(index.daemonsByJobID[job.ProvisionerJob.ID], job.ProvisionerJob.CreatedAt, provisionerdserver.StaleInterval) - resources := resourcesByJobID[job.ProvisionerJob.ID] + resources := index.resourcesByJobID[job.ProvisionerJob.ID] apiResources := make([]codersdk.WorkspaceResource, 0) resourceAgentsMinOrder := map[uuid.UUID]int32{} // map[resource.ID]minOrder for _, resource := range resources { - agents := agentsByResourceID[resource.ID] - sort.Slice(agents, func(i, j int) bool { - if agents[i].DisplayOrder != agents[j].DisplayOrder { - return agents[i].DisplayOrder < agents[j].DisplayOrder - } - return agents[i].Name < agents[j].Name - }) + agents := index.agentsByResourceID[resource.ID] apiAgents := make([]codersdk.WorkspaceAgent, 0) resourceAgentsMinOrder[resource.ID] = math.MaxInt32 @@ -1256,10 +1282,10 @@ func (api *API) convertWorkspaceBuild( for _, agent := range agents { resourceAgentsMinOrder[resource.ID] = min(resourceAgentsMinOrder[resource.ID], agent.DisplayOrder) - apps := appsByAgentID[agent.ID] - scripts := scriptsByAgentID[agent.ID] - statuses := statusesByAgentID[agent.ID] - logSources := logSourcesByAgentID[agent.ID] + apps := index.appsByAgentID[agent.ID] + scripts := index.scriptsByAgentID[agent.ID] + statuses := index.statusesByAgentID[agent.ID] + logSources := index.logSourcesByAgentID[agent.ID] apiAgent, err := db2sdk.WorkspaceAgent( api.DERPMap(), *api.TailnetCoordinator.Load(), agent, db2sdk.Apps(apps, statuses, agent, workspace.OwnerUsername, workspace.WorkspaceTable()), convertScripts(scripts), convertLogSources(logSources), api.AgentInactiveDisconnectTimeout, api.DeploymentValues.AgentFallbackTroubleshootingURL.String(), @@ -1269,7 +1295,7 @@ func (api *API) convertWorkspaceBuild( } apiAgents = append(apiAgents, apiAgent) } - metadata := append(make([]database.WorkspaceResourceMetadatum, 0), metadataByResourceID[resource.ID]...) + metadata := append(make([]database.WorkspaceResourceMetadatum, 0), index.metadataByResourceID[resource.ID]...) apiResources = append(apiResources, convertWorkspaceResource(resource, apiAgents, metadata)) } sort.Slice(apiResources, func(i, j int) bool { diff --git a/coderd/workspacebuilds_internal_test.go b/coderd/workspacebuilds_internal_test.go new file mode 100644 index 00000000000..f7e9e55c04d --- /dev/null +++ b/coderd/workspacebuilds_internal_test.go @@ -0,0 +1,299 @@ +package coderd + +import ( + "database/sql" + "fmt" + "strings" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "cdr.dev/slog/v3" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/tailnet" +) + +// buildFixture holds one batch of rows shaped like the output of +// workspaceBuildsData: a set of builds plus the flat resource, agent, app, +// script, log source, status, and daemon rows for all of them. +type buildFixture struct { + builds []database.WorkspaceBuild + workspaces []database.Workspace + jobs []database.GetProvisionerJobsByIDsWithQueuePositionRow + templateVersions []database.TemplateVersion + resources []database.WorkspaceResource + metadata []database.WorkspaceResourceMetadatum + agents []database.WorkspaceAgent + apps []database.WorkspaceApp + statuses []database.WorkspaceAppStatus + scripts []database.GetWorkspaceAgentScriptsByAgentIDsRow + logSources []database.WorkspaceAgentLogSource + daemons []database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow +} + +func newBuildFixture(builds, resourcesPerBuild int, agentsPerResource int32, appsPerAgent int) buildFixture { + now := time.Date(2026, 8, 11, 0, 0, 0, 0, time.UTC) + f := buildFixture{} + for b := range builds { + workspaceID := uuid.New() + jobID := uuid.New() + templateVersionID := uuid.New() + + f.workspaces = append(f.workspaces, database.Workspace{ + ID: workspaceID, + OwnerID: uuid.New(), + OwnerUsername: fmt.Sprintf("owner-%d", b), + Name: fmt.Sprintf("workspace-%d", b), + }) + f.jobs = append(f.jobs, database.GetProvisionerJobsByIDsWithQueuePositionRow{ + ProvisionerJob: database.ProvisionerJob{ + ID: jobID, + CreatedAt: now, + StartedAt: sql.NullTime{Time: now, Valid: true}, + }, + }) + f.templateVersions = append(f.templateVersions, database.TemplateVersion{ + ID: templateVersionID, + Name: fmt.Sprintf("version-%d", b), + }) + f.builds = append(f.builds, database.WorkspaceBuild{ + ID: uuid.New(), + WorkspaceID: workspaceID, + JobID: jobID, + TemplateVersionID: templateVersionID, + BuildNumber: 1, + Transition: database.WorkspaceTransitionStart, + }) + f.daemons = append(f.daemons, database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow{ + JobID: jobID, + ProvisionerDaemon: database.ProvisionerDaemon{ + ID: uuid.New(), + Name: fmt.Sprintf("daemon-%d", b), + LastSeenAt: sql.NullTime{Time: now, Valid: true}, + Provisioners: []database.ProvisionerType{database.ProvisionerTypeEcho}, + }, + }) + + for r := range resourcesPerBuild { + resourceID := uuid.New() + f.resources = append(f.resources, database.WorkspaceResource{ + ID: resourceID, + JobID: jobID, + Transition: database.WorkspaceTransitionStart, + Type: "example", + Name: fmt.Sprintf("resource-%d-%d", b, r), + }) + f.metadata = append(f.metadata, database.WorkspaceResourceMetadatum{ + WorkspaceResourceID: resourceID, + Key: "key", + Value: sql.NullString{String: "value", Valid: true}, + }) + + for a := range agentsPerResource { + agentID := uuid.New() + f.agents = append(f.agents, database.WorkspaceAgent{ + ID: agentID, + ResourceID: resourceID, + Name: fmt.Sprintf("agent-%d-%d-%d", b, r, a), + DisplayOrder: agentsPerResource - a, + APIKeyScope: database.AgentKeyScopeEnumAll, + }) + f.scripts = append(f.scripts, database.GetWorkspaceAgentScriptsByAgentIDsRow{ + WorkspaceAgentID: agentID, + Script: "echo hello", + }) + f.logSources = append(f.logSources, database.WorkspaceAgentLogSource{ + WorkspaceAgentID: agentID, + DisplayName: "startup", + }) + + for p := range appsPerAgent { + appID := uuid.New() + f.apps = append(f.apps, database.WorkspaceApp{ + ID: appID, + AgentID: agentID, + Slug: fmt.Sprintf("app-%d-%d-%d-%d", b, r, a, p), + DisplayName: "App", + Health: database.WorkspaceAppHealthHealthy, + SharingLevel: database.AppSharingLevelOwner, + OpenIn: database.WorkspaceAppOpenInSlimWindow, + }) + f.statuses = append(f.statuses, database.WorkspaceAppStatus{ + ID: uuid.New(), + AgentID: agentID, + AppID: appID, + State: database.WorkspaceAppStatusStateComplete, + }) + } + } + } + } + return f +} + +func newConvertAPI() *API { + api := &API{ + Options: &Options{ + AgentInactiveDisconnectTimeout: time.Minute, + DeploymentValues: &codersdk.DeploymentValues{}, + }, + } + coordinator := tailnet.NewCoordinator(slog.Logger{}) + api.TailnetCoordinator.Store(&coordinator) + return api +} + +func (f buildFixture) convert(api *API) ([]codersdk.WorkspaceBuild, error) { + return api.convertWorkspaceBuilds( + f.builds, + f.workspaces, + f.jobs, + f.resources, + f.metadata, + f.agents, + f.apps, + f.statuses, + f.scripts, + f.logSources, + f.templateVersions, + f.daemons, + ) +} + +// TestConvertWorkspaceBuildsAgentOrder covers the ordering that the shared +// index is responsible for: agents come back sorted by display order. The +// fixture assigns display order in reverse of agent name, so the expected +// result is names in descending order. +func TestConvertWorkspaceBuildsAgentOrder(t *testing.T) { + t.Parallel() + + api := newConvertAPI() + fixture := newBuildFixture(3, 2, 3, 1) + + builds, err := fixture.convert(api) + require.NoError(t, err) + require.Len(t, builds, 3) + + for _, build := range builds { + require.NotEmpty(t, build.Resources) + for _, resource := range build.Resources { + require.Len(t, resource.Agents, 3) + for i := 1; i < len(resource.Agents); i++ { + require.Greater(t, resource.Agents[i-1].Name, resource.Agents[i].Name) + } + } + } +} + +// TestConvertWorkspaceBuildsAgentNameTiebreak covers the second half of the +// agent comparison: agents sharing a display order are ordered by name. +func TestConvertWorkspaceBuildsAgentNameTiebreak(t *testing.T) { + t.Parallel() + + api := newConvertAPI() + fixture := newBuildFixture(1, 1, 3, 1) + for i := range fixture.agents { + fixture.agents[i].DisplayOrder = 0 + } + + builds, err := fixture.convert(api) + require.NoError(t, err) + require.Len(t, builds, 1) + require.Len(t, builds[0].Resources, 1) + + agents := builds[0].Resources[0].Agents + require.Len(t, agents, 3) + for i := 1; i < len(agents); i++ { + require.Less(t, agents[i-1].Name, agents[i].Name) + } +} + +// TestConvertWorkspaceBuildsRowsPerBuild asserts that a build receives only the +// rows keyed to its own job and agents. The fixture encodes the build index in +// app slugs, so apps read from another build are detectable by slug. +func TestConvertWorkspaceBuildsRowsPerBuild(t *testing.T) { + t.Parallel() + + const ( + buildCount = 3 + resourcesPerJob = 2 + agentsPerRes = 2 + appsPerAgent = 2 + ) + + api := newConvertAPI() + fixture := newBuildFixture(buildCount, resourcesPerJob, agentsPerRes, appsPerAgent) + + builds, err := fixture.convert(api) + require.NoError(t, err) + require.Len(t, builds, buildCount) + + for b, build := range builds { + require.Len(t, build.Resources, resourcesPerJob) + + for _, resource := range build.Resources { + require.Equal(t, fixture.jobs[b].ProvisionerJob.ID, resource.JobID) + require.Len(t, resource.Metadata, 1) + require.Len(t, resource.Agents, agentsPerRes) + + for _, agent := range resource.Agents { + require.Len(t, agent.Scripts, 1) + require.Len(t, agent.LogSources, 1) + require.Len(t, agent.Apps, appsPerAgent) + + for _, app := range agent.Apps { + require.True(t, strings.HasPrefix(app.Slug, fmt.Sprintf("app-%d-", b)), app.Slug) + require.Len(t, app.Statuses, 1) + } + } + } + } +} + +// TestConvertWorkspaceBuildsMatchedProvisioners asserts that eligible daemons +// are matched to the build whose job they were fetched for. The last build in +// the batch has no daemon rows, so it reports zero counts while the others +// report one. +func TestConvertWorkspaceBuildsMatchedProvisioners(t *testing.T) { + t.Parallel() + + api := newConvertAPI() + fixture := newBuildFixture(3, 1, 1, 1) + fixture.daemons = fixture.daemons[:len(fixture.daemons)-1] + + builds, err := fixture.convert(api) + require.NoError(t, err) + require.Len(t, builds, 3) + + for _, build := range builds[:2] { + require.NotNil(t, build.MatchedProvisioners) + require.Equal(t, 1, build.MatchedProvisioners.Count) + require.Equal(t, 1, build.MatchedProvisioners.Available) + require.True(t, build.MatchedProvisioners.MostRecentlySeen.Valid) + } + + last := builds[2] + require.NotNil(t, last.MatchedProvisioners) + require.Equal(t, 0, last.MatchedProvisioners.Count) + require.Equal(t, 0, last.MatchedProvisioners.Available) + require.False(t, last.MatchedProvisioners.MostRecentlySeen.Valid) +} + +func BenchmarkConvertWorkspaceBuilds(b *testing.B) { + for _, builds := range []int{1, 25, 100} { + b.Run(fmt.Sprintf("Builds%d", builds), func(b *testing.B) { + api := newConvertAPI() + fixture := newBuildFixture(builds, 5, 2, 4) + + b.ReportAllocs() + for b.Loop() { + if _, err := fixture.convert(api); err != nil { + b.Fatal(err) + } + } + }) + } +} diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 337c0aa9447..5b5252747a2 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -876,15 +876,8 @@ func createWorkspace( ProvisionerJob: *provisionerJob, QueuePosition: 0, }, - []database.WorkspaceResource{}, - []database.WorkspaceResourceMetadatum{}, - []database.WorkspaceAgent{}, - []database.WorkspaceApp{}, - []database.WorkspaceAppStatus{}, - []database.GetWorkspaceAgentScriptsByAgentIDsRow{}, - []database.WorkspaceAgentLogSource{}, + newWorkspaceBuildIndex(nil, nil, nil, nil, nil, nil, nil, provisionerDaemons), database.TemplateVersion{}, - provisionerDaemons, ) if err != nil { return codersdk.Workspace{}, httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{ From 4f9a5396517c0310fa4bfc5c011768a6ca84d6da Mon Sep 17 00:00:00 2001 From: Garrett Delfosse Date: Thu, 17 Sep 2026 23:17:53 -0400 Subject: [PATCH 128/138] fix(scripts/releaser): remove GPG prompt and scope breaking-change check (#27422, #29487) (#29498) Backport of #27422 and #29487 to `release/2.34`. Removes the "Continue without signing?" GPG pre-flight prompt from the release wizard (tags are plain annotated tags, as on `main`), and scopes the patch-release breaking-change check to `prevVersion..HEAD` so breaking changes from earlier patches are no longer reported on every subsequent patch release.
Backport context - `a81dd8379d3565258fcd9fffec5efe216c95302a` (#27422): conflicted because it edits `run.go`, which was split out of `main.go` by #27421 and does not exist on this branch. Resolved by dropping the `run.go` hunk and removing the same GPG check block and `gpgConfigured` argument from `main.go`. - `86a050de439cb04dca9bed546ce0c0a0b208fd7b` (#29487): applied cleanly.
Generated by Coder Agents on behalf of @f0ssel. --- scripts/releaser/commit.go | 15 +++++++ scripts/releaser/commit_test.go | 70 +++++++++++++++++++++++++++++++++ scripts/releaser/executor.go | 23 +++-------- scripts/releaser/github.go | 51 +++++++++++------------- scripts/releaser/main.go | 15 +------ scripts/releaser/release.go | 56 ++++++-------------------- 6 files changed, 127 insertions(+), 103 deletions(-) create mode 100644 scripts/releaser/commit_test.go diff --git a/scripts/releaser/commit.go b/scripts/releaser/commit.go index 37bdfcb7b5c..e182e25d2ee 100644 --- a/scripts/releaser/commit.go +++ b/scripts/releaser/commit.go @@ -186,6 +186,21 @@ func humanizeTitle(title string) string { // breakingCommitRe matches conventional commit "!:" breaking changes. var breakingCommitRe = regexp.MustCompile(`^[a-zA-Z]+(\(.+\))?!:`) +// findBreakingCommits returns the commits categorized as breaking, +// either by a "!" title marker or a release/breaking label on the +// source PR. Only the given commits are considered, so callers control +// the range (e.g. prevVersion..HEAD). +func findBreakingCommits(commits []commitEntry, prMeta *prMetadataMaps) []commitEntry { + var breaking []commitEntry + for _, c := range commits { + meta := prMeta.lookupCommit(c.FullSHA, c.PRCount) + if categorizeCommit(c.Title, meta.Labels) == "breaking" { + breaking = append(breaking, c) + } + } + return breaking +} + // categorizeCommit determines the release note section for a commit. // The priority order matches the bash script: breaking title first, // then labels (breaking, security, experimental), then prefix. diff --git a/scripts/releaser/commit_test.go b/scripts/releaser/commit_test.go new file mode 100644 index 00000000000..ff1771b3796 --- /dev/null +++ b/scripts/releaser/commit_test.go @@ -0,0 +1,70 @@ +package main //nolint:testpackage // Tests unexported release helpers. + +import ( + "testing" +) + +func TestFindBreakingCommits(t *testing.T) { + t.Parallel() + + // Metadata for PRs that were labeled release/breaking. #10 is + // keyed by merge-commit SHA (direct merge), #20 only by PR + // number (cherry-picked, so the SHA on the release branch + // differs from the merge commit on main). #99 is labeled but + // deliberately absent from the commit list, simulating a + // breaking PR that shipped in an earlier release on the branch. + prMeta := &prMetadataMaps{ + bySHA: map[string]prMetadata{ + "aaa": {Labels: []string{"release/breaking"}}, + }, + byNumber: map[int]prMetadata{ + 10: {Labels: []string{"release/breaking"}}, + 20: {Labels: []string{"release/breaking"}}, + 99: {Labels: []string{"release/breaking"}}, + }, + } + + commits := []commitEntry{ + {SHA: "aaa", FullSHA: "aaa", Title: "feat: labeled via sha (#10)", PRCount: 10}, + {SHA: "bbb", FullSHA: "bbb", Title: "fix: labeled via pr number (#20)", PRCount: 20}, + {SHA: "ccc", FullSHA: "ccc", Title: "feat(cli)!: bang title (#30)", PRCount: 30}, + {SHA: "ddd", FullSHA: "ddd", Title: "fix: ordinary change (#40)", PRCount: 40}, + {SHA: "eee", FullSHA: "eee", Title: "chore: no pr number"}, + } + + got := findBreakingCommits(commits, prMeta) + + want := []string{"aaa", "bbb", "ccc"} + if len(got) != len(want) { + t.Fatalf("got %d breaking commits, want %d: %+v", len(got), len(want), got) + } + for i, c := range got { + if c.SHA != want[i] { + t.Fatalf("breaking[%d] = %s, want %s", i, c.SHA, want[i]) + } + } +} + +func TestFindBreakingCommitsWithoutMetadata(t *testing.T) { + t.Parallel() + + // With no PR metadata (gh unavailable), only "!" titles are + // detected. + prMeta := &prMetadataMaps{ + bySHA: map[string]prMetadata{}, + byNumber: map[int]prMetadata{}, + } + commits := []commitEntry{ + {SHA: "aaa", FullSHA: "aaa", Title: "feat!: bang (#1)", PRCount: 1}, + {SHA: "bbb", FullSHA: "bbb", Title: "feat: plain (#2)", PRCount: 2}, + } + + got := findBreakingCommits(commits, prMeta) + if len(got) != 1 || got[0].SHA != "aaa" { + t.Fatalf("got %+v, want only aaa", got) + } + + if got := findBreakingCommits(nil, prMeta); got != nil { + t.Fatalf("got %+v for no commits, want nil", got) + } +} diff --git a/scripts/releaser/executor.go b/scripts/releaser/executor.go index 6c92f67aa37..8487328e473 100644 --- a/scripts/releaser/executor.go +++ b/scripts/releaser/executor.go @@ -18,8 +18,8 @@ import ( // here. Safe operations (file writes, fetches, editor) are // called directly. type ReleaseExecutor interface { - // CreateTag creates an annotated (optionally signed) git tag. - CreateTag(ctx context.Context, tag, ref, message string, sign bool) error + // CreateTag creates an annotated git tag. + CreateTag(ctx context.Context, tag, ref, message string) error // PushTag pushes a tag to the origin remote. PushTag(ctx context.Context, tag string) error // TriggerWorkflow dispatches the release.yaml GitHub Actions @@ -30,14 +30,8 @@ type ReleaseExecutor interface { // liveExecutor performs real operations. type liveExecutor struct{} -//nolint:revive // sign flag is part of the ReleaseExecutor interface contract. -func (e *liveExecutor) CreateTag(_ context.Context, tag, ref, message string, sign bool) error { - args := []string{"tag", "-a"} - if sign { - args = append(args, "-s") - } - args = append(args, tag, "-m", message, ref) - return gitRun(args...) +func (*liveExecutor) CreateTag(_ context.Context, tag, ref, message string) error { + return gitRun("tag", "-a", tag, "-m", message, ref) } func (*liveExecutor) PushTag(_ context.Context, tag string) error { @@ -70,13 +64,8 @@ type dryRunExecutor struct { w io.Writer } -//nolint:revive // sign flag is part of the ReleaseExecutor interface contract. -func (e *dryRunExecutor) CreateTag(_ context.Context, tag, ref, message string, sign bool) error { - signFlag := "" - if sign { - signFlag = "-s " - } - _, _ = fmt.Fprintf(e.w, "[DRYRUN] would run: git tag %s-a %s -m %q %s\n", signFlag, tag, message, ref) +func (e *dryRunExecutor) CreateTag(_ context.Context, tag, ref, message string) error { + _, _ = fmt.Fprintf(e.w, "[DRYRUN] would run: git tag -a %s -m %q %s\n", tag, message, ref) return nil } diff --git a/scripts/releaser/github.go b/scripts/releaser/github.go index 75df80960f0..980af43b54d 100644 --- a/scripts/releaser/github.go +++ b/scripts/releaser/github.go @@ -2,6 +2,7 @@ package main import ( "errors" + "io" "os/exec" "slices" "strconv" @@ -73,35 +74,6 @@ func ghListOpenPRs(branch string) ([]ghPR, error) { return prs, nil } -// ghListPRsWithLabel returns merged PRs targeting the given branch -// that have a specific label. -func ghListPRsWithLabel(branch, label string) ([]ghPR, error) { - out, err := ghOutput("pr", "list", - "--repo", owner+"/"+repo, - "--base", branch, - "--state", "merged", - "--label", label, - "--json", "number,title", - "--jq", `.[] | "\(.number)\t\(.title)"`, - ) - if err != nil { - return nil, err - } - if out == "" { - return nil, nil - } - var prs []ghPR - for _, line := range strings.Split(out, "\n") { - parts := strings.SplitN(line, "\t", 2) - if len(parts) < 2 { - continue - } - num, _ := strconv.Atoi(parts[0]) - prs = append(prs, ghPR{Number: num, Title: parts[1]}) - } - return prs, nil -} - // prMetadata holds labels and author for a merged PR. type prMetadata struct { Labels []string @@ -129,6 +101,27 @@ func (m *prMetadataMaps) lookupCommit(fullSHA string, prNumber int) prMetadata { return prMetadata{} } +// loadPRMetadata fetches PR metadata for the given commits via the gh +// CLI. It never fails: when gh is unavailable or the query errors, a +// warning is written and empty maps are returned so callers degrade to +// title-only categorization. +// +//nolint:revive // ghAvailable gates the gh CLI call, like the other flag params here. +func loadPRMetadata(w io.Writer, ghAvailable bool, commits []commitEntry) *prMetadataMaps { + if ghAvailable { + prMeta, err := ghBuildPRMetadataMap(commits) + if err != nil { + warnf(w, "Failed to fetch PR metadata: %v", err) + } else if prMeta != nil { + return prMeta + } + } + return &prMetadataMaps{ + bySHA: make(map[string]prMetadata), + byNumber: make(map[int]prMetadata), + } +} + // ghBuildPRMetadataMap returns PR metadata indexed by both // merge-commit SHA and PR number for merged PRs targeting main. // This matches the bash script's approach of querying --base main diff --git a/scripts/releaser/main.go b/scripts/releaser/main.go index 6394602f9ea..626305b032a 100644 --- a/scripts/releaser/main.go +++ b/scripts/releaser/main.go @@ -41,19 +41,6 @@ func main() { return xerrors.New("git is required but not found in PATH") } - // --- Check GPG signing --- - signingKey, _ := gitOutput("config", "--get", "user.signingkey") - gpgFormat, _ := gitOutput("config", "--get", "gpg.format") - gpgConfigured := signingKey != "" || gpgFormat != "" - if !gpgConfigured { - warnf(w, "GPG signing is not configured. Tags will be unsigned — there will be no way to verify who pushed the tag.") - _, _ = fmt.Fprintf(w, " To fix: set git config user.signingkey or gpg.format\n") - if err := confirmWithDefault(inv, "Continue without signing?", cliui.ConfirmNo); err != nil { - return err - } - _, _ = fmt.Fprintln(w) - } - // --- Check gh CLI auth --- ghAvailable := checkGHAuth() if !ghAvailable { @@ -71,7 +58,7 @@ func main() { executor = &liveExecutor{} } - return runRelease(ctx, inv, executor, ghAvailable, gpgConfigured, dryRun) + return runRelease(ctx, inv, executor, ghAvailable, dryRun) }, } diff --git a/scripts/releaser/release.go b/scripts/releaser/release.go index 9d9723c7c39..dd0988ea0c4 100644 --- a/scripts/releaser/release.go +++ b/scripts/releaser/release.go @@ -19,7 +19,7 @@ import ( ) //nolint:revive // Long function is fine for a sequential release flow. -func runRelease(ctx context.Context, inv *serpent.Invocation, executor ReleaseExecutor, ghAvailable, gpgConfigured, dryRun bool) error { +func runRelease(ctx context.Context, inv *serpent.Invocation, executor ReleaseExecutor, ghAvailable, dryRun bool) error { w := inv.Stderr // --- Release landscape --- @@ -451,43 +451,26 @@ func runRelease(ctx context.Context, inv *serpent.Invocation, executor ReleaseEx if newVersion.Major == prevVersion.Major && newVersion.Minor == prevVersion.Minor && newVersion.Patch > prevVersion.Patch { infof(w, "Checking for breaking changes in patch release...") + // Both the "!" title marker and the release/breaking PR + // label are evaluated over prevVersion..HEAD only, so + // breaking changes shipped in earlier releases on this + // branch are not reported again. commitRange := prevVersion.String() + "..HEAD" commits, err := commitLog(commitRange) if err != nil { return xerrors.Errorf("reading commit log: %w", err) } - var breakingCommits []commitEntry - for _, c := range commits { - if breakingCommitRe.MatchString(c.Title) { - breakingCommits = append(breakingCommits, c) - } - } + prMeta := loadPRMetadata(w, ghAvailable, commits) + breakingCommits := findBreakingCommits(commits, prMeta) - // Check PR labels for release/breaking. - var breakingPRLabeled []ghPR - if ghAvailable { - breakingPRLabeled, err = ghListPRsWithLabel(currentBranch, "release/breaking") - if err != nil { - warnf(w, "Failed to check PR labels: %v", err) - } - } - - if len(breakingCommits) > 0 || len(breakingPRLabeled) > 0 { + if len(breakingCommits) > 0 { fmt.Fprintln(w) warnf(w, "BREAKING CHANGES detected in a PATCH release — this violates semver!") fmt.Fprintln(w) - if len(breakingCommits) > 0 { - fmt.Fprintln(w, " Breaking commits (by conventional commit prefix):") - for _, c := range breakingCommits { - fmt.Fprintf(w, " - %s %s\n", c.SHA, c.Title) - } - } - if len(breakingPRLabeled) > 0 { - fmt.Fprintln(w, " PRs labeled release/breaking:") - for _, pr := range breakingPRLabeled { - fmt.Fprintf(w, " - #%d %s\n", pr.Number, pr.Title) - } + fmt.Fprintf(w, " Breaking changes since %s:\n", prevVersion) + for _, c := range breakingCommits { + fmt.Fprintf(w, " - %s %s\n", c.SHA, c.Title) } fmt.Fprintln(w) if err := confirmWithDefault(inv, "Continue with patch release despite breaking changes?", cliui.ConfirmNo); err != nil { @@ -576,20 +559,7 @@ func runRelease(ctx context.Context, inv *serpent.Invocation, executor ReleaseEx return xerrors.Errorf("reading commit log: %w", err) } - // Build PR metadata maps (by SHA and PR number) via gh CLI. - var prMeta *prMetadataMaps - if ghAvailable { - prMeta, err = ghBuildPRMetadataMap(commits) - if err != nil { - warnf(w, "Failed to fetch PR metadata: %v", err) - } - } - if prMeta == nil { - prMeta = &prMetadataMaps{ - bySHA: make(map[string]prMetadata), - byNumber: make(map[int]prMetadata), - } - } + prMeta := loadPRMetadata(w, ghAvailable, commits) type section struct { Key string @@ -767,7 +737,7 @@ func runRelease(ctx context.Context, inv *serpent.Invocation, executor ReleaseEx if err := confirm(inv, "Create tag?"); err != nil { return xerrors.New("cannot proceed without a tag") } - if err := executor.CreateTag(ctx, newVersion.String(), ref, "Release "+newVersion.String(), gpgConfigured); err != nil { + if err := executor.CreateTag(ctx, newVersion.String(), ref, "Release "+newVersion.String()); err != nil { return xerrors.Errorf("creating tag: %w", err) } successf(w, "Tag %s created.", newVersion) From c674545364bf1346f2926bd56b6706b5caed5be0 Mon Sep 17 00:00:00 2001 From: dylanhuff-at-coder Date: Tue, 22 Sep 2026 17:40:20 -0400 Subject: [PATCH 129/138] fix: prevent tailnet coordination panics (#29300) (#29743) Backport of #29300 to `release/2.34` for [PLAT-595](https://linear.app/codercom/issue/PLAT-595). Includes the full fix and regression tests for malformed coordinate requests, coordinator panic containment, and disconnect cleanup. The conflict in `tailnet/tunnel.go` preserves the release branch's existing tunnel check and adds the upstream validation unchanged. --- enterprise/tailnet/connio.go | 16 ++ enterprise/tailnet/connio_internal_test.go | 89 +++++++++ enterprise/tailnet/mapper_internal_test.go | 55 ++++++ enterprise/tailnet/pgcoord.go | 11 ++ enterprise/tailnet/requests_test.go | 33 ++++ tailnet/coordinator.go | 29 +++ tailnet/coordinator_internal_test.go | 212 +++++++++++++++++++++ tailnet/peer.go | 12 +- tailnet/peer_internal_test.go | 119 ++++++++++++ tailnet/requests.go | 31 +++ tailnet/requests_test.go | 100 ++++++++++ tailnet/test/peer.go | 9 + tailnet/test/requests.go | 116 +++++++++++ tailnet/tunnel.go | 6 + 14 files changed, 837 insertions(+), 1 deletion(-) create mode 100644 enterprise/tailnet/connio_internal_test.go create mode 100644 enterprise/tailnet/mapper_internal_test.go create mode 100644 enterprise/tailnet/requests_test.go create mode 100644 tailnet/peer_internal_test.go create mode 100644 tailnet/requests.go create mode 100644 tailnet/requests_test.go create mode 100644 tailnet/test/requests.go diff --git a/enterprise/tailnet/connio.go b/enterprise/tailnet/connio.go index 7c186dc1a04..0803aa79f5f 100644 --- a/enterprise/tailnet/connio.go +++ b/enterprise/tailnet/connio.go @@ -3,6 +3,7 @@ package tailnet import ( "context" "fmt" + "runtime/debug" "slices" "sync" @@ -100,6 +101,17 @@ func (c *connIO) recvLoop() { } }() defer c.Close() + // This must be deferred after Close so it runs first: Enqueue needs the + // response channel open to deliver CloseErrInternal to the peer. + defer func() { + if recovered := recover(); recovered != nil { + c.logger.Error(c.peerCtx, "panic handling peer request (recovered)", + slog.F("panic", recovered), + slog.F("stack", string(debug.Stack())), + ) + _ = c.Enqueue(&proto.CoordinateResponse{Error: agpl.CloseErrInternal}) + } + }() for { select { case <-c.coordCtx.Done(): @@ -128,6 +140,10 @@ var errDisconnect = xerrors.New("graceful disconnect") func (c *connIO) handleRequest(req *proto.CoordinateRequest) error { c.logger.Debug(c.peerCtx, "got request") + if err := agpl.ValidateCoordinateRequest(req); err != nil { + c.logger.Warn(c.peerCtx, "invalid coordinate request", slog.Error(err)) + return err + } err := c.auth.Authorize(c.peerCtx, req) if err != nil { c.logger.Warn(c.peerCtx, "unauthorized request", slog.Error(err)) diff --git a/enterprise/tailnet/connio_internal_test.go b/enterprise/tailnet/connio_internal_test.go new file mode 100644 index 00000000000..77649c2ed83 --- /dev/null +++ b/enterprise/tailnet/connio_internal_test.go @@ -0,0 +1,89 @@ +package tailnet + +import ( + "context" + "strings" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "cdr.dev/slog/v3/sloggers/sloghuman" + "cdr.dev/slog/v3/sloggers/slogtest" + agpl "github.com/coder/coder/v2/tailnet" + "github.com/coder/coder/v2/tailnet/proto" + agpltest "github.com/coder/coder/v2/tailnet/test" + "github.com/coder/coder/v2/testutil" +) + +func TestConnIOHandleRequestRejectsBeforeMutation(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + bindings := make(chan binding, 1) + var logbuf strings.Builder + c := &connIO{ + id: uuid.New(), + coordCtx: ctx, + peerCtx: ctx, + logger: slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).AppendSinks(sloghuman.Sink(&logbuf)), + bindings: bindings, + auth: agpl.SingleTailnetCoordinateeAuth{}, + } + + err := c.handleRequest(&proto.CoordinateRequest{ + UpdateSelf: &proto.CoordinateRequest_UpdateSelf{Node: &proto.Node{ + PreferredDerp: 31002, + }}, + ReadyForHandshake: []*proto.CoordinateRequest_ReadyForHandshake{nil}, + }) + require.EqualError(t, err, "ready_for_handshake entries must not be nil") + require.Contains(t, logbuf.String(), "invalid coordinate request") + select { + case binding := <-bindings: + t.Fatalf("unexpected binding: %+v", binding) + default: + } +} + +func TestConnIORecvLoopPanicAfterAuthorization(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) + bindings := make(chan binding, 1) + requests := make(chan *proto.CoordinateRequest, 1) + responses := make(chan *proto.CoordinateResponse, 1) + rfhs := make(chan readyForHandshake) + close(rfhs) + authorized := make(chan struct{}, 1) + peerID := uuid.New() + destinationID := uuid.New() + c := newConnIO(ctx, ctx, logger, bindings, make(chan tunnel, 1), rfhs, + requests, responses, peerID, t.Name(), agpltest.CoordinateeAuthFunc(func(context.Context, *proto.CoordinateRequest) error { + authorized <- struct{}{} + return nil + })) + t.Cleanup(func() { require.NoError(t, c.Close()) }) + c.setLatestMapping([]mapping{{peer: destinationID}}) + + testutil.RequireSend(ctx, t, requests, &proto.CoordinateRequest{ + ReadyForHandshake: []*proto.CoordinateRequest_ReadyForHandshake{{Id: agpl.UUIDToByteSlice(destinationID)}}, + }) + testutil.RequireReceive(ctx, t, authorized) + response := testutil.RequireReceive(ctx, t, responses) + require.Equal(t, agpl.CloseErrInternal, response.Error) + require.NotContains(t, response.Error, "send on closed channel") + select { + case _, ok := <-responses: + require.False(t, ok) + case <-ctx.Done(): + t.Fatal("response channel did not close") + } + select { + case <-c.Done(): + case <-ctx.Done(): + t.Fatal("connection did not close") + } + withdrawn := testutil.RequireReceive(ctx, t, bindings) + require.Equal(t, bKey(peerID), withdrawn.bKey) + require.Equal(t, proto.CoordinateResponse_PeerUpdate_LOST, withdrawn.kind) +} diff --git a/enterprise/tailnet/mapper_internal_test.go b/enterprise/tailnet/mapper_internal_test.go new file mode 100644 index 00000000000..f5c2c70f564 --- /dev/null +++ b/enterprise/tailnet/mapper_internal_test.go @@ -0,0 +1,55 @@ +package tailnet + +import ( + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "cdr.dev/slog/v3/sloggers/slogtest" + agpl "github.com/coder/coder/v2/tailnet" + "github.com/coder/coder/v2/tailnet/proto" + "github.com/coder/coder/v2/testutil" +) + +func TestMapperPanic(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) + bindings := make(chan binding, 1) + responses := make(chan *proto.CoordinateResponse, 2) + c := newConnIO(ctx, ctx, logger, bindings, make(chan tunnel, 1), nil, + make(chan *proto.CoordinateRequest), responses, uuid.New(), t.Name(), agpl.SingleTailnetCoordinateeAuth{}) + t.Cleanup(func() { require.NoError(t, c.Close()) }) + coordinatorID := uuid.New() + m := &mapper{ + ctx: c.peerCtx, + logger: logger, + c: c, + mappings: make(chan []mapping), + heartbeats: &heartbeats{self: coordinatorID}, + // A nil sent map forces a panic after the mapping has been selected. + sent: nil, + } + go m.run() + require.NoError(t, agpl.SendCtx(ctx, m.mappings, []mapping{{ + peer: uuid.New(), coordinator: coordinatorID, + node: &proto.Node{}, kind: proto.CoordinateResponse_PeerUpdate_NODE, + }})) + response := testutil.RequireReceive(ctx, t, responses) + require.Equal(t, agpl.CloseErrInternal, response.Error) + select { + case _, ok := <-responses: + require.False(t, ok) + case <-ctx.Done(): + t.Fatal("response channel did not close") + } + select { + case <-c.Done(): + case <-ctx.Done(): + t.Fatal("connection did not close") + } + withdrawn := testutil.RequireReceive(ctx, t, bindings) + require.Equal(t, bKey(c.id), withdrawn.bKey) + require.Equal(t, proto.CoordinateResponse_PeerUpdate_LOST, withdrawn.kind) +} diff --git a/enterprise/tailnet/pgcoord.go b/enterprise/tailnet/pgcoord.go index 08325e567fc..4018b8aabf7 100644 --- a/enterprise/tailnet/pgcoord.go +++ b/enterprise/tailnet/pgcoord.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "math" + "runtime/debug" "slices" "strings" "sync" @@ -742,6 +743,16 @@ func newMapper(c *connIO, logger slog.Logger, h *heartbeats) *mapper { } func (m *mapper) run() { + defer func() { + if recovered := recover(); recovered != nil { + m.logger.Error(m.ctx, "panic processing peer mappings (recovered)", + slog.F("panic", recovered), + slog.F("stack", string(debug.Stack())), + ) + _ = m.c.Enqueue(&proto.CoordinateResponse{Error: agpl.CloseErrInternal}) + _ = m.c.Close() + } + }() for { var best map[uuid.UUID]mapping select { diff --git a/enterprise/tailnet/requests_test.go b/enterprise/tailnet/requests_test.go new file mode 100644 index 00000000000..32ebeeb2ada --- /dev/null +++ b/enterprise/tailnet/requests_test.go @@ -0,0 +1,33 @@ +package tailnet_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "cdr.dev/slog/v3/sloggers/slogtest" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/enterprise/tailnet" + "github.com/coder/coder/v2/tailnet/test" + "github.com/coder/coder/v2/testutil" +) + +func TestPGCoordinator_InvalidRequests(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + store, ps := dbtestutil.NewDB(t) + coordinator, err := tailnet.NewPGCoord(ctx, slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}), ps, store) + require.NoError(t, err) + t.Cleanup(func() { _ = coordinator.Close() }) + test.InvalidCoordinateRequestTest(ctx, t, coordinator) +} + +func TestPGCoordinator_RequestPanic(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + store, ps := dbtestutil.NewDB(t) + coordinator, err := tailnet.NewPGCoord(ctx, slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}), ps, store) + require.NoError(t, err) + t.Cleanup(func() { _ = coordinator.Close() }) + test.CoordinateRequestPanicTest(ctx, t, coordinator) +} diff --git a/tailnet/coordinator.go b/tailnet/coordinator.go index c3df906b4d8..79db8ca0a22 100644 --- a/tailnet/coordinator.go +++ b/tailnet/coordinator.go @@ -7,6 +7,7 @@ import ( "io" "net/http" "net/netip" + runtimedebug "runtime/debug" "sync" "time" @@ -27,6 +28,7 @@ const ( RequestBufferSize = 32 CloseErrOverwritten = "peer ID overwritten by new connection" CloseErrCoordinatorClose = "coordinator closed" + CloseErrInternal = "internal coordinator error" ReadyForHandshakeError = "ready for handshake error" ) @@ -176,6 +178,15 @@ func (c *coordinator) Coordinate( c.wg.Add(1) go func() { defer c.wg.Done() + // Peer cleanup can also panic, outside reqLoop's recovery boundary. + defer func() { + if recovered := recover(); recovered != nil { + logger.Error(ctx, "panic coordinating peer (recovered)", + slog.F("panic", recovered), + slog.F("stack", string(runtimedebug.Stack())), + ) + } + }() loopErr := p.reqLoop(ctx, logger, c.core.handleRequest) closeErrStr := "" if loopErr != nil { @@ -235,6 +246,9 @@ func (c *core) node(id uuid.UUID) *Node { } func (c *core) handleRequest(ctx context.Context, p *peer, req *proto.CoordinateRequest) error { + if err := ValidateCoordinateRequest(req); err != nil { + return err + } c.mutex.Lock() defer c.mutex.Unlock() if c.closed { @@ -290,6 +304,10 @@ func (c *core) handleRequest(ctx context.Context, p *peer, req *proto.Coordinate } if req.Disconnect != nil { c.removePeerLocked(p.id, proto.CoordinateResponse_PeerUpdate_DISCONNECTED, "graceful disconnect", "") + // The peer and its response channel are gone, so nothing else in this + // request can be delivered. Matches the enterprise coordinator, which + // stops processing a request once it sees Disconnect. + return nil } if rfhs := req.ReadyForHandshake; rfhs != nil { err := c.handleReadyForHandshakeLocked(pr, rfhs) @@ -349,6 +367,10 @@ func (c *core) nodeUpdateLocked(p *peer, node *proto.Node) (err error) { p.node = node c.updateTunnelPeersLocked(p.id, node, proto.CoordinateResponse_PeerUpdate_NODE, "node update") + // Fan-out can recursively remove this peer if its response buffer is full. + if c.peers[p.id] != p { + return ErrAlreadyRemoved + } return nil } @@ -480,6 +502,13 @@ func (c *core) removePeerLocked(id uuid.UUID, kind proto.CoordinateResponse_Peer return } c.updateTunnelPeersLocked(id, nil, kind, reason) + if _, ok := c.peers[id]; !ok { + // A tunnel peer with a full response buffer gets removed during the + // fan-out above, and removing it fans out to us in turn. When that + // nested call already closed and deleted this peer, p is stale and + // closing p.resps again would panic. Nothing is left to do. + return + } c.tunnels.removeAll(id) if closeErr != "" { select { diff --git a/tailnet/coordinator_internal_test.go b/tailnet/coordinator_internal_test.go index 9f5ac7c6a46..654bf28015e 100644 --- a/tailnet/coordinator_internal_test.go +++ b/tailnet/coordinator_internal_test.go @@ -2,6 +2,7 @@ package tailnet import ( "bytes" + "context" "flag" "os" "path/filepath" @@ -11,6 +12,11 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/require" + + "cdr.dev/slog/v3" + "cdr.dev/slog/v3/sloggers/slogtest" + "github.com/coder/coder/v2/tailnet/proto" + "github.com/coder/coder/v2/testutil" ) // UpdateGoldenFiles indicates golden files should be updated. @@ -72,3 +78,209 @@ func TestDebugTemplate(t *testing.T) { goldenPath, ) } + +// TestCoreDisconnectIgnoresReadyForHandshake sends Disconnect and +// ReadyForHandshake in one request. Disconnect closes the peer's response +// channel, so the handler must stop there instead of trying to answer the +// handshake on a closed channel. Other peers still see DISCONNECTED. +func TestCoreDisconnectIgnoresReadyForHandshake(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + logger := testutil.Logger(t) + c := newCore(logger) + t.Cleanup(func() { require.NoError(t, c.close()) }) + + type testPeer struct { + *peer + resps chan *proto.CoordinateResponse + } + newPeer := func(name string, id uuid.UUID, auth CoordinateeAuth) testPeer { + resps := make(chan *proto.CoordinateResponse, ResponseBufferSize) + p := &peer{ + logger: logger.With(slog.F("peer_name", name)), + id: id, + name: name, + resps: resps, + reqs: make(chan *proto.CoordinateRequest, RequestBufferSize), + auth: auth, + sent: make(map[uuid.UUID]*proto.Node), + } + require.NoError(t, c.initPeer(p)) + return testPeer{peer: p, resps: resps} + } + observer := newPeer("observer", uuid.New(), SingleTailnetCoordinateeAuth{}) + leaverID := uuid.New() + leaver := newPeer("leaver", leaverID, AgentCoordinateeAuth{ID: leaverID}) + + require.NoError(t, c.handleRequest(ctx, observer.peer, &proto.CoordinateRequest{ + AddTunnel: &proto.CoordinateRequest_Tunnel{Id: leaver.id[:]}, + })) + require.NoError(t, c.handleRequest(ctx, leaver.peer, &proto.CoordinateRequest{ + UpdateSelf: &proto.CoordinateRequest_UpdateSelf{Node: &proto.Node{PreferredDerp: 1}}, + })) + nodeUpdate := testutil.RequireReceive(ctx, t, observer.resps) + require.Len(t, nodeUpdate.PeerUpdates, 1) + require.Equal(t, proto.CoordinateResponse_PeerUpdate_NODE, nodeUpdate.PeerUpdates[0].Kind) + + stranger := uuid.New() + var err error + require.NotPanics(t, func() { + err = c.handleRequest(ctx, leaver.peer, &proto.CoordinateRequest{ + Disconnect: &proto.CoordinateRequest_Disconnect{}, + ReadyForHandshake: []*proto.CoordinateRequest_ReadyForHandshake{{Id: stranger[:]}}, + }) + }) + require.NoError(t, err) + + // The leaver's channel closes without an error response. + for closed := false; !closed; { + select { + case resp, ok := <-leaver.resps: + if !ok { + closed = true + continue + } + require.Empty(t, resp.Error) + case <-ctx.Done(): + t.Fatal("leaver response channel was not closed") + } + } + c.mutex.RLock() + _, stillPresent := c.peers[leaver.id] + c.mutex.RUnlock() + require.False(t, stillPresent) + + disconnected := testutil.RequireReceive(ctx, t, observer.resps) + require.Len(t, disconnected.PeerUpdates, 1) + gotID, err := uuid.FromBytes(disconnected.PeerUpdates[0].Id) + require.NoError(t, err) + require.Equal(t, leaver.id, gotID) + require.Equal(t, proto.CoordinateResponse_PeerUpdate_DISCONNECTED, disconnected.PeerUpdates[0].Kind) +} + +// TestCoreRemovePeerNestedRemoval sets up two tunneled peers whose response +// buffers are both full, then removes one of them. Notifying the other peer +// fails with ErrWouldBlock, which removes that peer too, and notifying back +// removes the first peer inside the nested call. The outer removePeerLocked +// must notice its peer is already gone instead of closing the channel again. +// Both request handling and lostPeer cleanup must finish without relying on +// panic recovery. +func TestCoreRemovePeerNestedRemoval(t *testing.T) { + t.Parallel() + + type fullPeers struct { + core *core + a, b *peer + aResps chan *proto.CoordinateResponse + bResps chan *proto.CoordinateResponse + } + // setup returns a core with peers a and b sharing a tunnel, where each + // peer's single-slot response buffer already holds the other's node. + setup := func(t *testing.T) fullPeers { + // The full buffers make updateTunnelPeersLocked log "failed to update + // mapping" at Error, which is the expected trigger. Anything else at + // Error or Critical, such as "removed non-existent peer", still fails + // the test. + logger := slogtest.Make(t, &slogtest.Options{ + IgnoreErrorFn: func(ent slog.SinkEntry) bool { + return ent.Message == "failed to update mapping" + }, + }).Leveled(slog.LevelDebug) + c := newCore(logger) + t.Cleanup(func() { require.NoError(t, c.close()) }) + newPeer := func(name string) (*peer, chan *proto.CoordinateResponse) { + resps := make(chan *proto.CoordinateResponse, 1) + p := &peer{ + logger: logger.With(slog.F("peer_name", name)), + id: uuid.New(), + name: name, + resps: resps, + reqs: make(chan *proto.CoordinateRequest, RequestBufferSize), + auth: SingleTailnetCoordinateeAuth{}, + sent: make(map[uuid.UUID]*proto.Node), + } + require.NoError(t, c.initPeer(p)) + return p, resps + } + a, aResps := newPeer("a") + b, bResps := newPeer("b") + + c.mutex.Lock() + defer c.mutex.Unlock() + // No tunnel yet, so these updates are not sent anywhere. + require.NoError(t, c.nodeUpdateLocked(a, &proto.Node{PreferredDerp: 1})) + require.NoError(t, c.nodeUpdateLocked(b, &proto.Node{PreferredDerp: 2})) + // Adding the tunnel sends each node to the other peer, which fills + // both buffers. + require.NoError(t, c.addTunnelLocked(a, b.id)) + require.Len(t, aResps, 1) + require.Len(t, bResps, 1) + require.Contains(t, a.sent, b.id) + require.Contains(t, b.sent, a.id) + return fullPeers{core: c, a: a, b: b, aResps: aResps, bResps: bResps} + } + + requireClosed := func(ctx context.Context, t *testing.T, ch chan *proto.CoordinateResponse) { + for { + select { + case _, ok := <-ch: + if !ok { + return + } + case <-ctx.Done(): + t.Fatal("response channel was not closed") + } + } + } + + requireBothRemoved := func(ctx context.Context, t *testing.T, fp fullPeers) { + fp.core.mutex.RLock() + _, aPresent := fp.core.peers[fp.a.id] + _, bPresent := fp.core.peers[fp.b.id] + aTunnels := fp.core.tunnels.findTunnelPeers(fp.a.id) + bTunnels := fp.core.tunnels.findTunnelPeers(fp.b.id) + fp.core.mutex.RUnlock() + require.False(t, aPresent) + require.False(t, bPresent) + require.Empty(t, aTunnels) + require.Empty(t, bTunnels) + requireClosed(ctx, t, fp.aResps) + requireClosed(ctx, t, fp.bResps) + } + + for _, name := range []string{"UpdateSelf", "UpdateSelfAddTunnel", "UpdateSelfReadyForHandshake"} { + t.Run(name, func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + fp := setup(t) + req := &proto.CoordinateRequest{ + UpdateSelf: &proto.CoordinateRequest_UpdateSelf{Node: &proto.Node{PreferredDerp: 3}}, + } + dstID := uuid.New() + switch name { + case "UpdateSelfAddTunnel": + req.AddTunnel = &proto.CoordinateRequest_Tunnel{Id: dstID[:]} + case "UpdateSelfReadyForHandshake": + req.ReadyForHandshake = []*proto.CoordinateRequest_ReadyForHandshake{{Id: dstID[:]}} + } + var err error + require.NotPanics(t, func() { + err = fp.core.handleRequest(ctx, fp.a, req) + }) + require.NoError(t, err) + requireBothRemoved(ctx, t, fp) + }) + } + + t.Run("LostPeer", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + fp := setup(t) + var err error + require.NotPanics(t, func() { + err = fp.core.lostPeer(fp.a, "") + }) + require.NoError(t, err) + requireBothRemoved(ctx, t, fp) + }) +} diff --git a/tailnet/peer.go b/tailnet/peer.go index 1954534a1d1..8631c24e62d 100644 --- a/tailnet/peer.go +++ b/tailnet/peer.go @@ -2,6 +2,7 @@ package tailnet import ( "context" + "runtime/debug" "time" "github.com/google/uuid" @@ -121,7 +122,16 @@ func (p *peer) storeMappingLocked( }, nil } -func (p *peer) reqLoop(ctx context.Context, logger slog.Logger, handler func(context.Context, *peer, *proto.CoordinateRequest) error) error { +func (p *peer) reqLoop(ctx context.Context, logger slog.Logger, handler func(context.Context, *peer, *proto.CoordinateRequest) error) (err error) { + defer func() { + if recovered := recover(); recovered != nil { + logger.Error(ctx, "peerReadLoop panic handling request (recovered)", + slog.F("panic", recovered), + slog.F("stack", string(debug.Stack())), + ) + err = xerrors.New(CloseErrInternal) + } + }() for { select { case <-ctx.Done(): diff --git a/tailnet/peer_internal_test.go b/tailnet/peer_internal_test.go new file mode 100644 index 00000000000..1dd2083e82f --- /dev/null +++ b/tailnet/peer_internal_test.go @@ -0,0 +1,119 @@ +package tailnet + +import ( + "context" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "cdr.dev/slog/v3" + "cdr.dev/slog/v3/sloggers/slogtest" + "github.com/coder/coder/v2/tailnet/proto" + "github.com/coder/coder/v2/testutil" +) + +func TestPeerReqLoopPanic(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) + requests := make(chan *proto.CoordinateRequest, 1) + responses := make(chan *proto.CoordinateResponse, 1) + p := &peer{ + logger: logger, + id: uuid.New(), + resps: responses, + reqs: requests, + sent: make(map[uuid.UUID]*proto.Node), + } + core := newCore(logger) + require.NoError(t, core.initPeer(p)) + t.Cleanup(func() { require.NoError(t, core.close()) }) + + testutil.RequireSend(ctx, t, requests, &proto.CoordinateRequest{}) + err := p.reqLoop(ctx, logger, func(context.Context, *peer, *proto.CoordinateRequest) error { + panic("private handler panic") + }) + require.EqualError(t, err, CloseErrInternal) + require.NoError(t, core.lostPeer(p, err.Error())) + + response := testutil.RequireReceive(ctx, t, responses) + require.Equal(t, CloseErrInternal, response.Error) + require.NotContains(t, response.Error, "private handler panic") + _, ok := <-responses + require.False(t, ok) + core.mutex.RLock() + _, ok = core.peers[p.id] + core.mutex.RUnlock() + require.False(t, ok) +} + +func TestPeerCleanupPanic(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + id := uuid.New() + sink := &peerCleanupPanicSink{ + FakeSink: testutil.NewFakeSink(t), + peerID: id, + } + logger := slog.Make(sink).Leveled(slog.LevelDebug) + c := NewCoordinator(logger).(*coordinator) + t.Cleanup(func() { require.NoError(t, c.Close()) }) + + requests, responses := c.Coordinate(ctx, id, t.Name(), SingleTailnetCoordinateeAuth{}) + close(requests) + done := make(chan struct{}) + go func() { + c.wg.Wait() + close(done) + }() + testutil.TryReceive(ctx, t, done) + + entries := sink.Entries(func(entry slog.SinkEntry) bool { + return entry.Level == slog.LevelError + }) + require.Len(t, entries, 1) + require.Equal(t, "panic coordinating peer (recovered)", entries[0].Message) + fields := make(map[string]any) + for _, field := range entries[0].Fields { + fields[field.Name] = field.Value + } + require.Equal(t, id, fields["peer_id"]) + require.Equal(t, "private cleanup panic", fields["panic"]) + require.Contains(t, fields["stack"], "(*core).lostPeer") + + // Recovery must leave the core unlocked and the coordinator usable. + healthyID := uuid.New() + healthyRequests, healthyResponses := c.Coordinate(ctx, healthyID, t.Name(), SingleTailnetCoordinateeAuth{}) + close(healthyRequests) + require.Nil(t, testutil.TryReceive(ctx, t, healthyResponses)) + + // Shutdown must still remove the peer whose cleanup was interrupted. + closed := make(chan error, 1) + go func() { closed <- c.Close() }() + require.NoError(t, testutil.RequireReceive(ctx, t, closed)) + response := testutil.RequireReceive(ctx, t, responses) + require.Equal(t, CloseErrCoordinatorClose, response.Error) + require.NotContains(t, response.Error, "private cleanup panic") + require.Nil(t, testutil.TryReceive(ctx, t, responses)) + c.core.mutex.RLock() + _, present := c.core.peers[id] + c.core.mutex.RUnlock() + require.False(t, present) +} + +type peerCleanupPanicSink struct { + *testutil.FakeSink + peerID uuid.UUID +} + +func (s *peerCleanupPanicSink) LogEntry(ctx context.Context, entry slog.SinkEntry) { + if entry.Message == "lostPeer" { + for _, field := range entry.Fields { + if field.Name == "peer_id" && field.Value == s.peerID { + panic("private cleanup panic") + } + } + } + s.FakeSink.LogEntry(ctx, entry) +} diff --git a/tailnet/requests.go b/tailnet/requests.go new file mode 100644 index 00000000000..94cebe151ef --- /dev/null +++ b/tailnet/requests.go @@ -0,0 +1,31 @@ +package tailnet + +import ( + "slices" + + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/tailnet/proto" +) + +// ValidateCoordinateRequest checks the structural invariants required by the +// coordinator before authorization or state updates. +func ValidateCoordinateRequest(req *proto.CoordinateRequest) error { + if req == nil { + return xerrors.New("coordinate request is required") + } + if err := validateUpdateSelf(req); err != nil { + return err + } + if slices.Contains(req.ReadyForHandshake, nil) { + return xerrors.New("ready_for_handshake entries must not be nil") + } + return nil +} + +func validateUpdateSelf(req *proto.CoordinateRequest) error { + if upd := req.GetUpdateSelf(); upd != nil && upd.GetNode() == nil { + return xerrors.New("update_self node is required") + } + return nil +} diff --git a/tailnet/requests_test.go b/tailnet/requests_test.go new file mode 100644 index 00000000000..8d50a507719 --- /dev/null +++ b/tailnet/requests_test.go @@ -0,0 +1,100 @@ +package tailnet_test + +import ( + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "cdr.dev/slog/v3/sloggers/slogtest" + "github.com/coder/coder/v2/tailnet" + "github.com/coder/coder/v2/tailnet/proto" + "github.com/coder/coder/v2/tailnet/test" + "github.com/coder/coder/v2/testutil" +) + +func TestCoordinateeAuthNilNode(t *testing.T) { + t.Parallel() + for name, auth := range map[string]tailnet.CoordinateeAuth{ + "Agent": tailnet.AgentCoordinateeAuth{ID: uuid.New()}, + "Client": tailnet.ClientCoordinateeAuth{AgentID: uuid.New()}, + "ClientUser": tailnet.ClientUserCoordinateeAuth{}, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + require.NotPanics(t, func() { + require.EqualError(t, auth.Authorize(ctx, &proto.CoordinateRequest{ + UpdateSelf: &proto.CoordinateRequest_UpdateSelf{}, + }), "update_self node is required") + }) + }) + } +} + +func TestCoordinator_InvalidRequests(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + coordinator := tailnet.NewCoordinator(slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})) + t.Cleanup(func() { require.NoError(t, coordinator.Close()) }) + test.InvalidCoordinateRequestTest(ctx, t, coordinator) +} + +func TestCoordinator_RequestPanic(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + coordinator := tailnet.NewCoordinator(slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})) + t.Cleanup(func() { require.NoError(t, coordinator.Close()) }) + test.CoordinateRequestPanicTest(ctx, t, coordinator) +} + +func TestValidateCoordinateRequest(t *testing.T) { + t.Parallel() + validRFH := &proto.CoordinateRequest_ReadyForHandshake{Id: tailnet.UUIDToByteSlice(uuid.New())} + for _, tc := range []struct { + name string + req *proto.CoordinateRequest + err string // empty means the request is accepted + }{ + {name: "NilRequest", err: "coordinate request is required"}, + {name: "EmptyRequest", req: &proto.CoordinateRequest{}}, + { + name: "NilNode", + req: &proto.CoordinateRequest{UpdateSelf: &proto.CoordinateRequest_UpdateSelf{}}, + err: "update_self node is required", + }, + { + name: "EmptyNode", + req: &proto.CoordinateRequest{UpdateSelf: &proto.CoordinateRequest_UpdateSelf{Node: &proto.Node{}}}, + }, + {name: "NilReadyForHandshake", req: &proto.CoordinateRequest{ReadyForHandshake: nil}}, + { + name: "EmptyReadyForHandshake", + req: &proto.CoordinateRequest{ReadyForHandshake: []*proto.CoordinateRequest_ReadyForHandshake{}}, + }, + { + name: "ValidReadyForHandshake", + req: &proto.CoordinateRequest{ReadyForHandshake: []*proto.CoordinateRequest_ReadyForHandshake{validRFH}}, + }, + { + name: "NilReadyForHandshakeEntry", + req: &proto.CoordinateRequest{ReadyForHandshake: []*proto.CoordinateRequest_ReadyForHandshake{nil}}, + err: "ready_for_handshake entries must not be nil", + }, + { + name: "NilReadyForHandshakeAfterValid", + req: &proto.CoordinateRequest{ReadyForHandshake: []*proto.CoordinateRequest_ReadyForHandshake{validRFH, nil}}, + err: "ready_for_handshake entries must not be nil", + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + err := tailnet.ValidateCoordinateRequest(tc.req) + if tc.err == "" { + require.NoError(t, err) + return + } + require.EqualError(t, err, tc.err) + }) + } +} diff --git a/tailnet/test/peer.go b/tailnet/test/peer.go index 601dc748d42..9dc44f431ed 100644 --- a/tailnet/test/peer.go +++ b/tailnet/test/peer.go @@ -407,3 +407,12 @@ func (f FakeCoordinateeAuth) Authorize(ctx context.Context, _ *proto.CoordinateR } var _ tailnet.CoordinateeAuth = (*FakeCoordinateeAuth)(nil) + +// CoordinateeAuthFunc adapts a plain function to tailnet.CoordinateeAuth so a +// test can return a specific error or panic on a chosen request without +// declaring a new type. +type CoordinateeAuthFunc func(context.Context, *proto.CoordinateRequest) error + +func (f CoordinateeAuthFunc) Authorize(ctx context.Context, req *proto.CoordinateRequest) error { + return f(ctx, req) +} diff --git a/tailnet/test/requests.go b/tailnet/test/requests.go new file mode 100644 index 00000000000..da84432604e --- /dev/null +++ b/tailnet/test/requests.go @@ -0,0 +1,116 @@ +package test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/tailnet" + "github.com/coder/coder/v2/tailnet/proto" +) + +// InvalidCoordinateRequestTest checks that malformed requests are rejected +// before authorization and that a valid node bundled with a malformed entry +// is not published to tunnel peers. +func InvalidCoordinateRequestTest(ctx context.Context, t *testing.T, coordinator tailnet.CoordinatorV2) { + const ( + baselineDERP = 31001 + malformedDERP = 31002 + ) + for _, tc := range []struct { + name string + req *proto.CoordinateRequest + err string + verifyNoMutation bool + }{ + {name: "NilRequest", err: "coordinate request is required"}, + { + name: "NilNode", + req: &proto.CoordinateRequest{UpdateSelf: &proto.CoordinateRequest_UpdateSelf{}}, + err: "update_self node is required", + }, + { + name: "NilReadyForHandshake", + req: &proto.CoordinateRequest{ReadyForHandshake: []*proto.CoordinateRequest_ReadyForHandshake{nil}}, + err: "ready_for_handshake entries must not be nil", + }, + { + name: "NodeAndNilReadyForHandshake", + req: &proto.CoordinateRequest{ + UpdateSelf: &proto.CoordinateRequest_UpdateSelf{Node: &proto.Node{ + PreferredDerp: malformedDERP, + }}, + ReadyForHandshake: []*proto.CoordinateRequest_ReadyForHandshake{nil}, + }, + err: "ready_for_handshake entries must not be nil", + verifyNoMutation: true, + }, + } { + // The cases share the caller's ctx and coordinator, so they run + // sequentially. Parallel subtests would pause until the caller + // returns and, under -parallel=1, could resume after that ctx has + // already expired. + t.Run(tc.name, func(t *testing.T) { + var auth tailnet.CoordinateeAuth = CoordinateeAuthFunc(func(context.Context, *proto.CoordinateRequest) error { + panic("authorization must not see malformed requests") + }) + if tc.verifyNoMutation { + auth = tailnet.SingleTailnetCoordinateeAuth{} + } + peer := NewPeer(ctx, t, coordinator, tc.name, WithAuth(auth)) + defer peer.Close(ctx) + var observer *Peer + if tc.verifyNoMutation { + observer = NewPeer(ctx, t, coordinator, tc.name+"Observer") + defer observer.Close(ctx) + observer.AddTunnel(peer.ID) + peer.UpdateDERP(baselineDERP) + observer.AssertEventuallyHasDERP(peer.ID, baselineDERP) + } + require.NoError(t, tailnet.SendCtx(ctx, peer.reqs, tc.req)) + peer.AssertEventuallyResponsesClosed(tc.err) + if tc.verifyNoMutation { + observer.AssertEventuallyLost(peer.ID) + for _, update := range observer.peerUpdates[peer.ID] { + require.NotEqual(t, int32(malformedDERP), update.GetNode().GetPreferredDerp()) + } + } + }) + } +} + +// CoordinateRequestPanicTest checks that a failed connection is withdrawn while +// other connections continue exchanging updates. +func CoordinateRequestPanicTest(ctx context.Context, t *testing.T, coordinator tailnet.CoordinatorV2) { + broken := NewPeer(ctx, t, coordinator, "broken", WithAuth(CoordinateeAuthFunc(func(_ context.Context, req *proto.CoordinateRequest) error { + if req.Disconnect != nil { + panic("private panic detail") + } + return nil + }))) + defer broken.Close(ctx) + healthy := NewPeer(ctx, t, coordinator, "healthy") + defer healthy.Close(ctx) + other := NewPeer(ctx, t, coordinator, "other") + defer other.Close(ctx) + healthy.AddTunnel(broken.ID) + healthy.AddTunnel(other.ID) + broken.UpdateDERP(1) + healthy.UpdateDERP(2) + other.UpdateDERP(3) + healthy.AssertEventuallyHasDERP(broken.ID, 1) + healthy.AssertEventuallyHasDERP(other.ID, 3) + + broken.Disconnect() + broken.AssertEventuallyResponsesClosed(tailnet.CloseErrInternal) + healthy.AssertEventuallyLost(broken.ID) + + // Empty messages are valid no-ops, and an empty Node is still a Node. + require.NoError(t, tailnet.SendCtx(ctx, other.reqs, &proto.CoordinateRequest{})) + require.NoError(t, tailnet.SendCtx(ctx, other.reqs, &proto.CoordinateRequest{ + UpdateSelf: &proto.CoordinateRequest_UpdateSelf{Node: &proto.Node{}}, + })) + other.UpdateDERP(4) + healthy.AssertEventuallyHasDERP(other.ID, 4) +} diff --git a/tailnet/tunnel.go b/tailnet/tunnel.go index c575d0b986e..86b4a846afa 100644 --- a/tailnet/tunnel.go +++ b/tailnet/tunnel.go @@ -66,6 +66,9 @@ type AgentCoordinateeAuth struct { } func (a AgentCoordinateeAuth) Authorize(_ context.Context, req *proto.CoordinateRequest) error { + if err := validateUpdateSelf(req); err != nil { + return err + } if tun := req.GetAddTunnel(); tun != nil { return xerrors.New("agents cannot open tunnels") } @@ -130,6 +133,9 @@ func (a ClientUserCoordinateeAuth) Authorize(ctx context.Context, req *proto.Coo // handleClientNodeRequests validates GetUpdateSelf requests and declines ReadyForHandshake requests func handleClientNodeRequests(req *proto.CoordinateRequest) error { + if err := validateUpdateSelf(req); err != nil { + return err + } if upd := req.GetUpdateSelf(); upd != nil { for _, addrStr := range upd.Node.Addresses { pre, err := netip.ParsePrefix(addrStr) From d457119e4c80de32bd697354b9f2b01fc8d2f311 Mon Sep 17 00:00:00 2001 From: "coder-backporter-app[bot]" <335582564+coder-backporter-app[bot]@users.noreply.github.com> Date: Wed, 30 Sep 2026 21:01:34 +0200 Subject: [PATCH 130/138] fix(coderd): validate GCP instance token audience (#29765) (#30191) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport of https://github.com/coder/coder/pull/29765 Original PR: #29765 — fix(coderd): validate GCP instance token audience Merge commit: b5ce79b9e0f8e394322d0987531dcb5ffa9e7489 Requested by: @Lisa-Fiander Co-authored-by: dylanhuff-at-coder --- coderd/coderdtest/coderdtest.go | 3 ++- coderd/coderdtest/coderdtest_test.go | 2 +- coderd/workspaceresourceauth.go | 3 +-- coderd/workspaceresourceauth_test.go | 31 +++++++++++++++++++++++----- 4 files changed, 30 insertions(+), 9 deletions(-) diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index ab8d2271c3b..cc18462b55e 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -1531,10 +1531,11 @@ func RequestExternalAuthCallback(t testing.TB, providerID string, client *coders // NewGoogleInstanceIdentity returns a metadata client and ID token validator for faking // instance authentication for Google Cloud. // nolint:revive -func NewGoogleInstanceIdentity(t testing.TB, instanceID string, expired bool) (*idtoken.Validator, *metadata.Client) { +func NewGoogleInstanceIdentity(t testing.TB, instanceID string, expired bool, audience string) (*idtoken.Validator, *metadata.Client) { keyID, err := cryptorand.String(12) require.NoError(t, err) claims := jwt.MapClaims{ + "aud": audience, "google": map[string]interface{}{ "compute_engine": map[string]string{ "instance_id": instanceID, diff --git a/coderd/coderdtest/coderdtest_test.go b/coderd/coderdtest/coderdtest_test.go index e245898de6c..aa776b84fea 100644 --- a/coderd/coderdtest/coderdtest_test.go +++ b/coderd/coderdtest/coderdtest_test.go @@ -28,7 +28,7 @@ func TestNew(t *testing.T) { workspace := coderdtest.CreateWorkspace(t, client, template.ID) coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID) - _, _ = coderdtest.NewGoogleInstanceIdentity(t, "example", false) + _, _ = coderdtest.NewGoogleInstanceIdentity(t, "example", false, "coder") _, _ = coderdtest.NewAWSInstanceIdentity(t, "an-instance") } diff --git a/coderd/workspaceresourceauth.go b/coderd/workspaceresourceauth.go index 5a2c36f7ef9..674bb60956f 100644 --- a/coderd/workspaceresourceauth.go +++ b/coderd/workspaceresourceauth.go @@ -104,8 +104,7 @@ func (api *API) postWorkspaceAuthGoogleInstanceIdentity(rw http.ResponseWriter, return } - // We leave the audience blank. It's not important we validate who made the token. - payload, err := api.GoogleTokenValidator.Validate(ctx, req.JSONWebToken, "") + payload, err := api.GoogleTokenValidator.Validate(ctx, req.JSONWebToken, "coder") if err != nil { httpapi.Write(ctx, rw, http.StatusUnauthorized, codersdk.Response{ Message: "Invalid GCP identity.", diff --git a/coderd/workspaceresourceauth_test.go b/coderd/workspaceresourceauth_test.go index b327c120bc3..74a146a2d2a 100644 --- a/coderd/workspaceresourceauth_test.go +++ b/coderd/workspaceresourceauth_test.go @@ -291,7 +291,7 @@ func TestPostWorkspaceAuthGoogleInstanceIdentity(t *testing.T) { t.Parallel() instanceID := newTestInstanceID(t) - validator, metadata := coderdtest.NewGoogleInstanceIdentity(t, instanceID, true) + validator, metadata := coderdtest.NewGoogleInstanceIdentity(t, instanceID, true, "coder") client := coderdtest.New(t, &coderdtest.Options{ GoogleTokenValidator: validator, }) @@ -310,7 +310,7 @@ func TestPostWorkspaceAuthGoogleInstanceIdentity(t *testing.T) { t.Parallel() instanceID := newTestInstanceID(t) - validator, metadata := coderdtest.NewGoogleInstanceIdentity(t, instanceID, false) + validator, metadata := coderdtest.NewGoogleInstanceIdentity(t, instanceID, false, "coder") client := coderdtest.New(t, &coderdtest.Options{ GoogleTokenValidator: validator, }) @@ -329,10 +329,11 @@ func TestPostWorkspaceAuthGoogleInstanceIdentity(t *testing.T) { t.Parallel() instanceID := newTestInstanceID(t) - validator, metadata := coderdtest.NewGoogleInstanceIdentity(t, instanceID, false) - client, _ := setupInstanceIDWorkspace(t, &coderdtest.Options{ + validator, metadata := coderdtest.NewGoogleInstanceIdentity(t, instanceID, false, "coder") + client, store := setupInstanceIDWorkspace(t, &coderdtest.Options{ GoogleTokenValidator: validator, }, workspaceAgentsForInstanceID(instanceID, "dev")) + expectedAgent := requireWorkspaceAgentByInstanceIDAndName(t, store, instanceID, "dev") ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() @@ -340,13 +341,33 @@ func TestPostWorkspaceAuthGoogleInstanceIdentity(t *testing.T) { agentClient := agentsdk.New(client.URL, agentsdk.WithGoogleInstanceIdentity("", metadata)) err := agentClient.RefreshToken(ctx) require.NoError(t, err) + require.Equal(t, expectedAgent.AuthToken.String(), agentClient.SDK.SessionToken()) + }) + + t.Run("ForeignAudience", func(t *testing.T) { + t.Parallel() + + instanceID := newTestInstanceID(t) + validator, metadata := coderdtest.NewGoogleInstanceIdentity(t, instanceID, false, "other-service") + client, _ := setupInstanceIDWorkspace(t, &coderdtest.Options{ + GoogleTokenValidator: validator, + }, workspaceAgentsForInstanceID(instanceID, "dev")) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + agentClient := agentsdk.New(client.URL, agentsdk.WithGoogleInstanceIdentity("", metadata)) + err := agentClient.RefreshToken(ctx) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusUnauthorized, apiErr.StatusCode()) }) t.Run("Ambiguous/GoogleWithSelector", func(t *testing.T) { t.Parallel() instanceID := newTestInstanceID(t) - validator, metadata := coderdtest.NewGoogleInstanceIdentity(t, instanceID, false) + validator, metadata := coderdtest.NewGoogleInstanceIdentity(t, instanceID, false, "coder") client, store := setupInstanceIDWorkspace(t, &coderdtest.Options{ GoogleTokenValidator: validator, }, workspaceAgentsForInstanceID(instanceID, "alpha", "beta")) From d866f7d59bb4eb1429312e31f8b657101db968df Mon Sep 17 00:00:00 2001 From: Sas Swart Date: Thu, 15 Oct 2026 11:03:21 +0200 Subject: [PATCH 131/138] fix: reject encoded path traversal in ai gateway before forwarding upstream (#30178) (#30418) Backport of https://github.com/coder/coder/pull/30178 to `release/2.34`. Original PR: #30178, fix: reject encoded path traversal in ai gateway before forwarding upstream Merge commit: bf3db74b83e0553127eee8ea5b1552aa0819f6a1 Cherry-pick with conflict adjustments: - `aibridge/x/proxy` does not exist on this branch, so its changes are dropped. - `NewRequestBridge` has no inflight middleware wrapping the mux here, so the `xurl.ContainsEncodedPath` check runs in `RequestBridge.ServeHTTP` after request admission and before the body limit and mux. This is the same position as on `main`. - `TestPassthroughRejectsEncodedPathTraversal` is adapted to this branch's `keypool.New(keys, clk)` and `aibridge.NewAnthropicProvider` signatures. - `coderd/util/xurl` and `aibridge/routing/path.go` are identical to `main`. Verified: `go test` for `coderd/util/xurl`, `aibridge`, `aibridge/routing`; `go vet` and `gofmt` clean. --- _Generated by Coder Agents on behalf of @SasSwart._ --- aibridge/bridge.go | 7 +++ aibridge/bridge_test.go | 86 +++++++++++++++++++++++++++++++++++ aibridge/routing/path.go | 24 ++++++++++ aibridge/routing/path_test.go | 32 +++++++++++++ coderd/util/xurl/xurl.go | 51 +++++++++++++++++++++ coderd/util/xurl/xurl_test.go | 57 +++++++++++++++++++++++ 6 files changed, 257 insertions(+) create mode 100644 aibridge/routing/path.go create mode 100644 aibridge/routing/path_test.go create mode 100644 coderd/util/xurl/xurl.go create mode 100644 coderd/util/xurl/xurl_test.go diff --git a/aibridge/bridge.go b/aibridge/bridge.go index f736dd6c422..5a0f0121078 100644 --- a/aibridge/bridge.go +++ b/aibridge/bridge.go @@ -27,7 +27,9 @@ import ( "github.com/coder/coder/v2/aibridge/metrics" "github.com/coder/coder/v2/aibridge/provider" "github.com/coder/coder/v2/aibridge/recorder" + "github.com/coder/coder/v2/aibridge/routing" "github.com/coder/coder/v2/aibridge/tracing" + "github.com/coder/coder/v2/coderd/util/xurl" ) const ( @@ -397,6 +399,11 @@ func (b *RequestBridge) ServeHTTP(rw http.ResponseWriter, r *http.Request) { b.inflightWG.Done() }() + if xurl.ContainsEncodedPath(r.URL) { + http.Error(rw, routing.InvalidPathMessage, http.StatusBadRequest) + return + } + // Enforce the request body size limit. MaxBytesReader counts bytes as // they are read from the connection and fails when the limit is exceeded. r.Body = http.MaxBytesReader(rw, r.Body, maxRequestBodyBytes) diff --git a/aibridge/bridge_test.go b/aibridge/bridge_test.go index c8057b02ff3..095eb9c5d2b 100644 --- a/aibridge/bridge_test.go +++ b/aibridge/bridge_test.go @@ -7,6 +7,7 @@ import ( "net/http" "net/http/httptest" "strings" + "sync" "testing" "github.com/stretchr/testify/assert" @@ -19,7 +20,9 @@ import ( "github.com/coder/coder/v2/aibridge/config" "github.com/coder/coder/v2/aibridge/intercept" "github.com/coder/coder/v2/aibridge/internal/testutil" + "github.com/coder/coder/v2/aibridge/keypool" "github.com/coder/coder/v2/aibridge/provider" + "github.com/coder/quartz" ) var bridgeTestTracer = otel.Tracer("bridge_test") @@ -230,6 +233,89 @@ func TestPassthroughRoutesForProviders(t *testing.T) { } } +// TestPassthroughRejectsEncodedPathTraversal verifies that encoded dot +// segments, which upstreams decode and resolve outside the allowlisted route, +// are rejected before an upstream request is made with a centralized key. +func TestPassthroughRejectsEncodedPathTraversal(t *testing.T) { + t.Parallel() + + providers := []struct { + name string + prefix string + provider func(*testing.T, string, *keypool.Pool) provider.Provider + }{ + { + name: "openai", + prefix: "/openai/v1/models/", + provider: func(_ *testing.T, baseURL string, pool *keypool.Pool) provider.Provider { + return aibridge.NewOpenAIProvider(config.OpenAI{BaseURL: baseURL, KeyPool: pool}) + }, + }, + { + name: "anthropic", + prefix: "/anthropic/v1/models/", + provider: func(_ *testing.T, baseURL string, pool *keypool.Pool) provider.Provider { + return aibridge.NewAnthropicProvider(config.Anthropic{BaseURL: baseURL, KeyPool: pool}, nil) + }, + }, + { + name: "copilot", + prefix: "/copilot/models/", + provider: func(_ *testing.T, baseURL string, _ *keypool.Pool) provider.Provider { + return aibridge.NewCopilotProvider(config.Copilot{BaseURL: baseURL}) + }, + }, + } + + for _, p := range providers { + t.Run(p.name, func(t *testing.T) { + t.Parallel() + + var upstreamPaths []string + var mu sync.Mutex + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + upstreamPaths = append(upstreamPaths, r.URL.EscapedPath()) + mu.Unlock() + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(upstream.Close) + + pool, err := keypool.New([]string{"centralized-key"}, quartz.NewReal()) + require.NoError(t, err) + logger := slogtest.Make(t, nil) + rec := testutil.MockRecorder{} + bridge, err := aibridge.NewRequestBridge(t.Context(), []provider.Provider{p.provider(t, upstream.URL+"/v1", pool)}, &rec, nil, logger, nil, bridgeTestTracer) + require.NoError(t, err) + + for _, suffix := range []string{ + "%2e%2e/files", + "%2E%2E/%2E%2E/admin", + "..%2F..%2Fadmin", + "%252e%252e/files", + "../files", + } { + resp := httptest.NewRecorder() + bridge.ServeHTTP(resp, httptest.NewRequest(http.MethodGet, p.prefix+suffix, nil)) + assert.Equal(t, http.StatusBadRequest, resp.Code, suffix) + assert.Contains(t, resp.Body.String(), "invalid request path", suffix) + } + mu.Lock() + require.Empty(t, upstreamPaths, "rejected requests must not reach the upstream") + mu.Unlock() + + // Escaped separators in ordinary segments keep their escaping. + resp := httptest.NewRecorder() + bridge.ServeHTTP(resp, httptest.NewRequest(http.MethodGet, p.prefix+"org%2Fmodel", nil)) + require.Equal(t, http.StatusOK, resp.Code) + mu.Lock() + defer mu.Unlock() + require.Len(t, upstreamPaths, 1) + assert.True(t, strings.HasSuffix(upstreamPaths[0], "/models/org%2Fmodel"), upstreamPaths[0]) + }) + } +} + func TestWebSocketUpgradeRejected(t *testing.T) { t.Parallel() diff --git a/aibridge/routing/path.go b/aibridge/routing/path.go new file mode 100644 index 00000000000..bdd3a82baf4 --- /dev/null +++ b/aibridge/routing/path.go @@ -0,0 +1,24 @@ +package routing + +import ( + "net/http" + + "github.com/coder/coder/v2/coderd/util/xurl" +) + +// InvalidPathMessage is the response body for requests rejected by +// [RejectInvalidForwardPath]. +const InvalidPathMessage = "invalid request path" + +// RejectInvalidForwardPath wraps next and responds with 400 to requests whose +// path is unsafe to forward upstream, as reported by +// [xurl.ContainsEncodedPath], before any routing or upstream request. +func RejectInvalidForwardPath(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if xurl.ContainsEncodedPath(r.URL) { + http.Error(w, InvalidPathMessage, http.StatusBadRequest) + return + } + next.ServeHTTP(w, r) + }) +} diff --git a/aibridge/routing/path_test.go b/aibridge/routing/path_test.go new file mode 100644 index 00000000000..7268babb37f --- /dev/null +++ b/aibridge/routing/path_test.go @@ -0,0 +1,32 @@ +package routing_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/coder/coder/v2/aibridge/routing" +) + +func TestRejectInvalidForwardPath(t *testing.T) { + t.Parallel() + + var called bool + handler := routing.RejectInvalidForwardPath(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + called = true + w.WriteHeader(http.StatusOK) + })) + + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/openai/v1/models/%2e%2e/files", nil)) + assert.Equal(t, http.StatusBadRequest, rec.Code) + assert.Contains(t, rec.Body.String(), routing.InvalidPathMessage) + assert.False(t, called) + + rec = httptest.NewRecorder() + handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/openai/v1/models/org%2Fmodel", nil)) + assert.Equal(t, http.StatusOK, rec.Code) + assert.True(t, called) +} diff --git a/coderd/util/xurl/xurl.go b/coderd/util/xurl/xurl.go new file mode 100644 index 00000000000..2db01ec82e3 --- /dev/null +++ b/coderd/util/xurl/xurl.go @@ -0,0 +1,51 @@ +// Package xurl contains small helpers extending the standard net/url +// package. +package xurl + +import ( + "net/url" + "strings" + "unicode" +) + +// ContainsEncodedPath reports whether u's path is unsafe to forward to +// another server with its escaping intact, because that server could resolve +// it to a different path than the one it was matched on. +// +// [net/http.ServeMux] matches routes on the escaped path and only cleans +// literal dot segments, while upstreams decode percent-encoded unreserved +// characters (RFC 3986 section 6.2.2.2) and then remove dot segments. A path +// such as /v1/models/%2e%2e/files therefore matches a /v1/models/ route +// locally but is served as /v1/files upstream. +// +// Each escaped segment is decoded exactly once. It returns true if a segment +// fails to decode, or if a decoded segment: +// - contains a "." or ".." element, also when split on an escaped "/" or "\" +// for components that decode reserved characters, +// - still contains "%", which only arises from multiple encoding and would +// let a component that decodes twice produce a dot segment, +// - contains control characters. +// +// Escaped separators in ordinary segments, such as /models/org%2Fmodel, are +// allowed. +func ContainsEncodedPath(u *url.URL) bool { + for _, seg := range strings.Split(u.EscapedPath(), "/") { + decoded, err := url.PathUnescape(seg) + if err != nil { + return true + } + if strings.ContainsRune(decoded, '%') || strings.ContainsFunc(decoded, unicode.IsControl) { + return true + } + for _, part := range strings.FieldsFunc(decoded, isPathSeparator) { + if part == "." || part == ".." { + return true + } + } + } + return false +} + +func isPathSeparator(r rune) bool { + return r == '/' || r == '\\' +} diff --git a/coderd/util/xurl/xurl_test.go b/coderd/util/xurl/xurl_test.go new file mode 100644 index 00000000000..7c81661ff0f --- /dev/null +++ b/coderd/util/xurl/xurl_test.go @@ -0,0 +1,57 @@ +package xurl_test + +import ( + "net/url" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/util/xurl" +) + +func TestContainsEncodedPath(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + path string + want bool + }{ + {name: "Plain", path: "/v1/models"}, + {name: "ModelID", path: "/v1/models/gpt-4.1"}, + {name: "ResourceID", path: "/v1/responses/resp_abc123"}, + {name: "TrailingSlash", path: "/v1/models/"}, + {name: "EscapedSeparator", path: "/v1/models/org%2Fmodel"}, + {name: "EscapedSpace", path: "/v1/models/a%20b"}, + {name: "DotsInName", path: "/v1/models/..model"}, + {name: "LiteralDotDot", path: "/v1/models/../files", want: true}, + {name: "LiteralDot", path: "/v1/./models", want: true}, + {name: "EncodedDotDot", path: "/v1/models/%2e%2e/files", want: true}, + {name: "EncodedDotDotUpper", path: "/v1/models/%2E%2E/files", want: true}, + {name: "MixedDotDot", path: "/v1/models/.%2e/files", want: true}, + {name: "EncodedDot", path: "/v1/%2e/models", want: true}, + {name: "DotDotEscapedSlash", path: "/v1/models/..%2F..%2Ffiles", want: true}, + {name: "DotDotInsideSegment", path: "/v1/models/a%2F..%2Fb", want: true}, + {name: "DotDotEscapedBackslash", path: "/v1/models/..%5Cfiles", want: true}, + {name: "DoubleEncodedDotDot", path: "/v1/models/%252e%252e/files", want: true}, + {name: "TripleEncodedDotDot", path: "/v1/models/%25252e%25252e/files", want: true}, + {name: "EncodedNUL", path: "/v1/models/a%00b", want: true}, + {name: "EncodedNewline", path: "/v1/models/a%0Ab", want: true}, + {name: "InvalidEscape", path: "/v1/models/%zz", want: true}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // Build the URL the way the server does for an incoming + // request line, preserving the client's escaping. + u, err := url.ParseRequestURI(tc.path) + if err != nil { + // Paths the server itself refuses to parse never reach + // the handler. + require.True(t, tc.want, "unexpected parse error: %v", err) + return + } + require.Equal(t, tc.want, xurl.ContainsEncodedPath(u)) + }) + } +} From 879323c3754d29ddde88b30c190f0f56f9a6c268 Mon Sep 17 00:00:00 2001 From: Dmitry Makovey Date: Thu, 15 Oct 2026 10:56:12 -0700 Subject: [PATCH 132/138] ci(.github/workflows): remove nix from dogfood image matrix (backport 2.34) (#30858) Removes the nix dev image from `.github/workflows/dogfood.yaml` on `release/2.34`, aligning it with `release/2.35` and later (`["22.04", "26.04"]`). Tracked in [ENT-214](https://linear.app/codercom/issue/ENT-214). ## Why `build_image (nix)` fails on every `release/2.34` PR that triggers the dogfood workflow (e.g. #30811): `error: cannot download google-chrome-stable_143.0.7499.169-1_amd64.deb from any mirror` (HTTP 404) The nix dev image includes `google-chrome` from the `nixos-25.05` nixpkgs pinned in this branch's `flake.lock`. Google only serves recent Chrome builds, so the pinned `.deb` is gone. `main` removed the nix image in #26022 after 2.34 was cut, so only this branch still builds it. Bumping nixpkgs instead would change many packages on an old release branch, and the Chrome pin would go stale again. ## Changes `.github/workflows/dogfood.yaml` only, mirroring the workflow part of #26022: - Drop `nix` from the `build_image` matrix. - Remove the nix-only steps: Nix install, `cache-nix-action`, `nix build .#dev_image`, and the `codercom/oss-dogfood-nix` push. - Drop the `matrix.image-version != 'nix'` guards, which are now always true. - Drop the `flake.nix` / `flake.lock` path triggers, since no job in this workflow uses the flake anymore. - Update comments that mentioned the nix image. Not touched, to keep the backport small: `flake.nix` (`dev_image` output), `nix/docker.nix`, and the nix image option in `dogfood/coder/main.tf`. None of them is used by CI after this change. ## Notes - `Analyze PR for Documentation Updates Needed` fails here for an unrelated reason (outdated `agents-chat-action` pin, fixed by #30850 / [ENT-212](https://linear.app/codercom/issue/ENT-212)). - `chromatic` fails on all `release/2.34` PRs ("No app with code ... found"), also unrelated. --- .github/workflows/dogfood.yaml | 78 ++++++---------------------------- 1 file changed, 12 insertions(+), 66 deletions(-) diff --git a/.github/workflows/dogfood.yaml b/.github/workflows/dogfood.yaml index 9dcd853f792..82bec974bb5 100644 --- a/.github/workflows/dogfood.yaml +++ b/.github/workflows/dogfood.yaml @@ -16,14 +16,14 @@ on: # 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. + # packages; # `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 + # `codercom/oss-dogfood-vscode-coder` (`:latest`), + # plus a per-branch tag on # 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 @@ -36,8 +36,6 @@ on: paths: - "dogfood/**" - ".github/workflows/dogfood.yaml" - - "flake.lock" - - "flake.nix" - "mise.toml" - "mise.lock" - "scripts/dogfood/**" @@ -46,8 +44,6 @@ on: paths: - "dogfood/**" - ".github/workflows/dogfood.yaml" - - "flake.lock" - - "flake.nix" - "mise.toml" - "mise.lock" - "scripts/dogfood/**" @@ -62,7 +58,7 @@ jobs: strategy: fail-fast: false matrix: - image-version: ["22.04", "26.04", "nix"] + image-version: ["22.04", "26.04"] if: github.actor != 'dependabot[bot]' # Skip Dependabot PRs runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }} @@ -86,34 +82,6 @@ jobs: with: persist-credentials: false - - name: Setup Nix - uses: nixbuild/nix-quick-install-action@2c9db80fb984ceb1bcaa77cdda3fdf8cfba92035 # v34 - with: - # Pinning to 2.28 here, as Nix gets a "error: [json.exception.type_error.302] type must be array, but is string" - # on version 2.29 and above. - nix_version: "2.28.5" - if: matrix.image-version == 'nix' - - - uses: nix-community/cache-nix-action@7df957e333c1e5da7721f60227dbba6d06080569 # v7.0.2 - with: - # restore and save a cache using this key - primary-key: nix-${{ runner.os }}-${{ hashFiles('**/*.nix', '**/flake.lock') }} - # if there's no cache hit, restore a cache by this prefix - restore-prefixes-first-match: nix-${{ runner.os }}- - # collect garbage until Nix store size (in bytes) is at most this number - # before trying to save a new cache - # 1G = 1073741824 - gc-max-store-size-linux: 5G - # do purge caches - purge: true - # purge all versions of the cache - purge-prefixes: nix-${{ runner.os }}- - # created more than this number of seconds ago relative to the start of the `Post Restore` phase - purge-created: 0 - # except the version with the `primary-key`, if it exists - purge-primary-key: never - if: matrix.image-version == 'nix' - - name: Get branch name id: branch-name uses: tj-actions/branch-names@5250492686b253f06fa55861556d1027b067aeb5 # v9.0.2 @@ -129,14 +97,11 @@ jobs: - name: Set up Depot CLI uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.7.1 - if: matrix.image-version != 'nix' - name: Set up Docker Buildx 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. @@ -166,7 +131,7 @@ jobs: # Match the fork guard on the downstream consumers of these # outputs: nothing reads `steps.shas.outputs.*` outside the # base-push + mise-oci pipeline, which is gated below. - if: matrix.image-version != 'nix' && !github.event.pull_request.head.repo.fork + if: "!github.event.pull_request.head.repo.fork" id: shas env: IMAGE_VERSION: ${{ matrix.image-version }} @@ -179,8 +144,8 @@ jobs: - 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 + # mise oci build) for fork PRs; + if: "!github.event.pull_request.head.repo.fork" uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: registry: ghcr.io @@ -196,7 +161,7 @@ jobs: - 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 + if: "!github.event.pull_request.head.repo.fork" with: project: b4q6ltmpzh token: ${{ secrets.DEPOT_TOKEN }} @@ -217,7 +182,7 @@ jobs: 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 + if: "!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. @@ -236,7 +201,7 @@ jobs: 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 + if: "!github.event.pull_request.head.repo.fork" env: IMAGE_VERSION: ${{ matrix.image-version }} BASE_SHA: ${{ steps.shas.outputs.base_sha }} @@ -255,7 +220,7 @@ jobs: # 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 + if: "!github.event.pull_request.head.repo.fork" env: IMAGE_VERSION: ${{ matrix.image-version }} run: | @@ -275,7 +240,7 @@ jobs: # 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 + if: "!github.event.pull_request.head.repo.fork" env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: ./scripts/dogfood_test_image.sh "dogfood-test:${{ matrix.image-version }}" @@ -324,25 +289,6 @@ jobs: tags: "codercom/oss-dogfood-vscode-coder:${{ steps.docker-tag-name.outputs.tag }},codercom/oss-dogfood-vscode-coder:latest" if: matrix.image-version == '22.04' - - name: Build Nix image - run: nix build .#dev_image - if: matrix.image-version == 'nix' - - - name: Push Nix image - if: matrix.image-version == 'nix' && github.ref == 'refs/heads/main' - run: | - docker load -i result - - CURRENT_SYSTEM=$(nix eval --impure --raw --expr 'builtins.currentSystem') - - docker image tag "codercom/oss-dogfood-nix:latest-$CURRENT_SYSTEM" "codercom/oss-dogfood-nix:${DOCKER_TAG}" - docker image push "codercom/oss-dogfood-nix:${DOCKER_TAG}" - - docker image tag "codercom/oss-dogfood-nix:latest-$CURRENT_SYSTEM" "codercom/oss-dogfood-nix:latest" - docker image push "codercom/oss-dogfood-nix:latest" - env: - DOCKER_TAG: ${{ steps.docker-tag-name.outputs.tag }} - deploy_template: needs: build_image runs-on: ubuntu-latest From 3a5d040efaf5289e1e0061603e52d3274eea9f29 Mon Sep 17 00:00:00 2001 From: "coder[bot]" <137810501+coder[bot]@users.noreply.github.com> Date: Thu, 15 Oct 2026 10:56:58 -0700 Subject: [PATCH 133/138] ci: bump agents-chat-action to v0.5.1 in doc-check (backport 2.34) (#30850) Bumps the pinned `coder/agents-chat-action` in `.github/workflows/doc-check.yaml` on `release/2.34` from `v0` to v0.5.1 (`9b3ac42`). Same change as #29732 on `main`. ## Why The `Analyze PR for Documentation Updates Needed` job fails on every PR into `release/2.34` (e.g. #30811) with `Coder API error: Not Found`. The pinned version calls `/api/experimental/chats`, which #29548 removed after the chat API moved to `/api/v2` (#28496). v0.5.1 calls `/api/v2/chats`. v0.5.1 accepts every input this workflow passes (its inputs are a superset of v0.3.0's), so no other workflow changes are needed. The doc-check run on this PR is the end-to-end test. ## Changes - `.github/workflows/doc-check.yaml`: one `uses:` line. Related: [ENT-210](https://linear.app/codercom/issue/ENT-210/ironbank-critical-bump-go-to-1269-fix-by-2026-10-29) (surfaced on the Go 1.26.9 backports). Opened by Superblink on behalf of @droopy4096. Co-authored-by: Dmytro Makovey --- .github/workflows/doc-check.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/doc-check.yaml b/.github/workflows/doc-check.yaml index f29fd555cc0..935bd77647f 100644 --- a/.github/workflows/doc-check.yaml +++ b/.github/workflows/doc-check.yaml @@ -213,7 +213,7 @@ jobs: - name: Run doc-check via Coder Agent Chat if: steps.check-secrets.outputs.skip != 'true' - uses: coder/agents-chat-action@f0b975f503d3ff3e4478517baae290d4d01a2c7e # v0 + uses: coder/agents-chat-action@9b3ac42d568e9206f9cd51a97789820d6935cc8d # v0.5.1 with: coder-url: ${{ secrets.DOC_CHECK_CODER_URL }} coder-token: ${{ secrets.DOC_CHECK_CODER_SESSION_TOKEN }} From 1a28f035111ddae30a9b76918fe850a0f4cabbeb Mon Sep 17 00:00:00 2001 From: "coder[bot]" <137810501+coder[bot]@users.noreply.github.com> Date: Thu, 15 Oct 2026 12:05:50 -0700 Subject: [PATCH 134/138] fix: bump bundled terraform to 1.16.6 for 2.34 (#30554) Backport of #30516 to `release/2.34`. Bumps the bundled Terraform binary to 1.16.6 (latest stable, [released 2026-10-14](https://github.com/hashicorp/terraform/releases/tag/v1.16.6)) so all supported branches ship the same version. Previous pins were out of sync: the shipped binary (`install.sh`, `scripts/Dockerfile.base`, `mise`, `flake.nix`, `.github/actions/setup-tf`, `testdata/version.txt`) was 1.15.5, while `TerraformVersion` in `provisioner/terraform/install.go` was still 1.14.5. All are now 1.16.6. This is a minor version jump, so `maxTerraformVersion` is raised to 1.16.9. Checksums and Nix SRI hashes come from HashiCorp's `terraform_1.16.6_SHA256SUMS`. Verified with `go test ./provisioner/terraform/...` on this branch. Opened by Superblink on behalf of @droopy4096. _Updated from 1.16.5 to 1.16.6 (adds an HTTP/2 resource-exhaustion fix). Branch name still says 1.16.5. Tests not re-run locally for 1.16.6, relying on CI._ --------- Co-authored-by: coder[bot] <137810501+coder[bot]@users.noreply.github.com> Co-authored-by: Dmitry Makovey --- .github/actions/setup-tf/action.yaml | 2 +- docs/install/airgap.md | 2 +- flake.nix | 6 +-- install.sh | 2 +- mise.lock | 46 +++++++++++----------- mise.toml | 2 +- provisioner/terraform/install.go | 4 +- provisioner/terraform/testdata/version.txt | 2 +- scripts/Dockerfile.base | 2 +- 9 files changed, 34 insertions(+), 34 deletions(-) diff --git a/.github/actions/setup-tf/action.yaml b/.github/actions/setup-tf/action.yaml index 22c7253050f..2ab8ea10b17 100644 --- a/.github/actions/setup-tf/action.yaml +++ b/.github/actions/setup-tf/action.yaml @@ -7,5 +7,5 @@ runs: - name: Install Terraform uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2 with: - terraform_version: 1.15.5 + terraform_version: 1.16.6 terraform_wrapper: false diff --git a/docs/install/airgap.md b/docs/install/airgap.md index 7fc80d23149..76dab7d93a4 100644 --- a/docs/install/airgap.md +++ b/docs/install/airgap.md @@ -55,7 +55,7 @@ RUN mkdir -p /opt/terraform # The below step is optional if you wish to keep the existing version. # See https://github.com/coder/coder/blob/main/provisioner/terraform/install.go#L23-L24 # for supported Terraform versions. -ARG TERRAFORM_VERSION=1.11.0 +ARG TERRAFORM_VERSION=1.16.6 RUN apk update && \ curl -LOs https://releases.hashicorp.com/terraform/${TERRAFORM_VERSION}/terraform_${TERRAFORM_VERSION}_linux_amd64.zip \ && unzip -o terraform_${TERRAFORM_VERSION}_linux_amd64.zip \ diff --git a/flake.nix b/flake.nix index ecb6421e00e..0c8901ec698 100644 --- a/flake.nix +++ b/flake.nix @@ -154,11 +154,11 @@ # so `make gen` remains deterministic in Nix shells. terraform_1_15_5 = if pkgs.stdenv.isLinux && pkgs.stdenv.hostPlatform.isx86_64 then - pkgs.runCommand "terraform-1.15.5" { + pkgs.runCommand "terraform-1.16.6" { nativeBuildInputs = [ pkgs.unzip ]; src = pkgs.fetchurl { - url = "https://releases.hashicorp.com/terraform/1.15.5/terraform_1.15.5_linux_amd64.zip"; - hash = "sha256-cCshNq9nKMj/A3+EPdLbzit62IeGtzgdHXKu+iUPYBw="; + url = "https://releases.hashicorp.com/terraform/1.16.6/terraform_1.16.6_linux_amd64.zip"; + hash = "sha256-OANLONAwljmaehJkoUVhqAKbK0lv0fFpRSZW5Tlo8bc="; }; } '' mkdir -p "$out/bin" diff --git a/install.sh b/install.sh index daf4f598369..51350857a76 100755 --- a/install.sh +++ b/install.sh @@ -276,7 +276,7 @@ EOF main() { MAINLINE=1 STABLE=0 - TERRAFORM_VERSION="1.15.5" + TERRAFORM_VERSION="1.16.6" if [ "${TRACE-}" ]; then set -x diff --git a/mise.lock b/mise.lock index f10ba8b9601..8e0e5978479 100644 --- a/mise.lock +++ b/mise.lock @@ -896,49 +896,49 @@ checksum = "sha256:b8bfdedb261de2a69768097422a73bc72273ee92136ff676a20c3161e6588 url = "https://github.com/anchore/syft/releases/download/v1.20.0/syft_1.20.0_windows_amd64.zip" [[tools.terraform]] -version = "1.15.5" +version = "1.16.6" backend = "aqua:hashicorp/terraform" [tools.terraform."platforms.linux-arm64"] -checksum = "sha256:06e7b48de826146c6d9331ba35b13da12332d8392be30d1dd6b789ba4713fff0" -url = "https://releases.hashicorp.com/terraform/1.15.5/terraform_1.15.5_linux_arm64.zip" +checksum = "sha256:a750b5a11038f165846b2658c40c88cd7070aa67a61bab431a14ba27928ea9ea" +url = "https://releases.hashicorp.com/terraform/1.16.6/terraform_1.16.6_linux_arm64.zip" [tools.terraform."platforms.linux-arm64-musl"] -checksum = "sha256:06e7b48de826146c6d9331ba35b13da12332d8392be30d1dd6b789ba4713fff0" -url = "https://releases.hashicorp.com/terraform/1.15.5/terraform_1.15.5_linux_arm64.zip" +checksum = "sha256:a750b5a11038f165846b2658c40c88cd7070aa67a61bab431a14ba27928ea9ea" +url = "https://releases.hashicorp.com/terraform/1.16.6/terraform_1.16.6_linux_arm64.zip" [tools.terraform."platforms.linux-x64"] -checksum = "sha256:702b2136af6728c8ff037f843dd2dbce2b7ad88786b7381d1d72aefa250f601c" -url = "https://releases.hashicorp.com/terraform/1.15.5/terraform_1.15.5_linux_amd64.zip" +checksum = "sha256:38034b38d03096399a7a1264a14561a8029b2b496fd1f169452656e53968f1b7" +url = "https://releases.hashicorp.com/terraform/1.16.6/terraform_1.16.6_linux_amd64.zip" [tools.terraform."platforms.linux-x64-baseline"] -checksum = "sha256:702b2136af6728c8ff037f843dd2dbce2b7ad88786b7381d1d72aefa250f601c" -url = "https://releases.hashicorp.com/terraform/1.15.5/terraform_1.15.5_linux_amd64.zip" +checksum = "sha256:38034b38d03096399a7a1264a14561a8029b2b496fd1f169452656e53968f1b7" +url = "https://releases.hashicorp.com/terraform/1.16.6/terraform_1.16.6_linux_amd64.zip" [tools.terraform."platforms.linux-x64-musl"] -checksum = "sha256:702b2136af6728c8ff037f843dd2dbce2b7ad88786b7381d1d72aefa250f601c" -url = "https://releases.hashicorp.com/terraform/1.15.5/terraform_1.15.5_linux_amd64.zip" +checksum = "sha256:38034b38d03096399a7a1264a14561a8029b2b496fd1f169452656e53968f1b7" +url = "https://releases.hashicorp.com/terraform/1.16.6/terraform_1.16.6_linux_amd64.zip" [tools.terraform."platforms.linux-x64-musl-baseline"] -checksum = "sha256:702b2136af6728c8ff037f843dd2dbce2b7ad88786b7381d1d72aefa250f601c" -url = "https://releases.hashicorp.com/terraform/1.15.5/terraform_1.15.5_linux_amd64.zip" +checksum = "sha256:38034b38d03096399a7a1264a14561a8029b2b496fd1f169452656e53968f1b7" +url = "https://releases.hashicorp.com/terraform/1.16.6/terraform_1.16.6_linux_amd64.zip" [tools.terraform."platforms.macos-arm64"] -checksum = "sha256:01137660510005b918bba82154866fbeac4393163d8277c2abe861dfb5842c3c" -url = "https://releases.hashicorp.com/terraform/1.15.5/terraform_1.15.5_darwin_arm64.zip" +checksum = "sha256:85e1bc78ab45bdc5f82a7bac3929e37d072834a584d22583a2498dc67bb624cd" +url = "https://releases.hashicorp.com/terraform/1.16.6/terraform_1.16.6_darwin_arm64.zip" [tools.terraform."platforms.macos-x64"] -checksum = "sha256:3687d07c034b3e7deed5b072cd8ae2b34835bcb139baec3fc4f5fd534dabf5ed" -url = "https://releases.hashicorp.com/terraform/1.15.5/terraform_1.15.5_darwin_amd64.zip" +checksum = "sha256:794a36c3f7ebf6c58f2089c226068a539d1705ba358a5679b51671c8dc7ebc7b" +url = "https://releases.hashicorp.com/terraform/1.16.6/terraform_1.16.6_darwin_amd64.zip" [tools.terraform."platforms.macos-x64-baseline"] -checksum = "sha256:3687d07c034b3e7deed5b072cd8ae2b34835bcb139baec3fc4f5fd534dabf5ed" -url = "https://releases.hashicorp.com/terraform/1.15.5/terraform_1.15.5_darwin_amd64.zip" +checksum = "sha256:794a36c3f7ebf6c58f2089c226068a539d1705ba358a5679b51671c8dc7ebc7b" +url = "https://releases.hashicorp.com/terraform/1.16.6/terraform_1.16.6_darwin_amd64.zip" [tools.terraform."platforms.windows-x64"] -checksum = "sha256:2f652dd854af7b7fbb51301afc55b5ef1d3f6e287be7889d4cc3818df891cd38" -url = "https://releases.hashicorp.com/terraform/1.15.5/terraform_1.15.5_windows_amd64.zip" +checksum = "sha256:6ea2c186e0e76a113af30fbcdcf7a4b4c70756317994a04929e5c90b9e94b1df" +url = "https://releases.hashicorp.com/terraform/1.16.6/terraform_1.16.6_windows_amd64.zip" [tools.terraform."platforms.windows-x64-baseline"] -checksum = "sha256:2f652dd854af7b7fbb51301afc55b5ef1d3f6e287be7889d4cc3818df891cd38" -url = "https://releases.hashicorp.com/terraform/1.15.5/terraform_1.15.5_windows_amd64.zip" +checksum = "sha256:6ea2c186e0e76a113af30fbcdcf7a4b4c70756317994a04929e5c90b9e94b1df" +url = "https://releases.hashicorp.com/terraform/1.16.6/terraform_1.16.6_windows_amd64.zip" diff --git a/mise.toml b/mise.toml index b3aa2540d94..151298e1952 100644 --- a/mise.toml +++ b/mise.toml @@ -40,7 +40,7 @@ golangci-lint = "1.64.8" helm = "3.21.0" kubectx = "0.9.4" syft = "1.20.0" -terraform = "1.15.5" +terraform = "1.16.6" # Developer-environment niceties for the dogfood image. Non-dogfood # users who run `mise install` here will pull these too; they are diff --git a/provisioner/terraform/install.go b/provisioner/terraform/install.go index 2137e99cb92..f4b229ce56e 100644 --- a/provisioner/terraform/install.go +++ b/provisioner/terraform/install.go @@ -22,10 +22,10 @@ var ( // when Terraform is not available on the system. // NOTE: Keep this in sync with the version in scripts/Dockerfile.base. // NOTE: Keep this in sync with the version in install.sh. - TerraformVersion = version.Must(version.NewVersion("1.14.5")) + TerraformVersion = version.Must(version.NewVersion("1.16.6")) minTerraformVersion = version.Must(version.NewVersion("1.1.0")) - maxTerraformVersion = version.Must(version.NewVersion("1.14.9")) // use .9 to automatically allow patch releases + maxTerraformVersion = version.Must(version.NewVersion("1.16.9")) // use .9 to automatically allow patch releases errTerraformMinorVersionMismatch = xerrors.New("Terraform binary minor version mismatch.") ) diff --git a/provisioner/terraform/testdata/version.txt b/provisioner/terraform/testdata/version.txt index d32434904bc..de646d2fc11 100644 --- a/provisioner/terraform/testdata/version.txt +++ b/provisioner/terraform/testdata/version.txt @@ -1 +1 @@ -1.15.5 +1.16.6 diff --git a/scripts/Dockerfile.base b/scripts/Dockerfile.base index 315c099d78b..24fac984f5d 100644 --- a/scripts/Dockerfile.base +++ b/scripts/Dockerfile.base @@ -27,7 +27,7 @@ RUN apk add --no-cache \ # Terraform was disabled in the edge repo due to a build issue. # https://gitlab.alpinelinux.org/alpine/aports/-/commit/f3e263d94cfac02d594bef83790c280e045eba35 # Using wget for now. Note that busybox unzip doesn't support streaming. -RUN ARCH="$(arch)"; if [ "${ARCH}" == "x86_64" ]; then ARCH="amd64"; elif [ "${ARCH}" == "aarch64" ]; then ARCH="arm64"; elif [ "${ARCH}" == "armv7l" ]; then ARCH="arm"; fi; wget -O /tmp/terraform.zip "https://releases.hashicorp.com/terraform/1.15.5/terraform_1.15.5_linux_${ARCH}.zip" && \ +RUN ARCH="$(arch)"; if [ "${ARCH}" == "x86_64" ]; then ARCH="amd64"; elif [ "${ARCH}" == "aarch64" ]; then ARCH="arm64"; elif [ "${ARCH}" == "armv7l" ]; then ARCH="arm"; fi; wget -O /tmp/terraform.zip "https://releases.hashicorp.com/terraform/1.16.6/terraform_1.16.6_linux_${ARCH}.zip" && \ busybox unzip /tmp/terraform.zip -d /usr/local/bin && \ rm -f /tmp/terraform.zip && \ chmod +x /usr/local/bin/terraform && \ From 24cfb824a329e1146067576091fd52eac5e1f4e7 Mon Sep 17 00:00:00 2001 From: Dmitry Makovey Date: Thu, 15 Oct 2026 12:09:29 -0700 Subject: [PATCH 135/138] fix: bump golang.org/x/net to v0.60.0 (backport 2.34) (#30818) Backport of the `golang.org/x/net` v0.60.0 bump (#30772) to `release/2.34`, as a minimal `go.mod` / `go.sum` change. Tracked in Linear: [ENT-211](https://linear.app/codercom/issue/ENT-211/ironbank-critical-bump-golangorgxnet-to-v0600-on-release-branches-fix) (Iron Bank fix-by date 2026-10-29 for the Critical finding). It complements the Go bump in [ENT-210](https://linear.app/codercom/issue/ENT-210/ironbank-critical-bump-go-to-1269-fix-by-2026-10-29): the Go bump alone does not clear GO-2026-6612 (Critical), -6603, -6610, -6611 and -6617, which are fixed in x/net v0.60.0. `golang.org/x/net` is v0.55.0 here; this moves it to v0.60.0. Only `golang.org/x/*` modules change. The extra bumps are the minimum Go's version selection requires and cannot be held back: x/net v0.60.0 requires x/crypto v0.57.0, x/sys v0.48.0, x/term v0.46.0 and x/text v0.42.0, and x/text v0.42.0 in turn requires x/tools v0.49.0, x/mod v0.41.0 and x/sync v0.23.0. Also moved: golang.org/x/crypto v0.57.0, golang.org/x/mod v0.41.0, golang.org/x/sync v0.23.0, golang.org/x/sys v0.48.0, golang.org/x/term v0.46.0, golang.org/x/text v0.42.0, golang.org/x/tools v0.49.0, golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5. No non-`x` module changes. Built with go1.26.9: `go build ./enterprise/cmd/coder` succeeds. Opened by Superblink on behalf of @droopy4096. --- go.mod | 18 +++++++++--------- go.sum | 36 ++++++++++++++++++------------------ 2 files changed, 27 insertions(+), 27 deletions(-) diff --git a/go.mod b/go.mod index 040ebefafb6..be1d25dc079 100644 --- a/go.mod +++ b/go.mod @@ -228,16 +228,16 @@ 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.52.0 + golang.org/x/crypto v0.57.0 golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f - golang.org/x/mod v0.36.0 - golang.org/x/net v0.55.0 + golang.org/x/mod v0.41.0 + golang.org/x/net v0.60.0 golang.org/x/oauth2 v0.36.0 - golang.org/x/sync v0.20.0 - golang.org/x/sys v0.45.0 - golang.org/x/term v0.43.0 - golang.org/x/text v0.37.0 - golang.org/x/tools v0.45.0 + golang.org/x/sync v0.23.0 + golang.org/x/sys v0.48.0 + golang.org/x/term v0.46.0 + golang.org/x/text v0.42.0 + golang.org/x/tools v0.49.0 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da google.golang.org/api v0.280.0 google.golang.org/grpc v1.82.1 @@ -651,7 +651,7 @@ require ( go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect go.yaml.in/yaml/v4 v4.0.0-rc.3 // indirect - golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6 // indirect + golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5 // indirect google.golang.org/genai v1.51.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect k8s.io/utils v0.0.0-20250820121507-0af2bda4dd1d // indirect diff --git a/go.sum b/go.sum index d0cb206516b..e96d6c3aa3a 100644 --- a/go.sum +++ b/go.sum @@ -1401,8 +1401,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.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= -golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= 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= @@ -1413,8 +1413,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= -golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= +golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= @@ -1427,8 +1427,8 @@ golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU= +golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -1441,8 +1441,8 @@ golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -1480,11 +1480,11 @@ golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= -golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6 h1:HjU6IWBiAgRIdAJ9/y1rwCn+UELEmwV+VsTLzj/W4sE= -golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6/go.mod h1:Eqhaxk/wZsWEH8CRxLwj6xzEJbz7k1EFGqx7nyCoabE= +golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5 h1:ZUSxONxc981v7AW7QUg+I9WwZzSTTJ019ENBYr5pV/Q= +golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5/go.mod h1:LVehoXe41cL5SCVQilsV7Gg6BNG+Js6P9PhSbYTIUkQ= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -1497,8 +1497,8 @@ golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= -golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= -golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= +golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= +golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= @@ -1512,8 +1512,8 @@ golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -1523,8 +1523,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= -golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= +golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= +golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= From 5e15171d6f1b24b515d76838163d51fd35c58e02 Mon Sep 17 00:00:00 2001 From: Dmitry Makovey Date: Thu, 15 Oct 2026 12:34:11 -0700 Subject: [PATCH 136/138] fix: bump Go version from 1.26.5 to 1.26.9 (backport 2.34) (#30811) Backport of #30807 to `release/2.34`. Bumps the Go toolchain from 1.26.5 to 1.26.9. Tracked in Linear: [ENT-210](https://linear.app/codercom/issue/ENT-210/ironbank-critical-bump-go-to-1269-fix-by-2026-10-29) (Iron Bank fix-by date 2026-10-29 for the Critical finding). Same approach as the Terraform 1.16.5 backports (#30516 and its backports). Files changed: `go.mod`, `mise.toml`, `mise.lock`, and `.github/actions/setup-go/action.yaml` (this branch also pins Go there). Not built or scanned locally; CI will build it. **Companion PR:** the module side of GO-2026-6612 (Critical), -6603, -6610, -6611 and -6617 is fixed by `golang.org/x/net` v0.60.0, backported in https://github.com/coder/coder/pull/30818 ([ENT-211](https://linear.app/codercom/issue/ENT-211/ironbank-critical-bump-golangorgxnet-to-v0600-on-release-branches-fix)). This PR alone does not clear them; both are needed. Opened by Superblink on behalf of @droopy4096. --- .github/actions/setup-go/action.yaml | 2 +- go.mod | 2 +- mise.lock | 46 ++++++++++++++-------------- mise.toml | 2 +- 4 files changed, 26 insertions(+), 26 deletions(-) diff --git a/.github/actions/setup-go/action.yaml b/.github/actions/setup-go/action.yaml index 3d503af54b4..451f971c001 100644 --- a/.github/actions/setup-go/action.yaml +++ b/.github/actions/setup-go/action.yaml @@ -4,7 +4,7 @@ description: | inputs: version: description: "The Go version to use." - default: "1.26.5" + default: "1.26.9" use-cache: description: "Whether to use the cache." default: "true" diff --git a/go.mod b/go.mod index be1d25dc079..d26ef7201c9 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/coder/coder/v2 -go 1.26.5 +go 1.26.9 // Required until a v3 of chroma is created to lazily initialize all XML files. // None of our dependencies seem to use the registries anyways, so this diff --git a/mise.lock b/mise.lock index 8e0e5978479..4bdc18d88bd 100644 --- a/mise.lock +++ b/mise.lock @@ -385,52 +385,52 @@ checksum = "sha256:e1245a0a760a45b236e7a25bf118c1defc8447734bdeb4260ea3ec15d1797 url = "https://github.com/digitalocean/doctl/releases/download/v1.158.0/doctl-1.158.0-windows-amd64.zip" [[tools.go]] -version = "1.26.5" +version = "1.26.9" backend = "core:go" [tools.go."platforms.linux-arm64"] -checksum = "sha256:fe4789e92b1f33358680864bbe8704289e7bb5fc207d80623c308935bd696d49" -url = "https://dl.google.com/go/go1.26.5.linux-arm64.tar.gz" +checksum = "sha256:4a97373d49fcacdcf3694fea368a500b00ee3e963974f3e7514132717632f052" +url = "https://dl.google.com/go/go1.26.9.linux-arm64.tar.gz" [tools.go."platforms.linux-arm64-musl"] -checksum = "sha256:fe4789e92b1f33358680864bbe8704289e7bb5fc207d80623c308935bd696d49" -url = "https://dl.google.com/go/go1.26.5.linux-arm64.tar.gz" +checksum = "sha256:4a97373d49fcacdcf3694fea368a500b00ee3e963974f3e7514132717632f052" +url = "https://dl.google.com/go/go1.26.9.linux-arm64.tar.gz" [tools.go."platforms.linux-x64"] -checksum = "sha256:5c2c3b16caefa1d968a94c1daca04a7ca301a496d9b086e17ad77bb81393f053" -url = "https://dl.google.com/go/go1.26.5.linux-amd64.tar.gz" +checksum = "sha256:42d158b4d8f7b61ac0a830567c940a86098fb7aac52e467a5ebec03ef5cc2f8d" +url = "https://dl.google.com/go/go1.26.9.linux-amd64.tar.gz" [tools.go."platforms.linux-x64-baseline"] -checksum = "sha256:5c2c3b16caefa1d968a94c1daca04a7ca301a496d9b086e17ad77bb81393f053" -url = "https://dl.google.com/go/go1.26.5.linux-amd64.tar.gz" +checksum = "sha256:42d158b4d8f7b61ac0a830567c940a86098fb7aac52e467a5ebec03ef5cc2f8d" +url = "https://dl.google.com/go/go1.26.9.linux-amd64.tar.gz" [tools.go."platforms.linux-x64-musl"] -checksum = "sha256:5c2c3b16caefa1d968a94c1daca04a7ca301a496d9b086e17ad77bb81393f053" -url = "https://dl.google.com/go/go1.26.5.linux-amd64.tar.gz" +checksum = "sha256:42d158b4d8f7b61ac0a830567c940a86098fb7aac52e467a5ebec03ef5cc2f8d" +url = "https://dl.google.com/go/go1.26.9.linux-amd64.tar.gz" [tools.go."platforms.linux-x64-musl-baseline"] -checksum = "sha256:5c2c3b16caefa1d968a94c1daca04a7ca301a496d9b086e17ad77bb81393f053" -url = "https://dl.google.com/go/go1.26.5.linux-amd64.tar.gz" +checksum = "sha256:42d158b4d8f7b61ac0a830567c940a86098fb7aac52e467a5ebec03ef5cc2f8d" +url = "https://dl.google.com/go/go1.26.9.linux-amd64.tar.gz" [tools.go."platforms.macos-arm64"] -checksum = "sha256:efb87ff28af9a188d0536ef5d42e63dd52ba8263cd7344a993cc48dd11dedb6a" -url = "https://dl.google.com/go/go1.26.5.darwin-arm64.tar.gz" +checksum = "sha256:f9bb7c0a02506c5d9bf0d1eb1f7ee6c7684f844ae49558308a0427b830e022cc" +url = "https://dl.google.com/go/go1.26.9.darwin-arm64.tar.gz" [tools.go."platforms.macos-x64"] -checksum = "sha256:6231d8d3b8f5552ec6cbf6d685bdd5482e1e703214b120e89b3bf0d7bf1ef725" -url = "https://dl.google.com/go/go1.26.5.darwin-amd64.tar.gz" +checksum = "sha256:00c29e3d4c8562f547410daa18d2959b641bd691ab00c4a525ea817a0253d4bd" +url = "https://dl.google.com/go/go1.26.9.darwin-amd64.tar.gz" [tools.go."platforms.macos-x64-baseline"] -checksum = "sha256:6231d8d3b8f5552ec6cbf6d685bdd5482e1e703214b120e89b3bf0d7bf1ef725" -url = "https://dl.google.com/go/go1.26.5.darwin-amd64.tar.gz" +checksum = "sha256:00c29e3d4c8562f547410daa18d2959b641bd691ab00c4a525ea817a0253d4bd" +url = "https://dl.google.com/go/go1.26.9.darwin-amd64.tar.gz" [tools.go."platforms.windows-x64"] -checksum = "sha256:97e6b2a833b6d89f9ff17d25419ac0a7e3b482a044e9ab18cdef834bd834fd38" -url = "https://dl.google.com/go/go1.26.5.windows-amd64.zip" +checksum = "sha256:d722201a9c0c086d1610e111c48203009af690892ed708072bd5ae20160e7a59" +url = "https://dl.google.com/go/go1.26.9.windows-amd64.zip" [tools.go."platforms.windows-x64-baseline"] -checksum = "sha256:97e6b2a833b6d89f9ff17d25419ac0a7e3b482a044e9ab18cdef834bd834fd38" -url = "https://dl.google.com/go/go1.26.5.windows-amd64.zip" +checksum = "sha256:d722201a9c0c086d1610e111c48203009af690892ed708072bd5ae20160e7a59" +url = "https://dl.google.com/go/go1.26.9.windows-amd64.zip" [[tools."go:github.com/coder/sqlc/cmd/sqlc"]] version = "337309bfb9524f38466a5090e310040fc7af0203" diff --git a/mise.toml b/mise.toml index 151298e1952..e93d36df6b9 100644 --- a/mise.toml +++ b/mise.toml @@ -8,7 +8,7 @@ lockfile = true [tools] # Languages and runtimes. bun = "1.2.15" -go = "1.26.5" +go = "1.26.9" node = "22.19.0" pnpm = "10.33.2" From 4be26e43127b06ea37bee1777e7913466b195efd Mon Sep 17 00:00:00 2001 From: "coder-backporter-app[bot]" <335582564+coder-backporter-app[bot]@users.noreply.github.com> Date: Fri, 16 Oct 2026 12:04:11 +0200 Subject: [PATCH 137/138] fix(aibridge/intercept/messages): treat Bedrock Opus 5, Haiku 5.5, Fable 5, and Mythos 5 as adaptive-only (#30692) (#30749) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport of https://github.com/coder/coder/pull/30692 Original PR: #30692 — fix(aibridge/intercept/messages): treat Bedrock Opus 5, Haiku 5.5, Fable 5, and Mythos 5 as adaptive-only Merge commit: e6d24ff30aa04597220754a1b55aa5219778461c Requested by: @dannykoppingConflicts resolved manually. They came from #27339 (Sonnet 5) and #29762 (Opus 5.5) never being backported to this branch. Took the #30692 allowlist **without** `anthropic.claude-sonnet-5`, since the Sonnet 5 fix was only backported to 2.36 (#27642) and is out of scope here; the doc comment is adjusted to match. The `anthropic.claude-opus-5` prefix also covers Opus 5.5. Kept only the 8 test cases #30692 added. `go test ./aibridge/intercept/messages/` passes. Co-authored-by: Danny Kopping --- aibridge/intercept/messages/base.go | 11 +++- .../intercept/messages/base_internal_test.go | 56 +++++++++++++++++++ 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/aibridge/intercept/messages/base.go b/aibridge/intercept/messages/base.go index 83084e43e38..31e006c1f9e 100644 --- a/aibridge/intercept/messages/base.go +++ b/aibridge/intercept/messages/base.go @@ -431,13 +431,18 @@ func bedrockModelSupportsAdaptiveThinking(model string) bool { // bedrockModelRequiresAdaptiveThinking returns true if the given Bedrock model // ID only supports the "adaptive" thinking type and rejects the legacy -// "enabled" + budget_tokens shape with a 400. Claude Opus 4.7 was the first -// model in this category. +// "enabled" + budget_tokens shape with a 400. The Opus 5, Fable 5, and +// Mythos 5 prefixes also match their point releases. // // See https://docs.aws.amazon.com/bedrock/latest/userguide/model-card-anthropic-claude-opus-4-7.html +// and https://docs.aws.amazon.com/bedrock/latest/userguide/claude-messages-adaptive-thinking.html func bedrockModelRequiresAdaptiveThinking(model string) bool { return strings.Contains(model, "anthropic.claude-opus-4-7") || - strings.Contains(model, "anthropic.claude-opus-4-8") + strings.Contains(model, "anthropic.claude-opus-4-8") || + strings.Contains(model, "anthropic.claude-opus-5") || + strings.Contains(model, "anthropic.claude-haiku-5-5") || + strings.Contains(model, "anthropic.claude-fable-5") || + strings.Contains(model, "anthropic.claude-mythos-5") } // filterBedrockBetaFlags removes unsupported beta flags from the Anthropic-Beta diff --git a/aibridge/intercept/messages/base_internal_test.go b/aibridge/intercept/messages/base_internal_test.go index 6968eb4ec57..96255c18105 100644 --- a/aibridge/intercept/messages/base_internal_test.go +++ b/aibridge/intercept/messages/base_internal_test.go @@ -858,6 +858,62 @@ func TestAugmentRequestForBedrock_AdaptiveThinking(t *testing.T) { requestBody: `{"max_tokens":10000,"thinking":{"type":"enabled","budget_tokens":5000}}`, expectThinkingType: "adaptive", }, + { + name: "opus_5_model_with_enabled_thinking_is_converted_to_adaptive_and_drops_budget", + bedrockModel: "anthropic.claude-opus-5", + requestBody: `{"max_tokens":10000,"thinking":{"type":"enabled","budget_tokens":5000}}`, + expectThinkingType: "adaptive", + }, + { + name: "global_opus_5_model_keeps_adaptive_thinking_and_output_config_effort", + bedrockModel: "global.anthropic.claude-opus-5", + requestBody: `{"max_tokens":10000,"thinking":{"type":"adaptive"},"output_config":{"effort":"medium"}}`, + expectThinkingType: "adaptive", + expectEffort: "medium", + expectKeptFields: []string{"output_config"}, + }, + { + name: "haiku_5_5_model_with_enabled_thinking_is_converted_to_adaptive_and_drops_budget", + bedrockModel: "anthropic.claude-haiku-5-5", + requestBody: `{"max_tokens":10000,"thinking":{"type":"enabled","budget_tokens":5000}}`, + expectThinkingType: "adaptive", + }, + { + name: "regional_haiku_5_5_model_keeps_adaptive_thinking_and_output_config_effort", + bedrockModel: "us.anthropic.claude-haiku-5-5", + requestBody: `{"max_tokens":10000,"thinking":{"type":"adaptive"},"output_config":{"effort":"low"}}`, + expectThinkingType: "adaptive", + expectEffort: "low", + expectKeptFields: []string{"output_config"}, + }, + { + name: "fable_5_model_with_enabled_thinking_is_converted_to_adaptive_and_drops_budget", + bedrockModel: "anthropic.claude-fable-5", + requestBody: `{"max_tokens":10000,"thinking":{"type":"enabled","budget_tokens":5000}}`, + expectThinkingType: "adaptive", + }, + { + name: "global_fable_5_1_model_keeps_adaptive_thinking_and_output_config_effort", + bedrockModel: "global.anthropic.claude-fable-5-1", + requestBody: `{"max_tokens":10000,"thinking":{"type":"adaptive"},"output_config":{"effort":"high"}}`, + expectThinkingType: "adaptive", + expectEffort: "high", + expectKeptFields: []string{"output_config"}, + }, + { + name: "mythos_5_model_with_enabled_thinking_is_converted_to_adaptive_and_drops_budget", + bedrockModel: "anthropic.claude-mythos-5", + requestBody: `{"max_tokens":10000,"thinking":{"type":"enabled","budget_tokens":5000}}`, + expectThinkingType: "adaptive", + }, + { + name: "global_mythos_5_1_model_keeps_adaptive_thinking_and_output_config_effort", + bedrockModel: "global.anthropic.claude-mythos-5-1", + requestBody: `{"max_tokens":10000,"thinking":{"type":"adaptive"},"output_config":{"effort":"high"}}`, + expectThinkingType: "adaptive", + expectEffort: "high", + expectKeptFields: []string{"output_config"}, + }, { // Opus 4.7 on Bedrock rejects output_config.format (structured // outputs) with a 400 even though it accepts output_config.effort. From d099f03f315a0a81a91b8aa466749be86fe8a54a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?McKayla=20=E3=81=AF=E3=81=AA?= Date: Fri, 16 Oct 2026 15:12:04 -0600 Subject: [PATCH 138/138] ci: add coder-backporter-app[bot] to CLA allowlist (#30802) --- .github/workflows/contrib.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/contrib.yaml b/.github/workflows/contrib.yaml index 27fb9c86373..31f455b0236 100644 --- a/.github/workflows/contrib.yaml +++ b/.github/workflows/contrib.yaml @@ -116,7 +116,7 @@ jobs: # branch should not be protected branch: "main" # Some users have signed a corporate CLA with Coder so are exempt from signing our community one. - allowlist: "coryb,aaronlehmann,dependabot*,blink-so*,blinkagent*" + allowlist: "coryb,aaronlehmann,dependabot*,blink-so*,blinkagent*,coder-backporter-app[bot]" title: runs-on: ubuntu-latest