diff --git a/.claude/docs/DATABASE.md b/.claude/docs/DATABASE.md index 84f6125fa48d4..331d662d20f95 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/.claude/docs/OBSERVABILITY.md b/.claude/docs/OBSERVABILITY.md index 629a40915b0f4..a7533e95ecfce 100644 --- a/.claude/docs/OBSERVABILITY.md +++ b/.claude/docs/OBSERVABILITY.md @@ -30,8 +30,10 @@ under a named logger such as `api`, `site`, `proxy`, `ext-provisioner`, or `prometheus`. HTTP request logging is implemented in `coderd/httpmw/loggermw`. Request log -fields include `user_agent`, `host`, `path`, `proto`, `remote_addr`, `start`, -`status_code`, `latency_ms`, route params, and selected safe query params. +fields include `user_agent`, `host`, the effective trust-aware host, +`received_host`, the raw received Host header, `path`, `proto`, +`remote_addr`, `start`, `status_code`, `latency_ms`, route params, and +selected safe query params. Responses with status codes of 500 or higher include the response body in the request log. Successful `GET /api/v2` requests are skipped. diff --git a/.github/actions/setup-go/action.yaml b/.github/actions/setup-go/action.yaml index ee7f17a40ef7d..451f971c00156 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.2" + default: "1.26.9" use-cache: description: "Whether to use the cache." default: "true" diff --git a/.github/actions/setup-tf/action.yaml b/.github/actions/setup-tf/action.yaml index abcf9d7a22dbe..2ab8ea10b17fc 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.16.6 terraform_wrapper: false diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f0a7ccd76c7a4..78d6aba61a925 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 }} diff --git a/.github/workflows/contrib.yaml b/.github/workflows/contrib.yaml index 27fb9c86373dc..31f455b023649 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 diff --git a/.github/workflows/doc-check.yaml b/.github/workflows/doc-check.yaml index f29fd555cc0f1..935bd77647f8d 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 }} diff --git a/.github/workflows/dogfood.yaml b/.github/workflows/dogfood.yaml index 9dcd853f79294..82bec974bb5b8 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 diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index d7ef868576f9a..f45537c9e41b6 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 }} diff --git a/agent/agent.go b/agent/agent.go index 33ad83c804d8b..c598de819e7bd 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 1fe8ad2725b22..7412338404f91 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() diff --git a/agent/agentchat/log_test.go b/agent/agentchat/log_test.go index c9fb1fc49a60b..99cd94a133c05 100644 --- a/agent/agentchat/log_test.go +++ b/agent/agentchat/log_test.go @@ -22,7 +22,7 @@ func TestMiddlewareAccessLog(t *testing.T) { chatID := uuid.New() ancestorID := uuid.New() sink := testutil.NewFakeSink(t) - handler := tracing.StatusWriterMiddleware(loggermw.Logger(sink.Logger())( + handler := tracing.StatusWriterMiddleware(loggermw.Logger(sink.Logger(), nil)( agentchat.Middleware(http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) { rw.WriteHeader(http.StatusNoContent) })), @@ -46,7 +46,7 @@ func TestMiddlewareWithoutChatHeader(t *testing.T) { t.Parallel() sink := testutil.NewFakeSink(t) - handler := tracing.StatusWriterMiddleware(loggermw.Logger(sink.Logger())( + handler := tracing.StatusWriterMiddleware(loggermw.Logger(sink.Logger(), nil)( agentchat.Middleware(http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) { rw.WriteHeader(http.StatusNoContent) })), @@ -68,7 +68,7 @@ func TestMiddlewareContextFields(t *testing.T) { chatID := uuid.New() sink := testutil.NewFakeSink(t) - handler := tracing.StatusWriterMiddleware(loggermw.Logger(sink.Logger())( + handler := tracing.StatusWriterMiddleware(loggermw.Logger(sink.Logger(), nil)( agentchat.Middleware(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { sink.Logger().With(agentchat.Fields(r.Context())...).Info(r.Context(), "handler log") rw.WriteHeader(http.StatusNoContent) diff --git a/agent/agentcontainers/api_test.go b/agent/agentcontainers/api_test.go index 1429de7d85354..8acc6fabd38b8 100644 --- a/agent/agentcontainers/api_test.go +++ b/agent/agentcontainers/api_test.go @@ -3923,16 +3923,14 @@ func TestAPI(t *testing.T) { // Verify commands were executed through the custom shell and environment. require.NotEmpty(t, fakeExec.commands, "commands should be executed") - // Want: /bin/custom-shell -c '"docker" "ps" "--all" "--quiet" "--no-trunc"' + // Want: /bin/custom-shell -c "$@" "" docker ps --all --quiet --no-trunc + // The command is passed as positional parameters and run via "$@" so + // the shell forwards argv without re-parsing it. require.Equal(t, testShell, fakeExec.commands[0][0], "custom shell should be used") - if runtime.GOOS == "windows" { - require.Equal(t, "/c", fakeExec.commands[0][1], "shell should be called with /c on Windows") - } else { - require.Equal(t, "-c", fakeExec.commands[0][1], "shell should be called with -c") - } - require.Len(t, fakeExec.commands[0], 3, "command should have 3 arguments") - require.GreaterOrEqual(t, strings.Count(fakeExec.commands[0][2], " "), 2, "command/script should have multiple arguments") - require.True(t, strings.HasPrefix(fakeExec.commands[0][2], `"docker" "ps"`), "command should start with \"docker\" \"ps\"") + require.Equal(t, "-c", fakeExec.commands[0][1], "shell should be called with -c") + require.Equal(t, `"$@"`, fakeExec.commands[0][2], "script should run argv via \"$@\"") + require.Equal(t, "", fakeExec.commands[0][3], "$0 slot should be an empty placeholder") + require.Equal(t, []string{"docker", "ps", "--all", "--quiet", "--no-trunc"}, fakeExec.commands[0][4:], "argv should be passed through unquoted") // Verify the environment was set on the command. lastCmd := fakeExec.getLastCommand() diff --git a/agent/agentcontainers/execer.go b/agent/agentcontainers/execer.go index 0f85687893486..4695c95947716 100644 --- a/agent/agentcontainers/execer.go +++ b/agent/agentcontainers/execer.go @@ -2,10 +2,7 @@ package agentcontainers import ( "context" - "fmt" "os/exec" - "runtime" - "strings" "cdr.dev/slog/v3" "github.com/coder/coder/v2/agent/agentexec" @@ -51,15 +48,15 @@ func (e *commandEnvExecer) prepare(ctx context.Context, inName string, inArgs .. return inName, inArgs, "", nil } - caller := "-c" - if runtime.GOOS == "windows" { - caller = "/c" - } name = shell - for _, arg := range append([]string{inName}, inArgs...) { - args = append(args, fmt.Sprintf("%q", arg)) - } - args = []string{caller, strings.Join(args, " ")} + // Pass the command through the shell as positional parameters and run + // "$@" so the shell re-emits argv verbatim without re-parsing it. This + // prevents arguments containing shell metacharacters such as $, `, and + // quotes from being interpreted (e.g. command substitution). The token + // before them fills $0, which "$@" never references, so it is discarded. + // This assumes a POSIX shell; Windows is not supported here. + cmdArgs := append([]string{inName}, inArgs...) + args = append([]string{"-c", `"$@"`, ""}, cmdArgs...) return name, args, dir, env } diff --git a/agent/agentcontainers/execer_internal_test.go b/agent/agentcontainers/execer_internal_test.go new file mode 100644 index 0000000000000..8b98693b734cf --- /dev/null +++ b/agent/agentcontainers/execer_internal_test.go @@ -0,0 +1,84 @@ +package agentcontainers + +import ( + "bytes" + "context" + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "cdr.dev/slog/v3" + "cdr.dev/slog/v3/sloggers/slogtest" + "github.com/coder/coder/v2/agent/agentexec" + "github.com/coder/coder/v2/agent/usershell" + "github.com/coder/coder/v2/testutil" +) + +func TestCommandEnvExecer_Prepare(t *testing.T) { + t.Parallel() + + if runtime.GOOS == "windows" { + t.Skip("the POSIX shell quoting under test does not apply on Windows") + } + + const shell = "/bin/sh" + commandEnv := func(usershell.EnvInfoer, []string) (string, string, []string, error) { + return shell, "/tmp", []string{"FOO=bar"}, nil + } + e := newCommandEnvExecer(slogtest.Make(t, nil).Leveled(slog.LevelDebug), commandEnv, agentexec.DefaultExecer) + + t.Run("ArgvPassthrough", func(t *testing.T) { + t.Parallel() + + name, args, dir, env := e.prepare(context.Background(), "echo", "hello", "world") + // The command is run as: shell -c "$@" "" so that the + // shell re-emits argv without re-parsing it. The empty $0 slot is + // discarded. + require.Equal(t, shell, name) + require.Equal(t, []string{"-c", `"$@"`, "", "echo", "hello", "world"}, args) + require.Equal(t, "/tmp", dir) + require.Equal(t, []string{"FOO=bar"}, env) + }) + + t.Run("MetacharactersNotInterpreted", func(t *testing.T) { + t.Parallel() + + payloads := []string{ + "$(echo INJECTED)", + "`echo INJECTED`", + "$HOME", + "a; echo INJECTED", + "a && echo INJECTED", + "a | echo INJECTED", + "a\necho INJECTED", + "it's a \"test\" \\ end", + "", + } + for _, payload := range payloads { + ctx := testutil.Context(t, testutil.WaitShort) + cmd := e.CommandContext(ctx, "printf", "%s", payload) + var out bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &out + require.NoError(t, cmd.Run(), "payload %q", payload) + assert.Equal(t, payload, out.String(), "payload %q was altered by the shell", payload) + } + }) + + t.Run("CommandSubstitutionHasNoSideEffect", func(t *testing.T) { + t.Parallel() + + marker := filepath.Join(t.TempDir(), "pwned") + ctx := testutil.Context(t, testutil.WaitShort) + cmd := e.CommandContext(ctx, "echo", "$(touch "+marker+")") + var out bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &out + require.NoError(t, cmd.Run()) + require.Equal(t, "$(touch "+marker+")\n", out.String()) + require.NoFileExists(t, marker, "command substitution executed; injection is possible") + }) +} diff --git a/agent/agentproc/api_test.go b/agent/agentproc/api_test.go index 704d968899153..11b5d72bd583c 100644 --- a/agent/agentproc/api_test.go +++ b/agent/agentproc/api_test.go @@ -152,7 +152,7 @@ func TestAccessLogIncludesChatID(t *testing.T) { t.Cleanup(func() { _ = api.Close() }) - handler := tracing.StatusWriterMiddleware(loggermw.Logger(logger)( + handler := tracing.StatusWriterMiddleware(loggermw.Logger(logger, nil)( agentchat.Middleware(api.Routes()), )) diff --git a/agent/agentsocket/server.go b/agent/agentsocket/server.go index 380b792da1d0c..d8cad44347b51 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 474469d7ff050..24f84050a81d4 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/agent/api.go b/agent/api.go index 0346805528059..56e109e29acf3 100644 --- a/agent/api.go +++ b/agent/api.go @@ -20,7 +20,7 @@ func (a *agent) apiHandler() http.Handler { r.Use( httpmw.Recover(a.logger), tracing.StatusWriterMiddleware, - loggermw.Logger(a.logger), + loggermw.Logger(a.logger, nil), agentchat.Middleware, ) r.Get("/", func(rw http.ResponseWriter, r *http.Request) { diff --git a/aibridge/api.go b/aibridge/api.go index 809d452fe907a..34dce84ef8873 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 f604d0a38ab0c..5a0f012107814 100644 --- a/aibridge/bridge.go +++ b/aibridge/bridge.go @@ -2,6 +2,7 @@ package aibridge import ( "context" + "errors" "fmt" "net/http" "net/url" @@ -15,21 +16,45 @@ 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" "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" "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 ( // The duration after which an async recording will be aborted. recordingTimeout = time.Second * 5 + + // maxRequestBodyBytes caps the request body size for AI Bridge + // provider endpoints to prevent denial-of-service via memory exhaustion. + // Anthropic enforces 32 MB on the direct API, 30 MB on Vertex AI, + // and 20 MB on Amazon Bedrock. + // See https://docs.anthropic.com/en/api/overview#request-size-limits + // OpenAI and GitHub Copilot do not document an equivalent HTTP body size limit. + // Using highest documented provider limit (32 MiB). + // + // NOTE: aibridge does not currently proxy file-upload endpoints + // (e.g. /v1/files). Those endpoints accept much larger bodies + // (up to 500 MB for Anthropic, 50 MB for OpenAI). If file-upload + // routes are added, they will need a per-route limit instead of + // this single global cap. + maxRequestBodyBytes = 32 << 20 // 32 MiB + + // 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 +121,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 +203,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. @@ -183,11 +230,27 @@ 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)) - logger.Warn(ctx, "failed to create interceptor", slog.Error(err), slog.F("path", r.URL.Path)) - http.Error(w, fmt.Sprintf("failed to create %q interceptor", r.URL.Path), http.StatusInternalServerError) + if _, ok := errors.AsType[*http.MaxBytesError](err); ok { + writeRequestBodyTooLarge(w) + } else { + logger.Warn(ctx, "failed to create interceptor", slog.Error(err), slog.F("path", r.URL.Path)) + http.Error(w, fmt.Sprintf("failed to create %q interceptor", r.URL.Path), http.StatusInternalServerError) + } return } @@ -248,11 +311,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() { @@ -261,28 +331,52 @@ 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() } } +// 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) { + http.Error(w, fmt.Sprintf( + "Request body too large. The maximum allowed request body size is %dMiB.", + maxRequestBodyBytes>>20, + ), http.StatusRequestEntityTooLarge) +} + // ServeHTTP exposes the internal http.Handler, which has all [Provider]s' routes registered. // It also tracks inflight requests. func (b *RequestBridge) ServeHTTP(rw http.ResponseWriter, r *http.Request) { @@ -305,6 +399,14 @@ 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) b.mux.ServeHTTP(rw, r.WithContext(ctx)) } diff --git a/aibridge/bridge_internal_test.go b/aibridge/bridge_internal_test.go new file mode 100644 index 0000000000000..0b07ed98c3a2f --- /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 f2657ab80f5dd..095eb9c5d2bef 100644 --- a/aibridge/bridge_test.go +++ b/aibridge/bridge_test.go @@ -1,19 +1,28 @@ package aibridge_test import ( + "bytes" + "fmt" + "io" "net/http" "net/http/httptest" + "strings" + "sync" "testing" "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/keypool" "github.com/coder/coder/v2/aibridge/provider" + "github.com/coder/quartz" ) var bridgeTestTracer = otel.Tracer("bridge_test") @@ -119,11 +128,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", @@ -176,6 +186,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 { @@ -196,7 +223,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) @@ -205,3 +232,243 @@ 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() + + 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() + + newOpenAI := func(baseURL string) provider.Provider { + return aibridge.NewOpenAIProvider(config.OpenAI{Name: "openai", BaseURL: baseURL}) + } + newAnthropic := func(baseURL string) provider.Provider { + return aibridge.NewAnthropicProvider(config.Anthropic{Name: "anthropic", BaseURL: baseURL}, nil) + } + newCopilot := func(baseURL string) provider.Provider { + return aibridge.NewCopilotProvider(config.Copilot{Name: "copilot", BaseURL: baseURL}) + } + + // Each body is a well-formed, schema-valid request for its provider, with + // an oversized message content that pushes it past the 32 MiB limit. + filler := strings.Repeat("A", 32<<20) + chatCompletionsBody := fmt.Appendf(nil, `{"model":"gpt-4","messages":[{"role":"user","content":"%s"}]}`, filler) + responsesBody := fmt.Appendf(nil, `{"model":"gpt-4","input":"%s"}`, filler) + messagesBody := fmt.Appendf(nil, `{"model":"claude-3-5-sonnet-latest","max_tokens":1024,"messages":[{"role":"user","content":"%s"}]}`, filler) + + tests := []struct { + name string + provider func(baseURL string) provider.Provider + path string + body []byte + }{ + {name: "openai_passthrough", provider: newOpenAI, path: "/openai/v1/models", body: chatCompletionsBody}, + {name: "openai_chat_completions", provider: newOpenAI, path: "/openai/v1/chat/completions", body: chatCompletionsBody}, + {name: "openai_responses", provider: newOpenAI, path: "/openai/v1/responses", body: responsesBody}, + {name: "anthropic_passthrough", provider: newAnthropic, path: "/anthropic/v1/models", body: messagesBody}, + {name: "anthropic_messages", provider: newAnthropic, path: "/anthropic/v1/messages", body: messagesBody}, + {name: "copilot_passthrough", provider: newCopilot, path: "/copilot/models", body: chatCompletionsBody}, + {name: "copilot_chat_completions", provider: newCopilot, path: "/copilot/chat/completions", body: chatCompletionsBody}, + {name: "copilot_responses", provider: newCopilot, path: "/copilot/responses", body: responsesBody}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + logger := slogtest.Make(t, nil) + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(upstream.Close) + + prov := tc.provider(upstream.URL) + bridge, err := aibridge.NewRequestBridge( + t.Context(), + []provider.Provider{prov}, + nil, nil, logger, nil, bridgeTestTracer, + ) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodPost, tc.path, bytes.NewReader(tc.body)) + // Unknown Content-Length + req.ContentLength = -1 + // Copilot's bridged route checks Authorization before reading the + // body, so provide a token to reach the read path. + req.Header.Set("Authorization", "Bearer test-key") + resp := httptest.NewRecorder() + bridge.ServeHTTP(resp, req) + + assert.Equal(t, http.StatusRequestEntityTooLarge, resp.Code) + assert.Contains(t, resp.Body.String(), "Request body too large") + }) + } +} + +// 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/intercept/chatcompletions/base_internal_test.go b/aibridge/intercept/chatcompletions/base_internal_test.go index 1af6054cfa1aa..5e61853070987 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.go b/aibridge/intercept/chatcompletions/blocking.go index 95d065ce5b3ec..fccdcaf241b29 100644 --- a/aibridge/intercept/chatcompletions/blocking.go +++ b/aibridge/intercept/chatcompletions/blocking.go @@ -282,7 +282,15 @@ func (i *BlockingInterception) newChatCompletionWithKey(ctx context.Context, svc _, span := i.tracer.Start(ctx, "Intercept.ProcessRequest.Upstream", trace.WithAttributes(tracing.InterceptionAttributesFromContext(ctx)...)) defer tracing.EndSpanErr(span, &outErr) - return svc.New(ctx, i.req.ChatCompletionNewParams, opts...) + requestOpts, overrideBody, err := i.chatCompletionRequestOptions(opts) + if err != nil { + return nil, xerrors.Errorf("prepare request body: %w", err) + } + params := i.req.ChatCompletionNewParams + if overrideBody { + params = openai.ChatCompletionNewParams{} + } + return svc.New(ctx, params, requestOpts...) } // newChatCompletionWithKeyFailover walks the centralized key @@ -291,15 +299,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 3b3a917a54aa6..80c5cb18c017e 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,57 +108,59 @@ 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. + // Then: 2 requests, 200 response, key-0 temporary, 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, 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", - keys: []string{"k0", "k1"}, + // 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": {statusCode: http.StatusForbidden, body: authErrorBody}, - "k1": {statusCode: http.StatusOK, body: successBody}, + "k0-long-key": {statusCode: http.StatusForbidden, body: authErrorBody}, }, - expectedRequestCount: 2, - expectedStatusCode: http.StatusOK, + expectedRequestCount: 1, + expectedStatusCode: http.StatusForbidden, expectedKeyStates: []keypool.KeyState{ - keypool.KeyStatePermanent, + keypool.KeyStateValid, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k0-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,30 +174,32 @@ 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. + // Then: 2 requests, 502 api_error response, both keys temporary. 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, expectedKeyStates: []keypool.KeyState{ - keypool.KeyStatePermanent, - keypool.KeyStatePermanent, + keypool.KeyStateTemporary, + keypool.KeyStateTemporary, }, + 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 +207,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 +228,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 +262,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 +270,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 +281,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 +300,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 +322,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 +335,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 +359,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 +387,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 +428,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 +478,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/google_openai_compat.go b/aibridge/intercept/chatcompletions/google_openai_compat.go new file mode 100644 index 0000000000000..251cbc71a01a6 --- /dev/null +++ b/aibridge/intercept/chatcompletions/google_openai_compat.go @@ -0,0 +1,37 @@ +package chatcompletions + +import ( + "encoding/json" + "slices" + + "github.com/openai/openai-go/v3/option" + + "github.com/coder/coder/v2/internal/googleopenai" +) + +func (i *interceptionBase) chatCompletionRequestBody() ([]byte, error) { + body, err := json.Marshal(i.req.ChatCompletionNewParams) + if err != nil { + return nil, err + } + if !googleopenai.ShouldPatchGoogleUpstreamRequest(i.cfg.BaseURL) { + return body, nil + } + patched, _, err := googleopenai.PatchThoughtSignatures(body) + if err != nil { + return nil, err + } + return patched, nil +} + +func (i *interceptionBase) chatCompletionRequestOptions(opts []option.RequestOption) ([]option.RequestOption, bool, error) { + if !googleopenai.ShouldPatchGoogleUpstreamRequest(i.cfg.BaseURL) { + return opts, false, nil + } + body, err := i.chatCompletionRequestBody() + if err != nil { + return nil, false, err + } + updated := slices.Clone(opts) + return append(updated, option.WithRequestBody("application/json", body)), true, nil +} diff --git a/aibridge/intercept/chatcompletions/google_openai_compat_internal_test.go b/aibridge/intercept/chatcompletions/google_openai_compat_internal_test.go new file mode 100644 index 0000000000000..826dba07b6a95 --- /dev/null +++ b/aibridge/intercept/chatcompletions/google_openai_compat_internal_test.go @@ -0,0 +1,100 @@ +package chatcompletions + +import ( + "encoding/json" + "testing" + + "github.com/openai/openai-go/v3/option" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/aibridge/config" + "github.com/coder/coder/v2/internal/googleopenai" +) + +func TestGoogleOpenAICompatThoughtSignaturePatchSurvivesParamRoundTrip(t *testing.T) { + t.Parallel() + + const originalSignature = "SIG123" + raw := []byte(`{ + "model":"gemini-3.5-flash", + "stream":true, + "messages":[ + {"role":"user","content":"write a file"}, + { + "role":"assistant", + "content":"I'll search for available workspace templates.", + "tool_calls":[ + { + "id":"pbk491lp", + "function":{"arguments":"{}","name":"list_templates"}, + "type":"function", + "extra_content":{"google":{"thought_signature":"` + originalSignature + `"}} + } + ] + }, + {"role":"tool","tool_call_id":"pbk491lp","content":"{}"} + ] + }`) + + var req ChatCompletionNewParamsWrapper + require.NoError(t, json.Unmarshal(raw, &req)) + + roundTripped, err := json.Marshal(req.ChatCompletionNewParams) + require.NoError(t, err) + require.Empty(t, googleThoughtSignatureFromBody(t, roundTripped, 1, 0), + "openai-go drops extra_content during the typed param round-trip") + + body, err := (&interceptionBase{ + req: &req, + cfg: config.OpenAI{BaseURL: "https://generativelanguage.googleapis.com/v1beta/openai/"}, + }).chatCompletionRequestBody() + require.NoError(t, err) + require.Equal(t, googleopenai.DummyThoughtSignature, googleThoughtSignatureFromBody(t, body, 1, 0)) +} + +func TestGoogleOpenAICompatChatCompletionRequestOptions(t *testing.T) { + t.Parallel() + + var req ChatCompletionNewParamsWrapper + require.NoError(t, json.Unmarshal([]byte(`{ + "model":"gemini-3.5-flash", + "messages":[ + {"role":"user","content":"current turn"}, + { + "role":"assistant", + "tool_calls":[{"id":"call-1","function":{"arguments":"{}","name":"list_templates"},"type":"function"}] + } + ] + }`), &req)) + + opts := make([]option.RequestOption, 1) + updated, overrideBody, err := (&interceptionBase{ + req: &req, + cfg: config.OpenAI{BaseURL: "https://generativelanguage.googleapis.com/v1beta/openai/"}, + }).chatCompletionRequestOptions(opts) + require.NoError(t, err) + require.True(t, overrideBody) + require.Len(t, opts, 1) + require.Len(t, updated, 2) +} + +func googleThoughtSignatureFromBody(t *testing.T, body []byte, messageIndex int, toolCallIndex int) string { + t.Helper() + + var payload map[string]any + require.NoError(t, json.Unmarshal(body, &payload)) + messages, ok := payload["messages"].([]any) + require.True(t, ok) + require.Greater(t, len(messages), messageIndex) + message, ok := messages[messageIndex].(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/aibridge/intercept/chatcompletions/streaming.go b/aibridge/intercept/chatcompletions/streaming.go index 581ab49d034c1..b6134401f50e4 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 @@ -181,7 +186,9 @@ func (i *StreamingInterception) ProcessRequest(w http.ResponseWriter, r *http.Re // We take control of request body here and pass it to the SDK as a raw byte slice. // This is because the SDK's serialization applies hidden request options that result in // unexpected, breaking behavior. See https://github.com/coder/aibridge/pull/164 - body, err := json.Marshal(i.req.ChatCompletionNewParams) + // chatCompletionRequestBody also applies provider-specific + // compatibility patches to the exact body sent upstream. + body, err := i.chatCompletionRequestBody() if err != nil { return xerrors.Errorf("marshal request body: %w", err) } diff --git a/aibridge/intercept/chatcompletions/streaming_internal_test.go b/aibridge/intercept/chatcompletions/streaming_internal_test.go index 82c58f9bc1078..9d9b854e9e7dc 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. + // Then: 2 requests, 200 response, key-0 temporary, 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, @@ -203,29 +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", - keys: []string{"k0", "k1"}, + // 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": {statusCode: http.StatusForbidden, body: authErrorBody}, - "k1": { - statusCode: http.StatusOK, - headers: map[string]string{"Content-Type": "text/event-stream"}, - body: streamingSuccessBody, - }, + "k0-long-key": {statusCode: http.StatusForbidden, body: authErrorBody}, }, - expectedRequestCount: 2, - expectedStatusCode: http.StatusOK, + expectedRequestCount: 1, + expectedStatusCode: http.StatusForbidden, expectedKeyStates: []keypool.KeyState{ - keypool.KeyStatePermanent, + keypool.KeyStateValid, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k0-long-key"), }, { // Given: 3 keys; all return 429 pre-stream with @@ -233,19 +235,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,30 +261,32 @@ 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. + // Then: 2 requests, 502 api_error response, both keys temporary. 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, expectedKeyStates: []keypool.KeyState{ - keypool.KeyStatePermanent, - keypool.KeyStatePermanent, + keypool.KeyStateTemporary, + keypool.KeyStateTemporary, }, + 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 +294,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 +315,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 +348,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 +356,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 +367,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 +386,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 +444,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 +457,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 +482,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 +511,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 +519,7 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) { keypool.KeyStateTemporary, keypool.KeyStateTemporary, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, } @@ -538,7 +553,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 +611,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/client_headers.go b/aibridge/intercept/client_headers.go index 8d4b2def98e8d..5f83fa6cc9f91 100644 --- a/aibridge/intercept/client_headers.go +++ b/aibridge/intercept/client_headers.go @@ -36,8 +36,19 @@ var authHeaders = []string{ "X-Api-Key", } +// proxyHeaders describe the path the inbound request took to reach +// aibridge. On bridge routes aibridge acts as a client, not a proxy, +// so these headers are not meaningful on the outbound request. +var proxyHeaders = []string{ + "X-Forwarded-For", + "X-Forwarded-Host", + "X-Forwarded-Proto", + "X-Forwarded-Port", + "Forwarded", +} + // PrepareClientHeaders returns a copy of the client headers with hop-by-hop, -// transport, and auth headers removed. +// transport, auth, and proxy headers removed. func PrepareClientHeaders(clientHeaders http.Header) http.Header { prepared := clientHeaders.Clone() for _, h := range hopByHopHeaders { @@ -49,6 +60,9 @@ func PrepareClientHeaders(clientHeaders http.Header) http.Header { for _, h := range authHeaders { prepared.Del(h) } + for _, h := range proxyHeaders { + prepared.Del(h) + } return prepared } diff --git a/aibridge/intercept/client_headers_test.go b/aibridge/intercept/client_headers_test.go index f811fbecb05e2..d16d175d1d91e 100644 --- a/aibridge/intercept/client_headers_test.go +++ b/aibridge/intercept/client_headers_test.go @@ -74,6 +74,28 @@ func TestPrepareClientHeaders(t *testing.T) { assert.Equal(t, "preserved", result.Get("X-Custom")) }) + t.Run("proxy headers are removed", func(t *testing.T) { + t.Parallel() + + input := http.Header{ + "X-Forwarded-For": {"203.0.113.50"}, + "X-Forwarded-Host": {"app.example.com"}, + "X-Forwarded-Proto": {"https"}, + "X-Forwarded-Port": {"443"}, + "Forwarded": {"for=203.0.113.50;proto=https"}, + "X-Custom": {"preserved"}, + } + + result := intercept.PrepareClientHeaders(input) + + assert.Empty(t, result.Get("X-Forwarded-For")) + assert.Empty(t, result.Get("X-Forwarded-Host")) + assert.Empty(t, result.Get("X-Forwarded-Proto")) + assert.Empty(t, result.Get("X-Forwarded-Port")) + assert.Empty(t, result.Get("Forwarded")) + assert.Equal(t, "preserved", result.Get("X-Custom")) + }) + t.Run("multi-value headers are preserved", func(t *testing.T) { t.Parallel() diff --git a/aibridge/intercept/messages/base.go b/aibridge/intercept/messages/base.go index e35e2a9726175..31e006c1f9e77 100644 --- a/aibridge/intercept/messages/base.go +++ b/aibridge/intercept/messages/base.go @@ -329,6 +329,12 @@ func (*interceptionBase) withAWSBedrockOptions(ctx context.Context, cfg *aibconf } var out []option.RequestOption + out = append(out, option.WithMiddleware(func(req *http.Request, next option.MiddlewareNext) (*http.Response, error) { + if ua := req.Header.Get("User-Agent"); ua != "" { + req.Header.Set("User-Agent", ua+" sdk-ua-app-id/APN_1.1%2Fpc_cdfmjwn8i6u8l9fwz8h82e4w3%24") + } + return next(req) + })) out = append(out, bedrock.WithConfig(awsCfg)) // If a custom base URL is set, override the default endpoint constructed by the bedrock middleware. @@ -425,12 +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") + return strings.Contains(model, "anthropic.claude-opus-4-7") || + 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 @@ -587,12 +599,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 ef130deca1cce..96255c181058c 100644 --- a/aibridge/intercept/messages/base_internal_test.go +++ b/aibridge/intercept/messages/base_internal_test.go @@ -852,6 +852,68 @@ 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", + }, + { + 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. @@ -1090,6 +1152,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 { @@ -1127,18 +1196,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.go b/aibridge/intercept/messages/blocking.go index e91f80feb9e6e..bf74885b2b5a0 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 857d425fe381f..74ad6021057e1 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,57 +91,59 @@ 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. + // Then: 2 requests, 200 response, key-0 temporary, 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, 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", - keys: []string{"k0", "k1"}, + // 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": {statusCode: http.StatusForbidden, body: authErrorBody}, - "k1": {statusCode: http.StatusOK, body: successBody}, + "k0-long-key": {statusCode: http.StatusForbidden, body: authErrorBody}, }, - expectedRequestCount: 2, - expectedStatusCode: http.StatusOK, + expectedRequestCount: 1, + expectedStatusCode: http.StatusForbidden, expectedKeyStates: []keypool.KeyState{ - keypool.KeyStatePermanent, + keypool.KeyStateValid, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k0-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,30 +157,32 @@ 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. + // Then: 2 requests, 502 api_error response, both keys temporary. 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, expectedKeyStates: []keypool.KeyState{ - keypool.KeyStatePermanent, - keypool.KeyStatePermanent, + keypool.KeyStateTemporary, + keypool.KeyStateTemporary, }, + 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 +190,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 +211,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 +245,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 +253,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 +268,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 +284,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 +310,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 +323,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 +347,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 +375,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 +417,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 +467,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 475f32c99c459..47c49528a97b4 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 97f48d4cc3c67..4531492781a83 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. + // Then: 2 requests, 200 response, key-0 temporary, 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, @@ -119,29 +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", - keys: []string{"k0", "k1"}, + // 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": {statusCode: http.StatusForbidden, body: authErrorBody}, - "k1": { - statusCode: http.StatusOK, - headers: map[string]string{"Content-Type": "text/event-stream"}, - body: streamingSuccessBody, - }, + "k0-long-key": {statusCode: http.StatusForbidden, body: authErrorBody}, }, - expectedRequestCount: 2, - expectedStatusCode: http.StatusOK, + expectedRequestCount: 1, + expectedStatusCode: http.StatusForbidden, expectedKeyStates: []keypool.KeyState{ - keypool.KeyStatePermanent, + keypool.KeyStateValid, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k0-long-key"), }, { // Given: 3 keys; all return 429 pre-stream with @@ -149,19 +152,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,30 +178,32 @@ 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. + // Then: 2 requests, 502 api_error response, both keys temporary. 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, expectedKeyStates: []keypool.KeyState{ - keypool.KeyStatePermanent, - keypool.KeyStatePermanent, + keypool.KeyStateTemporary, + keypool.KeyStateTemporary, }, + 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 +211,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 +232,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 +265,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 +273,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 +288,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 +310,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 +397,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 +410,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 +435,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 +468,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 +476,7 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) { keypool.KeyStateTemporary, keypool.KeyStateTemporary, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, } @@ -494,7 +510,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 +569,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/openai_errors.go b/aibridge/intercept/openai_errors.go index faf2e19e3e023..ef0c88384c117 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 9b49c1e43ab80..256100f542462 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 f2b92ea029f01..a78549d4e3afe 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.go b/aibridge/intercept/responses/blocking.go index 9726b6f750efc..892dc1e71d5cb 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 678c2ce0f3c5a..a6cb07b829aa7 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,57 +94,59 @@ 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. + // Then: 2 requests, 200 response, key-0 temporary, 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, 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", - keys: []string{"k0", "k1"}, + // 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": {statusCode: http.StatusForbidden, body: authErrorBody}, - "k1": {statusCode: http.StatusOK, body: successBody}, + "k0-long-key": {statusCode: http.StatusForbidden, body: authErrorBody}, }, - expectedRequestCount: 2, - expectedStatusCode: http.StatusOK, + expectedRequestCount: 1, + expectedStatusCode: http.StatusForbidden, expectedKeyStates: []keypool.KeyState{ - keypool.KeyStatePermanent, + keypool.KeyStateValid, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k0-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,30 +160,32 @@ 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. + // Then: 2 requests, 502 api_error response, both keys temporary. 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, expectedKeyStates: []keypool.KeyState{ - keypool.KeyStatePermanent, - keypool.KeyStatePermanent, + keypool.KeyStateTemporary, + keypool.KeyStateTemporary, }, + 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 +193,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 +213,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 +245,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 +254,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 +268,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 +284,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 +309,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 +322,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 +346,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 +374,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 +415,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 +463,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 2140c5e6c8670..3b38b7a7e67fb 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 3226147cbd177..07938d6132d7d 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. + // Then: 2 requests, 200 response, key-0 temporary, 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, @@ -110,29 +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", - keys: []string{"k0", "k1"}, + // 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": {statusCode: http.StatusForbidden, body: authErrorBody}, - "k1": { - statusCode: http.StatusOK, - headers: map[string]string{"Content-Type": "text/event-stream"}, - body: streamingSuccessBody, - }, + "k0-long-key": {statusCode: http.StatusForbidden, body: authErrorBody}, }, - expectedRequestCount: 2, - expectedStatusCode: http.StatusOK, + expectedRequestCount: 1, + expectedStatusCode: http.StatusForbidden, expectedKeyStates: []keypool.KeyState{ - keypool.KeyStatePermanent, + keypool.KeyStateValid, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k0-long-key"), }, { // Given: 3 keys; all return 429 pre-stream with @@ -140,19 +142,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,30 +168,32 @@ 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. + // Then: 2 requests, 502 api_error response, both keys temporary. 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, expectedKeyStates: []keypool.KeyState{ - keypool.KeyStatePermanent, - keypool.KeyStatePermanent, + keypool.KeyStateTemporary, + keypool.KeyStateTemporary, }, + 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 +201,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 +221,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 +252,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 +261,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 +275,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 +291,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 +348,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 +361,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 +385,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 +413,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 +454,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 +504,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/internal/integrationtest/bridge_internal_test.go b/aibridge/internal/integrationtest/bridge_internal_test.go index cfb40599d33ba..9c75108685a48 100644 --- a/aibridge/internal/integrationtest/bridge_internal_test.go +++ b/aibridge/internal/integrationtest/bridge_internal_test.go @@ -3,17 +3,24 @@ package integrationtest import ( "bytes" "context" + "crypto/sha256" + "encoding/hex" "encoding/json" "fmt" "io" "net/http" + "net/http/httptest" "slices" "strings" + "sync/atomic" "testing" + "time" "github.com/anthropics/anthropic-sdk-go" "github.com/anthropics/anthropic-sdk-go/packages/ssestream" "github.com/anthropics/anthropic-sdk-go/shared/constant" + "github.com/aws/aws-sdk-go-v2/aws" + v4signer "github.com/aws/aws-sdk-go-v2/aws/signer/v4" "github.com/google/uuid" "github.com/openai/openai-go/v3" oaissestream "github.com/openai/openai-go/v3/packages/ssestream" @@ -131,6 +138,13 @@ func TestAnthropicMessages(t *testing.T) { require.Len(t, promptUsages, 1) assert.Equal(t, "read the foo file", promptUsages[0].Prompt) + // Verify PRM attribution is NOT present on non-Bedrock Anthropic requests. + received := upstream.receivedRequests() + require.Len(t, received, 1) + ua := received[0].Header.Get("User-Agent") + assert.NotContains(t, ua, "sdk-ua-app-id", + "PRM attribution should not be present on non-Bedrock requests") + bridgeServer.Recorder.VerifyAllInterceptionsEnded(t) }) } @@ -327,6 +341,11 @@ func TestAWSBedrockIntegration(t *testing.T) { require.False(t, gjson.GetBytes(received[0].Body, "model").Exists(), "model should be stripped from body") require.False(t, gjson.GetBytes(received[0].Body, "stream").Exists(), "stream should be stripped from body") + // Verify PRM attribution is appended to the User-Agent header. + ua := received[0].Header.Get("User-Agent") + require.Contains(t, ua, "sdk-ua-app-id/APN_1.1%2Fpc_cdfmjwn8i6u8l9fwz8h82e4w3%24", + "expected AWS PRM attribution in User-Agent header") + interceptions := bridgeServer.Recorder.RecordedInterceptions() require.Len(t, interceptions, 1) require.Equal(t, interceptions[0].Model, bedrockCfg.Model) @@ -467,6 +486,154 @@ func TestAWSBedrockIntegration(t *testing.T) { } } }) + + // SigV4 signs all headers on the outbound Bedrock request. If any header + // is modified in transit (e.g. an egress proxy appending to X-Forwarded-For), + // the signature becomes invalid and AWS rejects the request with: + // 403: "The request signature we calculated does not match the signature + // you provided." + t.Run("SigV4 signed headers", func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(t.Context(), testutil.WaitLong) + t.Cleanup(cancel) + + fix := fixtures.Parse(t, fixtures.AntSingleBuiltinTool) + + proxyHeaders := http.Header{ + "X-Forwarded-For": {"203.0.113.50, 10.0.0.1"}, + "X-Forwarded-Host": {"app.example.com"}, + "X-Forwarded-Proto": {"https"}, + } + + // Credentials used for both the Bedrock config and the mock's + // signature re-verification. + accessKey := "test-access-key" + secretKey := "test-secret-key" + region := "us-west-2" + + var signatureValid atomic.Bool + + // Mock Bedrock endpoint (simulates AWS). The OnRequest callback + // re-signs the received request using only the declared + // SignedHeaders and stores whether the signatures match. + fixResp := newFixtureResponse(fix) + fixResp.OnRequest = func(r *http.Request, body []byte) { + authHeader := r.Header.Get("Authorization") + // Passthrough requests have no SigV4 auth; skip verification. + if !strings.HasPrefix(authHeader, "AWS4-HMAC-SHA256") { + return + } + originalSig := extractSigV4Field(authHeader, "Signature=") + + // Rebuild the request the way AWS would: keep only + // the declared SignedHeaders. + signedHeaders := strings.Split(extractSigV4Field(authHeader, "SignedHeaders="), ";") + verifyReq := r.Clone(r.Context()) + verifyReq.Header.Del("Authorization") + for h := range verifyReq.Header { + if !slices.Contains(signedHeaders, strings.ToLower(h)) { + verifyReq.Header.Del(h) + } + } + // Restore ContentLength: Go's HTTP server parses it + // from the request but does not put it in r.Header; + // the SigV4 signer reads the struct field. + verifyReq.ContentLength = int64(len(body)) + + // Re-sign with the same credentials, body hash, and + // timestamp. SigV4 derives the signature from all three, + // so any difference means a header was altered in transit. + signingTime, err := time.Parse("20060102T150405Z", verifyReq.Header.Get("X-Amz-Date")) + require.NoError(t, err) + bodyHash := sha256.Sum256(body) + err = v4signer.NewSigner().SignHTTP( + ctx, + aws.Credentials{AccessKeyID: accessKey, SecretAccessKey: secretKey}, + verifyReq, hex.EncodeToString(bodyHash[:]), + "bedrock", region, signingTime, + ) + require.NoError(t, err) + + recomputedSig := extractSigV4Field(verifyReq.Header.Get("Authorization"), "Signature=") + signatureValid.Store(originalSig == recomputedSig) + } + mockBedrock := newMockUpstream(ctx, t, fixResp) + mockBedrock.AllowOverflow = true + + // Simulated egress proxy: modifies X-Forwarded-For and + // forwards to mockBedrock, preserving the original Host. + mockEgressProxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + r.Header.Set("X-Forwarded-For", xff+", 10.255.0.1") + } + + proxyReq, err := http.NewRequestWithContext(r.Context(), r.Method, mockBedrock.URL+r.URL.Path, r.Body) + require.NoError(t, err) + proxyReq.Header = r.Header.Clone() + proxyReq.Host = r.Host // preserve signed Host + + resp, err := http.DefaultClient.Do(proxyReq) + require.NoError(t, err) + defer resp.Body.Close() + + for k, vs := range resp.Header { + for _, v := range vs { + w.Header().Add(k, v) + } + } + w.WriteHeader(resp.StatusCode) + _, _ = io.Copy(w, resp.Body) + })) + t.Cleanup(mockEgressProxy.Close) + + bCfg := bedrockCfg(mockEgressProxy.URL) + bCfg.AccessKey = accessKey + bCfg.AccessKeySecret = secretKey + bCfg.Region = region + + bridgeServer := newBridgeTestServer(ctx, t, mockEgressProxy.URL, + withCustomProvider(provider.NewAnthropic(anthropicCfg(mockEgressProxy.URL, apiKey), bCfg)), + ) + + // Sends a bridge request through a mock egress proxy that + // mutates X-Forwarded-For, then verifies the SigV4 signature + // still matches at the mock Bedrock endpoint. + t.Run("bridge SigV4 signature valid", func(t *testing.T) { + reqBody, err := sjson.SetBytes(fix.Request(), "stream", false) + require.NoError(t, err) + resp, err := bridgeServer.makeRequest(t, http.MethodPost, pathAnthropicMessages, reqBody, proxyHeaders) + require.NoError(t, err) + defer resp.Body.Close() + _, _ = io.ReadAll(resp.Body) + + assert.True(t, signatureValid.Load(), + "SigV4 signature mismatch: a header modified in transit "+ + "was included in the signed-headers set") + }) + + // Passthrough routes use httputil.ReverseProxy, which forwards + // the request as-is without SigV4 signing, so proxy headers + // are safe to include. ReverseProxy sets its own X-Forwarded-* + // headers via SetXForwarded. This verifies they arrive upstream. + t.Run("passthrough proxy sets own forwarded headers", func(t *testing.T) { + resp, err := bridgeServer.makeRequest(t, http.MethodGet, "/anthropic/v1/models", nil, proxyHeaders) + require.NoError(t, err) + defer resp.Body.Close() + _, _ = io.ReadAll(resp.Body) + + received := mockBedrock.receivedRequests() + require.NotEmpty(t, received) + last := received[len(received)-1] + + assert.NotEmpty(t, last.Header.Get("X-Forwarded-For"), + "passthrough should set X-Forwarded-For via SetXForwarded") + assert.NotEmpty(t, last.Header.Get("X-Forwarded-Host"), + "passthrough should set X-Forwarded-Host via SetXForwarded") + assert.NotEmpty(t, last.Header.Get("X-Forwarded-Proto"), + "passthrough should set X-Forwarded-Proto via SetXForwarded") + }) + }) } func TestOpenAIChatCompletions(t *testing.T) { @@ -2132,3 +2299,17 @@ func TestActorHeaders(t *testing.T) { } } } + +// extractSigV4Field extracts a named field from an AWS SigV4 +// Authorization header value. +func extractSigV4Field(authHeader, prefix string) string { + idx := strings.Index(authHeader, prefix) + if idx == -1 { + return "" + } + val := authHeader[idx+len(prefix):] + if end := strings.IndexByte(val, ','); end != -1 { + val = val[:end] + } + return strings.TrimSpace(val) +} diff --git a/aibridge/internal/testutil/mockprovider.go b/aibridge/internal/testutil/mockprovider.go index 0fd85d2863637..e5015cd870efb 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/keypool/failover.go b/aibridge/keypool/failover.go index 38dcd3b972e94..ae6bcb9761188 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 9b00bb400ac49..c7cd735f905aa 100644 --- a/aibridge/keypool/keymark.go +++ b/aibridge/keypool/keymark.go @@ -5,13 +5,12 @@ import ( "net/http" "cdr.dev/slog/v3" - "github.com/coder/coder/v2/aibridge/utils" ) -// 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, @@ -24,27 +23,26 @@ 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", utils.MaskSecret(key.Value())), + slog.F("api_key_hint", key.Hint()), slog.F("status", statusCode), 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", utils.MaskSecret(key.Value())), - 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 228e576aa0d2c..15ae2d7f37f29 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 55d1712a935c8..9bb14509d58a9 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" ) @@ -30,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 @@ -43,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: @@ -69,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 @@ -116,6 +138,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 { @@ -132,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() @@ -175,6 +208,7 @@ func (k *Key) MarkTemporary(cooldown time.Duration) bool { } k.cooldownUntil = newDeadline + k.reason = reason return !inCooldown } @@ -193,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 2029dafd688c2..c8c3aa8d775fd 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.go b/aibridge/passthrough.go index 0dc6beb480ef0..c84802bc52a7e 100644 --- a/aibridge/passthrough.go +++ b/aibridge/passthrough.go @@ -2,6 +2,7 @@ package aibridge import ( "context" + "errors" "net/http" "net/http/httputil" "net/url" @@ -54,8 +55,12 @@ func newPassthroughRouter(prov provider.Provider, logger slog.Logger, m *metrics prov.KeyFailoverConfig(logger), ), ErrorHandler: func(rw http.ResponseWriter, req *http.Request, e error) { - logger.Warn(req.Context(), "reverse proxy error", slog.Error(e), slog.F("path", req.URL.Path)) - http.Error(rw, "upstream proxy error", http.StatusBadGateway) + if _, ok := errors.AsType[*http.MaxBytesError](e); ok { + writeRequestBodyTooLarge(rw) + } else { + logger.Warn(req.Context(), "reverse proxy error", slog.Error(e), slog.F("path", req.URL.Path)) + http.Error(rw, "upstream proxy error", http.StatusBadGateway) + } }, } diff --git a/aibridge/passthrough_internal_test.go b/aibridge/passthrough_internal_test.go index 0cfeb00f638bd..25fa8e5ae9f74 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/aibridge/provider/anthropic.go b/aibridge/provider/anthropic.go index eb50a3b2965e7..d053cce90326d 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()) } @@ -168,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 b3d89556a89ff..815a83ba031d8 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/copilot.go b/aibridge/provider/copilot.go index 1186e8b253f6d..7859342e81e68 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 } @@ -95,6 +97,8 @@ func (*Copilot) BridgedRoutes() []string { func (*Copilot) PassthroughRoutes() []string { return []string{ + "/_ping", + "/auto", "/models", "/models/", "/agents/", diff --git a/aibridge/provider/disabled.go b/aibridge/provider/disabled.go new file mode 100644 index 0000000000000..95384b4952e3a --- /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 177ae03409e26..88020b7eb234a 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 @@ -141,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 e1afcc872c4e6..1922d22c30dfe 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/provider/provider.go b/aibridge/provider/provider.go index 7520333b53b61..6f21d7290de16 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/aibridge/recorder/types.go b/aibridge/recorder/types.go index cd541eebd4b7e..faa571390099c 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/aibridge/routing/path.go b/aibridge/routing/path.go new file mode 100644 index 0000000000000..bdd3a82baf415 --- /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 0000000000000..7268babb37f68 --- /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/archive/archive.go b/archive/archive.go index db78b8c700010..54b6f31b24bf4 100644 --- a/archive/archive.go +++ b/archive/archive.go @@ -6,43 +6,153 @@ import ( "bytes" "errors" "io" - "log" + "math" "strings" + + "golang.org/x/xerrors" +) + +// Ref: +// https://github.com/golang/go/blob/go1.24.0/src/archive/tar/format.go +// https://github.com/golang/go/blob/go1.24.0/src/archive/tar/writer.go +const ( + tarBlockSize = 512 + tarEndBlockBytes = 2 * tarBlockSize ) +// ErrArchiveTooLarge reports that archive expansion would exceed the +// configured limit. +var ErrArchiveTooLarge = xerrors.New("archive exceeds maximum size") + +// ErrInvalidZipContent reports that a ZIP entry is malformed or its +// contents fail validation during conversion. +var ErrInvalidZipContent = xerrors.New("invalid zip content") + // CreateTarFromZip converts the given zipReader to a tar archive. +// maxSize limits the total tar output, including tar metadata. func CreateTarFromZip(zipReader *zip.Reader, maxSize int64) ([]byte, error) { + err := validateZipArchiveSize(zipReader, maxSize) + if err != nil { + return nil, err + } + var tarBuffer bytes.Buffer - err := writeTarArchive(&tarBuffer, zipReader, maxSize) + err = writeTarArchive(&tarBuffer, zipReader, maxSize) if err != nil { return nil, err } return tarBuffer.Bytes(), nil } -func writeTarArchive(w io.Writer, zipReader *zip.Reader, maxSize int64) error { - tarWriter := tar.NewWriter(w) - defer tarWriter.Close() +// validateZipArchiveSize performs a metadata-based preflight size +// check before conversion. The actual tar output limit will still be +// enforced while streaming. +func validateZipArchiveSize(zipReader *zip.Reader, maxSize int64) error { + if maxSize < 0 { + return ErrArchiveTooLarge + } + + maxBytes := uint64(maxSize) + totalBytes := uint64(tarEndBlockBytes) + if totalBytes > maxBytes { + return ErrArchiveTooLarge + } for _, file := range zipReader.File { - err := processFileInZipArchive(file, tarWriter, maxSize) + entrySize, err := projectedTarEntrySize(file) if err != nil { return err } + if entrySize > maxBytes-totalBytes { + return ErrArchiveTooLarge + } + totalBytes += entrySize } + return nil } -func processFileInZipArchive(file *zip.File, tarWriter *tar.Writer, maxSize int64) error { +func projectedTarEntrySize(file *zip.File) (uint64, error) { + // Each tar entry contributes one header block plus its data + // rounded up to the next tar block boundary. + size := file.UncompressedSize64 + if remainder := size % tarBlockSize; remainder != 0 { + padding := tarBlockSize - remainder + if size > math.MaxUint64-padding { + return 0, ErrArchiveTooLarge + } + size += padding + } + + if size > math.MaxUint64-tarBlockSize { + return 0, ErrArchiveTooLarge + } + + return tarBlockSize + size, nil +} + +type limitedWriter struct { + w io.Writer + remaining int64 +} + +func (w *limitedWriter) Write(p []byte) (int, error) { + if len(p) == 0 { + return 0, nil + } + if w.remaining <= 0 { + return 0, ErrArchiveTooLarge + } + + origLen := len(p) + if int64(origLen) > w.remaining { + p = p[:int(w.remaining)] + } + + n, err := w.w.Write(p) + // io.Writer may report both written bytes and an error, so + // account for any accepted bytes before returning the error. + w.remaining -= int64(n) + if err != nil { + return n, err + } + if n < origLen { + return n, ErrArchiveTooLarge + } + return n, nil +} + +func writeTarArchive(w io.Writer, zipReader *zip.Reader, maxSize int64) error { + tarWriter := tar.NewWriter(&limitedWriter{ + w: w, + remaining: maxSize, + }) + + for _, file := range zipReader.File { + err := processFileInZipArchive(file, tarWriter) + if err != nil { + return err + } + } + + return tarWriter.Close() +} + +func processFileInZipArchive(file *zip.File, tarWriter *tar.Writer) error { fileReader, err := file.Open() if err != nil { return err } defer fileReader.Close() + size := file.FileInfo().Size() + if size < 0 { + return ErrArchiveTooLarge + } + err = tarWriter.WriteHeader(&tar.Header{ Name: file.Name, - Size: file.FileInfo().Size(), + Size: size, Mode: int64(file.Mode()), ModTime: file.Modified, // Note: Zip archives do not store ownership information. @@ -53,12 +163,17 @@ func processFileInZipArchive(file *zip.File, tarWriter *tar.Writer, maxSize int6 return err } - n, err := io.CopyN(tarWriter, fileReader, maxSize) - log.Println(file.Name, n, err) - if errors.Is(err, io.EOF) { - err = nil + _, err = io.CopyN(tarWriter, fileReader, size) + switch { + case errors.Is(err, io.EOF), errors.Is(err, io.ErrUnexpectedEOF): + return ErrInvalidZipContent + case errors.Is(err, zip.ErrChecksum), errors.Is(err, zip.ErrFormat): + return ErrInvalidZipContent + case err != nil: + return err + default: + return nil } - return err } // CreateZipFromTar converts the given tarReader to a zip archive. diff --git a/archive/archive_test.go b/archive/archive_test.go index c10d103622fa7..79f3d894e3299 100644 --- a/archive/archive_test.go +++ b/archive/archive_test.go @@ -4,6 +4,7 @@ import ( "archive/tar" "archive/zip" "bytes" + "encoding/binary" "io/fs" "os" "os/exec" @@ -35,14 +36,15 @@ func TestCreateTarFromZip(t *testing.T) { zr, err := zip.NewReader(bytes.NewReader(zipBytes), int64(len(zipBytes))) require.NoError(t, err, "failed to parse sample zip file") - tarBytes, err := archive.CreateTarFromZip(zr, int64(len(zipBytes))) + wantTar := archivetest.TestTarFileBytes() + gotTar, err := archive.CreateTarFromZip(zr, int64(len(wantTar))) require.NoError(t, err, "failed to convert zip to tar") - archivetest.AssertSampleTarFile(t, tarBytes) + archivetest.AssertSampleTarFile(t, gotTar) tempDir := t.TempDir() tempFilePath := filepath.Join(tempDir, "test.tar") - err = os.WriteFile(tempFilePath, tarBytes, 0o600) + err = os.WriteFile(tempFilePath, gotTar, 0o600) require.NoError(t, err, "failed to write converted tar file") cmd := exec.CommandContext(ctx, "tar", "--extract", "--verbose", "--file", tempFilePath, "--directory", tempDir) @@ -50,6 +52,97 @@ func TestCreateTarFromZip(t *testing.T) { assertExtractedFiles(t, tempDir, true) } +func buildTestZip(t *testing.T, files map[string]string) []byte { + t.Helper() + + var zipBytes bytes.Buffer + zw := zip.NewWriter(&zipBytes) + for name, contents := range files { + w, err := zw.Create(name) + require.NoError(t, err) + + _, err = w.Write([]byte(contents)) + require.NoError(t, err) + } + require.NoError(t, zw.Close()) + + return zipBytes.Bytes() +} + +func TestCreateTarFromZip_RejectsOversizedAggregateExpansion(t *testing.T) { + t.Parallel() + + zipBytes := buildTestZip(t, map[string]string{ + "a.txt": strings.Repeat("a", 600), + "b.txt": strings.Repeat("b", 600), + "c.txt": strings.Repeat("c", 600), + }) + + zr, err := zip.NewReader(bytes.NewReader(zipBytes), int64(len(zipBytes))) + require.NoError(t, err) + + tarBytes, err := archive.CreateTarFromZip(zr, 1024) + require.Error(t, err) + require.Nil(t, tarBytes) +} + +func TestCreateTarFromZip_RejectsInvalidZipMetadata(t *testing.T) { + t.Parallel() + + // Ref: https://github.com/golang/go/blob/go1.24.0/src/archive/zip/struct.go + corruptZipUncompressedSize := func(t *testing.T, zipBytes []byte, size uint32) []byte { + t.Helper() + + const ( + directoryHeaderSignature = "PK\x01\x02" + uncompressedSizeOffset = 24 + ) + hdrOffset := bytes.Index(zipBytes, []byte(directoryHeaderSignature)) + require.NotEqual(t, -1, hdrOffset, "missing ZIP central directory header") + corrupted := bytes.Clone(zipBytes) + sizeBytes := corrupted[hdrOffset+uncompressedSizeOffset : hdrOffset+uncompressedSizeOffset+4] + binary.LittleEndian.PutUint32(sizeBytes, size) + + return corrupted + } + + zipBytes := buildTestZip(t, map[string]string{ + "hello.txt": "hello", + }) + zipBytes = corruptZipUncompressedSize(t, zipBytes, 6) + + zr, err := zip.NewReader(bytes.NewReader(zipBytes), int64(len(zipBytes))) + require.NoError(t, err) + + // Keep the size limit large so this test exercises the invalid + // ZIP metadata path rather than the tar output limit. + maxSize := int64(4096) + tarBytes, err := archive.CreateTarFromZip(zr, maxSize) + require.ErrorIs(t, err, archive.ErrInvalidZipContent) + require.Nil(t, tarBytes) +} + +func TestCreateTarFromZip_RejectsOversizedTarOverhead(t *testing.T) { + t.Parallel() + + // Empty files keep the ZIP payload tiny while still forcing tar + // headers and end-of-archive blocks to consume output budget. + zipBytes := buildTestZip(t, map[string]string{ + "empty-a.txt": "", + "empty-b.txt": "", + }) + + zr, err := zip.NewReader(bytes.NewReader(zipBytes), int64(len(zipBytes))) + require.NoError(t, err) + + // Two empty tar entries still need 2 header blocks plus the 2 + // end-of-archive blocks, so the output is 2048 bytes and must + // exceed this limit. + tarBytes, err := archive.CreateTarFromZip(zr, 2047) + require.Error(t, err) + require.Nil(t, tarBytes) +} + func TestCreateZipFromTar(t *testing.T) { t.Parallel() if runtime.GOOS != "linux" { diff --git a/cli/aibridged.go b/cli/aibridged.go index da28a74dee0df..a890488a1049e 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" @@ -37,6 +38,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 +52,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 +81,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 +98,23 @@ 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. +// 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, error) { +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 +126,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 +138,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 +158,25 @@ 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 { + 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,23 +185,36 @@ func BuildProviders(ctx context.Context, db database.Store, cfg codersdk.AIBridg ) continue } - out = append(out, prov) + if row.Enabled { + outcome.Status = aibridged.ProviderStatusEnabled + } else { + outcome.Status = aibridged.ProviderStatusDisabled + } + outcomes = append(outcomes, outcome) + providers = append(providers, prov) } - if len(rows) > 0 && len(out) == 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 out, nil + return providers, outcomes, nil } // 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) @@ -264,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 1f9f51267864e..6b3e1eb7ac731 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,75 @@ 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() + + 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/configssh.go b/cli/configssh.go index cc723bd3282f7..b81b39041ed36 100644 --- a/cli/configssh.go +++ b/cli/configssh.go @@ -578,6 +578,10 @@ func mergeSSHOptions( ) ( sshConfigOptions, error, ) { + if err := coderd.Validate(); err != nil { + return sshConfigOptions{}, xerrors.Errorf("invalid ssh config from coderd: %w", err) + } + // Write agent configuration. defaultOptions := []string{ "ConnectTimeout=0", diff --git a/cli/configssh_internal_test.go b/cli/configssh_internal_test.go index 0ea2ae6ea5f22..cf7a5bff05f9c 100644 --- a/cli/configssh_internal_test.go +++ b/cli/configssh_internal_test.go @@ -10,6 +10,8 @@ import ( "testing" "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/codersdk" ) func Test_sshConfigSplitOnCoderSection(t *testing.T) { @@ -302,6 +304,140 @@ func Test_sshConfigExecEscapeSeparatorForce(t *testing.T) { } } +func Test_mergeSSHOptions_RejectsUnsafeServerConfig(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + coderd codersdk.SSHConfigResponse + wantErr string + }{ + { + name: "HostnameSuffix", + coderd: codersdk.SSHConfigResponse{ + HostnameSuffix: "coder\nHost *", + }, + wantErr: "workspace hostname suffix", + }, + { + name: "HostnamePrefix", + coderd: codersdk.SSHConfigResponse{ + HostnamePrefix: "coder.\nHost *", + }, + wantErr: "workspace hostname prefix", + }, + { + name: "ProxyCommand", + coderd: codersdk.SSHConfigResponse{ + SSHConfigOptions: map[string]string{"ProxyCommand": "ssh -W %h:%p bastion"}, + }, + wantErr: `ssh config option "ProxyCommand" is not allowed`, + }, + { + name: "PermitLocalCommand", + coderd: codersdk.SSHConfigResponse{ + SSHConfigOptions: map[string]string{"PermitLocalCommand": "yes"}, + }, + wantErr: `ssh config option "PermitLocalCommand" is not allowed`, + }, + { + name: "KnownHostsCommand", + coderd: codersdk.SSHConfigResponse{ + SSHConfigOptions: map[string]string{"KnownHostsCommand": "echo key"}, + }, + wantErr: `ssh config option "KnownHostsCommand" is not allowed`, + }, + { + name: "PKCS11Provider", + coderd: codersdk.SSHConfigResponse{ + SSHConfigOptions: map[string]string{"PKCS11Provider": "/tmp/evil.so"}, + }, + wantErr: `ssh config option "PKCS11Provider" is not allowed`, + }, + { + name: "NewlineInValue", + coderd: codersdk.SSHConfigResponse{ + SSHConfigOptions: map[string]string{"UserKnownHostsFile": "/tmp/known_hosts\nHost *"}, + }, + wantErr: `ssh config option "UserKnownHostsFile" must not contain carriage return, newline, or NUL characters`, + }, + { + name: "SmartcardDevice", + coderd: codersdk.SSHConfigResponse{ + SSHConfigOptions: map[string]string{"SmartcardDevice": "/path/to/lib"}, + }, + wantErr: `not allowed`, + }, + { + name: "XAuthLocation", + coderd: codersdk.SSHConfigResponse{ + SSHConfigOptions: map[string]string{"XAuthLocation": "/usr/bin/xauth"}, + }, + wantErr: `not allowed`, + }, + { + name: "ProxyJump", + coderd: codersdk.SSHConfigResponse{ + SSHConfigOptions: map[string]string{"ProxyJump": "bastion.example.com"}, + }, + wantErr: `conflicts with`, + }, + { + name: "HostnameSuffixGlob", + coderd: codersdk.SSHConfigResponse{ + HostnameSuffix: "*", + }, + wantErr: `glob`, + }, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + _, err := mergeSSHOptions(sshConfigOptions{}, tt.coderd, t.TempDir(), "/tmp/coder") + require.ErrorContains(t, err, tt.wantErr) + }) + } +} + +func Test_mergeSSHOptions_UserOptionsOverrideServerConfig(t *testing.T) { + t.Parallel() + + user := sshConfigOptions{ + userHostPrefix: "dev.", + hostnameSuffix: "local", + } + got, err := mergeSSHOptions(user, codersdk.SSHConfigResponse{ + HostnamePrefix: "coder.", + HostnameSuffix: "coder", + }, t.TempDir(), "/tmp/coder") + require.NoError(t, err) + require.Equal(t, "dev.", got.userHostPrefix) + require.Equal(t, "local", got.hostnameSuffix) +} + +func Test_mergeSSHOptions_AllowsSafeServerConfig(t *testing.T) { + t.Parallel() + + got, err := mergeSSHOptions(sshConfigOptions{}, codersdk.SSHConfigResponse{ + HostnamePrefix: "coder.", + HostnameSuffix: "coder", + SSHConfigOptions: map[string]string{ + "HostName": "example.com", + "User": "coder", + "Port": "22", + "SetEnv": "FOO=bar BAZ=qux", + "UserKnownHostsFile": "/tmp/coder_known_hosts", + }, + }, t.TempDir(), "/tmp/coder") + require.NoError(t, err) + require.Equal(t, "coder.", got.userHostPrefix) + require.Equal(t, "coder", got.hostnameSuffix) + require.Contains(t, got.sshOptions, "HostName example.com") + require.Contains(t, got.sshOptions, "SetEnv FOO=bar BAZ=qux") +} + func Test_sshConfigOptions_addOption(t *testing.T) { t.Parallel() testCases := []struct { diff --git a/cli/configssh_test.go b/cli/configssh_test.go index 7e42bfe81a799..e0831491dfd17 100644 --- a/cli/configssh_test.go +++ b/cli/configssh_test.go @@ -169,6 +169,63 @@ func TestConfigSSH(t *testing.T) { <-copyDone } +func TestConfigSSH_RejectsUnsafeServerConfig(t *testing.T) { + t.Parallel() + + if runtime.GOOS == "windows" { + t.Skip("See coder/internal#117") + } + + testCases := []struct { + name string + configSSH codersdk.SSHConfigResponse + wantErr string + }{ + { + name: "HostnameSuffix", + configSSH: codersdk.SSHConfigResponse{HostnameSuffix: "coder\nHost *"}, + wantErr: "workspace hostname suffix", + }, + { + name: "HostnamePrefix", + configSSH: codersdk.SSHConfigResponse{HostnamePrefix: "coder.\nHost *"}, + wantErr: "workspace hostname prefix", + }, + { + name: "HostnameSuffixGlob", + configSSH: codersdk.SSHConfigResponse{HostnameSuffix: "*"}, + wantErr: "glob", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + const existingConfig = "Host safe\n\tHostName safe.example.com\n" + client := coderdtest.New(t, &coderdtest.Options{ + ConfigSSH: tc.configSSH, + }) + _ = coderdtest.CreateFirstUser(t, client) + + sshConfigPath := sshConfigFileName(t) + sshConfigFileCreate(t, sshConfigPath, strings.NewReader(existingConfig)) + + inv, root := clitest.New(t, + "config-ssh", + "--ssh-config-file", sshConfigPath, + "--yes", + ) + clitest.SetupConfig(t, client, root) + + err := inv.Run() + require.Error(t, err) + require.ErrorContains(t, err, tc.wantErr) + require.Equal(t, existingConfig, sshConfigFileRead(t, sshConfigPath)) + }) + } +} + func TestConfigSSH_MissingDirectory(t *testing.T) { t.Parallel() diff --git a/cli/create.go b/cli/create.go index 09a1d2c9c4b95..325e2515c965c 100644 --- a/cli/create.go +++ b/cli/create.go @@ -271,6 +271,11 @@ func (r *RootCmd) Create(opts CreateOptions) *serpent.Command { return xerrors.Errorf("can't parse given parameter defaults: %w", err) } + cliEphemeralParameters, err := asWorkspaceBuildParameters(parameterFlags.ephemeralParameters) + if err != nil { + return xerrors.Errorf("can't parse given ephemeral parameter values: %w", err) + } + var sourceWorkspaceParameters []codersdk.WorkspaceBuildParameter if copyParametersFrom != "" { sourceWorkspaceParameters, err = client.WorkspaceBuildParameters(inv.Context(), sourceWorkspace.LatestBuild.ID) @@ -330,6 +335,9 @@ func (r *RootCmd) Create(opts CreateOptions) *serpent.Command { RichParameters: cliBuildParameters, RichParameterDefaults: cliBuildParameterDefaults, + PromptEphemeralParameters: parameterFlags.promptEphemeralParameters, + EphemeralParameters: cliEphemeralParameters, + SourceWorkspaceParameters: sourceWorkspaceParameters, UseParameterDefaults: parameterFlags.useParameterDefaults, @@ -455,9 +463,8 @@ func (r *RootCmd) Create(opts CreateOptions) *serpent.Command { }, cliui.SkipPromptOption(), ) - cmd.Options = append(cmd.Options, parameterFlags.cliParameters()...) - cmd.Options = append(cmd.Options, parameterFlags.cliParameterDefaults()...) - cmd.Options = append(cmd.Options, parameterFlags.useParameterDefaultsOption()) + cmd.Options = append(cmd.Options, parameterFlags.allOptions()...) + orgContext.AttachOptions(cmd) return cmd } diff --git a/cli/open.go b/cli/open.go index fceda71394c13..5bee8d45c658b 100644 --- a/cli/open.go +++ b/cli/open.go @@ -39,6 +39,11 @@ func (r *RootCmd) open() *serpent.Command { const vscodeDesktopName = "VS Code Desktop" +// externalSessionTokenPlaceholder is the literal substring in an external +// workspace-app URL that the CLI replaces with the user's session token +// when the app belongs to a trusted (top-level) agent. +const externalSessionTokenPlaceholder = "$SESSION_TOKEN" + func (r *RootCmd) openVSCode() *serpent.Command { var ( generateToken bool @@ -387,8 +392,13 @@ func (r *RootCmd) openApp() *serpent.Command { pathAppURL := strings.TrimPrefix(region.PathAppURL, baseURL.String()) appURL := buildAppLinkURL(baseURL, ws, agt, foundApp, region.WildcardHostname, pathAppURL) - if foundApp.External { - appURL = replacePlaceholderExternalSessionTokenString(client, appURL) + externalSubAgentApp := foundApp.External && agt.ParentID.Valid + if foundApp.External && !agt.ParentID.Valid { + // Template-defined apps run on a top-level agent and are + // admin-authored, so their URLs are trusted. Substitute the + // session token placeholder so the OS open handler receives + // a usable URL. + appURL = strings.ReplaceAll(appURL, externalSessionTokenPlaceholder, client.SessionToken()) } // Check if we're inside a workspace. Generally, we know @@ -399,6 +409,18 @@ func (r *RootCmd) openApp() *serpent.Command { _, _ = fmt.Fprintf(inv.Stdout, "%s\n", appURL) return nil } + + // Sub-agent external app URLs are set at runtime. Only open + // sub-agent URLs that don't contain the placeholder to prevent + // token exfiltration. + if externalSubAgentApp && strings.Contains(appURL, externalSessionTokenPlaceholder) { + cliui.Warnf(inv.Stderr, + "This app was registered from inside the workspace rather than from the workspace template. "+ + "Inspect the URL below carefully and, if you trust the source, substitute the $SESSION_TOKEN placeholder "+ + "with your session token and manually open it:") + _, _ = fmt.Fprintf(inv.Stdout, "%s\n", appURL) + return nil + } _, _ = fmt.Fprintf(inv.Stderr, "Opening %s\n", appURL) if !testOpenError { @@ -664,15 +686,3 @@ func buildAppLinkURL(baseURL *url.URL, workspace codersdk.Workspace, agent coder } return u.String() } - -// replacePlaceholderExternalSessionTokenString replaces any $SESSION_TOKEN -// strings in the URL with the actual session token. -// This is consistent behavior with the frontend. See: site/src/modules/resources/AppLink/AppLink.tsx -func replacePlaceholderExternalSessionTokenString(client *codersdk.Client, appURL string) string { - if !strings.Contains(appURL, "$SESSION_TOKEN") { - return appURL - } - - // We will just re-use the existing session token we're already using. - return strings.ReplaceAll(appURL, "$SESSION_TOKEN", client.SessionToken()) -} diff --git a/cli/open_test.go b/cli/open_test.go index 564fbe657ab8e..e0ff97a63176b 100644 --- a/cli/open_test.go +++ b/cli/open_test.go @@ -1,7 +1,9 @@ package cli_test import ( + "bytes" "context" + "database/sql" "net/url" "os" "path" @@ -21,6 +23,9 @@ import ( "github.com/coder/coder/v2/agent/agenttest" "github.com/coder/coder/v2/cli/clitest" "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbfake" + "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/provisionersdk/proto" @@ -719,14 +724,16 @@ func TestOpenApp(t *testing.T) { w.RequireContains("region not found") }) - t.Run("ExternalAppSessionToken", func(t *testing.T) { + t.Run("ExternalAppOnTopLevelAgentSubstitutes", func(t *testing.T) { t.Parallel() + // Apps on the top-level (template-defined) agent are trusted, so the + // CLI substitutes $SESSION_TOKEN regardless of scheme. client, ws, _ := setupWorkspaceForAgent(t, func(agents []*proto.Agent) []*proto.Agent { agents[0].Apps = []*proto.App{ { Slug: "app1", - Url: "https://example.com/app1?token=$SESSION_TOKEN", + Url: "vscode://coder.coder-remote/open?token=$SESSION_TOKEN", External: true, }, } @@ -743,4 +750,92 @@ func TestOpenApp(t *testing.T) { w.RequireContains("test.open-error") w.RequireContains(client.SessionToken()) }) + + t.Run("ExternalAppOnSubAgentWithPlaceholderPrintsURLAndDoesNotOpen", func(t *testing.T) { + t.Parallel() + + // Sub-agent app URLs are attacker-influenceable through workspace + // configuration and runtime registration. The CLI must not + // substitute the session token, and must not hand the URL to the + // OS open handler. The URL is printed to stdout so a user who + // trusts the source can substitute and open it manually. + ownerClient, store := coderdtest.NewWithDatabase(t, nil) + ownerClient.SetLogger(testutil.Logger(t).Named("client")) + first := coderdtest.CreateFirstUser(t, ownerClient) + userClient, user := coderdtest.CreateAnotherUserMutators(t, ownerClient, first.OrganizationID, nil, func(r *codersdk.CreateUserRequestWithOrgs) { + r.Username = "subagentowner" + }) + r := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{ + Name: "subagentws", + OrganizationID: first.OrganizationID, + OwnerID: user.ID, + }).WithAgent().Do() + + require.NotEmpty(t, r.Agents, "expected at least one workspace agent") + mainAgent := r.Agents[0] + + subAgent := dbgen.WorkspaceSubAgent(t, store, mainAgent, database.WorkspaceAgent{ + Name: "devcontainer", + }) + _ = dbgen.WorkspaceApp(t, store, database.WorkspaceApp{ + AgentID: subAgent.ID, + Slug: "subapp", + External: true, + Url: sql.NullString{Valid: true, String: "vscode://coder.coder-remote/open?token=$SESSION_TOKEN"}, + }) + + inv, root := clitest.New(t, "open", "app", r.Workspace.Name+".devcontainer", "subapp", "--test.open-error") + clitest.SetupConfig(t, userClient, root) + var stdout, stderr bytes.Buffer + inv.Stdout = &stdout + inv.Stderr = &stderr + + w := clitest.StartWithWaiter(t, inv) + w.RequireSuccess() + require.NotContains(t, stderr.String(), "test.open-error") + require.NotContains(t, stdout.String(), "test.open-error") + require.Contains(t, stdout.String(), "vscode://coder.coder-remote/open?token=$SESSION_TOKEN") + require.NotContains(t, stdout.String(), userClient.SessionToken()) + require.Contains(t, stderr.String(), "substitute") + }) + + t.Run("ExternalAppOnSubAgentWithoutPlaceholderOpensAsIs", func(t *testing.T) { + t.Parallel() + + // Sub-agent app URLs that don't reference $SESSION_TOKEN carry no + // token to leak. The CLI auto-opens them like any other external + // app; only placeholder-bearing URLs are gated. + ownerClient, store := coderdtest.NewWithDatabase(t, nil) + ownerClient.SetLogger(testutil.Logger(t).Named("client")) + first := coderdtest.CreateFirstUser(t, ownerClient) + userClient, user := coderdtest.CreateAnotherUserMutators(t, ownerClient, first.OrganizationID, nil, func(r *codersdk.CreateUserRequestWithOrgs) { + r.Username = "subagentowner2" + }) + r := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{ + Name: "subagentws2", + OrganizationID: first.OrganizationID, + OwnerID: user.ID, + }).WithAgent().Do() + + require.NotEmpty(t, r.Agents, "expected at least one workspace agent") + mainAgent := r.Agents[0] + + subAgent := dbgen.WorkspaceSubAgent(t, store, mainAgent, database.WorkspaceAgent{ + Name: "devcontainer", + }) + _ = dbgen.WorkspaceApp(t, store, database.WorkspaceApp{ + AgentID: subAgent.ID, + Slug: "subapp", + External: true, + Url: sql.NullString{Valid: true, String: "https://example.com/some/path"}, + }) + + inv, root := clitest.New(t, "open", "app", r.Workspace.Name+".devcontainer", "subapp", "--test.open-error") + clitest.SetupConfig(t, userClient, root) + + w := clitest.StartWithWaiter(t, inv) + w.RequireError() + w.RequireContains("test.open-error") + w.RequireContains("https://example.com/some/path") + }) } diff --git a/cli/server.go b/cli/server.go index 0a15645e50538..a5510c9f2ea6c 100644 --- a/cli/server.go +++ b/cli/server.go @@ -63,6 +63,7 @@ import ( "github.com/coder/coder/v2/cli/config" "github.com/coder/coder/v2/coderd" "github.com/coder/coder/v2/coderd/aibridged" + "github.com/coder/coder/v2/coderd/authlink" "github.com/coder/coder/v2/coderd/autobuild" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/awsiamrds" @@ -114,6 +115,58 @@ import ( "github.com/coder/wgtunnel/tunnelsdk" ) +// oidcAuthLinks validates and can repair any broken OIDC auth links from changes in +// OIDC providers. This function should avoid returning a fatal error as much as possible. +// If this function fails, it should just log the error and exit. +func oidcAuthLinks(ctx context.Context, logger slog.Logger, cli *http.Client, vals *codersdk.DeploymentValues, db database.Store) error { + // nolint:gocritic // Requires system privileges + ctx = dbauthz.AsSystemRestricted(ctx) + expectedIssuer, err := authlink.ResolveIssuer(ctx, cli, vals.OIDC.IssuerURL.String()) + if err != nil { + // Always log if there is a failure here + logger.Error(ctx, "unable to resolve OIDC 'issuer'", + slog.F("error", err.Error()), + slog.F("url", vals.OIDC.IssuerURL.String()), + ) + return nil + } + + analysis, err := authlink.AnalyzeOIDCLinks(ctx, db, expectedIssuer) + if err != nil { + // Do not make this error fatal + logger.Error(ctx, "unable to analyze OIDC links, OIDC user links cannot be verified as linked to this issuer", + slog.F("error", err.Error()), + slog.F("url", vals.OIDC.IssuerURL.String()), + slog.F("issuer", expectedIssuer), + ) + return nil + } + + if !vals.OIDC.AutoRepairLinks.Value() { + return nil + } + + // Repair any broken OIDC links + if analysis.MismatchedTotal() > 0 { + count, err := authlink.ResetMismatchedOIDCLinks(ctx, db, expectedIssuer) + if err != nil { + logger.Error(ctx, "unable to reset mismatched OIDC links", + slog.F("error", err.Error()), + slog.F("url", vals.OIDC.IssuerURL.String()), + slog.F("issuer", expectedIssuer), + ) + return nil + } + + logger.Info(ctx, "oidc users OIDC links reset", + slog.F("url", vals.OIDC.IssuerURL.String()), + slog.F("issuer", expectedIssuer), + slog.F("count", count), + ) + } + return nil +} + func createOIDCConfig(ctx context.Context, logger slog.Logger, vals *codersdk.DeploymentValues) (*coderd.OIDCConfig, error) { if vals.OIDC.ClientID == "" { return nil, xerrors.Errorf("OIDC client ID must be set!") @@ -225,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 } @@ -428,6 +482,19 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. logger.Debug(ctx, "tracing closed", slog.Error(traceCloseErr)) }() + configSSHOptions, err := vals.SSHConfig.ParseOptions() + if err != nil { + return xerrors.Errorf("parse ssh config options %q: %w", vals.SSHConfig.SSHConfigOptions.String(), err) + } + sshConfigResponse := codersdk.SSHConfigResponse{ + HostnamePrefix: vals.SSHConfig.DeploymentName.String(), + HostnameSuffix: vals.WorkspaceHostnameSuffix.String(), + SSHConfigOptions: configSSHOptions, + } + if err := sshConfigResponse.Validate(); err != nil { + return xerrors.Errorf("invalid ssh config: %w", err) + } + httpServers, err := ConfigureHTTPServers(logger, inv, vals) if err != nil { return xerrors.Errorf("configure http(s): %w", err) @@ -638,20 +705,6 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. return xerrors.Errorf("parse real ip config: %w", err) } - configSSHOptions, err := vals.SSHConfig.ParseOptions() - if err != nil { - return xerrors.Errorf("parse ssh config options %q: %w", vals.SSHConfig.SSHConfigOptions.String(), err) - } - - // The workspace hostname suffix is always interpreted as implicitly beginning with a single dot, so it is - // a config error to explicitly include the dot. This ensures that we always interpret the suffix as a - // separate DNS label, and not just an ordinary string suffix. E.g. a suffix of 'coder' will match - // 'en.coder' but not 'encoder'. - if strings.HasPrefix(vals.WorkspaceHostnameSuffix.String(), ".") { - return xerrors.Errorf("you must omit any leading . in workspace hostname suffix: %s", - vals.WorkspaceHostnameSuffix.String()) - } - options := &coderd.Options{ AccessURL: vals.AccessURL.Value(), AppHostname: appHostname, @@ -681,14 +734,10 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. HTTPClient: httpClient, TemplateScheduleStore: &atomic.Pointer[schedule.TemplateScheduleStore]{}, UserQuietHoursScheduleStore: &atomic.Pointer[schedule.UserQuietHoursScheduleStore]{}, - SSHConfig: codersdk.SSHConfigResponse{ - HostnamePrefix: vals.SSHConfig.DeploymentName.String(), - SSHConfigOptions: configSSHOptions, - HostnameSuffix: vals.WorkspaceHostnameSuffix.String(), - }, - AllowWorkspaceRenames: vals.AllowWorkspaceRenames.Value(), - Entitlements: entitlements.New(), - NotificationsEnqueuer: notifications.NewNoopEnqueuer(), // Changed further down if notifications enabled. + SSHConfig: sshConfigResponse, + AllowWorkspaceRenames: vals.AllowWorkspaceRenames.Value(), + Entitlements: entitlements.New(), + NotificationsEnqueuer: notifications.NewNoopEnqueuer(), // Changed further down if notifications enabled. } if httpServers.TLSConfig != nil { options.TLSCertificates = httpServers.TLSConfig.Certificates @@ -722,29 +771,6 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. } } - // As OIDC clients can be confidential or public, - // we should only check for a client id being set. - // The underlying library handles the case of no - // client secrets correctly. For more details on - // client types: https://oauth.net/2/client-types/ - if vals.OIDC.ClientID != "" { - if vals.OIDC.IgnoreEmailVerified { - logger.Warn(ctx, "coder will not check email_verified for OIDC logins") - } - - // This OIDC config is **not** being instrumented with the - // oauth2 instrument wrapper. If we implement the missing - // oidc methods, then we can instrument it. - // Missing: - // - Userinfo - // - Verify - oc, err := createOIDCConfig(ctx, options.Logger, vals) - if err != nil { - return xerrors.Errorf("create oidc config: %w", err) - } - options.OIDCConfig = oc - } - // We'll read from this channel in the select below that tracks shutdown. If it remains // nil, that case of the select will just never fire, but it's important not to have a // "bare" read on this channel. @@ -822,6 +848,35 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. return xerrors.Errorf("set deployment id: %w", err) } + // As OIDC clients can be confidential or public, + // we should only check for a client id being set. + // The underlying library handles the case of no + // client secrets correctly. For more details on + // client types: https://oauth.net/2/client-types/ + if vals.OIDC.ClientID != "" { + if vals.OIDC.IgnoreEmailVerified { + logger.Warn(ctx, "coder will not check email_verified for OIDC logins") + } + + // This OIDC config is **not** being instrumented with the + // oauth2 instrument wrapper. If we implement the missing + // oidc methods, then we can instrument it. + // Missing: + // - Userinfo + // - Verify + oc, err := createOIDCConfig(ctx, options.Logger, vals) + if err != nil { + return xerrors.Errorf("create oidc config: %w", err) + } + options.OIDCConfig = oc + + // Repair any existing broken OIDC + err = oidcAuthLinks(ctx, logger, httpClient, vals, options.Database) + if err != nil { + return xerrors.Errorf("oidc auth links: %w", err) + } + } + extAuthEnv, err := ReadExternalAuthProvidersFromEnv(os.Environ()) if err != nil { return xerrors.Errorf("read external auth providers from env: %w", err) @@ -1032,6 +1087,11 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. ); err != nil { return xerrors.Errorf("seed ai providers from env: %w", err) } + // Must run after newAPI so options.Database is dbcrypt-wrapped. + coderd.BackfillBedrockProviderType(aibridgeInitCtx, options.Database, logger.Named("aibridge.backfill")) + // Must run after BackfillBedrockProviderType; shares aibridgeInitCtx so + // a timeout on the first backfill will skip this one until next startup. + coderd.BackfillChatModelConfigProviderStrings(aibridgeInitCtx, options.Database, logger.Named("aibridge.backfill")) // In-memory aibridge daemon. Registered on coderd so chatd can // dispatch LLM requests via the in-process transport without @@ -1041,7 +1101,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) } @@ -1404,6 +1464,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. createAdminUserCmd := r.newCreateAdminUserCommand() regenerateVapidKeypairCmd := r.newRegenerateVapidKeypairCommand() + fixOIDCLinksCmd := r.newFixOIDCLinksCommand() rawURLOpt := serpent.Option{ Flag: "raw-url", @@ -1417,7 +1478,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. serverCmd.Children = append( serverCmd.Children, - createAdminUserCmd, postgresBuiltinURLCmd, postgresBuiltinServeCmd, regenerateVapidKeypairCmd, + createAdminUserCmd, postgresBuiltinURLCmd, postgresBuiltinServeCmd, regenerateVapidKeypairCmd, fixOIDCLinksCmd, ) return serverCmd @@ -2979,6 +3040,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 +3052,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 +3149,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 6b712a6352988..fce45aa67406b 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() @@ -588,6 +641,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 +651,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 +661,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 +671,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 +681,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 +691,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 +701,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 +711,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 +721,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 +756,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/", @@ -711,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/server_fix_oidc_links.go b/cli/server_fix_oidc_links.go new file mode 100644 index 0000000000000..0baeca881f067 --- /dev/null +++ b/cli/server_fix_oidc_links.go @@ -0,0 +1,156 @@ +//go:build !slim + +package cli + +import ( + "fmt" + "net/http" + "strings" + + "golang.org/x/xerrors" + + "cdr.dev/slog/v3" + "cdr.dev/slog/v3/sloggers/sloghuman" + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/coderd/authlink" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/awsiamrds" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/serpent" +) + +func (r *RootCmd) newFixOIDCLinksCommand() *serpent.Command { + var ( + pgURL string + pgAuth string + issuerURL string + dryRun bool + ) + fixOIDCLinksCmd := &serpent.Command{ + Use: "fix-oidc-links", + Short: "Reset OIDC linked IDs that do not match the expected issuer, allowing users to re-authenticate.", + Handler: func(inv *serpent.Invocation) error { + var ( + ctx, cancel = inv.SignalNotifyContext(inv.Context(), StopSignals...) + logger = inv.Logger.AppendSinks(sloghuman.Sink(inv.Stderr)) + ) + if r.verbose { + logger = logger.Leveled(slog.LevelDebug) + } + defer cancel() + + issuerURL = strings.TrimSpace(issuerURL) + if issuerURL == "" { + return xerrors.Errorf("the --%s flag is required, set it to the OIDC issuer URL (e.g. https://accounts.google.com)", "issuer-url") + } + // Resolve the canonical issuer from OIDC discovery. + cliui.Infof(inv.Stdout, "Resolving OIDC issuer from %q...", issuerURL) + // TODO: The default client might not be configured with the right certs to make this request. + issuer, err := authlink.ResolveIssuer(ctx, http.DefaultClient, issuerURL) + if err != nil { + return xerrors.Errorf("resolve issuer: %w", err) + } + _, _ = fmt.Fprintf(inv.Stdout, "Resolved OIDC issuer: %q\n\n", issuer) + + // Connect to the database. + if pgURL == "" { + return xerrors.New("the --postgres-url flag is required") + } + + sqlDriver := "postgres" + if codersdk.PostgresAuth(pgAuth) == codersdk.PostgresAuthAWSIAMRDS { + sqlDriver, err = awsiamrds.Register(inv.Context(), sqlDriver) + if err != nil { + return xerrors.Errorf("register aws rds iam auth: %w", err) + } + } + + sqlDB, err := ConnectToPostgres(ctx, logger, sqlDriver, pgURL, nil) + if err != nil { + return xerrors.Errorf("connect to postgres: %w", err) + } + defer func() { + _ = sqlDB.Close() + }() + db := database.New(sqlDB) + + // Run analysis. + analysis, err := authlink.AnalyzeOIDCLinks(ctx, db, issuer) + if err != nil { + return xerrors.Errorf("analyze OIDC links: %w", err) + } + authlink.PrintAnalysis(inv.Stdout, analysis, issuer) + _, _ = fmt.Fprintln(inv.Stdout) + + if dryRun { + return nil + } + + mismatchedTotal := analysis.MismatchedTotal() + if mismatchedTotal == 0 { + _, _ = fmt.Fprintln(inv.Stdout, "Nothing to do. All OIDC links match the expected issuer.") + return nil + } + + // Molly guard. + _, _ = fmt.Fprintf(inv.Stdout, "This will reset %d linked IDs to allow affected users to re-authenticate.\n", mismatchedTotal) + if _, err := cliui.Prompt(inv, cliui.PromptOptions{ + Text: "Are you sure you want to continue?", + IsConfirm: true, + Default: cliui.ConfirmNo, + }); err != nil { + return err + } + _, _ = fmt.Fprintln(inv.Stdout) + + // Execute the reset. + count, err := authlink.ResetMismatchedOIDCLinks(ctx, db, issuer) + if err != nil { + return xerrors.Errorf("reset mismatched OIDC links: %w", err) + } + cliui.Infof(inv.Stdout, "Reset %d linked IDs.", count) + _, _ = fmt.Fprintln(inv.Stdout) + + // Print updated analysis. + analysis, err = authlink.AnalyzeOIDCLinks(ctx, db, issuer) + if err != nil { + return xerrors.Errorf("re-analyze OIDC links: %w", err) + } + authlink.PrintAnalysis(inv.Stdout, analysis, issuer) + return nil + }, + } + + fixOIDCLinksCmd.Options.Add( + cliui.SkipPromptOption(), + serpent.Option{ + Env: "CODER_PG_CONNECTION_URL", + Flag: "postgres-url", + Description: "URL of a PostgreSQL database. If empty, the built-in PostgreSQL deployment will be used (Coder must not be already running in this case).", + Value: serpent.StringOf(&pgURL), + }, + serpent.Option{ + Name: "Postgres Connection Auth", + Description: "Type of auth to use when connecting to postgres.", + Flag: "postgres-connection-auth", + Env: "CODER_PG_CONNECTION_AUTH", + Default: "password", + Value: serpent.EnumOf(&pgAuth, codersdk.PostgresAuthDrivers...), + }, + serpent.Option{ + Env: "CODER_OIDC_ISSUER_URL", + Flag: "issuer-url", + Description: "The OIDC issuer URL. The canonical issuer is resolved via OIDC discovery.", + Value: serpent.StringOf(&issuerURL), + }, + serpent.Option{ + Flag: "dry-run", + FlagShorthand: "n", + Env: "CODER_FIX_OIDC_LINKS_DRY_RUN", + Description: "Print analysis only, do not modify the database.", + Value: serpent.BoolOf(&dryRun), + }, + ) + + return fixOIDCLinksCmd +} diff --git a/cli/server_fix_oidc_links_internal_test.go b/cli/server_fix_oidc_links_internal_test.go new file mode 100644 index 0000000000000..71ce3389c1c84 --- /dev/null +++ b/cli/server_fix_oidc_links_internal_test.go @@ -0,0 +1,167 @@ +package cli + +import ( + "database/sql" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" + + "cdr.dev/slog/v3" + "cdr.dev/slog/v3/sloggers/slogtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/testutil" + "github.com/coder/serpent" +) + +func fakeOIDCIssuerDiscovery(t *testing.T, issuer string) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/.well-known/openid-configuration" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{ + "issuer": issuer, + }) + })) + t.Cleanup(srv.Close) + return srv +} + +func newDeploymentValues(t *testing.T, issuerURL string, autoRepair bool) *codersdk.DeploymentValues { + t.Helper() + vals := &codersdk.DeploymentValues{} + require.NoError(t, vals.OIDC.IssuerURL.Set(issuerURL)) + vals.OIDC.AutoRepairLinks = serpent.Bool(autoRepair) + return vals +} + +func TestOIDCAuthLinks(t *testing.T) { + t.Parallel() + + t.Run("RepairsMismatchedLinks", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + logger := testutil.Logger(t) + + const expectedIssuer = "https://accounts.google.com" + oidcSrv := fakeOIDCIssuerDiscovery(t, expectedIssuer) + + db, _ := dbtestutil.NewDB(t) + + // Correctly linked user. + correctUser := dbgen.User(t, db, database.User{LoginType: database.LoginTypeOIDC}) + dbgen.UserLink(t, db, database.UserLink{ + UserID: correctUser.ID, + LoginType: database.LoginTypeOIDC, + LinkedID: expectedIssuer + "||sub-correct", + }) + + // Mismatched user. + mismatchedUser := dbgen.User(t, db, database.User{LoginType: database.LoginTypeOIDC}) + dbgen.UserLink(t, db, database.UserLink{ + UserID: mismatchedUser.ID, + LoginType: database.LoginTypeOIDC, + LinkedID: "https://old-issuer.example.com||sub-mismatched", + }) + + vals := newDeploymentValues(t, oidcSrv.URL, true) + err := oidcAuthLinks(ctx, logger, oidcSrv.Client(), vals, db) + require.NoError(t, err) + + // Mismatched link should be reset. + link, err := db.GetUserLinkByUserIDLoginType(ctx, database.GetUserLinkByUserIDLoginTypeParams{ + UserID: mismatchedUser.ID, + LoginType: database.LoginTypeOIDC, + }) + require.NoError(t, err) + require.Equal(t, "", link.LinkedID) + + // Correct link should be untouched. + link, err = db.GetUserLinkByUserIDLoginType(ctx, database.GetUserLinkByUserIDLoginTypeParams{ + UserID: correctUser.ID, + LoginType: database.LoginTypeOIDC, + }) + require.NoError(t, err) + require.Equal(t, expectedIssuer+"||sub-correct", link.LinkedID) + }) + + t.Run("AutoRepairDisabledSkipsReset", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + logger := testutil.Logger(t) + + const expectedIssuer = "https://accounts.google.com" + oidcSrv := fakeOIDCIssuerDiscovery(t, expectedIssuer) + + db, _ := dbtestutil.NewDB(t) + + mismatchedUser := dbgen.User(t, db, database.User{LoginType: database.LoginTypeOIDC}) + dbgen.UserLink(t, db, database.UserLink{ + UserID: mismatchedUser.ID, + LoginType: database.LoginTypeOIDC, + LinkedID: "https://old-issuer.example.com||sub-mismatched", + }) + + vals := newDeploymentValues(t, oidcSrv.URL, false) + err := oidcAuthLinks(ctx, logger, oidcSrv.Client(), vals, db) + require.NoError(t, err) + + // Link must not be modified when auto-repair is off. + link, err := db.GetUserLinkByUserIDLoginType(ctx, database.GetUserLinkByUserIDLoginTypeParams{ + UserID: mismatchedUser.ID, + LoginType: database.LoginTypeOIDC, + }) + require.NoError(t, err) + require.Equal(t, "https://old-issuer.example.com||sub-mismatched", link.LinkedID) + }) + + t.Run("DiscoveryFailureNonFatal", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + // oidcAuthLinks logs errors but does not return them. + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) + + // Serve 500 so discovery fails. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + t.Cleanup(srv.Close) + + db, _ := dbtestutil.NewDB(t) + + vals := newDeploymentValues(t, srv.URL, true) + err := oidcAuthLinks(ctx, logger, srv.Client(), vals, db) + require.NoError(t, err, "discovery failure must not be fatal") + }) + + t.Run("AnalyzeFailureNonFatal", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + // oidcAuthLinks logs errors but does not return them. + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) + + const expectedIssuer = "https://accounts.google.com" + oidcSrv := fakeOIDCIssuerDiscovery(t, expectedIssuer) + + // Use a closed DB so the analysis query fails. + connectionURL, err := dbtestutil.Open(t) + require.NoError(t, err) + sqlDB, err := sql.Open("postgres", connectionURL) + require.NoError(t, err) + db := database.New(sqlDB) + // Close the underlying connection so the query fails. + sqlDB.Close() + + vals := newDeploymentValues(t, oidcSrv.URL, true) + err = oidcAuthLinks(ctx, logger, oidcSrv.Client(), vals, db) + require.NoError(t, err, "analysis failure must not be fatal") + }) +} diff --git a/cli/server_fix_oidc_links_test.go b/cli/server_fix_oidc_links_test.go new file mode 100644 index 0000000000000..407ebc4c4fe11 --- /dev/null +++ b/cli/server_fix_oidc_links_test.go @@ -0,0 +1,203 @@ +package cli_test + +import ( + "context" + "database/sql" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/testutil" + "github.com/coder/coder/v2/testutil/expecter" +) + +// fakeOIDCDiscovery returns a test server serving an OIDC discovery document. +func fakeOIDCDiscovery(t *testing.T, issuer string) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/.well-known/openid-configuration" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{ + "issuer": issuer, + }) + })) + t.Cleanup(srv.Close) + return srv +} + +func TestFixOIDCLinks(t *testing.T) { + t.Parallel() + + t.Run("DryRun", func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitMedium) + t.Cleanup(cancel) + + const expectedIssuer = "https://accounts.google.com" + oidcSrv := fakeOIDCDiscovery(t, expectedIssuer) + + connectionURL, err := dbtestutil.Open(t) + require.NoError(t, err) + + sqlDB, err := sql.Open("postgres", connectionURL) + require.NoError(t, err) + defer sqlDB.Close() + + db := database.New(sqlDB) + + // Seed a correctly linked user. + correctUser := dbgen.User(t, db, database.User{LoginType: database.LoginTypeOIDC}) + dbgen.UserLink(t, db, database.UserLink{ + UserID: correctUser.ID, + LoginType: database.LoginTypeOIDC, + LinkedID: expectedIssuer + "||sub-correct", + }) + + // Seed a mismatched user. + mismatchedUser := dbgen.User(t, db, database.User{LoginType: database.LoginTypeOIDC}) + dbgen.UserLink(t, db, database.UserLink{ + UserID: mismatchedUser.ID, + LoginType: database.LoginTypeOIDC, + LinkedID: "https://old-issuer.example.com||sub-mismatched", + }) + + inv, _ := clitest.New(t, + "server", "fix-oidc-links", + "--postgres-url", connectionURL, + "--issuer-url", oidcSrv.URL, + "--dry-run", + ) + + stdout := expecter.NewAttachedToInvocation(t, inv) + w := clitest.StartWithWaiter(t, inv) + + stdout.ExpectMatch(ctx, "Resolved OIDC issuer: \""+expectedIssuer+"\"") + stdout.ExpectMatch(ctx, "Total OIDC users:") + stdout.ExpectMatch(ctx, "Correctly linked:") + stdout.ExpectMatch(ctx, "Linked to other issuers:") + w.RequireSuccess() + + // Verify no changes were made. + link, err := db.GetUserLinkByUserIDLoginType(ctx, database.GetUserLinkByUserIDLoginTypeParams{ + UserID: mismatchedUser.ID, + LoginType: database.LoginTypeOIDC, + }) + require.NoError(t, err) + require.Equal(t, "https://old-issuer.example.com||sub-mismatched", link.LinkedID, "dry-run must not modify the database") + }) + + t.Run("Confirm", func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitMedium) + t.Cleanup(cancel) + + const expectedIssuer = "https://accounts.google.com" + oidcSrv := fakeOIDCDiscovery(t, expectedIssuer) + + connectionURL, err := dbtestutil.Open(t) + require.NoError(t, err) + + sqlDB, err := sql.Open("postgres", connectionURL) + require.NoError(t, err) + defer sqlDB.Close() + + db := database.New(sqlDB) + + // Seed a correctly linked user. + correctUser := dbgen.User(t, db, database.User{LoginType: database.LoginTypeOIDC}) + dbgen.UserLink(t, db, database.UserLink{ + UserID: correctUser.ID, + LoginType: database.LoginTypeOIDC, + LinkedID: expectedIssuer + "||sub-correct", + }) + + // Seed mismatched users. + mismatchedUser := dbgen.User(t, db, database.User{LoginType: database.LoginTypeOIDC}) + dbgen.UserLink(t, db, database.UserLink{ + UserID: mismatchedUser.ID, + LoginType: database.LoginTypeOIDC, + LinkedID: "https://old-issuer.example.com||sub-mismatched", + }) + + inv, _ := clitest.New(t, + "server", "fix-oidc-links", + "--postgres-url", connectionURL, + "--issuer-url", oidcSrv.URL, + "--yes", + ) + + stdout := expecter.NewAttachedToInvocation(t, inv) + w := clitest.StartWithWaiter(t, inv) + + stdout.ExpectMatch(ctx, "Reset 1 linked IDs.") + w.RequireSuccess() + + // Verify the mismatched link was reset. + link, err := db.GetUserLinkByUserIDLoginType(ctx, database.GetUserLinkByUserIDLoginTypeParams{ + UserID: mismatchedUser.ID, + LoginType: database.LoginTypeOIDC, + }) + require.NoError(t, err) + require.Equal(t, "", link.LinkedID) + + // Verify the correct link is unchanged. + link, err = db.GetUserLinkByUserIDLoginType(ctx, database.GetUserLinkByUserIDLoginTypeParams{ + UserID: correctUser.ID, + LoginType: database.LoginTypeOIDC, + }) + require.NoError(t, err) + require.Equal(t, expectedIssuer+"||sub-correct", link.LinkedID) + }) + + t.Run("NothingToDo", func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitMedium) + t.Cleanup(cancel) + + const expectedIssuer = "https://accounts.google.com" + oidcSrv := fakeOIDCDiscovery(t, expectedIssuer) + + connectionURL, err := dbtestutil.Open(t) + require.NoError(t, err) + + sqlDB, err := sql.Open("postgres", connectionURL) + require.NoError(t, err) + defer sqlDB.Close() + + db := database.New(sqlDB) + + // All users correctly linked. + user := dbgen.User(t, db, database.User{LoginType: database.LoginTypeOIDC}) + dbgen.UserLink(t, db, database.UserLink{ + UserID: user.ID, + LoginType: database.LoginTypeOIDC, + LinkedID: expectedIssuer + "||sub-correct", + }) + + inv, _ := clitest.New(t, + "server", "fix-oidc-links", + "--postgres-url", connectionURL, + "--issuer-url", oidcSrv.URL, + "--yes", + ) + + stdout := expecter.NewAttachedToInvocation(t, inv) + w := clitest.StartWithWaiter(t, inv) + + stdout.ExpectMatch(ctx, "Nothing to do") + w.RequireSuccess() + }) +} diff --git a/cli/server_test.go b/cli/server_test.go index 89e0ba7048c8d..7aada64c68dc1 100644 --- a/cli/server_test.go +++ b/cli/server_test.go @@ -1830,6 +1830,56 @@ func TestServer(t *testing.T) { }) } +// TestServer_InvalidSSHDeploymentConfig checks that unsafe SSH config flags are +// rejected at startup, before any database connection, so these invocations +// fail fast. +func TestServer_InvalidSSHDeploymentConfig(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + flag string + wantErr string + }{ + { + name: "HostnameSuffixLeadingDot", + flag: "--workspace-hostname-suffix=.coder", + wantErr: "workspace hostname suffix", + }, + { + name: "HostnameSuffixNewline", + flag: "--workspace-hostname-suffix=coder\nHost *", + wantErr: "workspace hostname suffix", + }, + { + name: "HostnamePrefixNewline", + flag: "--ssh-hostname-prefix=coder.\nHost *", + wantErr: "workspace hostname prefix", + }, + { + name: "SSHOptionUnparseable", + flag: "--ssh-config-options=NoSeparatorOption", + wantErr: "parse ssh config options", + }, + { + name: "SSHOptionDisallowedKey", + flag: "--ssh-config-options=ProxyCommand=ssh -W %h:%p bastion", + wantErr: `ssh config option "ProxyCommand" is not allowed`, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + inv, _ := clitest.New(t, "server", tc.flag) + err := inv.WithContext(ctx).Run() + require.Error(t, err) + require.ErrorContains(t, err, tc.wantErr) + }) + } +} + //nolint:tparallel,paralleltest // This test sets environment variables. func TestServer_ExternalAuthGitHubDefaultProvider(t *testing.T) { type testCase struct { diff --git a/cli/sharing.go b/cli/sharing.go index 61428d3b37243..61c0f75dd3089 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 26ad858d09ff0..fa8026554dfed 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/cli/testdata/coder_create_--help.golden b/cli/testdata/coder_create_--help.golden index b1f5968c7abe2..87b99c6c601e1 100644 --- a/cli/testdata/coder_create_--help.golden +++ b/cli/testdata/coder_create_--help.golden @@ -13,13 +13,29 @@ OPTIONS: -O, --org string, $CODER_ORGANIZATION Select which organization (uuid or name) to use. + --always-prompt bool + Always prompt all parameters. Does not pull parameter values from + existing workspace. + --automatic-updates string, $CODER_WORKSPACE_AUTOMATIC_UPDATES (default: never) Specify automatic updates setting for the workspace (accepts 'always' or 'never'). + --build-option string-array, $CODER_BUILD_OPTION + Build option value in the format "name=value". + DEPRECATED: Use --ephemeral-parameter instead. + + --build-options bool + Prompt for one-time build options defined with ephemeral parameters. + DEPRECATED: Use --prompt-ephemeral-parameters instead. + --copy-parameters-from string, $CODER_WORKSPACE_COPY_PARAMETERS_FROM Specify the source workspace name to copy parameters from. + --ephemeral-parameter string-array, $CODER_EPHEMERAL_PARAMETER + Set the value of ephemeral parameters defined in the template. The + format is "name=value". + --no-wait bool, $CODER_CREATE_NO_WAIT Return immediately after creating the workspace. The build will run in the background. @@ -34,6 +50,11 @@ OPTIONS: Specify the name of a template version preset. Use 'none' to explicitly indicate that no preset should be used. + --prompt-ephemeral-parameters bool, $CODER_PROMPT_EPHEMERAL_PARAMETERS + Prompt to set values of ephemeral parameters defined in the template. + If a value has been set via --ephemeral-parameter, it will not be + prompted for. + --rich-parameter-file string, $CODER_RICH_PARAMETER_FILE Specify a file path with values for rich parameters defined in the template. The file should be in YAML format, containing key-value diff --git a/cli/testdata/coder_server_--help.golden b/cli/testdata/coder_server_--help.golden index 4bcf9efda9e07..f39904b16bc2f 100644 --- a/cli/testdata/coder_server_--help.golden +++ b/cli/testdata/coder_server_--help.golden @@ -9,6 +9,9 @@ SUBCOMMANDS: create-admin-user Create a new admin user with the given username, email and password and adds it to every organization. + fix-oidc-links Reset OIDC linked IDs that do not match the + expected issuer, allowing users to + re-authenticate. postgres-builtin-serve Run the built-in PostgreSQL deployment. postgres-builtin-url Output the connection URL for the built-in PostgreSQL deployment. @@ -124,35 +127,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 +194,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 @@ -262,8 +291,12 @@ Clients include the Coder CLI, Coder Desktop, IDE extensions, and the web UI. --ssh-config-options string-array, $CODER_SSH_CONFIG_OPTIONS These SSH config options will override the default SSH config options. Provide options in "key=value" or "key value" format separated by - commas.Using this incorrectly can break SSH to your deployment, use - cautiously. + commas. Using this incorrectly can break SSH to your deployment, use + cautiously. The following options are not allowed: Host, Match, + Include, ProxyCommand, ProxyJump, LocalCommand, PermitLocalCommand, + RemoteCommand, KnownHostsCommand, PKCS11Provider, SecurityKeyProvider, + SmartcardDevice, XAuthLocation. Option values must not contain + newline, carriage return, or NUL characters. --web-terminal-renderer string, $CODER_WEB_TERMINAL_RENDERER (default: canvas) The renderer to use when opening a web terminal. Valid values are @@ -272,7 +305,8 @@ Clients include the Coder CLI, Coder Desktop, IDE extensions, and the web UI. --workspace-hostname-suffix string, $CODER_WORKSPACE_HOSTNAME_SUFFIX (default: coder) Workspace hostnames use this suffix in SSH config and Coder Connect on Coder Desktop. By default it is coder, resulting in names like - myworkspace.coder. + myworkspace.coder. The suffix must not start with a dot, and must not + contain spaces, newlines, or glob characters (* and ?). CONFIG OPTIONS: Use a YAML configuration file when your server launch become unwieldy. @@ -423,8 +457,8 @@ NETWORKING OPTIONS: True-Client-Ip, X-Forwarded-For. --proxy-trusted-origins string-array, $CODER_PROXY_TRUSTED_ORIGINS - Origin addresses to respect "proxy-trusted-headers". e.g. - 192.168.1.0/24. + Origin addresses to respect "proxy-trusted-headers" and + X-Forwarded-Host for subdomain app routing. e.g. 192.168.1.0/24. --redirect-to-access-url bool, $CODER_REDIRECT_TO_ACCESS_URL Specifies whether to redirect requests that do not match the access diff --git a/cli/testdata/coder_server_fix-oidc-links_--help.golden b/cli/testdata/coder_server_fix-oidc-links_--help.golden new file mode 100644 index 0000000000000..201a71435eae0 --- /dev/null +++ b/cli/testdata/coder_server_fix-oidc-links_--help.golden @@ -0,0 +1,29 @@ +coder v0.0.0-devel + +USAGE: + coder server fix-oidc-links [flags] + + Reset OIDC linked IDs that do not match the expected issuer, allowing users to + re-authenticate. + +OPTIONS: + --postgres-connection-auth password|awsiamrds, $CODER_PG_CONNECTION_AUTH (default: password) + Type of auth to use when connecting to postgres. + + -n, --dry-run bool, $CODER_FIX_OIDC_LINKS_DRY_RUN + Print analysis only, do not modify the database. + + --issuer-url string, $CODER_OIDC_ISSUER_URL + The OIDC issuer URL. The canonical issuer is resolved via OIDC + discovery. + + --postgres-url string, $CODER_PG_CONNECTION_URL + URL of a PostgreSQL database. If empty, the built-in PostgreSQL + deployment will be used (Coder must not be already running in this + case). + + -y, --yes bool + Bypass confirmation prompts. + +——— +Run `coder --help` for a list of global options. diff --git a/cli/testdata/server-config.yaml.golden b/cli/testdata/server-config.yaml.golden index 5bc02e0ae6073..0135f214c5690 100644 --- a/cli/testdata/server-config.yaml.golden +++ b/cli/testdata/server-config.yaml.golden @@ -172,7 +172,8 @@ networking: # True-Client-Ip, X-Forwarded-For. # (default: , type: string-array) proxyTrustedHeaders: [] - # Origin addresses to respect "proxy-trusted-headers". e.g. 192.168.1.0/24. + # Origin addresses to respect "proxy-trusted-headers" and X-Forwarded-Host for + # subdomain app routing. e.g. 192.168.1.0/24. # (default: , type: string-array) proxyTrustedOrigins: [] # Controls if the 'Secure' property is set on browser session cookies. @@ -427,6 +428,19 @@ oidc: # setting can also break OIDC, so use with caution. # (default: , type: url) oidc-redirect-url: + # OIDC based users require the IdP issuer and subject in the claims to be static. + # If a new provider is configured, this option is required to be 'true'. It will + # reset any existing users to the previous provider, and match by email on their + # 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. @@ -542,12 +556,18 @@ client: # (default: coder., type: string) sshHostnamePrefix: coder. # Workspace hostnames use this suffix in SSH config and Coder Connect on Coder - # Desktop. By default it is coder, resulting in names like myworkspace.coder. + # Desktop. By default it is coder, resulting in names like myworkspace.coder. The + # suffix must not start with a dot, and must not contain spaces, newlines, or glob + # characters (* and ?). # (default: coder, type: string) workspaceHostnameSuffix: coder # These SSH config options will override the default SSH config options. Provide - # options in "key=value" or "key value" format separated by commas.Using this - # incorrectly can break SSH to your deployment, use cautiously. + # options in "key=value" or "key value" format separated by commas. Using this + # incorrectly can break SSH to your deployment, use cautiously. The following + # options are not allowed: Host, Match, Include, ProxyCommand, ProxyJump, + # LocalCommand, PermitLocalCommand, RemoteCommand, KnownHostsCommand, + # PKCS11Provider, SecurityKeyProvider, SmartcardDevice, XAuthLocation. Option + # values must not contain newline, carriage return, or NUL characters. # (default: , type: string-array) sshConfigOptions: [] # The upgrade message to display to users when a client/server mismatch is @@ -874,25 +894,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/coderd/agentapi/api.go b/coderd/agentapi/api.go index b0cf95bcf2647..d00b67b0c3ecb 100644 --- a/coderd/agentapi/api.go +++ b/coderd/agentapi/api.go @@ -26,6 +26,7 @@ import ( "github.com/coder/coder/v2/coderd/database/pubsub" "github.com/coder/coder/v2/coderd/externalauth" "github.com/coder/coder/v2/coderd/notifications" + "github.com/coder/coder/v2/coderd/portsharing" "github.com/coder/coder/v2/coderd/prometheusmetrics" "github.com/coder/coder/v2/coderd/tracing" "github.com/coder/coder/v2/coderd/workspacestats" @@ -57,7 +58,7 @@ type API struct { *ConnLogAPI *SubAgentAPI *BoundaryLogsAPI - *tailnet.DRPCService + tailnetService *tailnet.DRPCService cachedWorkspaceFields *CachedWorkspaceFields @@ -66,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 @@ -90,6 +113,7 @@ type Options struct { NetworkTelemetryHandler func(batch []*tailnetproto.TelemetryEvent) BoundaryUsageTracker *boundaryusage.Tracker LifecycleMetrics *LifecycleMetrics + PortSharer *atomic.Pointer[portsharing.PortSharer] AccessURL *url.URL AppHostname string @@ -215,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, @@ -230,6 +254,7 @@ func New(opts Options, workspace database.Workspace, agent database.WorkspaceAge Log: opts.Log, Clock: opts.Clock, Database: opts.Database, + PortSharer: opts.PortSharer, } api.BoundaryLogsAPI = &BoundaryLogsAPI{ @@ -255,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/agentapi/lifecycle.go b/coderd/agentapi/lifecycle.go index 5003a16f04dae..d9b1aa296a162 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 12efe362abb02..8baee8eb3f236 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/subagent.go b/coderd/agentapi/subagent.go index dc739545cc8b4..ec509bc98e80a 100644 --- a/coderd/agentapi/subagent.go +++ b/coderd/agentapi/subagent.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "strings" + "sync/atomic" "github.com/google/uuid" "github.com/sqlc-dev/pqtype" @@ -17,6 +18,7 @@ import ( agentproto "github.com/coder/coder/v2/agent/proto" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/portsharing" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/provisioner" "github.com/coder/quartz" @@ -27,9 +29,10 @@ type SubAgentAPI struct { OrganizationID uuid.UUID AgentFn func(context.Context) (database.WorkspaceAgent, error) - Log slog.Logger - Clock quartz.Clock - Database database.Store + Log slog.Logger + Clock quartz.Clock + Database database.Store + PortSharer *atomic.Pointer[portsharing.PortSharer] } func (a *SubAgentAPI) CreateSubAgent(ctx context.Context, req *agentproto.CreateSubAgentRequest) (*agentproto.CreateSubAgentResponse, error) { @@ -129,6 +132,21 @@ func (a *SubAgentAPI) CreateSubAgent(ctx context.Context, req *agentproto.Create Detail: fmt.Sprintf("agent name %q does not match regex %q", agentName, provisioner.AgentNameRegex), } } + var template database.Template + if len(req.Apps) > 0 { + workspace, err := a.Database.GetWorkspaceByAgentID(ctx, parentAgent.ID) + if err != nil { + return nil, xerrors.Errorf("get workspace by agent id: %w", err) + } + + // Intentional: SubAgentAPI auth context enforces template ACL. + // Normal workspace operations depend on this. + template, err = a.Database.GetTemplateByID(ctx, workspace.TemplateID) + if err != nil { + return nil, xerrors.Errorf("get template policy: %w. If template access was recently changed, restart the workspace to refresh agent permissions", err) + } + } + subAgent, err := a.Database.InsertWorkspaceAgent(ctx, database.InsertWorkspaceAgentParams{ ID: uuid.New(), ParentID: uuid.NullUUID{Valid: true, UUID: parentAgent.ID}, @@ -155,6 +173,14 @@ func (a *SubAgentAPI) CreateSubAgent(ctx context.Context, req *agentproto.Create return nil, xerrors.Errorf("insert sub agent: %w", err) } + // A nil PortSharer uses the AGPL default, which permits all share levels. + portSharer := portsharing.DefaultPortSharer + if a.PortSharer != nil { + if loaded := a.PortSharer.Load(); loaded != nil { + portSharer = *loaded + } + } + var appCreationErrors []*agentproto.CreateSubAgentResponse_AppCreationError appSlugs := make(map[string]struct{}) @@ -198,6 +224,18 @@ func (a *SubAgentAPI) CreateSubAgent(ctx context.Context, req *agentproto.Create } } sharingLevel := database.AppSharingLevel(strings.ToLower(protoSharingLevel)) + // Clamp instead of rejecting so a too-permissive app share level does + // not block the sub-agent from starting. + if err := portSharer.AuthorizedLevel(template, codersdk.WorkspaceAgentPortShareLevel(sharingLevel)); err != nil { + a.Log.Warn(ctx, "clamping sub-agent app sharing level to template max port sharing level", + slog.F("sub_agent_name", subAgent.Name), + slog.F("sub_agent_id", subAgent.ID), + slog.F("app_slug", slug), + slog.F("requested_share_level", sharingLevel), + slog.F("max_port_share_level", template.MaxPortSharingLevel), + slog.Error(err)) + sharingLevel = template.MaxPortSharingLevel + } var openIn database.WorkspaceAppOpenIn switch app.GetOpenIn() { @@ -221,8 +259,9 @@ func (a *SubAgentAPI) CreateSubAgent(ctx context.Context, req *agentproto.Create slugHashEnc := base32.HexEncoding.WithPadding(base32.NoPadding).EncodeToString(slugHash[:]) computedSlug := strings.ToLower(slugHashEnc[:8]) + "-" + app.Slug + appID := uuid.New() _, err := a.Database.UpsertWorkspaceApp(ctx, database.UpsertWorkspaceAppParams{ - ID: uuid.New(), // NOTE: we may need to maintain the app's ID here for stability, but for now we'll leave this as-is. + ID: appID, // NOTE: we may need to maintain the app's ID here for stability, but for now we'll leave this as-is. CreatedAt: createdAt, AgentID: subAgent.ID, Slug: computedSlug, @@ -253,6 +292,12 @@ func (a *SubAgentAPI) CreateSubAgent(ctx context.Context, req *agentproto.Create Tooltip: "", // tooltips are not currently supported in subagent workspaces, default to empty string }) if err != nil { + if errors.Is(err, sql.ErrNoRows) { + // The upsert's ON CONFLICT guard refused to rebind an + // existing workspace-owned app to an agent outside that + // workspace, including agents that resolve to no workspace. + return xerrors.Errorf("workspace app slug %q with ID %q is already bound to a workspace-owned agent and cannot be rebound to an agent in another workspace or to an agent without a workspace; refusing to rebind to agent ID %q", computedSlug, appID, subAgent.ID) + } return xerrors.Errorf("insert workspace app: %w", err) } diff --git a/coderd/agentapi/subagent_test.go b/coderd/agentapi/subagent_test.go index a7217cc513f55..81c98091b1202 100644 --- a/coderd/agentapi/subagent_test.go +++ b/coderd/agentapi/subagent_test.go @@ -12,6 +12,7 @@ import ( "github.com/prometheus/client_golang/prometheus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" "cdr.dev/slog/v3" "github.com/coder/coder/v2/agent/proto" @@ -19,6 +20,7 @@ import ( "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" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/util/ptr" @@ -804,6 +806,81 @@ func TestSubAgentAPI(t *testing.T) { }) }) + t.Run("CreateSubAgentWithAppRebindRejected", func(t *testing.T) { + t.Parallel() + + clock := quartz.NewMock(t) + createdAt := clock.Now() + parentAgent := database.WorkspaceAgent{ + ID: uuid.New(), + ResourceID: uuid.New(), + ConnectionTimeoutSeconds: 30, + TroubleshootingURL: "https://example.com/troubleshoot", + APIKeyScope: database.AgentKeyScopeEnumAll, + } + workspace := database.Workspace{ + ID: uuid.New(), + TemplateID: uuid.New(), + } + template := database.Template{ + ID: workspace.TemplateID, + MaxPortSharingLevel: database.AppSharingLevelPublic, + } + insertedSubAgent := database.WorkspaceAgent{ + ID: uuid.New(), + ParentID: uuid.NullUUID{UUID: parentAgent.ID, Valid: true}, + ResourceID: parentAgent.ResourceID, + Name: "child-agent", + AuthToken: uuid.New(), + } + + dbM := dbmock.NewMockStore(gomock.NewController(t)) + dbM.EXPECT().GetWorkspaceByAgentID(gomock.Any(), parentAgent.ID).Return(workspace, nil) + dbM.EXPECT().GetTemplateByID(gomock.Any(), workspace.TemplateID).Return(template, nil) + dbM.EXPECT().InsertWorkspaceAgent(gomock.Any(), gomock.Cond(func(params database.InsertWorkspaceAgentParams) bool { + return params.ParentID.Valid && params.ParentID.UUID == parentAgent.ID && + params.ResourceID == parentAgent.ResourceID && + params.Name == insertedSubAgent.Name + })).Return(insertedSubAgent, nil) + dbM.EXPECT().UpsertWorkspaceApp(gomock.Any(), gomock.Cond(func(params database.UpsertWorkspaceAppParams) bool { + return params.ID != uuid.Nil && + params.AgentID == insertedSubAgent.ID && + params.CreatedAt.Equal(createdAt) && + params.Slug == "fdqf0lpd-code-server" && + params.DisplayName == "VS Code" + })).Return(database.WorkspaceApp{}, sql.ErrNoRows) + + api := &agentapi.SubAgentAPI{ + OwnerID: uuid.New(), + OrganizationID: uuid.New(), + AgentFn: func(context.Context) (database.WorkspaceAgent, error) { return parentAgent, nil }, + Clock: clock, + Database: dbM, + Log: testutil.Logger(t), + } + + createResp, err := api.CreateSubAgent(context.Background(), &proto.CreateSubAgentRequest{ + Name: insertedSubAgent.Name, + Directory: "/workspaces/coder", + Architecture: "amd64", + OperatingSystem: "linux", + Apps: []*proto.CreateSubAgentRequest_App{ + { + Slug: "code-server", + DisplayName: ptr.Ref("VS Code"), + }, + }, + }) + require.NoError(t, err) + require.Len(t, createResp.AppCreationErrors, 1) + require.Equal(t, int32(0), createResp.AppCreationErrors[0].Index) + require.Nil(t, createResp.AppCreationErrors[0].Field) + require.Contains(t, createResp.AppCreationErrors[0].Error, "workspace app slug \"fdqf0lpd-code-server\"") + require.Contains(t, createResp.AppCreationErrors[0].Error, "already bound to a workspace-owned agent") + require.Contains(t, createResp.AppCreationErrors[0].Error, "cannot be rebound to an agent in another workspace or to an agent without a workspace") + require.NotContains(t, createResp.AppCreationErrors[0].Error, "sql: no rows in result set") + }) + t.Run("DeleteSubAgent", func(t *testing.T) { t.Parallel() diff --git a/coderd/agentapi/validation_test.go b/coderd/agentapi/validation_test.go new file mode 100644 index 0000000000000..2a32556b2195d --- /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)) +} diff --git a/coderd/ai_providers.go b/coderd/ai_providers.go index 78ca50ecc99fb..19f7c7e20d3cd 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. @@ -347,6 +351,7 @@ func (api *API) aiProvidersUpdate(rw http.ResponseWriter, r *http.Request) { } params := database.UpdateAIProviderParams{ ID: old.ID, + Type: old.Type, DisplayName: displayName, Enabled: ptr.NilToDefault(req.Enabled, old.Enabled), BaseUrl: ptr.NilToDefault(req.BaseURL, old.BaseUrl), @@ -383,6 +388,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 +494,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_backfill.go b/coderd/ai_providers_backfill.go new file mode 100644 index 0000000000000..99b14075b9539 --- /dev/null +++ b/coderd/ai_providers_backfill.go @@ -0,0 +1,94 @@ +package coderd + +import ( + "context" + "database/sql" + "errors" + + "cdr.dev/slog/v3" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/db2sdk" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/codersdk" +) + +// BackfillBedrockProviderType promotes legacy ai_providers rows stored as +// type=anthropic with Bedrock settings to type=bedrock. Must run after newAPI +// so options.Database is dbcrypt-wrapped. Idempotent; errors are logged and +// startup continues. +// +// BackfillChatModelConfigProviderStrings must run after this function so +// provider types are correct when its JOIN executes. +func BackfillBedrockProviderType(ctx context.Context, db database.Store, logger slog.Logger) { + //nolint:gocritic // Startup-only backfill; no user actor is present. + sysCtx := dbauthz.AsSystemRestricted(ctx) + providers, err := db.GetAIProviders(sysCtx, database.GetAIProvidersParams{ + IncludeDeleted: false, + IncludeDisabled: true, + }) + if err != nil { + logger.Error(ctx, "backfill bedrock provider type: list providers", slog.Error(err)) + return + } + var promoted int + for _, provider := range providers { + if provider.Type != database.AiProviderTypeAnthropic { + continue + } + settings, err := db2sdk.AIProviderSettings(provider.Settings) + if err != nil { + logger.Warn(ctx, "backfill bedrock provider type: skip provider with unparsable settings", + slog.F("provider_id", provider.ID), slog.Error(err)) + continue + } + if settings.Bedrock == nil { + continue + } + _, err = db.UpdateAIProvider(sysCtx, database.UpdateAIProviderParams{ + ID: provider.ID, + Type: database.AiProviderTypeBedrock, + DisplayName: provider.DisplayName, + Enabled: provider.Enabled, + BaseUrl: provider.BaseUrl, + Settings: provider.Settings, + // SettingsKeyID is re-set by the dbcrypt wrapper on write. + SettingsKeyID: sql.NullString{}, + }) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + logger.Debug(ctx, "backfill bedrock provider type: provider deleted during backfill", + slog.F("provider_id", provider.ID)) + continue + } + logger.Error(ctx, "backfill bedrock provider type: provider update failed and will re-attempt on next server startup", + slog.F("provider_id", provider.ID), slog.Error(err)) + continue + } + promoted++ + } + if promoted > 0 { + logger.Info(ctx, "backfilled bedrock provider types", slog.F("count", promoted)) + } +} + +// BackfillChatModelConfigProviderStrings fixes stale chat_model_configs.provider +// strings left as "anthropic" when the linked provider was promoted from +// type=anthropic to type=bedrock by BackfillBedrockProviderType. Errors are +// logged and startup continues. +func BackfillChatModelConfigProviderStrings(ctx context.Context, db database.Store, logger slog.Logger) { + //nolint:gocritic // Startup-only backfill; no user actor is present. + sysCtx := dbauthz.AsSystemRestricted(ctx) + result, err := db.BackfillChatModelConfigProvider(sysCtx, database.BackfillChatModelConfigProviderParams{ + OldProvider: string(codersdk.AIProviderTypeAnthropic), + NewProvider: string(codersdk.AIProviderTypeBedrock), + }) + if err != nil { + logger.Error(ctx, "backfill chat model config provider strings", slog.Error(err)) + return + } + if result != nil { + if n, _ := result.RowsAffected(); n > 0 { + logger.Info(ctx, "backfilled chat model config provider strings", slog.F("count", n)) + } + } +} diff --git a/coderd/ai_providers_backfill_test.go b/coderd/ai_providers_backfill_test.go new file mode 100644 index 0000000000000..16d696107243b --- /dev/null +++ b/coderd/ai_providers_backfill_test.go @@ -0,0 +1,368 @@ +package coderd_test + +import ( + "database/sql" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/coder/coder/v2/coderd" + "github.com/coder/coder/v2/coderd/database" + "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/testutil" +) + +// TestBackfillBedrockProviderType runs all DB-backed cases against a single +// database instance. Subtests are intentionally sequential so that each one +// builds on the state left by the previous, which proves idempotency without +// extra setup: a second backfill call on an already-promoted DB must be a +// no-op. Failure-path tests use a mock and stay parallel. +func TestBackfillBedrockProviderType(t *testing.T) { + t.Parallel() + + bedrockSettings := sql.NullString{ + String: `{"_type":"bedrock","_version":1,"region":"us-east-1"}`, + Valid: true, + } + + // All DB subtests share one database instance and run sequentially. + t.Run("DB", func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitMedium) + logger := testLogger(t) + + t.Run("NoLegacyRows", func(t *testing.T) { + coderd.BackfillBedrockProviderType(ctx, db, logger) + + all, err := db.GetAIProviders(ctx, database.GetAIProvidersParams{ + IncludeDeleted: true, + IncludeDisabled: true, + }) + require.NoError(t, err) + require.Empty(t, all) + }) + + t.Run("PromotesLegacyRow", func(t *testing.T) { + legacy := dbgen.AIProvider(t, db, database.AIProvider{ + Type: database.AiProviderTypeAnthropic, + Settings: bedrockSettings, + }) + require.Equal(t, database.AiProviderTypeAnthropic, legacy.Type, "pre-condition: row must start as anthropic") + + coderd.BackfillBedrockProviderType(ctx, db, logger) + + row, err := db.GetAIProviderByName(ctx, legacy.Name) + require.NoError(t, err) + require.Equal(t, database.AiProviderTypeBedrock, row.Type) + }) + + t.Run("Idempotent", func(t *testing.T) { + // DB already has one bedrock row from the previous subtest. + // A second run must be a no-op: no type changes, no new rows. + before, err := db.GetAIProviders(ctx, database.GetAIProvidersParams{ + IncludeDeleted: true, + IncludeDisabled: true, + }) + require.NoError(t, err) + for _, r := range before { + require.Equal(t, database.AiProviderTypeBedrock, r.Type, + "pre-condition: all rows must already be promoted before testing idempotency") + } + + coderd.BackfillBedrockProviderType(ctx, db, logger) + + after, err := db.GetAIProviders(ctx, database.GetAIProvidersParams{ + IncludeDeleted: true, + IncludeDisabled: true, + }) + require.NoError(t, err) + require.Equal(t, len(before), len(after), "second run must not create rows") + for i := range after { + require.Equal(t, before[i].Type, after[i].Type, "second run must not change types") + } + }) + + t.Run("PreservesNativeAnthropicRow", func(t *testing.T) { + native := dbgen.AIProvider(t, db, database.AIProvider{ + Type: database.AiProviderTypeAnthropic, + }) + require.Equal(t, database.AiProviderTypeAnthropic, native.Type, "pre-condition") + + coderd.BackfillBedrockProviderType(ctx, db, logger) + + row, err := db.GetAIProviderByName(ctx, native.Name) + require.NoError(t, err) + require.Equal(t, database.AiProviderTypeAnthropic, row.Type) + }) + + t.Run("PreservesNativeBedrockRow", func(t *testing.T) { + native := dbgen.AIProvider(t, db, database.AIProvider{ + Type: database.AiProviderTypeBedrock, + Settings: bedrockSettings, + }) + require.Equal(t, database.AiProviderTypeBedrock, native.Type, "pre-condition") + + coderd.BackfillBedrockProviderType(ctx, db, logger) + + row, err := db.GetAIProviderByName(ctx, native.Name) + require.NoError(t, err) + require.Equal(t, database.AiProviderTypeBedrock, row.Type) + }) + + t.Run("SkipsDeletedRows", func(t *testing.T) { + deleted := dbgen.AIProvider(t, db, database.AIProvider{ + Type: database.AiProviderTypeAnthropic, + Settings: bedrockSettings, + }) + require.Equal(t, database.AiProviderTypeAnthropic, deleted.Type, "pre-condition") + require.NoError(t, db.DeleteAIProviderByID(ctx, deleted.ID)) + + coderd.BackfillBedrockProviderType(ctx, db, logger) + + row, err := db.GetAIProviders(ctx, database.GetAIProvidersParams{ + IncludeDeleted: true, + IncludeDisabled: true, + }) + require.NoError(t, err) + var found bool + for _, r := range row { + if r.ID == deleted.ID { + found = true + require.Equal(t, database.AiProviderTypeAnthropic, r.Type, "deleted row must not be promoted") + } + } + require.True(t, found, "deleted row must appear in IncludeDeleted result set") + }) + + t.Run("IncludesDisabledRows", func(t *testing.T) { + disabled := dbgen.AIProvider(t, db, database.AIProvider{ + Type: database.AiProviderTypeAnthropic, + Enabled: false, + Settings: bedrockSettings, + }) + require.Equal(t, database.AiProviderTypeAnthropic, disabled.Type, "pre-condition") + + coderd.BackfillBedrockProviderType(ctx, db, logger) + + row, err := db.GetAIProviderByName(ctx, disabled.Name) + require.NoError(t, err) + require.Equal(t, database.AiProviderTypeBedrock, row.Type, "disabled legacy row must be promoted") + }) + + t.Run("PreservesAnthropicRowWithNonBedrockSettings", func(t *testing.T) { + // {} has no _type discriminator, so UnmarshalJSON returns an error + // and the row is skipped via the unparsable-settings path, not the + // settings.Bedrock == nil guard. Either way the row must stay anthropic. + nonBedrock := dbgen.AIProvider(t, db, database.AIProvider{ + Type: database.AiProviderTypeAnthropic, + Settings: sql.NullString{String: "{}", Valid: true}, + }) + require.Equal(t, database.AiProviderTypeAnthropic, nonBedrock.Type, "pre-condition") + + coderd.BackfillBedrockProviderType(ctx, db, logger) + + row, err := db.GetAIProviderByName(ctx, nonBedrock.Name) + require.NoError(t, err) + require.Equal(t, database.AiProviderTypeAnthropic, row.Type, "anthropic row with non-bedrock settings must not be promoted") + }) + + t.Run("SkipsUnparsableSettings", func(t *testing.T) { + malformed := dbgen.AIProvider(t, db, database.AIProvider{ + Type: database.AiProviderTypeAnthropic, + Settings: sql.NullString{String: "{", Valid: true}, + }) + require.Equal(t, database.AiProviderTypeAnthropic, malformed.Type, "pre-condition") + good := dbgen.AIProvider(t, db, database.AIProvider{ + Type: database.AiProviderTypeAnthropic, + Settings: bedrockSettings, + }) + require.Equal(t, database.AiProviderTypeAnthropic, good.Type, "pre-condition") + + coderd.BackfillBedrockProviderType(ctx, db, logger) + + malformedRow, err := db.GetAIProviderByName(ctx, malformed.Name) + require.NoError(t, err) + require.Equal(t, database.AiProviderTypeAnthropic, malformedRow.Type, "row with unparsable settings must not be touched") + + goodRow, err := db.GetAIProviderByName(ctx, good.Name) + require.NoError(t, err) + require.Equal(t, database.AiProviderTypeBedrock, goodRow.Type, "valid row alongside unparsable one must still be promoted") + }) + + // --- chat_model_configs.provider backfill --- + // These subtests rely on the DB already having type=bedrock providers + // from the provider backfill subtests above. + + t.Run("FixesStaleModelConfigProvider", func(t *testing.T) { + // Simulate a model config created when the linked provider was still + // type=anthropic. The stored provider string is "anthropic" but the + // linked provider row now has type=bedrock. + bedrockProvider := dbgen.AIProvider(t, db, database.AIProvider{ + Type: database.AiProviderTypeBedrock, + Settings: bedrockSettings, + }) + staleConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{ + Provider: "anthropic", + AIProviderID: uuid.NullUUID{UUID: bedrockProvider.ID, Valid: true}, + }) + + coderd.BackfillChatModelConfigProviderStrings(ctx, db, logger) + + updated, err := db.GetChatModelConfigByID(ctx, staleConfig.ID) + require.NoError(t, err) + require.Equal(t, "bedrock", updated.Provider, "stale anthropic provider string must be fixed to bedrock") + + // Second run must be a no-op: the same config must still be "bedrock". + coderd.BackfillChatModelConfigProviderStrings(ctx, db, logger) + + updated, err = db.GetChatModelConfigByID(ctx, staleConfig.ID) + require.NoError(t, err) + require.Equal(t, "bedrock", updated.Provider, "provider must remain bedrock after second run") + }) + + t.Run("ModelConfigIdempotent", func(t *testing.T) { + before, err := db.GetChatModelConfigs(ctx) + require.NoError(t, err) + + coderd.BackfillChatModelConfigProviderStrings(ctx, db, logger) + + after, err := db.GetChatModelConfigs(ctx) + require.NoError(t, err) + require.Equal(t, len(before), len(after), "second run must not create or delete rows") + }) + + t.Run("PreservesNonAnthropicModelConfig", func(t *testing.T) { + // A model config with provider="openai" linked to a Bedrock provider + // must not be touched. Only "anthropic" → "bedrock" is in scope. + bedrockProvider := dbgen.AIProvider(t, db, database.AIProvider{ + Type: database.AiProviderTypeBedrock, + Settings: bedrockSettings, + }) + openAIConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{ + Provider: "openai", + AIProviderID: uuid.NullUUID{UUID: bedrockProvider.ID, Valid: true}, + }) + + coderd.BackfillChatModelConfigProviderStrings(ctx, db, logger) + + row, err := db.GetChatModelConfigByID(ctx, openAIConfig.ID) + require.NoError(t, err) + require.Equal(t, "openai", row.Provider, "non-anthropic provider string must not be changed") + }) + + t.Run("SkipsModelConfigWithDeletedProvider", func(t *testing.T) { + // Verifies the EXISTS subquery excludes soft-deleted providers. + // The model config provider string must stay "anthropic" because + // the linked provider is deleted and therefore excluded by the + // AND deleted = FALSE condition in the query. + deletedProvider := dbgen.AIProvider(t, db, database.AIProvider{ + Type: database.AiProviderTypeBedrock, + Settings: bedrockSettings, + }) + staleConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{ + Provider: "anthropic", + AIProviderID: uuid.NullUUID{UUID: deletedProvider.ID, Valid: true}, + }) + require.NoError(t, db.DeleteAIProviderByID(ctx, deletedProvider.ID)) + + coderd.BackfillChatModelConfigProviderStrings(ctx, db, logger) + + row, err := db.GetChatModelConfigByID(ctx, staleConfig.ID) + require.NoError(t, err) + require.Equal(t, "anthropic", row.Provider, "config linked to deleted provider must not be updated") + }) + + t.Run("SkipsDeletedModelConfig", func(t *testing.T) { + // The SQL query guards on deleted = FALSE. Capture the config ID + // before deletion so we delete the right row regardless of ordering. + bedrockProvider := dbgen.AIProvider(t, db, database.AIProvider{ + Type: database.AiProviderTypeBedrock, + Settings: bedrockSettings, + }) + cfg := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{ + Provider: "anthropic", + AIProviderID: uuid.NullUUID{UUID: bedrockProvider.ID, Valid: true}, + }) + + before, err := db.GetChatModelConfigs(ctx) + require.NoError(t, err) + require.NoError(t, db.DeleteChatModelConfigByID(ctx, cfg.ID)) + + coderd.BackfillChatModelConfigProviderStrings(ctx, db, logger) + + after, err := db.GetChatModelConfigs(ctx) + require.NoError(t, err) + require.Equal(t, len(before)-1, len(after), "deleted config must not reappear after backfill") + }) + }) + + t.Run("ListFailure", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + ctrl := gomock.NewController(t) + db := dbmock.NewMockStore(ctrl) + + db.EXPECT(). + GetAIProviders(gomock.Any(), gomock.Any()). + Return(nil, sql.ErrConnDone) + + coderd.BackfillBedrockProviderType(ctx, db, testLogger(t)) + }) + + t.Run("UpdateFailure", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + ctrl := gomock.NewController(t) + db := dbmock.NewMockStore(ctrl) + + db.EXPECT(). + GetAIProviders(gomock.Any(), gomock.Any()). + Return([]database.AIProvider{{ + Type: database.AiProviderTypeAnthropic, + Settings: bedrockSettings, + }}, nil) + db.EXPECT(). + UpdateAIProvider(gomock.Any(), gomock.Any()). + Return(database.AIProvider{}, sql.ErrConnDone) + + coderd.BackfillBedrockProviderType(ctx, db, testLogger(t)) + }) + + t.Run("ProviderDeletedDuringBackfill", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + ctrl := gomock.NewController(t) + db := dbmock.NewMockStore(ctrl) + + db.EXPECT(). + GetAIProviders(gomock.Any(), gomock.Any()). + Return([]database.AIProvider{{ + Type: database.AiProviderTypeAnthropic, + Settings: bedrockSettings, + }}, nil) + db.EXPECT(). + UpdateAIProvider(gomock.Any(), gomock.Any()). + Return(database.AIProvider{}, sql.ErrNoRows) + + // ErrNoRows is benign: provider was deleted between list and update. + coderd.BackfillBedrockProviderType(ctx, db, testLogger(t)) + }) + + t.Run("ModelConfigQueryFailure", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + ctrl := gomock.NewController(t) + db := dbmock.NewMockStore(ctrl) + + db.EXPECT(). + BackfillChatModelConfigProvider(gomock.Any(), gomock.Any()). + Return(nil, sql.ErrConnDone) + + coderd.BackfillChatModelConfigProviderStrings(ctx, db, testLogger(t)) + }) +} diff --git a/coderd/ai_providers_migrate.go b/coderd/ai_providers_migrate.go index 6bc99ad8408c4..cc317a57fa633 100644 --- a/coderd/ai_providers_migrate.go +++ b/coderd/ai_providers_migrate.go @@ -116,10 +116,28 @@ 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) + } + // Use the canonical type so that a row promoted from + // type=anthropic to type=bedrock by the startup backfill + // is not mistaken for drift on the next startup. + existingType := existing.Type + if existingSettings.Bedrock != nil && existing.Type == database.AiProviderTypeAnthropic { + existingType = database.AiProviderTypeBedrock + } existingDP := desiredAIProvider{ - Type: existing.Type, + Type: existingType, BaseURL: existing.BaseUrl, Bedrock: existingSettings.Bedrock, + Keys: existingKeys, } existingHash := computeProviderHash(existingDP.canonical()) if existingHash == dp.Hash { @@ -196,18 +214,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 +250,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. @@ -292,6 +337,7 @@ func providersFromEnv(ctx context.Context, cfg codersdk.AIBridgeConfig, logger s Type: database.AiProviderTypeAnthropic, } if hasLegacyBedrock { + dp.Type = database.AiProviderTypeBedrock if hasAnthropicKey { logger.Warn(ctx, "ignoring legacy Anthropic API key because Bedrock credentials are configured; Bedrock authenticates via access keys or credential chain", slog.F("provider", aibridge.ProviderAnthropic), diff --git a/coderd/ai_providers_migrate_test.go b/coderd/ai_providers_migrate_test.go index d4a07bfdc28cd..3e4324e17da65 100644 --- a/coderd/ai_providers_migrate_test.go +++ b/coderd/ai_providers_migrate_test.go @@ -2,6 +2,7 @@ package coderd_test import ( "bytes" + "database/sql" "testing" "github.com/stretchr/testify/require" @@ -91,21 +92,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 +123,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") }) @@ -140,8 +146,8 @@ func TestSeedAIProvidersFromEnv(t *testing.T) { db, _ := dbtestutil.NewDB(t) ctx := testutil.Context(t, testutil.WaitShort) - // Bedrock fields without an Anthropic key produce a Bedrock- - // authenticated Anthropic provider with no bearer keys. + // Bedrock fields without an Anthropic key produce a type=bedrock + // provider named "anthropic" with no bearer keys. cfg := codersdk.AIBridgeConfig{ LegacyBedrock: codersdk.AIBridgeBedrockConfig{ Region: serpent.String("us-west-2"), @@ -155,7 +161,7 @@ func TestSeedAIProvidersFromEnv(t *testing.T) { row, err := db.GetAIProviderByName(ctx, "anthropic") require.NoError(t, err) - require.Equal(t, database.AiProviderTypeAnthropic, row.Type) + require.Equal(t, database.AiProviderTypeBedrock, row.Type) require.Contains(t, row.Settings.String, "us-west-2") require.Contains(t, row.Settings.String, "anthropic.claude-3-5-sonnet") require.Contains(t, row.Settings.String, "anthropic.claude-3-5-haiku") @@ -293,6 +299,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 +481,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 +497,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 +541,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) @@ -508,6 +601,44 @@ func TestSeedAIProvidersFromEnv(t *testing.T) { require.Error(t, err) require.Contains(t, err.Error(), "conflicting fields") }) + + t.Run("SeedIsIdempotentAfterBedrockBackfill", func(t *testing.T) { + t.Parallel() + // Regression: seed must not treat a type=anthropic row promoted to + // type=bedrock by the backfill as drift. + db, _ := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitShort) + + cfg := codersdk.AIBridgeConfig{ + LegacyBedrock: codersdk.AIBridgeBedrockConfig{ + Region: serpent.String("us-east-1"), + AccessKey: serpent.String("AKIA"), + AccessKeySecret: serpent.String("secret"), + Model: serpent.String("anthropic.claude-3-5-sonnet"), + }, + } + + // Seed to get a row with correct settings, then set type=anthropic to + // simulate the pre-upgrade state where the old seed stored that type. + require.NoError(t, coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t))) + row, err := db.GetAIProviderByName(ctx, "anthropic") + require.NoError(t, err) + _, err = db.UpdateAIProvider(ctx, database.UpdateAIProviderParams{ + ID: row.ID, + Type: database.AiProviderTypeAnthropic, + DisplayName: row.DisplayName, + Enabled: row.Enabled, + BaseUrl: row.BaseUrl, + Settings: row.Settings, + SettingsKeyID: sql.NullString{}, + }) + require.NoError(t, err) + row, err = db.GetAIProviderByName(ctx, "anthropic") + require.NoError(t, err) + require.Equal(t, database.AiProviderTypeAnthropic, row.Type, "pre-condition: row must be anthropic before seed runs") + + require.NoError(t, coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t))) + }) } func testLogger(t *testing.T) slog.Logger { diff --git a/coderd/ai_providers_test.go b/coderd/ai_providers_test.go index c020f90c265de..e4f4f27a06d1c 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/coderd/aibridged.go b/coderd/aibridged.go index f448be39d07ed..5abb08fb699aa 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/aibridged/metrics.go b/coderd/aibridged/metrics.go new file mode 100644 index 0000000000000..b06a9c067cc26 --- /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 0000000000000..008c79dd3408b --- /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/pool.go b/coderd/aibridged/pool.go index 3b7e60955c03e..b86cefe00abeb 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/proto/aibridged.pb.go b/coderd/aibridged/proto/aibridged.pb.go index c364aeda40559..17fef851ea03b 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 cd614115168dc..08fceee676de1 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/provider.go b/coderd/aibridged/provider.go new file mode 100644 index 0000000000000..9d2faa030b587 --- /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 +// 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 + Status ProviderStatus + Err error +} diff --git a/coderd/aibridged/translator.go b/coderd/aibridged/translator.go index 2769ef0d89ebf..6d251df0fee79 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 c593b18f798a3..94411de16d865 100644 --- a/coderd/aibridgedserver/aibridgedserver.go +++ b/coderd/aibridgedserver/aibridgedserver.go @@ -43,6 +43,7 @@ var ( ErrExpired = xerrors.New("expired") ErrUnknownUser = xerrors.New("unknown user") ErrDeletedUser = xerrors.New("deleted user") + ErrInactiveUser = xerrors.New("inactive user") ErrSystemUser = xerrors.New("system user") ErrAmbiguousAuth = xerrors.New("both key and key_id set; exactly one required") @@ -222,8 +223,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) @@ -622,10 +624,13 @@ func (s *Server) IsAuthorized(ctx context.Context, in *proto.IsAuthorizedRequest return nil, ErrUnknownUser } - // User is not deleted or a system user. + // User is active, not deleted, and not a system user. if user.Deleted { return nil, ErrDeletedUser } + if user.Status != database.UserStatusActive { + return nil, ErrInactiveUser + } if user.IsSystem { return nil, ErrSystemUser } diff --git a/coderd/aibridgedserver/aibridgedserver_test.go b/coderd/aibridgedserver/aibridgedserver_test.go index eb2f413e1ed5b..5930260e058b2 100644 --- a/coderd/aibridgedserver/aibridgedserver_test.go +++ b/coderd/aibridgedserver/aibridgedserver_test.go @@ -102,16 +102,36 @@ func TestAuthorization(t *testing.T) { name: "deleted user", expectedErr: aibridgedserver.ErrDeletedUser, mocksFn: func(db *dbmock.MockStore, apiKey database.APIKey, user database.User) { + user.Deleted = true db.EXPECT().GetAPIKeyByID(gomock.Any(), apiKey.ID).Times(1).Return(apiKey, nil) - db.EXPECT().GetUserByID(gomock.Any(), user.ID).Times(1).Return(database.User{ID: user.ID, Deleted: true}, nil) + db.EXPECT().GetUserByID(gomock.Any(), user.ID).Times(1).Return(user, nil) + }, + }, + { + name: "suspended user", + expectedErr: aibridgedserver.ErrInactiveUser, + mocksFn: func(db *dbmock.MockStore, apiKey database.APIKey, user database.User) { + user.Status = database.UserStatusSuspended + db.EXPECT().GetAPIKeyByID(gomock.Any(), apiKey.ID).Times(1).Return(apiKey, nil) + db.EXPECT().GetUserByID(gomock.Any(), user.ID).Times(1).Return(user, nil) + }, + }, + { + name: "dormant user", + expectedErr: aibridgedserver.ErrInactiveUser, + mocksFn: func(db *dbmock.MockStore, apiKey database.APIKey, user database.User) { + user.Status = database.UserStatusDormant + db.EXPECT().GetAPIKeyByID(gomock.Any(), apiKey.ID).Times(1).Return(apiKey, nil) + db.EXPECT().GetUserByID(gomock.Any(), user.ID).Times(1).Return(user, nil) }, }, { name: "system user", expectedErr: aibridgedserver.ErrSystemUser, mocksFn: func(db *dbmock.MockStore, apiKey database.APIKey, user database.User) { + user.IsSystem = true db.EXPECT().GetAPIKeyByID(gomock.Any(), apiKey.ID).Times(1).Return(apiKey, nil) - db.EXPECT().GetUserByID(gomock.Any(), user.ID).Times(1).Return(database.User{ID: user.ID, IsSystem: true}, nil) + db.EXPECT().GetUserByID(gomock.Any(), user.ID).Times(1).Return(user, nil) }, }, { @@ -201,7 +221,7 @@ func TestAuthorization(t *testing.T) { // When IsAuthorizedRequest carries KeyId instead of Key, the server skips // the secret check and validates only that the key exists, is unexpired, and -// belongs to a non-deleted non-system user. This is the path used by +// belongs to an active, non-deleted, non-system user. This is the path used by // in-process delegated callers (e.g., chatd) that hold only the key ID. func TestAuthorization_Delegated(t *testing.T) { t.Parallel() @@ -260,8 +280,31 @@ func TestAuthorization_Delegated(t *testing.T) { name: "deleted user", expectedErr: aibridgedserver.ErrDeletedUser, mocksFn: func(db *dbmock.MockStore, apiKey database.APIKey, user database.User) { + user.Deleted = true db.EXPECT().GetAPIKeyByID(gomock.Any(), apiKey.ID).Times(1).Return(apiKey, nil) - db.EXPECT().GetUserByID(gomock.Any(), user.ID).Times(1).Return(database.User{ID: user.ID, Deleted: true}, nil) + db.EXPECT().GetUserByID(gomock.Any(), user.ID).Times(1).Return(user, nil) + }, + }, + { + // The delegated path must reject inactive users; transport + // trust does not override account suspension. + name: "suspended user", + expectedErr: aibridgedserver.ErrInactiveUser, + mocksFn: func(db *dbmock.MockStore, apiKey database.APIKey, user database.User) { + user.Status = database.UserStatusSuspended + db.EXPECT().GetAPIKeyByID(gomock.Any(), apiKey.ID).Times(1).Return(apiKey, nil) + db.EXPECT().GetUserByID(gomock.Any(), user.ID).Times(1).Return(user, nil) + }, + }, + { + // Dormant users are inactive unless they are explicitly + // reactivated through the HTTP middleware path. + name: "dormant user", + expectedErr: aibridgedserver.ErrInactiveUser, + mocksFn: func(db *dbmock.MockStore, apiKey database.APIKey, user database.User) { + user.Status = database.UserStatusDormant + db.EXPECT().GetAPIKeyByID(gomock.Any(), apiKey.ID).Times(1).Return(apiKey, nil) + db.EXPECT().GetUserByID(gomock.Any(), user.ID).Times(1).Return(user, nil) }, }, { @@ -270,8 +313,9 @@ func TestAuthorization_Delegated(t *testing.T) { name: "system user", expectedErr: aibridgedserver.ErrSystemUser, mocksFn: func(db *dbmock.MockStore, apiKey database.APIKey, user database.User) { + user.IsSystem = true db.EXPECT().GetAPIKeyByID(gomock.Any(), apiKey.ID).Times(1).Return(apiKey, nil) - db.EXPECT().GetUserByID(gomock.Any(), user.ID).Times(1).Return(database.User{ID: user.ID, IsSystem: true}, nil) + db.EXPECT().GetUserByID(gomock.Any(), user.ID).Times(1).Return(user, nil) }, }, } @@ -944,23 +988,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/aitasks.go b/coderd/aitasks.go index 7518a98d33590..3f88e8ede2b33 100644 --- a/coderd/aitasks.go +++ b/coderd/aitasks.go @@ -6,7 +6,6 @@ import ( "encoding/json" "errors" "fmt" - "net" "net/http" "net/url" "slices" @@ -1086,13 +1085,7 @@ func (api *API) authAndDoWithTaskAppClient( } defer release() - client := &http.Client{ - Transport: &http.Transport{ - DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { - return agentConn.DialContext(ctx, network, addr) - }, - }, - } + client := agentConn.AppHTTPClient() return do(ctx, client, parsedURL) } diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 65f2c1927c28e..d6e71c20b8749 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -16554,7 +16554,9 @@ const docTemplate = `{ "startup_timeout", "auth", "config", - "usage_limit" + "usage_limit", + "missing_key", + "provider_disabled" ], "x-enum-varnames": [ "ChatErrorKindGeneric", @@ -16564,7 +16566,9 @@ const docTemplate = `{ "ChatErrorKindStartupTimeout", "ChatErrorKindAuth", "ChatErrorKindConfig", - "ChatErrorKindUsageLimit" + "ChatErrorKindUsageLimit", + "ChatErrorKindMissingKey", + "ChatErrorKindProviderDisabled" ] }, "codersdk.ChatFileMetadata": { @@ -20710,6 +20714,9 @@ const docTemplate = `{ "auth_url_params": { "type": "object" }, + "auto_repair_links": { + "type": "boolean" + }, "client_cert_file": { "type": "string" }, @@ -20729,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 33ffe3e4b4fd0..41b65fd52b618 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -14904,7 +14904,9 @@ "startup_timeout", "auth", "config", - "usage_limit" + "usage_limit", + "missing_key", + "provider_disabled" ], "x-enum-varnames": [ "ChatErrorKindGeneric", @@ -14914,7 +14916,9 @@ "ChatErrorKindStartupTimeout", "ChatErrorKindAuth", "ChatErrorKindConfig", - "ChatErrorKindUsageLimit" + "ChatErrorKindUsageLimit", + "ChatErrorKindMissingKey", + "ChatErrorKindProviderDisabled" ] }, "codersdk.ChatFileMetadata": { @@ -18909,6 +18913,9 @@ "auth_url_params": { "type": "object" }, + "auto_repair_links": { + "type": "boolean" + }, "client_cert_file": { "type": "string" }, @@ -18928,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/audit.go b/coderd/audit.go index 661019d063b40..a58168567f3a8 100644 --- a/coderd/audit.go +++ b/coderd/audit.go @@ -303,6 +303,12 @@ func auditLogDescription(alog database.GetAuditLogsOffsetRow) string { _, _ = b.WriteString("{user} ") } + // Chat write operations get semantic descriptions derived from the diff. + if desc, ok := chatAuditLogDescription(alog); ok { + _, _ = b.WriteString(desc) + return b.String() + } + switch { case alog.AuditLog.StatusCode == int32(http.StatusSeeOther): _, _ = b.WriteString("was redirected attempting to ") @@ -345,6 +351,56 @@ func auditLogDescription(alog database.GetAuditLogsOffsetRow) string { return b.String() } +// chatAuditLogDescription returns a description for successful chat write +// operations based on the diff contents. It returns false for non-chat +// resources, non-write actions, or error/redirect status codes, letting +// the caller fall through to the generic description. +func chatAuditLogDescription(alog database.GetAuditLogsOffsetRow) (string, bool) { + if alog.AuditLog.ResourceType != database.ResourceTypeChat || + alog.AuditLog.Action != database.AuditActionWrite || + alog.AuditLog.StatusCode >= 400 || + alog.AuditLog.StatusCode == int32(http.StatusSeeOther) { + return "", false + } + + var diff codersdk.AuditDiff + if err := json.Unmarshal(alog.AuditLog.Diff, &diff); err != nil { + return "", false + } + + // Single "archived" field: archive or unarchive. + if len(diff) == 1 { + if field, ok := diff["archived"]; ok { + oldVal, oldOK := field.Old.(bool) + newVal, newOK := field.New.(bool) + if oldOK && newOK { + if !oldVal && newVal { + return "archived chat {target}", true + } + if oldVal && !newVal { + return "unarchived chat {target}", true + } + } + } + } + + // All fields are ACL changes: sharing update. + if len(diff) > 0 { + aclOnly := true + for field := range diff { + if field != "user_acl" && field != "group_acl" { + aclOnly = false + break + } + } + if aclOnly { + return "updated sharing for chat {target}", true + } + } + + return "", false +} + func (api *API) auditLogIsResourceDeleted(ctx context.Context, alog database.GetAuditLogsOffsetRow) bool { switch alog.AuditLog.ResourceType { case database.ResourceTypeTemplate: diff --git a/coderd/audit_internal_test.go b/coderd/audit_internal_test.go index cc7fddf3e0cf6..640690cff92db 100644 --- a/coderd/audit_internal_test.go +++ b/coderd/audit_internal_test.go @@ -3,6 +3,7 @@ package coderd import ( "context" "database/sql" + "encoding/json" "testing" "github.com/google/uuid" @@ -14,6 +15,7 @@ import ( "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/dbmock" + "github.com/coder/coder/v2/codersdk" ) func TestAuditLogIsResourceDeleted(t *testing.T) { @@ -111,6 +113,91 @@ func TestAuditLogDescription(t *testing.T) { }, want: "{user} deleted the git ssh key", }, + { + name: "chat_archived", + alog: chatAuditLogRow(t, codersdk.AuditDiff{ + "archived": {Old: false, New: true}, + }), + want: "{user} archived chat {target}", + }, + { + name: "chat_unarchived", + alog: chatAuditLogRow(t, codersdk.AuditDiff{ + "archived": {Old: true, New: false}, + }), + want: "{user} unarchived chat {target}", + }, + { + name: "chat_sharing_user_acl", + alog: chatAuditLogRow(t, codersdk.AuditDiff{ + "user_acl": {Old: map[string]any{}, New: map[string]any{"user-1": map[string]any{"permissions": []string{"read"}}}}, + }), + want: "{user} updated sharing for chat {target}", + }, + { + name: "chat_sharing_group_acl", + alog: chatAuditLogRow(t, codersdk.AuditDiff{ + "group_acl": {Old: map[string]any{}, New: map[string]any{"group-1": map[string]any{"permissions": []string{"read"}}}}, + }), + want: "{user} updated sharing for chat {target}", + }, + { + name: "chat_sharing_both_acls", + alog: chatAuditLogRow(t, codersdk.AuditDiff{ + "user_acl": {Old: map[string]any{}, New: map[string]any{"user-1": map[string]any{"permissions": []string{"read"}}}}, + "group_acl": {Old: map[string]any{}, New: map[string]any{"group-1": map[string]any{"permissions": []string{"read"}}}}, + }), + want: "{user} updated sharing for chat {target}", + }, + { + name: "chat_mixed_diff_falls_through", + alog: chatAuditLogRow(t, codersdk.AuditDiff{ + "archived": {Old: false, New: true}, + "pin_order": {Old: 1, New: 0}, + }), + want: "{user} updated chat {target}", + }, + { + name: "chat_acl_with_extra_field_falls_through", + alog: chatAuditLogRow(t, codersdk.AuditDiff{ + "user_acl": {Old: map[string]any{}, New: map[string]any{}}, + "pin_order": {Old: 1, New: 0}, + }), + want: "{user} updated chat {target}", + }, + { + name: "chat_failed_write_no_override", + alog: func() database.GetAuditLogsOffsetRow { + row := chatAuditLogRow(t, codersdk.AuditDiff{ + "archived": {Old: false, New: true}, + }) + row.AuditLog.StatusCode = 400 + return row + }(), + want: "{user} unsuccessfully attempted to write chat {target}", + }, + { + name: "chat_redirect_no_override", + alog: func() database.GetAuditLogsOffsetRow { + row := chatAuditLogRow(t, codersdk.AuditDiff{ + "archived": {Old: false, New: true}, + }) + row.AuditLog.StatusCode = 303 + return row + }(), + want: "{user} was redirected attempting to write chat {target}", + }, + { + name: "chat_non_write_action_no_override", + alog: func() database.GetAuditLogsOffsetRow { + row := chatAuditLogRow(t, codersdk.AuditDiff{ + "user_acl": {Old: map[string]any{}, New: map[string]any{"user-1": map[string]any{"permissions": []string{"read"}}}}, + }) + row.AuditLog.Action = database.AuditActionCreate + return row + }(), + want: "{user} created chat {target}", + }, } // nolint: paralleltest // no longer need to reinitialize loop vars in go 1.22 for _, tc := range testCases { @@ -121,3 +208,19 @@ func TestAuditLogDescription(t *testing.T) { }) } } + +// chatAuditLogRow builds a GetAuditLogsOffsetRow for a successful chat write +// with the given diff, suitable for testing auditLogDescription. +func chatAuditLogRow(t *testing.T, diff codersdk.AuditDiff) database.GetAuditLogsOffsetRow { + t.Helper() + rawDiff, err := json.Marshal(diff) + require.NoError(t, err) + return database.GetAuditLogsOffsetRow{ + AuditLog: database.AuditLog{ + Action: database.AuditActionWrite, + StatusCode: 200, + ResourceType: database.ResourceTypeChat, + Diff: rawDiff, + }, + } +} diff --git a/coderd/authlink/authlink.go b/coderd/authlink/authlink.go new file mode 100644 index 0000000000000..8bcd655ebefd3 --- /dev/null +++ b/coderd/authlink/authlink.go @@ -0,0 +1,135 @@ +package authlink + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "sort" + + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/coderd/database" +) + +// OIDCLinkAnalysis contains the results of analyzing OIDC user links +// grouped by their issuer prefix. +type OIDCLinkAnalysis struct { + Total int // Total OIDC user links + Unlinked int // linked_id == "" + CorrectIssuer int // linked_id starts with expectedIssuer|| + MismatchedCounts map[string]int // issuer -> count for non-matching issuers +} + +// MismatchedTotal returns the total number of links with a non-matching issuer. +func (a OIDCLinkAnalysis) MismatchedTotal() int { + total := 0 + for _, count := range a.MismatchedCounts { + total += count + } + return total +} + +// AnalyzeOIDCLinks queries OIDC user links grouped by issuer prefix and +// categorizes them relative to expectedIssuer. +func AnalyzeOIDCLinks(ctx context.Context, db database.Store, expectedIssuer string) (OIDCLinkAnalysis, error) { + rows, err := db.CountOIDCLinkedIDsByIssuer(ctx) + if err != nil { + return OIDCLinkAnalysis{}, xerrors.Errorf("count OIDC linked IDs by issuer: %w", err) + } + + analysis := OIDCLinkAnalysis{ + MismatchedCounts: make(map[string]int), + } + for _, row := range rows { + count := int(row.Count) + analysis.Total += count + switch { + case row.IssuerPrefix == "": + analysis.Unlinked += count + case row.IssuerPrefix == expectedIssuer: + analysis.CorrectIssuer += count + default: + analysis.MismatchedCounts[row.IssuerPrefix] += count + } + } + return analysis, nil +} + +// ResetMismatchedOIDCLinks resets linked_id to empty for all OIDC links whose +// issuer prefix does not match expectedIssuer. Returns the number of rows +// affected. +func ResetMismatchedOIDCLinks(ctx context.Context, db database.Store, expectedIssuer string) (int64, error) { + prefix := expectedIssuer + "||" + count, err := db.UnlinkOIDCUsersByIssuerMismatch(ctx, prefix) + if err != nil { + return 0, xerrors.Errorf("unlink OIDC users by issuer mismatch: %w", err) + } + return count, nil +} + +// ResolveIssuer uses OIDC discovery to fetch the canonical issuer string +// from the provider's .well-known/openid-configuration endpoint. +// This does not require OIDC client credentials. +// +// This works the same as `oidc.NewProvider`. The `oidc` package does not +// expose a method to extract the Issuer. So we have to manually make the +// http request. +func ResolveIssuer(ctx context.Context, cli *http.Client, issuerURL string) (string, error) { + wellKnownURL, err := url.JoinPath(issuerURL, "/.well-known/openid-configuration") + if err != nil { + return "", xerrors.Errorf("resolve issuer URL: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, wellKnownURL, nil) + if err != nil { + return "", xerrors.Errorf("create discovery request: %w", err) + } + + resp, err := cli.Do(req) + if err != nil { + return "", xerrors.Errorf("fetch OIDC discovery document: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", xerrors.Errorf("OIDC discovery returned HTTP %d", resp.StatusCode) + } + + var discovery struct { + Issuer string `json:"issuer"` + } + if err := json.NewDecoder(resp.Body).Decode(&discovery); err != nil { + return "", xerrors.Errorf("decode OIDC discovery document: %w", err) + } + if discovery.Issuer == "" { + return "", xerrors.New("OIDC discovery document has empty issuer field") + } + return discovery.Issuer, nil +} + +// PrintAnalysis writes a human-readable summary of the OIDC link analysis. +// Used for the cli command and debugging. +func PrintAnalysis(w io.Writer, analysis OIDCLinkAnalysis, issuer string) { + _, _ = fmt.Fprintf(w, "OIDC Link Analysis (issuer: %s)\n", issuer) + _, _ = fmt.Fprintf(w, " Total OIDC users: %d\n", analysis.Total) + _, _ = fmt.Fprintf(w, " Correctly linked: %d\n", analysis.CorrectIssuer) + _, _ = fmt.Fprintf(w, " Unlinked (empty linked_id): %d\n", analysis.Unlinked) + + mismatchedTotal := analysis.MismatchedTotal() + _, _ = fmt.Fprintf(w, " Linked to other issuers: %d\n", mismatchedTotal) + + if mismatchedTotal > 0 { + // Sort issuer keys for deterministic output. + issuers := make([]string, 0, len(analysis.MismatchedCounts)) + for issuer := range analysis.MismatchedCounts { + issuers = append(issuers, issuer) + } + sort.Strings(issuers) + for _, iss := range issuers { + _, _ = fmt.Fprintf(w, " %s: %d\n", iss, analysis.MismatchedCounts[iss]) + } + } +} diff --git a/coderd/authlink/authlink_test.go b/coderd/authlink/authlink_test.go new file mode 100644 index 0000000000000..cf7a0725cc461 --- /dev/null +++ b/coderd/authlink/authlink_test.go @@ -0,0 +1,305 @@ +package authlink_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/authlink" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/testutil" +) + +func TestAnalyzeOIDCLinks(t *testing.T) { + t.Parallel() + + t.Run("MixedIssuers", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + + db, _ := dbtestutil.NewDB(t) + const expectedIssuer = "https://accounts.google.com" + + // 3 users linked to the expected issuer. + for i := 0; i < 3; i++ { + user := dbgen.User(t, db, database.User{LoginType: database.LoginTypeOIDC}) + dbgen.UserLink(t, db, database.UserLink{ + UserID: user.ID, + LoginType: database.LoginTypeOIDC, + LinkedID: expectedIssuer + "||sub-" + user.ID.String(), + }) + } + + // 2 users linked to an old issuer. + for i := 0; i < 2; i++ { + user := dbgen.User(t, db, database.User{LoginType: database.LoginTypeOIDC}) + dbgen.UserLink(t, db, database.UserLink{ + UserID: user.ID, + LoginType: database.LoginTypeOIDC, + LinkedID: "https://old-issuer.example.com||sub-" + user.ID.String(), + }) + } + + // 1 user linked to another old issuer. + user := dbgen.User(t, db, database.User{LoginType: database.LoginTypeOIDC}) + dbgen.UserLink(t, db, database.UserLink{ + UserID: user.ID, + LoginType: database.LoginTypeOIDC, + LinkedID: "https://staging.example.com||sub-" + user.ID.String(), + }) + + // 1 unlinked user (empty linked_id). + unlinkedUser := dbgen.User(t, db, database.User{LoginType: database.LoginTypeOIDC}) + dbgen.UserLink(t, db, database.UserLink{ + UserID: unlinkedUser.ID, + LoginType: database.LoginTypeOIDC, + LinkedID: "", + }) + + analysis, err := authlink.AnalyzeOIDCLinks(ctx, db, expectedIssuer) + require.NoError(t, err) + + require.Equal(t, 7, analysis.Total) + require.Equal(t, 3, analysis.CorrectIssuer) + require.Equal(t, 1, analysis.Unlinked) + require.Equal(t, 3, analysis.MismatchedTotal()) + require.Equal(t, 2, analysis.MismatchedCounts["https://old-issuer.example.com"]) + require.Equal(t, 1, analysis.MismatchedCounts["https://staging.example.com"]) + }) + + t.Run("NoOIDCUsers", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + + db, _ := dbtestutil.NewDB(t) + + analysis, err := authlink.AnalyzeOIDCLinks(ctx, db, "https://issuer.example.com") + require.NoError(t, err) + require.Equal(t, 0, analysis.Total) + require.Equal(t, 0, analysis.CorrectIssuer) + require.Equal(t, 0, analysis.Unlinked) + require.Equal(t, 0, analysis.MismatchedTotal()) + }) + + t.Run("AllCorrect", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + + db, _ := dbtestutil.NewDB(t) + const expectedIssuer = "https://accounts.google.com" + + for i := 0; i < 3; i++ { + user := dbgen.User(t, db, database.User{LoginType: database.LoginTypeOIDC}) + dbgen.UserLink(t, db, database.UserLink{ + UserID: user.ID, + LoginType: database.LoginTypeOIDC, + LinkedID: expectedIssuer + "||sub-" + user.ID.String(), + }) + } + + analysis, err := authlink.AnalyzeOIDCLinks(ctx, db, expectedIssuer) + require.NoError(t, err) + require.Equal(t, 3, analysis.Total) + require.Equal(t, 3, analysis.CorrectIssuer) + require.Equal(t, 0, analysis.Unlinked) + require.Equal(t, 0, analysis.MismatchedTotal()) + }) + + t.Run("DeletedUsersExcluded", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + + db, _ := dbtestutil.NewDB(t) + const expectedIssuer = "https://accounts.google.com" + + // Active user with mismatched issuer. + activeUser := dbgen.User(t, db, database.User{LoginType: database.LoginTypeOIDC}) + dbgen.UserLink(t, db, database.UserLink{ + UserID: activeUser.ID, + LoginType: database.LoginTypeOIDC, + LinkedID: "https://old-issuer.example.com||sub-active", + }) + + // Create user and link first, then soft-delete the user. + // The DB trigger prevents inserting links for already-deleted users. + deletedUser := dbgen.User(t, db, database.User{ + LoginType: database.LoginTypeOIDC, + }) + dbgen.UserLink(t, db, database.UserLink{ + UserID: deletedUser.ID, + LoginType: database.LoginTypeOIDC, + LinkedID: "https://old-issuer.example.com||sub-deleted", + }) + require.NoError(t, db.UpdateUserDeletedByID(ctx, deletedUser.ID)) + + analysis, err := authlink.AnalyzeOIDCLinks(ctx, db, expectedIssuer) + require.NoError(t, err) + require.Equal(t, 1, analysis.Total) + require.Equal(t, 1, analysis.MismatchedTotal()) + }) + + t.Run("NonOIDCLinksExcluded", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + + db, _ := dbtestutil.NewDB(t) + const expectedIssuer = "https://accounts.google.com" + + // GitHub user link should not be counted. + ghUser := dbgen.User(t, db, database.User{LoginType: database.LoginTypeGithub}) + dbgen.UserLink(t, db, database.UserLink{ + UserID: ghUser.ID, + LoginType: database.LoginTypeGithub, + LinkedID: "github||12345", + }) + + analysis, err := authlink.AnalyzeOIDCLinks(ctx, db, expectedIssuer) + require.NoError(t, err) + require.Equal(t, 0, analysis.Total) + }) +} + +func TestResetMismatchedOIDCLinks(t *testing.T) { + t.Parallel() + + t.Run("ResetsOnlyMismatched", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + + db, _ := dbtestutil.NewDB(t) + const expectedIssuer = "https://accounts.google.com" + + // Correctly linked user. + correctUser := dbgen.User(t, db, database.User{LoginType: database.LoginTypeOIDC}) + correctLink := dbgen.UserLink(t, db, database.UserLink{ + UserID: correctUser.ID, + LoginType: database.LoginTypeOIDC, + LinkedID: expectedIssuer + "||sub-correct", + }) + + // Mismatched user. + mismatchedUser := dbgen.User(t, db, database.User{LoginType: database.LoginTypeOIDC}) + dbgen.UserLink(t, db, database.UserLink{ + UserID: mismatchedUser.ID, + LoginType: database.LoginTypeOIDC, + LinkedID: "https://old-issuer.example.com||sub-mismatched", + }) + + // Unlinked user (empty linked_id). + unlinkedUser := dbgen.User(t, db, database.User{LoginType: database.LoginTypeOIDC}) + dbgen.UserLink(t, db, database.UserLink{ + UserID: unlinkedUser.ID, + LoginType: database.LoginTypeOIDC, + LinkedID: "", + }) + + count, err := authlink.ResetMismatchedOIDCLinks(ctx, db, expectedIssuer) + require.NoError(t, err) + require.EqualValues(t, 1, count) + + // Verify the correct link is unchanged. + link, err := db.GetUserLinkByUserIDLoginType(ctx, database.GetUserLinkByUserIDLoginTypeParams{ + UserID: correctUser.ID, + LoginType: database.LoginTypeOIDC, + }) + require.NoError(t, err) + require.Equal(t, correctLink.LinkedID, link.LinkedID) + + // Verify the mismatched link was reset. + link, err = db.GetUserLinkByUserIDLoginType(ctx, database.GetUserLinkByUserIDLoginTypeParams{ + UserID: mismatchedUser.ID, + LoginType: database.LoginTypeOIDC, + }) + require.NoError(t, err) + require.Equal(t, "", link.LinkedID) + + // Verify the unlinked user is still unlinked. + link, err = db.GetUserLinkByUserIDLoginType(ctx, database.GetUserLinkByUserIDLoginTypeParams{ + UserID: unlinkedUser.ID, + LoginType: database.LoginTypeOIDC, + }) + require.NoError(t, err) + require.Equal(t, "", link.LinkedID) + }) + + t.Run("NothingToReset", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + + db, _ := dbtestutil.NewDB(t) + const expectedIssuer = "https://accounts.google.com" + + user := dbgen.User(t, db, database.User{LoginType: database.LoginTypeOIDC}) + dbgen.UserLink(t, db, database.UserLink{ + UserID: user.ID, + LoginType: database.LoginTypeOIDC, + LinkedID: expectedIssuer + "||sub-correct", + }) + + count, err := authlink.ResetMismatchedOIDCLinks(ctx, db, expectedIssuer) + require.NoError(t, err) + require.EqualValues(t, 0, count) + }) +} + +func TestResolveIssuer(t *testing.T) { + t.Parallel() + + t.Run("Success", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + + const expectedIssuer = "https://accounts.google.com" + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/.well-known/openid-configuration" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{ + "issuer": expectedIssuer, + }) + })) + defer srv.Close() + + issuer, err := authlink.ResolveIssuer(ctx, srv.Client(), srv.URL) + require.NoError(t, err) + require.Equal(t, expectedIssuer, issuer) + }) + + t.Run("EmptyIssuer", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{ + "issuer": "", + }) + })) + defer srv.Close() + + _, err := authlink.ResolveIssuer(ctx, srv.Client(), srv.URL) + require.Error(t, err) + require.Contains(t, err.Error(), "empty issuer") + }) + + t.Run("HTTPError", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + _, err := authlink.ResolveIssuer(ctx, srv.Client(), srv.URL) + require.Error(t, err) + require.Contains(t, err.Error(), "HTTP 500") + }) +} diff --git a/coderd/authlink/doc.go b/coderd/authlink/doc.go new file mode 100644 index 0000000000000..50396907c98ba --- /dev/null +++ b/coderd/authlink/doc.go @@ -0,0 +1,10 @@ +// Package authlink provides analysis and repair utilities for OIDC user link +// records stored in the user_links table. +// +// When an OIDC provider is changed, the issuer (and possibly subject) in the +// linked_id column changes. Because linked_id is composed as "issuer||subject", +// existing users get locked out with "Account already linked" errors. The +// functions in this package let an administrator inspect which links are +// affected and reset the mismatched ones so users can re-authenticate under the +// new provider. +package authlink diff --git a/coderd/autobuild/lifecycle_executor.go b/coderd/autobuild/lifecycle_executor.go index 84fff375e0e61..9e7e5dd071f4e 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, @@ -559,7 +559,7 @@ func getNextTransition( return database.WorkspaceTransitionStop, database.BuildReasonAutostop, nil case isEligibleForAutostart(user, ws, latestBuild, latestJob, templateSchedule, currentTick): return database.WorkspaceTransitionStart, database.BuildReasonAutostart, nil - case isEligibleForFailedStop(latestBuild, latestJob, templateSchedule, currentTick): + case isEligibleForFailedCleanup(latestBuild, latestJob, templateSchedule, currentTick): // Use task-specific reason for AI task workspaces. if ws.TaskID.Valid { return database.WorkspaceTransitionStop, database.BuildReasonTaskAutoPause, nil @@ -671,14 +671,17 @@ func isEligibleForDelete(ws database.Workspace, templateSchedule schedule.Templa return eligible } -// isEligibleForFailedStop returns true if the workspace is eligible to be stopped -// due to a failed build. -func isEligibleForFailedStop(build database.WorkspaceBuild, job database.ProvisionerJob, templateSchedule schedule.TemplateScheduleOptions, currentTick time.Time) bool { - // If the template has specified a failure TLL. +// isEligibleForFailedCleanup returns true if the workspace is eligible to be +// stopped due to a failed build. A failed start is cleaned up by stopping it, +// and a failed stop is retried by issuing another stop. In both cases the +// remediation is a stop build. +func isEligibleForFailedCleanup(build database.WorkspaceBuild, job database.ProvisionerJob, templateSchedule schedule.TemplateScheduleOptions, currentTick time.Time) bool { + // If the template has specified a failure TTL. return templateSchedule.FailureTTL > 0 && // And the job resulted in failure. job.JobStatus == database.ProvisionerJobStatusFailed && - build.Transition == database.WorkspaceTransitionStart && + (build.Transition == database.WorkspaceTransitionStart || + build.Transition == database.WorkspaceTransitionStop) && // And sufficient time has elapsed since the job has completed. job.CompletedAt.Valid && currentTick.Sub(job.CompletedAt.Time) > templateSchedule.FailureTTL diff --git a/coderd/autobuild/lifecycle_executor_test.go b/coderd/autobuild/lifecycle_executor_test.go index 345647977d663..f3126dc2f712a 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/azureidentity/azureidentity_test.go b/coderd/azureidentity/azureidentity_test.go index 04e2f003cdf3c..14ca0c53ab673 100644 --- a/coderd/azureidentity/azureidentity_test.go +++ b/coderd/azureidentity/azureidentity_test.go @@ -21,6 +21,7 @@ import ( func TestValidate(t *testing.T) { t.Parallel() + t.Skip("See https://github.com/coder/internal/issues/1602") mustTime := func(layout string, value string) time.Time { ti, err := time.Parse(layout, value) diff --git a/coderd/coderd.go b/coderd/coderd.go index c87adc564769b..558a9981a8787 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1004,7 +1004,9 @@ func New(options *Options) *API { tracing.Middleware(api.TracerProvider), httpmw.AttachRequestID, httpmw.ExtractRealIP(api.RealIPConfig), - loggermw.Logger(api.Logger), + loggermw.Logger(api.Logger, func(r *http.Request) string { + return httpmw.EffectiveHost(api.RealIPConfig, r) + }), singleSlashMW, rolestore.CustomRoleMW, // Validate API key on every request (if present) and store @@ -2490,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/coderd_test.go b/coderd/coderd_test.go index ccf9c8de8fd12..2a7f3ad9bf31e 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/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index ab8d2271c3b8f..cc18462b55e2c 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 e245898de6c42..aa776b84fea12 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/coderdtest/oidctest/idp.go b/coderd/coderdtest/oidctest/idp.go index 5f6a8587ddc95..0c14e0f0b0c5a 100644 --- a/coderd/coderdtest/oidctest/idp.go +++ b/coderd/coderdtest/oidctest/idp.go @@ -216,8 +216,9 @@ type FakeIDP struct { hookAuthenticateClient func(t testing.TB, req *http.Request) (url.Values, error) serve bool // optional middlewares - middlewares chi.Middlewares - defaultExpire time.Duration + middlewares chi.Middlewares + defaultExpire time.Duration + omitEmailVerifiedDefault bool } func StatusError(code int, err error) error { @@ -378,6 +379,15 @@ func WithIssuer(issuer string) func(*FakeIDP) { } } +// WithOmitEmailVerifiedDefault suppresses the default email_verified=true +// injection in encodeClaims. Use this for tests that exercise the handler's +// absent-claim rejection path. +func WithOmitEmailVerifiedDefault() func(*FakeIDP) { + return func(f *FakeIDP) { + f.omitEmailVerifiedDefault = true + } +} + type With429Arguments struct { AllPaths bool TokenPath bool @@ -907,6 +917,17 @@ func (f *FakeIDP) encodeClaims(t testing.TB, claims jwt.MapClaims) string { claims["iss"] = f.locked.Issuer() } + // Default email_verified to true so that tests that do not care + // about the email_verified flow are not forced to set it. + // Tests that need a different value can set it explicitly. + // Use WithOmitEmailVerifiedDefault() to suppress this default + // for tests that need to exercise the absent-claim path. + if !f.omitEmailVerifiedDefault { + if _, ok := claims["email_verified"]; !ok { + claims["email_verified"] = true + } + } + signed, err := jwt.NewWithClaims(jwt.SigningMethodRS256, claims).SignedString(f.locked.PrivateKey()) require.NoError(t, err) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 74aa3f9f468a9..f338ba7db94e2 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -412,6 +412,11 @@ var ( User: []rbac.Permission{}, ByOrgID: map[string]rbac.OrgPermissions{ orgID.String(): { + Org: rbac.Permissions(map[string][]policy.Action{ + // SubAgentAPI needs to check metadata of templates + // potentially shared via group_acl. + rbac.ResourceTemplate.Type: {policy.ActionRead}, + }), Member: rbac.Permissions(map[string][]policy.Action{ rbac.ResourceWorkspace.Type: {policy.ActionRead, policy.ActionUpdate, policy.ActionCreateAgent, policy.ActionDeleteAgent, policy.ActionUpdateAgent}, }), @@ -1657,6 +1662,13 @@ func (q *querier) AutoArchiveInactiveChats(ctx context.Context, arg database.Aut return q.db.AutoArchiveInactiveChats(ctx, arg) } +func (q *querier) BackfillChatModelConfigProvider(ctx context.Context, arg database.BackfillChatModelConfigProviderParams) (sql.Result, error) { + if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil { + return nil, err + } + return q.db.BackfillChatModelConfigProvider(ctx, arg) +} + func (q *querier) BackoffChatDiffStatus(ctx context.Context, arg database.BackoffChatDiffStatusParams) error { // This is a system-level operation used by the gitsync // background worker to reschedule failed refreshes. Same @@ -1841,6 +1853,14 @@ func (q *querier) CountInProgressPrebuilds(ctx context.Context) ([]database.Coun return q.db.CountInProgressPrebuilds(ctx) } +func (q *querier) CountOIDCLinkedIDsByIssuer(ctx context.Context) ([]database.CountOIDCLinkedIDsByIssuerRow, error) { + // Requires the ability to read all user's personal data. + if err := q.authorizeContext(ctx, policy.ActionReadPersonal, rbac.ResourceUser); err != nil { + return nil, err + } + return q.db.CountOIDCLinkedIDsByIssuer(ctx) +} + func (q *querier) CountPendingNonActivePrebuilds(ctx context.Context) ([]database.CountPendingNonActivePrebuildsRow, error) { if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceWorkspace.All()); err != nil { return nil, err @@ -1939,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 { @@ -5366,6 +5393,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) } @@ -6504,6 +6541,13 @@ func (q *querier) UnfavoriteWorkspace(ctx context.Context, id uuid.UUID) error { return update(q.log, q.auth, fetch, q.db.UnfavoriteWorkspace)(ctx, id) } +func (q *querier) UnlinkOIDCUsersByIssuerMismatch(ctx context.Context, expectedPrefix string) (int64, error) { + if err := q.authorizeContext(ctx, policy.ActionUpdatePersonal, rbac.ResourceUser); err != nil { + return 0, err + } + return q.db.UnlinkOIDCUsersByIssuerMismatch(ctx, expectedPrefix) +} + func (q *querier) UnpinChatByID(ctx context.Context, id uuid.UUID) error { chat, err := q.db.GetChatByID(ctx, id) if err != nil { @@ -7476,6 +7520,13 @@ func (q *querier) UpdateUserLink(ctx context.Context, arg database.UpdateUserLin return fetchAndQuery(q.log, q.auth, policy.ActionUpdatePersonal, fetch, q.db.UpdateUserLink)(ctx, arg) } +func (q *querier) UpdateUserLinkedID(ctx context.Context, arg database.UpdateUserLinkedIDParams) (database.UserLink, error) { + if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceUserObject(arg.UserID)); err != nil { + return database.UserLink{}, err + } + return q.db.UpdateUserLinkedID(ctx, arg) +} + func (q *querier) UpdateUserLoginType(ctx context.Context, arg database.UpdateUserLoginTypeParams) (database.User, error) { if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceSystem); err != nil { return database.User{}, err diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 2cff1d6c8ceee..8d06ce7cdccfc 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}}) @@ -3150,6 +3155,12 @@ func (s *MethodTestSuite) TestUser() { dbm.EXPECT().UpdateUserLink(gomock.Any(), arg).Return(link, nil).AnyTimes() check.Args(arg).Asserts(link, policy.ActionUpdatePersonal).Returns(link) })) + s.Run("UpdateUserLinkedID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + link := testutil.Fake(s.T(), faker, database.UserLink{}) + arg := database.UpdateUserLinkedIDParams{LinkedID: link.LinkedID, UserID: link.UserID, LoginType: link.LoginType} + dbm.EXPECT().UpdateUserLinkedID(gomock.Any(), arg).Return(link, nil).AnyTimes() + check.Args(arg).Asserts(link, policy.ActionUpdate).Returns(link) + })) s.Run("UpdateUserRoles", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { u := testutil.Fake(s.T(), faker, database.User{RBACRoles: []string{codersdk.RoleTemplateAdmin}}) o := u @@ -4528,6 +4539,15 @@ func (s *MethodTestSuite) TestSystemFunctions() { dbm.EXPECT().GetUserLinkByLinkedID(gomock.Any(), l.LinkedID).Return(l, nil).AnyTimes() check.Args(l.LinkedID).Asserts(rbac.ResourceSystem, policy.ActionRead).Returns(l) })) + s.Run("CountOIDCLinkedIDsByIssuer", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().CountOIDCLinkedIDsByIssuer(gomock.Any()).Return([]database.CountOIDCLinkedIDsByIssuerRow{}, nil).AnyTimes() + check.Args().Asserts(rbac.ResourceUser, policy.ActionReadPersonal).Returns([]database.CountOIDCLinkedIDsByIssuerRow{}) + })) + s.Run("UnlinkOIDCUsersByIssuerMismatch", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().UnlinkOIDCUsersByIssuerMismatch(gomock.Any(), "issuer||").Return(int64(0), nil).AnyTimes() + check.Args("issuer||").Asserts(rbac.ResourceUser, policy.ActionUpdatePersonal).Returns(int64(0)) + })) + s.Run("GetUserLinkByUserIDLoginType", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { l := testutil.Fake(s.T(), faker, database.UserLink{}) arg := database.GetUserLinkByUserIDLoginTypeParams{UserID: l.UserID, LoginType: l.LoginType} @@ -4860,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() @@ -6493,6 +6518,7 @@ func (s *MethodTestSuite) TestAIBridge() { provider := testutil.Fake(s.T(), faker, database.AIProvider{}) arg := database.UpdateAIProviderParams{ ID: provider.ID, + Type: provider.Type, Enabled: true, BaseUrl: "https://api.example.com/", } @@ -6504,6 +6530,14 @@ func (s *MethodTestSuite) TestAIBridge() { dbm.EXPECT().DeleteAIProviderByID(gomock.Any(), provider.ID).Return(nil).AnyTimes() check.Args(provider.ID).Asserts(rbac.ResourceAIProvider, policy.ActionDelete).Returns() })) + s.Run("BackfillChatModelConfigProvider", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + arg := database.BackfillChatModelConfigProviderParams{ + OldProvider: "anthropic", + NewProvider: "bedrock", + } + dbm.EXPECT().BackfillChatModelConfigProvider(gomock.Any(), arg).Return(nil, nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate) + })) s.Run("UpdateEncryptedAIProviderSettings", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { provider := testutil.Fake(s.T(), faker, database.AIProvider{}) arg := database.UpdateEncryptedAIProviderSettingsParams{ diff --git a/coderd/database/dbfake/dbfake.go b/coderd/database/dbfake/dbfake.go index 0b859a4fb1c66..82b66f504aaf7 100644 --- a/coderd/database/dbfake/dbfake.go +++ b/coderd/database/dbfake/dbfake.go @@ -69,6 +69,8 @@ type WorkspaceBuildBuilder struct { jobErrorCode string // Error code for failed jobs provisionerState []byte + + prebuiltWorkspaceBuildStage sdkproto.PrebuiltWorkspaceBuildStage } // BuilderOption is a functional option for customizing job timestamps @@ -149,6 +151,14 @@ func (b WorkspaceBuildBuilder) ProvisionerState(state []byte) WorkspaceBuildBuil return b } +// MarkPrebuiltWorkspaceClaim marks the build's provisioner job as the claim +// of a prebuilt workspace, mirroring wsbuilder.MarkPrebuiltWorkspaceClaim. +func (b WorkspaceBuildBuilder) MarkPrebuiltWorkspaceClaim() WorkspaceBuildBuilder { + //nolint: revive // returns modified struct + b.prebuiltWorkspaceBuildStage = sdkproto.PrebuiltWorkspaceBuildStage_CLAIM + return b +} + func (b WorkspaceBuildBuilder) Resource(resource ...*sdkproto.Resource) WorkspaceBuildBuilder { //nolint: revive // returns modified struct b.resources = append(b.resources, resource...) @@ -368,7 +378,8 @@ func (b WorkspaceBuildBuilder) doInTX() WorkspaceResponse { // Create a provisioner job for the build! payload, err := json.Marshal(provisionerdserver.WorkspaceProvisionJob{ - WorkspaceBuildID: b.seed.ID, + WorkspaceBuildID: b.seed.ID, + PrebuiltWorkspaceBuildStage: b.prebuiltWorkspaceBuildStage, }) require.NoError(b.t, err) diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index 834bad62749be..a1d4db401795e 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/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index fd4537ccec712..3da8dd6583002 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -5,6 +5,7 @@ package dbmetrics import ( "context" + "database/sql" "encoding/json" "slices" "time" @@ -185,6 +186,14 @@ func (m queryMetricsStore) AutoArchiveInactiveChats(ctx context.Context, arg dat return r0, r1 } +func (m queryMetricsStore) BackfillChatModelConfigProvider(ctx context.Context, arg database.BackfillChatModelConfigProviderParams) (sql.Result, error) { + start := time.Now() + r0, r1 := m.s.BackfillChatModelConfigProvider(ctx, arg) + m.queryLatencies.WithLabelValues("BackfillChatModelConfigProvider").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "BackfillChatModelConfigProvider").Inc() + return r0, r1 +} + func (m queryMetricsStore) BackoffChatDiffStatus(ctx context.Context, arg database.BackoffChatDiffStatusParams) error { start := time.Now() r0 := m.s.BackoffChatDiffStatus(ctx, arg) @@ -345,6 +354,14 @@ func (m queryMetricsStore) CountInProgressPrebuilds(ctx context.Context) ([]data return r0, r1 } +func (m queryMetricsStore) CountOIDCLinkedIDsByIssuer(ctx context.Context) ([]database.CountOIDCLinkedIDsByIssuerRow, error) { + start := time.Now() + r0, r1 := m.s.CountOIDCLinkedIDsByIssuer(ctx) + m.queryLatencies.WithLabelValues("CountOIDCLinkedIDsByIssuer").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "CountOIDCLinkedIDsByIssuer").Inc() + return r0, r1 +} + func (m queryMetricsStore) CountPendingNonActivePrebuilds(ctx context.Context) ([]database.CountPendingNonActivePrebuildsRow, error) { start := time.Now() r0, r1 := m.s.CountPendingNonActivePrebuilds(ctx) @@ -441,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) @@ -3665,6 +3690,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) @@ -4697,6 +4730,14 @@ func (m queryMetricsStore) UnfavoriteWorkspace(ctx context.Context, id uuid.UUID return r0 } +func (m queryMetricsStore) UnlinkOIDCUsersByIssuerMismatch(ctx context.Context, expectedPrefix string) (int64, error) { + start := time.Now() + r0, r1 := m.s.UnlinkOIDCUsersByIssuerMismatch(ctx, expectedPrefix) + m.queryLatencies.WithLabelValues("UnlinkOIDCUsersByIssuerMismatch").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UnlinkOIDCUsersByIssuerMismatch").Inc() + return r0, r1 +} + func (m queryMetricsStore) UnpinChatByID(ctx context.Context, id uuid.UUID) error { start := time.Now() r0 := m.s.UnpinChatByID(ctx, id) @@ -5353,6 +5394,14 @@ func (m queryMetricsStore) UpdateUserLink(ctx context.Context, arg database.Upda return r0, r1 } +func (m queryMetricsStore) UpdateUserLinkedID(ctx context.Context, arg database.UpdateUserLinkedIDParams) (database.UserLink, error) { + start := time.Now() + r0, r1 := m.s.UpdateUserLinkedID(ctx, arg) + m.queryLatencies.WithLabelValues("UpdateUserLinkedID").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpdateUserLinkedID").Inc() + return r0, r1 +} + func (m queryMetricsStore) UpdateUserLoginType(ctx context.Context, arg database.UpdateUserLoginTypeParams) (database.User, error) { start := time.Now() r0, r1 := m.s.UpdateUserLoginType(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 36f8429e8fa9c..f1628b5290cc7 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -11,6 +11,7 @@ package dbmock import ( context "context" + sql "database/sql" json "encoding/json" reflect "reflect" time "time" @@ -193,6 +194,21 @@ func (mr *MockStoreMockRecorder) AutoArchiveInactiveChats(ctx, arg any) *gomock. return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AutoArchiveInactiveChats", reflect.TypeOf((*MockStore)(nil).AutoArchiveInactiveChats), ctx, arg) } +// BackfillChatModelConfigProvider mocks base method. +func (m *MockStore) BackfillChatModelConfigProvider(ctx context.Context, arg database.BackfillChatModelConfigProviderParams) (sql.Result, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "BackfillChatModelConfigProvider", ctx, arg) + ret0, _ := ret[0].(sql.Result) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// BackfillChatModelConfigProvider indicates an expected call of BackfillChatModelConfigProvider. +func (mr *MockStoreMockRecorder) BackfillChatModelConfigProvider(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BackfillChatModelConfigProvider", reflect.TypeOf((*MockStore)(nil).BackfillChatModelConfigProvider), ctx, arg) +} + // BackoffChatDiffStatus mocks base method. func (m *MockStore) BackoffChatDiffStatus(ctx context.Context, arg database.BackoffChatDiffStatusParams) error { m.ctrl.T.Helper() @@ -543,6 +559,21 @@ func (mr *MockStoreMockRecorder) CountInProgressPrebuilds(ctx any) *gomock.Call return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CountInProgressPrebuilds", reflect.TypeOf((*MockStore)(nil).CountInProgressPrebuilds), ctx) } +// CountOIDCLinkedIDsByIssuer mocks base method. +func (m *MockStore) CountOIDCLinkedIDsByIssuer(ctx context.Context) ([]database.CountOIDCLinkedIDsByIssuerRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CountOIDCLinkedIDsByIssuer", ctx) + ret0, _ := ret[0].([]database.CountOIDCLinkedIDsByIssuerRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CountOIDCLinkedIDsByIssuer indicates an expected call of CountOIDCLinkedIDsByIssuer. +func (mr *MockStoreMockRecorder) CountOIDCLinkedIDsByIssuer(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CountOIDCLinkedIDsByIssuer", reflect.TypeOf((*MockStore)(nil).CountOIDCLinkedIDsByIssuer), ctx) +} + // CountPendingNonActivePrebuilds mocks base method. func (m *MockStore) CountPendingNonActivePrebuilds(ctx context.Context) ([]database.CountPendingNonActivePrebuildsRow, error) { m.ctrl.T.Helper() @@ -716,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() @@ -6870,6 +6916,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() @@ -8896,6 +8957,21 @@ func (mr *MockStoreMockRecorder) UnfavoriteWorkspace(ctx, id any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UnfavoriteWorkspace", reflect.TypeOf((*MockStore)(nil).UnfavoriteWorkspace), ctx, id) } +// UnlinkOIDCUsersByIssuerMismatch mocks base method. +func (m *MockStore) UnlinkOIDCUsersByIssuerMismatch(ctx context.Context, expectedPrefix string) (int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UnlinkOIDCUsersByIssuerMismatch", ctx, expectedPrefix) + ret0, _ := ret[0].(int64) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UnlinkOIDCUsersByIssuerMismatch indicates an expected call of UnlinkOIDCUsersByIssuerMismatch. +func (mr *MockStoreMockRecorder) UnlinkOIDCUsersByIssuerMismatch(ctx, expectedPrefix any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UnlinkOIDCUsersByIssuerMismatch", reflect.TypeOf((*MockStore)(nil).UnlinkOIDCUsersByIssuerMismatch), ctx, expectedPrefix) +} + // UnpinChatByID mocks base method. func (m *MockStore) UnpinChatByID(ctx context.Context, id uuid.UUID) error { m.ctrl.T.Helper() @@ -10092,6 +10168,21 @@ func (mr *MockStoreMockRecorder) UpdateUserLink(ctx, arg any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserLink", reflect.TypeOf((*MockStore)(nil).UpdateUserLink), ctx, arg) } +// UpdateUserLinkedID mocks base method. +func (m *MockStore) UpdateUserLinkedID(ctx context.Context, arg database.UpdateUserLinkedIDParams) (database.UserLink, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateUserLinkedID", ctx, arg) + ret0, _ := ret[0].(database.UserLink) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateUserLinkedID indicates an expected call of UpdateUserLinkedID. +func (mr *MockStoreMockRecorder) UpdateUserLinkedID(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserLinkedID", reflect.TypeOf((*MockStore)(nil).UpdateUserLinkedID), ctx, arg) +} + // UpdateUserLoginType mocks base method. func (m *MockStore) UpdateUserLoginType(ctx context.Context, arg database.UpdateUserLoginTypeParams) (database.User, error) { m.ctrl.T.Helper() diff --git a/coderd/database/dbpurge/dbpurge.go b/coderd/database/dbpurge/dbpurge.go index c87bc5a8df9ae..1f3644633f5ed 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 f49426e9560d2..a7fe08d1ebdf1 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 4ebd645a7a270..966b065e6c88b 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/migrations/000499_ai_provider_type_chatd_values.down.sql b/coderd/database/migrations/000499_ai_provider_type_chatd_values.down.sql index a8b89359aa51a..ab84bd795f5c9 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 104514d32c12c..30df7758dded1 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 0b3e0c240c9c2..f148860bc5f7b 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() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 6b16e0771a1d4..96ee7c6bbd4d4 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -6,6 +6,7 @@ package database import ( "context" + "database/sql" "encoding/json" "time" @@ -70,6 +71,11 @@ type sqlcQuerier interface { // created_at ASC flows through to dbpurge's digest truncation; see // buildDigestData in dbpurge.go for the tradeoff rationale. AutoArchiveInactiveChats(ctx context.Context, arg AutoArchiveInactiveChatsParams) ([]AutoArchiveInactiveChatsRow, error) + // old_provider is matched as text; new_provider is also cast to ai_provider_type + // for the EXISTS check against ai_providers.type. + // ai_provider_id IS NOT NULL is defensive; the check constraint already + // enforces that non-deleted rows always have a provider ID. + BackfillChatModelConfigProvider(ctx context.Context, arg BackfillChatModelConfigProviderParams) (sql.Result, error) BackoffChatDiffStatus(ctx context.Context, arg BackoffChatDiffStatusParams) error BatchUpdateWorkspaceAgentMetadata(ctx context.Context, arg BatchUpdateWorkspaceAgentMetadataParams) error BatchUpdateWorkspaceLastUsedAt(ctx context.Context, arg BatchUpdateWorkspaceLastUsedAtParams) error @@ -96,6 +102,11 @@ type sqlcQuerier interface { // CountInProgressPrebuilds returns the number of in-progress prebuilds, grouped by preset ID and transition. // Prebuild considered in-progress if it's in the "pending", "starting", "stopping", or "deleting" state. CountInProgressPrebuilds(ctx context.Context) ([]CountInProgressPrebuildsRow, error) + // Groups OIDC user links by their issuer prefix (the part before "||" in + // linked_id) and returns a count for each. Empty linked_ids are reported + // with an empty issuer_prefix. Used for analysis before resetting + // mismatched links. + CountOIDCLinkedIDsByIssuer(ctx context.Context) ([]CountOIDCLinkedIDsByIssuerRow, error) // CountPendingNonActivePrebuilds returns the number of pending prebuilds for non-active template versions CountPendingNonActivePrebuilds(ctx context.Context) ([]CountPendingNonActivePrebuildsRow, error) CountUnreadInboxNotificationsByUserID(ctx context.Context, userID uuid.UUID) (int64, error) @@ -113,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 @@ -907,6 +924,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) @@ -1161,6 +1183,10 @@ type sqlcQuerier interface { // This will always work regardless of the current state of the template version. UnarchiveTemplateVersion(ctx context.Context, arg UnarchiveTemplateVersionParams) error UnfavoriteWorkspace(ctx context.Context, id uuid.UUID) error + // Resets linked_id to '' for OIDC links where the linked_id is non-empty + // and does not begin with the expected issuer prefix. This allows users to + // re-authenticate under a new OIDC provider. + UnlinkOIDCUsersByIssuerMismatch(ctx context.Context, expectedPrefix string) (int64, error) UnpinChatByID(ctx context.Context, id uuid.UUID) error UnsetDefaultChatModelConfigs(ctx context.Context) error UpdateAIBridgeInterceptionEnded(ctx context.Context, arg UpdateAIBridgeInterceptionEndedParams) (AIBridgeInterception, error) @@ -1296,6 +1322,10 @@ type sqlcQuerier interface { UpdateUserHashedPassword(ctx context.Context, arg UpdateUserHashedPasswordParams) error UpdateUserLastSeenAt(ctx context.Context, arg UpdateUserLastSeenAtParams) (User, error) UpdateUserLink(ctx context.Context, arg UpdateUserLinkParams) (UserLink, error) + // Backfills linked_id for legacy user_links that were created before + // linked_id tracking was added. Only updates when linked_id is empty + // to avoid overwriting a valid binding. + UpdateUserLinkedID(ctx context.Context, arg UpdateUserLinkedIDParams) (UserLink, error) UpdateUserLoginType(ctx context.Context, arg UpdateUserLoginTypeParams) (User, error) UpdateUserNotificationPreferences(ctx context.Context, arg UpdateUserNotificationPreferencesParams) (int64, error) UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) (User, error) diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index f181e2e94bc46..9a453bc2f2f6c 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", @@ -7464,6 +7516,178 @@ func TestWorkspaceAgentNameUniqueTrigger(t *testing.T) { }) } +func TestUpsertWorkspaceAppCannotRebindAcrossWorkspaces(t *testing.T) { + t.Parallel() + + db, _ := dbtestutil.NewDB(t) + org := dbgen.Organization(t, db, database.Organization{}) + ctx := testutil.Context(t, testutil.WaitShort) + + // createWorkspace builds the owner -> template -> version -> workspace chain + // and returns the workspace plus its template version so callers can create + // additional builds (and thus agents) within the same workspace. + createWorkspace := func(t *testing.T) (database.WorkspaceTable, uuid.UUID) { + t.Helper() + user := dbgen.User(t, db, database.User{}) + template := dbgen.Template(t, db, database.Template{ + OrganizationID: org.ID, + CreatedBy: user.ID, + }) + version := dbgen.TemplateVersion(t, db, database.TemplateVersion{ + TemplateID: uuid.NullUUID{Valid: true, UUID: template.ID}, + OrganizationID: org.ID, + CreatedBy: user.ID, + }) + workspace := dbgen.Workspace(t, db, database.WorkspaceTable{ + OrganizationID: org.ID, + TemplateID: template.ID, + OwnerID: user.ID, + }) + return workspace, version.ID + } + + // addAgent creates a build, resource, and agent for the workspace. The + // build's JobID matches the resource's JobID so the upsert's + // agent -> resource -> workspace_builds(job_id) -> workspace_id traversal + // resolves to the workspace. + addAgent := func(t *testing.T, workspace database.WorkspaceTable, versionID uuid.UUID, buildNumber int32) database.WorkspaceAgent { + t.Helper() + job := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{ + Type: database.ProvisionerJobTypeWorkspaceBuild, + OrganizationID: org.ID, + }) + dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{ + BuildNumber: buildNumber, + JobID: job.ID, + WorkspaceID: workspace.ID, + TemplateVersionID: versionID, + }) + resource := dbgen.WorkspaceResource(t, db, database.WorkspaceResource{ + JobID: job.ID, + }) + return dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{ + ResourceID: resource.ID, + }) + } + + upsertApp := func(appID, agentID uuid.UUID, slug string) (database.WorkspaceApp, error) { + return db.UpsertWorkspaceApp(ctx, database.UpsertWorkspaceAppParams{ + ID: appID, + CreatedAt: dbtime.Now(), + AgentID: agentID, + Slug: slug, + DisplayName: "Code Server", + Icon: "/icon.png", + SharingLevel: database.AppSharingLevelOwner, + Health: database.WorkspaceAppHealthDisabled, + OpenIn: database.WorkspaceAppOpenInSlimWindow, + }) + } + + // Given: two independent workspaces, each with an agent that resolves to its + // own workspace. + workspaceA, versionA := createWorkspace(t) + workspaceB, versionB := createWorkspace(t) + agentA := addAgent(t, workspaceA, versionA, 1) + agentB := addAgent(t, workspaceB, versionB, 1) + + gotA, err := db.GetWorkspaceByAgentID(ctx, agentA.ID) + require.NoError(t, err) + require.Equal(t, workspaceA.ID, gotA.ID) + gotB, err := db.GetWorkspaceByAgentID(ctx, agentB.ID) + require.NoError(t, err) + require.Equal(t, workspaceB.ID, gotB.ID) + + appID := uuid.New() + const originalSlug = "code-server" + + // Initial insert under workspace A's agent succeeds (no conflict). + app, err := upsertApp(appID, agentA.ID, originalSlug) + require.NoError(t, err) + require.Equal(t, appID, app.ID) + require.Equal(t, agentA.ID, app.AgentID) + require.Equal(t, originalSlug, app.Slug) + + // Upserting the same app id onto workspace B's agent is rejected because the + // existing row and the incoming agent resolve to different workspaces. The + // guard updates zero rows, so the :one query returns sql.ErrNoRows. + _, err = upsertApp(appID, agentB.ID, "hijacked") + require.ErrorIs(t, err, sql.ErrNoRows) + + // The app remains bound to workspace A's agent, unchanged. + appsA, err := db.GetWorkspaceAppsByAgentID(ctx, agentA.ID) + require.NoError(t, err) + require.Len(t, appsA, 1) + require.Equal(t, appID, appsA[0].ID) + require.Equal(t, agentA.ID, appsA[0].AgentID) + require.Equal(t, originalSlug, appsA[0].Slug) + + // Workspace B's agent has no app. + appsB, err := db.GetWorkspaceAppsByAgentID(ctx, agentB.ID) + require.NoError(t, err) + require.Empty(t, appsB) + + // A legitimate rebuild of workspace A produces a new agent (agent IDs are + // regenerated every build). Rebinding the persistent app to it succeeds + // because both agents resolve to workspace A. + agentA2 := addAgent(t, workspaceA, versionA, 2) + app, err = upsertApp(appID, agentA2.ID, "code-server-v2") + require.NoError(t, err) + require.Equal(t, agentA2.ID, app.AgentID) + require.Equal(t, "code-server-v2", app.Slug) + + appsA2, err := db.GetWorkspaceAppsByAgentID(ctx, agentA2.ID) + require.NoError(t, err) + require.Len(t, appsA2, 1) + require.Equal(t, appID, appsA2[0].ID) + + // Set up a template-import agent. It is intentionally not associated with + // a workspace build, so it resolves to no workspace. + importJob := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{ + Type: database.ProvisionerJobTypeTemplateVersionImport, + OrganizationID: org.ID, + }) + importResource := dbgen.WorkspaceResource(t, db, database.WorkspaceResource{ + JobID: importJob.ID, + }) + importAgent := dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{ + ResourceID: importResource.ID, + }) + _, err = db.GetWorkspaceByAgentID(ctx, importAgent.ID) + require.ErrorIs(t, err, sql.ErrNoRows, "import agent must not resolve to a workspace") + + // An app that already belongs to a workspace cannot be rebound to a + // template-import agent. Otherwise a second update could move it from + // the import agent to a different workspace. + _, err = upsertApp(appID, importAgent.ID, "hijacked-by-import") + require.ErrorIs(t, err, sql.ErrNoRows) + + appsA2, err = db.GetWorkspaceAppsByAgentID(ctx, agentA2.ID) + require.NoError(t, err) + require.Len(t, appsA2, 1) + require.Equal(t, appID, appsA2[0].ID) + require.Equal(t, agentA2.ID, appsA2[0].AgentID) + require.Equal(t, "code-server-v2", appsA2[0].Slug) + + appsImport, err := db.GetWorkspaceAppsByAgentID(ctx, importAgent.ID) + require.NoError(t, err) + require.Empty(t, appsImport) + + _, err = upsertApp(appID, agentB.ID, "hijacked-after-import") + require.ErrorIs(t, err, sql.ErrNoRows) + + unownedAppID := uuid.New() + _, err = upsertApp(unownedAppID, importAgent.ID, "import-app") + require.NoError(t, err) + + // An app whose existing agent belongs to a template-import job resolves to + // no workspace, so rebinding it is permitted. It is not a cross-tenant + // victim. + rebound, err := upsertApp(unownedAppID, agentA.ID, "import-app") + require.NoError(t, err) + require.Equal(t, agentA.ID, rebound.AgentID) +} + func TestGetWorkspaceAgentsByParentID(t *testing.T) { t.Parallel() @@ -9921,8 +10145,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 +10182,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 +10207,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 2ba4b923def2e..80487a694a95e 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -632,19 +632,21 @@ const updateAIProvider = `-- name: UpdateAIProvider :one UPDATE ai_providers SET - display_name = $1::text, - enabled = $2::boolean, - base_url = $3::text, - settings = $4::text, - settings_key_id = $5::text, + type = $1::ai_provider_type, + display_name = $2::text, + enabled = $3::boolean, + base_url = $4::text, + settings = $5::text, + settings_key_id = $6::text, updated_at = NOW() WHERE - id = $6::uuid AND deleted = FALSE + id = $7::uuid AND deleted = FALSE RETURNING id, type, name, display_name, enabled, deleted, base_url, settings, settings_key_id, created_at, updated_at ` type UpdateAIProviderParams struct { + Type AIProviderType `db:"type" json:"type"` DisplayName sql.NullString `db:"display_name" json:"display_name"` Enabled bool `db:"enabled" json:"enabled"` BaseUrl string `db:"base_url" json:"base_url"` @@ -655,6 +657,7 @@ type UpdateAIProviderParams struct { func (q *sqlQuerier) UpdateAIProvider(ctx context.Context, arg UpdateAIProviderParams) (AIProvider, error) { row := q.db.QueryRowContext(ctx, updateAIProvider, + arg.Type, arg.DisplayName, arg.Enabled, arg.BaseUrl, @@ -2389,20 +2392,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, @@ -5314,6 +5325,37 @@ func (q *sqlQuerier) GetPRInsightsTimeSeries(ctx context.Context, arg GetPRInsig return items, nil } +const backfillChatModelConfigProvider = `-- name: BackfillChatModelConfigProvider :execresult +UPDATE + chat_model_configs +SET + provider = $1::text, + updated_at = NOW() +WHERE + provider = $2::text + AND deleted = FALSE + AND ai_provider_id IS NOT NULL + AND EXISTS ( + SELECT 1 FROM ai_providers + WHERE id = chat_model_configs.ai_provider_id + AND type = $1::ai_provider_type + AND deleted = FALSE + ) +` + +type BackfillChatModelConfigProviderParams struct { + NewProvider string `db:"new_provider" json:"new_provider"` + OldProvider string `db:"old_provider" json:"old_provider"` +} + +// old_provider is matched as text; new_provider is also cast to ai_provider_type +// for the EXISTS check against ai_providers.type. +// ai_provider_id IS NOT NULL is defensive; the check constraint already +// enforces that non-deleted rows always have a provider ID. +func (q *sqlQuerier) BackfillChatModelConfigProvider(ctx context.Context, arg BackfillChatModelConfigProviderParams) (sql.Result, error) { + return q.db.ExecContext(ctx, backfillChatModelConfigProvider, arg.NewProvider, arg.OldProvider) +} + const deleteChatModelConfigByID = `-- name: DeleteChatModelConfigByID :exec UPDATE chat_model_configs @@ -12205,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 @@ -21660,7 +21754,7 @@ WHERE ( ( $2 :: bool = true AND - url SIMILAR TO '[^:]*://' || $1 :: text || '([:/]?%)*' + url SIMILAR TO '[^:]*://' || $1 :: text || '([:/]%)*' ) OR ( $3 :: bool = true AND @@ -25998,6 +26092,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 ( @@ -26704,6 +26825,55 @@ func (q *sqlQuerier) UpsertUserAIProviderKey(ctx context.Context, arg UpsertUser return i, err } +const countOIDCLinkedIDsByIssuer = `-- name: CountOIDCLinkedIDsByIssuer :many +SELECT + (CASE + WHEN user_links.linked_id = '' THEN '' + ELSE split_part(user_links.linked_id, '||', 1) + END)::text AS issuer_prefix, + COUNT(*)::int AS count +FROM + user_links +INNER JOIN + users ON user_links.user_id = users.id +WHERE + user_links.login_type = 'oidc' + AND users.deleted = false +GROUP BY issuer_prefix +` + +type CountOIDCLinkedIDsByIssuerRow struct { + IssuerPrefix string `db:"issuer_prefix" json:"issuer_prefix"` + Count int32 `db:"count" json:"count"` +} + +// Groups OIDC user links by their issuer prefix (the part before "||" in +// linked_id) and returns a count for each. Empty linked_ids are reported +// with an empty issuer_prefix. Used for analysis before resetting +// mismatched links. +func (q *sqlQuerier) CountOIDCLinkedIDsByIssuer(ctx context.Context) ([]CountOIDCLinkedIDsByIssuerRow, error) { + rows, err := q.db.QueryContext(ctx, countOIDCLinkedIDsByIssuer) + if err != nil { + return nil, err + } + defer rows.Close() + var items []CountOIDCLinkedIDsByIssuerRow + for rows.Next() { + var i CountOIDCLinkedIDsByIssuerRow + if err := rows.Scan(&i.IssuerPrefix, &i.Count); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getUserLinkByLinkedID = `-- name: GetUserLinkByLinkedID :one SELECT user_links.user_id, user_links.login_type, user_links.linked_id, user_links.oauth_access_token, user_links.oauth_refresh_token, user_links.oauth_expiry, user_links.oauth_access_token_key_id, user_links.oauth_refresh_token_key_id, user_links.claims @@ -26962,6 +27132,28 @@ func (q *sqlQuerier) OIDCClaimFields(ctx context.Context, organizationID uuid.UU return items, nil } +const unlinkOIDCUsersByIssuerMismatch = `-- name: UnlinkOIDCUsersByIssuerMismatch :execrows +UPDATE user_links +SET linked_id = '' +FROM users +WHERE user_links.user_id = users.id + AND user_links.login_type = 'oidc' + AND user_links.linked_id != '' + AND NOT starts_with(user_links.linked_id, $1) + AND users.deleted = false +` + +// Resets linked_id to ” for OIDC links where the linked_id is non-empty +// and does not begin with the expected issuer prefix. This allows users to +// re-authenticate under a new OIDC provider. +func (q *sqlQuerier) UnlinkOIDCUsersByIssuerMismatch(ctx context.Context, expectedPrefix string) (int64, error) { + result, err := q.db.ExecContext(ctx, unlinkOIDCUsersByIssuerMismatch, expectedPrefix) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + const updateUserLink = `-- name: UpdateUserLink :one UPDATE user_links @@ -27013,6 +27205,41 @@ func (q *sqlQuerier) UpdateUserLink(ctx context.Context, arg UpdateUserLinkParam return i, err } +const updateUserLinkedID = `-- name: UpdateUserLinkedID :one +UPDATE + user_links +SET + linked_id = $1 +WHERE + user_id = $2 AND login_type = $3 AND linked_id = '' RETURNING user_id, login_type, linked_id, oauth_access_token, oauth_refresh_token, oauth_expiry, oauth_access_token_key_id, oauth_refresh_token_key_id, claims +` + +type UpdateUserLinkedIDParams struct { + LinkedID string `db:"linked_id" json:"linked_id"` + UserID uuid.UUID `db:"user_id" json:"user_id"` + LoginType LoginType `db:"login_type" json:"login_type"` +} + +// Backfills linked_id for legacy user_links that were created before +// linked_id tracking was added. Only updates when linked_id is empty +// to avoid overwriting a valid binding. +func (q *sqlQuerier) UpdateUserLinkedID(ctx context.Context, arg UpdateUserLinkedIDParams) (UserLink, error) { + row := q.db.QueryRowContext(ctx, updateUserLinkedID, arg.LinkedID, arg.UserID, arg.LoginType) + var i UserLink + err := row.Scan( + &i.UserID, + &i.LoginType, + &i.LinkedID, + &i.OAuthAccessToken, + &i.OAuthRefreshToken, + &i.OAuthExpiry, + &i.OAuthAccessTokenKeyID, + &i.OAuthRefreshTokenKeyID, + &i.Claims, + ) + return i, err +} + const createUserSecret = `-- name: CreateUserSecret :one INSERT INTO user_secrets ( id, @@ -32817,6 +33044,42 @@ ON CONFLICT (id) DO UPDATE SET agent_id = EXCLUDED.agent_id, slug = EXCLUDED.slug, tooltip = EXCLUDED.tooltip +WHERE + -- Prevent cross-tenant/cross-workspace agent rebinding (SEC-91). + -- App IDs persist across builds of the same workspace, but agent IDs are + -- regenerated every build, so compare by the workspace that owns the agent + -- rather than by agent_id. Permit unowned apps to be claimed and permit + -- same-workspace rebuilds. If an existing app belongs to a workspace, block + -- moves to both different workspaces and template import or dry-run agents + -- that resolve to no workspace. The conflicting row is then left untouched, + -- and the :one query returns no row, which the caller treats as a + -- rejection. + NOT EXISTS ( + SELECT 1 + FROM workspace_agents AS existing_agent + INNER JOIN workspace_resources AS existing_resource + ON existing_agent.resource_id = existing_resource.id + INNER JOIN workspace_builds AS existing_build + ON existing_resource.job_id = existing_build.job_id + WHERE existing_agent.id = workspace_apps.agent_id + ) + OR EXISTS ( + SELECT 1 + FROM workspace_agents AS existing_agent + INNER JOIN workspace_resources AS existing_resource + ON existing_agent.resource_id = existing_resource.id + INNER JOIN workspace_builds AS existing_build + ON existing_resource.job_id = existing_build.job_id + INNER JOIN workspace_agents AS incoming_agent + ON incoming_agent.id = EXCLUDED.agent_id + INNER JOIN workspace_resources AS incoming_resource + ON incoming_agent.resource_id = incoming_resource.id + INNER JOIN workspace_builds AS incoming_build + ON incoming_resource.job_id = incoming_build.job_id + WHERE + existing_agent.id = workspace_apps.agent_id + AND existing_build.workspace_id = incoming_build.workspace_id + ) RETURNING id, created_at, agent_id, display_name, icon, command, url, healthcheck_url, healthcheck_interval, healthcheck_threshold, health, subdomain, sharing_level, slug, external, display_order, hidden, open_in, display_group, tooltip ` @@ -35815,15 +36078,20 @@ WHERE END ) OR - -- A workspace may be eligible for failed stop if the following are true: + -- A workspace may be eligible for failed cleanup if the following are true: -- * The template has a failure ttl set. - -- * The workspace build was a start transition. + -- * The workspace build was a start or stop transition. A failed start + -- is cleaned up by stopping it; a failed stop is retried by issuing + -- another stop. -- * The provisioner job failed. -- * The provisioner job had completed. -- * The provisioner job has been completed for longer than the failure ttl. ( templates.failure_ttl > 0 AND - workspace_builds.transition = 'start'::workspace_transition AND + ( + workspace_builds.transition = 'start'::workspace_transition OR + workspace_builds.transition = 'stop'::workspace_transition + ) AND provisioner_jobs.job_status = 'failed'::provisioner_job_status AND provisioner_jobs.completed_at IS NOT NULL AND ($1 :: timestamptz) - provisioner_jobs.completed_at > (INTERVAL '1 millisecond' * (templates.failure_ttl / 1000000)) diff --git a/coderd/database/queries/ai_providers.sql b/coderd/database/queries/ai_providers.sql index 1c9a977e4c479..f7b4d5ec97768 100644 --- a/coderd/database/queries/ai_providers.sql +++ b/coderd/database/queries/ai_providers.sql @@ -66,6 +66,7 @@ RETURNING UPDATE ai_providers SET + type = @type::ai_provider_type, display_name = sqlc.narg('display_name')::text, enabled = @enabled::boolean, base_url = @base_url::text, diff --git a/coderd/database/queries/aibridge.sql b/coderd/database/queries/aibridge.sql index 7756c7086b473..a1b49d25cd479 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 diff --git a/coderd/database/queries/chatmodelconfigs.sql b/coderd/database/queries/chatmodelconfigs.sql index 2cf93698cf59e..4284521e1b426 100644 --- a/coderd/database/queries/chatmodelconfigs.sql +++ b/coderd/database/queries/chatmodelconfigs.sql @@ -144,6 +144,27 @@ WHERE provider = @provider::text AND deleted = FALSE; +-- name: BackfillChatModelConfigProvider :execresult +-- old_provider is matched as text; new_provider is also cast to ai_provider_type +-- for the EXISTS check against ai_providers.type. +-- ai_provider_id IS NOT NULL is defensive; the check constraint already +-- enforces that non-deleted rows always have a provider ID. +UPDATE + chat_model_configs +SET + provider = @new_provider::text, + updated_at = NOW() +WHERE + provider = @old_provider::text + AND deleted = FALSE + AND ai_provider_id IS NOT NULL + AND EXISTS ( + SELECT 1 FROM ai_providers + WHERE id = chat_model_configs.ai_provider_id + AND type = @new_provider::ai_provider_type + AND deleted = FALSE + ); + -- name: DeleteChatModelConfigsByAIProviderID :exec UPDATE chat_model_configs diff --git a/coderd/database/queries/files.sql b/coderd/database/queries/files.sql index cdf6e37ce081c..cefc3a2d02b4d 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; diff --git a/coderd/database/queries/proxies.sql b/coderd/database/queries/proxies.sql index df59d3baf107f..cac44de84d4c8 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/database/queries/templateversionterraformvalues.sql b/coderd/database/queries/templateversionterraformvalues.sql index 2ded4a2675375..97b20a5103c45 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/database/queries/user_links.sql b/coderd/database/queries/user_links.sql index b352e80840123..fb7567e4ff371 100644 --- a/coderd/database/queries/user_links.sql +++ b/coderd/database/queries/user_links.sql @@ -50,6 +50,17 @@ SET WHERE user_id = $7 AND login_type = $8 RETURNING *; +-- name: UpdateUserLinkedID :one +-- Backfills linked_id for legacy user_links that were created before +-- linked_id tracking was added. Only updates when linked_id is empty +-- to avoid overwriting a valid binding. +UPDATE + user_links +SET + linked_id = @linked_id +WHERE + user_id = @user_id AND login_type = @login_type AND linked_id = '' RETURNING *; + -- name: OIDCClaimFields :many -- OIDCClaimFields returns a list of distinct keys in the the merged_claims fields. -- This query is used to generate the list of available sync fields for idp sync settings. @@ -102,3 +113,36 @@ WHERE ELSE true END ; + +-- name: CountOIDCLinkedIDsByIssuer :many +-- Groups OIDC user links by their issuer prefix (the part before "||" in +-- linked_id) and returns a count for each. Empty linked_ids are reported +-- with an empty issuer_prefix. Used for analysis before resetting +-- mismatched links. +SELECT + (CASE + WHEN user_links.linked_id = '' THEN '' + ELSE split_part(user_links.linked_id, '||', 1) + END)::text AS issuer_prefix, + COUNT(*)::int AS count +FROM + user_links +INNER JOIN + users ON user_links.user_id = users.id +WHERE + user_links.login_type = 'oidc' + AND users.deleted = false +GROUP BY issuer_prefix; + +-- name: UnlinkOIDCUsersByIssuerMismatch :execrows +-- Resets linked_id to '' for OIDC links where the linked_id is non-empty +-- and does not begin with the expected issuer prefix. This allows users to +-- re-authenticate under a new OIDC provider. +UPDATE user_links +SET linked_id = '' +FROM users +WHERE user_links.user_id = users.id + AND user_links.login_type = 'oidc' + AND user_links.linked_id != '' + AND NOT starts_with(user_links.linked_id, @expected_prefix) + AND users.deleted = false; diff --git a/coderd/database/queries/workspaceapps.sql b/coderd/database/queries/workspaceapps.sql index 5f826d2985135..d297241a6814e 100644 --- a/coderd/database/queries/workspaceapps.sql +++ b/coderd/database/queries/workspaceapps.sql @@ -55,6 +55,42 @@ ON CONFLICT (id) DO UPDATE SET agent_id = EXCLUDED.agent_id, slug = EXCLUDED.slug, tooltip = EXCLUDED.tooltip +WHERE + -- Prevent cross-tenant/cross-workspace agent rebinding (SEC-91). + -- App IDs persist across builds of the same workspace, but agent IDs are + -- regenerated every build, so compare by the workspace that owns the agent + -- rather than by agent_id. Permit unowned apps to be claimed and permit + -- same-workspace rebuilds. If an existing app belongs to a workspace, block + -- moves to both different workspaces and template import or dry-run agents + -- that resolve to no workspace. The conflicting row is then left untouched, + -- and the :one query returns no row, which the caller treats as a + -- rejection. + NOT EXISTS ( + SELECT 1 + FROM workspace_agents AS existing_agent + INNER JOIN workspace_resources AS existing_resource + ON existing_agent.resource_id = existing_resource.id + INNER JOIN workspace_builds AS existing_build + ON existing_resource.job_id = existing_build.job_id + WHERE existing_agent.id = workspace_apps.agent_id + ) + OR EXISTS ( + SELECT 1 + FROM workspace_agents AS existing_agent + INNER JOIN workspace_resources AS existing_resource + ON existing_agent.resource_id = existing_resource.id + INNER JOIN workspace_builds AS existing_build + ON existing_resource.job_id = existing_build.job_id + INNER JOIN workspace_agents AS incoming_agent + ON incoming_agent.id = EXCLUDED.agent_id + INNER JOIN workspace_resources AS incoming_resource + ON incoming_agent.resource_id = incoming_resource.id + INNER JOIN workspace_builds AS incoming_build + ON incoming_resource.job_id = incoming_build.job_id + WHERE + existing_agent.id = workspace_apps.agent_id + AND existing_build.workspace_id = incoming_build.workspace_id + ) RETURNING *; -- name: UpdateWorkspaceAppHealthByID :exec diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index 5269ea8fba524..c42caef876ad3 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -786,15 +786,20 @@ WHERE END ) OR - -- A workspace may be eligible for failed stop if the following are true: + -- A workspace may be eligible for failed cleanup if the following are true: -- * The template has a failure ttl set. - -- * The workspace build was a start transition. + -- * The workspace build was a start or stop transition. A failed start + -- is cleaned up by stopping it; a failed stop is retried by issuing + -- another stop. -- * The provisioner job failed. -- * The provisioner job had completed. -- * The provisioner job has been completed for longer than the failure ttl. ( templates.failure_ttl > 0 AND - workspace_builds.transition = 'start'::workspace_transition AND + ( + workspace_builds.transition = 'start'::workspace_transition OR + workspace_builds.transition = 'stop'::workspace_transition + ) AND provisioner_jobs.job_status = 'failed'::provisioner_job_status AND provisioner_jobs.completed_at IS NOT NULL AND (@now :: timestamptz) - provisioner_jobs.completed_at > (INTERVAL '1 millisecond' * (templates.failure_ttl / 1000000)) diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index 4036cb774c1b4..d8db0647d021d 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -6768,6 +6768,26 @@ func (api *API) listChatModelConfigs(rw http.ResponseWriter, r *http.Request) { httpapi.Write(ctx, rw, http.StatusOK, resp) } +type chatModelConfigProviderModelError struct { + Response codersdk.Response +} + +func (e *chatModelConfigProviderModelError) Error() string { + return e.Response.Message +} + +func validateChatModelConfigProviderModel(aiProvider database.AIProvider, model string) *chatModelConfigProviderModelError { + if err := chatd.ValidateAIGatewayProviderModel(aiProvider, model); err != nil { + return &chatModelConfigProviderModelError{ + Response: codersdk.Response{ + Message: "OpenRouter-like provider configured as type openai does not support slash-namespaced models.", + Detail: "Change the AI provider type to openrouter or openai-compat. The openai type strips the vendor prefix from slash-namespaced model IDs, routing to the wrong upstream provider.", + }, + } + } + return nil +} + func (api *API) createChatModelConfig(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() apiKey := httpmw.APIKey(r) @@ -6813,6 +6833,11 @@ func (api *API) createChatModelConfig(rw http.ResponseWriter, r *http.Request) { return } + if validationErr := validateChatModelConfigProviderModel(aiProvider, model); validationErr != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, validationErr.Response) + return + } + enabled := true if req.Enabled != nil { enabled = *req.Enabled @@ -6880,6 +6905,9 @@ func (api *API) createChatModelConfig(rw http.ResponseWriter, r *http.Request) { return errChatProviderNotConfigured } insertParams.Provider = string(lockedAIProvider.Type) + if err := validateChatModelConfigProviderModel(lockedAIProvider, insertParams.Model); err != nil { + return err + } insertAsDefault := isDefault if !insertAsDefault { @@ -6919,7 +6947,11 @@ func (api *API) createChatModelConfig(rw http.ResponseWriter, r *http.Request) { return nil }, nil) if err != nil { + var providerModelErr *chatModelConfigProviderModelError switch { + case errors.As(err, &providerModelErr): + httpapi.Write(ctx, rw, http.StatusBadRequest, providerModelErr.Response) + return case database.IsUniqueViolation(err): httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{ Message: "Chat model config already exists.", @@ -7082,9 +7114,11 @@ func (api *API) updateChatModelConfig(rw http.ResponseWriter, r *http.Request) { ID: existing.ID, } + // Re-derive the provider type under lock when the model or provider changes. + revalidateProviderModel := updateParams.AIProviderID.Valid && (req.AIProviderID != nil || strings.TrimSpace(req.Model) != "") var updated database.ChatModelConfig err = api.Database.InTx(func(tx database.Store) error { - if updateParams.AIProviderID.Valid && req.AIProviderID != nil { + if revalidateProviderModel { //nolint:gocritic // The route already authorized chat model config updates. aiProvider, err := tx.GetAIProviderByIDForReferenceLock(dbauthz.AsChatd(ctx), updateParams.AIProviderID.UUID) if err != nil { @@ -7097,6 +7131,9 @@ func (api *API) updateChatModelConfig(rw http.ResponseWriter, r *http.Request) { return errChatProviderNotConfigured } updateParams.Provider = string(aiProvider.Type) + if err := validateChatModelConfigProviderModel(aiProvider, updateParams.Model); err != nil { + return err + } } setAsDefault := updateParams.IsDefault && !existing.IsDefault @@ -7139,7 +7176,11 @@ func (api *API) updateChatModelConfig(rw http.ResponseWriter, r *http.Request) { return nil }, nil) if err != nil { + var providerModelErr *chatModelConfigProviderModelError switch { + case errors.As(err, &providerModelErr): + httpapi.Write(ctx, rw, http.StatusBadRequest, providerModelErr.Response) + return case database.IsUniqueViolation(err): httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{ Message: "Chat model config already exists.", diff --git a/coderd/exp_chats_internal_test.go b/coderd/exp_chats_internal_test.go index 17c93182e79e6..bfa4dc6242455 100644 --- a/coderd/exp_chats_internal_test.go +++ b/coderd/exp_chats_internal_test.go @@ -5,9 +5,123 @@ import ( "github.com/stretchr/testify/require" + "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/codersdk" ) +func TestValidateChatModelConfigProviderModel(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + model string + provider database.AIProvider + wantErr bool + wantDetail string + }{ + { + name: "OpenRouterNameWithOpenAITypeAndSlashModel", + model: "anthropic/claude-opus-4.6", + provider: database.AIProvider{ + Name: "openrouter", + Type: database.AiProviderTypeOpenai, + }, + wantErr: true, + wantDetail: "Change the AI provider type to openrouter or openai-compat.", + }, + { + name: "OpenRouterNameWithWhitespaceAndCase", + model: "anthropic/claude-opus-4.6", + provider: database.AIProvider{ + Name: " OpenRouter ", + Type: database.AiProviderTypeOpenai, + }, + wantErr: true, + wantDetail: "Change the AI provider type to openrouter or openai-compat.", + }, + { + name: "OpenRouterHostWithOpenAITypeAndSlashModel", + model: "anthropic/claude-opus-4.6", + provider: database.AIProvider{ + Name: "private-relay", + Type: database.AiProviderTypeOpenai, + BaseUrl: "https://openrouter.ai/api/v1", + }, + wantErr: true, + wantDetail: "Change the AI provider type to openrouter or openai-compat.", + }, + { + name: "OpenRouterHostWithPort", + model: "anthropic/claude-opus-4.6", + provider: database.AIProvider{ + Name: "private-relay", + Type: database.AiProviderTypeOpenai, + BaseUrl: "https://openrouter.ai:443/api/v1", + }, + wantErr: true, + wantDetail: "Change the AI provider type to openrouter or openai-compat.", + }, + { + name: "OpenRouterSubdomainWithOpenAIType", + model: "anthropic/claude-opus-4.6", + provider: database.AIProvider{ + Name: "private-relay", + Type: database.AiProviderTypeOpenai, + BaseUrl: "https://api.openrouter.ai/v1", + }, + wantErr: true, + wantDetail: "Change the AI provider type to openrouter or openai-compat.", + }, + { + name: "OpenRouterTypeAllowsSlashModel", + model: "anthropic/claude-opus-4.6", + provider: database.AIProvider{ + Name: "openrouter", + Type: database.AiProviderTypeOpenrouter, + }, + }, + { + name: "OpenAICompatTypeAllowsSlashModel", + model: "anthropic/claude-opus-4.6", + provider: database.AIProvider{ + Name: "openrouter", + Type: database.AiProviderTypeOpenaiCompat, + }, + }, + { + name: "PrivateOpenAIProxyAllowsSlashModel", + model: "anthropic/claude-opus-4.6", + provider: database.AIProvider{ + Name: "private-relay", + Type: database.AiProviderTypeOpenai, + BaseUrl: "https://llm-relay.internal/v1", + }, + }, + { + name: "OpenRouterNameWithPlainModelAllowed", + model: "gpt-4.1", + provider: database.AIProvider{ + Name: "openrouter", + Type: database.AiProviderTypeOpenai, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := validateChatModelConfigProviderModel(tt.provider, tt.model) + if tt.wantErr { + require.NotNil(t, got) + require.Contains(t, got.Response.Detail, tt.wantDetail) + return + } + require.Nil(t, got) + }) + } +} + func TestRewriteChatStartWorkspaceManualUpdateResponse(t *testing.T) { t.Parallel() diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index fdbc1160d8ac8..570a550d5cccd 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -3744,6 +3744,33 @@ func TestCreateChatModelConfig(t *testing.T) { require.Equal(t, "AI provider is disabled.", sdkErr.Message) }) + t.Run("RejectsOpenRouterMisconfiguredAsOpenAI", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + _ = coderdtest.CreateFirstUser(t, client.Client) + + aiProvider, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ + Type: codersdk.AIProviderTypeOpenAI, + Name: "openrouter", + Enabled: true, + BaseURL: "https://openrouter.ai/api/v1", + APIKeys: []string{"test-api-key"}, + }) + require.NoError(t, err) + + contextLimit := int64(4096) + _, err = client.CreateChatModelConfig(ctx, codersdk.CreateChatModelConfigRequest{ + AIProviderID: &aiProvider.ID, + Model: "anthropic/claude-opus-4.6", + ContextLimit: &contextLimit, + }) + sdkErr := requireSDKError(t, err, http.StatusBadRequest) + require.Equal(t, "OpenRouter-like provider configured as type openai does not support slash-namespaced models.", sdkErr.Message) + require.Contains(t, sdkErr.Detail, "Change the AI provider type to openrouter or openai-compat.") + }) + t.Run("ForbiddenForOrganizationMember", func(t *testing.T) { t.Parallel() @@ -3823,6 +3850,108 @@ func TestUpdateChatModelConfig(t *testing.T) { require.Equal(t, "gpt-4o-mini-updated", updated.Model) }) + t.Run("RejectsOpenRouterMisconfiguredAsOpenAI", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + _ = coderdtest.CreateFirstUser(t, client.Client) + + aiProvider, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ + Type: codersdk.AIProviderTypeOpenAI, + Name: "openrouter", + Enabled: true, + BaseURL: "https://openrouter.ai/api/v1", + APIKeys: []string{"test-api-key"}, + }) + require.NoError(t, err) + + contextLimit := int64(4096) + modelConfig, err := client.CreateChatModelConfig(ctx, codersdk.CreateChatModelConfigRequest{ + AIProviderID: &aiProvider.ID, + Model: "gpt-4o-mini", + ContextLimit: &contextLimit, + }) + require.NoError(t, err) + + _, err = client.UpdateChatModelConfig(ctx, modelConfig.ID, codersdk.UpdateChatModelConfigRequest{ + Model: "anthropic/claude-opus-4.6", + }) + sdkErr := requireSDKError(t, err, http.StatusBadRequest) + require.Equal(t, "OpenRouter-like provider configured as type openai does not support slash-namespaced models.", sdkErr.Message) + require.Contains(t, sdkErr.Detail, "Change the AI provider type to openrouter or openai-compat.") + }) + + t.Run("AllowsUnrelatedEditOnExistingMisconfiguredOpenAI", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client, db := newChatClientWithDatabase(t) + _ = coderdtest.CreateFirstUser(t, client.Client) + + aiProvider, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ + Type: codersdk.AIProviderTypeOpenAI, + Name: "openrouter", + Enabled: true, + BaseURL: "https://openrouter.ai/api/v1", + APIKeys: []string{"test-api-key"}, + }) + require.NoError(t, err) + + modelConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{ + Provider: string(database.AiProviderTypeOpenai), + Model: "anthropic/claude-opus-4.6", + AIProviderID: uuid.NullUUID{UUID: aiProvider.ID, Valid: true}, + }) + + updated, err := client.UpdateChatModelConfig(ctx, modelConfig.ID, codersdk.UpdateChatModelConfigRequest{ + DisplayName: "Existing OpenRouter Config", + }) + require.NoError(t, err) + require.Equal(t, "Existing OpenRouter Config", updated.DisplayName) + require.Equal(t, modelConfig.Model, updated.Model) + }) + + t.Run("RejectsProviderChangeToMisconfiguredOpenAI", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + _ = coderdtest.CreateFirstUser(t, client.Client) + + validProvider, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ + Type: codersdk.AIProviderTypeOpenrouter, + Name: "openrouter-valid", + Enabled: true, + BaseURL: "https://openrouter.ai/api/v1", + APIKeys: []string{"test-api-key"}, + }) + require.NoError(t, err) + misconfiguredProvider, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ + Type: codersdk.AIProviderTypeOpenAI, + Name: "openrouter", + Enabled: true, + BaseURL: "https://openrouter.ai/api/v1", + APIKeys: []string{"test-api-key"}, + }) + require.NoError(t, err) + + contextLimit := int64(4096) + modelConfig, err := client.CreateChatModelConfig(ctx, codersdk.CreateChatModelConfigRequest{ + AIProviderID: &validProvider.ID, + Model: "anthropic/claude-opus-4.6", + ContextLimit: &contextLimit, + }) + require.NoError(t, err) + + _, err = client.UpdateChatModelConfig(ctx, modelConfig.ID, codersdk.UpdateChatModelConfigRequest{ + AIProviderID: &misconfiguredProvider.ID, + }) + sdkErr := requireSDKError(t, err, http.StatusBadRequest) + require.Equal(t, "OpenRouter-like provider configured as type openai does not support slash-namespaced models.", sdkErr.Message) + require.Contains(t, sdkErr.Detail, "Change the AI provider type to openrouter or openai-compat.") + }) + t.Run("DisablePreservesRecordAndHidesItFromNonAdmins", func(t *testing.T) { t.Parallel() diff --git a/coderd/externalauth.go b/coderd/externalauth.go index 29eb53e67971d..51b7727c00d69 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/files.go b/coderd/files.go index b77bd81375f3c..07040b20fe5fd 100644 --- a/coderd/files.go +++ b/coderd/files.go @@ -80,11 +80,24 @@ func (api *API) postFile(rw http.ResponseWriter, r *http.Request) { data, err = archive.CreateTarFromZip(zipReader, HTTPFileMaxBytes) if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error processing .zip archive.", - Detail: err.Error(), - }) - return + switch { + case errors.Is(err, archive.ErrArchiveTooLarge): + httpapi.Write(ctx, rw, http.StatusRequestEntityTooLarge, codersdk.Response{ + Message: "Expanded .zip archive exceeds maximum size.", + }) + return + case errors.Is(err, archive.ErrInvalidZipContent): + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid .zip archive contents.", + }) + return + default: + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error processing .zip archive.", + Detail: err.Error(), + }) + return + } } contentType = tarMimeType } diff --git a/coderd/files_test.go b/coderd/files_test.go index e1a87aad299a8..1f6a7e94f866e 100644 --- a/coderd/files_test.go +++ b/coderd/files_test.go @@ -2,8 +2,11 @@ package coderd_test import ( "archive/tar" + "archive/zip" "bytes" "context" + "encoding/binary" + "io" "net/http" "sync" "testing" @@ -14,6 +17,7 @@ import ( "github.com/coder/coder/v2/archive" "github.com/coder/coder/v2/archive/archivetest" + "github.com/coder/coder/v2/coderd" "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/testutil" @@ -22,6 +26,19 @@ import ( func TestPostFiles(t *testing.T) { t.Parallel() + buildZipWithFile := func(t *testing.T, name string, writeContents func(w io.Writer) error) []byte { + t.Helper() + + var zipBytes bytes.Buffer + zw := zip.NewWriter(&zipBytes) + w, err := zw.Create(name) + require.NoError(t, err) + require.NoError(t, writeContents(w)) + require.NoError(t, zw.Close()) + + return zipBytes.Bytes() + } + // Single instance shared across all sub-tests. Each sub-test // creates independent resources with unique IDs so parallel // execution is safe. @@ -65,6 +82,39 @@ func TestPostFiles(t *testing.T) { _, err = client.Upload(ctx, codersdk.ContentTypeTar, bytes.NewReader(data)) require.NoError(t, err) }) + t.Run("InvalidZipMetadata", func(t *testing.T) { + t.Parallel() + + corruptZipUncompressedSize := func(t *testing.T, zipBytes []byte, size uint32) []byte { + t.Helper() + + const ( + directoryHeaderSignature = "PK\x01\x02" + uncompressedSizeOffset = 24 + ) + hdrOffset := bytes.Index(zipBytes, []byte(directoryHeaderSignature)) + require.NotEqual(t, -1, hdrOffset, "missing ZIP central directory header") + corrupted := bytes.Clone(zipBytes) + sizeBytes := corrupted[hdrOffset+uncompressedSizeOffset : hdrOffset+uncompressedSizeOffset+4] + binary.LittleEndian.PutUint32(sizeBytes, size) + + return corrupted + } + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + zipBytes := buildZipWithFile(t, "hello.txt", func(w io.Writer) error { + _, err := w.Write([]byte("hello")) + return err + }) + zipBytes = corruptZipUncompressedSize(t, zipBytes, 6) + + _, err := client.Upload(ctx, codersdk.ContentTypeZip, bytes.NewReader(zipBytes)) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusBadRequest, apiErr.StatusCode()) + }) t.Run("InsertConcurrent", func(t *testing.T) { t.Parallel() ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) @@ -86,6 +136,43 @@ func TestPostFiles(t *testing.T) { wg.Done() end.Wait() }) + //nolint:paralleltest // This subtest is intentionally serial to + // avoid extra memory pressure. + t.Run("OversizedZipExpansion", func(t *testing.T) { + buildZipWithSizedFile := func(t *testing.T, name string, size int64) []byte { + return buildZipWithFile(t, name, func(w io.Writer) error { + chunk := bytes.Repeat([]byte("a"), 32*1024) + for written := int64(0); written < size; { + n := len(chunk) + if remaining := size - written; int64(n) > remaining { + n = int(remaining) + } + + _, err := w.Write(chunk[:n]) + if err != nil { + return err + } + written += int64(n) + } + + return nil + }) + } + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + // Leave only enough room for the tar trailer. The single + // entry header then pushes the converted tar output over the + // file size limit. + size := int64(coderd.HTTPFileMaxBytes - 1024) + zipBytes := buildZipWithSizedFile(t, "oversized.txt", size) + + _, err := client.Upload(ctx, codersdk.ContentTypeZip, bytes.NewReader(zipBytes)) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusRequestEntityTooLarge, apiErr.StatusCode()) + }) } func TestDownload(t *testing.T) { diff --git a/coderd/httpapi/redirect.go b/coderd/httpapi/redirect.go new file mode 100644 index 0000000000000..6c1c49e39e389 --- /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 0000000000000..7bb9b88d99c89 --- /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/httpapi/request.go b/coderd/httpapi/request.go index 6a07ede6dce19..95d786d241766 100644 --- a/coderd/httpapi/request.go +++ b/coderd/httpapi/request.go @@ -8,17 +8,6 @@ const ( XForwardedHostHeader = "X-Forwarded-Host" ) -// RequestHost returns the name of the host from the request. It prioritizes -// 'X-Forwarded-Host' over r.Host since most requests are being proxied. -func RequestHost(r *http.Request) string { - host := r.Header.Get(XForwardedHostHeader) - if host != "" { - return host - } - - return r.Host -} - func IsWebsocketUpgrade(r *http.Request) bool { vs := r.Header.Values("Upgrade") for _, v := range vs { diff --git a/coderd/httpmw/apikey.go b/coderd/httpmw/apikey.go index 40a87647f3633..6565786504cd8 100644 --- a/coderd/httpmw/apikey.go +++ b/coderd/httpmw/apikey.go @@ -424,7 +424,11 @@ func ValidateAPIKey(ctx context.Context, cfg ValidateAPIKeyConfig, r *http.Reque } changed = true } - if !cfg.DisableSessionExpiryRefresh { + // Only apply sliding-window expiry refresh to interactive login + // sessions. Programmatic API tokens (LoginTypeToken, created via + // `coder tokens create`) honor a fixed, finite lifetime and must not be + // silently extended to now+lifetime on each authenticated request. + if !cfg.DisableSessionExpiryRefresh && key.LoginType != database.LoginTypeToken { apiKeyLifetime := time.Duration(key.LifetimeSeconds) * time.Second if key.ExpiresAt.Sub(now) <= apiKeyLifetime-time.Hour { key.ExpiresAt = now.Add(apiKeyLifetime) diff --git a/coderd/httpmw/apikey_test.go b/coderd/httpmw/apikey_test.go index d060330427bd2..a56b8a825f298 100644 --- a/coderd/httpmw/apikey_test.go +++ b/coderd/httpmw/apikey_test.go @@ -471,6 +471,39 @@ func TestAPIKey(t *testing.T) { require.NotEqual(t, sentAPIKey.ExpiresAt, gotAPIKey.ExpiresAt) }) + t.Run("TokenNoExpiryRefresh", func(t *testing.T) { + t.Parallel() + var ( + db, _ = dbtestutil.NewDB(t) + user = dbgen.User(t, db, database.User{}) + sentAPIKey, token = dbgen.APIKey(t, db, database.APIKey{ + UserID: user.ID, + LastUsed: dbtime.Now(), + ExpiresAt: dbtime.Now().Add(time.Minute), + LoginType: database.LoginTypeToken, + }) + + r = httptest.NewRequest("GET", "/", nil) + rw = httptest.NewRecorder() + ) + r.Header.Set(codersdk.SessionTokenHeader, token) + + httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{ + DB: db, + RedirectToLogin: false, + })(successHandler).ServeHTTP(rw, r) + res := rw.Result() + defer res.Body.Close() + require.Equal(t, http.StatusOK, res.StatusCode) + + gotAPIKey, err := db.GetAPIKeyByID(r.Context(), sentAPIKey.ID) + require.NoError(t, err) + + // Programmatic tokens honor a fixed lifetime, so the expiry must not be + // extended on use even though it is within the refresh window. + require.Equal(t, sentAPIKey.ExpiresAt, gotAPIKey.ExpiresAt) + }) + t.Run("NoRefresh", func(t *testing.T) { t.Parallel() var ( diff --git a/coderd/httpmw/loggermw/logger.go b/coderd/httpmw/loggermw/logger.go index d6850e31c4fbc..767d757bc5055 100644 --- a/coderd/httpmw/loggermw/logger.go +++ b/coderd/httpmw/loggermw/logger.go @@ -12,7 +12,6 @@ import ( "github.com/go-chi/chi/v5" "cdr.dev/slog/v3" - "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/tracing" ) @@ -69,7 +68,7 @@ func safeQueryParams(params url.Values) []slog.Field { return fields } -func Logger(log slog.Logger) func(next http.Handler) http.Handler { +func Logger(log slog.Logger, hostResolver func(*http.Request) string) func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { start := time.Now() @@ -79,9 +78,15 @@ func Logger(log slog.Logger) func(next http.Handler) http.Handler { panic(fmt.Sprintf("ResponseWriter not a *tracing.StatusWriter; got %T", rw)) } + host := r.Host + if hostResolver != nil { + host = hostResolver(r) + } + httplog := log.With( slog.F("user_agent", r.Header.Get("User-Agent")), - slog.F("host", httpapi.RequestHost(r)), + slog.F("host", host), + slog.F("received_host", r.Host), slog.F("path", r.URL.Path), slog.F("proto", r.Proto), slog.F("remote_addr", r.RemoteAddr), diff --git a/coderd/httpmw/loggermw/logger_internal_test.go b/coderd/httpmw/loggermw/logger_internal_test.go index 2f0bc5c39d96f..5ebb6973d3307 100644 --- a/coderd/httpmw/loggermw/logger_internal_test.go +++ b/coderd/httpmw/loggermw/logger_internal_test.go @@ -68,7 +68,7 @@ func TestLoggerMiddleware_SingleRequest(t *testing.T) { }) // Wrap the test handler with the Logger middleware - loggerMiddleware := Logger(logger) + loggerMiddleware := Logger(logger, nil) wrappedHandler := loggerMiddleware(testHandler) // Create a test HTTP request @@ -91,7 +91,7 @@ func TestLoggerMiddleware_SingleRequest(t *testing.T) { } // Check that the log contains the expected fields - requiredFields := []string{"host", "path", "proto", "remote_addr", "start", "took", "status_code", "user_agent", "latency_ms"} + requiredFields := []string{"host", "received_host", "path", "proto", "remote_addr", "start", "took", "status_code", "user_agent", "latency_ms"} for _, field := range requiredFields { _, exists := fieldsMap[field] require.True(t, exists, "field %q is missing in log fields", field) @@ -103,6 +103,38 @@ func TestLoggerMiddleware_SingleRequest(t *testing.T) { require.Equal(t, fieldsMap["status_code"], http.StatusOK) } +func TestLoggerMiddleware_HostFields(t *testing.T) { + t.Parallel() + + sink := testutil.NewFakeSink(t) + logger := sink.Logger() + + testHandler := http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) { + rw.WriteHeader(http.StatusOK) + }) + + loggerMiddleware := Logger(logger, func(_ *http.Request) string { + return "effective.test" + }) + wrappedHandler := loggerMiddleware(testHandler) + + req := httptest.NewRequest(http.MethodGet, "http://received.test/path", nil) + + sw := &tracing.StatusWriter{ResponseWriter: httptest.NewRecorder()} + wrappedHandler.ServeHTTP(sw, req) + + entries := sink.Entries() + require.Len(t, entries, 1, "expected exactly one log entry") + + fieldsMap := make(map[string]any) + for _, field := range entries[0].Fields { + fieldsMap[field.Name] = field.Value + } + + require.Equal(t, "effective.test", fieldsMap["host"]) + require.Equal(t, "received.test", fieldsMap["received_host"]) +} + func TestLoggerMiddleware_WebSocket(t *testing.T) { t.Parallel() ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) @@ -129,7 +161,7 @@ func TestLoggerMiddleware_WebSocket(t *testing.T) { }) // Wrap the test handler with the Logger middleware - loggerMiddleware := Logger(logger) + loggerMiddleware := Logger(logger, nil) wrappedHandler := loggerMiddleware(testHandler) // RequestLogger expects the ResponseWriter to be *tracing.StatusWriter @@ -186,7 +218,7 @@ func TestRequestLogger_HTTPRouteParams(t *testing.T) { }) // Wrap the test handler with the Logger middleware - loggerMiddleware := Logger(logger) + loggerMiddleware := Logger(logger, nil) wrappedHandler := loggerMiddleware(testHandler) // Create a test HTTP request diff --git a/coderd/httpmw/oauth2.go b/coderd/httpmw/oauth2.go index 5f12543887a09..fe91b49a5eafe 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/httpmw/ratelimit.go b/coderd/httpmw/ratelimit.go index e89a280530e90..17af4be24218e 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 49e46ccf467cc..88441326c74a7 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() diff --git a/coderd/httpmw/realip.go b/coderd/httpmw/realip.go index 6f0f318b83224..b3e460b9e9d67 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,10 +111,47 @@ 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)) } } +// EffectiveHost returns the host Coder should trust for request handling. +// It uses X-Forwarded-Host only when the immediate peer is a configured +// trusted proxy. Otherwise it uses the received Host header. +func EffectiveHost(config *RealIPConfig, r *http.Request) string { + if config == nil { + config = &RealIPConfig{ + TrustedOrigins: nil, + TrustedHeaders: nil, + } + } + + // When ExtractRealIP has run, r.RemoteAddr may hold the forwarded + // client IP, and we should use the original socket peer for proxy + // trust decisions. + remoteAddr := r.RemoteAddr + state := RealIP(r.Context()) + if state != nil && state.OriginalRemoteAddr != "" { + remoteAddr = state.OriginalRemoteAddr + } + + if isContainedIn(config.TrustedOrigins, getRemoteAddress(remoteAddr)) { + if host := r.Header.Get(httpapi.XForwardedHostHeader); host != "" { + return host + } + } + + return r.Host +} + // EnsureXForwardedForHeader ensures that the request has an X-Forwarded-For // header. It uses the following logic: // @@ -156,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) @@ -177,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 18b870ae379c2..cce7445bf6812 100644 --- a/coderd/httpmw/realip_test.go +++ b/coderd/httpmw/realip_test.go @@ -11,6 +11,7 @@ import ( "github.com/stretchr/testify/require" + "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpmw" ) @@ -81,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{ @@ -455,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 { @@ -472,6 +564,112 @@ func TestFilterUntrusted(t *testing.T) { } } +func TestEffectiveHost(t *testing.T) { + t.Parallel() + + cidr32 := func(t *testing.T, ip string) *net.IPNet { + t.Helper() + + return &net.IPNet{ + IP: net.ParseIP(ip), + Mask: net.CIDRMask(32, 32), + } + } + + t.Run("UntrustedPeerFallsBackToReceivedHost", func(t *testing.T) { + t.Parallel() + + r := httptest.NewRequest(http.MethodGet, "http://received.test", nil) + r.RemoteAddr = "17.18.19.20:1234" + r.Header.Set(httpapi.XForwardedHostHeader, "app.test.coder.com") + + require.Equal(t, "received.test", httpmw.EffectiveHost(nil, r)) + }) + + t.Run("TrustedPeerUsesOriginalRemoteAddrForTrust", func(t *testing.T) { + t.Parallel() + + config := &httpmw.RealIPConfig{ + TrustedOrigins: []*net.IPNet{cidr32(t, "17.18.19.20")}, + TrustedHeaders: []string{"X-Real-Ip"}, + } + + r := httptest.NewRequest(http.MethodGet, "http://received.test", nil) + r.RemoteAddr = "17.18.19.20:1234" + // X-Real-Ip causes ExtractRealIP to rewrite r.RemoteAddr, so + // this test can verify trust still uses OriginalRemoteAddr, + // the actual socket peer. + r.Header.Set("X-Real-Ip", "99.88.77.66") + r.Header.Set(httpapi.XForwardedHostHeader, "app.test.coder.com") + + middleware := httpmw.ExtractRealIP(config) + next := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + require.Equal(t, "99.88.77.66", r.RemoteAddr) + require.Equal(t, "app.test.coder.com", httpmw.EffectiveHost(config, r)) + }) + + middleware(next).ServeHTTP(httptest.NewRecorder(), r) + }) + + t.Run("UntrustedPeerDoesNotHonorForwardedHost", func(t *testing.T) { + t.Parallel() + + config := &httpmw.RealIPConfig{ + TrustedOrigins: []*net.IPNet{cidr32(t, "99.88.77.66")}, + TrustedHeaders: []string{"X-Real-Ip"}, + } + + r := httptest.NewRequest(http.MethodGet, "http://received.test", nil) + r.RemoteAddr = "17.18.19.20:1234" + r.Header.Set("X-Real-Ip", "99.88.77.66") + r.Header.Set(httpapi.XForwardedHostHeader, "app.test.coder.com") + + middleware := httpmw.ExtractRealIP(config) + nextHandler := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + require.Equal(t, "17.18.19.20", r.RemoteAddr) + require.Equal(t, "received.test", httpmw.EffectiveHost(config, r)) + }) + + middleware(nextHandler).ServeHTTP(httptest.NewRecorder(), r) + }) + + t.Run("TrustedPeerWithoutForwardedHostFallsBackToReceivedHost", func(t *testing.T) { + t.Parallel() + + config := &httpmw.RealIPConfig{ + TrustedOrigins: []*net.IPNet{cidr32(t, "17.18.19.20")}, + TrustedHeaders: []string{"X-Real-Ip"}, + } + + r := httptest.NewRequest(http.MethodGet, "http://received.test", nil) + r.RemoteAddr = "17.18.19.20:1234" + + middleware := httpmw.ExtractRealIP(config) + nextHandler := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + require.Equal(t, "received.test", httpmw.EffectiveHost(config, r)) + }) + + middleware(nextHandler).ServeHTTP(httptest.NewRecorder(), r) + }) + + t.Run("MalformedRemoteAddrFallsBackToReceivedHost", func(t *testing.T) { + t.Parallel() + + config := &httpmw.RealIPConfig{ + TrustedOrigins: []*net.IPNet{cidr32(t, "17.18.19.20")}, + TrustedHeaders: []string{"X-Real-Ip"}, + } + + r := httptest.NewRequest(http.MethodGet, "http://received.test", nil) + // A RemoteAddr that cannot be parsed into an IP must be treated as + // untrusted, so the forwarded host is ignored. + r.RemoteAddr = "garbage" + r.Header.Set(httpapi.XForwardedHostHeader, "app.test.coder.com") + + require.Equal(t, "received.test", httpmw.EffectiveHost(config, r)) + }) +} + // TestApplicationProxy checks headers passed to DevURL services are as expected. func TestApplicationProxy(t *testing.T) { t.Parallel() diff --git a/coderd/httpmw/workspaceagent.go b/coderd/httpmw/workspaceagent.go index 47867e17b2c8b..7c7a28c07f3a3 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 378d75927cc78..c18e7aab91de7 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) diff --git a/coderd/notifications/dispatch/smtp.go b/coderd/notifications/dispatch/smtp.go index 5dfcc43851dee..d5760cfc1cead 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/html.gotmpl b/coderd/notifications/dispatch/smtp/html.gotmpl index 4e49c4239d1f4..2deb5505a4a8d 100644 --- a/coderd/notifications/dispatch/smtp/html.gotmpl +++ b/coderd/notifications/dispatch/smtp/html.gotmpl @@ -3,31 +3,31 @@ - {{ .Labels._subject }} + {{ .Labels._subject | html }}
- {{ app_name }} Logo + {{ app_name | html }} Logo

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

-

Hi {{ .UserName }},

+

Hi {{ .UserName | html }},

{{ .Labels._body }}
{{ range $action := .Actions }} - - {{ $action.Label }} + + {{ $action.Label | html }} {{ end }}
-

© {{ 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 cc193673f0db6..187191d7c876e 100644 --- a/coderd/notifications/dispatch/smtp_internal_test.go +++ b/coderd/notifications/dispatch/smtp_internal_test.go @@ -1,11 +1,197 @@ package dispatch import ( + "html" + "mime" + "strings" "testing" "github.com/stretchr/testify/require" + + "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() + + const ( + appName = `Coder">` + logoURL = `https://example.com/logo.png">` + ) + + payload := types.MessagePayload{ + NotificationTemplateID: "00000000-0000-0000-0000-000000000000", + UserName: "Test User", + Labels: map[string]string{ + "_subject": "Test notification", + "_body": "

Test body

", + }, + } + 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) + + require.True(t, strings.Contains(got, html.EscapeString(appName)), "application name must be HTML escaped") + require.True(t, strings.Contains(got, html.EscapeString(logoURL)), "logo URL must be HTML escaped") + require.False(t, strings.Contains(got, appName), "raw application name must not be rendered") + 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() @@ -79,3 +265,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 34aed0feed6b6..ad8ec69fc61e8 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/dormancy.go b/coderd/notifications/dormancy.go new file mode 100644 index 0000000000000..236bcd130e996 --- /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/notifications/notifications_test.go b/coderd/notifications/notifications_test.go index a59e64be42ff7..b6ad65f9e30eb 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{" - 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 5d0879bc82da2..1a17d690186d4 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 578e39e91a293..0c4fea1cf9d84 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 58a1f098f77e0..f22fc19c5b6da 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 81d2498b579e4..4d71d3c8ec9d2 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 21356601f6255..b0cc318c350cc 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 75af5a264e644..3103a58b97ab3 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 70c27eed18667..6eea0d7a8dbfa 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 011ef84ebfb1c..ff4ee4976af59 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 6fc619e4129a0..fa16f497bb9ed 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 cfcb22beec139..e52d1b71ab892 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 9664bc8892442..99cddd3593b11 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 12e29c47ed078..819fc0d0e8504 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 2304fbf01bdbf..378f13e534d72 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 c132ffb47d9c1..7cb61a6701104 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 9fccba0b1f239..97f6e720a2861 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 fcc9b57f17b9f..46ebe956cb2f8 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 7c1f7192b1fc8..cf1e96a2edac3 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 ee3021c18cef1..56b6be012a6e8 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 2f7bb2771c8a9..f1b67ebc39c50 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 0e70293b09065..d971cd94c1541 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 bbd73d07b27a1..d76fb4caf1bce 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 1e65a1eab12fc..15bffe4a810d7 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 aad0c2190c25a..1f134cb07399c 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 b75c2032003ee..44a0e92b24921 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 b86fd4bf6395d..b2c0fb7c1afc2 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 277195a2bd427..74dee9477963e 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,

diff --git a/coderd/notifications/testdata/rendered-templates/webhook/TemplateWorkspaceResourceReplaced.json.golden b/coderd/notifications/testdata/rendered-templates/webhook/TemplateWorkspaceResourceReplaced.json.golden index 09bf9431cdeed..6ae1b693ac2df 100644 --- a/coderd/notifications/testdata/rendered-templates/webhook/TemplateWorkspaceResourceReplaced.json.golden +++ b/coderd/notifications/testdata/rendered-templates/webhook/TemplateWorkspaceResourceReplaced.json.golden @@ -38,5 +38,5 @@ "title": "There might be a problem with a recently claimed prebuilt workspace", "title_markdown": "There might be a problem with a recently claimed prebuilt workspace", "body": "Workspace my-workspace was claimed from a prebuilt workspace by prebuilds-claimer.\n\nDuring the claim, Terraform destroyed and recreated the following resources\nbecause one or more immutable attributes changed:\n\ndocker_container[0] was replaced due to changes to env, hostname\n\nWhen Terraform must change an immutable attribute, it replaces the entire resource.\nIf you’re using prebuilds to speed up provisioning, unexpected replacements will slow down\nworkspace startup—even when claiming a prebuilt environment.\n\nFor tips on preventing replacements and improving claim performance, see this guide (https://coder.com/docs/admin/templates/extending-templates/prebuilt-workspaces#preventing-resource-replacement).\n\nNOTE: this prebuilt workspace used the particle-accelerator preset.", - "body_markdown": "\nWorkspace **my-workspace** was claimed from a prebuilt workspace by **prebuilds-claimer**.\n\nDuring the claim, Terraform destroyed and recreated the following resources\nbecause one or more immutable attributes changed:\n\n- _docker_container[0]_ was replaced due to changes to _env, hostname_\n\n\nWhen Terraform must change an immutable attribute, it replaces the entire resource.\nIf you’re using prebuilds to speed up provisioning, unexpected replacements will slow down\nworkspace startup—even when claiming a prebuilt environment.\n\nFor tips on preventing replacements and improving claim performance, see [this guide](https://coder.com/docs/admin/templates/extending-templates/prebuilt-workspaces#preventing-resource-replacement).\n\nNOTE: this prebuilt workspace used the **particle-accelerator** preset.\n" + "body_markdown": "\nWorkspace **my-workspace** was claimed from a prebuilt workspace by **prebuilds-claimer**.\n\nDuring the claim, Terraform destroyed and recreated the following resources\nbecause one or more immutable attributes changed:\n\n- _docker_container\\[0\\]_ was replaced due to changes to _env, hostname_\n\n\nWhen Terraform must change an immutable attribute, it replaces the entire resource.\nIf you’re using prebuilds to speed up provisioning, unexpected replacements will slow down\nworkspace startup—even when claiming a prebuilt environment.\n\nFor tips on preventing replacements and improving claim performance, see [this guide](https://coder.com/docs/admin/templates/extending-templates/prebuilt-workspaces#preventing-resource-replacement).\n\nNOTE: this prebuilt workspace used the **particle-accelerator** preset.\n" } \ No newline at end of file diff --git a/coderd/notifications/types/escape.go b/coderd/notifications/types/escape.go new file mode 100644 index 0000000000000..d267cc7651ed6 --- /dev/null +++ b/coderd/notifications/types/escape.go @@ -0,0 +1,58 @@ +package types + +import "github.com/coder/coder/v2/coderd/render" + +// EscapedForMarkdown returns a copy of the payload whose string values have +// Markdown structure neutralized, for rendering the title and body templates. +// The receiver is left untouched: the stored payload keeps the values as they +// were enqueued, which is what the webhook dispatcher surfaces to consumers and +// what the SMTP dispatcher escapes at its own HTML sinks. +func (p MessagePayload) EscapedForMarkdown() MessagePayload { + out := p + out.UserName = render.EscapeMarkdown(p.UserName) + + if p.Labels != nil { + labels := make(map[string]string, len(p.Labels)) + for k, v := range p.Labels { + labels[k] = render.EscapeMarkdown(v) + } + out.Labels = labels + } + + if p.Data != nil { + data := make(map[string]any, len(p.Data)) + for k, v := range p.Data { + data[k] = escapeValue(v) + } + out.Data = data + } + return out +} + +// escapeValue walks a decoded JSON value and escapes its string leaves. Numbers, +// booleans and nulls pass through unchanged so that template comparisons such as +// `{{if gt $version.failed_count 1}}` keep working. +// +// Nested keys are escaped too: a key is content whenever a template ranges with +// two variables, as the resource replacements body does over Terraform resource +// addresses. +func escapeValue(v any) any { + switch t := v.(type) { + case string: + return render.EscapeMarkdown(t) + case map[string]any: + out := make(map[string]any, len(t)) + for k, vv := range t { + out[render.EscapeMarkdown(k)] = escapeValue(vv) + } + return out + case []any: + out := make([]any, len(t)) + for i, vv := range t { + out[i] = escapeValue(vv) + } + return out + default: + return v + } +} diff --git a/coderd/notifications/types/escape_test.go b/coderd/notifications/types/escape_test.go new file mode 100644 index 0000000000000..f10e332a42f94 --- /dev/null +++ b/coderd/notifications/types/escape_test.go @@ -0,0 +1,132 @@ +package types_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/notifications/types" +) + +func TestEscapedForMarkdown(t *testing.T) { + t.Parallel() + + t.Run("EscapesLabelsAndUserName", func(t *testing.T) { + t.Parallel() + + payload := types.MessagePayload{ + UserName: "Eve [Re-auth](https://attacker.example)", + Labels: map[string]string{ + "suspended_account_user_name": "Eve\n## URGENT\n[Re-auth](https://attacker.example)", + "limit_source": "user_override", + }, + } + + escaped := payload.EscapedForMarkdown() + require.Equal(t, `Eve \[Re-auth\]\(https://attacker.example\)`, escaped.UserName) + require.NotContains(t, escaped.Labels["suspended_account_user_name"], "[Re-auth](") + // Control values must survive so template conditionals keep matching. + require.Equal(t, "user_override", escaped.Labels["limit_source"]) + }) + + t.Run("LeavesReceiverUntouched", func(t *testing.T) { + t.Parallel() + + // The webhook dispatcher surfaces the payload verbatim, so the original + // must not mutate. + payload := types.MessagePayload{ + UserName: "Eve [x](https://attacker.example)", + Labels: map[string]string{"name": "bobby-workspace", "risky": "[x](https://attacker.example)"}, + Data: map[string]any{"user": map[string]any{"name": "[x](https://attacker.example)"}}, + } + + _ = payload.EscapedForMarkdown() + + require.Equal(t, "Eve [x](https://attacker.example)", payload.UserName) + require.Equal(t, "[x](https://attacker.example)", payload.Labels["risky"]) + require.Equal(t, "[x](https://attacker.example)", + payload.Data["user"].(map[string]any)["name"]) + }) + + t.Run("RecursesIntoData", func(t *testing.T) { + t.Parallel() + + payload := types.MessagePayload{ + Data: map[string]any{ + "user": map[string]any{"name": "[x](https://attacker.example)"}, + "archived_chats": []any{ + map[string]any{"title": "[x](https://attacker.example)"}, + }, + }, + } + + escaped := payload.EscapedForMarkdown() + require.Equal(t, `\[x\]\(https://attacker.example\)`, + escaped.Data["user"].(map[string]any)["name"]) + require.Equal(t, `\[x\]\(https://attacker.example\)`, + escaped.Data["archived_chats"].([]any)[0].(map[string]any)["title"]) + }) + + t.Run("PreservesNonStringLeaves", func(t *testing.T) { + t.Parallel() + + // Body templates compare numbers, as {{if gt $version.failed_count 1}} + // does, so coercing them to strings would break the comparison. + payload := types.MessagePayload{ + Data: map[string]any{ + "failed_count": 3.0, + "enabled": true, + "absent": nil, + "versions": []any{map[string]any{"failed_count": 1.0}}, + }, + } + + escaped := payload.EscapedForMarkdown() + require.Equal(t, 3.0, escaped.Data["failed_count"]) + require.Equal(t, true, escaped.Data["enabled"]) + require.Nil(t, escaped.Data["absent"]) + require.Equal(t, 1.0, escaped.Data["versions"].([]any)[0].(map[string]any)["failed_count"]) + }) + + t.Run("NilMapsStayNil", func(t *testing.T) { + t.Parallel() + + escaped := types.MessagePayload{}.EscapedForMarkdown() + require.Nil(t, escaped.Labels) + require.Nil(t, escaped.Data) + }) + + t.Run("EscapesNestedMapKeys", func(t *testing.T) { + t.Parallel() + + // A nested key is content when a template ranges with two variables. + payload := types.MessagePayload{ + Data: map[string]any{ + "replacements": map[string]any{ + "[Re-auth](https://attacker.example/login)": "paths", + "null_resource.ok": "other", + }, + }, + } + + escaped := payload.EscapedForMarkdown() + replacements, ok := escaped.Data["replacements"].(map[string]any) + require.True(t, ok) + require.Contains(t, replacements, `\[Re-auth\]\(https://attacker.example/login\)`) + require.NotContains(t, replacements, "[Re-auth](https://attacker.example/login)") + // A key with nothing to escape must stay resolvable by name. + require.Contains(t, replacements, "null_resource.ok") + }) + + t.Run("LeavesTopLevelDataKeysAlone", func(t *testing.T) { + t.Parallel() + + // Top-level .Data keys are dereferenced by name, not content, so escaping + // one breaks the lookup. + payload := types.MessagePayload{ + Data: map[string]any{"failed_builds": []any{"x"}}, + } + + require.Contains(t, payload.EscapedForMarkdown().Data, "failed_builds") + }) +} diff --git a/coderd/provisionerdserver/provisionerdserver.go b/coderd/provisionerdserver/provisionerdserver.go index 5a52ecc0a1ada..9fb25812600b4 100644 --- a/coderd/provisionerdserver/provisionerdserver.go +++ b/coderd/provisionerdserver/provisionerdserver.go @@ -1584,11 +1584,34 @@ 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)) } - upload, chunks := sdkproto.BytesToDataUpload(sdkproto.DataUploadType_UPLOAD_TYPE_MODULE_FILES, file.Data) + upload, chunks, err := sdkproto.BytesToDataUpload(sdkproto.DataUploadType_UPLOAD_TYPE_MODULE_FILES, file.Data) + if err != nil { + return fail(xerrors.Errorf("prepare file upload: %w", err)) + } err = stream.Send(&sdkproto.FileUpload{ Type: &sdkproto.FileUpload_DataUpload{DataUpload: upload}, @@ -1701,6 +1724,7 @@ func (s *server) completeTemplateImportJob(ctx context.Context, job database.Pro slog.F("transition", transition)) if err := InsertWorkspaceResource(ctx, db, jobID, transition, resource, telemetrySnapshot); err != nil { + s.warnWorkspaceAppRebindRejected(ctx, jobID, err) return xerrors.Errorf("insert resource: %w", err) } } @@ -2119,6 +2143,7 @@ func (s *server) completeWorkspaceBuildJob(ctx context.Context, job database.Pro InsertWorkspaceResourceWithAgentIDsFromProto(), ) if err != nil { + s.warnWorkspaceAppRebindRejected(ctx, jobID, err) return xerrors.Errorf("insert provisioner job: %w", err) } } @@ -2587,6 +2612,7 @@ func (s *server) completeTemplateDryRunJob(ctx context.Context, job database.Pro err := InsertWorkspaceResource(ctx, db, jobID, database.WorkspaceTransitionStart, resource, telemetrySnapshot) if err != nil { + s.warnWorkspaceAppRebindRejected(ctx, jobID, err) return xerrors.Errorf("insert resource: %w", err) } } @@ -3611,6 +3637,32 @@ func insertAgentScriptsAndLogSources(ctx context.Context, db database.Store, age return nil } +type workspaceAppRebindError struct { + slug string + appID uuid.UUID + agentID uuid.UUID +} + +func (e *workspaceAppRebindError) Error() string { + return fmt.Sprintf("workspace app slug %q with ID %q is already bound to a workspace-owned agent and cannot be rebound to an agent in another workspace or to an agent without a workspace; refusing to rebind to agent ID %q", e.slug, e.appID, e.agentID) +} + +func (s *server) warnWorkspaceAppRebindRejected(ctx context.Context, jobID uuid.UUID, err error) { + slog.Helper() + + var rebindErr *workspaceAppRebindError + if !errors.As(err, &rebindErr) { + return + } + + s.Logger.Warn(ctx, "workspace app rebind rejected by SQL guard", + slog.F("job_id", jobID.String()), + slog.F("app_id", rebindErr.appID.String()), + slog.F("agent_id", rebindErr.agentID.String()), + slog.F("app_slug", rebindErr.slug), + ) +} + func insertAgentApp(ctx context.Context, db database.Store, agentID uuid.UUID, app *sdkproto.App, appSlugs map[string]struct{}, snapshot *telemetry.Snapshot) error { // Similar logic is duplicated in terraform/resources.go. slug := app.Slug @@ -3699,6 +3751,17 @@ func insertAgentApp(ctx context.Context, db database.Store, agentID uuid.UUID, a Tooltip: app.Tooltip, }) if err != nil { + if errors.Is(err, sql.ErrNoRows) { + // The upsert's ON CONFLICT guard refused to rebind an app + // owned by a workspace to an agent outside that workspace, + // including agents from import or dry-run jobs that resolve + // to no workspace (SEC-91). + return &workspaceAppRebindError{ + slug: slug, + appID: id, + agentID: agentID, + } + } return xerrors.Errorf("upsert app: %w", err) } diff --git a/coderd/provisionerdserver/provisionerdserver_test.go b/coderd/provisionerdserver/provisionerdserver_test.go index 007c26cb18e3a..7b59c46a54a72 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,7 +26,10 @@ 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" "github.com/coder/coder/v2/buildinfo" "github.com/coder/coder/v2/coderd" @@ -51,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" @@ -2348,6 +2353,109 @@ func TestCompleteJob(t *testing.T) { }) } }) + t.Run("WorkspaceBuild_CrossWorkspaceAppRebindRejected", func(t *testing.T) { + t.Parallel() + + logSink := &recordingSlogSink{} + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).AppendSinks(logSink) + srv, db, _, pd := setup(t, false, &overrides{provisionerdLogger: &logger}) + + // Given: a victim workspace whose agent owns an app with a known UUID. + victimAppID, victimAgentID, victimSlug := setupWorkspaceAppRebindVictim( + t, db, pd.OrganizationID, + ) + + // Given: an attacker workspace with a running build job acquired by the + // provisioner daemon. + attackerUser := dbgen.User(t, db, database.User{}) + attackerTemplate := dbgen.Template(t, db, database.Template{ + CreatedBy: attackerUser.ID, + OrganizationID: pd.OrganizationID, + }) + attackerVersion := dbgen.TemplateVersion(t, db, database.TemplateVersion{ + CreatedBy: attackerUser.ID, + OrganizationID: pd.OrganizationID, + TemplateID: uuid.NullUUID{UUID: attackerTemplate.ID, Valid: true}, + JobID: uuid.New(), + }) + attackerWorkspace := dbgen.Workspace(t, db, database.WorkspaceTable{ + TemplateID: attackerTemplate.ID, + OwnerID: attackerUser.ID, + OrganizationID: pd.OrganizationID, + }) + attackerBuildID := uuid.New() + attackerJob := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{ + InitiatorID: attackerUser.ID, + Type: database.ProvisionerJobTypeWorkspaceBuild, + Input: must(json.Marshal(provisionerdserver.WorkspaceProvisionJob{ + WorkspaceBuildID: attackerBuildID, + })), + OrganizationID: pd.OrganizationID, + }) + dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{ + ID: attackerBuildID, + JobID: attackerJob.ID, + WorkspaceID: attackerWorkspace.ID, + TemplateVersionID: attackerVersion.ID, + InitiatorID: attackerUser.ID, + Transition: database.WorkspaceTransitionStart, + Reason: database.BuildReasonInitiator, + }) + _, err := db.AcquireProvisionerJob(ctx, database.AcquireProvisionerJobParams{ + OrganizationID: pd.OrganizationID, + WorkerID: uuid.NullUUID{UUID: pd.ID, Valid: true}, + Types: []database.ProvisionerType{database.ProvisionerTypeEcho}, + StartedAt: sql.NullTime{Time: time.Now(), Valid: true}, + ProvisionerTags: must(json.Marshal(attackerJob.Tags)), + }) + require.NoError(t, err) + + // When: the attacker's build completes with an app that reuses the + // victim's app UUID but points at the attacker's (new) agent. + attackerAgent := &sdkproto.Agent{ + Id: uuid.NewString(), + Name: "dev", + Auth: &sdkproto.Agent_Token{Token: uuid.NewString()}, + Apps: []*sdkproto.App{{ + Id: victimAppID.String(), + Slug: "attacker-app", + }}, + } + _, err = srv.CompleteJob(ctx, &proto.CompletedJob{ + JobId: attackerJob.ID.String(), + Type: &proto.CompletedJob_WorkspaceBuild_{ + WorkspaceBuild: &proto.CompletedJob_WorkspaceBuild{ + State: []byte{}, + Resources: []*sdkproto.Resource{{ + Name: "example", + Type: "aws_instance", + Agents: []*sdkproto.Agent{attackerAgent}, + }}, + }, + }, + }) + // Then: the build is rejected with the cross-tenant rebind error. + require.Error(t, err) + require.ErrorContains(t, err, "already bound to a workspace-owned agent") + assertWorkspaceAppRebindWarning( + t, + logSink, + workspaceAppRebindWarning{ + jobID: attackerJob.ID, + appID: victimAppID, + slug: "attacker-app", + agentID: attackerAgent.Id, + }, + ) + + // And: the victim's app remains bound to the victim agent, unchanged. + victimApps, err := db.GetWorkspaceAppsByAgentID(ctx, victimAgentID) + require.NoError(t, err) + require.Len(t, victimApps, 1) + require.Equal(t, victimAppID, victimApps[0].ID) + require.Equal(t, victimAgentID, victimApps[0].AgentID) + require.Equal(t, victimSlug, victimApps[0].Slug) + }) t.Run("TemplateDryRun", func(t *testing.T) { t.Parallel() srv, db, _, pd := setup(t, false, &overrides{}) @@ -2398,6 +2506,161 @@ func TestCompleteJob(t *testing.T) { require.NoError(t, err) }) + t.Run("TemplateDryRun_CrossWorkspaceAppRebindRejected", func(t *testing.T) { + t.Parallel() + logSink := &recordingSlogSink{} + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).AppendSinks(logSink) + srv, db, _, pd := setup(t, false, &overrides{provisionerdLogger: &logger}) + + victimAppID, victimAgentID, victimSlug := setupWorkspaceAppRebindVictim( + t, db, pd.OrganizationID, + ) + + user := dbgen.User(t, db, database.User{}) + version := dbgen.TemplateVersion(t, db, database.TemplateVersion{ + CreatedBy: user.ID, + OrganizationID: pd.OrganizationID, + JobID: uuid.New(), + }) + job, err := db.InsertProvisionerJob(ctx, database.InsertProvisionerJobParams{ + ID: version.JobID, + Provisioner: database.ProvisionerTypeEcho, + Type: database.ProvisionerJobTypeTemplateVersionDryRun, + StorageMethod: database.ProvisionerStorageMethodFile, + Input: must(json.Marshal(provisionerdserver.TemplateVersionDryRunJob{ + TemplateVersionID: version.ID, + })), + OrganizationID: pd.OrganizationID, + Tags: pd.Tags, + }) + require.NoError(t, err) + _, err = db.AcquireProvisionerJob(ctx, database.AcquireProvisionerJobParams{ + WorkerID: uuid.NullUUID{UUID: pd.ID, Valid: true}, + Types: []database.ProvisionerType{database.ProvisionerTypeEcho}, + StartedAt: sql.NullTime{Time: dbtime.Now(), Valid: true}, + OrganizationID: pd.OrganizationID, + ProvisionerTags: must(json.Marshal(job.Tags)), + }) + require.NoError(t, err) + + dryRunAgent := &sdkproto.Agent{ + Name: "dev", + Auth: &sdkproto.Agent_Token{Token: uuid.NewString()}, + Apps: []*sdkproto.App{{ + Id: victimAppID.String(), + Slug: "dry-run-app", + }}, + } + _, err = srv.CompleteJob(ctx, &proto.CompletedJob{ + JobId: job.ID.String(), + Type: &proto.CompletedJob_TemplateDryRun_{ + TemplateDryRun: &proto.CompletedJob_TemplateDryRun{ + Resources: []*sdkproto.Resource{{ + Name: "something", + Type: "aws_instance", + Agents: []*sdkproto.Agent{dryRunAgent}, + }}, + }, + }, + }) + require.Error(t, err) + require.ErrorContains(t, err, "already bound to a workspace-owned agent") + assertWorkspaceAppRebindWarning( + t, + logSink, + workspaceAppRebindWarning{ + jobID: job.ID, + appID: victimAppID, + slug: "dry-run-app", + }, + ) + + victimApps, err := db.GetWorkspaceAppsByAgentID(ctx, victimAgentID) + require.NoError(t, err) + require.Len(t, victimApps, 1) + require.Equal(t, victimAppID, victimApps[0].ID) + require.Equal(t, victimAgentID, victimApps[0].AgentID) + require.Equal(t, victimSlug, victimApps[0].Slug) + }) + + t.Run("TemplateImport_CrossWorkspaceAppRebindRejected", func(t *testing.T) { + t.Parallel() + logSink := &recordingSlogSink{} + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).AppendSinks(logSink) + srv, db, _, pd := setup(t, false, &overrides{provisionerdLogger: &logger}) + + victimAppID, victimAgentID, victimSlug := setupWorkspaceAppRebindVictim( + t, db, pd.OrganizationID, + ) + + user := dbgen.User(t, db, database.User{}) + version := dbgen.TemplateVersion(t, db, database.TemplateVersion{ + CreatedBy: user.ID, + OrganizationID: pd.OrganizationID, + JobID: uuid.New(), + }) + job, err := db.InsertProvisionerJob(ctx, database.InsertProvisionerJobParams{ + ID: version.JobID, + Provisioner: database.ProvisionerTypeEcho, + Type: database.ProvisionerJobTypeTemplateVersionImport, + StorageMethod: database.ProvisionerStorageMethodFile, + Input: must(json.Marshal(provisionerdserver.TemplateVersionImportJob{ + TemplateVersionID: version.ID, + })), + OrganizationID: pd.OrganizationID, + Tags: pd.Tags, + }) + require.NoError(t, err) + _, err = db.AcquireProvisionerJob(ctx, database.AcquireProvisionerJobParams{ + WorkerID: uuid.NullUUID{UUID: pd.ID, Valid: true}, + Types: []database.ProvisionerType{database.ProvisionerTypeEcho}, + StartedAt: sql.NullTime{Time: dbtime.Now(), Valid: true}, + OrganizationID: pd.OrganizationID, + ProvisionerTags: must(json.Marshal(job.Tags)), + }) + require.NoError(t, err) + + importAgent := &sdkproto.Agent{ + Name: "dev", + Auth: &sdkproto.Agent_Token{Token: uuid.NewString()}, + Apps: []*sdkproto.App{{ + Id: victimAppID.String(), + Slug: "import-app", + }}, + } + _, err = srv.CompleteJob(ctx, &proto.CompletedJob{ + JobId: job.ID.String(), + Type: &proto.CompletedJob_TemplateImport_{ + TemplateImport: &proto.CompletedJob_TemplateImport{ + StartResources: []*sdkproto.Resource{{ + Name: "something", + Type: "aws_instance", + Agents: []*sdkproto.Agent{importAgent}, + }}, + Plan: []byte("{}"), + }, + }, + }) + require.Error(t, err) + require.ErrorContains(t, err, "already bound to a workspace-owned agent") + assertWorkspaceAppRebindWarning( + t, + logSink, + workspaceAppRebindWarning{ + jobID: job.ID, + appID: victimAppID, + slug: "import-app", + }, + ) + + victimApps, err := db.GetWorkspaceAppsByAgentID(ctx, victimAgentID) + require.NoError(t, err) + require.Len(t, victimApps, 1) + require.Equal(t, victimAppID, victimApps[0].ID) + require.Equal(t, victimAgentID, victimApps[0].AgentID) + require.Equal(t, victimSlug, victimApps[0].Slug) + }) + t.Run("Modules", func(t *testing.T) { t.Parallel() @@ -3383,6 +3646,59 @@ func TestCompleteJob(t *testing.T) { }) } +func setupWorkspaceAppRebindVictim( + t *testing.T, + db database.Store, + organizationID uuid.UUID, +) (appID uuid.UUID, agentID uuid.UUID, slug string) { + t.Helper() + + victimUser := dbgen.User(t, db, database.User{}) + victimTemplate := dbgen.Template(t, db, database.Template{ + CreatedBy: victimUser.ID, + OrganizationID: organizationID, + }) + victimVersion := dbgen.TemplateVersion(t, db, database.TemplateVersion{ + CreatedBy: victimUser.ID, + OrganizationID: organizationID, + TemplateID: uuid.NullUUID{UUID: victimTemplate.ID, Valid: true}, + }) + victimWorkspace := dbgen.Workspace(t, db, database.WorkspaceTable{ + TemplateID: victimTemplate.ID, + OwnerID: victimUser.ID, + OrganizationID: organizationID, + }) + victimJob := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{ + Type: database.ProvisionerJobTypeWorkspaceBuild, + OrganizationID: organizationID, + StartedAt: sql.NullTime{Time: time.Now().Add(-time.Hour), Valid: true}, + CompletedAt: sql.NullTime{Time: time.Now().Add(-time.Hour), Valid: true}, + }) + dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{ + JobID: victimJob.ID, + WorkspaceID: victimWorkspace.ID, + TemplateVersionID: victimVersion.ID, + InitiatorID: victimUser.ID, + Transition: database.WorkspaceTransitionStart, + Reason: database.BuildReasonInitiator, + }) + victimResource := dbgen.WorkspaceResource(t, db, database.WorkspaceResource{ + JobID: victimJob.ID, + }) + victimAgent := dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{ + ResourceID: victimResource.ID, + }) + victimAppID := uuid.New() + const victimSlug = "code-server" + dbgen.WorkspaceApp(t, db, database.WorkspaceApp{ + ID: victimAppID, + AgentID: victimAgent.ID, + Slug: victimSlug, + }) + + return victimAppID, victimAgent.ID, victimSlug +} + type mockPrebuildsOrchestrator struct { agplprebuilds.ReconciliationOrchestrator @@ -4781,6 +5097,70 @@ func TestServer_ExpirePrebuildsSessionToken(t *testing.T) { require.ErrorIs(t, err, sql.ErrNoRows, "api key for prebuilds user should be deleted") } +type workspaceAppRebindWarning struct { + jobID uuid.UUID + appID uuid.UUID + slug string + agentID string +} + +func assertWorkspaceAppRebindWarning(t *testing.T, logSink *recordingSlogSink, want workspaceAppRebindWarning) { + t.Helper() + + for _, entry := range logSink.Entries() { + if entry.Message != "workspace app rebind rejected by SQL guard" { + continue + } + + require.Equal(t, slog.LevelWarn, entry.Level) + require.Contains(t, entry.File, "coderd/provisionerdserver/provisionerdserver.go") + require.NotContains(t, entry.Func, "warnWorkspaceAppRebindRejected") + fields := slogFieldsByName(entry.Fields) + require.Equal(t, want.jobID.String(), fields["job_id"]) + require.Equal(t, want.appID.String(), fields["app_id"]) + require.Equal(t, want.slug, fields["app_slug"]) + agentID, ok := fields["agent_id"].(string) + require.True(t, ok) + require.NotEqual(t, uuid.Nil.String(), agentID) + if want.agentID != "" { + require.Equal(t, want.agentID, agentID) + } else { + _, err := uuid.Parse(agentID) + require.NoError(t, err) + } + return + } + + require.Fail(t, "expected workspace app rebind warning") +} + +type recordingSlogSink struct { + mu sync.Mutex + entries []slog.SinkEntry +} + +func (s *recordingSlogSink) LogEntry(_ context.Context, entry slog.SinkEntry) { + s.mu.Lock() + defer s.mu.Unlock() + s.entries = append(s.entries, entry) +} + +func (*recordingSlogSink) Sync() {} + +func (s *recordingSlogSink) Entries() []slog.SinkEntry { + s.mu.Lock() + defer s.mu.Unlock() + return append([]slog.SinkEntry(nil), s.entries...) +} + +func slogFieldsByName(fields []slog.Field) map[string]any { + byName := make(map[string]any, len(fields)) + for _, field := range fields { + byName[field.Name] = field.Value + } + return byName +} + type overrides struct { ctx context.Context deploymentValues *codersdk.DeploymentValues @@ -4795,6 +5175,7 @@ type overrides struct { auditor audit.Auditor notificationEnqueuer notifications.Enqueuer prebuildsOrchestrator agplprebuilds.ReconciliationOrchestrator + provisionerdLogger *slog.Logger } func setup(t *testing.T, ignoreLogErrors bool, ov *overrides) (proto.DRPCProvisionerDaemonServer, database.Store, pubsub.Pubsub, database.ProvisionerDaemon) { @@ -4871,6 +5252,10 @@ func setup(t *testing.T, ignoreLogErrors bool, ov *overrides) (proto.DRPCProvisi } else { notifEnq = notifications.NewNoopEnqueuer() } + provisionerdLogger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: ignoreLogErrors}) + if ov.provisionerdLogger != nil { + provisionerdLogger = *ov.provisionerdLogger + } daemon, err := db.UpsertProvisionerDaemon(ov.ctx, database.UpsertProvisionerDaemonParams{ Name: "test", @@ -4902,7 +5287,7 @@ func setup(t *testing.T, ignoreLogErrors bool, ov *overrides) (proto.DRPCProvisi &url.URL{}, daemon.ID, defOrg.ID, - slogtest.Make(t, &slogtest.Options{IgnoreErrors: ignoreLogErrors}), + provisionerdLogger, []database.ProvisionerType{database.ProvisionerTypeEcho}, provisionerdserver.Tags(daemon.Tags), serverDB, @@ -5039,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) + }) +} diff --git a/coderd/provisionerdserver/upload_file_test.go b/coderd/provisionerdserver/upload_file_test.go index d041bb9f981fc..f235095742d4a 100644 --- a/coderd/provisionerdserver/upload_file_test.go +++ b/coderd/provisionerdserver/upload_file_test.go @@ -48,7 +48,8 @@ func TestUploadFileLargeModuleFiles(t *testing.T) { require.NoError(t, err) // Convert to upload format - upload, chunks := sdkproto.BytesToDataUpload(sdkproto.DataUploadType_UPLOAD_TYPE_MODULE_FILES, moduleData) + upload, chunks, err := sdkproto.BytesToDataUpload(sdkproto.DataUploadType_UPLOAD_TYPE_MODULE_FILES, moduleData) + require.NoError(t, err) stream := newMockUploadStream(upload, chunks...) @@ -93,7 +94,8 @@ func TestUploadFileErrorScenarios(t *testing.T) { _, err := crand.Read(moduleData) require.NoError(t, err) - upload, chunks := sdkproto.BytesToDataUpload(sdkproto.DataUploadType_UPLOAD_TYPE_MODULE_FILES, moduleData) + upload, chunks, err := sdkproto.BytesToDataUpload(sdkproto.DataUploadType_UPLOAD_TYPE_MODULE_FILES, moduleData) + require.NoError(t, err) t.Run("chunk_before_upload", func(t *testing.T) { t.Parallel() diff --git a/coderd/rbac/authz_internal_test.go b/coderd/rbac/authz_internal_test.go index 3d93306017756..163e5d01e09dd 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 e8844a22bdbd8..0a15955badd0f 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 diff --git a/coderd/render/escape.go b/coderd/render/escape.go new file mode 100644 index 0000000000000..7c7d2b65f79db --- /dev/null +++ b/coderd/render/escape.go @@ -0,0 +1,188 @@ +package render + +import "strings" + +// Character classes for EscapeMarkdown, split by where each character carries +// structural meaning. The split is what keeps escaping away from the enum-like +// label values that body templates compare with `eq`, such as "user_override", +// "bobby-workspace" and "1.5": escaping one changes template control flow. +const ( + // inlineCritical characters can produce a link, an image, an angle autolink, + // or forge an escape from anywhere in a value, so they are always escaped. + // + // Backtick is here, not in blockStart, because a fence's info string is an + // HTML sink: gomarkdown writes it into class="language-..." unescaped, and + // SkipHTML does not apply to a CodeBlock node. Escaping only the leading + // backtick would leave two, which open an inline code span. + inlineCritical = "\\[]()!<`" + + // blockStart characters carry structural meaning only as the first + // non-space character of a line, so they are escaped only there. + blockStart = `#-+.>|` + + // 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 0000000000000..8cf2078f92bfa --- /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 0000000000000..5080f9817bd41 --- /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 75e6d8d1c1813..2e7a9ac8ed331 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/coderd/tailnet.go b/coderd/tailnet.go index 4d73c89fd11ed..ec061f015b64e 100644 --- a/coderd/tailnet.go +++ b/coderd/tailnet.go @@ -298,6 +298,7 @@ func (s *ServerTailnet) AgentConn(ctx context.Context, agentID uuid.UUID) (works conn = workspacesdk.NewAgentConn(s.conn, workspacesdk.AgentConnOptions{ AgentID: agentID, CloseFunc: func() error { return workspacesdk.ErrSkipClose }, + Logger: s.logger, }) // Since we now have an open conn, be careful to close it if we error diff --git a/coderd/templateversions.go b/coderd/templateversions.go index ef7f6e0899693..682a7bb0b1b80 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/userauth.go b/coderd/userauth.go index 046e8dc903423..fd686124d64f6 100644 --- a/coderd/userauth.go +++ b/coderd/userauth.go @@ -3,6 +3,7 @@ package coderd import ( "context" "database/sql" + "encoding/json" "errors" "fmt" "net/http" @@ -1036,7 +1037,16 @@ func (api *API) userOAuth2Github(rw http.ResponseWriter, r *http.Request) { }) return } - user, link, err := findLinkedUser(ctx, api.Database, githubLinkedID(ghUser), 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()), + ) + httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{ + Message: "This account is already linked to a different identity provider subject.", + }) + return + } if err != nil { logger.Error(ctx, "oauth2: unable to find linked user", slog.F("gh_user", ghUser.Name), slog.Error(err)) httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ @@ -1129,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{ @@ -1177,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. @@ -1339,27 +1355,39 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) { return } - verifiedRaw, ok := mergedClaims["email_verified"] - if ok { - verified, ok := verifiedRaw.(bool) - if ok && !verified { - if !api.OIDCConfig.IgnoreEmailVerified { - site.RenderStaticErrorPage(rw, r, site.ErrorPageData{ - Status: http.StatusForbidden, - HideStatus: true, - Title: "Email not verified", - Description: fmt.Sprintf( - "Verify the %q email address on your OIDC provider to authenticate!", - email, - ), - Actions: []site.Action{ - {URL: "/login", Text: "Back to login"}, - }, - }) - return - } - logger.Warn(ctx, "allowing unverified oidc email", slog.F("email", email)) + // Determine whether the email is verified. Default to unverified + // so that a missing claim or an unrecognized type is fail-closed. + emailVerified := false + verifiedRaw, hasVerifiedClaim := mergedClaims["email_verified"] + if hasVerifiedClaim { + v, coerceOK := coerceEmailVerified(verifiedRaw) + if coerceOK { + emailVerified = v + } else { + logger.Warn(ctx, "unrecognized email_verified claim type, treating as unverified", + slog.F("type", fmt.Sprintf("%T", verifiedRaw)), + slog.F("value", verifiedRaw), + ) + } + } + + if !emailVerified { + if !api.OIDCConfig.IgnoreEmailVerified { + site.RenderStaticErrorPage(rw, r, site.ErrorPageData{ + Status: http.StatusForbidden, + HideStatus: true, + Title: "Email not verified", + Description: fmt.Sprintf( + "Verify the %q email address on your OIDC provider to authenticate!", + email, + ), + Actions: []site.Action{ + {URL: "/login", Text: "Back to login"}, + }, + }) + return } + logger.Warn(ctx, "allowing unverified oidc email", slog.F("email", email)) } // The username is a required property in Coder. We make a best-effort @@ -1436,7 +1464,22 @@ 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), 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), + ) + site.RenderStaticErrorPage(rw, r, site.ErrorPageData{ + Status: http.StatusForbidden, + HideStatus: true, + Title: "Account already linked", + Description: "This account is already linked to a different identity provider subject. Contact your administrator.", + Actions: []site.Action{ + {URL: "/login", Text: "Back to login"}, + }, + }) + return + } if err != nil { logger.Error(ctx, "oauth2: unable to find linked user", slog.F("email", email), slog.Error(err)) httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ @@ -1489,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) }) @@ -1516,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) } @@ -1642,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() @@ -1870,6 +1921,34 @@ func (api *API) oauthLogin(r *http.Request, params *oauthLoginParams) ([]*http.C if err != nil { return xerrors.Errorf("update user link: %w", err) } + + // 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. + // 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", + Detail: "This account is already linked to a different identity provider subject. Contact your administrator.", + RenderStaticPage: true, + } + } + + // Backfill linked_id for legacy links. + if link.LinkedID == "" && params.LinkedID != "" { + //nolint:gocritic // System needs to update the user link. + link, err = tx.UpdateUserLinkedID(dbauthz.AsSystemRestricted(ctx), database.UpdateUserLinkedIDParams{ + LinkedID: params.LinkedID, + UserID: user.ID, + LoginType: params.LoginType, + }) + if err != nil { + return xerrors.Errorf("backfill user linked id: %w", err) + } + } } err = api.IDPSync.SyncOrganizations(ctx, tx, user, params.OrganizationSync) @@ -2090,9 +2169,26 @@ func oidcLinkedID(tok *oidc.IDToken) string { return strings.Join([]string{tok.Issuer, tok.Subject}, "||") } +// errLinkedIDAlreadyBound is returned by findLinkedUser when the user +// found by email already has a user_link with a different linked_id. +var errLinkedIDAlreadyBound = xerrors.New("user account is already linked to a different identity provider subject") + // findLinkedUser tries to find a user by their unique OAuth-linked ID. -// If it doesn't not find it, it returns the user by their email. -func findLinkedUser(ctx context.Context, db database.Store, linkedID string, emails ...string) (database.User, database.UserLink, error) { +// If it does not find a match, it falls back to email-based lookup. +// The email fallback is restricted to first-time account linking and +// 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. +// +// 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 @@ -2137,12 +2233,21 @@ func findLinkedUser(ctx context.Context, db database.Store, linkedID string, ema // possible that a user_link exists without a populated 'linked_id'. link, err = db.GetUserLinkByUserIDLoginType(ctx, database.GetUserLinkByUserIDLoginTypeParams{ UserID: user.ID, - LoginType: user.LoginType, + LoginType: loginType, }) if err != nil && !errors.Is(err, sql.ErrNoRows) { return database.User{}, database.UserLink{}, xerrors.Errorf("get user link by user id and login type: %w", err) } + // 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. 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 + } + return user, link, nil } @@ -2171,3 +2276,39 @@ func wrongLoginTypeHTTPError(user database.LoginType, params database.LoginType) params, user, addedMsg), } } + +// coerceEmailVerified attempts to convert an OIDC email_verified claim to a +// boolean. Some IdPs (e.g. SAML-to-OIDC bridges, certain Azure AD B2C +// configurations) return email_verified as a string ("true"/"false") or a +// number (1/0) rather than a native JSON boolean. This function handles +// those variants so that non-bool representations cannot silently bypass +// the verification check. +// +// Returns (value, true) on successful coercion, or (false, false) if the +// value is nil or an unrecognized type. +func coerceEmailVerified(v interface{}) (verified bool, ok bool) { + switch val := v.(type) { + case bool: + return val, true + case string: + b, err := strconv.ParseBool(val) + if err != nil { + return false, false + } + return b, true + case json.Number: + n, err := val.Int64() + if err != nil { + return false, false + } + return n != 0, true + case float64: + return val != 0, true + case int64: + return val != 0, true + case int: + return val != 0, true + default: + return false, false + } +} diff --git a/coderd/userauth_internal_test.go b/coderd/userauth_internal_test.go new file mode 100644 index 0000000000000..47e1883b52b35 --- /dev/null +++ b/coderd/userauth_internal_test.go @@ -0,0 +1,65 @@ +package coderd + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCoerceEmailVerified(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input interface{} + wantBool bool + wantOK bool + }{ + // Native booleans + {name: "BoolTrue", input: true, wantBool: true, wantOK: true}, + {name: "BoolFalse", input: false, wantBool: false, wantOK: true}, + + // Strings + {name: "StringTrue", input: "true", wantBool: true, wantOK: true}, + {name: "StringFalse", input: "false", wantBool: false, wantOK: true}, + {name: "StringOne", input: "1", wantBool: true, wantOK: true}, + {name: "StringZero", input: "0", wantBool: false, wantOK: true}, + {name: "StringTRUE", input: "TRUE", wantBool: true, wantOK: true}, + {name: "StringFALSE", input: "FALSE", wantBool: false, wantOK: true}, + {name: "StringT", input: "t", wantBool: true, wantOK: true}, + {name: "StringF", input: "f", wantBool: false, wantOK: true}, + {name: "StringInvalid", input: "invalid", wantBool: false, wantOK: false}, + {name: "StringEmpty", input: "", wantBool: false, wantOK: false}, + + // json.Number (when decoder uses UseNumber) + {name: "JSONNumberOne", input: json.Number("1"), wantBool: true, wantOK: true}, + {name: "JSONNumberZero", input: json.Number("0"), wantBool: false, wantOK: true}, + {name: "JSONNumberInvalid", input: json.Number("abc"), wantBool: false, wantOK: false}, + + // float64 (default JSON numeric type) + {name: "Float64One", input: float64(1), wantBool: true, wantOK: true}, + {name: "Float64Zero", input: float64(0), wantBool: false, wantOK: true}, + + // Integer types + {name: "IntOne", input: int(1), wantBool: true, wantOK: true}, + {name: "IntZero", input: int(0), wantBool: false, wantOK: true}, + {name: "Int64One", input: int64(1), wantBool: true, wantOK: true}, + {name: "Int64Zero", input: int64(0), wantBool: false, wantOK: true}, + + // Nil and unsupported types + {name: "Nil", input: nil, wantBool: false, wantOK: false}, + {name: "Slice", input: []string{}, wantBool: false, wantOK: false}, + {name: "Map", input: map[string]string{}, wantBool: false, wantOK: false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + gotBool, gotOK := coerceEmailVerified(tc.input) + assert.Equal(t, tc.wantBool, gotBool, "bool value mismatch") + assert.Equal(t, tc.wantOK, gotOK, "ok value mismatch") + }) + } +} diff --git a/coderd/userauth_test.go b/coderd/userauth_test.go index 26cdf48e87ea8..3833dc6423323 100644 --- a/coderd/userauth_test.go +++ b/coderd/userauth_test.go @@ -386,6 +386,67 @@ func TestUserOAuth2Github(t *testing.T) { require.Equal(t, http.StatusForbidden, resp.StatusCode) }) + t.Run("EmailFallbackBlockedByExistingLink", func(t *testing.T) { + t.Parallel() + + // A victim already has a GitHub link bound to a specific GitHub user + // ID. An attacker authenticates with a different GitHub user ID but + // the victim's verified email. The email fallback must not hand the + // attacker the victim's account, even with signups enabled. + owner, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{ + GithubOAuth2Config: &coderd.GithubOAuth2Config{ + OAuth2Config: &testutil.OAuth2Config{}, + AllowSignups: true, + AllowEveryone: true, + ListOrganizationMemberships: func(_ context.Context, _ *http.Client) ([]*github.Membership, error) { + return []*github.Membership{}, nil + }, + TeamMembership: func(_ context.Context, _ *http.Client, _, _, _ string) (*github.Membership, error) { + return nil, xerrors.New("no teams") + }, + AuthenticatedUser: func(_ context.Context, _ *http.Client) (*github.User, error) { + // Attacker's GitHub ID differs from the victim's link. + return &github.User{ + ID: github.Int64(200), + Login: github.String("attacker"), + Name: github.String("Attacker"), + }, nil + }, + ListEmails: func(_ context.Context, _ *http.Client) ([]*github.UserEmail, error) { + return []*github.UserEmail{{ + Email: github.String("victim@coder.com"), + Verified: github.Bool(true), + Primary: github.Bool(true), + }}, nil + }, + }, + }) + + // Seed the victim with an existing GitHub link (a different linked_id). + victim := dbgen.User(t, db, database.User{ + Email: "victim@coder.com", + LoginType: database.LoginTypeGithub, + }) + const victimLinkedID = "100" + dbgen.UserLink(t, db, database.UserLink{ + UserID: victim.ID, + LoginType: database.LoginTypeGithub, + LinkedID: victimLinkedID, + }) + + resp := oauth2Callback(t, owner) + require.Equal(t, http.StatusForbidden, resp.StatusCode, + "attacker with a different GitHub ID must not authenticate as the victim") + + // The victim's link must be untouched. + victimLink, err := db.GetUserLinkByUserIDLoginType(dbauthz.AsSystemRestricted(context.Background()), database.GetUserLinkByUserIDLoginTypeParams{ + UserID: victim.ID, + LoginType: database.LoginTypeGithub, + }) + require.NoError(t, err) + require.Equal(t, victimLinkedID, victimLink.LinkedID, + "victim's linked_id must remain unchanged") + }) t.Run("Signup", func(t *testing.T) { t.Parallel() auditor := audit.NewMock() @@ -1067,7 +1128,8 @@ func TestUserOIDC(t *testing.T) { "sub": uuid.NewString(), }, AccessTokenClaims: jwt.MapClaims{ - "email": "kyle@kwc.io", + "email": "kyle@kwc.io", + "email_verified": true, }, IgnoreUserInfo: true, AllowSignups: true, @@ -1090,8 +1152,22 @@ func TestUserOIDC(t *testing.T) { { Name: "EmailOnly", IDTokenClaims: jwt.MapClaims{ - "email": "kyle@kwc.io", - "sub": uuid.NewString(), + "email": "kyle@kwc.io", + "email_verified": true, + "sub": uuid.NewString(), + }, + AllowSignups: true, + StatusCode: http.StatusOK, + AssertUser: func(t testing.TB, u codersdk.User) { + assert.Equal(t, "kyle", u.Username) + }, + }, + { + Name: "EmailVerifiedAsStringTrue", + IDTokenClaims: jwt.MapClaims{ + "email": "kyle@kwc.io", + "email_verified": "true", + "sub": uuid.NewString(), }, AllowSignups: true, StatusCode: http.StatusOK, @@ -1099,6 +1175,16 @@ func TestUserOIDC(t *testing.T) { assert.Equal(t, "kyle", u.Username) }, }, + { + Name: "EmailVerifiedAsStringFalse", + IDTokenClaims: jwt.MapClaims{ + "email": "kyle@kwc.io", + "email_verified": "false", + "sub": uuid.NewString(), + }, + AllowSignups: true, + StatusCode: http.StatusForbidden, + }, { Name: "EmailNotVerified", IDTokenClaims: jwt.MapClaims{ @@ -1356,6 +1442,7 @@ func TestUserOIDC(t *testing.T) { // See: https://github.com/coder/coder/issues/4472 Name: "UsernameIsEmail", IDTokenClaims: jwt.MapClaims{ + "email_verified": true, "preferred_username": "kyle@kwc.io", "sub": uuid.NewString(), }, @@ -1405,9 +1492,10 @@ func TestUserOIDC(t *testing.T) { { Name: "GroupsDoesNothing", IDTokenClaims: jwt.MapClaims{ - "email": "coolin@coder.com", - "groups": []string{"pingpong"}, - "sub": uuid.NewString(), + "email": "coolin@coder.com", + "email_verified": true, + "groups": []string{"pingpong"}, + "sub": uuid.NewString(), }, AllowSignups: true, StatusCode: http.StatusOK, @@ -1580,6 +1668,57 @@ func TestUserOIDC(t *testing.T) { }) } + // Absent email_verified claim tests use a FakeIDP that suppresses the + // default email_verified=true injection so the handler's absent-claim + // branch is exercised end-to-end. + t.Run("EmailVerifiedMissing", 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(), + oidctest.WithOmitEmailVerifiedDefault(), + ) + cfg := fake.OIDCConfig(t, nil, func(cfg *coderd.OIDCConfig) { + cfg.AllowSignups = true + }) + client := coderdtest.New(t, &coderdtest.Options{ + OIDCConfig: cfg, + }) + _, resp := fake.AttemptLogin(t, client, jwt.MapClaims{ + "email": "kyle@kwc.io", + "sub": uuid.NewString(), + }) + require.Equal(t, http.StatusForbidden, resp.StatusCode) + }) + + t.Run("EmailVerifiedMissingIgnored", 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(), + oidctest.WithOmitEmailVerifiedDefault(), + ) + cfg := fake.OIDCConfig(t, nil, func(cfg *coderd.OIDCConfig) { + cfg.AllowSignups = true + cfg.IgnoreEmailVerified = true + }) + client := coderdtest.New(t, &coderdtest.Options{ + OIDCConfig: cfg, + }) + userClient, _ := fake.Login(t, client, jwt.MapClaims{ + "email": "kyle@kwc.io", + "sub": uuid.NewString(), + }) + ctx := testutil.Context(t, testutil.WaitShort) + user, err := userClient.User(ctx, "me") + require.NoError(t, err) + require.Equal(t, "kyle", user.Username) + }) + t.Run("OIDCDormancy", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitShort) @@ -1609,8 +1748,9 @@ func TestUserOIDC(t *testing.T) { auditor.ResetLogs() client, resp := fake.AttemptLogin(t, owner, jwt.MapClaims{ - "email": user.Email, - "sub": uuid.NewString(), + "email": user.Email, + "email_verified": true, + "sub": uuid.NewString(), }) require.Equal(t, http.StatusOK, resp.StatusCode) @@ -1624,6 +1764,414 @@ func TestUserOIDC(t *testing.T) { require.Equal(t, codersdk.UserStatusActive, me.Status) }) + // Tests that an attacker with a different OIDC subject but the same + // email cannot hijack an existing linked account. The email fallback + // must be restricted to first-time linking only. + t.Run("OIDCEmailFallbackBlockedByExistingLink", 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(), + ) + + for _, tc := range []struct { + name string + allowSignups bool + }{ + {"SignupsDisabled", false}, + {"SignupsEnabled", true}, + } { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + cfg := fake.OIDCConfig(t, nil, func(cfg *coderd.OIDCConfig) { + cfg.AllowSignups = tc.allowSignups + }) + + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) + owner, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{ + OIDCConfig: cfg, + Logger: &logger, + }) + + // Create a victim user with an existing OIDC link. + // Use the fake IDP's issuer so the linked_id format is + // realistic (same issuer, different subject). + victim := dbgen.User(t, db, database.User{ + LoginType: database.LoginTypeOIDC, + }) + victimLinkedID := fake.IssuerURL().String() + "||" + "victim-subject" + dbgen.UserLink(t, db, database.UserLink{ + UserID: victim.ID, + LoginType: database.LoginTypeOIDC, + LinkedID: victimLinkedID, + }) + + // Attacker tries to login with a different subject but the + // same email. The email fallback is blocked because the victim + // already has a user_link with a different linked_id. + _, resp := fake.AttemptLogin(t, owner, jwt.MapClaims{ + "email": victim.Email, + "sub": "attacker-subject", + }) + require.Equal(t, http.StatusForbidden, resp.StatusCode, + "attacker must not authenticate as the victim") + + // Verify the victim's link is unchanged. + victimLink, err := db.GetUserLinkByUserIDLoginType(dbauthz.AsSystemRestricted(context.Background()), database.GetUserLinkByUserIDLoginTypeParams{ + UserID: victim.ID, + LoginType: database.LoginTypeOIDC, + }) + require.NoError(t, err) + require.Equal(t, victimLinkedID, victimLink.LinkedID, + "victim's linked_id must remain unchanged") + }) + } + }) + + // Tests that a first-time OIDC user can still link via email when no + // user_link exists (e.g. a dormant OIDC user created via SCIM or API). + t.Run("OIDCFirstTimeLinkByEmailAllowed", 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 + }) + + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) + owner, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{ + OIDCConfig: cfg, + Logger: &logger, + }) + + // Create a user with OIDC login type but NO user_link. + // This simulates a user created via SCIM or the API. + user := dbgen.User(t, db, database.User{ + LoginType: database.LoginTypeOIDC, + }) + + // Login with a new OIDC subject and matching email. + // This should succeed because no user_link exists. + sub := uuid.NewString() + client, resp := fake.AttemptLogin(t, owner, jwt.MapClaims{ + "email": user.Email, + "sub": sub, + }) + require.Equal(t, http.StatusOK, resp.StatusCode) + + me, err := client.User(ctx, "me") + require.NoError(t, err) + require.Equal(t, user.ID, me.ID, + "should authenticate as the existing user") + + // Verify the created link has a populated linked_id. + link, err := db.GetUserLinkByUserIDLoginType( + dbauthz.AsSystemRestricted(context.Background()), + database.GetUserLinkByUserIDLoginTypeParams{ + UserID: user.ID, + LoginType: database.LoginTypeOIDC, + }) + require.NoError(t, err) + expectedLinkedID := fake.IssuerURL().String() + "||" + sub + require.Equal(t, expectedLinkedID, link.LinkedID, + "link should have the correct linked_id after first-time linking") + }) + + // Tests that a legacy user with an empty linked_id can still login + // and that their linked_id is backfilled with the correct value. + t.Run("OIDCLegacyLinkBackfill", 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 + }) + + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) + owner, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{ + OIDCConfig: cfg, + Logger: &logger, + }) + + // Create a legacy user with an empty linked_id. + user := dbgen.User(t, db, database.User{ + LoginType: database.LoginTypeOIDC, + }) + dbgen.UserLink(t, db, database.UserLink{ + UserID: user.ID, + LoginType: database.LoginTypeOIDC, + LinkedID: "", // Legacy: empty linked_id + }) + + sub := uuid.NewString() + client, resp := fake.AttemptLogin(t, owner, jwt.MapClaims{ + "email": user.Email, + "sub": sub, + }) + require.Equal(t, http.StatusOK, resp.StatusCode) + + me, err := client.User(ctx, "me") + require.NoError(t, err) + require.Equal(t, user.ID, me.ID, + "legacy user should still be able to login via email fallback") + + // Verify the linked_id was backfilled with the correct value. + link, err := db.GetUserLinkByUserIDLoginType( + dbauthz.AsSystemRestricted(context.Background()), + database.GetUserLinkByUserIDLoginTypeParams{ + UserID: user.ID, + LoginType: database.LoginTypeOIDC, + }) + require.NoError(t, err) + expectedLinkedID := fake.IssuerURL().String() + "||" + sub + require.Equal(t, expectedLinkedID, link.LinkedID, + "linked_id should be backfilled with the correct value after login") + }) + + // Tests that changing the OIDC issuer URL blocks an existing user whose + // linked_id was recorded under the old issuer. This is a deliberate + // breaking change: before this fix the email fallback silently rescued + // such users. Now the login is rejected because the existing link's + // linked_id (old issuer) differs from the newly computed one (new issuer). + t.Run("OIDCEmailFallbackBlockedByIssuerChange", 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 + }) + + 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 was created under a different (old) issuer + // but with the same subject the IdP presents on login. + user := dbgen.User(t, db, database.User{ + LoginType: database.LoginTypeOIDC, + }) + const sub = "stable-subject" + oldLinkedID := "https://old-issuer.example.com||" + sub + dbgen.UserLink(t, db, database.UserLink{ + UserID: user.ID, + LoginType: database.LoginTypeOIDC, + LinkedID: oldLinkedID, + }) + + // Login presents the same subject but the current issuer, so the + // computed linked_id differs from the stored one and is blocked. + _, resp := fake.AttemptLogin(t, owner, jwt.MapClaims{ + "email": user.Email, + "sub": sub, + }) + require.Equal(t, http.StatusForbidden, resp.StatusCode, + "issuer change must block the email fallback for an existing link") + + // The stored link must remain unchanged. + link, err := db.GetUserLinkByUserIDLoginType(dbauthz.AsSystemRestricted(ctx), database.GetUserLinkByUserIDLoginTypeParams{ + UserID: user.ID, + LoginType: database.LoginTypeOIDC, + }) + require.NoError(t, err) + require.Equal(t, oldLinkedID, link.LinkedID, + "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() @@ -1648,8 +2196,9 @@ func TestUserOIDC(t *testing.T) { require.Equal(t, codersdk.LoginTypePassword, userData.LoginType) claims := jwt.MapClaims{ - "email": userData.Email, - "sub": uuid.NewString(), + "email": userData.Email, + "email_verified": true, + "sub": uuid.NewString(), } var err error user.HTTPClient.Jar, err = cookiejar.New(nil) @@ -1719,8 +2268,9 @@ func TestUserOIDC(t *testing.T) { user, userData := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) claims := jwt.MapClaims{ - "email": userData.Email, - "sub": uuid.NewString(), + "email": userData.Email, + "email_verified": true, + "sub": uuid.NewString(), } user.HTTPClient.Jar, err = cookiejar.New(nil) require.NoError(t, err) @@ -1790,8 +2340,9 @@ func TestUserOIDC(t *testing.T) { numLogs := len(auditor.AuditLogs()) claims := jwt.MapClaims{ - "email": "jon@coder.com", - "sub": uuid.NewString(), + "email": "jon@coder.com", + "email_verified": true, + "sub": uuid.NewString(), } userClient, _ := fake.Login(t, client, claims) @@ -1805,8 +2356,9 @@ func TestUserOIDC(t *testing.T) { // Pass a different subject field so that we prompt creating a // new user userClient, _ = fake.Login(t, client, jwt.MapClaims{ - "email": "jon@example2.com", - "sub": "diff", + "email": "jon@example2.com", + "email_verified": true, + "sub": "diff", }) numLogs++ // add an audit log for login @@ -2171,9 +2723,10 @@ func TestOIDCSkipIssuer(t *testing.T) { ctx := testutil.Context(t, testutil.WaitShort) //nolint:bodyclose userClient, _ := fake.Login(t, owner, jwt.MapClaims{ - "iss": secondaryURLString, - "email": "alice@coder.com", - "sub": uuid.NewString(), + "iss": secondaryURLString, + "email": "alice@coder.com", + "email_verified": true, + "sub": uuid.NewString(), }) found, err := userClient.User(ctx, "me") require.NoError(t, err) diff --git a/coderd/userpassword/userpassword.go b/coderd/userpassword/userpassword.go index 2fb01a76d258f..8221ab8343be4 100644 --- a/coderd/userpassword/userpassword.go +++ b/coderd/userpassword/userpassword.go @@ -39,11 +39,12 @@ var ( // used. defaultSaltSize = 16 - // The simulated hash is used when trying to simulate password checks for - // users that don't exist. It's meant to preserve the timing of the hash - // comparison. + // The simulated hash is used when comparing against an empty stored hash + // (e.g. nonexistent or SSO users). It hashes a random value generated on + // first use, so no attacker-supplied password can ever match it. It exists + // purely to keep failed comparisons constant-time. simulatedHash = lazy.New(func() string { - h, err := Hash("hunter2") + h, err := Hash(rand.Text()) if err != nil { panic(err) } @@ -72,10 +73,10 @@ func init() { // uses pbkdf2 to ensure FIPS 140-2 compliance. See: // https://csrc.nist.gov/csrc/media/templates/cryptographic-module-validation-program/documents/security-policies/140sp2261.pdf func Compare(hashed string, password string) (bool, error) { - // If the hased password provided is empty, simulate comparing a real hash. + // If the hashed password provided is empty, simulate comparing a real hash + // to preserve timing. The simulated hash is derived from a random value, so + // the comparison below can never succeed. if hashed == "" { - // TODO: this seems ripe for creating a vulnerability where - // hunter2 can log into any account. hashed = simulatedHash.Load() } diff --git a/coderd/userpassword/userpassword_test.go b/coderd/userpassword/userpassword_test.go index 83a3bb532e606..67d66642d5b50 100644 --- a/coderd/userpassword/userpassword_test.go +++ b/coderd/userpassword/userpassword_test.go @@ -89,6 +89,30 @@ func TestUserPasswordCompare(t *testing.T) { wantErr: true, wantEqual: false, }, + { + name: "EmptyHashHunter2", + passwordToValidate: "", + password: "hunter2", + shouldHash: false, + wantErr: false, + wantEqual: false, + }, + { + name: "EmptyHashEmptyPassword", + passwordToValidate: "", + password: "", + shouldHash: false, + wantErr: false, + wantEqual: false, + }, + { + name: "EmptyHashArbitraryPassword", + passwordToValidate: "", + password: "anyOtherPassword", + shouldHash: false, + wantErr: false, + wantEqual: false, + }, } for _, tt := range tests { diff --git a/coderd/users.go b/coderd/users.go index 4245e6766c0ab..7b14180c88d44 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -1596,6 +1596,24 @@ func (api *API) putUserPassword(rw http.ResponseWriter, r *http.Request) { return } + // Only owners can change the password of another owner. + if apiKey.UserID != user.ID && slices.Contains(user.RBACRoles, rbac.RoleOwner().String()) { + actingUser, err := api.Database.GetUserByID(ctx, apiKey.UserID) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching acting user.", + Detail: err.Error(), + }) + return + } + if !slices.Contains(actingUser.RBACRoles, rbac.RoleOwner().String()) { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Only owners can change the password of an owner.", + }) + return + } + } + if !httpapi.Read(ctx, rw, r, ¶ms) { return } diff --git a/coderd/users_test.go b/coderd/users_test.go index 87ac9b6db516e..16ca01cc83303 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -21,6 +21,7 @@ import ( "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/coderdtest/oidctest" "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/dbfake" "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/database/dbtime" @@ -233,6 +234,59 @@ func TestPostLogin(t *testing.T) { require.Equal(t, database.AuditActionLogin, auditor.AuditLogs()[numLogs-1].Action) }) + // "hunter2" was the input of the previous hardcoded simulated hash, which + // an empty stored hash wrongly matched; this is a regression test. + t.Run("NonexistentUser401", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + _, err := client.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{ + Email: "does-not-exist@coder.com", + Password: "hunter2", + }) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusUnauthorized, apiErr.StatusCode()) + require.Equal(t, "Incorrect email or password.", apiErr.Message) + }) + + // Attempting built-in login as an SSO user returns a 401 to avoid + // divulging login type. + t.Run("SSOReturns401", func(t *testing.T) { + t.Parallel() + client, db := coderdtest.NewWithDatabase(t, nil) + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + // An SSO user has no password hash stored. Create one directly in the + // database since the API requires OIDC to be configured. dbgen.User + // substitutes a random hash for an empty one, so clear it explicitly. + ssoUser := dbgen.User(t, db, database.User{ + Email: "sso-user@coder.com", + LoginType: database.LoginTypeOIDC, + }) + //nolint:gocritic // Test setup requires a system context to clear the hash. + err := db.UpdateUserHashedPassword(dbauthz.AsSystemRestricted(ctx), database.UpdateUserHashedPasswordParams{ + ID: ssoUser.ID, + HashedPassword: []byte{}, + }) + require.NoError(t, err) + + anonClient := codersdk.New(client.URL) + _, err = anonClient.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{ + Email: ssoUser.Email, + Password: "hunter2", + }) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusUnauthorized, apiErr.StatusCode()) + require.Equal(t, "Incorrect email or password.", apiErr.Message) + // The login type must not be leaked. + require.NotContains(t, apiErr.Message, string(codersdk.LoginTypeOIDC)) + }) + t.Run("Suspended", func(t *testing.T) { t.Parallel() auditor := audit.NewMock() @@ -941,8 +995,9 @@ func TestPostUsers(t *testing.T) { // Try to log in with OIDC. userClient, _ := fake.Login(t, client, jwt.MapClaims{ - "email": email, - "sub": uuid.NewString(), + "email": email, + "email_verified": true, + "sub": uuid.NewString(), }) found, err := userClient.User(ctx, "me") @@ -1517,6 +1572,57 @@ func TestUpdateUserPassword(t *testing.T) { require.Equal(t, http.StatusNotFound, cerr.StatusCode()) }) + t.Run("UserAdminCannotResetOwnerPassword", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + owner := coderdtest.CreateFirstUser(t, client) + userAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleUserAdmin()) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + err := userAdmin.UpdateUserPassword(ctx, owner.UserID.String(), codersdk.UpdateUserPasswordRequest{ + Password: "SomeNewStrongPassword!", + }) + require.Error(t, err, "user-admin should not be able to reset owner password") + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusBadRequest, apiErr.StatusCode()) + require.Contains(t, apiErr.Message, "Only owners can change the password of an owner") + }) + + t.Run("OwnerCanResetOwnerPassword", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + owner := coderdtest.CreateFirstUser(t, client) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + anotherOwner, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{ + Email: "another-owner@coder.com", + Username: "another-owner", + Password: "SomeStrongPassword!", + OrganizationIDs: []uuid.UUID{owner.OrganizationID}, + }) + require.NoError(t, err) + _, err = client.UpdateUserRoles(ctx, anotherOwner.ID.String(), codersdk.UpdateRoles{ + Roles: []string{rbac.RoleOwner().String()}, + }) + require.NoError(t, err) + + err = client.UpdateUserPassword(ctx, anotherOwner.ID.String(), codersdk.UpdateUserPasswordRequest{ + Password: "SomeNewStrongPassword!", + }) + require.NoError(t, err, "owner should be able to reset another owner's password") + + _, err = client.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{ + Email: "another-owner@coder.com", + Password: "SomeNewStrongPassword!", + }) + require.NoError(t, err, "other owner should login with the new password") + }) + t.Run("PasswordsMustDiffer", func(t *testing.T) { t.Parallel() diff --git a/coderd/util/xurl/xurl.go b/coderd/util/xurl/xurl.go new file mode 100644 index 0000000000000..2db01ec82e33e --- /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 0000000000000..7c81661ff0fd1 --- /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)) + }) + } +} diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index b3074740b181b..1add579f81b9d 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -37,6 +37,7 @@ import ( "github.com/coder/coder/v2/coderd/httpmw/loggermw" "github.com/coder/coder/v2/coderd/jwtutils" "github.com/coder/coder/v2/coderd/prebuilds" + "github.com/coder/coder/v2/coderd/provisionerdserver" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/coderd/telemetry" @@ -1096,6 +1097,11 @@ func (api *API) workspaceAgentRecreateDevcontainer(rw http.ResponseWriter, r *ht ctx := r.Context() waws := httpmw.WorkspaceAgentAndWorkspaceParam(r) + if !api.Authorize(r, policy.ActionUpdate, waws.WorkspaceTable) { + httpapi.Forbidden(rw) + return + } + devcontainer := chi.URLParam(r, "devcontainer") if devcontainer == "" { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ @@ -1536,14 +1542,13 @@ func (api *API) workspaceAgentReinit(rw http.ResponseWriter, r *http.Request) { return } - // This workspace was a prebuild that got claimed. Check if - // the claim build completed successfully before sending - // reinit. We assume the latest build is the claim build - // (build 2). If a third build (e.g. a restart) starts - // between the claim and the agent's reconnection, this - // would check that build instead. The window is extremely - // small in practice, and a restart would trigger its own - // reinit path. + // This workspace was a prebuild that got claimed. The seeded + // reinit below recovers a claim event that was missed while + // the agent's /reinit connection was down. It only applies + // while the latest build is the claim build itself, which the + // build's provisioner job input records, mirroring the check + // the provisioner server uses when publishing the claim + // event. latestBuild, err := api.Database.GetLatestWorkspaceBuildByWorkspaceID(ctx, workspace.ID) if err != nil { log.Error(ctx, "failed to get latest workspace build", slog.Error(err)) @@ -1556,43 +1561,73 @@ func (api *API) workspaceAgentReinit(rw http.ResponseWriter, r *http.Request) { httpapi.InternalServerError(rw, xerrors.New("failed to get provisioner job")) return } + var jobInput provisionerdserver.WorkspaceProvisionJob + if err := json.Unmarshal(job.Input, &jobInput); err != nil { + log.Error(ctx, "failed to unmarshal provisioner job input", slog.Error(err)) + httpapi.InternalServerError(rw, xerrors.New("failed to unmarshal provisioner job input")) + return + } - if job.CompletedAt.Valid && !job.Error.Valid { - // Claim build succeeded — cancel the pubsub - // subscription (no longer needed) and swap in a - // pre-seeded channel so the transmitter delivers - // exactly one reinit event. - cancelSub() - seeded := make(chan agentsdk.ReinitializationEvent, 1) - seeded <- agentsdk.ReinitializationEvent{ - WorkspaceID: workspace.ID, - Reason: agentsdk.ReinitializeReasonPrebuildClaimed, - OwnerID: workspace.OwnerID, + switch { + case jobInput.PrebuiltWorkspaceBuildStage.IsPrebuiltWorkspaceClaim(): + if job.CompletedAt.Valid && !job.Error.Valid { + // Claim build succeeded: cancel the pubsub + // subscription (no longer needed) and swap in a + // pre-seeded channel so the transmitter delivers + // exactly one reinit event. + cancelSub() + seeded := make(chan agentsdk.ReinitializationEvent, 1) + seeded <- agentsdk.ReinitializationEvent{ + WorkspaceID: workspace.ID, + Reason: agentsdk.ReinitializeReasonPrebuildClaimed, + OwnerID: workspace.OwnerID, + } + reinitEvents = seeded + } else if job.CompletedAt.Valid && job.Error.Valid { + // Claim build failed permanently. Return 409 so the + // agent treats this as terminal and stops retrying + // (WaitForReinitLoop exits on any 409). + cancelSub() + log.Warn(ctx, "claim build failed", + slog.F("job_id", job.ID), + slog.F("error", job.Error.String)) + httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{ + Message: "Claim build failed permanently.", + Detail: job.Error.String, + }) + return } - reinitEvents = seeded - } else if job.CompletedAt.Valid && job.Error.Valid { - // Claim build failed permanently. Return 409 so the - // agent treats this as terminal and stops retrying - // (WaitForReinitLoop exits on any 409). - cancelSub() - log.Warn(ctx, "claim build failed", + // Claim build still in progress: proceed to the + // transmitter below. The pubsub subscription (set up + // above) will deliver the event when the build completes + // successfully. Note: FailJob does not publish a claim + // event, so a failed in-progress build will leave the + // agent blocking here until it disconnects and + // reconnects (at which point the durable check above + // handles it). + case latestBuild.InitiatorID == database.PrebuildsSystemUserID: + // The workspace owner has changed but the claim build has + // not been created yet. Proceed to the transmitter below; + // the pubsub subscription set up above delivers the claim + // event once the claim build completes. + default: + // The latest build is a user-initiated build other than + // the claim build, so the claim has already been handled. + // Re-sending the reinit event here would needlessly + // restart the agent of a long-claimed workspace on every + // reconnection. Return 409 so the agent stops polling, + // the same as a regular workspace. + log.Debug(ctx, "prebuild claim already handled, stopping reinit polling", slog.F("job_id", job.ID), - slog.F("error", job.Error.String)) + slog.F("latest_build_id", latestBuild.ID), + slog.F("latest_build_number", latestBuild.BuildNumber)) + cancelSub() httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{ - Message: "Claim build failed permanently.", - Detail: job.Error.String, + Message: "Workspace is not a prebuilt workspace waiting to be claimed.", + Detail: "The prebuild claim for this workspace has already been handled by an earlier build.", }) return } - - // Claim build still in progress — fall through to the - // transmitter. The pubsub subscription (set up above) - // will deliver the event when the build completes - // successfully. Note: FailJob does not publish a claim - // event, so a failed in-progress build will leave the - // agent blocking here until it disconnects and - // reconnects (at which point the durable check above - // handles it). } transmitter := agentsdk.NewSSEAgentReinitTransmitter(log, rw, r) @@ -2562,9 +2597,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/workspaceagents_test.go b/coderd/workspaceagents_test.go index 0fff131f5a4dc..656c76d5c324c 100644 --- a/coderd/workspaceagents_test.go +++ b/coderd/workspaceagents_test.go @@ -1876,6 +1876,51 @@ func TestWorkspaceAgentRecreateDevcontainer(t *testing.T) { }) } +func TestWorkspaceAgentRecreateDevcontainerAuthorization(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + role func(uuid.UUID) rbac.RoleIdentifier + }{ + { + name: "TemplateAdmin", + role: func(uuid.UUID) rbac.RoleIdentifier { + return rbac.RoleTemplateAdmin() + }, + }, + { + name: "OrgTemplateAdmin", + role: rbac.ScopedRoleOrgTemplateAdmin, + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + var ( + ctx = testutil.Context(t, testutil.WaitMedium) + client, db = coderdtest.NewWithDatabase(t, nil) + admin = coderdtest.CreateFirstUser(t, client) + _, workspaceOwner = coderdtest.CreateAnotherUser(t, client, admin.OrganizationID) + templateAdminClient, _ = coderdtest.CreateAnotherUser(t, client, admin.OrganizationID, tc.role(admin.OrganizationID)) + workspace = dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OrganizationID: admin.OrganizationID, + OwnerID: workspaceOwner.ID, + }).WithAgent(func(agents []*proto.Agent) []*proto.Agent { + return agents + }).Do() + ) + + _, err := templateAdminClient.WorkspaceAgentRecreateDevcontainer(ctx, workspace.Agents[0].ID, uuid.NewString()) + require.Error(t, err) + + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusForbidden, sdkErr.StatusCode()) + }) + } +} + func TestWorkspaceAgentDeleteDevcontainer(t *testing.T) { t.Parallel() @@ -3354,6 +3399,7 @@ func TestReinit(t *testing.T) { InitiatorID: claimerID, Transition: database.WorkspaceTransitionStart, }). + MarkPrebuiltWorkspaceClaim(). WithAgent() if !complete { builder = builder.Starting() @@ -3451,6 +3497,52 @@ func TestReinit(t *testing.T) { require.Equal(t, user.UserID, reinitEvent.OwnerID) }) + // Verifies that the durable claim check only applies while the + // latest build is the claim build. A workspace that was claimed + // in the past and has since had user-initiated builds must get a + // 409 instead of another reinit, otherwise its agent would be + // restarted on every /reinit reconnection for the rest of the + // workspace's life. + t.Run("workspace claimed in the past gets 409", func(t *testing.T) { + t.Parallel() + + db, ps, sqlDB := dbtestutil.NewDBWithSQLDB(t) + client := coderdtest.New(t, &coderdtest.Options{ + Database: db, + Pubsub: ps, + }) + user := coderdtest.CreateFirstUser(t, client) + + // Create an unclaimed prebuild (build 1, completed) and claim + // it (build 2, completed). + r := setupPrebuildWorkspace(t, db, user.OrganizationID) + claimPrebuild(t, db, sqlDB, r.Workspace, user.UserID, r.TemplateVersion.ID, true) + + // A later build initiated by the owner (e.g. a restart) means + // the claim has already been handled. + ws := r.Workspace + ws.OwnerID = user.UserID + laterR := dbfake.WorkspaceBuild(t, db, ws). + Seed(database.WorkspaceBuild{ + TemplateVersionID: r.TemplateVersion.ID, + BuildNumber: 3, + InitiatorID: user.UserID, + Transition: database.WorkspaceTransitionStart, + }). + WithAgent(). + Do() + + agentCtx := testutil.Context(t, testutil.WaitShort) + agentClient := agentsdk.New(client.URL, agentsdk.WithFixedToken(laterR.AgentToken)) + + // WaitForReinit should return an error wrapping a 409. + _, err := agentClient.WaitForReinit(agentCtx) + require.Error(t, err) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusConflict, sdkErr.StatusCode()) + }) + // Verifies that when the claim build completed with an error, // the handler returns 409 so the agent treats it as terminal // and stops retrying (WaitForReinitLoop exits on any 409). diff --git a/coderd/workspaceagentsrpc.go b/coderd/workspaceagentsrpc.go index 433f1f572b156..22b33b91f15a0 100644 --- a/coderd/workspaceagentsrpc.go +++ b/coderd/workspaceagentsrpc.go @@ -166,6 +166,7 @@ func (api *API) workspaceAgentRPC(rw http.ResponseWriter, r *http.Request) { PublishWorkspaceAgentLogsUpdateFn: api.publishWorkspaceAgentLogsUpdate, NetworkTelemetryHandler: api.NetworkTelemetryBatcher.Handler, BoundaryUsageTracker: api.BoundaryUsageTracker, + PortSharer: &api.PortSharer, AccessURL: api.AccessURL, AppHostname: api.AppHostname, diff --git a/coderd/workspaceagentsrpc_test.go b/coderd/workspaceagentsrpc_test.go index 1595462d19177..5d083c131f990 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/coderd/workspaceapps/apptest/apptest.go b/coderd/workspaceapps/apptest/apptest.go index d73336cedcb11..f0993e8f02f86 100644 --- a/coderd/workspaceapps/apptest/apptest.go +++ b/coderd/workspaceapps/apptest/apptest.go @@ -1247,6 +1247,34 @@ func Run(t *testing.T, appHostIsPrimary bool, factory DeploymentFactory) { assertWorkspaceLastUsedAtNotUpdated(t, appDetails, testutil.WaitLong) }) + // Security (PLAT-260): must 404 when the URL username segment + // names a different owner than the resolved workspace. + t.Run("WorkspaceUUIDOwnerMismatchShould404", func(t *testing.T) { + t.Parallel() + + appDetails := setupProxyTest(t, nil) + otherUserClient, otherUser := coderdtest.CreateAnotherUser(t, appDetails.SDKClient, appDetails.FirstUser.OrganizationID, rbac.RoleMember()) + appClient := appDetails.AppClient(t) + appClient.SetSessionToken(otherUserClient.SessionToken()) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + forgedApp := appDetails.Apps.Public + forgedApp.Username = otherUser.Username + forgedApp.WorkspaceName = appDetails.Workspace.ID.String() + + resp, err := requestWithRetries(ctx, t, appClient, http.MethodGet, appDetails.SubdomainAppURL(forgedApp).String(), nil) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusNotFound, resp.StatusCode) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Contains(t, string(body), "404 - Application Not Found") + assertWorkspaceLastUsedAtNotUpdated(t, appDetails, testutil.WaitLong) + }) + t.Run("RedirectsWithSlash", func(t *testing.T) { t.Parallel() diff --git a/coderd/workspaceapps/db_test.go b/coderd/workspaceapps/db_test.go index 1631c7d403ca0..5e341a7bc8052 100644 --- a/coderd/workspaceapps/db_test.go +++ b/coderd/workspaceapps/db_test.go @@ -908,6 +908,50 @@ func Test_ResolveRequest(t *testing.T) { require.Len(t, connLogger.ConnectionLogs(), 0) }) + // Security (PLAT-260): a UUID workspace lookup must reject when + // the URL's username segment names a different owner. Otherwise a + // same-owner origin can be spoofed for credentialed cross-origin + // reads. + t.Run("WorkspaceUUIDOwnerMismatch", func(t *testing.T) { + t.Parallel() + + req := (workspaceapps.Request{ + AccessMethod: workspaceapps.AccessMethodPath, + BasePath: "/app", + UsernameOrID: secondUser.Username, + WorkspaceNameOrID: workspace.ID.String(), + AgentNameOrID: agentName, + AppSlugOrPort: appNamePublic, + }).Normalize() + + connLogger := connectionlog.NewFake() + auditableIP := testutil.RandomIPv6(t) + + rw := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/app", nil) + r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken()) + r.RemoteAddr = auditableIP + + token, ok := workspaceappsResolveRequest(t, connLogger, rw, r, workspaceapps.ResolveRequestOptions{ + Logger: api.Logger, + SignedTokenProvider: api.WorkspaceAppsProvider, + DashboardURL: api.AccessURL, + PathAppBaseURL: api.AccessURL, + AppHostname: api.AppHostname, + AppRequest: req, + }) + require.False(t, ok) + require.Nil(t, token) + + w := rw.Result() + defer w.Body.Close() + b, err := io.ReadAll(w.Body) + require.NoError(t, err) + require.Contains(t, string(b), "404 - Application Not Found") + require.Equal(t, http.StatusNotFound, w.StatusCode) + require.Len(t, connLogger.ConnectionLogs(), 0) + }) + t.Run("RedirectSubdomainAuth", func(t *testing.T) { t.Parallel() diff --git a/coderd/workspaceapps/proxy.go b/coderd/workspaceapps/proxy.go index 86ec757f3112f..dfd8bff0b787d 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 } @@ -437,7 +447,7 @@ func (s *Server) HandleSubdomain(middlewares ...func(http.Handler) http.Handler) } // Step 2: Get the request Host. - host := httpapi.RequestHost(r) + host := httpmw.EffectiveHost(s.RealIPConfig, r) if host == "" { if r.URL.Path == "/derp" { // The /derp endpoint is used by wireguard clients to tunnel @@ -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 0000000000000..cde66026f3f4d --- /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 5c71f15ffa6a5..cd180b328970c 100644 --- a/coderd/workspaceapps/proxy_test.go +++ b/coderd/workspaceapps/proxy_test.go @@ -1,3 +1,184 @@ package workspaceapps_test // App tests can be found in the apptest package. + +import ( + "context" + "net" + "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" +) + +type fakeSignedTokenProvider struct { + fromRequestCalls int + issueCalls int +} + +func (s *fakeSignedTokenProvider) FromRequest(_ *http.Request) (*workspaceapps.SignedToken, bool) { + s.fromRequestCalls++ + return nil, false +} + +func (s *fakeSignedTokenProvider) Issue(_ context.Context, _ http.ResponseWriter, _ *http.Request, _ workspaceapps.IssueTokenRequest) (*workspaceapps.SignedToken, string, bool) { + s.issueCalls++ + return nil, "", false +} + +func TestHandleSubdomain(t *testing.T) { + t.Parallel() + + 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) + }) + + // 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")) + }) +} diff --git a/coderd/workspaceapps/request.go b/coderd/workspaceapps/request.go index 980ec7c3a678c..c0c85e74f3459 100644 --- a/coderd/workspaceapps/request.go +++ b/coderd/workspaceapps/request.go @@ -248,6 +248,9 @@ func (r Request) getDatabase(ctx context.Context, db database.Store) (*databaseR ) if workspaceID, uuidErr := uuid.Parse(r.WorkspaceNameOrID); uuidErr == nil { workspace, workspaceErr = db.GetWorkspaceByID(ctx, workspaceID) + if workspaceErr == nil && workspace.OwnerID != user.ID { + workspaceErr = sql.ErrNoRows + } } else { workspace, workspaceErr = db.GetWorkspaceByOwnerIDAndName(ctx, database.GetWorkspaceByOwnerIDAndNameParams{ OwnerID: user.ID, diff --git a/coderd/workspaceapps_test.go b/coderd/workspaceapps_test.go index 8db2858e01e32..db6f77ab95142 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", diff --git a/coderd/workspacebuilds.go b/coderd/workspacebuilds.go index 8ccb6417b8e2b..a1c9ed4a64d03 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 0000000000000..f7e9e55c04d57 --- /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/workspacebuilds_test.go b/coderd/workspacebuilds_test.go index 800076eaffc12..96a70a43dae49 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/workspaceresourceauth.go b/coderd/workspaceresourceauth.go index 5a2c36f7ef96a..674bb60956f35 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 b327c120bc348..74a146a2d2a3e 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")) diff --git a/coderd/workspaces.go b/coderd/workspaces.go index ed6c5c73b8c30..5b5252747a218 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -9,9 +9,9 @@ import ( "net/http" "slices" "strconv" + "strings" "time" - "github.com/dustin/go-humanize" "github.com/go-chi/chi/v5" "github.com/google/uuid" "golang.org/x/sync/errgroup" @@ -595,6 +595,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{ @@ -855,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{ @@ -892,6 +906,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 @@ -1448,19 +1506,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), @@ -1469,7 +1515,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, @@ -2188,6 +2234,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. @@ -2305,13 +2365,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)) @@ -2323,7 +2383,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) { @@ -2355,7 +2416,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) { @@ -2364,26 +2426,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), }) } @@ -2430,10 +2496,16 @@ func (api *API) patchWorkspaceACL(rw http.ResponseWriter, r *http.Request) { apiKey := httpmw.APIKey(r) if _, ok := req.UserRoles[apiKey.UserID.String()]; ok { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "You cannot change your own workspace sharing role.", - }) - return + // Block changing your own sharing role unless you can share any + // workspace in the organization. This keeps a user whose only access is + // this share from destructively demoting themselves, while letting org + // and deployment admins manage their own access. + if !api.Authorize(r, policy.ActionShare, rbac.ResourceWorkspace.InOrg(workspace.OrganizationID)) { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "You cannot change your own workspace sharing role.", + }) + return + } } validErrs := acl.Validate(ctx, api.Database, WorkspaceACLUpdateValidator(req)) diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index b03253b76ba6a..b2fdbd705081a 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) { @@ -3838,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) @@ -4872,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) { @@ -5588,6 +5934,59 @@ func TestUpdateWorkspaceACL(t *testing.T) { }) require.NoError(t, err) }) + + //nolint:tparallel,paralleltest // Modifies package global rbac.workspaceACLDisabled. + t.Run("OrgAdminCanChangeOwnRole", func(t *testing.T) { + // Save and restore the global to avoid affecting other tests. + prevWorkspaceACLDisabled := rbac.WorkspaceACLDisabled() + rbac.SetWorkspaceACLDisabled(false) + t.Cleanup(func() { rbac.SetWorkspaceACLDisabled(prevWorkspaceACLDisabled) }) + + dv := coderdtest.DeploymentValues(t) + + adminClient := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + DeploymentValues: dv, + }) + adminUser := coderdtest.CreateFirstUser(t, adminClient) + orgID := adminUser.OrganizationID + workspaceOwnerClient, _ := coderdtest.CreateAnotherUser(t, adminClient, orgID) + orgAdminClient, orgAdminUser := coderdtest.CreateAnotherUser(t, adminClient, orgID, rbac.ScopedRoleOrgAdmin(orgID)) + + 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, workspaceOwnerClient, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, workspaceOwnerClient, ws.LatestBuild.ID) + + ctx := testutil.Context(t, testutil.WaitMedium) + + // An org admin can share a workspace they do not own with themselves, + // because they hold workspace share permission across the organization + // independent of this workspace's ACL. + err := orgAdminClient.UpdateWorkspaceACL(ctx, ws.ID, codersdk.UpdateWorkspaceACL{ + UserRoles: map[string]codersdk.WorkspaceRole{ + orgAdminUser.ID.String(): codersdk.WorkspaceRoleAdmin, + }, + }) + require.NoError(t, err) + + // They can also change and then remove their own role. + err = orgAdminClient.UpdateWorkspaceACL(ctx, ws.ID, codersdk.UpdateWorkspaceACL{ + UserRoles: map[string]codersdk.WorkspaceRole{ + orgAdminUser.ID.String(): codersdk.WorkspaceRoleUse, + }, + }) + require.NoError(t, err) + + err = orgAdminClient.UpdateWorkspaceACL(ctx, ws.ID, codersdk.UpdateWorkspaceACL{ + UserRoles: map[string]codersdk.WorkspaceRole{ + orgAdminUser.ID.String(): codersdk.WorkspaceRoleDeleted, + }, + }) + require.NoError(t, err) + }) } func TestDeleteWorkspaceACL(t *testing.T) { diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index debee591e8fcb..5a5ba7fb60a95 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) @@ -6408,22 +6468,14 @@ type runChatResult struct { HistoryTipMessageID int64 } -func contextWithActiveTurnAPIKeyID(ctx context.Context, messages []database.ChatMessage) context.Context { - apiKeyID, ok := activeTurnAPIKeyIDFromMessages(messages) - if !ok { - return ctx - } - return aibridge.WithDelegatedAPIKeyID(ctx, apiKeyID) -} - func activeTurnAPIKeyIDFromMessages(messages []database.ChatMessage) (string, bool) { for i := len(messages) - 1; i >= 0; i-- { message := messages[i] if message.Role != database.ChatMessageRoleUser { continue } - if message.Visibility != database.ChatMessageVisibilityBoth && - message.Visibility != database.ChatMessageVisibilityUser { + if !isUserVisibleChatMessage(message) && + !(message.Visibility == database.ChatMessageVisibilityModel && message.Compressed) { continue } if !message.APIKeyID.Valid || message.APIKeyID.String == "" { @@ -6434,6 +6486,11 @@ func activeTurnAPIKeyIDFromMessages(messages []database.ChatMessage) (string, bo return "", false } +func isUserVisibleChatMessage(message database.ChatMessage) bool { + return message.Visibility == database.ChatMessageVisibilityBoth || + message.Visibility == database.ChatMessageVisibilityUser +} + func allToolNames(allTools []fantasy.AgentTool) []string { toolNames := make([]string, 0, len(allTools)) for _, tool := range allTools { @@ -7064,7 +7121,9 @@ func (p *Server) runChat( return result, xerrors.Errorf("get chat messages: %w", err) } modelOpts := modelBuildOptionsFromMessages(messages) - ctx = contextWithActiveTurnAPIKeyID(ctx, messages) + if modelOpts.ActiveAPIKeyID != "" { + ctx = aibridge.WithDelegatedAPIKeyID(ctx, modelOpts.ActiveAPIKeyID) + } // Load MCP server configs and user tokens in parallel with model // resolution. These queries have no dependencies on each other and all @@ -7771,6 +7830,7 @@ func (p *Server) runChat( persistCtx, chat.ID, modelConfig.ID, + modelOpts.ActiveAPIKeyID, compactionToolCallID, result, ); err != nil { @@ -8400,12 +8460,14 @@ func buildProviderTools(options *codersdk.ChatModelProviderOptions) []chatloop.P return tools } -// persistChatContextSummary persists a chat context summary to the database. -// This is invoked via the chat loop's compaction callback. +// persistChatContextSummary is called from the chat loop's compaction +// callback. activeAPIKeyID is stamped onto the summary user message. When +// empty, it falls back to the delegated key in ctx. func (p *Server) persistChatContextSummary( ctx context.Context, chatID uuid.UUID, modelConfigID uuid.UUID, + activeAPIKeyID string, toolCallID string, result chatloop.CompactionResult, ) error { @@ -8454,21 +8516,28 @@ func (p *Server) persistChatContextSummary( return xerrors.Errorf("encode summary tool result: %w", err) } + summaryAPIKeyID := activeAPIKeyID + if summaryAPIKeyID == "" { + summaryAPIKeyID, _ = aibridge.DelegatedAPIKeyIDFromContext(ctx) + } + 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, + 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 +9068,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 +9092,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 b28c0cdb0c879..965b6b474e9f7 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,82 @@ 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, + }) + + server := &Server{db: db} + persistAndAssertSummaryKey := func( + summaryCtx context.Context, + chatID uuid.UUID, + activeAPIKeyID string, + wantAPIKeyID string, + toolCallID string, + ) { + t.Helper() + + err := server.persistChatContextSummary( + summaryCtx, + chatID, + modelConfig.ID, + activeAPIKeyID, + toolCallID, + 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, chatID) + 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, wantAPIKeyID, msg.APIKeyID.String, "summary user message APIKeyID must match") + } + } + require.True(t, foundUserSummary, "expected to find compressed user summary message") + } + + persistAndAssertSummaryKey(ctx, chat.ID, apiKey.ID, apiKey.ID, "tool-call-id-1") + + fallbackChat := dbgen.Chat(t, db, database.Chat{ + OwnerID: user.ID, + OrganizationID: org.ID, + LastModelConfigID: modelConfig.ID, + }) + fallbackKey, _ := dbgen.APIKey(t, db, database.APIKey{ + UserID: user.ID, + }) + fallbackCtx := aibridge.WithDelegatedAPIKeyID(ctx, fallbackKey.ID) + persistAndAssertSummaryKey(fallbackCtx, fallbackChat.ID, "", fallbackKey.ID, "tool-call-id-2") +} diff --git a/coderd/x/chatd/chatd_test.go b/coderd/x/chatd/chatd_test.go index 9e67c1023015b..d91670117e2ce 100644 --- a/coderd/x/chatd/chatd_test.go +++ b/coderd/x/chatd/chatd_test.go @@ -6309,6 +6309,7 @@ func setOpenAIProviderBaseURL( } _, err = db.UpdateAIProvider(ctx, database.UpdateAIProviderParams{ ID: provider.ID, + Type: provider.Type, DisplayName: provider.DisplayName, Enabled: provider.Enabled, BaseUrl: baseURL, diff --git a/coderd/x/chatd/chaterror/classify.go b/coderd/x/chatd/chaterror/classify.go index 73e50f083be4e..4bf28efd4ffee 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 457704bd5fe35..0e2e008bb8db0 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 { @@ -1158,6 +1239,28 @@ func TestClassify_ChainBrokenSurvivesWithClassification(t *testing.T) { " can detect it after re-classification") } +func TestClassify_MissingKeyPreClassified(t *testing.T) { + t.Parallel() + + raw := xerrors.New("AI Gateway routing requires the active turn API key ID") + wrapped := chaterror.WithClassification(raw, chaterror.ClassifiedError{ + Kind: codersdk.ChatErrorKindMissingKey, + Retryable: false, + Detail: "If this error persists after resending, please report it as a bug.", + }) + + classified := chaterror.Classify(wrapped) + require.Equal(t, codersdk.ChatErrorKindMissingKey, classified.Kind) + require.False(t, classified.Retryable) + require.Equal(t, "If this error persists after resending, please report it as a bug.", classified.Detail) + require.Equal(t, + "This conversation was started with an API key that is no longer available."+ + " Send your message again to continue.", + classified.Message, + "Message should be filled by terminalMessage when not set explicitly", + ) +} + func testProviderError( message string, statusCode int, diff --git a/coderd/x/chatd/chaterror/message.go b/coderd/x/chatd/chaterror/message.go index a551078349ce5..49a6518154b1b 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,56 +17,59 @@ 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, + subject, ) + case codersdk.ChatErrorKindMissingKey: + return "This conversation was started with an API key that is no longer available." + + " Send your message again to continue." + case codersdk.ChatErrorKindConfig: - return fmt.Sprintf( + 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)) } } @@ -81,39 +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 { @@ -135,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/message_test.go b/coderd/x/chatd/chaterror/message_test.go index 87cb375cbc022..94bf14bd13500 100644 --- a/coderd/x/chatd/chaterror/message_test.go +++ b/coderd/x/chatd/chaterror/message_test.go @@ -90,6 +90,13 @@ func TestTerminalMessage(t *testing.T) { retryable: false, want: "The usage quota for the AI provider has been exceeded. Check the billing and quota settings for the provider account.", }, + { + name: "MissingKey", + kind: codersdk.ChatErrorKindMissingKey, + provider: "", + retryable: false, + want: "This conversation was started with an API key that is no longer available. Send your message again to continue.", + }, } for _, tt := range tests { diff --git a/coderd/x/chatd/chaterror/signals.go b/coderd/x/chatd/chaterror/signals.go index ebe6ff939b8ff..8dad919127622 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/coderd/x/chatd/chatloop/chatloop.go b/coderd/x/chatd/chatloop/chatloop.go index c67e2ee6d007b..7a81dc4d6e837 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 a7a2079d79e8b..64b1d8f97cb1e 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") } } diff --git a/coderd/x/chatd/chatloop/metrics_test.go b/coderd/x/chatd/chatloop/metrics_test.go index 7aa3885750378..c0c86deacc410 100644 --- a/coderd/x/chatd/chatloop/metrics_test.go +++ b/coderd/x/chatd/chatloop/metrics_test.go @@ -296,6 +296,7 @@ func TestRecordStreamRetry(t *testing.T) { {name: "startup_timeout", kind: codersdk.ChatErrorKindStartupTimeout}, {name: "auth", kind: codersdk.ChatErrorKindAuth}, {name: "config", kind: codersdk.ChatErrorKindConfig}, + {name: "missing_key", kind: codersdk.ChatErrorKindMissingKey}, {name: "generic", kind: codersdk.ChatErrorKindGeneric}, {name: "chain_broken", kind: codersdk.ChatErrorKindGeneric, chainBroken: true}, } diff --git a/coderd/x/chatd/chatprovider/chatprovider.go b/coderd/x/chatd/chatprovider/chatprovider.go index 768aa5774eca5..ac817e094034e 100644 --- a/coderd/x/chatd/chatprovider/chatprovider.go +++ b/coderd/x/chatd/chatprovider/chatprovider.go @@ -3,6 +3,7 @@ package chatprovider import ( "context" "net/http" + neturl "net/url" "sort" "strings" @@ -186,6 +187,30 @@ func (k ProviderAPIKeys) BaseURL(provider string) string { return strings.TrimSpace(k.BaseURLByProvider[normalized]) } +// ProviderBaseURLHostname returns the normalized hostname from a provider base URL. +func ProviderBaseURLHostname(baseURL string) string { + parsed, ok := parseProviderBaseURL(baseURL) + if !ok { + return "" + } + return strings.ToLower(parsed.Hostname()) +} + +func parseProviderBaseURL(baseURL string) (*neturl.URL, bool) { + baseURL = strings.TrimSpace(baseURL) + if baseURL == "" { + return nil, false + } + parsed, err := neturl.Parse(baseURL) + if err == nil && parsed.Hostname() == "" && !strings.Contains(baseURL, "://") { + parsed, err = neturl.Parse("https://" + baseURL) + } + if err != nil { + return nil, false + } + return parsed, true +} + // MergeProviderAPIKeys overlays configured provider keys over fallback keys. func MergeProviderAPIKeys(fallback ProviderAPIKeys, providers []ConfiguredProvider) ProviderAPIKeys { merged := ProviderAPIKeys{ @@ -1243,6 +1268,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/chatprovider_test.go b/coderd/x/chatd/chatprovider/chatprovider_test.go index 0c2cebfbad69a..5b2165840f994 100644 --- a/coderd/x/chatd/chatprovider/chatprovider_test.go +++ b/coderd/x/chatd/chatprovider/chatprovider_test.go @@ -29,6 +29,28 @@ import ( "github.com/coder/coder/v2/testutil" ) +func TestProviderBaseURLHostname(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + baseURL string + want string + }{ + {name: "URL", baseURL: "https://openrouter.ai/api/v1", want: "openrouter.ai"}, + {name: "BareHost", baseURL: "openrouter.ai", want: "openrouter.ai"}, + {name: "HostWithPort", baseURL: "https://openrouter.ai:443/api/v1", want: "openrouter.ai"}, + {name: "Empty", baseURL: "", want: ""}, + {name: "Invalid", baseURL: "://", want: ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tt.want, chatprovider.ProviderBaseURLHostname(tt.baseURL)) + }) + } +} + func TestResolveUserProviderKeys(t *testing.T) { t.Parallel() @@ -1473,6 +1495,34 @@ func TestResolveModelWithProviderHint(t *testing.T) { wantProvider: fantasyopenaicompat.Name, wantModel: "anthropic/claude-4-5-sonnet", }, + { + name: "OpenRouterHintPreservesOpenRouterModelID", + modelName: "anthropic/claude-opus-4.6", + providerHint: fantasyopenrouter.Name, + wantProvider: fantasyopenrouter.Name, + wantModel: "anthropic/claude-opus-4.6", + }, + { + name: "OpenAICompatHintPreservesOpenRouterModelID", + modelName: "anthropic/claude-opus-4.6", + providerHint: fantasyopenaicompat.Name, + wantProvider: fantasyopenaicompat.Name, + wantModel: "anthropic/claude-opus-4.6", + }, + { + name: "OpenAIHintStripsCanonicalPrefix", + modelName: "anthropic/claude-opus-4.6", + providerHint: fantasyopenai.Name, + wantProvider: fantasyanthropic.Name, + wantModel: "claude-opus-4.6", + }, + { + name: "OpenAIHintPreservesUnknownSlashNamespace", + modelName: "meta-llama/llama-3-70b", + providerHint: fantasyopenai.Name, + wantProvider: fantasyopenai.Name, + wantModel: "meta-llama/llama-3-70b", + }, { name: "AnthropicHintStripsCanonicalPrefix", modelName: "anthropic/claude-4-5-sonnet", 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 0000000000000..8c60f2a16b6a1 --- /dev/null +++ b/coderd/x/chatd/chatprovider/openai_compat_patches.go @@ -0,0 +1,143 @@ +package chatprovider + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "strings" + + "github.com/coder/coder/v2/internal/googleopenai" +) + +// 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. + +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 googleopenai.ShouldPatchOpenAICompatRequest(baseURL, modelID) { + changed = googleopenai.AddThoughtSignaturesToLatestTurn(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 +} 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 0000000000000..eace6c4173d23 --- /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 0000000000000..abb97079c6761 --- /dev/null +++ b/coderd/x/chatd/chatprovider/openai_compat_patches_test.go @@ -0,0 +1,185 @@ +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" + "github.com/coder/coder/v2/internal/googleopenai" +) + +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, googleopenai.DummyThoughtSignature, thoughtSignature(t, messages[4], 0)) + require.Equal(t, googleopenai.DummyThoughtSignature, thoughtSignature(t, messages[4], 1)) + require.Equal(t, googleopenai.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, googleopenai.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/model_routing_aibridge.go b/coderd/x/chatd/model_routing_aibridge.go index 5db1a16e535d3..a732da1a952dc 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 ( @@ -85,6 +87,32 @@ func (t *aiGatewayRoundTripper) RoundTrip(req *http.Request) (*http.Response, er return t.base.RoundTrip(cloned) } +// ValidateAIGatewayProviderModel rejects slash-namespaced models on +// OpenRouter-like providers typed as openai, where the provider type +// strips the vendor prefix. +func ValidateAIGatewayProviderModel(provider database.AIProvider, model string) error { + if provider.Type != database.AiProviderTypeOpenai { + return nil + } + if !isSlashNamespacedAIGatewayModel(model) || !isOpenRouterLikeAIGatewayProvider(provider) { + return nil + } + return xerrors.New("OpenRouter-like provider configured as type openai does not support slash-namespaced models") +} + +func isSlashNamespacedAIGatewayModel(model string) bool { + prefix, suffix, ok := strings.Cut(strings.TrimSpace(model), "/") + return ok && strings.TrimSpace(prefix) != "" && strings.TrimSpace(suffix) != "" +} + +func isOpenRouterLikeAIGatewayProvider(provider database.AIProvider) bool { + if strings.EqualFold(strings.TrimSpace(provider.Name), "openrouter") { + return true + } + host := chatprovider.ProviderBaseURLHostname(provider.BaseUrl) + return host == "openrouter.ai" || strings.HasSuffix(host, ".openrouter.ai") +} + func (p *Server) newAIGatewayModel( _ context.Context, req modelClientRequest, @@ -98,7 +126,25 @@ 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.", + }, + ) + } + + if err := ValidateAIGatewayProviderModel(route.Provider, req.ModelName); err != nil { + return nil, chaterror.WithClassification( + err, + chaterror.ClassifiedError{ + Kind: codersdk.ChatErrorKindConfig, + Retryable: false, + Detail: "Ask an administrator to change the AI provider type to openrouter or openai-compat.", + }, + ) } factoryPtr := p.aibridgeTransportFactory diff --git a/coderd/x/chatd/model_routing_internal_test.go b/coderd/x/chatd/model_routing_internal_test.go index 786365d9fb368..76ede361deb5a 100644 --- a/coderd/x/chatd/model_routing_internal_test.go +++ b/coderd/x/chatd/model_routing_internal_test.go @@ -2,6 +2,7 @@ package chatd import ( "database/sql" + "encoding/json" "fmt" "io" "net/http" @@ -20,8 +21,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 { @@ -403,7 +406,7 @@ func TestActiveTurnAPIKeyIDFromMessages(t *testing.T) { }, }, { - name: "SkipsModelOnlyUserMessages", + name: "SkipsUncompressedModelOnlyUserMessages", messages: []database.ChatMessage{ {ID: 1, Role: database.ChatMessageRoleUser, Visibility: database.ChatMessageVisibilityBoth, APIKeyID: sqlNullString(oldKeyID)}, {ID: 2, Role: database.ChatMessageRoleUser, Visibility: database.ChatMessageVisibilityModel, APIKeyID: sqlNullString(currentKeyID)}, @@ -411,6 +414,54 @@ func TestActiveTurnAPIKeyIDFromMessages(t *testing.T) { wantKey: oldKeyID, wantOK: true, }, + { + name: "CompressedSummaryFallback", + messages: []database.ChatMessage{ + {ID: 1, Role: database.ChatMessageRoleUser, Visibility: database.ChatMessageVisibilityModel, Compressed: true, APIKeyID: sqlNullString(currentKeyID)}, + {ID: 2, Role: database.ChatMessageRoleAssistant, Visibility: database.ChatMessageVisibilityBoth}, + }, + wantKey: currentKeyID, + wantOK: true, + }, + { + name: "LatestCompressedSummaryWins", + messages: []database.ChatMessage{ + {ID: 1, Role: database.ChatMessageRoleUser, Visibility: database.ChatMessageVisibilityModel, Compressed: true, APIKeyID: sqlNullString(oldKeyID)}, + {ID: 2, Role: database.ChatMessageRoleUser, Visibility: database.ChatMessageVisibilityModel, Compressed: true, APIKeyID: sqlNullString(currentKeyID)}, + {ID: 3, Role: database.ChatMessageRoleAssistant, Visibility: database.ChatMessageVisibilityBoth}, + }, + wantKey: currentKeyID, + wantOK: true, + }, + { + name: "VisibleUserWinsOverCompressedSummary", + messages: []database.ChatMessage{ + {ID: 1, Role: database.ChatMessageRoleUser, Visibility: database.ChatMessageVisibilityModel, Compressed: true, APIKeyID: sqlNullString(oldKeyID)}, + {ID: 2, Role: database.ChatMessageRoleUser, Visibility: database.ChatMessageVisibilityBoth, APIKeyID: sqlNullString(currentKeyID)}, + }, + wantKey: currentKeyID, + wantOK: true, + }, + { + name: "MissingVisibleUserKeyDoesNotFallBackToCompressedSummary", + messages: []database.ChatMessage{ + {ID: 1, Role: database.ChatMessageRoleUser, Visibility: database.ChatMessageVisibilityModel, Compressed: true, APIKeyID: sqlNullString(oldKeyID)}, + {ID: 2, Role: database.ChatMessageRoleUser, Visibility: database.ChatMessageVisibilityBoth}, + }, + }, + { + name: "UncompressedModelOnlyUserIgnored", + messages: []database.ChatMessage{ + {ID: 1, Role: database.ChatMessageRoleUser, Visibility: database.ChatMessageVisibilityModel, APIKeyID: sqlNullString(currentKeyID)}, + }, + }, + { + name: "CompressedSummaryMissingKeyDoesNotFallBack", + messages: []database.ChatMessage{ + {ID: 1, Role: database.ChatMessageRoleUser, Visibility: database.ChatMessageVisibilityBoth, APIKeyID: sqlNullString(oldKeyID)}, + {ID: 2, Role: database.ChatMessageRoleUser, Visibility: database.ChatMessageVisibilityModel, Compressed: true}, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -419,15 +470,11 @@ func TestActiveTurnAPIKeyIDFromMessages(t *testing.T) { gotKey, gotOK := activeTurnAPIKeyIDFromMessages(tt.messages) require.Equal(t, tt.wantOK, gotOK) require.Equal(t, tt.wantKey, gotKey) - ctx := contextWithActiveTurnAPIKeyID(t.Context(), tt.messages) - ctxKey, ctxOK := aibridge.DelegatedAPIKeyIDFromContext(ctx) - require.Equal(t, tt.wantOK, ctxOK) - require.Equal(t, tt.wantKey, ctxKey) }) } } -func TestActiveTurnContextUsesPromptMessages(t *testing.T) { +func TestPromptMessagesForVisibleUserPreserveActiveAPIKeyID(t *testing.T) { t.Parallel() db, _ := dbtestutil.NewDB(t) @@ -475,12 +522,70 @@ func TestActiveTurnContextUsesPromptMessages(t *testing.T) { messages, err := db.GetChatMessagesForPromptByChatID(ctx, chat.ID) require.NoError(t, err) - ctx = contextWithActiveTurnAPIKeyID(ctx, messages) - gotKey, ok := aibridge.DelegatedAPIKeyIDFromContext(ctx) + gotKey, ok := activeTurnAPIKeyIDFromMessages(messages) require.True(t, ok) require.Equal(t, currentKey.ID, gotKey) } +func TestPromptMessagesForCompactedChatPreserveActiveAPIKeyID(t *testing.T) { + t.Parallel() + + db, _ := dbtestutil.NewDB(t) + ctx := t.Context() + user := dbgen.User(t, db, database.User{}) + org := dbgen.Organization(t, db, database.Organization{}) + model := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{}) + chat := dbgen.Chat(t, db, database.Chat{OrganizationID: org.ID, OwnerID: user.ID, LastModelConfigID: model.ID}) + key, _ := dbgen.APIKey(t, db, database.APIKey{UserID: user.ID}) + + visibleUser := dbgen.ChatMessage(t, db, database.ChatMessage{ + ChatID: chat.ID, + CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, + ModelConfigID: uuid.NullUUID{UUID: model.ID, Valid: true}, + Role: database.ChatMessageRoleUser, + Visibility: database.ChatMessageVisibilityBoth, + APIKeyID: sqlNullString(key.ID), + }) + dbgen.ChatMessage(t, db, database.ChatMessage{ + ChatID: chat.ID, + ModelConfigID: uuid.NullUUID{UUID: model.ID, Valid: true}, + Role: database.ChatMessageRoleAssistant, + Visibility: database.ChatMessageVisibilityBoth, + }) + compressedSummary := dbgen.ChatMessage(t, db, database.ChatMessage{ + ChatID: chat.ID, + ModelConfigID: uuid.NullUUID{UUID: model.ID, Valid: true}, + Role: database.ChatMessageRoleUser, + Visibility: database.ChatMessageVisibilityModel, + Compressed: true, + APIKeyID: sqlNullString(key.ID), + }) + afterSummary := dbgen.ChatMessage(t, db, database.ChatMessage{ + ChatID: chat.ID, + ModelConfigID: uuid.NullUUID{UUID: model.ID, Valid: true}, + Role: database.ChatMessageRoleAssistant, + Visibility: database.ChatMessageVisibilityBoth, + }) + + messages, err := db.GetChatMessagesForPromptByChatID(ctx, chat.ID) + require.NoError(t, err) + + ids := make(map[int64]struct{}, len(messages)) + for _, message := range messages { + ids[message.ID] = struct{}{} + } + _, hasVisibleUser := ids[visibleUser.ID] + require.False(t, hasVisibleUser) + _, hasSummary := ids[compressedSummary.ID] + require.True(t, hasSummary) + _, hasAfterSummary := ids[afterSummary.ID] + require.True(t, hasAfterSummary) + + gotKey, ok := activeTurnAPIKeyIDFromMessages(messages) + require.True(t, ok) + require.Equal(t, key.ID, gotKey) +} + func sqlNullString(value string) sql.NullString { return sql.NullString{String: value, Valid: value != ""} } @@ -530,6 +635,34 @@ 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("OpenRouterMisconfiguredAsOpenAI", func(t *testing.T) { + t.Parallel() + factory := &aibridgeTestFactory{rt: roundTripFunc(func(*http.Request) (*http.Response, error) { + t.Fatal("transport must not be used for invalid provider config") + return nil, xerrors.New("unreachable") + })} + server := &Server{ + aiGatewayRoutingEnabled: true, + aibridgeTransportFactory: aibridgeTestFactoryPointer(factory), + } + provider := aibridgeTestAIProvider(providerID, "openrouter", database.AiProviderTypeOpenai) + _, err := server.newModel( + t.Context(), + aibridgeTestRequest(chat, "anthropic/claude-opus-4.6"), + aibridgeTestRoute(provider), + modelBuildOptions{ActiveAPIKeyID: uuid.NewString()}, + ) + require.ErrorContains(t, err, "does not support slash-namespaced models") + classified := chaterror.Classify(err) + require.Equal(t, codersdk.ChatErrorKindConfig, classified.Kind) + require.False(t, classified.Retryable) }) t.Run("StaticModel", func(t *testing.T) { @@ -540,6 +673,112 @@ func TestAIBridgeRoutingFailClosed(t *testing.T) { }) } +func TestAIBridgeGatewayProviderTypesPreserveSlashModelID(t *testing.T) { + t.Parallel() + + const modelName = "anthropic/claude-opus-4.6" + tests := []struct { + name string + providerName string + providerType database.AIProviderType + }{ + { + name: "OpenRouter", + providerName: "openrouter", + providerType: database.AiProviderTypeOpenrouter, + }, + { + name: "OpenAICompat", + providerName: "openai-compatible-relay", + providerType: database.AiProviderTypeOpenaiCompat, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + type seenRequest struct { + model string + path string + } + seen := make(chan seenRequest, 1) + factory := &aibridgeTestFactory{rt: roundTripFunc(func(req *http.Request) (*http.Response, error) { + body, err := io.ReadAll(req.Body) + require.NoError(t, err) + var payload struct { + Model string `json:"model"` + } + require.NoError(t, json.Unmarshal(body, &payload)) + seen <- seenRequest{model: payload.Model, path: req.URL.Path} + + var responsePayload map[string]any + if strings.Contains(req.URL.Path, "/responses") { + responsePayload = map[string]any{ + "id": "resp_test", + "object": "response", + "created_at": 0, + "status": "completed", + "model": modelName, + "output": []map[string]any{{ + "id": "msg_test", + "type": "message", + "role": "assistant", + "content": []map[string]any{{"type": "output_text", "text": "hello"}}, + }}, + "usage": map[string]any{"input_tokens": 1, "output_tokens": 1, "total_tokens": 2}, + } + } else { + responsePayload = map[string]any{ + "id": "chatcmpl-test", + "object": "chat.completion", + "created": 0, + "model": modelName, + "choices": []map[string]any{{ + "index": 0, + "message": map[string]any{"role": "assistant", "content": "hello"}, + "finish_reason": "stop", + }}, + "usage": map[string]any{"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + } + } + responseBody, err := json.Marshal(responsePayload) + require.NoError(t, err) + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(string(responseBody))), + Request: req, + }, nil + })} + chat := database.Chat{ID: uuid.New(), OwnerID: uuid.New()} + server := &Server{ + aiGatewayRoutingEnabled: true, + aibridgeTransportFactory: aibridgeTestFactoryPointer(factory), + } + + model, err := server.newModel( + t.Context(), + aibridgeTestRequest(chat, modelName), + aibridgeTestRoute(aibridgeTestAIProvider(uuid.New(), tt.providerName, tt.providerType)), + modelBuildOptions{ActiveAPIKeyID: uuid.NewString()}, + ) + require.NoError(t, err) + _, err = model.Generate(t.Context(), fantasy.Call{Prompt: []fantasy.Message{{ + Role: fantasy.MessageRoleUser, + Content: []fantasy.MessagePart{fantasy.TextPart{Text: "hello"}}, + }}}) + require.NoError(t, err) + + got := <-seen + require.NotEmpty(t, got.path) + require.Equal(t, modelName, got.model) + require.Equal(t, tt.providerName, factory.providerName) + require.Equal(t, aibridge.SourceAgents, factory.source) + }) + } +} + func TestDirectModelBuildDoesNotRequireActiveAPIKeyID(t *testing.T) { t.Parallel() diff --git a/coderd/x/chatd/quickgen_internal_test.go b/coderd/x/chatd/quickgen_internal_test.go index 09fc8001abf5e..0e46ccc0f74e0 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") }) } diff --git a/coderd/x/chatd/subagent.go b/coderd/x/chatd/subagent.go index 8984ac86cd90e..450397416b788 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 dc60e3330f559..5ccab312d6725 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) { diff --git a/codersdk/aiproviders.go b/codersdk/aiproviders.go index fe34b9c03c21a..7b513340bca62 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/codersdk/chats.go b/codersdk/chats.go index 665ace7aa8122..bcf235f590be0 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -1525,14 +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" + 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. @@ -1546,6 +1548,8 @@ var AllChatErrorKinds = []ChatErrorKind{ ChatErrorKindAuth, ChatErrorKindConfig, ChatErrorKindUsageLimit, + ChatErrorKindMissingKey, + ChatErrorKindProviderDisabled, } // ChatError represents a terminal chat error in persisted chat state or the diff --git a/codersdk/deployment.go b/codersdk/deployment.go index b4939ec022986..da03da6fd3a15 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -13,6 +13,7 @@ import ( "strconv" "strings" "time" + "unicode" "github.com/coreos/go-oidc/v3/oidc" "github.com/google/uuid" @@ -705,18 +706,115 @@ func (c SSHConfig) ParseOptions() (map[string]string, error) { return m, nil } -// ParseSSHConfigOption parses a single ssh config option into it's key/value pair. +// ParseSSHConfigOption parses a single ssh config option into its key/value pair. func ParseSSHConfigOption(opt string) (key string, value string, err error) { - // An equal sign or whitespace is the separator between the key and value. + if strings.ContainsAny(opt, "\r\n\x00") { + return "", "", xerrors.Errorf("config-ssh option %q must not contain carriage return, newline, or NUL characters", opt) + } + + // An equal sign or a space is the separator between the key and value. idx := strings.IndexFunc(opt, func(r rune) bool { return r == ' ' || r == '=' }) if idx == -1 { - return "", "", xerrors.Errorf("invalid config-ssh option %q", opt) + return "", "", xerrors.Errorf("config-ssh option %q is missing a key/value separator ('=' or ' ')", opt) } return opt[:idx], opt[idx+1:], nil } +// isSingleHostPatternToken reports whether s is safe to write as a single SSH +// host pattern token. Whitespace or control characters could break out into +// additional SSH config directives. +func isSingleHostPatternToken(s string) bool { + return !strings.ContainsFunc(s, func(r rune) bool { + return unicode.IsSpace(r) || unicode.IsControl(r) + }) +} + +// ValidateWorkspaceHostnameSuffix validates a deployment-provided SSH hostname +// suffix before it is made available to clients. +func ValidateWorkspaceHostnameSuffix(suffix string) error { + // The suffix is implicitly prefixed with a dot when matching, so a leading + // dot is a config error: it forces the suffix to be a separate DNS label + // rather than an ordinary string suffix. E.g. "coder" matches "en.coder" + // but not "encoder". + if strings.HasPrefix(suffix, ".") { + return xerrors.Errorf("workspace hostname suffix %q must not start with a leading dot", suffix) + } + if strings.ContainsAny(suffix, "*?") { + return xerrors.Errorf("workspace hostname suffix %q must not contain glob characters", suffix) + } + if !isSingleHostPatternToken(suffix) { + return xerrors.Errorf("workspace hostname suffix %q must not contain whitespace or control characters", suffix) + } + return nil +} + +// ValidateWorkspaceHostnamePrefix validates a deployment-provided SSH hostname +// prefix before it is made available to clients. Unlike the suffix, a prefix +// may legitimately contain a trailing dot (the default is "coder."), so only +// the single-token requirement is enforced. +func ValidateWorkspaceHostnamePrefix(prefix string) error { + if !isSingleHostPatternToken(prefix) { + return xerrors.Errorf("workspace hostname prefix %q must not contain whitespace or control characters", prefix) + } + return nil +} + +// ValidateSSHConfigOptions validates deployment SSH settings before they are +// written to users' local SSH configs. +func ValidateSSHConfigOptions(options map[string]string) error { + // Sort the keys so that, when several options are invalid, the surfaced + // error is deterministic across restarts rather than dependent on map + // iteration order. + keys := make([]string, 0, len(options)) + for key := range options { + keys = append(keys, key) + } + slices.Sort(keys) + for _, key := range keys { + if err := ValidateSSHConfigOption(key, options[key]); err != nil { + return err + } + } + return nil +} + +// ValidateSSHConfigOption validates one deployment SSH option before it is +// written to users' local SSH configs. +func ValidateSSHConfigOption(key, value string) error { + if key == "" { + return xerrors.New("ssh config option key must not be empty") + } + if strings.ContainsAny(key, "=\r\n\x00") || strings.ContainsFunc(key, unicode.IsSpace) { + return xerrors.Errorf("ssh config option key %q is invalid", key) + } + // These options are rejected because, written into a user's SSH config by a + // deployment, they can execute code, load shared libraries, or override + // Coder's managed SSH settings on the client machine. When extending this + // list, classify the directive against these categories; the newline and + // whitespace checks above already prevent multi-line injection, so only + // single-line dangerous directives belong here. + switch strings.ToLower(key) { + // Structural directives that escape Coder's managed block. + case "host", "match", "include", + // Directives that run an attacker-supplied command string. + "proxycommand", "localcommand", "permitlocalcommand", "remotecommand", "knownhostscommand", + // Directives that dlopen an attacker-controlled shared library. + "pkcs11provider", "securitykeyprovider", "smartcarddevice", + // Directives that execute a command for X11 authentication. + "xauthlocation": + return xerrors.Errorf("ssh config option %q is not allowed: it can execute code, load shared libraries, or override Coder's managed SSH settings on client machines", key) + // ProxyJump conflicts with Coder's managed ProxyCommand. + case "proxyjump": + return xerrors.Errorf("ssh config option %q is not allowed: it conflicts with Coder's managed ProxyCommand", key) + } + if strings.ContainsAny(value, "\r\n\x00") { + return xerrors.Errorf("ssh config option %q must not contain carriage return, newline, or NUL characters", key) + } + return nil +} + // SessionLifetime refers to "sessions" authenticating into Coderd. Coder has // multiple different session types: api keys, tokens, workspace app tokens, // agent tokens, etc. This configuration struct should be used to group all @@ -857,6 +955,15 @@ type OIDCConfig struct { // situations where the OIDC callback domain is different from the ACCESS_URL // domain. 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 { @@ -1688,7 +1795,7 @@ func (c *DeploymentValues) Options() serpent.OptionSet { } workspaceHostnameSuffix := serpent.Option{ Name: "Workspace Hostname Suffix", - Description: "Workspace hostnames use this suffix in SSH config and Coder Connect on Coder Desktop. By default it is coder, resulting in names like myworkspace.coder.", + Description: "Workspace hostnames use this suffix in SSH config and Coder Connect on Coder Desktop. By default it is coder, resulting in names like myworkspace.coder. The suffix must not start with a dot, and must not contain spaces, newlines, or glob characters (* and ?).", Flag: "workspace-hostname-suffix", Env: "CODER_WORKSPACE_HOSTNAME_SUFFIX", YAML: "workspaceHostnameSuffix", @@ -1699,6 +1806,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 +1819,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 +1829,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 +1839,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 +1849,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 +1858,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.<region>.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.<region>.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 +1889,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 +1899,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 +1909,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, @@ -2890,6 +2996,37 @@ func (c *DeploymentValues) Options() serpent.OptionSet { // So hide it, and only surface it to the small number of users that need it. Hidden: true, }, + { + Name: "OIDC Auto Repair Links", + Description: "OIDC based users require the IdP issuer and subject in the claims to be static. " + + "If a new provider is configured, this option is required to be 'true'. It will reset any existing users to the " + + "previous provider, and match by email on their next login.", + Required: false, + Default: "true", + Flag: "oidc-repair-links", + Env: "CODER_OIDC_REPAIR_LINKS", + YAML: "oidc-repair-links", + Value: &c.OIDC.AutoRepairLinks, + Group: &deploymentGroupOIDC, + UseInstead: nil, + // This flag should be removed after validation in real deployments. Leaving it + // 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, { @@ -3254,7 +3391,7 @@ func (c *DeploymentValues) Options() serpent.OptionSet { Name: "Proxy Trusted Origins", Flag: "proxy-trusted-origins", Env: "CODER_PROXY_TRUSTED_ORIGINS", - Description: "Origin addresses to respect \"proxy-trusted-headers\". e.g. 192.168.1.0/24.", + Description: "Origin addresses to respect \"proxy-trusted-headers\" and X-Forwarded-Host for subdomain app routing. e.g. 192.168.1.0/24.", Value: &c.ProxyTrustedOrigins, Group: &deploymentGroupNetworking, YAML: "proxyTrustedOrigins", @@ -3550,8 +3687,13 @@ func (c *DeploymentValues) Options() serpent.OptionSet { { Name: "SSH Config Options", Description: "These SSH config options will override the default SSH config options. " + - "Provide options in \"key=value\" or \"key value\" format separated by commas." + - "Using this incorrectly can break SSH to your deployment, use cautiously.", + "Provide options in \"key=value\" or \"key value\" format separated by commas. " + + "Using this incorrectly can break SSH to your deployment, use cautiously. " + + "The following options are not allowed: " + + "Host, Match, Include, ProxyCommand, ProxyJump, LocalCommand, PermitLocalCommand, " + + "RemoteCommand, KnownHostsCommand, PKCS11Provider, SecurityKeyProvider, " + + "SmartcardDevice, XAuthLocation. " + + "Option values must not contain newline, carriage return, or NUL characters.", Flag: "ssh-config-options", Env: "CODER_SSH_CONFIG_OPTIONS", YAML: "sshConfigOptions", @@ -5219,6 +5361,23 @@ type SSHConfigResponse struct { SSHConfigOptions map[string]string `json:"ssh_config_options"` } +// Validate checks that the deployment-provided SSH configuration is safe to +// write into a user's local SSH config. Validating here ensures a deployment +// can never serve config that the client would reject. +func (r SSHConfigResponse) Validate() error { + if r.HostnamePrefix != "" { + if err := ValidateWorkspaceHostnamePrefix(r.HostnamePrefix); err != nil { + return err + } + } + if r.HostnameSuffix != "" { + if err := ValidateWorkspaceHostnameSuffix(r.HostnameSuffix); err != nil { + return err + } + } + return ValidateSSHConfigOptions(r.SSHConfigOptions) +} + // SSHConfiguration returns information about the SSH configuration for the // Coder instance. func (c *Client) SSHConfiguration(ctx context.Context) (SSHConfigResponse, error) { diff --git a/codersdk/deployment_test.go b/codersdk/deployment_test.go index 0287c0daa5b82..1abaa5e000593 100644 --- a/codersdk/deployment_test.go +++ b/codersdk/deployment_test.go @@ -149,6 +149,270 @@ func TestDeploymentValues_HighlyConfigurable(t *testing.T) { } } +func TestParseSSHConfigOption(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + option string + wantKey string + wantValue string + wantErr bool + }{ + { + name: "ProxyCommandWithSpaces", + option: "ProxyCommand=ssh -W %h:%p bastion", + wantKey: "ProxyCommand", + wantValue: "ssh -W %h:%p bastion", + }, + { + name: "SetEnvWithEquals", + option: "SetEnv=FOO=bar BAZ=qux", + wantKey: "SetEnv", + wantValue: "FOO=bar BAZ=qux", + }, + { + name: "SetEnvWithSpaceSeparator", + option: "SetEnv FOO=bar BAZ=qux", + wantKey: "SetEnv", + wantValue: "FOO=bar BAZ=qux", + }, + { + name: "HostName", + option: "HostName example.com", + wantKey: "HostName", + wantValue: "example.com", + }, + { + name: "NewlineInValue", + option: "ProxyCommand=echo hi\nHost *", + wantErr: true, + }, + { + name: "CarriageReturnInValue", + option: "ProxyCommand=echo hi\rHost *", + wantErr: true, + }, + { + name: "NULInValue", + option: "ProxyCommand=echo hi\x00Host *", + wantErr: true, + }, + { + name: "NewlineInKey", + option: "Proxy\nCommand=value", + wantErr: true, + }, + { + name: "CarriageReturnInKey", + option: "Proxy\rCommand=value", + wantErr: true, + }, + { + name: "NULInKey", + option: "Proxy\x00Command=value", + wantErr: true, + }, + { + name: "MissingSeparator", + option: "JustAKeyNoValue", + wantErr: true, + }, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + key, value, err := codersdk.ParseSSHConfigOption(tt.option) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, tt.wantKey, key) + require.Equal(t, tt.wantValue, value) + }) + } +} + +func TestValidateWorkspaceHostnameSuffix(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + suffix string + wantErr bool + }{ + {name: "Coder", suffix: "coder"}, + {name: "Example", suffix: "example"}, + {name: "Dotted", suffix: "coder.example.com"}, + {name: "Empty", suffix: ""}, + {name: "LeadingDot", suffix: ".coder", wantErr: true}, + {name: "Newline", suffix: "coder\nHost *\n\tProxyCommand evil", wantErr: true}, + {name: "CarriageReturn", suffix: "coder\r\nHost *", wantErr: true}, + {name: "Space", suffix: "coder Host *", wantErr: true}, + {name: "Tab", suffix: "coder\t*", wantErr: true}, + {name: "NUL", suffix: "coder\x00", wantErr: true}, + {name: "NonBreakingSpace", suffix: "coder\u00A0suffix", wantErr: true}, + {name: "Glob", suffix: "*", wantErr: true}, + {name: "GlobPrefix", suffix: "*.*", wantErr: true}, + {name: "QuestionMark", suffix: "code?", wantErr: true}, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := codersdk.ValidateWorkspaceHostnameSuffix(tt.suffix) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + }) + } +} + +func TestValidateWorkspaceHostnamePrefix(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + prefix string + wantErr bool + }{ + {name: "Default", prefix: "coder."}, + {name: "NoDot", prefix: "coder"}, + {name: "Empty", prefix: ""}, + {name: "LeadingDot", prefix: ".coder"}, + {name: "Newline", prefix: "coder.\nHost *\n\tProxyCommand evil", wantErr: true}, + {name: "CarriageReturn", prefix: "coder.\r\nHost *", wantErr: true}, + {name: "Space", prefix: "coder. Host *", wantErr: true}, + {name: "Tab", prefix: "coder.\t*", wantErr: true}, + {name: "NUL", prefix: "coder.\x00", wantErr: true}, + {name: "NonBreakingSpace", prefix: "coder.\u00A0x", wantErr: true}, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := codersdk.ValidateWorkspaceHostnamePrefix(tt.prefix) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + }) + } +} + +func TestValidateSSHConfigOptions(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + options map[string]string + wantErr bool + }{ + {name: "HostName", options: map[string]string{"HostName": "example.com"}}, + {name: "User", options: map[string]string{"User": "coder"}}, + {name: "Port", options: map[string]string{"Port": "22"}}, + {name: "SetEnv", options: map[string]string{"SetEnv": "FOO=bar BAZ=qux"}}, + {name: "UserKnownHostsFile", options: map[string]string{"UserKnownHostsFile": "/tmp/coder_known_hosts"}}, + {name: "EmptyKey", options: map[string]string{"": "value"}, wantErr: true}, + {name: "NewlineInKey", options: map[string]string{"User\nProxyCommand": "evil"}, wantErr: true}, + {name: "CarriageReturnInKey", options: map[string]string{"User\rProxyCommand": "evil"}, wantErr: true}, + {name: "NULInKey", options: map[string]string{"User\x00ProxyCommand": "evil"}, wantErr: true}, + {name: "SpaceInKey", options: map[string]string{"User ProxyCommand": "evil"}, wantErr: true}, + {name: "EqualsInKey", options: map[string]string{"User=ProxyCommand": "evil"}, wantErr: true}, + {name: "Host", options: map[string]string{"Host": "*"}, wantErr: true}, + {name: "HostCaseInsensitive", options: map[string]string{"hOsT": "*"}, wantErr: true}, + {name: "Match", options: map[string]string{"Match": "all"}, wantErr: true}, + {name: "Include", options: map[string]string{"Include": "~/.ssh/config.d/*"}, wantErr: true}, + {name: "ProxyCommand", options: map[string]string{"ProxyCommand": "ssh -W %h:%p bastion"}, wantErr: true}, + {name: "ProxyCommandCaseInsensitive", options: map[string]string{"proxycommand": "ssh -W %h:%p bastion"}, wantErr: true}, + {name: "LocalCommand", options: map[string]string{"LocalCommand": "echo pwned"}, wantErr: true}, + {name: "PermitLocalCommand", options: map[string]string{"PermitLocalCommand": "yes"}, wantErr: true}, + {name: "RemoteCommand", options: map[string]string{"RemoteCommand": "some-command"}, wantErr: true}, + {name: "KnownHostsCommand", options: map[string]string{"KnownHostsCommand": "echo key"}, wantErr: true}, + {name: "PKCS11Provider", options: map[string]string{"PKCS11Provider": "/tmp/evil.so"}, wantErr: true}, + {name: "PKCS11ProviderCaseInsensitive", options: map[string]string{"pkcs11provider": "/tmp/evil.so"}, wantErr: true}, + {name: "SecurityKeyProvider", options: map[string]string{"SecurityKeyProvider": "/tmp/evil.so"}, wantErr: true}, + {name: "NewlineInValue", options: map[string]string{"UserKnownHostsFile": "/tmp/known_hosts\nHost *\nProxyCommand evil"}, wantErr: true}, + {name: "CarriageReturnInValue", options: map[string]string{"UserKnownHostsFile": "/tmp/known_hosts\r\nHost *"}, wantErr: true}, + {name: "NULInValue", options: map[string]string{"UserKnownHostsFile": "/tmp/known_hosts\x00suffix"}, wantErr: true}, + {name: "SmartcardDevice", options: map[string]string{"SmartcardDevice": "/path/to/lib"}, wantErr: true}, + {name: "XAuthLocation", options: map[string]string{"XAuthLocation": "/usr/bin/xauth"}, wantErr: true}, + {name: "ProxyJump", options: map[string]string{"ProxyJump": "bastion.example.com"}, wantErr: true}, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := codersdk.ValidateSSHConfigOptions(tt.options) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + }) + } +} + +func TestSSHConfigResponse_Validate(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + response codersdk.SSHConfigResponse + wantErr string + }{ + { + name: "Valid", + response: codersdk.SSHConfigResponse{ + HostnamePrefix: "coder.", + HostnameSuffix: "coder", + SSHConfigOptions: map[string]string{"HostName": "example.com"}, + }, + }, + { + name: "Empty", + response: codersdk.SSHConfigResponse{}, + }, + { + name: "PrefixUnsafe", + response: codersdk.SSHConfigResponse{HostnamePrefix: "coder.\nHost *"}, + wantErr: "workspace hostname prefix", + }, + { + name: "SuffixUnsafe", + response: codersdk.SSHConfigResponse{HostnameSuffix: "coder\nHost *"}, + wantErr: "workspace hostname suffix", + }, + { + name: "OptionUnsafe", + response: codersdk.SSHConfigResponse{SSHConfigOptions: map[string]string{"ProxyCommand": "ssh -W %h:%p bastion"}}, + wantErr: `ssh config option "ProxyCommand" is not allowed`, + }, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := tt.response.Validate() + if tt.wantErr != "" { + require.ErrorContains(t, err, tt.wantErr) + return + } + require.NoError(t, err) + }) + } +} + func TestSSHConfig_ParseOptions(t *testing.T) { t.Parallel() diff --git a/codersdk/drpcsdk/server.go b/codersdk/drpcsdk/server.go new file mode 100644 index 0000000000000..6fc9a505d932f --- /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 0000000000000..66298af68f537 --- /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 0000000000000..e906daa82e0fd --- /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/codersdk/workspacesdk/agentconn.go b/codersdk/workspacesdk/agentconn.go index 6882ff0d91630..2f005c9c04095 100644 --- a/codersdk/workspacesdk/agentconn.go +++ b/codersdk/workspacesdk/agentconn.go @@ -102,6 +102,7 @@ type AgentConn interface { DebugMagicsock(ctx context.Context) ([]byte, error) DebugManifest(ctx context.Context) ([]byte, error) DialContext(ctx context.Context, network string, addr string) (net.Conn, error) + AppHTTPClient() *http.Client GetPeerDiagnostics() tailnet.PeerDiagnostics ListContainers(ctx context.Context) (codersdk.WorkspaceAgentListContainersResponse, error) ListMCPTools(ctx context.Context) (ListMCPToolsResponse, error) @@ -158,6 +159,7 @@ func (c *agentConn) SetExtraHeaders(h http.Header) { type AgentConnOptions struct { AgentID uuid.UUID CloseFunc func() error + Logger slog.Logger } func (c *agentConn) agentAddress() netip.Addr { @@ -370,6 +372,24 @@ func (c *agentConn) DialContext(ctx context.Context, network string, addr string } } +// AppHTTPClient returns an HTTP client for reaching HTTP apps served by this +// workspace agent. Redirects are blocked to prevent misuse. +func (c *agentConn) AppHTTPClient() *http.Client { + return &http.Client{ + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, + Transport: &http.Transport{ + // Disable keep-alives so these short-lived clients don't leave + // idle connections (and their goroutines) lingering after they're + // discarded. + DisableKeepAlives: true, + // Host locked to agent, port from URL. + DialContext: c.DialContext, + }, + } +} + // ListeningPorts lists the ports that are currently in use by the workspace. func (c *agentConn) ListeningPorts(ctx context.Context) (codersdk.WorkspaceAgentListeningPortsResponse, error) { ctx, span := tracing.StartSpan(ctx) @@ -1358,7 +1378,12 @@ func (c *agentConn) apiRequest(ctx context.Context, method, path string, body in // apiClient returns an HTTP client that can be used to make // requests to the workspace agent's HTTP API server. func (c *agentConn) apiClient() *http.Client { + agentAddr := netip.AddrPortFrom(c.agentAddress(), AgentHTTPAPIServerPort) return &http.Client{ + // Redirects are blocked to prevent misuse. + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, Transport: &http.Transport{ // Disable keep alives as we're usually only making a single // request, and this triggers goleak in tests @@ -1378,16 +1403,21 @@ func (c *agentConn) apiClient() *http.Client { return nil, xerrors.Errorf("request %q does not appear to be for http api", addr) } - if !c.AwaitReachable(ctx) { - return nil, xerrors.Errorf("workspace agent not reachable in time: %v", ctx.Err()) + if reqAddr, err := netip.ParseAddr(host); err != nil || reqAddr != agentAddr.Addr() { + c.opts.Logger.Warn(ctx, "blocked workspace agent API request to unintended host", + slog.F("agent_id", c.opts.AgentID), + slog.F("request_host", host), + slog.F("intended_agent_addr", agentAddr.Addr()), + ) + return nil, xerrors.Errorf("request host %q does not match intended agent %q", host, agentAddr.Addr()) } - ipAddr, err := netip.ParseAddr(host) - if err != nil { - return nil, xerrors.Errorf("parse host addr: %w", err) + if !c.AwaitReachable(ctx) { + return nil, xerrors.Errorf("workspace agent not reachable in time: %v", ctx.Err()) } - conn, err := c.Conn.DialContextTCP(ctx, netip.AddrPortFrom(ipAddr, AgentHTTPAPIServerPort)) + // Always dial the pinned agent address, never the request host. + conn, err := c.Conn.DialContextTCP(ctx, agentAddr) if err != nil { return nil, xerrors.Errorf("dial http api: %w", err) } diff --git a/codersdk/workspacesdk/agentconn_test.go b/codersdk/workspacesdk/agentconn_test.go new file mode 100644 index 0000000000000..7407be46f223a --- /dev/null +++ b/codersdk/workspacesdk/agentconn_test.go @@ -0,0 +1,212 @@ +package workspacesdk_test + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "net/netip" + "strings" + "sync/atomic" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "tailscale.com/tailcfg" + + "github.com/coder/coder/v2/codersdk/workspacesdk" + "github.com/coder/coder/v2/tailnet" + "github.com/coder/coder/v2/tailnet/proto" + "github.com/coder/coder/v2/tailnet/tailnettest" + "github.com/coder/coder/v2/testutil" +) + +func TestAgentConnRejectsCrossAgentRedirects(t *testing.T) { + t.Parallel() + + derpMap, _ := tailnettest.RunDERPAndSTUN(t) + cases := []struct { + name string + status int + invoke func(context.Context, workspacesdk.AgentConn) error + }{ + { + name: "get 302", + status: http.StatusFound, + invoke: func(ctx context.Context, conn workspacesdk.AgentConn) error { + _, err := conn.ListeningPorts(ctx) + return err + }, + }, + { + name: "post 307", + status: http.StatusTemporaryRedirect, + invoke: func(ctx context.Context, conn workspacesdk.AgentConn) error { + return conn.WriteFile(ctx, "/tmp/attacker", strings.NewReader("redirect-body")) + }, + }, + { + name: "post 308", + status: http.StatusPermanentRedirect, + invoke: func(ctx context.Context, conn workspacesdk.AgentConn) error { + return conn.WriteFile(ctx, "/tmp/attacker", strings.NewReader("redirect-body")) + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitMedium) + + clientID := uuid.New() + attackerID := uuid.New() + victimID := uuid.New() + clientConn, _ := newTailnetConn(t, derpMap, clientID, "client") + attackerConn, attackerIP := newTailnetConn(t, derpMap, attackerID, "attacker") + victimConn, victimIP := newTailnetConn(t, derpMap, victimID, "victim") + stitchTailnet(t, map[uuid.UUID]*tailnet.Conn{ + clientID: clientConn, + attackerID: attackerConn, + victimID: victimConn, + }) + + var victimHit atomic.Bool + victimRouter := http.NewServeMux() + victimRouter.HandleFunc("/api/v0/listening-ports", func(rw http.ResponseWriter, _ *http.Request) { + victimHit.Store(true) + rw.Header().Set("Content-Type", "application/json") + _, _ = rw.Write([]byte(`{"ports":[]}`)) + }) + victimRouter.HandleFunc("/api/v0/write-file", func(rw http.ResponseWriter, _ *http.Request) { + victimHit.Store(true) + rw.WriteHeader(http.StatusOK) + }) + serveTailnetHTTP(t, victimConn, victimRouter) + + victimBaseURL := fmt.Sprintf("http://[%s]:%d", victimIP, workspacesdk.AgentHTTPAPIServerPort) + attackerRouter := http.NewServeMux() + attackerRouter.HandleFunc("/", func(rw http.ResponseWriter, r *http.Request) { + http.Redirect(rw, r, victimBaseURL+r.URL.RequestURI(), tc.status) + }) + serveTailnetHTTP(t, attackerConn, attackerRouter) + + require.True(t, clientConn.AwaitReachable(ctx, attackerIP)) + require.True(t, clientConn.AwaitReachable(ctx, victimIP)) + + conn := workspacesdk.NewAgentConn(clientConn, workspacesdk.AgentConnOptions{ + AgentID: attackerID, + }) + + err := tc.invoke(ctx, conn) + require.Error(t, err) + require.False(t, victimHit.Load()) + }) + } +} + +// TestAgentConnAppHTTPClientRefusesRedirects verifies the app HTTP client does +// not follow redirects. +func TestAgentConnAppHTTPClientRefusesRedirects(t *testing.T) { + t.Parallel() + + tailnetConn, err := tailnet.NewConn(&tailnet.Options{ + Addresses: []netip.Prefix{tailnet.TailscaleServicePrefix.RandomPrefix()}, + Logger: testutil.Logger(t), + }) + require.NoError(t, err) + t.Cleanup(func() { + _ = tailnetConn.Close() + }) + + conn := workspacesdk.NewAgentConn(tailnetConn, workspacesdk.AgentConnOptions{ + AgentID: uuid.New(), + }) + + client := conn.AppHTTPClient() + require.NotNil(t, client.CheckRedirect) + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://example.invalid/", nil) + require.NoError(t, err) + require.ErrorIs(t, client.CheckRedirect(req, nil), http.ErrUseLastResponse) +} + +func newTailnetConn(t *testing.T, derpMap *tailcfg.DERPMap, id uuid.UUID, name string) (*tailnet.Conn, netip.Addr) { + t.Helper() + + addr := tailnet.TailscaleServicePrefix.AddrFromUUID(id) + conn, err := tailnet.NewConn(&tailnet.Options{ + ID: id, + Addresses: []netip.Prefix{netip.PrefixFrom(addr, 128)}, + Logger: testutil.Logger(t).Named(name), + DERPMap: derpMap, + }) + require.NoError(t, err) + t.Cleanup(func() { + assert.NoError(t, conn.Close()) + }) + + return conn, addr +} + +func serveTailnetHTTP(t *testing.T, conn *tailnet.Conn, handler http.Handler) { + t.Helper() + + ln, err := conn.Listen("tcp", fmt.Sprintf(":%d", workspacesdk.AgentHTTPAPIServerPort)) + require.NoError(t, err) + + server := &http.Server{Handler: handler, ReadHeaderTimeout: testutil.WaitShort} + t.Cleanup(func() { + assert.NoError(t, server.Close()) + assert.NoError(t, ln.Close()) + }) + + go func() { + err := server.Serve(ln) + if err != nil && !errors.Is(err, net.ErrClosed) && !errors.Is(err, http.ErrServerClosed) { + assert.NoError(t, err) + } + }() +} + +// stitchTailnet cross-programs every conn's node into every other conn, the +// N-peer analog of tailnet's stitch test helper, so the peers can reach each +// other without a coordinator. +func stitchTailnet(t *testing.T, conns map[uuid.UUID]*tailnet.Conn) { + t.Helper() + + sendNode := func(srcID uuid.UUID, node *tailnet.Node) { + protoNode, err := tailnet.NodeToProto(node) + if !assert.NoError(t, err) { + return + } + for dstID, dst := range conns { + if dstID == srcID { + continue + } + err = dst.UpdatePeers([]*proto.CoordinateResponse_PeerUpdate{{ + Id: srcID[:], + Node: protoNode, + Kind: proto.CoordinateResponse_PeerUpdate_NODE, + }}) + assert.NoError(t, err) + } + } + + for srcID, src := range conns { + src.SetNodeCallback(func(node *tailnet.Node) { + sendNode(srcID, node) + }) + if node := src.Node(); node != nil { + sendNode(srcID, node) + } + } + + t.Cleanup(func() { + for _, conn := range conns { + conn.SetNodeCallback(nil) + } + }) +} diff --git a/codersdk/workspacesdk/agentconnmock/agentconnmock.go b/codersdk/workspacesdk/agentconnmock/agentconnmock.go index 5c23246cae81e..d74bee773c61a 100644 --- a/codersdk/workspacesdk/agentconnmock/agentconnmock.go +++ b/codersdk/workspacesdk/agentconnmock/agentconnmock.go @@ -56,6 +56,20 @@ func (m *MockAgentConn) EXPECT() *MockAgentConnMockRecorder { return m.recorder } +// AppHTTPClient mocks base method. +func (m *MockAgentConn) AppHTTPClient() *http.Client { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AppHTTPClient") + ret0, _ := ret[0].(*http.Client) + return ret0 +} + +// AppHTTPClient indicates an expected call of AppHTTPClient. +func (mr *MockAgentConnMockRecorder) AppHTTPClient() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AppHTTPClient", reflect.TypeOf((*MockAgentConn)(nil).AppHTTPClient)) +} + // AwaitReachable mocks base method. func (m *MockAgentConn) AwaitReachable(ctx context.Context) bool { m.ctrl.T.Helper() diff --git a/codersdk/workspacesdk/workspacesdk.go b/codersdk/workspacesdk/workspacesdk.go index 67eab8b4bcb3b..f0311c7973f3b 100644 --- a/codersdk/workspacesdk/workspacesdk.go +++ b/codersdk/workspacesdk/workspacesdk.go @@ -300,6 +300,7 @@ func (c *Client) DialAgent(dialCtx context.Context, agentID uuid.UUID, options * <-controller.Closed() return conn.Close() }, + Logger: options.Logger, }) if !agentConn.AwaitReachable(dialCtx) { diff --git a/docs/about/contributing/modules.md b/docs/about/contributing/modules.md index 05d06e9299fa4..835729c3065ab 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/infrastructure/architecture.md b/docs/admin/infrastructure/architecture.md index c409feeba9425..e751c84231ae9 100644 --- a/docs/admin/infrastructure/architecture.md +++ b/docs/admin/infrastructure/architecture.md @@ -6,15 +6,11 @@ page describes possible deployments, challenges, and risks associated with them. <div class="tabs"> -## Community Edition +## Community and Premium editions -![Architecture Diagram](../../images/architecture-diagram.png) +![Single Region Architecture Diagram](../../images/single-region-architecture.png) -## Premium - -![Single Region Architecture Diagram](../../images/architecture-single-region.png) - -## Multi-Region Premium +## Multi-Region Premium edition ![Multi Region Architecture Diagram](../../images/architecture-multi-region.png) diff --git a/docs/admin/infrastructure/scale-utility.md b/docs/admin/infrastructure/scale-utility.md index 6945b54bf559e..0761b23a0ff95 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 3556370535f63..51ae222f96183 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/integrations/prometheus.md b/docs/admin/integrations/prometheus.md index 210f22d0405c4..df99ed870df86 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. | | @@ -323,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 394a63d70492b..1ddaeac88bf28 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 diff --git a/docs/admin/networking/port-forwarding.md b/docs/admin/networking/port-forwarding.md index f5678403adb94..5e8ac2f334028 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/networking/wildcard-access-url.md b/docs/admin/networking/wildcard-access-url.md index 44afba2e5bb2d..fc9e917331982 100644 --- a/docs/admin/networking/wildcard-access-url.md +++ b/docs/admin/networking/wildcard-access-url.md @@ -56,6 +56,12 @@ Use a reverse proxy to handle TLS termination with automatic certificate managem - [Apache with Let's Encrypt](../../tutorials/reverse-proxy-apache.md) - [Caddy reverse proxy](../../tutorials/reverse-proxy-caddy.md) +If your reverse proxy rewrites the request `Host` and forwards the original +host in `X-Forwarded-Host`, configure +[`CODER_PROXY_TRUSTED_ORIGINS`](../../reference/cli/server.md#--proxy-trusted-origins) +to trust that proxy's address. Otherwise Coder will ignore `X-Forwarded-Host` +for subdomain app routing. + ### DNS Setup You'll need to configure DNS to point wildcard subdomains to your Coder server: diff --git a/docs/admin/setup/index.md b/docs/admin/setup/index.md index ea36467cfa106..df68b1efcbca3 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 diff --git a/docs/admin/templates/extending-templates/parameters.md b/docs/admin/templates/extending-templates/parameters.md index 57d2582bc8f02..3eb7957a73160 100644 --- a/docs/admin/templates/extending-templates/parameters.md +++ b/docs/admin/templates/extending-templates/parameters.md @@ -232,8 +232,8 @@ parameters, the **Create workspace** button is disabled until the issues are res Ephemeral parameters are introduced to users in order to model specific behaviors in a Coder workspace, such as reverting to a previous image, restoring from a volume snapshot, or building a project without using cache. These -parameters are only settable when starting, updating, or restarting a workspace -and do not persist after the workspace is stopped. +parameters are settable when creating, starting, updating, or restarting a workspace +but do not persist after the workspace is stopped. Since these parameters are ephemeral in nature, subsequent builds proceed in the standard manner: diff --git a/docs/admin/templates/extending-templates/variables.md b/docs/admin/templates/extending-templates/variables.md index 3c1d02f0baf63..7776b355a3c0a 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/templates/extending-templates/web-ides.md b/docs/admin/templates/extending-templates/web-ides.md index 4240dfe55205b..ba27683987fd1 100644 --- a/docs/admin/templates/extending-templates/web-ides.md +++ b/docs/admin/templates/extending-templates/web-ides.md @@ -240,8 +240,8 @@ EOT resource "coder_app" "rstudio" { agent_id = coder_agent.coder.id slug = "rstudio" - display_name = "R Studio" - icon = "https://upload.wikimedia.org/wikipedia/commons/d/d0/RStudio_logo_flat.svg" + display_name = "RStudio" + icon = "/icon/rstudio.svg" url = "http://localhost:8787" subdomain = true share = "owner" diff --git a/docs/admin/users/idp-sync.md b/docs/admin/users/idp-sync.md index 3c7ec708be3f9..824d66635fd50 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/admin/users/index.md b/docs/admin/users/index.md index b49ac35905439..92639fa6ae09b 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/agent-firewall/index.md b/docs/ai-coder/agent-firewall/index.md index d5d29210970f6..8fe5192756581 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/agents/chat-sharing.md b/docs/ai-coder/agents/chat-sharing.md new file mode 100644 index 0000000000000..89a9391d0e34d --- /dev/null +++ b/docs/ai-coder/agents/chat-sharing.md @@ -0,0 +1,24 @@ +# Chat Sharing + +Chat sharing lets you give other users or groups read-only access to a Coder Agents conversation. + +## Share a chat + +1. Open the chat you want to share on the **Agents** page. Only top-level chats can be shared; sub-agent chats inherit sharing from their parent. +1. Click the share icon in the chat top bar. +1. Click the **Search for user or group** field. +1. Search for and select a user or group. +1. Click **Add member** to grant **Read** access. +1. Copy the chat URL from your browser and send it to the recipients. + +Coder does not create a separate share link or notify recipients. They must open the chat from the URL you send them. + +## Shared chat access + +Viewers can open the chat from a direct link, view messages, stream live updates, and download chat attachments. They reach sub-agent chats by following sub-agent links inside the parent chat or by opening a direct URL. + +Shared chats do not appear in the viewer's normal chat list. Viewers have read-only access: they cannot send or edit messages, regenerate the chat title, archive the chat, or change its sharing settings. + +## Disable chat sharing + +Administrators can disable chat sharing for a deployment with `--disable-chat-sharing`, `CODER_DISABLE_CHAT_SHARING`, or `disableChatSharing`. When disabled, only chat owners can access their chats. diff --git a/docs/ai-coder/agents/getting-started.md b/docs/ai-coder/agents/getting-started.md index 8258ed44ada7d..a513cba7456fa 100644 --- a/docs/ai-coder/agents/getting-started.md +++ b/docs/ai-coder/agents/getting-started.md @@ -37,11 +37,13 @@ Before you begin, confirm the following: To configure Coder Agents: -1. Navigate to the **Agents** page in the Coder dashboard. -1. Open **Settings** > **Manage Agents** and select the **Providers** tab. - Pick a provider, enter your API key, and save. -1. Switch to the **Models** tab, click **Add**, and configure at least one - model with its identifier, display name, and context limit. +1. Navigate to **Admin settings** > **AI** and select **Providers**. +1. Add or update a provider with its credentials and upstream endpoint, then + save it. +1. Navigate to the **Agents** page, open **Settings** > **Manage Agents**, and + select **Models**. +1. Click **Add** and configure at least one model with its identifier, display + name, and context limit. 1. Click the **star icon** next to a model to set it as the default. Detailed instructions for each provider and model option are in the diff --git a/docs/ai-coder/agents/models.md b/docs/ai-coder/agents/models.md index a9a6c7bf38150..9e29f621db5f1 100644 --- a/docs/ai-coder/agents/models.md +++ b/docs/ai-coder/agents/models.md @@ -1,77 +1,87 @@ # Models -Administrators configure LLM providers and models from the Coder dashboard. -Providers, models, and centrally managed credentials are deployment-wide -settings managed by platform teams. Developers select from the set of models -that an administrator has enabled. +Administrators configure LLM providers from **Admin settings** > **AI** and +Coder Agents models from the **Agents** settings page. Providers, models, and +centrally managed credentials are deployment-wide settings managed by platform +teams. Developers select from the set of models that an administrator has +enabled. -Optionally, administrators can allow developers to supply their own API keys -for specific providers. See [User API keys](#user-api-keys-byok) below. +Optionally, administrators can enable AI Gateway Bring Your Own Key (BYOK) +so developers can supply personal API keys for providers. See +[User API keys](#user-api-keys-byok) below. ## Providers -Each LLM provider has a type, a credential configuration, and an optional base URL override. +Each LLM provider has a type, credentials, and an endpoint/base URL for the +upstream provider or proxy. Coder supports the following provider types: -| Provider | Description | -|-------------------|------------------------------------------------------------------| -| Anthropic | Claude models via Anthropic API | -| OpenAI | GPT and o-series models via OpenAI API | -| Google | Gemini models via Google AI API | -| Azure OpenAI | OpenAI models hosted on Azure | -| AWS Bedrock | Models via AWS Bedrock (bearer token or ambient AWS credentials) | -| OpenAI Compatible | Any endpoint implementing the OpenAI API | -| OpenRouter | Multi-model routing via OpenRouter | -| Vercel AI Gateway | Models via Vercel AI SDK | +| Provider | Description | +|-------------------|------------------------------------------| +| Anthropic | Claude models via Anthropic API | +| OpenAI | GPT and o-series models via OpenAI API | +| Google | Gemini models via Google AI API | +| Azure OpenAI | OpenAI models hosted on Azure | +| AWS Bedrock | Models via AWS Bedrock | +| OpenAI Compatible | Any endpoint implementing the OpenAI API | +| OpenRouter | Multi-model routing via OpenRouter | +| Vercel AI Gateway | Models via Vercel AI SDK | The **OpenAI Compatible** type is a catch-all for any service that exposes an OpenAI-compatible chat completions endpoint. Use it to connect to self-hosted models, internal gateways, or third-party proxies like LiteLLM. -### Add a provider +Coder Agents route model requests through AI Gateway automatically by using +the provider configuration stored in Coder's database. -1. Navigate to the **Agents** page in the Coder dashboard. -1. Open **Settings** > **Manage Agents** and select the **Providers** tab. -1. Click the provider you want to configure. -1. Enter the **API key** for the provider, if required. -1. Optionally set a **Base URL** to override the default endpoint. This is - useful for enterprise proxies, regional endpoints, or self-hosted models. -1. Click **Save**. +### Add a provider -<img src="../../images/guides/ai-agents/models-providers.png" alt="Screenshot of the providers list in the Agents settings"> +LLM providers are managed from the deployment AI settings, not from the Agents +settings page. -<small>The providers list shows all supported providers and their configuration -status.</small> +1. Navigate to **Admin settings** > **AI**. +1. Select **Providers**. +1. Click **Add provider**. +1. Select the provider type. +1. Enter a unique lowercase provider name, the credentials, and the upstream + provider or proxy + [endpoint/base URL](#endpointbase-url-for-openai-compatible-providers). +1. Click **Save**. -<img src="../../images/guides/ai-agents/models-add-provider.png" alt="Screenshot of the add provider form"> +After saving a provider, add an Agents model for it from **Agents** > +**Settings** > **Manage Agents** > **Models**. For provider-specific setup, +including AWS Bedrock, see +[AI Gateway provider configuration](../ai-gateway/providers.md#provider-types). -<small>Adding a provider usually requires an API key. AWS Bedrock can also use -ambient AWS credentials. The base URL is optional.</small> +## Endpoint/base URL for OpenAI-compatible providers -## Configuring AWS Bedrock +Provider configuration stores an absolute HTTP(S) endpoint/base URL. Syntax +validation confirms that the value is a URL, but it does not prove the upstream +implements the APIs Coder sends. -AWS Bedrock supports two credential modes for Agents providers: +For the default Agents path through AI Gateway, set the endpoint/base URL to +the upstream provider or proxy endpoint. Do not set it to Coder's public AI +Gateway route, such as `https://<coder-host>/api/v2/aibridge/openai/v1`. -- **Bearer token mode**: Enter a Bedrock-compatible bearer token in the - **API key** field when you add the provider. -- **Ambient AWS credentials mode**: Leave the **API key** field empty. The - Coder server resolves credentials from the standard AWS SDK credential chain, - including IAM instance roles and `AWS_ACCESS_KEY_ID` / - `AWS_SECRET_ACCESS_KEY` environment variables. +OpenAI-shaped provider types require the upstream OpenAI-compatible prefix in +the endpoint/base URL because Coder appends request suffixes such as +`/chat/completions`, `/responses`, and `/models`. This applies to **OpenAI**, +**Azure OpenAI**, **Google**, **OpenAI Compatible**, **OpenRouter**, and +**Vercel AI Gateway** provider types. -Region comes from the standard AWS SDK configuration. In most deployments, set -`AWS_REGION` on the Coder server. Bearer token mode falls back to `us-east-1` -when no region is configured. Ambient credentials require a region from the -standard AWS SDK chain, for example `AWS_REGION`. +Examples: -The **Base URL** field overrides the Bedrock runtime endpoint. Use it for -custom endpoints or VPC endpoints. +| Provider type | Example endpoint/base URL | +|-------------------------------------|------------------------------------------------------------| +| OpenAI | `https://api.openai.com/v1/` | +| Azure OpenAI | `https://<resource-name>.openai.azure.com/openai/v1` | +| Google Gemini OpenAI-compatible API | `https://generativelanguage.googleapis.com/v1beta/openai/` | +| OpenRouter | `https://openrouter.ai/api/v1` | +| Vercel AI Gateway | `https://ai-gateway.vercel.sh/v1` | +| Generic OpenAI-compatible proxy | `https://provider.example.com/v1` | -> [!NOTE] -> Agents Bedrock provider configuration is separate from AI Gateway Bedrock -> flags (`CODER_AI_GATEWAY_BEDROCK_*`). AI Gateway and Agents use independent -> credential paths. +Confirm the exact endpoint/base URL in your provider or proxy documentation. ## Provider credentials and security @@ -80,47 +90,30 @@ database. They are never exposed to workspaces, developers, or the browser after initial entry. The dashboard shows only whether a key is set, not the key itself. -When a provider uses ambient credentials, Coder resolves them from the server -environment at request time instead of storing a secret in the database. - Because the agent loop runs in the control plane, workspaces never need direct access to LLM providers. See [Architecture](./architecture.md#no-api-keys-in-workspaces) for details on this security model. -## Key policy +## Credential selection -Each provider has three policy flags that control how provider credentials are -sourced: +Coder Agents use the AI providers configured by administrators. Provider API +keys entered by administrators are centralized credentials for the deployment. -| Setting | Default | Description | -|-------------------------|---------|--------------------------------------------------------------------------------------------------------------------------| -| Central API key | On | The provider uses deployment-managed credentials configured by an administrator. For most providers, this is an API key. | -| Allow user API keys | Off | Developers may supply their own API key for this provider. | -| Central key as fallback | Off | When user keys are allowed, fall back to deployment-managed credentials if a developer has not set a personal key. | +BYOK for Coder Agents is controlled by the +[global AI Gateway BYOK setting](../ai-gateway/auth.md#bring-your-own-key-byok), +not by per-provider key policy flags. When BYOK is enabled, users can save a +personal API key for any enabled AI provider. When BYOK is disabled, saved user +keys are ignored and users cannot add or update personal keys. -At least one credential source must be enabled. These settings appear in the -provider configuration form under **Key policy**. +For each provider request, Coder selects credentials in this order: -The interaction between these flags determines whether a provider is available -to a given developer: - -| Central key | User keys allowed | Fallback | Developer has key | Result | -|-------------|-------------------|----------|-------------------|----------------------| -| On | Off | — | — | Uses central key | -| Off | On | — | Yes | Uses developer's key | -| Off | On | — | No | Unavailable | -| On | On | Off | Yes | Uses developer's key | -| On | On | Off | No | Unavailable | -| On | On | On | Yes | Uses developer's key | -| On | On | On | No | Uses central key | - -When a developer's personal key is present, it always takes precedence over -deployment-managed credentials. When user keys are required and fallback is -disabled, the provider is unavailable to developers who have not saved a -personal key, even if deployment-managed credentials exist. This is -intentional: it enforces that each developer authenticates with their own -credentials. +1. If BYOK is enabled and the user has saved a personal key for the selected + provider, Coder uses the user's key. +1. Otherwise, Coder uses centralized provider credentials when they are + configured. +1. If neither a usable user key nor centralized credentials are available, the + provider is unavailable for that user. ## Models @@ -131,11 +124,11 @@ generation parameters, and provider-specific options. 1. Open **Settings** > **Manage Agents** and select the **Models** tab. 1. Click **Add** and select the provider for the new model. -1. Enter the **Model Identifier** — the exact model string your provider +1. Enter the **Model Identifier**, the exact model string your provider expects (e.g., `claude-opus-4-6`, `gpt-5.3-codex`). 1. Set a **Display Name** so developers see a human-readable label in the model selector. -1. Set the **Context Limit** — the maximum number of tokens in the model's +1. Set the **Context Limit**, the maximum number of tokens in the model's context window (e.g., `200000` for Claude Sonnet). 1. Configure any provider-specific options (see below). 1. Click **Save**. @@ -171,7 +164,7 @@ These options apply to all providers: | Model Identifier | The API model string sent to the provider (e.g., `claude-opus-4-6`). | | Display Name | The label shown to developers in the model selector. | | Context Limit | Maximum tokens in the context window. Used to determine when context compaction triggers. | -| Compression Threshold | Percentage (0–100) of context usage at which the agent compresses older messages into a summary. | +| Compression Threshold | Percentage (0-100) of context usage at which the agent compresses older messages into a summary. | | Max Output Tokens | Maximum tokens generated per model response. | | Temperature | Controls randomness. Lower values produce more deterministic output. | | Top P | Nucleus sampling threshold. | @@ -238,9 +231,9 @@ are active. The model selector uses the following precedence to pre-select a model: -1. **Last used model** — stored in the browser's local storage. -1. **Admin-designated default** — the model marked with the star icon. -1. **First available model** — if no default is set and no history exists. +1. **Last used model**, stored in the browser's local storage. +1. **Admin-designated default**, the model marked with the star icon. +1. **First available model**, if no default is set and no history exists. Developers cannot add their own providers or models. If no models are configured, the chat interface displays a message directing developers to @@ -284,57 +277,44 @@ and resolution falls through to the next. ## User API keys (BYOK) -When an administrator enables **Allow user API keys** on a provider, -developers can supply their own API key from the Agents settings page. +When [AI Gateway BYOK](../ai-gateway/auth.md#bring-your-own-key-byok) is +enabled, developers can supply personal API keys for any enabled AI provider +from the Agents settings page. ### Managing personal API keys 1. Navigate to the **Agents** page in the Coder dashboard. 1. Open **Settings** and select the **API Keys** tab. -1. Each provider that allows user keys is listed with a status indicator: - - **Key saved** — your personal key is active and will be used for requests. - - **Using shared key** — no personal key set, but the central deployment - key is available as a fallback. - - **No key** — you must add a personal key before you can use this provider. +1. Each enabled provider is listed with a status indicator: + - **Key saved**, your personal key is active and will be used for requests to + that provider. + - **Using shared key**, no personal key is set and Coder is using + deployment-managed credentials for that provider. + - **No key**, no personal key or deployment-managed credential is available. + Add a personal key before you use models from this provider. 1. Enter your API key and click **Save**. Personal API keys are encrypted at rest using the same database encryption used for deployment-managed provider secrets. The dashboard never displays a saved key, only whether one is set. -### How key selection works - -When you start a chat, the control plane resolves which credential source to -use for each provider: - -1. If you have a personal key for the provider, it is used. -1. If you do not have a personal key and central key fallback is enabled, - deployment-managed credentials are used. -1. If you do not have a personal key and fallback is disabled, the provider - is unavailable to you. Models from that provider will not appear in the - model selector. - ### Removing a personal key -Click **Remove** on the provider card in the API Keys settings tab. If -central key fallback is enabled, subsequent requests will use the shared -deployment-managed credentials. If fallback is disabled, the provider becomes -unavailable until you add a new personal key. +Click **Remove** on the provider card in the API Keys settings tab. Subsequent +requests use deployment-managed credentials when they are configured for that +provider. If no deployment-managed credential is available, add a new personal +key before you use models from that provider. ## Using an LLM proxy -Organizations that route LLM traffic through a centralized proxy — such as -Coder's AI Gateway or third parties like LiteLLM — can point any provider's **Base URL** at their proxy endpoint. - -For example, to route all OpenAI traffic through Coder's AI Gateway: - -1. Add or edit the **OpenAI** provider. -1. Set the **Base URL** to your AI Gateway endpoint - (e.g., `https://example.coder.com/api/v2/aibridge/openai/v1`). -1. Enter the API key your proxy expects. +Organizations that route LLM traffic through a centralized proxy, such as +LiteLLM or an internal gateway, can point a provider's **Endpoint** or **Base +URL** at that upstream proxy endpoint. Enter the API key your proxy expects. -Alternatively, use the **OpenAI Compatible** provider type if your proxy serves -multiple model families through a single OpenAI-compatible endpoint. +Use the **OpenAI Compatible** provider type if your proxy serves multiple model +families through a single OpenAI-compatible endpoint. Include the proxy +provider's documented OpenAI-compatible path prefix, such as `/v1`, when +required. This lets you keep existing proxy-level features like per-user budgets, rate limiting, and audit logging while using Coder Agents as the developer interface. diff --git a/docs/ai-coder/agents/platform-controls/mcp-servers.md b/docs/ai-coder/agents/platform-controls/mcp-servers.md index 15b3b5f219bcf..6cd58ceb46551 100644 --- a/docs/ai-coder/agents/platform-controls/mcp-servers.md +++ b/docs/ai-coder/agents/platform-controls/mcp-servers.md @@ -143,10 +143,8 @@ auth header for the configured `auth_type`: | `X-Coder-Subchat-Id` | Subchat ID. Only present when the request originates from a child chat. | | `X-Coder-Workspace-Id` | Workspace associated with the chat, if any. | -These are the same headers Coder sends to LLM providers (see -[Coder agents headers](../../ai-gateway/clients/coder-agents.md)) so a -first-party MCP server can correlate a tool call back to the -originating chat. +Coder sends the same identity headers to LLM providers, so a first-party +MCP server can correlate a tool call back to the originating chat. Because the headers leak chat identity, the option is **off by default** and should only be enabled for first-party or trusted diff --git a/docs/ai-coder/agents/tasks-to-chats-migration.md b/docs/ai-coder/agents/tasks-to-chats-migration.md index 1d78edde8fbd4..db31d2fb4fe5a 100644 --- a/docs/ai-coder/agents/tasks-to-chats-migration.md +++ b/docs/ai-coder/agents/tasks-to-chats-migration.md @@ -68,10 +68,11 @@ With Tasks, LLM credentials are injected into the workspace as environment variables (e.g. `ANTHROPIC_API_KEY`). With Coder Agents, credentials are configured once in the control plane: -1. Navigate to the **Agents** page in the Coder dashboard. -1. Open **Settings** > **Manage Agents** > **Providers**, pick a provider, - enter your API key, and save. -1. Under **Models**, add at least one model and set it as the default. +1. Navigate to **Admin settings** > **AI** and select **Providers**. +1. Add or update a provider with its credentials and upstream endpoint, then + save it. +1. Navigate to the **Agents** page, open **Settings** > **Manage Agents** > + **Models**, add at least one model, and set it as the default. You no longer pass API keys in template variables or workspace environment. See https://coder.com/docs/ai-coder/agents/getting-started for more information. diff --git a/docs/ai-coder/ai-gateway/ai-gateway-proxy/setup.md b/docs/ai-coder/ai-gateway/ai-gateway-proxy/setup.md index 459450a5f0dd3..165bb89000e18 100644 --- a/docs/ai-coder/ai-gateway/ai-gateway-proxy/setup.md +++ b/docs/ai-coder/ai-gateway/ai-gateway-proxy/setup.md @@ -272,10 +272,6 @@ CODER_AI_GATEWAY_PROXY_UPSTREAM_CA=/path/to/corporate-ca.crt If the system already trusts the upstream proxy's CA certificate, [`CODER_AI_GATEWAY_PROXY_UPSTREAM_CA`](../../../reference/cli/server.md#--ai-gateway-proxy-upstream-ca) is not required. -<!-- TODO(ssncferreira): Add Client Configuration section --> - -<!-- TODO(ssncferreira): Add Troubleshooting section --> - ## Client Configuration To use AI Gateway Proxy, AI tools must be configured to: @@ -374,3 +370,21 @@ This provides a seamless experience where users don't need to configure anything <!-- TODO(ssncferreira): Add registry link for AI Gateway Proxy module for Coder workspaces: https://github.com/coder/internal/issues/1187 --> For tool-specific configuration details, check the [client compatibility table](../clients/index.md#compatibility) for clients that require proxy-based integration. + +## Troubleshooting + +### TLS certificate verification failures + +When the Coder access URL uses HTTPS, AI Gateway Proxy must trust the TLS certificate served at that URL (either Coder's +own certificate or a load balancer's, if TLS is terminated there) to forward intercepted requests to AI Gateway. +This primarily affects deployments using a self-signed or internal CA, since publicly trusted CAs are typically already +in the system trust store. +If the certificate is signed by a CA not in the system trust store, the connection fails and the Coder server logs: + +```shell +WARN: Cannot read TLS response from mitm'd server tls: failed to verify certificate: x509: certificate signed by unknown authority +``` + +To resolve, add the CA that signed that certificate to the [system trust store](#system-trust-store) of the host running +AI Gateway Proxy (the same host as `coderd`, since the proxy runs in-process), then restart Coder so AI Gateway Proxy +reloads the trust store. diff --git a/docs/ai-coder/ai-gateway/auth.md b/docs/ai-coder/ai-gateway/auth.md index d7bb48aa85155..d05e1c806c88f 100644 --- a/docs/ai-coder/ai-gateway/auth.md +++ b/docs/ai-coder/ai-gateway/auth.md @@ -88,10 +88,22 @@ In BYOK mode, users need two credentials: BYOK and centralized modes can be used together. When a user provides their own credential, AI Gateway forwards it directly. -When no user credential is present, AI Gateway falls back to the admin-configured provider key. +When no user credential is present, AI Gateway uses the admin-configured provider key. This approach offers centralized keys as a default, while allowing individual users to bring their own key. +> [!NOTE] +> When a BYOK credential is present, [key failover](./providers.md#key-failover) +> is skipped. + +Coder Agents requests routed through AI Gateway are in-process control plane +requests, not external client requests that send their own AI Gateway bearer +token. Coder Agents use this same global BYOK setting. When BYOK is enabled, +users can save personal API keys for any enabled AI provider from the Agents +settings page. See +[Agents credential selection](../agents/models.md#credential-selection) +for the Agents-specific behavior. + Visit individual [client pages](./clients/index.md) for configuration details. ### Enable or disable BYOK diff --git a/docs/ai-coder/ai-gateway/clients/coder-agents.md b/docs/ai-coder/ai-gateway/clients/coder-agents.md deleted file mode 100644 index f5187cce58379..0000000000000 --- a/docs/ai-coder/ai-gateway/clients/coder-agents.md +++ /dev/null @@ -1,192 +0,0 @@ -# Coder Agents - -[Coder Agents](../../agents/index.md) is a chat interface and API for delegating -development work to coding agents that run inside the Coder control plane. When -AI Gateway is enabled on the same deployment, Coder Agents traffic can be -routed through it for full audit and governance coverage. - -## Prerequisites - -- AI Gateway is [enabled](../setup.md#activation) on your Coder deployment. -- At least one [provider](../setup.md#configure-providers) is configured in - AI Gateway with a valid upstream key. -- You are an administrator with permission to configure Coder Agents - [providers](../../agents/models.md#providers). - -> [!NOTE] -> AI Gateway and Coder Agents use independent provider configurations. Adding -> a provider to AI Gateway does not enable it in Coder Agents, and vice versa. -> Configure each separately. - -## Configuration - -Point each Agents provider's **Base URL** at your local AI Gateway endpoint -and set the **API Key** to a credential AI Gateway accepts. Because both -services run in the same `coderd` process, the AI Gateway endpoint is just -your deployment URL plus `/api/v2/aibridge/<provider>`. - -The steps are the same regardless of provider type, only the Base URL -changes: - -1. Open the Coder dashboard and navigate to the **Agents** page. -1. Click **Admin**, then select the **Providers** tab. -1. Click the provider you want to route through AI Gateway. -1. Set the **Base URL** using the table below. -1. Set the **API Key** to a Coder API token. See - [Authentication](#authentication) for which token to use. -1. Click **Save**. - -| Agents provider | Base URL | -|-------------------------------------------|-------------------------------------------------------| -| Anthropic | `https://coder.example.com/api/v2/aibridge/anthropic` | -| OpenAI | `https://coder.example.com/api/v2/aibridge/openai/v1` | -| OpenAI Compatible (named OpenAI instance) | `https://coder.example.com/api/v2/aibridge/<name>/v1` | - -Replace `coder.example.com` with your Coder deployment URL. - -To target a [named AI Gateway instance](../setup.md#multiple-instances-of-the-same-provider) -through the **Anthropic** or **OpenAI** providers, swap the provider segment -of the Base URL for the instance name. For example, an Anthropic instance -named `anthropic-corp` becomes -`https://coder.example.com/api/v2/aibridge/anthropic-corp`, and an OpenAI -instance named `azure-openai` becomes -`https://coder.example.com/api/v2/aibridge/azure-openai/v1`. - -> [!NOTE] -> The table above covers the Coder Agents provider types most commonly -> routed through AI Gateway. Coder Agents also supports Azure OpenAI, -> AWS Bedrock, Google, OpenRouter, and Vercel AI Gateway provider types, -> but only providers that speak a wire protocol AI Gateway supports -> (Anthropic, OpenAI, or Copilot today) can be routed through it. The -> base URL pattern is the same for any compatible provider: point it at -> `https://<your-coder-host>/api/v2/aibridge/<instance-name>`. - -After saving, [add or update a model](../../agents/models.md#add-a-model) on -each provider so developers can select it from the chat. Models from a -provider only appear in the model selector once the provider has valid -credentials. - -## Authentication - -AI Gateway accepts Coder-issued tokens for client authentication and also -supports [Bring Your Own Key -(BYOK)](../auth.md#bring-your-own-key-byok) for other clients. -Coder Agents only uses the centralized key mode today. The upstream -provider keys you configured for AI Gateway (for example, -`CODER_AI_GATEWAY_OPENAI_KEY`) are used by AI Gateway internally to call the -upstream provider; they are not what Coder Agents sends. - -Coder Agents stores the **API Key** field on each provider as the bearer -credential it forwards to AI Gateway on every request from any chat that -uses that provider. AI Gateway resolves the bearer token to a Coder user -and uses **that user** as the initiator on every interception. - -Because the Agents provider config is deployment-wide, every chat that -uses this provider is logged in AI Gateway under the identity of whoever -owns the API token configured here. Per-chat attribution to the developer -who started a chat is **not** preserved when routing Agents traffic -through AI Gateway today. See -[Known limitations](#known-limitations) below. - -For that reason, **use a long-lived API token for a dedicated -[service account](../../../admin/users/headless-auth.md#create-a-service-account)** -that is intended to represent Agents traffic in audit. Avoid using an -admin's personal token: every chat would otherwise appear to have been -initiated by that admin. - -> [!NOTE] -> Coder Agents does not support Bring Your Own Key when routing through -> AI Gateway today, but we plan to unify these authentication modes in a -> future release. For now, the Agents [User API -> keys](../../agents/models.md#user-api-keys-byok) feature is independent -> of AI Gateway and applies to direct provider calls only. - -## Identity and correlation headers - -When Coder Agents calls a provider, it attaches identity headers to every -outgoing request. Today AI Gateway uses two of them: - -| Header | Used by AI Gateway today | -|-------------------|--------------------------------------------------------------------------------------------------------------------------| -| `User-Agent` | Detects Coder Agents traffic and labels sessions with the `Coder Agents` client name. | -| `X-Coder-Chat-Id` | Acts as the AI Gateway session key, so every interception in a chat (and its sub-agents) appears under a single session. | - -Coder Agents also sends `X-Coder-Owner-Id`, `X-Coder-Subchat-Id`, and -`X-Coder-Workspace-Id`. These are emitted for forward compatibility but -are not consumed by AI Gateway today, which is why per-developer -attribution is not preserved. See -[Known limitations](#known-limitations) for details. - -You don't need to configure these headers; they are set automatically. - -## Pre-configuring in templates - -You don't need to configure anything inside workspaces for Coder Agents -itself to use AI Gateway. The agent loop runs in the control plane, so -the Agents provider's Base URL is the only place AI Gateway needs to be -wired up. - -If you also want IDE-based clients running inside Agents-provisioned -workspaces (such as Claude Code or Codex CLI) to route through AI -Gateway, configure them on the workspace template. See the -[Configuring In-Workspace Tools](./index.md#configuring-in-workspace-tools) -section for the general pattern, plus the per-client pages such as -[Claude Code](./claude-code.md#pre-configuring-in-templates). - -## Verifying the integration - -After saving the provider, start a new chat from the Agents page and send -a short prompt. Then: - -1. Open the AI Gateway sessions UI at - `https://coder.example.com/aibridge/sessions`. -1. The most recent session should show **Coder Agents** as the client and - the user that owns the API token configured on the Agents provider as - the initiator. -1. Click into the session to see the chat's interceptions, token usage, - and any tool invocations. - -If the session does not appear, check that the Agents provider's Base URL -points at your deployment's `/api/v2/aibridge/...` path and that the API -key is a valid Coder token. - -## Troubleshooting - -- **`401 Unauthorized` from the chat.** The API key on the Agents provider - is not a valid Coder token, has been revoked, or belongs to a user that - cannot reach AI Gateway. Generate a new long-lived token and update the - provider. -- **Sessions in audit show a generic client instead of Coder Agents.** - This usually means the request bypassed AI Gateway. Confirm the - provider's Base URL starts with your deployment's `/api/v2/aibridge/` - path and not the upstream provider URL. -- **Provider does not appear in the Agents model selector.** Add at least - 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. - -## Known limitations - -- **Per-developer attribution is not preserved.** AI Gateway attributes - every interception to the user that owns the bearer token configured - on the Agents provider, regardless of which developer started the - chat. The chat owner ID is sent by Coder Agents in `X-Coder-Owner-Id` - but is not consumed by AI Gateway today. Use a dedicated service - account for the Agents provider's API token so audit data is - attributed to a single, non-human identity. -- **Bring Your Own Key (BYOK) is not supported through AI Gateway.** - Personal LLM credentials configured under - [User API keys](../../agents/models.md#user-api-keys-byok) are sent - directly to the provider; AI Gateway is not involved when BYOK is - active. - -## Related documentation - -- [Coder Agents: Models and providers](../../agents/models.md) for the - full reference on configuring providers in Agents. -- [Coder Agents: Using an LLM proxy](../../agents/models.md#using-an-llm-proxy) - for the short version of this same configuration. -- [AI Gateway setup](../setup.md) for enabling AI Gateway and - configuring upstream provider credentials. -- [Auditing AI sessions](../audit.md) for how AI Gateway groups Coder - Agents traffic into sessions. diff --git a/docs/ai-coder/ai-gateway/clients/index.md b/docs/ai-coder/ai-gateway/clients/index.md index 3c5f1e2018c42..a07752f90a61a 100644 --- a/docs/ai-coder/ai-gateway/clients/index.md +++ b/docs/ai-coder/ai-gateway/clients/index.md @@ -35,26 +35,25 @@ For information about authenticating with AI Gateway, visit [AI Gateway Authenti The table below shows tested AI clients and their compatibility with AI Gateway. -| Client | OpenAI | Anthropic | BYOK | Notes | -|-----------------------------------|--------|-----------|------|--------------------------------------------------------------------------------------------------------------------------------------------------------| -| [Coder Agents](./coder-agents.md) | ✅ | ✅ | ❌ | First-class AI Gateway client. Uses the Coder Agents [provider config](../../agents/models.md#providers). | -| [Mux](./mux.md) | ✅ | ✅ | - | | -| [Claude Code](./claude-code.md) | - | ✅ | ✅ | | -| [Codex CLI](./codex.md) | ✅ | - | ✅ | | -| [OpenCode](./opencode.md) | ✅ | ✅ | ✅ | | -| [Factory](./factory.md) | ✅ | ✅ | ✅ | | -| [Cline](./cline.md) | ✅ | ✅ | ✅ | | -| [Kilo Code](./kilo-code.md) | ✅ | ✅ | ❌ | | -| [VS Code](./vscode.md) | ✅ | ❌ | ❌ | Only supports Custom Base URL for OpenAI. | -| [JetBrains IDEs](./jetbrains.md) | ✅ | ❌ | ❌ | Works in Chat mode via [third-party model configuration](https://www.jetbrains.com/help/ai-assistant/use-custom-models.html#provide-your-own-api-key). | -| [Zed](./zed.md) | ✅ | ✅ | ❌ | | -| [GitHub Copilot](./copilot.md) | ⚙️ | - | - | Requires [AI Gateway Proxy](../ai-gateway-proxy/index.md). Uses per-user GitHub tokens. | -| WindSurf | ❌ | ❌ | ❌ | No option to override base URL. | -| Cursor | ❌ | ❌ | ❌ | Override for OpenAI broken ([upstream issue](https://forum.cursor.com/t/requests-are-sent-to-incorrect-endpoint-when-using-base-url-override/144894)). | -| Sourcegraph Amp | ❌ | ❌ | ❌ | No option to override base URL. | -| Kiro | ❌ | ❌ | ❌ | No option to override base URL. | -| Gemini CLI | ❌ | ❌ | ❌ | No Gemini API support. Upvote [this issue](https://github.com/coder/coder/issues/24804). | -| Antigravity | ❌ | ❌ | ❌ | No option to override base URL. | +| Client | OpenAI | Anthropic | BYOK | Notes | +|----------------------------------|--------|-----------|------|--------------------------------------------------------------------------------------------------------------------------------------------------------| +| [Mux](./mux.md) | ✅ | ✅ | - | | +| [Claude Code](./claude-code.md) | - | ✅ | ✅ | | +| [Codex CLI](./codex.md) | ✅ | - | ✅ | | +| [OpenCode](./opencode.md) | ✅ | ✅ | ✅ | | +| [Factory](./factory.md) | ✅ | ✅ | ✅ | | +| [Cline](./cline.md) | ✅ | ✅ | ✅ | | +| [Kilo Code](./kilo-code.md) | ✅ | ✅ | ❌ | | +| [VS Code](./vscode.md) | ✅ | ✅ | ❌ | VS Code 1.122+ via Custom Endpoint provider. GitHub sign-in not required. Inline suggestions still require GitHub Copilot. | +| [JetBrains IDEs](./jetbrains.md) | ✅ | ❌ | ❌ | Works in Chat mode via [third-party model configuration](https://www.jetbrains.com/help/ai-assistant/use-custom-models.html#provide-your-own-api-key). | +| [Zed](./zed.md) | ✅ | ✅ | ❌ | | +| [GitHub Copilot](./copilot.md) | ⚙️ | - | - | Requires [AI Gateway Proxy](../ai-gateway-proxy/index.md). Uses per-user GitHub tokens. | +| WindSurf | ❌ | ❌ | ❌ | No option to override base URL. | +| Cursor | ❌ | ❌ | ❌ | Override for OpenAI broken ([upstream issue](https://forum.cursor.com/t/requests-are-sent-to-incorrect-endpoint-when-using-base-url-override/144894)). | +| Sourcegraph Amp | ❌ | ❌ | ❌ | No option to override base URL. | +| Kiro | ❌ | ❌ | ❌ | No option to override base URL. | +| Gemini CLI | ❌ | ❌ | ❌ | No Gemini API support. Upvote [this issue](https://github.com/coder/coder/issues/24804). | +| Antigravity | ❌ | ❌ | ❌ | No option to override base URL. | | *Legend: ✅ supported, ⚙️ requires AI Gateway Proxy, ❌ not supported, - not applicable.* diff --git a/docs/ai-coder/ai-gateway/clients/vscode.md b/docs/ai-coder/ai-gateway/clients/vscode.md index d27a61459bbb3..def380b74f9d7 100644 --- a/docs/ai-coder/ai-gateway/clients/vscode.md +++ b/docs/ai-coder/ai-gateway/clients/vscode.md @@ -1,60 +1,77 @@ -# VS Code > [!NOTE] > AI Gateway requires the [AI Governance Add-On](../../ai-governance.md). > As of Coder v2.32, deployments without the add-on will not be able to > access AI Gateway. -VS Code's native chat can be configured to use AI Gateway with the GitHub Copilot Chat extension's custom language model support. +VS Code's native chat can be configured to use AI Gateway via the **Custom Endpoint** language model provider (VS Code 1.122+, Stable). GitHub sign-in is not required, so this works in air-gapped or restricted environments. -## Centralized API Key +## Setup + +Requires VS Code 1.122+ and the [GitHub Copilot Chat extension](https://marketplace.visualstudio.com/items?itemName=GitHub.copilot-chat). + +For each provider below, the setup steps are: + +1. Open the Command Palette (`Ctrl+Shift+P` / `Cmd+Shift+P` on Mac) and run **Chat: Manage Language Models**. +1. Select **Add** → **Custom Endpoint**. +1. Enter a **group name**, **display name**, your **[Coder API token](../../../admin/users/sessions-tokens.md#generate-a-long-lived-api-token-on-behalf-of-yourself)** as the API key, and the **API type** shown below. +1. To add or edit models, select the gear icon next to the provider in the Language Models view to open `chatLanguageModels.json`. > [!IMPORTANT] -> You need the **Pre-release** version of the [GitHub Copilot Chat extension](https://marketplace.visualstudio.com/items?itemName=GitHub.copilot-chat) and [VS Code Insiders](https://code.visualstudio.com/insiders/). +> Enter your API token through the UI. VS Code stores it securely and inserts a reference like `${input:chat.lm.secret.XXXXX}` into the JSON. Do not paste your token directly into the JSON file. + +_Replace `coder.example.com` with your Coder deployment URL. Model IDs must match what is configured in your AI Gateway._ + +### OpenAI-compatible models -1. Open command palette (`Ctrl+Shift+P` or `Cmd+Shift+P` on Mac) and search for _Chat: Open Language Models (JSON)_. -1. Paste the following JSON configuration, replacing `<your-coder-api-token>` with your **[Coder API token](../../../admin/users/sessions-tokens.md#generate-a-long-lived-api-token-on-behalf-of-yourself)**: +Set **API type** to `responses`. ```json -[ - { - "name": "Coder", - "vendor": "customoai", - "apiKey": "<your-coder-api-token>", - "models": [ - { - "name": "GPT 5.2", - "url": "https://coder.example.com/api/v2/aibridge/openai/v1/chat/completions", - "toolCalling": true, - "vision": true, - "thinking": true, - "maxInputTokens": 272000, - "maxOutputTokens": 128000, - "id": "gpt-5.2" - }, - { - "name": "GPT 5.2 Codex", - "url": "https://coder.example.com/api/v2/aibridge/openai/v1/responses", - "toolCalling": true, - "vision": true, - "thinking": true, - "maxInputTokens": 272000, - "maxOutputTokens": 128000, - "id": "gpt-5.2-codex" - } - ] - } -] +{ + "name": "Coder (OpenAI)", + "vendor": "customendpoint", + "apiKey": "${input:chat.lm.secret.XXXXX}", + "apiType": "responses", + "models": [ + { + "id": "gpt-5.5", + "name": "GPT 5.5", + "url": "https://coder.example.com/api/v2/aibridge/openai", + "toolCalling": true, + "vision": true, + "thinking": true, + "streaming": true, + "maxInputTokens": 272000, + "maxOutputTokens": 128000 + } + ] +} ``` -_Replace `coder.example.com` with your Coder deployment URL._ +### Anthropic models -> [!NOTE] -> The setting names may change as the feature moves from pre-release to stable. Refer to the official documentation for the latest setting keys. - -## BYOK (Personal API Key) +Set **API type** to `messages`. -> [!NOTE] -> At the time of writing, GitHub Copilot Chat does not support sending custom headers, so BYOK mode is not available. +```json +{ + "name": "Coder (Anthropic)", + "vendor": "customendpoint", + "apiKey": "${input:chat.lm.secret.XXXXX}", + "apiType": "messages", + "models": [ + { + "id": "claude-sonnet-4.6", + "name": "Claude Sonnet 4.6", + "url": "https://coder.example.com/api/v2/aibridge/anthropic", + "toolCalling": true, + "vision": true, + "thinking": true, + "streaming": true, + "maxInputTokens": 1000000, + "maxOutputTokens": 64000 + } + ] +} +``` -**References:** [GitHub Copilot - Bring your own language model](https://code.visualstudio.com/docs/copilot/customization/language-models#_add-an-openaicompatible-model) +**References:** [VS Code - Bring your own language model](https://code.visualstudio.com/docs/copilot/customization/language-models) diff --git a/docs/ai-coder/ai-gateway/mcp.md b/docs/ai-coder/ai-gateway/mcp.md index 494f43832b5df..b62ad603a6661 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. diff --git a/docs/ai-coder/ai-gateway/monitoring.md b/docs/ai-coder/ai-gateway/monitoring.md index 703938a8914f4..7b9e68090561b 100644 --- a/docs/ai-coder/ai-gateway/monitoring.md +++ b/docs/ai-coder/ai-gateway/monitoring.md @@ -15,6 +15,46 @@ We provide an example Grafana dashboard that you can import as a starting point These logs and metrics can be used to determine usage patterns, track costs, and evaluate tooling adoption. +## Provider metrics + +`aibridged` (the in-process daemon) and `aibridgeproxyd` (the external +proxy) each export Prometheus metrics describing the configured +provider pool and its reload loop. See +[Provider Configuration](./providers.md) for the lifecycle these +metrics describe. + +| Metric | Type | Labels | Purpose | +|------------------------------------------------------------------------|---------|--------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------| +| `coder_aibridged_provider_info` | gauge | `provider_name`, `provider_type`, `status` | One series per configured provider. Value is always `1`; the `status` label (`enabled`, `disabled`, `error`) carries the alertable signal. | +| `coder_aibridged_providers_last_reload_timestamp_seconds` | gauge | | Unix timestamp of the last reload attempt, success or failure. | +| `coder_aibridged_providers_last_reload_success_timestamp_seconds` | gauge | | Unix timestamp of the last reload that successfully refreshed the pool. | +| `coder_aibridgeproxyd_provider_info` | gauge | `provider_name`, `provider_type`, `status` | Same shape as `aibridged_provider_info` but reported by the external proxy. | +| `coder_aibridgeproxyd_providers_last_reload_timestamp_seconds` | gauge | | Last reload attempt timestamp in `aibridgeproxyd`. | +| `coder_aibridgeproxyd_providers_last_reload_success_timestamp_seconds` | gauge | | Last successful reload timestamp in `aibridgeproxyd`. | +| `coder_aibridgeproxyd_connect_sessions_total` | counter | `type` (`mitm`, `tunneled`) | CONNECT sessions established by the proxy. | +| `coder_aibridgeproxyd_mitm_requests_total` | counter | `provider` | MITM requests handled. | +| `coder_aibridgeproxyd_inflight_mitm_requests` | gauge | `provider` | In-flight MITM requests. | +| `coder_aibridgeproxyd_mitm_responses_total` | counter | `code`, `provider` | MITM responses by HTTP status code. | + +### Suggested alerts + +Alert on any provider entering a non-`enabled` status: + +```promql +sum by (provider_name, status) (coder_aibridged_provider_info{status!="enabled"}) > 0 +``` + +Alert when the reload loop is firing but failing to refresh the pool +for longer than a few minutes: + +```promql +(coder_aibridged_providers_last_reload_timestamp_seconds + - coder_aibridged_providers_last_reload_success_timestamp_seconds) > 300 +``` + +Repeat the same query against `coder_aibridgeproxyd_*` if you run the +external proxy. + ## Structured Logging AI Bridge can emit structured logs for every interception event to your diff --git a/docs/ai-coder/ai-gateway/providers.md b/docs/ai-coder/ai-gateway/providers.md new file mode 100644 index 0000000000000..ca1ff1db5edf3 --- /dev/null +++ b/docs/ai-coder/ai-gateway/providers.md @@ -0,0 +1,214 @@ +# Provider Configuration + +> [!NOTE] +> AI Gateway requires the [AI Governance Add-On](../ai-governance.md). + +Providers are deployment-scoped and managed from the dashboard or the +[AI Providers API](../../reference/api/aiproviders.md). See +[Setup](./setup.md#configure-providers) for the steps to add, edit, and +disable a provider. + +This page covers the provider types AI Gateway supports, the setup +considerations for each, how a provider's lifecycle affects request +handling, and how to monitor providers. + +## Database management of providers + +> [!NOTE] +> Since v2.34, provider environment variables and flags, including +> `CODER_AI_GATEWAY_PROVIDER_<N>_*`, `CODER_AI_GATEWAY_OPENAI_*`, +> `CODER_AI_GATEWAY_ANTHROPIC_*`, and their `--aibridge/ai-gateway-*` +> equivalents, are deprecated. Provider configuration is now stored in +> the database, and any environment variables set on startup are used to +> seed it. +> +> This is a once-off operation. The environment variables have no effect +> once seeding has completed. +> +> **Any changes to the provider environment variables after seeding will +> cause the server to fail to start, to prevent operators from updating a +> configuration that is ineffectual.** +> +> The environment variables can be safely removed once seeding has +> completed. Visit `https://<your-coder-host>/ai/settings` to see which +> providers have been seeded. + +After seeding, manage providers through the dashboard or API. A provider +that has been edited or removed there is not recreated or overwritten +from the environment on the next restart. + +## Provider types + +AI Gateway speaks two upstream API formats: the **OpenAI** format +(Chat Completions and Responses) and the **Anthropic** format +(Messages). Every provider type maps to one of these. + +| Type | API format | Setup notes | +|-----------------|------------|-------------------------------------------------------------------| +| `openai` | OpenAI | Native OpenAI, or any OpenAI-compatible endpoint via the base URL | +| `anthropic` | Anthropic | Native Anthropic, or an Anthropic-compatible broker | +| `bedrock` | Anthropic | Anthropic models hosted on AWS Bedrock; authenticates via AWS | +| `copilot` | OpenAI | GitHub Copilot; authenticates via each user's GitHub OAuth token | +| `azure` | OpenAI | OpenAI-compatible endpoint only | +| `google` | OpenAI | OpenAI-compatible endpoint only | +| `openrouter` | OpenAI | OpenAI-compatible endpoint only | +| `vercel` | OpenAI | OpenAI-compatible endpoint only | +| `openai-compat` | OpenAI | Generic OpenAI-compatible endpoint | + +`azure`, `google`, `openrouter`, `vercel`, and `openai-compat` are +supported only as OpenAI-compatible endpoints: AI Gateway sends them +OpenAI-format requests, so each must expose an OpenAI-compatible API at +its base URL. They have no provider-specific integration beyond that. + +### OpenAI + +Set the base URL to the upstream endpoint and provide an API key. The +default `https://api.openai.com/v1/` targets the native OpenAI service; +point it at any OpenAI-compatible endpoint (for example, a hosted proxy +or LiteLLM deployment) when needed. + +If you create an [OpenAI key](https://platform.openai.com/api-keys) +with minimal privileges, this is the minimum required set: + +![List Models scope should be set to "Read", Model Capabilities set to "Request"](../../images/aibridge/openai_key_scope.png) + +### Anthropic + +Set the base URL and provide an API key. The default +`https://api.anthropic.com/` targets Anthropic's public API; override it +for Anthropic-compatible brokers. + +Anthropic does not allow [API keys](https://console.anthropic.com/settings/keys) +to have restricted permissions at the time of writing (June 2026). + +### Amazon Bedrock + +Bedrock providers serve Anthropic models hosted on AWS and authenticate +with AWS credentials rather than a registered API key. Configure: + +- A **region** (or a full base URL when routing through a proxy or a + non-standard endpoint that does not follow the + `https://bedrock-runtime.<region>.amazonaws.com` format). +- The **model** and **small fast model** identifiers. + +Do not attach API keys to a Bedrock provider. + +AI Gateway resolves AWS credentials one of two ways: + +- **AWS SDK default credential chain (recommended).** When no explicit + credentials are configured, the AWS SDK resolves them automatically + from the environment: IAM Roles (instance profiles, IRSA, ECS task + roles), shared config files, environment variables, SSO, and more. + Attaching an IAM Role to the compute running Coder follows + [AWS best practices](https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html) + for temporary credentials. The role must permit `bedrock:InvokeModel` + and `bedrock:InvokeModelWithResponseStream` for the configured models. +- **Static credentials.** Provide an access key and secret for an IAM + user with the same Bedrock permissions. + +### GitHub Copilot + +GitHub Copilot offers three plans: Individual, Business, and Enterprise, +each with its own API endpoint. Add one `copilot` provider per plan your +organization uses, setting the base URL accordingly: + +| Plan | Base URL | +|------------|--------------------------------------------| +| Individual | `https://api.individual.githubcopilot.com` | +| Business | `https://api.business.githubcopilot.com` | +| Enterprise | `https://api.enterprise.githubcopilot.com` | + +Copilot providers authenticate with each user's request-time GitHub +OAuth token, so do not attach API keys. For client-side setup (proxy, +certificates, IDE configuration), see +[GitHub Copilot client configuration](./clients/copilot.md). + +### OpenAI-compatible providers + +Azure-hosted OpenAI, Google, OpenRouter, Vercel, and any other +OpenAI-compatible service are configured with the matching type (or the +generic `openai-compat`), the provider's OpenAI-compatible base URL, and +an API key. + +> [!NOTE] +> See the [Supported APIs](./reference.md#supported-apis) section for +> precise endpoint coverage and interception behavior. + +## Provider lifecycle + +Every provider carries an explicit status, surfaced through the +[`provider_info`](./monitoring.md#provider-metrics) metric and the API: + +| Status | Meaning | Effect on requests | +|------------|-------------------------------------------------------------------------------|--------------------------------------------------| +| `enabled` | Configuration is valid and the provider is serving traffic | Requests are proxied to the upstream | +| `disabled` | The provider exists but has been turned off | Requests are rejected with a non-retryable error | +| `error` | The provider is enabled but cannot be built (missing credentials, bad config) | Requests fail; the error is surfaced in metrics | + +Disabling a provider does not delete it, its credentials, or its +historical interception data. Re-enabling restores it to service. + +## Monitoring and reloads + +Provider configuration changes take effect automatically, without +restarting `coderd`. AI Gateway records the timestamp of each reload +attempt and each successful reload, exposed as Prometheus metrics: + +- `coder_aibridged_providers_last_reload_timestamp_seconds` +- `coder_aibridged_providers_last_reload_success_timestamp_seconds` + +If you run the [external proxy](./ai-gateway-proxy/index.md), it exposes +the same pair under the `coder_aibridgeproxyd_` prefix. + +A growing gap between the attempt and success timestamps means reloads +are firing but failing to apply. Alert on that gap rather than on a +single failure, which may resolve on the next change. See +[Monitoring](./monitoring.md#provider-metrics) for the full metric list +and sample alert queries. + +## Key failover + +You can configure multiple centralized API keys for a single provider instance +so that AI Gateway automatically retries with the next key when one fails. This +is transparent to end users, and clients see no difference in behavior or need +any configuration changes. + +Key failover is supported for **OpenAI** and **Anthropic** providers. Amazon +Bedrock and GitHub Copilot do not support key failover. + +Multiple keys can be added per provider through the +[AI Providers API](../../reference/api/aiproviders.md). Each provider supports +a maximum of **5 keys**. + +### Failover behavior + +Every request starts with the first key in the list. If a key is rate-limited +(`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 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 + +A provider's configured credentials are the centralized default. When +Bring Your Own Key (BYOK) is enabled, a user's own credential takes +precedence over the provider's for that user's requests, and AI Gateway +falls back to the provider credentials when the user has none. See +[Authentication](./auth.md#bring-your-own-key-byok) for the BYOK flow +and how to enable or disable it. + +## Failure modes + +| Symptom | Likely cause | Corrective action | +|------------------------------------------------|------------------------------------------------------------|------------------------------------------| +| Startup fails referencing an existing provider | Env config drifted from a provider already in the database | Remove the provider env vars and restart | +| Provider returns errors with no upstream call | The provider is `disabled` or in `error` status | Consult the server logs for details | +| Configuration changes not taking effect | Reloads are firing but failing to apply | Consult the server logs for details | diff --git a/docs/ai-coder/ai-gateway/setup.md b/docs/ai-coder/ai-gateway/setup.md index 27aed7c0506dc..d0050ad965e64 100644 --- a/docs/ai-coder/ai-gateway/setup.md +++ b/docs/ai-coder/ai-gateway/setup.md @@ -2,20 +2,17 @@ AI Gateway runs inside the Coder control plane (`coderd`), requiring no separate compute to deploy or scale. Once enabled, `coderd` runs the `aibridged` in-memory and brokers traffic to your configured AI providers on behalf of authenticated users. -**Required**: - -1. The [AI Governance Add-On](../ai-governance.md) license. -1. Feature must be [enabled](#activation) using the server flag -1. One or more [providers](#configure-providers) API key(s) must be configured - > [!NOTE] -> AI Gateway environment variables and CLI flags have migrated to the new -> `CODER_AI_GATEWAY_*` and `--ai-gateway-*` naming scheme. The earlier -> `CODER_AIBRIDGE_*` and `--aibridge-*` names continue to work as aliases. +> Since v2.34, provider environment variables and flags are deprecated. +> Provider configuration is now stored in the database, and any +> environment variables set on startup are used to seed it once. See +> [Database management of providers](./providers.md#database-management-of-providers) +> for details. ## Activation -You will need to enable AI Gateway explicitly: +AI Gateway must be enabled in deployment config before users can authenticate +to it. ```sh export CODER_AI_GATEWAY_ENABLED=true @@ -24,224 +21,41 @@ coder server coder server --ai-gateway-enabled=true ``` -## Configure Providers - -AI Gateway proxies requests to upstream LLM APIs. Configure at least one provider before exposing AI Gateway to end users. - -<div class="tabs"> - -### OpenAI - -Set the following when routing [OpenAI-compatible](https://coder.com/docs/reference/cli/server#--ai-gateway-openai-key) traffic through AI Gateway: - -- `CODER_AI_GATEWAY_OPENAI_KEY` or `--ai-gateway-openai-key` -- `CODER_AI_GATEWAY_OPENAI_BASE_URL` or `--ai-gateway-openai-base-url` - -The default base URL (`https://api.openai.com/v1/`) works for the native OpenAI service. Point the base URL at your preferred OpenAI-compatible endpoint (for example, a hosted proxy or LiteLLM deployment) when needed. - -If you'd like to create an [OpenAI key](https://platform.openai.com/api-keys) with minimal privileges, this is the minimum required set: - -![List Models scope should be set to "Read", Model Capabilities set to "Request"](../../images/aibridge/openai_key_scope.png) - -### Anthropic - -Set the following when routing [Anthropic-compatible](https://coder.com/docs/reference/cli/server#--ai-gateway-anthropic-key) traffic through AI Gateway: - -- `CODER_AI_GATEWAY_ANTHROPIC_KEY` or `--ai-gateway-anthropic-key` -- `CODER_AI_GATEWAY_ANTHROPIC_BASE_URL` or `--ai-gateway-anthropic-base-url` - -The default base URL (`https://api.anthropic.com/`) targets Anthropic's public API. Override it for Anthropic-compatible brokers. - -Anthropic does not allow [API keys](https://console.anthropic.com/settings/keys) to have restricted permissions at the time of writing (Nov 2025). - -### Amazon Bedrock - -Set the following when routing [Amazon Bedrock](https://coder.com/docs/reference/cli/server#--ai-gateway-bedrock-region) traffic through AI Gateway: - -**Required:** - -- `CODER_AI_GATEWAY_BEDROCK_REGION` or `--ai-gateway-bedrock-region`. -Alternatively, set `CODER_AI_GATEWAY_BEDROCK_BASE_URL` or `--ai-gateway-bedrock-base-url` to a full URL (e.g., when routing through a proxy between AI Gateway and AWS Bedrock or using a non-standard endpoint that doesn't follow the `https://bedrock-runtime.<region>.amazonaws.com` format). -If both are set, `CODER_AI_GATEWAY_BEDROCK_BASE_URL` takes precedence. -- `CODER_AI_GATEWAY_BEDROCK_MODEL` or `--ai-gateway-bedrock-model` -- `CODER_AI_GATEWAY_BEDROCK_SMALL_FAST_MODEL` or `--ai-gateway-bedrock-small-fastmodel` - -> [!NOTE] -> These Bedrock settings configure AI Gateway only. To configure Bedrock as an -> Agents provider, see [Configuring AWS Bedrock](../agents/models.md#configuring-aws-bedrock). - -**Optional:** - -- `CODER_AI_GATEWAY_BEDROCK_ACCESS_KEY` or `--ai-gateway-bedrock-access-key` -- `CODER_AI_GATEWAY_BEDROCK_ACCESS_KEY_SECRET` or `--ai-gateway-bedrock-access-key-secret` - -#### Authentication - -AI Gateway supports two credential configuration paths: - -##### AWS SDK default credential chain (recommended) - -When no credentials are set in AI Gateway config, the AWS SDK resolves them automatically from the environment. -This includes IAM Roles (instance profiles, IRSA, ECS task roles), shared config files, environment variables, SSO, and more. +_AI Gateway is enabled by default as of v2.34._ -**IAM Roles are the recommended approach** when AI Gateway runs on AWS infrastructure. -Attach an IAM Role with Bedrock permissions to the compute running AI Gateway (EC2 instance, EKS pod via IRSA, or ECS task), no credentials need to be configured in AI Gateway itself. - -The IAM Role must have permission to invoke the Bedrock models configured for AI Gateway (`bedrock:InvokeModel` and `bedrock:InvokeModelWithResponseStream`). -See [Amazon Bedrock identity-based policy examples](https://docs.aws.amazon.com/bedrock/latest/userguide/security_iam_id-based-policy-examples.html) for policy examples, -and [AWS IAM role creation](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_create_for-service.html) for general guidance on attaching roles to AWS services. - -This aligns with [AWS best practices](https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html) for using temporary credentials instead of long-lived access keys. - -##### Static credentials - -For deployments when explicit credentials are preferred, provide an access key and secret for an IAM User: - -1. **Choose a region** where you want to use Bedrock. - -2. **Generate API keys** in the [AWS Bedrock console](https://us-east-1.console.aws.amazon.com/bedrock/home?region=us-east-1#/api-keys/long-term/create) (replace `us-east-1` in the URL with your chosen region): - - Choose an expiry period for the key. - - Click **Generate**. - - This creates an IAM user with strictly-scoped permissions for Bedrock access. - -3. **Create an access key** for the IAM user: - - After generating the API key, click **"You can directly modify permissions for the IAM user associated"**. - - In the IAM user page, navigate to the **Security credentials** tab. - - Under **Access keys**, click **Create access key**. - - Select **"Application running outside AWS"** as the use case. - - Click **Next**. - - Add a description like "Coder AI Gateway token". - - Click **Create access key**. - - Save both the access key ID and secret access key securely. - -4. **Configure your Coder deployment** with the credentials: - - ```sh - export CODER_AI_GATEWAY_BEDROCK_REGION=us-east-1 - export CODER_AI_GATEWAY_BEDROCK_ACCESS_KEY=<your-access-key-id> - export CODER_AI_GATEWAY_BEDROCK_ACCESS_KEY_SECRET=<your-secret-access-key> - coder server - ``` - -### GitHub Copilot - -GitHub Copilot offers three plans: Individual, Business, and Enterprise, -each with its own API endpoint. Configure one or more `copilot` providers -using the [indexed provider format](#multiple-instances-of-the-same-provider) -depending on which plans your organization uses. -Copilot providers use OAuth app installations for authentication rather than -static API keys. - -```sh -# GitHub Copilot (Individual) -export CODER_AI_GATEWAY_PROVIDER_0_TYPE=copilot -export CODER_AI_GATEWAY_PROVIDER_0_NAME=copilot - -# GitHub Copilot Business -export CODER_AI_GATEWAY_PROVIDER_1_TYPE=copilot -export CODER_AI_GATEWAY_PROVIDER_1_NAME=copilot-business -export CODER_AI_GATEWAY_PROVIDER_1_BASE_URL=https://api.business.githubcopilot.com - -# GitHub Copilot Enterprise -export CODER_AI_GATEWAY_PROVIDER_2_TYPE=copilot -export CODER_AI_GATEWAY_PROVIDER_2_NAME=copilot-enterprise -export CODER_AI_GATEWAY_PROVIDER_2_BASE_URL=https://api.enterprise.githubcopilot.com -``` - -The default base URL targets the individual Copilot API -(`api.individual.githubcopilot.com`). Override `CODER_AI_GATEWAY_PROVIDER_<N>_BASE_URL` -for Business or Enterprise tiers as shown above. +## Configure Providers -For client-side setup (proxy, certificates, IDE configuration), see -[GitHub Copilot client configuration](./clients/copilot.md). +Configure at least one provider before exposing AI Gateway to end users. -### ChatGPT +Providers are deployment-scoped. Add them from the dashboard or the +[AI Providers API](../../reference/api/aiproviders.md). Changes take effect +without restarting `coderd`. -Configure a ChatGPT provider by creating an `openai`-typed instance with the -ChatGPT Codex base URL: +### Dashboard -```sh -export CODER_AI_GATEWAY_PROVIDER_0_TYPE=openai -export CODER_AI_GATEWAY_PROVIDER_0_NAME=chatgpt -export CODER_AI_GATEWAY_PROVIDER_0_BASE_URL=https://chatgpt.com/backend-api/codex -``` +1. Navigate to **Admin settings** > **AI** +1. Select **Providers** +1. Click **Add provider** +1. Select the provider type +1. Enter a unique lowercase name, the upstream endpoint, and the credentials +1. Save the provider -</div> +Each provider gets its own AI Gateway route at +`/api/v2/aibridge/<provider-name>/`. > [!NOTE] -> See the [Supported APIs](./reference.md#supported-apis) section below for precise endpoint coverage and interception behavior. - -### Multiple instances of the same provider - -You can configure multiple instances of the same provider type, for example, to -route different teams to separate API keys, use different base URLs per region, or -connect to both a direct API and a proxy simultaneously. Use indexed environment -variables following the pattern `CODER_AI_GATEWAY_PROVIDER_<N>_<KEY>`: - -```sh -# Anthropic routed through a corporate proxy -export CODER_AI_GATEWAY_PROVIDER_0_TYPE=anthropic -export CODER_AI_GATEWAY_PROVIDER_0_NAME=anthropic-corp -export CODER_AI_GATEWAY_PROVIDER_0_KEY=sk-ant-corp-xxx -export CODER_AI_GATEWAY_PROVIDER_0_BASE_URL=https://llm-proxy.internal.example.com/anthropic - -# Anthropic direct (for teams that need direct access) -export CODER_AI_GATEWAY_PROVIDER_1_TYPE=anthropic -export CODER_AI_GATEWAY_PROVIDER_1_NAME=anthropic-direct -export CODER_AI_GATEWAY_PROVIDER_1_KEY=sk-ant-direct-yyy - -# Azure-hosted OpenAI deployment -export CODER_AI_GATEWAY_PROVIDER_2_TYPE=openai -export CODER_AI_GATEWAY_PROVIDER_2_NAME=azure-openai -export CODER_AI_GATEWAY_PROVIDER_2_KEY=azure-key-zzz -export CODER_AI_GATEWAY_PROVIDER_2_BASE_URL=https://my-deployment.openai.azure.com/ - -# Anthropic via AWS Bedrock -export CODER_AI_GATEWAY_PROVIDER_3_TYPE=anthropic -export CODER_AI_GATEWAY_PROVIDER_3_NAME=anthropic-bedrock -export CODER_AI_GATEWAY_PROVIDER_3_BEDROCK_REGION=us-west-2 -export CODER_AI_GATEWAY_PROVIDER_3_BEDROCK_ACCESS_KEY=AKIAIOSFODNN7EXAMPLE -export CODER_AI_GATEWAY_PROVIDER_3_BEDROCK_ACCESS_KEY_SECRET=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY - -coder server -``` +> Provider names must be unique and use lowercase, hyphen-separated identifiers +> such as `anthropic-corp` or `azure-openai`. Once deleted, another provider +> may reuse the name. -Each provider instance gets a unique route based on its `NAME`. Clients send -requests to `/api/v2/aibridge/<NAME>/` to target a specific instance: +![AI Providers list page](../../images/aibridge/providers-list.png) -| Instance name | Route | -|---------------------|-----------------------------------------------------| -| `anthropic-corp` | `/api/v2/aibridge/anthropic-corp/v1/messages` | -| `anthropic-direct` | `/api/v2/aibridge/anthropic-direct/v1/messages` | -| `azure-openai` | `/api/v2/aibridge/azure-openai/v1/chat/completions` | -| `anthropic-bedrock` | `/api/v2/aibridge/anthropic-bedrock/v1/messages` | +![Add Anthropic provider form](../../images/aibridge/provider-add-anthropic.png) -**Supported keys per provider:** +Open an existing provider to rotate credentials, update its endpoint, or +disable it without restarting `coderd`. -| Key | Required | Description | -|------------|----------|------------------------------------------------------| -| `TYPE` | Yes | Provider type: `openai`, `anthropic`, or `copilot` | -| `NAME` | No | Unique instance name for routing. Defaults to `TYPE` | -| `KEY` | No | API key for upstream authentication (alias: `KEYS`) | -| `BASE_URL` | No | Base URL of the upstream API | - -For `anthropic` providers using AWS Bedrock, the following keys are also -available: `BEDROCK_BASE_URL`, `BEDROCK_REGION`, -`BEDROCK_ACCESS_KEY` (alias: `BEDROCK_ACCESS_KEYS`), -`BEDROCK_ACCESS_KEY_SECRET` (alias: `BEDROCK_ACCESS_KEY_SECRETS`), -`BEDROCK_MODEL`, `BEDROCK_SMALL_FAST_MODEL`. - -> [!NOTE] -> Indices must be contiguous and start at `0`. Each instance must have a unique -> `NAME`. If two instances of the same `TYPE` omit `NAME`, they will both -> default to the type name and fail with a duplicate name error. -> -> The legacy single-provider environment variables (`CODER_AI_GATEWAY_OPENAI_KEY`, -> `CODER_AI_GATEWAY_ANTHROPIC_KEY`, etc.) continue to work. However, setting -> both a legacy variable and an indexed provider with the same default name -> (e.g. `CODER_AI_GATEWAY_OPENAI_KEY` and an indexed provider named `openai`) -> will produce a startup error. Remove one or the other to resolve the -> conflict. +![Edit Anthropic provider form](../../images/aibridge/provider-edit-anthropic.png) ## API Dumps diff --git a/docs/ai-coder/ai-governance.md b/docs/ai-coder/ai-governance.md index 0c8f7b609a197..857af72eecd50 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/github-to-tasks.md b/docs/ai-coder/github-to-tasks.md index 408dd8c101c23..16c002fffcdc7 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://<your-coder-url>/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/ai-coder/tasks-migration.md b/docs/ai-coder/tasks-migration.md index b833e6e6ff95b..98d98bd3c8724 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/ai-coder/usage-data-reporting.md b/docs/ai-coder/usage-data-reporting.md index 9d8fe08bfae07..21c1e42d47b80 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/images/aibridge/provider-add-anthropic.png b/docs/images/aibridge/provider-add-anthropic.png new file mode 100644 index 0000000000000..7a718be5e4a84 Binary files /dev/null and b/docs/images/aibridge/provider-add-anthropic.png differ diff --git a/docs/images/aibridge/provider-edit-anthropic.png b/docs/images/aibridge/provider-edit-anthropic.png new file mode 100644 index 0000000000000..e960aed025b60 Binary files /dev/null and b/docs/images/aibridge/provider-edit-anthropic.png differ diff --git a/docs/images/aibridge/providers-list.png b/docs/images/aibridge/providers-list.png new file mode 100644 index 0000000000000..578b82a656604 Binary files /dev/null and b/docs/images/aibridge/providers-list.png differ diff --git a/docs/images/single-region-architecture.png b/docs/images/single-region-architecture.png new file mode 100644 index 0000000000000..b16633c410e74 Binary files /dev/null and b/docs/images/single-region-architecture.png differ diff --git a/docs/images/single-region-architecture.svg b/docs/images/single-region-architecture.svg new file mode 100644 index 0000000000000..ed7aa0001b9fe --- /dev/null +++ b/docs/images/single-region-architecture.svg @@ -0,0 +1,218 @@ +<svg width="1227" height="920" viewBox="0 0 1227 920" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> +<g clip-path="url(#clip0_242_649)"> +<path d="M1227 0H0V920H1227V0Z" fill="#FBF8F8"/> +<path d="M726.502 522.25C728.172 522.25 728.942 521.18 728.942 519.5C728.942 517.81 728.172 516.75 726.502 516.75H725.852V522.25H726.502ZM724.922 523V516H726.502C728.712 516 729.872 517.34 729.872 519.5C729.872 521.66 728.712 523 726.502 523H724.922ZM734.223 517.8V522.25H735.813V523H731.503V522.25H733.293V518.55H731.503V517.8H734.223ZM733.263 517.06V515.91H734.253V517.06H733.263ZM739.754 517.8V518.6C740.004 518.05 740.474 517.8 741.254 517.8H742.234V518.55H741.174C740.224 518.55 739.754 518.93 739.754 520.02V522.25H741.534V523H737.214V522.25H738.824V518.55H737.214V517.8H739.754ZM745.825 523.1C744.485 523.1 743.315 522.14 743.315 520.4C743.315 518.69 744.455 517.7 745.805 517.7C747.465 517.7 748.325 518.91 748.295 520.61H744.255V520.71C744.305 521.8 745.015 522.37 745.825 522.37C746.515 522.37 747.045 522.03 747.255 521.42H748.205C747.895 522.52 746.945 523.1 745.825 523.1ZM744.265 519.93H747.335C747.235 518.95 746.625 518.43 745.795 518.43C745.015 518.43 744.365 518.95 744.265 519.93ZM749.596 520.4C749.596 518.62 750.756 517.7 752.096 517.7C753.266 517.7 754.166 518.32 754.456 519.48H753.516C753.296 518.8 752.806 518.43 752.096 518.43C751.236 518.43 750.526 519.1 750.526 520.4C750.526 521.7 751.236 522.37 752.096 522.37C752.806 522.37 753.306 522 753.526 521.31H754.456C754.176 522.48 753.266 523.1 752.096 523.1C750.756 523.1 749.596 522.18 749.596 520.4ZM758.418 521.52C758.418 522.19 758.808 522.25 759.298 522.25H760.258V523H759.048C758.198 523 757.488 522.78 757.488 521.61V518.55H755.848V517.8H757.488V516.6H758.418V517.8H760.258V518.55H758.418V521.52ZM768.11 519.5C768.11 517.37 768.89 515.9 770.75 515.9C772.17 515.9 772.96 516.75 773.2 518.12H772.27C772.09 517.23 771.61 516.65 770.75 516.65C769.5 516.65 769.04 517.85 769.04 519.5C769.04 521.16 769.5 522.35 770.75 522.35C771.61 522.35 772.09 521.77 772.26 520.9H773.2C772.96 522.26 772.16 523.1 770.75 523.1C768.89 523.1 768.11 521.63 768.11 519.5ZM776.801 523.1C775.481 523.1 774.321 522.18 774.321 520.4C774.321 518.62 775.481 517.7 776.801 517.7C778.121 517.7 779.291 518.62 779.291 520.4C779.291 522.18 778.121 523.1 776.801 523.1ZM775.251 520.4C775.251 521.72 775.951 522.37 776.801 522.37C777.651 522.37 778.351 521.72 778.351 520.4C778.351 519.08 777.651 518.43 776.801 518.43C775.951 518.43 775.251 519.08 775.251 520.4ZM784.342 519.94C784.342 518.9 783.912 518.43 783.172 518.43C782.332 518.43 781.782 519.02 781.782 520.13V523H780.852V517.8H781.782V518.72C782.112 518.05 782.722 517.7 783.502 517.7C784.562 517.7 785.272 518.35 785.272 519.8V523H784.342V519.94ZM790.544 519.94C790.544 518.9 790.114 518.43 789.374 518.43C788.534 518.43 787.984 519.02 787.984 520.13V523H787.054V517.8H787.984V518.72C788.314 518.05 788.924 517.7 789.704 517.7C790.764 517.7 791.474 518.35 791.474 519.8V523H790.544V519.94ZM795.435 523.1C794.095 523.1 792.925 522.14 792.925 520.4C792.925 518.69 794.065 517.7 795.415 517.7C797.075 517.7 797.935 518.91 797.905 520.61H793.865V520.71C793.915 521.8 794.625 522.37 795.435 522.37C796.125 522.37 796.655 522.03 796.865 521.42H797.815C797.505 522.52 796.555 523.1 795.435 523.1ZM793.875 519.93H796.945C796.845 518.95 796.235 518.43 795.405 518.43C794.625 518.43 793.975 518.95 793.875 519.93ZM799.206 520.4C799.206 518.62 800.366 517.7 801.706 517.7C802.876 517.7 803.776 518.32 804.066 519.48H803.126C802.906 518.8 802.416 518.43 801.706 518.43C800.846 518.43 800.136 519.1 800.136 520.4C800.136 521.7 800.846 522.37 801.706 522.37C802.416 522.37 802.916 522 803.136 521.31H804.066C803.786 522.48 802.876 523.1 801.706 523.1C800.366 523.1 799.206 522.18 799.206 520.4ZM808.027 521.52C808.027 522.19 808.417 522.25 808.907 522.25H809.867V523H808.657C807.807 523 807.097 522.78 807.097 521.61V518.55H805.457V517.8H807.097V516.6H808.027V517.8H809.867V518.55H808.027V521.52Z" fill="black"/> +<path d="M68.128 42.384C61.408 42.384 57.024 37.552 57.024 30.64C57.024 23.728 61.408 18.896 68.128 18.896C73.6 18.896 77.12 22.352 78.048 26.8H73.536C72.96 24.368 71.072 22.64 68.128 22.64C63.968 22.64 61.536 25.968 61.536 30.64C61.536 35.312 63.968 38.64 68.128 38.64C71.264 38.64 72.96 36.912 73.536 34.48H78.048C77.152 38.928 73.76 42.384 68.128 42.384ZM87.838 42.384C82.846 42.384 79.55 38.768 79.55 33.84C79.55 28.976 82.846 25.36 87.838 25.36C92.83 25.36 96.126 28.976 96.126 33.84C96.126 38.768 92.83 42.384 87.838 42.384ZM87.838 39.44C90.398 39.44 91.838 37.136 91.838 33.84C91.838 30.576 90.398 28.304 87.838 28.304C85.278 28.304 83.838 30.576 83.838 33.84C83.838 37.136 85.278 39.44 87.838 39.44ZM104.65 42.384C100.49 42.384 97.7695 38.832 97.7695 33.872C97.7695 28.944 100.49 25.36 104.65 25.36C106.986 25.36 108.714 26.48 109.674 28.272V19.28H113.706V42H109.674V39.472C108.714 41.232 106.986 42.384 104.65 42.384ZM105.93 39.376C108.394 39.376 109.77 37.104 109.77 33.872C109.77 30.608 108.394 28.368 105.93 28.368C103.466 28.368 102.09 30.608 102.09 33.872C102.09 37.104 103.466 39.376 105.93 39.376ZM116.144 33.84C116.144 28.976 119.44 25.36 124.432 25.36C129.552 25.36 132.72 29.36 132.528 34.736H120.304C120.464 37.712 122 39.504 124.528 39.504C126.544 39.504 127.664 38.32 128.176 36.816H132.208C131.312 39.984 128.656 42.384 124.496 42.384C119.504 42.384 116.144 38.768 116.144 33.84ZM128.432 32.208C128.048 29.808 126.672 28.208 124.432 28.208C122.192 28.208 120.784 29.776 120.432 32.208H128.432ZM143.617 25.68H144.737V29.328H143.169C140.449 29.328 138.977 30.384 138.945 33.712V42H134.913V25.68H138.849V28.72C139.457 27.088 140.993 25.68 143.617 25.68ZM172.273 42L170.545 36.72H161.681L159.953 42H155.409L163.569 19.28H168.721L176.913 42H172.273ZM162.833 33.232H169.393L166.129 23.184L162.833 33.232ZM187.305 25.68H188.425V29.328H186.857C184.137 29.328 182.665 30.384 182.633 33.712V42H178.601V25.68H182.537V28.72C183.145 27.088 184.681 25.68 187.305 25.68ZM204.796 36.432C204.06 40.272 201.212 42.384 197.244 42.384C192.316 42.384 188.956 38.768 188.956 33.84C188.956 28.976 192.284 25.36 197.212 25.36C201.212 25.36 204.06 27.536 204.796 31.28H200.732C200.316 29.424 199.164 28.304 197.276 28.304C194.684 28.304 193.244 30.576 193.244 33.84C193.244 37.136 194.684 39.44 197.276 39.44C199.324 39.44 200.316 38.224 200.732 36.432H204.796ZM207.007 19.28H211.039V28.336C211.903 26.768 213.503 25.36 216.287 25.36C219.807 25.36 221.663 27.504 221.663 30.832V42H217.599V31.728C217.599 29.744 216.799 28.528 214.623 28.528C212.543 28.528 211.039 30.16 211.039 32.72V42H207.007V19.28ZM224.788 19.28H228.82V23.12H224.788V19.28ZM224.788 25.68H228.82V42H224.788V25.68ZM240.774 28.464H237.19V37.296C237.19 38.704 237.542 39.088 238.854 39.088H240.774V42H237.542C234.214 42 233.158 40.592 233.158 37.584V28.464H230.726V25.68H233.158V21.328H237.19V25.68H240.774V28.464ZM241.956 33.84C241.956 28.976 245.252 25.36 250.244 25.36C255.364 25.36 258.532 29.36 258.34 34.736H246.116C246.276 37.712 247.812 39.504 250.34 39.504C252.356 39.504 253.476 38.32 253.988 36.816H258.02C257.124 39.984 254.468 42.384 250.308 42.384C245.316 42.384 241.956 38.768 241.956 33.84ZM254.244 32.208C253.86 29.808 252.484 28.208 250.244 28.208C248.004 28.208 246.596 29.776 246.244 32.208H254.244ZM275.734 36.432C274.998 40.272 272.15 42.384 268.182 42.384C263.254 42.384 259.894 38.768 259.894 33.84C259.894 28.976 263.222 25.36 268.15 25.36C272.15 25.36 274.998 27.536 275.734 31.28H271.67C271.254 29.424 270.102 28.304 268.214 28.304C265.622 28.304 264.182 30.576 264.182 33.84C264.182 37.136 265.622 39.44 268.214 39.44C270.262 39.44 271.254 38.224 271.67 36.432H275.734ZM286.586 28.464H283.002V37.296C283.002 38.704 283.354 39.088 284.666 39.088H286.586V42H283.354C280.026 42 278.97 40.592 278.97 37.584V28.464H276.538V25.68H278.97V21.328H283.002V25.68H286.586V28.464ZM294.097 42.384C290.705 42.384 288.785 40.208 288.785 36.848V25.68H292.817V35.984C292.817 37.968 293.649 39.184 295.729 39.184C297.745 39.184 299.217 37.552 299.217 34.992V25.68H303.249V42H299.281V39.376C298.385 40.976 296.849 42.384 294.097 42.384ZM315.211 25.68H316.331V29.328H314.763C312.043 29.328 310.571 30.384 310.539 33.712V42H306.507V25.68H310.443V28.72C311.051 27.088 312.587 25.68 315.211 25.68ZM316.863 33.84C316.863 28.976 320.159 25.36 325.151 25.36C330.271 25.36 333.439 29.36 333.247 34.736H321.023C321.183 37.712 322.719 39.504 325.247 39.504C327.263 39.504 328.383 38.32 328.895 36.816H332.927C332.031 39.984 329.375 42.384 325.215 42.384C320.223 42.384 316.863 38.768 316.863 33.84ZM329.151 32.208C328.767 29.808 327.391 28.208 325.151 28.208C322.911 28.208 321.503 29.776 321.151 32.208H329.151ZM341.473 31.856H362.817V35.504H341.473V31.856ZM382.441 42.384C375.721 42.384 371.337 37.552 371.337 30.64C371.337 23.728 375.721 18.896 382.441 18.896C387.913 18.896 391.433 22.352 392.361 26.8H387.849C387.273 24.368 385.385 22.64 382.441 22.64C378.281 22.64 375.849 25.968 375.849 30.64C375.849 35.312 378.281 38.64 382.441 38.64C385.577 38.64 387.273 36.912 387.849 34.48H392.361C391.465 38.928 388.073 42.384 382.441 42.384ZM402.151 42.384C397.159 42.384 393.863 38.768 393.863 33.84C393.863 28.976 397.159 25.36 402.151 25.36C407.143 25.36 410.439 28.976 410.439 33.84C410.439 38.768 407.143 42.384 402.151 42.384ZM402.151 39.44C404.711 39.44 406.151 37.136 406.151 33.84C406.151 30.576 404.711 28.304 402.151 28.304C399.591 28.304 398.151 30.576 398.151 33.84C398.151 37.136 399.591 39.44 402.151 39.44ZM412.882 25.68H416.85V28.304C417.874 26.576 419.538 25.36 421.97 25.36C424.402 25.36 426.002 26.544 426.61 28.656C427.57 26.736 429.426 25.36 431.954 25.36C434.994 25.36 436.914 27.248 436.914 30.608V42H432.85V31.728C432.85 29.648 432.018 28.528 430.162 28.528C428.274 28.528 426.93 30.192 426.93 32.72V42H422.866V31.728C422.866 29.648 422.034 28.528 420.178 28.528C418.29 28.528 416.914 30.192 416.914 32.72V42H412.882V25.68ZM440.038 25.68H444.006V28.304C445.03 26.576 446.694 25.36 449.126 25.36C451.558 25.36 453.158 26.544 453.766 28.656C454.726 26.736 456.582 25.36 459.11 25.36C462.15 25.36 464.07 27.248 464.07 30.608V42H460.006V31.728C460.006 29.648 459.174 28.528 457.318 28.528C455.43 28.528 454.086 30.192 454.086 32.72V42H450.022V31.728C450.022 29.648 449.19 28.528 447.334 28.528C445.446 28.528 444.07 30.192 444.07 32.72V42H440.038V25.68ZM472.379 42.384C468.987 42.384 467.067 40.208 467.067 36.848V25.68H471.099V35.984C471.099 37.968 471.931 39.184 474.01 39.184C476.027 39.184 477.499 37.552 477.499 34.992V25.68H481.531V42H477.563V39.376C476.667 40.976 475.131 42.384 472.379 42.384ZM484.788 25.68H488.756V28.336C489.684 26.768 491.284 25.36 494.068 25.36C497.588 25.36 499.444 27.504 499.444 30.832V42H495.38V31.728C495.38 29.744 494.58 28.528 492.404 28.528C490.324 28.528 488.82 30.16 488.82 32.72V42H484.788V25.68ZM502.57 19.28H506.602V23.12H502.57V19.28ZM502.57 25.68H506.602V42H502.57V25.68ZM518.555 28.464H514.971V37.296C514.971 38.704 515.323 39.088 516.635 39.088H518.555V42H515.323C511.995 42 510.939 40.592 510.939 37.584V28.464H508.507V25.68H510.939V21.328H514.971V25.68H518.555V28.464ZM523.474 25.68L527.474 37.808L531.506 25.68H535.57L529.458 42.32C527.986 46.352 526.834 47.44 523.346 47.44H520.818V44.496H522.29C523.954 44.496 524.562 44.208 525.362 42L519.25 25.68H523.474ZM535.378 37.392H540.018V42C540.018 44.848 538.482 46.256 535.378 46.544V44.4C536.946 44.24 537.746 43.792 537.746 42H535.378V37.392ZM559 19.28C563.512 19.28 566.52 21.968 566.52 26.192C566.52 30.448 563.512 33.104 559 33.104H553.432V42H549.048V19.28H559ZM558.136 29.52C560.632 29.52 562.072 28.464 562.072 26.192C562.072 23.984 560.632 22.864 558.136 22.864H553.432V29.52H558.136ZM577.274 25.68H578.394V29.328H576.826C574.106 29.328 572.634 30.384 572.602 33.712V42H568.57V25.68H572.506V28.72C573.114 27.088 574.65 25.68 577.274 25.68ZM578.925 33.84C578.925 28.976 582.221 25.36 587.213 25.36C592.333 25.36 595.501 29.36 595.309 34.736H583.085C583.245 37.712 584.781 39.504 587.309 39.504C589.325 39.504 590.445 38.32 590.957 36.816H594.989C594.093 39.984 591.437 42.384 587.277 42.384C582.285 42.384 578.925 38.768 578.925 33.84ZM591.213 32.208C590.829 29.808 589.453 28.208 587.213 28.208C584.973 28.208 583.565 29.776 583.213 32.208H591.213ZM597.695 25.68H601.663V28.304C602.687 26.576 604.351 25.36 606.783 25.36C609.215 25.36 610.815 26.544 611.423 28.656C612.383 26.736 614.239 25.36 616.767 25.36C619.807 25.36 621.727 27.248 621.727 30.608V42H617.663V31.728C617.663 29.648 616.831 28.528 614.975 28.528C613.087 28.528 611.743 30.192 611.743 32.72V42H607.679V31.728C607.679 29.648 606.847 28.528 604.991 28.528C603.103 28.528 601.727 30.192 601.727 32.72V42H597.695V25.68ZM624.851 19.28H628.883V23.12H624.851V19.28ZM624.851 25.68H628.883V42H624.851V25.68ZM637.316 42.384C633.924 42.384 632.004 40.208 632.004 36.848V25.68H636.036V35.984C636.036 37.968 636.868 39.184 638.948 39.184C640.964 39.184 642.436 37.552 642.436 34.992V25.68H646.468V42H642.5V39.376C641.604 40.976 640.068 42.384 637.316 42.384ZM649.726 25.68H653.694V28.304C654.718 26.576 656.382 25.36 658.814 25.36C661.246 25.36 662.846 26.544 663.454 28.656C664.414 26.736 666.27 25.36 668.798 25.36C671.838 25.36 673.758 27.248 673.758 30.608V42H669.694V31.728C669.694 29.648 668.862 28.528 667.006 28.528C665.118 28.528 663.774 30.192 663.774 32.72V42H659.71V31.728C659.71 29.648 658.878 28.528 657.022 28.528C655.134 28.528 653.758 30.192 653.758 32.72V42H649.726V25.68ZM704.323 42H699.459L697.059 39.568C695.203 41.296 692.803 42.384 689.859 42.384C685.219 42.384 682.051 39.6 682.051 35.824C682.051 32.592 684.259 30.768 686.947 29.2C685.411 27.568 684.227 25.904 684.227 24.016C684.227 20.976 686.979 18.896 690.659 18.896C694.211 18.896 697.187 20.848 697.187 23.984C697.187 26.896 695.011 28.304 692.323 29.776C692.675 30.16 693.027 30.512 693.411 30.896L696.611 34.128C697.091 32.944 697.411 31.632 697.539 30.256L701.411 30.224C701.155 32.56 700.451 34.864 699.235 36.848L704.323 42ZM690.755 21.872C689.315 21.872 688.355 22.704 688.355 24.112C688.355 25.104 689.059 26.224 690.083 27.44C692.323 26.128 693.155 25.328 693.155 23.984C693.155 22.672 692.099 21.872 690.755 21.872ZM690.307 39.088C692.035 39.088 693.507 38.352 694.691 37.136L689.219 31.536C687.523 32.56 686.307 33.616 686.307 35.504C686.307 37.584 688.035 39.088 690.307 39.088ZM724.523 42L722.795 36.72H713.931L712.203 42H707.659L715.819 19.28H720.971L729.163 42H724.523ZM715.083 33.232H721.643L718.379 23.184L715.083 33.232ZM735.651 19.28V42H731.267V19.28H735.651ZM755.145 42.384C748.745 42.384 744.649 37.552 744.649 30.64C744.649 23.728 748.969 18.896 755.753 18.896C761.289 18.896 764.745 22.192 765.673 26.352H761.193C760.521 24.24 758.665 22.64 755.785 22.64C751.593 22.64 749.161 25.968 749.161 30.64C749.161 35.312 751.593 38.64 755.817 38.64C759.241 38.64 761.257 36.464 761.417 33.52V33.008H755.241V29.648H765.705V42H762.057V38.512C761.001 40.752 758.633 42.384 755.145 42.384ZM776.369 42.384C771.377 42.384 768.081 38.768 768.081 33.84C768.081 28.976 771.377 25.36 776.369 25.36C781.361 25.36 784.657 28.976 784.657 33.84C784.657 38.768 781.361 42.384 776.369 42.384ZM776.369 39.44C778.929 39.44 780.369 37.136 780.369 33.84C780.369 30.576 778.929 28.304 776.369 28.304C773.809 28.304 772.369 30.576 772.369 33.84C772.369 37.136 773.809 39.44 776.369 39.44ZM796.873 25.68H801.065L795.465 42H790.665L785.033 25.68H789.385L793.097 38.256L796.873 25.68ZM801.456 33.84C801.456 28.976 804.752 25.36 809.744 25.36C814.864 25.36 818.032 29.36 817.84 34.736H805.616C805.776 37.712 807.312 39.504 809.84 39.504C811.856 39.504 812.976 38.32 813.488 36.816H817.52C816.624 39.984 813.968 42.384 809.808 42.384C804.816 42.384 801.456 38.768 801.456 33.84ZM813.744 32.208C813.36 29.808 811.984 28.208 809.744 28.208C807.504 28.208 806.096 29.776 805.744 32.208H813.744ZM828.93 25.68H830.05V29.328H828.482C825.762 29.328 824.29 30.384 824.258 33.712V42H820.226V25.68H824.162V28.72C824.77 27.088 826.306 25.68 828.93 25.68ZM831.945 25.68H835.913V28.336C836.841 26.768 838.441 25.36 841.225 25.36C844.745 25.36 846.601 27.504 846.601 30.832V42H842.537V31.728C842.537 29.744 841.737 28.528 839.561 28.528C837.481 28.528 835.977 30.16 835.977 32.72V42H831.945V25.68ZM854.462 42.384C851.294 42.384 848.83 40.56 848.83 37.392C848.83 34.128 851.294 33.104 855.134 32.112C857.694 31.44 859.39 31.152 859.39 29.904C859.39 28.848 858.334 28.112 856.51 28.112C854.622 28.112 853.406 28.944 853.278 30.48H849.278C849.662 27.6 852.158 25.36 856.638 25.36C861.022 25.36 863.55 27.184 863.55 30.608V42H859.71V39.216C859.102 40.912 857.534 42.384 854.462 42.384ZM855.806 39.568C858.174 39.568 859.55 37.584 859.55 35.184V32.336C859.198 33.2 858.206 33.648 856.542 34.16C854.366 34.8 852.99 35.472 852.99 37.168C852.99 38.672 854.174 39.568 855.806 39.568ZM866.695 25.68H870.663V28.336C871.591 26.768 873.191 25.36 875.975 25.36C879.495 25.36 881.351 27.504 881.351 30.832V42H877.287V31.728C877.287 29.744 876.487 28.528 874.311 28.528C872.231 28.528 870.727 30.16 870.727 32.72V42H866.695V25.68ZM899.484 36.432C898.748 40.272 895.9 42.384 891.932 42.384C887.004 42.384 883.644 38.768 883.644 33.84C883.644 28.976 886.972 25.36 891.9 25.36C895.9 25.36 898.748 27.536 899.484 31.28H895.42C895.004 29.424 893.852 28.304 891.964 28.304C889.372 28.304 887.932 30.576 887.932 33.84C887.932 37.136 889.372 39.44 891.964 39.44C894.012 39.44 895.004 38.224 895.42 36.432H899.484ZM900.863 33.84C900.863 28.976 904.159 25.36 909.151 25.36C914.271 25.36 917.439 29.36 917.247 34.736H905.023C905.183 37.712 906.719 39.504 909.247 39.504C911.263 39.504 912.383 38.32 912.895 36.816H916.927C916.031 39.984 913.375 42.384 909.215 42.384C904.223 42.384 900.863 38.768 900.863 33.84ZM913.151 32.208C912.767 29.808 911.391 28.208 909.151 28.208C906.911 28.208 905.503 29.776 905.151 32.208H913.151ZM940.023 42L938.295 36.72H929.431L927.703 42H923.159L931.319 19.28H936.471L944.663 42H940.023ZM930.583 33.232H937.143L933.879 23.184L930.583 33.232ZM951.806 42.384C947.646 42.384 944.926 38.832 944.926 33.872C944.926 28.944 947.646 25.36 951.806 25.36C954.142 25.36 955.87 26.48 956.83 28.272V19.28H960.862V42H956.83V39.472C955.87 41.232 954.142 42.384 951.806 42.384ZM953.086 39.376C955.55 39.376 956.926 37.104 956.926 33.872C956.926 30.608 955.55 28.368 953.086 28.368C950.622 28.368 949.246 30.608 949.246 33.872C949.246 37.104 950.622 39.376 953.086 39.376ZM970.212 42.384C966.052 42.384 963.332 38.832 963.332 33.872C963.332 28.944 966.052 25.36 970.212 25.36C972.548 25.36 974.276 26.48 975.236 28.272V19.28H979.268V42H975.236V39.472C974.276 41.232 972.548 42.384 970.212 42.384ZM971.492 39.376C973.956 39.376 975.332 37.104 975.332 33.872C975.332 30.608 973.956 28.368 971.492 28.368C969.028 28.368 967.652 30.608 967.652 33.872C967.652 37.104 969.028 39.376 971.492 39.376ZM982.41 31.856H991.498V35.504H982.41V31.856ZM1005.1 42.384C998.344 42.384 994.024 37.552 994.024 30.64C994.024 23.728 998.344 18.896 1005.1 18.896C1011.85 18.896 1016.2 23.728 1016.2 30.64C1016.2 37.552 1011.85 42.384 1005.1 42.384ZM1005.1 38.64C1009.29 38.64 1011.69 35.312 1011.69 30.64C1011.69 25.968 1009.29 22.64 1005.1 22.64C1000.94 22.64 998.536 25.968 998.536 30.64C998.536 35.312 1000.94 38.64 1005.1 38.64ZM1018.69 25.68H1022.66V28.336C1023.59 26.768 1025.19 25.36 1027.97 25.36C1031.49 25.36 1033.35 27.504 1033.35 30.832V42H1029.29V31.728C1029.29 29.744 1028.49 28.528 1026.31 28.528C1024.23 28.528 1022.73 30.16 1022.73 32.72V42H1018.69V25.68Z" fill="#090B0B"/> +<path d="M58.849 69.359C57.661 69.073 56.902 68.468 56.902 67.302C56.902 65.971 58.046 65.19 59.377 65.19C60.719 65.19 61.698 65.949 61.896 67.28H60.873C60.719 66.466 60.202 66.015 59.377 66.015C58.585 66.015 57.925 66.466 57.925 67.269C57.925 67.984 58.442 68.303 59.058 68.479L60.103 68.754C61.401 69.062 62.072 69.744 62.072 70.888C62.072 72.197 60.983 73.11 59.454 73.11C57.98 73.11 56.946 72.241 56.748 70.833H57.771C57.936 71.724 58.563 72.285 59.454 72.285C60.466 72.285 61.049 71.691 61.049 70.921C61.049 70.206 60.587 69.832 59.894 69.634L58.849 69.359ZM66.2643 73.11C64.7903 73.11 63.5033 72.054 63.5033 70.14C63.5033 68.259 64.7573 67.17 66.2423 67.17C68.0683 67.17 69.0143 68.501 68.9813 70.371H64.5373V70.481C64.5923 71.68 65.3733 72.307 66.2643 72.307C67.0233 72.307 67.6063 71.933 67.8373 71.262H68.8823C68.5413 72.472 67.4963 73.11 66.2643 73.11ZM64.5483 69.623H67.9253C67.8153 68.545 67.1443 67.973 66.2313 67.973C65.3733 67.973 64.6583 68.545 64.5483 69.623ZM73.8226 65.3V72.175H75.5716V73H70.8306V72.175H72.7996V66.125H70.8306V65.3H73.8226ZM80.2809 66.829V67.28H82.3049V68.105H80.2809V72.175H82.1949V73H77.4429V72.175H79.2469V68.105H77.4429V67.28H79.2469V66.829C79.2469 65.542 80.0389 65.3 80.9739 65.3H82.3049V66.026H81.2379C80.7099 66.026 80.2809 66.092 80.2809 66.829ZM85.3202 69.722H88.0702V70.547H85.3202V69.722ZM95.1114 69.634C95.1114 68.49 94.6384 67.973 93.8244 67.973C92.9004 67.973 92.2954 68.622 92.2954 69.843V73H91.2724V65.3H92.2954V68.27C92.6694 67.544 93.3294 67.17 94.1764 67.17C95.3424 67.17 96.1344 67.885 96.1344 69.48V73H95.1114V69.634ZM100.338 73.11C98.8857 73.11 97.6097 72.098 97.6097 70.14C97.6097 68.182 98.8857 67.17 100.338 67.17C101.79 67.17 103.077 68.182 103.077 70.14C103.077 72.098 101.79 73.11 100.338 73.11ZM98.6327 70.14C98.6327 71.592 99.4027 72.307 100.338 72.307C101.273 72.307 102.043 71.592 102.043 70.14C102.043 68.688 101.273 67.973 100.338 67.973C99.4027 67.973 98.6327 68.688 98.6327 70.14ZM106.851 70.448C105.828 70.272 104.97 69.898 104.97 68.831C104.97 67.775 105.993 67.17 107.148 67.17C108.215 67.17 109.227 67.731 109.37 68.798H108.347C108.259 68.248 107.775 67.973 107.148 67.973C106.477 67.973 105.993 68.292 105.993 68.82C105.993 69.381 106.477 69.513 106.961 69.612L107.511 69.733C108.743 69.953 109.48 70.382 109.48 71.416C109.48 72.472 108.501 73.11 107.214 73.11C105.993 73.11 105.014 72.516 104.827 71.339H105.85C105.96 72.021 106.532 72.307 107.214 72.307C107.984 72.307 108.457 71.977 108.457 71.427C108.457 70.877 108.039 70.701 107.401 70.558L106.851 70.448ZM114.222 71.372C114.222 72.109 114.651 72.175 115.19 72.175H116.246V73H114.915C113.98 73 113.199 72.758 113.199 71.471V68.105H111.395V67.28H113.199V65.96H114.222V67.28H116.246V68.105H114.222V71.372ZM120.835 73.11C119.361 73.11 118.074 72.054 118.074 70.14C118.074 68.259 119.328 67.17 120.813 67.17C122.639 67.17 123.585 68.501 123.552 70.371H119.108V70.481C119.163 71.68 119.944 72.307 120.835 72.307C121.594 72.307 122.177 71.933 122.408 71.262H123.453C123.112 72.472 122.067 73.11 120.835 73.11ZM119.119 69.623H122.496C122.386 68.545 121.715 67.973 120.802 67.973C119.944 67.973 119.229 68.545 119.119 69.623ZM130.142 65.3V73H129.119V71.988C128.668 72.714 127.942 73.11 127.128 73.11C125.786 73.11 124.609 72.043 124.609 70.14C124.609 68.237 125.797 67.17 127.128 67.17C127.942 67.17 128.668 67.566 129.119 68.292V65.3H130.142ZM125.643 70.14C125.643 71.504 126.413 72.307 127.381 72.307C128.338 72.307 129.119 71.504 129.119 70.14C129.119 68.776 128.338 67.973 127.381 67.973C126.413 67.973 125.643 68.776 125.643 70.14ZM138.625 70.14C138.625 68.182 139.901 67.17 141.375 67.17C142.662 67.17 143.652 67.852 143.971 69.128H142.937C142.695 68.38 142.156 67.973 141.375 67.973C140.429 67.973 139.648 68.71 139.648 70.14C139.648 71.57 140.429 72.307 141.375 72.307C142.156 72.307 142.706 71.9 142.948 71.141H143.971C143.663 72.428 142.662 73.11 141.375 73.11C139.901 73.11 138.625 72.098 138.625 70.14ZM148.087 73.11C146.635 73.11 145.359 72.098 145.359 70.14C145.359 68.182 146.635 67.17 148.087 67.17C149.539 67.17 150.826 68.182 150.826 70.14C150.826 72.098 149.539 73.11 148.087 73.11ZM146.382 70.14C146.382 71.592 147.152 72.307 148.087 72.307C149.022 72.307 149.792 71.592 149.792 70.14C149.792 68.688 149.022 67.973 148.087 67.973C147.152 67.973 146.382 68.688 146.382 70.14ZM156.382 69.634C156.382 68.49 155.909 67.973 155.095 67.973C154.171 67.973 153.566 68.622 153.566 69.843V73H152.543V67.28H153.566V68.292C153.929 67.555 154.6 67.17 155.458 67.17C156.624 67.17 157.405 67.885 157.405 69.48V73H156.382V69.634ZM161.971 71.372C161.971 72.109 162.4 72.175 162.939 72.175H163.995V73H162.664C161.729 73 160.948 72.758 160.948 71.471V68.105H159.144V67.28H160.948V65.96H161.971V67.28H163.995V68.105H161.971V71.372ZM168.727 67.28V68.16C169.002 67.555 169.519 67.28 170.377 67.28H171.455V68.105H170.289C169.244 68.105 168.727 68.523 168.727 69.722V72.175H170.685V73H165.933V72.175H167.704V68.105H165.933V67.28H168.727ZM175.372 73.11C173.92 73.11 172.644 72.098 172.644 70.14C172.644 68.182 173.92 67.17 175.372 67.17C176.824 67.17 178.111 68.182 178.111 70.14C178.111 72.098 176.824 73.11 175.372 73.11ZM173.667 70.14C173.667 71.592 174.437 72.307 175.372 72.307C176.307 72.307 177.077 71.592 177.077 70.14C177.077 68.688 176.307 67.973 175.372 67.973C174.437 67.973 173.667 68.688 173.667 70.14ZM182.963 65.3V72.175H184.712V73H179.971V72.175H181.94V66.125H179.971V65.3H182.963ZM193.24 67.28H194.263V68.303C194.714 67.566 195.44 67.17 196.265 67.17C197.585 67.17 198.773 68.237 198.773 70.14C198.773 72.043 197.596 73.11 196.265 73.11C195.44 73.11 194.714 72.714 194.263 71.977V74.98H193.24V67.28ZM194.263 70.14C194.263 71.504 195.044 72.307 196.001 72.307C196.969 72.307 197.75 71.504 197.75 70.14C197.75 68.776 196.969 67.973 196.001 67.973C195.044 67.973 194.263 68.776 194.263 70.14ZM203.427 65.3V72.175H205.176V73H200.435V72.175H202.404V66.125H200.435V65.3H203.427ZM206.915 71.471C206.915 70.448 207.751 69.766 209.687 69.634L210.71 69.568V69.458C210.71 68.314 210.237 67.973 209.445 67.973C208.708 67.973 208.18 68.369 208.059 69.04H207.069C207.267 67.907 208.257 67.17 209.467 67.17C210.798 67.17 211.733 67.786 211.733 69.645V72.032C211.733 72.428 211.777 72.769 211.843 73H210.864C210.809 72.78 210.787 72.483 210.787 72.164C210.468 72.736 209.896 73.11 208.928 73.11C207.597 73.11 206.915 72.362 206.915 71.471ZM207.938 71.438C207.938 71.933 208.301 72.362 209.17 72.362C210.171 72.362 210.71 71.691 210.71 70.866V70.305L209.885 70.36C208.532 70.459 207.938 70.778 207.938 71.438ZM217.774 69.634C217.774 68.49 217.301 67.973 216.487 67.973C215.563 67.973 214.958 68.622 214.958 69.843V73H213.935V67.28H214.958V68.292C215.321 67.555 215.992 67.17 216.85 67.17C218.016 67.17 218.797 67.885 218.797 69.48V73H217.774V69.634ZM223.154 73.11C221.68 73.11 220.393 72.054 220.393 70.14C220.393 68.259 221.647 67.17 223.132 67.17C224.958 67.17 225.904 68.501 225.871 70.371H221.427V70.481C221.482 71.68 222.263 72.307 223.154 72.307C223.913 72.307 224.496 71.933 224.727 71.262H225.772C225.431 72.472 224.386 73.11 223.154 73.11ZM221.438 69.623H224.815C224.705 68.545 224.034 67.973 223.121 67.973C222.263 67.973 221.548 68.545 221.438 69.623ZM229.403 71.735H230.481V73H229.403V71.735ZM245.521 73V71.636C245.202 72.538 244.553 73.11 243.475 73.11C241.572 73.11 240.703 71.493 240.703 69.15C240.703 66.807 241.561 65.19 243.596 65.19C245.169 65.19 246.038 66.125 246.291 67.632H245.279C245.081 66.653 244.542 66.015 243.596 66.015C242.232 66.015 241.726 67.335 241.726 69.15C241.726 70.976 242.232 72.285 243.596 72.285C244.751 72.285 245.268 71.262 245.279 69.843H243.563V69.018H246.302V73H245.521ZM250.406 73.11C248.954 73.11 247.678 72.098 247.678 70.14C247.678 68.182 248.954 67.17 250.406 67.17C251.858 67.17 253.145 68.182 253.145 70.14C253.145 72.098 251.858 73.11 250.406 73.11ZM248.701 70.14C248.701 71.592 249.471 72.307 250.406 72.307C251.341 72.307 252.111 71.592 252.111 70.14C252.111 68.688 251.341 67.973 250.406 67.973C249.471 67.973 248.701 68.688 248.701 70.14ZM256.556 73L254.477 67.28H255.511L257.227 72.263L258.943 67.28H259.966L257.887 73H256.556ZM264.082 73.11C262.608 73.11 261.321 72.054 261.321 70.14C261.321 68.259 262.575 67.17 264.06 67.17C265.886 67.17 266.832 68.501 266.799 70.371H262.355V70.481C262.41 71.68 263.191 72.307 264.082 72.307C264.841 72.307 265.424 71.933 265.655 71.262H266.7C266.359 72.472 265.314 73.11 264.082 73.11ZM262.366 69.623H265.743C265.633 68.545 264.962 67.973 264.049 67.973C263.191 67.973 262.476 68.545 262.366 69.623ZM271.046 67.28V68.16C271.321 67.555 271.838 67.28 272.696 67.28H273.774V68.105H272.608C271.563 68.105 271.046 68.523 271.046 69.722V72.175H273.004V73H268.252V72.175H270.023V68.105H268.252V67.28H271.046ZM279.165 69.634C279.165 68.49 278.692 67.973 277.878 67.973C276.954 67.973 276.349 68.622 276.349 69.843V73H275.326V67.28H276.349V68.292C276.712 67.555 277.383 67.17 278.241 67.17C279.407 67.17 280.188 67.885 280.188 69.48V73H279.165V69.634ZM284.546 73.11C283.072 73.11 281.785 72.054 281.785 70.14C281.785 68.259 283.039 67.17 284.524 67.17C286.35 67.17 287.296 68.501 287.263 70.371H282.819V70.481C282.874 71.68 283.655 72.307 284.546 72.307C285.305 72.307 285.888 71.933 286.119 71.262H287.164C286.823 72.472 285.778 73.11 284.546 73.11ZM282.83 69.623H286.207C286.097 68.545 285.426 67.973 284.513 67.973C283.655 67.973 282.94 68.545 282.83 69.623ZM293.853 65.3V73H292.83V71.988C292.379 72.714 291.653 73.11 290.839 73.11C289.497 73.11 288.32 72.043 288.32 70.14C288.32 68.237 289.508 67.17 290.839 67.17C291.653 67.17 292.379 67.566 292.83 68.292V65.3H293.853ZM289.354 70.14C289.354 71.504 290.124 72.307 291.092 72.307C292.049 72.307 292.83 71.504 292.83 70.14C292.83 68.776 292.049 67.973 291.092 67.973C290.124 67.973 289.354 68.776 289.354 70.14ZM302.413 71.471C302.413 70.448 303.249 69.766 305.185 69.634L306.208 69.568V69.458C306.208 68.314 305.735 67.973 304.943 67.973C304.206 67.973 303.678 68.369 303.557 69.04H302.567C302.765 67.907 303.755 67.17 304.965 67.17C306.296 67.17 307.231 67.786 307.231 69.645V72.032C307.231 72.428 307.275 72.769 307.341 73H306.362C306.307 72.78 306.285 72.483 306.285 72.164C305.966 72.736 305.394 73.11 304.426 73.11C303.095 73.11 302.413 72.362 302.413 71.471ZM303.436 71.438C303.436 71.933 303.799 72.362 304.668 72.362C305.669 72.362 306.208 71.691 306.208 70.866V70.305L305.383 70.36C304.03 70.459 303.436 70.778 303.436 71.438ZM311.446 72.824C310.148 72.824 308.982 71.834 308.982 69.997C308.982 68.16 310.148 67.17 311.446 67.17C312.26 67.17 312.942 67.533 313.36 68.237V67.28H314.383V71.966C314.383 74.122 313.393 75.09 311.699 75.09C310.324 75.09 309.378 74.364 309.202 73.198H310.181C310.302 73.891 310.841 74.342 311.699 74.342C312.887 74.342 313.36 73.594 313.36 71.966V71.757C312.942 72.461 312.26 72.824 311.446 72.824ZM310.005 69.997C310.005 71.306 310.742 72.021 311.677 72.021C312.612 72.021 313.36 71.306 313.36 69.997C313.36 68.699 312.612 67.973 311.677 67.973C310.742 67.973 310.005 68.699 310.005 69.997ZM318.652 73.11C317.178 73.11 315.891 72.054 315.891 70.14C315.891 68.259 317.145 67.17 318.63 67.17C320.456 67.17 321.402 68.501 321.369 70.371H316.925V70.481C316.98 71.68 317.761 72.307 318.652 72.307C319.411 72.307 319.994 71.933 320.225 71.262H321.27C320.929 72.472 319.884 73.11 318.652 73.11ZM316.936 69.623H320.313C320.203 68.545 319.532 67.973 318.619 67.973C317.761 67.973 317.046 68.545 316.936 69.623ZM326.914 69.634C326.914 68.49 326.441 67.973 325.627 67.973C324.703 67.973 324.098 68.622 324.098 69.843V73H323.075V67.28H324.098V68.292C324.461 67.555 325.132 67.17 325.99 67.17C327.156 67.17 327.937 67.885 327.937 69.48V73H326.914V69.634ZM332.504 71.372C332.504 72.109 332.933 72.175 333.472 72.175H334.528V73H333.197C332.262 73 331.481 72.758 331.481 71.471V68.105H329.677V67.28H331.481V65.96H332.504V67.28H334.528V68.105H332.504V71.372ZM338.775 70.448C337.752 70.272 336.894 69.898 336.894 68.831C336.894 67.775 337.917 67.17 339.072 67.17C340.139 67.17 341.151 67.731 341.294 68.798H340.271C340.183 68.248 339.699 67.973 339.072 67.973C338.401 67.973 337.917 68.292 337.917 68.82C337.917 69.381 338.401 69.513 338.885 69.612L339.435 69.733C340.667 69.953 341.404 70.382 341.404 71.416C341.404 72.472 340.425 73.11 339.138 73.11C337.917 73.11 336.938 72.516 336.751 71.339H337.774C337.884 72.021 338.456 72.307 339.138 72.307C339.908 72.307 340.381 71.977 340.381 71.427C340.381 70.877 339.963 70.701 339.325 70.558L338.775 70.448ZM345.365 71.735H346.443V73H345.365V71.735ZM361.615 73L361.076 71.24H358.007L357.468 73H356.412L358.909 65.3H360.174L362.671 73H361.615ZM358.26 70.415H360.823L359.547 66.191L358.26 70.415ZM364.894 70.646C364.894 71.79 365.367 72.307 366.181 72.307C367.105 72.307 367.71 71.658 367.71 70.426V67.28H368.733V73H367.71V71.988C367.347 72.725 366.676 73.11 365.818 73.11C364.652 73.11 363.871 72.395 363.871 70.8V67.28H364.894V70.646ZM375.708 65.3V73H374.685V71.988C374.234 72.714 373.508 73.11 372.694 73.11C371.352 73.11 370.175 72.043 370.175 70.14C370.175 68.237 371.363 67.17 372.694 67.17C373.508 67.17 374.234 67.566 374.685 68.292V65.3H375.708ZM371.209 70.14C371.209 71.504 371.979 72.307 372.947 72.307C373.904 72.307 374.685 71.504 374.685 70.14C374.685 68.776 373.904 67.973 372.947 67.973C371.979 67.973 371.209 68.776 371.209 70.14ZM380.924 67.28V72.175H382.673V73H377.932V72.175H379.901V68.105H377.932V67.28H380.924ZM379.868 66.466V65.201H380.957V66.466H379.868ZM387.074 71.372C387.074 72.109 387.503 72.175 388.042 72.175H389.098V73H387.767C386.832 73 386.051 72.758 386.051 71.471V68.105H384.247V67.28H386.051V65.96H387.074V67.28H389.098V68.105H387.074V71.372ZM393.686 73.11C392.212 73.11 390.925 72.054 390.925 70.14C390.925 68.259 392.179 67.17 393.664 67.17C395.49 67.17 396.436 68.501 396.403 70.371H391.959V70.481C392.014 71.68 392.795 72.307 393.686 72.307C394.445 72.307 395.028 71.933 395.259 71.262H396.304C395.963 72.472 394.918 73.11 393.686 73.11ZM391.97 69.623H395.347C395.237 68.545 394.566 67.973 393.653 67.973C392.795 67.973 392.08 68.545 391.97 69.623ZM402.993 65.3V73H401.97V71.988C401.519 72.714 400.793 73.11 399.979 73.11C398.637 73.11 397.46 72.043 397.46 70.14C397.46 68.237 398.648 67.17 399.979 67.17C400.793 67.17 401.519 67.566 401.97 68.292V65.3H402.993ZM398.494 70.14C398.494 71.504 399.264 72.307 400.232 72.307C401.189 72.307 401.97 71.504 401.97 70.14C401.97 68.776 401.189 67.973 400.232 67.973C399.264 67.973 398.494 68.776 398.494 70.14ZM412.346 65.3H413.38V72.175H416.889V73H412.346V65.3ZM419.167 65.3H420.201V72.175H423.71V73H419.167V65.3ZM427.353 71.9L425.868 66.785L425.923 67.61V73H424.944V65.3H426.385L427.76 70.536L429.124 65.3H430.565V73H429.586V67.61L429.641 66.785L428.156 71.9H427.353ZM438.839 71.471C438.839 70.448 439.675 69.766 441.611 69.634L442.634 69.568V69.458C442.634 68.314 442.161 67.973 441.369 67.973C440.632 67.973 440.104 68.369 439.983 69.04H438.993C439.191 67.907 440.181 67.17 441.391 67.17C442.722 67.17 443.657 67.786 443.657 69.645V72.032C443.657 72.428 443.701 72.769 443.767 73H442.788C442.733 72.78 442.711 72.483 442.711 72.164C442.392 72.736 441.82 73.11 440.852 73.11C439.521 73.11 438.839 72.362 438.839 71.471ZM439.862 71.438C439.862 71.933 440.225 72.362 441.094 72.362C442.095 72.362 442.634 71.691 442.634 70.866V70.305L441.809 70.36C440.456 70.459 439.862 70.778 439.862 71.438ZM445.583 70.14C445.583 68.182 446.859 67.17 448.333 67.17C449.62 67.17 450.61 67.852 450.929 69.128H449.895C449.653 68.38 449.114 67.973 448.333 67.973C447.387 67.973 446.606 68.71 446.606 70.14C446.606 71.57 447.387 72.307 448.333 72.307C449.114 72.307 449.664 71.9 449.906 71.141H450.929C450.621 72.428 449.62 73.11 448.333 73.11C446.859 73.11 445.583 72.098 445.583 70.14ZM452.405 70.14C452.405 68.182 453.681 67.17 455.155 67.17C456.442 67.17 457.432 67.852 457.751 69.128H456.717C456.475 68.38 455.936 67.973 455.155 67.973C454.209 67.973 453.428 68.71 453.428 70.14C453.428 71.57 454.209 72.307 455.155 72.307C455.936 72.307 456.486 71.9 456.728 71.141H457.751C457.443 72.428 456.442 73.11 455.155 73.11C453.681 73.11 452.405 72.098 452.405 70.14ZM461.899 73.11C460.425 73.11 459.138 72.054 459.138 70.14C459.138 68.259 460.392 67.17 461.877 67.17C463.703 67.17 464.649 68.501 464.616 70.371H460.172V70.481C460.227 71.68 461.008 72.307 461.899 72.307C462.658 72.307 463.241 71.933 463.472 71.262H464.517C464.176 72.472 463.131 73.11 461.899 73.11ZM460.183 69.623H463.56C463.45 68.545 462.779 67.973 461.866 67.973C461.008 67.973 460.293 68.545 460.183 69.623ZM468.379 70.448C467.356 70.272 466.498 69.898 466.498 68.831C466.498 67.775 467.521 67.17 468.676 67.17C469.743 67.17 470.755 67.731 470.898 68.798H469.875C469.787 68.248 469.303 67.973 468.676 67.973C468.005 67.973 467.521 68.292 467.521 68.82C467.521 69.381 468.005 69.513 468.489 69.612L469.039 69.733C470.271 69.953 471.008 70.382 471.008 71.416C471.008 72.472 470.029 73.11 468.742 73.11C467.521 73.11 466.542 72.516 466.355 71.339H467.378C467.488 72.021 468.06 72.307 468.742 72.307C469.512 72.307 469.985 71.977 469.985 71.427C469.985 70.877 469.567 70.701 468.929 70.558L468.379 70.448ZM475.201 70.448C474.178 70.272 473.32 69.898 473.32 68.831C473.32 67.775 474.343 67.17 475.498 67.17C476.565 67.17 477.577 67.731 477.72 68.798H476.697C476.609 68.248 476.125 67.973 475.498 67.973C474.827 67.973 474.343 68.292 474.343 68.82C474.343 69.381 474.827 69.513 475.311 69.612L475.861 69.733C477.093 69.953 477.83 70.382 477.83 71.416C477.83 72.472 476.851 73.11 475.564 73.11C474.343 73.11 473.364 72.516 473.177 71.339H474.2C474.31 72.021 474.882 72.307 475.564 72.307C476.334 72.307 476.807 71.977 476.807 71.427C476.807 70.877 476.389 70.701 475.751 70.558L475.201 70.448ZM481.791 71.735H482.869V73H481.791V71.735Z" fill="#2F2D33"/> +<path d="M323.482 180V240" stroke="#2F2D33"/> +<path d="M535.418 180V240" stroke="#2F2D33"/> +<path d="M747.354 180V240" stroke="#2F2D33"/> +<path d="M89.7184 390.7H91.2934C92.8684 390.7 93.7774 391.303 93.7774 392.608C93.7774 393.913 92.8684 394.516 91.2934 394.516H90.5554V397H89.7184V390.7ZM90.5554 393.841H91.2934C92.3554 393.841 92.9404 393.499 92.9404 392.608C92.9404 391.717 92.3554 391.375 91.2934 391.375H90.5554V393.841ZM95.1915 397V390.7H96.8205C98.2695 390.7 99.2595 391.249 99.2595 392.554C99.2595 393.526 98.7195 394.075 97.8555 394.291L99.4305 397H98.4855L97.0095 394.408H96.8205H96.0375V397H95.1915ZM96.0375 393.733H96.8205C97.7565 393.733 98.4225 393.445 98.4225 392.554C98.4225 391.663 97.7565 391.375 96.8205 391.375H96.0375V393.733ZM100.818 390.7H104.58V391.375H101.655V393.463H104.418V394.138H101.655V396.325H104.625V397H100.818V390.7ZM107.866 396.1L106.651 391.915L106.696 392.59V397H105.895V390.7H107.074L108.199 394.984L109.315 390.7H110.494V397H109.693V392.59L109.738 391.915L108.523 396.1H107.866ZM115.814 390.7V391.375H114.203V396.325H115.814V397H111.746V396.325H113.357V391.375H111.746V390.7H115.814ZM119.361 397.09C117.633 397.09 117.165 395.992 117.165 394.678V390.7H118.002V394.678C118.002 395.578 118.236 396.415 119.361 396.415C120.477 396.415 120.72 395.578 120.72 394.678V390.7H121.557V394.678C121.557 395.992 121.071 397.09 119.361 397.09ZM124.609 396.1L123.394 391.915L123.439 392.59V397H122.638V390.7H123.817L124.942 394.984L126.058 390.7H127.237V397H126.436V392.59L126.481 391.915L125.266 396.1H124.609Z" fill="#FBF8F8"/> +<path d="M1049 239H904V336H1049V239Z" fill="#DDD9EE"/> +<path d="M953.023 265V255.77H959.029V257.057H954.427V259.748H958.873V260.996H954.427V263.713H959.133V265H953.023ZM960.045 265L962.554 261.464L960.149 258.084H961.683L963.386 260.567L965.037 258.084H966.61L964.231 261.49L966.714 265H965.18L963.399 262.361L961.605 265H960.045ZM970.297 265C969.638 265 969.153 264.848 968.841 264.545C968.529 264.242 968.373 263.769 968.373 263.128V256.459H969.751V263.024C969.751 263.345 969.82 263.566 969.959 263.687C970.098 263.808 970.31 263.869 970.596 263.869H971.584V265H970.297ZM967.32 259.215V258.084H971.584V259.215H967.32ZM975.634 265.156C974.958 265.156 974.373 265.009 973.879 264.714C973.393 264.419 973.016 263.999 972.748 263.453C972.488 262.907 972.358 262.27 972.358 261.542C972.358 260.814 972.488 260.181 972.748 259.644C973.016 259.098 973.393 258.678 973.879 258.383C974.364 258.08 974.936 257.928 975.595 257.928C976.219 257.928 976.769 258.075 977.246 258.37C977.722 258.656 978.091 259.072 978.351 259.618C978.619 260.164 978.754 260.823 978.754 261.594V261.945H973.788C973.822 262.621 974 263.128 974.321 263.466C974.65 263.804 975.092 263.973 975.647 263.973C976.054 263.973 976.392 263.878 976.661 263.687C976.929 263.496 977.116 263.241 977.22 262.92L978.65 263.011C978.468 263.652 978.108 264.172 977.571 264.571C977.042 264.961 976.396 265.156 975.634 265.156ZM973.788 260.905H977.298C977.254 260.29 977.077 259.835 976.765 259.54C976.461 259.245 976.071 259.098 975.595 259.098C975.101 259.098 974.693 259.254 974.373 259.566C974.061 259.869 973.866 260.316 973.788 260.905ZM980.15 265V258.084H981.411L981.463 259.917L981.346 259.878C981.441 259.254 981.632 258.799 981.918 258.513C982.212 258.227 982.607 258.084 983.101 258.084H983.764V259.319H983.101C982.754 259.319 982.464 259.375 982.23 259.488C981.996 259.601 981.818 259.774 981.697 260.008C981.584 260.242 981.528 260.545 981.528 260.918V265H980.15ZM984.796 265V258.084H986.057L986.109 259.93L985.94 259.839C986.018 259.388 986.166 259.024 986.382 258.747C986.599 258.47 986.863 258.266 987.175 258.136C987.487 257.997 987.825 257.928 988.189 257.928C988.709 257.928 989.138 258.045 989.476 258.279C989.823 258.504 990.083 258.816 990.256 259.215C990.438 259.605 990.529 260.051 990.529 260.554V265H989.151V260.97C989.151 260.563 989.108 260.22 989.021 259.943C988.935 259.666 988.792 259.453 988.592 259.306C988.393 259.159 988.133 259.085 987.812 259.085C987.327 259.085 986.933 259.245 986.629 259.566C986.326 259.887 986.174 260.355 986.174 260.97V265H984.796ZM994.274 265.156C993.555 265.156 992.979 264.991 992.545 264.662C992.121 264.333 991.908 263.869 991.908 263.271C991.908 262.673 992.086 262.209 992.441 261.88C992.805 261.542 993.364 261.299 994.118 261.152L996.497 260.697C996.497 260.16 996.372 259.761 996.12 259.501C995.869 259.232 995.496 259.098 995.002 259.098C994.56 259.098 994.214 259.198 993.962 259.397C993.711 259.588 993.538 259.874 993.442 260.255L992.025 260.164C992.155 259.471 992.476 258.925 992.987 258.526C993.507 258.127 994.179 257.928 995.002 257.928C995.938 257.928 996.649 258.179 997.134 258.682C997.628 259.176 997.875 259.874 997.875 260.775V263.466C997.875 263.631 997.901 263.748 997.953 263.817C998.014 263.878 998.109 263.908 998.239 263.908H998.486V265C998.443 265.009 998.374 265.017 998.278 265.026C998.183 265.035 998.083 265.039 997.979 265.039C997.685 265.039 997.429 264.991 997.212 264.896C997.004 264.801 996.848 264.645 996.744 264.428C996.64 264.203 996.588 263.904 996.588 263.531L996.731 263.596C996.662 263.899 996.51 264.168 996.276 264.402C996.051 264.636 995.761 264.822 995.405 264.961C995.059 265.091 994.682 265.156 994.274 265.156ZM994.495 264.064C994.911 264.064 995.267 263.986 995.561 263.83C995.856 263.665 996.086 263.44 996.25 263.154C996.415 262.868 996.497 262.543 996.497 262.179V261.737L994.469 262.127C994.053 262.205 993.759 262.331 993.585 262.504C993.421 262.669 993.338 262.881 993.338 263.141C993.338 263.436 993.438 263.665 993.637 263.83C993.845 263.986 994.131 264.064 994.495 264.064ZM1001.07 265C1000.62 265 1000.25 264.883 999.977 264.649C999.7 264.415 999.561 264.042 999.561 263.531V255.77H1000.94V263.401C1000.94 263.557 1000.98 263.674 1001.06 263.752C1001.14 263.83 1001.26 263.869 1001.42 263.869H1001.97V265H1001.07Z" fill="#090B0B"/> +<path d="M940.427 282.95V274.084H941.727L941.766 275.566L941.61 275.488C941.783 274.977 942.069 274.591 942.468 274.331C942.867 274.062 943.326 273.928 943.846 273.928C944.522 273.928 945.077 274.097 945.51 274.435C945.952 274.764 946.277 275.202 946.485 275.748C946.702 276.294 946.81 276.892 946.81 277.542C946.81 278.192 946.702 278.79 946.485 279.336C946.277 279.882 945.952 280.324 945.51 280.662C945.077 280.991 944.522 281.156 943.846 281.156C943.499 281.156 943.174 281.095 942.871 280.974C942.568 280.853 942.308 280.684 942.091 280.467C941.883 280.242 941.736 279.977 941.649 279.674L941.805 279.518V282.95H940.427ZM943.599 279.96C944.154 279.96 944.587 279.748 944.899 279.323C945.22 278.898 945.38 278.305 945.38 277.542C945.38 276.779 945.22 276.186 944.899 275.761C944.587 275.336 944.154 275.124 943.599 275.124C943.235 275.124 942.919 275.215 942.65 275.397C942.381 275.57 942.173 275.839 942.026 276.203C941.879 276.567 941.805 277.013 941.805 277.542C941.805 278.071 941.874 278.517 942.013 278.881C942.16 279.245 942.368 279.518 942.637 279.7C942.914 279.873 943.235 279.96 943.599 279.96ZM948.209 281V274.084H949.47L949.522 275.917L949.405 275.878C949.501 275.254 949.691 274.799 949.977 274.513C950.272 274.227 950.666 274.084 951.16 274.084H951.823V275.319H951.16C950.814 275.319 950.523 275.375 950.289 275.488C950.055 275.601 949.878 275.774 949.756 276.008C949.644 276.242 949.587 276.545 949.587 276.918V281H948.209ZM955.652 281.156C954.993 281.156 954.417 281.009 953.923 280.714C953.429 280.419 953.048 279.999 952.779 279.453C952.51 278.907 952.376 278.27 952.376 277.542C952.376 276.805 952.51 276.168 952.779 275.631C953.048 275.094 953.429 274.678 953.923 274.383C954.417 274.08 954.993 273.928 955.652 273.928C956.311 273.928 956.887 274.08 957.381 274.383C957.875 274.678 958.256 275.094 958.525 275.631C958.794 276.168 958.928 276.805 958.928 277.542C958.928 278.27 958.794 278.907 958.525 279.453C958.256 279.999 957.875 280.419 957.381 280.714C956.887 281.009 956.311 281.156 955.652 281.156ZM955.652 279.96C956.241 279.96 956.696 279.748 957.017 279.323C957.338 278.89 957.498 278.296 957.498 277.542C957.498 276.788 957.338 276.199 957.017 275.774C956.696 275.341 956.241 275.124 955.652 275.124C955.071 275.124 954.616 275.341 954.287 275.774C953.966 276.199 953.806 276.788 953.806 277.542C953.806 278.296 953.966 278.89 954.287 279.323C954.616 279.748 955.071 279.96 955.652 279.96ZM961.938 281L959.416 274.084H960.885L962.77 279.596L964.655 274.084H966.137L963.602 281H961.938ZM967.252 281V274.084H968.63V281H967.252ZM967.226 273.083V271.705H968.656V273.083H967.226ZM973.116 281.156C972.466 281.156 971.916 281.056 971.465 280.857C971.023 280.658 970.681 280.385 970.438 280.038C970.196 279.683 970.057 279.275 970.022 278.816L971.439 278.751C971.509 279.132 971.673 279.436 971.933 279.661C972.193 279.886 972.588 279.999 973.116 279.999C973.55 279.999 973.892 279.93 974.143 279.791C974.395 279.652 974.52 279.431 974.52 279.128C974.52 278.963 974.477 278.825 974.39 278.712C974.312 278.591 974.156 278.491 973.922 278.413C973.688 278.326 973.342 278.244 972.882 278.166C972.198 278.036 971.66 277.884 971.27 277.711C970.88 277.538 970.603 277.317 970.438 277.048C970.274 276.779 970.191 276.454 970.191 276.073C970.191 275.44 970.425 274.925 970.893 274.526C971.37 274.127 972.055 273.928 972.947 273.928C973.545 273.928 974.048 274.032 974.455 274.24C974.863 274.439 975.179 274.712 975.404 275.059C975.638 275.397 975.786 275.778 975.846 276.203L974.429 276.281C974.386 276.038 974.299 275.83 974.169 275.657C974.048 275.475 973.883 275.336 973.675 275.241C973.467 275.137 973.22 275.085 972.934 275.085C972.492 275.085 972.163 275.172 971.946 275.345C971.73 275.518 971.621 275.744 971.621 276.021C971.621 276.229 971.669 276.398 971.764 276.528C971.868 276.658 972.033 276.766 972.258 276.853C972.484 276.931 972.783 277 973.155 277.061C973.875 277.182 974.438 277.334 974.845 277.516C975.253 277.689 975.539 277.906 975.703 278.166C975.868 278.426 975.95 278.742 975.95 279.115C975.95 279.548 975.829 279.917 975.586 280.22C975.344 280.523 975.01 280.757 974.585 280.922C974.161 281.078 973.671 281.156 973.116 281.156ZM977.345 281V274.084H978.723V281H977.345ZM977.319 273.083V271.705H978.749V273.083H977.319ZM983.391 281.156C982.733 281.156 982.156 281.009 981.662 280.714C981.168 280.419 980.787 279.999 980.518 279.453C980.25 278.907 980.115 278.27 980.115 277.542C980.115 276.805 980.25 276.168 980.518 275.631C980.787 275.094 981.168 274.678 981.662 274.383C982.156 274.08 982.733 273.928 983.391 273.928C984.05 273.928 984.626 274.08 985.12 274.383C985.614 274.678 985.996 275.094 986.264 275.631C986.533 276.168 986.667 276.805 986.667 277.542C986.667 278.27 986.533 278.907 986.264 279.453C985.996 279.999 985.614 280.419 985.12 280.714C984.626 281.009 984.05 281.156 983.391 281.156ZM983.391 279.96C983.981 279.96 984.436 279.748 984.756 279.323C985.077 278.89 985.237 278.296 985.237 277.542C985.237 276.788 985.077 276.199 984.756 275.774C984.436 275.341 983.981 275.124 983.391 275.124C982.811 275.124 982.356 275.341 982.026 275.774C981.706 276.199 981.545 276.788 981.545 277.542C981.545 278.296 981.706 278.89 982.026 279.323C982.356 279.748 982.811 279.96 983.391 279.96ZM988.06 281V274.084H989.321L989.373 275.93L989.204 275.839C989.282 275.388 989.429 275.024 989.646 274.747C989.862 274.47 990.127 274.266 990.439 274.136C990.751 273.997 991.089 273.928 991.453 273.928C991.973 273.928 992.402 274.045 992.74 274.279C993.086 274.504 993.346 274.816 993.52 275.215C993.702 275.605 993.793 276.051 993.793 276.554V281H992.415V276.97C992.415 276.563 992.371 276.22 992.285 275.943C992.198 275.666 992.055 275.453 991.856 275.306C991.656 275.159 991.396 275.085 991.076 275.085C990.59 275.085 990.196 275.245 989.893 275.566C989.589 275.887 989.438 276.355 989.438 276.97V281H988.06ZM998.435 281.156C997.759 281.156 997.174 281.009 996.68 280.714C996.195 280.419 995.818 279.999 995.549 279.453C995.289 278.907 995.159 278.27 995.159 277.542C995.159 276.814 995.289 276.181 995.549 275.644C995.818 275.098 996.195 274.678 996.68 274.383C997.165 274.08 997.737 273.928 998.396 273.928C999.02 273.928 999.57 274.075 1000.05 274.37C1000.52 274.656 1000.89 275.072 1001.15 275.618C1001.42 276.164 1001.56 276.823 1001.56 277.594V277.945H996.589C996.624 278.621 996.801 279.128 997.122 279.466C997.451 279.804 997.893 279.973 998.448 279.973C998.855 279.973 999.193 279.878 999.462 279.687C999.731 279.496 999.917 279.241 1000.02 278.92L1001.45 279.011C1001.27 279.652 1000.91 280.172 1000.37 280.571C999.843 280.961 999.198 281.156 998.435 281.156ZM996.589 276.905H1000.1C1000.06 276.29 999.878 275.835 999.566 275.54C999.263 275.245 998.873 275.098 998.396 275.098C997.902 275.098 997.495 275.254 997.174 275.566C996.862 275.869 996.667 276.316 996.589 276.905ZM1002.95 281V274.084H1004.21L1004.26 275.917L1004.15 275.878C1004.24 275.254 1004.43 274.799 1004.72 274.513C1005.01 274.227 1005.41 274.084 1005.9 274.084H1006.57V275.319H1005.9C1005.56 275.319 1005.27 275.375 1005.03 275.488C1004.8 275.601 1004.62 275.774 1004.5 276.008C1004.39 276.242 1004.33 276.545 1004.33 276.918V281H1002.95ZM1010.21 281.156C1009.56 281.156 1009.01 281.056 1008.56 280.857C1008.12 280.658 1007.78 280.385 1007.53 280.038C1007.29 279.683 1007.15 279.275 1007.12 278.816L1008.54 278.751C1008.6 279.132 1008.77 279.436 1009.03 279.661C1009.29 279.886 1009.68 279.999 1010.21 279.999C1010.65 279.999 1010.99 279.93 1011.24 279.791C1011.49 279.652 1011.62 279.431 1011.62 279.128C1011.62 278.963 1011.57 278.825 1011.49 278.712C1011.41 278.591 1011.25 278.491 1011.02 278.413C1010.78 278.326 1010.44 278.244 1009.98 278.166C1009.29 278.036 1008.76 277.884 1008.37 277.711C1007.98 277.538 1007.7 277.317 1007.53 277.048C1007.37 276.779 1007.29 276.454 1007.29 276.073C1007.29 275.44 1007.52 274.925 1007.99 274.526C1008.47 274.127 1009.15 273.928 1010.04 273.928C1010.64 273.928 1011.14 274.032 1011.55 274.24C1011.96 274.439 1012.27 274.712 1012.5 275.059C1012.73 275.397 1012.88 275.778 1012.94 276.203L1011.53 276.281C1011.48 276.038 1011.4 275.83 1011.27 275.657C1011.14 275.475 1010.98 275.336 1010.77 275.241C1010.56 275.137 1010.32 275.085 1010.03 275.085C1009.59 275.085 1009.26 275.172 1009.04 275.345C1008.83 275.518 1008.72 275.744 1008.72 276.021C1008.72 276.229 1008.76 276.398 1008.86 276.528C1008.96 276.658 1009.13 276.766 1009.35 276.853C1009.58 276.931 1009.88 277 1010.25 277.061C1010.97 277.182 1011.53 277.334 1011.94 277.516C1012.35 277.689 1012.63 277.906 1012.8 278.166C1012.96 278.426 1013.05 278.742 1013.05 279.115C1013.05 279.548 1012.92 279.917 1012.68 280.22C1012.44 280.523 1012.11 280.757 1011.68 280.922C1011.26 281.078 1010.77 281.156 1010.21 281.156Z" fill="#090B0B"/> +<path d="M952.767 308V301H954.577C956.187 301 957.287 301.61 957.287 303.06C957.287 304.14 956.687 304.75 955.727 304.99L957.477 308H956.427L954.787 305.12H954.577H953.707V308H952.767ZM953.707 304.37H954.577C955.617 304.37 956.357 304.05 956.357 303.06C956.357 302.07 955.617 301.75 954.577 301.75H953.707V304.37ZM961.049 308.1C959.709 308.1 958.539 307.14 958.539 305.4C958.539 303.69 959.679 302.7 961.029 302.7C962.689 302.7 963.549 303.91 963.519 305.61H959.479V305.71C959.529 306.8 960.239 307.37 961.049 307.37C961.739 307.37 962.269 307.03 962.479 306.42H963.429C963.119 307.52 962.169 308.1 961.049 308.1ZM959.489 304.93H962.559C962.459 303.95 961.849 303.43 961.019 303.43C960.239 303.43 959.589 303.95 959.489 304.93ZM966.9 307.84C965.72 307.84 964.66 306.94 964.66 305.27C964.66 303.6 965.72 302.7 966.9 302.7C967.64 302.7 968.26 303.03 968.64 303.67V302.8H969.57V307.06C969.57 309.02 968.67 309.9 967.13 309.9C965.88 309.9 965.02 309.24 964.86 308.18H965.75C965.86 308.81 966.35 309.22 967.13 309.22C968.21 309.22 968.64 308.54 968.64 307.06V306.87C968.26 307.51 967.64 307.84 966.9 307.84ZM965.59 305.27C965.59 306.46 966.26 307.11 967.11 307.11C967.96 307.11 968.64 306.46 968.64 305.27C968.64 304.09 967.96 303.43 967.11 303.43C966.26 303.43 965.59 304.09 965.59 305.27ZM974.251 302.8V307.25H975.841V308H971.531V307.25H973.321V303.55H971.531V302.8H974.251ZM973.291 302.06V300.91H974.281V302.06H973.291ZM979.622 308.1C978.302 308.1 977.142 307.18 977.142 305.4C977.142 303.62 978.302 302.7 979.622 302.7C980.942 302.7 982.112 303.62 982.112 305.4C982.112 307.18 980.942 308.1 979.622 308.1ZM978.072 305.4C978.072 306.72 978.772 307.37 979.622 307.37C980.472 307.37 981.172 306.72 981.172 305.4C981.172 304.08 980.472 303.43 979.622 303.43C978.772 303.43 978.072 304.08 978.072 305.4ZM987.163 304.94C987.163 303.9 986.733 303.43 985.993 303.43C985.153 303.43 984.603 304.02 984.603 305.13V308H983.673V302.8H984.603V303.72C984.933 303.05 985.543 302.7 986.323 302.7C987.383 302.7 988.093 303.35 988.093 304.8V308H987.163V304.94ZM989.694 306.61C989.694 305.68 990.454 305.06 992.214 304.94L993.144 304.88V304.78C993.144 303.74 992.714 303.43 991.994 303.43C991.324 303.43 990.844 303.79 990.734 304.4H989.834C990.014 303.37 990.914 302.7 992.014 302.7C993.224 302.7 994.074 303.26 994.074 304.95V307.12C994.074 307.48 994.114 307.79 994.174 308H993.284C993.234 307.8 993.214 307.53 993.214 307.24C992.924 307.76 992.404 308.1 991.524 308.1C990.314 308.1 989.694 307.42 989.694 306.61ZM990.624 306.58C990.624 307.03 990.954 307.42 991.744 307.42C992.654 307.42 993.144 306.81 993.144 306.06V305.55L992.394 305.6C991.164 305.69 990.624 305.98 990.624 306.58ZM998.926 301V307.25H1000.52V308H996.206V307.25H997.996V301.75H996.206V301H998.926Z" fill="#090B0B"/> +<path d="M938.714 315V315.75H936.684V322H935.744V315.75H933.704V315H938.714ZM942.445 322.1C941.105 322.1 939.935 321.14 939.935 319.4C939.935 317.69 941.075 316.7 942.425 316.7C944.085 316.7 944.945 317.91 944.915 319.61H940.875V319.71C940.925 320.8 941.635 321.37 942.445 321.37C943.135 321.37 943.665 321.03 943.875 320.42H944.825C944.515 321.52 943.565 322.1 942.445 322.1ZM940.885 318.93H943.955C943.855 317.95 943.245 317.43 942.415 317.43C941.635 317.43 940.985 317.95 940.885 318.93ZM948.776 316.8V317.6C949.026 317.05 949.496 316.8 950.276 316.8H951.256V317.55H950.196C949.246 317.55 948.776 317.93 948.776 319.02V321.25H950.556V322H946.236V321.25H947.846V317.55H946.236V316.8H948.776ZM954.978 316.8V317.6C955.228 317.05 955.698 316.8 956.478 316.8H957.458V317.55H956.398C955.448 317.55 954.978 317.93 954.978 319.02V321.25H956.758V322H952.438V321.25H954.048V317.55H952.438V316.8H954.978ZM958.689 320.61C958.689 319.68 959.449 319.06 961.209 318.94L962.139 318.88V318.78C962.139 317.74 961.709 317.43 960.989 317.43C960.319 317.43 959.839 317.79 959.729 318.4H958.829C959.009 317.37 959.909 316.7 961.009 316.7C962.219 316.7 963.069 317.26 963.069 318.95V321.12C963.069 321.48 963.109 321.79 963.169 322H962.279C962.229 321.8 962.209 321.53 962.209 321.24C961.919 321.76 961.399 322.1 960.519 322.1C959.309 322.1 958.689 321.42 958.689 320.61ZM959.619 320.58C959.619 321.03 959.949 321.42 960.739 321.42C961.649 321.42 962.139 320.81 962.139 320.06V319.55L961.389 319.6C960.159 319.69 959.619 319.98 959.619 320.58ZM967.59 316.39V316.8H969.43V317.55H967.59V321.25H969.33V322H965.01V321.25H966.65V317.55H965.01V316.8H966.65V316.39C966.65 315.22 967.37 315 968.22 315H969.43V315.66H968.46C967.98 315.66 967.59 315.72 967.59 316.39ZM973.421 322.1C972.101 322.1 970.941 321.18 970.941 319.4C970.941 317.62 972.101 316.7 973.421 316.7C974.741 316.7 975.911 317.62 975.911 319.4C975.911 321.18 974.741 322.1 973.421 322.1ZM971.871 319.4C971.871 320.72 972.571 321.37 973.421 321.37C974.271 321.37 974.971 320.72 974.971 319.4C974.971 318.08 974.271 317.43 973.421 317.43C972.571 317.43 971.871 318.08 971.871 319.4ZM979.782 316.8V317.6C980.032 317.05 980.502 316.8 981.282 316.8H982.262V317.55H981.202C980.252 317.55 979.782 317.93 979.782 319.02V321.25H981.562V322H977.242V321.25H978.852V317.55H977.242V316.8H979.782ZM983.193 322V316.8H984.083V317.26C984.283 316.94 984.613 316.7 985.103 316.7C985.703 316.7 986.013 317.03 986.153 317.47C986.343 317.04 986.683 316.7 987.283 316.7C988.233 316.7 988.453 317.52 988.453 318.29V322H987.563V318.36C987.563 317.93 987.503 317.43 986.963 317.43C986.413 317.43 986.273 318.01 986.263 318.37V322H985.383V318.36C985.383 317.93 985.313 317.43 984.783 317.43C984.223 317.43 984.093 318.01 984.083 318.37V322H983.193ZM998.256 322.1C996.916 322.1 995.746 321.14 995.746 319.4C995.746 317.69 996.886 316.7 998.236 316.7C999.896 316.7 1000.76 317.91 1000.73 319.61H996.686V319.71C996.736 320.8 997.446 321.37 998.256 321.37C998.946 321.37 999.476 321.03 999.686 320.42H1000.64C1000.33 321.52 999.376 322.1 998.256 322.1ZM996.696 318.93H999.766C999.666 317.95 999.056 317.43 998.226 317.43C997.446 317.43 996.796 317.95 996.696 318.93ZM1002 322L1003.92 319.38L1002.02 316.8H1003.03L1004.42 318.72L1005.82 316.8H1006.83L1004.93 319.38L1006.86 322H1005.85L1004.43 320.04L1003 322H1002ZM1010.66 322.1C1009.32 322.1 1008.15 321.14 1008.15 319.4C1008.15 317.69 1009.29 316.7 1010.64 316.7C1012.3 316.7 1013.16 317.91 1013.13 319.61H1009.09V319.71C1009.14 320.8 1009.85 321.37 1010.66 321.37C1011.35 321.37 1011.88 321.03 1012.09 320.42H1013.04C1012.73 321.52 1011.78 322.1 1010.66 322.1ZM1009.1 318.93H1012.17C1012.07 317.95 1011.46 317.43 1010.63 317.43C1009.85 317.43 1009.2 317.95 1009.1 318.93ZM1014.43 319.4C1014.43 317.62 1015.59 316.7 1016.93 316.7C1018.1 316.7 1019 317.32 1019.29 318.48H1018.35C1018.13 317.8 1017.64 317.43 1016.93 317.43C1016.07 317.43 1015.36 318.1 1015.36 319.4C1015.36 320.7 1016.07 321.37 1016.93 321.37C1017.64 321.37 1018.14 321 1018.36 320.31H1019.29C1019.01 321.48 1018.1 322.1 1016.93 322.1C1015.59 322.1 1014.43 321.18 1014.43 319.4Z" fill="#090B0B"/> +<path d="M881.209 291H903.518" stroke="#2F2D33"/> +<path d="M881.209 240H189.627V495H881.209V240Z" fill="#F8F2F1" stroke="#2F2D33" stroke-dasharray="8 8"/> +<path d="M226.437 254H223.091V263H226.437V254Z" fill="#FBF8F8"/> +<path d="M480.182 260.168C477.242 260.168 475.324 258.054 475.324 255.03C475.324 252.006 477.242 249.892 480.182 249.892C482.576 249.892 484.116 251.404 484.522 253.35H482.548C482.296 252.286 481.47 251.53 480.182 251.53C478.362 251.53 477.298 252.986 477.298 255.03C477.298 257.074 478.362 258.53 480.182 258.53C481.554 258.53 482.296 257.774 482.548 256.71H484.522C484.13 258.656 482.646 260.168 480.182 260.168ZM488.805 260.168C486.621 260.168 485.179 258.586 485.179 256.43C485.179 254.302 486.621 252.72 488.805 252.72C490.989 252.72 492.431 254.302 492.431 256.43C492.431 258.586 490.989 260.168 488.805 260.168ZM488.805 258.88C489.925 258.88 490.555 257.872 490.555 256.43C490.555 255.002 489.925 254.008 488.805 254.008C487.685 254.008 487.055 255.002 487.055 256.43C487.055 257.872 487.685 258.88 488.805 258.88ZM496.16 260.168C494.34 260.168 493.15 258.614 493.15 256.444C493.15 254.288 494.34 252.72 496.16 252.72C497.182 252.72 497.938 253.21 498.358 253.994V250.06H500.122V260H498.358V258.894C497.938 259.664 497.182 260.168 496.16 260.168ZM496.72 258.852C497.798 258.852 498.4 257.858 498.4 256.444C498.4 255.016 497.798 254.036 496.72 254.036C495.642 254.036 495.04 255.016 495.04 256.444C495.04 257.858 495.642 258.852 496.72 258.852ZM501.189 256.43C501.189 254.302 502.631 252.72 504.815 252.72C507.055 252.72 508.441 254.47 508.357 256.822H503.009C503.079 258.124 503.751 258.908 504.857 258.908C505.739 258.908 506.229 258.39 506.453 257.732H508.217C507.825 259.118 506.663 260.168 504.843 260.168C502.659 260.168 501.189 258.586 501.189 256.43ZM506.565 255.716C506.397 254.666 505.795 253.966 504.815 253.966C503.835 253.966 503.219 254.652 503.065 255.716H506.565ZM513.209 252.86H513.699V254.456H513.013C511.823 254.456 511.179 254.918 511.165 256.374V260H509.401V252.86H511.123V254.19C511.389 253.476 512.061 252.86 513.209 252.86ZM523.391 257.564C523.069 259.244 521.823 260.168 520.087 260.168C517.931 260.168 516.461 258.586 516.461 256.43C516.461 254.302 517.917 252.72 520.073 252.72C521.823 252.72 523.069 253.672 523.391 255.31H521.613C521.431 254.498 520.927 254.008 520.101 254.008C518.967 254.008 518.337 255.002 518.337 256.43C518.337 257.872 518.967 258.88 520.101 258.88C520.997 258.88 521.431 258.348 521.613 257.564H523.391ZM527.62 260.168C525.436 260.168 523.994 258.586 523.994 256.43C523.994 254.302 525.436 252.72 527.62 252.72C529.804 252.72 531.246 254.302 531.246 256.43C531.246 258.586 529.804 260.168 527.62 260.168ZM527.62 258.88C528.74 258.88 529.37 257.872 529.37 256.43C529.37 255.002 528.74 254.008 527.62 254.008C526.5 254.008 525.87 255.002 525.87 256.43C525.87 257.872 526.5 258.88 527.62 258.88ZM532.315 252.86H534.051V254.022C534.457 253.336 535.157 252.72 536.375 252.72C537.915 252.72 538.727 253.658 538.727 255.114V260H536.949V255.506C536.949 254.638 536.599 254.106 535.647 254.106C534.737 254.106 534.079 254.82 534.079 255.94V260H532.315V252.86ZM543.834 254.078H542.266V257.942C542.266 258.558 542.42 258.726 542.994 258.726H543.834V260H542.42C540.964 260 540.502 259.384 540.502 258.068V254.078H539.438V252.86H540.502V250.956H542.266V252.86H543.834V254.078ZM548.66 252.86H549.15V254.456H548.464C547.274 254.456 546.63 254.918 546.616 256.374V260H544.852V252.86H546.574V254.19C546.84 253.476 547.512 252.86 548.66 252.86ZM553.009 260.168C550.825 260.168 549.383 258.586 549.383 256.43C549.383 254.302 550.825 252.72 553.009 252.72C555.193 252.72 556.635 254.302 556.635 256.43C556.635 258.586 555.193 260.168 553.009 260.168ZM553.009 258.88C554.129 258.88 554.759 257.872 554.759 256.43C554.759 255.002 554.129 254.008 553.009 254.008C551.889 254.008 551.259 255.002 551.259 256.43C551.259 257.872 551.889 258.88 553.009 258.88ZM557.704 250.06H559.468V260H557.704V250.06ZM563.5 252.86H565.264V253.994C565.684 253.224 566.44 252.72 567.462 252.72C569.296 252.72 570.472 254.274 570.472 256.444C570.472 258.6 569.296 260.168 567.462 260.168C566.44 260.168 565.684 259.678 565.264 258.894V262.38H563.5V252.86ZM566.902 258.852C567.98 258.852 568.596 257.872 568.596 256.444C568.596 255.03 567.98 254.036 566.902 254.036C565.824 254.036 565.222 255.03 565.222 256.444C565.222 257.872 565.824 258.852 566.902 258.852ZM571.553 250.06H573.317V260H571.553V250.06ZM576.811 260.168C575.425 260.168 574.347 259.37 574.347 257.984C574.347 256.556 575.425 256.108 577.105 255.674C578.225 255.38 578.967 255.254 578.967 254.708C578.967 254.246 578.505 253.924 577.707 253.924C576.881 253.924 576.349 254.288 576.293 254.96H574.543C574.711 253.7 575.803 252.72 577.763 252.72C579.681 252.72 580.787 253.518 580.787 255.016V260H579.107V258.782C578.841 259.524 578.155 260.168 576.811 260.168ZM577.399 258.936C578.435 258.936 579.037 258.068 579.037 257.018V255.772C578.883 256.15 578.449 256.346 577.721 256.57C576.769 256.85 576.167 257.144 576.167 257.886C576.167 258.544 576.685 258.936 577.399 258.936ZM582.163 252.86H583.899V254.022C584.305 253.336 585.005 252.72 586.223 252.72C587.763 252.72 588.575 253.658 588.575 255.114V260H586.797V255.506C586.797 254.638 586.447 254.106 585.495 254.106C584.585 254.106 583.927 254.82 583.927 255.94V260H582.163V252.86ZM589.578 256.43C589.578 254.302 591.02 252.72 593.204 252.72C595.444 252.72 596.83 254.47 596.746 256.822H591.398C591.468 258.124 592.14 258.908 593.246 258.908C594.128 258.908 594.618 258.39 594.842 257.732H596.606C596.214 259.118 595.052 260.168 593.232 260.168C591.048 260.168 589.578 258.586 589.578 256.43ZM594.954 255.716C594.786 254.666 594.184 253.966 593.204 253.966C592.224 253.966 591.608 254.652 591.454 255.716H594.954Z" fill="#090B0B"/> +<path d="M427.158 267.3C425.596 269.83 425.596 274.45 427.158 276.98H426.234C424.54 274.45 424.54 269.83 426.234 267.3H427.158ZM430.129 272.14C430.129 270.182 431.405 269.17 432.879 269.17C434.166 269.17 435.156 269.852 435.475 271.128H434.441C434.199 270.38 433.66 269.973 432.879 269.973C431.933 269.973 431.152 270.71 431.152 272.14C431.152 273.57 431.933 274.307 432.879 274.307C433.66 274.307 434.21 273.9 434.452 273.141H435.475C435.167 274.428 434.166 275.11 432.879 275.11C431.405 275.11 430.129 274.098 430.129 272.14ZM439.59 275.11C438.138 275.11 436.862 274.098 436.862 272.14C436.862 270.182 438.138 269.17 439.59 269.17C441.042 269.17 442.329 270.182 442.329 272.14C442.329 274.098 441.042 275.11 439.59 275.11ZM437.885 272.14C437.885 273.592 438.655 274.307 439.59 274.307C440.525 274.307 441.295 273.592 441.295 272.14C441.295 270.688 440.525 269.973 439.59 269.973C438.655 269.973 437.885 270.688 437.885 272.14ZM448.931 267.3V275H447.908V273.988C447.457 274.714 446.731 275.11 445.917 275.11C444.575 275.11 443.398 274.043 443.398 272.14C443.398 270.237 444.586 269.17 445.917 269.17C446.731 269.17 447.457 269.566 447.908 270.292V267.3H448.931ZM444.432 272.14C444.432 273.504 445.202 274.307 446.17 274.307C447.127 274.307 447.908 273.504 447.908 272.14C447.908 270.776 447.127 269.973 446.17 269.973C445.202 269.973 444.432 270.776 444.432 272.14ZM453.266 275.11C451.792 275.11 450.505 274.054 450.505 272.14C450.505 270.259 451.759 269.17 453.244 269.17C455.07 269.17 456.016 270.501 455.983 272.371H451.539V272.481C451.594 273.68 452.375 274.307 453.266 274.307C454.025 274.307 454.608 273.933 454.839 273.262H455.884C455.543 274.472 454.498 275.11 453.266 275.11ZM451.55 271.623H454.927C454.817 270.545 454.146 269.973 453.233 269.973C452.375 269.973 451.66 270.545 451.55 271.623ZM460.23 269.28V270.16C460.505 269.555 461.022 269.28 461.88 269.28H462.958V270.105H461.792C460.747 270.105 460.23 270.523 460.23 271.722V274.175H462.188V275H457.436V274.175H459.207V270.105H457.436V269.28H460.23ZM469.395 267.3V275H468.372V273.988C467.921 274.714 467.195 275.11 466.381 275.11C465.039 275.11 463.862 274.043 463.862 272.14C463.862 270.237 465.05 269.17 466.381 269.17C467.195 269.17 467.921 269.566 468.372 270.292V267.3H469.395ZM464.896 272.14C464.896 273.504 465.666 274.307 466.634 274.307C467.591 274.307 468.372 273.504 468.372 272.14C468.372 270.776 467.591 269.973 466.634 269.973C465.666 269.973 464.896 270.776 464.896 272.14ZM480.518 275.11C479.066 275.11 477.79 274.098 477.79 272.14C477.79 270.182 479.066 269.17 480.518 269.17C481.97 269.17 483.257 270.182 483.257 272.14C483.257 274.098 481.97 275.11 480.518 275.11ZM478.813 272.14C478.813 273.592 479.583 274.307 480.518 274.307C481.453 274.307 482.223 273.592 482.223 272.14C482.223 270.688 481.453 269.973 480.518 269.973C479.583 269.973 478.813 270.688 478.813 272.14ZM488.813 271.634C488.813 270.49 488.34 269.973 487.526 269.973C486.602 269.973 485.997 270.622 485.997 271.843V275H484.974V269.28H485.997V270.292C486.36 269.555 487.031 269.17 487.889 269.17C489.055 269.17 489.836 269.885 489.836 271.48V275H488.813V271.634ZM503.05 267.3H504.106L501.609 275H500.344L497.847 267.3H498.903L500.982 274.109L503.05 267.3ZM507.396 273.9L505.911 268.785L505.966 269.61V275H504.987V267.3H506.428L507.803 272.536L509.167 267.3H510.608V275H509.629V269.61L509.684 268.785L508.199 273.9H507.396ZM514.097 275V273.735H515.186V275L514.735 276.364H514.163L514.548 275H514.097ZM525.902 275V267.3H526.925V271.084L529.972 267.3H531.204L528.487 270.6L531.204 275H530.049L527.794 271.436L526.925 272.492V275H525.902ZM535.088 275.11C533.625 275.11 532.371 274.23 532.371 272.888C532.371 271.909 533.042 271.216 533.966 270.908C533.174 270.644 532.624 270.061 532.624 269.181C532.624 267.982 533.724 267.19 535.088 267.19C536.463 267.19 537.552 267.982 537.552 269.181C537.552 270.061 537.013 270.644 536.21 270.919C537.134 271.216 537.805 271.909 537.805 272.888C537.805 274.23 536.551 275.11 535.088 275.11ZM533.394 272.833C533.394 273.768 534.197 274.285 535.088 274.285C535.979 274.285 536.782 273.768 536.782 272.833C536.782 271.887 535.979 271.37 535.088 271.37C534.197 271.37 533.394 271.887 533.394 272.833ZM533.647 269.28C533.647 270.094 534.296 270.545 535.088 270.545C535.88 270.545 536.529 270.094 536.529 269.28C536.529 268.466 535.88 268.015 535.088 268.015C534.296 268.015 533.647 268.466 533.647 269.28ZM541.602 272.448C540.579 272.272 539.721 271.898 539.721 270.831C539.721 269.775 540.744 269.17 541.899 269.17C542.966 269.17 543.978 269.731 544.121 270.798H543.098C543.01 270.248 542.526 269.973 541.899 269.973C541.228 269.973 540.744 270.292 540.744 270.82C540.744 271.381 541.228 271.513 541.712 271.612L542.262 271.733C543.494 271.953 544.231 272.382 544.231 273.416C544.231 274.472 543.252 275.11 541.965 275.11C540.744 275.11 539.765 274.516 539.578 273.339H540.601C540.711 274.021 541.283 274.307 541.965 274.307C542.735 274.307 543.208 273.977 543.208 273.427C543.208 272.877 542.79 272.701 542.152 272.558L541.602 272.448ZM555.552 275.11C554.1 275.11 552.824 274.098 552.824 272.14C552.824 270.182 554.1 269.17 555.552 269.17C557.004 269.17 558.291 270.182 558.291 272.14C558.291 274.098 557.004 275.11 555.552 275.11ZM553.847 272.14C553.847 273.592 554.617 274.307 555.552 274.307C556.487 274.307 557.257 273.592 557.257 272.14C557.257 270.688 556.487 269.973 555.552 269.973C554.617 269.973 553.847 270.688 553.847 272.14ZM562.55 269.28V270.16C562.825 269.555 563.342 269.28 564.2 269.28H565.278V270.105H564.112C563.067 270.105 562.55 270.523 562.55 271.722V274.175H564.508V275H559.756V274.175H561.527V270.105H559.756V269.28H562.55ZM573.695 275V267.3H575.719C577.226 267.3 578.579 267.729 578.579 269.291C578.579 270.303 577.963 270.798 577.149 271.018C578.106 271.238 578.777 271.777 578.777 272.91C578.777 274.494 577.424 275 575.774 275H573.695ZM574.718 274.175H575.774C576.797 274.175 577.754 273.911 577.754 272.811C577.754 271.711 576.797 271.469 575.774 271.469H574.718V274.175ZM574.718 270.644H575.719C576.599 270.644 577.556 270.446 577.556 269.379C577.556 268.312 576.599 268.125 575.719 268.125H574.718V270.644ZM580.274 273.471C580.274 272.448 581.11 271.766 583.046 271.634L584.069 271.568V271.458C584.069 270.314 583.596 269.973 582.804 269.973C582.067 269.973 581.539 270.369 581.418 271.04H580.428C580.626 269.907 581.616 269.17 582.826 269.17C584.157 269.17 585.092 269.786 585.092 271.645V274.032C585.092 274.428 585.136 274.769 585.202 275H584.223C584.168 274.78 584.146 274.483 584.146 274.164C583.827 274.736 583.255 275.11 582.287 275.11C580.956 275.11 580.274 274.362 580.274 273.471ZM581.297 273.438C581.297 273.933 581.66 274.362 582.529 274.362C583.53 274.362 584.069 273.691 584.069 272.866V272.305L583.244 272.36C581.891 272.459 581.297 272.778 581.297 273.438ZM589.835 269.28V270.16C590.11 269.555 590.627 269.28 591.485 269.28H592.563V270.105H591.397C590.352 270.105 589.835 270.523 589.835 271.722V274.175H591.793V275H587.041V274.175H588.812V270.105H587.041V269.28H589.835ZM596.513 275.11C595.039 275.11 593.752 274.054 593.752 272.14C593.752 270.259 595.006 269.17 596.491 269.17C598.317 269.17 599.263 270.501 599.23 272.371H594.786V272.481C594.841 273.68 595.622 274.307 596.513 274.307C597.272 274.307 597.855 273.933 598.086 273.262H599.131C598.79 274.472 597.745 275.11 596.513 275.11ZM594.797 271.623H598.174C598.064 270.545 597.393 269.973 596.48 269.973C595.622 269.973 594.907 270.545 594.797 271.623ZM609.716 273.9L608.231 268.785L608.286 269.61V275H607.307V267.3H608.748L610.123 272.536L611.487 267.3H612.928V275H611.949V269.61L612.004 268.785L610.519 273.9H609.716ZM616.977 275.11C615.503 275.11 614.216 274.054 614.216 272.14C614.216 270.259 615.47 269.17 616.955 269.17C618.781 269.17 619.727 270.501 619.694 272.371H615.25V272.481C615.305 273.68 616.086 274.307 616.977 274.307C617.736 274.307 618.319 273.933 618.55 273.262H619.595C619.254 274.472 618.209 275.11 616.977 275.11ZM615.261 271.623H618.638C618.528 270.545 617.857 269.973 616.944 269.973C616.086 269.973 615.371 270.545 615.261 271.623ZM624.007 273.372C624.007 274.109 624.436 274.175 624.975 274.175H626.031V275H624.7C623.765 275 622.984 274.758 622.984 273.471V270.105H621.18V269.28H622.984V267.96H624.007V269.28H626.031V270.105H624.007V273.372ZM628.024 273.471C628.024 272.448 628.86 271.766 630.796 271.634L631.819 271.568V271.458C631.819 270.314 631.346 269.973 630.554 269.973C629.817 269.973 629.289 270.369 629.168 271.04H628.178C628.376 269.907 629.366 269.17 630.576 269.17C631.907 269.17 632.842 269.786 632.842 271.645V274.032C632.842 274.428 632.886 274.769 632.952 275H631.973C631.918 274.78 631.896 274.483 631.896 274.164C631.577 274.736 631.005 275.11 630.037 275.11C628.706 275.11 628.024 274.362 628.024 273.471ZM629.047 273.438C629.047 273.933 629.41 274.362 630.279 274.362C631.28 274.362 631.819 273.691 631.819 272.866V272.305L630.994 272.36C629.641 272.459 629.047 272.778 629.047 273.438ZM638.178 267.3V274.175H639.927V275H635.186V274.175H637.155V268.125H635.186V267.3H638.178ZM643.943 267.3C645.637 269.83 645.637 274.45 643.943 276.98H643.019C644.581 274.45 644.581 269.83 643.019 267.3H643.943Z" fill="#2F2D33"/> +<path d="M368 300H218V365H368V300Z" fill="#FCCBBE"/> +<path d="M246.771 326V316.77H249.735C251.182 316.77 252.296 317.173 253.076 317.979C253.856 318.776 254.246 319.916 254.246 321.398C254.246 322.871 253.86 324.007 253.089 324.804C252.326 325.601 251.234 326 249.813 326H246.771ZM248.175 324.713H249.735C250.757 324.713 251.52 324.44 252.023 323.894C252.534 323.339 252.79 322.507 252.79 321.398C252.79 320.271 252.534 319.435 252.023 318.889C251.52 318.334 250.757 318.057 249.735 318.057H248.175V324.713ZM257.832 326.156C257.113 326.156 256.536 325.991 256.103 325.662C255.678 325.333 255.466 324.869 255.466 324.271C255.466 323.673 255.644 323.209 255.999 322.88C256.363 322.542 256.922 322.299 257.676 322.152L260.055 321.697C260.055 321.16 259.929 320.761 259.678 320.501C259.427 320.232 259.054 320.098 258.56 320.098C258.118 320.098 257.771 320.198 257.52 320.397C257.269 320.588 257.095 320.874 257 321.255L255.583 321.164C255.713 320.471 256.034 319.925 256.545 319.526C257.065 319.127 257.737 318.928 258.56 318.928C259.496 318.928 260.207 319.179 260.692 319.682C261.186 320.176 261.433 320.874 261.433 321.775V324.466C261.433 324.631 261.459 324.748 261.511 324.817C261.572 324.878 261.667 324.908 261.797 324.908H262.044V326C262.001 326.009 261.931 326.017 261.836 326.026C261.741 326.035 261.641 326.039 261.537 326.039C261.242 326.039 260.987 325.991 260.77 325.896C260.562 325.801 260.406 325.645 260.302 325.428C260.198 325.203 260.146 324.904 260.146 324.531L260.289 324.596C260.22 324.899 260.068 325.168 259.834 325.402C259.609 325.636 259.318 325.822 258.963 325.961C258.616 326.091 258.239 326.156 257.832 326.156ZM258.053 325.064C258.469 325.064 258.824 324.986 259.119 324.83C259.414 324.665 259.643 324.44 259.808 324.154C259.973 323.868 260.055 323.543 260.055 323.179V322.737L258.027 323.127C257.611 323.205 257.316 323.331 257.143 323.504C256.978 323.669 256.896 323.881 256.896 324.141C256.896 324.436 256.996 324.665 257.195 324.83C257.403 324.986 257.689 325.064 258.053 325.064ZM265.822 326.156C265.172 326.156 264.621 326.056 264.171 325.857C263.729 325.658 263.386 325.385 263.144 325.038C262.901 324.683 262.762 324.275 262.728 323.816L264.145 323.751C264.214 324.132 264.379 324.436 264.639 324.661C264.899 324.886 265.293 324.999 265.822 324.999C266.255 324.999 266.597 324.93 266.849 324.791C267.1 324.652 267.226 324.431 267.226 324.128C267.226 323.963 267.182 323.825 267.096 323.712C267.018 323.591 266.862 323.491 266.628 323.413C266.394 323.326 266.047 323.244 265.588 323.166C264.903 323.036 264.366 322.884 263.976 322.711C263.586 322.538 263.308 322.317 263.144 322.048C262.979 321.779 262.897 321.454 262.897 321.073C262.897 320.44 263.131 319.925 263.599 319.526C264.075 319.127 264.76 318.928 265.653 318.928C266.251 318.928 266.753 319.032 267.161 319.24C267.568 319.439 267.884 319.712 268.11 320.059C268.344 320.397 268.491 320.778 268.552 321.203L267.135 321.281C267.091 321.038 267.005 320.83 266.875 320.657C266.753 320.475 266.589 320.336 266.381 320.241C266.173 320.137 265.926 320.085 265.64 320.085C265.198 320.085 264.868 320.172 264.652 320.345C264.435 320.518 264.327 320.744 264.327 321.021C264.327 321.229 264.374 321.398 264.47 321.528C264.574 321.658 264.738 321.766 264.964 321.853C265.189 321.931 265.488 322 265.861 322.061C266.58 322.182 267.143 322.334 267.551 322.516C267.958 322.689 268.244 322.906 268.409 323.166C268.573 323.426 268.656 323.742 268.656 324.115C268.656 324.548 268.534 324.917 268.292 325.22C268.049 325.523 267.715 325.757 267.291 325.922C266.866 326.078 266.376 326.156 265.822 326.156ZM270.05 326V316.77H271.428V320.644L271.259 320.618C271.337 320.228 271.48 319.912 271.688 319.669C271.896 319.418 272.152 319.231 272.455 319.11C272.759 318.989 273.092 318.928 273.456 318.928C273.959 318.928 274.384 319.041 274.73 319.266C275.077 319.483 275.337 319.79 275.51 320.189C275.692 320.588 275.783 321.043 275.783 321.554V326H274.405V321.918C274.405 321.294 274.297 320.835 274.08 320.54C273.872 320.237 273.543 320.085 273.092 320.085C272.59 320.085 272.187 320.245 271.883 320.566C271.58 320.878 271.428 321.337 271.428 321.944V326H270.05ZM281.049 326.156C280.572 326.156 280.148 326.052 279.775 325.844C279.411 325.627 279.129 325.333 278.93 324.96L278.891 326H277.604V316.77H278.982V320.059C279.164 319.756 279.433 319.491 279.788 319.266C280.152 319.041 280.572 318.928 281.049 318.928C281.647 318.928 282.167 319.075 282.609 319.37C283.051 319.665 283.389 320.085 283.623 320.631C283.866 321.168 283.987 321.805 283.987 322.542C283.987 323.279 283.866 323.92 283.623 324.466C283.389 325.003 283.051 325.419 282.609 325.714C282.167 326.009 281.647 326.156 281.049 326.156ZM280.828 324.96C281.357 324.96 281.777 324.748 282.089 324.323C282.401 323.89 282.557 323.296 282.557 322.542C282.557 321.788 282.401 321.199 282.089 320.774C281.777 320.341 281.361 320.124 280.841 320.124C280.451 320.124 280.117 320.219 279.84 320.41C279.563 320.601 279.35 320.878 279.203 321.242C279.056 321.597 278.982 322.031 278.982 322.542C278.982 323.036 279.056 323.465 279.203 323.829C279.35 324.193 279.563 324.475 279.84 324.674C280.117 324.865 280.447 324.96 280.828 324.96ZM288.233 326.156C287.575 326.156 286.998 326.009 286.504 325.714C286.01 325.419 285.629 324.999 285.36 324.453C285.092 323.907 284.957 323.27 284.957 322.542C284.957 321.805 285.092 321.168 285.36 320.631C285.629 320.094 286.01 319.678 286.504 319.383C286.998 319.08 287.575 318.928 288.233 318.928C288.892 318.928 289.468 319.08 289.962 319.383C290.456 319.678 290.838 320.094 291.106 320.631C291.375 321.168 291.509 321.805 291.509 322.542C291.509 323.27 291.375 323.907 291.106 324.453C290.838 324.999 290.456 325.419 289.962 325.714C289.468 326.009 288.892 326.156 288.233 326.156ZM288.233 324.96C288.823 324.96 289.278 324.748 289.598 324.323C289.919 323.89 290.079 323.296 290.079 322.542C290.079 321.788 289.919 321.199 289.598 320.774C289.278 320.341 288.823 320.124 288.233 320.124C287.653 320.124 287.198 320.341 286.868 320.774C286.548 321.199 286.387 321.788 286.387 322.542C286.387 323.296 286.548 323.89 286.868 324.323C287.198 324.748 287.653 324.96 288.233 324.96ZM294.979 326.156C294.259 326.156 293.683 325.991 293.25 325.662C292.825 325.333 292.613 324.869 292.613 324.271C292.613 323.673 292.79 323.209 293.146 322.88C293.51 322.542 294.069 322.299 294.823 322.152L297.202 321.697C297.202 321.16 297.076 320.761 296.825 320.501C296.573 320.232 296.201 320.098 295.707 320.098C295.265 320.098 294.918 320.198 294.667 320.397C294.415 320.588 294.242 320.874 294.147 321.255L292.73 321.164C292.86 320.471 293.18 319.925 293.692 319.526C294.212 319.127 294.883 318.928 295.707 318.928C296.643 318.928 297.353 319.179 297.839 319.682C298.333 320.176 298.58 320.874 298.58 321.775V324.466C298.58 324.631 298.606 324.748 298.658 324.817C298.718 324.878 298.814 324.908 298.944 324.908H299.191V326C299.147 326.009 299.078 326.017 298.983 326.026C298.887 326.035 298.788 326.039 298.684 326.039C298.389 326.039 298.133 325.991 297.917 325.896C297.709 325.801 297.553 325.645 297.449 325.428C297.345 325.203 297.293 324.904 297.293 324.531L297.436 324.596C297.366 324.899 297.215 325.168 296.981 325.402C296.755 325.636 296.465 325.822 296.11 325.961C295.763 326.091 295.386 326.156 294.979 326.156ZM295.2 325.064C295.616 325.064 295.971 324.986 296.266 324.83C296.56 324.665 296.79 324.44 296.955 324.154C297.119 323.868 297.202 323.543 297.202 323.179V322.737L295.174 323.127C294.758 323.205 294.463 323.331 294.29 323.504C294.125 323.669 294.043 323.881 294.043 324.141C294.043 324.436 294.142 324.665 294.342 324.83C294.55 324.986 294.836 325.064 295.2 325.064ZM300.354 326V319.084H301.615L301.667 320.917L301.55 320.878C301.645 320.254 301.836 319.799 302.122 319.513C302.417 319.227 302.811 319.084 303.305 319.084H303.968V320.319H303.305C302.958 320.319 302.668 320.375 302.434 320.488C302.2 320.601 302.022 320.774 301.901 321.008C301.788 321.242 301.732 321.545 301.732 321.918V326H300.354ZM307.407 326.156C306.817 326.156 306.302 326.009 305.86 325.714C305.426 325.419 305.093 325.003 304.859 324.466C304.633 323.92 304.521 323.279 304.521 322.542C304.521 321.805 304.638 321.168 304.872 320.631C305.106 320.085 305.439 319.665 305.873 319.37C306.306 319.075 306.817 318.928 307.407 318.928C307.883 318.928 308.308 319.028 308.681 319.227C309.053 319.426 309.335 319.704 309.526 320.059V316.77H310.904V326H309.617L309.578 324.96C309.387 325.333 309.097 325.627 308.707 325.844C308.325 326.052 307.892 326.156 307.407 326.156ZM307.771 324.96C308.143 324.96 308.46 324.865 308.72 324.674C308.98 324.483 309.179 324.206 309.318 323.842C309.456 323.478 309.526 323.045 309.526 322.542C309.526 322.022 309.456 321.584 309.318 321.229C309.179 320.865 308.98 320.592 308.72 320.41C308.46 320.219 308.143 320.124 307.771 320.124C307.216 320.124 306.774 320.341 306.445 320.774C306.115 321.199 305.951 321.788 305.951 322.542C305.951 323.287 306.115 323.877 306.445 324.31C306.774 324.743 307.216 324.96 307.771 324.96ZM318.555 326.208C317.61 326.208 316.874 325.996 316.345 325.571C315.816 325.138 315.552 324.527 315.552 323.738C315.552 323.339 315.608 322.984 315.721 322.672C315.842 322.36 316.037 322.065 316.306 321.788C316.575 321.502 316.939 321.207 317.398 320.904C317.199 320.653 317.03 320.419 316.891 320.202C316.752 319.977 316.644 319.751 316.566 319.526C316.497 319.292 316.462 319.041 316.462 318.772C316.462 318.087 316.674 317.55 317.099 317.16C317.532 316.761 318.13 316.562 318.893 316.562C319.404 316.562 319.838 316.649 320.193 316.822C320.548 316.987 320.817 317.225 320.999 317.537C321.181 317.84 321.272 318.2 321.272 318.616C321.272 319.127 321.116 319.578 320.804 319.968C320.501 320.358 320.007 320.77 319.322 321.203L321.207 323.595C321.493 323.205 321.636 322.616 321.636 321.827L323.027 321.957C323.01 322.468 322.914 322.962 322.741 323.439C322.576 323.916 322.36 324.31 322.091 324.622L323.248 326H321.61L321.181 325.48C320.921 325.697 320.566 325.87 320.115 326C319.673 326.139 319.153 326.208 318.555 326.208ZM318.698 324.947C319.088 324.947 319.413 324.904 319.673 324.817C319.933 324.73 320.132 324.618 320.271 324.479L318.178 321.866C317.762 322.152 317.463 322.434 317.281 322.711C317.099 322.98 317.008 323.296 317.008 323.66C317.008 324.05 317.142 324.362 317.411 324.596C317.688 324.83 318.117 324.947 318.698 324.947ZM318.594 320.293C318.889 320.111 319.127 319.942 319.309 319.786C319.491 319.621 319.621 319.452 319.699 319.279C319.777 319.106 319.816 318.919 319.816 318.72C319.816 318.443 319.734 318.23 319.569 318.083C319.413 317.927 319.192 317.849 318.906 317.849C318.594 317.849 318.347 317.931 318.165 318.096C317.992 318.261 317.905 318.495 317.905 318.798C317.905 318.954 317.927 319.11 317.97 319.266C318.013 319.413 318.083 319.569 318.178 319.734C318.282 319.899 318.421 320.085 318.594 320.293ZM326.921 326L330.249 316.77H332.03L335.358 326H333.863L333.018 323.595H329.248L328.416 326H326.921ZM329.69 322.321H332.589L331.133 318.109L329.69 322.321ZM336.463 326V316.77H339.908C340.965 316.77 341.789 317.026 342.378 317.537C342.967 318.04 343.262 318.742 343.262 319.643C343.262 320.241 343.128 320.761 342.859 321.203C342.59 321.636 342.205 321.97 341.702 322.204C341.208 322.429 340.61 322.542 339.908 322.542H337.867V326H336.463ZM337.867 321.268H339.869C340.502 321.268 340.983 321.134 341.312 320.865C341.641 320.588 341.806 320.18 341.806 319.643C341.806 319.114 341.641 318.72 341.312 318.46C340.983 318.191 340.502 318.057 339.869 318.057H337.867V321.268ZM344.753 326V316.77H346.157V326H344.753Z" fill="#090B0B"/> +<path d="M243.755 349.1C241.835 349.1 241.315 347.88 241.315 346.42V342H242.245V346.42C242.245 347.42 242.505 348.35 243.755 348.35C244.995 348.35 245.265 347.42 245.265 346.42V342H246.195V346.42C246.195 347.88 245.655 349.1 243.755 349.1ZM252.216 342V342.75H250.426V348.25H252.216V349H247.696V348.25H249.486V342.75H247.696V342H252.216ZM255.677 349V347.85H256.667V349L256.257 350.24H255.737L256.087 349H255.677ZM266.18 342H267.11V345.12H270.01V342H270.94V349H270.01V345.87H267.11V349H266.18V342ZM277.261 342V342.75H275.231V349H274.291V342.75H272.251V342H277.261ZM283.462 342V342.75H281.432V349H280.492V342.75H278.452V342H283.462ZM285.233 342H286.983C288.733 342 289.743 342.67 289.743 344.12C289.743 345.57 288.733 346.24 286.983 346.24H286.163V349H285.233V342ZM286.163 345.49H286.983C288.163 345.49 288.813 345.11 288.813 344.12C288.813 343.13 288.163 342.75 286.983 342.75H286.163V345.49ZM292.884 349V347.85H293.874V349L293.464 350.24H292.944L293.294 349H292.884ZM308.057 342V349H307.127V348.08C306.717 348.74 306.057 349.1 305.317 349.1C304.097 349.1 303.027 348.13 303.027 346.4C303.027 344.67 304.107 343.7 305.317 343.7C306.057 343.7 306.717 344.06 307.127 344.72V342H308.057ZM303.967 346.4C303.967 347.64 304.667 348.37 305.547 348.37C306.417 348.37 307.127 347.64 307.127 346.4C307.127 345.16 306.417 344.43 305.547 344.43C304.667 344.43 303.967 345.16 303.967 346.4ZM311.998 349.1C310.658 349.1 309.488 348.14 309.488 346.4C309.488 344.69 310.628 343.7 311.978 343.7C313.638 343.7 314.498 344.91 314.468 346.61H310.428V346.71C310.478 347.8 311.188 348.37 311.998 348.37C312.688 348.37 313.218 348.03 313.428 347.42H314.378C314.068 348.52 313.118 349.1 311.998 349.1ZM310.438 345.93H313.508C313.408 344.95 312.798 344.43 311.968 344.43C311.188 344.43 310.538 344.95 310.438 345.93ZM317.559 349L315.669 343.8H316.609L318.169 348.33L319.729 343.8H320.659L318.769 349H317.559ZM330.571 349.1C328.651 349.1 328.131 347.88 328.131 346.42V342H329.061V346.42C329.061 347.42 329.321 348.35 330.571 348.35C331.811 348.35 332.081 347.42 332.081 346.42V342H333.011V346.42C333.011 347.88 332.471 349.1 330.571 349.1ZM334.722 349V342H336.532C338.142 342 339.242 342.61 339.242 344.06C339.242 345.14 338.642 345.75 337.682 345.99L339.432 349H338.382L336.742 346.12H336.532H335.662V349H334.722ZM335.662 345.37H336.532C337.572 345.37 338.312 345.05 338.312 344.06C338.312 343.07 337.572 342.75 336.532 342.75H335.662V345.37ZM341.364 342H342.304V348.25H345.494V349H341.364V342ZM348.895 346.68C347.965 346.52 347.185 346.18 347.185 345.21C347.185 344.25 348.115 343.7 349.165 343.7C350.135 343.7 351.055 344.21 351.185 345.18H350.255C350.175 344.68 349.735 344.43 349.165 344.43C348.555 344.43 348.115 344.72 348.115 345.2C348.115 345.71 348.555 345.83 348.995 345.92L349.495 346.03C350.615 346.23 351.285 346.62 351.285 347.56C351.285 348.52 350.395 349.1 349.225 349.1C348.115 349.1 347.225 348.56 347.055 347.49H347.985C348.085 348.11 348.605 348.37 349.225 348.37C349.925 348.37 350.355 348.07 350.355 347.57C350.355 347.07 349.975 346.91 349.395 346.78L348.895 346.68Z" fill="#090B0B"/> +<path d="M528 300H378V365H528V300Z" fill="#FCCBBE"/> +<path d="M399.884 324L403.212 314.77H404.993L408.321 324H406.826L405.981 321.595H402.211L401.379 324H399.884ZM402.653 320.321H405.552L404.096 316.109L402.653 320.321ZM412.09 326.106C411.544 326.106 411.063 326.024 410.647 325.859C410.231 325.694 409.889 325.46 409.62 325.157C409.352 324.862 409.17 324.516 409.074 324.117L410.504 324.026C410.6 324.312 410.764 324.537 410.998 324.702C411.241 324.875 411.605 324.962 412.09 324.962C412.662 324.962 413.1 324.841 413.403 324.598C413.715 324.355 413.871 323.987 413.871 323.493V322.492C413.707 322.847 413.442 323.129 413.078 323.337C412.714 323.545 412.307 323.649 411.856 323.649C411.276 323.649 410.76 323.506 410.309 323.22C409.859 322.934 409.508 322.54 409.256 322.037C409.005 321.534 408.879 320.954 408.879 320.295C408.879 319.628 409.001 319.043 409.243 318.54C409.495 318.037 409.841 317.643 410.283 317.357C410.734 317.071 411.241 316.928 411.804 316.928C412.298 316.928 412.732 317.041 413.104 317.266C413.486 317.491 413.759 317.795 413.923 318.176V317.084H415.249V323.454C415.249 324.026 415.119 324.507 414.859 324.897C414.608 325.296 414.244 325.595 413.767 325.794C413.299 326.002 412.74 326.106 412.09 326.106ZM412.077 322.479C412.615 322.479 413.044 322.284 413.364 321.894C413.694 321.504 413.863 320.967 413.871 320.282C413.88 319.831 413.811 319.446 413.663 319.125C413.516 318.796 413.304 318.544 413.026 318.371C412.758 318.189 412.441 318.098 412.077 318.098C411.514 318.098 411.076 318.297 410.764 318.696C410.461 319.086 410.309 319.615 410.309 320.282C410.309 320.958 410.465 321.495 410.777 321.894C411.098 322.284 411.531 322.479 412.077 322.479ZM419.925 324.156C419.249 324.156 418.664 324.009 418.17 323.714C417.685 323.419 417.308 322.999 417.039 322.453C416.779 321.907 416.649 321.27 416.649 320.542C416.649 319.814 416.779 319.181 417.039 318.644C417.308 318.098 417.685 317.678 418.17 317.383C418.655 317.08 419.227 316.928 419.886 316.928C420.51 316.928 421.06 317.075 421.537 317.37C422.014 317.656 422.382 318.072 422.642 318.618C422.911 319.164 423.045 319.823 423.045 320.594V320.945H418.079C418.114 321.621 418.291 322.128 418.612 322.466C418.941 322.804 419.383 322.973 419.938 322.973C420.345 322.973 420.683 322.878 420.952 322.687C421.221 322.496 421.407 322.241 421.511 321.92L422.941 322.011C422.759 322.652 422.399 323.172 421.862 323.571C421.333 323.961 420.688 324.156 419.925 324.156ZM418.079 319.905H421.589C421.546 319.29 421.368 318.835 421.056 318.54C420.753 318.245 420.363 318.098 419.886 318.098C419.392 318.098 418.985 318.254 418.664 318.566C418.352 318.869 418.157 319.316 418.079 319.905ZM424.441 324V317.084H425.702L425.754 318.93L425.585 318.839C425.663 318.388 425.811 318.024 426.027 317.747C426.244 317.47 426.508 317.266 426.82 317.136C427.132 316.997 427.47 316.928 427.834 316.928C428.354 316.928 428.783 317.045 429.121 317.279C429.468 317.504 429.728 317.816 429.901 318.215C430.083 318.605 430.174 319.051 430.174 319.554V324H428.796V319.97C428.796 319.563 428.753 319.22 428.666 318.943C428.58 318.666 428.437 318.453 428.237 318.306C428.038 318.159 427.778 318.085 427.457 318.085C426.972 318.085 426.578 318.245 426.274 318.566C425.971 318.887 425.819 319.355 425.819 319.97V324H424.441ZM434.279 324C433.62 324 433.135 323.848 432.823 323.545C432.511 323.242 432.355 322.769 432.355 322.128V315.459H433.733V322.024C433.733 322.345 433.802 322.566 433.941 322.687C434.079 322.808 434.292 322.869 434.578 322.869H435.566V324H434.279ZM431.302 318.215V317.084H435.566V318.215H431.302ZM441.628 324C441.177 324 440.813 323.883 440.536 323.649C440.259 323.415 440.12 323.042 440.12 322.531V314.77H441.498V322.401C441.498 322.557 441.537 322.674 441.615 322.752C441.702 322.83 441.823 322.869 441.979 322.869H442.525V324H441.628ZM446.547 324.156C445.888 324.156 445.312 324.009 444.818 323.714C444.324 323.419 443.943 322.999 443.674 322.453C443.405 321.907 443.271 321.27 443.271 320.542C443.271 319.805 443.405 319.168 443.674 318.631C443.943 318.094 444.324 317.678 444.818 317.383C445.312 317.08 445.888 316.928 446.547 316.928C447.206 316.928 447.782 317.08 448.276 317.383C448.77 317.678 449.151 318.094 449.42 318.631C449.689 319.168 449.823 319.805 449.823 320.542C449.823 321.27 449.689 321.907 449.42 322.453C449.151 322.999 448.77 323.419 448.276 323.714C447.782 324.009 447.206 324.156 446.547 324.156ZM446.547 322.96C447.136 322.96 447.591 322.748 447.912 322.323C448.233 321.89 448.393 321.296 448.393 320.542C448.393 319.788 448.233 319.199 447.912 318.774C447.591 318.341 447.136 318.124 446.547 318.124C445.966 318.124 445.511 318.341 445.182 318.774C444.861 319.199 444.701 319.788 444.701 320.542C444.701 321.296 444.861 321.89 445.182 322.323C445.511 322.748 445.966 322.96 446.547 322.96ZM454.063 324.156C453.404 324.156 452.828 324.009 452.334 323.714C451.84 323.419 451.458 322.999 451.19 322.453C450.921 321.907 450.787 321.27 450.787 320.542C450.787 319.805 450.921 319.168 451.19 318.631C451.458 318.094 451.84 317.678 452.334 317.383C452.828 317.08 453.404 316.928 454.063 316.928C454.721 316.928 455.298 317.08 455.792 317.383C456.286 317.678 456.667 318.094 456.936 318.631C457.204 319.168 457.339 319.805 457.339 320.542C457.339 321.27 457.204 321.907 456.936 322.453C456.667 322.999 456.286 323.419 455.792 323.714C455.298 324.009 454.721 324.156 454.063 324.156ZM454.063 322.96C454.652 322.96 455.107 322.748 455.428 322.323C455.748 321.89 455.909 321.296 455.909 320.542C455.909 319.788 455.748 319.199 455.428 318.774C455.107 318.341 454.652 318.124 454.063 318.124C453.482 318.124 453.027 318.341 452.698 318.774C452.377 319.199 452.217 319.788 452.217 320.542C452.217 321.296 452.377 321.89 452.698 322.323C453.027 322.748 453.482 322.96 454.063 322.96ZM458.731 325.95V317.084H460.031L460.07 318.566L459.914 318.488C460.088 317.977 460.374 317.591 460.772 317.331C461.171 317.062 461.63 316.928 462.15 316.928C462.826 316.928 463.381 317.097 463.814 317.435C464.256 317.764 464.581 318.202 464.789 318.748C465.006 319.294 465.114 319.892 465.114 320.542C465.114 321.192 465.006 321.79 464.789 322.336C464.581 322.882 464.256 323.324 463.814 323.662C463.381 323.991 462.826 324.156 462.15 324.156C461.804 324.156 461.479 324.095 461.175 323.974C460.872 323.853 460.612 323.684 460.395 323.467C460.187 323.242 460.04 322.977 459.953 322.674L460.109 322.518V325.95H458.731ZM461.903 322.96C462.458 322.96 462.891 322.748 463.203 322.323C463.524 321.898 463.684 321.305 463.684 320.542C463.684 319.779 463.524 319.186 463.203 318.761C462.891 318.336 462.458 318.124 461.903 318.124C461.539 318.124 461.223 318.215 460.954 318.397C460.686 318.57 460.478 318.839 460.33 319.203C460.183 319.567 460.109 320.013 460.109 320.542C460.109 321.071 460.179 321.517 460.317 321.881C460.465 322.245 460.673 322.518 460.941 322.7C461.219 322.873 461.539 322.96 461.903 322.96ZM471.193 325.43C469.997 323.61 469.399 321.747 469.399 319.84C469.399 317.925 469.997 316.061 471.193 314.25H472.415C471.331 316.122 470.79 317.985 470.79 319.84C470.79 320.767 470.924 321.699 471.193 322.635C471.47 323.562 471.877 324.494 472.415 325.43H471.193ZM476.419 324.156C475.76 324.156 475.184 324.009 474.69 323.714C474.196 323.419 473.815 322.999 473.546 322.453C473.277 321.907 473.143 321.27 473.143 320.542C473.143 319.814 473.277 319.181 473.546 318.644C473.815 318.098 474.196 317.678 474.69 317.383C475.184 317.08 475.76 316.928 476.419 316.928C476.982 316.928 477.481 317.028 477.914 317.227C478.356 317.418 478.711 317.699 478.98 318.072C479.257 318.445 479.435 318.9 479.513 319.437L478.07 319.515C478.001 319.064 477.819 318.722 477.524 318.488C477.229 318.245 476.861 318.124 476.419 318.124C475.838 318.124 475.383 318.341 475.054 318.774C474.733 319.199 474.573 319.788 474.573 320.542C474.573 321.296 474.733 321.89 475.054 322.323C475.383 322.748 475.838 322.96 476.419 322.96C476.861 322.96 477.229 322.839 477.524 322.596C477.819 322.345 478.001 321.968 478.07 321.465L479.513 321.543C479.435 322.072 479.257 322.531 478.98 322.921C478.711 323.311 478.356 323.614 477.914 323.831C477.481 324.048 476.982 324.156 476.419 324.156ZM480.77 324V314.77H482.148V318.644L481.979 318.618C482.057 318.228 482.2 317.912 482.408 317.669C482.616 317.418 482.872 317.231 483.175 317.11C483.479 316.989 483.812 316.928 484.176 316.928C484.679 316.928 485.104 317.041 485.45 317.266C485.797 317.483 486.057 317.79 486.23 318.189C486.412 318.588 486.503 319.043 486.503 319.554V324H485.125V319.918C485.125 319.294 485.017 318.835 484.8 318.54C484.592 318.237 484.263 318.085 483.812 318.085C483.31 318.085 482.907 318.245 482.603 318.566C482.3 318.878 482.148 319.337 482.148 319.944V324H480.77ZM490.248 324.156C489.529 324.156 488.953 323.991 488.519 323.662C488.095 323.333 487.882 322.869 487.882 322.271C487.882 321.673 488.06 321.209 488.415 320.88C488.779 320.542 489.338 320.299 490.092 320.152L492.471 319.697C492.471 319.16 492.346 318.761 492.094 318.501C491.843 318.232 491.47 318.098 490.976 318.098C490.534 318.098 490.188 318.198 489.936 318.397C489.685 318.588 489.512 318.874 489.416 319.255L487.999 319.164C488.129 318.471 488.45 317.925 488.961 317.526C489.481 317.127 490.153 316.928 490.976 316.928C491.912 316.928 492.623 317.179 493.108 317.682C493.602 318.176 493.849 318.874 493.849 319.775V322.466C493.849 322.631 493.875 322.748 493.927 322.817C493.988 322.878 494.083 322.908 494.213 322.908H494.46V324C494.417 324.009 494.348 324.017 494.252 324.026C494.157 324.035 494.057 324.039 493.953 324.039C493.659 324.039 493.403 323.991 493.186 323.896C492.978 323.801 492.822 323.645 492.718 323.428C492.614 323.203 492.562 322.904 492.562 322.531L492.705 322.596C492.636 322.899 492.484 323.168 492.25 323.402C492.025 323.636 491.735 323.822 491.379 323.961C491.033 324.091 490.656 324.156 490.248 324.156ZM490.469 323.064C490.885 323.064 491.241 322.986 491.535 322.83C491.83 322.665 492.06 322.44 492.224 322.154C492.389 321.868 492.471 321.543 492.471 321.179V320.737L490.443 321.127C490.027 321.205 489.733 321.331 489.559 321.504C489.395 321.669 489.312 321.881 489.312 322.141C489.312 322.436 489.412 322.665 489.611 322.83C489.819 322.986 490.105 323.064 490.469 323.064ZM497.92 324C497.262 324 496.776 323.848 496.464 323.545C496.152 323.242 495.996 322.769 495.996 322.128V315.459H497.374V322.024C497.374 322.345 497.444 322.566 497.582 322.687C497.721 322.808 497.933 322.869 498.219 322.869H499.207V324H497.92ZM494.943 318.215V317.084H499.207V318.215H494.943ZM502.867 324.156C502.278 324.156 501.762 324.009 501.32 323.714C500.887 323.419 500.553 323.003 500.319 322.466C500.094 321.92 499.981 321.279 499.981 320.542C499.981 319.805 500.098 319.168 500.332 318.631C500.566 318.085 500.9 317.665 501.333 317.37C501.766 317.075 502.278 316.928 502.867 316.928C503.344 316.928 503.768 317.028 504.141 317.227C504.514 317.426 504.795 317.704 504.986 318.059V314.77H506.364V324H505.077L505.038 322.96C504.847 323.333 504.557 323.627 504.167 323.844C503.786 324.052 503.352 324.156 502.867 324.156ZM503.231 322.96C503.604 322.96 503.92 322.865 504.18 322.674C504.44 322.483 504.639 322.206 504.778 321.842C504.917 321.478 504.986 321.045 504.986 320.542C504.986 320.022 504.917 319.584 504.778 319.229C504.639 318.865 504.44 318.592 504.18 318.41C503.92 318.219 503.604 318.124 503.231 318.124C502.676 318.124 502.234 318.341 501.905 318.774C501.576 319.199 501.411 319.788 501.411 320.542C501.411 321.287 501.576 321.877 501.905 322.31C502.234 322.743 502.676 322.96 503.231 322.96ZM508.748 325.43H507.526C508.064 324.494 508.467 323.562 508.735 322.635C509.013 321.699 509.151 320.767 509.151 319.84C509.151 317.985 508.61 316.122 507.526 314.25H508.748C509.944 316.061 510.542 317.925 510.542 319.84C510.542 321.747 509.944 323.61 508.748 325.43Z" fill="#090B0B"/> +<path d="M396.511 343.5C396.511 341.37 397.291 339.9 399.151 339.9C400.571 339.9 401.361 340.75 401.601 342.12H400.671C400.491 341.23 400.011 340.65 399.151 340.65C397.901 340.65 397.441 341.85 397.441 343.5C397.441 345.16 397.901 346.35 399.151 346.35C400.011 346.35 400.491 345.77 400.661 344.9H401.601C401.361 346.26 400.561 347.1 399.151 347.1C397.291 347.1 396.511 345.63 396.511 343.5ZM406.652 343.94C406.652 342.9 406.222 342.43 405.482 342.43C404.642 342.43 404.092 343.02 404.092 344.13V347H403.162V340H404.092V342.7C404.432 342.04 405.032 341.7 405.802 341.7C406.862 341.7 407.582 342.35 407.582 343.8V347H406.652V343.94ZM409.073 345.61C409.073 344.68 409.833 344.06 411.593 343.94L412.523 343.88V343.78C412.523 342.74 412.093 342.43 411.373 342.43C410.703 342.43 410.223 342.79 410.113 343.4H409.213C409.393 342.37 410.293 341.7 411.393 341.7C412.603 341.7 413.453 342.26 413.453 343.95V346.12C413.453 346.48 413.493 346.79 413.553 347H412.663C412.613 346.8 412.593 346.53 412.593 346.24C412.303 346.76 411.783 347.1 410.903 347.1C409.693 347.1 409.073 346.42 409.073 345.61ZM410.003 345.58C410.003 346.03 410.333 346.42 411.123 346.42C412.033 346.42 412.523 345.81 412.523 345.06V344.55L411.773 344.6C410.543 344.69 410.003 344.98 410.003 345.58ZM417.824 345.52C417.824 346.19 418.214 346.25 418.704 346.25H419.664V347H418.454C417.604 347 416.894 346.78 416.894 345.61V342.55H415.254V341.8H416.894V340.6H417.824V341.8H419.664V342.55H417.824V345.52ZM423.325 347V345.85H424.315V347L423.905 348.24H423.385L423.735 347H423.325ZM436.428 345.52C436.428 346.19 436.818 346.25 437.308 346.25H438.268V347H437.058C436.208 347 435.498 346.78 435.498 345.61V342.55H433.858V341.8H435.498V340.6H436.428V341.8H438.268V342.55H436.428V345.52ZM442.409 347.1C441.089 347.1 439.929 346.18 439.929 344.4C439.929 342.62 441.089 341.7 442.409 341.7C443.729 341.7 444.899 342.62 444.899 344.4C444.899 346.18 443.729 347.1 442.409 347.1ZM440.859 344.4C440.859 345.72 441.559 346.37 442.409 346.37C443.259 346.37 443.959 345.72 443.959 344.4C443.959 343.08 443.259 342.43 442.409 342.43C441.559 342.43 440.859 343.08 440.859 344.4ZM448.61 347.1C447.29 347.1 446.13 346.18 446.13 344.4C446.13 342.62 447.29 341.7 448.61 341.7C449.93 341.7 451.1 342.62 451.1 344.4C451.1 346.18 449.93 347.1 448.61 347.1ZM447.06 344.4C447.06 345.72 447.76 346.37 448.61 346.37C449.46 346.37 450.16 345.72 450.16 344.4C450.16 343.08 449.46 342.43 448.61 342.43C447.76 342.43 447.06 343.08 447.06 344.4ZM455.511 340V346.25H457.101V347H452.791V346.25H454.581V340.75H452.791V340H455.511ZM469.504 340V347H468.574V346.08C468.164 346.74 467.504 347.1 466.764 347.1C465.544 347.1 464.474 346.13 464.474 344.4C464.474 342.67 465.554 341.7 466.764 341.7C467.504 341.7 468.164 342.06 468.574 342.72V340H469.504ZM465.414 344.4C465.414 345.64 466.114 346.37 466.994 346.37C467.864 346.37 468.574 345.64 468.574 344.4C468.574 343.16 467.864 342.43 466.994 342.43C466.114 342.43 465.414 343.16 465.414 344.4ZM474.245 341.8V346.25H475.835V347H471.525V346.25H473.315V342.55H471.525V341.8H474.245ZM473.285 341.06V339.91H474.275V341.06H473.285ZM479.336 344.68C478.406 344.52 477.626 344.18 477.626 343.21C477.626 342.25 478.556 341.7 479.606 341.7C480.576 341.7 481.496 342.21 481.626 343.18H480.696C480.616 342.68 480.176 342.43 479.606 342.43C478.996 342.43 478.556 342.72 478.556 343.2C478.556 343.71 478.996 343.83 479.436 343.92L479.936 344.03C481.056 344.23 481.726 344.62 481.726 345.56C481.726 346.52 480.836 347.1 479.666 347.1C478.556 347.1 477.666 346.56 477.496 345.49H478.426C478.526 346.11 479.046 346.37 479.666 346.37C480.366 346.37 480.796 346.07 480.796 345.57C480.796 345.07 480.416 344.91 479.836 344.78L479.336 344.68ZM483.457 341.8H484.387V342.73C484.797 342.06 485.457 341.7 486.207 341.7C487.407 341.7 488.487 342.67 488.487 344.4C488.487 346.13 487.417 347.1 486.207 347.1C485.457 347.1 484.797 346.74 484.387 346.07V348.8H483.457V341.8ZM484.387 344.4C484.387 345.64 485.097 346.37 485.967 346.37C486.847 346.37 487.557 345.64 487.557 344.4C487.557 343.16 486.847 342.43 485.967 342.43C485.097 342.43 484.387 343.16 484.387 344.4ZM489.688 345.61C489.688 344.68 490.448 344.06 492.208 343.94L493.138 343.88V343.78C493.138 342.74 492.708 342.43 491.988 342.43C491.318 342.43 490.838 342.79 490.728 343.4H489.828C490.008 342.37 490.908 341.7 492.008 341.7C493.218 341.7 494.068 342.26 494.068 343.95V346.12C494.068 346.48 494.108 346.79 494.168 347H493.278C493.228 346.8 493.208 346.53 493.208 346.24C492.918 346.76 492.398 347.1 491.518 347.1C490.308 347.1 489.688 346.42 489.688 345.61ZM490.618 345.58C490.618 346.03 490.948 346.42 491.738 346.42C492.648 346.42 493.138 345.81 493.138 345.06V344.55L492.388 344.6C491.158 344.69 490.618 344.98 490.618 345.58ZM498.439 345.52C498.439 346.19 498.829 346.25 499.319 346.25H500.279V347H499.069C498.219 347 497.509 346.78 497.509 345.61V342.55H495.869V341.8H497.509V340.6H498.439V341.8H500.279V342.55H498.439V345.52ZM502.021 344.4C502.021 342.62 503.181 341.7 504.521 341.7C505.691 341.7 506.591 342.32 506.881 343.48H505.941C505.721 342.8 505.231 342.43 504.521 342.43C503.661 342.43 502.951 343.1 502.951 344.4C502.951 345.7 503.661 346.37 504.521 346.37C505.231 346.37 505.731 346 505.951 345.31H506.881C506.601 346.48 505.691 347.1 504.521 347.1C503.181 347.1 502.021 346.18 502.021 344.4ZM512.072 343.94C512.072 342.9 511.642 342.43 510.902 342.43C510.062 342.43 509.512 343.02 509.512 344.13V347H508.582V340H509.512V342.7C509.852 342.04 510.452 341.7 511.222 341.7C512.282 341.7 513.002 342.35 513.002 343.8V347H512.072V343.94Z" fill="#090B0B"/> +<path d="M689 300H539V365H689V300Z" fill="#FCCBBE"/> +<path d="M576.649 323V313.77H580.094C581.151 313.77 581.975 314.026 582.564 314.537C583.153 315.04 583.448 315.742 583.448 316.643C583.448 317.241 583.314 317.761 583.045 318.203C582.776 318.636 582.391 318.97 581.888 319.204C581.394 319.429 580.796 319.542 580.094 319.542H578.053V323H576.649ZM578.053 318.268H580.055C580.688 318.268 581.169 318.134 581.498 317.865C581.827 317.588 581.992 317.18 581.992 316.643C581.992 316.114 581.827 315.72 581.498 315.46C581.169 315.191 580.688 315.057 580.055 315.057H578.053V318.268ZM584.936 323V316.084H586.197L586.249 317.917L586.132 317.878C586.227 317.254 586.418 316.799 586.704 316.513C586.998 316.227 587.393 316.084 587.887 316.084H588.55V317.319H587.887C587.54 317.319 587.25 317.375 587.016 317.488C586.782 317.601 586.604 317.774 586.483 318.008C586.37 318.242 586.314 318.545 586.314 318.918V323H584.936ZM592.378 323.156C591.72 323.156 591.143 323.009 590.649 322.714C590.155 322.419 589.774 321.999 589.505 321.453C589.237 320.907 589.102 320.27 589.102 319.542C589.102 318.805 589.237 318.168 589.505 317.631C589.774 317.094 590.155 316.678 590.649 316.383C591.143 316.08 591.72 315.928 592.378 315.928C593.037 315.928 593.613 316.08 594.107 316.383C594.601 316.678 594.983 317.094 595.252 317.631C595.52 318.168 595.654 318.805 595.654 319.542C595.654 320.27 595.52 320.907 595.252 321.453C594.983 321.999 594.601 322.419 594.107 322.714C593.613 323.009 593.037 323.156 592.378 323.156ZM592.378 321.96C592.968 321.96 593.423 321.748 593.743 321.323C594.064 320.89 594.224 320.296 594.224 319.542C594.224 318.788 594.064 318.199 593.743 317.774C593.423 317.341 592.968 317.124 592.378 317.124C591.798 317.124 591.343 317.341 591.013 317.774C590.693 318.199 590.532 318.788 590.532 319.542C590.532 320.296 590.693 320.89 591.013 321.323C591.343 321.748 591.798 321.96 592.378 321.96ZM598.664 323L596.142 316.084H597.611L599.496 321.596L601.381 316.084H602.863L600.328 323H598.664ZM603.979 323V316.084H605.357V323H603.979ZM603.953 315.083V313.705H605.383V315.083H603.953ZM609.843 323.156C609.193 323.156 608.643 323.056 608.192 322.857C607.75 322.658 607.408 322.385 607.165 322.038C606.922 321.683 606.784 321.275 606.749 320.816L608.166 320.751C608.235 321.132 608.4 321.436 608.66 321.661C608.92 321.886 609.314 321.999 609.843 321.999C610.276 321.999 610.619 321.93 610.87 321.791C611.121 321.652 611.247 321.431 611.247 321.128C611.247 320.963 611.204 320.825 611.117 320.712C611.039 320.591 610.883 320.491 610.649 320.413C610.415 320.326 610.068 320.244 609.609 320.166C608.924 320.036 608.387 319.884 607.997 319.711C607.607 319.538 607.33 319.317 607.165 319.048C607 318.779 606.918 318.454 606.918 318.073C606.918 317.44 607.152 316.925 607.62 316.526C608.097 316.127 608.781 315.928 609.674 315.928C610.272 315.928 610.775 316.032 611.182 316.24C611.589 316.439 611.906 316.712 612.131 317.059C612.365 317.397 612.512 317.778 612.573 318.203L611.156 318.281C611.113 318.038 611.026 317.83 610.896 317.657C610.775 317.475 610.61 317.336 610.402 317.241C610.194 317.137 609.947 317.085 609.661 317.085C609.219 317.085 608.89 317.172 608.673 317.345C608.456 317.518 608.348 317.744 608.348 318.021C608.348 318.229 608.396 318.398 608.491 318.528C608.595 318.658 608.76 318.766 608.985 318.853C609.21 318.931 609.509 319 609.882 319.061C610.601 319.182 611.165 319.334 611.572 319.516C611.979 319.689 612.265 319.906 612.43 320.166C612.595 320.426 612.677 320.742 612.677 321.115C612.677 321.548 612.556 321.917 612.313 322.22C612.07 322.523 611.737 322.757 611.312 322.922C610.887 323.078 610.398 323.156 609.843 323.156ZM614.072 323V316.084H615.45V323H614.072ZM614.046 315.083V313.705H615.476V315.083H614.046ZM620.118 323.156C619.459 323.156 618.883 323.009 618.389 322.714C617.895 322.419 617.513 321.999 617.245 321.453C616.976 320.907 616.842 320.27 616.842 319.542C616.842 318.805 616.976 318.168 617.245 317.631C617.513 317.094 617.895 316.678 618.389 316.383C618.883 316.08 619.459 315.928 620.118 315.928C620.776 315.928 621.353 316.08 621.847 316.383C622.341 316.678 622.722 317.094 622.991 317.631C623.259 318.168 623.394 318.805 623.394 319.542C623.394 320.27 623.259 320.907 622.991 321.453C622.722 321.999 622.341 322.419 621.847 322.714C621.353 323.009 620.776 323.156 620.118 323.156ZM620.118 321.96C620.707 321.96 621.162 321.748 621.483 321.323C621.803 320.89 621.964 320.296 621.964 319.542C621.964 318.788 621.803 318.199 621.483 317.774C621.162 317.341 620.707 317.124 620.118 317.124C619.537 317.124 619.082 317.341 618.753 317.774C618.432 318.199 618.272 318.788 618.272 319.542C618.272 320.296 618.432 320.89 618.753 321.323C619.082 321.748 619.537 321.96 620.118 321.96ZM624.786 323V316.084H626.047L626.099 317.93L625.93 317.839C626.008 317.388 626.156 317.024 626.372 316.747C626.589 316.47 626.853 316.266 627.165 316.136C627.477 315.997 627.815 315.928 628.179 315.928C628.699 315.928 629.128 316.045 629.466 316.279C629.813 316.504 630.073 316.816 630.246 317.215C630.428 317.605 630.519 318.051 630.519 318.554V323H629.141V318.97C629.141 318.563 629.098 318.22 629.011 317.943C628.925 317.666 628.782 317.453 628.582 317.306C628.383 317.159 628.123 317.085 627.802 317.085C627.317 317.085 626.923 317.245 626.619 317.566C626.316 317.887 626.164 318.355 626.164 318.97V323H624.786ZM635.162 323.156C634.486 323.156 633.901 323.009 633.407 322.714C632.921 322.419 632.544 321.999 632.276 321.453C632.016 320.907 631.886 320.27 631.886 319.542C631.886 318.814 632.016 318.181 632.276 317.644C632.544 317.098 632.921 316.678 633.407 316.383C633.892 316.08 634.464 315.928 635.123 315.928C635.747 315.928 636.297 316.075 636.774 316.37C637.25 316.656 637.619 317.072 637.879 317.618C638.147 318.164 638.282 318.823 638.282 319.594V319.945H633.316C633.35 320.621 633.528 321.128 633.849 321.466C634.178 321.804 634.62 321.973 635.175 321.973C635.582 321.973 635.92 321.878 636.189 321.687C636.457 321.496 636.644 321.241 636.748 320.92L638.178 321.011C637.996 321.652 637.636 322.172 637.099 322.571C636.57 322.961 635.924 323.156 635.162 323.156ZM633.316 318.905H636.826C636.782 318.29 636.605 317.835 636.293 317.54C635.989 317.245 635.599 317.098 635.123 317.098C634.629 317.098 634.221 317.254 633.901 317.566C633.589 317.869 633.394 318.316 633.316 318.905ZM639.678 323V316.084H640.939L640.991 317.917L640.874 317.878C640.969 317.254 641.16 316.799 641.446 316.513C641.741 316.227 642.135 316.084 642.629 316.084H643.292V317.319H642.629C642.282 317.319 641.992 317.375 641.758 317.488C641.524 317.601 641.346 317.774 641.225 318.008C641.112 318.242 641.056 318.545 641.056 318.918V323H639.678ZM646.731 323.156C646.141 323.156 645.626 323.009 645.184 322.714C644.75 322.419 644.417 322.003 644.183 321.466C643.957 320.92 643.845 320.279 643.845 319.542C643.845 318.805 643.962 318.168 644.196 317.631C644.43 317.085 644.763 316.665 645.197 316.37C645.63 316.075 646.141 315.928 646.731 315.928C647.207 315.928 647.632 316.028 648.005 316.227C648.377 316.426 648.659 316.704 648.85 317.059V313.77H650.228V323H648.941L648.902 321.96C648.711 322.333 648.421 322.627 648.031 322.844C647.649 323.052 647.216 323.156 646.731 323.156ZM647.095 321.96C647.467 321.96 647.784 321.865 648.044 321.674C648.304 321.483 648.503 321.206 648.642 320.842C648.78 320.478 648.85 320.045 648.85 319.542C648.85 319.022 648.78 318.584 648.642 318.229C648.503 317.865 648.304 317.592 648.044 317.41C647.784 317.219 647.467 317.124 647.095 317.124C646.54 317.124 646.098 317.341 645.769 317.774C645.439 318.199 645.275 318.788 645.275 319.542C645.275 320.287 645.439 320.877 645.769 321.31C646.098 321.743 646.54 321.96 647.095 321.96Z" fill="#090B0B"/> +<path d="M559.501 339V339.75H557.471V346H556.531V339.75H554.491V339H559.501ZM563.232 346.1C561.892 346.1 560.722 345.14 560.722 343.4C560.722 341.69 561.862 340.7 563.212 340.7C564.872 340.7 565.732 341.91 565.702 343.61H561.662V343.71C561.712 344.8 562.422 345.37 563.232 345.37C563.922 345.37 564.452 345.03 564.662 344.42H565.612C565.302 345.52 564.352 346.1 563.232 346.1ZM561.672 342.93H564.742C564.642 341.95 564.032 341.43 563.202 341.43C562.422 341.43 561.772 341.95 561.672 342.93ZM569.563 340.8V341.6C569.813 341.05 570.283 340.8 571.063 340.8H572.043V341.55H570.983C570.033 341.55 569.563 341.93 569.563 343.02V345.25H571.343V346H567.023V345.25H568.633V341.55H567.023V340.8H569.563ZM575.764 340.8V341.6C576.014 341.05 576.484 340.8 577.264 340.8H578.244V341.55H577.184C576.234 341.55 575.764 341.93 575.764 343.02V345.25H577.544V346H573.224V345.25H574.834V341.55H573.224V340.8H575.764ZM579.475 344.61C579.475 343.68 580.235 343.06 581.995 342.94L582.925 342.88V342.78C582.925 341.74 582.495 341.43 581.775 341.43C581.105 341.43 580.625 341.79 580.515 342.4H579.615C579.795 341.37 580.695 340.7 581.795 340.7C583.005 340.7 583.855 341.26 583.855 342.95V345.12C583.855 345.48 583.895 345.79 583.955 346H583.065C583.015 345.8 582.995 345.53 582.995 345.24C582.705 345.76 582.185 346.1 581.305 346.1C580.095 346.1 579.475 345.42 579.475 344.61ZM580.405 344.58C580.405 345.03 580.735 345.42 581.525 345.42C582.435 345.42 582.925 344.81 582.925 344.06V343.55L582.175 343.6C580.945 343.69 580.405 343.98 580.405 344.58ZM588.377 340.39V340.8H590.217V341.55H588.377V345.25H590.117V346H585.797V345.25H587.437V341.55H585.797V340.8H587.437V340.39C587.437 339.22 588.157 339 589.007 339H590.217V339.66H589.247C588.767 339.66 588.377 339.72 588.377 340.39ZM594.208 346.1C592.888 346.1 591.728 345.18 591.728 343.4C591.728 341.62 592.888 340.7 594.208 340.7C595.528 340.7 596.698 341.62 596.698 343.4C596.698 345.18 595.528 346.1 594.208 346.1ZM592.658 343.4C592.658 344.72 593.358 345.37 594.208 345.37C595.058 345.37 595.758 344.72 595.758 343.4C595.758 342.08 595.058 341.43 594.208 341.43C593.358 341.43 592.658 342.08 592.658 343.4ZM600.569 340.8V341.6C600.819 341.05 601.289 340.8 602.069 340.8H603.049V341.55H601.989C601.039 341.55 600.569 341.93 600.569 343.02V345.25H602.349V346H598.029V345.25H599.639V341.55H598.029V340.8H600.569ZM603.98 346V340.8H604.87V341.26C605.07 340.94 605.4 340.7 605.89 340.7C606.49 340.7 606.8 341.03 606.94 341.47C607.13 341.04 607.47 340.7 608.07 340.7C609.02 340.7 609.24 341.52 609.24 342.29V346H608.35V342.36C608.35 341.93 608.29 341.43 607.75 341.43C607.2 341.43 607.06 342.01 607.05 342.37V346H606.17V342.36C606.17 341.93 606.1 341.43 605.57 341.43C605.01 341.43 604.88 342.01 604.87 342.37V346H603.98ZM619.042 346.1C617.702 346.1 616.532 345.14 616.532 343.4C616.532 341.69 617.672 340.7 619.022 340.7C620.682 340.7 621.542 341.91 621.512 343.61H617.472V343.71C617.522 344.8 618.232 345.37 619.042 345.37C619.732 345.37 620.262 345.03 620.472 344.42H621.422C621.112 345.52 620.162 346.1 619.042 346.1ZM617.482 342.93H620.552C620.452 341.95 619.842 341.43 619.012 341.43C618.232 341.43 617.582 341.95 617.482 342.93ZM622.784 346L624.704 343.38L622.804 340.8H623.814L625.204 342.72L626.604 340.8H627.614L625.714 343.38L627.644 346H626.634L625.214 344.04L623.784 346H622.784ZM631.445 346.1C630.105 346.1 628.935 345.14 628.935 343.4C628.935 341.69 630.075 340.7 631.425 340.7C633.085 340.7 633.945 341.91 633.915 343.61H629.875V343.71C629.925 344.8 630.635 345.37 631.445 345.37C632.135 345.37 632.665 345.03 632.875 344.42H633.825C633.515 345.52 632.565 346.1 631.445 346.1ZM629.885 342.93H632.955C632.855 341.95 632.245 341.43 631.415 341.43C630.635 341.43 629.985 341.95 629.885 342.93ZM635.216 343.4C635.216 341.62 636.376 340.7 637.716 340.7C638.886 340.7 639.786 341.32 640.076 342.48H639.136C638.916 341.8 638.426 341.43 637.716 341.43C636.856 341.43 636.146 342.1 636.146 343.4C636.146 344.7 636.856 345.37 637.716 345.37C638.426 345.37 638.926 345 639.146 344.31H640.076C639.796 345.48 638.886 346.1 637.716 346.1C636.376 346.1 635.216 345.18 635.216 343.4ZM642.477 343.86C642.477 344.9 642.907 345.37 643.647 345.37C644.487 345.37 645.037 344.78 645.037 343.66V340.8H645.967V346H645.037V345.08C644.707 345.75 644.097 346.1 643.317 346.1C642.257 346.1 641.547 345.45 641.547 344V340.8H642.477V343.86ZM650.238 344.52C650.238 345.19 650.628 345.25 651.118 345.25H652.078V346H650.868C650.018 346 649.308 345.78 649.308 344.61V341.55H647.668V340.8H649.308V339.6H650.238V340.8H652.078V341.55H650.238V344.52ZM657.049 340.8V345.25H658.639V346H654.329V345.25H656.119V341.55H654.329V340.8H657.049ZM656.089 340.06V338.91H657.079V340.06H656.089ZM662.421 346.1C661.101 346.1 659.941 345.18 659.941 343.4C659.941 341.62 661.101 340.7 662.421 340.7C663.741 340.7 664.911 341.62 664.911 343.4C664.911 345.18 663.741 346.1 662.421 346.1ZM660.871 343.4C660.871 344.72 661.571 345.37 662.421 345.37C663.271 345.37 663.971 344.72 663.971 343.4C663.971 342.08 663.271 341.43 662.421 341.43C661.571 341.43 660.871 342.08 660.871 343.4ZM669.962 342.94C669.962 341.9 669.532 341.43 668.792 341.43C667.952 341.43 667.402 342.02 667.402 343.13V346H666.472V340.8H667.402V341.72C667.732 341.05 668.342 340.7 669.122 340.7C670.182 340.7 670.892 341.35 670.892 342.8V346H669.962V342.94Z" fill="#090B0B"/> +<path d="M368 390H218V455H368V390Z" fill="#FCCBBE"/> +<path d="M253.233 414L256.561 404.77H258.342L261.67 414H260.175L259.33 411.595H255.56L254.728 414H253.233ZM256.002 410.321H258.901L257.445 406.109L256.002 410.321ZM264.92 414.156C264.226 414.156 263.672 413.926 263.256 413.467C262.848 412.999 262.645 412.353 262.645 411.53V407.084H264.023V411.179C264.023 411.803 264.131 412.262 264.348 412.557C264.564 412.852 264.894 412.999 265.336 412.999C265.838 412.999 266.228 412.839 266.506 412.518C266.792 412.189 266.935 411.729 266.935 411.14V407.084H268.313V414H267.026L267 412.193L267.195 412.271C267.073 412.878 266.818 413.346 266.428 413.675C266.038 413.996 265.535 414.156 264.92 414.156ZM272.592 414.156C272.003 414.156 271.487 414.009 271.045 413.714C270.612 413.419 270.278 413.003 270.044 412.466C269.819 411.92 269.706 411.279 269.706 410.542C269.706 409.805 269.823 409.168 270.057 408.631C270.291 408.085 270.625 407.665 271.058 407.37C271.491 407.075 272.003 406.928 272.592 406.928C273.069 406.928 273.493 407.028 273.866 407.227C274.239 407.426 274.52 407.704 274.711 408.059V404.77H276.089V414H274.802L274.763 412.96C274.572 413.333 274.282 413.627 273.892 413.844C273.511 414.052 273.077 414.156 272.592 414.156ZM272.956 412.96C273.329 412.96 273.645 412.865 273.905 412.674C274.165 412.483 274.364 412.206 274.503 411.842C274.642 411.478 274.711 411.045 274.711 410.542C274.711 410.022 274.642 409.584 274.503 409.229C274.364 408.865 274.165 408.592 273.905 408.41C273.645 408.219 273.329 408.124 272.956 408.124C272.401 408.124 271.959 408.341 271.63 408.774C271.301 409.199 271.136 409.788 271.136 410.542C271.136 411.287 271.301 411.877 271.63 412.31C271.959 412.743 272.401 412.96 272.956 412.96ZM277.816 414V407.084H279.194V414H277.816ZM277.79 406.083V404.705H279.22V406.083H277.79ZM283.324 414C282.665 414 282.18 413.848 281.868 413.545C281.556 413.242 281.4 412.769 281.4 412.128V405.459H282.778V412.024C282.778 412.345 282.847 412.566 282.986 412.687C283.125 412.808 283.337 412.869 283.623 412.869H284.611V414H283.324ZM280.347 408.215V407.084H284.611V408.215H280.347ZM290.673 414C290.223 414 289.859 413.883 289.581 413.649C289.304 413.415 289.165 413.042 289.165 412.531V404.77H290.543V412.401C290.543 412.557 290.582 412.674 290.66 412.752C290.747 412.83 290.868 412.869 291.024 412.869H291.57V414H290.673ZM295.592 414.156C294.934 414.156 294.357 414.009 293.863 413.714C293.369 413.419 292.988 412.999 292.719 412.453C292.451 411.907 292.316 411.27 292.316 410.542C292.316 409.805 292.451 409.168 292.719 408.631C292.988 408.094 293.369 407.678 293.863 407.383C294.357 407.08 294.934 406.928 295.592 406.928C296.251 406.928 296.827 407.08 297.321 407.383C297.815 407.678 298.197 408.094 298.465 408.631C298.734 409.168 298.868 409.805 298.868 410.542C298.868 411.27 298.734 411.907 298.465 412.453C298.197 412.999 297.815 413.419 297.321 413.714C296.827 414.009 296.251 414.156 295.592 414.156ZM295.592 412.96C296.182 412.96 296.637 412.748 296.957 412.323C297.278 411.89 297.438 411.296 297.438 410.542C297.438 409.788 297.278 409.199 296.957 408.774C296.637 408.341 296.182 408.124 295.592 408.124C295.012 408.124 294.557 408.341 294.227 408.774C293.907 409.199 293.746 409.788 293.746 410.542C293.746 411.296 293.907 411.89 294.227 412.323C294.557 412.748 295.012 412.96 295.592 412.96ZM303.043 416.106C302.497 416.106 302.016 416.024 301.6 415.859C301.184 415.694 300.842 415.46 300.573 415.157C300.304 414.862 300.122 414.516 300.027 414.117L301.457 414.026C301.552 414.312 301.717 414.537 301.951 414.702C302.194 414.875 302.558 414.962 303.043 414.962C303.615 414.962 304.053 414.841 304.356 414.598C304.668 414.355 304.824 413.987 304.824 413.493V412.492C304.659 412.847 304.395 413.129 304.031 413.337C303.667 413.545 303.26 413.649 302.809 413.649C302.228 413.649 301.713 413.506 301.262 413.22C300.811 412.934 300.46 412.54 300.209 412.037C299.958 411.534 299.832 410.954 299.832 410.295C299.832 409.628 299.953 409.043 300.196 408.54C300.447 408.037 300.794 407.643 301.236 407.357C301.687 407.071 302.194 406.928 302.757 406.928C303.251 406.928 303.684 407.041 304.057 407.266C304.438 407.491 304.711 407.795 304.876 408.176V407.084H306.202V413.454C306.202 414.026 306.072 414.507 305.812 414.897C305.561 415.296 305.197 415.595 304.72 415.794C304.252 416.002 303.693 416.106 303.043 416.106ZM303.03 412.479C303.567 412.479 303.996 412.284 304.317 411.894C304.646 411.504 304.815 410.967 304.824 410.282C304.833 409.831 304.763 409.446 304.616 409.125C304.469 408.796 304.256 408.544 303.979 408.371C303.71 408.189 303.394 408.098 303.03 408.098C302.467 408.098 302.029 408.297 301.717 408.696C301.414 409.086 301.262 409.615 301.262 410.282C301.262 410.958 301.418 411.495 301.73 411.894C302.051 412.284 302.484 412.479 303.03 412.479ZM310.812 416.106C310.266 416.106 309.785 416.024 309.369 415.859C308.953 415.694 308.611 415.46 308.342 415.157C308.074 414.862 307.892 414.516 307.796 414.117L309.226 414.026C309.322 414.312 309.486 414.537 309.72 414.702C309.963 414.875 310.327 414.962 310.812 414.962C311.384 414.962 311.822 414.841 312.125 414.598C312.437 414.355 312.593 413.987 312.593 413.493V412.492C312.429 412.847 312.164 413.129 311.8 413.337C311.436 413.545 311.029 413.649 310.578 413.649C309.998 413.649 309.482 413.506 309.031 413.22C308.581 412.934 308.23 412.54 307.978 412.037C307.727 411.534 307.601 410.954 307.601 410.295C307.601 409.628 307.723 409.043 307.965 408.54C308.217 408.037 308.563 407.643 309.005 407.357C309.456 407.071 309.963 406.928 310.526 406.928C311.02 406.928 311.454 407.041 311.826 407.266C312.208 407.491 312.481 407.795 312.645 408.176V407.084H313.971V413.454C313.971 414.026 313.841 414.507 313.581 414.897C313.33 415.296 312.966 415.595 312.489 415.794C312.021 416.002 311.462 416.106 310.812 416.106ZM310.799 412.479C311.337 412.479 311.766 412.284 312.086 411.894C312.416 411.504 312.585 410.967 312.593 410.282C312.602 409.831 312.533 409.446 312.385 409.125C312.238 408.796 312.026 408.544 311.748 408.371C311.48 408.189 311.163 408.098 310.799 408.098C310.236 408.098 309.798 408.297 309.486 408.696C309.183 409.086 309.031 409.615 309.031 410.282C309.031 410.958 309.187 411.495 309.499 411.894C309.82 412.284 310.253 412.479 310.799 412.479ZM315.762 414V407.084H317.14V414H315.762ZM315.736 406.083V404.705H317.166V406.083H315.736ZM319.202 414V407.084H320.463L320.515 408.93L320.346 408.839C320.424 408.388 320.572 408.024 320.788 407.747C321.005 407.47 321.269 407.266 321.581 407.136C321.893 406.997 322.231 406.928 322.595 406.928C323.115 406.928 323.544 407.045 323.882 407.279C324.229 407.504 324.489 407.816 324.662 408.215C324.844 408.605 324.935 409.051 324.935 409.554V414H323.557V409.97C323.557 409.563 323.514 409.22 323.427 408.943C323.341 408.666 323.198 408.453 322.998 408.306C322.799 408.159 322.539 408.085 322.218 408.085C321.733 408.085 321.339 408.245 321.035 408.566C320.732 408.887 320.58 409.355 320.58 409.97V414H319.202ZM329.513 416.106C328.967 416.106 328.486 416.024 328.07 415.859C327.654 415.694 327.311 415.46 327.043 415.157C326.774 414.862 326.592 414.516 326.497 414.117L327.927 414.026C328.022 414.312 328.187 414.537 328.421 414.702C328.663 414.875 329.027 414.962 329.513 414.962C330.085 414.962 330.522 414.841 330.826 414.598C331.138 414.355 331.294 413.987 331.294 413.493V412.492C331.129 412.847 330.865 413.129 330.501 413.337C330.137 413.545 329.729 413.649 329.279 413.649C328.698 413.649 328.182 413.506 327.732 413.22C327.281 412.934 326.93 412.54 326.679 412.037C326.427 411.534 326.302 410.954 326.302 410.295C326.302 409.628 326.423 409.043 326.666 408.54C326.917 408.037 327.264 407.643 327.706 407.357C328.156 407.071 328.663 406.928 329.227 406.928C329.721 406.928 330.154 407.041 330.527 407.266C330.908 407.491 331.181 407.795 331.346 408.176V407.084H332.672V413.454C332.672 414.026 332.542 414.507 332.282 414.897C332.03 415.296 331.666 415.595 331.19 415.794C330.722 416.002 330.163 416.106 329.513 416.106ZM329.5 412.479C330.037 412.479 330.466 412.284 330.787 411.894C331.116 411.504 331.285 410.967 331.294 410.282C331.302 409.831 331.233 409.446 331.086 409.125C330.938 408.796 330.726 408.544 330.449 408.371C330.18 408.189 329.864 408.098 329.5 408.098C328.936 408.098 328.499 408.297 328.187 408.696C327.883 409.086 327.732 409.615 327.732 410.282C327.732 410.958 327.888 411.495 328.2 411.894C328.52 412.284 328.954 412.479 329.5 412.479Z" fill="#090B0B"/> +<path d="M247.145 431.1C245.225 431.1 244.705 429.88 244.705 428.42V424H245.635V428.42C245.635 429.42 245.895 430.35 247.145 430.35C248.385 430.35 248.655 429.42 248.655 428.42V424H249.585V428.42C249.585 429.88 249.045 431.1 247.145 431.1ZM253.066 428.68C252.136 428.52 251.356 428.18 251.356 427.21C251.356 426.25 252.286 425.7 253.336 425.7C254.306 425.7 255.226 426.21 255.356 427.18H254.426C254.346 426.68 253.906 426.43 253.336 426.43C252.726 426.43 252.286 426.72 252.286 427.2C252.286 427.71 252.726 427.83 253.166 427.92L253.666 428.03C254.786 428.23 255.456 428.62 255.456 429.56C255.456 430.52 254.566 431.1 253.396 431.1C252.286 431.1 251.396 430.56 251.226 429.49H252.156C252.256 430.11 252.776 430.37 253.396 430.37C254.096 430.37 254.526 430.07 254.526 429.57C254.526 429.07 254.146 428.91 253.566 428.78L253.066 428.68ZM259.577 431.1C258.237 431.1 257.067 430.14 257.067 428.4C257.067 426.69 258.207 425.7 259.557 425.7C261.217 425.7 262.077 426.91 262.047 428.61H258.007V428.71C258.057 429.8 258.767 430.37 259.577 430.37C260.267 430.37 260.797 430.03 261.007 429.42H261.957C261.647 430.52 260.697 431.1 259.577 431.1ZM258.017 427.93H261.087C260.987 426.95 260.377 426.43 259.547 426.43C258.767 426.43 258.117 426.95 258.017 427.93ZM265.909 425.8V426.6C266.159 426.05 266.629 425.8 267.409 425.8H268.389V426.55H267.329C266.379 426.55 265.909 426.93 265.909 428.02V430.25H267.689V431H263.369V430.25H264.979V426.55H263.369V425.8H265.909ZM277.621 431.1C276.271 431.1 275.351 430.39 275.351 429.23C275.351 428.19 276.181 427.64 276.911 427.22C276.401 426.58 276.101 426.07 276.101 425.49C276.101 424.57 276.871 423.9 277.861 423.9C278.841 423.9 279.611 424.57 279.611 425.49C279.611 426.25 279.111 426.74 278.081 427.38L279.541 429.13C279.781 428.69 279.971 428.11 280.081 427.36H280.861C280.751 428.32 280.481 429.14 280.071 429.76L281.111 431H280.091L279.561 430.37C279.051 430.84 278.401 431.1 277.621 431.1ZM276.261 429.23C276.261 429.92 276.821 430.37 277.621 430.37C278.121 430.37 278.631 430.2 279.061 429.78L277.401 427.79C276.801 428.15 276.261 428.56 276.261 429.23ZM276.941 425.49C276.941 425.91 277.161 426.27 277.601 426.82C278.461 426.3 278.761 425.99 278.761 425.49C278.761 425.03 278.411 424.63 277.861 424.63C277.301 424.63 276.941 425.03 276.941 425.49ZM287.773 425.8H288.653L289.383 429.77V429.97L290.193 425.8H290.913L291.723 429.97V429.77L292.453 425.8H293.333L292.293 431H291.193L290.553 427.5L289.913 431H288.813L287.773 425.8ZM296.754 431.1C295.434 431.1 294.274 430.18 294.274 428.4C294.274 426.62 295.434 425.7 296.754 425.7C298.074 425.7 299.244 426.62 299.244 428.4C299.244 430.18 298.074 431.1 296.754 431.1ZM295.204 428.4C295.204 429.72 295.904 430.37 296.754 430.37C297.604 430.37 298.304 429.72 298.304 428.4C298.304 427.08 297.604 426.43 296.754 426.43C295.904 426.43 295.204 427.08 295.204 428.4ZM303.116 425.8V426.6C303.366 426.05 303.836 425.8 304.616 425.8H305.596V426.55H304.536C303.586 426.55 303.116 426.93 303.116 428.02V430.25H304.896V431H300.576V430.25H302.186V426.55H300.576V425.8H303.116ZM307.177 431V424H308.107V428.41L310.527 425.8H311.657L309.547 428.01L311.657 431H310.587L308.947 428.64L308.107 429.52V431H307.177ZM315.078 428.68C314.148 428.52 313.368 428.18 313.368 427.21C313.368 426.25 314.298 425.7 315.348 425.7C316.318 425.7 317.238 426.21 317.368 427.18H316.438C316.358 426.68 315.918 426.43 315.348 426.43C314.738 426.43 314.298 426.72 314.298 427.2C314.298 427.71 314.738 427.83 315.178 427.92L315.678 428.03C316.798 428.23 317.468 428.62 317.468 429.56C317.468 430.52 316.578 431.1 315.408 431.1C314.298 431.1 313.408 430.56 313.238 429.49H314.168C314.268 430.11 314.788 430.37 315.408 430.37C316.108 430.37 316.538 430.07 316.538 429.57C316.538 429.07 316.158 428.91 315.578 428.78L315.078 428.68ZM319.199 425.8H320.129V426.73C320.539 426.06 321.199 425.7 321.949 425.7C323.149 425.7 324.229 426.67 324.229 428.4C324.229 430.13 323.159 431.1 321.949 431.1C321.199 431.1 320.539 430.74 320.129 430.07V432.8H319.199V425.8ZM320.129 428.4C320.129 429.64 320.839 430.37 321.709 430.37C322.589 430.37 323.299 429.64 323.299 428.4C323.299 427.16 322.589 426.43 321.709 426.43C320.839 426.43 320.129 427.16 320.129 428.4ZM325.43 429.61C325.43 428.68 326.19 428.06 327.95 427.94L328.88 427.88V427.78C328.88 426.74 328.45 426.43 327.73 426.43C327.06 426.43 326.58 426.79 326.47 427.4H325.57C325.75 426.37 326.65 425.7 327.75 425.7C328.96 425.7 329.81 426.26 329.81 427.95V430.12C329.81 430.48 329.85 430.79 329.91 431H329.02C328.97 430.8 328.95 430.53 328.95 430.24C328.66 430.76 328.14 431.1 327.26 431.1C326.05 431.1 325.43 430.42 325.43 429.61ZM326.36 429.58C326.36 430.03 326.69 430.42 327.48 430.42C328.39 430.42 328.88 429.81 328.88 429.06V428.55L328.13 428.6C326.9 428.69 326.36 428.98 326.36 429.58ZM331.561 428.4C331.561 426.62 332.721 425.7 334.061 425.7C335.231 425.7 336.131 426.32 336.421 427.48H335.481C335.261 426.8 334.771 426.43 334.061 426.43C333.201 426.43 332.491 427.1 332.491 428.4C332.491 429.7 333.201 430.37 334.061 430.37C334.771 430.37 335.271 430 335.491 429.31H336.421C336.141 430.48 335.231 431.1 334.061 431.1C332.721 431.1 331.561 430.18 331.561 428.4ZM340.193 431.1C338.853 431.1 337.683 430.14 337.683 428.4C337.683 426.69 338.823 425.7 340.173 425.7C341.833 425.7 342.693 426.91 342.663 428.61H338.623V428.71C338.673 429.8 339.383 430.37 340.193 430.37C340.883 430.37 341.413 430.03 341.623 429.42H342.573C342.263 430.52 341.313 431.1 340.193 431.1ZM338.633 427.93H341.703C341.603 426.95 340.993 426.43 340.163 426.43C339.383 426.43 338.733 426.95 338.633 427.93ZM281.281 445.1C279.941 445.1 278.771 444.14 278.771 442.4C278.771 440.69 279.911 439.7 281.261 439.7C282.921 439.7 283.781 440.91 283.751 442.61H279.711V442.71C279.761 443.8 280.471 444.37 281.281 444.37C281.971 444.37 282.501 444.03 282.711 443.42H283.661C283.351 444.52 282.401 445.1 281.281 445.1ZM279.721 441.93H282.791C282.691 440.95 282.081 440.43 281.251 440.43C280.471 440.43 279.821 440.95 279.721 441.93ZM286.843 445L284.953 439.8H285.893L287.453 444.33L289.013 439.8H289.943L288.053 445H286.843ZM293.684 445.1C292.344 445.1 291.174 444.14 291.174 442.4C291.174 440.69 292.314 439.7 293.664 439.7C295.324 439.7 296.184 440.91 296.154 442.61H292.114V442.71C292.164 443.8 292.874 444.37 293.684 444.37C294.374 444.37 294.904 444.03 295.114 443.42H296.064C295.754 444.52 294.804 445.1 293.684 445.1ZM292.124 441.93H295.194C295.094 440.95 294.484 440.43 293.654 440.43C292.874 440.43 292.224 440.95 292.124 441.93ZM301.195 441.94C301.195 440.9 300.765 440.43 300.025 440.43C299.185 440.43 298.635 441.02 298.635 442.13V445H297.705V439.8H298.635V440.72C298.965 440.05 299.575 439.7 300.355 439.7C301.415 439.7 302.125 440.35 302.125 441.8V445H301.195V441.94ZM306.276 443.52C306.276 444.19 306.666 444.25 307.156 444.25H308.116V445H306.906C306.056 445 305.346 444.78 305.346 443.61V440.55H303.706V439.8H305.346V438.6H306.276V439.8H308.116V440.55H306.276V443.52ZM311.977 442.68C311.047 442.52 310.267 442.18 310.267 441.21C310.267 440.25 311.197 439.7 312.247 439.7C313.217 439.7 314.137 440.21 314.267 441.18H313.337C313.257 440.68 312.817 440.43 312.247 440.43C311.637 440.43 311.197 440.72 311.197 441.2C311.197 441.71 311.637 441.83 312.077 441.92L312.577 442.03C313.697 442.23 314.367 442.62 314.367 443.56C314.367 444.52 313.477 445.1 312.307 445.1C311.197 445.1 310.307 444.56 310.137 443.49H311.067C311.167 444.11 311.687 444.37 312.307 444.37C313.007 444.37 313.437 444.07 313.437 443.57C313.437 443.07 313.057 442.91 312.477 442.78L311.977 442.68Z" fill="#090B0B"/> +<path d="M528 390H378V455H528V390Z" fill="#FCCBBE"/> +<path d="M401.071 415V405.77H404.75C405.408 405.77 405.976 405.883 406.453 406.108C406.929 406.333 407.298 406.654 407.558 407.07C407.818 407.486 407.948 407.98 407.948 408.552C407.948 408.968 407.852 409.341 407.662 409.67C407.48 409.999 407.233 410.264 406.921 410.463C406.617 410.654 406.292 410.762 405.946 410.788L405.881 410.671C406.461 410.671 406.912 410.805 407.233 411.074C407.553 411.334 407.74 411.746 407.792 412.309L408.039 415H406.622L406.401 412.491C406.375 412.118 406.249 411.845 406.024 411.672C405.798 411.49 405.43 411.399 404.919 411.399H402.475V415H401.071ZM402.475 410.112H404.776C405.304 410.112 405.72 409.982 406.024 409.722C406.336 409.453 406.492 409.076 406.492 408.591C406.492 408.097 406.336 407.72 406.024 407.46C405.712 407.191 405.257 407.057 404.659 407.057H402.475V410.112ZM409.906 415V405.77H413.221C414.331 405.77 415.167 405.969 415.73 406.368C416.294 406.767 416.575 407.399 416.575 408.266C416.575 408.656 416.493 408.998 416.328 409.293C416.164 409.588 415.921 409.822 415.6 409.995C415.288 410.16 414.916 410.246 414.482 410.255L414.469 410.203C415.258 410.22 415.869 410.437 416.302 410.853C416.744 411.26 416.965 411.798 416.965 412.465C416.965 413.306 416.684 413.938 416.12 414.363C415.557 414.788 414.755 415 413.715 415H409.906ZM411.31 413.739H413.676C414.257 413.739 414.708 413.622 415.028 413.388C415.358 413.145 415.522 412.794 415.522 412.335C415.522 411.884 415.358 411.533 415.028 411.282C414.708 411.031 414.257 410.905 413.676 410.905H411.31V413.739ZM411.31 409.709H413.195C413.811 409.709 414.287 409.592 414.625 409.358C414.963 409.124 415.132 408.795 415.132 408.37C415.132 407.919 414.968 407.586 414.638 407.369C414.318 407.144 413.837 407.031 413.195 407.031H411.31V409.709ZM417.707 415L421.035 405.77H422.816L426.144 415H424.649L423.804 412.595H420.034L419.202 415H417.707ZM420.476 411.321H423.375L421.919 407.109L420.476 411.321ZM430.749 415.208C429.917 415.208 429.185 415.017 428.552 414.636C427.92 414.246 427.426 413.691 427.07 412.972C426.715 412.253 426.537 411.395 426.537 410.398C426.537 409.41 426.711 408.556 427.057 407.837C427.413 407.109 427.907 406.55 428.539 406.16C429.172 405.761 429.913 405.562 430.762 405.562C431.906 405.562 432.795 405.848 433.427 406.42C434.06 406.992 434.472 407.785 434.662 408.799L433.193 408.877C433.072 408.244 432.812 407.75 432.413 407.395C432.015 407.031 431.464 406.849 430.762 406.849C430.199 406.849 429.709 406.992 429.293 407.278C428.886 407.564 428.565 407.971 428.331 408.5C428.106 409.029 427.993 409.661 427.993 410.398C427.993 411.135 428.106 411.767 428.331 412.296C428.565 412.825 428.89 413.228 429.306 413.505C429.722 413.782 430.203 413.921 430.749 413.921C431.495 413.921 432.071 413.726 432.478 413.336C432.894 412.937 433.154 412.391 433.258 411.698L434.727 411.776C434.615 412.478 434.389 413.085 434.051 413.596C433.713 414.107 433.267 414.506 432.712 414.792C432.158 415.069 431.503 415.208 430.749 415.208ZM441.904 415.208C440.959 415.208 440.223 414.996 439.694 414.571C439.165 414.138 438.901 413.527 438.901 412.738C438.901 412.339 438.957 411.984 439.07 411.672C439.191 411.36 439.386 411.065 439.655 410.788C439.924 410.502 440.288 410.207 440.747 409.904C440.548 409.653 440.379 409.419 440.24 409.202C440.101 408.977 439.993 408.751 439.915 408.526C439.846 408.292 439.811 408.041 439.811 407.772C439.811 407.087 440.023 406.55 440.448 406.16C440.881 405.761 441.479 405.562 442.242 405.562C442.753 405.562 443.187 405.649 443.542 405.822C443.897 405.987 444.166 406.225 444.348 406.537C444.53 406.84 444.621 407.2 444.621 407.616C444.621 408.127 444.465 408.578 444.153 408.968C443.85 409.358 443.356 409.77 442.671 410.203L444.556 412.595C444.842 412.205 444.985 411.616 444.985 410.827L446.376 410.957C446.359 411.468 446.263 411.962 446.09 412.439C445.925 412.916 445.709 413.31 445.44 413.622L446.597 415H444.959L444.53 414.48C444.27 414.697 443.915 414.87 443.464 415C443.022 415.139 442.502 415.208 441.904 415.208ZM442.047 413.947C442.437 413.947 442.762 413.904 443.022 413.817C443.282 413.73 443.481 413.618 443.62 413.479L441.527 410.866C441.111 411.152 440.812 411.434 440.63 411.711C440.448 411.98 440.357 412.296 440.357 412.66C440.357 413.05 440.491 413.362 440.76 413.596C441.037 413.83 441.466 413.947 442.047 413.947ZM441.943 409.293C442.238 409.111 442.476 408.942 442.658 408.786C442.84 408.621 442.97 408.452 443.048 408.279C443.126 408.106 443.165 407.919 443.165 407.72C443.165 407.443 443.083 407.23 442.918 407.083C442.762 406.927 442.541 406.849 442.255 406.849C441.943 406.849 441.696 406.931 441.514 407.096C441.341 407.261 441.254 407.495 441.254 407.798C441.254 407.954 441.276 408.11 441.319 408.266C441.362 408.413 441.432 408.569 441.527 408.734C441.631 408.899 441.77 409.085 441.943 409.293ZM450.985 415V408.084H452.246L452.285 409.8L452.129 409.748C452.207 409.367 452.341 409.042 452.532 408.773C452.731 408.504 452.974 408.296 453.26 408.149C453.546 408.002 453.858 407.928 454.196 407.928C454.785 407.928 455.266 408.097 455.639 408.435C456.012 408.764 456.246 409.219 456.341 409.8H456.12C456.198 409.393 456.328 409.055 456.51 408.786C456.701 408.509 456.943 408.296 457.238 408.149C457.533 408.002 457.866 407.928 458.239 407.928C458.724 407.928 459.136 408.032 459.474 408.24C459.821 408.439 460.081 408.734 460.254 409.124C460.436 409.514 460.527 409.991 460.527 410.554V415H459.149V410.892C459.149 410.294 459.041 409.843 458.824 409.54C458.607 409.237 458.278 409.085 457.836 409.085C457.55 409.085 457.299 409.159 457.082 409.306C456.865 409.453 456.696 409.666 456.575 409.943C456.462 410.22 456.406 410.554 456.406 410.944V415H455.093V410.944C455.093 410.355 454.993 409.9 454.794 409.579C454.603 409.25 454.274 409.085 453.806 409.085C453.511 409.085 453.256 409.159 453.039 409.306C452.831 409.453 452.666 409.67 452.545 409.956C452.424 410.233 452.363 410.563 452.363 410.944V415H450.985ZM464.584 415.156C463.891 415.156 463.336 414.926 462.92 414.467C462.513 413.999 462.309 413.353 462.309 412.53V408.084H463.687V412.179C463.687 412.803 463.796 413.262 464.012 413.557C464.229 413.852 464.558 413.999 465 413.999C465.503 413.999 465.893 413.839 466.17 413.518C466.456 413.189 466.599 412.729 466.599 412.14V408.084H467.977V415H466.69L466.664 413.193L466.859 413.271C466.738 413.878 466.482 414.346 466.092 414.675C465.702 414.996 465.2 415.156 464.584 415.156ZM471.435 415C470.984 415 470.62 414.883 470.343 414.649C470.065 414.415 469.927 414.042 469.927 413.531V405.77H471.305V413.401C471.305 413.557 471.344 413.674 471.422 413.752C471.508 413.83 471.63 413.869 471.786 413.869H472.332V415H471.435ZM475.854 415C475.195 415 474.71 414.848 474.398 414.545C474.086 414.242 473.93 413.769 473.93 413.128V406.459H475.308V413.024C475.308 413.345 475.377 413.566 475.516 413.687C475.655 413.808 475.867 413.869 476.153 413.869H477.141V415H475.854ZM472.877 409.215V408.084H477.141V409.215H472.877ZM478.458 415V408.084H479.836V415H478.458ZM478.432 407.083V405.705H479.862V407.083H478.432ZM481.355 411.815V410.606H485.71V411.815H481.355ZM490.065 415.156C489.406 415.156 488.83 415.009 488.336 414.714C487.842 414.419 487.46 413.999 487.192 413.453C486.923 412.907 486.789 412.27 486.789 411.542C486.789 410.805 486.923 410.168 487.192 409.631C487.46 409.094 487.842 408.678 488.336 408.383C488.83 408.08 489.406 407.928 490.065 407.928C490.723 407.928 491.3 408.08 491.794 408.383C492.288 408.678 492.669 409.094 492.938 409.631C493.206 410.168 493.341 410.805 493.341 411.542C493.341 412.27 493.206 412.907 492.938 413.453C492.669 413.999 492.288 414.419 491.794 414.714C491.3 415.009 490.723 415.156 490.065 415.156ZM490.065 413.96C490.654 413.96 491.109 413.748 491.43 413.323C491.75 412.89 491.911 412.296 491.911 411.542C491.911 410.788 491.75 410.199 491.43 409.774C491.109 409.341 490.654 409.124 490.065 409.124C489.484 409.124 489.029 409.341 488.7 409.774C488.379 410.199 488.219 410.788 488.219 411.542C488.219 412.296 488.379 412.89 488.7 413.323C489.029 413.748 489.484 413.96 490.065 413.96ZM494.733 415V408.084H495.994L496.046 409.917L495.929 409.878C496.025 409.254 496.215 408.799 496.501 408.513C496.796 408.227 497.19 408.084 497.684 408.084H498.347V409.319H497.684C497.338 409.319 497.047 409.375 496.813 409.488C496.579 409.601 496.402 409.774 496.28 410.008C496.168 410.242 496.111 410.545 496.111 410.918V415H494.733ZM502.111 417.106C501.565 417.106 501.084 417.024 500.668 416.859C500.252 416.694 499.91 416.46 499.641 416.157C499.372 415.862 499.19 415.516 499.095 415.117L500.525 415.026C500.62 415.312 500.785 415.537 501.019 415.702C501.262 415.875 501.626 415.962 502.111 415.962C502.683 415.962 503.121 415.841 503.424 415.598C503.736 415.355 503.892 414.987 503.892 414.493V413.492C503.727 413.847 503.463 414.129 503.099 414.337C502.735 414.545 502.328 414.649 501.877 414.649C501.296 414.649 500.781 414.506 500.33 414.22C499.879 413.934 499.528 413.54 499.277 413.037C499.026 412.534 498.9 411.954 498.9 411.295C498.9 410.628 499.021 410.043 499.264 409.54C499.515 409.037 499.862 408.643 500.304 408.357C500.755 408.071 501.262 407.928 501.825 407.928C502.319 407.928 502.752 408.041 503.125 408.266C503.506 408.491 503.779 408.795 503.944 409.176V408.084H505.27V414.454C505.27 415.026 505.14 415.507 504.88 415.897C504.629 416.296 504.265 416.595 503.788 416.794C503.32 417.002 502.761 417.106 502.111 417.106ZM502.098 413.479C502.635 413.479 503.064 413.284 503.385 412.894C503.714 412.504 503.883 411.967 503.892 411.282C503.901 410.831 503.831 410.446 503.684 410.125C503.537 409.796 503.324 409.544 503.047 409.371C502.778 409.189 502.462 409.098 502.098 409.098C501.535 409.098 501.097 409.297 500.785 409.696C500.482 410.086 500.33 410.615 500.33 411.282C500.33 411.958 500.486 412.495 500.798 412.894C501.119 413.284 501.552 413.479 502.098 413.479Z" fill="#090B0B"/> +<path d="M399.05 432V430.76C398.76 431.58 398.17 432.1 397.19 432.1C395.46 432.1 394.67 430.63 394.67 428.5C394.67 426.37 395.45 424.9 397.3 424.9C398.73 424.9 399.52 425.75 399.75 427.12H398.83C398.65 426.23 398.16 425.65 397.3 425.65C396.06 425.65 395.6 426.85 395.6 428.5C395.6 430.16 396.06 431.35 397.3 431.35C398.35 431.35 398.82 430.42 398.83 429.13H397.27V428.38H399.76V432H399.05ZM403.651 426.8V427.6C403.901 427.05 404.371 426.8 405.151 426.8H406.131V427.55H405.071C404.121 427.55 403.651 427.93 403.651 429.02V431.25H405.431V432H401.111V431.25H402.721V427.55H401.111V426.8H403.651ZM409.693 432.1C408.373 432.1 407.213 431.18 407.213 429.4C407.213 427.62 408.373 426.7 409.693 426.7C411.013 426.7 412.183 427.62 412.183 429.4C412.183 431.18 411.013 432.1 409.693 432.1ZM408.143 429.4C408.143 430.72 408.843 431.37 409.693 431.37C410.543 431.37 411.243 430.72 411.243 429.4C411.243 428.08 410.543 427.43 409.693 427.43C408.843 427.43 408.143 428.08 408.143 429.4ZM414.554 429.86C414.554 430.9 414.984 431.37 415.724 431.37C416.564 431.37 417.114 430.78 417.114 429.66V426.8H418.044V432H417.114V431.08C416.784 431.75 416.174 432.1 415.394 432.1C414.334 432.1 413.624 431.45 413.624 430V426.8H414.554V429.86ZM419.735 426.8H420.665V427.73C421.075 427.06 421.735 426.7 422.485 426.7C423.685 426.7 424.765 427.67 424.765 429.4C424.765 431.13 423.695 432.1 422.485 432.1C421.735 432.1 421.075 431.74 420.665 431.07V433.8H419.735V426.8ZM420.665 429.4C420.665 430.64 421.375 431.37 422.245 431.37C423.125 431.37 423.835 430.64 423.835 429.4C423.835 428.16 423.125 427.43 422.245 427.43C421.375 427.43 420.665 428.16 420.665 429.4ZM428.016 429.68C427.086 429.52 426.306 429.18 426.306 428.21C426.306 427.25 427.236 426.7 428.286 426.7C429.256 426.7 430.176 427.21 430.306 428.18H429.376C429.296 427.68 428.856 427.43 428.286 427.43C427.676 427.43 427.236 427.72 427.236 428.2C427.236 428.71 427.676 428.83 428.116 428.92L428.616 429.03C429.736 429.23 430.406 429.62 430.406 430.56C430.406 431.52 429.516 432.1 428.346 432.1C427.236 432.1 426.346 431.56 426.176 430.49H427.106C427.206 431.11 427.726 431.37 428.346 431.37C429.046 431.37 429.476 431.07 429.476 430.57C429.476 430.07 429.096 429.91 428.516 429.78L428.016 429.68ZM434.017 432V430.85H435.007V432L434.597 433.24H434.077L434.427 432H434.017ZM446.9 432.1C445.04 432.1 444.26 430.63 444.26 428.5C444.26 426.37 445.04 424.9 446.9 424.9C448.76 424.9 449.54 426.37 449.54 428.5C449.54 430.63 448.76 432.1 446.9 432.1ZM445.2 428.5C445.2 430.16 445.65 431.35 446.9 431.35C448.15 431.35 448.61 430.16 448.61 428.5C448.61 426.85 448.15 425.65 446.9 425.65C445.65 425.65 445.2 426.85 445.2 428.5ZM455.361 425V425.75H453.571V431.25H455.361V432H450.841V431.25H452.631V425.75H450.841V425H455.361ZM458.612 431.25C460.282 431.25 461.052 430.18 461.052 428.5C461.052 426.81 460.282 425.75 458.612 425.75H457.962V431.25H458.612ZM457.032 432V425H458.612C460.822 425 461.982 426.34 461.982 428.5C461.982 430.66 460.822 432 458.612 432H457.032ZM463.013 428.5C463.013 426.37 463.793 424.9 465.653 424.9C467.073 424.9 467.863 425.75 468.103 427.12H467.173C466.993 426.23 466.513 425.65 465.653 425.65C464.403 425.65 463.943 426.85 463.943 428.5C463.943 430.16 464.403 431.35 465.653 431.35C466.513 431.35 466.993 430.77 467.163 429.9H468.103C467.863 431.26 467.063 432.1 465.653 432.1C463.793 432.1 463.013 430.63 463.013 428.5ZM477.625 429.68C476.695 429.52 475.915 429.18 475.915 428.21C475.915 427.25 476.845 426.7 477.895 426.7C478.865 426.7 479.785 427.21 479.915 428.18H478.985C478.905 427.68 478.465 427.43 477.895 427.43C477.285 427.43 476.845 427.72 476.845 428.2C476.845 428.71 477.285 428.83 477.725 428.92L478.225 429.03C479.345 429.23 480.015 429.62 480.015 430.56C480.015 431.52 479.125 432.1 477.955 432.1C476.845 432.1 475.955 431.56 475.785 430.49H476.715C476.815 431.11 477.335 431.37 477.955 431.37C478.655 431.37 479.085 431.07 479.085 430.57C479.085 430.07 478.705 429.91 478.125 429.78L477.625 429.68ZM482.917 433.8L483.627 431.94L481.537 426.8H482.487L484.047 430.83L485.577 426.8H486.517L483.857 433.8H482.917ZM491.648 428.94C491.648 427.9 491.218 427.43 490.478 427.43C489.638 427.43 489.088 428.02 489.088 429.13V432H488.158V426.8H489.088V427.72C489.418 427.05 490.028 426.7 490.808 426.7C491.868 426.7 492.578 427.35 492.578 428.8V432H491.648V428.94ZM494.109 429.4C494.109 427.62 495.269 426.7 496.609 426.7C497.779 426.7 498.679 427.32 498.969 428.48H498.029C497.809 427.8 497.319 427.43 496.609 427.43C495.749 427.43 495.039 428.1 495.039 429.4C495.039 430.7 495.749 431.37 496.609 431.37C497.319 431.37 497.819 431 498.039 430.31H498.969C498.689 431.48 497.779 432.1 496.609 432.1C495.269 432.1 494.109 431.18 494.109 429.4ZM502.23 432V430.85H503.22V432L502.81 433.24H502.29L502.64 432H502.23ZM440.858 440.8V441.6C441.108 441.05 441.578 440.8 442.358 440.8H443.338V441.55H442.278C441.328 441.55 440.858 441.93 440.858 443.02V445.25H442.638V446H438.318V445.25H439.928V441.55H438.318V440.8H440.858ZM446.9 446.1C445.58 446.1 444.42 445.18 444.42 443.4C444.42 441.62 445.58 440.7 446.9 440.7C448.22 440.7 449.39 441.62 449.39 443.4C449.39 445.18 448.22 446.1 446.9 446.1ZM445.35 443.4C445.35 444.72 446.05 445.37 446.9 445.37C447.75 445.37 448.45 444.72 448.45 443.4C448.45 442.08 447.75 441.43 446.9 441.43C446.05 441.43 445.35 442.08 445.35 443.4ZM453.801 439V445.25H455.391V446H451.081V445.25H452.871V439.75H451.081V439H453.801ZM459.332 446.1C457.992 446.1 456.822 445.14 456.822 443.4C456.822 441.69 457.962 440.7 459.312 440.7C460.972 440.7 461.832 441.91 461.802 443.61H457.762V443.71C457.812 444.8 458.522 445.37 459.332 445.37C460.022 445.37 460.552 445.03 460.762 444.42H461.712C461.402 445.52 460.452 446.1 459.332 446.1ZM457.772 442.93H460.842C460.742 441.95 460.132 441.43 459.302 441.43C458.522 441.43 457.872 441.95 457.772 442.93ZM465.223 443.68C464.293 443.52 463.513 443.18 463.513 442.21C463.513 441.25 464.443 440.7 465.493 440.7C466.463 440.7 467.383 441.21 467.513 442.18H466.583C466.503 441.68 466.063 441.43 465.493 441.43C464.883 441.43 464.443 441.72 464.443 442.2C464.443 442.71 464.883 442.83 465.323 442.92L465.823 443.03C466.943 443.23 467.613 443.62 467.613 444.56C467.613 445.52 466.723 446.1 465.553 446.1C464.443 446.1 463.553 445.56 463.383 444.49H464.313C464.413 445.11 464.933 445.37 465.553 445.37C466.253 445.37 466.683 445.07 466.683 444.57C466.683 444.07 466.303 443.91 465.723 443.78L465.223 443.68Z" fill="#090B0B"/> +<path d="M689 390H539V455H689V390Z" fill="#FCCBBE"/> +<path d="M568.859 417.871L568.118 416.909C567.91 416.996 567.667 417.065 567.39 417.117C567.112 417.178 566.813 417.208 566.493 417.208C565.6 417.208 564.837 416.996 564.205 416.571C563.572 416.146 563.082 415.574 562.736 414.855C562.398 414.127 562.229 413.308 562.229 412.398C562.229 411.488 562.393 410.669 562.723 409.941C563.052 409.213 563.533 408.637 564.166 408.212C564.807 407.779 565.583 407.562 566.493 407.562C567.42 407.562 568.2 407.779 568.833 408.212C569.474 408.637 569.955 409.213 570.276 409.941C570.605 410.669 570.77 411.488 570.77 412.398C570.77 413.247 570.618 414.019 570.315 414.712C570.02 415.405 569.591 415.969 569.028 416.402L570.198 417.871H568.859ZM566.493 415.921C566.657 415.921 566.805 415.912 566.935 415.895C567.065 415.878 567.182 415.847 567.286 415.804L565.973 414.153H567.312L568.196 415.323C568.568 415.02 568.846 414.625 569.028 414.14C569.218 413.646 569.314 413.065 569.314 412.398C569.314 411.757 569.21 411.167 569.002 410.63C568.802 410.093 568.495 409.664 568.079 409.343C567.663 409.014 567.134 408.849 566.493 408.849C565.86 408.849 565.336 409.014 564.92 409.343C564.504 409.664 564.192 410.093 563.984 410.63C563.784 411.167 563.685 411.757 563.685 412.398C563.685 413.031 563.784 413.616 563.984 414.153C564.192 414.69 564.504 415.119 564.92 415.44C565.344 415.761 565.869 415.921 566.493 415.921ZM574.593 417.156C573.9 417.156 573.345 416.926 572.929 416.467C572.522 415.999 572.318 415.353 572.318 414.53V410.084H573.696V414.179C573.696 414.803 573.804 415.262 574.021 415.557C574.238 415.852 574.567 415.999 575.009 415.999C575.512 415.999 575.902 415.839 576.179 415.518C576.465 415.189 576.608 414.729 576.608 414.14V410.084H577.986V417H576.699L576.673 415.193L576.868 415.271C576.747 415.878 576.491 416.346 576.101 416.675C575.711 416.996 575.208 417.156 574.593 417.156ZM582.655 417.156C581.997 417.156 581.42 417.009 580.926 416.714C580.432 416.419 580.051 415.999 579.782 415.453C579.514 414.907 579.379 414.27 579.379 413.542C579.379 412.805 579.514 412.168 579.782 411.631C580.051 411.094 580.432 410.678 580.926 410.383C581.42 410.08 581.997 409.928 582.655 409.928C583.314 409.928 583.89 410.08 584.384 410.383C584.878 410.678 585.26 411.094 585.528 411.631C585.797 412.168 585.931 412.805 585.931 413.542C585.931 414.27 585.797 414.907 585.528 415.453C585.26 415.999 584.878 416.419 584.384 416.714C583.89 417.009 583.314 417.156 582.655 417.156ZM582.655 415.96C583.245 415.96 583.7 415.748 584.02 415.323C584.341 414.89 584.501 414.296 584.501 413.542C584.501 412.788 584.341 412.199 584.02 411.774C583.7 411.341 583.245 411.124 582.655 411.124C582.075 411.124 581.62 411.341 581.29 411.774C580.97 412.199 580.809 412.788 580.809 413.542C580.809 414.296 580.97 414.89 581.29 415.323C581.62 415.748 582.075 415.96 582.655 415.96ZM589.722 417C589.063 417 588.578 416.848 588.266 416.545C587.954 416.242 587.798 415.769 587.798 415.128V408.459H589.176V415.024C589.176 415.345 589.245 415.566 589.384 415.687C589.523 415.808 589.735 415.869 590.021 415.869H591.009V417H589.722ZM586.745 411.215V410.084H591.009V411.215H586.745ZM594.225 417.156C593.505 417.156 592.929 416.991 592.496 416.662C592.071 416.333 591.859 415.869 591.859 415.271C591.859 414.673 592.036 414.209 592.392 413.88C592.756 413.542 593.315 413.299 594.069 413.152L596.448 412.697C596.448 412.16 596.322 411.761 596.071 411.501C595.819 411.232 595.447 411.098 594.953 411.098C594.511 411.098 594.164 411.198 593.913 411.397C593.661 411.588 593.488 411.874 593.393 412.255L591.976 412.164C592.106 411.471 592.426 410.925 592.938 410.526C593.458 410.127 594.129 409.928 594.953 409.928C595.889 409.928 596.599 410.179 597.085 410.682C597.579 411.176 597.826 411.874 597.826 412.775V415.466C597.826 415.631 597.852 415.748 597.904 415.817C597.964 415.878 598.06 415.908 598.19 415.908H598.437V417C598.393 417.009 598.324 417.017 598.229 417.026C598.133 417.035 598.034 417.039 597.93 417.039C597.635 417.039 597.379 416.991 597.163 416.896C596.955 416.801 596.799 416.645 596.695 416.428C596.591 416.203 596.539 415.904 596.539 415.531L596.682 415.596C596.612 415.899 596.461 416.168 596.227 416.402C596.001 416.636 595.711 416.822 595.356 416.961C595.009 417.091 594.632 417.156 594.225 417.156ZM594.446 416.064C594.862 416.064 595.217 415.986 595.512 415.83C595.806 415.665 596.036 415.44 596.201 415.154C596.365 414.868 596.448 414.543 596.448 414.179V413.737L594.42 414.127C594.004 414.205 593.709 414.331 593.536 414.504C593.371 414.669 593.289 414.881 593.289 415.141C593.289 415.436 593.388 415.665 593.588 415.83C593.796 415.986 594.082 416.064 594.446 416.064ZM602.215 417.156C601.565 417.156 601.014 417.056 600.564 416.857C600.122 416.658 599.779 416.385 599.537 416.038C599.294 415.683 599.155 415.275 599.121 414.816L600.538 414.751C600.607 415.132 600.772 415.436 601.032 415.661C601.292 415.886 601.686 415.999 602.215 415.999C602.648 415.999 602.99 415.93 603.242 415.791C603.493 415.652 603.619 415.431 603.619 415.128C603.619 414.963 603.575 414.825 603.489 414.712C603.411 414.591 603.255 414.491 603.021 414.413C602.787 414.326 602.44 414.244 601.981 414.166C601.296 414.036 600.759 413.884 600.369 413.711C599.979 413.538 599.701 413.317 599.537 413.048C599.372 412.779 599.29 412.454 599.29 412.073C599.29 411.44 599.524 410.925 599.992 410.526C600.468 410.127 601.153 409.928 602.046 409.928C602.644 409.928 603.146 410.032 603.554 410.24C603.961 410.439 604.277 410.712 604.503 411.059C604.737 411.397 604.884 411.778 604.945 412.203L603.528 412.281C603.484 412.038 603.398 411.83 603.268 411.657C603.146 411.475 602.982 411.336 602.774 411.241C602.566 411.137 602.319 411.085 602.033 411.085C601.591 411.085 601.261 411.172 601.045 411.345C600.828 411.518 600.72 411.744 600.72 412.021C600.72 412.229 600.767 412.398 600.863 412.528C600.967 412.658 601.131 412.766 601.357 412.853C601.582 412.931 601.881 413 602.254 413.061C602.973 413.182 603.536 413.334 603.944 413.516C604.351 413.689 604.637 413.906 604.802 414.166C604.966 414.426 605.049 414.742 605.049 415.115C605.049 415.548 604.927 415.917 604.685 416.22C604.442 416.523 604.108 416.757 603.684 416.922C603.259 417.078 602.769 417.156 602.215 417.156ZM612.266 417.208C611.321 417.208 610.585 416.996 610.056 416.571C609.527 416.138 609.263 415.527 609.263 414.738C609.263 414.339 609.319 413.984 609.432 413.672C609.553 413.36 609.748 413.065 610.017 412.788C610.286 412.502 610.65 412.207 611.109 411.904C610.91 411.653 610.741 411.419 610.602 411.202C610.463 410.977 610.355 410.751 610.277 410.526C610.208 410.292 610.173 410.041 610.173 409.772C610.173 409.087 610.385 408.55 610.81 408.16C611.243 407.761 611.841 407.562 612.604 407.562C613.115 407.562 613.549 407.649 613.904 407.822C614.259 407.987 614.528 408.225 614.71 408.537C614.892 408.84 614.983 409.2 614.983 409.616C614.983 410.127 614.827 410.578 614.515 410.968C614.212 411.358 613.718 411.77 613.033 412.203L614.918 414.595C615.204 414.205 615.347 413.616 615.347 412.827L616.738 412.957C616.721 413.468 616.625 413.962 616.452 414.439C616.287 414.916 616.071 415.31 615.802 415.622L616.959 417H615.321L614.892 416.48C614.632 416.697 614.277 416.87 613.826 417C613.384 417.139 612.864 417.208 612.266 417.208ZM612.409 415.947C612.799 415.947 613.124 415.904 613.384 415.817C613.644 415.73 613.843 415.618 613.982 415.479L611.889 412.866C611.473 413.152 611.174 413.434 610.992 413.711C610.81 413.98 610.719 414.296 610.719 414.66C610.719 415.05 610.853 415.362 611.122 415.596C611.399 415.83 611.828 415.947 612.409 415.947ZM612.305 411.293C612.6 411.111 612.838 410.942 613.02 410.786C613.202 410.621 613.332 410.452 613.41 410.279C613.488 410.106 613.527 409.919 613.527 409.72C613.527 409.443 613.445 409.23 613.28 409.083C613.124 408.927 612.903 408.849 612.617 408.849C612.305 408.849 612.058 408.931 611.876 409.096C611.703 409.261 611.616 409.495 611.616 409.798C611.616 409.954 611.638 410.11 611.681 410.266C611.724 410.413 611.794 410.569 611.889 410.734C611.993 410.899 612.132 411.085 612.305 411.293ZM624.792 417.156C624.316 417.156 623.891 417.052 623.518 416.844C623.154 416.627 622.873 416.333 622.673 415.96L622.634 417H621.347V407.77H622.725V411.059C622.907 410.756 623.176 410.491 623.531 410.266C623.895 410.041 624.316 409.928 624.792 409.928C625.39 409.928 625.91 410.075 626.352 410.37C626.794 410.665 627.132 411.085 627.366 411.631C627.609 412.168 627.73 412.805 627.73 413.542C627.73 414.279 627.609 414.92 627.366 415.466C627.132 416.003 626.794 416.419 626.352 416.714C625.91 417.009 625.39 417.156 624.792 417.156ZM624.571 415.96C625.1 415.96 625.52 415.748 625.832 415.323C626.144 414.89 626.3 414.296 626.3 413.542C626.3 412.788 626.144 412.199 625.832 411.774C625.52 411.341 625.104 411.124 624.584 411.124C624.194 411.124 623.861 411.219 623.583 411.41C623.306 411.601 623.094 411.878 622.946 412.242C622.799 412.597 622.725 413.031 622.725 413.542C622.725 414.036 622.799 414.465 622.946 414.829C623.094 415.193 623.306 415.475 623.583 415.674C623.861 415.865 624.19 415.96 624.571 415.96ZM629.13 417V410.084H630.391L630.443 411.917L630.326 411.878C630.421 411.254 630.612 410.799 630.898 410.513C631.192 410.227 631.587 410.084 632.081 410.084H632.744V411.319H632.081C631.734 411.319 631.444 411.375 631.21 411.488C630.976 411.601 630.798 411.774 630.677 412.008C630.564 412.242 630.508 412.545 630.508 412.918V417H629.13ZM635.688 417.156C634.968 417.156 634.392 416.991 633.959 416.662C633.534 416.333 633.322 415.869 633.322 415.271C633.322 414.673 633.499 414.209 633.855 413.88C634.219 413.542 634.778 413.299 635.532 413.152L637.911 412.697C637.911 412.16 637.785 411.761 637.534 411.501C637.282 411.232 636.91 411.098 636.416 411.098C635.974 411.098 635.627 411.198 635.376 411.397C635.124 411.588 634.951 411.874 634.856 412.255L633.439 412.164C633.569 411.471 633.889 410.925 634.401 410.526C634.921 410.127 635.592 409.928 636.416 409.928C637.352 409.928 638.062 410.179 638.548 410.682C639.042 411.176 639.289 411.874 639.289 412.775V415.466C639.289 415.631 639.315 415.748 639.367 415.817C639.427 415.878 639.523 415.908 639.653 415.908H639.9V417C639.856 417.009 639.787 417.017 639.692 417.026C639.596 417.035 639.497 417.039 639.393 417.039C639.098 417.039 638.842 416.991 638.626 416.896C638.418 416.801 638.262 416.645 638.158 416.428C638.054 416.203 638.002 415.904 638.002 415.531L638.145 415.596C638.075 415.899 637.924 416.168 637.69 416.402C637.464 416.636 637.174 416.822 636.819 416.961C636.472 417.091 636.095 417.156 635.688 417.156ZM635.909 416.064C636.325 416.064 636.68 415.986 636.975 415.83C637.269 415.665 637.499 415.44 637.664 415.154C637.828 414.868 637.911 414.543 637.911 414.179V413.737L635.883 414.127C635.467 414.205 635.172 414.331 634.999 414.504C634.834 414.669 634.752 414.881 634.752 415.141C634.752 415.436 634.851 415.665 635.051 415.83C635.259 415.986 635.545 416.064 635.909 416.064ZM641.063 417V410.084H642.324L642.376 411.93L642.207 411.839C642.285 411.388 642.433 411.024 642.649 410.747C642.866 410.47 643.13 410.266 643.442 410.136C643.754 409.997 644.092 409.928 644.456 409.928C644.976 409.928 645.405 410.045 645.743 410.279C646.09 410.504 646.35 410.816 646.523 411.215C646.705 411.605 646.796 412.051 646.796 412.554V417H645.418V412.97C645.418 412.563 645.375 412.22 645.288 411.943C645.202 411.666 645.059 411.453 644.859 411.306C644.66 411.159 644.4 411.085 644.079 411.085C643.594 411.085 643.2 411.245 642.896 411.566C642.593 411.887 642.441 412.355 642.441 412.97V417H641.063ZM651.049 417.156C650.459 417.156 649.944 417.009 649.502 416.714C649.068 416.419 648.735 416.003 648.501 415.466C648.275 414.92 648.163 414.279 648.163 413.542C648.163 412.805 648.28 412.168 648.514 411.631C648.748 411.085 649.081 410.665 649.515 410.37C649.948 410.075 650.459 409.928 651.049 409.928C651.525 409.928 651.95 410.028 652.323 410.227C652.695 410.426 652.977 410.704 653.168 411.059V407.77H654.546V417H653.259L653.22 415.96C653.029 416.333 652.739 416.627 652.349 416.844C651.967 417.052 651.534 417.156 651.049 417.156ZM651.413 415.96C651.785 415.96 652.102 415.865 652.362 415.674C652.622 415.483 652.821 415.206 652.96 414.842C653.098 414.478 653.168 414.045 653.168 413.542C653.168 413.022 653.098 412.584 652.96 412.229C652.821 411.865 652.622 411.592 652.362 411.41C652.102 411.219 651.785 411.124 651.413 411.124C650.858 411.124 650.416 411.341 650.087 411.774C649.757 412.199 649.593 412.788 649.593 413.542C649.593 414.287 649.757 414.877 650.087 415.31C650.416 415.743 650.858 415.96 651.413 415.96ZM656.272 417V410.084H657.65V417H656.272ZM656.246 409.083V407.705H657.676V409.083H656.246ZM659.713 417V410.084H660.974L661.026 411.93L660.857 411.839C660.935 411.388 661.082 411.024 661.299 410.747C661.515 410.47 661.78 410.266 662.092 410.136C662.404 409.997 662.742 409.928 663.106 409.928C663.626 409.928 664.055 410.045 664.393 410.279C664.739 410.504 664.999 410.816 665.173 411.215C665.355 411.605 665.446 412.051 665.446 412.554V417H664.068V412.97C664.068 412.563 664.024 412.22 663.938 411.943C663.851 411.666 663.708 411.453 663.509 411.306C663.309 411.159 663.049 411.085 662.729 411.085C662.243 411.085 661.849 411.245 661.546 411.566C661.242 411.887 661.091 412.355 661.091 412.97V417H659.713ZM670.023 419.106C669.477 419.106 668.996 419.024 668.58 418.859C668.164 418.694 667.822 418.46 667.553 418.157C667.284 417.862 667.102 417.516 667.007 417.117L668.437 417.026C668.532 417.312 668.697 417.537 668.931 417.702C669.174 417.875 669.538 417.962 670.023 417.962C670.595 417.962 671.033 417.841 671.336 417.598C671.648 417.355 671.804 416.987 671.804 416.493V415.492C671.639 415.847 671.375 416.129 671.011 416.337C670.647 416.545 670.24 416.649 669.789 416.649C669.208 416.649 668.693 416.506 668.242 416.22C667.791 415.934 667.44 415.54 667.189 415.037C666.938 414.534 666.812 413.954 666.812 413.295C666.812 412.628 666.933 412.043 667.176 411.54C667.427 411.037 667.774 410.643 668.216 410.357C668.667 410.071 669.174 409.928 669.737 409.928C670.231 409.928 670.664 410.041 671.037 410.266C671.418 410.491 671.691 410.795 671.856 411.176V410.084H673.182V416.454C673.182 417.026 673.052 417.507 672.792 417.897C672.541 418.296 672.177 418.595 671.7 418.794C671.232 419.002 670.673 419.106 670.023 419.106ZM670.01 415.479C670.547 415.479 670.976 415.284 671.297 414.894C671.626 414.504 671.795 413.967 671.804 413.282C671.813 412.831 671.743 412.446 671.596 412.125C671.449 411.796 671.236 411.544 670.959 411.371C670.69 411.189 670.374 411.098 670.01 411.098C669.447 411.098 669.009 411.297 668.697 411.696C668.394 412.086 668.242 412.615 668.242 413.282C668.242 413.958 668.398 414.495 668.71 414.894C669.031 415.284 669.464 415.479 670.01 415.479Z" fill="#090B0B"/> +<path d="M566.692 434V427H568.502C570.112 427 571.212 427.61 571.212 429.06C571.212 430.14 570.612 430.75 569.652 430.99L571.402 434H570.352L568.712 431.12H568.502H567.632V434H566.692ZM567.632 430.37H568.502C569.542 430.37 570.282 430.05 570.282 429.06C570.282 428.07 569.542 427.75 568.502 427.75H567.632V430.37ZM574.973 434.1C573.633 434.1 572.463 433.14 572.463 431.4C572.463 429.69 573.603 428.7 574.953 428.7C576.613 428.7 577.473 429.91 577.443 431.61H573.403V431.71C573.453 432.8 574.163 433.37 574.973 433.37C575.663 433.37 576.193 433.03 576.403 432.42H577.353C577.043 433.52 576.093 434.1 574.973 434.1ZM573.413 430.93H576.483C576.383 429.95 575.773 429.43 574.943 429.43C574.163 429.43 573.513 429.95 573.413 430.93ZM580.864 431.68C579.934 431.52 579.154 431.18 579.154 430.21C579.154 429.25 580.084 428.7 581.134 428.7C582.104 428.7 583.024 429.21 583.154 430.18H582.224C582.144 429.68 581.704 429.43 581.134 429.43C580.524 429.43 580.084 429.72 580.084 430.2C580.084 430.71 580.524 430.83 580.964 430.92L581.464 431.03C582.584 431.23 583.254 431.62 583.254 432.56C583.254 433.52 582.364 434.1 581.194 434.1C580.084 434.1 579.194 433.56 579.024 432.49H579.954C580.054 433.11 580.574 433.37 581.194 433.37C581.894 433.37 582.324 433.07 582.324 432.57C582.324 432.07 581.944 431.91 581.364 431.78L580.864 431.68ZM587.345 434.1C586.025 434.1 584.865 433.18 584.865 431.4C584.865 429.62 586.025 428.7 587.345 428.7C588.665 428.7 589.835 429.62 589.835 431.4C589.835 433.18 588.665 434.1 587.345 434.1ZM585.795 431.4C585.795 432.72 586.495 433.37 587.345 433.37C588.195 433.37 588.895 432.72 588.895 431.4C588.895 430.08 588.195 429.43 587.345 429.43C586.495 429.43 585.795 430.08 585.795 431.4ZM592.207 431.86C592.207 432.9 592.637 433.37 593.377 433.37C594.217 433.37 594.767 432.78 594.767 431.66V428.8H595.697V434H594.767V433.08C594.437 433.75 593.827 434.1 593.047 434.1C591.987 434.1 591.277 433.45 591.277 432V428.8H592.207V431.86ZM599.908 428.8V429.6C600.158 429.05 600.628 428.8 601.408 428.8H602.388V429.55H601.328C600.378 429.55 599.908 429.93 599.908 431.02V433.25H601.688V434H597.368V433.25H598.978V429.55H597.368V428.8H599.908ZM603.549 431.4C603.549 429.62 604.709 428.7 606.049 428.7C607.219 428.7 608.119 429.32 608.409 430.48H607.469C607.249 429.8 606.759 429.43 606.049 429.43C605.189 429.43 604.479 430.1 604.479 431.4C604.479 432.7 605.189 433.37 606.049 433.37C606.759 433.37 607.259 433 607.479 432.31H608.409C608.129 433.48 607.219 434.1 606.049 434.1C604.709 434.1 603.549 433.18 603.549 431.4ZM612.18 434.1C610.84 434.1 609.67 433.14 609.67 431.4C609.67 429.69 610.81 428.7 612.16 428.7C613.82 428.7 614.68 429.91 614.65 431.61H610.61V431.71C610.66 432.8 611.37 433.37 612.18 433.37C612.87 433.37 613.4 433.03 613.61 432.42H614.56C614.25 433.52 613.3 434.1 612.18 434.1ZM610.62 430.93H613.69C613.59 429.95 612.98 429.43 612.15 429.43C611.37 429.43 610.72 429.95 610.62 430.93ZM625.252 427V433.25H626.842V434H622.532V433.25H624.322V427.75H622.532V427H625.252ZM631.584 428.8V433.25H633.174V434H628.864V433.25H630.654V429.55H628.864V428.8H631.584ZM630.624 428.06V426.91H631.614V428.06H630.624ZM634.325 434V428.8H635.215V429.26C635.415 428.94 635.745 428.7 636.235 428.7C636.835 428.7 637.145 429.03 637.285 429.47C637.475 429.04 637.815 428.7 638.415 428.7C639.365 428.7 639.585 429.52 639.585 430.29V434H638.695V430.36C638.695 429.93 638.635 429.43 638.095 429.43C637.545 429.43 637.405 430.01 637.395 430.37V434H636.515V430.36C636.515 429.93 636.445 429.43 635.915 429.43C635.355 429.43 635.225 430.01 635.215 430.37V434H634.325ZM643.986 428.8V433.25H645.576V434H641.266V433.25H643.056V429.55H641.266V428.8H643.986ZM643.026 428.06V426.91H644.016V428.06H643.026ZM649.577 432.52C649.577 433.19 649.967 433.25 650.457 433.25H651.417V434H650.207C649.357 434 648.647 433.78 648.647 432.61V429.55H647.007V428.8H648.647V427.6H649.577V428.8H651.417V429.55H649.577V432.52ZM655.278 431.68C654.348 431.52 653.568 431.18 653.568 430.21C653.568 429.25 654.498 428.7 655.548 428.7C656.518 428.7 657.438 429.21 657.568 430.18H656.638C656.558 429.68 656.118 429.43 655.548 429.43C654.938 429.43 654.498 429.72 654.498 430.2C654.498 430.71 654.938 430.83 655.378 430.92L655.878 431.03C656.998 431.23 657.668 431.62 657.668 432.56C657.668 433.52 656.778 434.1 655.608 434.1C654.498 434.1 653.608 433.56 653.438 432.49H654.368C654.468 433.11 654.988 433.37 655.608 433.37C656.308 433.37 656.738 433.07 656.738 432.57C656.738 432.07 656.358 431.91 655.778 431.78L655.278 431.68ZM661.279 434V432.85H662.269V434L661.859 435.24H661.339L661.689 434H661.279ZM591.147 445.4C591.147 443.62 592.307 442.7 593.647 442.7C594.817 442.7 595.717 443.32 596.007 444.48H595.067C594.847 443.8 594.357 443.43 593.647 443.43C592.787 443.43 592.077 444.1 592.077 445.4C592.077 446.7 592.787 447.37 593.647 447.37C594.357 447.37 594.857 447 595.077 446.31H596.007C595.727 447.48 594.817 448.1 593.647 448.1C592.307 448.1 591.147 447.18 591.147 445.4ZM598.408 445.86C598.408 446.9 598.838 447.37 599.578 447.37C600.418 447.37 600.968 446.78 600.968 445.66V442.8H601.898V448H600.968V447.08C600.638 447.75 600.028 448.1 599.248 448.1C598.188 448.1 597.478 447.45 597.478 446V442.8H598.408V445.86ZM605.669 445.68C604.739 445.52 603.959 445.18 603.959 444.21C603.959 443.25 604.889 442.7 605.939 442.7C606.909 442.7 607.829 443.21 607.959 444.18H607.029C606.949 443.68 606.509 443.43 605.939 443.43C605.329 443.43 604.889 443.72 604.889 444.2C604.889 444.71 605.329 444.83 605.769 444.92L606.269 445.03C607.389 445.23 608.059 445.62 608.059 446.56C608.059 447.52 607.169 448.1 605.999 448.1C604.889 448.1 603.999 447.56 603.829 446.49H604.759C604.859 447.11 605.379 447.37 605.999 447.37C606.699 447.37 607.129 447.07 607.129 446.57C607.129 446.07 606.749 445.91 606.169 445.78L605.669 445.68ZM612.37 446.52C612.37 447.19 612.76 447.25 613.25 447.25H614.21V448H613C612.15 448 611.44 447.78 611.44 446.61V443.55H609.8V442.8H611.44V441.6H612.37V442.8H614.21V443.55H612.37V446.52ZM618.351 448.1C617.031 448.1 615.871 447.18 615.871 445.4C615.871 443.62 617.031 442.7 618.351 442.7C619.671 442.7 620.841 443.62 620.841 445.4C620.841 447.18 619.671 448.1 618.351 448.1ZM616.801 445.4C616.801 446.72 617.501 447.37 618.351 447.37C619.201 447.37 619.901 446.72 619.901 445.4C619.901 444.08 619.201 443.43 618.351 443.43C617.501 443.43 616.801 444.08 616.801 445.4ZM621.922 448V442.8H622.812V443.26C623.012 442.94 623.342 442.7 623.832 442.7C624.432 442.7 624.742 443.03 624.882 443.47C625.072 443.04 625.412 442.7 626.012 442.7C626.962 442.7 627.182 443.52 627.182 444.29V448H626.292V444.36C626.292 443.93 626.232 443.43 625.692 443.43C625.142 443.43 625.002 444.01 624.992 444.37V448H624.112V444.36C624.112 443.93 624.042 443.43 623.512 443.43C622.952 443.43 622.822 444.01 622.812 444.37V448H621.922ZM636.955 448.1C635.035 448.1 634.515 446.88 634.515 445.42V441H635.445V445.42C635.445 446.42 635.705 447.35 636.955 447.35C638.195 447.35 638.465 446.42 638.465 445.42V441H639.395V445.42C639.395 446.88 638.855 448.1 636.955 448.1ZM645.416 441V441.75H643.626V447.25H645.416V448H640.896V447.25H642.686V441.75H640.896V441H645.416Z" fill="#090B0B"/> +<path d="M401.98 481L401.49 479.4H398.7L398.21 481H397.25L399.52 474H400.67L402.94 481H401.98ZM398.93 478.65H401.26L400.1 474.81L398.93 478.65ZM404.311 481L403.501 474H404.331L404.961 479.38V479.93L405.931 475H406.661L407.631 479.93V479.38L408.271 474H409.091L408.281 481H407.111L406.301 476.71L405.481 481H404.311ZM411.992 477.69C410.912 477.43 410.222 476.88 410.222 475.82C410.222 474.61 411.262 473.9 412.472 473.9C413.692 473.9 414.582 474.59 414.762 475.8H413.832C413.692 475.06 413.222 474.65 412.472 474.65C411.752 474.65 411.152 475.06 411.152 475.79C411.152 476.44 411.622 476.73 412.182 476.89L413.132 477.14C414.312 477.42 414.922 478.04 414.922 479.08C414.922 480.27 413.932 481.1 412.542 481.1C411.202 481.1 410.262 480.31 410.082 479.03H411.012C411.162 479.84 411.732 480.35 412.542 480.35C413.462 480.35 413.992 479.81 413.992 479.11C413.992 478.46 413.572 478.12 412.942 477.94L411.992 477.69ZM424.905 479.73C424.175 479.73 423.575 479.13 423.575 478.4C423.575 477.67 424.175 477.07 424.905 477.07C425.635 477.07 426.235 477.67 426.235 478.4C426.235 479.13 425.635 479.73 424.905 479.73ZM439.187 481L438.697 479.4H435.907L435.417 481H434.457L436.727 474H437.877L440.147 481H439.187ZM436.137 478.65H438.467L437.307 474.81L436.137 478.65ZM441.408 480.25L444.408 476.55H441.548V475.8H445.558V476.55L442.558 480.25H445.608V481H441.408V480.25ZM448.369 478.86C448.369 479.9 448.799 480.37 449.539 480.37C450.379 480.37 450.929 479.78 450.929 478.66V475.8H451.859V481H450.929V480.08C450.599 480.75 449.989 481.1 449.209 481.1C448.149 481.1 447.439 480.45 447.439 479V475.8H448.369V478.86ZM456.071 475.8V476.6C456.321 476.05 456.791 475.8 457.571 475.8H458.551V476.55H457.491C456.541 476.55 456.071 476.93 456.071 478.02V480.25H457.851V481H453.531V480.25H455.141V476.55H453.531V475.8H456.071ZM462.142 481.1C460.802 481.1 459.632 480.14 459.632 478.4C459.632 476.69 460.772 475.7 462.122 475.7C463.782 475.7 464.642 476.91 464.612 478.61H460.572V478.71C460.622 479.8 461.332 480.37 462.142 480.37C462.832 480.37 463.362 480.03 463.572 479.42H464.522C464.212 480.52 463.262 481.1 462.142 481.1ZM460.582 477.93H463.652C463.552 476.95 462.942 476.43 462.112 476.43C461.332 476.43 460.682 476.95 460.582 477.93ZM474.514 479.73C473.784 479.73 473.184 479.13 473.184 478.4C473.184 477.67 473.784 477.07 474.514 477.07C475.244 477.07 475.844 477.67 475.844 478.4C475.844 479.13 475.244 479.73 474.514 479.73ZM488.676 481V479.76C488.386 480.58 487.796 481.1 486.816 481.1C485.086 481.1 484.296 479.63 484.296 477.5C484.296 475.37 485.076 473.9 486.926 473.9C488.356 473.9 489.146 474.75 489.376 476.12H488.456C488.276 475.23 487.786 474.65 486.926 474.65C485.686 474.65 485.226 475.85 485.226 477.5C485.226 479.16 485.686 480.35 486.926 480.35C487.976 480.35 488.446 479.42 488.456 478.13H486.896V477.38H489.386V481H488.676ZM490.628 477.5C490.628 475.37 491.408 473.9 493.268 473.9C494.688 473.9 495.478 474.75 495.718 476.12H494.788C494.608 475.23 494.128 474.65 493.268 474.65C492.018 474.65 491.558 475.85 491.558 477.5C491.558 479.16 492.018 480.35 493.268 480.35C494.128 480.35 494.608 479.77 494.778 478.9H495.718C495.478 480.26 494.678 481.1 493.268 481.1C491.408 481.1 490.628 479.63 490.628 477.5ZM497.389 474H499.139C500.889 474 501.899 474.67 501.899 476.12C501.899 477.57 500.889 478.24 499.139 478.24H498.319V481H497.389V474ZM498.319 477.49H499.139C500.319 477.49 500.969 477.11 500.969 476.12C500.969 475.13 500.319 474.75 499.139 474.75H498.319V477.49ZM511.721 479.73C510.991 479.73 510.391 479.13 510.391 478.4C510.391 477.67 510.991 477.07 511.721 477.07C512.451 477.07 513.051 477.67 513.051 478.4C513.051 479.13 512.451 479.73 511.721 479.73ZM524.123 481.1C522.263 481.1 521.483 479.63 521.483 477.5C521.483 475.37 522.263 473.9 524.123 473.9C525.983 473.9 526.763 475.37 526.763 477.5C526.763 479.63 525.983 481.1 524.123 481.1ZM522.423 477.5C522.423 479.16 522.873 480.35 524.123 480.35C525.373 480.35 525.833 479.16 525.833 477.5C525.833 475.85 525.373 474.65 524.123 474.65C522.873 474.65 522.423 475.85 522.423 477.5ZM531.665 477.94C531.665 476.9 531.235 476.43 530.495 476.43C529.655 476.43 529.105 477.02 529.105 478.13V481H528.175V475.8H529.105V476.72C529.435 476.05 530.045 475.7 530.825 475.7C531.885 475.7 532.595 476.35 532.595 477.8V481H531.665V477.94ZM535.276 478.02H537.776V478.77H535.276V478.02ZM540.367 475.8H541.297V476.73C541.707 476.06 542.367 475.7 543.117 475.7C544.317 475.7 545.397 476.67 545.397 478.4C545.397 480.13 544.327 481.1 543.117 481.1C542.367 481.1 541.707 480.74 541.297 480.07V482.8H540.367V475.8ZM541.297 478.4C541.297 479.64 542.007 480.37 542.877 480.37C543.757 480.37 544.467 479.64 544.467 478.4C544.467 477.16 543.757 476.43 542.877 476.43C542.007 476.43 541.297 477.16 541.297 478.4ZM549.088 475.8V476.6C549.338 476.05 549.808 475.8 550.588 475.8H551.568V476.55H550.508C549.558 476.55 549.088 476.93 549.088 478.02V480.25H550.868V481H546.548V480.25H548.158V476.55H546.548V475.8H549.088ZM555.159 481.1C553.819 481.1 552.649 480.14 552.649 478.4C552.649 476.69 553.789 475.7 555.139 475.7C556.799 475.7 557.659 476.91 557.629 478.61H553.589V478.71C553.639 479.8 554.349 480.37 555.159 480.37C555.849 480.37 556.379 480.03 556.589 479.42H557.539C557.229 480.52 556.279 481.1 555.159 481.1ZM553.599 477.93H556.669C556.569 476.95 555.959 476.43 555.129 476.43C554.349 476.43 553.699 476.95 553.599 477.93ZM558.7 481V475.8H559.59V476.26C559.79 475.94 560.12 475.7 560.61 475.7C561.21 475.7 561.52 476.03 561.66 476.47C561.85 476.04 562.19 475.7 562.79 475.7C563.74 475.7 563.96 476.52 563.96 477.29V481H563.07V477.36C563.07 476.93 563.01 476.43 562.47 476.43C561.92 476.43 561.78 477.01 561.77 477.37V481H560.89V477.36C560.89 476.93 560.82 476.43 560.29 476.43C559.73 476.43 559.6 477.01 559.59 477.37V481H558.7ZM573.733 479.73C573.003 479.73 572.403 479.13 572.403 478.4C572.403 477.67 573.003 477.07 573.733 477.07C574.463 477.07 575.063 477.67 575.063 478.4C575.063 479.13 574.463 479.73 573.733 479.73ZM588.015 481L587.525 479.4H584.735L584.245 481H583.285L585.555 474H586.705L588.975 481H588.015ZM584.965 478.65H587.295L586.135 474.81L584.965 478.65ZM593.166 475.8V480.25H594.756V481H590.446V480.25H592.236V476.55H590.446V475.8H593.166ZM592.206 475.06V473.91H593.196V475.06H592.206ZM598.698 475.8V476.6C598.948 476.05 599.418 475.8 600.198 475.8H601.178V476.55H600.118C599.168 476.55 598.698 476.93 598.698 478.02V480.25H600.478V481H596.158V480.25H597.768V476.55H596.158V475.8H598.698ZM603.489 478.02H605.989V478.77H603.489V478.02ZM610.62 480.84C609.44 480.84 608.38 479.94 608.38 478.27C608.38 476.6 609.44 475.7 610.62 475.7C611.36 475.7 611.98 476.03 612.36 476.67V475.8H613.29V480.06C613.29 482.02 612.39 482.9 610.85 482.9C609.6 482.9 608.74 482.24 608.58 481.18H609.47C609.58 481.81 610.07 482.22 610.85 482.22C611.93 482.22 612.36 481.54 612.36 480.06V479.87C611.98 480.51 611.36 480.84 610.62 480.84ZM609.31 478.27C609.31 479.46 609.98 480.11 610.83 480.11C611.68 480.11 612.36 479.46 612.36 478.27C612.36 477.09 611.68 476.43 610.83 476.43C609.98 476.43 609.31 477.09 609.31 478.27ZM614.811 479.61C614.811 478.68 615.571 478.06 617.331 477.94L618.261 477.88V477.78C618.261 476.74 617.831 476.43 617.111 476.43C616.441 476.43 615.961 476.79 615.851 477.4H614.951C615.131 476.37 616.031 475.7 617.131 475.7C618.341 475.7 619.191 476.26 619.191 477.95V480.12C619.191 480.48 619.231 480.79 619.291 481H618.401C618.351 480.8 618.331 480.53 618.331 480.24C618.041 480.76 617.521 481.1 616.641 481.1C615.431 481.1 614.811 480.42 614.811 479.61ZM615.741 479.58C615.741 480.03 616.071 480.42 616.861 480.42C617.771 480.42 618.261 479.81 618.261 479.06V478.55L617.511 478.6C616.281 478.69 615.741 478.98 615.741 479.58ZM620.982 475.8H621.912V476.73C622.322 476.06 622.982 475.7 623.732 475.7C624.932 475.7 626.012 476.67 626.012 478.4C626.012 480.13 624.942 481.1 623.732 481.1C622.982 481.1 622.322 480.74 621.912 480.07V482.8H620.982V475.8ZM621.912 478.4C621.912 479.64 622.622 480.37 623.492 480.37C624.372 480.37 625.082 479.64 625.082 478.4C625.082 477.16 624.372 476.43 623.492 476.43C622.622 476.43 621.912 477.16 621.912 478.4ZM627.183 475.8H628.113V476.73C628.523 476.06 629.183 475.7 629.933 475.7C631.133 475.7 632.213 476.67 632.213 478.4C632.213 480.13 631.143 481.1 629.933 481.1C629.183 481.1 628.523 480.74 628.113 480.07V482.8H627.183V475.8ZM628.113 478.4C628.113 479.64 628.823 480.37 629.693 480.37C630.573 480.37 631.283 479.64 631.283 478.4C631.283 477.16 630.573 476.43 629.693 476.43C628.823 476.43 628.113 477.16 628.113 478.4ZM635.775 481.1C634.435 481.1 633.265 480.14 633.265 478.4C633.265 476.69 634.405 475.7 635.755 475.7C637.415 475.7 638.275 476.91 638.245 478.61H634.205V478.71C634.255 479.8 634.965 480.37 635.775 480.37C636.465 480.37 636.995 480.03 637.205 479.42H638.155C637.845 480.52 636.895 481.1 635.775 481.1ZM634.215 477.93H637.285C637.185 476.95 636.575 476.43 635.745 476.43C634.965 476.43 634.315 476.95 634.215 477.93ZM644.236 474V481H643.306V480.08C642.896 480.74 642.236 481.1 641.496 481.1C640.276 481.1 639.206 480.13 639.206 478.4C639.206 476.67 640.286 475.7 641.496 475.7C642.236 475.7 642.896 476.06 643.306 476.72V474H644.236ZM640.146 478.4C640.146 479.64 640.846 480.37 641.726 480.37C642.596 480.37 643.306 479.64 643.306 478.4C643.306 477.16 642.596 476.43 641.726 476.43C640.846 476.43 640.146 477.16 640.146 478.4Z" fill="#2F2D33"/> +<path d="M1049.01 346H904V639H1049.01V346Z" fill="#DDD9EE"/> +<path d="M938.163 371.511V361.571H939.675V371.511H938.163ZM942.055 371.511V364.063H943.413L943.469 366.051L943.287 365.953C943.371 365.467 943.53 365.075 943.763 364.777C943.997 364.478 944.281 364.259 944.617 364.119C944.953 363.969 945.317 363.895 945.709 363.895C946.269 363.895 946.731 364.021 947.095 364.273C947.469 364.515 947.749 364.851 947.935 365.281C948.131 365.701 948.229 366.181 948.229 366.723V371.511H946.745V367.171C946.745 366.732 946.699 366.363 946.605 366.065C946.512 365.766 946.358 365.537 946.143 365.379C945.929 365.22 945.649 365.141 945.303 365.141C944.781 365.141 944.356 365.313 944.029 365.659C943.703 366.004 943.539 366.508 943.539 367.171V371.511H942.055ZM952.65 371.511C951.94 371.511 951.418 371.347 951.082 371.021C950.746 370.694 950.578 370.185 950.578 369.495V362.313H952.062V369.383C952.062 369.728 952.136 369.966 952.286 370.097C952.435 370.227 952.664 370.293 952.972 370.293H954.036V371.511H952.65ZM949.444 365.281V364.063H954.036V365.281H949.444ZM958.397 371.679C957.669 371.679 957.039 371.52 956.507 371.203C955.984 370.885 955.578 370.433 955.289 369.845C955.009 369.257 954.869 368.571 954.869 367.787C954.869 367.003 955.009 366.321 955.289 365.743C955.578 365.155 955.984 364.702 956.507 364.385C957.029 364.058 957.645 363.895 958.355 363.895C959.027 363.895 959.619 364.053 960.133 364.371C960.646 364.679 961.043 365.127 961.323 365.715C961.612 366.303 961.757 367.012 961.757 367.843V368.221H956.409C956.446 368.949 956.637 369.495 956.983 369.859C957.337 370.223 957.813 370.405 958.411 370.405C958.849 370.405 959.213 370.302 959.503 370.097C959.792 369.891 959.993 369.616 960.105 369.271L961.645 369.369C961.449 370.059 961.061 370.619 960.483 371.049C959.913 371.469 959.218 371.679 958.397 371.679ZM956.409 367.101H960.189C960.142 366.438 959.951 365.948 959.615 365.631C959.288 365.313 958.868 365.155 958.355 365.155C957.823 365.155 957.384 365.323 957.039 365.659C956.703 365.985 956.493 366.466 956.409 367.101ZM966.257 373.779C965.669 373.779 965.151 373.69 964.703 373.513C964.255 373.335 963.886 373.083 963.597 372.757C963.307 372.439 963.111 372.066 963.009 371.637L964.549 371.539C964.651 371.847 964.829 372.089 965.081 372.267C965.342 372.453 965.734 372.547 966.257 372.547C966.873 372.547 967.344 372.416 967.671 372.155C968.007 371.893 968.175 371.497 968.175 370.965V369.887C967.997 370.269 967.713 370.573 967.321 370.797C966.929 371.021 966.49 371.133 966.005 371.133C965.379 371.133 964.824 370.979 964.339 370.671C963.853 370.363 963.475 369.938 963.205 369.397C962.934 368.855 962.799 368.23 962.799 367.521C962.799 366.802 962.929 366.172 963.191 365.631C963.461 365.089 963.835 364.665 964.311 364.357C964.796 364.049 965.342 363.895 965.949 363.895C966.481 363.895 966.947 364.016 967.349 364.259C967.759 364.501 968.053 364.828 968.231 365.239V364.063H969.659V370.923C969.659 371.539 969.519 372.057 969.239 372.477C968.968 372.906 968.576 373.228 968.063 373.443C967.559 373.667 966.957 373.779 966.257 373.779ZM966.243 369.873C966.821 369.873 967.283 369.663 967.629 369.243C967.983 368.823 968.165 368.244 968.175 367.507C968.184 367.021 968.109 366.606 967.951 366.261C967.792 365.906 967.563 365.635 967.265 365.449C966.975 365.253 966.635 365.155 966.243 365.155C965.636 365.155 965.165 365.369 964.829 365.799C964.502 366.219 964.339 366.788 964.339 367.507C964.339 368.235 964.507 368.813 964.843 369.243C965.188 369.663 965.655 369.873 966.243 369.873ZM971.764 371.511V364.063H973.122L973.178 366.037L973.052 365.995C973.155 365.323 973.36 364.833 973.668 364.525C973.986 364.217 974.41 364.063 974.942 364.063H975.656V365.393H974.942C974.569 365.393 974.256 365.453 974.004 365.575C973.752 365.696 973.561 365.883 973.43 366.135C973.309 366.387 973.248 366.713 973.248 367.115V371.511H971.764ZM978.827 371.679C978.052 371.679 977.432 371.501 976.965 371.147C976.508 370.792 976.279 370.293 976.279 369.649C976.279 369.005 976.47 368.505 976.853 368.151C977.245 367.787 977.847 367.525 978.659 367.367L981.221 366.877C981.221 366.298 981.086 365.869 980.815 365.589C980.544 365.299 980.143 365.155 979.611 365.155C979.135 365.155 978.762 365.262 978.491 365.477C978.22 365.682 978.034 365.99 977.931 366.401L976.405 366.303C976.545 365.556 976.89 364.968 977.441 364.539C978.001 364.109 978.724 363.895 979.611 363.895C980.619 363.895 981.384 364.165 981.907 364.707C982.439 365.239 982.705 365.99 982.705 366.961V369.859C982.705 370.036 982.733 370.162 982.789 370.237C982.854 370.302 982.957 370.335 983.097 370.335H983.363V371.511C983.316 371.52 983.242 371.529 983.139 371.539C983.036 371.548 982.929 371.553 982.817 371.553C982.5 371.553 982.224 371.501 981.991 371.399C981.767 371.296 981.599 371.128 981.487 370.895C981.375 370.652 981.319 370.33 981.319 369.929L981.473 369.999C981.398 370.325 981.235 370.615 980.983 370.867C980.74 371.119 980.428 371.319 980.045 371.469C979.672 371.609 979.266 371.679 978.827 371.679ZM979.065 370.503C979.513 370.503 979.896 370.419 980.213 370.251C980.53 370.073 980.778 369.831 980.955 369.523C981.132 369.215 981.221 368.865 981.221 368.473V367.997L979.037 368.417C978.589 368.501 978.272 368.636 978.085 368.823C977.908 369 977.819 369.229 977.819 369.509C977.819 369.826 977.926 370.073 978.141 370.251C978.365 370.419 978.673 370.503 979.065 370.503ZM987.089 371.511C986.38 371.511 985.857 371.347 985.521 371.021C985.185 370.694 985.017 370.185 985.017 369.495V362.313H986.501V369.383C986.501 369.728 986.576 369.966 986.725 370.097C986.875 370.227 987.103 370.293 987.411 370.293H988.475V371.511H987.089ZM983.883 365.281V364.063H988.475V365.281H983.883ZM989.893 371.511V364.063H991.377V371.511H989.893ZM989.865 362.985V361.501H991.405V362.985H989.865ZM996.405 371.679C995.695 371.679 995.075 371.52 994.543 371.203C994.011 370.885 993.6 370.433 993.311 369.845C993.021 369.257 992.877 368.571 992.877 367.787C992.877 366.993 993.021 366.307 993.311 365.729C993.6 365.15 994.011 364.702 994.543 364.385C995.075 364.058 995.695 363.895 996.405 363.895C997.114 363.895 997.735 364.058 998.267 364.385C998.799 364.702 999.209 365.15 999.499 365.729C999.788 366.307 999.933 366.993 999.933 367.787C999.933 368.571 999.788 369.257 999.499 369.845C999.209 370.433 998.799 370.885 998.267 371.203C997.735 371.52 997.114 371.679 996.405 371.679ZM996.405 370.391C997.039 370.391 997.529 370.162 997.875 369.705C998.22 369.238 998.393 368.599 998.393 367.787C998.393 366.975 998.22 366.34 997.875 365.883C997.529 365.416 997.039 365.183 996.405 365.183C995.779 365.183 995.289 365.416 994.935 365.883C994.589 366.34 994.417 366.975 994.417 367.787C994.417 368.599 994.589 369.238 994.935 369.705C995.289 370.162 995.779 370.391 996.405 370.391ZM1001.43 371.511V364.063H1002.79L1002.85 366.051L1002.66 365.953C1002.75 365.467 1002.91 365.075 1003.14 364.777C1003.37 364.478 1003.66 364.259 1003.99 364.119C1004.33 363.969 1004.69 363.895 1005.09 363.895C1005.65 363.895 1006.11 364.021 1006.47 364.273C1006.85 364.515 1007.13 364.851 1007.31 365.281C1007.51 365.701 1007.61 366.181 1007.61 366.723V371.511H1006.12V367.171C1006.12 366.732 1006.08 366.363 1005.98 366.065C1005.89 365.766 1005.74 365.537 1005.52 365.379C1005.31 365.22 1005.03 365.141 1004.68 365.141C1004.16 365.141 1003.73 365.313 1003.41 365.659C1003.08 366.004 1002.92 366.508 1002.92 367.171V371.511H1001.43ZM1012.41 371.679C1011.71 371.679 1011.12 371.571 1010.63 371.357C1010.16 371.142 1009.79 370.848 1009.53 370.475C1009.26 370.092 1009.12 369.653 1009.08 369.159L1010.6 369.089C1010.68 369.499 1010.86 369.826 1011.14 370.069C1011.42 370.311 1011.84 370.433 1012.41 370.433C1012.88 370.433 1013.25 370.358 1013.52 370.209C1013.79 370.059 1013.92 369.821 1013.92 369.495C1013.92 369.317 1013.88 369.168 1013.78 369.047C1013.7 368.916 1013.53 368.809 1013.28 368.725C1013.03 368.631 1012.65 368.543 1012.16 368.459C1011.42 368.319 1010.84 368.155 1010.42 367.969C1010 367.782 1009.7 367.544 1009.53 367.255C1009.35 366.965 1009.26 366.615 1009.26 366.205C1009.26 365.523 1009.51 364.968 1010.02 364.539C1010.53 364.109 1011.27 363.895 1012.23 363.895C1012.87 363.895 1013.41 364.007 1013.85 364.231C1014.29 364.445 1014.63 364.739 1014.87 365.113C1015.13 365.477 1015.28 365.887 1015.35 366.345L1013.82 366.429C1013.78 366.167 1013.68 365.943 1013.54 365.757C1013.41 365.561 1013.24 365.411 1013.01 365.309C1012.79 365.197 1012.52 365.141 1012.21 365.141C1011.74 365.141 1011.38 365.234 1011.15 365.421C1010.92 365.607 1010.8 365.85 1010.8 366.149C1010.8 366.373 1010.85 366.555 1010.95 366.695C1011.07 366.835 1011.24 366.951 1011.49 367.045C1011.73 367.129 1012.05 367.203 1012.45 367.269C1013.23 367.399 1013.83 367.563 1014.27 367.759C1014.71 367.945 1015.02 368.179 1015.2 368.459C1015.37 368.739 1015.46 369.079 1015.46 369.481C1015.46 369.947 1015.33 370.344 1015.07 370.671C1014.81 370.997 1014.45 371.249 1013.99 371.427C1013.53 371.595 1013.01 371.679 1012.41 371.679Z" fill="#090B0B"/> +<path d="M926.524 394.718L925.174 390.068L925.224 390.818V395.718H924.334V388.718H925.644L926.894 393.478L928.134 388.718H929.444V395.718H928.554V390.818L928.604 390.068L927.254 394.718H926.524ZM933.096 395.818C931.776 395.818 930.616 394.898 930.616 393.118C930.616 391.338 931.776 390.418 933.096 390.418C934.416 390.418 935.586 391.338 935.586 393.118C935.586 394.898 934.416 395.818 933.096 395.818ZM931.546 393.118C931.546 394.438 932.246 395.088 933.096 395.088C933.946 395.088 934.646 394.438 934.646 393.118C934.646 391.798 933.946 391.148 933.096 391.148C932.246 391.148 931.546 391.798 931.546 393.118ZM941.587 388.718V395.718H940.657V394.798C940.247 395.458 939.587 395.818 938.847 395.818C937.627 395.818 936.557 394.848 936.557 393.118C936.557 391.388 937.637 390.418 938.847 390.418C939.587 390.418 940.247 390.778 940.657 391.438V388.718H941.587ZM937.497 393.118C937.497 394.358 938.197 395.088 939.077 395.088C939.947 395.088 940.657 394.358 940.657 393.118C940.657 391.878 939.947 391.148 939.077 391.148C938.197 391.148 937.497 391.878 937.497 393.118ZM945.528 395.818C944.188 395.818 943.018 394.858 943.018 393.118C943.018 391.408 944.158 390.418 945.508 390.418C947.168 390.418 948.028 391.628 947.998 393.328H943.958V393.428C944.008 394.518 944.718 395.088 945.528 395.088C946.218 395.088 946.748 394.748 946.958 394.138H947.908C947.598 395.238 946.648 395.818 945.528 395.818ZM943.968 392.648H947.038C946.938 391.668 946.328 391.148 945.498 391.148C944.718 391.148 944.068 391.668 943.968 392.648ZM952.399 388.718V394.968H953.989V395.718H949.679V394.968H951.469V389.468H949.679V388.718H952.399ZM957.62 393.398C956.69 393.238 955.91 392.898 955.91 391.928C955.91 390.968 956.84 390.418 957.89 390.418C958.86 390.418 959.78 390.928 959.91 391.898H958.98C958.9 391.398 958.46 391.148 957.89 391.148C957.28 391.148 956.84 391.438 956.84 391.918C956.84 392.428 957.28 392.548 957.72 392.638L958.22 392.748C959.34 392.948 960.01 393.338 960.01 394.278C960.01 395.238 959.12 395.818 957.95 395.818C956.84 395.818 955.95 395.278 955.78 394.208H956.71C956.81 394.828 957.33 395.088 957.95 395.088C958.65 395.088 959.08 394.788 959.08 394.288C959.08 393.788 958.7 393.628 958.12 393.498L957.62 393.398ZM969.773 395.818C968.423 395.818 967.503 395.108 967.503 393.948C967.503 392.908 968.333 392.358 969.063 391.938C968.553 391.298 968.253 390.788 968.253 390.208C968.253 389.288 969.023 388.618 970.013 388.618C970.993 388.618 971.763 389.288 971.763 390.208C971.763 390.968 971.263 391.458 970.233 392.098L971.693 393.848C971.933 393.408 972.123 392.828 972.233 392.078H973.013C972.903 393.038 972.633 393.858 972.223 394.478L973.263 395.718H972.243L971.713 395.088C971.203 395.558 970.553 395.818 969.773 395.818ZM968.413 393.948C968.413 394.638 968.973 395.088 969.773 395.088C970.273 395.088 970.783 394.918 971.213 394.498L969.553 392.508C968.953 392.868 968.413 393.278 968.413 393.948ZM969.093 390.208C969.093 390.628 969.313 390.988 969.753 391.538C970.613 391.018 970.913 390.708 970.913 390.208C970.913 389.748 970.563 389.348 970.013 389.348C969.453 389.348 969.093 389.748 969.093 390.208ZM982.425 393.398C981.495 393.238 980.715 392.898 980.715 391.928C980.715 390.968 981.645 390.418 982.695 390.418C983.665 390.418 984.585 390.928 984.715 391.898H983.785C983.705 391.398 983.265 391.148 982.695 391.148C982.085 391.148 981.645 391.438 981.645 391.918C981.645 392.428 982.085 392.548 982.525 392.638L983.025 392.748C984.145 392.948 984.815 393.338 984.815 394.278C984.815 395.238 983.925 395.818 982.755 395.818C981.645 395.818 980.755 395.278 980.585 394.208H981.515C981.615 394.828 982.135 395.088 982.755 395.088C983.455 395.088 983.885 394.788 983.885 394.288C983.885 393.788 983.505 393.628 982.925 393.498L982.425 393.398ZM988.936 395.818C987.596 395.818 986.426 394.858 986.426 393.118C986.426 391.408 987.566 390.418 988.916 390.418C990.576 390.418 991.436 391.628 991.406 393.328H987.366V393.428C987.416 394.518 988.126 395.088 988.936 395.088C989.626 395.088 990.156 394.748 990.366 394.138H991.316C991.006 395.238 990.056 395.818 988.936 395.818ZM987.376 392.648H990.446C990.346 391.668 989.736 391.148 988.906 391.148C988.126 391.148 987.476 391.668 987.376 392.648ZM995.267 390.518V391.318C995.517 390.768 995.987 390.518 996.767 390.518H997.747V391.268H996.687C995.737 391.268 995.267 391.648 995.267 392.738V394.968H997.047V395.718H992.727V394.968H994.337V391.268H992.727V390.518H995.267ZM1000.7 395.718L998.808 390.518H999.748L1001.31 395.048L1002.87 390.518H1003.8L1001.91 395.718H1000.7ZM1008.34 390.518V394.968H1009.93V395.718H1005.62V394.968H1007.41V391.268H1005.62V390.518H1008.34ZM1007.38 389.778V388.628H1008.37V389.778H1007.38ZM1011.31 393.118C1011.31 391.338 1012.47 390.418 1013.81 390.418C1014.98 390.418 1015.88 391.038 1016.17 392.198H1015.23C1015.01 391.518 1014.52 391.148 1013.81 391.148C1012.95 391.148 1012.24 391.818 1012.24 393.118C1012.24 394.418 1012.95 395.088 1013.81 395.088C1014.52 395.088 1015.02 394.718 1015.24 394.028H1016.17C1015.89 395.198 1014.98 395.818 1013.81 395.818C1012.47 395.818 1011.31 394.898 1011.31 393.118ZM1019.94 395.818C1018.6 395.818 1017.43 394.858 1017.43 393.118C1017.43 391.408 1018.57 390.418 1019.92 390.418C1021.58 390.418 1022.44 391.628 1022.41 393.328H1018.37V393.428C1018.42 394.518 1019.13 395.088 1019.94 395.088C1020.63 395.088 1021.16 394.748 1021.37 394.138H1022.32C1022.01 395.238 1021.06 395.818 1019.94 395.818ZM1018.38 392.648H1021.45C1021.35 391.668 1020.74 391.148 1019.91 391.148C1019.13 391.148 1018.48 391.668 1018.38 392.648ZM1025.83 393.398C1024.9 393.238 1024.12 392.898 1024.12 391.928C1024.12 390.968 1025.05 390.418 1026.1 390.418C1027.07 390.418 1027.99 390.928 1028.12 391.898H1027.19C1027.11 391.398 1026.67 391.148 1026.1 391.148C1025.49 391.148 1025.05 391.438 1025.05 391.918C1025.05 392.428 1025.49 392.548 1025.93 392.638L1026.43 392.748C1027.55 392.948 1028.22 393.338 1028.22 394.278C1028.22 395.238 1027.33 395.818 1026.16 395.818C1025.05 395.818 1024.16 395.278 1023.99 394.208H1024.92C1025.02 394.828 1025.54 395.088 1026.16 395.088C1026.86 395.088 1027.29 394.788 1027.29 394.288C1027.29 393.788 1026.91 393.628 1026.33 393.498L1025.83 393.398Z" fill="#090B0B"/> +<path d="M937.687 413.832H938.627V420.082H941.817V420.832H937.687V413.832ZM943.888 413.832H944.828V420.082H948.018V420.832H943.888V413.832ZM951.329 419.832L949.979 415.182L950.029 415.932V420.832H949.139V413.832H950.449L951.699 418.592L952.939 413.832H954.249V420.832H953.359V415.932L953.409 415.182L952.059 419.832H951.329ZM961.742 415.632H962.672V416.562C963.082 415.892 963.742 415.532 964.492 415.532C965.692 415.532 966.772 416.502 966.772 418.232C966.772 419.962 965.702 420.932 964.492 420.932C963.742 420.932 963.082 420.572 962.672 419.902V422.632H961.742V415.632ZM962.672 418.232C962.672 419.472 963.382 420.202 964.252 420.202C965.132 420.202 965.842 419.472 965.842 418.232C965.842 416.992 965.132 416.262 964.252 416.262C963.382 416.262 962.672 416.992 962.672 418.232ZM970.463 415.632V416.432C970.713 415.882 971.183 415.632 971.963 415.632H972.943V416.382H971.883C970.933 416.382 970.463 416.762 970.463 417.852V420.082H972.243V420.832H967.923V420.082H969.533V416.382H967.923V415.632H970.463ZM976.504 420.932C975.184 420.932 974.024 420.012 974.024 418.232C974.024 416.452 975.184 415.532 976.504 415.532C977.824 415.532 978.994 416.452 978.994 418.232C978.994 420.012 977.824 420.932 976.504 420.932ZM974.954 418.232C974.954 419.552 975.654 420.202 976.504 420.202C977.354 420.202 978.054 419.552 978.054 418.232C978.054 416.912 977.354 416.262 976.504 416.262C975.654 416.262 974.954 416.912 974.954 418.232ZM982.095 420.832L980.205 415.632H981.145L982.705 420.162L984.265 415.632H985.195L983.305 420.832H982.095ZM989.736 415.632V420.082H991.326V420.832H987.016V420.082H988.806V416.382H987.016V415.632H989.736ZM988.776 414.892V413.742H989.766V414.892H988.776ZM997.397 413.832V420.832H996.467V419.912C996.057 420.572 995.397 420.932 994.657 420.932C993.437 420.932 992.367 419.962 992.367 418.232C992.367 416.502 993.447 415.532 994.657 415.532C995.397 415.532 996.057 415.892 996.467 416.552V413.832H997.397ZM993.307 418.232C993.307 419.472 994.007 420.202 994.887 420.202C995.757 420.202 996.467 419.472 996.467 418.232C996.467 416.992 995.757 416.262 994.887 416.262C994.007 416.262 993.307 416.992 993.307 418.232ZM1001.34 420.932C999.999 420.932 998.829 419.972 998.829 418.232C998.829 416.522 999.969 415.532 1001.32 415.532C1002.98 415.532 1003.84 416.742 1003.81 418.442H999.769V418.542C999.819 419.632 1000.53 420.202 1001.34 420.202C1002.03 420.202 1002.56 419.862 1002.77 419.252H1003.72C1003.41 420.352 1002.46 420.932 1001.34 420.932ZM999.779 417.762H1002.85C1002.75 416.782 1002.14 416.262 1001.31 416.262C1000.53 416.262 999.879 416.782 999.779 417.762ZM1007.67 415.632V416.432C1007.92 415.882 1008.39 415.632 1009.17 415.632H1010.15V416.382H1009.09C1008.14 416.382 1007.67 416.762 1007.67 417.852V420.082H1009.45V420.832H1005.13V420.082H1006.74V416.382H1005.13V415.632H1007.67ZM1013.43 418.512C1012.5 418.352 1011.72 418.012 1011.72 417.042C1011.72 416.082 1012.65 415.532 1013.7 415.532C1014.67 415.532 1015.59 416.042 1015.72 417.012H1014.79C1014.71 416.512 1014.27 416.262 1013.7 416.262C1013.09 416.262 1012.65 416.552 1012.65 417.032C1012.65 417.542 1013.09 417.662 1013.53 417.752L1014.03 417.862C1015.15 418.062 1015.82 418.452 1015.82 419.392C1015.82 420.352 1014.93 420.932 1013.76 420.932C1012.65 420.932 1011.76 420.392 1011.59 419.322H1012.52C1012.62 419.942 1013.14 420.202 1013.76 420.202C1014.46 420.202 1014.89 419.902 1014.89 419.402C1014.89 418.902 1014.51 418.742 1013.93 418.612L1013.43 418.512Z" fill="#090B0B"/> +<path d="M930.757 437.621L930.316 436.181H927.805L927.364 437.621H926.5L928.543 431.321H929.578L931.621 437.621H930.757ZM928.012 435.506H930.109L929.065 432.05L928.012 435.506ZM935.852 434.867C935.852 433.931 935.465 433.508 934.799 433.508C934.043 433.508 933.548 434.039 933.548 435.038V437.621H932.711V432.941H933.548V433.769C933.845 433.166 934.394 432.851 935.096 432.851C936.05 432.851 936.689 433.436 936.689 434.741V437.621H935.852V434.867ZM940.425 436.289C940.425 436.892 940.776 436.946 941.217 436.946H942.081V437.621H940.992C940.227 437.621 939.588 437.423 939.588 436.37V433.616H938.112V432.941H939.588V431.861H940.425V432.941H942.081V433.616H940.425V436.289ZM947.113 434.867C947.113 433.931 946.726 433.508 946.06 433.508C945.304 433.508 944.809 434.039 944.809 435.038V437.621H943.972V431.321H944.809V433.751C945.115 433.157 945.655 432.851 946.348 432.851C947.302 432.851 947.95 433.436 947.95 434.741V437.621H947.113V434.867ZM951.533 432.941V433.661C951.758 433.166 952.181 432.941 952.883 432.941H953.765V433.616H952.811C951.956 433.616 951.533 433.958 951.533 434.939V436.946H953.135V437.621H949.247V436.946H950.696V433.616H949.247V432.941H951.533ZM956.97 437.711C955.782 437.711 954.738 436.883 954.738 435.281C954.738 433.679 955.782 432.851 956.97 432.851C958.158 432.851 959.211 433.679 959.211 435.281C959.211 436.883 958.158 437.711 956.97 437.711ZM955.575 435.281C955.575 436.469 956.205 437.054 956.97 437.054C957.735 437.054 958.365 436.469 958.365 435.281C958.365 434.093 957.735 433.508 956.97 433.508C956.205 433.508 955.575 434.093 955.575 435.281ZM960.427 432.941H961.264V433.778C961.633 433.175 962.227 432.851 962.902 432.851C963.982 432.851 964.954 433.724 964.954 435.281C964.954 436.838 963.991 437.711 962.902 437.711C962.227 437.711 961.633 437.387 961.264 436.784V439.241H960.427V432.941ZM961.264 435.281C961.264 436.397 961.903 437.054 962.686 437.054C963.478 437.054 964.117 436.397 964.117 435.281C964.117 434.165 963.478 433.508 962.686 433.508C961.903 433.508 961.264 434.165 961.264 435.281ZM968.879 432.941V436.946H970.31V437.621H966.431V436.946H968.042V433.616H966.431V432.941H968.879ZM968.015 432.275V431.24H968.906V432.275H968.015ZM971.553 435.281C971.553 433.679 972.597 432.851 973.803 432.851C974.856 432.851 975.666 433.409 975.927 434.453H975.081C974.883 433.841 974.442 433.508 973.803 433.508C973.029 433.508 972.39 434.111 972.39 435.281C972.39 436.451 973.029 437.054 973.803 437.054C974.442 437.054 974.892 436.721 975.09 436.1H975.927C975.675 437.153 974.856 437.711 973.803 437.711C972.597 437.711 971.553 436.883 971.553 435.281ZM978.863 437.621V436.586H979.754V437.621L979.385 438.737H978.917L979.232 437.621H978.863ZM990.457 437.711C988.783 437.711 988.081 436.388 988.081 434.471C988.081 432.554 988.783 431.231 990.457 431.231C992.131 431.231 992.833 432.554 992.833 434.471C992.833 436.388 992.131 437.711 990.457 437.711ZM988.927 434.471C988.927 435.965 989.332 437.036 990.457 437.036C991.582 437.036 991.996 435.965 991.996 434.471C991.996 432.986 991.582 431.906 990.457 431.906C989.332 431.906 988.927 432.986 988.927 434.471ZM993.914 432.941H994.751V433.778C995.12 433.175 995.714 432.851 996.389 432.851C997.469 432.851 998.441 433.724 998.441 435.281C998.441 436.838 997.478 437.711 996.389 437.711C995.714 437.711 995.12 437.387 994.751 436.784V439.241H993.914V432.941ZM994.751 435.281C994.751 436.397 995.39 437.054 996.173 437.054C996.965 437.054 997.604 436.397 997.604 435.281C997.604 434.165 996.965 433.508 996.173 433.508C995.39 433.508 994.751 434.165 994.751 435.281ZM1001.65 437.711C1000.44 437.711 999.387 436.847 999.387 435.281C999.387 433.742 1000.41 432.851 1001.63 432.851C1003.12 432.851 1003.9 433.94 1003.87 435.47H1000.23V435.56C1000.28 436.541 1000.92 437.054 1001.65 437.054C1002.27 437.054 1002.74 436.748 1002.93 436.199H1003.79C1003.51 437.189 1002.65 437.711 1001.65 437.711ZM1000.24 434.858H1003C1002.91 433.976 1002.37 433.508 1001.62 433.508C1000.92 433.508 1000.33 433.976 1000.24 434.858ZM1008.41 434.867C1008.41 433.931 1008.02 433.508 1007.35 433.508C1006.6 433.508 1006.1 434.039 1006.1 435.038V437.621H1005.26V432.941H1006.1V433.769C1006.4 433.166 1006.95 432.851 1007.65 432.851C1008.6 432.851 1009.24 433.436 1009.24 434.741V437.621H1008.41V434.867ZM1014.47 437.621L1014.03 436.181H1011.52L1011.08 437.621H1010.22L1012.26 431.321H1013.29L1015.34 437.621H1014.47ZM1011.73 435.506H1013.82L1012.78 432.05L1011.73 435.506ZM1020.4 431.321V431.996H1018.78V436.946H1020.4V437.621H1016.33V436.946H1017.94V431.996H1016.33V431.321H1020.4ZM1023.51 437.621V436.586H1024.4V437.621L1024.03 438.737H1023.56L1023.88 437.621H1023.51Z" fill="#2F2D33"/> +<path d="M939.02 450.179V449.063C938.759 449.801 938.228 450.269 937.346 450.269C935.789 450.269 935.078 448.946 935.078 447.029C935.078 445.112 935.78 443.789 937.445 443.789C938.732 443.789 939.443 444.554 939.65 445.787H938.822C938.66 444.986 938.219 444.464 937.445 444.464C936.329 444.464 935.915 445.544 935.915 447.029C935.915 448.523 936.329 449.594 937.445 449.594C938.39 449.594 938.813 448.757 938.822 447.596H937.418V446.921H939.659V450.179H939.02ZM943.045 450.269C941.839 450.269 940.786 449.405 940.786 447.839C940.786 446.3 941.812 445.409 943.027 445.409C944.521 445.409 945.295 446.498 945.268 448.028H941.632V448.118C941.677 449.099 942.316 449.612 943.045 449.612C943.666 449.612 944.143 449.306 944.332 448.757H945.187C944.908 449.747 944.053 450.269 943.045 450.269ZM941.641 447.416H944.404C944.314 446.534 943.765 446.066 943.018 446.066C942.316 446.066 941.731 446.534 941.641 447.416ZM946.232 450.179V445.499H947.033V445.913C947.213 445.625 947.51 445.409 947.951 445.409C948.491 445.409 948.77 445.706 948.896 446.102C949.067 445.715 949.373 445.409 949.913 445.409C950.768 445.409 950.966 446.147 950.966 446.84V450.179H950.165V446.903C950.165 446.516 950.111 446.066 949.625 446.066C949.13 446.066 949.004 446.588 948.995 446.912V450.179H948.203V446.903C948.203 446.516 948.14 446.066 947.663 446.066C947.159 446.066 947.042 446.588 947.033 446.912V450.179H946.232ZM954.927 445.499V449.504H956.358V450.179H952.479V449.504H954.09V446.174H952.479V445.499H954.927ZM954.063 444.833V443.798H954.954V444.833H954.063ZM960.967 447.425C960.967 446.489 960.58 446.066 959.914 446.066C959.158 446.066 958.663 446.597 958.663 447.596V450.179H957.826V445.499H958.663V446.327C958.96 445.724 959.509 445.409 960.211 445.409C961.165 445.409 961.804 445.994 961.804 447.299V450.179H960.967V447.425ZM966.089 445.499V449.504H967.52V450.179H963.641V449.504H965.252V446.174H963.641V445.499H966.089ZM965.225 444.833V443.798H966.116V444.833H965.225ZM970.491 450.179V449.144H971.382V450.179L971.013 451.295H970.545L970.86 450.179H970.491ZM980.186 450.179V443.879H981.842C983.075 443.879 984.182 444.23 984.182 445.508C984.182 446.336 983.678 446.741 983.012 446.921C983.795 447.101 984.344 447.542 984.344 448.469C984.344 449.765 983.237 450.179 981.887 450.179H980.186ZM981.023 449.504H981.887C982.724 449.504 983.507 449.288 983.507 448.388C983.507 447.488 982.724 447.29 981.887 447.29H981.023V449.504ZM981.023 446.615H981.842C982.562 446.615 983.345 446.453 983.345 445.58C983.345 444.707 982.562 444.554 981.842 444.554H981.023V446.615ZM987.693 450.269C986.487 450.269 985.434 449.405 985.434 447.839C985.434 446.3 986.46 445.409 987.675 445.409C989.169 445.409 989.943 446.498 989.916 448.028H986.28V448.118C986.325 449.099 986.964 449.612 987.693 449.612C988.314 449.612 988.791 449.306 988.98 448.757H989.835C989.556 449.747 988.701 450.269 987.693 450.269ZM986.289 447.416H989.052C988.962 446.534 988.413 446.066 987.666 446.066C986.964 446.066 986.379 446.534 986.289 447.416ZM995.308 443.879V450.179H994.471V449.351C994.102 449.945 993.508 450.269 992.842 450.269C991.744 450.269 990.781 449.396 990.781 447.839C990.781 446.282 991.753 445.409 992.842 445.409C993.508 445.409 994.102 445.733 994.471 446.327V443.879H995.308ZM991.627 447.839C991.627 448.955 992.257 449.612 993.049 449.612C993.832 449.612 994.471 448.955 994.471 447.839C994.471 446.723 993.832 446.066 993.049 446.066C992.257 446.066 991.627 446.723 991.627 447.839ZM998.972 445.499V446.219C999.197 445.724 999.62 445.499 1000.32 445.499H1001.2V446.174H1000.25C999.395 446.174 998.972 446.516 998.972 447.497V449.504H1000.57V450.179H996.686V449.504H998.135V446.174H996.686V445.499H998.972ZM1004.41 450.269C1003.22 450.269 1002.18 449.441 1002.18 447.839C1002.18 446.237 1003.22 445.409 1004.41 445.409C1005.6 445.409 1006.65 446.237 1006.65 447.839C1006.65 449.441 1005.6 450.269 1004.41 450.269ZM1003.01 447.839C1003.01 449.027 1003.64 449.612 1004.41 449.612C1005.17 449.612 1005.8 449.027 1005.8 447.839C1005.8 446.651 1005.17 446.066 1004.41 446.066C1003.64 446.066 1003.01 446.651 1003.01 447.839ZM1007.83 447.839C1007.83 446.237 1008.87 445.409 1010.08 445.409C1011.13 445.409 1011.94 445.967 1012.2 447.011H1011.36C1011.16 446.399 1010.72 446.066 1010.08 446.066C1009.31 446.066 1008.67 446.669 1008.67 447.839C1008.67 449.009 1009.31 449.612 1010.08 449.612C1010.72 449.612 1011.17 449.279 1011.37 448.658H1012.2C1011.95 449.711 1011.13 450.269 1010.08 450.269C1008.87 450.269 1007.83 449.441 1007.83 447.839ZM1013.79 450.179V443.879H1014.63V447.848L1016.8 445.499H1017.82L1015.92 447.488L1017.82 450.179H1016.86L1015.38 448.055L1014.63 448.847V450.179H1013.79Z" fill="#2F2D33"/> +<path d="M929.485 476.122C928.405 475.862 927.715 475.312 927.715 474.252C927.715 473.042 928.755 472.332 929.965 472.332C931.185 472.332 932.075 473.022 932.255 474.232H931.325C931.185 473.492 930.715 473.082 929.965 473.082C929.245 473.082 928.645 473.492 928.645 474.222C928.645 474.872 929.115 475.162 929.675 475.322L930.625 475.572C931.805 475.852 932.415 476.472 932.415 477.512C932.415 478.702 931.425 479.532 930.035 479.532C928.695 479.532 927.755 478.742 927.575 477.462H928.505C928.655 478.272 929.225 478.782 930.035 478.782C930.955 478.782 931.485 478.242 931.485 477.542C931.485 476.892 931.065 476.552 930.435 476.372L929.485 476.122ZM936.226 479.532C934.886 479.532 933.716 478.572 933.716 476.832C933.716 475.122 934.856 474.132 936.206 474.132C937.866 474.132 938.726 475.342 938.696 477.042H934.656V477.142C934.706 478.232 935.416 478.802 936.226 478.802C936.916 478.802 937.446 478.462 937.656 477.852H938.606C938.296 478.952 937.346 479.532 936.226 479.532ZM934.666 476.362H937.736C937.636 475.382 937.026 474.862 936.196 474.862C935.416 474.862 934.766 475.382 934.666 476.362ZM943.098 472.432V478.682H944.688V479.432H940.378V478.682H942.168V473.182H940.378V472.432H943.098ZM948.969 473.822V474.232H950.809V474.982H948.969V478.682H950.709V479.432H946.389V478.682H948.029V474.982H946.389V474.232H948.029V473.822C948.029 472.652 948.749 472.432 949.599 472.432H950.809V473.092H949.839C949.359 473.092 948.969 473.152 948.969 473.822ZM953.55 476.452H956.05V477.202H953.55V476.452ZM962.451 476.372C962.451 475.332 962.021 474.862 961.281 474.862C960.441 474.862 959.891 475.452 959.891 476.562V479.432H958.961V472.432H959.891V475.132C960.231 474.472 960.831 474.132 961.601 474.132C962.661 474.132 963.381 474.782 963.381 476.232V479.432H962.451V476.372ZM967.202 479.532C965.882 479.532 964.722 478.612 964.722 476.832C964.722 475.052 965.882 474.132 967.202 474.132C968.522 474.132 969.692 475.052 969.692 476.832C969.692 478.612 968.522 479.532 967.202 479.532ZM965.652 476.832C965.652 478.152 966.352 478.802 967.202 478.802C968.052 478.802 968.752 478.152 968.752 476.832C968.752 475.512 968.052 474.862 967.202 474.862C966.352 474.862 965.652 475.512 965.652 476.832ZM973.123 477.112C972.193 476.952 971.413 476.612 971.413 475.642C971.413 474.682 972.343 474.132 973.393 474.132C974.363 474.132 975.283 474.642 975.413 475.612H974.483C974.403 475.112 973.963 474.862 973.393 474.862C972.783 474.862 972.343 475.152 972.343 475.632C972.343 476.142 972.783 476.262 973.223 476.352L973.723 476.462C974.843 476.662 975.513 477.052 975.513 477.992C975.513 478.952 974.623 479.532 973.453 479.532C972.343 479.532 971.453 478.992 971.283 477.922H972.213C972.313 478.542 972.833 478.802 973.453 478.802C974.153 478.802 974.583 478.502 974.583 478.002C974.583 477.502 974.203 477.342 973.623 477.212L973.123 477.112ZM979.825 477.952C979.825 478.622 980.215 478.682 980.705 478.682H981.665V479.432H980.455C979.605 479.432 978.895 479.212 978.895 478.042V474.982H977.255V474.232H978.895V473.032H979.825V474.232H981.665V474.982H979.825V477.952ZM985.836 479.532C984.496 479.532 983.326 478.572 983.326 476.832C983.326 475.122 984.466 474.132 985.816 474.132C987.476 474.132 988.336 475.342 988.306 477.042H984.266V477.142C984.316 478.232 985.026 478.802 985.836 478.802C986.526 478.802 987.056 478.462 987.266 477.852H988.216C987.906 478.952 986.956 479.532 985.836 479.532ZM984.276 476.362H987.346C987.246 475.382 986.636 474.862 985.806 474.862C985.026 474.862 984.376 475.382 984.276 476.362ZM994.297 472.432V479.432H993.367V478.512C992.957 479.172 992.297 479.532 991.557 479.532C990.337 479.532 989.267 478.562 989.267 476.832C989.267 475.102 990.347 474.132 991.557 474.132C992.297 474.132 992.957 474.492 993.367 475.152V472.432H994.297ZM990.207 476.832C990.207 478.072 990.907 478.802 991.787 478.802C992.657 478.802 993.367 478.072 993.367 476.832C993.367 475.592 992.657 474.862 991.787 474.862C990.907 474.862 990.207 475.592 990.207 476.832ZM1002.8 472.432H1003.74V478.682H1006.93V479.432H1002.8V472.432ZM1009 472.432H1009.94V478.682H1013.13V479.432H1009V472.432ZM1016.44 478.432L1015.09 473.782L1015.14 474.532V479.432H1014.25V472.432H1015.56L1016.81 477.192L1018.05 472.432H1019.36V479.432H1018.47V474.532L1018.52 473.782L1017.17 478.432H1016.44ZM1022.73 477.112C1021.8 476.952 1021.02 476.612 1021.02 475.642C1021.02 474.682 1021.95 474.132 1023 474.132C1023.97 474.132 1024.89 474.642 1025.02 475.612H1024.09C1024.01 475.112 1023.57 474.862 1023 474.862C1022.39 474.862 1021.95 475.152 1021.95 475.632C1021.95 476.142 1022.39 476.262 1022.83 476.352L1023.33 476.462C1024.45 476.662 1025.12 477.052 1025.12 477.992C1025.12 478.952 1024.23 479.532 1023.06 479.532C1021.95 479.532 1021.06 478.992 1020.89 477.922H1021.82C1021.92 478.542 1022.44 478.802 1023.06 478.802C1023.76 478.802 1024.19 478.502 1024.19 478.002C1024.19 477.502 1023.81 477.342 1023.23 477.212L1022.73 477.112Z" fill="#090B0B"/> +<path d="M931.307 496.221L929.606 491.541H930.452L931.856 495.618L933.26 491.541H934.097L932.396 496.221H931.307ZM935.988 489.921H936.834V495.546H939.705V496.221H935.988V489.921ZM941.569 489.921H942.415V495.546H945.286V496.221H941.569V489.921ZM948.266 495.321L947.051 491.136L947.096 491.811V496.221H946.295V489.921H947.474L948.599 494.205L949.715 489.921H950.894V496.221H950.093V491.811L950.138 491.136L948.923 495.321H948.266ZM953.748 496.221V495.186H954.639V496.221L954.27 497.337H953.802L954.117 496.221H953.748ZM965.342 496.311C963.668 496.311 962.966 494.988 962.966 493.071C962.966 491.154 963.668 489.831 965.342 489.831C967.016 489.831 967.718 491.154 967.718 493.071C967.718 494.988 967.016 496.311 965.342 496.311ZM963.812 493.071C963.812 494.565 964.217 495.636 965.342 495.636C966.467 495.636 966.881 494.565 966.881 493.071C966.881 491.586 966.467 490.506 965.342 490.506C964.217 490.506 963.812 491.586 963.812 493.071ZM971.553 489.921V495.546H972.984V496.221H969.105V495.546H970.716V490.596H969.105V489.921H971.553ZM977.134 489.921V495.546H978.565V496.221H974.686V495.546H976.297V490.596H974.686V489.921H977.134ZM979.988 494.97C979.988 494.133 980.672 493.575 982.256 493.467L983.093 493.413V493.323C983.093 492.387 982.706 492.108 982.058 492.108C981.455 492.108 981.023 492.432 980.924 492.981H980.114C980.276 492.054 981.086 491.451 982.076 491.451C983.165 491.451 983.93 491.955 983.93 493.476V495.429C983.93 495.753 983.966 496.032 984.02 496.221H983.219C983.174 496.041 983.156 495.798 983.156 495.537C982.895 496.005 982.427 496.311 981.635 496.311C980.546 496.311 979.988 495.699 979.988 494.97ZM980.825 494.943C980.825 495.348 981.122 495.699 981.833 495.699C982.652 495.699 983.093 495.15 983.093 494.475V494.016L982.418 494.061C981.311 494.142 980.825 494.403 980.825 494.943ZM985.299 496.221V491.541H986.1V491.955C986.28 491.667 986.577 491.451 987.018 491.451C987.558 491.451 987.837 491.748 987.963 492.144C988.134 491.757 988.44 491.451 988.98 491.451C989.835 491.451 990.033 492.189 990.033 492.882V496.221H989.232V492.945C989.232 492.558 989.178 492.108 988.692 492.108C988.197 492.108 988.071 492.63 988.062 492.954V496.221H987.27V492.945C987.27 492.558 987.207 492.108 986.73 492.108C986.226 492.108 986.109 492.63 986.1 492.954V496.221H985.299ZM991.15 494.97C991.15 494.133 991.834 493.575 993.418 493.467L994.255 493.413V493.323C994.255 492.387 993.868 492.108 993.22 492.108C992.617 492.108 992.185 492.432 992.086 492.981H991.276C991.438 492.054 992.248 491.451 993.238 491.451C994.327 491.451 995.092 491.955 995.092 493.476V495.429C995.092 495.753 995.128 496.032 995.182 496.221H994.381C994.336 496.041 994.318 495.798 994.318 495.537C994.057 496.005 993.589 496.311 992.797 496.311C991.708 496.311 991.15 495.699 991.15 494.97ZM991.987 494.943C991.987 495.348 992.284 495.699 992.995 495.699C993.814 495.699 994.255 495.15 994.255 494.475V494.016L993.58 494.061C992.473 494.142 991.987 494.403 991.987 494.943ZM998.396 496.221V495.186H999.287V496.221L998.918 497.337H998.45L998.765 496.221H998.396ZM1012.24 489.921V490.596H1010.41V496.221H1009.57V490.596H1007.73V489.921H1012.24ZM1017.16 496.221V495.105C1016.89 495.843 1016.36 496.311 1015.48 496.311C1013.92 496.311 1013.21 494.988 1013.21 493.071C1013.21 491.154 1013.92 489.831 1015.58 489.831C1016.87 489.831 1017.58 490.596 1017.79 491.829H1016.96C1016.8 491.028 1016.35 490.506 1015.58 490.506C1014.46 490.506 1014.05 491.586 1014.05 493.071C1014.05 494.565 1014.46 495.636 1015.58 495.636C1016.53 495.636 1016.95 494.799 1016.96 493.638H1015.55V492.963H1017.79V496.221H1017.16ZM1023.19 489.921V490.596H1021.58V495.546H1023.19V496.221H1019.12V495.546H1020.73V490.596H1019.12V489.921H1023.19Z" fill="#2F2D33"/> +<path d="M931.175 526.521L928.495 520.841V526.521H927.565V519.521H928.805L931.495 525.281V519.521H932.425V526.521H931.175ZM936.196 526.621C934.876 526.621 933.716 525.701 933.716 523.921C933.716 522.141 934.876 521.221 936.196 521.221C937.516 521.221 938.686 522.141 938.686 523.921C938.686 525.701 937.516 526.621 936.196 526.621ZM934.646 523.921C934.646 525.241 935.346 525.891 936.196 525.891C937.046 525.891 937.746 525.241 937.746 523.921C937.746 522.601 937.046 521.951 936.196 521.951C935.346 521.951 934.646 522.601 934.646 523.921ZM943.738 523.461C943.738 522.421 943.308 521.951 942.568 521.951C941.728 521.951 941.178 522.541 941.178 523.651V526.521H940.248V521.321H941.178V522.241C941.508 521.571 942.118 521.221 942.898 521.221C943.958 521.221 944.668 521.871 944.668 523.321V526.521H943.738V523.461ZM947.349 523.541H949.849V524.291H947.349V523.541ZM953.19 519.521H954.13V525.771H957.32V526.521H953.19V519.521ZM959.391 519.521H960.331V525.771H963.521V526.521H959.391V519.521ZM966.832 525.521L965.482 520.871L965.532 521.621V526.521H964.642V519.521H965.952L967.202 524.281L968.442 519.521H969.752V526.521H968.862V521.621L968.912 520.871L967.562 525.521H966.832ZM979.325 524.201C978.395 524.041 977.615 523.701 977.615 522.731C977.615 521.771 978.545 521.221 979.595 521.221C980.565 521.221 981.485 521.731 981.615 522.701H980.685C980.605 522.201 980.165 521.951 979.595 521.951C978.985 521.951 978.545 522.241 978.545 522.721C978.545 523.231 978.985 523.351 979.425 523.441L979.925 523.551C981.045 523.751 981.715 524.141 981.715 525.081C981.715 526.041 980.825 526.621 979.655 526.621C978.545 526.621 977.655 526.081 977.485 525.011H978.415C978.515 525.631 979.035 525.891 979.655 525.891C980.355 525.891 980.785 525.591 980.785 525.091C980.785 524.591 980.405 524.431 979.825 524.301L979.325 524.201ZM985.836 526.621C984.496 526.621 983.326 525.661 983.326 523.921C983.326 522.211 984.466 521.221 985.816 521.221C987.476 521.221 988.336 522.431 988.306 524.131H984.266V524.231C984.316 525.321 985.026 525.891 985.836 525.891C986.526 525.891 987.056 525.551 987.266 524.941H988.216C987.906 526.041 986.956 526.621 985.836 526.621ZM984.276 523.451H987.346C987.246 522.471 986.636 521.951 985.806 521.951C985.026 521.951 984.376 522.471 984.276 523.451ZM992.167 521.321V522.121C992.417 521.571 992.887 521.321 993.667 521.321H994.647V522.071H993.587C992.637 522.071 992.167 522.451 992.167 523.541V525.771H993.947V526.521H989.627V525.771H991.237V522.071H989.627V521.321H992.167ZM997.598 526.521L995.708 521.321H996.648L998.208 525.851L999.768 521.321H1000.7L998.808 526.521H997.598ZM1005.24 521.321V525.771H1006.83V526.521H1002.52V525.771H1004.31V522.071H1002.52V521.321H1005.24ZM1004.28 520.581V519.431H1005.27V520.581H1004.28ZM1008.21 523.921C1008.21 522.141 1009.37 521.221 1010.71 521.221C1011.88 521.221 1012.78 521.841 1013.07 523.001H1012.13C1011.91 522.321 1011.42 521.951 1010.71 521.951C1009.85 521.951 1009.14 522.621 1009.14 523.921C1009.14 525.221 1009.85 525.891 1010.71 525.891C1011.42 525.891 1011.92 525.521 1012.14 524.831H1013.07C1012.79 526.001 1011.88 526.621 1010.71 526.621C1009.37 526.621 1008.21 525.701 1008.21 523.921ZM1016.84 526.621C1015.5 526.621 1014.33 525.661 1014.33 523.921C1014.33 522.211 1015.47 521.221 1016.82 521.221C1018.48 521.221 1019.34 522.431 1019.31 524.131H1015.27V524.231C1015.32 525.321 1016.03 525.891 1016.84 525.891C1017.53 525.891 1018.06 525.551 1018.27 524.941H1019.22C1018.91 526.041 1017.96 526.621 1016.84 526.621ZM1015.28 523.451H1018.35C1018.25 522.471 1017.64 521.951 1016.81 521.951C1016.03 521.951 1015.38 522.471 1015.28 523.451ZM1022.73 524.201C1021.8 524.041 1021.02 523.701 1021.02 522.731C1021.02 521.771 1021.95 521.221 1023 521.221C1023.97 521.221 1024.89 521.731 1025.02 522.701H1024.09C1024.01 522.201 1023.57 521.951 1023 521.951C1022.39 521.951 1021.95 522.241 1021.95 522.721C1021.95 523.231 1022.39 523.351 1022.83 523.441L1023.33 523.551C1024.45 523.751 1025.12 524.141 1025.12 525.081C1025.12 526.041 1024.23 526.621 1023.06 526.621C1021.95 526.621 1021.06 526.081 1020.89 525.011H1021.82C1021.92 525.631 1022.44 525.891 1023.06 525.891C1023.76 525.891 1024.19 525.591 1024.19 525.091C1024.19 524.591 1023.81 524.431 1023.23 524.301L1022.73 524.201Z" fill="#090B0B"/> +<path d="M947.392 541.218V540.102C947.131 540.84 946.6 541.308 945.718 541.308C944.161 541.308 943.45 539.985 943.45 538.068C943.45 536.151 944.152 534.828 945.817 534.828C947.104 534.828 947.815 535.593 948.022 536.826H947.194C947.032 536.025 946.591 535.503 945.817 535.503C944.701 535.503 944.287 536.583 944.287 538.068C944.287 539.562 944.701 540.633 945.817 540.633C946.762 540.633 947.185 539.796 947.194 538.635H945.79V537.96H948.031V541.218H947.392ZM952.136 536.538V540.543H953.567V541.218H949.688V540.543H951.299V537.213H949.688V536.538H952.136ZM951.272 535.872V534.837H952.163V535.872H951.272ZM957.168 539.886C957.168 540.489 957.519 540.543 957.96 540.543H958.824V541.218H957.735C956.97 541.218 956.331 541.02 956.331 539.967V537.213H954.855V536.538H956.331V535.458H957.168V536.538H958.824V537.213H957.168V539.886ZM962.119 541.218V540.183H963.01V541.218L962.641 542.334H962.173L962.488 541.218H962.119ZM975.297 541.218V540.102C975.036 540.84 974.505 541.308 973.623 541.308C972.066 541.308 971.355 539.985 971.355 538.068C971.355 536.151 972.057 534.828 973.722 534.828C975.009 534.828 975.72 535.593 975.927 536.826H975.099C974.937 536.025 974.496 535.503 973.722 535.503C972.606 535.503 972.192 536.583 972.192 538.068C972.192 539.562 972.606 540.633 973.722 540.633C974.667 540.633 975.09 539.796 975.099 538.635H973.695V537.96H975.936V541.218H975.297ZM980.041 536.538V540.543H981.472V541.218H977.593V540.543H979.204V537.213H977.593V536.538H980.041ZM979.177 535.872V534.837H980.068V535.872H979.177ZM985.073 539.886C985.073 540.489 985.424 540.543 985.865 540.543H986.729V541.218H985.64C984.875 541.218 984.236 541.02 984.236 539.967V537.213H982.76V536.538H984.236V535.458H985.073V536.538H986.729V537.213H985.073V539.886ZM988.315 534.918H989.152V537.726H991.762V534.918H992.599V541.218H991.762V538.401H989.152V541.218H988.315V534.918ZM994.832 539.292C994.832 540.228 995.219 540.651 995.885 540.651C996.641 540.651 997.136 540.12 997.136 539.112V536.538H997.973V541.218H997.136V540.39C996.839 540.993 996.29 541.308 995.588 541.308C994.634 541.308 993.995 540.723 993.995 539.418V536.538H994.832V539.292ZM999.558 534.918H1000.39V537.366C1000.76 536.772 1001.36 536.448 1002.02 536.448C1003.11 536.448 1004.08 537.321 1004.08 538.878C1004.08 540.435 1003.12 541.308 1002.02 541.308C1001.36 541.308 1000.76 540.984 1000.39 540.39V541.218H999.558V534.918ZM1000.39 538.878C1000.39 539.994 1001.03 540.651 1001.82 540.651C1002.61 540.651 1003.24 539.994 1003.24 538.878C1003.24 537.762 1002.61 537.105 1001.82 537.105C1001.03 537.105 1000.39 537.762 1000.39 538.878ZM1006.77 541.218V540.183H1007.66V541.218L1007.29 542.334H1006.82L1007.14 541.218H1006.77Z" fill="#2F2D33"/> +<path d="M930.623 552.128C930.695 552.704 930.92 553.19 931.649 553.19C932.549 553.19 932.693 552.488 932.693 551.714V548.15H930.155V547.475H933.539V551.714C933.539 552.857 933.161 553.865 931.667 553.865C930.362 553.865 929.885 553.073 929.822 552.128H930.623ZM935.727 547.475H939.534V548.15H936.564V550.238H939.354V550.913H936.564V553.775H935.727V547.475ZM943.162 549.095V549.815C943.387 549.32 943.81 549.095 944.512 549.095H945.394V549.77H944.44C943.585 549.77 943.162 550.112 943.162 551.093V553.1H944.764V553.775H940.876V553.1H942.325V549.77H940.876V549.095H943.162ZM948.599 553.865C947.411 553.865 946.367 553.037 946.367 551.435C946.367 549.833 947.411 549.005 948.599 549.005C949.787 549.005 950.84 549.833 950.84 551.435C950.84 553.037 949.787 553.865 948.599 553.865ZM947.204 551.435C947.204 552.623 947.834 553.208 948.599 553.208C949.364 553.208 949.994 552.623 949.994 551.435C949.994 550.247 949.364 549.662 948.599 549.662C947.834 549.662 947.204 550.247 947.204 551.435ZM953.892 553.631C952.83 553.631 951.876 552.821 951.876 551.318C951.876 549.815 952.83 549.005 953.892 549.005C954.558 549.005 955.116 549.302 955.458 549.878V549.095H956.295V552.929C956.295 554.693 955.485 555.485 954.099 555.485C952.974 555.485 952.2 554.891 952.056 553.937H952.857C952.956 554.504 953.397 554.873 954.099 554.873C955.071 554.873 955.458 554.261 955.458 552.929V552.758C955.116 553.334 954.558 553.631 953.892 553.631ZM952.713 551.318C952.713 552.389 953.316 552.974 954.081 552.974C954.846 552.974 955.458 552.389 955.458 551.318C955.458 550.256 954.846 549.662 954.081 549.662C953.316 549.662 952.713 550.256 952.713 551.318ZM959.329 553.775V552.74H960.22V553.775L959.851 554.891H959.383L959.698 553.775H959.329ZM971.067 549.095V549.815C971.292 549.32 971.715 549.095 972.417 549.095H973.299V549.77H972.345C971.49 549.77 971.067 550.112 971.067 551.093V553.1H972.669V553.775H968.781V553.1H970.23V549.77H968.781V549.095H971.067ZM976.531 553.865C975.325 553.865 974.272 553.001 974.272 551.435C974.272 549.896 975.298 549.005 976.513 549.005C978.007 549.005 978.781 550.094 978.754 551.624H975.118V551.714C975.163 552.695 975.802 553.208 976.531 553.208C977.152 553.208 977.629 552.902 977.818 552.353H978.673C978.394 553.343 977.539 553.865 976.531 553.865ZM975.127 551.012H977.89C977.8 550.13 977.251 549.662 976.504 549.662C975.802 549.662 975.217 550.13 975.127 551.012ZM981.797 553.631C980.735 553.631 979.781 552.821 979.781 551.318C979.781 549.815 980.735 549.005 981.797 549.005C982.463 549.005 983.021 549.302 983.363 549.878V549.095H984.2V552.929C984.2 554.693 983.39 555.485 982.004 555.485C980.879 555.485 980.105 554.891 979.961 553.937H980.762C980.861 554.504 981.302 554.873 982.004 554.873C982.976 554.873 983.363 554.261 983.363 552.929V552.758C983.021 553.334 982.463 553.631 981.797 553.631ZM980.618 551.318C980.618 552.389 981.221 552.974 981.986 552.974C982.751 552.974 983.363 552.389 983.363 551.318C983.363 550.256 982.751 549.662 981.986 549.662C981.221 549.662 980.618 550.256 980.618 551.318ZM988.413 549.095V553.1H989.844V553.775H985.965V553.1H987.576V549.77H985.965V549.095H988.413ZM987.549 548.429V547.394H988.44V548.429H987.549ZM992.995 551.687C992.158 551.543 991.456 551.237 991.456 550.364C991.456 549.5 992.293 549.005 993.238 549.005C994.111 549.005 994.939 549.464 995.056 550.337H994.219C994.147 549.887 993.751 549.662 993.238 549.662C992.689 549.662 992.293 549.923 992.293 550.355C992.293 550.814 992.689 550.922 993.085 551.003L993.535 551.102C994.543 551.282 995.146 551.633 995.146 552.479C995.146 553.343 994.345 553.865 993.292 553.865C992.293 553.865 991.492 553.379 991.339 552.416H992.176C992.266 552.974 992.734 553.208 993.292 553.208C993.922 553.208 994.309 552.938 994.309 552.488C994.309 552.038 993.967 551.894 993.445 551.777L992.995 551.687ZM999.026 552.443C999.026 553.046 999.377 553.1 999.818 553.1H1000.68V553.775H999.593C998.828 553.775 998.189 553.577 998.189 552.524V549.77H996.713V549.095H998.189V548.015H999.026V549.095H1000.68V549.77H999.026V552.443ZM1004.55 549.095V549.815C1004.78 549.32 1005.2 549.095 1005.9 549.095H1006.79V549.77H1005.83C1004.98 549.77 1004.55 550.112 1004.55 551.093V553.1H1006.16V553.775H1002.27V553.1H1003.72V549.77H1002.27V549.095H1004.55ZM1010.74 549.095V553.1H1012.17V553.775H1008.29V553.1H1009.9V549.77H1008.29V549.095H1010.74ZM1009.87 548.429V547.394H1010.76V548.429H1009.87ZM1015.6 553.865C1014.39 553.865 1013.34 553.001 1013.34 551.435C1013.34 549.896 1014.37 549.005 1015.58 549.005C1017.07 549.005 1017.85 550.094 1017.82 551.624H1014.19V551.714C1014.23 552.695 1014.87 553.208 1015.6 553.208C1016.22 553.208 1016.7 552.902 1016.89 552.353H1017.74C1017.46 553.343 1016.61 553.865 1015.6 553.865ZM1014.19 551.012H1016.96C1016.87 550.13 1016.32 549.662 1015.57 549.662C1014.87 549.662 1014.28 550.13 1014.19 551.012ZM1020.9 551.687C1020.06 551.543 1019.36 551.237 1019.36 550.364C1019.36 549.5 1020.2 549.005 1021.14 549.005C1022.02 549.005 1022.84 549.464 1022.96 550.337H1022.12C1022.05 549.887 1021.66 549.662 1021.14 549.662C1020.59 549.662 1020.2 549.923 1020.2 550.355C1020.2 550.814 1020.59 550.922 1020.99 551.003L1021.44 551.102C1022.45 551.282 1023.05 551.633 1023.05 552.479C1023.05 553.343 1022.25 553.865 1021.2 553.865C1020.2 553.865 1019.4 553.379 1019.24 552.416H1020.08C1020.17 552.974 1020.64 553.208 1021.2 553.208C1021.83 553.208 1022.21 552.938 1022.21 552.488C1022.21 552.038 1021.87 551.894 1021.35 551.777L1020.9 551.687Z" fill="#2F2D33"/> +<path d="M937.297 591.725H941.477V592.475H938.227V594.795H941.297V595.545H938.227V597.975H941.527V598.725H937.297V591.725ZM943.068 598.725L944.988 596.105L943.088 593.525H944.098L945.488 595.445L946.888 593.525H947.898L945.998 596.105L947.928 598.725H946.918L945.498 596.765L944.068 598.725H943.068ZM951.919 597.245C951.919 597.915 952.309 597.975 952.799 597.975H953.759V598.725H952.549C951.699 598.725 950.989 598.505 950.989 597.335V594.275H949.349V593.525H950.989V592.325H951.919V593.525H953.759V594.275H951.919V597.245ZM957.93 598.825C956.59 598.825 955.42 597.865 955.42 596.125C955.42 594.415 956.56 593.425 957.91 593.425C959.57 593.425 960.43 594.635 960.4 596.335H956.36V596.435C956.41 597.525 957.12 598.095 957.93 598.095C958.62 598.095 959.15 597.755 959.36 597.145H960.31C960 598.245 959.05 598.825 957.93 598.825ZM956.37 595.655H959.44C959.34 594.675 958.73 594.155 957.9 594.155C957.12 594.155 956.47 594.675 956.37 595.655ZM964.262 593.525V594.325C964.512 593.775 964.982 593.525 965.762 593.525H966.742V594.275H965.682C964.732 594.275 964.262 594.655 964.262 595.745V597.975H966.042V598.725H961.722V597.975H963.332V594.275H961.722V593.525H964.262ZM971.643 595.665C971.643 594.625 971.213 594.155 970.473 594.155C969.633 594.155 969.083 594.745 969.083 595.855V598.725H968.153V593.525H969.083V594.445C969.413 593.775 970.023 593.425 970.803 593.425C971.863 593.425 972.573 594.075 972.573 595.525V598.725H971.643V595.665ZM974.174 597.335C974.174 596.405 974.934 595.785 976.694 595.665L977.624 595.605V595.505C977.624 594.465 977.194 594.155 976.474 594.155C975.804 594.155 975.324 594.515 975.214 595.125H974.314C974.494 594.095 975.394 593.425 976.494 593.425C977.704 593.425 978.554 593.985 978.554 595.675V597.845C978.554 598.205 978.594 598.515 978.654 598.725H977.764C977.714 598.525 977.694 598.255 977.694 597.965C977.404 598.485 976.884 598.825 976.004 598.825C974.794 598.825 974.174 598.145 974.174 597.335ZM975.104 597.305C975.104 597.755 975.434 598.145 976.224 598.145C977.134 598.145 977.624 597.535 977.624 596.785V596.275L976.874 596.325C975.644 596.415 975.104 596.705 975.104 597.305ZM983.405 591.725V597.975H984.995V598.725H980.685V597.975H982.475V592.475H980.685V591.725H983.405ZM992.777 597.335C992.777 596.405 993.537 595.785 995.297 595.665L996.227 595.605V595.505C996.227 594.465 995.797 594.155 995.077 594.155C994.407 594.155 993.927 594.515 993.817 595.125H992.917C993.097 594.095 993.997 593.425 995.097 593.425C996.307 593.425 997.157 593.985 997.157 595.675V597.845C997.157 598.205 997.197 598.515 997.257 598.725H996.367C996.317 598.525 996.297 598.255 996.297 597.965C996.007 598.485 995.487 598.825 994.607 598.825C993.397 598.825 992.777 598.145 992.777 597.335ZM993.707 597.305C993.707 597.755 994.037 598.145 994.827 598.145C995.737 598.145 996.227 597.535 996.227 596.785V596.275L995.477 596.325C994.247 596.415 993.707 596.705 993.707 597.305ZM999.969 596.585C999.969 597.625 1000.4 598.095 1001.14 598.095C1001.98 598.095 1002.53 597.505 1002.53 596.385V593.525H1003.46V598.725H1002.53V597.805C1002.2 598.475 1001.59 598.825 1000.81 598.825C999.749 598.825 999.039 598.175 999.039 596.725V593.525H999.969V596.585ZM1007.73 597.245C1007.73 597.915 1008.12 597.975 1008.61 597.975H1009.57V598.725H1008.36C1007.51 598.725 1006.8 598.505 1006.8 597.335V594.275H1005.16V593.525H1006.8V592.325H1007.73V593.525H1009.57V594.275H1007.73V597.245ZM1015.16 595.665C1015.16 594.625 1014.73 594.155 1013.99 594.155C1013.15 594.155 1012.6 594.745 1012.6 595.855V598.725H1011.67V591.725H1012.6V594.425C1012.94 593.765 1013.54 593.425 1014.31 593.425C1015.37 593.425 1016.09 594.075 1016.09 595.525V598.725H1015.16V595.665Z" fill="#090B0B"/> +<path d="M953.721 612.535C952.749 612.301 952.128 611.806 952.128 610.852C952.128 609.763 953.064 609.124 954.153 609.124C955.251 609.124 956.052 609.745 956.214 610.834H955.377C955.251 610.168 954.828 609.799 954.153 609.799C953.505 609.799 952.965 610.168 952.965 610.825C952.965 611.41 953.388 611.671 953.892 611.815L954.747 612.04C955.809 612.292 956.358 612.85 956.358 613.786C956.358 614.857 955.467 615.604 954.216 615.604C953.01 615.604 952.164 614.893 952.002 613.741H952.839C952.974 614.47 953.487 614.929 954.216 614.929C955.044 614.929 955.521 614.443 955.521 613.813C955.521 613.228 955.143 612.922 954.576 612.76L953.721 612.535ZM959.302 612.535C958.33 612.301 957.709 611.806 957.709 610.852C957.709 609.763 958.645 609.124 959.734 609.124C960.832 609.124 961.633 609.745 961.795 610.834H960.958C960.832 610.168 960.409 609.799 959.734 609.799C959.086 609.799 958.546 610.168 958.546 610.825C958.546 611.41 958.969 611.671 959.473 611.815L960.328 612.04C961.39 612.292 961.939 612.85 961.939 613.786C961.939 614.857 961.048 615.604 959.797 615.604C958.591 615.604 957.745 614.893 957.583 613.741H958.42C958.555 614.47 959.068 614.929 959.797 614.929C960.625 614.929 961.102 614.443 961.102 613.813C961.102 613.228 960.724 612.922 960.157 612.76L959.302 612.535ZM965.342 615.604C963.668 615.604 962.966 614.281 962.966 612.364C962.966 610.447 963.668 609.124 965.342 609.124C967.016 609.124 967.718 610.447 967.718 612.364C967.718 614.281 967.016 615.604 965.342 615.604ZM963.812 612.364C963.812 613.858 964.217 614.929 965.342 614.929C966.467 614.929 966.881 613.858 966.881 612.364C966.881 610.879 966.467 609.799 965.342 609.799C964.217 609.799 963.812 610.879 963.812 612.364ZM969.258 615.514L971.733 609.214H972.579L970.104 615.514H969.258ZM976.504 615.604C974.83 615.604 974.128 614.281 974.128 612.364C974.128 610.447 974.83 609.124 976.504 609.124C978.178 609.124 978.88 610.447 978.88 612.364C978.88 614.281 978.178 615.604 976.504 615.604ZM974.974 612.364C974.974 613.858 975.379 614.929 976.504 614.929C977.629 614.929 978.043 613.858 978.043 612.364C978.043 610.879 977.629 609.799 976.504 609.799C975.379 609.799 974.974 610.879 974.974 612.364ZM984.119 609.214V609.889H982.508V614.839H984.119V615.514H980.051V614.839H981.662V609.889H980.051V609.214H984.119ZM987.045 614.839C988.548 614.839 989.241 613.876 989.241 612.364C989.241 610.843 988.548 609.889 987.045 609.889H986.46V614.839H987.045ZM985.623 615.514V609.214H987.045C989.034 609.214 990.078 610.42 990.078 612.364C990.078 614.308 989.034 615.514 987.045 615.514H985.623ZM991.006 612.364C991.006 610.447 991.708 609.124 993.382 609.124C994.66 609.124 995.371 609.889 995.587 611.122H994.75C994.588 610.321 994.156 609.799 993.382 609.799C992.257 609.799 991.843 610.879 991.843 612.364C991.843 613.858 992.257 614.929 993.382 614.929C994.156 614.929 994.588 614.407 994.741 613.624H995.587C995.371 614.848 994.651 615.604 993.382 615.604C991.708 615.604 991.006 614.281 991.006 612.364ZM998.396 615.514V614.479H999.287V615.514L998.918 616.63H998.45L998.765 615.514H998.396Z" fill="#2F2D33"/> +<path d="M950.93 625.092C949.958 624.858 949.337 624.363 949.337 623.409C949.337 622.32 950.273 621.681 951.362 621.681C952.46 621.681 953.261 622.302 953.423 623.391H952.586C952.46 622.725 952.037 622.356 951.362 622.356C950.714 622.356 950.174 622.725 950.174 623.382C950.174 623.967 950.597 624.228 951.101 624.372L951.956 624.597C953.018 624.849 953.567 625.407 953.567 626.343C953.567 627.414 952.676 628.161 951.425 628.161C950.219 628.161 949.373 627.45 949.211 626.298H950.048C950.183 627.027 950.696 627.486 951.425 627.486C952.253 627.486 952.73 627 952.73 626.37C952.73 625.785 952.352 625.479 951.785 625.317L950.93 625.092ZM958.662 628.071L958.221 626.631H955.71L955.269 628.071H954.405L956.448 621.771H957.483L959.526 628.071H958.662ZM955.917 625.956H958.014L956.97 622.5L955.917 625.956ZM962.218 627.171L961.003 622.986L961.048 623.661V628.071H960.247V621.771H961.426L962.551 626.055L963.667 621.771H964.846V628.071H964.045V623.661L964.09 622.986L962.875 627.171H962.218ZM966.683 621.771H967.529V627.396H970.4V628.071H966.683V621.771ZM973.281 628.071V627.036H974.172V628.071L973.803 629.187H973.335L973.65 628.071H973.281ZM986.91 621.771V622.446H985.299V627.396H986.91V628.071H982.842V627.396H984.453V622.446H982.842V621.771H986.91ZM992.518 621.771V628.071H991.681V627.243C991.312 627.837 990.718 628.161 990.052 628.161C988.954 628.161 987.991 627.288 987.991 625.731C987.991 624.174 988.963 623.301 990.052 623.301C990.718 623.301 991.312 623.625 991.681 624.219V621.771H992.518ZM988.837 625.731C988.837 626.847 989.467 627.504 990.259 627.504C991.042 627.504 991.681 626.847 991.681 625.731C991.681 624.615 991.042 623.958 990.259 623.958C989.467 623.958 988.837 624.615 988.837 625.731ZM994.301 621.771H995.876C997.451 621.771 998.36 622.374 998.36 623.679C998.36 624.984 997.451 625.587 995.876 625.587H995.138V628.071H994.301V621.771ZM995.138 624.912H995.876C996.938 624.912 997.523 624.57 997.523 623.679C997.523 622.788 996.938 622.446 995.876 622.446H995.138V624.912ZM1001.37 625.983C1000.53 625.839 999.828 625.533 999.828 624.66C999.828 623.796 1000.66 623.301 1001.61 623.301C1002.48 623.301 1003.31 623.76 1003.43 624.633H1002.59C1002.52 624.183 1002.12 623.958 1001.61 623.958C1001.06 623.958 1000.66 624.219 1000.66 624.651C1000.66 625.11 1001.06 625.218 1001.46 625.299L1001.91 625.398C1002.91 625.578 1003.52 625.929 1003.52 626.775C1003.52 627.639 1002.72 628.161 1001.66 628.161C1000.66 628.161 999.864 627.675 999.711 626.712H1000.55C1000.64 627.27 1001.11 627.504 1001.66 627.504C1002.29 627.504 1002.68 627.234 1002.68 626.784C1002.68 626.334 1002.34 626.19 1001.82 626.073L1001.37 625.983Z" fill="#2F2D33"/> +<path d="M881 430H904" stroke="#2F2D33"/> +<path d="M435.4 542H190V642H435.4V542Z" fill="#B8D7F5"/> +<path d="M273.795 568V558.06H277.505C278.643 558.06 279.53 558.335 280.165 558.886C280.799 559.427 281.117 560.183 281.117 561.154C281.117 561.798 280.972 562.358 280.683 562.834C280.393 563.301 279.978 563.66 279.437 563.912C278.905 564.155 278.261 564.276 277.505 564.276H275.307V568H273.795ZM275.307 562.904H277.463C278.144 562.904 278.662 562.759 279.017 562.47C279.371 562.171 279.549 561.733 279.549 561.154C279.549 560.585 279.371 560.16 279.017 559.88C278.662 559.591 278.144 559.446 277.463 559.446H275.307V562.904ZM285.511 568.168C284.802 568.168 284.181 568.009 283.649 567.692C283.117 567.375 282.707 566.922 282.417 566.334C282.128 565.746 281.983 565.06 281.983 564.276C281.983 563.483 282.128 562.797 282.417 562.218C282.707 561.639 283.117 561.191 283.649 560.874C284.181 560.547 284.802 560.384 285.511 560.384C286.221 560.384 286.841 560.547 287.373 560.874C287.905 561.191 288.316 561.639 288.605 562.218C288.895 562.797 289.039 563.483 289.039 564.276C289.039 565.06 288.895 565.746 288.605 566.334C288.316 566.922 287.905 567.375 287.373 567.692C286.841 568.009 286.221 568.168 285.511 568.168ZM285.511 566.88C286.146 566.88 286.636 566.651 286.981 566.194C287.327 565.727 287.499 565.088 287.499 564.276C287.499 563.464 287.327 562.829 286.981 562.372C286.636 561.905 286.146 561.672 285.511 561.672C284.886 561.672 284.396 561.905 284.041 562.372C283.696 562.829 283.523 563.464 283.523 564.276C283.523 565.088 283.696 565.727 284.041 566.194C284.396 566.651 284.886 566.88 285.511 566.88ZM293.409 568.168C292.709 568.168 292.116 568.061 291.631 567.846C291.155 567.631 290.786 567.337 290.525 566.964C290.264 566.581 290.114 566.143 290.077 565.648L291.603 565.578C291.678 565.989 291.855 566.315 292.135 566.558C292.415 566.801 292.84 566.922 293.409 566.922C293.876 566.922 294.244 566.847 294.515 566.698C294.786 566.549 294.921 566.311 294.921 565.984C294.921 565.807 294.874 565.657 294.781 565.536C294.697 565.405 294.529 565.298 294.277 565.214C294.025 565.121 293.652 565.032 293.157 564.948C292.42 564.808 291.841 564.645 291.421 564.458C291.001 564.271 290.702 564.033 290.525 563.744C290.348 563.455 290.259 563.105 290.259 562.694C290.259 562.013 290.511 561.457 291.015 561.028C291.528 560.599 292.266 560.384 293.227 560.384C293.871 560.384 294.412 560.496 294.851 560.72C295.29 560.935 295.63 561.229 295.873 561.602C296.125 561.966 296.284 562.377 296.349 562.834L294.823 562.918C294.776 562.657 294.683 562.433 294.543 562.246C294.412 562.05 294.235 561.901 294.011 561.798C293.787 561.686 293.521 561.63 293.213 561.63C292.737 561.63 292.382 561.723 292.149 561.91C291.916 562.097 291.799 562.339 291.799 562.638C291.799 562.862 291.85 563.044 291.953 563.184C292.065 563.324 292.242 563.441 292.485 563.534C292.728 563.618 293.05 563.693 293.451 563.758C294.226 563.889 294.832 564.052 295.271 564.248C295.71 564.435 296.018 564.668 296.195 564.948C296.372 565.228 296.461 565.569 296.461 565.97C296.461 566.437 296.33 566.833 296.069 567.16C295.808 567.487 295.448 567.739 294.991 567.916C294.534 568.084 294.006 568.168 293.409 568.168ZM300.545 568C299.836 568 299.313 567.837 298.977 567.51C298.641 567.183 298.473 566.675 298.473 565.984V558.802H299.957V565.872C299.957 566.217 300.032 566.455 300.181 566.586C300.331 566.717 300.559 566.782 300.867 566.782H301.931V568H300.545ZM297.339 561.77V560.552H301.931V561.77H297.339ZM306.222 570.268C305.634 570.268 305.116 570.179 304.668 570.002C304.22 569.825 303.852 569.573 303.562 569.246C303.273 568.929 303.077 568.555 302.974 568.126L304.514 568.028C304.617 568.336 304.794 568.579 305.046 568.756C305.308 568.943 305.7 569.036 306.222 569.036C306.838 569.036 307.31 568.905 307.636 568.644C307.972 568.383 308.14 567.986 308.14 567.454V566.376C307.963 566.759 307.678 567.062 307.286 567.286C306.894 567.51 306.456 567.622 305.97 567.622C305.345 567.622 304.79 567.468 304.304 567.16C303.819 566.852 303.441 566.427 303.17 565.886C302.9 565.345 302.764 564.719 302.764 564.01C302.764 563.291 302.895 562.661 303.156 562.12C303.427 561.579 303.8 561.154 304.276 560.846C304.762 560.538 305.308 560.384 305.914 560.384C306.446 560.384 306.913 560.505 307.314 560.748C307.725 560.991 308.019 561.317 308.196 561.728V560.552H309.624V567.412C309.624 568.028 309.484 568.546 309.204 568.966C308.934 569.395 308.542 569.717 308.028 569.932C307.524 570.156 306.922 570.268 306.222 570.268ZM306.208 566.362C306.787 566.362 307.249 566.152 307.594 565.732C307.949 565.312 308.131 564.733 308.14 563.996C308.15 563.511 308.075 563.095 307.916 562.75C307.758 562.395 307.529 562.125 307.23 561.938C306.941 561.742 306.6 561.644 306.208 561.644C305.602 561.644 305.13 561.859 304.794 562.288C304.468 562.708 304.304 563.277 304.304 563.996C304.304 564.724 304.472 565.303 304.808 565.732C305.154 566.152 305.62 566.362 306.208 566.362ZM311.73 568V560.552H313.088L313.144 562.526L313.018 562.484C313.121 561.812 313.326 561.322 313.634 561.014C313.952 560.706 314.376 560.552 314.908 560.552H315.622V561.882H314.908C314.535 561.882 314.222 561.943 313.97 562.064C313.718 562.185 313.527 562.372 313.396 562.624C313.275 562.876 313.214 563.203 313.214 563.604V568H311.73ZM319.746 568.168C319.018 568.168 318.388 568.009 317.856 567.692C317.333 567.375 316.927 566.922 316.638 566.334C316.358 565.746 316.218 565.06 316.218 564.276C316.218 563.492 316.358 562.811 316.638 562.232C316.927 561.644 317.333 561.191 317.856 560.874C318.378 560.547 318.994 560.384 319.704 560.384C320.376 560.384 320.968 560.543 321.482 560.86C321.995 561.168 322.392 561.616 322.672 562.204C322.961 562.792 323.106 563.501 323.106 564.332V564.71H317.758C317.795 565.438 317.986 565.984 318.332 566.348C318.686 566.712 319.162 566.894 319.76 566.894C320.198 566.894 320.562 566.791 320.852 566.586C321.141 566.381 321.342 566.105 321.454 565.76L322.994 565.858C322.798 566.549 322.41 567.109 321.832 567.538C321.262 567.958 320.567 568.168 319.746 568.168ZM317.758 563.59H321.538C321.491 562.927 321.3 562.437 320.964 562.12C320.637 561.803 320.217 561.644 319.704 561.644C319.172 561.644 318.733 561.812 318.388 562.148C318.052 562.475 317.842 562.955 317.758 563.59ZM328.484 568.224C327.691 568.224 327 568.079 326.412 567.79C325.833 567.501 325.376 567.099 325.04 566.586C324.704 566.073 324.508 565.475 324.452 564.794L326.006 564.696C326.071 565.163 326.207 565.559 326.412 565.886C326.617 566.203 326.893 566.446 327.238 566.614C327.593 566.773 328.017 566.852 328.512 566.852C328.941 566.852 329.305 566.801 329.604 566.698C329.912 566.586 330.145 566.423 330.304 566.208C330.463 565.993 330.542 565.732 330.542 565.424C330.542 565.144 330.472 564.897 330.332 564.682C330.201 564.458 329.945 564.257 329.562 564.08C329.189 563.893 328.638 563.711 327.91 563.534C327.117 563.338 326.482 563.123 326.006 562.89C325.53 562.657 325.185 562.367 324.97 562.022C324.765 561.667 324.662 561.233 324.662 560.72C324.662 560.151 324.797 559.651 325.068 559.222C325.348 558.783 325.745 558.443 326.258 558.2C326.771 557.957 327.387 557.836 328.106 557.836C328.862 557.836 329.506 557.976 330.038 558.256C330.579 558.536 331.004 558.919 331.312 559.404C331.629 559.889 331.825 560.445 331.9 561.07L330.346 561.154C330.299 560.771 330.183 560.435 329.996 560.146C329.809 559.847 329.557 559.619 329.24 559.46C328.923 559.292 328.535 559.208 328.078 559.208C327.499 559.208 327.042 559.343 326.706 559.614C326.379 559.875 326.216 560.225 326.216 560.664C326.216 560.944 326.281 561.182 326.412 561.378C326.552 561.565 326.795 561.728 327.14 561.868C327.495 562.008 327.994 562.157 328.638 562.316C329.497 562.512 330.178 562.755 330.682 563.044C331.195 563.324 331.559 563.655 331.774 564.038C331.989 564.421 332.096 564.855 332.096 565.34C332.096 565.928 331.942 566.441 331.634 566.88C331.335 567.309 330.915 567.641 330.374 567.874C329.833 568.107 329.203 568.224 328.484 568.224ZM340.612 568.938L339.814 567.902C339.59 567.995 339.329 568.07 339.03 568.126C338.731 568.191 338.409 568.224 338.064 568.224C337.103 568.224 336.281 567.995 335.6 567.538C334.919 567.081 334.391 566.465 334.018 565.69C333.654 564.906 333.472 564.024 333.472 563.044C333.472 562.064 333.649 561.182 334.004 560.398C334.359 559.614 334.877 558.993 335.558 558.536C336.249 558.069 337.084 557.836 338.064 557.836C339.063 557.836 339.903 558.069 340.584 558.536C341.275 558.993 341.793 559.614 342.138 560.398C342.493 561.182 342.67 562.064 342.67 563.044C342.67 563.959 342.507 564.789 342.18 565.536C341.863 566.283 341.401 566.889 340.794 567.356L342.054 568.938H340.612ZM338.064 566.838C338.241 566.838 338.4 566.829 338.54 566.81C338.68 566.791 338.806 566.759 338.918 566.712L337.504 564.934H338.946L339.898 566.194C340.299 565.867 340.598 565.443 340.794 564.92C340.999 564.388 341.102 563.763 341.102 563.044C341.102 562.353 340.99 561.719 340.766 561.14C340.551 560.561 340.22 560.099 339.772 559.754C339.324 559.399 338.755 559.222 338.064 559.222C337.383 559.222 336.818 559.399 336.37 559.754C335.922 560.099 335.586 560.561 335.362 561.14C335.147 561.719 335.04 562.353 335.04 563.044C335.04 563.725 335.147 564.355 335.362 564.934C335.586 565.513 335.922 565.975 336.37 566.32C336.827 566.665 337.392 566.838 338.064 566.838ZM344.341 568V558.06H345.853V567.44L345.055 566.614H350.697V568H344.341Z" fill="#090B0B"/> +<path d="M246.045 587.359C244.857 587.073 244.098 586.468 244.098 585.302C244.098 583.971 245.242 583.19 246.573 583.19C247.915 583.19 248.894 583.949 249.092 585.28H248.069C247.915 584.466 247.398 584.015 246.573 584.015C245.781 584.015 245.121 584.466 245.121 585.269C245.121 585.984 245.638 586.303 246.254 586.479L247.299 586.754C248.597 587.062 249.268 587.744 249.268 588.888C249.268 590.197 248.179 591.11 246.65 591.11C245.176 591.11 244.142 590.241 243.944 588.833H244.967C245.132 589.724 245.759 590.285 246.65 590.285C247.662 590.285 248.245 589.691 248.245 588.921C248.245 588.206 247.783 587.832 247.09 587.634L246.045 587.359ZM253.669 589.372C253.669 590.109 254.098 590.175 254.637 590.175H255.693V591H254.362C253.427 591 252.646 590.758 252.646 589.471V586.105H250.842V585.28H252.646V583.96H253.669V585.28H255.693V586.105H253.669V589.372ZM257.685 589.471C257.685 588.448 258.521 587.766 260.457 587.634L261.48 587.568V587.458C261.48 586.314 261.007 585.973 260.215 585.973C259.478 585.973 258.95 586.369 258.829 587.04H257.839C258.037 585.907 259.027 585.17 260.237 585.17C261.568 585.17 262.503 585.786 262.503 587.645V590.032C262.503 590.428 262.547 590.769 262.613 591H261.634C261.579 590.78 261.557 590.483 261.557 590.164C261.238 590.736 260.666 591.11 259.698 591.11C258.367 591.11 257.685 590.362 257.685 589.471ZM258.708 589.438C258.708 589.933 259.071 590.362 259.94 590.362C260.941 590.362 261.48 589.691 261.48 588.866V588.305L260.655 588.36C259.302 588.459 258.708 588.778 258.708 589.438ZM267.312 589.372C267.312 590.109 267.741 590.175 268.28 590.175H269.336V591H268.005C267.07 591 266.289 590.758 266.289 589.471V586.105H264.485V585.28H266.289V583.96H267.312V585.28H269.336V586.105H267.312V589.372ZM273.924 591.11C272.45 591.11 271.163 590.054 271.163 588.14C271.163 586.259 272.417 585.17 273.902 585.17C275.728 585.17 276.674 586.501 276.641 588.371H272.197V588.481C272.252 589.68 273.033 590.307 273.924 590.307C274.683 590.307 275.266 589.933 275.497 589.262H276.542C276.201 590.472 275.156 591.11 273.924 591.11ZM272.208 587.623H275.585C275.475 586.545 274.804 585.973 273.891 585.973C273.033 585.973 272.318 586.545 272.208 587.623ZM287.115 590.604V588.558H285.069V587.722H287.115V585.676H287.951V587.722H289.997V588.558H287.951V590.604H287.115ZM298.536 588.14C298.536 586.182 299.812 585.17 301.286 585.17C302.573 585.17 303.563 585.852 303.882 587.128H302.848C302.606 586.38 302.067 585.973 301.286 585.973C300.34 585.973 299.559 586.71 299.559 588.14C299.559 589.57 300.34 590.307 301.286 590.307C302.067 590.307 302.617 589.9 302.859 589.141H303.882C303.574 590.428 302.573 591.11 301.286 591.11C299.812 591.11 298.536 590.098 298.536 588.14ZM309.592 587.634C309.592 586.49 309.119 585.973 308.305 585.973C307.381 585.973 306.776 586.622 306.776 587.843V591H305.753V583.3H306.776V586.27C307.15 585.544 307.81 585.17 308.657 585.17C309.823 585.17 310.615 585.885 310.615 587.48V591H309.592V587.634ZM312.256 589.471C312.256 588.448 313.092 587.766 315.028 587.634L316.051 587.568V587.458C316.051 586.314 315.578 585.973 314.786 585.973C314.049 585.973 313.521 586.369 313.4 587.04H312.41C312.608 585.907 313.598 585.17 314.808 585.17C316.139 585.17 317.074 585.786 317.074 587.645V590.032C317.074 590.428 317.118 590.769 317.184 591H316.205C316.15 590.78 316.128 590.483 316.128 590.164C315.809 590.736 315.237 591.11 314.269 591.11C312.938 591.11 312.256 590.362 312.256 589.471ZM313.279 589.438C313.279 589.933 313.642 590.362 314.511 590.362C315.512 590.362 316.051 589.691 316.051 588.866V588.305L315.226 588.36C313.873 588.459 313.279 588.778 313.279 589.438ZM321.882 589.372C321.882 590.109 322.311 590.175 322.85 590.175H323.906V591H322.575C321.64 591 320.859 590.758 320.859 589.471V586.105H319.055V585.28H320.859V583.96H321.882V585.28H323.906V586.105H321.882V589.372ZM336.878 587.634C336.878 586.49 336.405 585.973 335.591 585.973C334.667 585.973 334.062 586.622 334.062 587.843V591H333.039V583.3H334.062V586.27C334.436 585.544 335.096 585.17 335.943 585.17C337.109 585.17 337.901 585.885 337.901 587.48V591H336.878V587.634ZM343.017 585.28V590.175H344.766V591H340.025V590.175H341.994V586.105H340.025V585.28H343.017ZM341.961 584.466V583.201H343.05V584.466H341.961ZM348.617 588.448C347.594 588.272 346.736 587.898 346.736 586.831C346.736 585.775 347.759 585.17 348.914 585.17C349.981 585.17 350.993 585.731 351.136 586.798H350.113C350.025 586.248 349.541 585.973 348.914 585.973C348.243 585.973 347.759 586.292 347.759 586.82C347.759 587.381 348.243 587.513 348.727 587.612L349.277 587.733C350.509 587.953 351.246 588.382 351.246 589.416C351.246 590.472 350.267 591.11 348.98 591.11C347.759 591.11 346.78 590.516 346.593 589.339H347.616C347.726 590.021 348.298 590.307 348.98 590.307C349.75 590.307 350.223 589.977 350.223 589.427C350.223 588.877 349.805 588.701 349.167 588.558L348.617 588.448ZM355.988 589.372C355.988 590.109 356.417 590.175 356.956 590.175H358.012V591H356.681C355.746 591 354.965 590.758 354.965 589.471V586.105H353.161V585.28H354.965V583.96H355.988V585.28H358.012V586.105H355.988V589.372ZM362.568 591.11C361.116 591.11 359.84 590.098 359.84 588.14C359.84 586.182 361.116 585.17 362.568 585.17C364.02 585.17 365.307 586.182 365.307 588.14C365.307 590.098 364.02 591.11 362.568 591.11ZM360.863 588.14C360.863 589.592 361.633 590.307 362.568 590.307C363.503 590.307 364.273 589.592 364.273 588.14C364.273 586.688 363.503 585.973 362.568 585.973C361.633 585.973 360.863 586.688 360.863 588.14ZM369.565 585.28V586.16C369.84 585.555 370.357 585.28 371.215 585.28H372.293V586.105H371.127C370.082 586.105 369.565 586.523 369.565 587.722V590.175H371.523V591H366.771V590.175H368.542V586.105H366.771V585.28H369.565ZM374.901 592.98L375.682 590.934L373.383 585.28H374.428L376.144 589.713L377.827 585.28H378.861L375.935 592.98H374.901Z" fill="#090B0B"/> +<path d="M263.241 608.604V606.558H261.195V605.722H263.241V603.676H264.077V605.722H266.123V606.558H264.077V608.604H263.241ZM274.738 607.471C274.738 606.448 275.574 605.766 277.51 605.634L278.533 605.568V605.458C278.533 604.314 278.06 603.973 277.268 603.973C276.531 603.973 276.003 604.369 275.882 605.04H274.892C275.09 603.907 276.08 603.17 277.29 603.17C278.621 603.17 279.556 603.786 279.556 605.645V608.032C279.556 608.428 279.6 608.769 279.666 609H278.687C278.632 608.78 278.61 608.483 278.61 608.164C278.291 608.736 277.719 609.11 276.751 609.11C275.42 609.11 274.738 608.362 274.738 607.471ZM275.761 607.438C275.761 607.933 276.124 608.362 276.993 608.362C277.994 608.362 278.533 607.691 278.533 606.866V606.305L277.708 606.36C276.355 606.459 275.761 606.778 275.761 607.438ZM282.649 606.646C282.649 607.79 283.122 608.307 283.936 608.307C284.86 608.307 285.465 607.658 285.465 606.426V603.28H286.488V609H285.465V607.988C285.102 608.725 284.431 609.11 283.573 609.11C282.407 609.11 281.626 608.395 281.626 606.8V603.28H282.649V606.646ZM293.463 601.3V609H292.44V607.988C291.989 608.714 291.263 609.11 290.449 609.11C289.107 609.11 287.93 608.043 287.93 606.14C287.93 604.237 289.118 603.17 290.449 603.17C291.263 603.17 291.989 603.566 292.44 604.292V601.3H293.463ZM288.964 606.14C288.964 607.504 289.734 608.307 290.702 608.307C291.659 608.307 292.44 607.504 292.44 606.14C292.44 604.776 291.659 603.973 290.702 603.973C289.734 603.973 288.964 604.776 288.964 606.14ZM298.678 603.28V608.175H300.427V609H295.686V608.175H297.655V604.105H295.686V603.28H298.678ZM297.622 602.466V601.201H298.711V602.466H297.622ZM304.829 607.372C304.829 608.109 305.258 608.175 305.797 608.175H306.853V609H305.522C304.587 609 303.806 608.758 303.806 607.471V604.105H302.002V603.28H303.806V601.96H304.829V603.28H306.853V604.105H304.829V607.372ZM318.405 603.28V604.16C318.68 603.555 319.197 603.28 320.055 603.28H321.133V604.105H319.967C318.922 604.105 318.405 604.523 318.405 605.722V608.175H320.363V609H315.611V608.175H317.382V604.105H315.611V603.28H318.405ZM325.083 609.11C323.609 609.11 322.322 608.054 322.322 606.14C322.322 604.259 323.576 603.17 325.061 603.17C326.887 603.17 327.833 604.501 327.8 606.371H323.356V606.481C323.411 607.68 324.192 608.307 325.083 608.307C325.842 608.307 326.425 607.933 326.656 607.262H327.701C327.36 608.472 326.315 609.11 325.083 609.11ZM323.367 605.623H326.744C326.634 604.545 325.963 603.973 325.05 603.973C324.192 603.973 323.477 604.545 323.367 605.623ZM329.232 606.14C329.232 604.182 330.508 603.17 331.982 603.17C333.269 603.17 334.259 603.852 334.578 605.128H333.544C333.302 604.38 332.763 603.973 331.982 603.973C331.036 603.973 330.255 604.71 330.255 606.14C330.255 607.57 331.036 608.307 331.982 608.307C332.763 608.307 333.313 607.9 333.555 607.141H334.578C334.27 608.428 333.269 609.11 331.982 609.11C330.508 609.11 329.232 608.098 329.232 606.14ZM338.693 609.11C337.241 609.11 335.965 608.098 335.965 606.14C335.965 604.182 337.241 603.17 338.693 603.17C340.145 603.17 341.432 604.182 341.432 606.14C341.432 608.098 340.145 609.11 338.693 609.11ZM336.988 606.14C336.988 607.592 337.758 608.307 338.693 608.307C339.628 608.307 340.398 607.592 340.398 606.14C340.398 604.688 339.628 603.973 338.693 603.973C337.758 603.973 336.988 604.688 336.988 606.14ZM345.69 603.28V604.16C345.965 603.555 346.482 603.28 347.34 603.28H348.418V604.105H347.252C346.207 604.105 345.69 604.523 345.69 605.722V608.175H347.648V609H342.896V608.175H344.667V604.105H342.896V603.28H345.69ZM354.855 601.3V609H353.832V607.988C353.381 608.714 352.655 609.11 351.841 609.11C350.499 609.11 349.322 608.043 349.322 606.14C349.322 604.237 350.51 603.17 351.841 603.17C352.655 603.17 353.381 603.566 353.832 604.292V601.3H354.855ZM350.356 606.14C350.356 607.504 351.126 608.307 352.094 608.307C353.051 608.307 353.832 607.504 353.832 606.14C353.832 604.776 353.051 603.973 352.094 603.973C351.126 603.973 350.356 604.776 350.356 606.14ZM358.849 606.448C357.826 606.272 356.968 605.898 356.968 604.831C356.968 603.775 357.991 603.17 359.146 603.17C360.213 603.17 361.225 603.731 361.368 604.798H360.345C360.257 604.248 359.773 603.973 359.146 603.973C358.475 603.973 357.991 604.292 357.991 604.82C357.991 605.381 358.475 605.513 358.959 605.612L359.509 605.733C360.741 605.953 361.478 606.382 361.478 607.416C361.478 608.472 360.499 609.11 359.212 609.11C357.991 609.11 357.012 608.516 356.825 607.339H357.848C357.958 608.021 358.53 608.307 359.212 608.307C359.982 608.307 360.455 607.977 360.455 607.427C360.455 606.877 360.037 606.701 359.399 606.558L358.849 606.448Z" fill="#090B0B"/> +<path d="M312.327 495V542" stroke="#2F2D33"/> +<path d="M660.349 495V540" stroke="#2F2D33"/> +<path d="M749.949 400L749.459 398.4H746.669L746.179 400H745.219L747.489 393H748.639L750.909 400H749.949ZM746.899 397.65H749.229L748.069 393.81L746.899 397.65ZM752.93 397.86C752.93 398.9 753.36 399.37 754.1 399.37C754.94 399.37 755.49 398.78 755.49 397.66V394.8H756.42V400H755.49V399.08C755.16 399.75 754.55 400.1 753.77 400.1C752.71 400.1 752 399.45 752 398V394.8H752.93V397.86ZM760.691 398.52C760.691 399.19 761.081 399.25 761.571 399.25H762.531V400H761.321C760.471 400 759.761 399.78 759.761 398.61V395.55H758.121V394.8H759.761V393.6H760.691V394.8H762.531V395.55H760.691V398.52ZM768.122 396.94C768.122 395.9 767.692 395.43 766.952 395.43C766.112 395.43 765.562 396.02 765.562 397.13V400H764.632V393H765.562V395.7C765.902 395.04 766.502 394.7 767.272 394.7C768.332 394.7 769.052 395.35 769.052 396.8V400H768.122V396.94ZM779.075 398.73C778.345 398.73 777.745 398.13 777.745 397.4C777.745 396.67 778.345 396.07 779.075 396.07C779.805 396.07 780.405 396.67 780.405 397.4C780.405 398.13 779.805 398.73 779.075 398.73ZM789.147 398.61C789.147 397.68 789.907 397.06 791.667 396.94L792.597 396.88V396.78C792.597 395.74 792.167 395.43 791.447 395.43C790.777 395.43 790.297 395.79 790.187 396.4H789.287C789.467 395.37 790.367 394.7 791.467 394.7C792.677 394.7 793.527 395.26 793.527 396.95V399.12C793.527 399.48 793.567 399.79 793.627 400H792.737C792.687 399.8 792.667 399.53 792.667 399.24C792.377 399.76 791.857 400.1 790.977 400.1C789.767 400.1 789.147 399.42 789.147 398.61ZM790.077 398.58C790.077 399.03 790.407 399.42 791.197 399.42C792.107 399.42 792.597 398.81 792.597 398.06V397.55L791.847 397.6C790.617 397.69 790.077 397.98 790.077 398.58ZM796.338 397.86C796.338 398.9 796.768 399.37 797.508 399.37C798.348 399.37 798.898 398.78 798.898 397.66V394.8H799.828V400H798.898V399.08C798.568 399.75 797.958 400.1 797.178 400.1C796.118 400.1 795.408 399.45 795.408 398V394.8H796.338V397.86ZM806.169 393V400H805.239V399.08C804.829 399.74 804.169 400.1 803.429 400.1C802.209 400.1 801.139 399.13 801.139 397.4C801.139 395.67 802.219 394.7 803.429 394.7C804.169 394.7 804.829 395.06 805.239 395.72V393H806.169ZM802.079 397.4C802.079 398.64 802.779 399.37 803.659 399.37C804.529 399.37 805.239 398.64 805.239 397.4C805.239 396.16 804.529 395.43 803.659 395.43C802.779 395.43 802.079 396.16 802.079 397.4ZM810.911 394.8V399.25H812.501V400H808.191V399.25H809.981V395.55H808.191V394.8H810.911ZM809.951 394.06V392.91H810.941V394.06H809.951ZM816.502 398.52C816.502 399.19 816.892 399.25 817.382 399.25H818.342V400H817.132C816.282 400 815.572 399.78 815.572 398.61V395.55H813.932V394.8H815.572V393.6H816.502V394.8H818.342V395.55H816.502V398.52Z" fill="#090B0B"/> +<path d="M854 290H720V460H854V290Z" fill="#F1D5F7"/> +<path d="M748.461 311L752.045 301.06H753.963L757.547 311H755.937L755.027 308.41H750.967L750.071 311H748.461ZM751.443 307.038H754.565L752.997 302.502L751.443 307.038ZM758.736 311V301.06H760.248V311H758.736ZM769.97 311.224C769.028 311.224 768.22 311.005 767.548 310.566C766.876 310.118 766.363 309.507 766.008 308.732C765.654 307.948 765.476 307.052 765.476 306.044C765.476 305.045 765.654 304.154 766.008 303.37C766.372 302.586 766.895 301.97 767.576 301.522C768.258 301.065 769.074 300.836 770.026 300.836C770.838 300.836 771.524 300.981 772.084 301.27C772.654 301.559 773.102 301.956 773.428 302.46C773.764 302.964 773.998 303.538 774.128 304.182L772.546 304.266C772.434 303.659 772.182 303.169 771.79 302.796C771.398 302.413 770.81 302.222 770.026 302.222C769.336 302.222 768.771 302.395 768.332 302.74C767.894 303.085 767.567 303.547 767.352 304.126C767.147 304.705 767.044 305.344 767.044 306.044C767.044 306.772 767.147 307.421 767.352 307.99C767.567 308.559 767.894 309.012 768.332 309.348C768.78 309.675 769.35 309.838 770.04 309.838C770.582 309.838 771.048 309.721 771.44 309.488C771.832 309.255 772.136 308.937 772.35 308.536C772.574 308.135 772.696 307.691 772.714 307.206H770.054V305.932H774.156V311H773.12L773.022 308.76L773.218 308.928C773.116 309.376 772.906 309.773 772.588 310.118C772.271 310.463 771.884 310.734 771.426 310.93C770.978 311.126 770.493 311.224 769.97 311.224ZM778.004 311.168C777.23 311.168 776.609 310.991 776.142 310.636C775.685 310.281 775.456 309.782 775.456 309.138C775.456 308.494 775.648 307.995 776.03 307.64C776.422 307.276 777.024 307.015 777.836 306.856L780.398 306.366C780.398 305.787 780.263 305.358 779.992 305.078C779.722 304.789 779.32 304.644 778.788 304.644C778.312 304.644 777.939 304.751 777.668 304.966C777.398 305.171 777.211 305.479 777.108 305.89L775.582 305.792C775.722 305.045 776.068 304.457 776.618 304.028C777.178 303.599 777.902 303.384 778.788 303.384C779.796 303.384 780.562 303.655 781.084 304.196C781.616 304.728 781.882 305.479 781.882 306.45V309.348C781.882 309.525 781.91 309.651 781.966 309.726C782.032 309.791 782.134 309.824 782.274 309.824H782.54V311C782.494 311.009 782.419 311.019 782.316 311.028C782.214 311.037 782.106 311.042 781.994 311.042C781.677 311.042 781.402 310.991 781.168 310.888C780.944 310.785 780.776 310.617 780.664 310.384C780.552 310.141 780.496 309.819 780.496 309.418L780.65 309.488C780.576 309.815 780.412 310.104 780.16 310.356C779.918 310.608 779.605 310.809 779.222 310.958C778.849 311.098 778.443 311.168 778.004 311.168ZM778.242 309.992C778.69 309.992 779.073 309.908 779.39 309.74C779.708 309.563 779.955 309.32 780.132 309.012C780.31 308.704 780.398 308.354 780.398 307.962V307.486L778.214 307.906C777.766 307.99 777.449 308.125 777.262 308.312C777.085 308.489 776.996 308.718 776.996 308.998C776.996 309.315 777.104 309.563 777.318 309.74C777.542 309.908 777.85 309.992 778.242 309.992ZM786.266 311C785.557 311 785.034 310.837 784.698 310.51C784.362 310.183 784.194 309.675 784.194 308.984V301.802H785.678V308.872C785.678 309.217 785.753 309.455 785.902 309.586C786.052 309.717 786.28 309.782 786.588 309.782H787.652V311H786.266ZM783.06 304.77V303.552H787.652V304.77H783.06ZM792.014 311.168C791.286 311.168 790.656 311.009 790.124 310.692C789.601 310.375 789.195 309.922 788.906 309.334C788.626 308.746 788.486 308.06 788.486 307.276C788.486 306.492 788.626 305.811 788.906 305.232C789.195 304.644 789.601 304.191 790.124 303.874C790.646 303.547 791.262 303.384 791.972 303.384C792.644 303.384 793.236 303.543 793.75 303.86C794.263 304.168 794.66 304.616 794.94 305.204C795.229 305.792 795.374 306.501 795.374 307.332V307.71H790.026C790.063 308.438 790.254 308.984 790.6 309.348C790.954 309.712 791.43 309.894 792.028 309.894C792.466 309.894 792.83 309.791 793.12 309.586C793.409 309.381 793.61 309.105 793.722 308.76L795.262 308.858C795.066 309.549 794.678 310.109 794.1 310.538C793.53 310.958 792.835 311.168 792.014 311.168ZM790.026 306.59H793.806C793.759 305.927 793.568 305.437 793.232 305.12C792.905 304.803 792.485 304.644 791.972 304.644C791.44 304.644 791.001 304.812 790.656 305.148C790.32 305.475 790.11 305.955 790.026 306.59ZM798.171 311L795.903 303.552H797.457L799.053 309.376L800.691 303.552H802.091L803.729 309.376L805.339 303.552H806.893L804.625 311H802.959L801.391 305.68L799.823 311H798.171ZM809.996 311.168C809.222 311.168 808.601 310.991 808.134 310.636C807.677 310.281 807.448 309.782 807.448 309.138C807.448 308.494 807.64 307.995 808.022 307.64C808.414 307.276 809.016 307.015 809.828 306.856L812.39 306.366C812.39 305.787 812.255 305.358 811.984 305.078C811.714 304.789 811.312 304.644 810.78 304.644C810.304 304.644 809.931 304.751 809.66 304.966C809.39 305.171 809.203 305.479 809.1 305.89L807.574 305.792C807.714 305.045 808.06 304.457 808.61 304.028C809.17 303.599 809.894 303.384 810.78 303.384C811.788 303.384 812.554 303.655 813.076 304.196C813.608 304.728 813.874 305.479 813.874 306.45V309.348C813.874 309.525 813.902 309.651 813.958 309.726C814.024 309.791 814.126 309.824 814.266 309.824H814.532V311C814.486 311.009 814.411 311.019 814.308 311.028C814.206 311.037 814.098 311.042 813.986 311.042C813.669 311.042 813.394 310.991 813.16 310.888C812.936 310.785 812.768 310.617 812.656 310.384C812.544 310.141 812.488 309.819 812.488 309.418L812.642 309.488C812.568 309.815 812.404 310.104 812.152 310.356C811.91 310.608 811.597 310.809 811.214 310.958C810.841 311.098 810.435 311.168 809.996 311.168ZM810.234 309.992C810.682 309.992 811.065 309.908 811.382 309.74C811.7 309.563 811.947 309.32 812.124 309.012C812.302 308.704 812.39 308.354 812.39 307.962V307.486L810.206 307.906C809.758 307.99 809.441 308.125 809.254 308.312C809.077 308.489 808.988 308.718 808.988 308.998C808.988 309.315 809.096 309.563 809.31 309.74C809.534 309.908 809.842 309.992 810.234 309.992ZM815.864 313.1V311.882H816.774C817.026 311.882 817.212 311.845 817.334 311.77C817.455 311.695 817.548 311.569 817.614 311.392L817.852 310.776H817.39L814.702 303.552H816.27L818.314 309.404L820.26 303.552H821.842L818.874 311.868C818.724 312.297 818.5 312.61 818.202 312.806C817.912 313.002 817.506 313.1 816.984 313.1H815.864Z" fill="#090B0B"/> +<path d="M755.987 330.5C755.987 328.37 756.767 326.9 758.627 326.9C760.047 326.9 760.837 327.75 761.077 329.12H760.147C759.967 328.23 759.487 327.65 758.627 327.65C757.377 327.65 756.917 328.85 756.917 330.5C756.917 332.16 757.377 333.35 758.627 333.35C759.487 333.35 759.967 332.77 760.137 331.9H761.077C760.837 333.26 760.037 334.1 758.627 334.1C756.767 334.1 755.987 332.63 755.987 330.5ZM764.708 334.1C763.368 334.1 762.198 333.14 762.198 331.4C762.198 329.69 763.338 328.7 764.688 328.7C766.348 328.7 767.208 329.91 767.178 331.61H763.138V331.71C763.188 332.8 763.898 333.37 764.708 333.37C765.398 333.37 765.928 333.03 766.138 332.42H767.088C766.778 333.52 765.828 334.1 764.708 334.1ZM763.148 330.93H766.218C766.118 329.95 765.508 329.43 764.678 329.43C763.898 329.43 763.248 329.95 763.148 330.93ZM772.219 330.94C772.219 329.9 771.789 329.43 771.049 329.43C770.209 329.43 769.659 330.02 769.659 331.13V334H768.729V328.8H769.659V329.72C769.989 329.05 770.599 328.7 771.379 328.7C772.439 328.7 773.149 329.35 773.149 330.8V334H772.219V330.94ZM777.3 332.52C777.3 333.19 777.69 333.25 778.18 333.25H779.14V334H777.93C777.08 334 776.37 333.78 776.37 332.61V329.55H774.73V328.8H776.37V327.6H777.3V328.8H779.14V329.55H777.3V332.52ZM783.441 328.8V329.6C783.691 329.05 784.161 328.8 784.941 328.8H785.921V329.55H784.861C783.911 329.55 783.441 329.93 783.441 331.02V333.25H785.221V334H780.901V333.25H782.511V329.55H780.901V328.8H783.441ZM787.153 332.61C787.153 331.68 787.913 331.06 789.673 330.94L790.603 330.88V330.78C790.603 329.74 790.173 329.43 789.453 329.43C788.783 329.43 788.303 329.79 788.193 330.4H787.293C787.473 329.37 788.373 328.7 789.473 328.7C790.683 328.7 791.533 329.26 791.533 330.95V333.12C791.533 333.48 791.573 333.79 791.633 334H790.743C790.693 333.8 790.673 333.53 790.673 333.24C790.383 333.76 789.863 334.1 788.983 334.1C787.773 334.1 787.153 333.42 787.153 332.61ZM788.083 332.58C788.083 333.03 788.413 333.42 789.203 333.42C790.113 333.42 790.603 332.81 790.603 332.06V331.55L789.853 331.6C788.623 331.69 788.083 331.98 788.083 332.58ZM796.384 327V333.25H797.974V334H793.664V333.25H795.454V327.75H793.664V327H796.384ZM802.715 328.8V333.25H804.305V334H799.995V333.25H801.785V329.55H799.995V328.8H802.715ZM801.755 328.06V326.91H802.745V328.06H801.755ZM805.986 333.25L808.986 329.55H806.126V328.8H810.136V329.55L807.136 333.25H810.186V334H805.986V333.25ZM814.317 334.1C812.977 334.1 811.807 333.14 811.807 331.4C811.807 329.69 812.947 328.7 814.297 328.7C815.957 328.7 816.817 329.91 816.787 331.61H812.747V331.71C812.797 332.8 813.507 333.37 814.317 333.37C815.007 333.37 815.537 333.03 815.747 332.42H816.697C816.387 333.52 815.437 334.1 814.317 334.1ZM812.757 330.93H815.827C815.727 329.95 815.117 329.43 814.287 329.43C813.507 329.43 812.857 329.95 812.757 330.93ZM822.778 327V334H821.848V333.08C821.438 333.74 820.778 334.1 820.038 334.1C818.818 334.1 817.748 333.13 817.748 331.4C817.748 329.67 818.828 328.7 820.038 328.7C820.778 328.7 821.438 329.06 821.848 329.72V327H822.778ZM818.688 331.4C818.688 332.64 819.388 333.37 820.268 333.37C821.138 333.37 821.848 332.64 821.848 331.4C821.848 330.16 821.138 329.43 820.268 329.43C819.388 329.43 818.688 330.16 818.688 331.4Z" fill="#090B0B"/> +<path d="M763.068 341H764.008V347.25H767.198V348H763.068V341ZM769.269 341H770.209V347.25H773.399V348H769.269V341ZM776.71 347L775.36 342.35L775.41 343.1V348H774.52V341H775.83L777.08 345.76L778.32 341H779.63V348H778.74V343.1L778.79 342.35L777.44 347H776.71ZM787.122 342.8H788.052V343.73C788.462 343.06 789.122 342.7 789.872 342.7C791.072 342.7 792.152 343.67 792.152 345.4C792.152 347.13 791.082 348.1 789.872 348.1C789.122 348.1 788.462 347.74 788.052 347.07V349.8H787.122V342.8ZM788.052 345.4C788.052 346.64 788.762 347.37 789.632 347.37C790.512 347.37 791.222 346.64 791.222 345.4C791.222 344.16 790.512 343.43 789.632 343.43C788.762 343.43 788.052 344.16 788.052 345.4ZM795.844 342.8V343.6C796.094 343.05 796.564 342.8 797.344 342.8H798.324V343.55H797.264C796.314 343.55 795.844 343.93 795.844 345.02V347.25H797.624V348H793.304V347.25H794.914V343.55H793.304V342.8H795.844ZM801.885 348.1C800.565 348.1 799.405 347.18 799.405 345.4C799.405 343.62 800.565 342.7 801.885 342.7C803.205 342.7 804.375 343.62 804.375 345.4C804.375 347.18 803.205 348.1 801.885 348.1ZM800.335 345.4C800.335 346.72 801.035 347.37 801.885 347.37C802.735 347.37 803.435 346.72 803.435 345.4C803.435 344.08 802.735 343.43 801.885 343.43C801.035 343.43 800.335 344.08 800.335 345.4ZM805.656 348L807.576 345.38L805.676 342.8H806.686L808.076 344.72L809.476 342.8H810.486L808.586 345.38L810.516 348H809.506L808.086 346.04L806.656 348H805.656ZM813.097 349.8L813.807 347.94L811.717 342.8H812.667L814.227 346.83L815.757 342.8H816.697L814.037 349.8H813.097Z" fill="#090B0B"/> +<path d="M757.624 370L756.274 365.35L756.324 366.1V371H755.434V364H756.744L757.994 368.76L759.234 364H760.544V371H759.654V366.1L759.704 365.35L758.354 370H757.624ZM761.705 367.5C761.705 365.37 762.485 363.9 764.345 363.9C765.765 363.9 766.555 364.75 766.795 366.12H765.865C765.685 365.23 765.205 364.65 764.345 364.65C763.095 364.65 762.635 365.85 762.635 367.5C762.635 369.16 763.095 370.35 764.345 370.35C765.205 370.35 765.685 369.77 765.855 368.9H766.795C766.555 370.26 765.755 371.1 764.345 371.1C762.485 371.1 761.705 369.63 761.705 367.5ZM768.466 364H770.216C771.966 364 772.976 364.67 772.976 366.12C772.976 367.57 771.966 368.24 770.216 368.24H769.396V371H768.466V364ZM769.396 367.49H770.216C771.396 367.49 772.046 367.11 772.046 366.12C772.046 365.13 771.396 364.75 770.216 364.75H769.396V367.49ZM782.518 368.68C781.588 368.52 780.808 368.18 780.808 367.21C780.808 366.25 781.738 365.7 782.788 365.7C783.758 365.7 784.678 366.21 784.808 367.18H783.878C783.798 366.68 783.358 366.43 782.788 366.43C782.178 366.43 781.738 366.72 781.738 367.2C781.738 367.71 782.178 367.83 782.618 367.92L783.118 368.03C784.238 368.23 784.908 368.62 784.908 369.56C784.908 370.52 784.018 371.1 782.848 371.1C781.738 371.1 780.848 370.56 780.678 369.49H781.608C781.708 370.11 782.228 370.37 782.848 370.37C783.548 370.37 783.978 370.07 783.978 369.57C783.978 369.07 783.598 368.91 783.018 368.78L782.518 368.68ZM789.029 371.1C787.689 371.1 786.519 370.14 786.519 368.4C786.519 366.69 787.659 365.7 789.009 365.7C790.669 365.7 791.529 366.91 791.499 368.61H787.459V368.71C787.509 369.8 788.219 370.37 789.029 370.37C789.719 370.37 790.249 370.03 790.459 369.42H791.409C791.099 370.52 790.149 371.1 789.029 371.1ZM787.469 367.93H790.539C790.439 366.95 789.829 366.43 788.999 366.43C788.219 366.43 787.569 366.95 787.469 367.93ZM795.361 365.8V366.6C795.611 366.05 796.081 365.8 796.861 365.8H797.841V366.55H796.781C795.831 366.55 795.361 366.93 795.361 368.02V370.25H797.141V371H792.821V370.25H794.431V366.55H792.821V365.8H795.361ZM800.792 371L798.902 365.8H799.842L801.402 370.33L802.962 365.8H803.892L802.002 371H800.792ZM807.633 371.1C806.293 371.1 805.123 370.14 805.123 368.4C805.123 366.69 806.263 365.7 807.613 365.7C809.273 365.7 810.133 366.91 810.103 368.61H806.063V368.71C806.113 369.8 806.823 370.37 807.633 370.37C808.323 370.37 808.853 370.03 809.063 369.42H810.013C809.703 370.52 808.753 371.1 807.633 371.1ZM806.073 367.93H809.143C809.043 366.95 808.433 366.43 807.603 366.43C806.823 366.43 806.173 366.95 806.073 367.93ZM813.964 365.8V366.6C814.214 366.05 814.684 365.8 815.464 365.8H816.444V366.55H815.384C814.434 366.55 813.964 366.93 813.964 368.02V370.25H815.744V371H811.424V370.25H813.034V366.55H811.424V365.8H813.964ZM767.455 379.8V380.6C767.705 380.05 768.175 379.8 768.955 379.8H769.935V380.55H768.875C767.925 380.55 767.455 380.93 767.455 382.02V384.25H769.235V385H764.915V384.25H766.525V380.55H764.915V379.8H767.455ZM773.526 385.1C772.186 385.1 771.016 384.14 771.016 382.4C771.016 380.69 772.156 379.7 773.506 379.7C775.166 379.7 776.026 380.91 775.996 382.61H771.956V382.71C772.006 383.8 772.716 384.37 773.526 384.37C774.216 384.37 774.746 384.03 774.956 383.42H775.906C775.596 384.52 774.646 385.1 773.526 385.1ZM771.966 381.93H775.036C774.936 380.95 774.326 380.43 773.496 380.43C772.716 380.43 772.066 380.95 771.966 381.93ZM779.378 384.84C778.198 384.84 777.138 383.94 777.138 382.27C777.138 380.6 778.198 379.7 779.378 379.7C780.118 379.7 780.738 380.03 781.118 380.67V379.8H782.048V384.06C782.048 386.02 781.148 386.9 779.608 386.9C778.358 386.9 777.498 386.24 777.338 385.18H778.228C778.338 385.81 778.828 386.22 779.608 386.22C780.688 386.22 781.118 385.54 781.118 384.06V383.87C780.738 384.51 780.118 384.84 779.378 384.84ZM778.068 382.27C778.068 383.46 778.738 384.11 779.588 384.11C780.438 384.11 781.118 383.46 781.118 382.27C781.118 381.09 780.438 380.43 779.588 380.43C778.738 380.43 778.068 381.09 778.068 382.27ZM786.729 379.8V384.25H788.319V385H784.009V384.25H785.799V380.55H784.009V379.8H786.729ZM785.769 379.06V377.91H786.759V379.06H785.769ZM791.82 382.68C790.89 382.52 790.11 382.18 790.11 381.21C790.11 380.25 791.04 379.7 792.09 379.7C793.06 379.7 793.98 380.21 794.11 381.18H793.18C793.1 380.68 792.66 380.43 792.09 380.43C791.48 380.43 791.04 380.72 791.04 381.2C791.04 381.71 791.48 381.83 791.92 381.92L792.42 382.03C793.54 382.23 794.21 382.62 794.21 383.56C794.21 384.52 793.32 385.1 792.15 385.1C791.04 385.1 790.15 384.56 789.98 383.49H790.91C791.01 384.11 791.53 384.37 792.15 384.37C792.85 384.37 793.28 384.07 793.28 383.57C793.28 383.07 792.9 382.91 792.32 382.78L791.82 382.68ZM798.521 383.52C798.521 384.19 798.911 384.25 799.401 384.25H800.361V385H799.151C798.301 385 797.591 384.78 797.591 383.61V380.55H795.951V379.8H797.591V378.6H798.521V379.8H800.361V380.55H798.521V383.52ZM804.662 379.8V380.6C804.912 380.05 805.382 379.8 806.162 379.8H807.142V380.55H806.082C805.132 380.55 804.662 380.93 804.662 382.02V384.25H806.442V385H802.122V384.25H803.732V380.55H802.122V379.8H804.662ZM809.514 386.8L810.224 384.94L808.134 379.8H809.084L810.644 383.83L812.174 379.8H813.114L810.454 386.8H809.514Z" fill="#090B0B"/> +<path d="M745.478 410.5C745.478 408.37 746.258 406.9 748.118 406.9C749.538 406.9 750.328 407.75 750.568 409.12H749.638C749.458 408.23 748.978 407.65 748.118 407.65C746.868 407.65 746.408 408.85 746.408 410.5C746.408 412.16 746.868 413.35 748.118 413.35C748.978 413.35 749.458 412.77 749.628 411.9H750.568C750.328 413.26 749.528 414.1 748.118 414.1C746.258 414.1 745.478 412.63 745.478 410.5ZM754.169 414.1C752.849 414.1 751.689 413.18 751.689 411.4C751.689 409.62 752.849 408.7 754.169 408.7C755.489 408.7 756.659 409.62 756.659 411.4C756.659 413.18 755.489 414.1 754.169 414.1ZM752.619 411.4C752.619 412.72 753.319 413.37 754.169 413.37C755.019 413.37 755.719 412.72 755.719 411.4C755.719 410.08 755.019 409.43 754.169 409.43C753.319 409.43 752.619 410.08 752.619 411.4ZM760.091 411.68C759.161 411.52 758.381 411.18 758.381 410.21C758.381 409.25 759.311 408.7 760.361 408.7C761.331 408.7 762.251 409.21 762.381 410.18H761.451C761.371 409.68 760.931 409.43 760.361 409.43C759.751 409.43 759.311 409.72 759.311 410.2C759.311 410.71 759.751 410.83 760.191 410.92L760.691 411.03C761.811 411.23 762.481 411.62 762.481 412.56C762.481 413.52 761.591 414.1 760.421 414.1C759.311 414.1 758.421 413.56 758.251 412.49H759.181C759.281 413.11 759.801 413.37 760.421 413.37C761.121 413.37 761.551 413.07 761.551 412.57C761.551 412.07 761.171 411.91 760.591 411.78L760.091 411.68ZM766.792 412.52C766.792 413.19 767.182 413.25 767.672 413.25H768.632V414H767.422C766.572 414 765.862 413.78 765.862 412.61V409.55H764.222V408.8H765.862V407.6H766.792V408.8H768.632V409.55H766.792V412.52ZM778.974 412.73C778.244 412.73 777.644 412.13 777.644 411.4C777.644 410.67 778.244 410.07 778.974 410.07C779.704 410.07 780.304 410.67 780.304 411.4C780.304 412.13 779.704 412.73 778.974 412.73ZM789.016 408.8H789.946V409.73C790.356 409.06 791.016 408.7 791.766 408.7C792.966 408.7 794.046 409.67 794.046 411.4C794.046 413.13 792.976 414.1 791.766 414.1C791.016 414.1 790.356 413.74 789.946 413.07V415.8H789.016V408.8ZM789.946 411.4C789.946 412.64 790.656 413.37 791.526 413.37C792.406 413.37 793.116 412.64 793.116 411.4C793.116 410.16 792.406 409.43 791.526 409.43C790.656 409.43 789.946 410.16 789.946 411.4ZM797.578 414.1C796.258 414.1 795.098 413.18 795.098 411.4C795.098 409.62 796.258 408.7 797.578 408.7C798.898 408.7 800.068 409.62 800.068 411.4C800.068 413.18 798.898 414.1 797.578 414.1ZM796.028 411.4C796.028 412.72 796.728 413.37 797.578 413.37C798.428 413.37 799.128 412.72 799.128 411.4C799.128 410.08 798.428 409.43 797.578 409.43C796.728 409.43 796.028 410.08 796.028 411.4ZM804.479 407V413.25H806.069V414H801.759V413.25H803.549V407.75H801.759V407H804.479ZM810.81 408.8V413.25H812.4V414H808.09V413.25H809.88V409.55H808.09V408.8H810.81ZM809.85 408.06V406.91H810.84V408.06H809.85ZM813.781 411.4C813.781 409.62 814.941 408.7 816.281 408.7C817.451 408.7 818.351 409.32 818.641 410.48H817.701C817.481 409.8 816.991 409.43 816.281 409.43C815.421 409.43 814.711 410.1 814.711 411.4C814.711 412.7 815.421 413.37 816.281 413.37C816.991 413.37 817.491 413 817.711 412.31H818.641C818.361 413.48 817.451 414.1 816.281 414.1C814.941 414.1 813.781 413.18 813.781 411.4ZM821.192 415.8L821.902 413.94L819.812 408.8H820.762L822.322 412.83L823.852 408.8H824.792L822.132 415.8H821.192Z" fill="#090B0B"/> +<path d="M737.444 433H739.194C740.944 433 741.954 433.67 741.954 435.12C741.954 436.57 740.944 437.24 739.194 437.24H738.374V440H737.444V433ZM738.374 436.49H739.194C740.374 436.49 741.024 436.11 741.024 435.12C741.024 434.13 740.374 433.75 739.194 433.75H738.374V436.49ZM745.605 440.1C744.265 440.1 743.095 439.14 743.095 437.4C743.095 435.69 744.235 434.7 745.585 434.7C747.245 434.7 748.105 435.91 748.075 437.61H744.035V437.71C744.085 438.8 744.795 439.37 745.605 439.37C746.295 439.37 746.825 439.03 747.035 438.42H747.985C747.675 439.52 746.725 440.1 745.605 440.1ZM744.045 436.93H747.115C747.015 435.95 746.405 435.43 745.575 435.43C744.795 435.43 744.145 435.95 744.045 436.93ZM751.936 434.8V435.6C752.186 435.05 752.656 434.8 753.436 434.8H754.416V435.55H753.356C752.406 435.55 751.936 435.93 751.936 437.02V439.25H753.716V440H749.396V439.25H751.006V435.55H749.396V434.8H751.936ZM756.728 437.02H759.228V437.77H756.728V437.02ZM762.839 437.86C762.839 438.9 763.269 439.37 764.009 439.37C764.849 439.37 765.399 438.78 765.399 437.66V434.8H766.329V440H765.399V439.08C765.069 439.75 764.459 440.1 763.679 440.1C762.619 440.1 761.909 439.45 761.909 438V434.8H762.839V437.86ZM770.1 437.68C769.17 437.52 768.39 437.18 768.39 436.21C768.39 435.25 769.32 434.7 770.37 434.7C771.34 434.7 772.26 435.21 772.39 436.18H771.46C771.38 435.68 770.94 435.43 770.37 435.43C769.76 435.43 769.32 435.72 769.32 436.2C769.32 436.71 769.76 436.83 770.2 436.92L770.7 437.03C771.82 437.23 772.49 437.62 772.49 438.56C772.49 439.52 771.6 440.1 770.43 440.1C769.32 440.1 768.43 439.56 768.26 438.49H769.19C769.29 439.11 769.81 439.37 770.43 439.37C771.13 439.37 771.56 439.07 771.56 438.57C771.56 438.07 771.18 437.91 770.6 437.78L770.1 437.68ZM776.611 440.1C775.271 440.1 774.101 439.14 774.101 437.4C774.101 435.69 775.241 434.7 776.591 434.7C778.251 434.7 779.111 435.91 779.081 437.61H775.041V437.71C775.091 438.8 775.801 439.37 776.611 439.37C777.301 439.37 777.831 439.03 778.041 438.42H778.991C778.681 439.52 777.731 440.1 776.611 440.1ZM775.051 436.93H778.121C778.021 435.95 777.411 435.43 776.581 435.43C775.801 435.43 775.151 435.95 775.051 436.93ZM782.942 434.8V435.6C783.192 435.05 783.662 434.8 784.442 434.8H785.422V435.55H784.362C783.412 435.55 782.942 435.93 782.942 437.02V439.25H784.722V440H780.402V439.25H782.012V435.55H780.402V434.8H782.942ZM795.405 438.52C795.405 439.19 795.795 439.25 796.285 439.25H797.245V440H796.035C795.185 440 794.475 439.78 794.475 438.61V435.55H792.835V434.8H794.475V433.6H795.405V434.8H797.245V435.55H795.405V438.52ZM801.546 434.8V435.6C801.796 435.05 802.266 434.8 803.046 434.8H804.026V435.55H802.966C802.016 435.55 801.546 435.93 801.546 437.02V439.25H803.326V440H799.006V439.25H800.616V435.55H799.006V434.8H801.546ZM805.257 438.61C805.257 437.68 806.017 437.06 807.777 436.94L808.707 436.88V436.78C808.707 435.74 808.277 435.43 807.557 435.43C806.887 435.43 806.407 435.79 806.297 436.4H805.397C805.577 435.37 806.477 434.7 807.577 434.7C808.787 434.7 809.637 435.26 809.637 436.95V439.12C809.637 439.48 809.677 439.79 809.737 440H808.847C808.797 439.8 808.777 439.53 808.777 439.24C808.487 439.76 807.967 440.1 807.087 440.1C805.877 440.1 805.257 439.42 805.257 438.61ZM806.187 438.58C806.187 439.03 806.517 439.42 807.307 439.42C808.217 439.42 808.707 438.81 808.707 438.06V437.55L807.957 437.6C806.727 437.69 806.187 437.98 806.187 438.58ZM811.388 437.4C811.388 435.62 812.548 434.7 813.888 434.7C815.058 434.7 815.958 435.32 816.248 436.48H815.308C815.088 435.8 814.598 435.43 813.888 435.43C813.028 435.43 812.318 436.1 812.318 437.4C812.318 438.7 813.028 439.37 813.888 439.37C814.598 439.37 815.098 439 815.318 438.31H816.248C815.968 439.48 815.058 440.1 813.888 440.1C812.548 440.1 811.388 439.18 811.388 437.4ZM818.009 440V433H818.939V437.41L821.359 434.8H822.489L820.379 437.01L822.489 440H821.419L819.779 437.64L818.939 438.52V440H818.009ZM827.021 434.8V439.25H828.611V440H824.301V439.25H826.091V435.55H824.301V434.8H827.021ZM826.061 434.06V432.91H827.051V434.06H826.061ZM833.732 436.94C833.732 435.9 833.302 435.43 832.562 435.43C831.722 435.43 831.172 436.02 831.172 437.13V440H830.242V434.8H831.172V435.72C831.502 435.05 832.112 434.7 832.892 434.7C833.952 434.7 834.662 435.35 834.662 436.8V440H833.732V436.94ZM838.273 439.84C837.093 439.84 836.033 438.94 836.033 437.27C836.033 435.6 837.093 434.7 838.273 434.7C839.013 434.7 839.633 435.03 840.013 435.67V434.8H840.943V439.06C840.943 441.02 840.043 441.9 838.503 441.9C837.253 441.9 836.393 441.24 836.233 440.18H837.123C837.233 440.81 837.723 441.22 838.503 441.22C839.583 441.22 840.013 440.54 840.013 439.06V438.87C839.633 439.51 839.013 439.84 838.273 439.84ZM836.963 437.27C836.963 438.46 837.633 439.11 838.483 439.11C839.333 439.11 840.013 438.46 840.013 437.27C840.013 436.09 839.333 435.43 838.483 435.43C837.633 435.43 836.963 436.09 836.963 437.27Z" fill="#090B0B"/> +<path d="M851.199 280H750.808C746.496 280 743 283.134 743 287C743 290.866 746.496 294 750.808 294H851.199C855.511 294 859.007 290.866 859.007 287C859.007 283.134 855.511 280 851.199 280Z" fill="#9900B1"/> +<path d="M772.741 290L772.349 288.72H770.117L769.725 290H768.957L770.773 284.4H771.693L773.509 290H772.741ZM770.301 288.12H772.165L771.237 285.048L770.301 288.12ZM778.006 284.4V285H776.574V289.4H778.006V290H774.39V289.4H775.822V285H774.39V284.4H778.006ZM787.528 290V289.008C787.296 289.664 786.824 290.08 786.04 290.08C784.656 290.08 784.024 288.904 784.024 287.2C784.024 285.496 784.648 284.32 786.128 284.32C787.272 284.32 787.904 285 788.088 286.096H787.352C787.208 285.384 786.816 284.92 786.128 284.92C785.136 284.92 784.768 285.88 784.768 287.2C784.768 288.528 785.136 289.48 786.128 289.48C786.968 289.48 787.344 288.736 787.352 287.704H786.104V287.104H788.096V290H787.528ZM791.081 290.08C789.593 290.08 788.969 288.904 788.969 287.2C788.969 285.496 789.593 284.32 791.081 284.32C792.569 284.32 793.193 285.496 793.193 287.2C793.193 288.904 792.569 290.08 791.081 290.08ZM789.721 287.2C789.721 288.528 790.081 289.48 791.081 289.48C792.081 289.48 792.449 288.528 792.449 287.2C792.449 285.88 792.081 284.92 791.081 284.92C790.081 284.92 789.721 285.88 789.721 287.2ZM797.546 284.4H798.314L796.498 290H795.578L793.762 284.4H794.53L796.042 289.352L797.546 284.4ZM799.403 284.4H802.747V285H800.147V286.856H802.603V287.456H800.147V289.4H802.787V290H799.403V284.4ZM804.324 290V284.4H805.772C807.06 284.4 807.94 284.888 807.94 286.048C807.94 286.912 807.46 287.4 806.692 287.592L808.092 290H807.252L805.94 287.696H805.772H805.076V290H804.324ZM805.076 287.096H805.772C806.604 287.096 807.196 286.84 807.196 286.048C807.196 285.256 806.604 285 805.772 285H805.076V287.096ZM811.869 290L809.725 285.456V290H808.981V284.4H809.973L812.125 289.008V284.4H812.869V290H811.869ZM817.39 290L816.998 288.72H814.766L814.374 290H813.606L815.422 284.4H816.342L818.158 290H817.39ZM814.95 288.12H816.814L815.886 285.048L814.95 288.12ZM821.791 290L819.647 285.456V290H818.903V284.4H819.895L822.047 289.008V284.4H822.791V290H821.791ZM823.816 287.2C823.816 285.496 824.44 284.32 825.928 284.32C827.064 284.32 827.696 285 827.888 286.096H827.144C827 285.384 826.616 284.92 825.928 284.92C824.928 284.92 824.56 285.88 824.56 287.2C824.56 288.528 824.928 289.48 825.928 289.48C826.616 289.48 827 289.016 827.136 288.32H827.888C827.696 289.408 827.056 290.08 825.928 290.08C824.44 290.08 823.816 288.904 823.816 287.2ZM829.169 284.4H832.513V285H829.913V286.856H832.369V287.456H829.913V289.4H832.553V290H829.169V284.4Z" fill="#FBF8F8"/> +<path d="M251.478 680L250.939 678.24H247.87L247.331 680H246.275L248.772 672.3H250.037L252.534 680H251.478ZM248.123 677.415H250.686L249.41 673.191L248.123 677.415ZM257.001 672.3V679.175H258.75V680H254.009V679.175H255.978V673.125H254.009V672.3H257.001ZM263.823 672.3V679.175H265.572V680H260.831V679.175H262.8V673.125H260.831V672.3H263.823ZM274.924 672.3H275.958V679.175H279.467V680H274.924V672.3ZM281.745 672.3H282.779V679.175H286.288V680H281.745V672.3ZM289.931 678.9L288.446 673.785L288.501 674.61V680H287.522V672.3H288.963L290.338 677.536L291.702 672.3H293.143V680H292.164V674.61L292.219 673.785L290.734 678.9H289.931ZM304.222 678.372C304.222 679.109 304.651 679.175 305.19 679.175H306.246V680H304.915C303.98 680 303.199 679.758 303.199 678.471V675.105H301.395V674.28H303.199V672.96H304.222V674.28H306.246V675.105H304.222V678.372ZM310.978 674.28V675.16C311.253 674.555 311.77 674.28 312.628 674.28H313.706V675.105H312.54C311.495 675.105 310.978 675.523 310.978 676.722V679.175H312.936V680H308.184V679.175H309.955V675.105H308.184V674.28H310.978ZM315.06 678.471C315.06 677.448 315.896 676.766 317.832 676.634L318.855 676.568V676.458C318.855 675.314 318.382 674.973 317.59 674.973C316.853 674.973 316.325 675.369 316.204 676.04H315.214C315.412 674.907 316.402 674.17 317.612 674.17C318.943 674.17 319.878 674.786 319.878 676.645V679.032C319.878 679.428 319.922 679.769 319.988 680H319.009C318.954 679.78 318.932 679.483 318.932 679.164C318.613 679.736 318.041 680.11 317.073 680.11C315.742 680.11 315.06 679.362 315.06 678.471ZM316.083 678.438C316.083 678.933 316.446 679.362 317.315 679.362C318.316 679.362 318.855 678.691 318.855 677.866V677.305L318.03 677.36C316.677 677.459 316.083 677.778 316.083 678.438ZM324.851 673.829V674.28H326.875V675.105H324.851V679.175H326.765V680H322.013V679.175H323.817V675.105H322.013V674.28H323.817V673.829C323.817 672.542 324.609 672.3 325.544 672.3H326.875V673.026H325.808C325.28 673.026 324.851 673.092 324.851 673.829ZM331.672 673.829V674.28H333.696V675.105H331.672V679.175H333.586V680H328.834V679.175H330.638V675.105H328.834V674.28H330.638V673.829C330.638 672.542 331.43 672.3 332.365 672.3H333.696V673.026H332.629C332.101 673.026 331.672 673.092 331.672 673.829ZM339 674.28V679.175H340.749V680H336.008V679.175H337.977V675.105H336.008V674.28H339ZM337.944 673.466V672.201H339.033V673.466H337.944ZM342.268 677.14C342.268 675.182 343.544 674.17 345.018 674.17C346.305 674.17 347.295 674.852 347.614 676.128H346.58C346.338 675.38 345.799 674.973 345.018 674.973C344.072 674.973 343.291 675.71 343.291 677.14C343.291 678.57 344.072 679.307 345.018 679.307C345.799 679.307 346.349 678.9 346.591 678.141H347.614C347.306 679.428 346.305 680.11 345.018 680.11C343.544 680.11 342.268 679.098 342.268 677.14ZM359.464 674.28V679.175H361.213V680H356.472V679.175H358.441V675.105H356.472V674.28H359.464ZM358.408 673.466V672.201H359.497V673.466H358.408ZM365.064 677.448C364.041 677.272 363.183 676.898 363.183 675.831C363.183 674.775 364.206 674.17 365.361 674.17C366.428 674.17 367.44 674.731 367.583 675.798H366.56C366.472 675.248 365.988 674.973 365.361 674.973C364.69 674.973 364.206 675.292 364.206 675.82C364.206 676.381 364.69 676.513 365.174 676.612L365.724 676.733C366.956 676.953 367.693 677.382 367.693 678.416C367.693 679.472 366.714 680.11 365.427 680.11C364.206 680.11 363.227 679.516 363.04 678.339H364.063C364.173 679.021 364.745 679.307 365.427 679.307C366.197 679.307 366.67 678.977 366.67 678.427C366.67 677.877 366.252 677.701 365.614 677.558L365.064 677.448ZM376.418 674.28H377.441V675.303C377.892 674.566 378.618 674.17 379.443 674.17C380.763 674.17 381.951 675.237 381.951 677.14C381.951 679.043 380.774 680.11 379.443 680.11C378.618 680.11 377.892 679.714 377.441 678.977V681.98H376.418V674.28ZM377.441 677.14C377.441 678.504 378.222 679.307 379.179 679.307C380.147 679.307 380.928 678.504 380.928 677.14C380.928 675.776 380.147 674.973 379.179 674.973C378.222 674.973 377.441 675.776 377.441 677.14ZM386.012 674.28V675.16C386.287 674.555 386.804 674.28 387.662 674.28H388.74V675.105H387.574C386.529 675.105 386.012 675.523 386.012 676.722V679.175H387.97V680H383.218V679.175H384.989V675.105H383.218V674.28H386.012ZM392.657 680.11C391.205 680.11 389.929 679.098 389.929 677.14C389.929 675.182 391.205 674.17 392.657 674.17C394.109 674.17 395.396 675.182 395.396 677.14C395.396 679.098 394.109 680.11 392.657 680.11ZM390.952 677.14C390.952 678.592 391.722 679.307 392.657 679.307C393.592 679.307 394.362 678.592 394.362 677.14C394.362 675.688 393.592 674.973 392.657 674.973C391.722 674.973 390.952 675.688 390.952 677.14ZM396.805 680L398.917 677.118L396.827 674.28H397.938L399.467 676.392L401.007 674.28H402.118L400.028 677.118L402.151 680H401.04L399.478 677.844L397.905 680H396.805ZM407.213 674.28V679.175H408.962V680H404.221V679.175H406.19V675.105H404.221V674.28H407.213ZM406.157 673.466V672.201H407.246V673.466H406.157ZM413.154 680.11C411.68 680.11 410.393 679.054 410.393 677.14C410.393 675.259 411.647 674.17 413.132 674.17C414.958 674.17 415.904 675.501 415.871 677.371H411.427V677.481C411.482 678.68 412.263 679.307 413.154 679.307C413.913 679.307 414.496 678.933 414.727 678.262H415.772C415.431 679.472 414.386 680.11 413.154 680.11ZM411.438 676.623H414.815C414.705 675.545 414.034 674.973 413.121 674.973C412.263 674.973 411.548 675.545 411.438 676.623ZM422.461 672.3V680H421.438V678.988C420.987 679.714 420.261 680.11 419.447 680.11C418.105 680.11 416.928 679.043 416.928 677.14C416.928 675.237 418.116 674.17 419.447 674.17C420.261 674.17 420.987 674.566 421.438 675.292V672.3H422.461ZM417.962 677.14C417.962 678.504 418.732 679.307 419.7 679.307C420.657 679.307 421.438 678.504 421.438 677.14C421.438 675.776 420.657 674.973 419.7 674.973C418.732 674.973 417.962 675.776 417.962 677.14ZM433.827 678.372C433.827 679.109 434.256 679.175 434.795 679.175H435.851V680H434.52C433.585 680 432.804 679.758 432.804 678.471V675.105H431V674.28H432.804V672.96H433.827V674.28H435.851V675.105H433.827V678.372ZM442.001 676.634C442.001 675.49 441.528 674.973 440.714 674.973C439.79 674.973 439.185 675.622 439.185 676.843V680H438.162V672.3H439.185V675.27C439.559 674.544 440.219 674.17 441.066 674.17C442.232 674.17 443.024 674.885 443.024 676.48V680H442.001V676.634ZM447.403 674.28V675.16C447.678 674.555 448.195 674.28 449.053 674.28H450.131V675.105H448.965C447.92 675.105 447.403 675.523 447.403 676.722V679.175H449.361V680H444.609V679.175H446.38V675.105H444.609V674.28H447.403ZM454.049 680.11C452.597 680.11 451.321 679.098 451.321 677.14C451.321 675.182 452.597 674.17 454.049 674.17C455.501 674.17 456.788 675.182 456.788 677.14C456.788 679.098 455.501 680.11 454.049 680.11ZM452.344 677.14C452.344 678.592 453.114 679.307 454.049 679.307C454.984 679.307 455.754 678.592 455.754 677.14C455.754 675.688 454.984 674.973 454.049 674.973C453.114 674.973 452.344 675.688 452.344 677.14ZM459.396 677.646C459.396 678.79 459.869 679.307 460.683 679.307C461.607 679.307 462.212 678.658 462.212 677.426V674.28H463.235V680H462.212V678.988C461.849 679.725 461.178 680.11 460.32 680.11C459.154 680.11 458.373 679.395 458.373 677.8V674.28H459.396V677.646ZM467.339 679.824C466.041 679.824 464.875 678.834 464.875 676.997C464.875 675.16 466.041 674.17 467.339 674.17C468.153 674.17 468.835 674.533 469.253 675.237V674.28H470.276V678.966C470.276 681.122 469.286 682.09 467.592 682.09C466.217 682.09 465.271 681.364 465.095 680.198H466.074C466.195 680.891 466.734 681.342 467.592 681.342C468.78 681.342 469.253 680.594 469.253 678.966V678.757C468.835 679.461 468.153 679.824 467.339 679.824ZM465.898 676.997C465.898 678.306 466.635 679.021 467.57 679.021C468.505 679.021 469.253 678.306 469.253 676.997C469.253 675.699 468.505 674.973 467.57 674.973C466.635 674.973 465.898 675.699 465.898 676.997ZM476.108 676.634C476.108 675.49 475.635 674.973 474.821 674.973C473.897 674.973 473.292 675.622 473.292 676.843V680H472.269V672.3H473.292V675.27C473.666 674.544 474.326 674.17 475.173 674.17C476.339 674.17 477.131 674.885 477.131 676.48V680H476.108V676.634ZM488.397 678.372C488.397 679.109 488.826 679.175 489.365 679.175H490.421V680H489.09C488.155 680 487.374 679.758 487.374 678.471V675.105H485.57V674.28H487.374V672.96H488.397V674.28H490.421V675.105H488.397V678.372ZM496.571 676.634C496.571 675.49 496.098 674.973 495.284 674.973C494.36 674.973 493.755 675.622 493.755 676.843V680H492.732V672.3H493.755V675.27C494.129 674.544 494.789 674.17 495.636 674.17C496.802 674.17 497.594 674.885 497.594 676.48V680H496.571V676.634ZM501.831 680.11C500.357 680.11 499.07 679.054 499.07 677.14C499.07 675.259 500.324 674.17 501.809 674.17C503.635 674.17 504.581 675.501 504.548 677.371H500.104V677.481C500.159 678.68 500.94 679.307 501.831 679.307C502.59 679.307 503.173 678.933 503.404 678.262H504.449C504.108 679.472 503.063 680.11 501.831 680.11ZM500.115 676.623H503.492C503.382 675.545 502.711 674.973 501.798 674.973C500.94 674.973 500.225 675.545 500.115 676.623ZM517.508 680L516.969 678.24H513.9L513.361 680H512.305L514.802 672.3H516.067L518.564 680H517.508ZM514.153 677.415H516.716L515.44 673.191L514.153 677.415ZM524.748 672.3V673.125H522.779V679.175H524.748V680H519.776V679.175H521.745V673.125H519.776V672.3H524.748ZM537.84 680V678.636C537.521 679.538 536.872 680.11 535.794 680.11C533.891 680.11 533.022 678.493 533.022 676.15C533.022 673.807 533.88 672.19 535.915 672.19C537.488 672.19 538.357 673.125 538.61 674.632H537.598C537.4 673.653 536.861 673.015 535.915 673.015C534.551 673.015 534.045 674.335 534.045 676.15C534.045 677.976 534.551 679.285 535.915 679.285C537.07 679.285 537.587 678.262 537.598 676.843H535.882V676.018H538.621V680H537.84ZM540.162 678.471C540.162 677.448 540.998 676.766 542.934 676.634L543.957 676.568V676.458C543.957 675.314 543.484 674.973 542.692 674.973C541.955 674.973 541.427 675.369 541.306 676.04H540.316C540.514 674.907 541.504 674.17 542.714 674.17C544.045 674.17 544.98 674.786 544.98 676.645V679.032C544.98 679.428 545.024 679.769 545.09 680H544.111C544.056 679.78 544.034 679.483 544.034 679.164C543.715 679.736 543.143 680.11 542.175 680.11C540.844 680.11 540.162 679.362 540.162 678.471ZM541.185 678.438C541.185 678.933 541.548 679.362 542.417 679.362C543.418 679.362 543.957 678.691 543.957 677.866V677.305L543.132 677.36C541.779 677.459 541.185 677.778 541.185 678.438ZM549.789 678.372C549.789 679.109 550.218 679.175 550.757 679.175H551.813V680H550.482C549.547 680 548.766 679.758 548.766 678.471V675.105H546.962V674.28H548.766V672.96H549.789V674.28H551.813V675.105H549.789V678.372ZM556.401 680.11C554.927 680.11 553.64 679.054 553.64 677.14C553.64 675.259 554.894 674.17 556.379 674.17C558.205 674.17 559.151 675.501 559.118 677.371H554.674V677.481C554.729 678.68 555.51 679.307 556.401 679.307C557.16 679.307 557.743 678.933 557.974 678.262H559.019C558.678 679.472 557.633 680.11 556.401 680.11ZM554.685 676.623H558.062C557.952 675.545 557.281 674.973 556.368 674.973C555.51 674.973 554.795 675.545 554.685 676.623ZM560.131 674.28H561.099L561.902 678.647V678.867L562.793 674.28H563.585L564.476 678.867V678.647L565.279 674.28H566.247L565.103 680H563.893L563.189 676.15L562.485 680H561.275L560.131 674.28ZM567.448 678.471C567.448 677.448 568.284 676.766 570.22 676.634L571.243 676.568V676.458C571.243 675.314 570.77 674.973 569.978 674.973C569.241 674.973 568.713 675.369 568.592 676.04H567.602C567.8 674.907 568.79 674.17 570 674.17C571.331 674.17 572.266 674.786 572.266 676.645V679.032C572.266 679.428 572.31 679.769 572.376 680H571.397C571.342 679.78 571.32 679.483 571.32 679.164C571.001 679.736 570.429 680.11 569.461 680.11C568.13 680.11 567.448 679.362 567.448 678.471ZM568.471 678.438C568.471 678.933 568.834 679.362 569.703 679.362C570.704 679.362 571.243 678.691 571.243 677.866V677.305L570.418 677.36C569.065 677.459 568.471 677.778 568.471 678.438ZM575.523 681.98L576.304 679.934L574.005 674.28H575.05L576.766 678.713L578.449 674.28H579.483L576.557 681.98H575.523ZM587.713 676.722H593.235V677.547H587.713V676.722ZM601.477 677.14C601.477 675.182 602.753 674.17 604.227 674.17C605.514 674.17 606.504 674.852 606.823 676.128H605.789C605.547 675.38 605.008 674.973 604.227 674.973C603.281 674.973 602.5 675.71 602.5 677.14C602.5 678.57 603.281 679.307 604.227 679.307C605.008 679.307 605.558 678.9 605.8 678.141H606.823C606.515 679.428 605.514 680.11 604.227 680.11C602.753 680.11 601.477 679.098 601.477 677.14ZM610.971 680.11C609.497 680.11 608.21 679.054 608.21 677.14C608.21 675.259 609.464 674.17 610.949 674.17C612.775 674.17 613.721 675.501 613.688 677.371H609.244V677.481C609.299 678.68 610.08 679.307 610.971 679.307C611.73 679.307 612.313 678.933 612.544 678.262H613.589C613.248 679.472 612.203 680.11 610.971 680.11ZM609.255 676.623H612.632C612.522 675.545 611.851 674.973 610.938 674.973C610.08 674.973 609.365 675.545 609.255 676.623ZM619.234 676.634C619.234 675.49 618.761 674.973 617.947 674.973C617.023 674.973 616.418 675.622 616.418 676.843V680H615.395V674.28H616.418V675.292C616.781 674.555 617.452 674.17 618.31 674.17C619.476 674.17 620.257 674.885 620.257 676.48V680H619.234V676.634ZM624.823 678.372C624.823 679.109 625.252 679.175 625.791 679.175H626.847V680H625.516C624.581 680 623.8 679.758 623.8 678.471V675.105H621.996V674.28H623.8V672.96H624.823V674.28H626.847V675.105H624.823V678.372ZM631.578 674.28V675.16C631.853 674.555 632.37 674.28 633.228 674.28H634.306V675.105H633.14C632.095 675.105 631.578 675.523 631.578 676.722V679.175H633.536V680H628.784V679.175H630.555V675.105H628.784V674.28H631.578ZM635.66 678.471C635.66 677.448 636.496 676.766 638.432 676.634L639.455 676.568V676.458C639.455 675.314 638.982 674.973 638.19 674.973C637.453 674.973 636.925 675.369 636.804 676.04H635.814C636.012 674.907 637.002 674.17 638.212 674.17C639.543 674.17 640.478 674.786 640.478 676.645V679.032C640.478 679.428 640.522 679.769 640.588 680H639.609C639.554 679.78 639.532 679.483 639.532 679.164C639.213 679.736 638.641 680.11 637.673 680.11C636.342 680.11 635.66 679.362 635.66 678.471ZM636.683 678.438C636.683 678.933 637.046 679.362 637.915 679.362C638.916 679.362 639.455 678.691 639.455 677.866V677.305L638.63 677.36C637.277 677.459 636.683 677.778 636.683 678.438ZM645.815 672.3V679.175H647.564V680H642.823V679.175H644.792V673.125H642.823V672.3H645.815ZM652.779 674.28V679.175H654.528V680H649.787V679.175H651.756V675.105H649.787V674.28H652.779ZM651.723 673.466V672.201H652.812V673.466H651.723ZM656.377 679.175L659.677 675.105H656.531V674.28H660.942V675.105L657.642 679.175H660.997V680H656.377V679.175ZM665.542 680.11C664.068 680.11 662.781 679.054 662.781 677.14C662.781 675.259 664.035 674.17 665.52 674.17C667.346 674.17 668.292 675.501 668.259 677.371H663.815V677.481C663.87 678.68 664.651 679.307 665.542 679.307C666.301 679.307 666.884 678.933 667.115 678.262H668.16C667.819 679.472 666.774 680.11 665.542 680.11ZM663.826 676.623H667.203C667.093 675.545 666.422 674.973 665.509 674.973C664.651 674.973 663.936 675.545 663.826 676.623ZM674.849 672.3V680H673.826V678.988C673.375 679.714 672.649 680.11 671.835 680.11C670.493 680.11 669.316 679.043 669.316 677.14C669.316 675.237 670.504 674.17 671.835 674.17C672.649 674.17 673.375 674.566 673.826 675.292V672.3H674.849ZM670.35 677.14C670.35 678.504 671.12 679.307 672.088 679.307C673.045 679.307 673.826 678.504 673.826 677.14C673.826 675.776 673.045 674.973 672.088 674.973C671.12 674.973 670.35 675.776 670.35 677.14ZM683.41 678.471C683.41 677.448 684.246 676.766 686.182 676.634L687.205 676.568V676.458C687.205 675.314 686.732 674.973 685.94 674.973C685.203 674.973 684.675 675.369 684.554 676.04H683.564C683.762 674.907 684.752 674.17 685.962 674.17C687.293 674.17 688.228 674.786 688.228 676.645V679.032C688.228 679.428 688.272 679.769 688.338 680H687.359C687.304 679.78 687.282 679.483 687.282 679.164C686.963 679.736 686.391 680.11 685.423 680.11C684.092 680.11 683.41 679.362 683.41 678.471ZM684.433 678.438C684.433 678.933 684.796 679.362 685.665 679.362C686.666 679.362 687.205 678.691 687.205 677.866V677.305L686.38 677.36C685.027 677.459 684.433 677.778 684.433 678.438ZM691.32 677.646C691.32 678.79 691.793 679.307 692.607 679.307C693.531 679.307 694.136 678.658 694.136 677.426V674.28H695.159V680H694.136V678.988C693.773 679.725 693.102 680.11 692.244 680.11C691.078 680.11 690.297 679.395 690.297 677.8V674.28H691.32V677.646ZM699.857 678.372C699.857 679.109 700.286 679.175 700.825 679.175H701.881V680H700.55C699.615 680 698.834 679.758 698.834 678.471V675.105H697.03V674.28H698.834V672.96H699.857V674.28H701.881V675.105H699.857V678.372ZM708.031 676.634C708.031 675.49 707.558 674.973 706.744 674.973C705.82 674.973 705.215 675.622 705.215 676.843V680H704.192V672.3H705.215V675.27C705.589 674.544 706.249 674.17 707.096 674.17C708.262 674.17 709.054 674.885 709.054 676.48V680H708.031V676.634ZM712.73 680V678.735H713.819V680L713.368 681.364H712.796L713.181 680H712.73ZM724.337 678.471C724.337 677.448 725.173 676.766 727.109 676.634L728.132 676.568V676.458C728.132 675.314 727.659 674.973 726.867 674.973C726.13 674.973 725.602 675.369 725.481 676.04H724.491C724.689 674.907 725.679 674.17 726.889 674.17C728.22 674.17 729.155 674.786 729.155 676.645V679.032C729.155 679.428 729.199 679.769 729.265 680H728.286C728.231 679.78 728.209 679.483 728.209 679.164C727.89 679.736 727.318 680.11 726.35 680.11C725.019 680.11 724.337 679.362 724.337 678.471ZM725.36 678.438C725.36 678.933 725.723 679.362 726.592 679.362C727.593 679.362 728.132 678.691 728.132 677.866V677.305L727.307 677.36C725.954 677.459 725.36 677.778 725.36 678.438ZM732.248 677.646C732.248 678.79 732.721 679.307 733.535 679.307C734.459 679.307 735.064 678.658 735.064 677.426V674.28H736.087V680H735.064V678.988C734.701 679.725 734.03 680.11 733.172 680.11C732.006 680.11 731.225 679.395 731.225 677.8V674.28H732.248V677.646ZM743.062 672.3V680H742.039V678.988C741.588 679.714 740.862 680.11 740.048 680.11C738.706 680.11 737.529 679.043 737.529 677.14C737.529 675.237 738.717 674.17 740.048 674.17C740.862 674.17 741.588 674.566 742.039 675.292V672.3H743.062ZM738.563 677.14C738.563 678.504 739.333 679.307 740.301 679.307C741.258 679.307 742.039 678.504 742.039 677.14C742.039 675.776 741.258 674.973 740.301 674.973C739.333 674.973 738.563 675.776 738.563 677.14ZM748.277 674.28V679.175H750.026V680H745.285V679.175H747.254V675.105H745.285V674.28H748.277ZM747.221 673.466V672.201H748.31V673.466H747.221ZM754.427 678.372C754.427 679.109 754.856 679.175 755.395 679.175H756.451V680H755.12C754.185 680 753.404 679.758 753.404 678.471V675.105H751.6V674.28H753.404V672.96H754.427V674.28H756.451V675.105H754.427V678.372ZM760.479 680V678.735H761.568V680L761.117 681.364H760.545L760.93 680H760.479ZM772.009 677.14C772.009 675.182 773.285 674.17 774.759 674.17C776.046 674.17 777.036 674.852 777.355 676.128H776.321C776.079 675.38 775.54 674.973 774.759 674.973C773.813 674.973 773.032 675.71 773.032 677.14C773.032 678.57 773.813 679.307 774.759 679.307C775.54 679.307 776.09 678.9 776.332 678.141H777.355C777.047 679.428 776.046 680.11 774.759 680.11C773.285 680.11 772.009 679.098 772.009 677.14ZM781.471 680.11C780.019 680.11 778.743 679.098 778.743 677.14C778.743 675.182 780.019 674.17 781.471 674.17C782.923 674.17 784.21 675.182 784.21 677.14C784.21 679.098 782.923 680.11 781.471 680.11ZM779.766 677.14C779.766 678.592 780.536 679.307 781.471 679.307C782.406 679.307 783.176 678.592 783.176 677.14C783.176 675.688 782.406 674.973 781.471 674.973C780.536 674.973 779.766 675.688 779.766 677.14ZM787.984 677.448C786.961 677.272 786.103 676.898 786.103 675.831C786.103 674.775 787.126 674.17 788.281 674.17C789.348 674.17 790.36 674.731 790.503 675.798H789.48C789.392 675.248 788.908 674.973 788.281 674.973C787.61 674.973 787.126 675.292 787.126 675.82C787.126 676.381 787.61 676.513 788.094 676.612L788.644 676.733C789.876 676.953 790.613 677.382 790.613 678.416C790.613 679.472 789.634 680.11 788.347 680.11C787.126 680.11 786.147 679.516 785.96 678.339H786.983C787.093 679.021 787.665 679.307 788.347 679.307C789.117 679.307 789.59 678.977 789.59 678.427C789.59 677.877 789.172 677.701 788.534 677.558L787.984 677.448ZM795.355 678.372C795.355 679.109 795.784 679.175 796.323 679.175H797.379V680H796.048C795.113 680 794.332 679.758 794.332 678.471V675.105H792.528V674.28H794.332V672.96H795.355V674.28H797.379V675.105H795.355V678.372ZM808.998 678.372C808.998 679.109 809.427 679.175 809.966 679.175H811.022V680H809.691C808.756 680 807.975 679.758 807.975 678.471V675.105H806.171V674.28H807.975V672.96H808.998V674.28H811.022V675.105H808.998V678.372ZM815.753 674.28V675.16C816.028 674.555 816.545 674.28 817.403 674.28H818.481V675.105H817.315C816.27 675.105 815.753 675.523 815.753 676.722V679.175H817.711V680H812.959V679.175H814.73V675.105H812.959V674.28H815.753ZM819.835 678.471C819.835 677.448 820.671 676.766 822.607 676.634L823.63 676.568V676.458C823.63 675.314 823.157 674.973 822.365 674.973C821.628 674.973 821.1 675.369 820.979 676.04H819.989C820.187 674.907 821.177 674.17 822.387 674.17C823.718 674.17 824.653 674.786 824.653 676.645V679.032C824.653 679.428 824.697 679.769 824.763 680H823.784C823.729 679.78 823.707 679.483 823.707 679.164C823.388 679.736 822.816 680.11 821.848 680.11C820.517 680.11 819.835 679.362 819.835 678.471ZM820.858 678.438C820.858 678.933 821.221 679.362 822.09 679.362C823.091 679.362 823.63 678.691 823.63 677.866V677.305L822.805 677.36C821.452 677.459 820.858 677.778 820.858 678.438ZM826.58 677.14C826.58 675.182 827.856 674.17 829.33 674.17C830.617 674.17 831.607 674.852 831.926 676.128H830.892C830.65 675.38 830.111 674.973 829.33 674.973C828.384 674.973 827.603 675.71 827.603 677.14C827.603 678.57 828.384 679.307 829.33 679.307C830.111 679.307 830.661 678.9 830.903 678.141H831.926C831.618 679.428 830.617 680.11 829.33 680.11C827.856 680.11 826.58 679.098 826.58 677.14ZM833.863 680V672.3H834.886V677.151L837.548 674.28H838.791L836.47 676.711L838.791 680H837.614L835.81 677.404L834.886 678.372V680H833.863ZM843.775 674.28V679.175H845.524V680H840.783V679.175H842.752V675.105H840.783V674.28H843.775ZM842.719 673.466V672.201H843.808V673.466H842.719ZM851.157 676.634C851.157 675.49 850.684 674.973 849.87 674.973C848.946 674.973 848.341 675.622 848.341 676.843V680H847.318V674.28H848.341V675.292C848.704 674.555 849.375 674.17 850.233 674.17C851.399 674.17 852.18 674.885 852.18 676.48V680H851.157V676.634ZM856.153 679.824C854.855 679.824 853.689 678.834 853.689 676.997C853.689 675.16 854.855 674.17 856.153 674.17C856.967 674.17 857.649 674.533 858.067 675.237V674.28H859.09V678.966C859.09 681.122 858.1 682.09 856.406 682.09C855.031 682.09 854.085 681.364 853.909 680.198H854.888C855.009 680.891 855.548 681.342 856.406 681.342C857.594 681.342 858.067 680.594 858.067 678.966V678.757C857.649 679.461 856.967 679.824 856.153 679.824ZM854.712 676.997C854.712 678.306 855.449 679.021 856.384 679.021C857.319 679.021 858.067 678.306 858.067 676.997C858.067 675.699 857.319 674.973 856.384 674.973C855.449 674.973 854.712 675.699 854.712 676.997ZM862.798 680V678.735H863.887V680L863.436 681.364H862.864L863.249 680H862.798ZM874.406 678.471C874.406 677.448 875.242 676.766 877.178 676.634L878.201 676.568V676.458C878.201 675.314 877.728 674.973 876.936 674.973C876.199 674.973 875.671 675.369 875.55 676.04H874.56C874.758 674.907 875.748 674.17 876.958 674.17C878.289 674.17 879.224 674.786 879.224 676.645V679.032C879.224 679.428 879.268 679.769 879.334 680H878.355C878.3 679.78 878.278 679.483 878.278 679.164C877.959 679.736 877.387 680.11 876.419 680.11C875.088 680.11 874.406 679.362 874.406 678.471ZM875.429 678.438C875.429 678.933 875.792 679.362 876.661 679.362C877.662 679.362 878.201 678.691 878.201 677.866V677.305L877.376 677.36C876.023 677.459 875.429 677.778 875.429 678.438ZM885.264 676.634C885.264 675.49 884.791 674.973 883.977 674.973C883.053 674.973 882.448 675.622 882.448 676.843V680H881.425V674.28H882.448V675.292C882.811 674.555 883.482 674.17 884.34 674.17C885.506 674.17 886.287 674.885 886.287 676.48V680H885.264V676.634ZM893.13 672.3V680H892.107V678.988C891.656 679.714 890.93 680.11 890.116 680.11C888.774 680.11 887.597 679.043 887.597 677.14C887.597 675.237 888.785 674.17 890.116 674.17C890.93 674.17 891.656 674.566 892.107 675.292V672.3H893.13ZM888.631 677.14C888.631 678.504 889.401 679.307 890.369 679.307C891.326 679.307 892.107 678.504 892.107 677.14C892.107 675.776 891.326 674.973 890.369 674.973C889.401 674.973 888.631 675.776 888.631 677.14ZM901.658 674.28H902.681V675.303C903.132 674.566 903.858 674.17 904.683 674.17C906.003 674.17 907.191 675.237 907.191 677.14C907.191 679.043 906.014 680.11 904.683 680.11C903.858 680.11 903.132 679.714 902.681 678.977V681.98H901.658V674.28ZM902.681 677.14C902.681 678.504 903.462 679.307 904.419 679.307C905.387 679.307 906.168 678.504 906.168 677.14C906.168 675.776 905.387 674.973 904.419 674.973C903.462 674.973 902.681 675.776 902.681 677.14ZM911.075 680.11C909.623 680.11 908.347 679.098 908.347 677.14C908.347 675.182 909.623 674.17 911.075 674.17C912.527 674.17 913.814 675.182 913.814 677.14C913.814 679.098 912.527 680.11 911.075 680.11ZM909.37 677.14C909.37 678.592 910.14 679.307 911.075 679.307C912.01 679.307 912.78 678.592 912.78 677.14C912.78 675.688 912.01 674.973 911.075 674.973C910.14 674.973 909.37 675.688 909.37 677.14ZM918.666 672.3V679.175H920.415V680H915.674V679.175H917.643V673.125H915.674V672.3H918.666ZM925.631 674.28V679.175H927.38V680H922.639V679.175H924.608V675.105H922.639V674.28H925.631ZM924.575 673.466V672.201H925.664V673.466H924.575ZM928.899 677.14C928.899 675.182 930.175 674.17 931.649 674.17C932.936 674.17 933.926 674.852 934.245 676.128H933.211C932.969 675.38 932.43 674.973 931.649 674.973C930.703 674.973 929.922 675.71 929.922 677.14C929.922 678.57 930.703 679.307 931.649 679.307C932.43 679.307 932.98 678.9 933.222 678.141H934.245C933.937 679.428 932.936 680.11 931.649 680.11C930.175 680.11 928.899 679.098 928.899 677.14ZM937.051 681.98L937.832 679.934L935.533 674.28H936.578L938.294 678.713L939.977 674.28H941.011L938.085 681.98H937.051ZM944.642 678.735H945.72V680H944.642V678.735Z" fill="#2F2D33"/> +<path d="M288 700H273V714H288V700Z" fill="#FCCBBE" stroke="#2F2D33"/> +<path d="M295.61 709.5C295.61 707.37 296.39 705.9 298.25 705.9C299.67 705.9 300.46 706.75 300.7 708.12H299.77C299.59 707.23 299.11 706.65 298.25 706.65C297 706.65 296.54 707.85 296.54 709.5C296.54 711.16 297 712.35 298.25 712.35C299.11 712.35 299.59 711.77 299.76 710.9H300.7C300.46 712.26 299.66 713.1 298.25 713.1C296.39 713.1 295.61 711.63 295.61 709.5ZM304.301 713.1C302.981 713.1 301.821 712.18 301.821 710.4C301.821 708.62 302.981 707.7 304.301 707.7C305.621 707.7 306.791 708.62 306.791 710.4C306.791 712.18 305.621 713.1 304.301 713.1ZM302.751 710.4C302.751 711.72 303.451 712.37 304.301 712.37C305.151 712.37 305.851 711.72 305.851 710.4C305.851 709.08 305.151 708.43 304.301 708.43C303.451 708.43 302.751 709.08 302.751 710.4ZM312.792 706V713H311.862V712.08C311.452 712.74 310.792 713.1 310.052 713.1C308.832 713.1 307.762 712.13 307.762 710.4C307.762 708.67 308.842 707.7 310.052 707.7C310.792 707.7 311.452 708.06 311.862 708.72V706H312.792ZM308.702 710.4C308.702 711.64 309.402 712.37 310.282 712.37C311.152 712.37 311.862 711.64 311.862 710.4C311.862 709.16 311.152 708.43 310.282 708.43C309.402 708.43 308.702 709.16 308.702 710.4ZM316.734 713.1C315.394 713.1 314.224 712.14 314.224 710.4C314.224 708.69 315.364 707.7 316.714 707.7C318.374 707.7 319.234 708.91 319.204 710.61H315.164V710.71C315.214 711.8 315.924 712.37 316.734 712.37C317.424 712.37 317.954 712.03 318.164 711.42H319.114C318.804 712.52 317.854 713.1 316.734 713.1ZM315.174 709.93H318.244C318.144 708.95 317.534 708.43 316.704 708.43C315.924 708.43 315.274 708.95 315.174 709.93ZM323.065 707.8V708.6C323.315 708.05 323.785 707.8 324.565 707.8H325.545V708.55H324.485C323.535 708.55 323.065 708.93 323.065 710.02V712.25H324.845V713H320.525V712.25H322.135V708.55H320.525V707.8H323.065ZM336.407 706C334.987 708.3 334.987 712.5 336.407 714.8H335.567C334.027 712.5 334.027 708.3 335.567 706H336.407ZM341.508 713.1C339.648 713.1 338.868 711.63 338.868 709.5C338.868 707.37 339.648 705.9 341.508 705.9C343.368 705.9 344.148 707.37 344.148 709.5C344.148 711.63 343.368 713.1 341.508 713.1ZM339.808 709.5C339.808 711.16 340.258 712.35 341.508 712.35C342.758 712.35 343.218 711.16 343.218 709.5C343.218 707.85 342.758 706.65 341.508 706.65C340.258 706.65 339.808 707.85 339.808 709.5ZM347.199 709.69C346.119 709.43 345.429 708.88 345.429 707.82C345.429 706.61 346.469 705.9 347.679 705.9C348.899 705.9 349.789 706.59 349.969 707.8H349.039C348.899 707.06 348.429 706.65 347.679 706.65C346.959 706.65 346.359 707.06 346.359 707.79C346.359 708.44 346.829 708.73 347.389 708.89L348.339 709.14C349.519 709.42 350.129 710.04 350.129 711.08C350.129 712.27 349.139 713.1 347.749 713.1C346.409 713.1 345.469 712.31 345.289 711.03H346.219C346.369 711.84 346.939 712.35 347.749 712.35C348.669 712.35 349.199 711.81 349.199 711.11C349.199 710.46 348.779 710.12 348.149 709.94L347.199 709.69ZM353.401 709.69C352.321 709.43 351.631 708.88 351.631 707.82C351.631 706.61 352.671 705.9 353.881 705.9C355.101 705.9 355.991 706.59 356.171 707.8H355.241C355.101 707.06 354.631 706.65 353.881 706.65C353.161 706.65 352.561 707.06 352.561 707.79C352.561 708.44 353.031 708.73 353.591 708.89L354.541 709.14C355.721 709.42 356.331 710.04 356.331 711.08C356.331 712.27 355.341 713.1 353.951 713.1C352.611 713.1 351.671 712.31 351.491 711.03H352.421C352.571 711.84 353.141 712.35 353.951 712.35C354.871 712.35 355.401 711.81 355.401 711.11C355.401 710.46 354.981 710.12 354.351 709.94L353.401 709.69ZM365.933 712.64V710.78H364.073V710.02H365.933V708.16H366.693V710.02H368.553V710.78H366.693V712.64H365.933ZM376.785 706H378.535C380.285 706 381.295 706.67 381.295 708.12C381.295 709.57 380.285 710.24 378.535 710.24H377.715V713H376.785V706ZM377.715 709.49H378.535C379.715 709.49 380.365 709.11 380.365 708.12C380.365 707.13 379.715 706.75 378.535 706.75H377.715V709.49ZM385.076 707.8V708.6C385.326 708.05 385.796 707.8 386.576 707.8H387.556V708.55H386.496C385.546 708.55 385.076 708.93 385.076 710.02V712.25H386.856V713H382.536V712.25H384.146V708.55H382.536V707.8H385.076ZM391.148 713.1C389.808 713.1 388.638 712.14 388.638 710.4C388.638 708.69 389.778 707.7 391.128 707.7C392.788 707.7 393.648 708.91 393.618 710.61H389.578V710.71C389.628 711.8 390.338 712.37 391.148 712.37C391.838 712.37 392.368 712.03 392.578 711.42H393.528C393.218 712.52 392.268 713.1 391.148 713.1ZM389.588 709.93H392.658C392.558 708.95 391.948 708.43 391.118 708.43C390.338 708.43 389.688 708.95 389.588 709.93ZM394.689 713V707.8H395.579V708.26C395.779 707.94 396.109 707.7 396.599 707.7C397.199 707.7 397.509 708.03 397.649 708.47C397.839 708.04 398.179 707.7 398.779 707.7C399.729 707.7 399.949 708.52 399.949 709.29V713H399.059V709.36C399.059 708.93 398.999 708.43 398.459 708.43C397.909 708.43 397.769 709.01 397.759 709.37V713H396.879V709.36C396.879 708.93 396.809 708.43 396.279 708.43C395.719 708.43 395.589 709.01 395.579 709.37V713H394.689ZM404.35 707.8V712.25H405.94V713H401.63V712.25H403.42V708.55H401.63V707.8H404.35ZM403.39 707.06V705.91H404.38V707.06H403.39ZM408.381 710.86C408.381 711.9 408.811 712.37 409.551 712.37C410.391 712.37 410.941 711.78 410.941 710.66V707.8H411.871V713H410.941V712.08C410.611 712.75 410.001 713.1 409.221 713.1C408.161 713.1 407.451 712.45 407.451 711V707.8H408.381V710.86ZM413.292 713V707.8H414.182V708.26C414.382 707.94 414.712 707.7 415.202 707.7C415.802 707.7 416.112 708.03 416.252 708.47C416.442 708.04 416.782 707.7 417.382 707.7C418.332 707.7 418.552 708.52 418.552 709.29V713H417.662V709.36C417.662 708.93 417.602 708.43 417.062 708.43C416.512 708.43 416.372 709.01 416.362 709.37V713H415.482V709.36C415.482 708.93 415.412 708.43 414.882 708.43C414.322 708.43 414.192 709.01 414.182 709.37V713H413.292ZM421.863 706C423.403 708.3 423.403 712.5 421.863 714.8H421.023C422.443 712.5 422.443 708.3 421.023 706H421.863Z" fill="#090B0B"/> +<path d="M288.903 724H273.286V738H288.903V724Z" fill="#DDD9EE" stroke="#2F2D33"/> +<path d="M296.205 733.5C296.205 731.37 296.985 729.9 298.845 729.9C300.265 729.9 301.055 730.75 301.295 732.12H300.365C300.185 731.23 299.705 730.65 298.845 730.65C297.595 730.65 297.135 731.85 297.135 733.5C297.135 735.16 297.595 736.35 298.845 736.35C299.705 736.35 300.185 735.77 300.355 734.9H301.295C301.055 736.26 300.255 737.1 298.845 737.1C296.985 737.1 296.205 735.63 296.205 733.5ZM305.597 730V736.25H307.187V737H302.877V736.25H304.667V730.75H302.877V730H305.597ZM311.928 731.8V736.25H313.518V737H309.208V736.25H310.998V732.55H309.208V731.8H311.928ZM310.968 731.06V729.91H311.958V731.06H310.968ZM317.329 737.1C315.989 737.1 314.819 736.14 314.819 734.4C314.819 732.69 315.959 731.7 317.309 731.7C318.969 731.7 319.829 732.91 319.799 734.61H315.759V734.71C315.809 735.8 316.519 736.37 317.329 736.37C318.019 736.37 318.549 736.03 318.759 735.42H319.709C319.399 736.52 318.449 737.1 317.329 737.1ZM315.769 733.93H318.839C318.739 732.95 318.129 732.43 317.299 732.43C316.519 732.43 315.869 732.95 315.769 733.93ZM324.84 733.94C324.84 732.9 324.41 732.43 323.67 732.43C322.83 732.43 322.28 733.02 322.28 734.13V737H321.35V731.8H322.28V732.72C322.61 732.05 323.22 731.7 324 731.7C325.06 731.7 325.77 732.35 325.77 733.8V737H324.84V733.94ZM329.921 735.52C329.921 736.19 330.311 736.25 330.801 736.25H331.761V737H330.551C329.701 737 328.991 736.78 328.991 735.61V732.55H327.351V731.8H328.991V730.6H329.921V731.8H331.761V732.55H329.921V735.52ZM335.622 734.68C334.692 734.52 333.912 734.18 333.912 733.21C333.912 732.25 334.842 731.7 335.892 731.7C336.862 731.7 337.782 732.21 337.912 733.18H336.982C336.902 732.68 336.462 732.43 335.892 732.43C335.282 732.43 334.842 732.72 334.842 733.2C334.842 733.71 335.282 733.83 335.722 733.92L336.222 734.03C337.342 734.23 338.012 734.62 338.012 735.56C338.012 736.52 337.122 737.1 335.952 737.1C334.842 737.1 333.952 736.56 333.782 735.49H334.712C334.812 736.11 335.332 736.37 335.952 736.37C336.652 736.37 337.082 736.07 337.082 735.57C337.082 735.07 336.702 734.91 336.122 734.78L335.622 734.68ZM347.775 737.1C346.425 737.1 345.505 736.39 345.505 735.23C345.505 734.19 346.335 733.64 347.065 733.22C346.555 732.58 346.255 732.07 346.255 731.49C346.255 730.57 347.025 729.9 348.015 729.9C348.995 729.9 349.765 730.57 349.765 731.49C349.765 732.25 349.265 732.74 348.235 733.38L349.695 735.13C349.935 734.69 350.125 734.11 350.235 733.36H351.015C350.905 734.32 350.635 735.14 350.225 735.76L351.265 737H350.245L349.715 736.37C349.205 736.84 348.555 737.1 347.775 737.1ZM346.415 735.23C346.415 735.92 346.975 736.37 347.775 736.37C348.275 736.37 348.785 736.2 349.215 735.78L347.555 733.79C346.955 734.15 346.415 734.56 346.415 735.23ZM347.095 731.49C347.095 731.91 347.315 732.27 347.755 732.82C348.615 732.3 348.915 731.99 348.915 731.49C348.915 731.03 348.565 730.63 348.015 730.63C347.455 730.63 347.095 731.03 347.095 731.49ZM360.737 737.1C359.397 737.1 358.227 736.14 358.227 734.4C358.227 732.69 359.367 731.7 360.717 731.7C362.377 731.7 363.237 732.91 363.207 734.61H359.167V734.71C359.217 735.8 359.927 736.37 360.737 736.37C361.427 736.37 361.957 736.03 362.167 735.42H363.117C362.807 736.52 361.857 737.1 360.737 737.1ZM359.177 733.93H362.247C362.147 732.95 361.537 732.43 360.707 732.43C359.927 732.43 359.277 732.95 359.177 733.93ZM364.478 737L366.398 734.38L364.498 731.8H365.508L366.898 733.72L368.298 731.8H369.308L367.408 734.38L369.338 737H368.328L366.908 735.04L365.478 737H364.478ZM373.33 735.52C373.33 736.19 373.72 736.25 374.21 736.25H375.17V737H373.96C373.11 737 372.4 736.78 372.4 735.61V732.55H370.76V731.8H372.4V730.6H373.33V731.8H375.17V732.55H373.33V735.52ZM379.341 737.1C378.001 737.1 376.831 736.14 376.831 734.4C376.831 732.69 377.971 731.7 379.321 731.7C380.981 731.7 381.841 732.91 381.811 734.61H377.771V734.71C377.821 735.8 378.531 736.37 379.341 736.37C380.031 736.37 380.561 736.03 380.771 735.42H381.721C381.411 736.52 380.461 737.1 379.341 737.1ZM377.781 733.93H380.851C380.751 732.95 380.141 732.43 379.311 732.43C378.531 732.43 377.881 732.95 377.781 733.93ZM385.672 731.8V732.6C385.922 732.05 386.392 731.8 387.172 731.8H388.152V732.55H387.092C386.142 732.55 385.672 732.93 385.672 734.02V736.25H387.452V737H383.132V736.25H384.742V732.55H383.132V731.8H385.672ZM393.053 733.94C393.053 732.9 392.623 732.43 391.883 732.43C391.043 732.43 390.493 733.02 390.493 734.13V737H389.563V731.8H390.493V732.72C390.823 732.05 391.433 731.7 392.213 731.7C393.273 731.7 393.983 732.35 393.983 733.8V737H393.053V733.94ZM395.584 735.61C395.584 734.68 396.344 734.06 398.104 733.94L399.034 733.88V733.78C399.034 732.74 398.604 732.43 397.884 732.43C397.214 732.43 396.734 732.79 396.624 733.4H395.724C395.904 732.37 396.804 731.7 397.904 731.7C399.114 731.7 399.964 732.26 399.964 733.95V736.12C399.964 736.48 400.004 736.79 400.064 737H399.174C399.124 736.8 399.104 736.53 399.104 736.24C398.814 736.76 398.294 737.1 397.414 737.1C396.204 737.1 395.584 736.42 395.584 735.61ZM396.514 735.58C396.514 736.03 396.844 736.42 397.634 736.42C398.544 736.42 399.034 735.81 399.034 735.06V734.55L398.284 734.6C397.054 734.69 396.514 734.98 396.514 735.58ZM404.815 730V736.25H406.405V737H402.095V736.25H403.885V730.75H402.095V730H404.815ZM416.238 734.68C415.308 734.52 414.528 734.18 414.528 733.21C414.528 732.25 415.458 731.7 416.508 731.7C417.478 731.7 418.398 732.21 418.528 733.18H417.598C417.518 732.68 417.078 732.43 416.508 732.43C415.898 732.43 415.458 732.72 415.458 733.2C415.458 733.71 415.898 733.83 416.338 733.92L416.838 734.03C417.958 734.23 418.628 734.62 418.628 735.56C418.628 736.52 417.738 737.1 416.568 737.1C415.458 737.1 414.568 736.56 414.398 735.49H415.328C415.428 736.11 415.948 736.37 416.568 736.37C417.268 736.37 417.698 736.07 417.698 735.57C417.698 735.07 417.318 734.91 416.738 734.78L416.238 734.68ZM422.749 737.1C421.409 737.1 420.239 736.14 420.239 734.4C420.239 732.69 421.379 731.7 422.729 731.7C424.389 731.7 425.249 732.91 425.219 734.61H421.179V734.71C421.229 735.8 421.939 736.37 422.749 736.37C423.439 736.37 423.969 736.03 424.179 735.42H425.129C424.819 736.52 423.869 737.1 422.749 737.1ZM421.189 733.93H424.259C424.159 732.95 423.549 732.43 422.719 732.43C421.939 732.43 421.289 732.95 421.189 733.93ZM429.08 731.8V732.6C429.33 732.05 429.8 731.8 430.58 731.8H431.56V732.55H430.5C429.55 732.55 429.08 732.93 429.08 734.02V736.25H430.86V737H426.54V736.25H428.15V732.55H426.54V731.8H429.08ZM434.511 737L432.621 731.8H433.561L435.121 736.33L436.681 731.8H437.611L435.721 737H434.511ZM442.152 731.8V736.25H443.742V737H439.432V736.25H441.222V732.55H439.432V731.8H442.152ZM441.192 731.06V729.91H442.182V731.06H441.192ZM445.124 734.4C445.124 732.62 446.284 731.7 447.624 731.7C448.794 731.7 449.694 732.32 449.984 733.48H449.044C448.824 732.8 448.334 732.43 447.624 732.43C446.764 732.43 446.054 733.1 446.054 734.4C446.054 735.7 446.764 736.37 447.624 736.37C448.334 736.37 448.834 736 449.054 735.31H449.984C449.704 736.48 448.794 737.1 447.624 737.1C446.284 737.1 445.124 736.18 445.124 734.4ZM453.755 737.1C452.415 737.1 451.245 736.14 451.245 734.4C451.245 732.69 452.385 731.7 453.735 731.7C455.395 731.7 456.255 732.91 456.225 734.61H452.185V734.71C452.235 735.8 452.945 736.37 453.755 736.37C454.445 736.37 454.975 736.03 455.185 735.42H456.135C455.825 736.52 454.875 737.1 453.755 737.1ZM452.195 733.93H455.265C455.165 732.95 454.555 732.43 453.725 732.43C452.945 732.43 452.295 732.95 452.195 733.93ZM459.646 734.68C458.716 734.52 457.936 734.18 457.936 733.21C457.936 732.25 458.866 731.7 459.916 731.7C460.886 731.7 461.806 732.21 461.936 733.18H461.006C460.926 732.68 460.486 732.43 459.916 732.43C459.306 732.43 458.866 732.72 458.866 733.2C458.866 733.71 459.306 733.83 459.746 733.92L460.246 734.03C461.366 734.23 462.036 734.62 462.036 735.56C462.036 736.52 461.146 737.1 459.976 737.1C458.866 737.1 457.976 736.56 457.806 735.49H458.736C458.836 736.11 459.356 736.37 459.976 736.37C460.676 736.37 461.106 736.07 461.106 735.57C461.106 735.07 460.726 734.91 460.146 734.78L459.646 734.68Z" fill="#090B0B"/> +<path d="M506.416 724H490.8V738H506.416V724Z" fill="#B8D7F5" stroke="#2F2D33"/> +<path d="M515.699 733.69C514.619 733.43 513.929 732.88 513.929 731.82C513.929 730.61 514.969 729.9 516.179 729.9C517.399 729.9 518.289 730.59 518.469 731.8H517.539C517.399 731.06 516.929 730.65 516.179 730.65C515.459 730.65 514.859 731.06 514.859 731.79C514.859 732.44 515.329 732.73 515.889 732.89L516.839 733.14C518.019 733.42 518.629 734.04 518.629 735.08C518.629 736.27 517.639 737.1 516.249 737.1C514.909 737.1 513.969 736.31 513.789 735.03H514.719C514.869 735.84 515.439 736.35 516.249 736.35C517.169 736.35 517.699 735.81 517.699 735.11C517.699 734.46 517.279 734.12 516.649 733.94L515.699 733.69ZM522.63 735.52C522.63 736.19 523.02 736.25 523.51 736.25H524.47V737H523.26C522.41 737 521.7 736.78 521.7 735.61V732.55H520.06V731.8H521.7V730.6H522.63V731.8H524.47V732.55H522.63V735.52ZM526.281 735.61C526.281 734.68 527.041 734.06 528.801 733.94L529.731 733.88V733.78C529.731 732.74 529.301 732.43 528.581 732.43C527.911 732.43 527.431 732.79 527.321 733.4H526.421C526.601 732.37 527.501 731.7 528.601 731.7C529.811 731.7 530.661 732.26 530.661 733.95V736.12C530.661 736.48 530.701 736.79 530.761 737H529.871C529.821 736.8 529.801 736.53 529.801 736.24C529.511 736.76 528.991 737.1 528.111 737.1C526.901 737.1 526.281 736.42 526.281 735.61ZM527.211 735.58C527.211 736.03 527.541 736.42 528.331 736.42C529.241 736.42 529.731 735.81 529.731 735.06V734.55L528.981 734.6C527.751 734.69 527.211 734.98 527.211 735.58ZM535.033 735.52C535.033 736.19 535.423 736.25 535.913 736.25H536.873V737H535.663C534.813 737 534.103 736.78 534.103 735.61V732.55H532.463V731.8H534.103V730.6H535.033V731.8H536.873V732.55H535.033V735.52ZM541.044 737.1C539.704 737.1 538.534 736.14 538.534 734.4C538.534 732.69 539.674 731.7 541.024 731.7C542.684 731.7 543.544 732.91 543.514 734.61H539.474V734.71C539.524 735.8 540.234 736.37 541.044 736.37C541.734 736.37 542.264 736.03 542.474 735.42H543.424C543.114 736.52 542.164 737.1 541.044 737.1ZM539.484 733.93H542.554C542.454 732.95 541.844 732.43 541.014 732.43C540.234 732.43 539.584 732.95 539.484 733.93ZM553.136 734.68C552.206 734.52 551.426 734.18 551.426 733.21C551.426 732.25 552.356 731.7 553.406 731.7C554.376 731.7 555.296 732.21 555.426 733.18H554.496C554.416 732.68 553.976 732.43 553.406 732.43C552.796 732.43 552.356 732.72 552.356 733.2C552.356 733.71 552.796 733.83 553.236 733.92L553.736 734.03C554.856 734.23 555.526 734.62 555.526 735.56C555.526 736.52 554.636 737.1 553.466 737.1C552.356 737.1 551.466 736.56 551.296 735.49H552.226C552.326 736.11 552.846 736.37 553.466 736.37C554.166 736.37 554.596 736.07 554.596 735.57C554.596 735.07 554.216 734.91 553.636 734.78L553.136 734.68ZM559.837 735.52C559.837 736.19 560.227 736.25 560.717 736.25H561.677V737H560.467C559.617 737 558.907 736.78 558.907 735.61V732.55H557.267V731.8H558.907V730.6H559.837V731.8H561.677V732.55H559.837V735.52ZM565.819 737.1C564.499 737.1 563.339 736.18 563.339 734.4C563.339 732.62 564.499 731.7 565.819 731.7C567.139 731.7 568.309 732.62 568.309 734.4C568.309 736.18 567.139 737.1 565.819 737.1ZM564.269 734.4C564.269 735.72 564.969 736.37 565.819 736.37C566.669 736.37 567.369 735.72 567.369 734.4C567.369 733.08 566.669 732.43 565.819 732.43C564.969 732.43 564.269 733.08 564.269 734.4ZM572.18 731.8V732.6C572.43 732.05 572.9 731.8 573.68 731.8H574.66V732.55H573.6C572.65 732.55 572.18 732.93 572.18 734.02V736.25H573.96V737H569.64V736.25H571.25V732.55H569.64V731.8H572.18ZM578.251 737.1C576.911 737.1 575.741 736.14 575.741 734.4C575.741 732.69 576.881 731.7 578.231 731.7C579.891 731.7 580.751 732.91 580.721 734.61H576.681V734.71C576.731 735.8 577.441 736.37 578.251 736.37C578.941 736.37 579.471 736.03 579.681 735.42H580.631C580.321 736.52 579.371 737.1 578.251 737.1ZM576.691 733.93H579.761C579.661 732.95 579.051 732.43 578.221 732.43C577.441 732.43 576.791 732.95 576.691 733.93Z" fill="#090B0B"/> +<path d="M506.616 699H491V713H506.616V699Z" fill="#F1D5F7" stroke="#9900B1" stroke-width="1.5"/> +<path d="M518.289 712L517.799 710.4H515.009L514.519 712H513.559L515.829 705H516.979L519.249 712H518.289ZM515.239 709.65H517.569L516.409 705.81L515.239 709.65ZM524.87 705V705.75H523.08V711.25H524.87V712H520.35V711.25H522.14V705.75H520.35V705H524.87ZM536.773 712V710.76C536.483 711.58 535.893 712.1 534.913 712.1C533.183 712.1 532.393 710.63 532.393 708.5C532.393 706.37 533.173 704.9 535.023 704.9C536.453 704.9 537.243 705.75 537.473 707.12H536.553C536.373 706.23 535.883 705.65 535.023 705.65C533.783 705.65 533.323 706.85 533.323 708.5C533.323 710.16 533.783 711.35 535.023 711.35C536.073 711.35 536.543 710.42 536.553 709.13H534.993V708.38H537.483V712H536.773ZM541.214 712.1C539.894 712.1 538.734 711.18 538.734 709.4C538.734 707.62 539.894 706.7 541.214 706.7C542.534 706.7 543.704 707.62 543.704 709.4C543.704 711.18 542.534 712.1 541.214 712.1ZM539.664 709.4C539.664 710.72 540.364 711.37 541.214 711.37C542.064 711.37 542.764 710.72 542.764 709.4C542.764 708.08 542.064 707.43 541.214 707.43C540.364 707.43 539.664 708.08 539.664 709.4ZM546.805 712L544.915 706.8H545.855L547.415 711.33L548.975 706.8H549.905L548.015 712H546.805ZM553.646 712.1C552.306 712.1 551.136 711.14 551.136 709.4C551.136 707.69 552.276 706.7 553.626 706.7C555.286 706.7 556.146 707.91 556.116 709.61H552.076V709.71C552.126 710.8 552.836 711.37 553.646 711.37C554.336 711.37 554.866 711.03 555.076 710.42H556.026C555.716 711.52 554.766 712.1 553.646 712.1ZM552.086 708.93H555.156C555.056 707.95 554.446 707.43 553.616 707.43C552.836 707.43 552.186 707.95 552.086 708.93ZM559.977 706.8V707.6C560.227 707.05 560.697 706.8 561.477 706.8H562.457V707.55H561.397C560.447 707.55 559.977 707.93 559.977 709.02V711.25H561.757V712H557.437V711.25H559.047V707.55H557.437V706.8H559.977ZM567.358 708.94C567.358 707.9 566.928 707.43 566.188 707.43C565.348 707.43 564.798 708.02 564.798 709.13V712H563.868V706.8H564.798V707.72C565.128 707.05 565.738 706.7 566.518 706.7C567.578 706.7 568.288 707.35 568.288 708.8V712H567.358V708.94ZM569.89 710.61C569.89 709.68 570.65 709.06 572.41 708.94L573.34 708.88V708.78C573.34 707.74 572.91 707.43 572.19 707.43C571.52 707.43 571.04 707.79 570.93 708.4H570.03C570.21 707.37 571.11 706.7 572.21 706.7C573.42 706.7 574.27 707.26 574.27 708.95V711.12C574.27 711.48 574.31 711.79 574.37 712H573.48C573.43 711.8 573.41 711.53 573.41 711.24C573.12 711.76 572.6 712.1 571.72 712.1C570.51 712.1 569.89 711.42 569.89 710.61ZM570.82 710.58C570.82 711.03 571.15 711.42 571.94 711.42C572.85 711.42 573.34 710.81 573.34 710.06V709.55L572.59 709.6C571.36 709.69 570.82 709.98 570.82 710.58ZM579.761 708.94C579.761 707.9 579.331 707.43 578.591 707.43C577.751 707.43 577.201 708.02 577.201 709.13V712H576.271V706.8H577.201V707.72C577.531 707.05 578.141 706.7 578.921 706.7C579.981 706.7 580.691 707.35 580.691 708.8V712H579.761V708.94ZM582.222 709.4C582.222 707.62 583.382 706.7 584.722 706.7C585.892 706.7 586.792 707.32 587.082 708.48H586.142C585.922 707.8 585.432 707.43 584.722 707.43C583.862 707.43 583.152 708.1 583.152 709.4C583.152 710.7 583.862 711.37 584.722 711.37C585.432 711.37 585.932 711 586.152 710.31H587.082C586.802 711.48 585.892 712.1 584.722 712.1C583.382 712.1 582.222 711.18 582.222 709.4ZM590.853 712.1C589.513 712.1 588.343 711.14 588.343 709.4C588.343 707.69 589.483 706.7 590.833 706.7C592.493 706.7 593.353 707.91 593.323 709.61H589.283V709.71C589.333 710.8 590.043 711.37 590.853 711.37C591.543 711.37 592.073 711.03 592.283 710.42H593.233C592.923 711.52 591.973 712.1 590.853 712.1ZM589.293 708.93H592.363C592.263 707.95 591.653 707.43 590.823 707.43C590.043 707.43 589.393 707.95 589.293 708.93ZM605.105 712L604.615 710.4H601.825L601.335 712H600.375L602.645 705H603.795L606.065 712H605.105ZM602.055 709.65H604.385L603.225 705.81L602.055 709.65ZM611.717 705V712H610.787V711.08C610.377 711.74 609.717 712.1 608.977 712.1C607.757 712.1 606.687 711.13 606.687 709.4C606.687 707.67 607.767 706.7 608.977 706.7C609.717 706.7 610.377 707.06 610.787 707.72V705H611.717ZM607.627 709.4C607.627 710.64 608.327 711.37 609.207 711.37C610.077 711.37 610.787 710.64 610.787 709.4C610.787 708.16 610.077 707.43 609.207 707.43C608.327 707.43 607.627 708.16 607.627 709.4ZM617.918 705V712H616.988V711.08C616.578 711.74 615.918 712.1 615.178 712.1C613.958 712.1 612.888 711.13 612.888 709.4C612.888 707.67 613.968 706.7 615.178 706.7C615.918 706.7 616.578 707.06 616.988 707.72V705H617.918ZM613.828 709.4C613.828 710.64 614.528 711.37 615.408 711.37C616.278 711.37 616.988 710.64 616.988 709.4C616.988 708.16 616.278 707.43 615.408 707.43C614.528 707.43 613.828 708.16 613.828 709.4ZM620.579 709.02H623.079V709.77H620.579V709.02ZM628.03 712.1C626.17 712.1 625.39 710.63 625.39 708.5C625.39 706.37 626.17 704.9 628.03 704.9C629.89 704.9 630.67 706.37 630.67 708.5C630.67 710.63 629.89 712.1 628.03 712.1ZM626.33 708.5C626.33 710.16 626.78 711.35 628.03 711.35C629.28 711.35 629.74 710.16 629.74 708.5C629.74 706.85 629.28 705.65 628.03 705.65C626.78 705.65 626.33 706.85 626.33 708.5ZM635.571 708.94C635.571 707.9 635.141 707.43 634.401 707.43C633.561 707.43 633.011 708.02 633.011 709.13V712H632.081V706.8H633.011V707.72C633.341 707.05 633.951 706.7 634.731 706.7C635.791 706.7 636.501 707.35 636.501 708.8V712H635.571V708.94Z" fill="#090B0B"/> +<path d="M717.248 716H670.399C666.087 716 662.591 719.134 662.591 723C662.591 726.866 666.087 730 670.399 730H717.248C721.56 730 725.056 726.866 725.056 723C725.056 719.134 721.56 716 717.248 716Z" fill="#4A408F"/> +<path d="M677.396 720.4H678.796C680.196 720.4 681.004 720.936 681.004 722.096C681.004 723.256 680.196 723.792 678.796 723.792H678.14V726H677.396V720.4ZM678.14 723.192H678.796C679.74 723.192 680.26 722.888 680.26 722.096C680.26 721.304 679.74 721 678.796 721H678.14V723.192ZM682.261 726V720.4H683.709C684.997 720.4 685.877 720.888 685.877 722.048C685.877 722.912 685.397 723.4 684.629 723.592L686.029 726H685.189L683.877 723.696H683.709H683.013V726H682.261ZM683.013 723.096H683.709C684.541 723.096 685.133 722.84 685.133 722.048C685.133 721.256 684.541 721 683.709 721H683.013V723.096ZM687.262 720.4H690.606V721H688.006V722.856H690.462V723.456H688.006V725.4H690.646V726H687.262V720.4ZM693.527 725.2L692.447 721.48L692.487 722.08V726H691.775V720.4H692.823L693.823 724.208L694.815 720.4H695.863V726H695.151V722.08L695.191 721.48L694.111 725.2H693.527ZM700.592 720.4V721H699.16V725.4H700.592V726H696.976V725.4H698.408V721H696.976V720.4H700.592ZM703.745 726.08C702.209 726.08 701.793 725.104 701.793 723.936V720.4H702.537V723.936C702.537 724.736 702.745 725.48 703.745 725.48C704.737 725.48 704.953 724.736 704.953 723.936V720.4H705.697V723.936C705.697 725.104 705.265 726.08 703.745 726.08ZM708.41 725.2L707.33 721.48L707.37 722.08V726H706.658V720.4H707.706L708.706 724.208L709.698 720.4H710.746V726H710.034V722.08L710.074 721.48L708.994 725.2H708.41Z" fill="#FBF8F8"/> +<path d="M732.609 722.83V722.08H737.089V722.83H732.609ZM732.609 724.72V723.97H737.089V724.72H732.609ZM744.761 722.5C744.761 720.37 745.541 718.9 747.401 718.9C748.821 718.9 749.611 719.75 749.851 721.12H748.921C748.741 720.23 748.261 719.65 747.401 719.65C746.151 719.65 745.691 720.85 745.691 722.5C745.691 724.16 746.151 725.35 747.401 725.35C748.261 725.35 748.741 724.77 748.911 723.9H749.851C749.611 725.26 748.811 726.1 747.401 726.1C745.541 726.1 744.761 724.63 744.761 722.5ZM753.453 726.1C752.133 726.1 750.973 725.18 750.973 723.4C750.973 721.62 752.133 720.7 753.453 720.7C754.773 720.7 755.943 721.62 755.943 723.4C755.943 725.18 754.773 726.1 753.453 726.1ZM751.903 723.4C751.903 724.72 752.603 725.37 753.453 725.37C754.303 725.37 755.003 724.72 755.003 723.4C755.003 722.08 754.303 721.43 753.453 721.43C752.603 721.43 751.903 722.08 751.903 723.4ZM761.944 719V726H761.014V725.08C760.604 725.74 759.944 726.1 759.204 726.1C757.984 726.1 756.914 725.13 756.914 723.4C756.914 721.67 757.994 720.7 759.204 720.7C759.944 720.7 760.604 721.06 761.014 721.72V719H761.944ZM757.854 723.4C757.854 724.64 758.554 725.37 759.434 725.37C760.304 725.37 761.014 724.64 761.014 723.4C761.014 722.16 760.304 721.43 759.434 721.43C758.554 721.43 757.854 722.16 757.854 723.4ZM765.885 726.1C764.545 726.1 763.375 725.14 763.375 723.4C763.375 721.69 764.515 720.7 765.865 720.7C767.525 720.7 768.385 721.91 768.355 723.61H764.315V723.71C764.365 724.8 765.075 725.37 765.885 725.37C766.575 725.37 767.105 725.03 767.315 724.42H768.265C767.955 725.52 767.005 726.1 765.885 726.1ZM764.325 722.93H767.395C767.295 721.95 766.685 721.43 765.855 721.43C765.075 721.43 764.425 721.95 764.325 722.93ZM772.216 720.8V721.6C772.466 721.05 772.936 720.8 773.716 720.8H774.696V721.55H773.636C772.686 721.55 772.216 721.93 772.216 723.02V725.25H773.996V726H769.676V725.25H771.286V721.55H769.676V720.8H772.216ZM782.528 719H784.278C786.028 719 787.038 719.67 787.038 721.12C787.038 722.57 786.028 723.24 784.278 723.24H783.458V726H782.528V719ZM783.458 722.49H784.278C785.458 722.49 786.108 722.11 786.108 721.12C786.108 720.13 785.458 719.75 784.278 719.75H783.458V722.49ZM790.82 720.8V721.6C791.07 721.05 791.54 720.8 792.32 720.8H793.3V721.55H792.24C791.29 721.55 790.82 721.93 790.82 723.02V725.25H792.6V726H788.28V725.25H789.89V721.55H788.28V720.8H790.82ZM796.891 726.1C795.551 726.1 794.381 725.14 794.381 723.4C794.381 721.69 795.521 720.7 796.871 720.7C798.531 720.7 799.391 721.91 799.361 723.61H795.321V723.71C795.371 724.8 796.081 725.37 796.891 725.37C797.581 725.37 798.111 725.03 798.321 724.42H799.271C798.961 725.52 798.011 726.1 796.891 726.1ZM795.331 722.93H798.401C798.301 721.95 797.691 721.43 796.861 721.43C796.081 721.43 795.431 721.95 795.331 722.93ZM800.432 726V720.8H801.322V721.26C801.522 720.94 801.852 720.7 802.342 720.7C802.942 720.7 803.252 721.03 803.392 721.47C803.582 721.04 803.922 720.7 804.522 720.7C805.472 720.7 805.692 721.52 805.692 722.29V726H804.802V722.36C804.802 721.93 804.742 721.43 804.202 721.43C803.652 721.43 803.512 722.01 803.502 722.37V726H802.622V722.36C802.622 721.93 802.552 721.43 802.022 721.43C801.462 721.43 801.332 722.01 801.322 722.37V726H800.432ZM810.093 720.8V725.25H811.683V726H807.373V725.25H809.163V721.55H807.373V720.8H810.093ZM809.133 720.06V718.91H810.123V720.06H809.133ZM814.124 723.86C814.124 724.9 814.554 725.37 815.294 725.37C816.134 725.37 816.684 724.78 816.684 723.66V720.8H817.614V726H816.684V725.08C816.354 725.75 815.744 726.1 814.964 726.1C813.904 726.1 813.194 725.45 813.194 724V720.8H814.124V723.86ZM819.035 726V720.8H819.925V721.26C820.125 720.94 820.455 720.7 820.945 720.7C821.545 720.7 821.855 721.03 821.995 721.47C822.185 721.04 822.525 720.7 823.125 720.7C824.075 720.7 824.295 721.52 824.295 722.29V726H823.405V722.36C823.405 721.93 823.345 721.43 822.805 721.43C822.255 721.43 822.115 722.01 822.105 722.37V726H821.225V722.36C821.225 721.93 821.155 721.43 820.625 721.43C820.065 721.43 819.935 722.01 819.925 722.37V726H819.035ZM834.438 720.39V720.8H836.278V721.55H834.438V725.25H836.178V726H831.858V725.25H833.498V721.55H831.858V720.8H833.498V720.39C833.498 719.22 834.218 719 835.068 719H836.278V719.66H835.308C834.828 719.66 834.438 719.72 834.438 720.39ZM840.299 726.1C838.959 726.1 837.789 725.14 837.789 723.4C837.789 721.69 838.929 720.7 840.279 720.7C841.939 720.7 842.799 721.91 842.769 723.61H838.729V723.71C838.779 724.8 839.489 725.37 840.299 725.37C840.989 725.37 841.519 725.03 841.729 724.42H842.679C842.369 725.52 841.419 726.1 840.299 726.1ZM838.739 722.93H841.809C841.709 721.95 841.099 721.43 840.269 721.43C839.489 721.43 838.839 721.95 838.739 722.93ZM844.14 724.61C844.14 723.68 844.9 723.06 846.66 722.94L847.59 722.88V722.78C847.59 721.74 847.16 721.43 846.44 721.43C845.77 721.43 845.29 721.79 845.18 722.4H844.28C844.46 721.37 845.36 720.7 846.46 720.7C847.67 720.7 848.52 721.26 848.52 722.95V725.12C848.52 725.48 848.56 725.79 848.62 726H847.73C847.68 725.8 847.66 725.53 847.66 725.24C847.37 725.76 846.85 726.1 845.97 726.1C844.76 726.1 844.14 725.42 844.14 724.61ZM845.07 724.58C845.07 725.03 845.4 725.42 846.19 725.42C847.1 725.42 847.59 724.81 847.59 724.06V723.55L846.84 723.6C845.61 723.69 845.07 723.98 845.07 724.58ZM852.891 724.52C852.891 725.19 853.281 725.25 853.771 725.25H854.731V726H853.521C852.671 726 851.961 725.78 851.961 724.61V721.55H850.321V720.8H851.961V719.6H852.891V720.8H854.731V721.55H852.891V724.52ZM857.532 723.86C857.532 724.9 857.962 725.37 858.702 725.37C859.542 725.37 860.092 724.78 860.092 723.66V720.8H861.022V726H860.092V725.08C859.762 725.75 859.152 726.1 858.372 726.1C857.312 726.1 856.602 725.45 856.602 724V720.8H857.532V723.86ZM865.234 720.8V721.6C865.484 721.05 865.954 720.8 866.734 720.8H867.714V721.55H866.654C865.704 721.55 865.234 721.93 865.234 723.02V725.25H867.014V726H862.694V725.25H864.304V721.55H862.694V720.8H865.234ZM871.305 726.1C869.965 726.1 868.795 725.14 868.795 723.4C868.795 721.69 869.935 720.7 871.285 720.7C872.945 720.7 873.805 721.91 873.775 723.61H869.735V723.71C869.785 724.8 870.495 725.37 871.305 725.37C871.995 725.37 872.525 725.03 872.735 724.42H873.685C873.375 725.52 872.425 726.1 871.305 726.1ZM869.745 722.93H872.815C872.715 721.95 872.105 721.43 871.275 721.43C870.495 721.43 869.845 721.95 869.745 722.93Z" fill="#090B0B"/> +<g clip-path="url(#clip1_242_649)"> +<path d="M209.903 249C215.829 249 219.151 251.777 219.263 255.863L214.145 256.019C214.011 253.753 211.979 252.265 209.903 252.31C207.052 252.365 204.942 254.242 204.942 257.218C204.942 260.195 207.052 262.038 209.903 262.038C211.979 262.038 213.966 260.617 214.19 258.351L219.308 258.462C219.174 262.616 215.649 265.437 209.903 265.437C204.156 265.437 199.666 262.216 199.666 257.218C199.666 252.198 203.976 249 209.903 249ZM235.232 249.486V265.034H221.763V249.486H235.232Z" fill="#090B0B"/> +</g> +<path d="M367.581 380H311.808C307.496 380 304 383.134 304 387C304 390.866 307.496 394 311.808 394H367.581C371.893 394 375.389 390.866 375.389 387C375.389 383.134 371.893 380 367.581 380Z" fill="#4A408F"/> +<path d="M323.267 384.4H324.667C326.067 384.4 326.875 384.936 326.875 386.096C326.875 387.256 326.067 387.792 324.667 387.792H324.011V390H323.267V384.4ZM324.011 387.192H324.667C325.611 387.192 326.131 386.888 326.131 386.096C326.131 385.304 325.611 385 324.667 385H324.011V387.192ZM328.132 390V384.4H329.58C330.868 384.4 331.748 384.888 331.748 386.048C331.748 386.912 331.268 387.4 330.5 387.592L331.9 390H331.06L329.748 387.696H329.58H328.884V390H328.132ZM328.884 387.096H329.58C330.412 387.096 331.004 386.84 331.004 386.048C331.004 385.256 330.412 385 329.58 385H328.884V387.096ZM333.133 384.4H336.477V385H333.877V386.856H336.333V387.456H333.877V389.4H336.517V390H333.133V384.4ZM339.398 389.2L338.318 385.48L338.358 386.08V390H337.646V384.4H338.694L339.694 388.208L340.686 384.4H341.734V390H341.022V386.08L341.062 385.48L339.982 389.2H339.398ZM346.463 384.4V385H345.031V389.4H346.463V390H342.847V389.4H344.279V385H342.847V384.4H346.463ZM349.616 390.08C348.08 390.08 347.664 389.104 347.664 387.936V384.4H348.408V387.936C348.408 388.736 348.616 389.48 349.616 389.48C350.608 389.48 350.824 388.736 350.824 387.936V384.4H351.568V387.936C351.568 389.104 351.136 390.08 349.616 390.08ZM354.281 389.2L353.201 385.48L353.241 386.08V390H352.529V384.4H353.577L354.577 388.208L355.569 384.4H356.617V390H355.905V386.08L355.945 385.48L354.865 389.2H354.281Z" fill="#FBF8F8"/> +<path d="M527.581 380H471.808C467.496 380 464 383.134 464 387C464 390.866 467.496 394 471.808 394H527.581C531.893 394 535.389 390.866 535.389 387C535.389 383.134 531.893 380 527.581 380Z" fill="#4A408F"/> +<path d="M483.267 384.4H484.667C486.067 384.4 486.875 384.936 486.875 386.096C486.875 387.256 486.067 387.792 484.667 387.792H484.011V390H483.267V384.4ZM484.011 387.192H484.667C485.611 387.192 486.131 386.888 486.131 386.096C486.131 385.304 485.611 385 484.667 385H484.011V387.192ZM488.132 390V384.4H489.58C490.868 384.4 491.748 384.888 491.748 386.048C491.748 386.912 491.268 387.4 490.5 387.592L491.9 390H491.06L489.748 387.696H489.58H488.884V390H488.132ZM488.884 387.096H489.58C490.412 387.096 491.004 386.84 491.004 386.048C491.004 385.256 490.412 385 489.58 385H488.884V387.096ZM493.133 384.4H496.477V385H493.877V386.856H496.333V387.456H493.877V389.4H496.517V390H493.133V384.4ZM499.398 389.2L498.318 385.48L498.358 386.08V390H497.646V384.4H498.694L499.694 388.208L500.686 384.4H501.734V390H501.022V386.08L501.062 385.48L499.982 389.2H499.398ZM506.463 384.4V385H505.031V389.4H506.463V390H502.847V389.4H504.279V385H502.847V384.4H506.463ZM509.616 390.08C508.08 390.08 507.664 389.104 507.664 387.936V384.4H508.408V387.936C508.408 388.736 508.616 389.48 509.616 389.48C510.608 389.48 510.824 388.736 510.824 387.936V384.4H511.568V387.936C511.568 389.104 511.136 390.08 509.616 390.08ZM514.281 389.2L513.201 385.48L513.241 386.08V390H512.529V384.4H513.577L514.577 388.208L515.569 384.4H516.617V390H515.905V386.08L515.945 385.48L514.865 389.2H514.281Z" fill="#FBF8F8"/> +<path d="M689.581 380H633.808C629.496 380 626 383.134 626 387C626 390.866 629.496 394 633.808 394H689.581C693.893 394 697.389 390.866 697.389 387C697.389 383.134 693.893 380 689.581 380Z" fill="#4A408F"/> +<path d="M645.093 384.4H646.493C647.893 384.4 648.701 384.936 648.701 386.096C648.701 387.256 647.893 387.792 646.493 387.792H645.837V390H645.093V384.4ZM645.837 387.192H646.493C647.437 387.192 647.957 386.888 647.957 386.096C647.957 385.304 647.437 385 646.493 385H645.837V387.192ZM649.958 390V384.4H651.406C652.694 384.4 653.574 384.888 653.574 386.048C653.574 386.912 653.094 387.4 652.326 387.592L653.726 390H652.886L651.574 387.696H651.406H650.71V390H649.958ZM650.71 387.096H651.406C652.238 387.096 652.83 386.84 652.83 386.048C652.83 385.256 652.238 385 651.406 385H650.71V387.096ZM654.959 384.4H658.303V385H655.703V386.856H658.159V387.456H655.703V389.4H658.343V390H654.959V384.4ZM661.224 389.2L660.144 385.48L660.184 386.08V390H659.472V384.4H660.52L661.52 388.208L662.512 384.4H663.56V390H662.848V386.08L662.888 385.48L661.808 389.2H661.224ZM668.289 384.4V385H666.857V389.4H668.289V390H664.673V389.4H666.105V385H664.673V384.4H668.289ZM671.442 390.08C669.906 390.08 669.49 389.104 669.49 387.936V384.4H670.234V387.936C670.234 388.736 670.442 389.48 671.442 389.48C672.434 389.48 672.65 388.736 672.65 387.936V384.4H673.394V387.936C673.394 389.104 672.962 390.08 671.442 390.08ZM676.107 389.2L675.027 385.48L675.067 386.08V390H674.355V384.4H675.403L676.403 388.208L677.395 384.4H678.443V390H677.731V386.08L677.771 385.48L676.691 389.2H676.107Z" fill="#FBF8F8"/> +<path d="M1046.58 229H990.808C986.496 229 983 232.134 983 236C983 239.866 986.496 243 990.808 243H1046.58C1050.89 243 1054.39 239.866 1054.39 236C1054.39 232.134 1050.89 229 1046.58 229Z" fill="#4A408F"/> +<path d="M1002.09 233.4H1003.49C1004.89 233.4 1005.7 233.936 1005.7 235.096C1005.7 236.256 1004.89 236.792 1003.49 236.792H1002.84V239H1002.09V233.4ZM1002.84 236.192H1003.49C1004.44 236.192 1004.96 235.888 1004.96 235.096C1004.96 234.304 1004.44 234 1003.49 234H1002.84V236.192ZM1006.96 239V233.4H1008.41C1009.69 233.4 1010.57 233.888 1010.57 235.048C1010.57 235.912 1010.09 236.4 1009.33 236.592L1010.73 239H1009.89L1008.57 236.696H1008.41H1007.71V239H1006.96ZM1007.71 236.096H1008.41C1009.24 236.096 1009.83 235.84 1009.83 235.048C1009.83 234.256 1009.24 234 1008.41 234H1007.71V236.096ZM1011.96 233.4H1015.3V234H1012.7V235.856H1015.16V236.456H1012.7V238.4H1015.34V239H1011.96V233.4ZM1018.22 238.2L1017.14 234.48L1017.18 235.08V239H1016.47V233.4H1017.52L1018.52 237.208L1019.51 233.4H1020.56V239H1019.85V235.08L1019.89 234.48L1018.81 238.2H1018.22ZM1025.29 233.4V234H1023.86V238.4H1025.29V239H1021.67V238.4H1023.1V234H1021.67V233.4H1025.29ZM1028.44 239.08C1026.91 239.08 1026.49 238.104 1026.49 236.936V233.4H1027.23V236.936C1027.23 237.736 1027.44 238.48 1028.44 238.48C1029.43 238.48 1029.65 237.736 1029.65 236.936V233.4H1030.39V236.936C1030.39 238.104 1029.96 239.08 1028.44 239.08ZM1033.11 238.2L1032.03 234.48L1032.07 235.08V239H1031.35V233.4H1032.4L1033.4 237.208L1034.39 233.4H1035.44V239H1034.73V235.08L1034.77 234.48L1033.69 238.2H1033.11Z" fill="#FBF8F8"/> +<path d="M689.581 290H633.808C629.496 290 626 293.134 626 297C626 300.866 629.496 304 633.808 304H689.581C693.893 304 697.389 300.866 697.389 297C697.389 293.134 693.893 290 689.581 290Z" fill="#A13000"/> +<path d="M640.664 296.2C640.664 294.496 641.288 293.32 642.776 293.32C643.912 293.32 644.544 294 644.736 295.096H643.992C643.848 294.384 643.464 293.92 642.776 293.92C641.776 293.92 641.408 294.88 641.408 296.2C641.408 297.528 641.776 298.48 642.776 298.48C643.464 298.48 643.848 298.016 643.984 297.32H644.736C644.544 298.408 643.904 299.08 642.776 299.08C641.288 299.08 640.664 297.904 640.664 296.2ZM647.617 299.08C646.129 299.08 645.505 297.904 645.505 296.2C645.505 294.496 646.129 293.32 647.617 293.32C649.105 293.32 649.729 294.496 649.729 296.2C649.729 297.904 649.105 299.08 647.617 299.08ZM646.257 296.2C646.257 297.528 646.617 298.48 647.617 298.48C648.617 298.48 648.985 297.528 648.985 296.2C648.985 294.88 648.617 293.92 647.617 293.92C646.617 293.92 646.257 294.88 646.257 296.2ZM652.282 298.2L651.202 294.48L651.242 295.08V299H650.53V293.4H651.578L652.578 297.208L653.57 293.4H654.618V299H653.906V295.08L653.946 294.48L652.866 298.2H652.282ZM657.243 298.2L656.163 294.48L656.203 295.08V299H655.491V293.4H656.539L657.539 297.208L658.531 293.4H659.579V299H658.867V295.08L658.907 294.48L657.827 298.2H657.243ZM662.5 299.08C660.964 299.08 660.548 298.104 660.548 296.936V293.4H661.292V296.936C661.292 297.736 661.5 298.48 662.5 298.48C663.492 298.48 663.708 297.736 663.708 296.936V293.4H664.452V296.936C664.452 298.104 664.02 299.08 662.5 299.08ZM668.404 299L666.26 294.456V299H665.516V293.4H666.508L668.66 298.008V293.4H669.404V299H668.404ZM674.229 293.4V294H672.797V298.4H674.229V299H670.613V298.4H672.045V294H670.613V293.4H674.229ZM679.382 293.4V294H677.758V299H677.006V294H675.374V293.4H679.382ZM681.967 299V296.92L680.215 293.4H680.999L682.343 296.288L683.687 293.4H684.471L682.719 296.92V299H681.967Z" fill="white"/> +<path d="M528.581 290H472.808C468.496 290 465 293.134 465 297C465 300.866 468.496 304 472.808 304H528.581C532.893 304 536.389 300.866 536.389 297C536.389 293.134 532.893 290 528.581 290Z" fill="#A13000"/> +<path d="M479.664 296.2C479.664 294.496 480.288 293.32 481.776 293.32C482.912 293.32 483.544 294 483.736 295.096H482.992C482.848 294.384 482.464 293.92 481.776 293.92C480.776 293.92 480.408 294.88 480.408 296.2C480.408 297.528 480.776 298.48 481.776 298.48C482.464 298.48 482.848 298.016 482.984 297.32H483.736C483.544 298.408 482.904 299.08 481.776 299.08C480.288 299.08 479.664 297.904 479.664 296.2ZM486.617 299.08C485.129 299.08 484.505 297.904 484.505 296.2C484.505 294.496 485.129 293.32 486.617 293.32C488.105 293.32 488.729 294.496 488.729 296.2C488.729 297.904 488.105 299.08 486.617 299.08ZM485.257 296.2C485.257 297.528 485.617 298.48 486.617 298.48C487.617 298.48 487.985 297.528 487.985 296.2C487.985 294.88 487.617 293.92 486.617 293.92C485.617 293.92 485.257 294.88 485.257 296.2ZM491.282 298.2L490.202 294.48L490.242 295.08V299H489.53V293.4H490.578L491.578 297.208L492.57 293.4H493.618V299H492.906V295.08L492.946 294.48L491.866 298.2H491.282ZM496.243 298.2L495.163 294.48L495.203 295.08V299H494.491V293.4H495.539L496.539 297.208L497.531 293.4H498.579V299H497.867V295.08L497.907 294.48L496.827 298.2H496.243ZM501.5 299.08C499.964 299.08 499.548 298.104 499.548 296.936V293.4H500.292V296.936C500.292 297.736 500.5 298.48 501.5 298.48C502.492 298.48 502.708 297.736 502.708 296.936V293.4H503.452V296.936C503.452 298.104 503.02 299.08 501.5 299.08ZM507.404 299L505.26 294.456V299H504.516V293.4H505.508L507.66 298.008V293.4H508.404V299H507.404ZM513.229 293.4V294H511.797V298.4H513.229V299H509.613V298.4H511.045V294H509.613V293.4H513.229ZM518.382 293.4V294H516.758V299H516.006V294H514.374V293.4H518.382ZM520.967 299V296.92L519.215 293.4H519.999L521.343 296.288L522.687 293.4H523.471L521.719 296.92V299H520.967Z" fill="white"/> +<path d="M367.581 291H311.808C307.496 291 304 294.134 304 298C304 301.866 307.496 305 311.808 305H367.581C371.893 305 375.389 301.866 375.389 298C375.389 294.134 371.893 291 367.581 291Z" fill="#A13000"/> +<path d="M318.664 297.2C318.664 295.496 319.288 294.32 320.776 294.32C321.912 294.32 322.544 295 322.736 296.096H321.992C321.848 295.384 321.464 294.92 320.776 294.92C319.776 294.92 319.408 295.88 319.408 297.2C319.408 298.528 319.776 299.48 320.776 299.48C321.464 299.48 321.848 299.016 321.984 298.32H322.736C322.544 299.408 321.904 300.08 320.776 300.08C319.288 300.08 318.664 298.904 318.664 297.2ZM325.617 300.08C324.129 300.08 323.505 298.904 323.505 297.2C323.505 295.496 324.129 294.32 325.617 294.32C327.105 294.32 327.729 295.496 327.729 297.2C327.729 298.904 327.105 300.08 325.617 300.08ZM324.257 297.2C324.257 298.528 324.617 299.48 325.617 299.48C326.617 299.48 326.985 298.528 326.985 297.2C326.985 295.88 326.617 294.92 325.617 294.92C324.617 294.92 324.257 295.88 324.257 297.2ZM330.282 299.2L329.202 295.48L329.242 296.08V300H328.53V294.4H329.578L330.578 298.208L331.57 294.4H332.618V300H331.906V296.08L331.946 295.48L330.866 299.2H330.282ZM335.243 299.2L334.163 295.48L334.203 296.08V300H333.491V294.4H334.539L335.539 298.208L336.531 294.4H337.579V300H336.867V296.08L336.907 295.48L335.827 299.2H335.243ZM340.5 300.08C338.964 300.08 338.548 299.104 338.548 297.936V294.4H339.292V297.936C339.292 298.736 339.5 299.48 340.5 299.48C341.492 299.48 341.708 298.736 341.708 297.936V294.4H342.452V297.936C342.452 299.104 342.02 300.08 340.5 300.08ZM346.404 300L344.26 295.456V300H343.516V294.4H344.508L346.66 299.008V294.4H347.404V300H346.404ZM352.229 294.4V295H350.797V299.4H352.229V300H348.613V299.4H350.045V295H348.613V294.4H352.229ZM357.382 294.4V295H355.758V300H355.006V295H353.374V294.4H357.382ZM359.967 300V297.92L358.215 294.4H358.999L360.343 297.288L361.687 294.4H362.471L360.719 297.92V300H359.967Z" fill="white"/> +<rect width="199" height="71" transform="translate(223 109)" fill="#A19CC8" fill-opacity="0.5"/> +<path d="M291.83 136V124.64H295.91C297.275 124.64 298.305 124.885 298.998 125.376C299.691 125.867 300.038 126.645 300.038 127.712C300.038 128.192 299.937 128.613 299.734 128.976C299.531 129.339 299.233 129.627 298.838 129.84C298.454 130.043 297.995 130.149 297.462 130.16L297.446 130.096C298.417 130.117 299.169 130.384 299.702 130.896C300.246 131.397 300.518 132.059 300.518 132.88C300.518 133.915 300.171 134.693 299.478 135.216C298.785 135.739 297.798 136 296.518 136H291.83ZM293.558 134.448H296.47C297.185 134.448 297.739 134.304 298.134 134.016C298.539 133.717 298.742 133.285 298.742 132.72C298.742 132.165 298.539 131.733 298.134 131.424C297.739 131.115 297.185 130.96 296.47 130.96H293.558V134.448ZM293.558 129.488H295.878C296.635 129.488 297.222 129.344 297.638 129.056C298.054 128.768 298.262 128.363 298.262 127.84C298.262 127.285 298.059 126.875 297.654 126.608C297.259 126.331 296.667 126.192 295.878 126.192H293.558V129.488ZM302.833 136V127.488H304.385L304.449 129.744L304.305 129.696C304.423 128.928 304.657 128.368 305.009 128.016C305.372 127.664 305.857 127.488 306.465 127.488H307.281V129.008H306.465C306.039 129.008 305.681 129.077 305.393 129.216C305.105 129.355 304.887 129.568 304.737 129.856C304.599 130.144 304.529 130.517 304.529 130.976V136H302.833ZM312.314 136.192C311.503 136.192 310.794 136.011 310.186 135.648C309.578 135.285 309.108 134.768 308.778 134.096C308.447 133.424 308.282 132.64 308.282 131.744C308.282 130.837 308.447 130.053 308.778 129.392C309.108 128.731 309.578 128.219 310.186 127.856C310.794 127.483 311.503 127.296 312.314 127.296C313.124 127.296 313.834 127.483 314.442 127.856C315.05 128.219 315.519 128.731 315.85 129.392C316.18 130.053 316.346 130.837 316.346 131.744C316.346 132.64 316.18 133.424 315.85 134.096C315.519 134.768 315.05 135.285 314.442 135.648C313.834 136.011 313.124 136.192 312.314 136.192ZM312.314 134.72C313.039 134.72 313.599 134.459 313.994 133.936C314.388 133.403 314.586 132.672 314.586 131.744C314.586 130.816 314.388 130.091 313.994 129.568C313.599 129.035 313.039 128.768 312.314 128.768C311.599 128.768 311.039 129.035 310.634 129.568C310.239 130.091 310.042 130.816 310.042 131.744C310.042 132.672 310.239 133.403 310.634 133.936C311.039 134.459 311.599 134.72 312.314 134.72ZM319.858 136L317.266 127.488H319.042L320.866 134.144L322.738 127.488H324.338L326.21 134.144L328.05 127.488H329.826L327.234 136H325.33L323.538 129.92L321.746 136H319.858ZM334.542 136.192C333.742 136.192 333.065 136.069 332.51 135.824C331.966 135.579 331.545 135.243 331.246 134.816C330.948 134.379 330.777 133.877 330.734 133.312L332.478 133.232C332.564 133.701 332.766 134.075 333.086 134.352C333.406 134.629 333.892 134.768 334.542 134.768C335.076 134.768 335.497 134.683 335.806 134.512C336.116 134.341 336.27 134.069 336.27 133.696C336.27 133.493 336.217 133.323 336.11 133.184C336.014 133.035 335.822 132.912 335.534 132.816C335.246 132.709 334.82 132.608 334.254 132.512C333.412 132.352 332.75 132.165 332.27 131.952C331.79 131.739 331.449 131.467 331.246 131.136C331.044 130.805 330.942 130.405 330.942 129.936C330.942 129.157 331.23 128.523 331.806 128.032C332.393 127.541 333.236 127.296 334.334 127.296C335.07 127.296 335.689 127.424 336.19 127.68C336.692 127.925 337.081 128.261 337.358 128.688C337.646 129.104 337.828 129.573 337.902 130.096L336.158 130.192C336.105 129.893 335.998 129.637 335.838 129.424C335.689 129.2 335.486 129.029 335.23 128.912C334.974 128.784 334.67 128.72 334.318 128.72C333.774 128.72 333.369 128.827 333.102 129.04C332.836 129.253 332.702 129.531 332.702 129.872C332.702 130.128 332.761 130.336 332.878 130.496C333.006 130.656 333.209 130.789 333.486 130.896C333.764 130.992 334.132 131.077 334.59 131.152C335.476 131.301 336.169 131.488 336.67 131.712C337.172 131.925 337.524 132.192 337.726 132.512C337.929 132.832 338.03 133.221 338.03 133.68C338.03 134.213 337.881 134.667 337.582 135.04C337.284 135.413 336.873 135.701 336.35 135.904C335.828 136.096 335.225 136.192 334.542 136.192ZM343.571 136.192C342.739 136.192 342.019 136.011 341.411 135.648C340.813 135.285 340.349 134.768 340.019 134.096C339.699 133.424 339.539 132.64 339.539 131.744C339.539 130.848 339.699 130.069 340.019 129.408C340.349 128.736 340.813 128.219 341.411 127.856C342.008 127.483 342.712 127.296 343.523 127.296C344.291 127.296 344.968 127.477 345.555 127.84C346.141 128.192 346.595 128.704 346.915 129.376C347.245 130.048 347.411 130.859 347.411 131.808V132.24H341.299C341.341 133.072 341.56 133.696 341.955 134.112C342.36 134.528 342.904 134.736 343.587 134.736C344.088 134.736 344.504 134.619 344.835 134.384C345.165 134.149 345.395 133.835 345.523 133.44L347.283 133.552C347.059 134.341 346.616 134.981 345.955 135.472C345.304 135.952 344.509 136.192 343.571 136.192ZM341.299 130.96H345.619C345.565 130.203 345.347 129.643 344.963 129.28C344.589 128.917 344.109 128.736 343.523 128.736C342.915 128.736 342.413 128.928 342.019 129.312C341.635 129.685 341.395 130.235 341.299 130.96ZM349.449 136V127.488H351.001L351.065 129.744L350.921 129.696C351.038 128.928 351.273 128.368 351.625 128.016C351.988 127.664 352.473 127.488 353.081 127.488H353.897V129.008H353.081C352.654 129.008 352.297 129.077 352.009 129.216C351.721 129.355 351.502 129.568 351.353 129.856C351.214 130.144 351.145 130.517 351.145 130.976V136H349.449Z" fill="#090B0B"/> +<path d="M275.324 151.95C277.662 151.95 278.74 150.452 278.74 148.1C278.74 145.734 277.662 144.25 275.324 144.25H274.414V151.95H275.324ZM273.112 153V143.2H275.324C278.418 143.2 280.042 145.076 280.042 148.1C280.042 151.124 278.418 153 275.324 153H273.112ZM281.43 151.054C281.43 149.752 282.494 148.884 284.958 148.716L286.26 148.632V148.492C286.26 147.036 285.658 146.602 284.65 146.602C283.712 146.602 283.04 147.106 282.886 147.96H281.626C281.878 146.518 283.138 145.58 284.678 145.58C286.372 145.58 287.562 146.364 287.562 148.73V151.768C287.562 152.272 287.618 152.706 287.702 153H286.456C286.386 152.72 286.358 152.342 286.358 151.936C285.952 152.664 285.224 153.14 283.992 153.14C282.298 153.14 281.43 152.188 281.43 151.054ZM282.732 151.012C282.732 151.642 283.194 152.188 284.3 152.188C285.574 152.188 286.26 151.334 286.26 150.284V149.57L285.21 149.64C283.488 149.766 282.732 150.172 282.732 151.012ZM292.701 149.752C291.399 149.528 290.307 149.052 290.307 147.694C290.307 146.35 291.609 145.58 293.079 145.58C294.437 145.58 295.725 146.294 295.907 147.652H294.605C294.493 146.952 293.877 146.602 293.079 146.602C292.225 146.602 291.609 147.008 291.609 147.68C291.609 148.394 292.225 148.562 292.841 148.688L293.541 148.842C295.109 149.122 296.047 149.668 296.047 150.984C296.047 152.328 294.801 153.14 293.163 153.14C291.609 153.14 290.363 152.384 290.125 150.886H291.427C291.567 151.754 292.295 152.118 293.163 152.118C294.143 152.118 294.745 151.698 294.745 150.998C294.745 150.298 294.213 150.074 293.401 149.892L292.701 149.752ZM303.525 148.716C303.525 147.26 302.923 146.602 301.887 146.602C300.711 146.602 299.941 147.428 299.941 148.982V153H298.639V143.2H299.941V146.98C300.417 146.056 301.257 145.58 302.335 145.58C303.819 145.58 304.827 146.49 304.827 148.52V153H303.525V148.716ZM306.691 143.2H307.993V147.008C308.567 146.084 309.491 145.58 310.527 145.58C312.221 145.58 313.733 146.938 313.733 149.36C313.733 151.782 312.235 153.14 310.527 153.14C309.491 153.14 308.567 152.636 307.993 151.712V153H306.691V143.2ZM307.993 149.36C307.993 151.096 308.987 152.118 310.205 152.118C311.437 152.118 312.417 151.096 312.417 149.36C312.417 147.624 311.437 146.602 310.205 146.602C308.987 146.602 307.993 147.624 307.993 149.36ZM318.298 153.14C316.45 153.14 314.826 151.852 314.826 149.36C314.826 146.868 316.45 145.58 318.298 145.58C320.146 145.58 321.784 146.868 321.784 149.36C321.784 151.852 320.146 153.14 318.298 153.14ZM316.128 149.36C316.128 151.208 317.108 152.118 318.298 152.118C319.488 152.118 320.468 151.208 320.468 149.36C320.468 147.512 319.488 146.602 318.298 146.602C317.108 146.602 316.128 147.512 316.128 149.36ZM323.438 151.054C323.438 149.752 324.502 148.884 326.966 148.716L328.268 148.632V148.492C328.268 147.036 327.666 146.602 326.658 146.602C325.72 146.602 325.048 147.106 324.894 147.96H323.634C323.886 146.518 325.146 145.58 326.686 145.58C328.38 145.58 329.57 146.364 329.57 148.73V151.768C329.57 152.272 329.626 152.706 329.71 153H328.464C328.394 152.72 328.366 152.342 328.366 151.936C327.96 152.664 327.232 153.14 326 153.14C324.306 153.14 323.438 152.188 323.438 151.054ZM324.74 151.012C324.74 151.642 325.202 152.188 326.308 152.188C327.582 152.188 328.268 151.334 328.268 150.284V149.57L327.218 149.64C325.496 149.766 324.74 150.172 324.74 151.012ZM335.326 145.72V146.84C335.676 146.07 336.334 145.72 337.426 145.72H338.798V146.77H337.314C335.984 146.77 335.326 147.302 335.326 148.828V151.95H337.818V153H331.77V151.95H334.024V146.77H331.77V145.72H335.326ZM346.709 143.2V153H345.407V151.712C344.833 152.636 343.909 153.14 342.873 153.14C341.165 153.14 339.667 151.782 339.667 149.36C339.667 146.938 341.179 145.58 342.873 145.58C343.909 145.58 344.833 146.084 345.407 147.008V143.2H346.709ZM340.983 149.36C340.983 151.096 341.963 152.118 343.195 152.118C344.413 152.118 345.407 151.096 345.407 149.36C345.407 147.624 344.413 146.602 343.195 146.602C341.963 146.602 340.983 147.624 340.983 149.36ZM359.775 152.496V149.892H357.171V148.828H359.775V146.224H360.839V148.828H363.443V149.892H360.839V152.496H359.775ZM283.123 168L282.437 165.76H278.531L277.845 168H276.501L279.679 158.2H281.289L284.467 168H283.123ZM278.853 164.71H282.115L280.491 159.334L278.853 164.71ZM288.445 167.776C286.793 167.776 285.309 166.516 285.309 164.178C285.309 161.84 286.793 160.58 288.445 160.58C289.481 160.58 290.349 161.042 290.881 161.938V160.72H292.183V166.684C292.183 169.428 290.923 170.66 288.767 170.66C287.017 170.66 285.813 169.736 285.589 168.252H286.835C286.989 169.134 287.675 169.708 288.767 169.708C290.279 169.708 290.881 168.756 290.881 166.684V166.418C290.349 167.314 289.481 167.776 288.445 167.776ZM286.611 164.178C286.611 165.844 287.549 166.754 288.739 166.754C289.929 166.754 290.881 165.844 290.881 164.178C290.881 162.526 289.929 161.602 288.739 161.602C287.549 161.602 286.611 162.526 286.611 164.178ZM297.336 168.14C295.46 168.14 293.822 166.796 293.822 164.36C293.822 161.966 295.418 160.58 297.308 160.58C299.632 160.58 300.836 162.274 300.794 164.654H295.138V164.794C295.208 166.32 296.202 167.118 297.336 167.118C298.302 167.118 299.044 166.642 299.338 165.788H300.668C300.234 167.328 298.904 168.14 297.336 168.14ZM295.152 163.702H299.45C299.31 162.33 298.456 161.602 297.294 161.602C296.202 161.602 295.292 162.33 295.152 163.702ZM307.572 163.716C307.572 162.26 306.97 161.602 305.934 161.602C304.758 161.602 303.988 162.428 303.988 163.982V168H302.686V160.72H303.988V162.008C304.45 161.07 305.304 160.58 306.396 160.58C307.88 160.58 308.874 161.49 308.874 163.52V168H307.572V163.716ZM314.406 165.928C314.406 166.866 314.952 166.95 315.638 166.95H316.982V168H315.288C314.098 168 313.104 167.692 313.104 166.054V161.77H310.808V160.72H313.104V159.04H314.406V160.72H316.982V161.77H314.406V165.928ZM322.107 164.752C320.805 164.528 319.713 164.052 319.713 162.694C319.713 161.35 321.015 160.58 322.485 160.58C323.843 160.58 325.131 161.294 325.313 162.652H324.011C323.899 161.952 323.283 161.602 322.485 161.602C321.631 161.602 321.015 162.008 321.015 162.68C321.015 163.394 321.631 163.562 322.247 163.688L322.947 163.842C324.515 164.122 325.453 164.668 325.453 165.984C325.453 167.328 324.207 168.14 322.569 168.14C321.015 168.14 319.769 167.384 319.531 165.886H320.833C320.973 166.754 321.701 167.118 322.569 167.118C323.549 167.118 324.151 166.698 324.151 165.998C324.151 165.298 323.619 165.074 322.807 164.892L322.107 164.752ZM335.942 164.36C335.942 161.868 337.566 160.58 339.442 160.58C341.08 160.58 342.34 161.448 342.746 163.072H341.43C341.122 162.12 340.436 161.602 339.442 161.602C338.238 161.602 337.244 162.54 337.244 164.36C337.244 166.18 338.238 167.118 339.442 167.118C340.436 167.118 341.136 166.6 341.444 165.634H342.746C342.354 167.272 341.08 168.14 339.442 168.14C337.566 168.14 335.942 166.852 335.942 164.36ZM349.734 163.716C349.734 162.26 349.132 161.602 348.096 161.602C346.92 161.602 346.15 162.428 346.15 163.982V168H344.848V158.2H346.15V161.98C346.626 161.056 347.466 160.58 348.544 160.58C350.028 160.58 351.036 161.49 351.036 163.52V168H349.734V163.716ZM352.844 166.054C352.844 164.752 353.908 163.884 356.372 163.716L357.674 163.632V163.492C357.674 162.036 357.072 161.602 356.064 161.602C355.126 161.602 354.454 162.106 354.3 162.96H353.04C353.292 161.518 354.552 160.58 356.092 160.58C357.786 160.58 358.976 161.364 358.976 163.73V166.768C358.976 167.272 359.032 167.706 359.116 168H357.87C357.8 167.72 357.772 167.342 357.772 166.936C357.366 167.664 356.638 168.14 355.406 168.14C353.712 168.14 352.844 167.188 352.844 166.054ZM354.146 166.012C354.146 166.642 354.608 167.188 355.714 167.188C356.988 167.188 357.674 166.334 357.674 165.284V164.57L356.624 164.64C354.902 164.766 354.146 165.172 354.146 166.012ZM364.815 165.928C364.815 166.866 365.361 166.95 366.047 166.95H367.391V168H365.697C364.507 168 363.513 167.692 363.513 166.054V161.77H361.217V160.72H363.513V159.04H364.815V160.72H367.391V161.77H364.815V165.928Z" fill="#090B0B"/> +<rect x="247" y="112" width="17" height="17" fill="url(#pattern0_242_649)"/> +<rect x="226" y="113" width="17" height="17" fill="url(#pattern1_242_649)"/> +<rect width="199" height="71" transform="translate(435 109)" fill="#A19CC8" fill-opacity="0.5"/> +<path d="M517.41 136L521.506 124.64H523.698L527.794 136H525.954L524.914 133.04H520.274L519.25 136H517.41ZM520.818 131.472H524.386L522.594 126.288L520.818 131.472ZM529.474 136V124.64H533.714C535.015 124.64 536.028 124.955 536.754 125.584C537.479 126.203 537.842 127.067 537.842 128.176C537.842 128.912 537.676 129.552 537.346 130.096C537.015 130.629 536.54 131.04 535.922 131.328C535.314 131.605 534.578 131.744 533.714 131.744H531.202V136H529.474ZM531.202 130.176H533.666C534.444 130.176 535.036 130.011 535.442 129.68C535.847 129.339 536.05 128.837 536.05 128.176C536.05 127.525 535.847 127.04 535.442 126.72C535.036 126.389 534.444 126.224 533.666 126.224H531.202V130.176ZM539.997 136V124.64H541.725V136H539.997ZM547.749 136.192C546.949 136.192 546.272 136.069 545.717 135.824C545.173 135.579 544.752 135.243 544.453 134.816C544.154 134.379 543.984 133.877 543.941 133.312L545.685 133.232C545.77 133.701 545.973 134.075 546.293 134.352C546.613 134.629 547.098 134.768 547.749 134.768C548.282 134.768 548.704 134.683 549.013 134.512C549.322 134.341 549.477 134.069 549.477 133.696C549.477 133.493 549.424 133.323 549.317 133.184C549.221 133.035 549.029 132.912 548.741 132.816C548.453 132.709 548.026 132.608 547.461 132.512C546.618 132.352 545.957 132.165 545.477 131.952C544.997 131.739 544.656 131.467 544.453 131.136C544.25 130.805 544.149 130.405 544.149 129.936C544.149 129.157 544.437 128.523 545.013 128.032C545.6 127.541 546.442 127.296 547.541 127.296C548.277 127.296 548.896 127.424 549.397 127.68C549.898 127.925 550.288 128.261 550.565 128.688C550.853 129.104 551.034 129.573 551.109 130.096L549.365 130.192C549.312 129.893 549.205 129.637 549.045 129.424C548.896 129.2 548.693 129.029 548.437 128.912C548.181 128.784 547.877 128.72 547.525 128.72C546.981 128.72 546.576 128.827 546.309 129.04C546.042 129.253 545.909 129.531 545.909 129.872C545.909 130.128 545.968 130.336 546.085 130.496C546.213 130.656 546.416 130.789 546.693 130.896C546.97 130.992 547.338 131.077 547.797 131.152C548.682 131.301 549.376 131.488 549.877 131.712C550.378 131.925 550.73 132.192 550.933 132.512C551.136 132.832 551.237 133.221 551.237 133.68C551.237 134.213 551.088 134.667 550.789 135.04C550.49 135.413 550.08 135.701 549.557 135.904C549.034 136.096 548.432 136.192 547.749 136.192Z" fill="#090B0B"/> +<path d="M494.13 143.2H496.58C499.03 143.2 500.444 144.138 500.444 146.168C500.444 148.198 499.03 149.136 496.58 149.136H495.432V153H494.13V143.2ZM495.432 148.086H496.58C498.232 148.086 499.142 147.554 499.142 146.168C499.142 144.782 498.232 144.25 496.58 144.25H495.432V148.086ZM506.395 145.72V151.95H508.621V153H502.587V151.95H505.093V146.77H502.587V145.72H506.395ZM505.051 144.684V143.074H506.437V144.684H505.051ZM510.331 145.72H511.633V147.022C512.207 146.084 513.131 145.58 514.181 145.58C515.861 145.58 517.373 146.938 517.373 149.36C517.373 151.782 515.875 153.14 514.181 153.14C513.131 153.14 512.207 152.636 511.633 151.698V155.52H510.331V145.72ZM511.633 149.36C511.633 151.096 512.627 152.118 513.845 152.118C515.077 152.118 516.071 151.096 516.071 149.36C516.071 147.624 515.077 146.602 513.845 146.602C512.627 146.602 511.633 147.624 511.633 149.36ZM522.079 153.14C520.203 153.14 518.565 151.796 518.565 149.36C518.565 146.966 520.161 145.58 522.051 145.58C524.375 145.58 525.579 147.274 525.537 149.654H519.881V149.794C519.951 151.32 520.945 152.118 522.079 152.118C523.045 152.118 523.787 151.642 524.081 150.788H525.411C524.977 152.328 523.647 153.14 522.079 153.14ZM519.895 148.702H524.193C524.053 147.33 523.199 146.602 522.037 146.602C520.945 146.602 520.035 147.33 519.895 148.702ZM531.418 143.2V151.95H533.644V153H527.61V151.95H530.116V144.25H527.61V143.2H531.418ZM540.002 145.72V151.95H542.228V153H536.194V151.95H538.7V146.77H536.194V145.72H540.002ZM538.658 144.684V143.074H540.044V144.684H538.658ZM549.118 148.716C549.118 147.26 548.516 146.602 547.48 146.602C546.304 146.602 545.534 147.428 545.534 148.982V153H544.232V145.72H545.534V147.008C545.996 146.07 546.85 145.58 547.942 145.58C549.426 145.58 550.42 146.49 550.42 148.52V153H549.118V148.716ZM555.685 153.14C553.809 153.14 552.171 151.796 552.171 149.36C552.171 146.966 553.767 145.58 555.657 145.58C557.981 145.58 559.185 147.274 559.143 149.654H553.487V149.794C553.557 151.32 554.551 152.118 555.685 152.118C556.651 152.118 557.393 151.642 557.687 150.788H559.017C558.583 152.328 557.253 153.14 555.685 153.14ZM553.501 148.702H557.799C557.659 147.33 556.805 146.602 555.643 146.602C554.551 146.602 553.641 147.33 553.501 148.702ZM563.653 149.752C562.351 149.528 561.259 149.052 561.259 147.694C561.259 146.35 562.561 145.58 564.031 145.58C565.389 145.58 566.677 146.294 566.859 147.652H565.557C565.445 146.952 564.829 146.602 564.031 146.602C563.177 146.602 562.561 147.008 562.561 147.68C562.561 148.394 563.177 148.562 563.793 148.688L564.493 148.842C566.061 149.122 566.999 149.668 566.999 150.984C566.999 152.328 565.753 153.14 564.115 153.14C562.561 153.14 561.315 152.384 561.077 150.886H562.379C562.519 151.754 563.247 152.118 564.115 152.118C565.095 152.118 565.697 151.698 565.697 150.998C565.697 150.298 565.165 150.074 564.353 149.892L563.653 149.752ZM571.775 153V151.39H573.161V153L572.587 154.736H571.859L572.349 153H571.775ZM464.5 168L463.366 158.2H464.528L465.41 165.732V166.502L466.768 159.6H467.79L469.148 166.502V165.732L470.044 158.2H471.192L470.058 168H468.42L467.286 161.994L466.138 168H464.5ZM475.73 168.14C473.854 168.14 472.216 166.796 472.216 164.36C472.216 161.966 473.812 160.58 475.702 160.58C478.026 160.58 479.23 162.274 479.188 164.654H473.532V164.794C473.602 166.32 474.596 167.118 475.73 167.118C476.696 167.118 477.438 166.642 477.732 165.788H479.062C478.628 167.328 477.298 168.14 475.73 168.14ZM473.546 163.702H477.844C477.704 162.33 476.85 161.602 475.688 161.602C474.596 161.602 473.686 162.33 473.546 163.702ZM480.883 158.2H482.185V162.008C482.759 161.084 483.683 160.58 484.719 160.58C486.413 160.58 487.925 161.938 487.925 164.36C487.925 166.782 486.427 168.14 484.719 168.14C483.683 168.14 482.759 167.636 482.185 166.712V168H480.883V158.2ZM482.185 164.36C482.185 166.096 483.179 167.118 484.397 167.118C485.629 167.118 486.609 166.096 486.609 164.36C486.609 162.624 485.629 161.602 484.397 161.602C483.179 161.602 482.185 162.624 482.185 164.36ZM494.521 163.716C494.521 162.26 493.919 161.602 492.883 161.602C491.707 161.602 490.937 162.428 490.937 163.982V168H489.635V158.2H490.937V161.98C491.413 161.056 492.253 160.58 493.331 160.58C494.815 160.58 495.823 161.49 495.823 163.52V168H494.521V163.716ZM500.893 168.14C499.045 168.14 497.421 166.852 497.421 164.36C497.421 161.868 499.045 160.58 500.893 160.58C502.741 160.58 504.379 161.868 504.379 164.36C504.379 166.852 502.741 168.14 500.893 168.14ZM498.723 164.36C498.723 166.208 499.703 167.118 500.893 167.118C502.083 167.118 503.063 166.208 503.063 164.36C503.063 162.512 502.083 161.602 500.893 161.602C499.703 161.602 498.723 162.512 498.723 164.36ZM509.294 168.14C507.446 168.14 505.822 166.852 505.822 164.36C505.822 161.868 507.446 160.58 509.294 160.58C511.142 160.58 512.78 161.868 512.78 164.36C512.78 166.852 511.142 168.14 509.294 168.14ZM507.124 164.36C507.124 166.208 508.104 167.118 509.294 167.118C510.484 167.118 511.464 166.208 511.464 164.36C511.464 162.512 510.484 161.602 509.294 161.602C508.104 161.602 507.124 162.512 507.124 164.36ZM514.924 168V158.2H516.226V164.374L519.614 160.72H521.196L518.242 163.814L521.196 168H519.698L517.402 164.696L516.226 165.928V168H514.924ZM525.706 164.752C524.404 164.528 523.312 164.052 523.312 162.694C523.312 161.35 524.614 160.58 526.084 160.58C527.442 160.58 528.73 161.294 528.912 162.652H527.61C527.498 161.952 526.882 161.602 526.084 161.602C525.23 161.602 524.614 162.008 524.614 162.68C524.614 163.394 525.23 163.562 525.846 163.688L526.546 163.842C528.114 164.122 529.052 164.668 529.052 165.984C529.052 167.328 527.806 168.14 526.168 168.14C524.614 168.14 523.368 167.384 523.13 165.886H524.432C524.572 166.754 525.3 167.118 526.168 167.118C527.148 167.118 527.75 166.698 527.75 165.998C527.75 165.298 527.218 165.074 526.406 164.892L525.706 164.752ZM533.827 168V166.39H535.213V168L534.639 169.736H533.911L534.401 168H533.827ZM553.934 168L553.248 165.76H549.342L548.656 168H547.312L550.49 158.2H552.1L555.278 168H553.934ZM549.664 164.71H552.926L551.302 159.334L549.664 164.71ZM556.344 164.36C556.344 161.868 557.968 160.58 559.844 160.58C561.482 160.58 562.742 161.448 563.148 163.072H561.832C561.524 162.12 560.838 161.602 559.844 161.602C558.64 161.602 557.646 162.54 557.646 164.36C557.646 166.18 558.64 167.118 559.844 167.118C560.838 167.118 561.538 166.6 561.846 165.634H563.148C562.756 167.272 561.482 168.14 559.844 168.14C557.968 168.14 556.344 166.852 556.344 164.36ZM568.414 165.928C568.414 166.866 568.96 166.95 569.646 166.95H570.99V168H569.296C568.106 168 567.112 167.692 567.112 166.054V161.77H564.816V160.72H567.112V159.04H568.414V160.72H570.99V161.77H568.414V165.928ZM577.669 160.72V166.95H579.895V168H573.861V166.95H576.367V161.77H573.861V160.72H577.669ZM576.325 159.684V158.074H577.711V159.684H576.325ZM584.909 168.14C583.061 168.14 581.437 166.852 581.437 164.36C581.437 161.868 583.061 160.58 584.909 160.58C586.757 160.58 588.395 161.868 588.395 164.36C588.395 166.852 586.757 168.14 584.909 168.14ZM582.739 164.36C582.739 166.208 583.719 167.118 584.909 167.118C586.099 167.118 587.079 166.208 587.079 164.36C587.079 162.512 586.099 161.602 584.909 161.602C583.719 161.602 582.739 162.512 582.739 164.36ZM595.187 163.716C595.187 162.26 594.585 161.602 593.549 161.602C592.373 161.602 591.603 162.428 591.603 163.982V168H590.301V160.72H591.603V162.008C592.065 161.07 592.919 160.58 594.011 160.58C595.495 160.58 596.489 161.49 596.489 163.52V168H595.187V163.716ZM601.32 164.752C600.018 164.528 598.926 164.052 598.926 162.694C598.926 161.35 600.228 160.58 601.698 160.58C603.056 160.58 604.344 161.294 604.526 162.652H603.224C603.112 161.952 602.496 161.602 601.698 161.602C600.844 161.602 600.228 162.008 600.228 162.68C600.228 163.394 600.844 163.562 601.46 163.688L602.16 163.842C603.728 164.122 604.666 164.668 604.666 165.984C604.666 167.328 603.42 168.14 601.782 168.14C600.228 168.14 598.982 167.384 598.744 165.886H600.046C600.186 166.754 600.914 167.118 601.782 167.118C602.762 167.118 603.364 166.698 603.364 165.998C603.364 165.298 602.832 165.074 602.02 164.892L601.32 164.752Z" fill="#090B0B"/> +<path d="M446.761 123.604C444.635 123.346 443.136 121.815 443.136 119.834C443.136 119.028 443.426 118.158 443.909 117.578C443.7 117.046 443.732 115.918 443.974 115.451C444.618 115.37 445.489 115.709 446.004 116.176C446.616 115.982 447.261 115.886 448.051 115.886C448.84 115.886 449.485 115.982 450.065 116.16C450.564 115.709 451.45 115.37 452.095 115.451C452.321 115.886 452.353 117.014 452.143 117.562C452.659 118.174 452.933 118.996 452.933 119.834C452.933 121.815 451.434 123.314 449.275 123.588C449.823 123.942 450.194 124.716 450.194 125.602V127.278C450.194 127.761 450.596 128.035 451.08 127.842C453.996 126.73 456.284 123.813 456.284 120.204C456.284 115.644 452.578 111.922 448.018 111.922C443.458 111.922 439.784 115.644 439.784 120.204C439.784 123.781 442.056 126.746 445.118 127.858C445.553 128.019 445.972 127.729 445.972 127.294V126.005C445.746 126.102 445.456 126.166 445.198 126.166C444.135 126.166 443.507 125.586 443.055 124.506C442.878 124.071 442.685 123.813 442.314 123.765C442.121 123.749 442.056 123.668 442.056 123.572C442.056 123.378 442.379 123.233 442.701 123.233C443.168 123.233 443.571 123.523 443.99 124.12C444.312 124.587 444.651 124.796 445.053 124.796C445.456 124.796 445.714 124.651 446.085 124.281C446.359 124.007 446.568 123.765 446.761 123.604Z" fill="black"/> +<rect x="460" y="112" width="16" height="16" fill="url(#pattern2_242_649)"/> +<rect width="199" height="71" transform="translate(647 109)" fill="#A19CC8" fill-opacity="0.5"/> +<path d="M730.716 136V124.64H732.444V136H730.716ZM735.239 136V124.64H738.887C740.669 124.64 742.039 125.136 742.999 126.128C743.959 127.109 744.439 128.512 744.439 130.336C744.439 132.149 743.965 133.547 743.015 134.528C742.077 135.509 740.733 136 738.983 136H735.239ZM736.967 134.416H738.887C740.146 134.416 741.085 134.08 741.703 133.408C742.333 132.725 742.647 131.701 742.647 130.336C742.647 128.949 742.333 127.92 741.703 127.248C741.085 126.565 740.146 126.224 738.887 126.224H736.967V134.416ZM746.606 136V124.64H753.998V126.224H748.334V129.536H753.806V131.072H748.334V134.416H754.126V136H746.606ZM759.499 136.192C758.699 136.192 758.022 136.069 757.467 135.824C756.923 135.579 756.502 135.243 756.203 134.816C755.904 134.379 755.734 133.877 755.691 133.312L757.435 133.232C757.52 133.701 757.723 134.075 758.043 134.352C758.363 134.629 758.848 134.768 759.499 134.768C760.032 134.768 760.454 134.683 760.763 134.512C761.072 134.341 761.227 134.069 761.227 133.696C761.227 133.493 761.174 133.323 761.067 133.184C760.971 133.035 760.779 132.912 760.491 132.816C760.203 132.709 759.776 132.608 759.211 132.512C758.368 132.352 757.707 132.165 757.227 131.952C756.747 131.739 756.406 131.467 756.203 131.136C756 130.805 755.899 130.405 755.899 129.936C755.899 129.157 756.187 128.523 756.763 128.032C757.35 127.541 758.192 127.296 759.291 127.296C760.027 127.296 760.646 127.424 761.147 127.68C761.648 127.925 762.038 128.261 762.315 128.688C762.603 129.104 762.784 129.573 762.859 130.096L761.115 130.192C761.062 129.893 760.955 129.637 760.795 129.424C760.646 129.2 760.443 129.029 760.187 128.912C759.931 128.784 759.627 128.72 759.275 128.72C758.731 128.72 758.326 128.827 758.059 129.04C757.792 129.253 757.659 129.531 757.659 129.872C757.659 130.128 757.718 130.336 757.835 130.496C757.963 130.656 758.166 130.789 758.443 130.896C758.72 130.992 759.088 131.077 759.547 131.152C760.432 131.301 761.126 131.488 761.627 131.712C762.128 131.925 762.48 132.192 762.683 132.512C762.886 132.832 762.987 133.221 762.987 133.68C762.987 134.213 762.838 134.667 762.539 135.04C762.24 135.413 761.83 135.701 761.307 135.904C760.784 136.096 760.182 136.192 759.499 136.192Z" fill="#090B0B"/> +<path d="M686.259 143.2H687.603L684.425 153H682.815L679.637 143.2H680.981L683.627 151.866L686.259 143.2ZM691.315 148.366C689.803 148.002 688.837 147.232 688.837 145.748C688.837 144.054 690.293 143.06 691.987 143.06C693.695 143.06 694.941 144.026 695.193 145.72H693.891C693.695 144.684 693.037 144.11 691.987 144.11C690.979 144.11 690.139 144.684 690.139 145.706C690.139 146.616 690.797 147.022 691.581 147.246L692.911 147.596C694.563 147.988 695.417 148.856 695.417 150.312C695.417 151.978 694.031 153.14 692.085 153.14C690.209 153.14 688.893 152.034 688.641 150.242H689.943C690.153 151.376 690.951 152.09 692.085 152.09C693.373 152.09 694.115 151.334 694.115 150.354C694.115 149.444 693.527 148.968 692.645 148.716L691.315 148.366ZM705.346 148.1C705.346 145.118 706.438 143.06 709.042 143.06C711.03 143.06 712.136 144.25 712.472 146.168H711.17C710.918 144.922 710.246 144.11 709.042 144.11C707.292 144.11 706.648 145.79 706.648 148.1C706.648 150.424 707.292 152.09 709.042 152.09C710.246 152.09 710.918 151.278 711.156 150.06H712.472C712.136 151.964 711.016 153.14 709.042 153.14C706.438 153.14 705.346 151.082 705.346 148.1ZM717.233 153.14C715.385 153.14 713.761 151.852 713.761 149.36C713.761 146.868 715.385 145.58 717.233 145.58C719.081 145.58 720.719 146.868 720.719 149.36C720.719 151.852 719.081 153.14 717.233 153.14ZM715.063 149.36C715.063 151.208 716.043 152.118 717.233 152.118C718.423 152.118 719.403 151.208 719.403 149.36C719.403 147.512 718.423 146.602 717.233 146.602C716.043 146.602 715.063 147.512 715.063 149.36ZM728.841 143.2V153H727.539V151.712C726.965 152.636 726.041 153.14 725.005 153.14C723.297 153.14 721.799 151.782 721.799 149.36C721.799 146.938 723.311 145.58 725.005 145.58C726.041 145.58 726.965 146.084 727.539 147.008V143.2H728.841ZM723.115 149.36C723.115 151.096 724.095 152.118 725.327 152.118C726.545 152.118 727.539 151.096 727.539 149.36C727.539 147.624 726.545 146.602 725.327 146.602C724.095 146.602 723.115 147.624 723.115 149.36ZM734.079 153.14C732.203 153.14 730.565 151.796 730.565 149.36C730.565 146.966 732.161 145.58 734.051 145.58C736.375 145.58 737.579 147.274 737.537 149.654H731.881V149.794C731.951 151.32 732.945 152.118 734.079 152.118C735.045 152.118 735.787 151.642 736.081 150.788H737.411C736.977 152.328 735.647 153.14 734.079 153.14ZM731.895 148.702H736.193C736.053 147.33 735.199 146.602 734.037 146.602C732.945 146.602 732.035 147.33 731.895 148.702ZM741.766 153V151.39H743.152V153L742.578 154.736H741.85L742.34 153H741.766ZM755.756 148.1C755.756 145.118 756.848 143.06 759.452 143.06C761.44 143.06 762.546 144.25 762.882 146.168H761.58C761.328 144.922 760.656 144.11 759.452 144.11C757.702 144.11 757.058 145.79 757.058 148.1C757.058 150.424 757.702 152.09 759.452 152.09C760.656 152.09 761.328 151.278 761.566 150.06H762.882C762.546 151.964 761.426 153.14 759.452 153.14C756.848 153.14 755.756 151.082 755.756 148.1ZM765.767 150.004C765.767 151.46 766.369 152.118 767.405 152.118C768.581 152.118 769.351 151.292 769.351 149.724V145.72H770.653V153H769.351V151.712C768.889 152.65 768.035 153.14 766.943 153.14C765.459 153.14 764.465 152.23 764.465 150.2V145.72H765.767V150.004ZM776.269 145.72V146.84C776.619 146.07 777.277 145.72 778.369 145.72H779.741V146.77H778.257C776.927 146.77 776.269 147.302 776.269 148.828V151.95H778.761V153H772.713V151.95H774.967V146.77H772.713V145.72H776.269ZM784.055 149.752C782.753 149.528 781.661 149.052 781.661 147.694C781.661 146.35 782.963 145.58 784.433 145.58C785.791 145.58 787.079 146.294 787.261 147.652H785.959C785.847 146.952 785.231 146.602 784.433 146.602C783.579 146.602 782.963 147.008 782.963 147.68C782.963 148.394 783.579 148.562 784.195 148.688L784.895 148.842C786.463 149.122 787.401 149.668 787.401 150.984C787.401 152.328 786.155 153.14 784.517 153.14C782.963 153.14 781.717 152.384 781.479 150.886H782.781C782.921 151.754 783.649 152.118 784.517 152.118C785.497 152.118 786.099 151.698 786.099 150.998C786.099 150.298 785.567 150.074 784.755 149.892L784.055 149.752ZM792.848 153.14C791 153.14 789.376 151.852 789.376 149.36C789.376 146.868 791 145.58 792.848 145.58C794.696 145.58 796.334 146.868 796.334 149.36C796.334 151.852 794.696 153.14 792.848 153.14ZM790.678 149.36C790.678 151.208 791.658 152.118 792.848 152.118C794.038 152.118 795.018 151.208 795.018 149.36C795.018 147.512 794.038 146.602 792.848 146.602C791.658 146.602 790.678 147.512 790.678 149.36ZM801.474 145.72V146.84C801.824 146.07 802.482 145.72 803.574 145.72H804.946V146.77H803.462C802.132 146.77 801.474 147.302 801.474 148.828V151.95H803.966V153H797.918V151.95H800.172V146.77H797.918V145.72H801.474ZM808.979 153V151.39H810.365V153L809.791 154.736H809.063L809.553 153H808.979ZM710.975 165.438C711.087 166.334 711.437 167.09 712.571 167.09C713.971 167.09 714.195 165.998 714.195 164.794V159.25H710.247V158.2H715.511V164.794C715.511 166.572 714.923 168.14 712.599 168.14C710.569 168.14 709.827 166.908 709.729 165.438H710.975ZM721.336 168.14C719.46 168.14 717.822 166.796 717.822 164.36C717.822 161.966 719.418 160.58 721.308 160.58C723.632 160.58 724.836 162.274 724.794 164.654H719.138V164.794C719.208 166.32 720.202 167.118 721.336 167.118C722.302 167.118 723.044 166.642 723.338 165.788H724.668C724.234 167.328 722.904 168.14 721.336 168.14ZM719.152 163.702H723.45C723.31 162.33 722.456 161.602 721.294 161.602C720.202 161.602 719.292 162.33 719.152 163.702ZM730.004 165.928C730.004 166.866 730.55 166.95 731.236 166.95H732.58V168H730.886C729.696 168 728.702 167.692 728.702 166.054V161.77H726.406V160.72H728.702V159.04H730.004V160.72H732.58V161.77H730.004V165.928ZM735.144 168V158.2H737.72C739.638 158.2 741.36 158.746 741.36 160.734C741.36 162.022 740.576 162.652 739.54 162.932C740.758 163.212 741.612 163.898 741.612 165.34C741.612 167.356 739.89 168 737.79 168H735.144ZM736.446 166.95H737.79C739.092 166.95 740.31 166.614 740.31 165.214C740.31 163.814 739.092 163.506 737.79 163.506H736.446V166.95ZM736.446 162.456H737.72C738.84 162.456 740.058 162.204 740.058 160.846C740.058 159.488 738.84 159.25 737.72 159.25H736.446V162.456ZM746.723 160.72V161.84C747.073 161.07 747.731 160.72 748.823 160.72H750.195V161.77H748.711C747.381 161.77 746.723 162.302 746.723 163.828V166.95H749.215V168H743.167V166.95H745.421V161.77H743.167V160.72H746.723ZM751.639 166.054C751.639 164.752 752.703 163.884 755.167 163.716L756.469 163.632V163.492C756.469 162.036 755.867 161.602 754.859 161.602C753.921 161.602 753.249 162.106 753.095 162.96H751.835C752.087 161.518 753.347 160.58 754.887 160.58C756.581 160.58 757.771 161.364 757.771 163.73V166.768C757.771 167.272 757.827 167.706 757.911 168H756.665C756.595 167.72 756.567 167.342 756.567 166.936C756.161 167.664 755.433 168.14 754.201 168.14C752.507 168.14 751.639 167.188 751.639 166.054ZM752.941 166.012C752.941 166.642 753.403 167.188 754.509 167.188C755.783 167.188 756.469 166.334 756.469 165.284V164.57L755.419 164.64C753.697 164.766 752.941 165.172 752.941 166.012ZM764.464 160.72V166.95H766.69V168H760.656V166.95H763.162V161.77H760.656V160.72H764.464ZM763.12 159.684V158.074H764.506V159.684H763.12ZM773.58 163.716C773.58 162.26 772.978 161.602 771.942 161.602C770.766 161.602 769.996 162.428 769.996 163.982V168H768.694V160.72H769.996V162.008C770.458 161.07 771.312 160.58 772.404 160.58C773.888 160.58 774.882 161.49 774.882 163.52V168H773.58V163.716ZM779.714 164.752C778.412 164.528 777.32 164.052 777.32 162.694C777.32 161.35 778.622 160.58 780.092 160.58C781.45 160.58 782.738 161.294 782.92 162.652H781.618C781.506 161.952 780.89 161.602 780.092 161.602C779.238 161.602 778.622 162.008 778.622 162.68C778.622 163.394 779.238 163.562 779.854 163.688L780.554 163.842C782.122 164.122 783.06 164.668 783.06 165.984C783.06 167.328 781.814 168.14 780.176 168.14C778.622 168.14 777.376 167.384 777.138 165.886H778.44C778.58 166.754 779.308 167.118 780.176 167.118C781.156 167.118 781.758 166.698 781.758 165.998C781.758 165.298 781.226 165.074 780.414 164.892L779.714 164.752Z" fill="#090B0B"/> +<g clip-path="url(#clip2_242_649)"> +<path d="M666.681 116.26L659.379 112.1C659.144 111.967 658.855 111.967 658.621 112.1L651.319 116.26C651.122 116.373 651 116.58 651 116.805V125.195C651 125.42 651.122 125.627 651.319 125.74L658.621 129.9C658.856 130.034 659.145 130.034 659.379 129.9L666.682 125.74C666.879 125.627 667 125.42 667 125.195V116.805C667 116.58 666.879 116.373 666.682 116.26H666.681ZM666.223 117.142L659.173 129.19C659.126 129.271 659 129.238 659 129.144V121.255C659 121.097 658.914 120.951 658.776 120.872L651.853 116.928C651.77 116.881 651.804 116.757 651.899 116.757H665.998C666.198 116.757 666.323 116.971 666.223 117.142H666.223V117.142Z" fill="#26251E"/> +</g> +<g clip-path="url(#clip3_242_649)"> +<mask id="mask0_242_649" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="669" y="112" width="17" height="17"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M681.055 128.884C681.323 128.988 681.628 128.982 681.898 128.852L685.398 127.167C685.766 126.99 686 126.618 686 126.21V114.79C686 114.382 685.766 114.01 685.398 113.833L681.898 112.149C681.544 111.978 681.129 112.02 680.817 112.246C680.773 112.278 680.73 112.314 680.691 112.354L673.99 118.467L671.072 116.252C670.8 116.045 670.42 116.062 670.168 116.292L669.232 117.143C668.923 117.424 668.923 117.91 669.231 118.191L671.762 120.5L669.231 122.809C668.923 123.09 668.923 123.576 669.232 123.857L670.168 124.708C670.42 124.938 670.8 124.955 671.072 124.748L673.99 122.533L680.691 128.646C680.797 128.752 680.921 128.832 681.055 128.884ZM681.753 116.641L676.669 120.5L681.753 124.359V116.641Z" fill="white"/> +</mask> +<g mask="url(#mask0_242_649)"> +<path d="M685.398 113.835L681.896 112.149C681.49 111.954 681.006 112.036 680.687 112.354L669.221 122.809C668.912 123.09 668.913 123.576 669.221 123.857L670.158 124.708C670.411 124.938 670.791 124.955 671.063 124.748L684.871 114.273C685.335 113.921 686 114.252 686 114.833V114.793C686 114.385 685.766 114.012 685.398 113.835Z" fill="#0065A9"/> +<g filter="url(#filter0_d_242_649)"> +<path d="M685.398 127.165L681.896 128.851C681.49 129.046 681.006 128.964 680.687 128.646L669.221 118.191C668.912 117.91 668.913 117.424 669.221 117.143L670.158 116.292C670.411 116.062 670.791 116.045 671.063 116.252L684.871 126.727C685.335 127.079 686 126.748 686 126.167V126.207C686 126.615 685.766 126.988 685.398 127.165Z" fill="#007ACC"/> +</g> +<g filter="url(#filter1_d_242_649)"> +<path d="M681.896 128.851C681.49 129.047 681.006 128.964 680.688 128.646C681.08 129.038 681.75 128.76 681.75 128.206V112.794C681.75 112.24 681.08 111.962 680.688 112.354C681.006 112.036 681.49 111.953 681.896 112.149L685.398 113.833C685.766 114.01 686 114.382 686 114.79V126.21C686 126.618 685.766 126.99 685.398 127.167L681.896 128.851Z" fill="#1F9CF0"/> +</g> +<g style="mix-blend-mode:overlay" opacity="0.25"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M681.045 128.884C681.312 128.988 681.618 128.982 681.888 128.852L685.388 127.167C685.756 126.99 685.99 126.618 685.99 126.21V114.79C685.99 114.382 685.756 114.01 685.388 113.833L681.888 112.149C681.533 111.978 681.118 112.02 680.807 112.246C680.762 112.278 680.72 112.314 680.68 112.354L673.98 118.467L671.061 116.252C670.79 116.045 670.41 116.062 670.157 116.292L669.221 117.143C668.913 117.424 668.912 117.91 669.221 118.191L671.752 120.5L669.221 122.809C668.912 123.09 668.913 123.576 669.221 123.857L670.157 124.708C670.41 124.938 670.79 124.955 671.061 124.748L673.98 122.533L680.68 128.646C680.786 128.752 680.911 128.832 681.045 128.884ZM681.742 116.641L676.658 120.5L681.742 124.359V116.641Z" fill="url(#paint0_linear_242_649)"/> +</g> +</g> +</g> +<path d="M694.403 112.973L689.972 117.41C689.351 118.032 689 118.878 689 119.761V127.693C689 128.427 689.595 129.023 690.328 129.023H698.249C699.131 129.023 699.973 128.672 700.597 128.049L705.028 123.613C705.649 122.99 706 122.145 706 121.261V113.33C706 112.596 705.405 112 704.672 112H696.751C695.869 112 695.027 112.351 694.403 112.973Z" fill="url(#paint1_linear_242_649)"/> +<path d="M701.75 116.256H691.125V126.895H701.75V116.256Z" fill="black"/> +<path d="M696.969 124.501H692.453V125.565H696.969V124.501Z" fill="white"/> +<path d="M806.611 539V539.5H810V543.167H810.5V551.5H810V559.833H810.5V568.167H810V576.5H810.5V584.833H810V593.167H810.5V601.5H810V609.833H810.5V618.167H810V626.5H810.5V634.833H810V638.5H806.611V639H798.833V638.5H791.056V639H783.277V638.5H775.5V639H767.722V638.5H759.944V639H752.167V638.5H744.389V639H736.611V638.5H728.833V639H721.056V638.5H713.277V639H705.5V638.5H697.723V639H689.944V638.5H682.167V639H674.389V638.5H666.611V639H658.833V638.5H651.056V639H643.277V638.5H635.5V639H627.723V638.5H619.944V639H612.167V638.5H604.389V639H596.611V638.5H588.833V639H581.056V638.5H573.277V639H565.5V638.5H557.723V639H549.944V638.5H542.167V639H534.389V638.5H531V634.833H530.5V626.5H531V618.167H530.5V609.833H531V601.5H530.5V593.167H531V584.833H530.5V576.5H531V568.167H530.5V559.833H531V551.5H530.5V543.167H531V539.5H534.389V539H542.167V539.5H549.944V539H557.723V539.5H565.5V539H573.278V539.5H581.056V539H588.833V539.5H596.611V539H604.389V539.5H612.167V539H619.944V539.5H627.723V539H635.5V539.5H643.277V539H651.056V539.5H658.833V539H666.611V539.5H674.389V539H682.167V539.5H689.944V539H697.723V539.5H705.5V539H713.277V539.5H721.056V539H728.833V539.5H736.611V539H744.389V539.5H752.167V539H759.944V539.5H767.723V539H775.5V539.5H783.277V539H791.056V539.5H798.833V539H806.611Z" fill="#F8F2F1" stroke="#2F2D33" stroke-dasharray="8 8"/> +<rect width="236" height="58" transform="translate(555 561)" fill="#FF8067" fill-opacity="0.24"/> +<path d="M628.949 588L625.941 576.64H627.781L629.989 585.616L632.197 576.64H633.957L636.165 585.616L638.357 576.64H640.229L637.205 588H635.189L633.077 579.712L630.965 588H628.949ZM645.139 588.192C644.328 588.192 643.619 588.011 643.011 587.648C642.403 587.285 641.933 586.768 641.603 586.096C641.272 585.424 641.107 584.64 641.107 583.744C641.107 582.837 641.272 582.053 641.603 581.392C641.933 580.731 642.403 580.219 643.011 579.856C643.619 579.483 644.328 579.296 645.139 579.296C645.949 579.296 646.659 579.483 647.267 579.856C647.875 580.219 648.344 580.731 648.675 581.392C649.005 582.053 649.171 582.837 649.171 583.744C649.171 584.64 649.005 585.424 648.675 586.096C648.344 586.768 647.875 587.285 647.267 587.648C646.659 588.011 645.949 588.192 645.139 588.192ZM645.139 586.72C645.864 586.72 646.424 586.459 646.819 585.936C647.213 585.403 647.411 584.672 647.411 583.744C647.411 582.816 647.213 582.091 646.819 581.568C646.424 581.035 645.864 580.768 645.139 580.768C644.424 580.768 643.864 581.035 643.459 581.568C643.064 582.091 642.867 582.816 642.867 583.744C642.867 584.672 643.064 585.403 643.459 585.936C643.864 586.459 644.424 586.72 645.139 586.72ZM651.205 588V579.488H652.757L652.821 581.744L652.677 581.696C652.794 580.928 653.029 580.368 653.381 580.016C653.743 579.664 654.229 579.488 654.837 579.488H655.653V581.008H654.837C654.41 581.008 654.053 581.077 653.765 581.216C653.477 581.355 653.258 581.568 653.109 581.856C652.97 582.144 652.901 582.517 652.901 582.976V588H651.205ZM657.368 588V576.64H659.064V583.696L662.888 579.488H665.032L661.704 583.072L665.16 588H663.208L660.568 584.128L659.064 585.728V588H657.368ZM669.812 588.192C669.012 588.192 668.335 588.069 667.78 587.824C667.236 587.579 666.815 587.243 666.516 586.816C666.218 586.379 666.047 585.877 666.004 585.312L667.748 585.232C667.834 585.701 668.036 586.075 668.356 586.352C668.676 586.629 669.162 586.768 669.812 586.768C670.346 586.768 670.767 586.683 671.076 586.512C671.386 586.341 671.54 586.069 671.54 585.696C671.54 585.493 671.487 585.323 671.38 585.184C671.284 585.035 671.092 584.912 670.804 584.816C670.516 584.709 670.09 584.608 669.524 584.512C668.682 584.352 668.02 584.165 667.54 583.952C667.06 583.739 666.719 583.467 666.516 583.136C666.314 582.805 666.212 582.405 666.212 581.936C666.212 581.157 666.5 580.523 667.076 580.032C667.663 579.541 668.506 579.296 669.604 579.296C670.34 579.296 670.959 579.424 671.46 579.68C671.962 579.925 672.351 580.261 672.628 580.688C672.916 581.104 673.098 581.573 673.172 582.096L671.428 582.192C671.375 581.893 671.268 581.637 671.108 581.424C670.959 581.2 670.756 581.029 670.5 580.912C670.244 580.784 669.94 580.72 669.588 580.72C669.044 580.72 668.639 580.827 668.372 581.04C668.106 581.253 667.972 581.531 667.972 581.872C667.972 582.128 668.031 582.336 668.148 582.496C668.276 582.656 668.479 582.789 668.756 582.896C669.034 582.992 669.402 583.077 669.86 583.152C670.746 583.301 671.439 583.488 671.94 583.712C672.442 583.925 672.794 584.192 672.996 584.512C673.199 584.832 673.3 585.221 673.3 585.68C673.3 586.213 673.151 586.667 672.852 587.04C672.554 587.413 672.143 587.701 671.62 587.904C671.098 588.096 670.495 588.192 669.812 588.192ZM675.337 590.4V579.488H676.937L676.985 581.312L676.793 581.216C677.006 580.587 677.358 580.112 677.849 579.792C678.339 579.461 678.905 579.296 679.545 579.296C680.377 579.296 681.059 579.504 681.593 579.92C682.137 580.325 682.537 580.864 682.793 581.536C683.059 582.208 683.193 582.944 683.193 583.744C683.193 584.544 683.059 585.28 682.793 585.952C682.537 586.624 682.137 587.168 681.593 587.584C681.059 587.989 680.377 588.192 679.545 588.192C679.118 588.192 678.718 588.117 678.345 587.968C677.971 587.819 677.651 587.611 677.385 587.344C677.129 587.067 676.947 586.741 676.841 586.368L677.033 586.176V590.4H675.337ZM679.241 586.72C679.923 586.72 680.457 586.459 680.841 585.936C681.235 585.413 681.433 584.683 681.433 583.744C681.433 582.805 681.235 582.075 680.841 581.552C680.457 581.029 679.923 580.768 679.241 580.768C678.793 580.768 678.403 580.88 678.073 581.104C677.742 581.317 677.486 581.648 677.305 582.096C677.123 582.544 677.033 583.093 677.033 583.744C677.033 584.395 677.118 584.944 677.289 585.392C677.47 585.84 677.726 586.176 678.057 586.4C678.398 586.613 678.793 586.72 679.241 586.72ZM687.791 588.192C686.905 588.192 686.196 587.989 685.663 587.584C685.14 587.179 684.879 586.608 684.879 585.872C684.879 585.136 685.097 584.565 685.535 584.16C685.983 583.744 686.671 583.445 687.599 583.264L690.527 582.704C690.527 582.043 690.372 581.552 690.063 581.232C689.753 580.901 689.295 580.736 688.687 580.736C688.143 580.736 687.716 580.859 687.407 581.104C687.097 581.339 686.884 581.691 686.767 582.16L685.023 582.048C685.183 581.195 685.577 580.523 686.207 580.032C686.847 579.541 687.673 579.296 688.687 579.296C689.839 579.296 690.713 579.605 691.311 580.224C691.919 580.832 692.223 581.691 692.223 582.8V586.112C692.223 586.315 692.255 586.459 692.319 586.544C692.393 586.619 692.511 586.656 692.671 586.656H692.975V588C692.921 588.011 692.836 588.021 692.719 588.032C692.601 588.043 692.479 588.048 692.351 588.048C691.988 588.048 691.673 587.989 691.407 587.872C691.151 587.755 690.959 587.563 690.831 587.296C690.703 587.019 690.639 586.651 690.639 586.192L690.815 586.272C690.729 586.645 690.543 586.976 690.255 587.264C689.977 587.552 689.62 587.781 689.183 587.952C688.756 588.112 688.292 588.192 687.791 588.192ZM688.063 586.848C688.575 586.848 689.012 586.752 689.375 586.56C689.737 586.357 690.02 586.08 690.223 585.728C690.425 585.376 690.527 584.976 690.527 584.528V583.984L688.031 584.464C687.519 584.56 687.156 584.715 686.943 584.928C686.74 585.131 686.639 585.392 686.639 585.712C686.639 586.075 686.761 586.357 687.007 586.56C687.263 586.752 687.615 586.848 688.063 586.848ZM698.168 588.192C697.357 588.192 696.648 588.011 696.04 587.648C695.432 587.285 694.963 586.768 694.632 586.096C694.301 585.424 694.136 584.64 694.136 583.744C694.136 582.848 694.301 582.069 694.632 581.408C694.963 580.736 695.432 580.219 696.04 579.856C696.648 579.483 697.357 579.296 698.168 579.296C698.861 579.296 699.475 579.419 700.008 579.664C700.552 579.899 700.989 580.245 701.32 580.704C701.661 581.163 701.88 581.723 701.976 582.384L700.2 582.48C700.115 581.925 699.891 581.504 699.528 581.216C699.165 580.917 698.712 580.768 698.168 580.768C697.453 580.768 696.893 581.035 696.488 581.568C696.093 582.091 695.896 582.816 695.896 583.744C695.896 584.672 696.093 585.403 696.488 585.936C696.893 586.459 697.453 586.72 698.168 586.72C698.712 586.72 699.165 586.571 699.528 586.272C699.891 585.963 700.115 585.499 700.2 584.88L701.976 584.976C701.88 585.627 701.661 586.192 701.32 586.672C700.989 587.152 700.552 587.525 700.008 587.792C699.475 588.059 698.861 588.192 698.168 588.192ZM707.347 588.192C706.515 588.192 705.795 588.011 705.187 587.648C704.59 587.285 704.126 586.768 703.795 586.096C703.475 585.424 703.315 584.64 703.315 583.744C703.315 582.848 703.475 582.069 703.795 581.408C704.126 580.736 704.59 580.219 705.187 579.856C705.785 579.483 706.489 579.296 707.299 579.296C708.067 579.296 708.745 579.477 709.331 579.84C709.918 580.192 710.371 580.704 710.691 581.376C711.022 582.048 711.187 582.859 711.187 583.808V584.24H705.075C705.118 585.072 705.337 585.696 705.731 586.112C706.137 586.528 706.681 586.736 707.363 586.736C707.865 586.736 708.281 586.619 708.611 586.384C708.942 586.149 709.171 585.835 709.299 585.44L711.059 585.552C710.835 586.341 710.393 586.981 709.731 587.472C709.081 587.952 708.286 588.192 707.347 588.192ZM705.075 582.96H709.395C709.342 582.203 709.123 581.643 708.739 581.28C708.366 580.917 707.886 580.736 707.299 580.736C706.691 580.736 706.19 580.928 705.795 581.312C705.411 581.685 705.171 582.235 705.075 582.96ZM716.506 588.192C715.706 588.192 715.029 588.069 714.474 587.824C713.93 587.579 713.509 587.243 713.21 586.816C712.911 586.379 712.741 585.877 712.698 585.312L714.442 585.232C714.527 585.701 714.73 586.075 715.05 586.352C715.37 586.629 715.855 586.768 716.506 586.768C717.039 586.768 717.461 586.683 717.77 586.512C718.079 586.341 718.234 586.069 718.234 585.696C718.234 585.493 718.181 585.323 718.074 585.184C717.978 585.035 717.786 584.912 717.498 584.816C717.21 584.709 716.783 584.608 716.218 584.512C715.375 584.352 714.714 584.165 714.234 583.952C713.754 583.739 713.413 583.467 713.21 583.136C713.007 582.805 712.906 582.405 712.906 581.936C712.906 581.157 713.194 580.523 713.77 580.032C714.357 579.541 715.199 579.296 716.298 579.296C717.034 579.296 717.653 579.424 718.154 579.68C718.655 579.925 719.045 580.261 719.322 580.688C719.61 581.104 719.791 581.573 719.866 582.096L718.122 582.192C718.069 581.893 717.962 581.637 717.802 581.424C717.653 581.2 717.45 581.029 717.194 580.912C716.938 580.784 716.634 580.72 716.282 580.72C715.738 580.72 715.333 580.827 715.066 581.04C714.799 581.253 714.666 581.531 714.666 581.872C714.666 582.128 714.725 582.336 714.842 582.496C714.97 582.656 715.173 582.789 715.45 582.896C715.727 582.992 716.095 583.077 716.554 583.152C717.439 583.301 718.133 583.488 718.634 583.712C719.135 583.925 719.487 584.192 719.69 584.512C719.893 584.832 719.994 585.221 719.994 585.68C719.994 586.213 719.845 586.667 719.546 587.04C719.247 587.413 718.837 587.701 718.314 587.904C717.791 588.096 717.189 588.192 716.506 588.192Z" fill="#090B0B"/> +<path d="M609.273 600.366C607.761 600.002 606.795 599.232 606.795 597.748C606.795 596.054 608.251 595.06 609.945 595.06C611.653 595.06 612.899 596.026 613.151 597.72H611.849C611.653 596.684 610.995 596.11 609.945 596.11C608.937 596.11 608.097 596.684 608.097 597.706C608.097 598.616 608.755 599.022 609.539 599.246L610.869 599.596C612.521 599.988 613.375 600.856 613.375 602.312C613.375 603.978 611.989 605.14 610.043 605.14C608.167 605.14 606.851 604.034 606.599 602.242H607.901C608.111 603.376 608.909 604.09 610.043 604.09C611.331 604.09 612.073 603.334 612.073 602.354C612.073 601.444 611.485 600.968 610.603 600.716L609.273 600.366ZM618.697 602.928C618.697 603.866 619.243 603.95 619.929 603.95H621.273V605H619.579C618.389 605 617.395 604.692 617.395 603.054V598.77H615.099V597.72H617.395V596.04H618.697V597.72H621.273V598.77H618.697V602.928ZM623.528 603.054C623.528 601.752 624.592 600.884 627.056 600.716L628.358 600.632V600.492C628.358 599.036 627.756 598.602 626.748 598.602C625.81 598.602 625.138 599.106 624.984 599.96H623.724C623.976 598.518 625.236 597.58 626.776 597.58C628.47 597.58 629.66 598.364 629.66 600.73V603.768C629.66 604.272 629.716 604.706 629.8 605H628.554C628.484 604.72 628.456 604.342 628.456 603.936C628.05 604.664 627.322 605.14 626.09 605.14C624.396 605.14 623.528 604.188 623.528 603.054ZM624.83 603.012C624.83 603.642 625.292 604.188 626.398 604.188C627.672 604.188 628.358 603.334 628.358 602.284V601.57L627.308 601.64C625.586 601.766 624.83 602.172 624.83 603.012ZM637.068 600.716C637.068 599.26 636.466 598.602 635.43 598.602C634.254 598.602 633.484 599.428 633.484 600.982V605H632.182V597.72H633.484V599.008C633.946 598.07 634.8 597.58 635.892 597.58C637.376 597.58 638.37 598.49 638.37 600.52V605H637.068V600.716ZM646.799 595.2V605H645.497V603.712C644.923 604.636 643.999 605.14 642.963 605.14C641.255 605.14 639.757 603.782 639.757 601.36C639.757 598.938 641.269 597.58 642.963 597.58C643.999 597.58 644.923 598.084 645.497 599.008V595.2H646.799ZM641.073 601.36C641.073 603.096 642.053 604.118 643.285 604.118C644.503 604.118 645.497 603.096 645.497 601.36C645.497 599.624 644.503 598.602 643.285 598.602C642.053 598.602 641.073 599.624 641.073 601.36ZM648.733 603.054C648.733 601.752 649.797 600.884 652.261 600.716L653.563 600.632V600.492C653.563 599.036 652.961 598.602 651.953 598.602C651.015 598.602 650.343 599.106 650.189 599.96H648.929C649.181 598.518 650.441 597.58 651.981 597.58C653.675 597.58 654.865 598.364 654.865 600.73V603.768C654.865 604.272 654.921 604.706 655.005 605H653.759C653.689 604.72 653.661 604.342 653.661 603.936C653.255 604.664 652.527 605.14 651.295 605.14C649.601 605.14 648.733 604.188 648.733 603.054ZM650.035 603.012C650.035 603.642 650.497 604.188 651.603 604.188C652.877 604.188 653.563 603.334 653.563 602.284V601.57L652.513 601.64C650.791 601.766 650.035 602.172 650.035 603.012ZM660.621 597.72V598.84C660.971 598.07 661.629 597.72 662.721 597.72H664.093V598.77H662.609C661.279 598.77 660.621 599.302 660.621 600.828V603.95H663.113V605H657.065V603.95H659.319V598.77H657.065V597.72H660.621ZM672.004 595.2V605H670.702V603.712C670.128 604.636 669.204 605.14 668.168 605.14C666.46 605.14 664.962 603.782 664.962 601.36C664.962 598.938 666.474 597.58 668.168 597.58C669.204 597.58 670.128 598.084 670.702 599.008V595.2H672.004ZM666.278 601.36C666.278 603.096 667.258 604.118 668.49 604.118C669.708 604.118 670.702 603.096 670.702 601.36C670.702 599.624 669.708 598.602 668.49 598.602C667.258 598.602 666.278 599.624 666.278 601.36ZM682.242 601.36C682.242 598.868 683.866 597.58 685.742 597.58C687.38 597.58 688.64 598.448 689.046 600.072H687.73C687.422 599.12 686.736 598.602 685.742 598.602C684.538 598.602 683.544 599.54 683.544 601.36C683.544 603.18 684.538 604.118 685.742 604.118C686.736 604.118 687.436 603.6 687.744 602.634H689.046C688.654 604.272 687.38 605.14 685.742 605.14C683.866 605.14 682.242 603.852 682.242 601.36ZM694.003 605.14C692.155 605.14 690.531 603.852 690.531 601.36C690.531 598.868 692.155 597.58 694.003 597.58C695.851 597.58 697.489 598.868 697.489 601.36C697.489 603.852 695.851 605.14 694.003 605.14ZM691.833 601.36C691.833 603.208 692.813 604.118 694.003 604.118C695.193 604.118 696.173 603.208 696.173 601.36C696.173 599.512 695.193 598.602 694.003 598.602C692.813 598.602 691.833 599.512 691.833 601.36ZM698.723 605V597.72H699.969V598.364C700.249 597.916 700.711 597.58 701.397 597.58C702.237 597.58 702.671 598.042 702.867 598.658C703.133 598.056 703.609 597.58 704.449 597.58C705.779 597.58 706.087 598.728 706.087 599.806V605H704.841V599.904C704.841 599.302 704.757 598.602 704.001 598.602C703.231 598.602 703.035 599.414 703.021 599.918V605H701.789V599.904C701.789 599.302 701.691 598.602 700.949 598.602C700.165 598.602 699.983 599.414 699.969 599.918V605H698.723ZM707.503 597.72H708.805V599.022C709.379 598.084 710.303 597.58 711.353 597.58C713.033 597.58 714.545 598.938 714.545 601.36C714.545 603.782 713.047 605.14 711.353 605.14C710.303 605.14 709.379 604.636 708.805 603.698V607.52H707.503V597.72ZM708.805 601.36C708.805 603.096 709.799 604.118 711.017 604.118C712.249 604.118 713.243 603.096 713.243 601.36C713.243 599.624 712.249 598.602 711.017 598.602C709.799 598.602 708.805 599.624 708.805 601.36ZM717.332 602.004C717.332 603.46 717.934 604.118 718.97 604.118C720.146 604.118 720.916 603.292 720.916 601.724V597.72H722.218V605H720.916V603.712C720.454 604.65 719.6 605.14 718.508 605.14C717.024 605.14 716.03 604.23 716.03 602.2V597.72H717.332V602.004ZM727.918 602.928C727.918 603.866 728.464 603.95 729.15 603.95H730.494V605H728.8C727.61 605 726.616 604.692 726.616 603.054V598.77H724.32V597.72H726.616V596.04H727.918V597.72H730.494V598.77H727.918V602.928ZM736.053 605.14C734.177 605.14 732.539 603.796 732.539 601.36C732.539 598.966 734.135 597.58 736.025 597.58C738.349 597.58 739.553 599.274 739.511 601.654H733.855V601.794C733.925 603.32 734.919 604.118 736.053 604.118C737.019 604.118 737.761 603.642 738.055 602.788H739.385C738.951 604.328 737.621 605.14 736.053 605.14ZM733.869 600.702H738.167C738.027 599.33 737.173 598.602 736.011 598.602C734.919 598.602 734.009 599.33 733.869 600.702Z" fill="#090B0B"/> +<rect x="559" y="565" width="16" height="16" fill="url(#pattern3_242_649)"/> +<rect x="579" y="563" width="22" height="22" fill="url(#pattern4_242_649)"/> +<g clip-path="url(#clip4_242_649)"> +<path d="M606.5 567.5H613.5V569H614.5V567.5C614.5 567.235 614.395 566.98 614.207 566.793C614.02 566.605 613.765 566.5 613.5 566.5H606.5C606.235 566.5 605.98 566.605 605.793 566.793C605.605 566.98 605.5 567.235 605.5 567.5V570.925H606.5V567.5Z" fill="black"/> +<path d="M616 570H609.5V571H613.5V574H614.5V571H616V578H612V573.5C612 573.235 611.895 572.98 611.707 572.793C611.52 572.605 611.265 572.5 611 572.5H604C603.735 572.5 603.48 572.605 603.293 572.793C603.105 572.98 603 573.235 603 573.5V580.5C603 580.765 603.105 581.02 603.293 581.207C603.48 581.395 603.735 581.5 604 581.5H611C611.265 581.5 611.52 581.395 611.707 581.207C611.895 581.02 612 580.765 612 580.5V579H616C616.265 579 616.52 578.895 616.707 578.707C616.895 578.52 617 578.265 617 578V571C617 570.735 616.895 570.48 616.707 570.293C616.52 570.105 616.265 570 616 570ZM604 580.5V573.5H611V578H609V575H608V578C608 578.265 608.105 578.52 608.293 578.707C608.48 578.895 608.735 579 609 579H611V580.5H604Z" fill="black"/> +</g> +<path d="M790.581 552H734.808C730.496 552 727 555.134 727 559C727 562.866 730.496 566 734.808 566H790.581C794.893 566 798.389 562.866 798.389 559C798.389 555.134 794.893 552 790.581 552Z" fill="#A13000"/> +<path d="M741.664 558.2C741.664 556.496 742.288 555.32 743.776 555.32C744.912 555.32 745.544 556 745.736 557.096H744.992C744.848 556.384 744.464 555.92 743.776 555.92C742.776 555.92 742.408 556.88 742.408 558.2C742.408 559.528 742.776 560.48 743.776 560.48C744.464 560.48 744.848 560.016 744.984 559.32H745.736C745.544 560.408 744.904 561.08 743.776 561.08C742.288 561.08 741.664 559.904 741.664 558.2ZM748.617 561.08C747.129 561.08 746.505 559.904 746.505 558.2C746.505 556.496 747.129 555.32 748.617 555.32C750.105 555.32 750.729 556.496 750.729 558.2C750.729 559.904 750.105 561.08 748.617 561.08ZM747.257 558.2C747.257 559.528 747.617 560.48 748.617 560.48C749.617 560.48 749.985 559.528 749.985 558.2C749.985 556.88 749.617 555.92 748.617 555.92C747.617 555.92 747.257 556.88 747.257 558.2ZM753.282 560.2L752.202 556.48L752.242 557.08V561H751.53V555.4H752.578L753.578 559.208L754.57 555.4H755.618V561H754.906V557.08L754.946 556.48L753.866 560.2H753.282ZM758.243 560.2L757.163 556.48L757.203 557.08V561H756.491V555.4H757.539L758.539 559.208L759.531 555.4H760.579V561H759.867V557.08L759.907 556.48L758.827 560.2H758.243ZM763.5 561.08C761.964 561.08 761.548 560.104 761.548 558.936V555.4H762.292V558.936C762.292 559.736 762.5 560.48 763.5 560.48C764.492 560.48 764.708 559.736 764.708 558.936V555.4H765.452V558.936C765.452 560.104 765.02 561.08 763.5 561.08ZM769.404 561L767.26 556.456V561H766.516V555.4H767.508L769.66 560.008V555.4H770.404V561H769.404ZM775.229 555.4V556H773.797V560.4H775.229V561H771.613V560.4H773.045V556H771.613V555.4H775.229ZM780.382 555.4V556H778.758V561H778.006V556H776.374V555.4H780.382ZM782.967 561V558.92L781.215 555.4H781.999L783.343 558.288L784.687 555.4H785.471L783.719 558.92V561H782.967Z" fill="white"/> +<line x1="917" y1="574.5" x2="1033" y2="574.5" stroke="#4A408F"/> +<path d="M1004.58 567H948.808C944.496 567 941 570.134 941 574C941 577.866 944.496 581 948.808 581H1004.58C1008.89 581 1012.39 577.866 1012.39 574C1012.39 570.134 1008.89 567 1004.58 567Z" fill="#4A408F"/> +<path d="M960.093 571.4H961.493C962.893 571.4 963.701 571.936 963.701 573.096C963.701 574.256 962.893 574.792 961.493 574.792H960.837V577H960.093V571.4ZM960.837 574.192H961.493C962.437 574.192 962.957 573.888 962.957 573.096C962.957 572.304 962.437 572 961.493 572H960.837V574.192ZM964.958 577V571.4H966.406C967.694 571.4 968.574 571.888 968.574 573.048C968.574 573.912 968.094 574.4 967.326 574.592L968.726 577H967.886L966.574 574.696H966.406H965.71V577H964.958ZM965.71 574.096H966.406C967.238 574.096 967.83 573.84 967.83 573.048C967.83 572.256 967.238 572 966.406 572H965.71V574.096ZM969.959 571.4H973.303V572H970.703V573.856H973.159V574.456H970.703V576.4H973.343V577H969.959V571.4ZM976.224 576.2L975.144 572.48L975.184 573.08V577H974.472V571.4H975.52L976.52 575.208L977.512 571.4H978.56V577H977.848V573.08L977.888 572.48L976.808 576.2H976.224ZM983.289 571.4V572H981.857V576.4H983.289V577H979.673V576.4H981.105V572H979.673V571.4H983.289ZM986.442 577.08C984.906 577.08 984.49 576.104 984.49 574.936V571.4H985.234V574.936C985.234 575.736 985.442 576.48 986.442 576.48C987.434 576.48 987.65 575.736 987.65 574.936V571.4H988.394V574.936C988.394 576.104 987.962 577.08 986.442 577.08ZM991.107 576.2L990.027 572.48L990.067 573.08V577H989.355V571.4H990.403L991.403 575.208L992.395 571.4H993.443V577H992.731V573.08L992.771 572.48L991.691 576.2H991.107Z" fill="#FBF8F8"/> +<line x1="705" y1="181.003" x2="703.983" y2="539.003" stroke="#090B0B" stroke-width="2" stroke-dasharray="4 4"/> +<path d="M711.646 519.646C711.451 519.842 711.451 520.158 711.646 520.354L714.828 523.536C715.024 523.731 715.34 523.731 715.536 523.536C715.731 523.34 715.731 523.024 715.536 522.828L712.707 520L715.536 517.172C715.731 516.976 715.731 516.66 715.536 516.464C715.34 516.269 715.024 516.269 714.828 516.464L711.646 519.646ZM712 520V520.5H721V520V519.5H712V520Z" fill="#090B0B"/> +</g> +<defs> +<pattern id="pattern0_242_649" patternContentUnits="objectBoundingBox" width="1" height="1"> +<use xlink:href="#image0_242_649" transform="scale(0.00104167)"/> +</pattern> +<pattern id="pattern1_242_649" patternContentUnits="objectBoundingBox" width="1" height="1"> +<use xlink:href="#image1_242_649" transform="scale(0.0166667)"/> +</pattern> +<pattern id="pattern2_242_649" patternContentUnits="objectBoundingBox" width="1" height="1"> +<use xlink:href="#image2_242_649" transform="scale(0.015625)"/> +</pattern> +<filter id="filter0_d_242_649" x="660.656" y="107.774" width="33.6771" height="29.5151" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset/> +<feGaussianBlur stdDeviation="4.16667"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/> +<feBlend mode="overlay" in2="BackgroundImageFix" result="effect1_dropShadow_242_649"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_242_649" result="shape"/> +</filter> +<filter id="filter1_d_242_649" x="672.354" y="103.71" width="21.9792" height="33.5791" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset/> +<feGaussianBlur stdDeviation="4.16667"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/> +<feBlend mode="overlay" in2="BackgroundImageFix" result="effect1_dropShadow_242_649"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_242_649" result="shape"/> +</filter> +<pattern id="pattern3_242_649" patternContentUnits="objectBoundingBox" width="1" height="1"> +<use xlink:href="#image3_242_649" transform="translate(-0.0144695) scale(0.00107181)"/> +</pattern> +<pattern id="pattern4_242_649" patternContentUnits="objectBoundingBox" width="1" height="1"> +<use xlink:href="#image4_242_649" transform="scale(0.00104167)"/> +</pattern> +<linearGradient id="paint0_linear_242_649" x1="677.49" y1="112.044" x2="677.49" y2="128.956" gradientUnits="userSpaceOnUse"> +<stop stop-color="white"/> +<stop offset="1" stop-color="white" stop-opacity="0"/> +</linearGradient> +<linearGradient id="paint1_linear_242_649" x1="689.226" y1="128.682" x2="705.655" y2="112.503" gradientUnits="userSpaceOnUse"> +<stop stop-color="#FF9419"/> +<stop offset="0.43" stop-color="#FF021D"/> +<stop offset="0.99" stop-color="#E600FF"/> +</linearGradient> +<clipPath id="clip0_242_649"> +<rect width="1227" height="920" fill="white"/> +</clipPath> +<clipPath id="clip1_242_649"> +<rect width="35.6945" height="16.7546" fill="white" transform="translate(199.666 249)"/> +</clipPath> +<clipPath id="clip2_242_649"> +<rect width="16" height="18" fill="white" transform="translate(650 112)"/> +</clipPath> +<clipPath id="clip3_242_649"> +<rect width="17" height="17" fill="white" transform="translate(669 112)"/> +</clipPath> +<clipPath id="clip4_242_649"> +<rect width="18" height="18" fill="white" transform="translate(601 565)"/> +</clipPath> +<image id="image0_242_649" width="960" height="960" preserveAspectRatio="none" xlink:href="data:image/png;base64,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"/> +<image id="image1_242_649" width="60" height="60" preserveAspectRatio="none" xlink:href="data:image/png;base64,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"/> +<image id="image2_242_649" width="64" height="64" preserveAspectRatio="none" xlink:href="data:image/png;base64,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"/> +<image id="image3_242_649" width="960" height="933" preserveAspectRatio="none" xlink:href="data:image/png;base64,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"/> +<image id="image4_242_649" width="960" height="960" preserveAspectRatio="none" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAA8AAAAPACAYAAAD61hCbAAAAAXNSR0IArs4c6QAAAERlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAADwKADAAQAAAABAAADwAAAAAAhKZYGAABAAElEQVR4AezdC5ycZ10o/ud5d9M27e4m2U1KuYsiKqjI/X60UsilXBWrcP7gARRUPAj+OYp4kKqI4AUQ5aCg4gW8VBGwNElbpCKXAwgoyFVAkGJpm+wm2dmkaXb3fc4zSVNy2WQuOzM77zvf+RBm5p3n8vt9n0mzv31vIXgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAIEQIgQCBAgQINCOwNTOvf8zxfi6dtpq051ASudcuLBjck93vfUiQIAAAQIEWgkUrRr4nAABAgQIECBAgAABAgQI1EFAAVyHVZQDAQIECBAgQIAAAQIECLQUUAC3JNKAAAECBAgQIECAAAECBOogoACuwyrKgQABAgQIECBAgAABAgRaCiiAWxJpQIAAAQIECBAgQIAAAQJ1EFAA12EV5UCAAAECBAgQIECAAAECLQUUwC2JNCBAgAABAgQIECBAgACBOggogOuwinIgQIAAAQIECBAgQIAAgZYCCuCWRBoQIECAAAECBAgQIECAQB0EFMB1WEU5ECBAgAABAgQIECBAgEBLAQVwSyINCBAgQIAAAQIECBAgQKAOAgrgOqyiHAgQIECAAAECBAgQIECgpYACuCWRBgQIECBAgAABAgQIECBQBwEFcB1WUQ4ECBAgQIAAAQIECBAg0FJAAdySSAMCBAgQIECAAAECBAgQqIOAArgOqygHAgQIECBAgAABAgQIEGgpoABuSaQBAQIECBAgQIAAAQIECNRBQAFch1WUAwECBAgQIECAAAECBAi0FFAAtyTSgAABAgQIECBAgAABAgTqIKAArsMqyoEAAQIECBAgQIAAAQIEWgoogFsSaUCAAAECBAgQIECAAAECdRBQANdhFeVAgAABAgQIECBAgAABAi0FFMAtiTQgQIAAAQIECBAgQIAAgToIKIDrsIpyIECAAAECBAgQIECAAIGWAgrglkQaECBAgAABAgQIECBAgEAdBBTAdVhFORAgQIAAAQIECBAgQIBASwEFcEsiDQgQIECAAAECBAgQIECgDgIK4DqsohwIECBAgAABAgQIECBAoKWAArglkQYECBAgQIAAAQIECBAgUAcBBXAdVlEOBAgQIECAAAECBAgQINBSQAHckkgDAgQIECBAgAABAgQIEKiDgAK4DqsoBwIECBAgQIAAAQIECBBoKaAAbkmkAQECBAgQIECAAAECBAjUQUABXIdVlAMBAgQIECBAgAABAgQItBRQALck0oAAAQIECBAgQIAAAQIE6iCgAK7DKsqBAAECBAgQIECAAAECBFoKKIBbEmlAgAABAgQIECBAgAABAnUQUADXYRXlQIAAAQIECBAgQIAAAQItBRTALYk0IECAAAECBAgQIECAAIE6CCiA67CKciBAgAABAgQIECBAgACBlgIK4JZEGhAgQIAAAQIECBAgQIBAHQQUwHVYRTkQIECAAAECBAgQIECAQEsBBXBLIg0IECBAgAABAgQIECBAoA4CCuA6rKIcCBAgQIAAAQIECBAgQKClgAK4JZEGBAgQIECAAAECBAgQIFAHAQVwHVZRDgQIECBAgAABAgQIECDQUkAB3JJIAwIECBAgQIAAAQIECBCog4ACuA6rKAcCBAgQIECAAAECBAgQaCmgAG5JpAEBAgQIECBAgAABAgQI1EFAAVyHVZQDAQIECBAgQIAAAQIECLQUUAC3JNKAAAECBAgQIECAAAECBOogoACuwyrKgQABAgQIECBAgAABAgRaCiiAWxJpQIAAAQIECBAgQIAAAQJ1EFAA12EV5UCAAAECBAgQIECAAAECLQUUwC2JNCBAgAABAgQIECBAgACBOggogOuwinIgQIAAAQIECBAgQIAAgZYCCuCWRBoQIECAAAECBAgQIECAQB0EFMB1WEU5ECBAgAABAgQIECBAgEBLAQVwSyINCBAgQIAAAQIECBAgQKAOAgrgOqyiHAgQIECAAAECBAgQIECgpYACuCWRBgQIECBAgAABAgQIECBQBwEFcB1WUQ4ECBAgQIAAAQIECBAg0FJAAdySSAMCBAgQIECAAAECBAgQqIOAArgOqygHAgQIECBAgAABAgQIEGgpoABuSaQBAQIECBAgQIAAAQIECNRBQAFch1WUAwECBAgQIECAAAECBAi0FFAAtyTSgAABAgQIECBAgAABAgTqIKAArsMqyoEAAQIECBAgQIAAAQIEWgoogFsSaUCAAAECBAgQIECAAAECdRBQANdhFeVAgAABAgQIECBAgAABAi0FFMAtiTQgQIAAAQIECBAgQIAAgToIKIDrsIpyIECAAAECBAgQIECAAIGWAgrglkQaECBAgAABAgQIECBAgEAdBBTAdVhFORAgQIAAAQIECBAgQIBASwEFcEsiDQgQIECAAAECBAgQIECgDgIK4DqsohwIECBAgAABAgQIECBAoKWAArglkQYECBAgQIAAAQIECBAgUAcBBXAdVlEOBAgQIECAAAECBAgQINBSQAHckkgDAgQIECBAgAABAgQIEKiDgAK4DqsoBwIECBAgQIAAAQIECBBoKaAAbkmkAQECBAgQIECAAAECBAjUQUABXIdVlAMBAgQIECBAgAABAgQItBRQALck0oAAAQIECBAgQIAAAQIE6iCgAK7DKsqBAAECBAgQIECAAAECBFoKKIBbEmlAgAABAgQIECBAgAABAnUQUADXYRXlQIAAAQIECBAgQIAAAQItBRTALYk0IECAAAECBAgQIECAAIE6CIzXIQk5ECBAgED/BdJY/FhI6VX9n2l0Z7igOHhoYXTTlzkBAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQIECBAgAABAgQIECAwSgJxlJKVK4FTBaZ27X19CnHbqdu9741ASuFLCztmHtvpaJO7974kpPjsTvtp37bALY3tM9/ZduvbGk7t3PuMFOPLOu2nffsCMY4/aH7bhrn2e2hJgAABAgQIdCIw3kljbQnUTSCleIcQwzfXLa9hyScW4dauYinjtHXpSq6tTimEQ201PL3RhrzJ35fTXXq2pSyLsZ4NZiACBAgQIEDgNIHitC02ECBAgAABAgQIECBAgACBGgoogGu4qFIiQIAAAQIECBAgQIAAgdMFFMCnm9hCgAABAgQIECBAgAABAjUUUADXcFGlRIAAAQIECBAgQIAAAQKnCyiATzexhQABAgQIECBAgAABAgRqKKAAruGiSokAAQIECBAgQIAAAQIEThdQAJ9uYgsBAgQI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AQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQKC9Av8/hIby4Q4+/isAAAAASUVORK5CYII="/> +</defs> +</svg> diff --git a/docs/install/airgap.md b/docs/install/airgap.md index 7fc80d231498a..76dab7d93a4e2 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/docs/install/kubernetes.md b/docs/install/kubernetes.md index db4a63d8ea04d..12a46608b7321 100644 --- a/docs/install/kubernetes.md +++ b/docs/install/kubernetes.md @@ -135,7 +135,7 @@ We support two release channels: mainline and stable - read the helm install coder coder-v2/coder \ --namespace coder \ --values values.yaml \ - --version 2.33.2 + --version 2.34.0 ``` - **OCI Registry** @@ -146,7 +146,7 @@ We support two release channels: mainline and stable - read the helm install coder oci://ghcr.io/coder/chart/coder \ --namespace coder \ --values values.yaml \ - --version 2.33.2 + --version 2.34.0 ``` - **Stable** Coder release: @@ -159,7 +159,7 @@ We support two release channels: mainline and stable - read the helm install coder coder-v2/coder \ --namespace coder \ --values values.yaml \ - --version 2.32.1 + --version 2.33.6 ``` - **OCI Registry** @@ -170,7 +170,7 @@ We support two release channels: mainline and stable - read the helm install coder oci://ghcr.io/coder/chart/coder \ --namespace coder \ --values values.yaml \ - --version 2.32.1 + --version 2.33.6 ``` You can watch Coder start up by running `kubectl get pods -n coder`. Once Coder diff --git a/docs/install/rancher.md b/docs/install/rancher.md index fdb32ed26c7fd..0a81c7a73d18a 100644 --- a/docs/install/rancher.md +++ b/docs/install/rancher.md @@ -134,8 +134,8 @@ kubectl create secret generic coder-db-url -n coder \ 1. Select a Coder version: - - **Mainline**: `2.33.2` - - **Stable**: `2.32.1` + - **Mainline**: `2.34.0` + - **Stable**: `2.33.6` Learn more about release channels in the [Releases documentation](./releases/index.md). diff --git a/docs/install/releases/esr-2.29-2.34-upgrade.md b/docs/install/releases/esr-2.29-2.34-upgrade.md new file mode 100644 index 0000000000000..d7c22e0bfcb91 --- /dev/null +++ b/docs/install/releases/esr-2.29-2.34-upgrade.md @@ -0,0 +1,287 @@ +# Upgrading from ESR 2.29 to 2.34 + +## Guide Overview + +Coder provides Extended Support Releases (ESR) biannually. This guide walks +through upgrading from Coder 2.29 ESR to Coder 2.34 ESR. It +summarizes key changes, highlights breaking updates, and provides a recommended +upgrade process. + +Read more about the +[ESR release process](./index.md#extended-support-release) and how Coder +supports it. + +## What's New in Coder 2.34 + +### Coder Agents + +[Coder Agents](../../ai-coder/agents/index.md) was introduced in v2.32, and is the long-term replacement for +Coder Tasks. Coder Agents is a native AI coding agent that runs entirely within the Coder control plane, managing the agent loop, conversation state, and workspace provisioning in one place. This gives administrators centralized control over model access, credentials, and audit trails across every agent session. Coder Agents was made Beta in v2.33. + +Coder Agents includes the following high-level functionality: + +- Supports all major LLM providers +- Multi-turn chat +- Automatic workspace provisioning +- MCP server integration, personal skills, and administrator-managed skills +- ACL-based chat sharing across users and groups +- Admin-configurable advisor for planning and architecture guidance +- Plan and subagent explore modes +- Chat debugging +- Virtual desktop + +Administrators have the following levers to configure appropriate access to various parts of Coder Agents: + +- Template allow lists for agents +- BYOK for users +- Cost controls +- Configurable chat retention +- Automatic chat archiving +- Configurable system instructions +- Observability via AI Gateway, part of Coder's AI Governance Add-On + +> [!CAUTION] +> Coder Tasks is officially deprecated in 2.34. It remains supported through the 2.34 ESR support window +> but receives no new features. Coder recommends migrating to Coder Agents +> and the Chats API now. See the [Tasks to Chats migration guide](../../ai-coder/agents/tasks-to-chats-migration.md) +> for API migration details. + +### AI Gateway and AI Governance + +AI Gateway, previously AI Bridge, matured into a broader governance and +observability layer for AI usage. It now supports: + +- [AI Gateway Proxy](../../ai-coder/ai-gateway/ai-gateway-proxy/index.md). +- OpenAI Responses API interception. +- Expanded Copilot and ChatGPT support. +- Custom Bedrock endpoints. +- Structured logs and client/session views. +- Model filtering. +- Multiple providers of the same type. +- [BYOK](../../ai-coder/ai-gateway/auth.md#bring-your-own-key-byok) and + [key failover](../../ai-coder/ai-gateway/providers.md#key-failover). + +[AI Governance](../../ai-coder/ai-governance.md) adds administrative controls +around AI usage: + +- License and seat visibility. +- AI session auditing. + +These features help administrators understand who is using AI tools, which +providers are being used, and how spend changes over time. + +For more information, visit the +[AI Gateway documentation](../../ai-coder/ai-gateway/index.md). + +### Agent Firewall + +Agent Firewall, previously Agent Boundaries, moved from an early capability into +a stronger governance primitive for AI agents. It can audit and restrict network +access from agent processes, forward machine-readable logs to the control plane, +track usage, and use [landjail mode](../../ai-coder/agent-firewall/landjail.md) +for environments where changing Linux capabilities is not practical. + +For more information, visit the +[Agent Firewall documentation](../../ai-coder/agent-firewall/index.md). + +### Service Accounts + +[Service accounts](../../admin/users/headless-auth.md) are a +[Premium](../../admin/licensing/index.md) feature and now integrate with workspace +sharing, user and workspace filtering, organization membership, and role +assignment. + +### Templates, Prebuilds, and User Secrets + +Template and workspace operations received several improvements: + +- Terraform modules are [cached per template version](../../tutorials/best-practices/speed-up-templates.md) + to reduce repeated downloads and make workspace starts more deterministic. +- [Prebuild](../../admin/templates/extending-templates/prebuilt-workspaces.md) + claiming is more durable and idempotent. +- Prebuild presets are validated with dynamic parameter validation. +- [`coder_env`](../../admin/templates/extending-templates/environment-variables.md) + supports `merge_strategy`. +- [User secrets](../../user-guides/user-secrets.md) can be created, encrypted, + audited, and injected into workspaces. +- The dashboard warns about active prebuilds when duplicating templates. + +These changes reduce operational surprises for template authors, but templates +that assumed a clean Terraform module download on every build should be tested. + +### Security and Networking + +Coder added several security and networking controls between 2.29 and 2.34: + +- OAuth2 external auth providers now support PKCE, and unknown providers default + to PKCE unless explicitly disabled. +- Secure auth cookies are now enabled automatically when `CODER_ACCESS_URL` uses + HTTPS. +- AI Gateway Proxy blocks CONNECT tunnels to private or reserved IP ranges, while + always exempting the Coder access URL. +- Workspace agents can disable reverse and local port forwarding through agent + flags. +- Authenticated request rate limiting is keyed by user instead of IP address. +- Kubernetes Gateway API `HTTPRoute` is supported as an alternative to Ingress. +- Helm chart probes are more configurable, and Prometheus and pprof addresses can + be overridden through chart environment values. +- DERP TLS configuration is wired through the CLI, SDK, tailnet, VPN, agent, and + health checks. + +### Operations and Scale + +Large deployments should now have improvements in database, logging, and +observability behavior. Coder added the following: + +- Configurable PostgreSQL connection pool settings. +- [Retention configuration](../../admin/setup/data-retention.md) for audit logs, + connection logs, API keys, and workspace agent logs. +- `dbpurge` metrics. +- Support bundle improvements. +- `chatd` metrics. +- Agent first-connection duration metrics. +- A `coder_build_info` metric. + +Coder also removed several deprecated Prometheus metrics, so dashboards and +alerts should be reviewed before the upgrade. + +Several expensive queries and write paths were optimized, including: + +- AI Gateway session listing. +- Audit and connection log counts. +- Connection log batching. +- Provisioner job queue lookups. +- Chat streaming. +- Coordinator peer mapping. + +### CLI and Dashboard Enhancements + +The CLI and dashboard gained smaller but meaningful workflow improvements: + +- `coder create --no-wait` creates a workspace without waiting for startup. +- `coder logs` provides easier access to logs. +- `coder login token` prints the current session token for scripts and automation. +- `coder support bundle` can infer the workspace from the environment. +- `coder groups list -o json` now returns a flat JSON structure. +- The dashboard includes user editing, service account management, group member + filtering, role selection during user creation, improved accessibility, and + clearer confirmation flows for destructive actions. + +## Changes to be Aware of + +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_<N>_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_<N>_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 + +> [!NOTE] +> You can upgrade directly from 2.29 to 2.34. Stepping through intermediate +> minor versions is not required. +> +> This upgrade applies 108 database migrations. Coder applies them in order +> on startup. Most are fast schema changes, but a few rewrite or backfill +> long-lived tables and hold locks while they run. Total time ranges from under +> a minute to several minutes, scaling with the size of the tables called out +> in [Database migrations to watch](#database-migrations-to-watch) below. +> +> Take a database backup before upgrading and validate the upgrade in a +> staging environment that mirrors production data volume. + +### Database migrations to watch + +The batch runs in order on the first startup of the new version. Most +migrations create new tables or make fast schema changes, but the following +pre-existing tables receive the heaviest operations. Size your maintenance +window for whichever are largest in your deployment: + +- **Tailnet coordination tables** (`tailnet_peers`, `tailnet_tunnels`, + `tailnet_coordinators`) are converted to `UNLOGGED` and rewritten under an + exclusive lock. **`UNLOGGED` tables are not replicated to standby servers and + are truncated on crash recovery.** This is intentional, since coordinators + re-register and peers reconnect on startup, but confirm your high + availability strategy does not rely on replicating tailnet state to read + replicas. +- **`users`** gains a service account column plus check constraints and unique + index rebuilds, held under an exclusive lock. This briefly blocks logins and + API key validation, so the duration matters most on deployments with many + users. +- **`workspace_agents`** (joined with `workspace_builds`, `workspace_resources`, + and `workspaces`) is bulk updated to soft-delete stale agents left behind by + a pre-2.33 bug. This is typically the slowest step on long-lived deployments + with extensive build history. It is safe, but plan for the time. +- **`workspaces`** receives full-table updates and new ACL check constraints. +- **`usage_events`** has a check constraint revalidated and an index added; the + cost scales with retained event volume. + +Several of these changes are irreversible, including the `users` service +account reclassification and the cleanup of `user_secrets`, +`organization_members`, and related rows for already soft-deleted users. Take a +database backup before upgrading. + +The Coder team recommends taking the following steps when performing the upgrade: + +- **Perform the upgrade in a staging environment first:** The cumulative changes + between 2.29 and 2.34 affect AI workflows, templates, prebuilds, + authentication, RBAC, and dashboard behavior. Validate representative + workspaces before production rollout. +- **Retest templates and prebuilds:** Focus on Terraform module caching, + prebuild preset validation, `coder_env` merging, user secrets, and workspace + starts with changed parameters. +- **Audit AI Gateway integrations:** Update experimental API routes, check + permissions for interception/session data, migrate provider configuration + from env vars to the database via `/ai/settings`, verify proxy mode behavior, + and review any injected MCP usage. +- **Plan the Tasks to Agents migration:** Tasks remains available during the + 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. 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. +- **Update CLI parsers, API clients, and scripts:** Check `coder groups list -o + json`, `coder tokens rm`, `coder login` with `CODER_SESSION_TOKEN`, SFTP/SCP + destination paths, template metadata update clients, provisionerd protocol + consumers, and any script that depends on terminal command URL execution. +- **Review networking controls before enabling them:** Test AI Gateway Proxy, + private IP restrictions, port forwarding blocks, DERP TLS configuration, + Kubernetes `HTTPRoute`, and Helm probe settings in environments that use custom + networking. +- **Tune operational settings after rollout:** Review PostgreSQL connection pool + settings, retention policies, dbpurge behavior, Prometheus metrics, secure + cookie behavior, support bundle output, and log ingestion pipelines. +- **Communicate user-facing changes:** Service accounts, Coder Agents, AI + Governance, Tasks deprecation, dashboard confirmations, and workspace parameter + restarts can change user workflows. Share the expected behavior before the + production upgrade. diff --git a/docs/install/releases/index.md b/docs/install/releases/index.md index 6957fa023b6b7..0d5305adf3d75 100644 --- a/docs/install/releases/index.md +++ b/docs/install/releases/index.md @@ -46,9 +46,9 @@ For more information on feature rollout, see our - Receives only critical bugfixes and security patches - Ideal for regulated environments or large deployments with strict upgrade cycles -ESR releases will be updated with critical bugfixes and security patches that are available to paying customers. This extended support model provides predictable, long-term maintenance for organizations that require enhanced stability. Because ESR forgoes new features in favor of maintenance and stability, it is best suited for teams with strict upgrade constraints. The latest ESR version is [Coder 2.29](https://github.com/coder/coder/releases/tag/v2.29.0). +ESR releases will be updated with critical bugfixes and security patches that are available to paying customers. This extended support model provides predictable, long-term maintenance for organizations that require enhanced stability. Because ESR forgoes new features in favor of maintenance and stability, it is best suited for teams with strict upgrade constraints. The latest ESR version is [Coder 2.34](https://github.com/coder/coder/releases/tag/v2.34.0). -For more information, see the [Coder ESR announcement](https://coder.com/blog/esr) or our [ESR Upgrade Guide](./esr-2.24-2.29-upgrade.md). +For more information, see the [Coder ESR announcement](https://coder.com/blog/esr) or the [2.29 to 2.34 ESR Upgrade Guide](./esr-2.29-2.34-upgrade.md). ### Release Candidates @@ -79,14 +79,13 @@ pages. <!-- RELEASE_CALENDAR_START --> | Release name | Release Date | Status | Latest Release | |------------------------------------------------|-------------------|--------------------------|------------------------------------------------------------------| -| [2.24](https://coder.com/changelog/coder-2-24) | July 01, 2025 | Extended Support Release | [v2.24.4](https://github.com/coder/coder/releases/tag/v2.24.4) | -| [2.28](https://coder.com/changelog/coder-2-28) | November 04, 2025 | Not Supported | [v2.28.11](https://github.com/coder/coder/releases/tag/v2.28.11) | -| [2.29](https://coder.com/changelog/coder-2-29) | December 02, 2025 | Extended Support Release | [v2.29.12](https://github.com/coder/coder/releases/tag/v2.29.12) | -| [2.30](https://coder.com/changelog/coder-2-30) | February 03, 2026 | Not Supported | [v2.30.7](https://github.com/coder/coder/releases/tag/v2.30.7) | -| [2.31](https://coder.com/changelog/coder-2-31) | February 23, 2026 | Security Support | [v2.31.11](https://github.com/coder/coder/releases/tag/v2.31.11) | -| [2.32](https://coder.com/changelog/coder-2-32) | April 14, 2026 | Stable | [v2.32.1](https://github.com/coder/coder/releases/tag/v2.32.1) | -| [2.33](https://coder.com/changelog/coder-2-33) | May 05, 2026 | Mainline | [v2.33.2](https://github.com/coder/coder/releases/tag/v2.33.2) | -| 2.34 | | Not Released | N/A | +| [2.29](https://coder.com/changelog/coder-2-29) | December 02, 2025 | Extended Support Release | [v2.29.16](https://github.com/coder/coder/releases/tag/v2.29.16) | +| [2.30](https://coder.com/changelog/coder-2-30) | February 03, 2026 | Not Supported | [v2.30.9](https://github.com/coder/coder/releases/tag/v2.30.9) | +| [2.31](https://coder.com/changelog/coder-2-31) | February 23, 2026 | Not Supported | [v2.31.14](https://github.com/coder/coder/releases/tag/v2.31.14) | +| [2.32](https://coder.com/changelog/coder-2-32) | April 14, 2026 | Security Support | [v2.32.5](https://github.com/coder/coder/releases/tag/v2.32.5) | +| [2.33](https://coder.com/changelog/coder-2-33) | May 05, 2026 | Stable | [v2.33.6](https://github.com/coder/coder/releases/tag/v2.33.6) | +| [2.34](https://coder.com/changelog/coder-2-34) | June 02, 2026 | Mainline (ESR) | [v2.34.0](https://github.com/coder/coder/releases/tag/v2.34.0) | +| 2.35 | | Not Released | N/A | <!-- RELEASE_CALENDAR_END --> > [!TIP] diff --git a/docs/manifest.json b/docs/manifest.json index cbbeceefbefb9..77d50b84a36d1 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -197,8 +197,13 @@ }, { "title": "Upgrading from ESR 2.24 to 2.29", - "description": "Upgrade Guide for ESR Releases", + "description": "Upgrade from ESR 2.24 to 2.29", "path": "./install/releases/esr-2.24-2.29-upgrade.md" + }, + { + "title": "Upgrading from ESR 2.29 to 2.34", + "description": "Upgrade from ESR 2.29 to 2.34", + "path": "./install/releases/esr-2.29-2.34-upgrade.md" } ] } @@ -1007,6 +1012,12 @@ "path": "./ai-coder/agents/architecture.md", "state": ["beta"] }, + { + "title": "Chat Sharing", + "description": "Share Coder Agents conversations with users and groups", + "path": "./ai-coder/agents/chat-sharing.md", + "state": ["beta"] + }, { "title": "Models", "description": "Configure LLM providers and models for Coder Agents", @@ -1144,85 +1155,93 @@ { "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" - }, { "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 +1252,42 @@ { "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": "Provider Configuration", + "description": "How AI Gateway stores, seeds, and reloads provider configuration", + "path": "./ai-coder/ai-gateway/providers.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"] } ] }, diff --git a/docs/reference/api/chats.md b/docs/reference/api/chats.md index 758f6641f5942..f475d8482d2e8 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`, `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/general.md b/docs/reference/api/general.md index 02dbfe4135c47..a0b0421e63eba 100644 --- a/docs/reference/api/general.md +++ b/docs/reference/api/general.md @@ -424,6 +424,7 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \ "oidc": { "allow_signups": true, "auth_url_params": {}, + "auto_repair_links": true, "client_cert_file": "string", "client_id": "string", "client_key_file": "string", @@ -431,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 912d53f8cef47..09919429dca9b 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`, `provider_disabled`, `rate_limit`, `startup_timeout`, `timeout`, `usage_limit` | ## codersdk.ChatFileMetadata @@ -5944,6 +5944,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "oidc": { "allow_signups": true, "auth_url_params": {}, + "auto_repair_links": true, "client_cert_file": "string", "client_id": "string", "client_key_file": "string", @@ -5951,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" @@ -6543,6 +6545,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "oidc": { "allow_signups": true, "auth_url_params": {}, + "auto_repair_links": true, "client_cert_file": "string", "client_id": "string", "client_key_file": "string", @@ -6550,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" @@ -9020,6 +9024,7 @@ Only certain features set these fields: - FeatureManagedAgentLimit| { "allow_signups": true, "auth_url_params": {}, + "auto_repair_links": true, "client_cert_file": "string", "client_id": "string", "client_key_file": "string", @@ -9027,6 +9032,7 @@ Only certain features set these fields: - FeatureManagedAgentLimit| "email_domain": [ "string" ], + "email_fallback": true, "email_field": "string", "group_allow_list": [ "string" @@ -9090,11 +9096,13 @@ Only certain features set these fields: - FeatureManagedAgentLimit| |--------------------------------------|----------------------------------|----------|--------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | `allow_signups` | boolean | false | | | | `auth_url_params` | object | false | | | +| `auto_repair_links` | boolean | false | | | | `client_cert_file` | string | false | | | | `client_id` | string | false | | | | `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/docs/reference/cli/create.md b/docs/reference/cli/create.md index 7ea327ed5ad2b..7e6fd1ce2c648 100644 --- a/docs/reference/cli/create.md +++ b/docs/reference/cli/create.md @@ -100,6 +100,41 @@ Return immediately after creating the workspace. The build will run in the backg Bypass confirmation prompts. +### --build-option + +| | | +|-------------|----------------------------------| +| Type | <code>string-array</code> | +| Environment | <code>$CODER_BUILD_OPTION</code> | + +Build option value in the format "name=value". + +### --build-options + +| | | +|------|-------------------| +| Type | <code>bool</code> | + +Prompt for one-time build options defined with ephemeral parameters. + +### --ephemeral-parameter + +| | | +|-------------|-----------------------------------------| +| Type | <code>string-array</code> | +| Environment | <code>$CODER_EPHEMERAL_PARAMETER</code> | + +Set the value of ephemeral parameters defined in the template. The format is "name=value". + +### --prompt-ephemeral-parameters + +| | | +|-------------|-------------------------------------------------| +| Type | <code>bool</code> | +| Environment | <code>$CODER_PROMPT_EPHEMERAL_PARAMETERS</code> | + +Prompt to set values of ephemeral parameters defined in the template. If a value has been set via --ephemeral-parameter, it will not be prompted for. + ### --parameter | | | @@ -136,6 +171,14 @@ Rich parameter default values in the format "name=value". Automatically accept parameter defaults when no value is provided. +### --always-prompt + +| | | +|------|-------------------| +| Type | <code>bool</code> | + +Always prompt all parameters. Does not pull parameter values from existing workspace. + ### -O, --org | | | diff --git a/docs/reference/cli/external-workspaces_create.md b/docs/reference/cli/external-workspaces_create.md index 26c104d03cd4b..cb15a0fc6dd0f 100644 --- a/docs/reference/cli/external-workspaces_create.md +++ b/docs/reference/cli/external-workspaces_create.md @@ -100,6 +100,41 @@ Return immediately after creating the workspace. The build will run in the backg Bypass confirmation prompts. +### --build-option + +| | | +|-------------|----------------------------------| +| Type | <code>string-array</code> | +| Environment | <code>$CODER_BUILD_OPTION</code> | + +Build option value in the format "name=value". + +### --build-options + +| | | +|------|-------------------| +| Type | <code>bool</code> | + +Prompt for one-time build options defined with ephemeral parameters. + +### --ephemeral-parameter + +| | | +|-------------|-----------------------------------------| +| Type | <code>string-array</code> | +| Environment | <code>$CODER_EPHEMERAL_PARAMETER</code> | + +Set the value of ephemeral parameters defined in the template. The format is "name=value". + +### --prompt-ephemeral-parameters + +| | | +|-------------|-------------------------------------------------| +| Type | <code>bool</code> | +| Environment | <code>$CODER_PROMPT_EPHEMERAL_PARAMETERS</code> | + +Prompt to set values of ephemeral parameters defined in the template. If a value has been set via --ephemeral-parameter, it will not be prompted for. + ### --parameter | | | @@ -136,6 +171,14 @@ Rich parameter default values in the format "name=value". Automatically accept parameter defaults when no value is provided. +### --always-prompt + +| | | +|------|-------------------| +| Type | <code>bool</code> | + +Always prompt all parameters. Does not pull parameter values from existing workspace. + ### -O, --org | | | diff --git a/docs/reference/cli/server.md b/docs/reference/cli/server.md index de694faa7940f..cb39a0e23c058 100644 --- a/docs/reference/cli/server.md +++ b/docs/reference/cli/server.md @@ -16,6 +16,7 @@ coder server [flags] | [<code>create-admin-user</code>](./server_create-admin-user.md) | Create a new admin user with the given username, email and password and adds it to every organization. | | [<code>postgres-builtin-url</code>](./server_postgres-builtin-url.md) | Output the connection URL for the built-in PostgreSQL deployment. | | [<code>postgres-builtin-serve</code>](./server_postgres-builtin-serve.md) | Run the built-in PostgreSQL deployment. | +| [<code>fix-oidc-links</code>](./server_fix-oidc-links.md) | Reset OIDC linked IDs that do not match the expected issuer, allowing users to re-authenticate. | | [<code>dbcrypt</code>](./server_dbcrypt.md) | Manage database encryption. | ## Options @@ -982,7 +983,7 @@ Headers to trust for forwarding IP addresses. e.g. Cf-Connecting-Ip, True-Client | Environment | <code>$CODER_PROXY_TRUSTED_ORIGINS</code> | | YAML | <code>networking.proxyTrustedOrigins</code> | -Origin addresses to respect "proxy-trusted-headers". e.g. 192.168.1.0/24. +Origin addresses to respect "proxy-trusted-headers" and X-Forwarded-Host for subdomain app routing. e.g. 192.168.1.0/24. ### --cache-dir @@ -1228,7 +1229,7 @@ Specify a YAML file to load configuration from. | YAML | <code>client.workspaceHostnameSuffix</code> | | Default | <code>coder</code> | -Workspace hostnames use this suffix in SSH config and Coder Connect on Coder Desktop. By default it is coder, resulting in names like myworkspace.coder. +Workspace hostnames use this suffix in SSH config and Coder Connect on Coder Desktop. By default it is coder, resulting in names like myworkspace.coder. The suffix must not start with a dot, and must not contain spaces, newlines, or glob characters (* and ?). ### --ssh-config-options @@ -1238,7 +1239,7 @@ Workspace hostnames use this suffix in SSH config and Coder Connect on Coder Des | Environment | <code>$CODER_SSH_CONFIG_OPTIONS</code> | | YAML | <code>client.sshConfigOptions</code> | -These SSH config options will override the default SSH config options. Provide options in "key=value" or "key value" format separated by commas.Using this incorrectly can break SSH to your deployment, use cautiously. +These SSH config options will override the default SSH config options. Provide options in "key=value" or "key value" format separated by commas. Using this incorrectly can break SSH to your deployment, use cautiously. The following options are not allowed: Host, Match, Include, ProxyCommand, ProxyJump, LocalCommand, PermitLocalCommand, RemoteCommand, KnownHostsCommand, PKCS11Provider, SecurityKeyProvider, SmartcardDevice, XAuthLocation. Option values must not contain newline, carriage return, or NUL characters. ### --cli-upgrade-message @@ -1743,7 +1744,7 @@ Whether to start an in-memory AI Gateway instance. | YAML | <code>ai_gateway.openai_base_url</code> | | Default | <code>https://api.openai.com/v1/</code> | -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 +1753,7 @@ The base URL of the OpenAI API. | Type | <code>string</code> | | Environment | <code>$CODER_AI_GATEWAY_OPENAI_KEY</code> | -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 +1764,7 @@ The key to authenticate against the OpenAI API. | YAML | <code>ai_gateway.anthropic_base_url</code> | | Default | <code>https://api.anthropic.com/</code> | -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 +1773,7 @@ The base URL of the Anthropic API. | Type | <code>string</code> | | Environment | <code>$CODER_AI_GATEWAY_ANTHROPIC_KEY</code> | -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 +1783,7 @@ The key to authenticate against the Anthropic API. | Environment | <code>$CODER_AI_GATEWAY_BEDROCK_BASE_URL</code> | | YAML | <code>ai_gateway.bedrock_base_url</code> | -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 +1793,7 @@ The base URL to use for the AWS Bedrock API. Use this setting to specify an exac | Environment | <code>$CODER_AI_GATEWAY_BEDROCK_REGION</code> | | YAML | <code>ai_gateway.bedrock_region</code> | -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.<region>.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.<region>.amazonaws.com'. ### --ai-gateway-bedrock-access-key @@ -1801,7 +1802,7 @@ The AWS Bedrock API region to use. Constructs a base URL to use for the AWS Bedr | Type | <code>string</code> | | Environment | <code>$CODER_AI_GATEWAY_BEDROCK_ACCESS_KEY</code> | -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 +1811,7 @@ The access key to authenticate against the AWS Bedrock API. | Type | <code>string</code> | | Environment | <code>$CODER_AI_GATEWAY_BEDROCK_ACCESS_KEY_SECRET</code> | -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 +1822,7 @@ The access key secret to use with the access key to authenticate against the AWS | YAML | <code>ai_gateway.bedrock_model</code> | | Default | <code>global.anthropic.claude-sonnet-4-5-20250929-v1:0</code> | -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 +1833,7 @@ The model to use when making requests to the AWS Bedrock API. | YAML | <code>ai_gateway.bedrock_small_fast_model</code> | | Default | <code>global.anthropic.claude-haiku-4-5-20251001-v1:0</code> | -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/docs/reference/cli/server_fix-oidc-links.md b/docs/reference/cli/server_fix-oidc-links.md new file mode 100644 index 0000000000000..ef5250948e57a --- /dev/null +++ b/docs/reference/cli/server_fix-oidc-links.md @@ -0,0 +1,57 @@ +<!-- DO NOT EDIT | GENERATED CONTENT --> +# server fix-oidc-links + +Reset OIDC linked IDs that do not match the expected issuer, allowing users to re-authenticate. + +## Usage + +```console +coder server fix-oidc-links [flags] +``` + +## Options + +### -y, --yes + +| | | +|------|-------------------| +| Type | <code>bool</code> | + +Bypass confirmation prompts. + +### --postgres-url + +| | | +|-------------|---------------------------------------| +| Type | <code>string</code> | +| Environment | <code>$CODER_PG_CONNECTION_URL</code> | + +URL of a PostgreSQL database. If empty, the built-in PostgreSQL deployment will be used (Coder must not be already running in this case). + +### --postgres-connection-auth + +| | | +|-------------|----------------------------------------| +| Type | <code>password\|awsiamrds</code> | +| Environment | <code>$CODER_PG_CONNECTION_AUTH</code> | +| Default | <code>password</code> | + +Type of auth to use when connecting to postgres. + +### --issuer-url + +| | | +|-------------|-------------------------------------| +| Type | <code>string</code> | +| Environment | <code>$CODER_OIDC_ISSUER_URL</code> | + +The OIDC issuer URL. The canonical issuer is resolved via OIDC discovery. + +### -n, --dry-run + +| | | +|-------------|--------------------------------------------| +| Type | <code>bool</code> | +| Environment | <code>$CODER_FIX_OIDC_LINKS_DRY_RUN</code> | + +Print analysis only, do not modify the database. diff --git a/docs/support/support-bundle.md b/docs/support/support-bundle.md index 11bd7a41834e8..b28ffc6c1dfcc 100644 --- a/docs/support/support-bundle.md +++ b/docs/support/support-bundle.md @@ -53,6 +53,7 @@ A brief overview of all files contained in the bundle is provided below: | `workspace/template.json` | The template currently in use by the selected workspace. | | `workspace/template_file.zip` | The source code of the template currently in use by the selected workspace. | | `workspace/template_version.json` | The template version currently in use by the selected workspace. | +| `vscode-logs/` | Only present when generated from the VS Code Coder Remote extension. Includes logs, redacted settings, and local telemetry files. | ## How do I generate a Support Bundle? @@ -76,6 +77,13 @@ A brief overview of all files contained in the bundle is provided below: prompt. The support bundle will be generated in the current directory with the filename `coder-support-$TIMESTAMP.zip`. + If you use VS Code, you can also run **Coder: Create Support Bundle** from + the Command Palette. The VS Code Coder Remote extension runs + `coder support bundle` and appends recent VS Code diagnostics to the + generated archive. Bundles created with the CLI alone do not include + `vscode-logs/`. Learn more about + [VS Code diagnostics](../user-guides/workspace-access/vscode.md#diagnostics-and-support-bundles). + > [!NOTE] > While support bundles can be generated without a running workspace, it is > recommended to specify one to maximize troubleshooting information. diff --git a/docs/tutorials/quickstart.md b/docs/tutorials/quickstart.md index eb231b791961d..45a067608c073 100644 --- a/docs/tutorials/quickstart.md +++ b/docs/tutorials/quickstart.md @@ -35,15 +35,23 @@ explained through a cooking analogy: - 10 minutes of your time > [!TIP] -> If you use a coding agent like Claude Code, the [coder/skills](https://github.com/coder/skills) `setup` skill can train the coding agent on the following steps (install Docker, install Coder, create your first template, and launch a workspace). +> If you use a coding agent like Claude Code, the [coder/skills](https://github.com/coder/skills) `setup` skill can train the coding agent on the following steps (install a container runtime, install Coder, create your first template, and launch a workspace). -## Step 1: Install Docker and set up permissions +## Step 1: Install a container runtime + +Coder needs a Docker-compatible container runtime running on the host, such as +[Colima](https://colima.run), [Rancher Desktop](https://rancherdesktop.io), +[Podman](https://podman.io), or +[Docker Desktop](https://www.docker.com/products/docker-desktop/). If you +already have one installed and running, skip ahead to +[Step 2](#step-2-install-and-start-coder). Otherwise, follow the steps below to +install a free runtime quickly on your platform. <div class="tabs"> ### Linux -1. Install Docker: +1. Install Docker Engine: ```bash curl -sSL https://get.docker.com | sh @@ -74,15 +82,23 @@ explained through a cooking analogy: ### macOS -1. [Install Docker](https://docs.docker.com/desktop/setup/install/mac-install/). -There is a Homebrew formula for the Docker command and a Homebrew cask of Docker -Desktop if you prefer: +[Colima](https://colima.run) is a free, lightweight container runtime that +provides the Docker daemon on macOS without the overhead of Docker Desktop. + +1. Install Colima and the Docker CLI with [Homebrew](https://brew.sh): + + ```shell + brew install colima docker + ``` + +1. Start Colima to launch the Docker daemon: ```shell - brew install --cask docker-desktop + colima start ``` -1. Open Docker Desktop. + Colima exposes the Docker socket at `/var/run/docker.sock`, so the Coder + Quickstart template works without additional configuration. ### Windows @@ -90,9 +106,32 @@ If you plan to use the built-in PostgreSQL database, ensure that the [Visual C++ Runtime](https://learn.microsoft.com/en-US/cpp/windows/latest-supported-vc-redist#latest-microsoft-visual-c-redistributable-version) is installed. -1. [Install Docker](https://docs.docker.com/desktop/install/windows-install/). +[Podman Desktop](https://podman-desktop.io) is a free GUI for the Podman container runtime. +Its onboarding installs and configures the required +Windows Subsystem for Linux (WSL2) or Hyper-V layer if it isn't already enabled. + +1. Download and install [Podman Desktop](https://podman-desktop.io/downloads). + +1. Follow the onboarding to configure Podman. + +1. If you configured Podman to use WSL2, then you will need to do either + upgrade WSL2 to version 2.5.1 or later + (which uses [cgroups](https://wikipedia.org/wiki/Cgroups) v2 by default) + or create a `.wslconfig` file in the `%USERPROFILE%` directory + with the following contents + + ```text + [wsl2] + kernelCommandLine=cgroup_no_v1=all + ``` + + This is not required for Podman with Hyper-V. + +1. Open Podman Desktop and complete the onboarding to create and start a + Podman machine. -1. Open Docker Desktop. + Podman Desktop enables Docker socket compatibility by default, so tools + that expect the Docker daemon work without additional configuration. </div> @@ -275,23 +314,31 @@ When creating a workspace from a Docker template, you may see an error like: Error: Error pinging Docker server: Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running? ``` -This means Docker is either not installed or not running on the machine where -Coder is running. Docker must be running before you create a workspace from a -Docker-based template. +This means a container runtime is either not installed or not running on the +machine where Coder is running. A runtime must be running before you create a +workspace from a Docker-based template. <div class="tabs"> #### macOS -1. If Docker Desktop is not installed, - [install it](https://docs.docker.com/desktop/setup/install/mac-install/) or - use Homebrew: +1. If Colima is not installed, install it with [Homebrew](https://brew.sh): + + ```shell + brew install colima docker + ``` + +1. Start Colima to launch the Docker daemon: ```shell - brew install --cask docker-desktop + colima start ``` -1. Open Docker Desktop and verify that it is running. +1. Verify that the daemon is reachable: + + ```shell + docker ps + ``` #### Linux @@ -324,10 +371,10 @@ Docker-based template. #### Windows -1. If Docker Desktop is not installed, - [install it](https://docs.docker.com/desktop/install/windows-install/). +1. If Podman Desktop is not installed, + [download and install it](https://podman-desktop.io/downloads). -1. Open Docker Desktop and verify that it is running. +1. Open Podman Desktop and verify that a Podman machine is running. </div> diff --git a/docs/tutorials/reverse-proxy-nginx.md b/docs/tutorials/reverse-proxy-nginx.md index afc48cd6ef75c..18dfd8a734470 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; } } diff --git a/docs/tutorials/testing-templates.md b/docs/tutorials/testing-templates.md index 3e0de88bc92a4..fcaa89793a314 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 }} ``` diff --git a/docs/user-guides/devcontainers/customizing-dev-containers.md b/docs/user-guides/devcontainers/customizing-dev-containers.md index 53570981dcd5f..b5010d29ad994 100644 --- a/docs/user-guides/devcontainers/customizing-dev-containers.md +++ b/docs/user-guides/devcontainers/customizing-dev-containers.md @@ -247,27 +247,6 @@ Standard dev container variables are also available: | `${containerWorkspaceFolder}` | Workspace folder path inside the container | | `${localWorkspaceFolder}` | Workspace folder path on the host | -### Session token - -Use `$SESSION_TOKEN` in external app URLs to include the user's session token: - -```json -{ - "customizations": { - "coder": { - "apps": [ - { - "slug": "custom-ide", - "displayName": "Custom IDE", - "url": "custom-ide://open?token=$SESSION_TOKEN&folder=${containerWorkspaceFolder}", - "external": true - } - ] - } - } -} -``` - ## Feature options as environment variables When your dev container uses features, Coder exposes feature options as diff --git a/docs/user-guides/workspace-access/index.md b/docs/user-guides/workspace-access/index.md index da72459cbbd66..22aa1d7140041 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)) @@ -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) diff --git a/docs/user-guides/workspace-access/vscode.md b/docs/user-guides/workspace-access/vscode.md index 3f89ac8e258bb..a467fbc3766ca 100644 --- a/docs/user-guides/workspace-access/vscode.md +++ b/docs/user-guides/workspace-access/vscode.md @@ -34,6 +34,73 @@ ext install coder.coder-remote Alternatively, manually install the VSIX from the [latest release](https://github.com/coder/vscode-coder/releases/latest). +## Local telemetry + +The Coder Remote extension records local telemetry to help diagnose extension +and workspace connection issues. Telemetry is stored on your machine. It is not +sent to Coder unless you export it or include it in a support bundle and share +that file. + +Local telemetry is controlled by the VS Code setting `coder.telemetry.level`: + +| Value | Behavior | +|---------|---------------------------------------------------------------| +| `off` | Disable extension telemetry collection. | +| `local` | Record telemetry events on this machine. This is the default. | + +### Stored data + +Telemetry can include diagnostic details such as extension version, VS Code +version, operating system, machine and session identifiers, deployment URL, +workspace and agent names, command outcomes, connection state, request routes, +timing, and error details. It does not intentionally collect source code, +terminal contents, tokens, or credentials. + +### Tracked activity + +The exact events vary by extension version. For a comprehensive list of current +events, properties, and attributes, see the +[extension event reference](https://github.com/coder/vscode-coder/blob/main/src/instrumentation/EVENTS.md). +The following categories summarize the diagnostic signals the extension may +record: + +| Area | Examples | +|--------------------------------|----------------------------------------------------------------------------------------------| +| Extension lifecycle | Activation, deployment initialization, and configuration loading. | +| Authentication and credentials | Sign-in state, token refresh, logout, credential storage, and deployment recovery. | +| Commands and diagnostics | Command outcomes, telemetry exports, support bundle creation, ping, and speed tests. | +| Workspace workflows | Workspace selection, open attempts, dev container handoff, start, and update prompts. | +| CLI and remote setup | CLI binary resolution, download, verification, configuration, and setup through SSH handoff. | +| Connection health | Workspace and agent state transitions, reconnects, SSH process health, and network samples. | +| HTTP diagnostics | Normalized routes, status classes, and latency rollups. | + +### Storage and retention + +The extension stores telemetry as JSON Lines files in its VS Code global storage +under a `telemetry` directory. Files rotate at 5 MiB, are kept for up to 30 days, +and are capped at 100 MiB total by default. + +You can tune local retention with the advanced `coder.telemetry.local` setting. +Most users should keep the default values. + +### Diagnostics and support bundles + +The extension includes commands for collecting diagnostics from VS Code: + +- **Coder: Export Telemetry** exports only local telemetry. Choose a date range + and JSON or OTLP JSON zip format, then review the file before sharing it. +- **Coder: Create Support Bundle** runs `coder support bundle` and adds a + `vscode-logs/` directory with recent VS Code extension diagnostics, including + extension logs, proxy and Remote-SSH logs, redacted VS Code settings, and + local telemetry files when available. The `vscode-logs/` directory is only + added when the bundle is created from the VS Code Coder Remote extension; + bundles created with the CLI alone do not include it. +- **Coder: View Logs** opens the extension output logs in VS Code. + +Support bundles can contain sensitive diagnostic data. Review the generated +bundle before sharing it. Learn more about +[support bundles](../../support/support-bundle.md). + ## VS Code extensions There are multiple ways to add extensions to VS Code Desktop: diff --git a/enterprise/aibridgeproxyd/aibridgeproxyd.go b/enterprise/aibridgeproxyd/aibridgeproxyd.go index 19d05ca5113b8..a48a190deb55e 100644 --- a/enterprise/aibridgeproxyd/aibridgeproxyd.go +++ b/enterprise/aibridgeproxyd/aibridgeproxyd.go @@ -28,21 +28,10 @@ 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" ) -// 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" @@ -139,10 +128,12 @@ 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 - // 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. @@ -159,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 allowlist for the goproxy condition. - 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. @@ -218,15 +216,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 +240,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 } @@ -284,7 +275,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 == "" { @@ -296,14 +286,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 { @@ -330,14 +312,21 @@ func New(ctx context.Context, logger slog.Logger, opts Options) (*Server, error) proxy.CertStore = NewCertCache() } - // Always set secure TLS defaults, overriding goproxy's default. - // This ensures secure TLS connections for: - // - HTTPS upstream proxy connections - // - MITM'd requests if aibridge uses HTTPS + // Override goproxy's default transport, which has InsecureSkipVerify: true. + // This applies to all proxy.Tr traffic: MITM'd requests forwarded to aibridge, + // passthrough requests, and HTTPS upstream proxy connections. Proxy is + // intentionally unset so MITM'd requests go directly to aibridge, never + // through an upstream proxy or HTTPS_PROXY env var. rootCAs, err := x509.SystemCertPool() if err != nil { return nil, xerrors.Errorf("failed to load system certificate pool: %w", err) } + proxy.Tr = &http.Transport{ + TLSClientConfig: &tls.Config{ + MinVersion: tls.VersionTLS12, + RootCAs: rootCAs, + }, + } srv := &Server{ ctx: ctx, @@ -345,6 +334,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, @@ -352,13 +342,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) @@ -380,15 +370,6 @@ func New(ctx context.Context, logger slog.Logger, opts Options) (*Server, error) } } - // Set transport without Proxy to ensure MITM'd requests go directly to aibridge, - // not through any upstream proxy. - proxy.Tr = &http.Transport{ - TLSClientConfig: &tls.Config{ - MinVersion: tls.VersionTLS12, - RootCAs: rootCAs, - }, - } - // Add custom CA certificate if provided (for corporate proxies with private CAs). // If no CA certificate is provided, the system certificate pool is used. if opts.UpstreamProxyCA != "" { @@ -443,7 +424,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 +433,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 +476,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), @@ -679,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") @@ -709,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") @@ -810,7 +790,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) { @@ -831,7 +811,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 } @@ -946,16 +926,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.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.name), ) return req, nil } @@ -1004,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()) @@ -1031,30 +1024,45 @@ 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) } } // 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,11 +1085,21 @@ 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") + 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") } @@ -1104,6 +1122,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 0000000000000..397ed1cf3ec3b --- /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/enterprise/aibridgeproxyd/aibridgeproxyd_test.go b/enterprise/aibridgeproxyd/aibridgeproxyd_test.go index 6b843d8b14100..e433bd6692ae1 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" @@ -34,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" ) @@ -145,20 +147,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,36 +182,64 @@ 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 well-known providers +// via testProviderFromHost. Unknown hosts use a generic name and OpenAI type. +func withProviderHosts(hosts ...string) testProxyOption { return func(cfg *testProxyConfig) { - cfg.aibridgeProviderFromHost = fn + providers := make([]aibridgeproxyd.ReloadedProvider, 0, len(hosts)) + for _, h := range hosts { + provider := testProviderFromHost(h) + host, _, splitErr := net.SplitHostPort(h) + if splitErr != nil { + host = h + } + providers = append(providers, aibridgeproxyd.ReloadedProvider{ + ProviderOutcome: aibridged.ProviderOutcome{ + Name: provider.name, + Type: provider.providerType, + Status: aibridged.ProviderStatusEnabled, + }, + Host: strings.ToLower(host), + }) + } + cfg.providers = providers } } -// 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} } } @@ -264,39 +293,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{ + {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 { 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 +344,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 +486,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 +501,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 +517,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 +534,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 +551,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 +569,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 +584,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 +600,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,16 +616,13 @@ 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()) - require.Equal(t, "80", srv.CoderAccessURL().Port()) + require.Equal(t, "localhost", srv.CoderAccessURL().Host) }) t.Run("CoderAccessURLDefaultHTTPSPort", func(t *testing.T) { @@ -602,16 +632,13 @@ 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()) - require.Equal(t, "443", srv.CoderAccessURL().Port()) + require.Equal(t, "localhost", srv.CoderAccessURL().Host) }) t.Run("CoderAccessURLExplicitPort", func(t *testing.T) { @@ -621,12 +648,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 +664,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 +678,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 +692,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 +729,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 +743,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 +760,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 +777,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 +794,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 +812,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 +830,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 +848,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 +865,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 +883,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 +900,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 +921,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,17 +938,54 @@ 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) }) + + 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) { @@ -1053,12 +998,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 +1024,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 +1051,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 +1076,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 +1085,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 +1096,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 +1130,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 +1174,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 +1185,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 +1250,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 +1261,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 +1306,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 +1325,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 +1335,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 +1345,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 +1355,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 +1399,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,14 +1547,13 @@ func TestProxy_MITM_BYOKInjection(t *testing.T) { srv := newTestProxy(t, withCoderAccessURL(aibridgedServer.URL), - withDomainAllowlist(aibridgeproxyd.HostCopilot), - withAIBridgeProviderFromHost(testProviderFromHost), + 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) @@ -1638,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) { @@ -1687,8 +1766,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...) @@ -1722,6 +1801,37 @@ func TestListenerTLS(t *testing.T) { } } +// TestProxy_AIBridgeTLSVerification verifies the proxy refuses to forward +// MITM'd requests to an aibridge endpoint whose TLS certificate is not trusted. +func TestProxy_AIBridgeTLSVerification(t *testing.T) { + t.Parallel() + + // HTTPS server with a self-signed cert untrusted by the system pool, + // standing in for aibridge. + aibridgeServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(aibridgeServer.Close) + + srv := newTestProxy(t, + withCoderAccessURL(aibridgeServer.URL), + withProviderHosts(aibridgeproxyd.HostAnthropic), + ) + + client := newProxyClient(t, srv, makeProxyAuthHeader("test-token"), getProxyCertPool(t), false) + + req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, + "https://"+aibridgeproxyd.HostAnthropic+"/v1/messages", + strings.NewReader(`{}`)) + require.NoError(t, err) + + resp, err := client.Do(req) + if resp != nil { + defer resp.Body.Close() + } + require.Error(t, err, "proxy must refuse to forward MITM'd requests to an untrusted aibridge cert") +} + // TestServeCACert validates that a configured certificate file can be served correctly by the API. // // Note: Tests for certificate file errors (missing file, invalid PEM) are @@ -1791,14 +1901,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 +1955,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 +1971,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 +1979,7 @@ func TestUpstreamProxy(t *testing.T) { }, }, { - name: "NonAllowlistedDomain_TunneledToHTTPSUpstreamProxy", + name: "NonProviderHost_TunneledToHTTPSUpstreamProxy", tunneled: true, upstreamProxyTLS: true, buildTargetURL: func(finalDestinationURL *url.URL) string { @@ -1881,7 +1987,7 @@ func TestUpstreamProxy(t *testing.T) { }, }, { - name: "NonAllowlistedDomain_TunneledToHTTPUpstreamProxyWithAuth", + name: "NonProviderHost_TunneledToHTTPUpstreamProxyWithAuth", tunneled: true, upstreamProxyTLS: false, upstreamProxyAuth: "proxyuser:proxypass", @@ -1890,7 +1996,7 @@ func TestUpstreamProxy(t *testing.T) { }, }, { - name: "NonAllowlistedDomain_TunneledToHTTPUpstreamProxyWithUsernameOnly", + name: "NonProviderHost_TunneledToHTTPUpstreamProxyWithUsernameOnly", tunneled: true, upstreamProxyTLS: false, upstreamProxyAuth: "proxyuser", @@ -1899,7 +2005,7 @@ func TestUpstreamProxy(t *testing.T) { }, }, { - name: "NonAllowlistedDomain_TunneledToHTTPUpstreamProxyWithUsernameAndColon", + name: "NonProviderHost_TunneledToHTTPUpstreamProxyWithUsernameAndColon", tunneled: true, upstreamProxyTLS: false, upstreamProxyAuth: "proxyuser:", @@ -1908,7 +2014,7 @@ func TestUpstreamProxy(t *testing.T) { }, }, { - name: "NonAllowlistedDomain_TunneledToHTTPUpstreamProxyWithTokenAuth", + name: "NonProviderHost_TunneledToHTTPUpstreamProxyWithTokenAuth", tunneled: true, upstreamProxyTLS: false, upstreamProxyAuth: ":proxypass", @@ -1917,7 +2023,7 @@ func TestUpstreamProxy(t *testing.T) { }, }, { - name: "AllowlistedDomain_MITMByAIProxy", + name: "ProviderHost_MITMByAIProxy", tunneled: false, upstreamProxyTLS: false, buildTargetURL: func(_ *url.URL) string { @@ -2057,10 +2163,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 +2179,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 +2219,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 +2229,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 +2246,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 +2260,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 +2282,18 @@ 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{ + ProviderOutcome: aibridged.ProviderOutcome{ + Name: openrouterProvider, + Type: "openai", + Status: aibridged.ProviderStatusEnabled, + }, + Host: openrouterDomain, }), ) @@ -2307,10 +2414,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 +2502,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 +2549,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/metrics.go b/enterprise/aibridgeproxyd/metrics.go index 55a1fa417759c..ccfd334aa70fc 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 0000000000000..6ebefbd56be83 --- /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 d235a9be3b093..9dc54c5fff647 100644 --- a/enterprise/aibridgeproxyd/reload.go +++ b/enterprise/aibridgeproxyd/reload.go @@ -3,37 +3,94 @@ package aibridgeproxyd import ( "context" "net/http" - "net/url" "slices" "strings" + "time" "github.com/elazarl/goproxy" "golang.org/x/xerrors" "cdr.dev/slog/v3" + "github.com/coder/coder/v2/coderd/aibridged" ) +// 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 { + aibridged.ProviderOutcome + Host string +} + +// 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) + s.recordReloadAttempt() + 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 == 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)), + slog.F("mitm_hosts", router.mitmHosts), ) 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 @@ -42,7 +99,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,71 +111,36 @@ 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), - ) +// buildProviderRouter constructs a router snapshot from a classified +// 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) { + 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 { 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), - ) + host := strings.ToLower(p.Host) + if host == "" { 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), - ) + if _, exists := providerByHost[host]; exists { continue } - host := strings.ToLower(u.Hostname()) - if _, exists := nameByHost[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 -} - -// 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 + return &providerRouter{ + mitmHosts: mitmHosts, + providerByHost: providerByHost, + }, nil } diff --git a/enterprise/aibridgeproxyd/reload_internal_test.go b/enterprise/aibridgeproxyd/reload_internal_test.go index 7a8b0f9fae30c..537392fc44751 100644 --- a/enterprise/aibridgeproxyd/reload_internal_test.go +++ b/enterprise/aibridgeproxyd/reload_internal_test.go @@ -9,34 +9,46 @@ 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{ + ProviderOutcome: aibridged.ProviderOutcome{ + Name: name, + Type: "openai", + Status: aibridged.ProviderStatusEnabled, + }, + Host: host, + } +} + 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) 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")) - 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() 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) } @@ -45,103 +57,112 @@ 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) 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) } -// 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"), + {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. + {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"}) 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") + 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{ + {ProviderOutcome: aibridged.ProviderOutcome{Name: "provider", Type: "openai", Status: aibridged.ProviderStatusEnabled}, Host: "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) - // First provider wins on duplicate host. - assert.Equal(t, "first", 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("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, routedProvider{name: "first", providerType: "openai"}, 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{ + {ProviderOutcome: aibridged.ProviderOutcome{Name: "no-host", Type: "openai", Status: aibridged.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, routedProvider{name: "good", providerType: "openai"}, router.providerFromHost("api.good.example.com")) 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 d51aa5bc98cfd..bfc90338d42b6 100644 --- a/enterprise/aibridgeproxyd/reload_test.go +++ b/enterprise/aibridgeproxyd/reload_test.go @@ -5,15 +5,19 @@ import ( "io" "net/http" "net/http/httptest" + "net/url" "slices" "strings" "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" ) @@ -27,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 @@ -55,16 +60,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 +90,61 @@ 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 a copy so callers can't mutate our internal snapshot. - return slices.Clone(s.providers), nil + return reload, nil } -// 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. +// 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{ + ProviderOutcome: aibridged.ProviderOutcome{Name: p.name, Type: "openai"}, + } + if strings.TrimSpace(p.baseURL) == "" { + 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 = 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 = aibridged.ProviderStatusError + out.Err = xerrors.Errorf("base url %q has no hostname", p.baseURL) + return out + } + if claimedBy, taken := seenHost[host]; taken { + 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 = aibridged.ProviderStatusEnabled + return out +} + +// 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() @@ -103,14 +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"), - // Empty boot allowlist: the router must be populated by Reload, - // matching the production daemon's behavior. - withDomainAllowlist(), - withAIBridgeProviderFromHost(nil), withRefreshProviders(store.refresh), + withMetrics(metrics), ) certPool := getProxyCertPool(t) @@ -125,6 +177,7 @@ func newReloadTestHarness(t *testing.T) *reloadTestHarness { return &reloadTestHarness{ srv: srv, store: store, + metrics: metrics, client: client, bridged: bridged, recorder: recorder, @@ -192,16 +245,147 @@ 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 +// 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/<name>/<path> -// - 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,53 +394,62 @@ 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") + h.expectProviderStatus(t, "alpha", "enabled") // 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"}, + // 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. - 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") 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. - 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") 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. - h.store.set([]aibridgeproxyd.ProviderRoute{ - {Name: "beta", BaseURL: "https://beta.invalid/v1"}, + // 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. @@ -264,15 +457,21 @@ 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 // 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") + 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 @@ -288,15 +487,17 @@ 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())) 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) { @@ -304,31 +505,36 @@ 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())) 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) { 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())) 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) { @@ -337,10 +543,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 +558,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 +575,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 0f7ba976a5b79..08641f5769cc1 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,69 @@ 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{ + ProviderOutcome: aibridged.ProviderOutcome{ + Name: row.Name, + Type: string(row.Type), + }, + } + if !row.Enabled { + out.Status = aibridged.ProviderStatusDisabled + return out + } + if strings.TrimSpace(row.BaseUrl) == "" { + 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 = 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 = aibridged.ProviderStatusError + out.Err = xerrors.Errorf("base url %q has no hostname", row.BaseUrl) + return out + } + if claimedBy, taken := seenHost[host]; taken { + 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 = aibridged.ProviderStatusEnabled + return out } diff --git a/enterprise/cli/aibridgeproxyd_internal_test.go b/enterprise/cli/aibridgeproxyd_internal_test.go new file mode 100644 index 0000000000000..2c8520878b60d --- /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/aibridged" + "github.com/coder/coder/v2/coderd/database" +) + +// 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, 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"]) + }) + + 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, 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") + }) + + t.Run("EmptyBaseURL", func(t *testing.T) { + t.Parallel() + + seen := map[string]string{} + got := classifyProviderRow(enabledRow("no-url", " "), seen) + assert.Equal(t, aibridged.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, aibridged.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, aibridged.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, aibridged.ProviderStatusEnabled, first.Status) + + second := classifyProviderRow(enabledRow("second", "https://shared.example.com/v2"), seen) + 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") + }) + + 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, aibridged.ProviderStatusEnabled, got.Status) + assert.Equal(t, "api.example.com", got.Host) + }) +} diff --git a/enterprise/cli/sharing_test.go b/enterprise/cli/sharing_test.go index 6e1e3c8dd4ff8..c972cd6cd9e10 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/cli/testdata/coder_external-workspaces_create_--help.golden b/enterprise/cli/testdata/coder_external-workspaces_create_--help.golden index 6f33cda59b0a3..12c826a5fa377 100644 --- a/enterprise/cli/testdata/coder_external-workspaces_create_--help.golden +++ b/enterprise/cli/testdata/coder_external-workspaces_create_--help.golden @@ -13,13 +13,29 @@ OPTIONS: -O, --org string, $CODER_ORGANIZATION Select which organization (uuid or name) to use. + --always-prompt bool + Always prompt all parameters. Does not pull parameter values from + existing workspace. + --automatic-updates string, $CODER_WORKSPACE_AUTOMATIC_UPDATES (default: never) Specify automatic updates setting for the workspace (accepts 'always' or 'never'). + --build-option string-array, $CODER_BUILD_OPTION + Build option value in the format "name=value". + DEPRECATED: Use --ephemeral-parameter instead. + + --build-options bool + Prompt for one-time build options defined with ephemeral parameters. + DEPRECATED: Use --prompt-ephemeral-parameters instead. + --copy-parameters-from string, $CODER_WORKSPACE_COPY_PARAMETERS_FROM Specify the source workspace name to copy parameters from. + --ephemeral-parameter string-array, $CODER_EPHEMERAL_PARAMETER + Set the value of ephemeral parameters defined in the template. The + format is "name=value". + --no-wait bool, $CODER_CREATE_NO_WAIT Return immediately after creating the workspace. The build will run in the background. @@ -34,6 +50,11 @@ OPTIONS: Specify the name of a template version preset. Use 'none' to explicitly indicate that no preset should be used. + --prompt-ephemeral-parameters bool, $CODER_PROMPT_EPHEMERAL_PARAMETERS + Prompt to set values of ephemeral parameters defined in the template. + If a value has been set via --ephemeral-parameter, it will not be + prompted for. + --rich-parameter-file string, $CODER_RICH_PARAMETER_FILE Specify a file path with values for rich parameters defined in the template. The file should be in YAML format, containing key-value diff --git a/enterprise/cli/testdata/coder_server_--help.golden b/enterprise/cli/testdata/coder_server_--help.golden index a9062a426f3d5..fe409a62179e3 100644 --- a/enterprise/cli/testdata/coder_server_--help.golden +++ b/enterprise/cli/testdata/coder_server_--help.golden @@ -10,6 +10,9 @@ SUBCOMMANDS: email and password and adds it to every organization. dbcrypt Manage database encryption. + fix-oidc-links Reset OIDC linked IDs that do not match the + expected issuer, allowing users to + re-authenticate. postgres-builtin-serve Run the built-in PostgreSQL deployment. postgres-builtin-url Output the connection URL for the built-in PostgreSQL deployment. @@ -125,35 +128,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.<region>.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.<region>.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 +195,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 @@ -263,8 +292,12 @@ Clients include the Coder CLI, Coder Desktop, IDE extensions, and the web UI. --ssh-config-options string-array, $CODER_SSH_CONFIG_OPTIONS These SSH config options will override the default SSH config options. Provide options in "key=value" or "key value" format separated by - commas.Using this incorrectly can break SSH to your deployment, use - cautiously. + commas. Using this incorrectly can break SSH to your deployment, use + cautiously. The following options are not allowed: Host, Match, + Include, ProxyCommand, ProxyJump, LocalCommand, PermitLocalCommand, + RemoteCommand, KnownHostsCommand, PKCS11Provider, SecurityKeyProvider, + SmartcardDevice, XAuthLocation. Option values must not contain + newline, carriage return, or NUL characters. --web-terminal-renderer string, $CODER_WEB_TERMINAL_RENDERER (default: canvas) The renderer to use when opening a web terminal. Valid values are @@ -273,7 +306,8 @@ Clients include the Coder CLI, Coder Desktop, IDE extensions, and the web UI. --workspace-hostname-suffix string, $CODER_WORKSPACE_HOSTNAME_SUFFIX (default: coder) Workspace hostnames use this suffix in SSH config and Coder Connect on Coder Desktop. By default it is coder, resulting in names like - myworkspace.coder. + myworkspace.coder. The suffix must not start with a dot, and must not + contain spaces, newlines, or glob characters (* and ?). CONFIG OPTIONS: Use a YAML configuration file when your server launch become unwieldy. @@ -424,8 +458,8 @@ NETWORKING OPTIONS: True-Client-Ip, X-Forwarded-For. --proxy-trusted-origins string-array, $CODER_PROXY_TRUSTED_ORIGINS - Origin addresses to respect "proxy-trusted-headers". e.g. - 192.168.1.0/24. + Origin addresses to respect "proxy-trusted-headers" and + X-Forwarded-Host for subdomain app routing. e.g. 192.168.1.0/24. --redirect-to-access-url bool, $CODER_REDIRECT_TO_ACCESS_URL Specifies whether to redirect requests that do not match the access diff --git a/enterprise/cli/testdata/coder_server_fix-oidc-links_--help.golden b/enterprise/cli/testdata/coder_server_fix-oidc-links_--help.golden new file mode 100644 index 0000000000000..201a71435eae0 --- /dev/null +++ b/enterprise/cli/testdata/coder_server_fix-oidc-links_--help.golden @@ -0,0 +1,29 @@ +coder v0.0.0-devel + +USAGE: + coder server fix-oidc-links [flags] + + Reset OIDC linked IDs that do not match the expected issuer, allowing users to + re-authenticate. + +OPTIONS: + --postgres-connection-auth password|awsiamrds, $CODER_PG_CONNECTION_AUTH (default: password) + Type of auth to use when connecting to postgres. + + -n, --dry-run bool, $CODER_FIX_OIDC_LINKS_DRY_RUN + Print analysis only, do not modify the database. + + --issuer-url string, $CODER_OIDC_ISSUER_URL + The OIDC issuer URL. The canonical issuer is resolved via OIDC + discovery. + + --postgres-url string, $CODER_PG_CONNECTION_URL + URL of a PostgreSQL database. If empty, the built-in PostgreSQL + deployment will be used (Coder must not be already running in this + case). + + -y, --yes bool + Bypass confirmation prompts. + +——— +Run `coder --help` for a list of global options. diff --git a/enterprise/coderd/ai_providers_backfill_test.go b/enterprise/coderd/ai_providers_backfill_test.go new file mode 100644 index 0000000000000..ea385bead34a5 --- /dev/null +++ b/enterprise/coderd/ai_providers_backfill_test.go @@ -0,0 +1,51 @@ +package coderd_test + +import ( + "crypto/rand" + "database/sql" + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" + + "cdr.dev/slog/v3/sloggers/slogtest" + agplcoderd "github.com/coder/coder/v2/coderd" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/enterprise/dbcrypt" + "github.com/coder/coder/v2/testutil" +) + +func TestBackfillBedrockProviderTypeEncryptedSettings(t *testing.T) { + t.Parallel() + + rawDB, _ := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitShort) + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) + + key := make([]byte, 32) + _, _ = rand.Read(key) + ciphers, err := dbcrypt.NewCiphers(key) + require.NoError(t, err) + cryptDB, err := dbcrypt.New(ctx, rawDB, ciphers...) + require.NoError(t, err) + + rawSettings, err := json.Marshal(codersdk.AIProviderSettings{ + Bedrock: &codersdk.AIProviderBedrockSettings{Region: "us-east-1"}, + }) + require.NoError(t, err) + provider := dbgen.AIProvider(t, cryptDB, database.AIProvider{ + Type: database.AiProviderTypeAnthropic, + Settings: sql.NullString{String: string(rawSettings), Valid: true}, + }) + + agplcoderd.BackfillBedrockProviderType(ctx, cryptDB, logger) + + // Verify via raw DB: type is not encrypted so it is directly readable. + row, err := rawDB.GetAIProviderByName(ctx, provider.Name) + require.NoError(t, err) + require.Equal(t, database.AiProviderTypeBedrock, row.Type, "encrypted legacy row must be promoted") + require.True(t, row.SettingsKeyID.Valid, "settings must remain encrypted after backfill") +} diff --git a/enterprise/coderd/aibridge_reload_test.go b/enterprise/coderd/aibridge_reload_test.go index 6aee3afa91a53..e3370c8f7d2ea 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) @@ -177,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, @@ -188,6 +234,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 +243,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. + // 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. enabled := true @@ -213,6 +262,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 +275,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/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go index 8c7875fa93714..713637bdfca31 100644 --- a/enterprise/coderd/license/license.go +++ b/enterprise/coderd/license/license.go @@ -494,9 +494,10 @@ func LicensesEntitlements( feature := entitlements.Features[codersdk.FeatureAIGovernanceUserLimit] switch { case feature.Entitlement == codersdk.EntitlementNotEntitled: - // If the limit is not set - entitlements.Errors = append(entitlements.Errors, - fmt.Sprintf("Your deployment has %d active AI Governance seats but the license is not entitled to this feature.", actual)) + // Not-entitled deployments can accumulate phantom ai_seat_state + // rows from prior Gateway testing or Task usage. Surfacing an + // error here is alarming and inactionable for customers who + // never purchased the AI Governance addon. case feature.Entitlement == codersdk.EntitlementGracePeriod && feature.Limit != nil: entitlements.Warnings = append(entitlements.Warnings, fmt.Sprintf( diff --git a/enterprise/coderd/license/license_test.go b/enterprise/coderd/license/license_test.go index 3481e5b2b1d7b..0a19250c93a56 100644 --- a/enterprise/coderd/license/license_test.go +++ b/enterprise/coderd/license/license_test.go @@ -1146,6 +1146,59 @@ func TestEntitlements(t *testing.T) { require.NotContains(t, warning, "over the limit") } }) + + t.Run("NotEntitledSuppressed", func(t *testing.T) { + t.Parallel() + + const activeSeatCount int64 = 42 + + ctrl := gomock.NewController(t) + mDB := dbmock.NewMockStore(ctrl) + + // Premium license without the AI Governance addon. + licenseOpts := (&coderdenttest.LicenseOptions{ + FeatureSet: codersdk.FeatureSetPremium, + NotBefore: dbtime.Now().Add(-time.Hour).Truncate(time.Second), + GraceAt: dbtime.Now().Add(time.Hour * 24 * 60).Truncate(time.Second), + ExpiresAt: dbtime.Now().Add(time.Hour * 24 * 90).Truncate(time.Second), + }). + UserLimit(100) + + lic := database.License{ + ID: 1, + JWT: coderdenttest.GenerateLicense(t, *licenseOpts), + Exp: licenseOpts.ExpiresAt, + } + + mDB.EXPECT(). + GetUnexpiredLicenses(gomock.Any()). + Return([]database.License{lic}, nil) + mDB.EXPECT(). + GetActiveUserCount(gomock.Any(), false). + Return(int64(1), nil) + mDB.EXPECT(). + GetActiveAISeatCount(gomock.Any()). + Return(activeSeatCount, nil) + mDB.EXPECT(). + GetTotalUsageDCManagedAgentsV1(gomock.Any(), gomock.Any()). + Return(int64(0), nil) + mDB.EXPECT(). + GetTemplatesWithFilter(gomock.Any(), gomock.Any()). + Return([]database.Template{}, nil) + + entitlements, err := license.Entitlements(context.Background(), mDB, 1, 0, coderdenttest.Keys, all) + require.NoError(t, err) + require.True(t, entitlements.HasLicense) + + // The not-entitled case should not produce errors about + // AI Governance seat counts. + for _, e := range entitlements.Errors { + require.NotContains(t, e, "AI Governance seats") + } + for _, w := range entitlements.Warnings { + require.NotContains(t, w, "AI Governance seats") + } + }) }) } diff --git a/enterprise/coderd/provisionerdaemons.go b/enterprise/coderd/provisionerdaemons.go index 17a00d22421b1..f0ace6c7c5732 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/enterprise/coderd/subagent_test.go b/enterprise/coderd/subagent_test.go new file mode 100644 index 0000000000000..8b893954ca4d2 --- /dev/null +++ b/enterprise/coderd/subagent_test.go @@ -0,0 +1,515 @@ +package coderd_test + +import ( + "cmp" + "context" + "slices" + "strings" + "sync/atomic" + "testing" + + "github.com/google/uuid" + "github.com/prometheus/client_golang/prometheus" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/coder/coder/v2/agent/agentcontainers" + "github.com/coder/coder/v2/agent/proto" + "github.com/coder/coder/v2/coderd/agentapi" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/db2sdk" + agpldbauthz "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" + agplportsharing "github.com/coder/coder/v2/coderd/portsharing" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/util/ptr" + "github.com/coder/coder/v2/codersdk" + entdbauthz "github.com/coder/coder/v2/enterprise/coderd/dbauthz" + entportsharing "github.com/coder/coder/v2/enterprise/coderd/portsharing" + "github.com/coder/coder/v2/testutil" + "github.com/coder/quartz" +) + +func TestSubAgentAPICreateSubAgentAppShareRespectsEnterpriseMaxPortShareLevel(t *testing.T) { + t.Parallel() + + type expectedApp struct { + slugSuffix string + sharingLevel database.AppSharingLevel + } + + tests := []struct { + name string + maxPortShareLevel database.AppSharingLevel + apps []*proto.CreateSubAgentRequest_App + expectedStoredApps []expectedApp + }{ + { + name: "AuthenticatedClampsPublicOnly", + maxPortShareLevel: database.AppSharingLevelAuthenticated, + apps: []*proto.CreateSubAgentRequest_App{ + { + Slug: "public-app", + Share: proto.CreateSubAgentRequest_App_PUBLIC.Enum(), + Url: ptr.Ref("http://localhost:8080"), + }, + { + Slug: "authenticated-app", + Share: proto.CreateSubAgentRequest_App_AUTHENTICATED.Enum(), + Url: ptr.Ref("http://localhost:8081"), + }, + { + Slug: "owner-app", + Share: proto.CreateSubAgentRequest_App_OWNER.Enum(), + Url: ptr.Ref("http://localhost:8082"), + }, + { + Slug: "organization-app", + Share: proto.CreateSubAgentRequest_App_ORGANIZATION.Enum(), + Url: ptr.Ref("http://localhost:8083"), + }, + }, + expectedStoredApps: []expectedApp{ + { + slugSuffix: "-authenticated-app", + sharingLevel: database.AppSharingLevelAuthenticated, + }, + { + slugSuffix: "-organization-app", + sharingLevel: database.AppSharingLevelOrganization, + }, + { + slugSuffix: "-owner-app", + sharingLevel: database.AppSharingLevelOwner, + }, + { + slugSuffix: "-public-app", + sharingLevel: database.AppSharingLevelAuthenticated, + }, + }, + }, + { + name: "PublicAllowsPublicAuthenticatedOrganizationAndOwner", + maxPortShareLevel: database.AppSharingLevelPublic, + apps: []*proto.CreateSubAgentRequest_App{ + { + Slug: "public-app", + Share: proto.CreateSubAgentRequest_App_PUBLIC.Enum(), + Url: ptr.Ref("http://localhost:8080"), + }, + { + Slug: "authenticated-app", + Share: proto.CreateSubAgentRequest_App_AUTHENTICATED.Enum(), + Url: ptr.Ref("http://localhost:8081"), + }, + { + Slug: "owner-app", + Share: proto.CreateSubAgentRequest_App_OWNER.Enum(), + Url: ptr.Ref("http://localhost:8082"), + }, + { + Slug: "organization-app", + Share: proto.CreateSubAgentRequest_App_ORGANIZATION.Enum(), + Url: ptr.Ref("http://localhost:8083"), + }, + }, + expectedStoredApps: []expectedApp{ + { + slugSuffix: "-authenticated-app", + sharingLevel: database.AppSharingLevelAuthenticated, + }, + { + slugSuffix: "-organization-app", + sharingLevel: database.AppSharingLevelOrganization, + }, + { + slugSuffix: "-owner-app", + sharingLevel: database.AppSharingLevelOwner, + }, + { + slugSuffix: "-public-app", + sharingLevel: database.AppSharingLevelPublic, + }, + }, + }, + { + name: "OrganizationClampsAuthenticatedAndPublic", + maxPortShareLevel: database.AppSharingLevelOrganization, + apps: []*proto.CreateSubAgentRequest_App{ + { + Slug: "authenticated-app", + Share: proto.CreateSubAgentRequest_App_AUTHENTICATED.Enum(), + Url: ptr.Ref("http://localhost:8080"), + }, + { + Slug: "public-app", + Share: proto.CreateSubAgentRequest_App_PUBLIC.Enum(), + Url: ptr.Ref("http://localhost:8081"), + }, + { + Slug: "owner-app", + Share: proto.CreateSubAgentRequest_App_OWNER.Enum(), + Url: ptr.Ref("http://localhost:8082"), + }, + { + Slug: "organization-app", + Share: proto.CreateSubAgentRequest_App_ORGANIZATION.Enum(), + Url: ptr.Ref("http://localhost:8083"), + }, + }, + expectedStoredApps: []expectedApp{ + { + slugSuffix: "-authenticated-app", + sharingLevel: database.AppSharingLevelOrganization, + }, + { + slugSuffix: "-organization-app", + sharingLevel: database.AppSharingLevelOrganization, + }, + { + slugSuffix: "-owner-app", + sharingLevel: database.AppSharingLevelOwner, + }, + { + slugSuffix: "-public-app", + sharingLevel: database.AppSharingLevelOrganization, + }, + }, + }, + { + name: "OwnerClampsOrganizationAuthenticatedAndPublic", + maxPortShareLevel: database.AppSharingLevelOwner, + apps: []*proto.CreateSubAgentRequest_App{ + { + Slug: "authenticated-app", + Share: proto.CreateSubAgentRequest_App_AUTHENTICATED.Enum(), + Url: ptr.Ref("http://localhost:8080"), + }, + { + Slug: "public-app", + Share: proto.CreateSubAgentRequest_App_PUBLIC.Enum(), + Url: ptr.Ref("http://localhost:8081"), + }, + { + Slug: "owner-app", + Share: proto.CreateSubAgentRequest_App_OWNER.Enum(), + Url: ptr.Ref("http://localhost:8082"), + }, + { + Slug: "organization-app", + Share: proto.CreateSubAgentRequest_App_ORGANIZATION.Enum(), + Url: ptr.Ref("http://localhost:8083"), + }, + }, + expectedStoredApps: []expectedApp{ + { + slugSuffix: "-authenticated-app", + sharingLevel: database.AppSharingLevelOwner, + }, + { + slugSuffix: "-organization-app", + sharingLevel: database.AppSharingLevelOwner, + }, + { + slugSuffix: "-owner-app", + sharingLevel: database.AppSharingLevelOwner, + }, + { + slugSuffix: "-public-app", + sharingLevel: database.AppSharingLevelOwner, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ctx, api, upsertedApps := newMockSubAgentAPIWithMaxPortShareLevel(t, tt.maxPortShareLevel, len(tt.apps)) + resp, err := api.CreateSubAgent(ctx, &proto.CreateSubAgentRequest{ + Name: "child-agent", + Directory: "/workspaces/coder", + Architecture: "amd64", + OperatingSystem: "linux", + Apps: tt.apps, + }) + require.NoError(t, err) + require.NotNil(t, resp.Agent) + require.Empty(t, resp.AppCreationErrors) + require.Len(t, *upsertedApps, len(tt.expectedStoredApps)) + + slices.SortFunc(*upsertedApps, func(a, b database.UpsertWorkspaceAppParams) int { + return cmp.Compare(appSlugSuffix(a.Slug), appSlugSuffix(b.Slug)) + }) + slices.SortFunc(tt.expectedStoredApps, func(a, b expectedApp) int { + return cmp.Compare(a.slugSuffix, b.slugSuffix) + }) + + for i, expectedApp := range tt.expectedStoredApps { + require.Equal(t, expectedApp.slugSuffix, appSlugSuffix((*upsertedApps)[i].Slug)) + require.Equal(t, expectedApp.sharingLevel, (*upsertedApps)[i].SharingLevel) + } + }) + } +} + +func appSlugSuffix(slug string) string { + _, suffix, ok := strings.Cut(slug, "-") + if !ok { + return slug + } + return "-" + suffix +} + +func newMockSubAgentAPIWithMaxPortShareLevel( + t *testing.T, + maxPortShareLevel database.AppSharingLevel, + appCount int, +) (context.Context, *agentapi.SubAgentAPI, *[]database.UpsertWorkspaceAppParams) { + t.Helper() + + ctx := testutil.Context(t, testutil.WaitShort) + log := testutil.Logger(t) + clock := quartz.NewMock(t) + ownerID := uuid.New() + organizationID := uuid.New() + templateID := uuid.New() + parentAgent := database.WorkspaceAgent{ + ID: uuid.New(), + ResourceID: uuid.New(), + } + workspace := database.Workspace{ + ID: uuid.New(), + OwnerID: ownerID, + OrganizationID: organizationID, + TemplateID: templateID, + } + template := database.Template{ + ID: templateID, + MaxPortSharingLevel: maxPortShareLevel, + } + upsertedApps := []database.UpsertWorkspaceAppParams{} + + db := dbmock.NewMockStore(gomock.NewController(t)) + db.EXPECT().GetWorkspaceByAgentID(gomock.Any(), parentAgent.ID).Return(workspace, nil) + db.EXPECT().GetTemplateByID(gomock.Any(), templateID).Return(template, nil) + db.EXPECT().InsertWorkspaceAgent(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, params database.InsertWorkspaceAgentParams) (database.WorkspaceAgent, error) { + require.True(t, params.ParentID.Valid) + require.Equal(t, parentAgent.ID, params.ParentID.UUID) + + return database.WorkspaceAgent{ + ID: params.ID, + Name: params.Name, + AuthToken: params.AuthToken, + }, nil + }, + ) + db.EXPECT().UpsertWorkspaceApp(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, params database.UpsertWorkspaceAppParams) (database.WorkspaceApp, error) { + upsertedApps = append(upsertedApps, params) + return database.WorkspaceApp{ + ID: params.ID, + AgentID: params.AgentID, + Slug: params.Slug, + SharingLevel: params.SharingLevel, + }, nil + }, + ).Times(appCount) + + portSharer := &atomic.Pointer[agplportsharing.PortSharer]{} + var ps agplportsharing.PortSharer = entportsharing.NewEnterprisePortSharer() + portSharer.Store(&ps) + api := &agentapi.SubAgentAPI{ + OwnerID: ownerID, + OrganizationID: organizationID, + AgentFn: func(context.Context) (database.WorkspaceAgent, error) { + return parentAgent, nil + }, + Log: log, + Clock: clock, + Database: db, + PortSharer: portSharer, + } + + return ctx, api, &upsertedApps +} + +func TestDevcontainerSubAgentAppShareClampedByEnterpriseTemplateMaxPortShareLevel(t *testing.T) { + t.Parallel() + + ctx, db, client := newDevcontainerSubAgentClientWithMaxPortShareLevel(t, database.AppSharingLevelAuthenticated) + subAgent, err := client.Create(ctx, agentcontainers.SubAgent{ + Name: "devcontainer", + Directory: "/workspaces/coder", + Architecture: "amd64", + OperatingSystem: "linux", + Apps: []agentcontainers.SubAgentApp{ + { + Slug: "public-app", + URL: "http://localhost:8080", + Share: codersdk.WorkspaceAppSharingLevelPublic, + }, + { + Slug: "owner-app", + URL: "http://localhost:8081", + Share: codersdk.WorkspaceAppSharingLevelOwner, + }, + }, + }) + require.NoError(t, err) + require.NotEqual(t, uuid.Nil, subAgent.ID) + + apps, err := db.GetWorkspaceAppsByAgentID(ctx, subAgent.ID) + require.NoError(t, err) + require.Len(t, apps, 2) + slices.SortFunc(apps, func(a, b database.WorkspaceApp) int { + return cmp.Compare(appSlugSuffix(a.Slug), appSlugSuffix(b.Slug)) + }) + require.Equal(t, "-owner-app", appSlugSuffix(apps[0].Slug)) + require.Equal(t, database.AppSharingLevelOwner, apps[0].SharingLevel) + require.Equal(t, "-public-app", appSlugSuffix(apps[1].Slug)) + require.Equal(t, database.AppSharingLevelAuthenticated, apps[1].SharingLevel) +} + +func TestDevcontainerCoderAppShareClampedWithGroupRestrictedEnterpriseTemplateACL(t *testing.T) { + t.Parallel() + + ctx, db, client := newDevcontainerSubAgentClientWithMaxPortShareLevel(t, + database.AppSharingLevelAuthenticated, + withGroupRestrictedTemplateACL, + ) + subAgent, err := client.Create(ctx, agentcontainers.SubAgent{ + Name: "devcontainer", + Directory: "/workspaces/coder", + Architecture: "amd64", + OperatingSystem: "linux", + Apps: []agentcontainers.SubAgentApp{ + { + Slug: "public-app", + URL: "http://localhost:8080", + Share: codersdk.WorkspaceAppSharingLevelPublic, + }, + }, + }) + require.NoError(t, err) + + apps, err := db.GetWorkspaceAppsByAgentID(ctx, subAgent.ID) + require.NoError(t, err) + require.Len(t, apps, 1) + require.Equal(t, "-public-app", appSlugSuffix(apps[0].Slug)) + require.Equal(t, database.AppSharingLevelAuthenticated, apps[0].SharingLevel) +} + +type devcontainerSubAgentClientOption func(testing.TB, database.Store, database.Organization, database.User, *database.Template) + +func newDevcontainerSubAgentClientWithMaxPortShareLevel( + t *testing.T, + maxPortShareLevel database.AppSharingLevel, + options ...devcontainerSubAgentClientOption, +) (context.Context, database.Store, agentcontainers.SubAgentClient) { + t.Helper() + + ctx := testutil.Context(t, testutil.WaitShort) + log := testutil.Logger(t) + clock := quartz.NewMock(t) + + rawDB, _ := dbtestutil.NewDB(t) + org := dbgen.Organization(t, rawDB, database.Organization{}) + user := dbgen.User(t, rawDB, database.User{}) + template := dbgen.Template(t, rawDB, database.Template{ + OrganizationID: org.ID, + CreatedBy: user.ID, + MaxPortSharingLevel: maxPortShareLevel, + }) + for _, option := range options { + option(t, rawDB, org, user, &template) + } + templateVersion := dbgen.TemplateVersion(t, rawDB, database.TemplateVersion{ + TemplateID: uuid.NullUUID{Valid: true, UUID: template.ID}, + OrganizationID: org.ID, + CreatedBy: user.ID, + }) + workspace := dbgen.Workspace(t, rawDB, database.WorkspaceTable{ + OrganizationID: org.ID, + TemplateID: template.ID, + OwnerID: user.ID, + }) + job := dbgen.ProvisionerJob(t, rawDB, nil, database.ProvisionerJob{ + Type: database.ProvisionerJobTypeWorkspaceBuild, + OrganizationID: org.ID, + }) + build := dbgen.WorkspaceBuild(t, rawDB, database.WorkspaceBuild{ + JobID: job.ID, + WorkspaceID: workspace.ID, + TemplateVersionID: templateVersion.ID, + }) + resource := dbgen.WorkspaceResource(t, rawDB, database.WorkspaceResource{ + JobID: build.JobID, + }) + parentAgent := dbgen.WorkspaceAgent(t, rawDB, database.WorkspaceAgent{ + ResourceID: resource.ID, + }) + + auth := rbac.NewStrictCachingAuthorizer(prometheus.NewRegistry()) + accessControlStore := &atomic.Pointer[agpldbauthz.AccessControlStore]{} + var acs agpldbauthz.AccessControlStore = entdbauthz.EnterpriseTemplateAccessControlStore{} + accessControlStore.Store(&acs) + db := agpldbauthz.New(rawDB, auth, log, accessControlStore) + portSharer := &atomic.Pointer[agplportsharing.PortSharer]{} + var ps agplportsharing.PortSharer = entportsharing.NewEnterprisePortSharer() + portSharer.Store(&ps) + api := &agentapi.SubAgentAPI{ + OwnerID: user.ID, + OrganizationID: org.ID, + AgentFn: func(context.Context) (database.WorkspaceAgent, error) { + return parentAgent, nil + }, + Log: log, + Clock: clock, + Database: db, + PortSharer: portSharer, + } + + client := agentcontainers.NewSubAgentClientFromAPI(log, devcontainerSubAgentDRPCClient{api: api}) + return ctx, rawDB, client +} + +func withGroupRestrictedTemplateACL(t testing.TB, db database.Store, org database.Organization, user database.User, template *database.Template) { + t.Helper() + + group := dbgen.Group(t, db, database.Group{OrganizationID: org.ID}) + dbgen.GroupMember(t, db, database.GroupMemberTable{ + GroupID: group.ID, + UserID: user.ID, + }) + template.GroupACL = database.TemplateACL{ + group.ID.String(): db2sdk.TemplateRoleActions(codersdk.TemplateRoleUse), + } + template.UserACL = database.TemplateACL{} + require.NoError(t, db.UpdateTemplateACLByID(context.Background(), database.UpdateTemplateACLByIDParams{ + ID: template.ID, + GroupACL: template.GroupACL, + UserACL: template.UserACL, + })) +} + +type devcontainerSubAgentDRPCClient struct { + proto.DRPCAgentClient28 + api *agentapi.SubAgentAPI +} + +func (c devcontainerSubAgentDRPCClient) CreateSubAgent(ctx context.Context, req *proto.CreateSubAgentRequest) (*proto.CreateSubAgentResponse, error) { + return c.api.CreateSubAgent(ctx, req) +} + +func (c devcontainerSubAgentDRPCClient) DeleteSubAgent(ctx context.Context, req *proto.DeleteSubAgentRequest) (*proto.DeleteSubAgentResponse, error) { + return c.api.DeleteSubAgent(ctx, req) +} + +func (c devcontainerSubAgentDRPCClient) ListSubAgents(ctx context.Context, req *proto.ListSubAgentsRequest) (*proto.ListSubAgentsResponse, error) { + return c.api.ListSubAgents(ctx, req) +} diff --git a/enterprise/coderd/userauth_test.go b/enterprise/coderd/userauth_test.go index 4dde31c6258ae..5a0986788acea 100644 --- a/enterprise/coderd/userauth_test.go +++ b/enterprise/coderd/userauth_test.go @@ -172,7 +172,7 @@ func TestUserOIDC(t *testing.T) { fields, err := runner.AdminClient.GetAvailableIDPSyncFields(ctx) require.NoError(t, err) require.ElementsMatch(t, []string{ - "sub", "aud", "exp", "iss", // Always included from jwt + "sub", "aud", "exp", "iss", "email_verified", // Always included from jwt "email", "organization", }, fields) diff --git a/enterprise/coderd/workspaces_test.go b/enterprise/coderd/workspaces_test.go index 95bf50e74fda0..0d0c97bdf3072 100644 --- a/enterprise/coderd/workspaces_test.go +++ b/enterprise/coderd/workspaces_test.go @@ -653,6 +653,72 @@ func TestWorkspaceAutobuild(t *testing.T) { require.Equal(t, stats.Transitions[ws.ID], database.WorkspaceTransitionStop) }) + // FailureTTLStopOK verifies that a workspace whose latest build is a failed + // stop is retried by issuing another stop after the failure TTL elapses. + t.Run("FailureTTLStopOK", func(t *testing.T) { + t.Parallel() + + var ( + ticker = make(chan time.Time) + statCh = make(chan autobuild.Stats) + logger = slogtest.Make(t, &slogtest.Options{ + // We ignore errors here since we expect to fail + // builds. + IgnoreErrors: true, + }) + failureTTL = time.Minute + ) + + client, db, user := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + Logger: &logger, + AutobuildTicker: ticker, + IncludeProvisionerDaemon: true, + AutobuildStats: statCh, + TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore(), notifications.NewNoopEnqueuer(), logger, nil), + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1}, + }, + }) + + // The start build succeeds, but the stop build fails. This leaves the + // workspace's latest build as a failed stop. + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionPlan: echo.PlanComplete, + ProvisionApplyMap: map[proto.WorkspaceTransition][]*proto.Response{ + proto.WorkspaceTransition_START: echo.ApplyComplete, + proto.WorkspaceTransition_STOP: echo.ApplyFailed, + }, + }) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { + ctr.FailureTTLMillis = ptr.Ref[int64](failureTTL.Milliseconds()) + }) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + ws := coderdtest.CreateWorkspace(t, client, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID) + + ctx := testutil.Context(t, testutil.WaitLong) + stopBuild, err := client.CreateWorkspaceBuild(ctx, ws.ID, codersdk.CreateWorkspaceBuildRequest{ + Transition: codersdk.WorkspaceTransitionStop, + }) + require.NoError(t, err) + build := coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, stopBuild.ID) + require.Equal(t, codersdk.WorkspaceStatusFailed, build.Status) + require.Equal(t, codersdk.WorkspaceTransitionStop, build.Transition) + tickTime := build.Job.CompletedAt.Add(failureTTL * 2) + + p, err := coderdtest.GetProvisionerForTags(db, time.Now(), ws.OrganizationID, nil) + require.NoError(t, err) + coderdtest.UpdateProvisionerLastSeenAt(t, db, p.ID, tickTime) + ticker <- tickTime + stats := <-statCh + // Expect the workspace to be stopped again for breaching failure TTL. + require.Len(t, stats.Transitions, 1) + require.Equal(t, stats.Transitions[ws.ID], database.WorkspaceTransitionStop) + }) + t.Run("FailureTTLTooEarly", func(t *testing.T) { t.Parallel() @@ -4411,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() diff --git a/enterprise/coderd/x/chatd/chatd_test.go b/enterprise/coderd/x/chatd/chatd_test.go index 9587c7e6b3e20..ec43e1092845c 100644 --- a/enterprise/coderd/x/chatd/chatd_test.go +++ b/enterprise/coderd/x/chatd/chatd_test.go @@ -200,6 +200,7 @@ func setOpenAIProviderBaseURL( } _, err = db.UpdateAIProvider(ctx, database.UpdateAIProviderParams{ ID: provider.ID, + Type: provider.Type, DisplayName: provider.DisplayName, Enabled: provider.Enabled, BaseUrl: baseURL, diff --git a/enterprise/dbcrypt/dbcrypt_internal_test.go b/enterprise/dbcrypt/dbcrypt_internal_test.go index e5a433399b541..f976ef4fcb74e 100644 --- a/enterprise/dbcrypt/dbcrypt_internal_test.go +++ b/enterprise/dbcrypt/dbcrypt_internal_test.go @@ -1195,6 +1195,7 @@ func TestAIProviders(t *testing.T) { const newSettings = `{"_type":"bedrock","_version":1,"region":"us-east-1","model":"anthropic.claude-sonnet-4-5-20250929-v1:0","access_key":"AKIA-test","access_key_secret":"test-secret"}` updated, err := crypt.UpdateAIProvider(ctx, database.UpdateAIProviderParams{ ID: provider.ID, + Type: provider.Type, DisplayName: provider.DisplayName, Enabled: provider.Enabled, BaseUrl: provider.BaseUrl, @@ -1211,6 +1212,7 @@ func TestAIProviders(t *testing.T) { provider := insertProvider(t, crypt, ciphers) updated, err := crypt.UpdateAIProvider(ctx, database.UpdateAIProviderParams{ ID: provider.ID, + Type: provider.Type, DisplayName: provider.DisplayName, Enabled: provider.Enabled, BaseUrl: provider.BaseUrl, diff --git a/enterprise/tailnet/connio.go b/enterprise/tailnet/connio.go index 7c186dc1a0480..0803aa79f5fe3 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 0000000000000..77649c2ed8332 --- /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 0000000000000..f5c2c70f5649a --- /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 08325e567fcf7..4018b8aabf781 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/pgcoord_test.go b/enterprise/tailnet/pgcoord_test.go index 3ec874ad1741b..728387e3d941a 100644 --- a/enterprise/tailnet/pgcoord_test.go +++ b/enterprise/tailnet/pgcoord_test.go @@ -120,7 +120,7 @@ func TestPGCoordinatorSingle_AgentInvalidIP(t *testing.T) { // The agent connection should be closed immediately after sending an invalid addr agent.AssertEventuallyResponsesClosed( - agpl.AuthorizationError{Wrapped: agpl.InvalidNodeAddressError{Addr: prefix.Addr().String()}}.Error()) + agpl.AuthorizationError{Wrapped: xerrors.Errorf("Addresses: %w", agpl.InvalidNodeAddressError{Addr: prefix.Addr().String()})}.Error()) assertEventuallyLost(ctx, t, store, agent.ID) } @@ -146,7 +146,37 @@ func TestPGCoordinatorSingle_AgentInvalidIPBits(t *testing.T) { // The agent connection should be closed immediately after sending an invalid addr agent.AssertEventuallyResponsesClosed( - agpl.AuthorizationError{Wrapped: agpl.InvalidAddressBitsError{Bits: 64}}.Error()) + agpl.AuthorizationError{Wrapped: xerrors.Errorf("Addresses: %w", agpl.InvalidAddressBitsError{Bits: 64})}.Error()) + assertEventuallyLost(ctx, t, store, agent.ID) +} + +func TestPGCoordinatorSingle_AgentInvalidAllowedIP(t *testing.T) { + t.Parallel() + + store, ps := dbtestutil.NewDB(t) + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitSuperLong) + defer cancel() + logger := testutil.Logger(t) + coordinator, err := tailnet.NewPGCoord(ctx, logger, ps, store) + require.NoError(t, err) + defer coordinator.Close() + + agent := agpltest.NewAgent(ctx, t, coordinator, "agent") + defer agent.Close(ctx) + // A valid self-address paired with an AllowedIP belonging to a different + // (victim) agent must be rejected. + victim := agpl.TailscaleServicePrefix.PrefixFromUUID(uuid.New()) + agent.UpdateNode(&proto.Node{ + Addresses: []string{ + agpl.TailscaleServicePrefix.PrefixFromUUID(agent.ID).String(), + }, + AllowedIps: []string{victim.String()}, + PreferredDerp: 10, + }) + + // The agent connection should be closed after sending an invalid AllowedIP. + agent.AssertEventuallyResponsesClosed( + agpl.AuthorizationError{Wrapped: xerrors.Errorf("AllowedIps: %w", agpl.InvalidNodeAddressError{Addr: victim.Addr().String()})}.Error()) assertEventuallyLost(ctx, t, store, agent.ID) } diff --git a/enterprise/tailnet/requests_test.go b/enterprise/tailnet/requests_test.go new file mode 100644 index 0000000000000..32ebeeb2ada69 --- /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/enterprise/wsproxy/wsproxy.go b/enterprise/wsproxy/wsproxy.go index 4359213d4e018..90235289136b1 100644 --- a/enterprise/wsproxy/wsproxy.go +++ b/enterprise/wsproxy/wsproxy.go @@ -354,7 +354,9 @@ func New(ctx context.Context, opts *Options) (*Server, error) { tracing.Middleware(s.TracerProvider), httpmw.AttachRequestID, httpmw.ExtractRealIP(s.Options.RealIPConfig), - loggermw.Logger(s.Logger), + loggermw.Logger(s.Logger, func(r *http.Request) string { + return httpmw.EffectiveHost(s.Options.RealIPConfig, r) + }), prometheusMW, // HandleSubdomain is a middleware that handles all requests to the diff --git a/examples/examples.gen.json b/examples/examples.gen.json index dc19d9fcf3d43..0ff50e421451b 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 c7f3ebed83562..640151b43a88a 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 3bb89b39cfa69..362a8eb266fa4 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) diff --git a/flake.lock b/flake.lock index dea5417e7e685..2da9acc52c228 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 2465f94fac361..0c8901ec698a6 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 @@ -111,13 +152,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.16.6" { 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.16.6/terraform_1.16.6_linux_amd64.zip"; + hash = "sha256-OANLONAwljmaehJkoUVhqAKbK0lv0fFpRSZW5Tlo8bc="; }; } '' mkdir -p "$out/bin" @@ -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,14 +250,14 @@ pnpm postgresql_16 proto_gen_go_1_30 - protobuf_23 + protobuf_23_4 ripgrep shellcheck (pinnedPkgs.shfmt) # sqlc sqlc-custom syft - terraform_1_15_2 + terraform_1_15_5 typos which # Needed for many LD system libs! diff --git a/go.mod b/go.mod index c84156c8c05c0..d26ef7201c9e6 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/coder/coder/v2 -go 1.26.2 +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 @@ -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 @@ -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 @@ -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 @@ -228,19 +228,19 @@ 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.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 @@ -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 @@ -478,7 +472,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 @@ -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/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.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 @@ -640,13 +645,13 @@ 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 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 314cc3fa1f7f2..e96d6c3aa3a13 100644 --- a/go.sum +++ b/go.sum @@ -45,44 +45,52 @@ 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/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/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= 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= @@ -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= @@ -332,6 +341,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= @@ -438,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= @@ -502,8 +511,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= @@ -600,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= @@ -796,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= @@ -846,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= @@ -885,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= @@ -969,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= @@ -999,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= @@ -1077,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= @@ -1174,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= @@ -1254,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= @@ -1282,39 +1294,45 @@ 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= -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= @@ -1330,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= @@ -1344,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= @@ -1368,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= @@ -1378,26 +1401,22 @@ 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= 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= 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-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= @@ -1408,14 +1427,12 @@ 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= 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= @@ -1424,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= @@ -1435,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= @@ -1464,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= @@ -1481,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= @@ -1496,21 +1512,19 @@ 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= 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= 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= @@ -1534,12 +1548,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= @@ -1570,12 +1584,10 @@ 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= -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= diff --git a/install.sh b/install.sh index cbb74248fce8a..51350857a76b8 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.16.6" if [ "${TRACE-}" ]; then set -x diff --git a/internal/googleopenai/thought_signature.go b/internal/googleopenai/thought_signature.go new file mode 100644 index 0000000000000..84467bec18333 --- /dev/null +++ b/internal/googleopenai/thought_signature.go @@ -0,0 +1,163 @@ +// Package googleopenai contains compatibility helpers for Google's +// OpenAI-compatible Gemini APIs. +package googleopenai + +import ( + "encoding/json" + "net/url" + "strings" +) + +// DummyThoughtSignature is Google's documented last-resort bypass for callers +// that cannot preserve a real Gemini thought signature through OpenAI-compatible +// serialization. See https://ai.google.dev/gemini-api/docs/thought-signatures. +const DummyThoughtSignature = "skip_thought_signature_validator" + +// ShouldPatchOpenAICompatRequest reports whether a client-side +// OpenAI-compatible request should carry Gemini thought signatures. +func ShouldPatchOpenAICompatRequest(baseURL string, modelID string) bool { + // Direct Google endpoints are already provider-scoped. Patch them even when + // the configured model ID is an alias without a Gemini prefix. + if isDirectGeminiOpenAIEndpoint(baseURL) { + return true + } + return isCoderAIBridgeEndpoint(baseURL) && isGeminiModelID(modelID) +} + +// ShouldPatchGoogleUpstreamRequest reports whether an AI Bridge upstream +// OpenAI-compatible request should carry Gemini thought signatures. +func ShouldPatchGoogleUpstreamRequest(baseURL string) bool { + return isDirectGeminiOpenAIEndpoint(baseURL) +} + +// Vertex AI has different hosts and paths. Add it here only with a fixture that +// confirms it accepts the same thought-signature fallback shape. +func isDirectGeminiOpenAIEndpoint(baseURL string) bool { + parsed, ok := parseBaseURL(baseURL) + if !ok { + return false + } + host := strings.ToLower(parsed.Hostname()) + path := strings.ToLower(parsed.EscapedPath()) + return host == "generativelanguage.googleapis.com" && strings.Contains(path, "/openai") +} + +func isCoderAIBridgeEndpoint(baseURL string) bool { + parsed, ok := parseBaseURL(baseURL) + if !ok { + return false + } + return strings.ToLower(parsed.Hostname()) == "coder-aibridge" +} + +// parseBaseURL parses a provider base URL, handling bare hostnames without +// a scheme by prepending "https://". +func parseBaseURL(baseURL string) (*url.URL, bool) { + baseURL = strings.TrimSpace(baseURL) + if baseURL == "" { + return nil, false + } + parsed, err := url.Parse(baseURL) + if err == nil && parsed.Hostname() == "" && !strings.Contains(baseURL, "://") { + parsed, err = url.Parse("https://" + baseURL) + } + if err != nil { + return nil, false + } + return parsed, true +} + +func isGeminiModelID(modelID string) bool { + modelID = strings.ToLower(strings.TrimSpace(modelID)) + return strings.HasPrefix(modelID, "gemini-") || strings.Contains(modelID, "/gemini-") +} + +// PatchThoughtSignatures adds fallback thought signatures to Gemini tool-call +// history in body. It returns changed=false when no patch is needed. +func PatchThoughtSignatures(body []byte) ([]byte, bool, error) { + var payload map[string]any + if err := json.Unmarshal(body, &payload); err != nil { + return nil, false, err + } + if !AddThoughtSignaturesToLatestTurn(payload) { + return body, false, nil + } + patched, err := json.Marshal(payload) + if err != nil { + return nil, false, err + } + return patched, true, nil +} + +// AddThoughtSignaturesToLatestTurn patches only the current turn because +// completed tool-call/result pairs from earlier turns are not validated by +// Google as active function calls. +func AddThoughtSignaturesToLatestTurn(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 || !isAssistantRole(message["role"]) { + continue + } + toolCalls, ok := message["tool_calls"].([]any) + if !ok || len(toolCalls) == 0 { + continue + } + // Every tool call in parallel batches needs a signature, + // not just the first one. + for _, rawToolCall := range toolCalls { + toolCall, ok := rawToolCall.(map[string]any) + if !ok { + continue + } + if ensureThoughtSignature(toolCall) { + changed = true + } + } + } + return changed +} + +// Gemini can serialize assistant messages with its native "model" role. +func isAssistantRole(role any) bool { + roleValue, _ := role.(string) + return roleValue == "assistant" || roleValue == "model" +} + +// Real provider signatures are preserved when present. +func ensureThoughtSignature(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"] = DummyThoughtSignature + return true +} diff --git a/internal/googleopenai/thought_signature_test.go b/internal/googleopenai/thought_signature_test.go new file mode 100644 index 0000000000000..c73bf25ee7f77 --- /dev/null +++ b/internal/googleopenai/thought_signature_test.go @@ -0,0 +1,167 @@ +package googleopenai_test + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/internal/googleopenai" +) + +func TestShouldPatchOpenAICompatRequest(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + baseURL string + modelID string + want bool + }{ + { + name: "direct endpoint with gemini model", + baseURL: "https://generativelanguage.googleapis.com/v1beta/openai/", + modelID: "gemini-3.5-flash", + want: true, + }, + { + name: "direct endpoint does not require gemini model name", + baseURL: "https://generativelanguage.googleapis.com/v1beta/openai/", + modelID: "gpt-4o", + want: true, + }, + { + name: "coder aibridge gemini route", + baseURL: "http://coder-aibridge/v1", + modelID: "gemini-3.5-flash", + want: true, + }, + { + name: "aibridge endpoint requires gemini model", + baseURL: "http://coder-aibridge/v1", + modelID: "gpt-4o", + }, + { + name: "other gateway unchanged", + baseURL: "https://gateway.vercel.ai/v1", + modelID: "google/gemini-3.5-flash", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + require.Equal(t, tt.want, googleopenai.ShouldPatchOpenAICompatRequest(tt.baseURL, tt.modelID)) + }) + } +} + +func TestShouldPatchGoogleUpstreamRequest(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + baseURL string + want bool + }{ + { + name: "gemini api openai endpoint", + baseURL: "https://generativelanguage.googleapis.com/v1beta/openai/", + want: true, + }, + { + name: "openai endpoint", + baseURL: "https://api.openai.com/v1/", + }, + { + name: "vertex endpoint not enabled without fixture", + baseURL: "https://us-central1-aiplatform.googleapis.com/v1/", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + require.Equal(t, tt.want, googleopenai.ShouldPatchGoogleUpstreamRequest(tt.baseURL)) + }) + } +} + +func TestAddThoughtSignaturesToLatestTurn(t *testing.T) { + t.Parallel() + + payload := decodePayload(t, []byte(`{ + "messages":[ + {"role":"user","content":"previous turn"}, + { + "role":"assistant", + "tool_calls":[{"id":"old-call","type":"function","function":{"name":"old","arguments":"{}"}}] + }, + {"role":"tool","tool_call_id":"old-call","content":"{}"}, + {"role":"user","content":"current turn"}, + { + "role":"model", + "tool_calls":[ + {"id":"call-1","type":"function","function":{"name":"list_templates","arguments":"{}"}}, + {"id":"call-2","type":"function","function":{"name":"read_template","arguments":"{}"}} + ] + } + ] + }`)) + + require.True(t, googleopenai.AddThoughtSignaturesToLatestTurn(payload)) + require.Empty(t, thoughtSignature(t, payload, 1, 0), "previous turns should stay unchanged") + require.Equal(t, googleopenai.DummyThoughtSignature, thoughtSignature(t, payload, 4, 0)) + require.Equal(t, googleopenai.DummyThoughtSignature, thoughtSignature(t, payload, 4, 1)) +} + +func TestAddThoughtSignaturesToLatestTurnPreservesRealSignature(t *testing.T) { + t.Parallel() + + payload := decodePayload(t, []byte(`{ + "messages":[ + {"role":"user","content":"current turn"}, + { + "role":"assistant", + "tool_calls":[{ + "id":"call-1", + "type":"function", + "function":{"name":"list_templates","arguments":"{}"}, + "extra_content":{"google":{"thought_signature":"real-signature"}} + }] + } + ] + }`)) + + require.False(t, googleopenai.AddThoughtSignaturesToLatestTurn(payload)) + require.Equal(t, "real-signature", thoughtSignature(t, payload, 1, 0)) +} + +func decodePayload(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 thoughtSignature(t *testing.T, payload map[string]any, messageIndex int, toolCallIndex int) string { + t.Helper() + + messages, ok := payload["messages"].([]any) + require.True(t, ok) + require.Greater(t, len(messages), messageIndex) + message, ok := messages[messageIndex].(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/mise.lock b/mise.lock index 7f96bd9e3cc5f..4bdc18d88bd85 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.2" +version = "1.26.9" backend = "core:go" [tools.go."platforms.linux-arm64"] -checksum = "sha256:c958a1fe1b361391db163a485e21f5f228142d6f8b584f6bef89b26f66dc5b23" -url = "https://dl.google.com/go/go1.26.2.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:c958a1fe1b361391db163a485e21f5f228142d6f8b584f6bef89b26f66dc5b23" -url = "https://dl.google.com/go/go1.26.2.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:990e6b4bbba816dc3ee129eaeaf4b42f17c2800b88a2166c265ac1a200262282" -url = "https://dl.google.com/go/go1.26.2.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:990e6b4bbba816dc3ee129eaeaf4b42f17c2800b88a2166c265ac1a200262282" -url = "https://dl.google.com/go/go1.26.2.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:990e6b4bbba816dc3ee129eaeaf4b42f17c2800b88a2166c265ac1a200262282" -url = "https://dl.google.com/go/go1.26.2.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:990e6b4bbba816dc3ee129eaeaf4b42f17c2800b88a2166c265ac1a200262282" -url = "https://dl.google.com/go/go1.26.2.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:32af1522bf3e3ff3975864780a429cc0b41d190ec7bf90faa661d6d64566e7af" -url = "https://dl.google.com/go/go1.26.2.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:bc3f1500d9968c36d705442d90ba91addf9271665033748b82532682e90a7966" -url = "https://dl.google.com/go/go1.26.2.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:bc3f1500d9968c36d705442d90ba91addf9271665033748b82532682e90a7966" -url = "https://dl.google.com/go/go1.26.2.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:98eb3570bade15cb826b0909338df6cc6d2cf590bc39c471142002db3832b708" -url = "https://dl.google.com/go/go1.26.2.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:98eb3570bade15cb826b0909338df6cc6d2cf590bc39c471142002db3832b708" -url = "https://dl.google.com/go/go1.26.2.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" @@ -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.16.6" 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: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:cf27657e96bbdc6116f4c16a0c801d36ae6410d7210183a520ac6b2198fb723e" -url = "https://releases.hashicorp.com/terraform/1.15.2/terraform_1.15.2_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:c56ff2bc7e6ce9b3879a50392b03c2ea074b47688bf503ff966c87fb01b2aab8" -url = "https://releases.hashicorp.com/terraform/1.15.2/terraform_1.15.2_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:c56ff2bc7e6ce9b3879a50392b03c2ea074b47688bf503ff966c87fb01b2aab8" -url = "https://releases.hashicorp.com/terraform/1.15.2/terraform_1.15.2_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:c56ff2bc7e6ce9b3879a50392b03c2ea074b47688bf503ff966c87fb01b2aab8" -url = "https://releases.hashicorp.com/terraform/1.15.2/terraform_1.15.2_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:c56ff2bc7e6ce9b3879a50392b03c2ea074b47688bf503ff966c87fb01b2aab8" -url = "https://releases.hashicorp.com/terraform/1.15.2/terraform_1.15.2_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:4204bc3450418a7ce423e58451b053e5daed625ad6c6a15de98bc09345269f99" -url = "https://releases.hashicorp.com/terraform/1.15.2/terraform_1.15.2_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:2bb701bc2db93ed39613df4f4e033ec4c2de9eba1c036d9a2f62cffc988af066" -url = "https://releases.hashicorp.com/terraform/1.15.2/terraform_1.15.2_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:2bb701bc2db93ed39613df4f4e033ec4c2de9eba1c036d9a2f62cffc988af066" -url = "https://releases.hashicorp.com/terraform/1.15.2/terraform_1.15.2_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:a7e25570dd85f363581e96cac0b468257c45945ca8875d951413b6606c9b86d4" -url = "https://releases.hashicorp.com/terraform/1.15.2/terraform_1.15.2_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:a7e25570dd85f363581e96cac0b468257c45945ca8875d951413b6606c9b86d4" -url = "https://releases.hashicorp.com/terraform/1.15.2/terraform_1.15.2_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 c7366deecd4f5..e93d36df6b91a 100644 --- a/mise.toml +++ b/mise.toml @@ -8,7 +8,7 @@ lockfile = true [tools] # Languages and runtimes. bun = "1.2.15" -go = "1.26.2" +go = "1.26.9" node = "22.19.0" pnpm = "10.33.2" @@ -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.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 2137e99cb9280..f4b229ce56ee4 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/provision.go b/provisioner/terraform/provision.go index 592bc3c9cc93d..90c96403bc14d 100644 --- a/provisioner/terraform/provision.go +++ b/provisioner/terraform/provision.go @@ -381,7 +381,7 @@ func provisionEnv( "CODER_WORKSPACE_BUILD_ID="+metadata.GetWorkspaceBuildId(), "CODER_TASK_ID="+metadata.GetTaskId(), "CODER_TASK_PROMPT="+metadata.GetTaskPrompt(), - "AWS_SDK_UA_APP_ID=APN_1.1/pc_cdfmjwn8i6u8l9fwz8h82e4w3$", + awsSDKUserAgentEnv(safeEnvironValue(env, awsSDKUserAgentEnvKey)), ) if metadata.GetPrebuiltWorkspaceBuildStage().IsPrebuild() { env = append(env, provider.IsPrebuildEnvironmentVariable()+"=true") diff --git a/provisioner/terraform/safeenv.go b/provisioner/terraform/safeenv.go index 4da2fc32cd996..a42a899bc82ef 100644 --- a/provisioner/terraform/safeenv.go +++ b/provisioner/terraform/safeenv.go @@ -53,3 +53,39 @@ func safeEnviron() []string { } return strippedEnv } + +// safeEnvironValue returns the value of the named variable in the given +// `KEY=VALUE` environment slice, or an empty string if it is not present. +func safeEnvironValue(env []string, name string) string { + prefix := name + "=" + for _, e := range env { + if strings.HasPrefix(e, prefix) { + return strings.TrimPrefix(e, prefix) + } + } + return "" +} + +const ( + awsSDKUserAgentEnvKey = "AWS_SDK_UA_APP_ID" + // awsSDKUserAgentCoder is Coder's AWS Partner Revenue Measurement + // User-Agent string. The `APN_1.1/pc_<product-code>$` format and the + // space-delimited append behavior below follow AWS's guidance: + // https://docs.aws.amazon.com/PRM/latest/aws-prm-onboarding-guide/automated-user-agent.html + awsSDKUserAgentCoder = "APN_1.1/pc_cdfmjwn8i6u8l9fwz8h82e4w3$" +) + +// awsSDKUserAgentEnv returns the AWS_SDK_UA_APP_ID value to pass to the +// Terraform subprocess. If the caller's environment already configures an +// Application ID (e.g. an operator who is also an AWS Partner and wants +// their own revenue attribution), Coder's value is appended with a space +// delimiter so both attributions are preserved. Otherwise Coder's value is +// used on its own. +// +// See: https://docs.aws.amazon.com/PRM/latest/aws-prm-onboarding-guide/automated-user-agent.html +func awsSDKUserAgentEnv(existing string) string { + if existing == "" { + return awsSDKUserAgentEnvKey + "=" + awsSDKUserAgentCoder + } + return awsSDKUserAgentEnvKey + "=" + existing + " " + awsSDKUserAgentCoder +} diff --git a/provisioner/terraform/safeenv_internal_test.go b/provisioner/terraform/safeenv_internal_test.go new file mode 100644 index 0000000000000..1863f8fee18c5 --- /dev/null +++ b/provisioner/terraform/safeenv_internal_test.go @@ -0,0 +1,44 @@ +package terraform + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestSafeEnvironValue(t *testing.T) { + t.Parallel() + + env := []string{ + "FOO=bar", + "AWS_SDK_UA_APP_ID=my-existing-id", + "BAZ=qux", + } + require.Equal(t, "my-existing-id", safeEnvironValue(env, "AWS_SDK_UA_APP_ID")) + require.Equal(t, "bar", safeEnvironValue(env, "FOO")) + require.Equal(t, "", safeEnvironValue(env, "MISSING")) +} + +func TestAWSSDKUserAgentEnv(t *testing.T) { + t.Parallel() + + t.Run("NoExisting", func(t *testing.T) { + t.Parallel() + require.Equal(t, + "AWS_SDK_UA_APP_ID=APN_1.1/pc_cdfmjwn8i6u8l9fwz8h82e4w3$", + awsSDKUserAgentEnv(""), + ) + }) + + t.Run("AppendToExisting", func(t *testing.T) { + t.Parallel() + // When the operator is themselves an AWS Partner and has set their own + // Application ID, we append Coder's with a space delimiter so both + // attributions are preserved. See: + // https://docs.aws.amazon.com/PRM/latest/aws-prm-onboarding-guide/automated-user-agent.html + require.Equal(t, + "AWS_SDK_UA_APP_ID=EXISTING_APP_ID APN_1.1/pc_cdfmjwn8i6u8l9fwz8h82e4w3$", + awsSDKUserAgentEnv("EXISTING_APP_ID"), + ) + }) +} 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 455f32871c302..a3ce227430c3e 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 42cf0675c5668..de646d2fc11c7 100644 --- a/provisioner/terraform/testdata/version.txt +++ b/provisioner/terraform/testdata/version.txt @@ -1 +1 @@ -1.15.2 +1.16.6 diff --git a/provisionerd/provisionerd.go b/provisionerd/provisionerd.go index 769bdb8446f11..2cbdb6eabd1d1 100644 --- a/provisionerd/provisionerd.go +++ b/provisionerd/provisionerd.go @@ -533,7 +533,10 @@ func (p *Server) UploadModuleFiles(ctx context.Context, moduleFiles []byte) erro } defer stream.Close() - dataUp, chunks := sdkproto.BytesToDataUpload(sdkproto.DataUploadType_UPLOAD_TYPE_MODULE_FILES, moduleFiles) + dataUp, chunks, err := sdkproto.BytesToDataUpload(sdkproto.DataUploadType_UPLOAD_TYPE_MODULE_FILES, moduleFiles) + if err != nil { + return nil, xerrors.Errorf("prepare module files upload: %w", err) + } err = stream.Send(&sdkproto.FileUpload{Type: &sdkproto.FileUpload_DataUpload{DataUpload: dataUp}}) if err != nil { diff --git a/provisionerd/runner/init.go b/provisionerd/runner/init.go index 45c762b7fafbf..13a8c5066a653 100644 --- a/provisionerd/runner/init.go +++ b/provisionerd/runner/init.go @@ -19,14 +19,17 @@ func (r *Runner) init(ctx context.Context, omitModules bool, templateArchive []b // If `moduleTar` is populated, `init` will send it over in multiple parts. This // It must be called before the initial request to populate the correct hash if // there is data to send. This is safe to call on nil or empty slices. - data, chunks := sdkproto.BytesToDataUpload(sdkproto.DataUploadType_UPLOAD_TYPE_MODULE_FILES, moduleTar) + data, chunks, err := sdkproto.BytesToDataUpload(sdkproto.DataUploadType_UPLOAD_TYPE_MODULE_FILES, moduleTar) + if err != nil { + return nil, r.failedJobf("prepare module files upload: %v", err) + } hash := []byte{} if len(moduleTar) > 0 { hash = data.DataHash } - err := r.session.Send(&sdkproto.Request{Type: &sdkproto.Request_Init{Init: &sdkproto.InitRequest{ + err = r.session.Send(&sdkproto.Request{Type: &sdkproto.Request_Init{Init: &sdkproto.InitRequest{ TemplateSourceArchive: templateArchive, OmitModuleFiles: omitModules, InitialModuleTarHash: hash, diff --git a/provisionersdk/proto/dataupload.go b/provisionersdk/proto/dataupload.go index e9b6d9ddfb047..f8832d3616dda 100644 --- a/provisionersdk/proto/dataupload.go +++ b/provisionersdk/proto/dataupload.go @@ -9,7 +9,8 @@ import ( ) const ( - ChunkSize = 2 << 20 // 2 MiB + ChunkSize = 2 << 20 // 2 MiB + MaxFileSize = 10 * (10 << 20) // 100 MiB, matches coderd HTTPFileMaxBytes ) type DataBuilder struct { @@ -29,6 +30,21 @@ func NewDataBuilder(req *DataUpload) (*DataBuilder, error) { return nil, xerrors.Errorf("data hash must be 32 bytes, got %d bytes", len(req.DataHash)) } + if req.FileSize < 0 { + return nil, xerrors.Errorf("file size must not be negative, got %d", req.FileSize) + } + if req.FileSize > MaxFileSize { + return nil, xerrors.Errorf("file size %d exceeds maximum allowed %d", req.FileSize, MaxFileSize) + } + if req.Chunks < 0 { + return nil, xerrors.Errorf("chunk count must not be negative, got %d", req.Chunks) + } + //nolint:gosec // FileSize is validated to be <= MaxFileSize, well within int32 range + maxChunks := int32((req.FileSize + ChunkSize - 1) / ChunkSize) + if req.Chunks > maxChunks { + return nil, xerrors.Errorf("chunk count %d exceeds maximum %d for file size %d", req.Chunks, maxChunks, req.FileSize) + } + return &DataBuilder{ Type: req.UploadType, Hash: req.DataHash, @@ -60,7 +76,7 @@ func (b *DataBuilder) Add(chunk *ChunkPiece) (bool, error) { expectedSize := len(b.data) + len(chunk.Data) if expectedSize > int(b.Size) { return b.done(), xerrors.Errorf("data exceeds expected size, data is now %d bytes, %d bytes over the limit of %d", - expectedSize, b.Size-int64(expectedSize), b.Size) + expectedSize, int64(expectedSize)-b.Size, b.Size) } b.data = append(b.data, chunk.Data...) @@ -103,7 +119,11 @@ func (b *DataBuilder) done() bool { return b.chunkIndex >= b.ChunkCount } -func BytesToDataUpload(dataType DataUploadType, data []byte) (*DataUpload, []*ChunkPiece) { +func BytesToDataUpload(dataType DataUploadType, data []byte) (*DataUpload, []*ChunkPiece, error) { + if int64(len(data)) > MaxFileSize { + return nil, nil, xerrors.Errorf("data size %d exceeds maximum allowed %d", len(data), MaxFileSize) + } + fullHash := sha256.Sum256(data) //nolint:gosec // not going over int32 size := int32(len(data)) @@ -135,5 +155,5 @@ func BytesToDataUpload(dataType DataUploadType, data []byte) (*DataUpload, []*Ch chunks = append(chunks, chunk) } - return req, chunks + return req, chunks, nil } diff --git a/provisionersdk/proto/dataupload_test.go b/provisionersdk/proto/dataupload_test.go index 496a7956c9cc6..d8876240b0d27 100644 --- a/provisionersdk/proto/dataupload_test.go +++ b/provisionersdk/proto/dataupload_test.go @@ -2,6 +2,7 @@ package proto_test import ( crand "crypto/rand" + "crypto/sha256" "math/rand" "testing" @@ -10,6 +11,101 @@ import ( "github.com/coder/coder/v2/provisionersdk/proto" ) +func TestNewDataBuilderValidation(t *testing.T) { + t.Parallel() + + validHash := sha256.Sum256([]byte{}) + + t.Run("ExactMaxFileSize", func(t *testing.T) { + t.Parallel() + builder, err := proto.NewDataBuilder(&proto.DataUpload{ + DataHash: validHash[:], + FileSize: proto.MaxFileSize, + Chunks: int32((proto.MaxFileSize + proto.ChunkSize - 1) / proto.ChunkSize), + UploadType: proto.DataUploadType_UPLOAD_TYPE_MODULE_FILES, + }) + require.NoError(t, err) + require.NotNil(t, builder) + }) + + t.Run("OversizedFileSize", func(t *testing.T) { + t.Parallel() + _, err := proto.NewDataBuilder(&proto.DataUpload{ + DataHash: validHash[:], + FileSize: proto.MaxFileSize + 1, + Chunks: 1, + UploadType: proto.DataUploadType_UPLOAD_TYPE_MODULE_FILES, + }) + require.ErrorContains(t, err, "exceeds maximum allowed") + }) + + t.Run("NegativeFileSize", func(t *testing.T) { + t.Parallel() + _, err := proto.NewDataBuilder(&proto.DataUpload{ + DataHash: validHash[:], + FileSize: -1, + Chunks: 1, + UploadType: proto.DataUploadType_UPLOAD_TYPE_MODULE_FILES, + }) + require.ErrorContains(t, err, "must not be negative") + }) + + t.Run("NegativeChunks", func(t *testing.T) { + t.Parallel() + _, err := proto.NewDataBuilder(&proto.DataUpload{ + DataHash: validHash[:], + FileSize: 100, + Chunks: -1, + UploadType: proto.DataUploadType_UPLOAD_TYPE_MODULE_FILES, + }) + require.ErrorContains(t, err, "chunk count must not be negative") + }) + + t.Run("ExcessiveChunkCount", func(t *testing.T) { + t.Parallel() + _, err := proto.NewDataBuilder(&proto.DataUpload{ + DataHash: validHash[:], + FileSize: 100, + Chunks: 1000, + UploadType: proto.DataUploadType_UPLOAD_TYPE_MODULE_FILES, + }) + require.ErrorContains(t, err, "chunk count 1000 exceeds maximum") + }) + + t.Run("ZeroFileSize", func(t *testing.T) { + t.Parallel() + builder, err := proto.NewDataBuilder(&proto.DataUpload{ + DataHash: validHash[:], + FileSize: 0, + Chunks: 0, + UploadType: proto.DataUploadType_UPLOAD_TYPE_MODULE_FILES, + }) + require.NoError(t, err) + require.True(t, builder.IsDone(), "zero-chunk upload should be immediately done") + }) + + t.Run("ValidRoundTrip", func(t *testing.T) { + t.Parallel() + data := make([]byte, 256) + _, _ = crand.Read(data) + + first, chunks, err := proto.BytesToDataUpload(proto.DataUploadType_UPLOAD_TYPE_MODULE_FILES, data) + require.NoError(t, err) + + builder, err := proto.NewDataBuilder(first) + require.NoError(t, err) + + for _, chunk := range chunks { + _, err = builder.Add(chunk) + require.NoError(t, err) + } + + got, err := builder.Complete() + require.NoError(t, err) + require.Equal(t, data, got) + }) +} + // Fuzz must be run manually with the `-fuzz` flag to generate random test cases. // By default, it only runs the added seed corpus cases. // go test -fuzz=FuzzBytesToDataUpload @@ -25,7 +121,11 @@ func FuzzBytesToDataUpload(f *testing.F) { } f.Fuzz(func(t *testing.T, data []byte) { - first, chunks := proto.BytesToDataUpload(proto.DataUploadType_UPLOAD_TYPE_MODULE_FILES, data) + first, chunks, err := proto.BytesToDataUpload(proto.DataUploadType_UPLOAD_TYPE_MODULE_FILES, data) + if err != nil { + // Data exceeds MaxFileSize, which is expected for large fuzz inputs. + return + } builder, err := proto.NewDataBuilder(first) require.NoError(t, err) @@ -62,7 +162,9 @@ func TestBytesToDataUpload(t *testing.T) { _, err := crand.Read(data) require.NoError(t, err) - first, chunks := proto.BytesToDataUpload(proto.DataUploadType_UPLOAD_TYPE_MODULE_FILES, data) + first, chunks, err := proto.BytesToDataUpload(proto.DataUploadType_UPLOAD_TYPE_MODULE_FILES, data) + require.NoError(t, err) + builder, err := proto.NewDataBuilder(first) require.NoError(t, err) diff --git a/provisionersdk/serve.go b/provisionersdk/serve.go index 4afcee962693c..013626b22a4a9 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/provisionersdk/session.go b/provisionersdk/session.go index 094fe38aba493..543fdd3a51e5b 100644 --- a/provisionersdk/session.go +++ b/provisionersdk/session.go @@ -246,24 +246,28 @@ func (s *Session) handleInitRequest(init *proto.InitRequest, requests <-chan *pr s.Logger.Info(s.Context(), "plan response too large, sending modules as stream", slog.F("size_bytes", len(complete.ModuleFiles)), ) - dataUp, chunks := proto.BytesToDataUpload(proto.DataUploadType_UPLOAD_TYPE_MODULE_FILES, complete.ModuleFiles) - - complete.ModuleFiles = nil // sent over the stream - complete.ModuleFilesHash = dataUp.DataHash - - err := s.stream.Send(&proto.Response{Type: &proto.Response_DataUpload{DataUpload: dataUp}}) + dataUp, chunks, err := proto.BytesToDataUpload(proto.DataUploadType_UPLOAD_TYPE_MODULE_FILES, complete.ModuleFiles) if err != nil { - complete.Error = fmt.Sprintf("send data upload: %s", err.Error()) + complete.Error = fmt.Sprintf("prepare module files upload: %s", err.Error()) } else { - for i, chunk := range chunks { - err := s.stream.Send(&proto.Response{Type: &proto.Response_ChunkPiece{ChunkPiece: chunk}}) - if err != nil { - complete.Error = fmt.Sprintf("send data piece upload %d/%d: %s", i, dataUp.Chunks, err.Error()) - break + complete.ModuleFiles = nil // sent over the stream + complete.ModuleFilesHash = dataUp.DataHash + + err := s.stream.Send(&proto.Response{Type: &proto.Response_DataUpload{DataUpload: dataUp}}) + if err != nil { + complete.Error = fmt.Sprintf("send data upload: %s", err.Error()) + } else { + for i, chunk := range chunks { + err := s.stream.Send(&proto.Response{Type: &proto.Response_ChunkPiece{ChunkPiece: chunk}}) + if err != nil { + complete.Error = fmt.Sprintf("send data piece upload %d/%d: %s", i, dataUp.Chunks, err.Error()) + break + } } } } } + s.initialized = true return complete, nil diff --git a/scaletest/agentconn/run.go b/scaletest/agentconn/run.go index 4a4587e478dd8..f26db4355a6da 100644 --- a/scaletest/agentconn/run.go +++ b/scaletest/agentconn/run.go @@ -214,7 +214,7 @@ func verifyConnection(ctx context.Context, logs io.Writer, conn workspacesdk.Age ctx, span := tracing.StartSpan(ctx) defer span.End() - client := agentHTTPClient(conn) + client := conn.AppHTTPClient() for i := 0; i < verifyConnectionAttempts; i++ { _, _ = fmt.Fprintf(logs, "\tVerify connection attempt %d/%d...\n", i+1, verifyConnectionAttempts) verifyCtx, cancel := context.WithTimeout(ctx, defaultRequestTimeout) @@ -258,7 +258,7 @@ func performInitialConnections(ctx context.Context, logs io.Writer, conn workspa defer span.End() _, _ = fmt.Fprintln(logs, "Performing initial service connections...") - client := agentHTTPClient(conn) + client := conn.AppHTTPClient() for i, connSpec := range specs { _, _ = fmt.Fprintf(logs, "\t%d. %s\n", i, connSpec.URL) @@ -292,7 +292,7 @@ func holdConnection(ctx context.Context, logs io.Writer, conn workspacesdk.Agent defer span.End() eg, egCtx := errgroup.WithContext(ctx) - client := agentHTTPClient(conn) + client := conn.AppHTTPClient() if len(specs) > 0 { _, _ = fmt.Fprintln(logs, "\nStarting connection loops...") } @@ -362,26 +362,3 @@ func holdConnection(ctx context.Context, logs io.Writer, conn workspacesdk.Agent return nil } - -func agentHTTPClient(conn workspacesdk.AgentConn) *http.Client { - return &http.Client{ - Transport: &http.Transport{ - DisableKeepAlives: true, - DialContext: func(ctx context.Context, _ string, addr string) (net.Conn, error) { - _, port, err := net.SplitHostPort(addr) - if err != nil { - return nil, xerrors.Errorf("split host port %q: %w", addr, err) - } - - portUint, err := strconv.ParseUint(port, 10, 16) - if err != nil { - return nil, xerrors.Errorf("parse port %q: %w", port, err) - } - - // Addr doesn't matter here, besides the port. DialContext will - // automatically choose the right IP to dial. - return conn.DialContext(ctx, "tcp", fmt.Sprintf("127.0.0.1:%d", portUint)) - }, - }, - } -} diff --git a/scripts/Dockerfile.base b/scripts/Dockerfile.base index 337ee5a84fccb..24fac984f5d74 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.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 && \ diff --git a/scripts/check_emdash.sh b/scripts/check_emdash.sh index ffd4e092ff304..28e059353eeef 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() { @@ -50,8 +55,16 @@ else fi if [[ -z "$base" ]]; then - echo "WARNING: no base ref found, scanning all tracked files." - scan_all_files + # No base ref is available outside of pull requests, for + # example push builds on release branches with a shallow + # clone where neither GITHUB_BASE_REF nor origin/main is + # present. The diff check has nothing to compare against, + # and scanning every tracked file would flag pre-existing + # characters that are unrelated to the change under test. + # Skip instead; pass --all to force a full-tree scan. + echo "OK: no base ref found (not a pull request); skipping emdash check." + echo " Pass --all to scan every tracked file." + exit 0 else # Ensure the base ref is fetchable. CI shallow clones # (fetch-depth: 1) may not have the base branch available. diff --git a/scripts/metricsdocgen/metrics b/scripts/metricsdocgen/metrics index 653de992419ff..036ac496a1616 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 eee4166e494c0..f7ab57f9d4ad7 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", } diff --git a/scripts/releaser/commit.go b/scripts/releaser/commit.go index 37bdfcb7b5cb1..e182e25d2ee2b 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 0000000000000..ff1771b379695 --- /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/docs.go b/scripts/releaser/docs.go index e605d365bfefb..33fbfd79a04cf 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 0000000000000..b682106f9f98f --- /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) + } +} diff --git a/scripts/releaser/executor.go b/scripts/releaser/executor.go index 6c92f67aa3758..8487328e473f9 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 75df80960f0f7..980af43b54d62 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 6394602f9ea35..626305b032a7e 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 9d9723c7c399f..dd0988ea0c405 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) diff --git a/scripts/should_deploy.sh b/scripts/should_deploy.sh index 6259f9e10962c..a23d3293d6c9f 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,59 +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) -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<x.y>.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 +# 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. +if [[ "$branch_name" == "main" ]]; then log "VERDICT: DEPLOY" echo "DEPLOY" # stdout +else + log "VERDICT: NOOP" + echo "NOOP" # stdout fi diff --git a/scripts/update-release-calendar.sh b/scripts/update-release-calendar.sh index 2a7e511e6a6bb..801f5b9c707b8 100755 --- a/scripts/update-release-calendar.sh +++ b/scripts/update-release-calendar.sh @@ -18,7 +18,7 @@ CALENDAR_END_MARKER="<!-- RELEASE_CALENDAR_END -->" # Known active ESR (Extended Support Release) minor versions. # Update this list when new ESR versions are designated or old ones reach end of life. -ESR_VERSIONS=(24 29) +ESR_VERSIONS=(29 34) # Check if a minor version is a known active ESR version. is_esr_version() { @@ -194,9 +194,15 @@ generate_release_calendar() { status="Not Supported" fi - # Override status for active ESR versions that would otherwise be "Not Supported" - if [[ "$status" == "Not Supported" ]] && is_esr_version "$rel_minor"; then - status="Extended Support Release" + # Mark ESR versions. An ESR that has aged out of support shows as a + # full "Extended Support Release"; while it is still in an active + # channel we append "(ESR)" to that channel, e.g. "Mainline (ESR)". + if is_esr_version "$rel_minor"; then + if [[ "$status" == "Not Supported" ]]; then + status="Extended Support Release" + elif [[ "$status" != "Not Released" ]]; then + status="$status (ESR)" + fi fi result+="$(generate_release_row "$version_major" "$rel_minor" "$status")\n" diff --git a/site/e2e/tests/roles.spec.ts b/site/e2e/tests/roles.spec.ts index a1d39c7c42a11..973b815991d17 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/package.json b/site/package.json index d2cf5e5512d19..832403a28aa8f 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 be78e8c75d6ea..d96aecbb121bd 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/site.go b/site/site.go index 9ba43f79f9113..b0a90ef0f0003 100644 --- a/site/site.go +++ b/site/site.go @@ -397,8 +397,8 @@ func (h *Handler) renderHTMLWithState(r *http.Request, filePath string, state ht // nolint:gocritic // User is not expected to be signed in. ctx := dbauthz.AsSystemRestricted(r.Context()) cfg, _ = af.Fetch(ctx) - state.ApplicationName = applicationNameOrDefault(cfg) - state.LogoURL = cfg.LogoURL + state.ApplicationName = html.EscapeString(applicationNameOrDefault(cfg)) + state.LogoURL = html.EscapeString(cfg.LogoURL) return execTmpl(tmpl, state) } @@ -488,8 +488,8 @@ func (h *Handler) populateHTMLState( appr, err := json.Marshal(cfg) if err == nil { state.Appearance = html.EscapeString(string(appr)) - state.ApplicationName = applicationNameOrDefault(cfg) - state.LogoURL = cfg.LogoURL + state.ApplicationName = html.EscapeString(applicationNameOrDefault(cfg)) + state.LogoURL = html.EscapeString(cfg.LogoURL) } } }) diff --git a/site/site_test.go b/site/site_test.go index ef72ee0bc482b..32d4bd27a2758 100644 --- a/site/site_test.go +++ b/site/site_test.go @@ -14,6 +14,7 @@ import ( "path/filepath" "strconv" "strings" + "sync/atomic" "testing" "testing/fstest" "time" @@ -26,6 +27,7 @@ import ( "github.com/stretchr/testify/require" "golang.org/x/exp/maps" + "github.com/coder/coder/v2/coderd/appearance" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/db2sdk" "github.com/coder/coder/v2/coderd/database/dbgen" @@ -39,6 +41,82 @@ import ( "github.com/coder/coder/v2/testutil" ) +type staticAppearanceFetcher struct { + cfg codersdk.AppearanceConfig +} + +func (f staticAppearanceFetcher) Fetch(context.Context) (codersdk.AppearanceConfig, error) { + return f.cfg, nil +} + +func TestInjectionAppearanceEscapesMetaAttributes(t *testing.T) { + t.Parallel() + + const ( + applicationName = `Coder"><script>alert(1)</script>` + logoURL = `https://example.com/logo.png"><img src=x onerror=alert(1)>` + ) + + tests := []struct { + name string + authenticated bool + }{ + { + name: "unauthenticated", + }, + { + name: "authenticated", + authenticated: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + siteFS := fstest.MapFS{ + "index.html": &fstest.MapFile{ + Data: []byte(`<meta name="application-name" content="{{ .ApplicationName }}" /><meta property="logo-url" content="{{ .LogoURL }}" />`), + }, + } + db, _ := dbtestutil.NewDB(t) + var appearanceFetcher atomic.Pointer[appearance.Fetcher] + fetcher := appearance.Fetcher(staticAppearanceFetcher{cfg: codersdk.AppearanceConfig{ + ApplicationName: applicationName, + LogoURL: logoURL, + }}) + appearanceFetcher.Store(&fetcher) + handler, err := site.New(&site.Options{ + Telemetry: telemetry.NewNoop(), + Database: db, + SiteFS: siteFS, + AppearanceFetcher: &appearanceFetcher, + }) + require.NoError(t, err) + + r := httptest.NewRequest("GET", "/", nil) + if tt.authenticated { + user := dbgen.User(t, db, database.User{}) + _, token := dbgen.APIKey(t, db, database.APIKey{ + UserID: user.ID, + ExpiresAt: time.Now().Add(time.Hour), + }) + r.Header.Set(codersdk.SessionTokenHeader, token) + } + rw := httptest.NewRecorder() + + handler.ServeHTTP(rw, r) + require.Equal(t, http.StatusOK, rw.Code) + body := rw.Body.String() + + require.True(t, strings.Contains(body, html.EscapeString(applicationName)), "application name must be HTML escaped") + require.True(t, strings.Contains(body, html.EscapeString(logoURL)), "logo URL must be HTML escaped") + require.False(t, strings.Contains(body, applicationName), "raw application name must not be rendered") + require.False(t, strings.Contains(body, logoURL), "raw logo URL must not be rendered") + }) + } +} + func TestInjection(t *testing.T) { t.Parallel() diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 2c0df043ff4dc..e87613eba7475 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1959,7 +1959,9 @@ export type ChatErrorKind = | "auth" | "config" | "generic" + | "missing_key" | "overloaded" + | "provider_disabled" | "rate_limit" | "startup_timeout" | "timeout" @@ -1969,7 +1971,9 @@ export const ChatErrorKinds: ChatErrorKind[] = [ "auth", "config", "generic", + "missing_key", "overloaded", + "provider_disabled", "rate_limit", "startup_timeout", "timeout", @@ -3228,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; @@ -6019,6 +6024,15 @@ export interface OIDCConfig { * domain. */ 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 diff --git a/site/src/components/SyntaxHighlighter/SyntaxHighlighter.stories.tsx b/site/src/components/SyntaxHighlighter/SyntaxHighlighter.stories.tsx new file mode 100644 index 0000000000000..8740b3af492c9 --- /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<typeof SyntaxHighlighter> = { + title: "components/SyntaxHighlighter", + component: SyntaxHighlighter, + decorators: [withDashboardProvider], + args: { + language: "hcl", + editorProps: { options: stableTeardownOptions }, + }, +}; + +export default meta; +type Story = StoryObj<typeof SyntaxHighlighter>; + +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 ( + <div> + <button type="button" onClick={() => setShowDiff((show) => !show)}> + Toggle diff + </button> + <SyntaxHighlighter + language="hcl" + value={showDiff ? modified : original} + compareWith={original} + editorProps={{ options: stableTeardownOptions }} + /> + </div> + ); +}; + +export const DisposesModelsOnDiffToggle: Story = { + render: () => <DiffToggle />, + 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 d66d7973951a2..60e24b69ec443 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<SyntaxHighlighterProps> = ({ 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<SyntaxHighlighterProps> = ({ }} > {hasDiff ? ( - <DiffEditor - original={compareWith} - modified={value} - {...commonProps} - // Let the editor handle model cleanup. Without this, - // @monaco-editor/react disposes models before the - // DiffEditorWidget and throws an error. - keepCurrentOriginalModel - keepCurrentModifiedModel - onMount={handleDiffEditorMount} - /> + <DiffFile original={compareWith} modified={value} {...commonProps} /> ) : ( <Editor value={value} {...commonProps} /> )} </div> ); }; + +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<DiffFileProps> = ({ + 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 ( + <DiffEditor + original={original} + modified={modified} + {...editorProps} + keepCurrentOriginalModel + keepCurrentModifiedModel + onMount={handleMount} + /> + ); +}; diff --git a/site/src/hooks/useClipboard.test.tsx b/site/src/hooks/useClipboard.test.tsx index dde538614ccdc..a2a4969c9159f 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 11c2518ba803f..fdbb2d9235b45 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<void>; + + /** + * Reads text from the clipboard. When the asynchronous Clipboard API is + * available (secure contexts), it returns the live clipboard contents. + */ + readFromClipboard: () => Promise<string>; + error: Error | undefined; /** @@ -44,6 +51,7 @@ export const useClipboard = ( const [showCopiedSuccess, setShowCopiedSuccess] = useState(false); const [error, setError] = useState<Error>(); const timeoutIdRef = useRef<number | undefined>(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<string> => { + // 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/hooks/useExternalAuth.ts b/site/src/hooks/useExternalAuth.ts index 6c0db550d8915..81dcae5de063a 100644 --- a/site/src/hooks/useExternalAuth.ts +++ b/site/src/hooks/useExternalAuth.ts @@ -5,13 +5,16 @@ import { templateVersionExternalAuth } from "#/api/queries/templates"; export type ExternalAuthPollingState = "idle" | "polling" | "abandoned"; export const useExternalAuth = (versionId: string | undefined) => { - const [externalAuthPollingState, setExternalAuthPollingState] = - useState<ExternalAuthPollingState>("idle"); + const [pollingState, setPollingState] = useState< + Record<string, ExternalAuthPollingState> + >({}); - const startPollingExternalAuth = useCallback(() => { - setExternalAuthPollingState("polling"); + const startPollingExternalAuth = useCallback((providerId: string) => { + setPollingState((prev) => ({ ...prev, [providerId]: "polling" })); }, []); + const isAnyPolling = Object.values(pollingState).some((s) => s === "polling"); + const { data: externalAuth, isPending: isLoadingExternalAuth, @@ -19,37 +22,58 @@ export const useExternalAuth = (versionId: string | undefined) => { } = useQuery({ ...templateVersionExternalAuth(versionId ?? ""), enabled: Boolean(versionId), - refetchInterval: externalAuthPollingState === "polling" ? 1000 : false, + refetchInterval: isAnyPolling ? 1000 : false, }); - const allSignedIn = externalAuth?.every((it) => it.authenticated); - + // Stop polling individual providers once they authenticate. useEffect(() => { - if (allSignedIn) { - setExternalAuthPollingState("idle"); + if (!externalAuth) { return; } + setPollingState((prev) => { + let changed = false; + const next = { ...prev }; + for (const auth of externalAuth) { + if (auth.authenticated && next[auth.id] === "polling") { + next[auth.id] = "idle"; + changed = true; + } + } + return changed ? next : prev; + }); + }, [externalAuth]); - if (externalAuthPollingState !== "polling") { + // Per-provider 60-second timeout. + useEffect(() => { + const pollingIds = Object.entries(pollingState) + .filter(([, authPollingState]) => authPollingState === "polling") + .map(([id]) => id); + + if (pollingIds.length === 0) { return; } - // Poll for a maximum of one minute - const quitPolling = setTimeout( - () => setExternalAuthPollingState("abandoned"), - 60_000, + const timers = pollingIds.map((id) => + setTimeout(() => { + setPollingState((prev) => + prev[id] === "polling" ? { ...prev, [id]: "abandoned" } : prev, + ); + }, 60_000), ); + return () => { - clearTimeout(quitPolling); + for (const t of timers) { + clearTimeout(t); + } }; - }, [externalAuthPollingState, allSignedIn]); + }, [pollingState]); return { startPollingExternalAuth, externalAuth, - externalAuthPollingState, + externalAuthPollingState: pollingState, isLoadingExternalAuth, externalAuthError: error, - isPollingExternalAuth: externalAuthPollingState === "polling", + isPollingExternalAuth: isAnyPolling, }; }; diff --git a/site/src/modules/apps/apps.test.ts b/site/src/modules/apps/apps.test.ts index 146964af78b9f..6a9ccd0dcefe3 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 d3f5b7c8e2837..179358cbd1794 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 = { diff --git a/site/src/modules/dashboard/Navbar/DeploymentDropdown.tsx b/site/src/modules/dashboard/Navbar/DeploymentDropdown.tsx index 6517feca268ef..fcda1e15a3213 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<DeploymentDropdownProps> = ({ const DeploymentDropdownContent: FC<DeploymentDropdownProps> = ({ canViewDeployment, + canViewOrganizations, canViewAuditLog, canViewConnectionLog, canViewAIBridge, @@ -80,9 +81,11 @@ const DeploymentDropdownContent: FC<DeploymentDropdownProps> = ({ <Link to="/deployment">Deployment</Link> </DropdownMenuItem> )} - <DropdownMenuItem asChild> - <Link to="/organizations">Organizations</Link> - </DropdownMenuItem> + {canViewOrganizations && ( + <DropdownMenuItem asChild> + <Link to="/organizations">Organizations</Link> + </DropdownMenuItem> + )} {canViewAISettings && ( <DropdownMenuItem asChild> <Link to="/ai/settings">AI</Link> diff --git a/site/src/modules/dashboard/Navbar/MobileMenu.tsx b/site/src/modules/dashboard/Navbar/MobileMenu.tsx index ff498e1ac2a7c..8a8877dba59f8 100644 --- a/site/src/modules/dashboard/Navbar/MobileMenu.tsx +++ b/site/src/modules/dashboard/Navbar/MobileMenu.tsx @@ -208,6 +208,7 @@ const ProxySettingsSub: FC<ProxySettingsSubProps> = ({ proxyContextValue }) => { const AdminSettingsSub: FC<MobileMenuPermissions> = ({ canViewDeployment, + canViewOrganizations, canViewAuditLog, canViewConnectionLog, canViewHealth, @@ -239,12 +240,14 @@ const AdminSettingsSub: FC<MobileMenuPermissions> = ({ <Link to="/deployment">Deployment</Link> </DropdownMenuItem> )} - <DropdownMenuItem - asChild - className={cn(itemStyles.default, itemStyles.sub)} - > - <Link to="/organizations">Organizations</Link> - </DropdownMenuItem> + {canViewOrganizations && ( + <DropdownMenuItem + asChild + className={cn(itemStyles.default, itemStyles.sub)} + > + <Link to="/organizations">Organizations</Link> + </DropdownMenuItem> + )} {canViewAuditLog && ( <DropdownMenuItem asChild diff --git a/site/src/modules/permissions/index.ts b/site/src/modules/permissions/index.ts index f192ae1a57a54..71d39b374b7a4 100644 --- a/site/src/modules/permissions/index.ts +++ b/site/src/modules/permissions/index.ts @@ -39,8 +39,7 @@ export const canViewAnyOrganization = ( ): permissions is Permissions => { return ( permissions !== undefined && - (permissions.viewAnyMembers || - permissions.editAnyGroups || + (permissions.editAnyGroups || permissions.assignAnyRoles || permissions.viewAnyIdpSyncSettings || permissions.editAnySettings) diff --git a/site/src/modules/resources/AgentLogs/AgentLogLine.test.tsx b/site/src/modules/resources/AgentLogs/AgentLogLine.test.tsx new file mode 100644 index 0000000000000..7dcdc1a5de437 --- /dev/null +++ b/site/src/modules/resources/AgentLogs/AgentLogLine.test.tsx @@ -0,0 +1,52 @@ +import { screen } from "@testing-library/react"; +import type { Line } from "#/components/Logs/LogLine"; +import { renderComponent } from "#/testHelpers/renderHelpers"; +import { AgentLogLine } from "./AgentLogLine"; + +const makeLine = (output: string): Line => ({ + id: 1, + level: "info", + output, + sourceId: "source-id", + time: "2024-03-14T11:31:04.090715Z", +}); + +const renderLine = (output: string) => + renderComponent( + <AgentLogLine line={makeLine(output)} sourceIcon={null} style={{}} />, + ); + +describe("AgentLogLine", () => { + it("renders log HTML as escaped text", () => { + renderLine('safe <span data-testid="agent-log-xss">xss</span>'); + + expect(screen.queryByTestId("agent-log-xss")).not.toBeInTheDocument(); + expect( + screen.getByText(/safe <span data-testid="agent-log-xss">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 2fc68a63c230e..46f6e24784e8c 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(); - interface AgentLogLineProps { line: Line; style: React.CSSProperties; @@ -18,9 +16,13 @@ export const AgentLogLine: FC<AgentLogLineProps> = ({ 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<AgentLogLineProps> = ({ <LogLine className="pl-4" level={line.level} style={style}> {sourceIcon} <LogLinePrefix>{timestamp}</LogLinePrefix> - <span - // biome-ignore lint/security/noDangerouslySetInnerHtml: Output contains HTML to represent ANSI-code formatting - dangerouslySetInnerHTML={{ - __html: output, - }} - /> + <AnsiHtml text={output} /> </LogLine> ); }; diff --git a/site/src/modules/resources/AgentLogs/AgentLogs.stories.tsx b/site/src/modules/resources/AgentLogs/AgentLogs.stories.tsx index bd4f3364b7b28..1cd1035a07928 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, + }, +}; diff --git a/site/src/modules/resources/AgentRow.stories.tsx b/site/src/modules/resources/AgentRow.stories.tsx index a8213ae5d12fd..30002d60a8d74 100644 --- a/site/src/modules/resources/AgentRow.stories.tsx +++ b/site/src/modules/resources/AgentRow.stories.tsx @@ -189,6 +189,42 @@ export const Connecting: Story = { }, }; +export const ConnectingWithStartupLogs: Story = { + args: { + agent: { + ...M.MockWorkspaceAgentConnecting, + logs_length: 1, + }, + initialMetadata: [], + }, + parameters: { + webSocket: [ + { + event: "message", + data: JSON.stringify([ + { + id: 1, + level: "info", + output: "starting up", + source_id: M.MockWorkspaceAgentLogSource.id, + created_at: fixedLogTimestamp, + }, + ]), + }, + ], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Agent is connecting (hasAgentIssues=true) but no script has failed. + // Old code snapped to the Startup Script tab; the fix keeps us on All Logs. + const allLogsTab = await canvas.findByRole("tab", { name: "All Logs" }); + await waitFor(() => + expect(allLogsTab).toHaveAttribute("data-state", "active"), + ); + }, +}; + export const Timeout: Story = { args: { agent: M.MockWorkspaceAgentTimeout, @@ -257,6 +293,135 @@ export const StartError: Story = { }, ], }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // MockWorkspaceAgentStartError ships with a Startup Script whose script + // has exit_code: 1, so the auto-select should land us there. + const startupScriptTab = await canvas.findByRole("tab", { + name: "Startup Script", + }); + await waitFor(() => + expect(startupScriptTab).toHaveAttribute("data-state", "active"), + ); + }, +}; + +export const StartErrorWithoutFailedSourceLogs: Story = { + args: { + agent: M.MockWorkspaceAgentStartError, + }, + parameters: { + // Send log entries only for the OK script, mirroring the case where a + // failed script never emitted any output. The selected tab must not be + // initialized to a source that has no rendered tab. + webSocket: [ + { + event: "message", + data: JSON.stringify( + M.MockWorkspaceAgentStartError.log_sources + .filter((source) => { + const script = M.MockWorkspaceAgentStartError.scripts.find( + (s) => s.log_source_id === source.id, + ); + return !script?.exit_code && script?.status === "ok"; + }) + .flatMap((source, i) => [ + { + id: i, + level: "info", + output: `output from '${source.display_name}'`, + source_id: source.id, + created_at: fixedLogTimestamp, + }, + ]), + ), + }, + ], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Wait for a non-failed source tab to render, confirming logs streamed in. + await canvas.findByRole("tab", { name: "coder" }); + + // All Logs must stay active because no failed source has rendered logs. + const allLogsTab = canvas.getByRole("tab", { name: "All Logs" }); + await waitFor(() => + expect(allLogsTab).toHaveAttribute("data-state", "active"), + ); + }, +}; + +const NON_STARTUP_SCRIPT_SOURCE_ID = "install-script-source-id"; + +export const NonStartupScriptError: Story = { + args: { + agent: { + ...M.MockWorkspaceAgent, + logs_length: 2, + scripts: [ + // Startup Script succeeded. + { + ...M.MockWorkspaceAgent.scripts[0], + exit_code: 0, + status: "ok", + }, + // A non-startup script failed; that's the tab we should auto-select. + { + ...M.MockWorkspaceAgent.scripts[0], + id: "install-script-id", + log_source_id: NON_STARTUP_SCRIPT_SOURCE_ID, + exit_code: 1, + status: "exit_failure", + display_name: "Install Script", + }, + ], + log_sources: [ + ...M.MockWorkspaceAgent.log_sources, + { + ...M.MockWorkspaceAgent.log_sources[0], + id: NON_STARTUP_SCRIPT_SOURCE_ID, + display_name: "Install Script", + }, + ], + }, + }, + parameters: { + webSocket: [ + { + event: "message", + data: JSON.stringify([ + { + id: 1, + level: "info", + output: "startup ok", + source_id: M.MockWorkspaceAgentLogSource.id, + created_at: fixedLogTimestamp, + }, + { + id: 2, + level: "error", + output: "install failed", + source_id: NON_STARTUP_SCRIPT_SOURCE_ID, + created_at: fixedLogTimestamp, + }, + ]), + }, + ], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Startup Script is OK; only Install Script failed. The auto-select must + // follow the failure, not the position or display name. + const installScriptTab = await canvas.findByRole("tab", { + name: "Install Script", + }); + await waitFor(() => + expect(installScriptTab).toHaveAttribute("data-state", "active"), + ); + }, }; export const ShuttingDown: Story = { diff --git a/site/src/modules/resources/AgentRow.tsx b/site/src/modules/resources/AgentRow.tsx index 46c306613e97e..5dd010ec9fb5b 100644 --- a/site/src/modules/resources/AgentRow.tsx +++ b/site/src/modules/resources/AgentRow.tsx @@ -25,6 +25,7 @@ import type { Workspace, WorkspaceAgent, WorkspaceAgentMetadata, + WorkspaceAgentScript, } from "#/api/typesGenerated"; import { CheckIcon } from "#/components/AnimatedIcons/Check"; import { ChevronDownIcon } from "#/components/AnimatedIcons/ChevronDown"; @@ -128,6 +129,13 @@ const getAgentBorderClass = ( const STARTUP_SCRIPT_DISPLAY_NAME = "Startup Script"; +// A script is considered failed if it exited with a non-zero code, or if its +// status reports a known failure mode (anything other than "ok"). Kept aligned +// with the per-tab error indicator so the auto-selected tab matches the visual +// warning badge. +const isScriptFailed = (script: WorkspaceAgentScript | undefined): boolean => + Boolean(script?.exit_code || (script?.status && script.status !== "ok")); + export const AgentRow: FC<AgentRowProps> = ({ agent, subAgents, @@ -235,14 +243,34 @@ export const AgentRow: FC<AgentRowProps> = ({ agent, Boolean(hasDevcontainerErrors || shouldShowWildcardWarning), ); - const failedStartupScriptSource = hasAgentIssues - ? agent.log_sources.find( - (s) => s.display_name === STARTUP_SCRIPT_DISPLAY_NAME, - ) - : undefined; - const [selectedLogTab, setSelectedLogTab] = useState( - failedStartupScriptSource?.id ?? "all", - ); + const [selectedLogTab, setSelectedLogTab] = useState("all"); + const hasAutoSelectedLogTabRef = useRef(false); + // Auto-select the first log tab whose script failed and has rendered output. + useEffect(() => { + if (hasAutoSelectedLogTabRef.current) { + return; + } + const failedSourceWithLogs = agent.log_sources.find((logSource) => { + const script = agent.scripts.find( + (s) => s.log_source_id === logSource.id, + ); + if (!isScriptFailed(script)) { + return false; + } + return agentLogs.some( + (log) => + log.source_id === logSource.id && (log.output?.length ?? 0) > 0, + ); + }); + if (failedSourceWithLogs) { + hasAutoSelectedLogTabRef.current = true; + setSelectedLogTab(failedSourceWithLogs.id); + } + }, [agent.log_sources, agent.scripts, agentLogs]); + const handleSelectedLogTabChange = (value: string) => { + hasAutoSelectedLogTabRef.current = true; + setSelectedLogTab(value); + }; const sortedSourceLogTabs = agent.log_sources .filter((logSource) => { // Remove the logSources that have no entries. @@ -269,9 +297,7 @@ export const AgentRow: FC<AgentRowProps> = ({ ) : null, title: logSource.display_name, value: logSource.id, - error: Boolean( - script?.exit_code || (script?.status && script.status !== "ok"), - ), + error: isScriptFailed(script), }; }) .sort((a, b) => { @@ -563,7 +589,7 @@ export const AgentRow: FC<AgentRowProps> = ({ <Tabs className="-mx-px -mt-px" value={selectedLogTab} - onValueChange={setSelectedLogTab} + onValueChange={handleSelectedLogTabChange} > <div className="flex items-stretch"> <div className="min-w-0 flex-1 overflow-hidden"> @@ -621,7 +647,7 @@ export const AgentRow: FC<AgentRowProps> = ({ <DropdownMenuContent align="end"> <DropdownMenuRadioGroup value={selectedLogTab} - onValueChange={setSelectedLogTab} + onValueChange={handleSelectedLogTabChange} > {overflowLogTabs.map((tab) => ( <DropdownMenuRadioItem diff --git a/site/src/modules/tasks/TaskPrompt/TaskPrompt.stories.tsx b/site/src/modules/tasks/TaskPrompt/TaskPrompt.stories.tsx index 43e0b6083f4bc..809c9256ebd89 100644 --- a/site/src/modules/tasks/TaskPrompt/TaskPrompt.stories.tsx +++ b/site/src/modules/tasks/TaskPrompt/TaskPrompt.stories.tsx @@ -10,6 +10,7 @@ import { MockTasks, MockTemplate, MockTemplateVersion, + MockTemplateVersionExternalAuthAzure, MockTemplateVersionExternalAuthGithub, MockTemplateVersionExternalAuthGithubAuthenticated, MockUserOwner, @@ -387,6 +388,39 @@ export const MissingExternalAuth: Story = { }, }; +export const MissingExternalAuthMultipleProviders: Story = { + beforeEach: () => { + spyOn(API, "getTasks") + .mockResolvedValueOnce(MockTasks) + .mockResolvedValue([MockNewTaskData, ...MockTasks]); + spyOn(API, "createTask").mockResolvedValue(MockTask); + spyOn(API, "getTemplateVersionExternalAuth").mockResolvedValue([ + MockTemplateVersionExternalAuthGithub, + MockTemplateVersionExternalAuthAzure, + ]); + // Prevent the auth button from actually opening a popup. + spyOn(window, "open").mockReturnValue(null); + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + const githubButton = await canvas.findByRole("button", { + name: /connect to github/i, + }); + const azureButton = await canvas.findByRole("button", { + name: /connect to azure/i, + }); + + await step("Click GitHub auth button", async () => { + await userEvent.click(githubButton); + }); + + await step("Azure button remains enabled", () => { + expect(azureButton).toBeEnabled(); + }); + }, +}; + export const ExternalAuthError: Story = { beforeEach: () => { spyOn(API, "getTasks") diff --git a/site/src/modules/tasks/TaskPrompt/TaskPrompt.tsx b/site/src/modules/tasks/TaskPrompt/TaskPrompt.tsx index 8468f75581680..21481e6c2d48d 100644 --- a/site/src/modules/tasks/TaskPrompt/TaskPrompt.tsx +++ b/site/src/modules/tasks/TaskPrompt/TaskPrompt.tsx @@ -436,14 +436,14 @@ const ExternalAuthButtons: FC<ExternalAuthButtonProps> = ({ versionId, missedExternalAuth, }) => { - const { - startPollingExternalAuth, - isPollingExternalAuth, - externalAuthPollingState, - } = useExternalAuth(versionId); - const shouldRetry = externalAuthPollingState === "abandoned"; + const { startPollingExternalAuth, externalAuthPollingState } = + useExternalAuth(versionId); return missedExternalAuth.map((auth) => { + const isPollingExternalAuth = + externalAuthPollingState[auth.id] === "polling"; + const shouldRetry = externalAuthPollingState[auth.id] === "abandoned"; + return ( <div className="flex items-center gap-2" key={auth.id}> <Button @@ -456,7 +456,7 @@ const ExternalAuthButtons: FC<ExternalAuthButtonProps> = ({ "_blank", "width=900,height=600", ); - startPollingExternalAuth(); + startPollingExternalAuth(auth.id); }} > <Spinner loading={isPollingExternalAuth}> @@ -471,7 +471,7 @@ const ExternalAuthButtons: FC<ExternalAuthButtonProps> = ({ <Button variant="outline" size="icon" - onClick={startPollingExternalAuth} + onClick={() => startPollingExternalAuth(auth.id)} > <RedoIcon /> <span className="sr-only">Refresh external auth</span> diff --git a/site/src/modules/terminal/WorkspaceTerminal.tsx b/site/src/modules/terminal/WorkspaceTerminal.tsx index 3cad12fd462c9..c4d95d1c2f2cc 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<Terminal>(); - 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)); } `}</style> - <div - className={cn( - "workspace-terminal h-full w-full flex-1 min-h-0 overflow-hidden bg-surface-tertiary", - className, - )} - ref={terminalWrapperRef} - data-terminal-scope={scopeId} - data-testid={testId} - /> + <ContextMenu onOpenChange={handleContextMenuOpenChange}> + <ContextMenuTrigger asChild disabled={isMac()}> + <div + className={cn( + "workspace-terminal h-full w-full flex-1 min-h-0 overflow-hidden bg-surface-tertiary", + className, + )} + ref={terminalWrapperRef} + data-terminal-scope={scopeId} + data-testid={testId} + /> + </ContextMenuTrigger> + <ContextMenuContent + onCloseAutoFocus={(event) => { + event.preventDefault(); + terminal?.focus(); + }} + > + <ContextMenuItem + disabled={!hasSelection} + onSelect={copyTerminalSelection} + > + Copy + </ContextMenuItem> + <ContextMenuItem onSelect={() => void pasteIntoTerminal()}> + Paste + </ContextMenuItem> + </ContextMenuContent> + </ContextMenu> </> ); }; diff --git a/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsPageView.stories.tsx b/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsPageView.stories.tsx index 0c5f02196970d..ede9692b8ef1d 100644 --- a/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsPageView.stories.tsx +++ b/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsPageView.stories.tsx @@ -92,6 +92,7 @@ export const MultipleSessions: Story = { ...MockSession, id: `session-${i}`, threads: i + 1, + providers: i % 2 === 0 ? ["anthropic", "openai"] : ["anthropic"], last_prompt: [ "But *can* I really fix it?", "Can you refactor the entire authentication module to use JWT tokens instead of session cookies?", diff --git a/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsRow.stories.tsx b/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsRow.stories.tsx index 1407977398ef5..18db56d9385e5 100644 --- a/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsRow.stories.tsx +++ b/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsRow.stories.tsx @@ -30,6 +30,33 @@ export const Default: Story = { }, }; +export const SingleProvider: Story = { + args: { + session: { + ...MockSession, + providers: ["anthropic"], + }, + }, +}; + +export const MultipleProviders: Story = { + args: { + session: { + ...MockSession, + providers: ["anthropic", "openai", "copilot"], + }, + }, +}; + +export const EmptyProviders: Story = { + args: { + session: { + ...MockSession, + providers: [], + }, + }, +}; + export const NullClient: Story = { args: { session: { ...MockSession, client: null }, diff --git a/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsRow.tsx b/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsRow.tsx index a630512b49d11..92dd035178aec 100644 --- a/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsRow.tsx +++ b/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsRow.tsx @@ -63,17 +63,23 @@ export const ListSessionsRow: FC<ListSessionsRowProps> = ({ </TableCell> <TableCell className="w-40 max-w-40"> <div className="min-w-0 overflow-hidden"> - <Badge className="gap-1.5 max-w-full"> - <div className="flex-shrink-0 flex items-center"> - <AIBridgeProviderIcon - provider={getProviderIconName(session.providers[0])} - className="size-icon-xs" - /> - </div> - <span className="truncate min-w-0"> - {getProviderDisplayName(session.providers[0])} - </span> - </Badge> + {session.providers.length > 1 ? ( + <Badge className="max-w-full"> + {session.providers.length} providers + </Badge> + ) : session.providers.length === 1 ? ( + <Badge className="gap-1.5 max-w-full"> + <div className="flex-shrink-0 flex items-center"> + <AIBridgeProviderIcon + provider={getProviderIconName(session.providers[0])} + className="size-icon-xs" + /> + </div> + <span className="truncate min-w-0"> + {getProviderDisplayName(session.providers[0])} + </span> + </Badge> + ) : null} </div> </TableCell> <TableCell className="w-40 max-w-40"> diff --git a/site/src/pages/AISettingsPage/ProvidersPage/AddProviderPage/AddProviderPageView.stories.tsx b/site/src/pages/AISettingsPage/ProvidersPage/AddProviderPage/AddProviderPageView.stories.tsx index f7bbab97617cd..176f8fb9ed938 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 6f21156077e59..dbdb31b36a4ef 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<AddProviderPageViewProps> = ({ provider, }) => { @@ -38,7 +41,9 @@ const AddProviderPageView: React.FC<AddProviderPageViewProps> = ({ size="lg" src={getProviderIcon(provider.value)} /> - <SettingsHeaderTitle>{`Add a ${provider.label} provider`}</SettingsHeaderTitle> + <SettingsHeaderTitle>{`Add ${indefiniteArticle( + provider.label, + )} ${provider.label} provider`}</SettingsHeaderTitle> </div> <p className="text-sm text-content-secondary m-0"> Configure connection details and credentials. diff --git a/site/src/pages/AISettingsPage/ProvidersPage/ProvidersPageView.tsx b/site/src/pages/AISettingsPage/ProvidersPage/ProvidersPageView.tsx index d507d8fcd21c3..ec82741894154 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<ProvidersPageViewProps> = ({ 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.{" "} - <Link - to={docs("/ai-coder/ai-gateway/auth#enable-or-disable-byok")} - target="_blank" - rel="noreferrer" - > - Manage deployment-wide BYOK + <Link href={docs("/ai-coder/ai-gateway/auth#enable-or-disable-byok")}> + View docs </Link> </SettingsHeaderDescription> </SettingsHeader> diff --git a/site/src/pages/AISettingsPage/ProvidersPage/UpdateProviderPage/UpdateProviderPageView.stories.tsx b/site/src/pages/AISettingsPage/ProvidersPage/UpdateProviderPage/UpdateProviderPageView.stories.tsx index 15713428f9d77..4d212d2b14b51 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 dbeb16003d99f..9991c3b8f6759 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 8fda28fae2644..f09d049e6df6d 100644 --- a/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.stories.tsx +++ b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.stories.tsx @@ -31,19 +31,105 @@ export const AddOpenAI: Story = { }; export const AddBedrock: Story = { + args: { + initialValues: { type: "bedrock" }, + }, + play: async ({ canvasElement }) => { + // AddProviderPageView passes only `{ type }`; verify the form + // pre-fills the deployment defaults for the model fields so an + // operator does not have to copy them from the docs. + const canvas = within(canvasElement); + const modelInput = await canvas.findByLabelText(/^model\s*\*?$/i); + const smallFastModelInput = await canvas.findByLabelText( + /^small-fast model\s*\*?$/i, + ); + expect(modelInput).toHaveValue( + "global.anthropic.claude-sonnet-4-5-20250929-v1:0", + ); + expect(smallFastModelInput).toHaveValue( + "global.anthropic.claude-haiku-4-5-20251001-v1:0", + ); + }, +}; + +// Regression coverage for CODAGT-626. The create form must accept Bedrock +// configurations whose credentials come from the AWS environment (IAM +// role, instance profile, AWS_PROFILE) instead of static access keys. +export const AddBedrockWithoutStaticCredentials: Story = { args: { initialValues: { type: "bedrock", - name: "bedrock-prod", - displayName: "Bedrock Prod", + name: "bedrock-iam", + displayName: "Bedrock IAM", baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", model: "anthropic.claude-3-5-sonnet-20241022-v2:0", smallFastModel: "anthropic.claude-3-5-haiku-20241022-v1:0", - accessKey: "AKIAIOSFODNN7EXAMPLE", - accessKeySecret: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + accessKey: "", + accessKeySecret: "", enabled: true, }, }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + const accessKeyInput = await canvas.findByLabelText(/^access key\s*$/i); + const accessKeySecretInput = + await canvas.findByLabelText(/access key secret/i); + + // Neither field renders the required asterisk. + expect(accessKeyInput).toHaveValue(""); + expect(accessKeySecretInput).toHaveValue(""); + + // The Add provider button is enabled even with both credentials blank. + const submitButton = canvas.getByRole("button", { + name: /add provider/i, + }); + await waitFor(() => expect(submitButton).toBeEnabled()); + await userEvent.click(submitButton); + + await waitFor(() => + expect(args.onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + type: "bedrock", + accessKey: "", + accessKeySecret: "", + }), + // Formik invokes onSubmit as (values, formikHelpers), so the + // second argument must be matched for the assertion to pass. + expect.anything(), + ), + ); + }, +}; + +// A half-typed credential pair is blocked at the form layer because the +// backend treats access_key and access_key_secret as a pair. This story +// keeps the cross-validation honest. +export const AddBedrockHalfCredentialPairBlocked: Story = { + args: { + initialValues: { + type: "bedrock", + name: "bedrock-half", + displayName: "Bedrock Half", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + model: "anthropic.claude-3-5-sonnet-20241022-v2:0", + smallFastModel: "anthropic.claude-3-5-haiku-20241022-v1:0", + accessKey: "", + accessKeySecret: "", + enabled: true, + }, + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + const accessKeyInput = await canvas.findByLabelText(/^access key\s*$/i); + + await userEvent.type(accessKeyInput, "AKIAIOSFODNN7EXAMPLE"); + + const submitButton = canvas.getByRole("button", { + name: /add provider/i, + }); + await waitFor(() => expect(submitButton).toBeDisabled()); + expect(args.onSubmit).not.toHaveBeenCalled(); + }, }; export const EditBedrockKeepCredentials: Story = { @@ -64,6 +150,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 0a609de2acd68..1570a5027f719 100644 --- a/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.tsx +++ b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.tsx @@ -9,8 +9,10 @@ import { Button } from "#/components/Button/Button"; import { ConfirmDialog } from "#/components/Dialogs/ConfirmDialog/ConfirmDialog"; import { Form, FormFields } from "#/components/Form/Form"; import { FormField } from "#/components/FormField/FormField"; +import { Link as DocsLink } from "#/components/Link/Link"; import { Spinner } from "#/components/Spinner/Spinner"; import { useUnsavedChangesPrompt } from "#/hooks/useUnsavedChangesPrompt"; +import { docs } from "#/utils/docs"; import { getFormHelpers } from "#/utils/formUtils"; import { CredentialField } from "./CredentialField"; @@ -68,6 +70,17 @@ const defaultInitialValues: ProviderFormValues = { enabled: true, }; +// Bedrock model defaults mirror codersdk/deployment.go's +// aiGatewayBedrockModel and aiGatewayBedrockSmallFastModel defaults +// so the create form lands on the same models the env-seeded path +// uses. Update both sides together when AWS publishes new model IDs. +const BEDROCK_DEFAULT_MODEL = + "global.anthropic.claude-sonnet-4-5-20250929-v1:0"; +const BEDROCK_DEFAULT_SMALL_FAST_MODEL = + "global.anthropic.claude-haiku-4-5-20251001-v1:0"; +const BEDROCK_MODEL_CARDS_URL = + "https://docs.aws.amazon.com/bedrock/latest/userguide/model-cards.html"; + const providerDefaults: Partial< Record<AIProviderType, Partial<ProviderFormValues>> > = { @@ -76,11 +89,17 @@ const providerDefaults: Partial< bedrock: { name: "bedrock", baseUrl: "https://bedrock-runtime.us-east-2.amazonaws.com", + model: BEDROCK_DEFAULT_MODEL, + smallFastModel: BEDROCK_DEFAULT_SMALL_FAST_MODEL, }, azure: { 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/", @@ -121,6 +140,13 @@ const credentialFilled = (value: string | undefined): boolean => { return trimmed !== "" && trimmed !== SAVED_CREDENTIAL_MASK; }; +const BEDROCK_ACCESS_KEY_PAIRED_MESSAGE = + "Enter both access key and secret, or leave both blank to use AWS environment credentials."; + +// Bedrock access keys are optional: when both are blank the server +// falls back to ambient AWS credentials (IAM role, AWS_PROFILE, IRSA, +// instance profile). Yup still requires them to be supplied as a pair +// so a half-typed rotation does not slip through. const makeBedrockSchema = (editing: boolean) => Yup.object({ type: Yup.string() @@ -138,24 +164,18 @@ const makeBedrockSchema = (editing: boolean) => apiKey: Yup.string(), model: Yup.string().required("Model is required"), smallFastModel: Yup.string().required("Small-fast model is required"), - accessKey: (editing - ? Yup.string() - : Yup.string().required("Access key is required") - ).test( + accessKey: Yup.string().test( "access-key-paired", - "Enter both access key and secret to rotate credentials.", + BEDROCK_ACCESS_KEY_PAIRED_MESSAGE, function (value) { const secret = (this.parent as { accessKeySecret?: string }) .accessKeySecret; return !(credentialFilled(secret) && !credentialFilled(value)); }, ), - accessKeySecret: (editing - ? Yup.string() - : Yup.string().required("Access key secret is required") - ).test( + accessKeySecret: Yup.string().test( "access-key-secret-paired", - "Enter both access key and secret to rotate credentials.", + BEDROCK_ACCESS_KEY_PAIRED_MESSAGE, function (value) { const accessKey = (this.parent as { accessKey?: string }).accessKey; return !(credentialFilled(accessKey) && !credentialFilled(value)); @@ -164,6 +184,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 +211,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 +221,7 @@ const getProviderFormSchema = (editing: boolean) => "anthropic", "bedrock", "azure", + "copilot", "google", "openai-compat", "openrouter", @@ -319,18 +356,38 @@ export const ProviderForm: FC<ProviderFormProps> = ({ 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:{" "} + <code>https://api.individual.githubcopilot.com</code>,{" "} + <code>https://api.business.githubcopilot.com</code>, or{" "} + <code>https://api.enterprise.githubcopilot.com</code>. + </> + ) : ( + "The base URL where the provider's API is hosted." + ) + } className="w-full" placeholder={baseUrlPlaceholder(form.values.type)} /> - <CredentialField - required - label="API key" - helpers={getFieldHelpers("apiKey")} - onFocus={() => handleCredentialFocus("apiKey")} - autoComplete="new-password" - placeholder={apiKeyPlaceholder(form.values.type)} - /> + {typeSelectValue === "copilot" ? ( + <p className="text-sm text-content-secondary m-0"> + 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. + </p> + ) : ( + <CredentialField + required + label="API key" + helpers={getFieldHelpers("apiKey")} + onFocus={() => handleCredentialFocus("apiKey")} + autoComplete="new-password" + placeholder={apiKeyPlaceholder(form.values.type)} + /> + )} </> )} @@ -374,31 +431,53 @@ export const ProviderForm: FC<ProviderFormProps> = ({ field={getFieldHelpers("model")} label="Model" className="w-full" - placeholder="anthropic.claude-3-5-sonnet-20241022-v2:0" + placeholder={BEDROCK_DEFAULT_MODEL} /> <FormField required field={getFieldHelpers("smallFastModel")} label="Small-fast model" className="w-full" - placeholder="anthropic.claude-3-haiku-20240307-v1:0" + placeholder={BEDROCK_DEFAULT_SMALL_FAST_MODEL} /> </div> + <p className="text-xs text-content-secondary m-0"> + Find available Bedrock model IDs in the{" "} + <DocsLink + size="sm" + href={BEDROCK_MODEL_CARDS_URL} + target="_blank" + rel="noreferrer" + > + AWS Bedrock model cards + </DocsLink> + . + </p> <div className="grid grid-cols-2 items-start gap-4"> <CredentialField - required label="Access key" helpers={getFieldHelpers("accessKey")} onFocus={() => handleCredentialFocus("accessKey")} /> <CredentialField - required label="Access key secret" helpers={getFieldHelpers("accessKeySecret")} onFocus={() => handleCredentialFocus("accessKeySecret")} autoComplete="new-password" /> </div> + <p className="text-xs text-content-secondary m-0"> + Optional. Leave both fields blank to authenticate with the AWS + environment (IAM role, instance profile, AWS_PROFILE).{" "} + <DocsLink + size="sm" + href={docs("/ai-coder/ai-gateway/providers#amazon-bedrock")} + target="_blank" + rel="noreferrer" + > + View docs + </DocsLink> + </p> </> )} @@ -409,7 +488,7 @@ export const ProviderForm: FC<ProviderFormProps> = ({ </Button> </Link> <Button - disabled={isLoading || !form.dirty || !form.isValid} + disabled={isLoading || !form.isValid || (editing && !form.dirty)} type="submit" > <Spinner loading={isLoading} /> diff --git a/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderIcon.stories.tsx b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderIcon.stories.tsx index dc16ada4807e2..1c3b04d28fecb 100644 --- a/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderIcon.stories.tsx +++ b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderIcon.stories.tsx @@ -27,6 +27,12 @@ export const Bedrock: Story = { }, }; +export const Copilot: Story = { + args: { + provider: "copilot", + }, +}; + export const Azure: Story = { args: { provider: "azure", diff --git a/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderIcon.tsx b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderIcon.tsx index f84dcc8375398..43bf9228431e6 100644 --- a/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderIcon.tsx +++ b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderIcon.tsx @@ -15,6 +15,8 @@ export const getProviderIcon = (provider: string): string | undefined => { return "/icon/aws.svg"; case "azure": return "/icon/azure.svg"; + case "copilot": + return "/icon/github-copilot.svg"; case "google": return "/icon/google.svg"; case "vercel": @@ -34,6 +36,8 @@ const getProviderName = (provider: string): string => { return "AWS Bedrock"; case "azure": return "Azure OpenAI"; + case "copilot": + return "GitHub Copilot"; case "google": return "Google"; case "openai-compat": diff --git a/site/src/pages/AISettingsPage/ProvidersPage/components/addableProviderTypes.ts b/site/src/pages/AISettingsPage/ProvidersPage/components/addableProviderTypes.ts index aebf2af08be82..3ccba3cd6caf9 100644 --- a/site/src/pages/AISettingsPage/ProvidersPage/components/addableProviderTypes.ts +++ b/site/src/pages/AISettingsPage/ProvidersPage/components/addableProviderTypes.ts @@ -9,6 +9,7 @@ export const addableProviders: readonly AddableProvider[] = [ { value: "anthropic", label: "Anthropic" }, { value: "bedrock", label: "AWS Bedrock" }, { value: "azure", label: "Azure OpenAI" }, + { value: "copilot", label: "GitHub Copilot" }, { value: "google", label: "Google" }, { value: "openai", label: "OpenAI" }, { value: "openai-compat", label: "OpenAI-compatible" }, diff --git a/site/src/pages/AISettingsPage/ProvidersPage/components/providerFormApiMap.test.ts b/site/src/pages/AISettingsPage/ProvidersPage/components/providerFormApiMap.test.ts index 3f7ab9ca597a3..a437d0d23fde5 100644 --- a/site/src/pages/AISettingsPage/ProvidersPage/components/providerFormApiMap.test.ts +++ b/site/src/pages/AISettingsPage/ProvidersPage/components/providerFormApiMap.test.ts @@ -3,6 +3,7 @@ import type { AIProvider } from "#/api/typesGenerated"; import { MockAIProviderAnthropic, MockAIProviderBedrock, + MockAIProviderCopilot, MockAIProviderOpenAI, } from "#/testHelpers/entities"; import { @@ -45,6 +46,19 @@ const baseBedrockFormValues: ProviderFormValues = { enabled: true, }; +const baseCopilotFormValues: ProviderFormValues = { + type: "copilot", + name: "copilot", + displayName: "GitHub Copilot", + baseUrl: "https://api.business.githubcopilot.com", + model: "", + smallFastModel: "", + accessKey: "", + accessKeySecret: "", + apiKey: "", + enabled: true, +}; + // Cast a plain object to AIProvider's discriminated `settings` shape; // the generated TS interface is empty and the wire form carries the // discriminator keys flattened in alongside the variant fields. @@ -173,6 +187,10 @@ describe("getProviderDisplayType", () => { expect(getProviderDisplayType(MockAIProviderOpenAI)).toBe("openai"); }); + it("returns copilot for a Copilot provider", () => { + expect(getProviderDisplayType(MockAIProviderCopilot)).toBe("copilot"); + }); + it.each([ ["azure", "https://my-resource.openai.azure.com/openai/v1"], ["azure", "https://YOUR-RESOURCE.openai.azure.com/openai/v1"], @@ -325,6 +343,35 @@ describe("providerFormValuesToCreate", () => { expect(s.access_key_secret).toBeUndefined(); }); + it("keeps the region so the backend recognises the Bedrock provider when access keys are omitted", () => { + // The backend treats Region as a configuration signal + // (codersdk.AIProviderBedrockSettings.IsConfigured), so omitting + // the keys must not also strip the region; otherwise the request + // would fail with "type=bedrock requires bedrock settings". + const req = providerFormValuesToCreate({ + ...baseBedrockFormValues, + accessKey: "", + accessKeySecret: "", + }); + const s = req.settings as unknown as Record<string, unknown>; + expect(s.region).toBe("us-east-1"); + expect(s._type).toBe("bedrock"); + }); + + it("omits the access fields when only whitespace is supplied", () => { + // Mirrors the OpenAI/Anthropic whitespace handling: callers must + // not accidentally persist a credential whose plaintext is just + // blanks. + const req = providerFormValuesToCreate({ + ...baseBedrockFormValues, + accessKey: " ", + accessKeySecret: "\t", + }); + const s = req.settings as unknown as Record<string, unknown>; + expect(s.access_key).toBeUndefined(); + expect(s.access_key_secret).toBeUndefined(); + }); + it("ignores the OpenAI/Anthropic api key field", () => { const req = providerFormValuesToCreate({ ...baseBedrockFormValues, @@ -333,6 +380,31 @@ describe("providerFormValuesToCreate", () => { expect(req.api_keys).toBeUndefined(); }); }); + + describe("Copilot", () => { + it("maps to a distinct wire type with no api_keys", () => { + const req = providerFormValuesToCreate(baseCopilotFormValues); + expect(req.type).toBe("copilot"); + expect(req.base_url).toBe("https://api.business.githubcopilot.com"); + expect(req.api_keys).toBeUndefined(); + }); + + it("never sends api_keys even if the field carries a value", () => { + const req = providerFormValuesToCreate({ + ...baseCopilotFormValues, + apiKey: "should-be-ignored", + }); + expect(req.api_keys).toBeUndefined(); + }); + + it("omits display_name when blank so the server stores NULL", () => { + const req = providerFormValuesToCreate({ + ...baseCopilotFormValues, + displayName: "", + }); + expect(req.display_name).toBeUndefined(); + }); + }); }); describe("providerFormValuesToUpdate", () => { @@ -460,6 +532,18 @@ describe("providerFormValuesToUpdate", () => { expect(s.access_key_secret).toBeUndefined(); }); }); + + describe("Copilot", () => { + it("patches only the base fields and never sends api_keys", () => { + const req = providerFormValuesToUpdate( + { ...baseCopilotFormValues, apiKey: "should-be-ignored" }, + MockAIProviderCopilot, + ); + expect(req.api_keys).toBeUndefined(); + expect(req.settings).toBeUndefined(); + expect(req.base_url).toBe("https://api.business.githubcopilot.com"); + }); + }); }); describe("aiProviderToFormValues", () => { @@ -486,6 +570,14 @@ describe("aiProviderToFormValues", () => { expect(values.accessKeySecret).toBe(""); }); + it("seeds Copilot form values without a credential field", () => { + const values = aiProviderToFormValues(MockAIProviderCopilot); + expect(values.type).toBe("copilot"); + expect(values.name).toBe(MockAIProviderCopilot.name); + expect(values.baseUrl).toBe(MockAIProviderCopilot.base_url); + expect(values.apiKey).toBeUndefined(); + }); + it("falls back to the slug when display_name is empty", () => { const provider: AIProvider = { ...MockAIProviderOpenAI, diff --git a/site/src/pages/AISettingsPage/ProvidersPage/components/providerFormApiMap.ts b/site/src/pages/AISettingsPage/ProvidersPage/components/providerFormApiMap.ts index 4f958fdea7488..2fdb8dd8d68fd 100644 --- a/site/src/pages/AISettingsPage/ProvidersPage/components/providerFormApiMap.ts +++ b/site/src/pages/AISettingsPage/ProvidersPage/components/providerFormApiMap.ts @@ -98,6 +98,9 @@ export const getProviderDisplayType = ( if (provider.type === "anthropic") { return "anthropic"; } + if (provider.type === "copilot") { + return "copilot"; + } const host = parseProviderHost(provider.base_url ?? ""); const match = displayTypeHosts.find(([h]) => matchesHost(host, h)); return match?.[1] ?? provider.type; @@ -125,12 +128,16 @@ const buildBedrockSettings = ( export const providerFormValuesToCreate = ( values: ProviderFormValues, ): CreateAIProviderRequest => { - const name = values.name.trim(); const displayName = values.displayName.trim(); - const baseUrl = values.baseUrl.trim(); + const base: Omit<CreateAIProviderRequest, "type"> = { + name: values.name.trim(), + ...(displayName ? { display_name: displayName } : {}), + base_url: values.baseUrl.trim(), + enabled: values.enabled, + }; if (values.type === "bedrock") { - const region = parseBedrockRegionFromBaseUrl(baseUrl); + const region = parseBedrockRegionFromBaseUrl(base.base_url); const settings = buildBedrockSettings( region, values.model.trim(), @@ -140,17 +147,18 @@ export const providerFormValuesToCreate = ( ); return { type: "anthropic", - name, - ...(displayName ? { display_name: displayName } : {}), - base_url: baseUrl, - enabled: values.enabled, + ...base, settings: settings as AIProviderSettings, }; } + if (values.type === "copilot") { + return { type: "copilot", ...base }; + } + const apiKey = sanitizeCredential(values.apiKey); - // `""` is unreachable here (Yup blocks it, Bedrock branched out), but the - // union still includes it; narrow so TS stays honest. + // `""` is unreachable here (Yup blocks it, Bedrock and Copilot branched + // out), but the union still includes it; narrow so TS stays honest. if (values.type === "") { throw new Error("provider type is required"); } @@ -160,10 +168,7 @@ export const providerFormValuesToCreate = ( values.type === "anthropic" ? "anthropic" : "openai"; return { type: wireType, - name, - ...(displayName ? { display_name: displayName } : {}), - base_url: baseUrl, - enabled: values.enabled, + ...base, ...(apiKey ? { api_keys: [apiKey] } : {}), }; }; @@ -182,6 +187,10 @@ export const providerFormValuesToUpdate = ( base_url: values.baseUrl.trim(), }; + if (values.type === "copilot") { + return base; + } + if (values.type !== "bedrock") { // If the user didn't touch the input, the form still holds the seeded // mask and sanitizes to `""` (no rotation). @@ -240,8 +249,18 @@ export const aiProviderToFormValues = ( }; } - // Wire `type` is only `openai` or `anthropic`; the dropdown's richer - // labels apply only on create. + if (provider.type === "copilot") { + return { + type: "copilot", + name: provider.name, + displayName, + baseUrl: provider.base_url, + enabled: provider.enabled, + }; + } + + // Wire `type` is otherwise only `openai` or `anthropic`; the dropdown's + // richer labels apply only on create. return { type: provider.type === "anthropic" ? "anthropic" : "openai", name: provider.name, diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index 9a73a122cf9fb..95fef9778649f 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -1182,9 +1182,11 @@ const AgentChatPage: FC = () => { store.setStreamError(reason); setChatErrorReason(agentId, reason); } else if (isApiError(error)) { + const detail = error.response?.data?.detail?.trim() || undefined; const reason: ChatDetailError = { kind: "generic", - message: error.message || "An unexpected error occurred.", + message: getErrorMessage(error, "An unexpected error occurred."), + ...(detail ? { detail } : {}), }; store.setStreamError(reason); setChatErrorReason(agentId, reason); diff --git a/site/src/pages/AgentsPage/AgentSettingsModelsPage.tsx b/site/src/pages/AgentsPage/AgentSettingsModelsPage.tsx index b7a8b36ba72f3..4b8c50fd96ea8 100644 --- a/site/src/pages/AgentsPage/AgentSettingsModelsPage.tsx +++ b/site/src/pages/AgentsPage/AgentSettingsModelsPage.tsx @@ -47,7 +47,7 @@ const AgentSettingsModelsPage: FC = () => { <ChatModelAdminPanel section="models" sectionLabel="Models" - sectionDescription="Choose which models from your configured providers are available for users to select. You can set a default and adjust context limits." + sectionDescription="Choose which models from your configured providers are available for Coder Agents. Set a default and adjust context limits." providerConfigsData={providerConfigsQuery.data} modelConfigsData={modelConfigsQuery.data} modelCatalogData={modelCatalogQuery.data} diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ChatStatusCallout.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ChatStatusCallout.tsx index e44ba842fc8e1..9d54a0c524eef 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ChatStatusCallout.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ChatStatusCallout.tsx @@ -164,11 +164,17 @@ const StatusAlert: FC<{ status: RetryOrFailedStatus }> = ({ status }) => { </Link> )} </span> - {status.phase === "failed" && status.detail && ( - <span className="mt-1 block text-content-secondary"> - {status.detail} - </span> - )} + {status.phase === "failed" && + status.detail && + (status.kind === "generic" ? ( + <code className="mt-1 block whitespace-pre-wrap text-xs text-content-secondary font-mono bg-surface-secondary rounded-md"> + {status.detail} + </code> + ) : ( + <span className="mt-1 block text-content-secondary"> + {status.detail} + </span> + ))} </AlertDescription> </Alert> ); diff --git a/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.stories.tsx index a4ddf3bf6fc66..32484ed15c0b8 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: { @@ -253,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: { @@ -282,7 +351,7 @@ export const GenericErrorDoesNotShowUsageAction: Story = { }, }; -/** Provider detail renders as a muted secondary line under the main error. */ +/** Provider detail renders in a monospace block for generic errors. */ export const GenericErrorShowsProviderDetail: Story = { args: { ...defaultArgs, diff --git a/site/src/pages/AgentsPage/components/ChatConversation/chatStatusHelpers.ts b/site/src/pages/AgentsPage/components/ChatConversation/chatStatusHelpers.ts index c718e2a00761f..d9ea6f6e59426 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/chatStatusHelpers.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/chatStatusHelpers.ts @@ -42,6 +42,10 @@ export const getErrorTitle = ( return "Configuration error"; case "usage_limit": 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/components/ChatModelAdminPanel/ChatModelAdminPanel.stories.tsx b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ChatModelAdminPanel.stories.tsx index ec422bca5eee5..09a538636976e 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 0ad417439c34a..2e1762d758aab 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 297ddabe2cc2d..985fb49f7e892 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 d325c53385444..d537aa726c440 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(""); diff --git a/site/src/pages/AgentsPage/utils/usageLimitMessage.ts b/site/src/pages/AgentsPage/utils/usageLimitMessage.ts index 1986da0227300..1c7f10f00f43c 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; diff --git a/site/src/pages/AuditPage/AuditLogRow/AuditLogDiff/AuditLogDiff.tsx b/site/src/pages/AuditPage/AuditLogRow/AuditLogDiff/AuditLogDiff.tsx index 7565a144e2fcc..d3e035c33d4a1 100644 --- a/site/src/pages/AuditPage/AuditLogRow/AuditLogDiff/AuditLogDiff.tsx +++ b/site/src/pages/AuditPage/AuditLogRow/AuditLogDiff/AuditLogDiff.tsx @@ -1,43 +1,6 @@ import type { FC } from "react"; import type { AuditDiff } from "#/api/typesGenerated"; - -const getDiffValue = (value: unknown): string => { - if (typeof value === "string") { - return `"${value}"`; - } - - if (isTimeObject(value)) { - if (!value.Valid) { - return "null"; - } - - return new Date(value.Time).toLocaleString(); - } - - if (Array.isArray(value)) { - const values = value.map((v) => getDiffValue(v)); - return `[${values.join(", ")}]`; - } - - if (value === null || value === undefined) { - return "null"; - } - - return String(value); -}; - -const isTimeObject = ( - value: unknown, -): value is { Time: string; Valid: boolean } => { - return ( - value !== null && - typeof value === "object" && - "Time" in value && - typeof value.Time === "string" && - "Valid" in value && - typeof value.Valid === "boolean" - ); -}; +import { formatAuditDiffValue } from "./auditUtils"; interface AuditLogDiffProps { diff: AuditDiff; @@ -58,7 +21,9 @@ export const AuditLogDiff: FC<AuditLogDiffProps> = ({ diff }) => { <div> {attrName}:{" "} <span className="rounded p-px bg-red-800"> - {valueDiff.secret ? "••••••••" : getDiffValue(valueDiff.old)} + {valueDiff.secret + ? "••••••••" + : formatAuditDiffValue(valueDiff.old)} </span> </div> </div> @@ -74,7 +39,9 @@ export const AuditLogDiff: FC<AuditLogDiffProps> = ({ diff }) => { <div> {attrName}:{" "} <span className="rounded p-px bg-green-800"> - {valueDiff.secret ? "••••••••" : getDiffValue(valueDiff.new)} + {valueDiff.secret + ? "••••••••" + : formatAuditDiffValue(valueDiff.new)} </span> </div> </div> diff --git a/site/src/pages/AuditPage/AuditLogRow/AuditLogDiff/auditUtils.test.ts b/site/src/pages/AuditPage/AuditLogRow/AuditLogDiff/auditUtils.test.ts index 12c6cabb418ff..74cca5a2c69f1 100644 --- a/site/src/pages/AuditPage/AuditLogRow/AuditLogDiff/auditUtils.test.ts +++ b/site/src/pages/AuditPage/AuditLogRow/AuditLogDiff/auditUtils.test.ts @@ -1,4 +1,4 @@ -import { determineGroupDiff } from "./auditUtils"; +import { determineGroupDiff, formatAuditDiffValue } from "./auditUtils"; const auditDiffForNewGroup = { id: { @@ -120,3 +120,70 @@ describe("determineAuditDiff", () => { expect(determineGroupDiff(AuditDiffForDeletedGroup)).toEqual(result); }); }); + +describe("formatAuditDiffValue", () => { + it.each([ + { name: "string", value: "hello", expected: '"hello"' }, + { + name: "string containing double quotes", + value: 'he said "hello"', + expected: '"he said \\"hello\\""', + }, + { + name: "array of primitives", + value: ["admin", "auditor"], + expected: '["admin", "auditor"]', + }, + { name: "boolean true", value: true, expected: "true" }, + { name: "boolean false", value: false, expected: "false" }, + { name: "number", value: 42, expected: "42" }, + { name: "null", value: null, expected: "null" }, + { name: "undefined", value: undefined, expected: "null" }, + { + name: "invalid SQL time", + value: { Time: "0001-01-01T00:00:00Z", Valid: false }, + expected: "null", + }, + ])("preserves current behavior for $name", ({ value, expected }) => { + expect(formatAuditDiffValue(value)).toBe(expected); + }); + + it("preserves current behavior for valid SQL time objects", () => { + const value = { Time: "2024-10-22T09:03:23.961702Z", Valid: true }; + + expect(formatAuditDiffValue(value)).toBe( + new Date(value.Time).toLocaleString(), + ); + }); + + it("formats plain objects as deterministic compact JSON", () => { + expect( + formatAuditDiffValue({ + z: ["read"], + a: { permissions: ["read"] }, + }), + ).toBe('{"a":{"permissions":["read"]},"z":["read"]}'); + }); + + it("formats chat ACL objects as deterministic compact JSON", () => { + expect( + formatAuditDiffValue({ + "user-2": { permissions: ["read"] }, + "user-1": { permissions: ["read"] }, + }), + ).toBe( + '{"user-1":{"permissions":["read"]},"user-2":{"permissions":["read"]}}', + ); + }); + + it("formats arrays containing objects without object string coercion", () => { + expect( + formatAuditDiffValue([ + { user_id: "user-2", permissions: ["read"] }, + { permissions: ["read"], user_id: "user-1" }, + ]), + ).toBe( + '[{"permissions":["read"],"user_id":"user-2"}, {"permissions":["read"],"user_id":"user-1"}]', + ); + }); +}); diff --git a/site/src/pages/AuditPage/AuditLogRow/AuditLogDiff/auditUtils.ts b/site/src/pages/AuditPage/AuditLogRow/AuditLogDiff/auditUtils.ts index 7e9033841ccf9..881463d46e5a3 100644 --- a/site/src/pages/AuditPage/AuditLogRow/AuditLogDiff/auditUtils.ts +++ b/site/src/pages/AuditPage/AuditLogRow/AuditLogDiff/auditUtils.ts @@ -25,27 +25,68 @@ export const determineGroupDiff = (auditLogDiff: AuditDiff): AuditDiff => { }; /** - * - * @param auditLogDiff - * @returns a diff with the 'mappings' as a JSON string. Otherwise, it is [Object object] + * Formats an audit diff value for display. Strings are quoted, nullish values + * become "null", SQL time objects are localized, arrays are recursed, and plain + * objects are serialized as compact JSON with sorted keys. */ -export const determineIdPSyncMappingDiff = ( - auditLogDiff: AuditDiff, -): AuditDiff => { - const old = auditLogDiff.mapping?.old as Record<string, string[]> | undefined; - const new_ = auditLogDiff.mapping?.new as - | Record<string, string[]> - | undefined; - if (!old || !new_) { - return auditLogDiff; +export const formatAuditDiffValue = (value: unknown): string => { + if (typeof value === "string") { + return JSON.stringify(value); } - return { - ...auditLogDiff, - mapping: { - old: JSON.stringify(old), - new: JSON.stringify(new_), - secret: auditLogDiff.mapping?.secret, - }, - }; + if (isTimeObject(value)) { + if (!value.Valid) { + return "null"; + } + + return new Date(value.Time).toLocaleString(); + } + + if (Array.isArray(value)) { + const values = value.map((v) => formatAuditDiffValue(v)); + return `[${values.join(", ")}]`; + } + + if (value === null || value === undefined) { + return "null"; + } + + if (isPlainObject(value)) { + return JSON.stringify(sortObjectKeys(value)); + } + + return String(value); +}; + +const isTimeObject = ( + value: unknown, +): value is { Time: string; Valid: boolean } => { + return ( + value !== null && + typeof value === "object" && + "Time" in value && + typeof value.Time === "string" && + "Valid" in value && + typeof value.Valid === "boolean" + ); +}; + +const isPlainObject = (value: unknown): value is Record<string, unknown> => { + return Object.prototype.toString.call(value) === "[object Object]"; +}; + +const sortObjectKeys = (value: unknown): unknown => { + if (Array.isArray(value)) { + return value.map(sortObjectKeys); + } + + if (!isPlainObject(value)) { + return value; + } + + const sorted: Record<string, unknown> = {}; + for (const key of Object.keys(value).sort()) { + sorted[key] = sortObjectKeys(value[key]); + } + return sorted; }; diff --git a/site/src/pages/AuditPage/AuditLogRow/AuditLogRow.stories.tsx b/site/src/pages/AuditPage/AuditLogRow/AuditLogRow.stories.tsx index 2d4fd6e28e0c1..d84ef1669716d 100644 --- a/site/src/pages/AuditPage/AuditLogRow/AuditLogRow.stories.tsx +++ b/site/src/pages/AuditPage/AuditLogRow/AuditLogRow.stories.tsx @@ -1,4 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { AuditLog } from "#/api/typesGenerated"; import { Table, TableBody } from "#/components/Table/Table"; import { chromatic } from "#/testHelpers/chromatic"; import { @@ -187,3 +188,87 @@ export const WithConnectionType: Story = { }, }, }; + +const MockChatAuditLog: AuditLog = { + ...MockAuditLog, + resource_type: "chat", + resource_id: "c542b43f-4375-421a-a7e0-b39187e35131", + resource_target: "c542b43f", + resource_icon: "", + resource_link: "/agents/c542b43f-4375-421a-a7e0-b39187e35131", + description: "{user} updated chat {target}", + additional_fields: {}, +}; + +export const WithChatACLDiff: Story = { + parameters: { chromatic }, + args: { + auditLog: { + ...MockChatAuditLog, + id: "1d718c45-5dfb-4f24-9546-4f61fa8e3402", + action: "write", + description: "{user} updated sharing for chat {target}", + diff: { + user_acl: { + old: {}, + new: { + "9a68e35d-bf3a-43bd-8e68-130df721cc71": { + permissions: ["read"], + }, + }, + secret: false, + }, + group_acl: { + old: {}, + new: { + "6d130d81-017e-44ff-8fca-3a38623dcb14": { + permissions: ["read"], + }, + }, + secret: false, + }, + }, + }, + defaultIsDiffOpen: true, + }, +}; + +export const WithArchivedChatDescription: Story = { + args: { + auditLog: { + ...MockChatAuditLog, + id: "57329396-084a-4074-9930-385a7eed858a", + action: "write", + description: "{user} archived chat {target}", + diff: { + archived: { + old: false, + new: true, + secret: false, + }, + }, + }, + }, +}; + +export const WithUpdatedChatSharingDescription: Story = { + args: { + auditLog: { + ...MockChatAuditLog, + id: "8f26cabf-8867-4d2f-942d-77e759a16c1c", + action: "write", + description: "{user} updated sharing for chat {target}", + diff: { + user_acl: { + old: {}, + new: { + "9a68e35d-bf3a-43bd-8e68-130df721cc71": { + permissions: ["read"], + }, + }, + secret: false, + }, + }, + }, + }, +}; diff --git a/site/src/pages/AuditPage/AuditLogRow/AuditLogRow.tsx b/site/src/pages/AuditPage/AuditLogRow/AuditLogRow.tsx index ea0ccc37a8c3d..448aa393af467 100644 --- a/site/src/pages/AuditPage/AuditLogRow/AuditLogRow.tsx +++ b/site/src/pages/AuditPage/AuditLogRow/AuditLogRow.tsx @@ -22,10 +22,7 @@ import { cn } from "#/utils/cn"; import { buildReasonLabels } from "#/utils/workspace"; import { AuditLogDescription } from "./AuditLogDescription/AuditLogDescription"; import { AuditLogDiff } from "./AuditLogDiff/AuditLogDiff"; -import { - determineGroupDiff, - determineIdPSyncMappingDiff, -} from "./AuditLogDiff/auditUtils"; +import { determineGroupDiff } from "./AuditLogDiff/auditUtils"; interface AuditLogRowProps { auditLog: AuditLog; @@ -53,14 +50,6 @@ export const AuditLogRow: FC<AuditLogRowProps> = ({ auditDiff = determineGroupDiff(auditLog.diff); } - if ( - auditLog.resource_type === "idp_sync_settings_organization" || - auditLog.resource_type === "idp_sync_settings_group" || - auditLog.resource_type === "idp_sync_settings_role" - ) { - auditDiff = determineIdPSyncMappingDiff(auditLog.diff); - } - const toggle = () => { if (shouldDisplayDiff) { setIsDiffOpen((v) => !v); diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.stories.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.stories.tsx new file mode 100644 index 0000000000000..da204818816fb --- /dev/null +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.stories.tsx @@ -0,0 +1,220 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, spyOn, userEvent, waitFor, within } from "storybook/test"; +import { reactRouterParameters } from "storybook-addon-remix-react-router"; +import { API } from "#/api/api"; +import { + MockTemplate, + MockTemplateVersion, + MockTemplateVersionExternalAuthAzure, + MockTemplateVersionExternalAuthGithub, + MockTemplateVersionExternalAuthGithubAuthenticated, + MockUserOwner, +} from "#/testHelpers/entities"; +import { + withAuthProvider, + withDashboardProvider, +} from "#/testHelpers/storybook"; +import CreateWorkspacePage from "./CreateWorkspacePage"; + +/** + * Mocks API.templateVersionDynamicParameters to immediately send an empty + * DynamicParametersResponse so the page renders the form instead of the + * loader. + */ +function mockDynamicParameters() { + spyOn(API, "templateVersionDynamicParameters").mockImplementation( + (_versionId, _ownerId, callbacks) => { + // Fire asynchronously so the component mounts before the message + // arrives, matching real WebSocket behavior. + setTimeout(() => { + callbacks.onMessage({ id: 0, parameters: [], diagnostics: [] }); + }, 0); + + return { close: () => {} } as unknown as WebSocket; + }, + ); +} + +const meta: Meta<typeof CreateWorkspacePage> = { + title: "pages/CreateWorkspacePage", + component: CreateWorkspacePage, + decorators: [withAuthProvider, withDashboardProvider], + parameters: { + layout: "fullscreen", + user: MockUserOwner, + reactRouter: reactRouterParameters({ + location: { + pathParams: { + organization: MockTemplate.organization_name, + template: MockTemplate.name, + }, + }, + routing: { + path: "/templates/:organization/:template/workspace", + }, + }), + }, + beforeEach: () => { + // Prevent the auth button from actually opening a popup. + spyOn(window, "open").mockReturnValue(null); + + // Template, version, and preset queries. + spyOn(API, "getTemplateByName").mockResolvedValue(MockTemplate); + spyOn(API, "getTemplateVersion").mockResolvedValue(MockTemplateVersion); + spyOn(API, "getTemplateVersionPresets").mockResolvedValue(null); + spyOn(API, "checkAuthorization").mockResolvedValue({ + createWorkspaceForAny: true, + canUpdateTemplate: false, + }); + + // Dynamic parameters over WebSocket. + mockDynamicParameters(); + + // Default: no external auth required. + spyOn(API, "getTemplateVersionExternalAuth").mockResolvedValue([]); + }, +}; + +export default meta; +type Story = StoryObj<typeof CreateWorkspacePage>; + +/** + * Renders two unauthenticated external auth providers. Both "Login with" + * buttons should be visible and enabled. + */ +export const MultipleExternalAuth: Story = { + beforeEach: () => { + spyOn(API, "getTemplateVersionExternalAuth").mockResolvedValue([ + MockTemplateVersionExternalAuthGithub, + MockTemplateVersionExternalAuthAzure, + ]); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const githubButton = await canvas.findByRole("button", { + name: /login with github/i, + }); + const azureButton = await canvas.findByRole("button", { + name: /login with azure/i, + }); + + expect(githubButton).toBeEnabled(); + expect(azureButton).toBeEnabled(); + }, +}; + +/** + * Clicking one external auth button should only show a loading spinner on + * that button. The other provider's button must remain enabled so the user + * can authenticate with both without a page refresh. + * + * This is the regression test for coder/coder#22420. + */ +export const ClickingOneAuthDoesNotDisableOthers: Story = { + beforeEach: () => { + spyOn(API, "getTemplateVersionExternalAuth").mockResolvedValue([ + MockTemplateVersionExternalAuthGithub, + MockTemplateVersionExternalAuthAzure, + ]); + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + const githubButton = await canvas.findByRole("button", { + name: /login with github/i, + }); + const azureButton = await canvas.findByRole("button", { + name: /login with azure/i, + }); + + await step("Click GitHub auth button", async () => { + await userEvent.click(githubButton); + }); + + await step("Azure button remains enabled", () => { + expect(azureButton).toBeEnabled(); + }); + }, +}; + +/** + * After the first provider completes authentication and the API starts + * returning it as authenticated, its button should be replaced with the + * "Authenticated" badge. The second provider's button should still be + * clickable. + */ +export const OneProviderAuthenticated: Story = { + beforeEach: () => { + spyOn(API, "getTemplateVersionExternalAuth").mockResolvedValue([ + MockTemplateVersionExternalAuthGithubAuthenticated, + MockTemplateVersionExternalAuthAzure, + ]); + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + await step("GitHub shows authenticated", async () => { + await canvas.findByText("Authenticated"); + }); + + await step("Azure login button is still enabled", async () => { + const azureButton = await canvas.findByRole("button", { + name: /login with azure/i, + }); + expect(azureButton).toBeEnabled(); + }); + }, +}; + +/** + * Simulates the full two-provider authentication flow: click the first + * provider, have polling return it as authenticated, then click the second + * provider. + */ +export const SequentialAuthFlow: Story = { + beforeEach: () => { + // First call: both unauthenticated. + // Subsequent calls: GitHub authenticated (simulating a successful login + // during the polling interval). + spyOn(API, "getTemplateVersionExternalAuth") + .mockResolvedValueOnce([ + MockTemplateVersionExternalAuthGithub, + MockTemplateVersionExternalAuthAzure, + ]) + .mockResolvedValue([ + MockTemplateVersionExternalAuthGithubAuthenticated, + MockTemplateVersionExternalAuthAzure, + ]); + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + await step("Both buttons render initially", async () => { + await canvas.findByRole("button", { name: /login with github/i }); + await canvas.findByRole("button", { name: /login with azure/i }); + }); + + await step("Click GitHub and wait for it to authenticate", async () => { + const githubButton = await canvas.findByRole("button", { + name: /login with github/i, + }); + await userEvent.click(githubButton); + + // Polling picks up the updated mock that returns GitHub as + // authenticated. The "Authenticated" text replaces the button. + await waitFor(() => { + expect( + canvas.queryByRole("button", { name: /login with github/i }), + ).not.toBeInTheDocument(); + }); + }); + + await step("Azure button is still clickable", async () => { + const azureButton = await canvas.findByRole("button", { + name: /login with azure/i, + }); + expect(azureButton).toBeEnabled(); + }); + }, +}; diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx index 5dc9ab5f70953..1e9102c21a62f 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx @@ -15,7 +15,6 @@ import { checkAuthorization } from "#/api/queries/authCheck"; import { templateByName, templateVersion, - templateVersionExternalAuth, templateVersionPresets, } from "#/api/queries/templates"; import { autoCreateWorkspace, createWorkspace } from "#/api/queries/workspaces"; @@ -28,6 +27,7 @@ import type { } from "#/api/typesGenerated"; import { Loader } from "#/components/Loader/Loader"; import { useAuthenticated } from "#/hooks/useAuthenticated"; +import { useExternalAuth } from "#/hooks/useExternalAuth"; import { getInitialParameterValues } from "#/modules/workspaces/DynamicParameter/DynamicParameter"; import { generateWorkspaceName } from "#/modules/workspaces/generateWorkspaceName"; import { pageTitle } from "#/utils/page"; @@ -41,7 +41,6 @@ import { const createWorkspaceModes = ["form", "auto", "duplicate"] as const; export type CreateWorkspaceMode = (typeof createWorkspaceModes)[number]; -type ExternalAuthPollingState = "idle" | "polling" | "abandoned"; const CreateWorkspacePage: FC = () => { const { organization: organizationName = "default", template: templateName } = @@ -364,50 +363,6 @@ const CreateWorkspacePage: FC = () => { ); }; -const useExternalAuth = (versionId: string | undefined) => { - const [externalAuthPollingState, setExternalAuthPollingState] = - useState<ExternalAuthPollingState>("idle"); - - const startPollingExternalAuth = useCallback(() => { - setExternalAuthPollingState("polling"); - }, []); - - const { data: externalAuth, isLoading: isLoadingExternalAuth } = useQuery({ - ...templateVersionExternalAuth(versionId ?? ""), - enabled: Boolean(versionId), - refetchInterval: externalAuthPollingState === "polling" ? 1000 : false, - }); - - const allSignedIn = externalAuth?.every((it) => it.authenticated); - - useEffect(() => { - if (allSignedIn) { - setExternalAuthPollingState("idle"); - return; - } - - if (externalAuthPollingState !== "polling") { - return; - } - - // Poll for a maximum of one minute - const quitPolling = setTimeout( - () => setExternalAuthPollingState("abandoned"), - 60_000, - ); - return () => { - clearTimeout(quitPolling); - }; - }, [externalAuthPollingState, allSignedIn]); - - return { - startPollingExternalAuth, - externalAuth, - externalAuthPollingState, - isLoadingExternalAuth, - }; -}; - const getAutofillParameters = ( urlSearchParams: URLSearchParams, ): AutofillBuildParameter[] => { diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx index b689d72bd0b47..f7257d8857d44 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx @@ -16,7 +16,7 @@ const meta: Meta<typeof CreateWorkspacePageView> = { defaultName: "", defaultOwner: MockUserOwner, externalAuth: [], - externalAuthPollingState: "idle", + externalAuthPollingState: {}, hasAllRequiredExternalAuth: true, mode: "form", parameters: [], diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx index 167b93838d06b..75a6261d75ac5 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx @@ -66,7 +66,7 @@ interface CreateWorkspacePageViewProps { disabledParams?: string[]; error: unknown; externalAuth: TypesGen.TemplateVersionExternalAuth[]; - externalAuthPollingState: ExternalAuthPollingState; + externalAuthPollingState: Record<string, ExternalAuthPollingState>; hasAllRequiredExternalAuth: boolean; mode: CreateWorkspaceMode; parameters: PreviewParameter[]; @@ -82,7 +82,7 @@ interface CreateWorkspacePageViewProps { ) => void; resetMutation: () => void; sendMessage: (message: Record<string, string>, ownerId?: string) => void; - startPollingExternalAuth: () => void; + startPollingExternalAuth: (providerId: string) => void; owner: TypesGen.MinimalUser; setOwner: (user: TypesGen.MinimalUser) => void; } @@ -389,7 +389,7 @@ export const CreateWorkspacePageView: FC<CreateWorkspacePageViewProps> = ({ Go back </button> </div> - <div className="flex flex-col gap-6 max-w-screen-md mx-auto"> + <div className="flex flex-col gap-6 max-w-screen-md mx-auto pb-96"> <header className="flex flex-col items-start gap-3 mt-10"> <div className="flex items-center gap-2 justify-between w-full"> <span className="flex items-center gap-2"> @@ -559,9 +559,11 @@ export const CreateWorkspacePageView: FC<CreateWorkspacePageViewProps> = ({ key={auth.id} error={error} auth={auth} - isLoading={externalAuthPollingState === "polling"} - onStartPolling={startPollingExternalAuth} - displayRetry={externalAuthPollingState === "abandoned"} + isLoading={externalAuthPollingState[auth.id] === "polling"} + onStartPolling={() => startPollingExternalAuth(auth.id)} + displayRetry={ + externalAuthPollingState[auth.id] === "abandoned" + } /> ))} </div> diff --git a/site/src/pages/OrganizationSettingsPage/CreateOrganizationPage.tsx b/site/src/pages/OrganizationSettingsPage/CreateOrganizationPage.tsx index 2da4aafba5bfd..51cdccf5ab5b9 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}`); }} /> </RequirePermission> diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TTLHelperText.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TTLHelperText.tsx index 90a1606d35b9e..f58e56a7408fb 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 ( <span> - 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. </span> ); } diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx index 2db8dafc2f9e3..d4eda81d32d92 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<TemplateScheduleForm> = ({ 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<TemplateScheduleForm> = ({ <ActivityBumpHelperText bump={form.values.activity_bump_ms} defaultTTL={form.values.default_ttl_ms} + allowUserAutostop={form.values.allow_user_autostop} /> ), })} - 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 21837080e625c..d2edb82598f6c 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 5ba34480bfbbb..d3b4686ee5e87 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(); + }, +}; diff --git a/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditor.stories.tsx b/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditor.stories.tsx index b4a553e4ec898..9fb9a8c6e8e69 100644 --- a/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditor.stories.tsx +++ b/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditor.stories.tsx @@ -31,7 +31,8 @@ const meta: Meta<typeof TemplateVersionEditor> = { 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 29e8767b3e36a..9c7e122ff2042 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<TemplateVersionEditorProps> = ({ canPublish, template, templateVersion, - defaultFileTree, + fileTree, + onFileTreeChange, onPreview, onPublish, onConfirmPublish, @@ -133,7 +135,6 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({ const navigate = useNavigate(); const getLink = useLinks(); const [selectedTab, setSelectedTab] = useState<Tab>(defaultTab); - const [fileTree, setFileTree] = useState(defaultFileTree); const [createFileOpen, setCreateFileOpen] = useState(false); const [deleteFileOpen, setDeleteFileOpen] = useState<string>(); const [renameFileOpen, setRenameFileOpen] = useState<string>(); @@ -351,7 +352,9 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({ }} 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<TemplateVersionEditorProps> = ({ 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<TemplateVersionEditorProps> = ({ if (!renameFileOpen) { return; } - setFileTree((fileTree) => + onFileTreeChange((fileTree) => moveFile(renameFileOpen, newPath, fileTree), ); onActivePathChange(newPath); @@ -433,7 +436,7 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({ 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 157ec23a2592a..2c660fcbaa103 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(<TemplateVersionEditorPage />, { + 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(<TemplateVersionEditorPage />, { + 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 d46f06b7baca0..96281a1d9ed83 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<FileTree | undefined>(undefined); + const [tarFile, setTarFile] = useState<TarReader | undefined>(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) => { diff --git a/site/src/pages/TerminalPage/TerminalPage.stories.tsx b/site/src/pages/TerminalPage/TerminalPage.stories.tsx index 947ad728721ae..e15d192f8ab43 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<HTMLElement>(".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: { diff --git a/site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx b/site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx index 8dbda6b01a1bd..dfda18d6e563a 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 bcf0571d90a10..676b2204c9206 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesTable.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesTable.tsx @@ -655,15 +655,21 @@ const WorkspaceApps: FC<WorkspaceAppsProps> = ({ 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; } diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 07bd0641292f0..cb6a57fe70805 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -3635,6 +3635,16 @@ export const MockTemplateVersionExternalAuthGithubAuthenticated: TypesGen.Templa display_name: "GitHub", }; +export const MockTemplateVersionExternalAuthAzure: TypesGen.TemplateVersionExternalAuth = + { + id: "azure", + type: "azure", + authenticate_url: "https://example.com/external-auth/azure", + authenticated: false, + display_icon: "/icon/azure.svg", + display_name: "Azure", + }; + export const MockDeploymentStats: TypesGen.DeploymentStats = { aggregated_from: "2023-03-06T19:08:55.211625Z", collected_at: "2023-03-06T19:12:55.211625Z", @@ -5574,8 +5584,22 @@ export const MockAIProviderBedrock: TypesGen.AIProvider = { updated_at: "2026-05-14T10:00:00Z", }; +export const MockAIProviderCopilot: TypesGen.AIProvider = { + id: "b3f0d2c8-6a4e-4d11-8c2f-1e9a7c5b4d31", + type: "copilot", + name: "copilot", + display_name: "GitHub Copilot", + base_url: "https://api.business.githubcopilot.com", + enabled: true, + api_keys: [], + settings: null as unknown as TypesGen.AIProviderSettings, + created_at: "2026-05-14T10:00:00Z", + updated_at: "2026-05-14T10:00:00Z", +}; + export const MockAIProviders: TypesGen.AIProvider[] = [ MockAIProviderOpenAI, MockAIProviderAnthropic, MockAIProviderBedrock, + MockAIProviderCopilot, ]; diff --git a/site/src/theme/icons.json b/site/src/theme/icons.json index ec3b21da7db3f..d9ef015946fc1 100644 --- a/site/src/theme/icons.json +++ b/site/src/theme/icons.json @@ -120,6 +120,7 @@ "rider.svg", "rockylinux.svg", "roo-code.svg", + "rproject.svg", "rstudio.svg", "ruby.png", "ruby.svg", diff --git a/site/static/icon/rproject.svg b/site/static/icon/rproject.svg new file mode 100644 index 0000000000000..b5545c3fa4aeb --- /dev/null +++ b/site/static/icon/rproject.svg @@ -0,0 +1,14 @@ +<svg width="256" height="256" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M128.486 200.448C58.0777 200.448 1 162.292 1 115.224C1 68.1561 58.0777 30 128.486 30C198.895 30 255.972 68.1561 255.972 115.224C255.972 162.292 198.895 200.448 128.486 200.448ZM148 63.3198C94.4835 63.3198 51.0996 89.4049 51.0996 121.583C51.0996 153.76 94.4835 179.845 148 179.845C201.517 179.845 241.011 162.012 241.011 121.583C241.011 81.1662 201.517 63.3198 148 63.3198Z" fill="url(#paint0_linear_2115_2436)"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M195.156 162.001C195.156 162.001 202.872 164.325 207.355 166.589C208.91 167.375 211.601 168.942 213.542 171.001C215.444 173.017 216.371 175.059 216.371 175.059L246.78 226.236L197.631 226.258L174.647 183.177C174.647 183.177 169.941 175.106 167.045 172.765C164.63 170.813 163.6 170.118 161.211 170.118C159.548 170.118 149.533 170.118 149.533 170.118L149.542 226.227L106.051 226.245V82.9199H193.388C193.388 82.9199 233.167 83.636 233.167 121.412C233.167 159.189 195.156 162.001 195.156 162.001ZM176.239 114.009L149.909 113.992L149.896 138.363L176.239 138.354C176.239 138.354 188.438 138.317 188.438 125.957C188.438 113.349 176.239 114.009 176.239 114.009Z" fill="url(#paint1_linear_2115_2436)"/> +<defs> +<linearGradient id="paint0_linear_2115_2436" x1="1" y1="30" x2="158.502" y2="265.607" gradientUnits="userSpaceOnUse"> +<stop stop-color="#CBCED0"/> +<stop offset="1" stop-color="#84838B"/> +</linearGradient> +<linearGradient id="paint1_linear_2115_2436" x1="106.051" y1="82.9199" x2="249.364" y2="223.625" gradientUnits="userSpaceOnUse"> +<stop stop-color="#276DC3"/> +<stop offset="1" stop-color="#165CAA"/> +</linearGradient> +</defs> +</svg> diff --git a/site/static/icon/rstudio.svg b/site/static/icon/rstudio.svg index b5545c3fa4aeb..4e615c95b02f2 100644 --- a/site/static/icon/rstudio.svg +++ b/site/static/icon/rstudio.svg @@ -1,14 +1,4 @@ <svg width="256" height="256" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg"> -<path fill-rule="evenodd" clip-rule="evenodd" d="M128.486 200.448C58.0777 200.448 1 162.292 1 115.224C1 68.1561 58.0777 30 128.486 30C198.895 30 255.972 68.1561 255.972 115.224C255.972 162.292 198.895 200.448 128.486 200.448ZM148 63.3198C94.4835 63.3198 51.0996 89.4049 51.0996 121.583C51.0996 153.76 94.4835 179.845 148 179.845C201.517 179.845 241.011 162.012 241.011 121.583C241.011 81.1662 201.517 63.3198 148 63.3198Z" fill="url(#paint0_linear_2115_2436)"/> -<path fill-rule="evenodd" clip-rule="evenodd" d="M195.156 162.001C195.156 162.001 202.872 164.325 207.355 166.589C208.91 167.375 211.601 168.942 213.542 171.001C215.444 173.017 216.371 175.059 216.371 175.059L246.78 226.236L197.631 226.258L174.647 183.177C174.647 183.177 169.941 175.106 167.045 172.765C164.63 170.813 163.6 170.118 161.211 170.118C159.548 170.118 149.533 170.118 149.533 170.118L149.542 226.227L106.051 226.245V82.9199H193.388C193.388 82.9199 233.167 83.636 233.167 121.412C233.167 159.189 195.156 162.001 195.156 162.001ZM176.239 114.009L149.909 113.992L149.896 138.363L176.239 138.354C176.239 138.354 188.438 138.317 188.438 125.957C188.438 113.349 176.239 114.009 176.239 114.009Z" fill="url(#paint1_linear_2115_2436)"/> -<defs> -<linearGradient id="paint0_linear_2115_2436" x1="1" y1="30" x2="158.502" y2="265.607" gradientUnits="userSpaceOnUse"> -<stop stop-color="#CBCED0"/> -<stop offset="1" stop-color="#84838B"/> -</linearGradient> -<linearGradient id="paint1_linear_2115_2436" x1="106.051" y1="82.9199" x2="249.364" y2="223.625" gradientUnits="userSpaceOnUse"> -<stop stop-color="#276DC3"/> -<stop offset="1" stop-color="#165CAA"/> -</linearGradient> -</defs> +<circle cx="128" cy="128" r="128" fill="#75AADB"/> +<path d="M171.796,168.862h13.883v10.784h-21.196L129.363,127.008h-18.717v41.854h18.303v10.784H82.882v-10.784h15.824V76.106l-15.824,-1.942v-10.205c5.991,1.363,11.197,2.314,17.725,2.314c9.833,0,19.873,-2.314,29.707,-2.314c19.088,0,36.813,8.677,36.813,29.872c0,16.403,-9.833,26.815,-25.079,31.236L171.796,168.862zM110.647,116.597l10.04,0.207c24.501,0.372,33.921,-9.048,33.921,-21.609c0,-14.668,-10.618,-20.452,-24.088,-20.452c-6.363,0,-12.932,0.578,-19.873,1.363V116.597z" fill="#FFFFFF"/> </svg> diff --git a/tailnet/coordinator.go b/tailnet/coordinator.go index c3df906b4d865..79db8ca0a22ba 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 9f5ac7c6a46eb..654bf28015e1f 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/coordinator_test.go b/tailnet/coordinator_test.go index fb7ecc73dc8b3..e35d0c3fa843a 100644 --- a/tailnet/coordinator_test.go +++ b/tailnet/coordinator_test.go @@ -7,6 +7,7 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/require" + "golang.org/x/xerrors" "cdr.dev/slog/v3" "cdr.dev/slog/v3/sloggers/slogtest" @@ -102,7 +103,32 @@ func TestCoordinator(t *testing.T) { PreferredDerp: 10, }) agent.AssertEventuallyResponsesClosed( - tailnet.AuthorizationError{Wrapped: tailnet.InvalidNodeAddressError{Addr: prefix.Addr().String()}}.Error()) + tailnet.AuthorizationError{Wrapped: xerrors.Errorf("Addresses: %w", tailnet.InvalidNodeAddressError{Addr: prefix.Addr().String()})}.Error()) + }) + + t.Run("AgentWithoutClients_InvalidAllowedIP", func(t *testing.T) { + t.Parallel() + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) + ctx := testutil.Context(t, testutil.WaitShort) + coordinator := tailnet.NewCoordinator(logger) + defer func() { + err := coordinator.Close() + require.NoError(t, err) + }() + agent := test.NewAgent(ctx, t, coordinator, "agent") + defer agent.Close(ctx) + // A valid self-address paired with an AllowedIP belonging to a different + // (victim) agent must be rejected. + victim := tailnet.TailscaleServicePrefix.PrefixFromUUID(uuid.New()) + agent.UpdateNode(&proto.Node{ + Addresses: []string{ + tailnet.TailscaleServicePrefix.PrefixFromUUID(agent.ID).String(), + }, + AllowedIps: []string{victim.String()}, + PreferredDerp: 10, + }) + agent.AssertEventuallyResponsesClosed( + tailnet.AuthorizationError{Wrapped: xerrors.Errorf("AllowedIps: %w", tailnet.InvalidNodeAddressError{Addr: victim.Addr().String()})}.Error()) }) t.Run("AgentWithoutClients_InvalidBits", func(t *testing.T) { @@ -124,7 +150,7 @@ func TestCoordinator(t *testing.T) { PreferredDerp: 10, }) agent.AssertEventuallyResponsesClosed( - tailnet.AuthorizationError{Wrapped: tailnet.InvalidAddressBitsError{Bits: 64}}.Error()) + tailnet.AuthorizationError{Wrapped: xerrors.Errorf("Addresses: %w", tailnet.InvalidAddressBitsError{Bits: 64})}.Error()) }) t.Run("AgentWithClient", func(t *testing.T) { diff --git a/tailnet/peer.go b/tailnet/peer.go index 1954534a1d142..8631c24e62d58 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 0000000000000..1dd2083e82f0c --- /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 0000000000000..94cebe151efa6 --- /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 0000000000000..8d50a5077195c --- /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/service.go b/tailnet/service.go index 6ae02876a4dd3..9773530c76fcf 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 0c268b05edb50..21a290ef35803 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() diff --git a/tailnet/test/integration/integration.go b/tailnet/test/integration/integration.go index 43648e89d81c8..449d32f5795bc 100644 --- a/tailnet/test/integration/integration.go +++ b/tailnet/test/integration/integration.go @@ -216,7 +216,7 @@ func (o SimpleServerOptions) Router(t *testing.T, logger slog.Logger) *chi.Mux { }) }, tracing.StatusWriterMiddleware, - loggermw.Logger(logger), + loggermw.Logger(logger, nil), ) r.Route("/derp", func(r chi.Router) { diff --git a/tailnet/test/peer.go b/tailnet/test/peer.go index 601dc748d42d7..9dc44f431ed45 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 0000000000000..da84432604efe --- /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 75a943ba13249..86b4a846afa4d 100644 --- a/tailnet/tunnel.go +++ b/tailnet/tunnel.go @@ -66,26 +66,46 @@ 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") } if upd := req.GetUpdateSelf(); upd != nil { - for _, addrStr := range upd.Node.Addresses { - pre, err := netip.ParsePrefix(addrStr) - if err != nil { - return xerrors.Errorf("parse node address: %w", err) - } + // Both Addresses and AllowedIPs are installed into the WireGuard peer + // config and drive routing, so an agent may only advertise prefixes + // derived from its own UUID. Without this an agent could claim a victim + // agent's IP and have traffic routed to it. + if err := a.authorizeNodePrefixes(upd.Node.Addresses); err != nil { + return xerrors.Errorf("Addresses: %w", err) + } + if err := a.authorizeNodePrefixes(upd.Node.AllowedIps); err != nil { + return xerrors.Errorf("AllowedIps: %w", err) + } + } - if pre.Bits() != 128 { - return InvalidAddressBitsError{pre.Bits()} - } + return nil +} - if TailscaleServicePrefix.AddrFromUUID(a.ID).Compare(pre.Addr()) != 0 && - CoderServicePrefix.AddrFromUUID(a.ID).Compare(pre.Addr()) != 0 && - legacyWorkspaceAgentIP.Compare(pre.Addr()) != 0 { - return InvalidNodeAddressError{pre.Addr().String()} - } +// authorizeNodePrefixes verifies that every prefix is a /128 address derived +// from the agent's own UUID (or the legacy workspace agent IP). +func (a AgentCoordinateeAuth) authorizeNodePrefixes(prefixes []string) error { + for _, prefixStr := range prefixes { + pre, err := netip.ParsePrefix(prefixStr) + if err != nil { + return xerrors.Errorf("parse node address: %w", err) + } + + if pre.Bits() != 128 { + return InvalidAddressBitsError{pre.Bits()} + } + + if TailscaleServicePrefix.AddrFromUUID(a.ID).Compare(pre.Addr()) != 0 && + CoderServicePrefix.AddrFromUUID(a.ID).Compare(pre.Addr()) != 0 && + legacyWorkspaceAgentIP.Compare(pre.Addr()) != 0 { + return InvalidNodeAddressError{pre.Addr().String()} } } @@ -113,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) diff --git a/testutil/expecter/expecter.go b/testutil/expecter/expecter.go new file mode 100644 index 0000000000000..bbcbdb5b21f73 --- /dev/null +++ b/testutil/expecter/expecter.go @@ -0,0 +1,355 @@ +package expecter + +import ( + "bufio" + "bytes" + "context" + "fmt" + "io" + "regexp" + "slices" + "strings" + "testing" + "time" + "unicode/utf8" + + "github.com/acarl005/stripansi" + "github.com/stretchr/testify/require" + "go.uber.org/atomic" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/testutil" + "github.com/coder/serpent" +) + +func New(t *testing.T, r io.Reader, name string) *Expecter { + // Use pipe for logging. + logDone := make(chan struct{}) + logr, logw := io.Pipe() + + // Write to log and output buffer. + copyDone := make(chan struct{}) + out := newStdbuf() + w := io.MultiWriter(logw, out) + + ex := &Expecter{ + t: t, + out: out, + name: atomic.NewString(name), + + runeReader: bufio.NewReaderSize(out, utf8.UTFMax), + logDone: logDone, + copyDone: copyDone, + logr: logr, + logw: logw, + } + + go func() { + defer close(copyDone) + _, err := io.Copy(w, r) + ex.Logf("copy done: %v", err) + ex.Logf("closing out") + err = out.closeErr(err) + ex.Logf("closed out: %v", err) + }() + + // Log all output as part of test for easier debugging on errors. + go func() { + defer close(logDone) + s := bufio.NewScanner(logr) + for s.Scan() { + ex.Logf("%q", stripansi.Strip(s.Text())) + } + // Surface non-EOF scanner errors; otherwise they're invisible. + if err := s.Err(); err != nil { + ex.Logf("log scanner stopped: %v", err) + } + }() + + return ex +} + +func NewAttachedToInvocation(t *testing.T, invocation *serpent.Invocation) *Expecter { + r, w := io.Pipe() + invocation.Stdout = w + invocation.Stderr = w + e := New(t, r, "cmd") + + t.Cleanup(func() { + // Serpent doesn't handle closing stdout after running the Invocation; normally the OS does that automatically when + // the process exits. Close it here at the end of the test to ensure we don't leak goroutines reading from the + // stdout/stderr. + _ = w.Close() + e.Close("test end") + }) + return e +} + +func NewPiped(t *testing.T) (*Expecter, io.Writer) { + r, w := io.Pipe() + e := New(t, r, "cmd") + + t.Cleanup(func() { + // Close writer here at the end of the test to ensure we don't leak goroutines reading from the pipe. + _ = w.Close() + e.Close("test end") + }) + return e, w +} + +type Expecter struct { + t *testing.T + out *stdbuf + name *atomic.String + + runeReader *bufio.Reader + copyDone, logDone chan struct{} + logr, logw io.Closer +} + +// Rename the expecter. Make sure you set this before anything starts writing to the +// stream, or it may not be named consistently. +func (e *Expecter) Rename(name string) { + e.name.Store(name) +} + +func (e *Expecter) Close(reason string) { + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) + defer cancel() + + e.Logf("closing expecter: %s", reason) + + // Caller needs to have closed the stream so that copying can complete + select { + case <-ctx.Done(): + e.fatalf("close", "copy did not close in time") + return + case <-e.copyDone: + } + + e.logClose("logw", e.logw) + e.logClose("logr", e.logr) + select { + case <-ctx.Done(): + e.fatalf("close", "log pipe did not close in time") + return + case <-e.logDone: + } + + e.Logf("closed expecter") +} + +func (e *Expecter) logClose(name string, c io.Closer) { + e.Logf("closing %s", name) + err := c.Close() + e.Logf("closed %s: %v", name, err) +} + +func (e *Expecter) ExpectMatch(ctx context.Context, str string) string { + return e.expectMatcherFunc(ctx, str, strings.Contains) +} + +func (e *Expecter) ExpectRegexMatch(ctx context.Context, str string) string { + return e.expectMatcherFunc(ctx, str, func(src, pattern string) bool { + return regexp.MustCompile(pattern).MatchString(src) + }) +} + +func (e *Expecter) expectMatcherFunc(ctx context.Context, str string, fn func(src, pattern string) bool) string { + e.t.Helper() + + var buffer bytes.Buffer + err := e.doMatchWithDeadline(ctx, "ExpectMatchContext", func(rd *bufio.Reader) error { + for { + r, _, err := rd.ReadRune() + if err != nil { + return err + } + _, err = buffer.WriteRune(r) + if err != nil { + return err + } + if fn(buffer.String(), str) { + return nil + } + } + }) + if err != nil { + e.fatalf("read error", "%v (wanted %q; got %q)", err, str, buffer.String()) + return "" + } + e.Logf("matched %q = %q", str, buffer.String()) + return buffer.String() +} + +// ExpectNoMatchBefore validates that `match` does not occur before `before`. +func (e *Expecter) ExpectNoMatchBefore(ctx context.Context, match, before string) string { + e.t.Helper() + + var buffer bytes.Buffer + err := e.doMatchWithDeadline(ctx, "ExpectNoMatchBefore", func(rd *bufio.Reader) error { + for { + r, _, err := rd.ReadRune() + if err != nil { + return err + } + _, err = buffer.WriteRune(r) + if err != nil { + return err + } + + if strings.Contains(buffer.String(), match) { + return xerrors.Errorf("found %q before %q", match, before) + } + + if strings.Contains(buffer.String(), before) { + return nil + } + } + }) + if err != nil { + e.fatalf("read error", "%v (wanted no %q before %q; got %q)", err, match, before, buffer.String()) + return "" + } + e.Logf("matched %q = %q", before, stripansi.Strip(buffer.String())) + return buffer.String() +} + +func (e *Expecter) Peek(ctx context.Context, n int) []byte { + e.t.Helper() + + var out []byte + err := e.doMatchWithDeadline(ctx, "Peek", func(rd *bufio.Reader) error { + var err error + out, err = rd.Peek(n) + return err + }) + if err != nil { + e.fatalf("read error", "%v (wanted %d bytes; got %d: %q)", err, n, len(out), out) + return nil + } + e.Logf("peeked %d/%d bytes = %q", len(out), n, out) + return slices.Clone(out) +} + +//nolint:govet // We don't care about conforming to ReadRune() (rune, int, error). +func (e *Expecter) ReadRune(ctx context.Context) rune { + e.t.Helper() + + var r rune + err := e.doMatchWithDeadline(ctx, "ReadRune", func(rd *bufio.Reader) error { + var err error + r, _, err = rd.ReadRune() + return err + }) + if err != nil { + e.fatalf("read error", "%v (wanted rune; got %q)", err, r) + return 0 + } + e.Logf("matched rune = %q", r) + return r +} + +func (e *Expecter) ReadLine(ctx context.Context) string { + e.t.Helper() + + var buffer bytes.Buffer + err := e.doMatchWithDeadline(ctx, "ReadLine", func(rd *bufio.Reader) error { + for { + r, _, err := rd.ReadRune() + if err != nil { + return err + } + if r == '\n' { + return nil + } + if r == '\r' { + // Peek the next rune to see if it's an LF and then consume + // it. + + // Unicode code points can be up to 4 bytes, but the + // ones we're looking for are only 1 byte. + b, _ := rd.Peek(1) + if len(b) == 0 { + return nil + } + + r, _ = utf8.DecodeRune(b) + if r == '\n' { + _, _, err = rd.ReadRune() + if err != nil { + return err + } + } + + return nil + } + + _, err = buffer.WriteRune(r) + if err != nil { + return err + } + } + }) + if err != nil { + e.fatalf("read error", "%v (wanted newline; got %q)", err, buffer.String()) + return "" + } + e.Logf("matched newline = %q", buffer.String()) + return buffer.String() +} + +func (e *Expecter) ReadAll() []byte { + e.t.Helper() + return e.out.ReadAll() +} + +func (e *Expecter) doMatchWithDeadline(ctx context.Context, name string, fn func(*bufio.Reader) error) error { + e.t.Helper() + + // A timeout is mandatory, caller can decide by passing a context + // that times out. + if _, ok := ctx.Deadline(); !ok { + timeout := testutil.WaitMedium + e.Logf("%s ctx has no deadline, using %s", name, timeout) + var cancel context.CancelFunc + //nolint:gocritic // Rule guard doesn't detect that we're using testutil.Wait*. + ctx, cancel = context.WithTimeout(ctx, timeout) + defer cancel() + } + + match := make(chan error, 1) + go func() { + defer close(match) + match <- fn(e.runeReader) + }() + select { + case err := <-match: + return err + case <-ctx.Done(): + // Ensure goroutine is cleaned up before test exit, do not call + // (*outExpecter).close here to let the caller decide. + _ = e.out.Close() + <-match + + return xerrors.Errorf("match deadline exceeded: %w", ctx.Err()) + } +} + +func (e *Expecter) Logf(format string, args ...interface{}) { + e.t.Helper() + + // Match regular logger timestamp format, we seem to be logging in + // UTC in other places as well, so match here. + e.t.Logf("%s: %s: %s", time.Now().UTC().Format("2006-01-02 15:04:05.000"), e.name.Load(), fmt.Sprintf(format, args...)) +} + +func (e *Expecter) fatalf(reason string, format string, args ...interface{}) { + e.t.Helper() + + // Ensure the message is part of the normal log stream before + // failing the test. + e.Logf("%s: %s", reason, fmt.Sprintf(format, args...)) + + require.FailNowf(e.t, reason, format, args...) +} diff --git a/testutil/expecter/stdbuf.go b/testutil/expecter/stdbuf.go new file mode 100644 index 0000000000000..092f401d1eb22 --- /dev/null +++ b/testutil/expecter/stdbuf.go @@ -0,0 +1,119 @@ +package expecter + +import ( + "bytes" + "io" + "sync" + + "golang.org/x/xerrors" +) + +// stdbuf is like a buffered stdout, it buffers writes until read. +type stdbuf struct { + r io.Reader + + mu sync.Mutex // Protects following. + b []byte + more chan struct{} + err error +} + +func newStdbuf() *stdbuf { + return &stdbuf{more: make(chan struct{}, 1)} +} + +func (b *stdbuf) ReadAll() []byte { + b.mu.Lock() + defer b.mu.Unlock() + + if b.err != nil { + return nil + } + p := append([]byte(nil), b.b...) + b.b = b.b[len(b.b):] + return p +} + +func (b *stdbuf) Read(p []byte) (int, error) { + if b.r == nil { + return b.readOrWaitForMore(p) + } + + n, err := b.r.Read(p) + if xerrors.Is(err, io.EOF) { + b.r = nil + err = nil + if n == 0 { + return b.readOrWaitForMore(p) + } + } + return n, err +} + +func (b *stdbuf) readOrWaitForMore(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + + // Deplete channel so that more check + // is for future input into buffer. + select { + case <-b.more: + default: + } + + if len(b.b) == 0 { + if b.err != nil { + return 0, b.err + } + + b.mu.Unlock() + <-b.more + b.mu.Lock() + } + + b.r = bytes.NewReader(b.b) + b.b = b.b[len(b.b):] + + return b.r.Read(p) +} + +func (b *stdbuf) Write(p []byte) (int, error) { + if len(p) == 0 { + return 0, nil + } + + b.mu.Lock() + defer b.mu.Unlock() + + if b.err != nil { + return 0, b.err + } + + b.b = append(b.b, p...) + + select { + case b.more <- struct{}{}: + default: + } + + return len(p), nil +} + +func (b *stdbuf) Close() error { + return b.closeErr(nil) +} + +func (b *stdbuf) closeErr(err error) error { + b.mu.Lock() + defer b.mu.Unlock() + if b.err != nil { + return err + } + if err == nil { + b.err = io.EOF + } else { + b.err = err + } + close(b.more) + return err +} diff --git a/testutil/expecter/stdbuf_internal_test.go b/testutil/expecter/stdbuf_internal_test.go new file mode 100644 index 0000000000000..02365a8ff63e8 --- /dev/null +++ b/testutil/expecter/stdbuf_internal_test.go @@ -0,0 +1,37 @@ +package expecter + +import ( + "bytes" + "io" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestStdbuf(t *testing.T) { + t.Parallel() + + var got bytes.Buffer + + b := newStdbuf() + done := make(chan struct{}) + go func() { + defer close(done) + _, err := io.Copy(&got, b) + assert.NoError(t, err) + }() + + _, err := b.Write([]byte("hello ")) + require.NoError(t, err) + _, err = b.Write([]byte("world\n")) + require.NoError(t, err) + _, err = b.Write([]byte("bye\n")) + require.NoError(t, err) + + err = b.Close() + require.NoError(t, err) + <-done + + assert.Equal(t, "hello world\nbye\n", got.String()) +}