Merge branch 'main' into aqandrew/add-group-page-avatar

This commit is contained in:
Andrew Aquino
2026-06-01 12:18:45 -07:00
committed by GitHub
114 changed files with 2936 additions and 1327 deletions
+35
View File
@@ -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:
+76
View File
@@ -0,0 +1,76 @@
name: "Go cache"
description: Restore and save Go build and module caches.
inputs:
cache-path:
description: "Optional newline-delimited cache paths. Defaults to go env GOCACHE and GOMODCACHE."
required: false
default: ""
key-prefix:
description: "Prefix for the cache key."
required: false
default: "go"
download-modules:
description: "Whether to run go mod download after restoring cache."
required: false
default: "true"
runs:
using: "composite"
steps:
- name: Compute Go cache key
id: go-cache
shell: bash
run: |
set -euo pipefail
if [[ -n "${INPUT_CACHE_PATH}" ]]; then
paths="${INPUT_CACHE_PATH}"
else
paths="$(printf '%s\n%s' "$(go env GOCACHE)" "$(go env GOMODCACHE)")"
fi
go_version="$(go env GOVERSION)"
paths_hash="$(printf '%s\n' "${paths}" | git hash-object --stdin)"
hash="$(
{
printf '%s\n' "${go_version}"
for file in go.mod go.sum; do
if [[ -f "${file}" ]]; then
git hash-object "${file}"
fi
done
} | git hash-object --stdin
)"
{
echo "path<<EOF"
echo "${paths}"
echo "EOF"
echo "key=${INPUT_KEY_PREFIX}-${RUNNER_OS}-${RUNNER_ARCH}-${paths_hash}-${hash}"
echo "restore-key=${INPUT_KEY_PREFIX}-${RUNNER_OS}-${RUNNER_ARCH}-${paths_hash}-"
} >> "$GITHUB_OUTPUT"
env:
INPUT_CACHE_PATH: ${{ inputs.cache-path }}
INPUT_KEY_PREFIX: ${{ inputs.key-prefix }}
- name: Restore Go cache, save on main
if: ${{ github.ref == 'refs/heads/main' }}
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ${{ steps.go-cache.outputs.path }}
key: ${{ steps.go-cache.outputs.key }}
restore-keys: |
${{ steps.go-cache.outputs.restore-key }}
- name: Restore Go cache read-only
if: ${{ github.ref != 'refs/heads/main' }}
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ${{ steps.go-cache.outputs.path }}
key: ${{ steps.go-cache.outputs.key }}
restore-keys: |
${{ steps.go-cache.outputs.restore-key }}
- name: Download Go modules
if: ${{ inputs.download-modules == 'true' }}
shell: bash
run: ./.github/scripts/retry.sh -- go mod download -x
@@ -1,10 +0,0 @@
name: "Install cosign"
description: |
Cosign Github Action.
runs:
using: "composite"
steps:
- name: Install cosign
uses: sigstore/cosign-installer@d7d6bc7722e3daa8354c50bcb52f4837da5e9b6a # v3.8.1
with:
cosign-release: "v2.4.3"
-10
View File
@@ -1,10 +0,0 @@
name: "Install syft"
description: |
Downloads Syft to the Action tool cache and provides a reference.
runs:
using: "composite"
steps:
- name: Install syft
uses: anchore/sbom-action/download-syft@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0
with:
syft-version: "v1.26.1"
+59
View File
@@ -0,0 +1,59 @@
name: "pnpm install"
description: Restore pnpm store cache and install root plus workspace dependencies.
inputs:
directory:
description: "Workspace directory to install after the repository root."
required: false
default: "site"
runs:
using: "composite"
steps:
- name: Compute pnpm cache key
id: pnpm-cache
shell: bash
run: |
set -euo pipefail
store_path="$(pnpm store path --silent)"
hash="$(
for file in pnpm-lock.yaml "${INPUT_DIRECTORY}/pnpm-lock.yaml"; do
if [[ -f "${file}" ]]; then
git hash-object "${file}"
fi
done | git hash-object --stdin
)"
{
echo "store-path=${store_path}"
echo "key=pnpm-${RUNNER_OS}-${RUNNER_ARCH}-${INPUT_DIRECTORY}-${hash}"
echo "restore-key=pnpm-${RUNNER_OS}-${RUNNER_ARCH}-${INPUT_DIRECTORY}-"
} >> "$GITHUB_OUTPUT"
env:
INPUT_DIRECTORY: ${{ inputs.directory }}
- name: Restore and save pnpm cache
if: ${{ github.ref == 'refs/heads/main' }}
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ${{ steps.pnpm-cache.outputs.store-path }}
key: ${{ steps.pnpm-cache.outputs.key }}
restore-keys: |
${{ steps.pnpm-cache.outputs.restore-key }}
- name: Restore pnpm cache
if: ${{ github.ref != 'refs/heads/main' }}
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ${{ steps.pnpm-cache.outputs.store-path }}
key: ${{ steps.pnpm-cache.outputs.key }}
restore-keys: |
${{ steps.pnpm-cache.outputs.restore-key }}
- name: Install root node_modules
shell: bash
run: ./scripts/pnpm_install.sh
- name: Install node_modules
shell: bash
run: "${GITHUB_WORKSPACE}/scripts/pnpm_install.sh"
working-directory: ${{ github.workspace }}/${{ inputs.directory }}
@@ -1,12 +0,0 @@
name: "Setup Go tools"
description: |
Set up tools for `make gen`, `offlinedocs` and Schmoder CI.
runs:
using: "composite"
steps:
- name: go install tools
shell: bash
run: |
./.github/scripts/retry.sh -- go install tool
# NOTE: protoc-gen-go cannot be installed with `go get`
./.github/scripts/retry.sh -- go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.30
-32
View File
@@ -1,32 +0,0 @@
name: "Setup Go"
description: |
Sets up the Go environment for tests, builds, etc.
inputs:
version:
description: "The Go version to use."
default: "1.26.2"
use-cache:
description: "Whether to use the cache."
default: "true"
runs:
using: "composite"
steps:
- name: Setup Go
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0
with:
go-version: ${{ inputs.version }}
cache: ${{ inputs.use-cache }}
- name: Install gotestsum
shell: bash
run: ./.github/scripts/retry.sh -- go install gotest.tools/gotestsum@0d9599e513d70e5792bb9334869f82f6e8b53d4d # main as of 2025-05-15
- name: Install mtimehash
shell: bash
run: ./.github/scripts/retry.sh -- go install github.com/slsyy/mtimehash/cmd/mtimehash@a6b5da4ed2c4a40e7b805534b004e9fde7b53ce0 # v1.0.0
# It isn't necessary that we ever do this, but it helps
# separate the "setup" from the "run" times.
- name: go mod download
shell: bash
run: ./.github/scripts/retry.sh -- go mod download -x
+168
View File
@@ -0,0 +1,168 @@
name: Setup mise
description: Install mise tools from SHA256-pinned binaries, with CI-layer caching.
inputs:
install-args:
description: Tool names or extra arguments passed to mise install. --locked is added by default.
required: false
default: ""
locked:
description: Whether to pass --locked to mise install.
required: false
default: "true"
cache-key-prefix:
description: Prefix for mise tool cache keys.
required: false
default: mise-ci-v1
mise-version:
description: mise version to install.
required: false
default: "2026.5.12"
mise-sha256:
description: SHA256 checksum for the mise binary.
required: false
default: ""
use-cache:
description: Whether to restore and save mise tool caches.
required: false
default: "true"
runs:
using: composite
steps:
- name: Compute mise cache key
id: cache-key
shell: bash
env:
CACHE_KEY_PREFIX: ${{ inputs.cache-key-prefix }}
INPUT_INSTALL_ARGS: ${{ inputs.install-args }}
INPUT_LOCKED: ${{ inputs.locked }}
MISE_VERSION: ${{ inputs.mise-version }}
RUNNER_ARCH: ${{ runner.arch }}
RUNNER_OS: ${{ runner.os }}
run: |
set -euo pipefail
case "${INPUT_LOCKED}" in
true)
if [[ -n "${INPUT_INSTALL_ARGS}" ]]; then
install_args="--locked ${INPUT_INSTALL_ARGS}"
else
install_args="--locked"
fi
;;
false)
install_args="${INPUT_INSTALL_ARGS}"
;;
*)
echo "::error::locked must be true or false."
exit 1
;;
esac
install_args_hash="$(printf '%s' "$install_args" | git hash-object --stdin)"
files_hash="$(git hash-object mise.toml mise.lock | git hash-object --stdin)"
key="${CACHE_KEY_PREFIX}-${RUNNER_OS}-${RUNNER_ARCH}-${MISE_VERSION}-${install_args_hash}-${files_hash}"
restore_key="${CACHE_KEY_PREFIX}-${RUNNER_OS}-${RUNNER_ARCH}-${MISE_VERSION}-${install_args_hash}-"
{
echo "install-args<<EOF"
echo "${install_args}"
echo "EOF"
echo "key=$key"
echo "restore-key=$restore_key"
} >> "$GITHUB_OUTPUT"
- name: Select mise checksum
id: checksum
shell: bash
env:
CHECKSUMS_FILE: ${{ github.action_path }}/checksums.toml
INPUT_MISE_SHA256: ${{ inputs.mise-sha256 }}
MISE_CHECKSUM_SCRIPT: ${{ github.workspace }}/scripts/mise_checksum.sh
MISE_VERSION: ${{ inputs.mise-version }}
RUNNER_ARCH: ${{ runner.arch }}
RUNNER_OS: ${{ runner.os }}
run: |
set -euo pipefail
checksum="${INPUT_MISE_SHA256}"
if [[ -z "${checksum}" ]]; then
case "${RUNNER_OS}-${RUNNER_ARCH}" in
Linux-X64)
target="linux-x64"
;;
Linux-ARM64)
target="linux-arm64"
;;
macOS-X64)
target="macos-x64"
;;
macOS-ARM64)
target="macos-arm64"
;;
Windows-X64)
target="windows-x64"
;;
*)
echo "::error::No mise checksum is pinned for ${RUNNER_OS}-${RUNNER_ARCH}."
exit 1
;;
esac
checksum="$("${MISE_CHECKSUM_SCRIPT}" "${CHECKSUMS_FILE}" "${MISE_VERSION}" "${target}")"
if [[ -z "${checksum}" ]]; then
echo "::error::No mise checksum is pinned for mise ${MISE_VERSION} on ${target}."
exit 1
fi
fi
echo "sha256=${checksum}" >> "$GITHUB_OUTPUT"
- name: Configure mise data directory
id: mise-data-dir
shell: bash
env:
RUNNER_OS: ${{ runner.os }}
run: | # zizmor: ignore[github-env] MISE_DATA_DIR uses only runner-provided paths.
set -euo pipefail
if [[ "${RUNNER_OS}" == "Windows" ]]; then
data_dir="${LOCALAPPDATA:-${USERPROFILE}\\AppData\\Local}\\mise"
else
data_dir="${RUNNER_TEMP}/mise-data"
fi
{
printf 'path=%s\n' "${data_dir}"
} >> "$GITHUB_OUTPUT"
printf 'MISE_DATA_DIR=%s\n' "${data_dir}" >> "$GITHUB_ENV"
- name: Cache mise tools
if: ${{ inputs.use-cache == 'true' && github.ref == 'refs/heads/main' }}
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/.cache/mise
${{ steps.mise-data-dir.outputs.path }}
key: ${{ steps.cache-key.outputs.key }}
restore-keys: |
${{ steps.cache-key.outputs.restore-key }}
- name: Restore mise tools
if: ${{ inputs.use-cache == 'true' && github.ref != 'refs/heads/main' }}
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/.cache/mise
${{ steps.mise-data-dir.outputs.path }}
key: ${{ steps.cache-key.outputs.key }}
restore-keys: |
${{ steps.cache-key.outputs.restore-key }}
- name: Install mise tools
uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4.0.1
with:
version: ${{ inputs.mise-version }}
sha256: ${{ steps.checksum.outputs.sha256 }}
mise_dir: ${{ steps.mise-data-dir.outputs.path }}
install_args: ${{ steps.cache-key.outputs.install-args }}
cache: "false"
@@ -0,0 +1,9 @@
# SHA256 hashes of the extracted mise binary verified by jdx/mise-action.
# Keys use the GitHub runner target for each release artifact.
["2026.5.12"]
linux-x64 = "a238972a3162d710b85b28c324372e96ca4e4b486c81fe78695000d9fbc77c48"
linux-arm64 = "fd2d5227a8ad0b1e359c70527a8345a9ada72077f8dcbb559371653c3d95464f"
macos-x64 = "de57e8dc82bbd880a69c9bc8aee06b9dcc578184b3e5cf86fcef80635d6a90b4"
macos-arm64 = "e777070540ffe22cf8b2b9f88aed88b461d0887d940c4f1c1a97359463cde6e1"
windows-x64 = "adf1b4c9f51e7d15cff723056fcd8fd51f40ebacadcca97fd5758c44d469d5ea"
-44
View File
@@ -1,44 +0,0 @@
name: "Setup Node"
description: |
Sets up the node environment for tests, builds, etc.
inputs:
directory:
description: |
The directory to run the setup in.
required: false
default: "site"
runs:
using: "composite"
steps:
- name: Install pnpm
uses: pnpm/action-setup@739bfe42ca9233c5e6aca07c1a25a9d34aca49b0 # v6.0.7
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22.19.0
# See https://github.com/actions/setup-node#caching-global-packages-data
cache: "pnpm"
cache-dependency-path: ${{ inputs.directory }}/pnpm-lock.yaml
- name: Verify Node
shell: bash
run: |
set -euo pipefail
expected="v22.19.0"
actual="$(node --version)"
if [[ "$actual" != "$expected" ]]; then
echo "::error::Expected Node.js $expected, but got $actual from $(command -v node)."
exit 1
fi
echo "Node.js $actual is active at $(command -v node)."
- name: Install root node_modules
shell: bash
run: ./scripts/pnpm_install.sh
- name: Install node_modules
shell: bash
run: ../scripts/pnpm_install.sh
working-directory: ${{ inputs.directory }}
-17
View File
@@ -1,17 +0,0 @@
name: Setup sqlc
description: |
Sets up the sqlc environment for tests, builds, etc.
runs:
using: "composite"
steps:
- name: Setup sqlc
# uses: sqlc-dev/setup-sqlc@c0209b9199cd1cce6a14fc27cabcec491b651761 # v4.0.0
# with:
# sqlc-version: "1.30.0"
# Switched to coder/sqlc fork to fix ambiguous column bug, see:
# - https://github.com/coder/sqlc/pull/1
# - https://github.com/sqlc-dev/sqlc/pull/4159
shell: bash
run: |
./.github/scripts/retry.sh -- env CGO_ENABLED=1 go install github.com/coder/sqlc/cmd/sqlc@337309bfb9524f38466a5090e310040fc7af0203
-11
View File
@@ -1,11 +0,0 @@
name: "Setup Terraform"
description: |
Sets up Terraform for tests, builds, etc.
runs:
using: "composite"
steps:
- name: Install Terraform
uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2
with:
terraform_version: 1.15.5
terraform_wrapper: false
+155 -149
View File
@@ -151,8 +151,13 @@ jobs:
fetch-depth: 1
persist-credentials: false
- name: Setup Node
uses: ./.github/actions/setup-node
- name: Set up mise tools
uses: ./.github/actions/setup-mise
with:
install-args: "node pnpm"
- name: Install pnpm dependencies
uses: ./.github/actions/pnpm-install
- name: Check docs
run: pnpm check-docs
@@ -171,8 +176,10 @@ jobs:
# # See: https://github.com/stefanzweifel/git-auto-commit-action?tab=readme-ov-file#commits-made-by-this-action-do-not-trigger-new-workflow-runs
# token: ${{ secrets.CDRCI_GITHUB_TOKEN }}
# - name: Setup Go
# uses: ./.github/actions/setup-go
# - name: Set up mise tools
# uses: ./.github/actions/setup-mise
# with:
# install-args: "go"
# - name: Update Nix Flake SRI Hash
# run: ./scripts/update-flake.sh
@@ -208,18 +215,22 @@ jobs:
fetch-depth: 1
persist-credentials: false
- name: Setup Node
uses: ./.github/actions/setup-node
- name: Set up mise tools
uses: ./.github/actions/setup-mise
with:
install-args: "go node pnpm helm actionlint aqua:crate-ci/typos"
- name: Setup Go
uses: ./.github/actions/setup-go
- name: Install pnpm dependencies
uses: ./.github/actions/pnpm-install
- name: Restore Go cache
uses: ./.github/actions/go-cache
- name: Install Go mise tools
run: ./.github/scripts/retry.sh -- mise install --locked go:github.com/golangci/golangci-lint/cmd/golangci-lint go:github.com/coder/paralleltestctx/cmd/paralleltestctx
- name: Get golangci-lint cache dir
run: |
# mise.toml is the source of truth for tool versions baked into
# the dogfood image; pull the same version for the lint job.
linter_ver=$(grep -Eo '^golangci-lint = "[^"]+"' mise.toml | sed -E 's/.*"([^"]+)"/\1/')
./.github/scripts/retry.sh -- go install "github.com/golangci/golangci-lint/cmd/golangci-lint@v$linter_ver"
dir=$(golangci-lint cache status | awk '/Dir/ { print $2 }')
echo "LINT_CACHE_DIR=$dir" >> "$GITHUB_ENV"
@@ -239,35 +250,13 @@ jobs:
# Check for any typos
- name: Check for typos
uses: crate-ci/typos@cf5f1c29a8ac336af8568821ec41919923b05a83 # v1.45.1
with:
config: .github/workflows/typos.toml
run: typos --config .github/workflows/typos.toml
- name: Fix the typos
if: ${{ failure() }}
run: |
echo "::notice:: you can automatically fix typos from your CLI:
cargo install typos-cli
typos -c .github/workflows/typos.toml -w"
# Needed for helm chart linting
- name: Install helm
uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # v5.0.0
with:
version: v3.9.2
continue-on-error: true
id: setup-helm
- name: Install helm (fallback)
if: steps.setup-helm.outcome == 'failure'
# Fallback to Buildkite's apt repository if get.helm.sh is down.
# See: https://github.com/coder/internal/issues/1109
run: |
set -euo pipefail
curl -fsSL https://packages.buildkite.com/helm-linux/helm-debian/gpgkey | gpg --dearmor | sudo tee /usr/share/keyrings/helm.gpg > /dev/null
echo "deb [signed-by=/usr/share/keyrings/helm.gpg] https://packages.buildkite.com/helm-linux/helm-debian/any/ any main" | sudo tee /etc/apt/sources.list.d/helm-stable-debian.list
sudo apt-get update
sudo apt-get install -y helm=3.9.2-1
mise exec aqua:crate-ci/typos -- typos -c .github/workflows/typos.toml -w"
- name: Verify helm version
run: helm version --short
@@ -287,15 +276,11 @@ jobs:
key: ${{ steps.golangci-lint-cache.outputs.cache-primary-key }}
- name: Check workflow files
run: |
bash <(curl https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash) 1.7.4
./actionlint -color -shellcheck= -ignore "set-output"
run: actionlint -color -shellcheck= -ignore "set-output"
shell: bash
- name: Check for unstaged files
run: |
rm -f ./actionlint ./typos
./scripts/check_unstaged.sh
run: ./scripts/check_unstaged.sh
shell: bash
lint-actions:
@@ -303,7 +288,7 @@ jobs:
# Only run this job if changes to CI workflow files are detected. This job
# can flake as it reaches out to GitHub to check referenced actions.
if: needs.changes.outputs.ci == 'true'
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-24.04-8' || 'ubuntu-24.04' }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2.17.0
@@ -316,8 +301,10 @@ jobs:
fetch-depth: 1
persist-credentials: false
- name: Setup Go
uses: ./.github/actions/setup-go
- name: Set up mise tools
uses: ./.github/actions/setup-mise
with:
install-args: "actionlint zizmor"
- name: make lint/actions
run: make --output-sync=line -j lint/actions
@@ -341,30 +328,19 @@ jobs:
fetch-depth: 1
persist-credentials: false
- name: Setup Node
uses: ./.github/actions/setup-node
- name: Set up mise tools
uses: ./.github/actions/setup-mise
with:
install-args: "go node pnpm terraform protoc protoc-gen-go"
- name: Setup Go
uses: ./.github/actions/setup-go
- name: Install pnpm dependencies
uses: ./.github/actions/pnpm-install
- name: Setup sqlc
uses: ./.github/actions/setup-sqlc
- name: Restore Go cache
uses: ./.github/actions/go-cache
- name: Setup Terraform
uses: ./.github/actions/setup-tf
- name: go install tools
uses: ./.github/actions/setup-go-tools
- name: Install Protoc
run: |
mkdir -p /tmp/proto
pushd /tmp/proto
curl -L -o protoc.zip https://github.com/protocolbuffers/protobuf/releases/download/v23.4/protoc-23.4-linux-x86_64.zip
unzip protoc.zip
sudo cp -r ./bin/* /usr/local/bin
sudo cp -r ./include /usr/local/bin/include
popd
- name: Install Go mise tools
run: ./.github/scripts/retry.sh -- mise install --locked go:storj.io/drpc/cmd/protoc-gen-go-drpc go:github.com/coder/sqlc/cmd/sqlc
- name: make gen
timeout-minutes: 8
@@ -396,24 +372,26 @@ jobs:
fetch-depth: 1
persist-credentials: false
- name: Setup Node
uses: ./.github/actions/setup-node
- name: Check Go version
run: IGNORE_NIX=true ./scripts/check_go_versions.sh
# Use default Go version
- name: Setup Go
uses: ./.github/actions/setup-go
- name: Set up mise tools
uses: ./.github/actions/setup-mise
with:
install-args: "go node pnpm terraform"
- name: Install shfmt
run: ./.github/scripts/retry.sh -- go install mvdan.cc/sh/v3/cmd/shfmt@v3.7.0
- name: Install pnpm dependencies
uses: ./.github/actions/pnpm-install
- name: Restore Go cache
uses: ./.github/actions/go-cache
- name: Install Go mise tools
run: ./.github/scripts/retry.sh -- mise install --locked go:mvdan.cc/sh/v3/cmd/shfmt
- name: make fmt
timeout-minutes: 7
run: |
PATH="${PATH}:$(go env GOPATH)/bin" \
make --output-sync -j -B fmt
run: make --output-sync -j -B fmt
- name: Check for unstaged files
run: ./scripts/check_unstaged.sh
@@ -476,13 +454,18 @@ jobs:
- name: Setup GNU tools (macOS)
uses: ./.github/actions/setup-gnu-tools
- name: Setup Go
uses: ./.github/actions/setup-go
- name: Set up mise tools
uses: ./.github/actions/setup-mise
with:
use-cache: true
install-args: "go terraform"
- name: Setup Terraform
uses: ./.github/actions/setup-tf
- name: Restore Go cache
uses: ./.github/actions/go-cache
with:
cache-path: ${{ steps.go-paths.outputs.cached-dirs }}
- name: Install Go mise tools
run: ./.github/scripts/retry.sh -- mise install --locked go:gotest.tools/gotestsum go:github.com/slsyy/mtimehash/cmd/mtimehash
- name: Download Test Cache
id: download-cache
@@ -651,11 +634,16 @@ jobs:
fetch-depth: 1
persist-credentials: false
- name: Setup Go
uses: ./.github/actions/setup-go
- name: Set up mise tools
uses: ./.github/actions/setup-mise
with:
install-args: "go terraform"
- name: Setup Terraform
uses: ./.github/actions/setup-tf
- name: Restore Go cache
uses: ./.github/actions/go-cache
- name: Install Go mise tools
run: ./.github/scripts/retry.sh -- mise install --locked go:gotest.tools/gotestsum
- name: Download Test Cache
id: download-cache
@@ -720,11 +708,16 @@ jobs:
fetch-depth: 1
persist-credentials: false
- name: Setup Go
uses: ./.github/actions/setup-go
- name: Set up mise tools
uses: ./.github/actions/setup-mise
with:
install-args: "go terraform"
- name: Setup Terraform
uses: ./.github/actions/setup-tf
- name: Restore Go cache
uses: ./.github/actions/go-cache
- name: Install Go mise tools
run: ./.github/scripts/retry.sh -- mise install --locked go:gotest.tools/gotestsum
- name: Download Test Cache
id: download-cache
@@ -799,8 +792,13 @@ jobs:
fetch-depth: 1
persist-credentials: false
- name: Setup Go
uses: ./.github/actions/setup-go
- name: Set up mise tools
uses: ./.github/actions/setup-mise
with:
install-args: "go"
- name: Restore Go cache
uses: ./.github/actions/go-cache
# Used by some integration tests.
- name: Install Nginx
@@ -826,8 +824,13 @@ jobs:
fetch-depth: 1
persist-credentials: false
- name: Setup Node
uses: ./.github/actions/setup-node
- name: Set up mise tools
uses: ./.github/actions/setup-mise
with:
install-args: "node pnpm"
- name: Install pnpm dependencies
uses: ./.github/actions/pnpm-install
- run: pnpm test:ci --max-workers "$(nproc)"
working-directory: site
@@ -859,11 +862,16 @@ jobs:
fetch-depth: 1
persist-credentials: false
- name: Setup Node
uses: ./.github/actions/setup-node
- name: Set up mise tools
uses: ./.github/actions/setup-mise
with:
install-args: "go node pnpm"
- name: Setup Go
uses: ./.github/actions/setup-go
- name: Install pnpm dependencies
uses: ./.github/actions/pnpm-install
- name: Restore Go cache
uses: ./.github/actions/go-cache
# Assume that the checked-in versions are up-to-date
- run: make gen/mark-fresh
@@ -951,8 +959,13 @@ jobs:
ref: ${{ github.event.pull_request.head.ref }}
persist-credentials: false
- name: Setup Node
uses: ./.github/actions/setup-node
- name: Set up mise tools
uses: ./.github/actions/setup-mise
with:
install-args: "node pnpm"
- name: Install pnpm dependencies
uses: ./.github/actions/pnpm-install
# This step is not meant for mainline because any detected changes to
# storybook snapshots will require manual approval/review in order for
@@ -1030,29 +1043,21 @@ jobs:
fetch-depth: 0
persist-credentials: false
- name: Setup Node
uses: ./.github/actions/setup-node
- name: Set up mise tools
uses: ./.github/actions/setup-mise
with:
install-args: "go node pnpm protoc protoc-gen-go"
- name: Install pnpm dependencies
uses: ./.github/actions/pnpm-install
with:
directory: offlinedocs
- name: Install Protoc
run: |
mkdir -p /tmp/proto
pushd /tmp/proto
curl -L -o protoc.zip https://github.com/protocolbuffers/protobuf/releases/download/v23.4/protoc-23.4-linux-x86_64.zip
unzip protoc.zip
sudo cp -r ./bin/* /usr/local/bin
sudo cp -r ./include /usr/local/bin/include
popd
- name: Restore Go cache
uses: ./.github/actions/go-cache
- name: Setup Go
uses: ./.github/actions/setup-go
- name: Install go tools
uses: ./.github/actions/setup-go-tools
- name: Setup sqlc
uses: ./.github/actions/setup-sqlc
- name: Install Go mise tools
run: ./.github/scripts/retry.sh -- mise install --locked go:storj.io/drpc/cmd/protoc-gen-go-drpc go:github.com/coder/sqlc/cmd/sqlc
- name: Format
run: |
@@ -1144,17 +1149,19 @@ jobs:
fetch-depth: 0
persist-credentials: false
- name: Setup Node
uses: ./.github/actions/setup-node
- name: Set up mise tools
uses: ./.github/actions/setup-mise
with:
install-args: "go node pnpm"
- name: Setup Go
uses: ./.github/actions/setup-go
- name: Install pnpm dependencies
uses: ./.github/actions/pnpm-install
- name: Install go-winres
run: ./.github/scripts/retry.sh -- go install github.com/tc-hib/go-winres@d743268d7ea168077ddd443c4240562d4f5e8c3e # v0.3.3
- name: Restore Go cache
uses: ./.github/actions/go-cache
- name: Install nfpm
run: ./.github/scripts/retry.sh -- go install github.com/goreleaser/nfpm/v2/cmd/nfpm@v2.35.1
- name: Install Go mise tools
run: ./.github/scripts/retry.sh -- mise install --locked go:github.com/tc-hib/go-winres go:github.com/goreleaser/nfpm/v2/cmd/nfpm
- name: Install zstd
run: sudo apt-get install -y zstd
@@ -1205,13 +1212,19 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Node
uses: ./.github/actions/setup-node
- name: Setup Go
uses: ./.github/actions/setup-go
- name: Set up mise tools
uses: ./.github/actions/setup-mise
with:
use-cache: false
install-args: "go node pnpm cosign syft"
- name: Install pnpm dependencies
uses: ./.github/actions/pnpm-install
- name: Restore Go cache
uses: ./.github/actions/go-cache
- name: Install Go mise tools
run: ./.github/scripts/retry.sh -- mise install --locked go:github.com/tc-hib/go-winres go:github.com/goreleaser/nfpm/v2/cmd/nfpm
- name: Install rcodesign
run: |
@@ -1241,21 +1254,9 @@ jobs:
distribution: "zulu"
java-version: "11.0"
- name: Install go-winres
run: ./.github/scripts/retry.sh -- go install github.com/tc-hib/go-winres@d743268d7ea168077ddd443c4240562d4f5e8c3e # v0.3.3
- name: Install nfpm
run: ./.github/scripts/retry.sh -- go install github.com/goreleaser/nfpm/v2/cmd/nfpm@v2.35.1
- name: Install zstd
run: sudo apt-get install -y zstd
- name: Install cosign
uses: ./.github/actions/install-cosign
- name: Install syft
uses: ./.github/actions/install-syft
- name: Setup Windows EV Signing Certificate
run: |
set -euo pipefail
@@ -1579,11 +1580,16 @@ jobs:
with:
fetch-depth: 1
persist-credentials: false
- name: Setup Go
uses: ./.github/actions/setup-go
- name: Set up mise tools
uses: ./.github/actions/setup-mise
with:
install-args: "go"
- name: Setup sqlc
uses: ./.github/actions/setup-sqlc
- name: Restore Go cache
uses: ./.github/actions/go-cache
- name: Install Go mise tools
run: ./.github/scripts/retry.sh -- mise install --locked go:github.com/coder/sqlc/cmd/sqlc
- name: Setup and run sqlc vet
run: |
+7 -50
View File
@@ -71,9 +71,6 @@ jobs:
packages: write # push the dogfood base image to ghcr.io/coder/oss-dogfood-base
env:
# MISE_EXPERIMENTAL opts into the experimental `oci` subcommand.
# Trust is set via a config file (see the Install mise step
# below) rather than MISE_TRUSTED_CONFIG_PATHS so the workspace
# template can keep parity with the same file-based approach.
MISE_EXPERIMENTAL: "1"
steps:
- name: Harden Runner
@@ -135,32 +132,9 @@ jobs:
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
if: matrix.image-version != 'nix'
- name: Install mise
if: matrix.image-version != 'nix'
# MISE_VERSION + MISE_SHA256 match dogfood/coder/ubuntu-*/Dockerfile.base
# so the mise binary baking the image is the same one a workspace
# ships with. `min_version` in mise.toml catches downgrades.
# Write trust config to ~/.config/mise/conf.d/ instead of using
# MISE_TRUSTED_CONFIG_PATHS so the same file-based approach
# works in workspaces (where the user owns the file).
env:
MISE_VERSION: v2026.5.12
MISE_SHA256: a238972a3162d710b85b28c324372e96ca4e4b486c81fe78695000d9fbc77c48
WORKSPACE: ${{ github.workspace }}
run: |
set -euo pipefail
curl --silent --show-error --location --fail \
"https://github.com/jdx/mise/releases/download/${MISE_VERSION}/mise-${MISE_VERSION}-linux-x64" \
--output /tmp/mise
echo "${MISE_SHA256} /tmp/mise" | sha256sum -c
sudo install -m 0755 /tmp/mise /usr/local/bin/mise
rm /tmp/mise
mise --version
mkdir -p "$HOME/.config/mise/conf.d"
cat > "$HOME/.config/mise/conf.d/00-ci-trust.toml" <<EOF
[settings]
trusted_config_paths = ["$WORKSPACE"]
EOF
- name: Set up mise tools
if: matrix.image-version != 'nix' && !github.event.pull_request.head.repo.fork
uses: ./.github/actions/setup-mise
- name: Compute image SHAs
# Match the fork guard on the downstream consumers of these
@@ -216,25 +190,6 @@ jobs:
ghcr.io/coder/oss-dogfood-base:${{ matrix.image-version }}-${{ steps.shas.outputs.base_sha }}
ghcr.io/coder/oss-dogfood-base:${{ matrix.image-version }}-${{ steps.docker-tag-name.outputs.tag }}
- name: Install mise tools
if: matrix.image-version != 'nix' && !github.event.pull_request.head.repo.fork
# `mise oci build` packages already-installed tools into OCI
# layers; it does not install them. Run `mise install` first so
# the tools land in MISE_DATA_DIR on the runner.
# github_token raises aqua's API quota during tool installs.
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# --locked refuses to resolve URLs at install time and forces
# the runner to consume what mise.lock already committed,
# so a forgotten lockfile entry fails CI instead of silently
# being added on next run.
mise install --yes --locked
# Put mise's shims dir on PATH for subsequent steps so
# `mise oci push --tool crane` can find crane (and any other
# mise-managed binary it shells out to).
echo "$HOME/.local/share/mise/shims" >> "$GITHUB_PATH"
- name: Build mise oci layer
if: matrix.image-version != 'nix' && !github.event.pull_request.head.repo.fork
env:
@@ -360,8 +315,10 @@ jobs:
with:
persist-credentials: false
- name: Setup Terraform
uses: ./.github/actions/setup-tf
- name: Set up mise tools
uses: ./.github/actions/setup-mise
with:
install-args: "terraform"
- name: Authenticate to Google Cloud
uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3.0.0
+13 -7
View File
@@ -39,12 +39,16 @@ jobs:
fetch-depth: 0
persist-credentials: false
- name: Setup Go
uses: ./.github/actions/setup-go
- name: Set up Go
uses: ./.github/actions/setup-mise
with:
install-args: "go"
- name: Install whichtests
shell: bash
run: ./.github/scripts/retry.sh -- go install github.com/coder/whichtests@ec33bab1ec04cd86beb7a61a069db4463dba63f5
- name: Restore Go cache
uses: ./.github/actions/go-cache
- name: Install Go mise tools
run: ./.github/scripts/retry.sh -- mise install --locked go:github.com/coder/whichtests
- name: Select changed tests
id: selector
@@ -57,9 +61,11 @@ jobs:
--coalesce \
--out-matrix "$RUNNER_TEMP/flake-matrix.json"
- name: Setup Terraform
- name: Set up Terraform
if: ${{ fromJSON(steps.selector.outputs.matrix).include[0] != null }}
uses: ./.github/actions/setup-tf
uses: ./.github/actions/setup-mise
with:
install-args: "terraform"
- name: Run targeted Go flake checks
id: flake_check
+9 -4
View File
@@ -62,11 +62,16 @@ jobs:
- name: Setup GNU tools (macOS)
uses: ./.github/actions/setup-gnu-tools
- name: Setup Go
uses: ./.github/actions/setup-go
- name: Set up mise tools
uses: ./.github/actions/setup-mise
with:
install-args: "go terraform"
- name: Setup Terraform
uses: ./.github/actions/setup-tf
- name: Restore Go cache
uses: ./.github/actions/go-cache
- name: Install Go mise tools
run: ./.github/scripts/retry.sh -- mise install --locked go:gotest.tools/gotestsum
- name: Setup Embedded Postgres Cache Paths
id: embedded-pg-cache
+11 -6
View File
@@ -238,14 +238,19 @@ jobs:
fetch-depth: 0
persist-credentials: false
- name: Setup Node
uses: ./.github/actions/setup-node
- name: Set up mise tools
uses: ./.github/actions/setup-mise
with:
install-args: "go node pnpm"
- name: Setup Go
uses: ./.github/actions/setup-go
- name: Install pnpm dependencies
uses: ./.github/actions/pnpm-install
- name: Setup sqlc
uses: ./.github/actions/setup-sqlc
- name: Restore Go cache
uses: ./.github/actions/go-cache
- name: Install Go mise tools
run: ./.github/scripts/retry.sh -- mise install --locked go:github.com/coder/sqlc/cmd/sqlc
- name: GHCR Login
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
+8 -21
View File
@@ -172,13 +172,16 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Go
uses: ./.github/actions/setup-go
- name: Set up mise tools
uses: ./.github/actions/setup-mise
with:
use-cache: false
install-args: "go node pnpm helm cosign syft"
- name: Setup Node
uses: ./.github/actions/setup-node
- name: Install pnpm dependencies
uses: ./.github/actions/pnpm-install
- name: Install Go mise tools
run: ./.github/scripts/retry.sh -- mise install --locked go:github.com/tc-hib/go-winres go:github.com/goreleaser/nfpm/v2/cmd/nfpm
# Necessary for signing Windows binaries.
- name: Setup Java
@@ -187,19 +190,9 @@ jobs:
distribution: "zulu"
java-version: "11.0"
- name: Install go-winres
run: ./.github/scripts/retry.sh -- go install github.com/tc-hib/go-winres@d743268d7ea168077ddd443c4240562d4f5e8c3e # v0.3.3
- name: Install nsis and zstd
run: sudo apt-get install -y nsis zstd
- name: Install nfpm
run: |
set -euo pipefail
wget -O /tmp/nfpm.deb https://github.com/goreleaser/nfpm/releases/download/v2.35.1/nfpm_2.35.1_amd64.deb
sudo dpkg -i /tmp/nfpm.deb
rm /tmp/nfpm.deb
- name: Install rcodesign
run: |
set -euo pipefail
@@ -210,12 +203,6 @@ jobs:
apple-codesign-0.22.0-x86_64-unknown-linux-musl/rcodesign
rm /tmp/rcodesign.tar.gz
- name: Install cosign
uses: ./.github/actions/install-cosign
- name: Install syft
uses: ./.github/actions/install-syft
- name: Setup Apple Developer certificate and API key
run: |
set -euo pipefail
+7 -2
View File
@@ -36,8 +36,13 @@ jobs:
with:
persist-credentials: false
- name: Setup Go
uses: ./.github/actions/setup-go
- name: Set up mise tools
uses: ./.github/actions/setup-mise
with:
install-args: "go"
- name: Restore Go cache
uses: ./.github/actions/go-cache
- name: Initialize CodeQL
uses: github/codeql-action/init@c10b8064de6f491fea524254123dbe5e09572f13 # v3.29.5
+59 -1
View File
@@ -14,7 +14,54 @@ permissions:
contents: read
jobs:
prepare-linkspector-browser:
# later versions of Ubuntu have disabled unprivileged user namespaces, which are required by the action
runs-on: ubuntu-22.04
permissions:
contents: read
env:
CHROME_BUILD_ID: "145.0.7632.77"
outputs:
browser-cache-key: ${{ steps.browser-versions.outputs.cache-key }}
chrome-path: ${{ steps.install-chrome.outputs.path }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2.17.0
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up mise tools
uses: ./.github/actions/setup-mise
with:
install-args: "node npm:@puppeteer/browsers"
- name: Get browser versions
id: browser-versions
run: |
set -euo pipefail
installer_version="$(mise current npm:@puppeteer/browsers)"
echo "cache-key=puppeteer-${RUNNER_OS}-${RUNNER_ARCH}-browsers-${installer_version}-chrome-${CHROME_BUILD_ID}" >> "$GITHUB_OUTPUT"
- name: Restore Puppeteer browser cache
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.cache/puppeteer
key: ${{ steps.browser-versions.outputs.cache-key }}
- name: Install Linkspector Chrome
id: install-chrome
run: |
set -euo pipefail
chrome_path="$(browsers install "chrome@${CHROME_BUILD_ID}" --path "${HOME}/.cache/puppeteer" --format '{{path}}')"
echo "path=${chrome_path}" >> "$GITHUB_OUTPUT"
check-docs:
needs: prepare-linkspector-browser
# later versions of Ubuntu have disabled unprivileged user namespaces, which are required by the action
runs-on: ubuntu-22.04
permissions:
@@ -54,10 +101,21 @@ jobs:
corepack enable pnpm
mkdir -p "$(pnpm store path --silent)"
- name: Restore Puppeteer browser cache
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.cache/puppeteer
key: ${{ needs.prepare-linkspector-browser.outputs.browser-cache-key }}
- name: Check Markdown links
uses: umbrelladocs/action-linkspector@37c85bcde51b30bf929936502bac6bfb7e8f0a4d # v1.4.1
uses: umbrelladocs/action-linkspector@036f295d12b67b0c4b445bc83db0538afb78db69 # v1.5.2
id: markdown-link-check
# checks all markdown files from /docs including all subfolders
env:
# Use the Chrome build prepared from mise-pinned Puppeteer instead
# of letting linkspector download a mutable browser at runtime.
# See: https://github.com/UmbrellaDocs/action-linkspector/issues/62
PUPPETEER_EXECUTABLE_PATH: ${{ needs.prepare-linkspector-browser.outputs.chrome-path }}
with:
reporter: github-pr-review
config_file: ".github/.linkspector.yml"
+20 -30
View File
@@ -728,11 +728,11 @@ endif
# GitHub Actions linters are run in a separate CI job (lint-actions) that only
# triggers when workflow files change, so we skip them here when CI=true.
LINT_ACTIONS_TARGETS := $(if $(CI),,lint/actions/actionlint)
lint: lint/shellcheck lint/go lint/ts lint/examples lint/helm lint/site-icons lint/markdown lint/check-scopes lint/migrations lint/bootstrap lint/architecture lint/emdash lint/agents $(LINT_ACTIONS_TARGETS)
lint: lint/shellcheck lint/go lint/ts lint/examples lint/helm lint/site-icons lint/markdown lint/check-scopes lint/migrations lint/bootstrap lint/architecture lint/emdash lint/agents lint/mise-versions $(LINT_ACTIONS_TARGETS)
.PHONY: lint
# Subset of lint that does not require Go or Node toolchains.
lint-light: lint/shellcheck lint/markdown lint/helm lint/bootstrap lint/migrations lint/actions/actionlint lint/typos lint/emdash
# Fast lint subset for lightweight hooks. Some targets use mise-managed tools.
lint-light: lint/shellcheck lint/markdown lint/helm lint/bootstrap lint/migrations lint/actions/actionlint lint/typos lint/emdash lint/mise-versions
.PHONY: lint-light
lint/site-icons:
@@ -745,9 +745,8 @@ lint/ts: site/node_modules/.installed
.PHONY: lint/ts
lint/go:
linter_ver=$$(grep -Eo '^golangci-lint = "[^"]+"' mise.toml | sed -E 's/.*"([^"]+)"/\1/')
go run github.com/golangci/golangci-lint/cmd/golangci-lint@v$$linter_ver run
go tool github.com/coder/paralleltestctx/cmd/paralleltestctx -custom-funcs="testutil.Context,chatdTestContext" ./...
golangci-lint run
paralleltestctx -custom-funcs="testutil.Context,chatdTestContext" ./...
go run ./scripts/intxcheck ./...
.PHONY: lint/go
@@ -790,16 +789,27 @@ lint/actions: lint/actions/actionlint lint/actions/zizmor
.PHONY: lint/actions
lint/actions/actionlint:
go tool github.com/rhysd/actionlint/cmd/actionlint
mise exec actionlint -- actionlint
.PHONY: lint/actions/actionlint
# zizmor uses GH_TOKEN to fetch imported workflows from GitHub; without it,
# external action references are skipped silently.
lint/actions/zizmor:
./scripts/zizmor.sh \
@set -euo pipefail; \
if [ -z "$${GH_TOKEN:-}" ] && command -v gh >/dev/null 2>&1; then \
GH_TOKEN="$$(gh auth token 2>/dev/null || true)"; \
export GH_TOKEN; \
fi; \
mise exec zizmor -- zizmor \
--strict-collection \
--persona=regular \
.
.PHONY: lint/actions/zizmor
lint/mise-versions:
./scripts/check_mise_versions.sh
.PHONY: lint/mise-versions
# Verify api_key_scope enum contains all RBAC <resource>:<action> values.
lint/check-scopes: coderd/database/dump.sql | _gen/bin/check-scopes
_gen/bin/check-scopes
@@ -811,28 +821,8 @@ lint/migrations:
./scripts/check_pg_schema.sh "Fixtures" $(FIXTURE_FILES)
.PHONY: lint/migrations
TYPOS_VERSION := $(shell grep -oP 'crate-ci/typos@\S+\s+\#\s+v\K[0-9.]+' .github/workflows/ci.yaml)
# Map uname values to typos release asset names.
TYPOS_ARCH := $(shell uname -m)
# typos release assets use aarch64, but macOS ARM reports arm64 via uname -m.
ifeq ($(TYPOS_ARCH),arm64)
TYPOS_ARCH := aarch64
endif
ifeq ($(shell uname -s),Darwin)
TYPOS_OS := apple-darwin
else
TYPOS_OS := unknown-linux-musl
endif
build/typos-$(TYPOS_VERSION):
mkdir -p build/
curl -sSfL "https://github.com/crate-ci/typos/releases/download/v$(TYPOS_VERSION)/typos-v$(TYPOS_VERSION)-$(TYPOS_ARCH)-$(TYPOS_OS).tar.gz" \
| tar -xzf - -C build/ ./typos
mv build/typos "$@"
lint/typos: build/typos-$(TYPOS_VERSION)
build/typos-$(TYPOS_VERSION) --config .github/workflows/typos.toml
lint/typos:
typos --config .github/workflows/typos.toml
.PHONY: lint/typos
# pre-commit and pre-push mirror CI checks locally.
+12 -8
View File
@@ -975,15 +975,19 @@ func (m *Manager) createTransport(ctx context.Context, cfg ServerConfig) (transp
}),
), nil
case "http", "":
return transport.NewStreamableHTTP(
cfg.URL,
transport.WithHTTPHeaders(cfg.Headers),
)
var opts []transport.StreamableHTTPCOption
opts = append(opts, transport.WithHTTPHeaders(cfg.Headers))
if c := mcpHTTPClient(); c != nil {
opts = append(opts, transport.WithHTTPBasicClient(c))
}
return transport.NewStreamableHTTP(cfg.URL, opts...)
case "sse":
return transport.NewSSE(
cfg.URL,
transport.WithHeaders(cfg.Headers),
)
var sseOpts []transport.ClientOption
sseOpts = append(sseOpts, transport.WithHeaders(cfg.Headers))
if c := mcpHTTPClient(); c != nil {
sseOpts = append(sseOpts, transport.WithHTTPClient(c))
}
return transport.NewSSE(cfg.URL, sseOpts...)
default:
return nil, xerrors.Errorf("unsupported transport %q", cfg.Transport)
}
+25
View File
@@ -0,0 +1,25 @@
package agentmcp
import (
"net/http"
"testing"
)
// mcpHTTPClient returns an isolated *http.Client when running
// inside tests, or nil for production. During tests,
// httptest.Server.Close() calls
// http.DefaultTransport.CloseIdleConnections(), which disrupts
// any MCP client sharing that transport. When DefaultTransport
// is a *http.Transport it is cloned; otherwise a minimal
// transport with ProxyFromEnvironment is created as a fallback.
func mcpHTTPClient() *http.Client {
if !testing.Testing() {
return nil
}
if dt, ok := http.DefaultTransport.(*http.Transport); ok {
return &http.Client{Transport: dt.Clone()}
}
return &http.Client{Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
}}
}
+25
View File
@@ -0,0 +1,25 @@
package mcp
import (
"net/http"
"testing"
)
// mcpHTTPClient returns an isolated *http.Client when running
// inside tests, or nil for production. During tests,
// httptest.Server.Close() calls
// http.DefaultTransport.CloseIdleConnections(), which disrupts
// any MCP client sharing that transport. When DefaultTransport
// is a *http.Transport it is cloned; otherwise a minimal
// transport with ProxyFromEnvironment is created as a fallback.
func mcpHTTPClient() *http.Client {
if !testing.Testing() {
return nil
}
if dt, ok := http.DefaultTransport.(*http.Transport); ok {
return &http.Client{Transport: dt.Clone()}
}
return &http.Client{Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
}}
}
+11
View File
@@ -39,6 +39,17 @@ func NewStreamableHTTPServerProxy(serverName, serverURL string, headers map[stri
opts = append(opts, transport.WithHTTPHeaders(headers))
}
// Prepend an isolated HTTP client when running in tests so
// httptest.Server.Close() does not disrupt this proxy's
// connections via http.DefaultTransport.CloseIdleConnections().
// Caller-provided WithHTTPBasicClient in opts overrides this
// (last-wins).
if c := mcpHTTPClient(); c != nil {
opts = append([]transport.StreamableHTTPCOption{
transport.WithHTTPBasicClient(c),
}, opts...)
}
mcpClient, err := client.NewStreamableHttpClient(serverURL, opts...)
if err != nil {
return nil, xerrors.Errorf("create streamable http client: %w", err)
+32 -3
View File
@@ -2979,6 +2979,11 @@ func parseExternalAuthProvidersFromEnv(prefix string, environ []string) ([]coder
return providers, nil
}
const (
aiGatewayProviderEnvPrefix = "CODER_AI_GATEWAY_PROVIDER_"
aiBridgeProviderEnvPrefix = "CODER_AIBRIDGE_PROVIDER_"
)
// ReadAIProvidersFromEnv parses CODER_AI_GATEWAY_PROVIDER_<N>_<KEY>
// environment variables into a slice of AIProviderConfig.
// Deprecated alias env vars with the CODER_AIBRIDGE_PROVIDER_<N>_<KEY>
@@ -2986,16 +2991,22 @@ func parseExternalAuthProvidersFromEnv(prefix string, environ []string) ([]coder
//
// This follows the same indexed pattern as ReadExternalAuthProvidersFromEnv.
func ReadAIProvidersFromEnv(logger slog.Logger, environ []string) ([]codersdk.AIProviderConfig, error) {
providers, err := readAIProvidersForPrefix(logger, environ, "CODER_AIBRIDGE_PROVIDER_")
providers, err := readAIProvidersForPrefix(logger, environ, aiBridgeProviderEnvPrefix)
if err != nil {
return nil, err
}
gatewayProviders, err := readAIProvidersForPrefix(logger, environ, "CODER_AI_GATEWAY_PROVIDER_")
gatewayProviders, err := readAIProvidersForPrefix(logger, environ, aiGatewayProviderEnvPrefix)
if err != nil {
return nil, err
}
if len(providers) > 0 && len(gatewayProviders) > 0 {
return nil, xerrors.New("cannot mix CODER_AIBRIDGE_PROVIDER_* and CODER_AI_GATEWAY_PROVIDER_* environment variables, please consolidate onto CODER_AI_GATEWAY_PROVIDER_*")
return nil, xerrors.Errorf("cannot mix %s* and %s* environment variables, please consolidate onto %s*", aiBridgeProviderEnvPrefix, aiGatewayProviderEnvPrefix, aiGatewayProviderEnvPrefix)
}
var activePrefix string
if len(providers) > 0 {
activePrefix = aiBridgeProviderEnvPrefix
} else if len(gatewayProviders) > 0 {
activePrefix = aiGatewayProviderEnvPrefix
}
providers = append(providers, gatewayProviders...)
@@ -3077,9 +3088,27 @@ func ReadAIProvidersFromEnv(logger slog.Logger, environ []string) ([]codersdk.AI
names[p.Name] = i
}
warnIfAIProvidersConfiguredFromEnv(context.Background(), logger, activePrefix, providers)
return providers, nil
}
func warnIfAIProvidersConfiguredFromEnv(ctx context.Context, logger slog.Logger, prefix string, providers []codersdk.AIProviderConfig) {
if len(providers) == 0 {
return
}
if prefix == "" {
return
}
logger.Warn(ctx,
"ai provider environment variables are deprecated for provider management and only seed provider configuration at startup",
slog.F("env_prefix", prefix),
slog.F("replacement", "Manage AI Providers from the Coder UI or HTTP API."),
)
}
// readAIProvidersForPrefix parses provider env vars under a single
// indexed prefix (e.g. CODER_AI_GATEWAY_PROVIDER_) into a slice of
// AIProviderConfig. Per-field syntax errors and unknown keys are
+64
View File
@@ -1,6 +1,7 @@
package cli
import (
"context"
"database/sql"
"encoding/json"
"fmt"
@@ -575,6 +576,58 @@ func TestValidateLegacyAIBridgeConfig(t *testing.T) {
}
}
func TestWarnIfAIProvidersConfiguredFromEnv(t *testing.T) {
t.Parallel()
t.Run("NoProviders", func(t *testing.T) {
t.Parallel()
sink := testutil.NewFakeSink(t)
warnIfAIProvidersConfiguredFromEnv(context.Background(), sink.Logger(), aiGatewayProviderEnvPrefix, nil)
require.Empty(t, sink.Entries())
})
t.Run("EmptyPrefix", func(t *testing.T) {
t.Parallel()
sink := testutil.NewFakeSink(t)
warnIfAIProvidersConfiguredFromEnv(context.Background(), sink.Logger(), "", []codersdk.AIProviderConfig{{Type: "openai", Name: "openai"}})
require.Empty(t, sink.Entries())
})
t.Run("AIGatewayPrefix", func(t *testing.T) {
t.Parallel()
sink := testutil.NewFakeSink(t)
warnIfAIProvidersConfiguredFromEnv(context.Background(), sink.Logger(), aiGatewayProviderEnvPrefix, []codersdk.AIProviderConfig{{Type: "openai", Name: "openai"}})
entries := sink.Entries(func(e slog.SinkEntry) bool {
return e.Message == "ai provider environment variables are deprecated for provider management and only seed provider configuration at startup"
})
require.Len(t, entries, 1)
require.Len(t, entries[0].Fields, 2)
assertFieldValue(t, entries[0].Fields, "env_prefix", aiGatewayProviderEnvPrefix)
assertFieldValue(t, entries[0].Fields, "replacement", "Manage AI Providers from the Coder UI or HTTP API.")
})
t.Run("AIBridgePrefix", func(t *testing.T) {
t.Parallel()
sink := testutil.NewFakeSink(t)
warnIfAIProvidersConfiguredFromEnv(context.Background(), sink.Logger(), aiBridgeProviderEnvPrefix, []codersdk.AIProviderConfig{{Type: "openai", Name: "openai"}})
entries := sink.Entries(func(e slog.SinkEntry) bool {
return e.Message == "ai provider environment variables are deprecated for provider management and only seed provider configuration at startup"
})
require.Len(t, entries, 1)
require.Len(t, entries[0].Fields, 2)
assertFieldValue(t, entries[0].Fields, "env_prefix", aiBridgeProviderEnvPrefix)
assertFieldValue(t, entries[0].Fields, "replacement", "Manage AI Providers from the Coder UI or HTTP API.")
})
}
func TestBuildAIProviderFromRowSetsAPIDumpDir(t *testing.T) {
t.Parallel()
@@ -721,3 +774,14 @@ func mustMarshalSettings(s codersdk.AIProviderSettings) sql.NullString {
}
return sql.NullString{String: string(data), Valid: true}
}
func assertFieldValue(t *testing.T, fields slog.Map, name string, expected interface{}) {
t.Helper()
for _, f := range fields {
if f.Name == name {
assert.Equal(t, expected, f.Value)
return
}
}
t.Errorf("field %q not found", name)
}
+35 -21
View File
@@ -237,7 +237,10 @@ func Test_TaskSend(t *testing.T) {
t.Parallel()
// Given: An initializing task (workspace running, no agent
// connected).
// connected). Close the agent, pause, then resume so the
// workspace is started but no agent is connected. The
// command enters waitForTaskIdle directly (initializing
// path), where we verify it handles an external pause.
setupCtx := testutil.Context(t, testutil.WaitLong)
setup := setupCLITaskTest(setupCtx, t, nil)
@@ -246,8 +249,6 @@ func Test_TaskSend(t *testing.T) {
resumeTask(setupCtx, t, setup.userClient, setup.task)
// Set up mock clock and traps before starting the command.
// Without a mock clock the poll can race with the stop build
// and see a transient 'unknown' status instead of 'paused'.
mClock := quartz.NewMock(t)
tickTrap := mClock.Trap().NewTicker("task_send", "poll")
resetTrap := mClock.Trap().TickerReset("task_send", "poll")
@@ -271,23 +272,28 @@ func Test_TaskSend(t *testing.T) {
tickCall.MustRelease(ctx)
tickTrap.Close()
// Fire the immediate first poll (time.Nanosecond initial interval).
// This poll sees 'initializing' because no agent is connected.
// Fire the first poll. The goroutine calls ticker.Reset
// which the trap catches, freezing the goroutine BEFORE
// client.TaskByID runs. Release it so the first poll
// sees 'initializing' and continues.
mClock.Advance(time.Nanosecond).MustWait(ctx)
// Wait for Reset (confirms first poll completed).
resetCall := resetTrap.MustWait(ctx)
resetCall.MustRelease(ctx)
resetTrap.Close()
// Pause the task while waitForTaskIdle is polling. Since
// no agent is connected, the task stays initializing until
// we pause it, at which point the status becomes paused.
// Fire the second poll. The goroutine is again frozen at
// ticker.Reset by the trap.
mClock.Advance(5 * time.Second).MustWait(ctx)
resetCall = resetTrap.MustWait(ctx)
// While the goroutine is frozen (before client.TaskByID),
// pause the task. The stop build completes, so the DB has
// (stop, succeeded) = 'paused'.
pauseTask(ctx, t, setup.userClient, setup.task)
// Fire second poll at the regular 5s interval. The stop
// build has completed, so the poll sees 'paused'.
mClock.Advance(5 * time.Second).MustWait(ctx)
// Release the trap. The goroutine unfreezes and
// client.TaskByID deterministically sees 'paused'.
resetCall.MustRelease(ctx)
resetTrap.Close()
// Then: The command should fail because the task was paused.
err := w.Wait()
@@ -328,23 +334,31 @@ func Test_TaskSend(t *testing.T) {
tickCall.MustRelease(ctx)
tickTrap.Close()
// Fire the immediate first poll (time.Nanosecond initial interval).
// Fire the first poll. The goroutine calls ticker.Reset
// which the trap catches, freezing the goroutine BEFORE
// client.TaskByID runs. Release it so the first poll
// sees "working" and continues.
mClock.Advance(time.Nanosecond).MustWait(ctx)
// Wait for Reset (confirms first poll completed and saw "working").
resetCall := resetTrap.MustWait(ctx)
resetCall.MustRelease(ctx)
resetTrap.Close()
// Transition the app back to idle so waitForTaskIdle proceeds.
// Fire the second poll. The goroutine is again frozen
// at ticker.Reset by the trap.
mClock.Advance(5 * time.Second).MustWait(ctx)
resetCall = resetTrap.MustWait(ctx)
// While the goroutine is frozen (before client.TaskByID),
// transition the app to idle.
require.NoError(t, agentClient.PatchAppStatus(ctx, agentsdk.PatchAppStatus{
AppSlug: "task-sidebar",
State: codersdk.WorkspaceAppStatusStateIdle,
Message: "ready",
}))
// Fire second poll at the regular 5s interval.
mClock.Advance(5 * time.Second).MustWait(ctx)
// Release the trap. The goroutine unfreezes and
// client.TaskByID deterministically sees "idle".
resetCall.MustRelease(ctx)
resetTrap.Close()
// Then: The command should complete successfully.
require.NoError(t, w.Wait())
+44 -18
View File
@@ -124,35 +124,55 @@ AI GATEWAY OPTIONS:
disabled, only centralized key authentication is permitted.
--ai-gateway-anthropic-base-url string, $CODER_AI_GATEWAY_ANTHROPIC_BASE_URL (default: https://api.anthropic.com/)
The base URL of the Anthropic API.
Deprecated: manage AI Providers from the Coder UI or HTTP API. If set,
this option seeds provider configuration at startup only exactly once.
It will not be used in service runtime. The base URL of the Anthropic
API.
--ai-gateway-anthropic-key string, $CODER_AI_GATEWAY_ANTHROPIC_KEY
The key to authenticate against the Anthropic API.
Deprecated: manage AI Providers from the Coder UI or HTTP API. If set,
this option seeds provider configuration at startup only exactly once.
It will not be used in service runtime. The key to authenticate
against the Anthropic API.
--ai-gateway-bedrock-access-key string, $CODER_AI_GATEWAY_BEDROCK_ACCESS_KEY
The access key to authenticate against the AWS Bedrock API.
--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
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
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)
@@ -171,10 +191,16 @@ AI GATEWAY OPTIONS:
to disable (unlimited).
--ai-gateway-openai-base-url string, $CODER_AI_GATEWAY_OPENAI_BASE_URL (default: https://api.openai.com/v1/)
The base URL of the OpenAI API.
Deprecated: manage AI Providers from the Coder UI or HTTP API. If set,
this option seeds provider configuration at startup only exactly once.
It will not be used in service runtime. The base URL of the OpenAI
API.
--ai-gateway-openai-key string, $CODER_AI_GATEWAY_OPENAI_KEY
The key to authenticate against the OpenAI API.
Deprecated: manage AI Providers from the Coder UI or HTTP API. If set,
this option seeds provider configuration at startup only exactly once.
It will not be used in service runtime. The key to authenticate
against the OpenAI API.
--ai-gateway-rate-limit int, $CODER_AI_GATEWAY_RATE_LIMIT (default: 0)
Maximum number of AI Gateway requests per second per replica. Set to 0
+25 -9
View File
@@ -874,25 +874,41 @@ ai_gateway:
# Whether to start an in-memory AI Gateway instance.
# (default: true, type: bool)
enabled: true
# The base URL of the OpenAI API.
# Deprecated: manage AI Providers from the Coder UI or HTTP API. If set, this
# option seeds provider configuration at startup only exactly once. It will not be
# used in service runtime. The base URL of the OpenAI API.
# (default: https://api.openai.com/v1/, type: string)
openai_base_url: https://api.openai.com/v1/
# The base URL of the Anthropic API.
# Deprecated: manage AI Providers from the Coder UI or HTTP API. If set, this
# option seeds provider configuration at startup only exactly once. It will not be
# used in service runtime. The base URL of the Anthropic API.
# (default: https://api.anthropic.com/, type: string)
anthropic_base_url: https://api.anthropic.com/
# The base URL to use for the AWS Bedrock API. Use this setting to specify an
# exact URL to use. Takes precedence over CODER_AI_GATEWAY_BEDROCK_REGION.
# Deprecated: manage AI Providers from the Coder UI or HTTP API. If set, this
# option seeds provider configuration at startup only exactly once. It will not be
# used in service runtime. The base URL to use for the AWS Bedrock API. Use this
# setting to specify an exact URL to use. Takes precedence over
# CODER_AI_GATEWAY_BEDROCK_REGION.
# (default: <unset>, 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.<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'.
# (default: <unset>, 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
+16
View File
@@ -340,6 +340,10 @@ func (api *API) aiProvidersUpdate(rw http.ResponseWriter, r *http.Request) {
return errBedrockRejectsAPIKeys
}
if req.APIKeys != nil && old.Type == database.AiProviderTypeCopilot && len(*req.APIKeys) > 0 {
return errCopilotRejectsAPIKeys
}
displayName := old.DisplayName
if req.DisplayName != nil {
// Empty string clears the column.
@@ -383,6 +387,12 @@ func (api *API) aiProvidersUpdate(rw http.ResponseWriter, r *http.Request) {
})
return
}
if errors.Is(err, errCopilotRejectsAPIKeys) {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Copilot providers do not accept api_keys; they authenticate via request-time GitHub OAuth tokens.",
})
return
}
if errors.Is(err, errAIProviderBedrockTypeMismatch) {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Bedrock settings are only valid for type=anthropic or type=bedrock.",
@@ -483,6 +493,12 @@ func (api *API) publishAIProvidersChanged(ctx context.Context) {
// Bedrock-typed provider; the outer handler translates it into a 400.
var errBedrockRejectsAPIKeys = xerrors.New("bedrock providers do not accept api_keys")
// errCopilotRejectsAPIKeys is the sentinel returned from inside the
// update transaction when a caller attempts to attach api_keys to a
// Copilot-typed provider; the outer handler translates it into a 400.
// Copilot authenticates via request-time GitHub OAuth tokens.
var errCopilotRejectsAPIKeys = xerrors.New("copilot providers do not accept api_keys")
// errAIProviderBedrockTypeMismatch is the sentinel returned from
// inside the update transaction when the post-merge settings carry a
// Bedrock block but the provider is not anthropic- or bedrock-typed;
+69
View File
@@ -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)
@@ -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.
@@ -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;
@@ -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()
+107 -30
View File
@@ -12,6 +12,7 @@ import (
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"github.com/google/uuid"
@@ -57,11 +58,10 @@ func TestMCPHTTP_E2E_ClientIntegration(t *testing.T) {
mcpURL := api.AccessURL.String() + mcpserver.MCPEndpoint
// Configure client with authentication headers using RFC 6750 Bearer token
mcpClient, err := mcpclient.NewStreamableHttpClient(mcpURL,
mcpClient := newIsolatedMCPClient(t, mcpURL,
transport.WithHTTPHeaders(map[string]string{
"Authorization": "Bearer " + coderClient.SessionToken(),
}))
require.NoError(t, err)
defer func() {
if closeErr := mcpClient.Close(); closeErr != nil {
t.Logf("Failed to close MCP client: %v", closeErr)
@@ -72,7 +72,7 @@ func TestMCPHTTP_E2E_ClientIntegration(t *testing.T) {
defer cancel()
// Start client
err = mcpClient.Start(ctx)
err := mcpClient.Start(ctx)
require.NoError(t, err)
// Initialize connection
@@ -190,8 +190,7 @@ func TestMCPHTTP_E2E_UnauthenticatedAccess(t *testing.T) {
require.Equal(t, http.StatusUnauthorized, resp.StatusCode, "Should get HTTP 401 for unauthenticated access")
// Also test with MCP client to ensure it handles the error gracefully
mcpClient, err := mcpclient.NewStreamableHttpClient(mcpURL)
require.NoError(t, err, "Should be able to create MCP client without authentication")
mcpClient := newIsolatedMCPClient(t, mcpURL)
defer func() {
if closeErr := mcpClient.Close(); closeErr != nil {
t.Logf("Failed to close MCP client: %v", closeErr)
@@ -245,11 +244,10 @@ func TestMCPHTTP_E2E_ToolWithWorkspace(t *testing.T) {
coderdtest.NewWorkspaceAgentWaiter(t, coderClient, r.Workspace.ID).Wait()
mcpURL := api.AccessURL.String() + mcpserver.MCPEndpoint
mcpClient, err := mcpclient.NewStreamableHttpClient(mcpURL,
mcpClient := newIsolatedMCPClient(t, mcpURL,
transport.WithHTTPHeaders(map[string]string{
"Authorization": "Bearer " + coderClient.SessionToken(),
}))
require.NoError(t, err)
defer func() {
if closeErr := mcpClient.Close(); closeErr != nil {
t.Logf("Failed to close MCP client: %v", closeErr)
@@ -260,7 +258,7 @@ func TestMCPHTTP_E2E_ToolWithWorkspace(t *testing.T) {
defer cancel()
require.NoError(t, mcpClient.Start(ctx))
_, err = mcpClient.Initialize(ctx, mcp.InitializeRequest{
_, err := mcpClient.Initialize(ctx, mcp.InitializeRequest{
Params: mcp.InitializeParams{
ProtocolVersion: mcp.LATEST_PROTOCOL_VERSION,
ClientInfo: mcp.Implementation{
@@ -307,11 +305,10 @@ func TestMCPHTTP_E2E_ErrorHandling(t *testing.T) {
// Create MCP client
mcpURL := api.AccessURL.String() + mcpserver.MCPEndpoint
mcpClient, err := mcpclient.NewStreamableHttpClient(mcpURL,
mcpClient := newIsolatedMCPClient(t, mcpURL,
transport.WithHTTPHeaders(map[string]string{
"Authorization": "Bearer " + coderClient.SessionToken(),
}))
require.NoError(t, err)
defer func() {
if closeErr := mcpClient.Close(); closeErr != nil {
t.Logf("Failed to close MCP client: %v", closeErr)
@@ -322,7 +319,7 @@ func TestMCPHTTP_E2E_ErrorHandling(t *testing.T) {
defer cancel()
// Start and initialize client
err = mcpClient.Start(ctx)
err := mcpClient.Start(ctx)
require.NoError(t, err)
initReq := mcp.InitializeRequest{
@@ -366,11 +363,10 @@ func TestMCPHTTP_E2E_ConcurrentRequests(t *testing.T) {
// Create MCP client
mcpURL := api.AccessURL.String() + mcpserver.MCPEndpoint
mcpClient, err := mcpclient.NewStreamableHttpClient(mcpURL,
mcpClient := newIsolatedMCPClient(t, mcpURL,
transport.WithHTTPHeaders(map[string]string{
"Authorization": "Bearer " + coderClient.SessionToken(),
}))
require.NoError(t, err)
defer func() {
if closeErr := mcpClient.Close(); closeErr != nil {
t.Logf("Failed to close MCP client: %v", closeErr)
@@ -381,7 +377,7 @@ func TestMCPHTTP_E2E_ConcurrentRequests(t *testing.T) {
defer cancel()
// Start and initialize client
err = mcpClient.Start(ctx)
err := mcpClient.Start(ctx)
require.NoError(t, err)
initReq := mcp.InitializeRequest{
@@ -520,11 +516,10 @@ func TestMCPHTTP_E2E_OAuth2_EndToEnd(t *testing.T) {
sessionToken := coderClient.SessionToken()
mcpURL := api.AccessURL.String() + mcpserver.MCPEndpoint
mcpClient, err := mcpclient.NewStreamableHttpClient(mcpURL,
mcpClient := newIsolatedMCPClient(t, mcpURL,
transport.WithHTTPHeaders(map[string]string{
"Authorization": "Bearer " + sessionToken,
}))
require.NoError(t, err)
defer func() {
if closeErr := mcpClient.Close(); closeErr != nil {
t.Logf("Failed to close MCP client: %v", closeErr)
@@ -669,11 +664,10 @@ func TestMCPHTTP_E2E_OAuth2_EndToEnd(t *testing.T) {
// Step 3: Use access token to authenticate with MCP endpoint
mcpURL := api.AccessURL.String() + mcpserver.MCPEndpoint
mcpClient, err := mcpclient.NewStreamableHttpClient(mcpURL,
mcpClient := newIsolatedMCPClient(t, mcpURL,
transport.WithHTTPHeaders(map[string]string{
"Authorization": "Bearer " + accessToken,
}))
require.NoError(t, err)
defer func() {
if closeErr := mcpClient.Close(); closeErr != nil {
t.Logf("Failed to close MCP client: %v", closeErr)
@@ -762,11 +756,10 @@ func TestMCPHTTP_E2E_OAuth2_EndToEnd(t *testing.T) {
t.Logf("Successfully refreshed token: %s...", newAccessToken[:10])
// Step 5: Use new access token to create another MCP connection
newMcpClient, err := mcpclient.NewStreamableHttpClient(mcpURL,
newMcpClient := newIsolatedMCPClient(t, mcpURL,
transport.WithHTTPHeaders(map[string]string{
"Authorization": "Bearer " + newAccessToken,
}))
require.NoError(t, err)
defer func() {
if closeErr := newMcpClient.Close(); closeErr != nil {
t.Logf("Failed to close new MCP client: %v", closeErr)
@@ -990,11 +983,10 @@ func TestMCPHTTP_E2E_OAuth2_EndToEnd(t *testing.T) {
t.Logf("Successfully obtained access token: %s...", accessToken[:10])
// Step 5: Use access token to get user information via MCP
mcpClient, err := mcpclient.NewStreamableHttpClient(mcpURL,
mcpClient := newIsolatedMCPClient(t, mcpURL,
transport.WithHTTPHeaders(map[string]string{
"Authorization": "Bearer " + accessToken,
}))
require.NoError(t, err)
defer func() {
if closeErr := mcpClient.Close(); closeErr != nil {
t.Logf("Failed to close MCP client: %v", closeErr)
@@ -1088,11 +1080,10 @@ func TestMCPHTTP_E2E_OAuth2_EndToEnd(t *testing.T) {
t.Logf("Successfully refreshed token: %s...", newAccessToken[:10])
// Step 7: Use refreshed token to get user information again via MCP
newMcpClient, err := mcpclient.NewStreamableHttpClient(mcpURL,
newMcpClient := newIsolatedMCPClient(t, mcpURL,
transport.WithHTTPHeaders(map[string]string{
"Authorization": "Bearer " + newAccessToken,
}))
require.NoError(t, err)
defer func() {
if closeErr := newMcpClient.Close(); closeErr != nil {
t.Logf("Failed to close new MCP client: %v", closeErr)
@@ -1268,11 +1259,10 @@ func TestMCPHTTP_E2E_ChatGPTEndpoint(t *testing.T) {
mcpURL := api.AccessURL.String() + mcpserver.MCPEndpoint + "?toolset=chatgpt"
// Configure client with authentication headers using RFC 6750 Bearer token
mcpClient, err := mcpclient.NewStreamableHttpClient(mcpURL,
mcpClient := newIsolatedMCPClient(t, mcpURL,
transport.WithHTTPHeaders(map[string]string{
"Authorization": "Bearer " + coderClient.SessionToken(),
}))
require.NoError(t, err)
t.Cleanup(func() {
if closeErr := mcpClient.Close(); closeErr != nil {
t.Logf("Failed to close MCP client: %v", closeErr)
@@ -1283,7 +1273,7 @@ func TestMCPHTTP_E2E_ChatGPTEndpoint(t *testing.T) {
defer cancel()
// Start client
err = mcpClient.Start(ctx)
err := mcpClient.Start(ctx)
require.NoError(t, err)
// Initialize connection
@@ -1433,11 +1423,10 @@ func TestMCPHTTP_E2E_WorkspaceSSHAuthz(t *testing.T) {
// Connect with the template-admin user.
mcpURL := api.AccessURL.String() + mcpserver.MCPEndpoint
mcpClient, err := mcpclient.NewStreamableHttpClient(mcpURL,
mcpClient := newIsolatedMCPClient(t, mcpURL,
transport.WithHTTPHeaders(map[string]string{
"Authorization": "Bearer " + tmplAdminClient.SessionToken(),
}))
require.NoError(t, err)
defer func() {
_ = mcpClient.Close()
}()
@@ -1446,7 +1435,7 @@ func TestMCPHTTP_E2E_WorkspaceSSHAuthz(t *testing.T) {
defer cancel()
require.NoError(t, mcpClient.Start(ctx))
_, err = mcpClient.Initialize(ctx, mcp.InitializeRequest{
_, err := mcpClient.Initialize(ctx, mcp.InitializeRequest{
Params: mcp.InitializeParams{
ProtocolVersion: mcp.LATEST_PROTOCOL_VERSION,
ClientInfo: mcp.Implementation{
@@ -1489,3 +1478,91 @@ func mustParseURL(t *testing.T, rawURL string) *url.URL {
require.NoError(t, err, "Failed to parse URL %q", rawURL)
return u
}
// newIsolatedMCPClient creates a streamable HTTP MCP client that uses
// an isolated http.Transport cloned from http.DefaultTransport.
// This prevents httptest.Server.Close() (which calls
// http.DefaultTransport.CloseIdleConnections()) from disrupting the
// client's connections during parallel tests.
func newIsolatedMCPClient(t *testing.T, mcpURL string, opts ...transport.StreamableHTTPCOption) *mcpclient.Client {
t.Helper()
isolated := coderdtest.NewIsolatedHTTPClient(nil)
opts = append([]transport.StreamableHTTPCOption{transport.WithHTTPBasicClient(isolated)}, opts...)
client, err := mcpclient.NewStreamableHttpClient(mcpURL, opts...)
require.NoError(t, err)
return client
}
// sentinelTransport wraps an http.RoundTripper and counts how many
// requests flow through it. Used as a test sentinel to verify
// whether a client is (or is not) using http.DefaultTransport.
type sentinelTransport struct {
inner http.RoundTripper
hits atomic.Int64
}
func (s *sentinelTransport) RoundTrip(req *http.Request) (*http.Response, error) {
s.hits.Add(1)
return s.inner.RoundTrip(req)
}
// TestMCPHTTP_E2E_TransportIsolation verifies that the
// newIsolatedMCPClient helper creates clients that do NOT route
// requests through http.DefaultTransport, while raw
// mcpclient.NewStreamableHttpClient (without explicit
// WithHTTPBasicClient) does use it.
//
//nolint:paralleltest // Mutates http.DefaultTransport.
func TestMCPHTTP_E2E_TransportIsolation(t *testing.T) {
// Replace DefaultTransport with a counting sentinel.
original := http.DefaultTransport
sentinel := &sentinelTransport{inner: original}
http.DefaultTransport = sentinel
t.Cleanup(func() { http.DefaultTransport = original })
coderClient, closer, api := coderdtest.NewWithAPI(t, nil)
t.Cleanup(func() { closer.Close() })
_ = coderdtest.CreateFirstUser(t, coderClient)
mcpURL := api.AccessURL.String() + mcpserver.MCPEndpoint
authOpt := transport.WithHTTPHeaders(map[string]string{
"Authorization": "Bearer " + coderClient.SessionToken(),
})
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
initReq := mcp.InitializeRequest{
Params: mcp.InitializeParams{
ProtocolVersion: mcp.LATEST_PROTOCOL_VERSION,
ClientInfo: mcp.Implementation{Name: "sentinel-test", Version: "1.0.0"},
},
}
t.Run("RawClientUsesDefaultTransport", func(t *testing.T) {
sentinel.hits.Store(0)
rawClient, err := mcpclient.NewStreamableHttpClient(mcpURL, authOpt)
require.NoError(t, err)
defer func() { _ = rawClient.Close() }()
require.NoError(t, rawClient.Start(ctx))
_, err = rawClient.Initialize(ctx, initReq)
require.NoError(t, err)
require.Greater(t, sentinel.hits.Load(), int64(0),
"raw client should route requests through http.DefaultTransport")
})
t.Run("IsolatedClientBypassesDefaultTransport", func(t *testing.T) {
sentinel.hits.Store(0)
isoClient := newIsolatedMCPClient(t, mcpURL, authOpt)
defer func() { _ = isoClient.Close() }()
require.NoError(t, isoClient.Start(ctx))
_, err := isoClient.Initialize(ctx, initReq)
require.NoError(t, err)
require.Equal(t, int64(0), sentinel.hits.Load(),
"isolated client must NOT route requests through http.DefaultTransport")
})
}
+10 -2
View File
@@ -35,14 +35,22 @@ const (
"- Key decisions made and their rationale\n" +
"- Concrete technical details: file paths, function names, " +
"commands, APIs, and configurations\n" +
"- Errors encountered and how they were resolved\n" +
"- Errors encountered and how they were resolved. Keep error " +
"notes specific: name the file, the error, and the fix. Do not " +
"generalize from a specific failure to a blanket tool-avoidance " +
"rule (e.g. \"tool X is unreliable\" or \"always use Y instead " +
"of Z\")\n" +
"- Current state of the work: what is DONE, what is IN PROGRESS, " +
"and what REMAINS to be done\n" +
"- The specific action the assistant was performing or about to " +
"perform when this summary was triggered\n\n" +
"Be dense and factual. Every sentence should convey essential " +
"context for continuation. Do not include pleasantries or " +
"conversational filler."
"conversational filler. For content that can be reproduced " +
"(repo files, command output, API responses), reference how to " +
"obtain it (file path, command, URL) rather than inlining the " +
"full content. Include brief inline summaries when the content " +
"itself would exceed a few lines."
defaultCompactionSystemSummaryPrefix = "The following is a summary of " +
"the earlier conversation. The assistant was actively working when " +
"the context was compacted. Continue the work described below:"
+37 -1
View File
@@ -5,6 +5,7 @@ import (
"database/sql"
"errors"
"fmt"
"net/http"
"strings"
"sync"
"time"
@@ -238,7 +239,7 @@ func CreateWorkspace(db database.Store, organizationID, chatID uuid.UUID, option
)
}
workspace, err := options.CreateFn(ctx, ownerID, createReq)
workspace, err := createWorkspaceWithNameRetry(ctx, ownerID, createReq, options.CreateFn)
if err != nil {
if responseErr, ok := httperror.IsResponder(err); ok {
_, resp := responseErr.Response()
@@ -703,6 +704,41 @@ func waitForAgentReady(
}
}
func createWorkspaceWithNameRetry(
ctx context.Context,
ownerID uuid.UUID,
req codersdk.CreateWorkspaceRequest,
createFn CreateWorkspaceFn,
) (codersdk.Workspace, error) {
workspace, err := createFn(ctx, ownerID, req)
if err == nil {
return workspace, nil
}
if !isWorkspaceNameConflict(err) {
return codersdk.Workspace{}, err
}
req.Name = generatedWorkspaceName(req.Name)
return createFn(ctx, ownerID, req)
}
func isWorkspaceNameConflict(err error) bool {
responseErr, ok := httperror.IsResponder(err)
if !ok {
return false
}
status, resp := responseErr.Response()
if status != http.StatusConflict {
return false
}
for _, validation := range resp.Validations {
if validation.Field == "name" {
return true
}
}
return false
}
func generatedWorkspaceName(seed string) string {
base := codersdk.UsernameFrom(strings.TrimSpace(strings.ToLower(seed)))
if strings.TrimSpace(base) == "" {
@@ -5,6 +5,7 @@ import (
"database/sql"
"encoding/json"
"fmt"
"net/http"
"sync"
"testing"
"time"
@@ -855,6 +856,70 @@ func TestCreateWorkspace_ResponderErrorPreservesStructuredFields(t *testing.T) {
}}, result.Validations)
}
func TestCreateWorkspaceWithNameRetry(t *testing.T) {
t.Parallel()
t.Run("NameConflictRetriesWithGeneratedName", func(t *testing.T) {
t.Parallel()
var names []string
workspace, err := createWorkspaceWithNameRetry(
context.Background(),
uuid.New(),
codersdk.CreateWorkspaceRequest{Name: "fun-dashboard"},
func(_ context.Context, _ uuid.UUID, req codersdk.CreateWorkspaceRequest) (codersdk.Workspace, error) {
names = append(names, req.Name)
if len(names) == 1 {
return codersdk.Workspace{}, workspaceNameConflictError(req.Name)
}
require.Regexp(t, `^fun-dashboard-[0-9a-f]{4}$`, req.Name)
return codersdk.Workspace{Name: req.Name}, nil
},
)
require.NoError(t, err)
require.Len(t, names, 2)
require.Equal(t, "fun-dashboard", names[0])
require.Equal(t, names[1], workspace.Name)
})
t.Run("OtherConflictDoesNotRetry", func(t *testing.T) {
t.Parallel()
calls := 0
wantErr := httperror.NewResponseError(http.StatusConflict, codersdk.Response{
Message: "quota exceeded",
Validations: []codersdk.ValidationError{{
Field: "quota",
Detail: "quota exceeded",
}},
})
_, err := createWorkspaceWithNameRetry(
context.Background(),
uuid.New(),
codersdk.CreateWorkspaceRequest{Name: "fun-dashboard"},
func(context.Context, uuid.UUID, codersdk.CreateWorkspaceRequest) (codersdk.Workspace, error) {
calls++
return codersdk.Workspace{}, wantErr
},
)
require.Same(t, wantErr, err)
require.Equal(t, 1, calls)
})
}
func workspaceNameConflictError(name string) error {
return httperror.NewResponseError(http.StatusConflict, codersdk.Response{
Message: fmt.Sprintf("Workspace %q already exists.", name),
Validations: []codersdk.ValidationError{{
Field: "name",
Detail: "This value is already in use and should be unique.",
}},
})
}
func TestCreateWorkspace_GlobalTTL(t *testing.T) {
t.Parallel()
+13 -20
View File
@@ -285,31 +285,24 @@ func createTransport(
cfg database.MCPServerConfig,
headers map[string]string,
) (transport.Interface, error) {
// Each connection gets its own HTTP client with a dedicated
// transport so that httptest.Server.Close() (which calls
// CloseIdleConnections on http.DefaultTransport) does not
// disrupt unrelated connections during parallel tests.
var httpClient *http.Client
if dt, ok := http.DefaultTransport.(*http.Transport); ok {
httpClient = &http.Client{Transport: dt.Clone()}
} else {
httpClient = &http.Client{}
}
httpClient := mcpHTTPClient()
switch cfg.Transport {
case "sse":
return transport.NewSSE(
cfg.Url,
transport.WithHeaders(headers),
transport.WithHTTPClient(httpClient),
)
var opts []transport.ClientOption
opts = append(opts, transport.WithHeaders(headers))
if httpClient != nil {
opts = append(opts, transport.WithHTTPClient(httpClient))
}
return transport.NewSSE(cfg.Url, opts...)
case "", "streamable_http":
// Default to streamable HTTP, the newer transport.
return transport.NewStreamableHTTP(
cfg.Url,
transport.WithHTTPHeaders(headers),
transport.WithHTTPBasicClient(httpClient),
)
var opts []transport.StreamableHTTPCOption
opts = append(opts, transport.WithHTTPHeaders(headers))
if httpClient != nil {
opts = append(opts, transport.WithHTTPBasicClient(httpClient))
}
return transport.NewStreamableHTTP(cfg.Url, opts...)
default:
return nil, xerrors.Errorf(
"unsupported transport %q", cfg.Transport,
+25
View File
@@ -0,0 +1,25 @@
package mcpclient
import (
"net/http"
"testing"
)
// mcpHTTPClient returns an isolated *http.Client when running
// inside tests, or nil for production. During tests,
// httptest.Server.Close() calls
// http.DefaultTransport.CloseIdleConnections(), which disrupts
// any MCP client sharing that transport. When DefaultTransport
// is a *http.Transport it is cloned; otherwise a minimal
// transport with ProxyFromEnvironment is created as a fallback.
func mcpHTTPClient() *http.Client {
if !testing.Testing() {
return nil
}
if dt, ok := http.DefaultTransport.(*http.Transport); ok {
return &http.Client{Transport: dt.Clone()}
}
return &http.Client{Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
}}
}
+10 -2
View File
@@ -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
}
+25 -26
View File
@@ -1700,6 +1700,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.",
@@ -1712,7 +1713,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,
@@ -1722,7 +1723,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,
@@ -1732,7 +1733,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,
@@ -1742,7 +1743,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,
@@ -1751,30 +1752,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,
@@ -1784,7 +1783,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,
@@ -1794,7 +1793,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,
@@ -1804,7 +1803,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,
+15 -16
View File
@@ -4,12 +4,12 @@ import (
"context"
"database/sql"
"encoding/json"
"flag"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"sort"
"sync"
"testing"
@@ -1048,9 +1048,6 @@ func TestTools(t *testing.T) {
})
t.Run("WorkspaceSSHExec", func(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("WorkspaceSSHExec is not supported on Windows")
}
// Setup workspace exactly like main SSH tests
client, workspace, agentToken := setupWorkspaceForAgent(t, nil)
@@ -1076,7 +1073,7 @@ func TestTools(t *testing.T) {
// Test output trimming
result, err = testTool(t, toolsdk.WorkspaceBash, tb, toolsdk.WorkspaceBashArgs{
Workspace: workspace.Name,
Command: "echo -e '\\n test with whitespace \\n'",
Command: "echo ' test with whitespace '",
})
require.NoError(t, err)
require.Equal(t, 0, result.ExitCode)
@@ -2576,29 +2573,31 @@ func TestMain(m *testing.M) {
var untested []string
for _, tool := range toolsdk.All {
if tested, ok := testedTools.Load(tool.Name); !ok || !tested.(bool) {
// Test is skipped on Windows
if runtime.GOOS == "windows" && tool.Name == "coder_workspace_bash" {
continue
}
untested = append(untested, tool.Name)
}
}
if len(untested) > 0 && code == 0 {
code = 1
println("The following tools were not tested:")
_, _ = fmt.Fprintln(os.Stderr, "The following tools were not tested:")
for _, tool := range untested {
println(" - " + tool)
_, _ = fmt.Fprintf(os.Stderr, " - %s\n", tool)
}
_, _ = fmt.Fprintln(os.Stderr, "Please ensure that all tools are tested using testTool().")
_, _ = fmt.Fprintln(os.Stderr, "If you just added a new tool, please add a test for it.")
// Only fail when the full suite ran. When -run filters to a
// subset (e.g. CI flake checks use -run ^TestTools), tools
// covered by other top-level functions appear untested.
if f := flag.Lookup("test.run"); f == nil || f.Value.String() == "" {
code = 1
} else {
_, _ = fmt.Fprintln(os.Stderr, "NOTE: if you just ran an individual test, this is expected.")
}
println("Please ensure that all tools are tested using testTool().")
println("If you just added a new tool, please add a test for it.")
println("NOTE: if you just ran an individual test, this is expected.")
}
// Check for goroutine leaks. Below is adapted from goleak.VerifyTestMain:
if code == 0 {
if err := goleak.Find(testutil.GoleakOptions...); err != nil {
println("goleak: Errors on successful test run: ", err.Error())
_, _ = fmt.Fprintln(os.Stderr, "goleak: Errors on successful test run:", err.Error())
code = 1
}
}
+5 -1
View File
@@ -58,7 +58,11 @@ Learn more [how Nix works](https://nixos.org/guides/how-nix-works).
If you're not using the Nix environment, you can launch a local [DevContainer](https://github.com/coder/coder/tree/main/.devcontainer) to get a fully configured development environment.
DevContainers are supported in tools like **VS Code** and **GitHub Codespaces**, and come preloaded with all required dependencies: Docker, Go, Node.js with `pnpm`, and `make`.
DevContainers are supported in tools like **VS Code** and **GitHub Codespaces**, and come preloaded with all required dependencies: Docker, Go, Node.js with `pnpm`, `mise`, and `make`.
For manual setup outside Nix and DevContainers, install Docker, `mise`, and
`make`. Run `mise install` from the repository root to install Go, Node.js
with `pnpm`, and development tools at the versions pinned in `mise.toml`.
</div>
+3 -3
View File
@@ -169,9 +169,9 @@ There are two types of fixtures that are used to test that migrations don't
break existing Coder deployments:
* Partial fixtures
[`migrations/testdata/fixtures`](../../../coderd/database/migrations/testdata/fixtures)
[`migrations/testdata/fixtures`](https://github.com/coder/coder/tree/main/coderd/database/migrations/testdata/fixtures)
* Full database dumps
[`migrations/testdata/full_dumps`](../../../coderd/database/migrations/testdata/full_dumps)
[`migrations/testdata/full_dumps`](https://github.com/coder/coder/tree/main/coderd/database/migrations/testdata/full_dumps)
Both types behave like database migrations (they also
[`migrate`](https://github.com/golang-migrate/migrate)). Their behavior mirrors
@@ -194,7 +194,7 @@ To add a new partial fixture, run the following command:
```
Then add some queries to insert data and commit the file to the repo. See
[`000024_example.up.sql`](../../../coderd/database/migrations/testdata/fixtures/000024_example.up.sql)
[`000024_example.up.sql`](https://github.com/coder/coder/blob/main/coderd/database/migrations/testdata/fixtures/000024_example.up.sql)
for an example.
To create a full dump, run a fully fledged Coder deployment and use it to
+1 -1
View File
@@ -142,7 +142,7 @@ as OpenAI and Anthropic. Users authenticate through Coder instead of managing se
provider API keys. All prompts, token usage, and tool invocations are recorded
for compliance and cost tracking.
Learn more: [AI Gateway](../../ai-coder/ai-gateway)
Learn more: [AI Gateway](../../ai-coder/ai-gateway/index.md)
### Agent Firewall
+4 -4
View File
@@ -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
```
+24
View File
@@ -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.
+4
View File
@@ -92,6 +92,10 @@ When no user credential is present, AI Gateway falls back to the admin-configure
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.
Visit individual [client pages](./clients/index.md) for configuration details.
### Enable or disable BYOK
+40
View File
@@ -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
+214
View File
@@ -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
or returns an authentication error, AI Gateway automatically retries the request
with the next available key.
> [!WARNING]
> A key that fails with an authentication error (`401 Unauthorized` or
> `403 Forbidden`) is permanently disabled and will not be used again until the
> server is restarted or the provider configuration is reloaded.
If all keys in the pool are exhausted, AI Gateway returns:
- `429 Too Many Requests` when at least one key is rate-limited, with a `Retry-After` header set to the shortest cooldown across all keys.
- `502 Bad Gateway` when every key has failed permanently.
## 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 |
+30 -216
View File
@@ -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
```
_AI Gateway is enabled by default as of v2.34._
## Configure Providers
AI Gateway proxies requests to upstream LLM APIs. Configure at least one provider before exposing AI Gateway to end users.
Configure at least one provider before exposing AI Gateway to end users.
<div class="tabs">
Providers are deployment-scoped. Add them from the dashboard or the
[AI Providers API](../../reference/api/aiproviders.md). Changes take effect
without restarting `coderd`.
### OpenAI
### Dashboard
Set the following when routing [OpenAI-compatible](https://coder.com/docs/reference/cli/server#--ai-gateway-openai-key) traffic through AI Gateway:
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
- `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`
Each provider gets its own AI Gateway route at
`/api/v2/aibridge/<provider-name>/`.
> [!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).
> 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.
**Optional:**
![AI Providers list page](../../images/aibridge/providers-list.png)
- `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`
![Add Anthropic provider form](../../images/aibridge/provider-add-anthropic.png)
#### Authentication
Open an existing provider to rotate credentials, update its endpoint, or
disable it without restarting `coderd`.
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.
**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.
For client-side setup (proxy, certificates, IDE configuration), see
[GitHub Copilot client configuration](./clients/copilot.md).
### ChatGPT
Configure a ChatGPT provider by creating an `openai`-typed instance with the
ChatGPT Codex base URL:
```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
```
</div>
> [!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
```
Each provider instance gets a unique route based on its `NAME`. Clients send
requests to `/api/v2/aibridge/<NAME>/` to target a specific instance:
| 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` |
**Supported keys per provider:**
| Key | Required | Description |
|------------|----------|------------------------------------------------------|
| `TYPE` | Yes | Provider type: `openai`, `anthropic`, or `copilot` |
| `NAME` | No | Unique instance name for routing. Defaults to `TYPE` |
| `KEY` | No | API key for upstream authentication (alias: `KEYS`) |
| `BASE_URL` | No | Base URL of the upstream API |
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
+3 -5
View File
@@ -51,12 +51,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
+1 -1
View File
@@ -17,7 +17,7 @@ Coder Tasks is Coder's platform for managing coding agents. With Coder Tasks, yo
![Tasks UI](../images/guides/ai-agents/tasks-ui.png)Coder Tasks Dashboard view to see all available tasks.
Coder Tasks allows you and your organization to build and automate workflows to fully leverage AI. Tasks operate through Coder Workspaces. We support interacting with an agent through the Task UI and CLI. Some Tasks can also be accessed through the Coder Workspace IDE; see [connect via an IDE](../user-guides/workspace-access).
Coder Tasks allows you and your organization to build and automate workflows to fully leverage AI. Tasks operate through Coder Workspaces. We support interacting with an agent through the Task UI and CLI. Some Tasks can also be accessed through the Coder Workspace IDE; see [connect via an IDE](../user-guides/workspace-access/index.md).
## Why Use Tasks?
+1 -1
View File
@@ -11,7 +11,7 @@ Coder Tasks is an interface for running & managing coding agents such as Claude
![Tasks UI](../images/guides/ai-agents/tasks-ui.png)
Coder Tasks is best for cases where the IDE is secondary, such as prototyping or running long-running background jobs. However, tasks run inside full workspaces so developers can [connect via an IDE](../user-guides/workspace-access) to take a task to completion.
Coder Tasks is best for cases where the IDE is secondary, such as prototyping or running long-running background jobs. However, tasks run inside full workspaces so developers can [connect via an IDE](../user-guides/workspace-access/index.md) to take a task to completion.
You can also interact with Coder Tasks from your IDE. The [Coder extension for VS Code](https://marketplace.visualstudio.com/items?itemName=coder.coder-remote) (and compatible forks like Cursor) enables you to create, monitor, and manage Tasks directly from the IDE, eliminating the need to context-switch to a browser. After logging in, you get access to a dedicated Tasks view in the sidebar that lets you select a template, configure parameters, prompt an agent, and track task status or download logs. Your tasks run in Coder workspaces with access to your repos, credentials, and internal network.
+2 -2
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

+1 -1
View File
@@ -12,7 +12,7 @@ For upgrade recommendations and troubleshooting, see
## Reinstall Coder to upgrade
To upgrade your Coder server, reinstall Coder using your original method
of [install](../install).
of [install](../install/index.md).
### Coder install script
+52 -21
View File
@@ -1007,6 +1007,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 +1150,99 @@
{
"title": "Setup",
"description": "How to set up and configure AI Gateway",
"path": "./ai-coder/ai-gateway/setup.md"
"path": "./ai-coder/ai-gateway/setup.md",
"state": ["ai governance add-on"]
},
{
"title": "Authentication",
"description": "Learn how to authenticate against AI Gateway",
"path": "./ai-coder/ai-gateway/auth.md"
"path": "./ai-coder/ai-gateway/auth.md",
"state": ["ai governance add-on"]
},
{
"title": "Client Configuration",
"description": "How to configure your AI coding tools to use AI Gateway",
"path": "./ai-coder/ai-gateway/clients/index.md",
"state": ["ai governance add-on"],
"children": [
{
"title": "Coder Agents",
"description": "Route Coder Agents traffic through AI Gateway",
"path": "./ai-coder/ai-gateway/clients/coder-agents.md"
"path": "./ai-coder/ai-gateway/clients/coder-agents.md",
"state": ["ai governance add-on"]
},
{
"title": "Claude Code",
"description": "Configure Claude Code to use AI Gateway",
"path": "./ai-coder/ai-gateway/clients/claude-code.md"
"path": "./ai-coder/ai-gateway/clients/claude-code.md",
"state": ["ai governance add-on"]
},
{
"title": "Codex",
"description": "Configure Codex to use AI Gateway",
"path": "./ai-coder/ai-gateway/clients/codex.md"
"path": "./ai-coder/ai-gateway/clients/codex.md",
"state": ["ai governance add-on"]
},
{
"title": "Mux",
"description": "Configure Mux to use AI Gateway",
"path": "./ai-coder/ai-gateway/clients/mux.md"
"path": "./ai-coder/ai-gateway/clients/mux.md",
"state": ["ai governance add-on"]
},
{
"title": "OpenCode",
"description": "Configure OpenCode to use AI Gateway",
"path": "./ai-coder/ai-gateway/clients/opencode.md"
"path": "./ai-coder/ai-gateway/clients/opencode.md",
"state": ["ai governance add-on"]
},
{
"title": "Factory",
"description": "Configure Factory to use AI Gateway",
"path": "./ai-coder/ai-gateway/clients/factory.md"
"path": "./ai-coder/ai-gateway/clients/factory.md",
"state": ["ai governance add-on"]
},
{
"title": "Cline",
"description": "Configure Cline to use AI Gateway",
"path": "./ai-coder/ai-gateway/clients/cline.md"
"path": "./ai-coder/ai-gateway/clients/cline.md",
"state": ["ai governance add-on"]
},
{
"title": "Kilo Code",
"description": "Configure Kilo Code to use AI Gateway",
"path": "./ai-coder/ai-gateway/clients/kilo-code.md"
"path": "./ai-coder/ai-gateway/clients/kilo-code.md",
"state": ["ai governance add-on"]
},
{
"title": "VS Code",
"description": "Configure VS Code to use AI Gateway",
"path": "./ai-coder/ai-gateway/clients/vscode.md"
"path": "./ai-coder/ai-gateway/clients/vscode.md",
"state": ["ai governance add-on"]
},
{
"title": "JetBrains",
"description": "Configure JetBrains IDEs to use AI Gateway",
"path": "./ai-coder/ai-gateway/clients/jetbrains.md"
"path": "./ai-coder/ai-gateway/clients/jetbrains.md",
"state": ["ai governance add-on"]
},
{
"title": "Zed",
"description": "Configure Zed to use AI Gateway",
"path": "./ai-coder/ai-gateway/clients/zed.md"
"path": "./ai-coder/ai-gateway/clients/zed.md",
"state": ["ai governance add-on"]
},
{
"title": "GitHub Copilot",
"description": "Configure GitHub Copilot to use AI Gateway via AI Gateway Proxy",
"path": "./ai-coder/ai-gateway/clients/copilot.md"
"path": "./ai-coder/ai-gateway/clients/copilot.md",
"state": ["ai governance add-on"]
}
]
},
{
"title": "MCP Tools Injection",
"description": "How to configure MCP servers for tools injection through AI Gateway",
"path": "./ai-coder/ai-gateway/mcp.md",
"state": ["early access"]
"path": "./ai-coder/ai-gateway/mcp.md"
},
{
"title": "AI Gateway Proxy",
@@ -1233,31 +1253,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"]
}
]
},
+10 -10
View File
@@ -1743,7 +1743,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 +1752,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 +1763,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 +1772,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 +1782,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 +1792,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 +1801,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 +1810,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 +1821,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 +1832,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
+2 -2
View File
@@ -67,7 +67,7 @@ This starter template lets you connect to your workspace in a few ways:
- VS Code Desktop: Loads your workspace into
[VS Code Desktop](https://code.visualstudio.com/Download) installed on your
local computer.
- code-server: Opens [browser-based VS Code](../ides/web-ides.md) with your
- code-server: Opens [browser-based VS Code](../user-guides/workspace-access/web-ides.md) with your
workspace.
- Terminal: Opens a browser-based terminal with a shell in the workspace's
Docker instance.
@@ -77,7 +77,7 @@ This starter template lets you connect to your workspace in a few ways:
> [!TIP]
> You can edit the template to let developers connect to a workspace in
> [a few more ways](../ides.md).
> [a few more ways](../user-guides/workspace-access/index.md).
When you're done, you can stop the workspace. -->
+1 -1
View File
@@ -16,7 +16,7 @@ repository.
## Content
Defer to our [Contributing/Documentation](../contributing/documentation.md) page
Defer to our [Contributing/Documentation](../about/contributing/documentation.md) page
for rules on technical writing.
### Adding Photos
-5
View File
@@ -277,7 +277,6 @@ data "coder_external_auth" "github" {
data "coder_workspace" "me" {}
data "coder_workspace_owner" "me" {}
data "coder_task" "me" {}
data "coder_workspace_tags" "tags" {
tags = {
"cluster" : "dogfood-v2"
@@ -991,10 +990,6 @@ resource "coder_metadata" "container_info" {
key = "region"
value = data.coder_parameter.region.option[index(data.coder_parameter.region.option.*.value, data.coder_parameter.region.value)].name
}
item {
key = "ai_task"
value = data.coder_task.me.enabled ? "yes" : "no"
}
}
resource "coder_script" "boundary_config_setup" {
+17 -89
View File
@@ -204,7 +204,6 @@ data "coder_external_auth" "github" {
data "coder_workspace" "me" {}
data "coder_workspace_owner" "me" {}
data "coder_task" "me" {}
data "coder_workspace_tags" "tags" {
tags = {
"cluster" : "dogfood-v2"
@@ -541,99 +540,28 @@ resource "coder_metadata" "container_info" {
key = "region"
value = data.coder_parameter.region.option[index(data.coder_parameter.region.option.*.value, data.coder_parameter.region.value)].name
}
item {
key = "ai_task"
value = data.coder_task.me.enabled ? "yes" : "no"
}
}
# --- AI task support ---
locals {
claude_system_prompt = <<-EOT
-- Framing --
You are a helpful coding assistant working on the coder/vscode-coder
VS Code extension. Aim to autonomously investigate and solve issues
the user gives you and test your work, whenever possible.
Avoid shortcuts like mocking tests. When you get stuck, you can ask
the user but opt for autonomy.
-- Tool Selection --
- Built-in tools for everything:
(file operations, git commands, builds & installs, one-off shell commands)
-- Testing --
Integration tests launch a real VS Code instance and require a
virtual framebuffer. Run them headlessly with:
xvfb-run -a pnpm test:integration
This matches how CI runs them. Unit tests do not need xvfb-run:
pnpm test
-- Workflow --
When starting new work:
1. If given a GitHub issue URL, use the `gh` CLI to read the full
issue details with `gh issue view <issue-number>`.
2. Create a feature branch for the work using a descriptive name
based on the issue or task.
Example: `git checkout -b fix/issue-123-ssh-retry`
3. Proceed with implementation following the AGENTS.md guidelines.
-- Context --
This is the coder/vscode-coder VS Code extension. It is a real-world
production extension used by developers to connect to Coder workspaces.
Be sure to read AGENTS.md before making any changes.
EOT
}
module "claude-code" {
count = data.coder_task.me.enabled ? data.coder_workspace.me.start_count : 0
source = "dev.registry.coder.com/coder/claude-code/coder"
version = "4.9.2"
enable_boundary = true
agent_id = coder_agent.dev.id
workdir = local.repo_dir
claude_code_version = "latest"
model = "opus"
order = 999
claude_api_key = data.coder_parameter.use_ai_bridge.value ? data.coder_workspace_owner.me.session_token : var.anthropic_api_key
agentapi_version = "latest"
system_prompt = local.claude_system_prompt
ai_prompt = data.coder_task.me.prompt
count = data.coder_workspace.me.start_count
source = "dev.registry.coder.com/coder/claude-code/coder"
version = "5.2.0"
enable_ai_gateway = data.coder_parameter.use_ai_bridge.value
anthropic_api_key = data.coder_parameter.use_ai_bridge.value ? "" : var.anthropic_api_key
agent_id = coder_agent.dev.id
workdir = local.repo_dir
}
resource "coder_ai_task" "task" {
count = data.coder_task.me.enabled ? data.coder_workspace.me.start_count : 0
app_id = module.claude-code[count.index].task_app_id
}
resource "coder_app" "watch" {
count = data.coder_task.me.enabled ? data.coder_workspace.me.start_count : 0
resource "coder_app" "claude" {
agent_id = coder_agent.dev.id
slug = "watch"
display_name = "pnpm watch"
icon = "${data.coder_workspace.me.access_url}/icon/code.svg"
command = "screen -x pnpm_watch"
share = "authenticated"
open_in = "tab"
order = 0
}
resource "coder_script" "watch" {
count = data.coder_task.me.enabled ? data.coder_workspace.me.start_count : 0
display_name = "pnpm watch"
agent_id = coder_agent.dev.id
run_on_start = true
start_blocks_login = false
icon = "${data.coder_workspace.me.access_url}/icon/code.svg"
script = <<-EOT
#!/usr/bin/env bash
set -eux -o pipefail
trap 'coder exp sync complete pnpm-watch' EXIT
coder exp sync want pnpm-watch install-deps
coder exp sync start pnpm-watch
cd "${local.repo_dir}" && screen -dmS pnpm_watch /bin/sh -c 'while true; do pnpm watch; echo "pnpm watch exited with code $? restarting in 10s"; sleep 10; done'
slug = "claude"
display_name = "Claude Code"
icon = "/icon/claude.svg"
open_in = "slim-window"
command = <<-EOT
#!/bin/bash
set -e
cd "${local.repo_dir}"
exec tmux new-session -A -s claude claude
EOT
}
+33 -2
View File
@@ -1076,9 +1076,11 @@ func (s *Server) handleResponse(resp *http.Response, ctx *goproxy.ProxyCtx) *htt
switch {
case resp.StatusCode >= http.StatusInternalServerError:
logger.Error(s.ctx, "received error response from aibridged")
logger.Error(s.ctx, "received error response from aibridged",
slog.F("response_body", s.readErrorBodyForLog(resp, logger)))
case resp.StatusCode >= http.StatusBadRequest:
logger.Warn(s.ctx, "received error response from aibridged")
logger.Warn(s.ctx, "received error response from aibridged",
slog.F("response_body", s.readErrorBodyForLog(resp, logger)))
default:
logger.Debug(s.ctx, "received response from aibridged")
}
@@ -1101,6 +1103,35 @@ func (s *Server) handleResponse(resp *http.Response, ctx *goproxy.ProxyCtx) *htt
return resp
}
// maxLoggedErrorBodyBytes bounds how much of an aibridged error response
// body is rendered into a log line, so a large upstream error payload
// cannot blow up log volume.
const maxLoggedErrorBodyBytes = 16 << 10 // 16 KiB
// readErrorBodyForLog reads resp.Body for diagnostic logging and restores
// it with an equivalent reader, so the proxy still forwards the body
// downstream and the response dumper can read it again. The returned
// string is truncated to maxLoggedErrorBodyBytes; the restored body is
// always complete.
func (s *Server) readErrorBodyForLog(resp *http.Response, logger slog.Logger) string {
if resp.Body == nil {
return ""
}
body, err := io.ReadAll(resp.Body)
_ = resp.Body.Close()
// Restore the full body even on a read error: the proxy and dumper
// downstream still expect a readable body, and a partial body is
// better than a nil one.
resp.Body = io.NopCloser(bytes.NewReader(body))
if err != nil {
logger.Warn(s.ctx, "failed to read aibridged error response body", slog.Error(err))
}
if len(body) > maxLoggedErrorBodyBytes {
return string(body[:maxLoggedErrorBodyBytes]) + "...(truncated)"
}
return string(body)
}
// Handler returns an HTTP handler for the AI Bridge Proxy's HTTP endpoints.
// This is separate from the proxy server itself and is used by coderd to
// serve endpoints like the CA certificate.
@@ -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))
})
}
+44 -18
View File
@@ -125,35 +125,55 @@ AI GATEWAY OPTIONS:
disabled, only centralized key authentication is permitted.
--ai-gateway-anthropic-base-url string, $CODER_AI_GATEWAY_ANTHROPIC_BASE_URL (default: https://api.anthropic.com/)
The base URL of the Anthropic API.
Deprecated: manage AI Providers from the Coder UI or HTTP API. If set,
this option seeds provider configuration at startup only exactly once.
It will not be used in service runtime. The base URL of the Anthropic
API.
--ai-gateway-anthropic-key string, $CODER_AI_GATEWAY_ANTHROPIC_KEY
The key to authenticate against the Anthropic API.
Deprecated: manage AI Providers from the Coder UI or HTTP API. If set,
this option seeds provider configuration at startup only exactly once.
It will not be used in service runtime. The key to authenticate
against the Anthropic API.
--ai-gateway-bedrock-access-key string, $CODER_AI_GATEWAY_BEDROCK_ACCESS_KEY
The access key to authenticate against the AWS Bedrock API.
--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
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
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 +192,16 @@ AI GATEWAY OPTIONS:
to disable (unlimited).
--ai-gateway-openai-base-url string, $CODER_AI_GATEWAY_OPENAI_BASE_URL (default: https://api.openai.com/v1/)
The base URL of the OpenAI API.
Deprecated: manage AI Providers from the Coder UI or HTTP API. If set,
this option seeds provider configuration at startup only exactly once.
It will not be used in service runtime. The base URL of the OpenAI
API.
--ai-gateway-openai-key string, $CODER_AI_GATEWAY_OPENAI_KEY
The key to authenticate against the OpenAI API.
Deprecated: manage AI Providers from the Coder UI or HTTP API. If set,
this option seeds provider configuration at startup only exactly once.
It will not be used in service runtime. The key to authenticate
against the OpenAI API.
--ai-gateway-rate-limit int, $CODER_AI_GATEWAY_RATE_LIMIT (default: 0)
Maximum number of AI Gateway requests per second per replica. Set to 0
+41
View File
@@ -61,6 +61,30 @@
inherit nodejs; # Ensure it points to the above nodejs version
};
mise = pkgs.stdenvNoCC.mkDerivation rec {
pname = "mise";
version = "2026.5.12";
target = {
x86_64-linux = "linux-x64";
aarch64-linux = "linux-arm64";
x86_64-darwin = "macos-x64";
aarch64-darwin = "macos-arm64";
}.${system};
src = pkgs.fetchurl {
url = "https://github.com/jdx/mise/releases/download/v${version}/mise-v${version}-${target}";
hash = {
x86_64-linux = "sha256-ojiXKjFi1xC4WyjDJDculspOS0hsgf54aVAA2fvHfEg=";
aarch64-linux = "sha256-/S1SJ6itCx41nHBSeoNFqa2nIHf43LtVk3FlPD2VRk8=";
x86_64-darwin = "sha256-3lfo3IK72ICmnJvIruBrncxXgYSz5c+G/O+AY11qkLQ=";
aarch64-darwin = "sha256-53cHBUD/4iz4srn4iu2ItGHQiH2UDE8cGpc1lGPN5uE=";
}.${system};
};
dontUnpack = true;
installPhase = ''
install -Dm755 "$src" "$out/bin/mise"
'';
};
# Check in https://search.nixos.org/packages to find new packages.
# Use `nix --extra-experimental-features nix-command --extra-experimental-features flakes flake update`
# to update the lock file if packages are out-of-date.
@@ -109,6 +133,21 @@
vendorHash = "sha256-4Cb15MhKyhRvYVKfMqBwuC3WBBIJE6AinJt02+TSMVY=";
};
paralleltestctx = unstablePkgs.buildGo126Module {
pname = "paralleltestctx";
version = "0.0.2";
src = pkgs.fetchFromGitHub {
owner = "coder";
repo = "paralleltestctx";
rev = "v0.0.2";
sha256 = "sha256-qFQ4LZR2IwqscypD0URSZKXTlhUcz/axDb8NTH5CxLw=";
};
subPackages = [ "cmd/paralleltestctx" ];
vendorHash = "sha256-OuQWmZmofdJKq1hvk43RPkILQwAuFzqhmB22Xf6Z3lA=";
};
# Keep Terraform aligned with provisioner/terraform/testdata/version.txt
# so `make gen` remains deterministic in Nix shells.
terraform_1_15_5 =
@@ -188,6 +227,7 @@
lazydocker
lazygit
less
mise
unstablePkgs.mockgen
moreutils
nfpm
@@ -195,6 +235,7 @@
nodejs
openssh
openssl
paralleltestctx
pango
pixman
pkg-config
-5
View File
@@ -571,7 +571,6 @@ require (
github.com/clipperhouse/displaywidth v0.10.0 // indirect
github.com/clipperhouse/uax29/v2 v2.6.0 // indirect
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect
github.com/coder/paralleltestctx v0.0.2 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
@@ -618,7 +617,6 @@ require (
github.com/lestrrat-go/httprc/v3 v3.0.5 // indirect
github.com/lestrrat-go/jwx/v3 v3.1.1 // indirect
github.com/lestrrat-go/option/v2 v2.0.0 // indirect
github.com/mattn/go-shellwords v1.0.12 // indirect
github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76 // indirect
github.com/moby/moby/api v1.54.0 // indirect
github.com/moby/moby/client v0.3.0 // indirect
@@ -632,7 +630,6 @@ require (
github.com/pb33f/ordered-map/v2 v2.3.1 // indirect
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect
github.com/rhysd/actionlint v1.7.10 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/samber/lo v1.52.0 // indirect
github.com/segmentio/asm v1.2.1 // indirect
@@ -664,9 +661,7 @@ require (
)
tool (
github.com/coder/paralleltestctx/cmd/paralleltestctx
github.com/daixiang0/gci
github.com/rhysd/actionlint/cmd/actionlint
github.com/swaggo/swag/cmd/swag
go.uber.org/mock/mockgen
golang.org/x/tools/cmd/goimports
-6
View File
@@ -334,8 +334,6 @@ 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/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=
github.com/coder/pq v1.10.5-0.20250807075151-6ad9b0a25151/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0 h1:3A0ES21Ke+FxEM8CXx9n47SZOKOpgSE1bbJzlE4qPVs=
@@ -881,8 +879,6 @@ github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+Ei
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk=
github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw=
@@ -1063,8 +1059,6 @@ github.com/quasilyte/go-ruleguard/dsl v0.3.23 h1:lxjt5B6ZCiBeeNO8/oQsegE6fLeCzuM
github.com/quasilyte/go-ruleguard/dsl v0.3.23/go.mod h1:KeCP03KrjuSO0H1kTuZQCWlQPulDV6YMIXmpQss17rU=
github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 h1:bsUq1dX0N8AOIL7EB/X911+m4EHsnWEHeJ0c+3TTBrg=
github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
github.com/rhysd/actionlint v1.7.10 h1:FL3XIEs72G4/++168vlv5FKOWMSWvWIQw1kBCadyOcM=
github.com/rhysd/actionlint v1.7.10/go.mod h1:ZHX/hrmknlsJN73InPTKsKdXpAv9wVdrJy8h8HAwFHg=
github.com/riandyrn/otelchi v0.5.1 h1:0/45omeqpP7f/cvdL16GddQBfAEmZvUyl2QzLSE6uYo=
github.com/riandyrn/otelchi v0.5.1/go.mod h1:ZxVxNEl+jQ9uHseRYIxKWRb3OY8YXFEu+EkNiiSNUEA=
github.com/richardartoul/molecule v1.0.1-0.20240531184615-7ca0df43c0b3 h1:4+LEVOB87y175cLJC/mbsgKmoDOjrBldtXvioEy96WY=
+148 -71
View File
@@ -1,5 +1,53 @@
# @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html
[[tools.actionlint]]
version = "1.7.10"
backend = "aqua:rhysd/actionlint"
[tools.actionlint."platforms.linux-arm64"]
checksum = "sha256:cd3dfe5f66887ec6b987752d8d9614e59fd22f39415c5ad9f28374623f41773a"
url = "https://github.com/rhysd/actionlint/releases/download/v1.7.10/actionlint_1.7.10_linux_arm64.tar.gz"
[tools.actionlint."platforms.linux-arm64-musl"]
checksum = "sha256:cd3dfe5f66887ec6b987752d8d9614e59fd22f39415c5ad9f28374623f41773a"
url = "https://github.com/rhysd/actionlint/releases/download/v1.7.10/actionlint_1.7.10_linux_arm64.tar.gz"
[tools.actionlint."platforms.linux-x64"]
checksum = "sha256:f4c76b71db5755a713e6055cbb0857ed07e103e028bda117817660ebadb4386f"
url = "https://github.com/rhysd/actionlint/releases/download/v1.7.10/actionlint_1.7.10_linux_amd64.tar.gz"
[tools.actionlint."platforms.linux-x64-baseline"]
checksum = "sha256:f4c76b71db5755a713e6055cbb0857ed07e103e028bda117817660ebadb4386f"
url = "https://github.com/rhysd/actionlint/releases/download/v1.7.10/actionlint_1.7.10_linux_amd64.tar.gz"
[tools.actionlint."platforms.linux-x64-musl"]
checksum = "sha256:f4c76b71db5755a713e6055cbb0857ed07e103e028bda117817660ebadb4386f"
url = "https://github.com/rhysd/actionlint/releases/download/v1.7.10/actionlint_1.7.10_linux_amd64.tar.gz"
[tools.actionlint."platforms.linux-x64-musl-baseline"]
checksum = "sha256:f4c76b71db5755a713e6055cbb0857ed07e103e028bda117817660ebadb4386f"
url = "https://github.com/rhysd/actionlint/releases/download/v1.7.10/actionlint_1.7.10_linux_amd64.tar.gz"
[tools.actionlint."platforms.macos-arm64"]
checksum = "sha256:004ca87b367b37f4d75c55ab6cf80f9b8c043adbfbd440f31c604d417939c442"
url = "https://github.com/rhysd/actionlint/releases/download/v1.7.10/actionlint_1.7.10_darwin_arm64.tar.gz"
[tools.actionlint."platforms.macos-x64"]
checksum = "sha256:16782c41f2af264db80f855ee5d09164ca98fc78edf3bcd0f46eecff279682ba"
url = "https://github.com/rhysd/actionlint/releases/download/v1.7.10/actionlint_1.7.10_darwin_amd64.tar.gz"
[tools.actionlint."platforms.macos-x64-baseline"]
checksum = "sha256:16782c41f2af264db80f855ee5d09164ca98fc78edf3bcd0f46eecff279682ba"
url = "https://github.com/rhysd/actionlint/releases/download/v1.7.10/actionlint_1.7.10_darwin_amd64.tar.gz"
[tools.actionlint."platforms.windows-x64"]
checksum = "sha256:283467f9d6202a8cb8c00ad8dd0ee4e685b71fb86a6a56c68fcbb9ae8ed91237"
url = "https://github.com/rhysd/actionlint/releases/download/v1.7.10/actionlint_1.7.10_windows_amd64.zip"
[tools.actionlint."platforms.windows-x64-baseline"]
checksum = "sha256:283467f9d6202a8cb8c00ad8dd0ee4e685b71fb86a6a56c68fcbb9ae8ed91237"
url = "https://github.com/rhysd/actionlint/releases/download/v1.7.10/actionlint_1.7.10_windows_amd64.zip"
[[tools."aqua:ahmetb/kubectx/kubens"]]
version = "0.9.4"
backend = "aqua:ahmetb/kubectx/kubens"
@@ -432,14 +480,26 @@ url = "https://dl.google.com/go/go1.26.2.windows-amd64.zip"
checksum = "sha256:98eb3570bade15cb826b0909338df6cc6d2cf590bc39c471142002db3832b708"
url = "https://dl.google.com/go/go1.26.2.windows-amd64.zip"
[[tools."go:github.com/coder/paralleltestctx/cmd/paralleltestctx"]]
version = "0.0.2"
backend = "go:github.com/coder/paralleltestctx/cmd/paralleltestctx"
[[tools."go:github.com/coder/sqlc/cmd/sqlc"]]
version = "337309bfb9524f38466a5090e310040fc7af0203"
backend = "go:github.com/coder/sqlc/cmd/sqlc"
[[tools."go:github.com/coder/whichtests"]]
version = "ec33bab1ec04cd86beb7a61a069db4463dba63f5"
backend = "go:github.com/coder/whichtests"
[[tools."go:github.com/golang-migrate/migrate/v4/cmd/migrate"]]
version = "v4.19.0"
backend = "go:github.com/golang-migrate/migrate/v4/cmd/migrate"
[[tools."go:github.com/golangci/golangci-lint/cmd/golangci-lint"]]
version = "1.64.8"
backend = "go:github.com/golangci/golangci-lint/cmd/golangci-lint"
[[tools."go:github.com/goreleaser/nfpm/v2/cmd/nfpm"]]
version = "v2.35.1"
backend = "go:github.com/goreleaser/nfpm/v2/cmd/nfpm"
@@ -452,10 +512,18 @@ backend = "go:github.com/mikefarah/yq/v4"
version = "v0.3.13"
backend = "go:github.com/quasilyte/go-ruleguard/cmd/ruleguard"
[[tools."go:github.com/slsyy/mtimehash/cmd/mtimehash"]]
version = "1.0.0"
backend = "go:github.com/slsyy/mtimehash/cmd/mtimehash"
[[tools."go:github.com/swaggo/swag/cmd/swag"]]
version = "v1.16.2"
backend = "go:github.com/swaggo/swag/cmd/swag"
[[tools."go:github.com/tc-hib/go-winres"]]
version = "0.3.3"
backend = "go:github.com/tc-hib/go-winres"
[[tools."go:go.uber.org/mock/mockgen"]]
version = "v0.6.0"
backend = "go:go.uber.org/mock/mockgen"
@@ -480,54 +548,6 @@ backend = "go:mvdan.cc/sh/v3/cmd/shfmt"
version = "v0.0.34"
backend = "go:storj.io/drpc/cmd/protoc-gen-go-drpc"
[[tools.golangci-lint]]
version = "1.64.8"
backend = "aqua:golangci/golangci-lint"
[tools.golangci-lint."platforms.linux-arm64"]
checksum = "sha256:a6ab58ebcb1c48572622146cdaec2956f56871038a54ed1149f1386e287789a5"
url = "https://github.com/golangci/golangci-lint/releases/download/v1.64.8/golangci-lint-1.64.8-linux-arm64.tar.gz"
[tools.golangci-lint."platforms.linux-arm64-musl"]
checksum = "sha256:a6ab58ebcb1c48572622146cdaec2956f56871038a54ed1149f1386e287789a5"
url = "https://github.com/golangci/golangci-lint/releases/download/v1.64.8/golangci-lint-1.64.8-linux-arm64.tar.gz"
[tools.golangci-lint."platforms.linux-x64"]
checksum = "sha256:b6270687afb143d019f387c791cd2a6f1cb383be9b3124d241ca11bd3ce2e54e"
url = "https://github.com/golangci/golangci-lint/releases/download/v1.64.8/golangci-lint-1.64.8-linux-amd64.tar.gz"
[tools.golangci-lint."platforms.linux-x64-baseline"]
checksum = "sha256:b6270687afb143d019f387c791cd2a6f1cb383be9b3124d241ca11bd3ce2e54e"
url = "https://github.com/golangci/golangci-lint/releases/download/v1.64.8/golangci-lint-1.64.8-linux-amd64.tar.gz"
[tools.golangci-lint."platforms.linux-x64-musl"]
checksum = "sha256:b6270687afb143d019f387c791cd2a6f1cb383be9b3124d241ca11bd3ce2e54e"
url = "https://github.com/golangci/golangci-lint/releases/download/v1.64.8/golangci-lint-1.64.8-linux-amd64.tar.gz"
[tools.golangci-lint."platforms.linux-x64-musl-baseline"]
checksum = "sha256:b6270687afb143d019f387c791cd2a6f1cb383be9b3124d241ca11bd3ce2e54e"
url = "https://github.com/golangci/golangci-lint/releases/download/v1.64.8/golangci-lint-1.64.8-linux-amd64.tar.gz"
[tools.golangci-lint."platforms.macos-arm64"]
checksum = "sha256:70543d21e5b02a94079be8aa11267a5b060865583e337fe768d39b5d3e2faf1f"
url = "https://github.com/golangci/golangci-lint/releases/download/v1.64.8/golangci-lint-1.64.8-darwin-arm64.tar.gz"
[tools.golangci-lint."platforms.macos-x64"]
checksum = "sha256:b52aebb8cb51e00bfd5976099083fbe2c43ef556cef9c87e58a8ae656e740444"
url = "https://github.com/golangci/golangci-lint/releases/download/v1.64.8/golangci-lint-1.64.8-darwin-amd64.tar.gz"
[tools.golangci-lint."platforms.macos-x64-baseline"]
checksum = "sha256:b52aebb8cb51e00bfd5976099083fbe2c43ef556cef9c87e58a8ae656e740444"
url = "https://github.com/golangci/golangci-lint/releases/download/v1.64.8/golangci-lint-1.64.8-darwin-amd64.tar.gz"
[tools.golangci-lint."platforms.windows-x64"]
checksum = "sha256:54c2ed3a6b4f2f5da1056fb6e83d6b73b592e06684b65a5999174fabbb251a8f"
url = "https://github.com/golangci/golangci-lint/releases/download/v1.64.8/golangci-lint-1.64.8-windows-amd64.zip"
[tools.golangci-lint."platforms.windows-x64-baseline"]
checksum = "sha256:54c2ed3a6b4f2f5da1056fb6e83d6b73b592e06684b65a5999174fabbb251a8f"
url = "https://github.com/golangci/golangci-lint/releases/download/v1.64.8/golangci-lint-1.64.8-windows-amd64.zip"
[[tools.helm]]
version = "3.21.0"
backend = "aqua:helm/helm"
@@ -723,6 +743,10 @@ url = "https://nodejs.org/dist/v22.19.0/node-v22.19.0-win-x64.zip"
version = "0.87.0"
backend = "npm:@devcontainers/cli"
[[tools."npm:@puppeteer/browsers"]]
version = "2.13.0"
backend = "npm:@puppeteer/browsers"
[[tools.pnpm]]
version = "10.33.2"
backend = "aqua:pnpm/pnpm"
@@ -848,52 +872,52 @@ url = "https://github.com/protocolbuffers/protobuf-go/releases/download/v1.30.0/
url = "https://github.com/protocolbuffers/protobuf-go/releases/download/v1.30.0/protoc-gen-go.v1.30.0.windows.amd64.zip"
[[tools.syft]]
version = "1.20.0"
version = "1.26.1"
backend = "aqua:anchore/syft"
[tools.syft."platforms.linux-arm64"]
checksum = "sha256:53f76737ddbf425c89240d5b0be0990b1a71e66890b44f19743221b17e6ee635"
url = "https://github.com/anchore/syft/releases/download/v1.20.0/syft_1.20.0_linux_arm64.tar.gz"
checksum = "sha256:ed3915cbc9c039f0501cb49d4485125befbd729acc263e767f70a18de3fec10d"
url = "https://github.com/anchore/syft/releases/download/v1.26.1/syft_1.26.1_linux_arm64.tar.gz"
[tools.syft."platforms.linux-arm64-musl"]
checksum = "sha256:53f76737ddbf425c89240d5b0be0990b1a71e66890b44f19743221b17e6ee635"
url = "https://github.com/anchore/syft/releases/download/v1.20.0/syft_1.20.0_linux_arm64.tar.gz"
checksum = "sha256:ed3915cbc9c039f0501cb49d4485125befbd729acc263e767f70a18de3fec10d"
url = "https://github.com/anchore/syft/releases/download/v1.26.1/syft_1.26.1_linux_arm64.tar.gz"
[tools.syft."platforms.linux-x64"]
checksum = "sha256:689e12c5cbf67521ce61b9c126068f9eaabe1223e77971b2fede50033ff6b5cc"
url = "https://github.com/anchore/syft/releases/download/v1.20.0/syft_1.20.0_linux_amd64.tar.gz"
checksum = "sha256:4f3e84f9467080c876deb0fa968da54309c6d21fb8c00fd3a4e547eb9f006835"
url = "https://github.com/anchore/syft/releases/download/v1.26.1/syft_1.26.1_linux_amd64.tar.gz"
[tools.syft."platforms.linux-x64-baseline"]
checksum = "sha256:689e12c5cbf67521ce61b9c126068f9eaabe1223e77971b2fede50033ff6b5cc"
url = "https://github.com/anchore/syft/releases/download/v1.20.0/syft_1.20.0_linux_amd64.tar.gz"
checksum = "sha256:4f3e84f9467080c876deb0fa968da54309c6d21fb8c00fd3a4e547eb9f006835"
url = "https://github.com/anchore/syft/releases/download/v1.26.1/syft_1.26.1_linux_amd64.tar.gz"
[tools.syft."platforms.linux-x64-musl"]
checksum = "sha256:689e12c5cbf67521ce61b9c126068f9eaabe1223e77971b2fede50033ff6b5cc"
url = "https://github.com/anchore/syft/releases/download/v1.20.0/syft_1.20.0_linux_amd64.tar.gz"
checksum = "sha256:4f3e84f9467080c876deb0fa968da54309c6d21fb8c00fd3a4e547eb9f006835"
url = "https://github.com/anchore/syft/releases/download/v1.26.1/syft_1.26.1_linux_amd64.tar.gz"
[tools.syft."platforms.linux-x64-musl-baseline"]
checksum = "sha256:689e12c5cbf67521ce61b9c126068f9eaabe1223e77971b2fede50033ff6b5cc"
url = "https://github.com/anchore/syft/releases/download/v1.20.0/syft_1.20.0_linux_amd64.tar.gz"
checksum = "sha256:4f3e84f9467080c876deb0fa968da54309c6d21fb8c00fd3a4e547eb9f006835"
url = "https://github.com/anchore/syft/releases/download/v1.26.1/syft_1.26.1_linux_amd64.tar.gz"
[tools.syft."platforms.macos-arm64"]
checksum = "sha256:91365712a06af0c0dcd06f5e87fc8791c4332831b3dd6f5474acaaf803d71d82"
url = "https://github.com/anchore/syft/releases/download/v1.20.0/syft_1.20.0_darwin_arm64.tar.gz"
checksum = "sha256:00435a3fe2ae940203708ee2eae9976d1719982c628d30b2b78aacd36133ec6b"
url = "https://github.com/anchore/syft/releases/download/v1.26.1/syft_1.26.1_darwin_arm64.tar.gz"
[tools.syft."platforms.macos-x64"]
checksum = "sha256:5fdf7afd0f1bfdbb2a1a575eacef8e10edfcb4783631baaa7572a9f4a4d86441"
url = "https://github.com/anchore/syft/releases/download/v1.20.0/syft_1.20.0_darwin_amd64.tar.gz"
checksum = "sha256:2eae0b76a208c5916cf02847b94e861024c7a5a6c1e2e606f5436f97747b1f76"
url = "https://github.com/anchore/syft/releases/download/v1.26.1/syft_1.26.1_darwin_amd64.tar.gz"
[tools.syft."platforms.macos-x64-baseline"]
checksum = "sha256:5fdf7afd0f1bfdbb2a1a575eacef8e10edfcb4783631baaa7572a9f4a4d86441"
url = "https://github.com/anchore/syft/releases/download/v1.20.0/syft_1.20.0_darwin_amd64.tar.gz"
checksum = "sha256:2eae0b76a208c5916cf02847b94e861024c7a5a6c1e2e606f5436f97747b1f76"
url = "https://github.com/anchore/syft/releases/download/v1.26.1/syft_1.26.1_darwin_amd64.tar.gz"
[tools.syft."platforms.windows-x64"]
checksum = "sha256:b8bfdedb261de2a69768097422a73bc72273ee92136ff676a20c3161e658881f"
url = "https://github.com/anchore/syft/releases/download/v1.20.0/syft_1.20.0_windows_amd64.zip"
checksum = "sha256:7af7acb9f81bdddbc343855cb3a42e1d38ae9a1b044bfcd9b975a118d107849e"
url = "https://github.com/anchore/syft/releases/download/v1.26.1/syft_1.26.1_windows_amd64.zip"
[tools.syft."platforms.windows-x64-baseline"]
checksum = "sha256:b8bfdedb261de2a69768097422a73bc72273ee92136ff676a20c3161e658881f"
url = "https://github.com/anchore/syft/releases/download/v1.20.0/syft_1.20.0_windows_amd64.zip"
checksum = "sha256:7af7acb9f81bdddbc343855cb3a42e1d38ae9a1b044bfcd9b975a118d107849e"
url = "https://github.com/anchore/syft/releases/download/v1.26.1/syft_1.26.1_windows_amd64.zip"
[[tools.terraform]]
version = "1.15.5"
@@ -942,3 +966,56 @@ url = "https://releases.hashicorp.com/terraform/1.15.5/terraform_1.15.5_windows_
[tools.terraform."platforms.windows-x64-baseline"]
checksum = "sha256:2f652dd854af7b7fbb51301afc55b5ef1d3f6e287be7889d4cc3818df891cd38"
url = "https://releases.hashicorp.com/terraform/1.15.5/terraform_1.15.5_windows_amd64.zip"
[[tools.zizmor]]
version = "1.11.0"
backend = "aqua:zizmorcore/zizmor"
[tools.zizmor."platforms.linux-arm64"]
checksum = "sha256:ce6d71e796b7d3663449151b08cee7c659f89bf36095c432e25169c857f479f0"
url = "https://github.com/zizmorcore/zizmor/releases/download/v1.11.0/zizmor-aarch64-unknown-linux-gnu.tar.gz"
provenance = "github-attestations"
[tools.zizmor."platforms.linux-arm64-musl"]
provenance = "github-attestations"
[tools.zizmor."platforms.linux-x64"]
checksum = "sha256:da35e666827cbb1e6ca98b18b7969657b9f186467bfebfa25e730aac527c36f8"
url = "https://github.com/zizmorcore/zizmor/releases/download/v1.11.0/zizmor-x86_64-unknown-linux-gnu.tar.gz"
provenance = "github-attestations"
[tools.zizmor."platforms.linux-x64-baseline"]
checksum = "sha256:da35e666827cbb1e6ca98b18b7969657b9f186467bfebfa25e730aac527c36f8"
url = "https://github.com/zizmorcore/zizmor/releases/download/v1.11.0/zizmor-x86_64-unknown-linux-gnu.tar.gz"
provenance = "github-attestations"
[tools.zizmor."platforms.linux-x64-musl"]
provenance = "github-attestations"
[tools.zizmor."platforms.linux-x64-musl-baseline"]
provenance = "github-attestations"
[tools.zizmor."platforms.macos-arm64"]
checksum = "sha256:7cf59f08cb50f539ab9ddc6be1d463c81e31f5b189d148fc6f786adf9fc42a5f"
url = "https://github.com/zizmorcore/zizmor/releases/download/v1.11.0/zizmor-aarch64-apple-darwin.tar.gz"
provenance = "github-attestations"
[tools.zizmor."platforms.macos-x64"]
checksum = "sha256:a1f60dd09527ce546ff86e49ebfa1ab4a6c5d16365662e6932f8d0f46fbb18b2"
url = "https://github.com/zizmorcore/zizmor/releases/download/v1.11.0/zizmor-x86_64-apple-darwin.tar.gz"
provenance = "github-attestations"
[tools.zizmor."platforms.macos-x64-baseline"]
checksum = "sha256:a1f60dd09527ce546ff86e49ebfa1ab4a6c5d16365662e6932f8d0f46fbb18b2"
url = "https://github.com/zizmorcore/zizmor/releases/download/v1.11.0/zizmor-x86_64-apple-darwin.tar.gz"
provenance = "github-attestations"
[tools.zizmor."platforms.windows-x64"]
checksum = "sha256:35e038bdbde6fcfdf947c947c7c3fc83c5043e0ded0e5b0d59c30c8eda97fd3a"
url = "https://github.com/zizmorcore/zizmor/releases/download/v1.11.0/zizmor-x86_64-pc-windows-msvc.zip"
provenance = "github-attestations"
[tools.zizmor."platforms.windows-x64-baseline"]
checksum = "sha256:35e038bdbde6fcfdf947c947c7c3fc83c5043e0ded0e5b0d59c30c8eda97fd3a"
url = "https://github.com/zizmorcore/zizmor/releases/download/v1.11.0/zizmor-x86_64-pc-windows-msvc.zip"
provenance = "github-attestations"
+18 -4
View File
@@ -1,5 +1,6 @@
# Keep in lockstep with MISE_VERSION in dogfood/coder/ubuntu-*/Dockerfile.base,
# .github/workflows/dogfood.yaml, and scripts/dogfood/mise-oci-wrapper.sh.
# Keep in lockstep with .github/actions/setup-mise/action.yml,
# .github/actions/setup-mise/checksums.toml, flake.nix,
# dogfood/coder/ubuntu-*/Dockerfile.base, and scripts/dogfood/mise-oci-wrapper.sh.
min_version = "2026.5.12"
[settings]
@@ -19,8 +20,17 @@ protoc = "23.4"
protoc-gen-go = "1.30.0"
# Go development tools.
"go:github.com/coder/paralleltestctx/cmd/paralleltestctx" = "v0.0.2"
"go:github.com/coder/whichtests" = "ec33bab1ec04cd86beb7a61a069db4463dba63f5"
# Keep golangci-lint on the Go backend while pinned to v1. The upstream
# precompiled v1 binary is built with an older Go toolchain and cannot lint
# this module's Go version. Upgrading to v2 should let us use the native
# golangci-lint mise/aqua backend and GitHub release binaries.
"go:github.com/golangci/golangci-lint/cmd/golangci-lint" = "v1.64.8"
"go:github.com/golang-migrate/migrate/v4/cmd/migrate" = "v4.19.0"
"go:github.com/goreleaser/nfpm/v2/cmd/nfpm" = "v2.35.1"
"go:github.com/slsyy/mtimehash/cmd/mtimehash" = "v1.0.0"
"go:github.com/tc-hib/go-winres" = "v0.3.3"
"go:github.com/mikefarah/yq/v4" = "v4.44.3"
"go:github.com/quasilyte/go-ruleguard/cmd/ruleguard" = "v0.3.13"
"go:github.com/swaggo/swag/cmd/swag" = "v1.16.2"
@@ -30,17 +40,18 @@ protoc-gen-go = "1.30.0"
"go:mvdan.cc/sh/v3/cmd/shfmt" = "v3.12.0"
# Infrastructure, release, and lint CLIs.
actionlint = "1.7.10"
"aqua:ahmetb/kubectx/kubens" = "0.9.4"
cosign = "2.4.3"
# crane is the registry client `mise oci push` shells out to. Sourced
# here so it travels with the rest of the mise toolset (one source of
# truth, deterministic version, no apt drift across CI / wrapper).
crane = "0.21.6"
golangci-lint = "1.64.8"
helm = "3.21.0"
kubectx = "0.9.4"
syft = "1.20.0"
syft = "1.26.1"
terraform = "1.15.5"
zizmor = "1.11.0"
# Developer-environment niceties for the dogfood image. Non-dogfood
# users who run `mise install` here will pull these too; they are
@@ -60,6 +71,9 @@ lazygit = "0.61.1"
# Pre-installs the binary so the upstream devcontainers-cli coder
# module's `command -v devcontainer` short-circuit fires
"npm:@devcontainers/cli" = "0.87.0"
# weekly-docs uses this pinned Puppeteer browser installer to install Chrome for
# action-linkspector without resolving mutable npm metadata at runtime.
"npm:@puppeteer/browsers" = "2.13.0"
# sqlc (coder fork) bundles sqlite via cgo, so the `go install` build
# needs CGO_ENABLED=1. Scope it with `install_env` so it only applies
+1 -1
View File
@@ -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")
+36
View File
@@ -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
}
@@ -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"),
)
})
}
-7
View File
@@ -5,7 +5,6 @@
# - go.mod
# - mise.toml (the dogfood image installs from this manifest)
# - flake.nix
# - .github/actions/setup-go/action.yml
# The version of Go in go.mod is considered the source of truth.
set -euo pipefail
@@ -19,23 +18,17 @@ IGNORE_NIX=${IGNORE_NIX:-false}
GO_VERSION_GO_MOD=$(grep -Eo 'go [0-9]+\.[0-9]+\.[0-9]+' ./go.mod | cut -d' ' -f2)
GO_VERSION_MISE_TOML=$(grep -Eo '^go = "[0-9]+\.[0-9]+\.[0-9]+"' ./mise.toml | sed -E 's/.*"([^"]+)"/\1/')
GO_VERSION_SETUP_GO=$(yq '.inputs.version.default' .github/actions/setup-go/action.yaml)
GO_VERSION_FLAKE_NIX=$(grep -Eo '\bgo_[0-9]+_[0-9]+\b' ./flake.nix)
# Convert to major.minor format.
GO_VERSION_FLAKE_NIX_MAJOR_MINOR=$(echo "$GO_VERSION_FLAKE_NIX" | cut -d '_' -f 2-3 | tr '_' '.')
log "INFO : go.mod : $GO_VERSION_GO_MOD"
log "INFO : mise.toml : $GO_VERSION_MISE_TOML"
log "INFO : setup-go/action.yaml : $GO_VERSION_SETUP_GO"
log "INFO : flake.nix : $GO_VERSION_FLAKE_NIX_MAJOR_MINOR"
if [ "$GO_VERSION_GO_MOD" != "$GO_VERSION_MISE_TOML" ]; then
error "Go version mismatch between go.mod and mise.toml"
fi
if [ "$GO_VERSION_GO_MOD" != "$GO_VERSION_SETUP_GO" ]; then
error "Go version mismatch between go.mod and .github/actions/setup-go/action.yaml"
fi
# At the time of writing, Nix only constrains the major.minor version.
# We need to check that specifically.
if [ "$IGNORE_NIX" = "false" ]; then
+150
View File
@@ -0,0 +1,150 @@
#!/usr/bin/env bash
# This script checks the mise values used by CI and dogfood images:
# - mise.toml min_version is the source of truth for the mise version.
# - .github/actions/setup-mise/checksums.toml stores pinned binary checksums.
# - .github/actions/setup-mise/action.yml
# - flake.nix
# - scripts/dogfood/mise-oci-wrapper.sh
# - dogfood/coder/ubuntu-*/Dockerfile.base
set -euo pipefail
# shellcheck source=scripts/lib.sh
source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
cdroot
check_not_empty() {
local label="$1"
local value="$2"
log "INFO : ${label}: ${value}"
if [[ -z "${value}" ]]; then
error "Missing mise value for ${label}"
fi
}
check_equal() {
local label="$1"
local actual="$2"
local expected="$3"
check_not_empty "${label}" "${actual}"
if [[ "${actual}" != "${expected}" ]]; then
error "Mise mismatch for ${label}: expected ${expected}, got ${actual}"
fi
}
check_sha256_format() {
local label="$1"
local value="$2"
if [[ -z "${value}" ]]; then
error "Missing mise value for ${label}"
fi
if [[ ! "${value}" =~ ^[a-f0-9]{64}$ ]]; then
error "Expected 64-character lowercase SHA256 for ${label}: ${value}"
fi
}
mise_version="$(sed -n 's/^min_version = "\([^"]*\)"/\1/p' mise.toml)"
check_not_empty "mise.toml min_version" "${mise_version}"
action_version="$(
awk '
$1 == "mise-version:" { in_input = 1; next }
in_input && /^ [A-Za-z0-9_-]+:/ { exit }
in_input && $1 == "default:" {
gsub(/"/, "", $2)
print $2
exit
}
' .github/actions/setup-mise/action.yml
)"
check_equal ".github/actions/setup-mise/action.yml" "${action_version}" "${mise_version}"
checksum_version="$(
awk -v version="${mise_version}" '
$0 == "[\"" version "\"]" {
print version
exit
}
' .github/actions/setup-mise/checksums.toml
)"
check_equal ".github/actions/setup-mise/checksums.toml" "${checksum_version}" "${mise_version}"
declare -A setup_mise_checksums=()
for target in linux-x64 linux-arm64 macos-x64 macos-arm64 windows-x64; do
checksum="$(./scripts/mise_checksum.sh .github/actions/setup-mise/checksums.toml "${mise_version}" "${target}")"
check_not_empty ".github/actions/setup-mise/checksums.toml ${target}" "${checksum}"
check_sha256_format ".github/actions/setup-mise/checksums.toml ${target}" "${checksum}"
setup_mise_checksums["${target}"]="${checksum}"
done
linux_x64_checksum="${setup_mise_checksums["linux-x64"]}"
sri_sha256_to_hex() {
local label="$1"
local sri="$2"
if [[ "${sri}" != sha256-* ]]; then
error "Expected SRI SHA256 hash for ${label}: ${sri}"
fi
printf '%s' "${sri#sha256-}" | openssl base64 -A -d | od -An -tx1 -v | tr -d ' \n'
}
flake_version="$(
awk '
/^[[:space:]]*mise = / { in_mise = 1; next }
in_mise && /^[[:space:]]*version = / {
gsub(/[";]/, "", $3)
print $3
exit
}
in_mise && /^[[:space:]]*};/ { exit }
' flake.nix
)"
check_equal "flake.nix" "${flake_version}" "${mise_version}"
declare -A flake_targets=(
["x86_64-linux"]="linux-x64"
["aarch64-linux"]="linux-arm64"
["x86_64-darwin"]="macos-x64"
["aarch64-darwin"]="macos-arm64"
)
for system in "${!flake_targets[@]}"; do
target="${flake_targets[${system}]}"
expected_checksum="${setup_mise_checksums[${target}]}"
flake_hash="$(
awk -v nix_system="${system}" '
/^[[:space:]]*hash = \{/ { in_hash = 1; next }
in_hash && $1 == nix_system {
gsub(/[";]/, "", $3)
print $3
exit
}
in_hash && /^[[:space:]]*};/ { exit }
' flake.nix
)"
check_not_empty "flake.nix ${system} hash" "${flake_hash}"
actual_checksum="$(sri_sha256_to_hex "flake.nix ${system}" "${flake_hash}")"
check_equal "flake.nix ${system} sha256" "${actual_checksum}" "${expected_checksum}"
done
wrapper_version="$(sed -n 's/^MISE_VERSION="v\([^"]*\)"/\1/p' scripts/dogfood/mise-oci-wrapper.sh)"
check_equal "scripts/dogfood/mise-oci-wrapper.sh" "${wrapper_version}" "${mise_version}"
wrapper_checksum="$(sed -n 's/^MISE_SHA256="\([a-f0-9]*\)"/\1/p' scripts/dogfood/mise-oci-wrapper.sh)"
check_equal "scripts/dogfood/mise-oci-wrapper.sh sha256" "${wrapper_checksum}" "${linux_x64_checksum}"
check_sha256_format "scripts/dogfood/mise-oci-wrapper.sh sha256" "${wrapper_checksum}"
for dockerfile in dogfood/coder/ubuntu-*/Dockerfile.base; do
dockerfile_version="$(sed -n 's/.*MISE_VERSION=v\([0-9.]*\).*/\1/p' "${dockerfile}" | head -n 1)"
check_equal "${dockerfile}" "${dockerfile_version}" "${mise_version}"
dockerfile_checksum="$(sed -n 's/.*MISE_SHA256=\([a-f0-9]*\).*/\1/p' "${dockerfile}" | head -n 1)"
check_equal "${dockerfile} sha256" "${dockerfile_checksum}" "${linux_x64_checksum}"
check_sha256_format "${dockerfile} sha256" "${dockerfile_checksum}"
done
log "Mise version check passed, all versions are ${mise_version}"
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env bash
# Print the pinned mise SHA256 checksum for a version and release target.
set -euo pipefail
if [[ "$#" -ne 3 ]]; then
echo "usage: $0 <checksums.toml> <mise-version> <target>" >&2
exit 1
fi
checksums_file="$1"
mise_version="$2"
target="$3"
awk -F= -v version="${mise_version}" -v target="${target}" '
$0 == "[\"" version "\"]" { in_table = 1; next }
/^\[/ { in_table = 0 }
in_table {
key = $1
gsub(/^[[:space:]]+|[[:space:]]+$/, "", key)
if (key == target) {
value = $2
gsub(/^[[:space:]]+|[[:space:]]+$/, "", value)
gsub(/^"|"$/, "", value)
print value
exit
}
}
' "${checksums_file}"
+9 -53
View File
@@ -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
-46
View File
@@ -1,46 +0,0 @@
#!/usr/bin/env bash
# Usage: ./zizmor.sh [args...]
#
# This script is a wrapper around the zizmor Docker image. Zizmor lints GitHub
# actions workflows.
#
# We use Docker to run zizmor since it's written in Rust and is difficult to
# install on Ubuntu runners without building it with a Rust toolchain, which
# takes a long time.
#
# The repo is mounted at /repo and the working directory is set to /repo.
set -euo pipefail
# shellcheck source=scripts/lib.sh
source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
cdroot
image_tag="ghcr.io/zizmorcore/zizmor:1.11.0"
docker_args=(
"--rm"
"--volume" "$(pwd):/repo"
"--workdir" "/repo"
"--network" "host"
)
if [[ -t 0 ]]; then
docker_args+=("-it")
fi
# If no GH_TOKEN is set, try to get one from `gh auth token`.
if [[ "${GH_TOKEN:-}" == "" ]] && command -v gh &>/dev/null; then
set +e
GH_TOKEN="$(gh auth token)"
export GH_TOKEN
set -e
fi
# Pass through the GitHub token if it's set, which allows zizmor to scan
# imported workflows too.
if [[ "${GH_TOKEN:-}" != "" ]]; then
docker_args+=("--env" "GH_TOKEN")
fi
logrun exec docker run "${docker_args[@]}" "$image_tag" "$@"
+3 -2
View File
@@ -3240,8 +3240,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;
@@ -34,7 +34,7 @@ export const ClickOnDownload: Story = {
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
await userEvent.click(
canvas.getByRole("button", { name: "Download logs" }),
canvas.getByRole("button", { name: "Download agent logs" }),
);
await waitFor(() =>
expect(args.download).toHaveBeenCalledWith(
@@ -58,7 +58,7 @@ export const DownloadAgentLogsButton: FC<DownloadAgentLogsButtonProps> = ({
}}
>
<DownloadIcon />
{isDownloading ? "Downloading..." : "Download logs"}
{isDownloading ? "Downloading..." : "Download agent logs"}
</Button>
);
};
@@ -63,7 +63,7 @@ export const DownloadSelectedAgentLogsButton: FC<
>
<DownloadIcon />
<span className="sr-only">
{isDownloading ? "Downloading..." : "Download logs"}
{isDownloading ? "Downloading..." : "Download agent logs"}
</span>
<ChevronDownIcon className="size-icon-sm" />
</Button>
@@ -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?",
@@ -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 },
@@ -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">
@@ -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");
},
};
@@ -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.
@@ -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: {
@@ -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;
@@ -64,6 +64,38 @@ export const EditBedrockKeepCredentials: Story = {
},
};
export const AddCopilot: Story = {
args: {
// The real add flow passes only the type; the form fills name and
// endpoint from the copilot defaults.
initialValues: { type: "copilot" },
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByLabelText(/endpoint/i);
expect(canvas.queryByLabelText(/api key/i)).not.toBeInTheDocument();
},
};
export const EditCopilot: Story = {
args: {
editing: true,
initialValues: {
type: "copilot",
name: "copilot",
displayName: "GitHub Copilot",
baseUrl: "https://api.business.githubcopilot.com",
enabled: true,
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const name = await canvas.findByLabelText(/^name/i);
expect(name).toBeDisabled();
expect(canvas.queryByLabelText(/api key/i)).not.toBeInTheDocument();
},
};
export const EditProvider: Story = {
args: {
editing: true,
@@ -81,6 +81,10 @@ const providerDefaults: Partial<
name: "azure",
baseUrl: "https://YOUR-RESOURCE.openai.azure.com/openai/v1",
},
copilot: {
name: "copilot",
baseUrl: "https://api.business.githubcopilot.com",
},
google: {
name: "google",
baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai/",
@@ -164,6 +168,20 @@ const makeBedrockSchema = (editing: boolean) =>
enabled: Yup.boolean(),
});
const makeCopilotSchema = (editing: boolean) =>
Yup.object({
type: Yup.string()
.oneOf(["copilot"] as const)
.required(),
name: makeNameSchema(editing),
displayName: makeDisplayNameSchema(editing),
baseUrl: Yup.string()
.url("Endpoint must be a valid URL")
.matches(HTTP_SCHEME_REGEX, "Endpoint must use http or https.")
.required("Endpoint is required"),
enabled: Yup.boolean(),
});
const getProviderFormSchema = (editing: boolean) =>
Yup.lazy((value: { type?: AIProviderType } | undefined) => {
switch (value?.type) {
@@ -177,6 +195,8 @@ const getProviderFormSchema = (editing: boolean) =>
return makeOpenAiAnthropicSchema(editing);
case "bedrock":
return makeBedrockSchema(editing);
case "copilot":
return makeCopilotSchema(editing);
default:
return Yup.object({
type: Yup.string()
@@ -185,6 +205,7 @@ const getProviderFormSchema = (editing: boolean) =>
"anthropic",
"bedrock",
"azure",
"copilot",
"google",
"openai-compat",
"openrouter",
@@ -319,18 +340,38 @@ export const ProviderForm: FC<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)}
/>
)}
</>
)}
@@ -409,7 +450,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} />
@@ -27,6 +27,12 @@ export const Bedrock: Story = {
},
};
export const Copilot: Story = {
args: {
provider: "copilot",
},
};
export const Azure: Story = {
args: {
provider: "azure",
@@ -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":
@@ -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" },
@@ -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"],
@@ -333,6 +351,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 +503,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 +541,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,

Some files were not shown because too many files have changed in this diff Show More