From 2595faa20b9bb6665d928ffd02be89be068008d2 Mon Sep 17 00:00:00 2001 From: "Marcus R. Brown" Date: Fri, 30 Oct 2026 07:59:53 -0700 Subject: [PATCH 1/2] ci(release): skip git hook install in the release job (#1991) --- .github/workflows/auto-release.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/auto-release.yaml b/.github/workflows/auto-release.yaml index b75ff9b10b..ecd2a2c757 100644 --- a/.github/workflows/auto-release.yaml +++ b/.github/workflows/auto-release.yaml @@ -17,6 +17,10 @@ jobs: perform-release: name: Perform Release runs-on: ubuntu-latest + env: + # semantic-release pushes the release commit and the tag; the repo's pre-push hook would + # re-run install, lint and build on each push. CI and the Build release step already cover those. + SKIP_INSTALL_SIMPLE_GIT_HOOKS: '1' if: >- github.event.pull_request.merged == true && github.event.pull_request.head.ref == 'next' && From e8fee23e0b04fbaece2eb2b908377b2cf5eb8337 Mon Sep 17 00:00:00 2001 From: "Marcus R. Brown" Date: Fri, 30 Oct 2026 19:07:06 -0700 Subject: [PATCH 2/2] fix(workspace): provision provider credentials into each OpenCode V2 spawn (#1993) --- .github/workflows/ci.yaml | 186 ++++- apps/workspace-agent/AGENTS.md | 4 +- apps/workspace-agent/src/config.test.ts | 51 +- apps/workspace-agent/src/config.ts | 34 +- .../src/opencode-credentials-v2.test.ts | 534 ++++++++++++++ .../src/opencode-credentials-v2.ts | 284 ++++++++ .../src/opencode-proxy-v2.test.ts | 334 ++++++++- apps/workspace-agent/src/opencode-proxy-v2.ts | 102 ++- .../opencode-server-v2.integration.test.ts | 439 +++++++++++- .../src/opencode-server-v2.test.ts | 678 +++++++++++++++++- .../workspace-agent/src/opencode-server-v2.ts | 146 +++- deploy/README.md | 2 +- deploy/scripts/fake-anthropic-provider.mjs | 87 +++ .../scripts/fake-anthropic-provider.test.mjs | 62 ++ deploy/scripts/provision-agent-config.mjs | 5 +- .../scripts/provision-agent-config.test.mjs | 42 ++ ...-provisioning-best-practices-2026-06-01.md | 2 +- .../src/agent/gateway-routes-v2.test.ts | 187 +++++ .../runtime/src/agent/gateway-routes-v2.ts | 64 ++ packages/runtime/src/agent/workspace-v2.ts | 4 + 20 files changed, 3140 insertions(+), 107 deletions(-) create mode 100644 apps/workspace-agent/src/opencode-credentials-v2.test.ts create mode 100644 apps/workspace-agent/src/opencode-credentials-v2.ts create mode 100644 deploy/scripts/fake-anthropic-provider.mjs create mode 100644 deploy/scripts/fake-anthropic-provider.test.mjs create mode 100644 packages/runtime/src/agent/gateway-routes-v2.test.ts create mode 100644 packages/runtime/src/agent/gateway-routes-v2.ts diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 9d211caf7f..c558f68dc3 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -749,6 +749,188 @@ jobs: exit 1 fi echo "OK: V2 image ready on ${oc_version}, plugins active, no network" + - name: Smoke-test OpenCode V2 provider credential (the provisioned key reaches the model request) + run: | + set -euo pipefail + # fro-bot/agent#1992: V2 imports auth.json only through a migration that a fresh per-spawn database + # skips, so every V2 model call went out with no key. The supervisor now provisions the root-only + # credential blob into each spawn over loopback. This step proves it through the real entrypoint: + # a no-network fake Anthropic-compatible provider runs INSIDE the container (loopback only) and answers + # 401 unless the request carries the provisioned dummy key in x-api-key, the header V1 sent. + smoke_key='sk-ci-v2-smoke-dummy-not-real' + fake_port=9701 + # The trap goes in before anything it cleans up exists, so a failed mktemp or `docker run` still removes the + # temp dir and the container. + cid='' + auth_dir='' + cleanup() { + status=$? + if [ -n "${cid}" ]; then + if [ "${status}" -ne 0 ]; then + echo "--- workspace logs ---" + docker logs "${cid}" 2>&1 || true + fi + docker rm -f "${cid}" >/dev/null 2>&1 || true + fi + if [ -n "${auth_dir}" ]; then rm -rf "${auth_dir}"; fi + } + trap cleanup EXIT + auth_dir="$(mktemp -d)" + chmod 755 "${auth_dir}" + printf '{"anthropic":{"type":"api","key":"%s"}}' "${smoke_key}" > "${auth_dir}/auth.json" + cid="$(docker run -d --network none \ + -e WORKSPACE_OPENCODE_TOKEN=dummy-smoke-token \ + -e MITMPROXY_CA_WAIT_SECONDS=0 \ + -e NO_PROXY='*' \ + -e WORKSPACE_OPENCODE_MODEL=anthropic/claude-sonnet-4-6 \ + -e WORKSPACE_OPENCODE_CONFIG="{\"provider\":{\"anthropic\":{\"options\":{\"baseURL\":\"http://127.0.0.1:${fake_port}/v1\"}}}}" \ + $WORKSPACE_DOCKER_SECURITY_FLAGS \ + -v "${auth_dir}/auth.json:/run/workspace-agent/secrets/workspace_opencode_auth:ro" \ + -v "$PWD/deploy/scripts/fake-anthropic-provider.mjs:/fake-provider.mjs:ro" \ + fro-bot-workspace:smoke-v2)" + + docker exec -d "${cid}" node /fake-provider.mjs "${fake_port}" "${smoke_key}" + fake_up=false + for _ in $(seq 1 30); do + if docker exec "${cid}" curl -fsS --max-time 2 "http://127.0.0.1:${fake_port}/__hits" >/dev/null 2>&1; then + fake_up=true + break + fi + sleep 1 + done + [ "${fake_up}" = "true" ] || { echo "REGRESSION: the fake provider did not start"; exit 1; } + + # Readiness now includes the credential gate: the model's provider must hold a credential. + ready=false + for _ in $(seq 1 90); do + if docker exec "${cid}" curl -fsS --max-time 5 http://127.0.0.1:9100/readyz >/dev/null 2>&1; then + ready=true + break + fi + sleep 1 + done + [ "${ready}" = "true" ] || { echo "REGRESSION: /readyz did not return 200 within 90s with a provisioned credential"; exit 1; } + + # One prompt through the :9200 bearer proxy, the path the gateway uses. + dir_header="x-opencode-directory: $(node -p 'encodeURIComponent("/workspace/repos")')" + session="$(docker exec "${cid}" curl -fsS --max-time 15 \ + -H 'Authorization: Bearer dummy-smoke-token' -H "${dir_header}" -H 'content-type: application/json' \ + -d '{"location":{"directory":"/workspace/repos"}}' http://127.0.0.1:9200/api/session)" + session_id="$(echo "${session}" | node -p 'JSON.parse(fs.readFileSync(0, "utf8")).data.id')" + docker exec "${cid}" curl -fsS --max-time 15 \ + -H 'Authorization: Bearer dummy-smoke-token' -H "${dir_header}" -H 'content-type: application/json' \ + -d '{"text":"ping"}' "http://127.0.0.1:9200/api/session/${session_id}/prompt" >/dev/null + + # The stored key is readable through /api/credential on the loopback server, so the bearer proxy forwards only + # the gateway's own routes (an allowlist) and answers 404 for anything else, in every spelling: case, + # percent-encoding, absolute-form, and `;` plus dot segments (the server's router truncates a path at `;`). + for target in /api/credential /api/CREDENTIAL /api/%63redential /api/credential/cred_x \ + 'http://localhost/api/credential' '/api/credential;ignored/../info' /api/info /api/integration/anthropic; do + proxy_status="$(docker exec "${cid}" curl -s -o /dev/null -w '%{http_code}' --max-time 10 --path-as-is \ + --request-target "${target}" -H 'Authorization: Bearer dummy-smoke-token' http://127.0.0.1:9200/)" + if [ "${proxy_status}" != "404" ]; then + echo "REGRESSION: the :9200 proxy answered ${proxy_status} for ${target}, expected 404" + exit 1 + fi + done + + # The fake must see a model request carrying the key in x-api-key. + hits='' + matched=false + for _ in $(seq 1 60); do + hits="$(docker exec "${cid}" curl -fsS --max-time 5 "http://127.0.0.1:${fake_port}/__hits")" + if echo "${hits}" | node -e 'process.exit(JSON.parse(fs.readFileSync(0, "utf8")).requests.some(r => r.xApiKey === "match") ? 0 : 1)'; then + matched=true + break + fi + sleep 1 + done + echo "provider verdicts: ${hits}" + [ "${matched}" = "true" ] || { echo "REGRESSION: no model request carried the provisioned key in x-api-key"; exit 1; } + # Every request carried it, and none sent it as a bearer. + echo "${hits}" | node -e ' + const bad = JSON.parse(fs.readFileSync(0, "utf8")).requests.filter(r => r.xApiKey !== "match" || r.authorization !== "absent") + if (bad.length > 0) { console.error("unexpected provider requests: " + JSON.stringify(bad)); process.exit(1) } + ' + + logs="$(docker logs "${cid}" 2>&1)" + echo "--- workspace logs ---" + echo "${logs}" + case "${logs}" in + *"provider credentials provisioned"*) : ;; + *) echo "REGRESSION: the supervisor did not log credential provisioning"; exit 1 ;; + esac + if echo "${logs}" | grep -qF "${smoke_key}"; then + echo "REGRESSION: the provider key leaked into the workspace logs" + exit 1 + fi + echo "OK: V2 provisioned the credential into the fresh database; the model request carried it in x-api-key" + - name: Smoke-test OpenCode V2 credential gate (model configured, no credential, never ready) + run: | + set -euo pipefail + # The fail-loud half of #1992: with a model configured and no credential blob, the supervisor must + # refuse readiness at spawn instead of letting the first model call 401. + fake_port=9701 + cid="$(docker run -d --network none \ + -e WORKSPACE_OPENCODE_TOKEN=dummy-smoke-token \ + -e MITMPROXY_CA_WAIT_SECONDS=0 \ + -e NO_PROXY='*' \ + -e WORKSPACE_OPENCODE_MODEL=anthropic/claude-sonnet-4-6 \ + -e WORKSPACE_OPENCODE_CONFIG="{\"provider\":{\"anthropic\":{\"options\":{\"baseURL\":\"http://127.0.0.1:${fake_port}/v1\"}}}}" \ + $WORKSPACE_DOCKER_SECURITY_FLAGS \ + -v "$PWD/deploy/scripts/fake-anthropic-provider.mjs:/fake-provider.mjs:ro" \ + fro-bot-workspace:smoke-v2)" + cleanup() { + status=$? + if [ "${status}" -ne 0 ]; then + echo "--- workspace logs ---" + docker logs "${cid}" 2>&1 || true + fi + docker rm -f "${cid}" >/dev/null 2>&1 || true + } + trap cleanup EXIT + + docker exec -d "${cid}" node /fake-provider.mjs "${fake_port}" 'sk-ci-v2-smoke-dummy-not-real' + + # Wait for the supervisor's verdict, failing at once if /readyz ever answers 200. + decided=false + for _ in $(seq 1 90); do + if docker exec "${cid}" curl -fsS --max-time 5 http://127.0.0.1:9100/readyz >/dev/null 2>&1; then + echo "REGRESSION: /readyz returned 200 with a model configured and no credential" + exit 1 + fi + logs="$(docker logs "${cid}" 2>&1)" + case "${logs}" in + *"credential gate failed"*) decided=true; break ;; + esac + sleep 1 + done + [ "${decided}" = "true" ] || { echo "REGRESSION: the credential gate never reported within 90s"; exit 1; } + + # Keep watching across the respawns: readiness must stay refused. + for _ in $(seq 1 15); do + if docker exec "${cid}" curl -fsS --max-time 5 http://127.0.0.1:9100/readyz >/dev/null 2>&1; then + echo "REGRESSION: /readyz turned 200 after the credential gate failed" + exit 1 + fi + sleep 1 + done + + logs="$(docker logs "${cid}" 2>&1)" + echo "--- workspace logs ---" + echo "${logs}" + case "${logs}" in + *"credential-missing"*) : ;; + *) echo "REGRESSION: the gate failure was not credential-missing"; exit 1 ;; + esac + case "${logs}" in + *"opencode-server-v2: ready"*) echo "REGRESSION: the supervisor reported ready without a credential"; exit 1 ;; + esac + hits="$(docker exec "${cid}" curl -fsS --max-time 5 "http://127.0.0.1:${fake_port}/__hits")" + echo "provider verdicts: ${hits}" + echo "${hits}" | node -e 'if (JSON.parse(fs.readFileSync(0, "utf8")).requests.length !== 0) process.exit(1)' \ + || { echo "REGRESSION: a model request reached the provider with no credential provisioned"; exit 1; } + echo "OK: no credential → the credential gate refused readiness and no model request was made" - name: Smoke-test auth provisioning (valid blob → auth.json written 0600) run: | set -e @@ -827,7 +1009,7 @@ jobs: logs="$(docker logs "$cid" 2>&1)" echo "--- workspace logs ---"; echo "$logs" [ "$ok" = "true" ] || { echo "REGRESSION: /healthz down with config overlay"; exit 1; } - echo "$logs" | grep -q "provider-overlay=applied" + echo "$logs" | grep -q "provider-overlay=merged-into-config-file" # Effective config carries the overlaid model + provider baseURL, AND the # hostile overlay could NOT drop the plugin or re-enable autoupdate. docker exec "$cid" sh -c 'grep -q "\"model\": \"anthropic/claude-sonnet-4-6\"" /home/opencode/.config/opencode/opencode.json' @@ -876,7 +1058,7 @@ jobs: echo "--- workspace logs ---"; echo "$logs" [ "$ok" = "true" ] || { echo "REGRESSION: /healthz down with combined auth+model+overlay"; exit 1; } echo "$logs" | grep -q "auth: provisioned" - echo "$logs" | grep -q "provider-overlay=applied" + echo "$logs" | grep -q "provider-overlay=merged-into-config-file" docker exec "$cid" sh -c 'test "$(stat -c %u:%a /home/opencode/.local/share/opencode/auth.json)" = "10001:600"' docker exec "$cid" sh -c 'grep -q "\"model\": \"anthropic/claude-sonnet-4-6\"" /home/opencode/.config/opencode/opencode.json' docker exec "$cid" sh -c 'grep -q "cliproxy.fro.bot/v1" /home/opencode/.config/opencode/opencode.json' diff --git a/apps/workspace-agent/AGENTS.md b/apps/workspace-agent/AGENTS.md index 09bfe26de8..0ac029dd6b 100644 --- a/apps/workspace-agent/AGENTS.md +++ b/apps/workspace-agent/AGENTS.md @@ -138,7 +138,8 @@ src/ ├── backups.ts /backups list/delete; the quarantine-generation metadata schema (including the optional run id) ├── opencode-instance.ts Best-effort POST /instance/dispose to the loopback OpenCode, run after a successful /recover ├── opencode-generation.ts Reads WORKSPACE_IMAGE_OPENCODE_VERSION (baked by the image); major 2+ selects the V2 trio below -├── opencode-server-v2.ts V2 supervisor: allowlisted env, fresh database per spawn, credential holder, plugin + permission gates +├── opencode-server-v2.ts V2 supervisor: allowlisted env, fresh database per spawn, credential holder, plugin + permission + credential gates +├── opencode-credentials-v2.ts V2 provider-credential provisioning (POST /api/credential per provider) and the credential-missing gate ├── opencode-proxy-v2.ts V2 proxy: gateway bearer in, the ready server's Basic credential out, upstream 401 reported as such ├── opencode-instance-v2.ts V2 best-effort location evict (DELETE /api/debug/location), authenticated as the running server ├── checkout-profile.ts Config inventory, layout, and temp-index cleanliness admission checks shared by update/recover @@ -164,6 +165,7 @@ src/ - **The credential holder** is written before readiness flips to ready and cleared on any exit, so the proxy answers 503 (never forwards a stale password) between servers. An upstream 401 is reported as a 502 with `x-fro-bot-proxy-error: upstream-auth`. - **Shutdown** lists the sessions still active, interrupts them (reason `user`), closes stdin and confirms the exit, inside the 25s drain. `main.ts` waits for the V2 supervisor before it closes the proxy. - **Permission gate:** after the plugin gate the supervisor checks the effective rules from `/api/agent` (`ask` or `deny` outside the checkout, `session_move` and `worktree_create` denied). A server that fails never serves. +- **Credential gate (#1992):** V2 imports `auth.json` only through a migration that a fresh per-spawn database skips, so each spawn starts with an empty credential store. After the permission gate the supervisor reads the root-only blob (`WORKSPACE_OPENCODE_AUTH_FILE`, default `/run/workspace-agent/secrets/workspace_opencode_auth`), `POST`s each `{type:"api",key}` entry to `/api/credential` over loopback with the per-spawn Basic credential, then reads `GET /api/integration/` back (the effective model is the `model` in the merged `opencode.json` the spawn loads, read fresh for every spawn, so it covers a model that came only from the `WORKSPACE_OPENCODE_CONFIG` overlay; read with `O_NOFOLLOW` and a size cap because the file is agent-writable). A provider whose merged config carries a non-empty inline `options.`/`settings.` `apiKey`, `authToken` or `accessToken`, literal or a `{file:...}`/`{env:...}` reference with a non-empty target (an empty string, `{file:}` and `{env:}` do not count), counts as authenticated without a stored connection; only presence is read, and root never resolves the referenced path or variable, because both are agent-controlled. Residual: a reference that resolves to nothing at load time (unreadable file, unset variable) passes the gate and fails at the first model call with a 401, as before the gate existed and requires a `credential` (or `env`) connection (`credential-missing` otherwise: never ready). With no model in that file it checks the providers it provisioned, and with neither (clone-only) it checks nothing. The key never reaches argv, env, config content, logs, errors or `/readyz`; a non-`api` entry or an unparseable blob is a loud error naming the provider id only. The bearer proxy is an allowlist: it forwards only `V2_GATEWAY_ROUTES` (`packages/runtime/src/agent/gateway-routes-v2.ts`, pinned to `createRemoteOpenCodeV2Client` by `gateway-routes-v2.test.ts`, so a new client route fails CI until it is listed) for a canonical origin-form target (no absolute-form, `;`, `\`, `.`/`..`/empty segments, `%`-encoded `/ \ . ; %`, control bytes or `#`), and answers everything else 404 with no upstream call, because `credential.list` returns stored keys in the clear; provisioning goes straight to loopback and never through the proxy. `OPENCODE_DISABLE_MODELS_FETCH=1` is set in `buildOpencodeV2Env` (the egress allowlist blocks `models.opencode.ai`; the bundled snapshot is the floor). ## Build diff --git a/apps/workspace-agent/src/config.test.ts b/apps/workspace-agent/src/config.test.ts index 3c608214c6..0716259234 100644 --- a/apps/workspace-agent/src/config.test.ts +++ b/apps/workspace-agent/src/config.test.ts @@ -6,8 +6,55 @@ * invalid (non-numeric, zero, negative) → throws (fail-fast). */ -import {describe, expect, it} from 'vitest' -import {readReadyTimeoutMs, readUpdateNetworkConfig} from './config.js' +import {mkdtempSync, rmSync, symlinkSync, writeFileSync} from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +import {afterEach, describe, expect, it} from 'vitest' +import {readOptionalSecretBlob, readReadyTimeoutMs, readUpdateNetworkConfig} from './config.js' + +describe('readOptionalSecretBlob', () => { + const roots: string[] = [] + afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, {recursive: true, force: true}) + }) + function root(): string { + const dir = mkdtempSync(path.join(os.tmpdir(), 'ws-blob-')) + roots.push(dir) + return dir + } + + it('returns the file contents, line breaks included', () => { + // #given a pretty-printed JSON blob + const file = path.join(root(), 'auth') + writeFileSync(file, '{\n "anthropic": {"type": "api", "key": "k"}\n}\n') + + // #when / #then + expect(readOptionalSecretBlob(file)).toBe('{\n "anthropic": {"type": "api", "key": "k"}\n}\n') + }) + + it('returns null for an absent or blank file', () => { + const dir = root() + const blank = path.join(dir, 'blank') + writeFileSync(blank, ' \n\t\n') + expect(readOptionalSecretBlob(path.join(dir, 'missing'))).toBeNull() + expect(readOptionalSecretBlob(blank)).toBeNull() + }) + + it('refuses a symlink, a directory and an oversized file', () => { + const dir = root() + const target = path.join(dir, 'target') + writeFileSync(target, '{}') + const link = path.join(dir, 'link') + symlinkSync(target, link) + const big = path.join(dir, 'big') + writeFileSync(big, 'x'.repeat(5000)) + + expect(() => readOptionalSecretBlob(link)).toThrow(/symlink/) + expect(() => readOptionalSecretBlob(dir)).toThrow(/directory/) + expect(() => readOptionalSecretBlob(big)).toThrow(/too large/) + }) +}) describe('readReadyTimeoutMs', () => { describe('absent / empty → fail-soft default', () => { diff --git a/apps/workspace-agent/src/config.ts b/apps/workspace-agent/src/config.ts index dfbcce60f5..a975515bca 100644 --- a/apps/workspace-agent/src/config.ts +++ b/apps/workspace-agent/src/config.ts @@ -25,7 +25,7 @@ class SecretFileNotFoundError extends Error { * and read), then `fstatSync` on the already-open file descriptor to confirm * the file is a regular file under the size limit. */ -function readSecretFile(filePath: string): string { +function readSecretFile(filePath: string, maxBytes: number = MAX_SECRET_BYTES): string { let fd: number try { fd = openSync(filePath, constants.O_RDONLY | constants.O_NOFOLLOW) @@ -50,8 +50,8 @@ function readSecretFile(filePath: string): string { `Secret path is not a regular file: ${filePath} (got ${kind}). FIFOs, devices, and directories are not supported.`, ) } - if (stat.size > MAX_SECRET_BYTES) { - throw new Error(`Secret file is too large: ${filePath} (${stat.size} bytes > ${MAX_SECRET_BYTES} byte limit).`) + if (stat.size > maxBytes) { + throw new Error(`Secret file is too large: ${filePath} (${stat.size} bytes > ${maxBytes} byte limit).`) } return readFileSync(fd, 'utf8') } finally { @@ -115,6 +115,34 @@ export function readOptionalSecret(name: string): string | null { return null } +/** + * Read an optional secret file that holds a JSON blob, with the same hardened open as `readOptionalSecret`: + * `O_NOFOLLOW`, regular file only, size cap. Unlike `readOptionalSecret` it takes a path, has no env-var fallback, and + * accepts embedded line breaks, because the credential blob is JSON an operator may have pretty-printed (the + * entrypoint tolerates that too). Returns null when the file is absent or blank. Never logs the contents. + */ +export function readOptionalSecretBlob(filePath: string): string | null { + return readOptionalBoundedFile(filePath, MAX_SECRET_BYTES) +} + +/** Size cap for the merged `opencode.json`, which carries provider and model definitions, not a secret. */ +export const MAX_CONFIG_FILE_BYTES = 512 * 1024 + +/** + * `readOptionalSecretBlob` with a caller-chosen size cap: the same `O_NOFOLLOW`, regular-file-only open, for a file an + * unprivileged uid can write (the merged config), so a planted symlink cannot redirect a root read. + */ +export function readOptionalBoundedFile(filePath: string, maxBytes: number): string | null { + let contents: string + try { + contents = readSecretFile(filePath, maxBytes) + } catch (error) { + if (error instanceof SecretFileNotFoundError) return null + throw error + } + return contents.trim() === '' ? null : contents +} + /** * Read a required secret by name. Throws if missing. */ diff --git a/apps/workspace-agent/src/opencode-credentials-v2.test.ts b/apps/workspace-agent/src/opencode-credentials-v2.test.ts new file mode 100644 index 0000000000..32c08ad8b3 --- /dev/null +++ b/apps/workspace-agent/src/opencode-credentials-v2.test.ts @@ -0,0 +1,534 @@ +import type {Logger} from './opencode-server.js' + +import {describe, expect, it, vi} from 'vitest' + +import { + modelProviderId, + parseAuthBlob, + provisionCredentials, + spawnConfigFromMergedConfig, + verifyCredentialGate, + WORKSPACE_CREDENTIAL_LABEL, +} from './opencode-credentials-v2.js' + +const KEY = 'sk-ant-SUPERSECRETKEY-1234' + +function capturingLogger(): {logger: Logger; serialized: () => string} { + const calls: unknown[] = [] + const record = + (level: string) => + (message: string, meta?: Record): void => { + calls.push({level, message, meta}) + } + return { + logger: {info: record('info'), warn: record('warn'), error: record('error')}, + serialized: () => JSON.stringify(calls), + } +} + +function handleWith(respond: (pathname: string, init?: RequestInit) => Response | Promise): { + fetch: (pathname: string, init?: RequestInit) => Promise + calls: {pathname: string; init?: RequestInit}[] +} { + const calls: {pathname: string; init?: RequestInit}[] = [] + return { + calls, + fetch: async (pathname, init) => { + calls.push({pathname, init}) + return respond(pathname, init) + }, + } +} + +describe('parseAuthBlob', () => { + it('parses API-key entries for every provider', () => { + // #given the blob shape the V1 path writes to auth.json + const raw = JSON.stringify({anthropic: {type: 'api', key: KEY}, openai: {type: 'api', key: 'sk-2'}}) + + // #when + const result = parseAuthBlob(raw) + + // #then + expect(result).toEqual({ + success: true, + data: [ + {providerId: 'anthropic', key: KEY}, + {providerId: 'openai', key: 'sk-2'}, + ], + }) + }) + + it('accepts a pretty-printed blob, as the entrypoint does', () => { + expect(parseAuthBlob(`{\n "anthropic": {\n "type": "api",\n "key": "k"\n }\n}\n`).success).toBe(true) + }) + + it.each([ + ['oauth', {anthropic: {type: 'oauth', access: 'SUPERSECRETACCESS', refresh: 'r', expires: 1}}], + ['wellknown', {anthropic: {type: 'wellknown', key: 'SUPERSECRETKEY', token: 't'}}], + ['no type', {anthropic: {key: 'SUPERSECRETKEY'}}], + ['non-object entry', {anthropic: 'SUPERSECRETKEY'}], + ])('refuses a %s entry, naming the provider id and nothing else', (_label, blob) => { + // #when + const result = parseAuthBlob(JSON.stringify(blob)) + + // #then + expect(result.success).toBe(false) + if (result.success) return + expect(result.error.kind).toBe('credential-blob-invalid') + expect(result.error.providers).toEqual(['anthropic']) + expect(JSON.stringify(result.error)).not.toContain('SUPERSECRET') + }) + + it.each([ + ['an empty key', {anthropic: {type: 'api', key: ' '}}], + ['a non-string key', {anthropic: {type: 'api', key: 42}}], + ])('refuses %s', (_label, blob) => { + const result = parseAuthBlob(JSON.stringify(blob)) + expect(result).toMatchObject({success: false, error: {providers: ['anthropic']}}) + }) + + it('refuses an empty object, a non-object and a provider id with a path separator', () => { + for (const raw of ['{}', '[]', '"x"', '{"a/b":{"type":"api","key":"k"}}']) { + const result = parseAuthBlob(raw) + expect(result.success).toBe(false) + } + }) + + it('does not forward the JSON parse error, which quotes the input', () => { + // #given malformed JSON that starts like a secret + const result = parseAuthBlob(`{"anthropic": ${KEY}`) + + // #then + expect(result.success).toBe(false) + if (result.success) return + expect(result.error.message).not.toContain('SUPERSECRET') + expect(JSON.stringify(result.error)).not.toContain(KEY) + }) +}) + +describe('modelProviderId', () => { + it.each([ + ['anthropic/claude-sonnet-4-6', 'anthropic'], + [' openai/gpt-5.4-mini ', 'openai'], + ['openrouter/anthropic/claude', 'openrouter'], + ['', undefined], + [' ', undefined], + [undefined, undefined], + ['/model', undefined], + ])('reads %j as %j', (model, expected) => { + expect(modelProviderId(model)).toBe(expected) + }) +}) + +describe('spawnConfigFromMergedConfig', () => { + it('reads the model the merged config names, wherever it came from (env model or overlay)', () => { + // #given the file merge-config writes: base + overlay, with the env model winning only when it is non-blank + const merged = JSON.stringify({ + autoupdate: false, + provider: {openai: {options: {baseURL: 'https://x/v1'}}}, + model: 'openai/gpt-5.4-mini', + }) + + // #then + expect(spawnConfigFromMergedConfig(merged)).toEqual({ + success: true, + data: {model: 'openai/gpt-5.4-mini', authenticatedProviders: []}, + }) + }) + + it.each([ + ['no model', {autoupdate: false}], + ['a blank model', {model: ' '}], + ['a non-string model', {model: {id: 'x'}}], + ])('reads %s as no model', (_label, config) => { + expect(spawnConfigFromMergedConfig(JSON.stringify(config))).toEqual({ + success: true, + data: {authenticatedProviders: []}, + }) + }) + + it('trims the model', () => { + expect(spawnConfigFromMergedConfig('{"model":" anthropic/claude-sonnet-4-6 "}')).toMatchObject({ + success: true, + data: {model: 'anthropic/claude-sonnet-4-6'}, + }) + }) + + it.each([ + ['options.apiKey (V1 shape)', {anthropic: {options: {apiKey: 'sk-INLINE'}}}], + ['settings.apiKey (V2 shape)', {anthropic: {settings: {apiKey: 'sk-INLINE'}}}], + ['options.authToken', {anthropic: {options: {authToken: 'tok-INLINE'}}}], + ['settings.accessToken', {anthropic: {settings: {accessToken: 'tok-INLINE'}}}], + ])( + 'counts a provider with an inline %s as authenticated, naming the provider and never the value', + (_label, provider) => { + // #when + const result = spawnConfigFromMergedConfig(JSON.stringify({provider})) + + // #then + expect(result).toEqual({success: true, data: {authenticatedProviders: ['anthropic']}}) + expect(JSON.stringify(result)).not.toContain('INLINE') + }, + ) + + it.each([ + ['a file reference', '{file:/home/opencode/provider-key}'], + ['an env reference', '{env:ANTHROPIC_API_KEY}'], + ['a file reference with a home-relative path', '{file:~/.config/key}'], + ['a literal with an empty reference next to it', 'sk-INLINE{env:}'], + ])('counts %s as configured authentication, without resolving it', (_label, value) => { + for (const bag of ['options', 'settings']) { + for (const key of ['apiKey', 'authToken', 'accessToken']) { + const result = spawnConfigFromMergedConfig(JSON.stringify({provider: {anthropic: {[bag]: {[key]: value}}}})) + expect(result, `${bag}.${key}`).toEqual({success: true, data: {authenticatedProviders: ['anthropic']}}) + } + } + }) + + it('never reads the referenced file or variable: presence only', () => { + // #given a reference to a path that would be an error to open, and a variable that is not set + const result = spawnConfigFromMergedConfig( + JSON.stringify({ + provider: {a: {options: {apiKey: '{file:/proc/1/environ}'}}, b: {options: {apiKey: '{env:HOME}'}}}, + }), + ) + + // #then the answer names the providers and nothing from the target + expect(result).toEqual({success: true, data: {authenticatedProviders: ['a', 'b']}}) + }) + + it.each([ + ['an empty key', {anthropic: {options: {apiKey: ''}}}], + ['a blank key', {anthropic: {options: {apiKey: ' '}}}], + ['a non-string key', {anthropic: {options: {apiKey: 42}}}], + ['a baseURL only', {anthropic: {options: {baseURL: 'https://x/v1'}}}], + ['a file reference with an empty target', {anthropic: {options: {apiKey: '{file:}'}}}], + ['an env reference with an empty target', {anthropic: {options: {apiKey: '{env:}'}}}], + ['a reference with a blank target', {anthropic: {settings: {apiKey: '{env: }'}}}], + ['only empty references', {anthropic: {options: {apiKey: ' {file:}{env:} '}}}], + ['a model-level key (not provider auth)', {anthropic: {models: {m: {options: {apiKey: 'sk-INLINE'}}}}}], + ['a provider that is not an object', {anthropic: 'sk-INLINE'}], + ])('does not count %s as authentication', (_label, provider) => { + expect(spawnConfigFromMergedConfig(JSON.stringify({provider}))).toEqual({ + success: true, + data: {authenticatedProviders: []}, + }) + }) + + it('lists only the providers that carry one', () => { + const result = spawnConfigFromMergedConfig( + JSON.stringify({provider: {anthropic: {options: {baseURL: 'x'}}, openai: {options: {apiKey: 'k'}}}}), + ) + expect(result).toEqual({success: true, data: {authenticatedProviders: ['openai']}}) + }) + + it('refuses a config that is not a JSON object, without quoting it (it may hold an inline key)', () => { + for (const raw of ['{"provider": {"x": {"options": {"apiKey": "sk-SUPERSECRET', '[]', '"model"']) { + const result = spawnConfigFromMergedConfig(raw) + expect(result.success).toBe(false) + expect(JSON.stringify(result)).not.toContain('SUPERSECRET') + } + }) +}) + +describe('provisionCredentials', () => { + const entries = [ + {providerId: 'anthropic', key: KEY}, + {providerId: 'openai', key: 'sk-openai-SUPERSECRETKEY'}, + ] as const + + it('posts one key credential per provider to the credential route', async () => { + // #given + const handle = handleWith(() => new Response('{}', {status: 200})) + const capture = capturingLogger() + + // #when + const result = await provisionCredentials({handle, entries, requestTimeoutMs: 1000, logger: capture.logger}) + + // #then + expect(result).toEqual({success: true, data: ['anthropic', 'openai']}) + expect(handle.calls.map(call => call.pathname)).toEqual(['/api/credential', '/api/credential']) + expect(handle.calls.map(call => call.init?.method)).toEqual(['POST', 'POST']) + expect(handle.calls.map(call => JSON.parse(String(call.init?.body)) as unknown)).toEqual([ + {integrationID: 'anthropic', label: WORKSPACE_CREDENTIAL_LABEL, value: {type: 'key', key: KEY}}, + { + integrationID: 'openai', + label: WORKSPACE_CREDENTIAL_LABEL, + value: {type: 'key', key: 'sk-openai-SUPERSECRETKEY'}, + }, + ]) + expect(handle.calls[0]?.init?.headers).toEqual({'content-type': 'application/json'}) + expect(capture.serialized()).not.toContain('SUPERSECRETKEY') + }) + + it('discards the response body unread, because the create route echoes the secret', async () => { + // #given a body that records whether anyone consumed it + const cancel = vi.fn() + const body = new ReadableStream({ + pull: controller => controller.enqueue(new TextEncoder().encode(`{"value":{"key":"${KEY}"}}`)), + cancel, + }) + const handle = handleWith(() => new Response(body, {status: 200})) + + // #when + await provisionCredentials({ + handle, + entries: [entries[0]], + requestTimeoutMs: 1000, + logger: capturingLogger().logger, + }) + + // #then + expect(cancel).toHaveBeenCalled() + }) + + it('stops at the first refusal and reports the provider and status only', async () => { + // #given a route that conflicts, with a body that echoes the key + const handle = handleWith(() => new Response(`{"message":"${KEY}"}`, {status: 409})) + const capture = capturingLogger() + + // #when + const result = await provisionCredentials({handle, entries, requestTimeoutMs: 1000, logger: capture.logger}) + + // #then + expect(result.success).toBe(false) + if (result.success) return + expect(result.error).toEqual({ + kind: 'credential-provision-failed', + message: 'Provider "anthropic": credential route answered HTTP 409', + providers: ['anthropic'], + }) + expect(handle.calls).toHaveLength(1) + expect(JSON.stringify(result)).not.toContain('SUPERSECRET') + expect(capture.serialized()).not.toContain('SUPERSECRET') + }) + + it('reports a transport error without its text, which may quote the request', async () => { + // #given + const handle = handleWith(() => { + throw new Error(`write failed: ${KEY}`) + }) + + // #when + const result = await provisionCredentials({ + handle, + entries, + requestTimeoutMs: 1000, + logger: capturingLogger().logger, + }) + + // #then + expect(result.success).toBe(false) + if (result.success) return + expect(result.error.kind).toBe('credential-provision-failed') + expect(JSON.stringify(result)).not.toContain('SUPERSECRET') + }) + + it('bounds a request that never answers', async () => { + // #given a route that never responds + const handle = handleWith(async () => new Promise(() => undefined)) + + // #when + const result = await provisionCredentials({ + handle, + entries: [entries[0]], + requestTimeoutMs: 30, + logger: capturingLogger().logger, + }) + + // #then + expect(result).toMatchObject({success: false, error: {kind: 'credential-provision-failed'}}) + }) + + it('stops when the service aborts', async () => { + // #given + const controller = new AbortController() + controller.abort() + const handle = handleWith(async () => new Promise(() => undefined)) + + // #when + const result = await provisionCredentials({ + handle, + entries: [entries[0]], + requestTimeoutMs: 5000, + signal: controller.signal, + logger: capturingLogger().logger, + }) + + // #then + expect(result.success).toBe(false) + }) +}) + +describe('verifyCredentialGate', () => { + const connected = (id: string): Response => + new Response( + JSON.stringify({ + data: { + id, + name: id, + methods: [{type: 'key'}], + connections: [{type: 'credential', id: 'cred_1', label: 'workspace', method: 'key'}], + }, + }), + {status: 200}, + ) + + it('passes when the provider lists a credential connection', async () => { + // #given + const handle = handleWith(() => connected('anthropic')) + + // #when + const result = await verifyCredentialGate({handle, providers: ['anthropic'], requestTimeoutMs: 1000}) + + // #then + expect(result).toEqual({success: true, data: ['anthropic']}) + expect(handle.calls[0]?.pathname).toBe('/api/integration/anthropic') + }) + + it('accepts an env connection, which V2 also resolves', async () => { + const handle = handleWith( + () => + new Response(JSON.stringify({data: {connections: [{type: 'env', name: 'ANTHROPIC_API_KEY'}]}}), {status: 200}), + ) + const result = await verifyCredentialGate({handle, providers: ['anthropic'], requestTimeoutMs: 1000}) + expect(result.success).toBe(true) + }) + + it.each([ + ['no connections', () => new Response(JSON.stringify({data: {connections: []}}), {status: 200})], + ['an unknown integration', () => new Response('{}', {status: 404})], + ['a server error', () => new Response('{}', {status: 500})], + ['a body without data', () => new Response('{}', {status: 200})], + ['a body that is not JSON', () => new Response('not json', {status: 200})], + ])('refuses on %s with credential-missing, naming the provider', async (_label, respond) => { + // #given + const handle = handleWith(respond) + + // #when + const result = await verifyCredentialGate({handle, providers: ['anthropic'], requestTimeoutMs: 1000}) + + // #then + expect(result.success).toBe(false) + if (result.success) return + expect(result.error.kind).toBe('credential-missing') + expect(result.error.providers).toEqual(['anthropic']) + expect(result.error.message).toContain('anthropic') + }) + + it('refuses when the read cannot complete', async () => { + const handle = handleWith(() => { + throw new Error('ECONNREFUSED') + }) + const result = await verifyCredentialGate({handle, providers: ['anthropic'], requestTimeoutMs: 1000}) + expect(result.success).toBe(false) + }) + + it('lists every missing provider, and encodes the provider id in the path', async () => { + // #given one provider connected and one not + const handle = handleWith(pathname => + pathname === '/api/integration/anthropic' ? connected('anthropic') : new Response('{}', {status: 404}), + ) + + // #when + const result = await verifyCredentialGate({ + handle, + providers: ['anthropic', 'a b/c'], + requestTimeoutMs: 1000, + }) + + // #then + expect(result).toMatchObject({success: false, error: {providers: ['a b/c']}}) + expect(handle.calls.map(call => call.pathname)).toContain('/api/integration/a%20b%2Fc') + }) +}) + +describe('verifyCredentialGate with inline authentication', () => { + it('passes a provider whose merged config carries an inline key, without asking the server', async () => { + // #given a server that lists no connection at all + const handle = handleWith(() => new Response(JSON.stringify({data: {connections: []}}), {status: 200})) + + // #when + const result = await verifyCredentialGate({ + handle, + providers: ['anthropic'], + configuredAuth: ['anthropic'], + requestTimeoutMs: 1000, + }) + + // #then + expect(result).toEqual({success: true, data: ['anthropic']}) + expect(handle.calls).toHaveLength(0) + }) + + it('still refuses a provider that has neither a connection nor an inline key', async () => { + const handle = handleWith(() => new Response(JSON.stringify({data: {connections: []}}), {status: 200})) + + const result = await verifyCredentialGate({ + handle, + providers: ['openai'], + configuredAuth: ['anthropic'], + requestTimeoutMs: 1000, + }) + + expect(result).toMatchObject({success: false, error: {kind: 'credential-missing', providers: ['openai']}}) + expect(handle.calls).toHaveLength(1) + }) +}) + +describe('request bounds after the headers arrive', () => { + const entry = [{providerId: 'anthropic', key: KEY}] as const + + /** Headers now, a body that never produces a byte and never closes. */ + const stalledBody = (): Response => + new Response(new ReadableStream({start: () => undefined}), {status: 200}) + + it('bounds a gate read whose integration JSON body stalls', async () => { + // #given + const handle = handleWith(stalledBody) + const started = Date.now() + + // #when + const result = await verifyCredentialGate({handle, providers: ['anthropic'], requestTimeoutMs: 40}) + + // #then it gave up at the whole-request bound and refused, instead of waiting on the body + expect(result).toMatchObject({success: false, error: {kind: 'credential-missing'}}) + expect(Date.now() - started).toBeLessThan(2000) + }) + + it('bounds a credential response whose body cancellation never settles', async () => { + // #given a created response whose body ignores cancel(): the transport honours neither the signal nor the cancel + const body = new ReadableStream({ + start: () => undefined, + cancel: async () => new Promise(() => undefined), + }) + const handle = handleWith(() => new Response(body, {status: 200})) + const started = Date.now() + + // #when + const result = await provisionCredentials({ + handle, + entries: entry, + requestTimeoutMs: 40, + logger: capturingLogger().logger, + }) + + // #then the whole-request bound ended it: a provisioning failure naming the provider, not a hang + expect(result).toMatchObject({ + success: false, + error: {kind: 'credential-provision-failed', providers: ['anthropic']}, + }) + expect(Date.now() - started).toBeLessThan(2000) + }) + + it('bounds a credential response whose headers never arrive', async () => { + const handle = handleWith(async () => new Promise(() => undefined)) + const result = await provisionCredentials({ + handle, + entries: entry, + requestTimeoutMs: 40, + logger: capturingLogger().logger, + }) + expect(result).toMatchObject({success: false, error: {kind: 'credential-provision-failed'}}) + }) +}) diff --git a/apps/workspace-agent/src/opencode-credentials-v2.ts b/apps/workspace-agent/src/opencode-credentials-v2.ts new file mode 100644 index 0000000000..c85f1a4b8a --- /dev/null +++ b/apps/workspace-agent/src/opencode-credentials-v2.ts @@ -0,0 +1,284 @@ +/** + * Provider credentials for a fresh OpenCode V2 spawn (fro-bot/agent#1992). + * + * V2 reads `auth.json` in exactly one place, the legacy-import migration, and a fresh per-spawn database bootstraps by + * marking that migration applied without running it. So every spawn starts with an empty credential store and the model + * resolves with no auth. The supervisor therefore provisions the credentials itself, over loopback, after the server + * is up and before it reports ready: + * + * 1. `parseAuthBlob` turns the root-only secret (the same `{"":{"type":"api","key":"…"}}` blob the V1 path + * writes to `auth.json`) into typed entries, once, before any spawn. + * 2. `provisionCredentials` calls `POST /api/credential` once per provider (`value: {type: "key", key}`). + * 3. `verifyCredentialGate` reads `GET /api/integration/` back and refuses unless the provider has a + * connection. That response lists connection ids and labels only, never the secret. + * + * SECURITY INVARIANTS: + * - The key travels in one HTTP body over 127.0.0.1 with the per-spawn Basic credential. It is never in argv, the + * child environment, `OPENCODE_CONFIG_CONTENT`, a log line, an error message, or `/readyz`. + * - Nothing here ever reads `GET /api/credential` (it returns secret values) and nothing logs or parses a response body + * from the create route (it echoes the stored value). Failures name the provider id and an HTTP status only. + * - `JSON.parse` errors quote the input, so they are never forwarded. + */ + +import type {OpenCodeV2ServerHandle} from '@fro-bot/runtime/workspace-v2' +import type {Logger} from './opencode-server.js' + +/** One provider's API key, parsed from the credential blob. */ +export interface ProviderKeyEntry { + readonly providerId: string + readonly key: string +} + +export type CredentialErrorKind = + 'credential-blob-invalid' | 'credential-missing' | 'credential-provision-failed' | 'model-config-invalid' + +/** A credential failure. `message` and `providers` carry provider ids and statuses, never a key or a response body. */ +export interface CredentialError { + readonly kind: CredentialErrorKind + readonly message: string + readonly providers?: readonly string[] +} + +export type CredentialResult = + {readonly success: true; readonly data: T} | {readonly success: false; readonly error: CredentialError} + +function fail(kind: CredentialErrorKind, message: string, providers?: readonly string[]): CredentialResult { + return {success: false, error: {kind, message, ...(providers === undefined ? {} : {providers})}} +} + +/** Same alphabet `deploy/scripts/validate-auth.mjs` enforces on provider ids. */ +const PROVIDER_ID_PATTERN = /^[\w.-]+$/ + +/** The label stored with every credential this module creates. */ +export const WORKSPACE_CREDENTIAL_LABEL = 'workspace' + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** + * Parses the credential blob. The entrypoint has already run `validate-auth.mjs` on it, so a failure here means the + * secret changed under the supervisor or the two validators drifted; either way it must be loud. + * + * The blob holds API keys only. Any other `type` (oauth, wellknown, …) is refused by naming the provider id, because + * silently dropping it would leave that provider without a credential. + */ +export function parseAuthBlob(raw: string): CredentialResult { + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + return fail('credential-blob-invalid', 'The provider credential blob is not valid JSON') + } + if (!isRecord(parsed)) { + return fail('credential-blob-invalid', 'The provider credential blob must be a JSON object keyed by provider id') + } + const entries: ProviderKeyEntry[] = [] + for (const [providerId, value] of Object.entries(parsed)) { + if (!PROVIDER_ID_PATTERN.test(providerId)) { + return fail('credential-blob-invalid', 'The provider credential blob has a provider id with invalid characters') + } + if (!isRecord(value) || value.type !== 'api') { + return fail('credential-blob-invalid', `Provider "${providerId}": only API-key credentials are supported`, [ + providerId, + ]) + } + if (typeof value.key !== 'string' || value.key.trim() === '') { + return fail('credential-blob-invalid', `Provider "${providerId}": the API key is missing or empty`, [providerId]) + } + entries.push({providerId, key: value.key}) + } + if (entries.length === 0) { + return fail('credential-blob-invalid', 'The provider credential blob has no provider entries') + } + return {success: true, data: entries} +} + +/** What the merged `opencode.json` says about the model and about authentication, with no secret value in it. */ +export interface SpawnConfig { + /** The `model` the spawn will use (`provider/model`), when the file names one. */ + readonly model?: string + /** Providers that carry a non-empty inline credential setting in the file. Presence only: never the value. */ + readonly authenticatedProviders: readonly string[] +} + +/** The provider settings the resolver counts as configured authentication (`hasConfiguredAuth`, model-resolver.ts). */ +const INLINE_AUTH_KEYS = ['apiKey', 'authToken', 'accessToken'] as const + +/** A `{env:X}` or `{file:X}` reference whose target is empty or only whitespace: it can name nothing. */ +const EMPTY_REFERENCE = /\{(?:env|file):\s*\}/g + +/** + * Whether an inline credential setting is present. A literal counts, and so does a `{file:...}` or `{env:...}` + * reference with a non-empty target, because the server substitutes it before it resolves provider settings. Presence + * only: the reference is NEVER resolved here. Root must not read a path or an environment variable that the agent + * controls, and the value would be a secret in the supervisor's memory for nothing. + * + * Residual, accepted: a reference that resolves to nothing at load time (a missing or unreadable file, an unset variable) + * passes the gate and then fails at the first model call with a 401, the same as before this gate existed. An empty + * string, or a reference with an empty target (`{file:}`, `{env:}`), does not count. + */ +function isConfiguredKey(value: unknown): boolean { + if (typeof value !== 'string') return false + return value.replaceAll(EMPTY_REFERENCE, '').trim() !== '' +} + +function hasInlineAuth(provider: unknown): boolean { + if (!isRecord(provider)) return false + // V1-shaped `options` is migrated into V2 `settings`; V2-native config writes `settings` directly. Both are read. + return [provider.options, provider.settings].some( + bag => isRecord(bag) && INLINE_AUTH_KEYS.some(key => isConfiguredKey(bag[key])), + ) +} + +/** + * The model and the authenticated providers of the merged `opencode.json` the entrypoint wrote and the server loads. + * That file is the one place `deploy/scripts/merge-config.mjs` has already resolved env model against overlay model, + * so this does not re-implement that rule. The error never quotes the file, which may hold an inline provider key from + * the operator's overlay, and the result carries provider ids only. + */ +export function spawnConfigFromMergedConfig(raw: string): CredentialResult { + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + return fail('model-config-invalid', 'The merged OpenCode config is not valid JSON') + } + if (!isRecord(parsed)) return fail('model-config-invalid', 'The merged OpenCode config is not a JSON object') + const model = typeof parsed.model === 'string' ? parsed.model.trim() : '' + const providers = isRecord(parsed.provider) ? parsed.provider : {} + const authenticatedProviders = Object.entries(providers) + .filter(([, provider]) => hasInlineAuth(provider)) + .map(([providerId]) => providerId) + return {success: true, data: {...(model === '' ? {} : {model}), authenticatedProviders}} +} + +/** The provider half of a `provider/model` string, or undefined when no model is configured. */ +export function modelProviderId(model: string | undefined): string | undefined { + const trimmed = model?.trim() ?? '' + if (trimmed === '') return undefined + const provider = trimmed.split('/')[0] ?? '' + return provider === '' ? undefined : provider +} + +export interface CredentialRequestOptions { + /** The spawned server. Its `fetch` carries the Basic credential. */ + readonly handle: Pick + /** Bound on each request, headers and body together. */ + readonly requestTimeoutMs: number + /** Service cancellation, joined with the per-request bound. */ + readonly signal?: AbortSignal +} + +/** + * Runs one bounded request. The bound covers a body that stalls after its headers, which the transport may not honour + * on its own. Any thrown error (network, timeout, abort) becomes `undefined`; the caller reports a status-free failure. + */ +async function boundedRequest( + options: CredentialRequestOptions, + send: (signal: AbortSignal) => Promise, +): Promise { + const bound = AbortSignal.timeout(options.requestTimeoutMs) + const signal = options.signal === undefined ? bound : AbortSignal.any([options.signal, bound]) + try { + return await new Promise((resolve, reject) => { + const onAbort = (): void => reject(signal.reason) + if (signal.aborted) { + onAbort() + return + } + signal.addEventListener('abort', onAbort, {once: true}) + send(signal) + .then(resolve, reject) + .finally(() => signal.removeEventListener('abort', onAbort)) + }) + } catch { + return undefined + } +} + +/** + * Stores each key in the spawn's credential store. Stops at the first failure, so a half-provisioned server never + * reaches the gate. Returns the provider ids it stored. + */ +export async function provisionCredentials( + options: CredentialRequestOptions & {readonly entries: readonly ProviderKeyEntry[]; readonly logger: Logger}, +): Promise> { + const stored: string[] = [] + for (const {providerId, key} of options.entries) { + const status = await boundedRequest(options, async signal => { + const response = await options.handle.fetch('/api/credential', { + method: 'POST', + headers: {'content-type': 'application/json'}, + body: JSON.stringify({ + integrationID: providerId, + label: WORKSPACE_CREDENTIAL_LABEL, + value: {type: 'key', key}, + }), + signal, + }) + // The create route echoes the stored value. Discard the body unread. + await response.body?.cancel().catch(() => undefined) + return response.status + }) + if (status === undefined) { + return fail('credential-provision-failed', `Provider "${providerId}": the credential request did not complete`, [ + providerId, + ]) + } + if (status < 200 || status >= 300) { + return fail('credential-provision-failed', `Provider "${providerId}": credential route answered HTTP ${status}`, [ + providerId, + ]) + } + stored.push(providerId) + } + options.logger.info('opencode-server-v2: provider credentials provisioned', { + providers: stored, + count: stored.length, + }) + return {success: true, data: stored} +} + +/** True when the integration response lists a connection that can authenticate a request. */ +function hasUsableConnection(body: unknown): boolean { + if (!isRecord(body) || !isRecord(body.data) || !Array.isArray(body.data.connections)) return false + return body.data.connections.some( + connection => isRecord(connection) && (connection.type === 'credential' || connection.type === 'env'), + ) +} + +/** + * The fail-loud gate. Each required provider must either carry an inline credential setting in the merged config + * (`configuredAuth`; the integration listing omits those) or have `GET /api/integration/` list a `credential` + * (or `env`) connection, or the server never goes ready. That route returns connection ids, labels and methods only. + */ +export async function verifyCredentialGate( + options: CredentialRequestOptions & { + readonly providers: readonly string[] + /** Providers whose merged config carries an inline credential setting: usable without a stored connection. */ + readonly configuredAuth?: readonly string[] + }, +): Promise> { + const missing: string[] = [] + for (const providerId of options.providers) { + if (options.configuredAuth?.includes(providerId) === true) continue + const usable = await boundedRequest(options, async signal => { + const response = await options.handle.fetch(`/api/integration/${encodeURIComponent(providerId)}`, {signal}) + if (response.status !== 200) { + await response.body?.cancel().catch(() => undefined) + return false + } + return hasUsableConnection(await response.json()) + }) + if (usable !== true) missing.push(providerId) + } + if (missing.length > 0) { + return fail( + 'credential-missing', + `No credential for provider(s): ${missing.join(', ')}. Provision the workspace-opencode-auth secret.`, + missing, + ) + } + return {success: true, data: options.providers} +} diff --git a/apps/workspace-agent/src/opencode-proxy-v2.test.ts b/apps/workspace-agent/src/opencode-proxy-v2.test.ts index fe9545c5d4..b49d265e66 100644 --- a/apps/workspace-agent/src/opencode-proxy-v2.test.ts +++ b/apps/workspace-agent/src/opencode-proxy-v2.test.ts @@ -12,10 +12,12 @@ import type {Logger} from './opencode-server.js' import {Buffer} from 'node:buffer' import http from 'node:http' +import net from 'node:net' +import {V2_GATEWAY_ROUTES} from '@fro-bot/runtime/workspace-v2' import {afterEach, describe, expect, it} from 'vitest' -import {createOpencodeProxyV2} from './opencode-proxy-v2.js' +import {createOpencodeProxyV2, isForwardableTarget} from './opencode-proxy-v2.js' import {createUpstreamCredentials} from './opencode-server-v2.js' const TOKEN = 'gateway-bearer-token-xyz' @@ -69,6 +71,20 @@ async function startUpstream(initialPassword: string): Promise { res.end(body) return } + // A router that behaves like the pinned one: case-insensitive, decoded, truncated at `;`, authority stripped. + const routed = (() => { + try { + return decodeURIComponent(new URL(req.url ?? '', 'http://x').pathname.split(';', 1)[0] ?? '').toLowerCase() + } catch { + return '' + } + })() + if (routed.includes('/api/credential') || (req.url ?? '').toLowerCase().includes('/api/credential')) { + const leak = JSON.stringify({data: [{id: 'cred_1', value: {type: 'key', key: 'sk-LEAKED-PROVIDER-KEY'}}]}) + res.writeHead(200, {'content-type': 'application/json', 'content-length': String(Buffer.byteLength(leak))}) + res.end(leak) + return + } if (req.url === '/api/event') { sseOpen += 1 res.writeHead(200, {'content-type': 'text/event-stream', 'cache-control': 'no-cache, no-transform'}) @@ -79,7 +95,7 @@ async function startUpstream(initialPassword: string): Promise { }) return } - if (req.url === '/api/slow') return + if (req.url === '/api/session/slow') return const body = JSON.stringify({directory: req.headers['x-opencode-directory'] ?? null, echoed: req.url}) res.writeHead(200, {'content-type': 'application/json', 'content-length': String(Buffer.byteLength(body))}) res.end(body) @@ -144,6 +160,24 @@ function basic(password: string): string { return `Basic ${Buffer.from(`opencode:${password}`).toString('base64')}` } +/** A raw request: the request line is written exactly as given (absolute-form, `*` and unescaped bytes included). */ +async function raw(base: string, requestLine: string, headers: string[] = [`authorization: Bearer ${TOKEN}`]) { + const parsed = new URL(base) + return new Promise<{status: number; body: string}>((resolve, reject) => { + const socket = net.connect(Number(parsed.port), parsed.hostname, () => { + socket.write([requestLine, 'host: workspace', 'connection: close', ...headers, '', ''].join('\r\n')) + }) + const chunks: Buffer[] = [] + socket.on('data', chunk => chunks.push(chunk)) + socket.on('error', reject) + socket.on('close', () => { + const text = Buffer.concat(chunks).toString('utf8') + const status = Number(/^HTTP\/1\.[01] (\d{3})/.exec(text)?.[1] ?? 0) + resolve({status, body: text.split('\r\n\r\n').slice(1).join('\r\n\r\n')}) + }) + }) +} + describe('opencode-proxy-v2', () => { const cleanups: (() => Promise)[] = [] afterEach(async () => { @@ -177,7 +211,7 @@ describe('opencode-proxy-v2', () => { const {upstream, url} = await setup() // #when a request carries the gateway bearer - const reply = await request(url, {path: '/api/info', headers: BEARER}) + const reply = await request(url, {path: '/api/session/active', headers: BEARER}) // #then the server saw Basic auth with its password, never the bearer expect(reply.status).toBe(200) @@ -225,9 +259,9 @@ describe('opencode-proxy-v2', () => { const {upstream, url} = await setup() // #when - const missing = await request(url, {path: '/api/info'}) - const wrong = await request(url, {path: '/api/info', headers: {authorization: 'Bearer nope'}}) - const basicScheme = await request(url, {path: '/api/info', headers: {authorization: basic('pw-one')}}) + const missing = await request(url, {path: '/api/session/active'}) + const wrong = await request(url, {path: '/api/session/active', headers: {authorization: 'Bearer nope'}}) + const basicScheme = await request(url, {path: '/api/session/active', headers: {authorization: basic('pw-one')}}) // #then the fixed body, and the server never saw a request for (const reply of [missing, wrong, basicScheme]) { @@ -242,8 +276,8 @@ describe('opencode-proxy-v2', () => { const {url, lines} = await setup() // #when a bad request and a good one both pass through - await request(url, {path: '/api/info', headers: {authorization: 'Bearer wrong-token-value'}}) - await request(url, {path: '/api/info', headers: BEARER}) + await request(url, {path: '/api/session/active', headers: {authorization: 'Bearer wrong-token-value'}}) + await request(url, {path: '/api/session/active', headers: BEARER}) // #then const serialized = JSON.stringify(lines) @@ -258,7 +292,7 @@ describe('opencode-proxy-v2', () => { const {upstream, url} = await setup({credentialed: false}) // #when - const reply = await request(url, {path: '/api/info', headers: BEARER}) + const reply = await request(url, {path: '/api/session/active', headers: BEARER}) // #then a request is never sent unauthenticated to a server that is not the current one expect(reply.status).toBe(503) @@ -269,13 +303,13 @@ describe('opencode-proxy-v2', () => { it('uses the new password for the next request after a respawn', async () => { // #given a ready server, then a respawn that rotates the password in the holder const {upstream, credentials, url} = await setup({password: 'pw-one'}) - expect((await request(url, {path: '/api/info', headers: BEARER})).status).toBe(200) + expect((await request(url, {path: '/api/session/active', headers: BEARER})).status).toBe(200) credentials.clear() upstream.password = 'pw-two' credentials.set(() => basic('pw-two')) // #when - const reply = await request(url, {path: '/api/info', headers: BEARER}) + const reply = await request(url, {path: '/api/session/active', headers: BEARER}) // #then the proxy read the holder again and the request succeeds expect(reply.status).toBe(200) @@ -289,7 +323,7 @@ describe('opencode-proxy-v2', () => { upstream.password = 'pw-rotated' // #when - const reply = await request(url, {path: '/api/info', headers: BEARER}) + const reply = await request(url, {path: '/api/session/active', headers: BEARER}) // #then the caller can tell it from its own 401 (wrong bearer) and from a 502 (nothing listening) expect(reply.status).toBe(502) @@ -306,7 +340,7 @@ describe('opencode-proxy-v2', () => { await upstream.close() // #when - const reply = await request(url, {path: '/api/info', headers: BEARER}) + const reply = await request(url, {path: '/api/session/active', headers: BEARER}) // #then expect(reply.status).toBe(502) @@ -318,7 +352,7 @@ describe('opencode-proxy-v2', () => { const {url} = await setup({timeoutMs: 80}) // #when - const reply = await request(url, {path: '/api/slow', headers: BEARER}) + const reply = await request(url, {path: '/api/session/slow', headers: BEARER}) // #then expect(reply.status).toBe(504) @@ -362,6 +396,258 @@ describe('opencode-proxy-v2', () => { expect(upstream.openSse()).toBe(0) }) + describe('route allowlist (fro-bot/agent#1992)', () => { + // The supervisor stores provider keys through /api/credential and `credential.list` returns them in the clear, so the + // gateway bearer may reach only the routes the gateway's clients call (V2_GATEWAY_ROUTES). Requests here are raw + // sockets, so the request line is exactly what is written: absolute-form, `*` and unescaped bytes included. + const METHODS = ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'] + + const BLOCKED_TARGETS = [ + // the credential group, every spelling the pinned router could match + '/api/credential', + '/api/credential/', + '/api/credential?x=1', + '/api/credential/cred_1', + '/api/credential/cred_1/activate', + '/API/credential', + '/api/CREDENTIAL', + '/api/%63redential', + '/api/%43REDENTIAL/cred_1', + '/%61pi/credential', + '/api/credential%2F', + '/api/credential%2Fcred_1', + '/api%2Fcredential', + '/api/credential%252Fcred_1', + '/api/%2563redential', + '/api//credential', + '//api/credential', + '/api/./credential', + '/api/x/../credential', + '/api/x/%2e%2e/credential', + '/api/credential;x=1', + String.raw`/api\credential`, + '/api%5Ccredential/cred_1', + // the two bypasses of the earlier denylist (review of #1993) + 'http://localhost/api/credential', + '/api/credential;ignored/../info', + // absolute-form, authority-form and asterisk-form, whatever the path + 'http://localhost:9200/api/credential', + 'HTTP://LOCALHOST/api/credential', + 'http://localhost/api/session/active', + 'http://user@localhost/api/session/active', + 'https://workspace/api/session', + 'workspace:9200', + '*', + // dot, empty and parameter segments around an allowlisted route + '/api/session/active;x', + '/api/session;x/active', + '/api/session/active/', + '/api/session//active', + '/api/session/./active', + '/api/session/..', + '/api/session/.', + '/api/shell/..', + '/api/session/../session/active', + '/api/session/ses_abc/../../credential', + '/api/session/ses_abc/%2e%2e/%2e%2e/credential', + '/api/session/active%2e', + '/api/session%2Factive', + '/api/session/active%3B', + '/api/session/active%3b/../credential', + '/api/session/active%5C', + '/api/session/%25', + '/api/session/ses_abc%2F..%2Fcredential', + String.raw`/api/session/active\..\credential`, + '/api/session/ses_abc/prompt/..', + // a fragment is never part of a request target + '/api/session/active#/../../credential', + '/api/session/active?x#y', + // not routes the gateway calls + '/api/info', + '/api/plugin', + '/api/agent', + '/api/config', + '/api/provider', + '/api/model', + '/api/integration', + '/api/integration/anthropic', + '/api/integration/anthropic/connect/key', + '/api/debug/location', + '/api/location/reload', + '/api/experimental/integration/wellknown', + '/api/session/ses_abc/shell', + '/api/session/ses_abc/message', + '/api', + '/', + '/credential', + '/v1/credential', + ] + + it.each(BLOCKED_TARGETS)('answers 404 and never calls the server for %s, whatever the method', async target => { + // #given a ready server + const {upstream, url} = await setup() + + // #when every method asks for it, raw + const replies = [] + for (const method of METHODS) { + replies.push({method, ...(await raw(url, `${method} ${target} HTTP/1.1`))}) + } + + // #then each is a 404 with the fixed body (HEAD has none) and the server saw nothing + for (const reply of replies) { + // authority-form is a CONNECT target: for any other method the HTTP parser itself may answer 400 first + if (target === 'workspace:9200' && reply.status === 400) continue + expect(reply.status).toBe(404) + expect(reply.body).toBe(reply.method === 'HEAD' ? '' : 'Not Found\n') + } + expect(upstream.seen).toHaveLength(0) + }) + + it('refuses control characters, a space and non-ASCII bytes in the target, and never calls the server', async () => { + // #given + const {upstream, url} = await setup() + + // #when + const replies = [ + await raw(url, 'GET /api/session/active?x=\u0001 HTTP/1.1'), + await raw(url, 'GET /api/session/active?x=\u007F HTTP/1.1'), + await raw(url, 'GET /api/session/\u00E9 HTTP/1.1'), + await raw(url, 'GET /api/session/active\u0000/../credential HTTP/1.1'), + ] + + // #then the parser or the proxy refuses each (400 or 404), and nothing was forwarded + for (const reply of replies) expect([400, 404]).toContain(reply.status) + expect(upstream.seen).toHaveLength(0) + }) + + it('never forwards a bypass attempt that reaches the key, even with the privileged credential held', async () => { + // #given an upstream that answers the credential group with a key, wherever its router would put the request + const {upstream, url} = await setup() + + // #when + const replies = [] + for (const target of BLOCKED_TARGETS) replies.push(await raw(url, `GET ${target} HTTP/1.1`)) + + // #then no reply carries a key, and nothing reached the server except the one allowlisted route in the list + for (const reply of replies) expect(reply.body).not.toContain('sk-LEAKED-PROVIDER-KEY') + expect(upstream.seen).toHaveLength(0) + }) + + it.each( + V2_GATEWAY_ROUTES.filter(route => route.path !== '/api/event').map( + route => [route.method, route.path.replaceAll(/:\w+/g, 'abc_123')] as const, + ), + )('forwards the allowlisted route %s %s unchanged', async (method, path) => { + // #given + const {upstream, url} = await setup() + + // #when + const reply = await request(url, {method, path: `${path}?x=1&y=%2Fapi%2Fcredential`, headers: BEARER}) + + // #then + expect(reply.status).toBe(200) + expect(upstream.seen).toHaveLength(1) + expect(upstream.seen[0]).toMatchObject({method, url: `${path}?x=1&y=%2Fapi%2Fcredential`}) + }) + + it('lets a query say anything without changing the route: it is not inspected for routing', async () => { + // #given + const {upstream, url} = await setup() + + // #when a query names credential routes, dot segments and parameters + await request(url, {path: '/api/session/active?next=/api/credential;x=1/../..', headers: BEARER}) + + // #then the allowlisted route was forwarded with its query intact + expect(upstream.seen[0]?.url).toBe('/api/session/active?next=/api/credential;x=1/../..') + }) + + it('answers 404 for a route outside the allowlist while no server is ready, and still asks for the bearer first', async () => { + // #given + const {upstream, url} = await setup({credentialed: false}) + + // #when + const noBearer = await request(url, {path: '/api/credential'}) + const withBearer = await request(url, {path: '/api/credential', headers: BEARER}) + const allowlisted = await request(url, {path: '/api/session/active', headers: BEARER}) + + // #then + expect(noBearer.status).toBe(401) + expect(withBearer.status).toBe(404) + expect(allowlisted.status).toBe(503) + expect(upstream.seen).toHaveLength(0) + }) + + it('logs a refusal with the method only: no path, query or header', async () => { + // #given + const {url, lines} = await setup() + + // #when + await request(url, {path: '/api/credential/cred_secret_id?label=x', headers: BEARER}) + + // #then + const blocked = lines.find(line => line.message === 'opencode-proxy-v2: route not allowed') + expect(blocked?.meta).toEqual({method: 'GET'}) + expect(JSON.stringify(lines)).not.toContain('cred_secret_id') + }) + }) + + describe('isForwardableTarget', () => { + it.each([ + ['GET', '/api/session/active'], + ['GET', '/api/session?parentID=ses_1&limit=5'], + ['POST', '/api/session/ses_1/prompt'], + ['GET', '/api/event'], + ['DELETE', '/api/shell/shl_1?x=%2F'], + ])('accepts %s %s', (method, target) => { + expect(isForwardableTarget(method, target)).toBe(true) + }) + + it.each([ + ['GET', undefined], + [undefined, '/api/session/active'], + ['GET', ''], + ['GET', 'api/session/active'], + ['GET', 'http://localhost/api/session/active'], + ['GET', '//localhost/api/session/active'], + ['CONNECT', 'localhost:9200'], + ['OPTIONS', '*'], + ['GET', '/api/session/active\t'], + ['GET', '/api/session/active\n'], + ['GET', '/api/session/active\u0000'], + ['GET', '/api/session/active?x=\u001F'], + ['GET', '/api/session/active?x=\u007F'], + ['GET', '/api/session/active x'], + ['GET', '/api/session/\u00E9'], + ['GET', '/api/session/active#x'], + ['GET', '/api/session/active;x'], + ['GET', String.raw`/api/session\active`], + ['GET', '/api/session/%2fx'], + ['GET', '/api/session/%2Fx'], + ['GET', '/api/session/%5cx'], + ['GET', '/api/session/%5Cx'], + ['GET', '/api/session/%2ex'], + ['GET', '/api/session/%2Ex'], + ['GET', '/api/session/%3bx'], + ['GET', '/api/session/%3Bx'], + ['GET', '/api/session/%25x'], + ['GET', '/api/session/%41'], + ['GET', '/api/session//x'], + ['GET', '/api/session/'], + ['GET', '/api/session/./x'], + ['GET', '/api/session/../x'], + ['GET', '/api/session/.'], + ['GET', '/api/session/..'], + ['GET', '/api/shell/..'], + ['DELETE', '/api/shell/.'], + ['GET', '/api/session/ses_1/inbox/..'], + ['GET', '/'], + ['GET', '/api/credential'], + ['get', '/api/session/active'], + ])('refuses %j %j', (method, target) => { + expect(isForwardableTarget(method, target)).toBe(false) + }) + }) + describe('teardown logging', () => { it('logs an interrupt with the session id, the status the caller saw, and no directory', async () => { // #given @@ -381,26 +667,18 @@ describe('opencode-proxy-v2', () => { expect(JSON.stringify(lines)).not.toContain('secret-repo') }) - it('logs session delete, location evict and location reload', async () => { + it('does not forward session delete, location evict or location reload: no gateway client calls them', async () => { // #given - const {url, lines} = await setup() + const {upstream, url, lines} = await setup() // #when await request(url, {method: 'DELETE', path: '/api/session/ses_abc123', headers: BEARER}) - await request(url, { - method: 'DELETE', - path: '/api/debug/location?location[directory]=%2Fworkspace%2Frepos%2Fa%2Fb', - headers: BEARER, - }) + await request(url, {method: 'DELETE', path: '/api/debug/location?location[directory]=%2Fa', headers: BEARER}) await request(url, {method: 'POST', path: '/api/location/reload', headers: BEARER}) - await new Promise(resolve => setTimeout(resolve, 20)) - // #then each teardown request is recorded once, and the query string (which carries the directory) is not - const messages = lines.map(line => line.message) - expect(messages).toContain('opencode-proxy-v2: session delete forwarded') - expect(messages).toContain('opencode-proxy-v2: location evict forwarded') - expect(messages).toContain('opencode-proxy-v2: location reload forwarded') - expect(JSON.stringify(lines)).not.toContain('workspace%2Frepos') + // #then + expect(upstream.seen).toHaveLength(0) + expect(lines.some(line => line.message.includes('forwarded'))).toBe(false) }) it('does not log a forwarded line for an unauthorised caller', async () => { diff --git a/apps/workspace-agent/src/opencode-proxy-v2.ts b/apps/workspace-agent/src/opencode-proxy-v2.ts index 3fc067bf9c..38143b98ba 100644 --- a/apps/workspace-agent/src/opencode-proxy-v2.ts +++ b/apps/workspace-agent/src/opencode-proxy-v2.ts @@ -15,7 +15,13 @@ * - `x-opencode-directory` selects the location and passes through untouched (all headers except `authorization` * and `host` do). The SSE stream (`/api/event`) is uncompressed, delivered incrementally, and sends a `: * heartbeat` comment every 15 seconds, so it needs no special handling beyond the timeout exemption. - * - The teardown classifier knows the V2 routes. + * - The teardown classifier knows the one allowlisted stop route (session interrupt). + * - Only the routes the gateway's clients call are forwarded (`V2_GATEWAY_ROUTES`, an allowlist; fro-bot/agent#1992). + * The supervisor stores provider keys through `/api/credential`, straight to loopback and not through this proxy, + * and `credential.list` returns every stored value in the clear, so a bearer holder must not be able to reach it by + * any spelling. A denylist lost that race twice (absolute-form `http://host/api/credential`, and + * `/api/credential;x/../info`, which the server's router truncates at `;`), so the target is checked to be + * canonical origin-form and then matched against the allowlist by exact segments, with no decoding. * * SECURITY INVARIANTS: * 1. The bearer is compared with `timingSafeEqual`, length-checked first, and is never logged or echoed. @@ -24,6 +30,8 @@ * 4. No header value, query string or request body is logged. The workspace directory travels in a header and in * the evict query, so the teardown log carries only a validated session id, the status and the duration. * 5. Non-SSE requests have an inactivity timeout (504). SSE is exempt. + * 6. A request whose target is not canonical origin-form, or is not an allowlisted method and route, is answered 404 and + * never forwarded, and only the method is logged. */ import type {OpencodeProxyHandle} from './opencode-proxy.js' @@ -36,11 +44,14 @@ import http from 'node:http' import process from 'node:process' import {URL} from 'node:url' +import {isGatewayRouteV2} from '@fro-bot/runtime/workspace-v2' + const UNAUTHORIZED_BODY = 'Unauthorized\n' const SERVICE_UNAVAILABLE_BODY = 'Service Unavailable\n' const GATEWAY_TIMEOUT_BODY = 'Gateway Timeout\n' const UPSTREAM_AUTH_FAILED_BODY = 'Upstream Authentication Failed\n' const BAD_GATEWAY_BODY = 'Bad Gateway\n' +const NOT_FOUND_BODY = 'Not Found\n' /** Names the cause of a 502 the proxy synthesised, so a caller can tell an auth failure from a missing server. */ const PROXY_ERROR_HEADER = 'x-fro-bot-proxy-error' @@ -68,41 +79,63 @@ interface TeardownRequest { } /** - * Recognizes the V2 requests that tear down a session or a location. Matches on method and path only (the query - * string is dropped), and returns a fixed message so nothing request-derived but a validated session id is logged. + * Recognizes the one allowlisted request that stops a session. Runs only on a target `isForwardableTarget` accepted, so + * the path is canonical, and returns a fixed message so nothing request-derived but a validated session id is logged. */ function classifyTeardown(method: string | undefined, rawUrl: string | undefined): TeardownRequest | undefined { - if (method === undefined || rawUrl === undefined) return undefined - let pathname: string - try { - pathname = new URL(rawUrl, 'http://proxy.invalid').pathname - } catch { - return undefined - } - // ['', 'api', 'session', ':id', 'interrupt'] | ['', 'api', 'session', ':id'] - // | ['', 'api', 'debug', 'location'] | ['', 'api', 'location', 'reload'] - const segments = pathname.split('/') - if (segments[0] !== '' || segments[1] !== 'api') return undefined - - const [, , head, second, third] = segments - if (head === 'session' && second !== undefined && SESSION_ID_PATTERN.test(second)) { - if (method === 'POST' && segments.length === 5 && third === 'interrupt') { - return {message: 'opencode-proxy-v2: session interrupt forwarded', sessionId: second} - } - if (method === 'DELETE' && segments.length === 4) { - return {message: 'opencode-proxy-v2: session delete forwarded', sessionId: second} - } - return undefined - } - if (method === 'DELETE' && segments.length === 4 && head === 'debug' && second === 'location') { - return {message: 'opencode-proxy-v2: location evict forwarded'} - } - if (method === 'POST' && segments.length === 4 && head === 'location' && second === 'reload') { - return {message: 'opencode-proxy-v2: location reload forwarded'} + if (method !== 'POST' || rawUrl === undefined) return undefined + const segments = (rawUrl.split('?', 1)[0] ?? '').split('/') + // ['', 'api', 'session', ':id', 'interrupt'] + const [, api, head, id, tail] = segments + if ( + segments.length === 5 && + api === 'api' && + head === 'session' && + tail === 'interrupt' && + id !== undefined && + SESSION_ID_PATTERN.test(id) + ) { + return {message: 'opencode-proxy-v2: session interrupt forwarded', sessionId: id} } return undefined } +/** Printable ASCII only: no control character, space, DEL or byte above 0x7e anywhere in the target. */ +const PRINTABLE_ASCII = /^[\x21-\x7E]+$/ + +/** The percent-encodings of `/`, `\`, `.`, `;` and `%`: the characters a router can decode into path structure. */ +const STRUCTURAL_ENCODING = /%(?:2f|5c|2e|3b|25)/i + +/** + * True when a request may be forwarded: its target is canonical origin-form and its method and path are an allowlisted + * route. The check is strict on purpose, because the forwarded target is what the server's router sees: + * + * - origin-form only: it starts with exactly one `/`, so absolute-form (`http://host/...`, which the server's router + * reduces to its path), authority-form and `*` are refused; + * - printable ASCII only, and no `#`; + * - the path (everything before the first `?`) has no `;` (the router truncates a segment there), no `\`, no empty + * segment, no `.` or `..` segment, and no percent-encoding of `/`, `\`, `.`, `;` or `%`; + * - the path matches an allowlisted route by exact segments, with no decoding (`isGatewayRouteV2`). + * + * The query is allowed and is never inspected for routing: nothing after the first `?` decides the route. + */ +export function isForwardableTarget(method: string | undefined, target: string | undefined): boolean { + if (method === undefined || target === undefined) return false + if (!PRINTABLE_ASCII.test(target) || target.includes('#')) return false + if (!target.startsWith('/') || target.startsWith('//')) return false + const queryStart = target.indexOf('?') + const path = queryStart === -1 ? target : target.slice(0, queryStart) + if (path.includes(';') || path.includes('\\') || STRUCTURAL_ENCODING.test(path)) return false + if ( + path + .split('/') + .slice(1) + .some(segment => segment === '' || segment === '.' || segment === '..') + ) + return false + return isGatewayRouteV2(method, path) +} + export interface OpencodeProxyV2Options { /** Expected bearer token. Compared with `timingSafeEqual`. Never logged. */ readonly token: string @@ -158,6 +191,15 @@ export function createOpencodeProxyV2(options: OpencodeProxyV2Options): Opencode return } + // --- Allowlist: only the gateway's own routes, in canonical origin-form --- + // After the bearer check (an unauthenticated caller learns nothing) and before the 503 and the forward, so the + // answer is the same whether or not a server is ready. Nothing request-derived is logged but the method. + if (!isForwardableTarget(req.method, req.url)) { + logger.warn('opencode-proxy-v2: route not allowed', {method: req.method}) + reply(res, 404, NOT_FOUND_BODY) + return + } + // --- Upstream credential: the server that is ready now --- const upstreamAuthorization = credentials.authorization() if (upstreamAuthorization === undefined) { diff --git a/apps/workspace-agent/src/opencode-server-v2.integration.test.ts b/apps/workspace-agent/src/opencode-server-v2.integration.test.ts index 1781c117b2..efa285b989 100644 --- a/apps/workspace-agent/src/opencode-server-v2.integration.test.ts +++ b/apps/workspace-agent/src/opencode-server-v2.integration.test.ts @@ -15,12 +15,18 @@ * What it proves that the fake-server tests cannot: with the plugins installed that way, the real runner passes both * the plugin gate and the permission gate; the proxy turns the gateway bearer into the live credential; a new spawn * starts on an empty database; and a shutdown with a turn in flight leaves no execution claim in the database. + * + * It also pins fro-bot/agent#1992. The provider credential reaches the model request only through the production path: + * a root-only auth blob read by the supervisor and provisioned over `POST /api/credential`, with the config overlay + * carrying a `baseURL` and no key. A fresh database never imports `auth.json`, so without provisioning the fake + * Anthropic-compatible provider sees no `x-api-key`, and with no credential at all readiness never turns ready. */ import type {AddressInfo} from 'node:net' import type {OpenCodeV2StartError, StartOpenCodeV2ServerOptions} from '@fro-bot/runtime/workspace-v2' import type {Logger, SupervisedStatusRef} from './opencode-server.js' +import {Buffer} from 'node:buffer' import {execFileSync, spawnSync} from 'node:child_process' import {chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync} from 'node:fs' import http from 'node:http' @@ -45,6 +51,7 @@ import {createOpencodeProxyV2} from './opencode-proxy-v2.js' import { createUpstreamCredentials, prepareFreshDatabase, + readSpawnConfigFromFile, runSupervisedOpencodeV2, WORKSPACE_V2_PLUGIN_SPECS, } from './opencode-server-v2.js' @@ -60,6 +67,9 @@ const PIN = JSON.parse( const INSTALL_SCRIPT = fileURLToPath(new URL('../../../deploy/scripts/install-opencode-v2-plugins.sh', import.meta.url)) const GATEWAY_BEARER = 'integration-gateway-bearer' +/** The provider key. It reaches the fake provider only through the supervisor's credential provisioning. */ +const PROVIDER_KEY = 'sk-integration-FIXTURE-KEY-not-a-credential' +const MODEL = 'anthropic/claude-sonnet-4-6' const RUNNER_TIMEOUT_MS = 180_000 const lines: string[] = [] @@ -75,38 +85,84 @@ interface Sandbox { readonly reposRoot: string readonly repo: string readonly dbBase: string + /** The root-only credential blob the supervisor reads (`WORKSPACE_OPENCODE_AUTH_FILE`). */ + readonly authFile: string + /** A key file the child can read, for `{file:}` references. A trailing newline is trimmed by the server. */ + readonly keyFile: string } let box: Sandbox -let provider: HeldProvider | undefined +let provider: FakeProvider | undefined + +interface ProviderHit { + readonly headers: http.IncomingHttpHeaders + readonly authorized: boolean +} -interface HeldProvider { +interface FakeProvider { readonly baseURL: string - readonly requests: () => number + /** Every model request, with the headers it carried. */ + readonly hits: () => readonly ProviderHit[] + /** Model requests that carried the expected `x-api-key`. */ + readonly authorizedRequests: () => number + /** When true, an authorized request gets its first frame and is then held open (a turn in flight). */ + readonly setHold: (hold: boolean) => void readonly stop: () => Promise } -/** An OpenAI-compatible endpoint that answers every chat request with headers and then never finishes. */ -async function startHeldProvider(): Promise { - let requests = 0 +/** + * An Anthropic-compatible endpoint that records request headers. It answers 401 unless `x-api-key` equals the key, so a + * request that carries no credential, or the wrong header shape, fails the turn instead of passing. + */ +async function startFakeProvider(expectedKey: string): Promise { + const hits: ProviderHit[] = [] + let hold = false const server = http.createServer((req, res) => { req.resume() - if (req.method === 'GET') { - res.writeHead(200, {'content-type': 'application/json'}) - res.end(JSON.stringify({object: 'list', data: [{id: 'fixture-model', object: 'model'}]})) + if (req.method !== 'POST') { + res.writeHead(404).end() + return + } + const authorized = req.headers['x-api-key'] === expectedKey + hits.push({headers: req.headers, authorized}) + if (!authorized) { + res.writeHead(401, {'content-type': 'application/json'}) + res.end(JSON.stringify({type: 'error', error: {type: 'authentication_error', message: 'invalid x-api-key'}})) return } - requests += 1 res.writeHead(200, {'content-type': 'text/event-stream', 'cache-control': 'no-cache'}) - res.write( - `data: ${JSON.stringify({id: 'c', object: 'chat.completion.chunk', created: 1, model: 'fixture-model', choices: [{index: 0, delta: {role: 'assistant', content: ''}, finish_reason: null}]})}\n\n`, - ) + const frame = (event: string, data: unknown): void => { + res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`) + } + frame('message_start', { + type: 'message_start', + message: { + id: 'msg_1', + type: 'message', + role: 'assistant', + model: 'claude-sonnet-4-6', + content: [], + stop_reason: null, + usage: {input_tokens: 1, output_tokens: 1}, + }, + }) + if (hold) return + frame('content_block_start', {type: 'content_block_start', index: 0, content_block: {type: 'text', text: ''}}) + frame('content_block_delta', {type: 'content_block_delta', index: 0, delta: {type: 'text_delta', text: 'pong'}}) + frame('content_block_stop', {type: 'content_block_stop', index: 0}) + frame('message_delta', {type: 'message_delta', delta: {stop_reason: 'end_turn'}, usage: {output_tokens: 1}}) + frame('message_stop', {type: 'message_stop'}) + res.end() }) await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) const {port} = server.address() as AddressInfo return { baseURL: `http://127.0.0.1:${port}/v1`, - requests: () => requests, + hits: () => hits, + authorizedRequests: () => hits.filter(hit => hit.authorized).length, + setHold: next => { + hold = next + }, stop: async () => { server.closeAllConnections() await new Promise(resolve => server.close(() => resolve())) @@ -151,7 +207,11 @@ function createSandbox(): Sandbox { writeFileSync(path.join(repo, 'a.txt'), 'hello\n') git('add', '.') git('commit', '-qm', 'init') - return {root, home, reposRoot, repo, dbBase: path.join(root, 'db')} + const authFile = path.join(root, 'workspace_opencode_auth') + writeFileSync(authFile, JSON.stringify({anthropic: {type: 'api', key: PROVIDER_KEY}}), {mode: 0o600}) + const keyFile = path.join(root, 'provider-key') + writeFileSync(keyFile, `${PROVIDER_KEY}\n`, {mode: 0o600}) + return {root, home, reposRoot, repo, dbBase: path.join(root, 'db'), authFile, keyFile} } /** Installs both plugins exactly as the image build does, into the sandbox home. */ @@ -173,7 +233,19 @@ function installPlugins(sandbox: Sandbox): void { timeout: 280_000, }) if (result.status !== 0) throw new Error(`plugin install failed:\n${result.stderr}`) - // The entrypoint's merged global config: provider and model only, no plugin entry (the config content carries those). + writeMergedConfig(sandbox) +} + +/** + * The entrypoint's merged global config, as production writes it. By default the provider overlay carries a baseURL and + * NO key (the key lives in the root-only blob the supervisor provisions), plus the model. `inlineKey` puts the key in the + * overlay instead, the way an operator who keeps it in `WORKSPACE_OPENCODE_CONFIG` would. No plugin entry (the config + * content carries those). + */ +function writeMergedConfig( + sandbox: Sandbox, + options: {readonly inlineKey?: boolean; readonly fileKey?: boolean} = {}, +): void { writeFileSync( path.join(sandbox.home, '.config', 'opencode', 'opencode.json'), JSON.stringify({ @@ -181,14 +253,15 @@ function installPlugins(sandbox: Sandbox): void { autoupdate: false, share: 'disabled', provider: { - fake: { - npm: '@ai-sdk/openai-compatible', - name: 'Fake', - options: {baseURL: provider?.baseURL, apiKey: 'fake-integration-key-not-a-credential'}, - models: {'fixture-model': {name: 'Fixture', tool_call: true, limit: {context: 100_000, output: 8000}}}, + anthropic: { + options: { + baseURL: provider?.baseURL, + ...(options.inlineKey === true ? {apiKey: PROVIDER_KEY} : {}), + ...(options.fileKey === true ? {apiKey: `{file:${sandbox.keyFile}}`} : {}), + }, }, }, - model: 'fake/fixture-model', + model: MODEL, }), ) } @@ -209,12 +282,13 @@ function sandboxEnv(base: NodeJS.ProcessEnv): NodeJS.ProcessEnv { // The fake provider is on loopback; everything else (the registry included) is refused. NO_PROXY: '127.0.0.1,localhost', npm_config_registry: `${refused}/`, - OPENCODE_DISABLE_MODELS_FETCH: '1', } } interface RunningWorkspace { readonly statusRef: SupervisedStatusRef + /** Every status the supervisor wrote, in order. */ + readonly statuses: readonly SupervisedStatusRef['status'][] readonly credentials: ReturnType readonly proxyUrl: string readonly upstreamUrl: string @@ -223,9 +297,28 @@ interface RunningWorkspace { readonly stop: () => Promise } +interface StartWorkspaceOptions { + /** Provision the blob like production does. False leaves the model configured and no credential anywhere. */ + readonly withCredential?: boolean + /** The outcome the caller expects: `ready` (default) or a supervisor that gave up without ever being ready. */ + readonly expect?: 'ready' | 'refused' +} + /** Runs the real supervisor and the real V2 proxy on loopback ports, against the real binary. */ -async function startWorkspace(): Promise { - const statusRef: SupervisedStatusRef = {status: 'starting'} +async function startWorkspace(options: StartWorkspaceOptions = {}): Promise { + const {withCredential = true, expect: outcome = 'ready'} = options + // Records every transition, so a test can prove readiness was never reported, not merely absent at the end. + const seen: SupervisedStatusRef['status'][] = [] + let current: SupervisedStatusRef['status'] = 'starting' + const statusRef: SupervisedStatusRef = { + get status() { + return current + }, + set status(value) { + current = value + seen.push(value) + }, + } const credentials = createUpstreamCredentials() const controller = new AbortController() const databasePaths: string[] = [] @@ -242,8 +335,15 @@ async function startWorkspace(): Promise { port, readyTimeoutMs: 90_000, maxAttempts: 1, - parentEnv: {}, + // What the service gets from compose: the model, and the path of the root-only credential blob. + parentEnv: { + WORKSPACE_OPENCODE_AUTH_FILE: withCredential + ? box.authFile + : path.join(box.root, 'absent-workspace_opencode_auth'), + }, deps: { + // The model comes from the merged config the sandbox wrote (installPlugins), as in production. + readSpawnConfig: () => readSpawnConfigFromFile(path.join(box.home, '.config', 'opencode', 'opencode.json')), prepareDatabase: async () => { const dbPath = await prepareFreshDatabase({ baseDir: box.dbBase, @@ -278,6 +378,10 @@ async function startWorkspace(): Promise { await vi.waitFor( () => { + if (outcome === 'refused') { + expect(statusRef.status).toBe('degraded') + return + } if (statusRef.status === 'degraded') throw new Error(`supervisor degraded: ${JSON.stringify(startErrors)}`) expect(statusRef.status).toBe('ready') }, @@ -286,6 +390,7 @@ async function startWorkspace(): Promise { return { statusRef, + statuses: seen, credentials, proxyUrl, upstreamUrl: `http://127.0.0.1:${port}`, @@ -333,7 +438,7 @@ describe.skipIf(!hasBinary)('the workspace V2 runner against the real stock bina throw new Error(`FRO_BOT_OPENCODE_V2_BIN reports "${reported}", not the pinned ${PIN.version}`) } box = createSandbox() - provider = await startHeldProvider() + provider = await startFakeProvider(PROVIDER_KEY) installPlugins(box) }, 300_000) @@ -357,9 +462,13 @@ describe.skipIf(!hasBinary)('the workspace V2 runner against the real stock bina // #and both plugins are listed active under the ids the workspace expects // (plugins activate per location, so a location the gates did not ask about answers /api/agent 503 until its // own activation finishes; the supervisor's location is box.reposRoot) + // (read straight from the loopback server: /api/plugin is not a route the gateway calls, so the proxy refuses it) const listPlugins = async (): Promise => { - const response = await viaProxy(workspace, '/api/plugin', { - headers: {'x-opencode-directory': encodeURIComponent(box.reposRoot)}, + const response = await fetch(`${workspace.upstreamUrl}/api/plugin`, { + headers: { + authorization: workspace.credentials.authorization() as string, + 'x-opencode-directory': encodeURIComponent(box.reposRoot), + }, }) const body = (await response.json()) as {data: {id?: string; state: {status: string}}[]} return body.data.filter(entry => entry.state.status === 'active').map(entry => entry.id ?? '') @@ -373,8 +482,9 @@ describe.skipIf(!hasBinary)('the workspace V2 runner against the real stock bina // #and the bearer is the only door: no bearer is refused by the proxy, and the server itself refuses anonymous callers expect((await fetch(`${workspace.proxyUrl}/api/info`)).status).toBe(401) expect((await fetch(`${workspace.upstreamUrl}/api/info`)).status).toBe(401) - const info = await viaProxy(workspace, '/api/info') - expect(info.status).toBe(200) + // #and the proxy forwards only the gateway's routes: the server's own info route is not one of them + expect((await viaProxy(workspace, '/api/info')).status).toBe(404) + expect((await viaProxy(workspace, '/api/session/active')).status).toBe(200) // #and the SSE stream opens through the proxy with the server.connected frame first const stream = await viaProxy(workspace, '/api/event', {headers: {accept: 'text/event-stream'}}) @@ -406,6 +516,8 @@ describe.skipIf(!hasBinary)('the workspace V2 runner against the real stock bina {timeout: RUNNER_TIMEOUT_MS}, async () => { // #given a first spawn with a session and a turn held at the provider + provider?.setHold(true) + const authorizedBefore = provider?.authorizedRequests() ?? 0 const first = await startWorkspace() const firstDb = first.databasePaths[0] as string let sessionId: string @@ -422,7 +534,10 @@ describe.skipIf(!hasBinary)('the workspace V2 runner against the real stock bina body: JSON.stringify({text: 'go'}), }) expect(prompt.status).toBeLessThan(300) - await vi.waitFor(() => expect(provider?.requests()).toBeGreaterThan(0), {timeout: 30_000, interval: 100}) + await vi.waitFor(() => expect(provider?.authorizedRequests()).toBeGreaterThan(authorizedBefore), { + timeout: 30_000, + interval: 100, + }) await vi.waitFor(() => expect(readClaim(firstDb, sessionId)).not.toBeNull(), {timeout: 15_000, interval: 100}) } finally { // #when the service stops: the supervisor interrupts the active session before it closes stdin @@ -443,6 +558,266 @@ describe.skipIf(!hasBinary)('the workspace V2 runner against the real stock bina expect(old.status).toBe(404) } finally { await second.stop() + provider?.setHold(false) + } + }, + ) + + it( + 'provisions the key into a fresh database so the model request carries it (fro-bot/agent#1992)', + {timeout: RUNNER_TIMEOUT_MS}, + async () => { + // #given the production layout: an overlay with a baseURL and no key, a model, and a root-only blob + provider?.setHold(false) + const hitsBefore = provider?.hits().length ?? 0 + const workspace = await startWorkspace() + try { + // #then the spawn's credential store holds the provisioned credential (counted, never read) + const dbPath = workspace.databasePaths[0] as string + const db = new DatabaseSync(dbPath) + try { + const row = db.prepare('SELECT count(*) AS n FROM credential WHERE integration_id = ?').get('anthropic') as { + n: number + } + expect(row.n).toBe(1) + } finally { + db.close() + } + + // #and the read-back the gate relies on lists the connection and carries no secret + const integration = await fetch(`${workspace.upstreamUrl}/api/integration/anthropic`, { + headers: { + authorization: workspace.credentials.authorization() as string, + 'x-opencode-directory': encodeURIComponent(box.reposRoot), + }, + }) + const integrationText = await integration.text() + expect(integration.status).toBe(200) + expect(integrationText).not.toContain(PROVIDER_KEY) + expect(JSON.parse(integrationText)).toMatchObject({data: {connections: [{type: 'credential', method: 'key'}]}}) + + // #and provisioning works while the bearer proxy refuses the credential group: the supervisor stored the key + // straight to loopback, the group answers there (control), and the gateway bearer gets a 404 for it + const direct = await fetch(`${workspace.upstreamUrl}/api/credential`, { + headers: {authorization: workspace.credentials.authorization() as string}, + }) + expect(direct.status).toBe(200) + for (const spelling of ['/api/credential', '/api/CREDENTIAL', '/api/%63redential', '/api/credential/']) { + const viaGateway = await viaProxy(workspace, spelling) + expect(viaGateway.status).toBe(404) + expect(await viaGateway.text()).not.toContain(PROVIDER_KEY) + } + + // #when one prompt is sent + const created = (await ( + await viaProxy(workspace, '/api/session', { + method: 'POST', + body: JSON.stringify({location: {directory: box.repo}}), + }) + ).json()) as {data: {id: string}} + const prompt = await viaProxy(workspace, `/api/session/${created.data.id}/prompt`, { + method: 'POST', + body: JSON.stringify({text: 'ping'}), + }) + expect(prompt.status).toBeLessThan(300) + await vi.waitFor(() => expect(provider?.hits().slice(hitsBefore).length).toBeGreaterThan(0), { + timeout: 30_000, + interval: 100, + }) + + // #then the request arrived with the key in x-api-key, the header V1 sent, and no authorization header + const hits = provider?.hits().slice(hitsBefore) ?? [] + expect(hits.length).toBeGreaterThan(0) + for (const hit of hits) { + expect(hit.headers['x-api-key']).toBe(PROVIDER_KEY) + expect(hit.headers.authorization).toBeUndefined() + } + + // #and the key reached no log line + expect(lines.join('\n')).not.toContain(PROVIDER_KEY) + } finally { + await workspace.stop() + } + }, + ) + + it( + 'never turns ready when the model provider has no credential, and the provider sees no request', + {timeout: RUNNER_TIMEOUT_MS}, + async () => { + // #given a configured model and no credential blob + provider?.setHold(false) + const hitsBefore = provider?.hits().length ?? 0 + + // #when the supervisor runs to its verdict + const workspace = await startWorkspace({withCredential: false, expect: 'refused'}) + try { + // #then readiness was never reported, the proxy holds no credential, and the failure is named in the log + expect(workspace.statuses).not.toContain('ready') + expect(workspace.statusRef.status).toBe('degraded') + expect(workspace.credentials.authorization()).toBeUndefined() + expect(lines).toContain('error opencode-server-v2: credential gate failed') + expect(provider?.hits().length).toBe(hitsBefore) + } finally { + await workspace.stop() + } + }, + ) + + it( + 'cannot be made to return a provider key through the bearer proxy, by any request target tried', + {timeout: RUNNER_TIMEOUT_MS}, + async () => { + // #given a ready server that holds the provisioned key, behind the real proxy + const workspace = await startWorkspace() + try { + const proxy = new URL(workspace.proxyUrl) + const rawGet = async (target: string, method = 'GET'): Promise<{status: number; body: string}> => + new Promise((resolve, reject) => { + const socket = net.connect(Number(proxy.port), proxy.hostname, () => { + socket.write( + [ + `${method} ${target} HTTP/1.1`, + `host: ${proxy.host}`, + `authorization: Bearer ${GATEWAY_BEARER}`, + `x-opencode-directory: ${encodeURIComponent(box.reposRoot)}`, + 'connection: close', + '', + '', + ].join('\r\n'), + ) + }) + const chunks: Buffer[] = [] + socket.on('data', chunk => chunks.push(chunk)) + socket.on('error', reject) + socket.setTimeout(15_000, () => socket.destroy(new Error('timeout'))) + socket.on('close', () => { + const text = Buffer.concat(chunks).toString('utf8') + resolve({ + status: Number(/^HTTP\/1\.[01] (\d{3})/.exec(text)?.[1] ?? 0), + body: text.split('\r\n\r\n').slice(1).join('\r\n\r\n'), + }) + }) + }) + + // #and the control: the loopback server really does return the key at /api/credential + const direct = await fetch(`${workspace.upstreamUrl}/api/credential`, { + headers: {authorization: workspace.credentials.authorization() as string}, + }) + expect(await direct.text()).toContain(PROVIDER_KEY) + + // #when every spelling the earlier denylist missed, and the ones it caught, is tried raw + const targets = [ + 'http://localhost/api/credential', + 'http://localhost:9200/api/credential', + 'HTTP://LOCALHOST/api/credential', + 'http://user@localhost/api/credential', + 'http://localhost/api/session/active', + '/api/credential;ignored/../info', + '/api/credential;ignored/../session/active', + '/api/session/active;x/../../credential', + '/api/session;x/../credential', + '/api/credential', + '/api/credential/', + '/api/CREDENTIAL', + '/api/%63redential', + '/api//credential', + '//api/credential', + '/api/./credential', + '/api/x/../credential', + '/api/x/%2e%2e/credential', + '/api/credential%2F', + '/api/credential%252F', + '/api/credential;x=1', + String.raw`/api\credential`, + '/api%2Fcredential', + '/api/session/active%3B/../credential', + '/api/session/ses_abc/%2e%2e/%2e%2e/credential', + '/api/session/active?x=/../credential', + '/api/session/active#/../../credential', + '/api/integration/anthropic/connect/key', + '/api/provider', + '/api/model', + '/api/config', + '*', + ] + const replies: {target: string; method: string; status: number; body: string}[] = [] + for (const target of targets) { + for (const method of ['GET', 'POST', 'DELETE', 'PATCH']) { + // The one allowlisted route in the list answers 200 and is the control for "the proxy still forwards". + replies.push({target, method, ...(await rawGet(target, method))}) + } + } + + // #then none returned the key; and none is a bypass: anything but the plain allowlisted GET is a 404 + for (const reply of replies) { + expect(reply.body, `${reply.method} ${reply.target}`).not.toContain(PROVIDER_KEY) + expect(reply.body, `${reply.method} ${reply.target}`).not.toContain('"value"') + // the plain allowlisted GET (its query only looks like a traversal) is forwarded; `*` may be refused by the parser + const allowed = + reply.target === '/api/session/active?x=/../credential' && reply.method === 'GET' + ? [200] + : reply.target === '*' + ? [400, 404] + : [404] + expect(allowed, `${reply.method} ${reply.target}`).toContain(reply.status) + } + + // #and the gateway's own routes still work through the same proxy + expect((await viaProxy(workspace, '/api/session/active')).status).toBe(200) + } finally { + await workspace.stop() + } + }, + ) + + it.each([ + ['inline in the overlay', {inlineKey: true}], + ['as a {file:} reference to a key file the child can read', {fileKey: true}], + ] as const)( + 'reaches ready and sends the key when it is configured %s, with no stored credential', + {timeout: RUNNER_TIMEOUT_MS}, + async (_label, configOptions) => { + // #given the key in provider.anthropic.options.apiKey (the shape V1 migrates into V2 settings) and no blob + provider?.setHold(false) + const hitsBefore = provider?.hits().length ?? 0 + writeMergedConfig(box, configOptions) + const workspace = await startWorkspace({withCredential: false}) + try { + // #then the gate accepted the inline key: the spawn's credential store is empty and it is ready anyway + const db = new DatabaseSync(workspace.databasePaths[0] as string) + try { + const row = db.prepare('SELECT count(*) AS n FROM credential').get() as {n: number} + expect(row.n).toBe(0) + } finally { + db.close() + } + expect(workspace.statuses).toContain('ready') + + // #when a prompt is sent + const created = (await ( + await viaProxy(workspace, '/api/session', { + method: 'POST', + body: JSON.stringify({location: {directory: box.repo}}), + }) + ).json()) as {data: {id: string}} + await viaProxy(workspace, `/api/session/${created.data.id}/prompt`, { + method: 'POST', + body: JSON.stringify({text: 'ping'}), + }) + await vi.waitFor(() => expect(provider?.hits().slice(hitsBefore).length).toBeGreaterThan(0), { + timeout: 30_000, + interval: 100, + }) + + // #then the inline key reached the provider in x-api-key, and never in a log line + for (const hit of provider?.hits().slice(hitsBefore) ?? []) { + expect(hit.headers['x-api-key']).toBe(PROVIDER_KEY) + } + expect(lines.join('\n')).not.toContain(PROVIDER_KEY) + } finally { + await workspace.stop() + writeMergedConfig(box) } }, ) diff --git a/apps/workspace-agent/src/opencode-server-v2.test.ts b/apps/workspace-agent/src/opencode-server-v2.test.ts index b069d9bd3b..35a0217ff7 100644 --- a/apps/workspace-agent/src/opencode-server-v2.test.ts +++ b/apps/workspace-agent/src/opencode-server-v2.test.ts @@ -3,10 +3,11 @@ import type { OpenCodeV2StartError, StartOpenCodeV2ServerOptions, } from '@fro-bot/runtime/workspace-v2' +import type {SpawnConfig} from './opencode-credentials-v2.js' import type {RunSupervisedOpencodeV2Options} from './opencode-server-v2.js' import type {Logger, SupervisedStatusRef} from './opencode-server.js' -import {mkdtempSync, readdirSync, rmSync, statSync, writeFileSync} from 'node:fs' +import {mkdtempSync, readdirSync, rmSync, statSync, symlinkSync, writeFileSync} from 'node:fs' import os from 'node:os' import path from 'node:path' import process from 'node:process' @@ -19,10 +20,12 @@ import { } from '@fro-bot/runtime/workspace-v2' import {afterEach, describe, expect, it, vi} from 'vitest' +import {spawnConfigFromMergedConfig} from './opencode-credentials-v2.js' import { buildOpencodeV2Env, createUpstreamCredentials, prepareFreshDatabase, + readSpawnConfigFromFile, runSupervisedOpencodeV2, } from './opencode-server-v2.js' @@ -59,6 +62,8 @@ function createHandle(options: { agentsBody?: unknown /** Replaces the `/api/agent` answer, to stall the response headers or the body. */ agentResponse?: () => Promise + /** Answers any other path (the credential and integration routes); undefined falls through to 404. */ + routes?: (pathname: string, init?: RequestInit) => Response | Promise | undefined }): FakeHandle { let resolveExit: (info: {code: number | null; signal: NodeJS.Signals | null}) => void = () => undefined const exited = new Promise<{code: number | null; signal: NodeJS.Signals | null}>(resolve => { @@ -79,8 +84,10 @@ function createHandle(options: { url: 'http://127.0.0.1:54321', pid: 4242, directory: '/workspace/repos', - fetch: async pathname => { + fetch: async (pathname, init) => { fetchPaths.push(pathname) + const routed = await options.routes?.(pathname, init) + if (routed !== undefined) return routed if (pathname === '/api/session/active') { if (options.activeSessions === 'fail') throw new Error('unreachable') const active = options.activeSessions ?? [] @@ -180,6 +187,14 @@ describe('buildOpencodeV2Env', () => { expect(env).not.toHaveProperty('OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS') }) + it('turns the models.dev catalog fetch off: the egress allowlist blocks it and the bundled snapshot is the floor', () => { + // #when the parent asks for the opposite + const env = buildOpencodeV2Env({OPENCODE_DISABLE_MODELS_FETCH: '0'}, '/db/opencode.db') + + // #then the workspace decides, not the parent + expect(env.OPENCODE_DISABLE_MODELS_FETCH).toBe('1') + }) + it('does not forward a parent file-watcher flag: it is not on the allowlist', () => { // #when const env = buildOpencodeV2Env({OPENCODE_DISABLE_FILEWATCHER: 'true'}, '/db/opencode.db') @@ -189,6 +204,70 @@ describe('buildOpencodeV2Env', () => { }) }) +describe('readSpawnConfigFromFile', () => { + const roots: string[] = [] + afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, {recursive: true, force: true}) + }) + function configDir(): string { + const root = mkdtempSync(path.join(os.tmpdir(), 'ws-v2-model-')) + roots.push(root) + return root + } + + it('reads the model the merged config names, including one that came only from the overlay', () => { + // #given the merged file for WORKSPACE_OPENCODE_MODEL unset and an overlay that carries `model` + const file = path.join(configDir(), 'opencode.json') + writeFileSync(file, JSON.stringify({autoupdate: false, model: 'openai/gpt-5.4-mini', provider: {openai: {}}})) + + // #when / #then + expect(readSpawnConfigFromFile(file)).toEqual({model: 'openai/gpt-5.4-mini', authenticatedProviders: []}) + }) + + it('reads inline-authenticated providers as ids only', () => { + const file = path.join(configDir(), 'opencode.json') + writeFileSync(file, JSON.stringify({model: 'anthropic/m', provider: {anthropic: {options: {apiKey: 'sk-INLINE'}}}})) + + const config = readSpawnConfigFromFile(file) + + expect(config).toEqual({model: 'anthropic/m', authenticatedProviders: ['anthropic']}) + expect(JSON.stringify(config)).not.toContain('INLINE') + }) + + it('reads no model for an absent file or one without a model', () => { + const dir = configDir() + const file = path.join(dir, 'opencode.json') + writeFileSync(file, JSON.stringify({autoupdate: false})) + expect(readSpawnConfigFromFile(path.join(dir, 'missing.json'))).toEqual({authenticatedProviders: []}) + expect(readSpawnConfigFromFile(file)).toEqual({authenticatedProviders: []}) + }) + + it('refuses a symlink, an oversized file and malformed JSON, without quoting the file', () => { + const dir = configDir() + const target = path.join(dir, 'target.json') + writeFileSync(target, '{"model":"x/y"}') + const link = path.join(dir, 'link.json') + symlinkSync(target, link) + const big = path.join(dir, 'big.json') + writeFileSync(big, `${' '.repeat(600 * 1024)}{}`) + const bad = path.join(dir, 'bad.json') + writeFileSync(bad, '{"provider":{"x":{"options":{"apiKey":"sk-SUPERSECRETKEY"') + + expect(() => readSpawnConfigFromFile(link)).toThrow(/symlink/) + expect(() => readSpawnConfigFromFile(big)).toThrow(/too large/) + expect(() => readSpawnConfigFromFile(bad)).toThrow(/not valid JSON/) + const thrown = (): string => { + try { + readSpawnConfigFromFile(bad) + return '' + } catch (error) { + return String(error) + } + } + expect(thrown()).not.toContain('SUPERSECRET') + }) +}) + describe('prepareFreshDatabase', () => { const roots: string[] = [] afterEach(() => { @@ -304,7 +383,18 @@ function stalledBody(): Response { describe('runSupervisedOpencodeV2', () => { const NO_BACKOFF = {initialBackoffMs: 0, maxBackoffMs: 0} - function setup(overrides: Partial = {}) { + /** + * `model` is the effective model the merged config names (absent: none configured). `spawnConfigs` replaces it with + * one config per spawn, in order, the last repeating: the file is read fresh for every spawn. + */ + function setup( + overrides: Partial & { + readonly model?: string + readonly spawnConfigs?: readonly SpawnConfig[] + } = {}, + ) { + const {model, spawnConfigs, ...runOverrides} = overrides + let spawnConfigReads = 0 const capture = createCapturingLogger() const credentials = createUpstreamCredentials() const statusRef: SupervisedStatusRef = {status: 'starting'} @@ -319,7 +409,7 @@ describe('runSupervisedOpencodeV2', () => { parentEnv: {HTTPS_PROXY: 'http://mitmproxy:8080'}, ...NO_BACKOFF, maxAttempts: 3, - ...overrides, + ...runOverrides, deps: { prepareDatabase: async () => { dbCounter += 1 @@ -328,6 +418,14 @@ describe('runSupervisedOpencodeV2', () => { return dbPath }, verifyRules: OK_GATE, + // Never the real secret path: the default reads /run/workspace-agent/secrets. + readAuthBlob: () => null, + readSpawnConfig: () => { + const configs = spawnConfigs ?? [{...(model === undefined ? {} : {model}), authenticatedProviders: []}] + const next = configs[Math.min(spawnConfigReads, configs.length - 1)] as SpawnConfig + spawnConfigReads += 1 + return next + }, ...overrides.deps, }, } @@ -784,4 +882,576 @@ describe('runSupervisedOpencodeV2', () => { // #then expect(capture.serialized()).not.toContain('TOPSECRETCREDENTIAL') }) + describe('provider credentials', () => { + const KEY = 'sk-ant-SUPERSECRETKEY-1234' + const MODEL = 'anthropic/claude-sonnet-4-6' + const BLOB = JSON.stringify({anthropic: {type: 'api', key: KEY}}) + + interface CredentialStore { + readonly posts: {readonly body: Record; readonly method: string | undefined}[] + readonly routes: NonNullable[0]['routes']> + } + + /** The two routes the supervisor uses: create stores a connection, integration lists what is stored. */ + function credentialStore(options: {createStatus?: number} = {}): CredentialStore { + const stored = new Set() + const posts: {body: Record; method: string | undefined}[] = [] + return { + posts, + routes: (pathname, init) => { + if (pathname === '/api/credential' && init?.method === 'POST') { + const body = JSON.parse(String(init.body)) as Record + posts.push({body, method: init.method}) + const status = options.createStatus ?? 200 + if (status === 200) stored.add(String(body.integrationID)) + // The real route echoes the stored value, secret included. + return new Response(JSON.stringify({data: {id: 'cred_1', value: body.value}}), {status}) + } + const match = /^\/api\/integration\/([^/]+)$/.exec(pathname) + if (match !== null) { + const id = decodeURIComponent(match[1] as string) + const connections = stored.has(id) + ? [{type: 'credential', id: 'cred_1', label: 'workspace', method: 'key'}] + : [] + return new Response(JSON.stringify({data: {id, name: id, methods: [], connections}}), {status: 200}) + } + return undefined + }, + } + } + + it('posts one key credential per provider before the server reports ready, and keeps the key out of every channel', async () => { + // #given a two-provider blob, a model, and a server that stores what it is given + const store = credentialStore() + const fake = createHandle({authorization: 'Basic one', routes: store.routes}) + const blob = JSON.stringify({anthropic: {type: 'api', key: KEY}, openai: {type: 'api', key: `${KEY}-openai`}}) + const {options, startOptions, capture, credentials} = setup({ + parentEnv: {HTTPS_PROXY: 'http://mitmproxy:8080'}, + model: MODEL, + }) + const seenAtReady: (string | undefined)[] = [] + let status: SupervisedStatusRef['status'] = 'starting' + const statusRef: SupervisedStatusRef = { + get status() { + return status + }, + set status(value) { + status = value + if (value === 'ready') seenAtReady.push(credentials.authorization()) + }, + } + const controller = new AbortController() + options.deps = { + ...options.deps, + readAuthBlob: () => blob, + startServer: scriptedStart([fake], startOptions), + } + + // #when + const done = runSupervisedOpencodeV2({...options, statusRef, signal: controller.signal}) + await vi.waitFor(() => expect(status).toBe('ready')) + + // #then each provider is posted exactly once with the key credential shape, and the gate read the model's provider back + expect(store.posts.map(post => post.body)).toEqual([ + {integrationID: 'anthropic', label: 'workspace', value: {type: 'key', key: KEY}}, + {integrationID: 'openai', label: 'workspace', value: {type: 'key', key: `${KEY}-openai`}}, + ]) + expect(fake.fetchPaths.filter(p => p === '/api/credential')).toHaveLength(2) + expect(fake.fetchPaths).toContain('/api/integration/anthropic') + expect(fake.fetchPaths).not.toContain('/api/integration/openai') + expect(seenAtReady).toEqual(['Basic one']) + + // #and the key is in no channel but the request body + expect(JSON.stringify(startOptions)).not.toContain(KEY) + expect(JSON.stringify(startOptions[0]?.env)).not.toContain('SUPERSECRETKEY') + expect(JSON.stringify(startOptions[0]?.config)).not.toContain('SUPERSECRETKEY') + controller.abort() + await done + expect(capture.serialized()).not.toContain('SUPERSECRETKEY') + expect(capture.serialized()).toContain('provider credentials provisioned') + }) + + it('provisions again on every respawn, because each spawn has an empty database', async () => { + // #given two servers with separate stores + const firstStore = credentialStore() + const secondStore = credentialStore() + const first = createHandle({authorization: 'Basic first', routes: firstStore.routes}) + const second = createHandle({authorization: 'Basic second', routes: secondStore.routes}) + const {options, startOptions, credentials} = setup({model: MODEL}) + const controller = new AbortController() + options.deps = { + ...options.deps, + readAuthBlob: () => BLOB, + startServer: scriptedStart([first, second], startOptions), + } + const done = runSupervisedOpencodeV2({...options, signal: controller.signal}) + await vi.waitFor(() => expect(credentials.authorization()).toBe('Basic first')) + + // #when the first server dies + first.exit(1) + + // #then the second one is provisioned too before it serves + await vi.waitFor(() => expect(credentials.authorization()).toBe('Basic second')) + expect(firstStore.posts).toHaveLength(1) + expect(secondStore.posts).toHaveLength(1) + controller.abort() + await done + }) + + it('does not go ready, and stops the server, when the credential route refuses', async () => { + // #given a server whose credential route answers 500 + const store = credentialStore({createStatus: 500}) + const fake = createHandle({authorization: 'Basic one', routes: store.routes}) + const {options, startOptions, capture, credentials} = setup({ + maxAttempts: 1, + model: MODEL, + }) + const {statusRef, seen} = recordStatuses() + options.deps = {...options.deps, readAuthBlob: () => BLOB, startServer: scriptedStart([fake], startOptions)} + + // #when + await runSupervisedOpencodeV2({...options, statusRef}) + + // #then it never turned ready, held no credential, shut the server down, and said why without the key + expect(seen).not.toContain('ready') + expect(statusRef.status).toBe('degraded') + expect(credentials.authorization()).toBeUndefined() + expect(fake.shutdown).toHaveBeenCalledTimes(1) + expect(capture.serialized()).toContain('credential-provision-failed') + expect(capture.serialized()).toContain('HTTP 500') + expect(capture.serialized()).not.toContain('SUPERSECRETKEY') + }) + + it('does not go ready when the credential request throws, and the error text never carries the key', async () => { + // #given a transport that fails with a message that quotes the request + const fake = createHandle({ + authorization: 'Basic one', + routes: (pathname, init) => { + if (pathname === '/api/credential') throw new Error(`socket hang up while sending ${String(init?.body)}`) + return undefined + }, + }) + const {options, startOptions, capture} = setup({maxAttempts: 1, model: MODEL}) + const {statusRef, seen} = recordStatuses() + options.deps = {...options.deps, readAuthBlob: () => BLOB, startServer: scriptedStart([fake], startOptions)} + + // #when + await runSupervisedOpencodeV2({...options, statusRef}) + + // #then + expect(seen).not.toContain('ready') + expect(capture.serialized()).toContain('credential-provision-failed') + expect(capture.serialized()).not.toContain('SUPERSECRETKEY') + }) + + it('refuses readiness when the configured model has no credential because the blob is absent', async () => { + // #given a model is configured and no credential blob was provisioned + const store = credentialStore() + const fake = createHandle({authorization: 'Basic one', routes: store.routes}) + const {options, startOptions, capture, credentials} = setup({ + maxAttempts: 2, + model: MODEL, + }) + const {statusRef, seen} = recordStatuses() + options.deps = {...options.deps, readAuthBlob: () => null, startServer: scriptedStart([fake], startOptions)} + + // #when + await runSupervisedOpencodeV2({...options, statusRef}) + + // #then the gate refuses on every attempt: never ready, nothing posted, no credential for the proxy + expect(seen).not.toContain('ready') + expect(statusRef.status).toBe('degraded') + expect(store.posts).toHaveLength(0) + expect(credentials.authorization()).toBeUndefined() + expect(startOptions).toHaveLength(2) + expect(capture.serialized()).toContain('credential-missing') + expect(capture.serialized()).toContain('anthropic') + }) + + it('refuses readiness when the blob has credentials for other providers but not the model provider', async () => { + // #given a blob for openai and a model on anthropic + const store = credentialStore() + const fake = createHandle({authorization: 'Basic one', routes: store.routes}) + const {options, startOptions, capture} = setup({maxAttempts: 1, model: MODEL}) + const {statusRef, seen} = recordStatuses() + options.deps = { + ...options.deps, + readAuthBlob: () => JSON.stringify({openai: {type: 'api', key: KEY}}), + startServer: scriptedStart([fake], startOptions), + } + + // #when + await runSupervisedOpencodeV2({...options, statusRef}) + + // #then + expect(seen).not.toContain('ready') + expect(capture.serialized()).toContain('credential-missing') + expect(capture.serialized()).not.toContain('SUPERSECRETKEY') + }) + + it('stays ready for a clone-only deployment: no model and no blob leaves nothing to check', async () => { + // #given neither a model nor a credential blob + const fake = createHandle({authorization: 'Basic one'}) + const {options, startOptions, statusRef, capture} = setup() + const controller = new AbortController() + options.deps = {...options.deps, startServer: scriptedStart([fake], startOptions)} + + // #when + const done = runSupervisedOpencodeV2({...options, signal: controller.signal}) + await vi.waitFor(() => expect(statusRef.status).toBe('ready')) + + // #then the credential routes were never touched + expect(fake.fetchPaths.some(p => p.startsWith('/api/credential') || p.startsWith('/api/integration'))).toBe(false) + expect(capture.serialized()).toContain('credential gate skipped') + controller.abort() + await done + }) + + it('checks the provisioned providers when a blob exists but no model is configured', async () => { + // #given a blob and no model, on a server that stores nothing + const fake = createHandle({ + authorization: 'Basic one', + routes: pathname => { + if (pathname === '/api/credential') return new Response('{}', {status: 200}) + if (pathname.startsWith('/api/integration/')) { + return new Response(JSON.stringify({data: {id: 'anthropic', connections: []}}), {status: 200}) + } + return undefined + }, + }) + const {options, startOptions, capture} = setup({maxAttempts: 1}) + const {statusRef, seen} = recordStatuses() + options.deps = {...options.deps, readAuthBlob: () => BLOB, startServer: scriptedStart([fake], startOptions)} + + // #when + await runSupervisedOpencodeV2({...options, statusRef}) + + // #then the read-back refuses + expect(seen).not.toContain('ready') + expect(capture.serialized()).toContain('credential-missing') + }) + + it('does not spawn when the blob holds a credential type it cannot provision, naming only the provider', async () => { + // #given an oauth entry that carries a token-looking string + const {options, startOptions, statusRef, capture} = setup() + options.deps = { + ...options.deps, + readAuthBlob: () => JSON.stringify({anthropic: {type: 'oauth', access: 'SUPERSECRETACCESS', refresh: 'x'}}), + startServer: scriptedStart([createHandle({authorization: 'Basic x'})], startOptions), + } + + // #when + await runSupervisedOpencodeV2(options) + + // #then + expect(startOptions).toHaveLength(0) + expect(statusRef.status).toBe('degraded') + expect(capture.serialized()).toContain('credential-blob-invalid') + expect(capture.serialized()).toContain('anthropic') + expect(capture.serialized()).not.toContain('SUPERSECRETACCESS') + }) + + it('does not spawn when the blob is not JSON, and the parse error never quotes it', async () => { + // #given + const {options, startOptions, statusRef, capture} = setup() + options.deps = { + ...options.deps, + readAuthBlob: () => '{"anthropic": SUPERSECRETKEY', + startServer: scriptedStart([createHandle({authorization: 'Basic x'})], startOptions), + } + + // #when + await runSupervisedOpencodeV2(options) + + // #then + expect(startOptions).toHaveLength(0) + expect(statusRef.status).toBe('degraded') + expect(capture.serialized()).toContain('credential-blob-invalid') + expect(capture.serialized()).not.toContain('SUPERSECRETKEY') + }) + + it('does not spawn when the secret file cannot be read', async () => { + // #given a read that fails + const {options, startOptions, statusRef, capture} = setup() + options.deps = { + ...options.deps, + readAuthBlob: () => { + throw new Error('Secret path is not a regular file: /run/workspace-agent/secrets/workspace_opencode_auth') + }, + startServer: scriptedStart([createHandle({authorization: 'Basic x'})], startOptions), + } + + // #when + await runSupervisedOpencodeV2(options) + + // #then + expect(startOptions).toHaveLength(0) + expect(statusRef.status).toBe('degraded') + expect(capture.serialized()).toContain('cannot read the provider credential blob') + }) + + it('refuses readiness for an overlay-only model whose provider the blob does not cover', async () => { + // #given the effective model came from the overlay (openai) and the blob holds only anthropic + const store = credentialStore() + const fake = createHandle({authorization: 'Basic one', routes: store.routes}) + const {options, startOptions, capture} = setup({maxAttempts: 1, model: 'openai/gpt-5.4-mini'}) + const {statusRef, seen} = recordStatuses() + options.deps = {...options.deps, readAuthBlob: () => BLOB, startServer: scriptedStart([fake], startOptions)} + + // #when + await runSupervisedOpencodeV2({...options, statusRef}) + + // #then the gate looked at openai, found nothing, and the server never served + expect(seen).not.toContain('ready') + expect(fake.fetchPaths).toContain('/api/integration/openai') + expect(capture.serialized()).toContain('credential-missing') + expect(capture.serialized()).toContain('openai') + }) + + it('reaches ready for an overlay-only model whose provider the blob covers', async () => { + // #given + const store = credentialStore() + const fake = createHandle({authorization: 'Basic one', routes: store.routes}) + const {options, startOptions, statusRef} = setup({model: 'openai/gpt-5.4-mini'}) + const controller = new AbortController() + options.deps = { + ...options.deps, + readAuthBlob: () => JSON.stringify({openai: {type: 'api', key: KEY}}), + startServer: scriptedStart([fake], startOptions), + } + + // #when + const done = runSupervisedOpencodeV2({...options, signal: controller.signal}) + await vi.waitFor(() => expect(statusRef.status).toBe('ready')) + + // #then + expect(fake.fetchPaths).toContain('/api/integration/openai') + expect(fake.fetchPaths).not.toContain('/api/integration/anthropic') + controller.abort() + await done + }) + + it('refuses readiness and stops the server when the merged config cannot be read after the spawn', async () => { + // #given + const fake = createHandle({authorization: 'Basic one'}) + const {options, startOptions, capture} = setup({maxAttempts: 1}) + const {statusRef, seen} = recordStatuses() + options.deps = { + ...options.deps, + readSpawnConfig: () => { + throw new Error('Secret path is not a regular file: /home/opencode/.config/opencode/opencode.json') + }, + startServer: scriptedStart([fake], startOptions), + } + + // #when + await runSupervisedOpencodeV2({...options, statusRef}) + + // #then it never turned ready, the server was stopped, and the failure kind is named + expect(seen).not.toContain('ready') + expect(statusRef.status).toBe('degraded') + expect(fake.shutdown).toHaveBeenCalledTimes(1) + expect(capture.serialized()).toContain('model-config-invalid') + expect(capture.serialized()).toContain('Cannot read the effective model') + }) + + it('reaches ready for a provider authenticated by an inline key in the merged config, with no stored credential', async () => { + // #given an overlay that carries the key inline, and no blob + const store = credentialStore() + const fake = createHandle({authorization: 'Basic one', routes: store.routes}) + const {options, startOptions, statusRef, capture} = setup({ + spawnConfigs: [{model: MODEL, authenticatedProviders: ['anthropic']}], + }) + const controller = new AbortController() + options.deps = {...options.deps, startServer: scriptedStart([fake], startOptions)} + + // #when + const done = runSupervisedOpencodeV2({...options, signal: controller.signal}) + await vi.waitFor(() => expect(statusRef.status).toBe('ready')) + + // #then the gate accepted the inline key without a connection, and nothing was posted + expect(store.posts).toHaveLength(0) + expect(fake.fetchPaths).not.toContain('/api/integration/anthropic') + expect(capture.serialized()).not.toContain('SUPERSECRETKEY') + controller.abort() + await done + }) + + it.each([ + ['a {file:} reference', '{file:/home/opencode/provider-key}'], + ['an {env:} reference', '{env:ANTHROPIC_API_KEY}'], + ])('reaches ready for a provider keyed only by %s in the merged config', async (_label, reference) => { + // #given the real parser on a merged config whose only credential is a reference, and no blob + const config = JSON.stringify({model: MODEL, provider: {anthropic: {options: {apiKey: reference}}}}) + const store = credentialStore() + const fake = createHandle({authorization: 'Basic one', routes: store.routes}) + const {options, startOptions, statusRef} = setup() + const controller = new AbortController() + options.deps = { + ...options.deps, + readSpawnConfig: () => { + const parsed = spawnConfigFromMergedConfig(config) + if (!parsed.success) throw new Error(parsed.error.message) + return parsed.data + }, + startServer: scriptedStart([fake], startOptions), + } + + // #when + const done = runSupervisedOpencodeV2({...options, signal: controller.signal}) + await vi.waitFor(() => expect(statusRef.status).toBe('ready')) + + // #then the reference satisfied the gate without a connection read, and nothing was posted + expect(fake.fetchPaths).not.toContain('/api/integration/anthropic') + expect(store.posts).toHaveLength(0) + controller.abort() + await done + }) + + it.each([ + ['an empty key', ''], + ['a {file:} reference with an empty target', '{file:}'], + ['an {env:} reference with an empty target', '{env:}'], + ])('refuses readiness for a provider whose only configured key is %s', async (_label, value) => { + // #given + const store = credentialStore() + const fake = createHandle({authorization: 'Basic one', routes: store.routes}) + const {options, startOptions, capture} = setup({maxAttempts: 1}) + const {statusRef, seen} = recordStatuses() + const config = JSON.stringify({model: MODEL, provider: {anthropic: {options: {apiKey: value}}}}) + options.deps = { + ...options.deps, + readSpawnConfig: () => { + const parsed = spawnConfigFromMergedConfig(config) + if (!parsed.success) throw new Error(parsed.error.message) + return parsed.data + }, + startServer: scriptedStart([fake], startOptions), + } + + // #when + await runSupervisedOpencodeV2({...options, statusRef}) + + // #then + expect(seen).not.toContain('ready') + expect(capture.serialized()).toContain('credential-missing') + }) + + it('still refuses a provider with no inline key, no stored credential and no connection', async () => { + // #given an inline key for openai only, and a model on anthropic + const store = credentialStore() + const fake = createHandle({authorization: 'Basic one', routes: store.routes}) + const {options, startOptions, capture} = setup({ + maxAttempts: 1, + spawnConfigs: [{model: MODEL, authenticatedProviders: ['openai']}], + }) + const {statusRef, seen} = recordStatuses() + options.deps = {...options.deps, startServer: scriptedStart([fake], startOptions)} + + // #when + await runSupervisedOpencodeV2({...options, statusRef}) + + // #then + expect(seen).not.toContain('ready') + expect(capture.serialized()).toContain('credential-missing') + }) + + it('reads the merged config fresh for every spawn and gates the provider the replacement server loaded', async () => { + // #given the file names anthropic for the first spawn and openai for the second, and a blob that covers only anthropic + const store = credentialStore() + const first = createHandle({authorization: 'Basic first', routes: store.routes}) + const second = createHandle({authorization: 'Basic second', routes: store.routes}) + const {options, startOptions, credentials, capture} = setup({ + maxAttempts: 2, + spawnConfigs: [ + {model: 'anthropic/claude-sonnet-4-6', authenticatedProviders: []}, + {model: 'openai/gpt-5.4-mini', authenticatedProviders: []}, + ], + }) + const {statusRef, seen} = recordStatuses() + options.deps = { + ...options.deps, + readAuthBlob: () => BLOB, + startServer: scriptedStart([first, second], startOptions), + } + const done = runSupervisedOpencodeV2({...options, statusRef}) + await vi.waitFor(() => expect(credentials.authorization()).toBe('Basic first')) + + // #when the first server dies and the replacement loads a different model + first.exit(1) + await done + + // #then the replacement was gated on openai, found no credential, and never served + expect(first.fetchPaths).toContain('/api/integration/anthropic') + expect(second.fetchPaths).toContain('/api/integration/openai') + expect(second.fetchPaths).not.toContain('/api/integration/anthropic') + expect(seen.filter(status => status === 'ready')).toHaveLength(1) + expect(statusRef.status).toBe('degraded') + expect(capture.serialized()).toContain('credential-missing') + expect(credentials.authorization()).toBeUndefined() + }) + + it.each([ + ['during provisioning', 'provision'], + ['during the gate read-back', 'gate'], + ] as const)( + 'stops cleanly when the service aborts %s: no gate-failure log and no degraded status', + async (_label, phase) => { + // #given a server whose credential route (or integration route) never answers + let reached: () => void = () => undefined + const inFlight = new Promise(resolve => { + reached = resolve + }) + const store = credentialStore() + const fake = createHandle({ + authorization: 'Basic one', + routes: async (pathname, init) => { + const stall = + phase === 'provision' ? pathname === '/api/credential' : pathname.startsWith('/api/integration/') + if (stall) { + reached() + return new Promise(() => undefined) + } + return store.routes(pathname, init) + }, + }) + const {options, startOptions, capture} = setup({model: MODEL, requestTimeoutMs: 60_000, maxAttempts: 3}) + const {statusRef, seen} = recordStatuses() + const controller = new AbortController() + options.deps = {...options.deps, readAuthBlob: () => BLOB, startServer: scriptedStart([fake], startOptions)} + const done = runSupervisedOpencodeV2({...options, statusRef, signal: controller.signal}) + await inFlight + + // #when the service stops while the request is in flight + controller.abort() + await done + + // #then the server was stopped once, nothing was retried, and the stop is not reported as a failure + expect(fake.shutdown).toHaveBeenCalledTimes(1) + expect(startOptions).toHaveLength(1) + expect(seen).not.toContain('degraded') + expect(seen).not.toContain('ready') + expect(capture.serialized()).not.toContain('credential gate failed') + expect(capture.serialized()).not.toContain('credential-provision-failed') + expect(capture.serialized()).not.toContain('credential-missing') + }, + ) + + it('puts OPENCODE_DISABLE_MODELS_FETCH=1 in the spawn environment', async () => { + // #given + const fake = createHandle({authorization: 'Basic one'}) + const {options, startOptions, statusRef} = setup({parentEnv: {OPENCODE_DISABLE_MODELS_FETCH: '0'}}) + const controller = new AbortController() + options.deps = {...options.deps, startServer: scriptedStart([fake], startOptions)} + + // #when + const done = runSupervisedOpencodeV2({...options, signal: controller.signal}) + await vi.waitFor(() => expect(statusRef.status).toBe('ready')) + + // #then + expect(startOptions[0]?.env?.OPENCODE_DISABLE_MODELS_FETCH).toBe('1') + controller.abort() + await done + }) + }) }) diff --git a/apps/workspace-agent/src/opencode-server-v2.ts b/apps/workspace-agent/src/opencode-server-v2.ts index cd330f7660..ebb3031649 100644 --- a/apps/workspace-agent/src/opencode-server-v2.ts +++ b/apps/workspace-agent/src/opencode-server-v2.ts @@ -10,6 +10,8 @@ * execution claim to resume after a restart; * - the per-spawn Basic credential is published to the proxy through a holder, before readiness flips to ready; * - the effective permission rules are checked after the plugin gate, and a server that fails the check never serves; + * - the provider credentials are provisioned into each spawn's empty credential store over loopback, and a server + * whose model provider has no credential never serves (opencode-credentials-v2.ts, fro-bot/agent#1992); * - on shutdown every session still active is interrupted (reason `user`) before stdin closes. * * SECURITY INVARIANTS: @@ -22,8 +24,8 @@ */ import type {OpenCodeV2ServerHandle, Logger as RuntimeLogger} from '@fro-bot/runtime/workspace-v2' +import type {CredentialResult, ProviderKeyEntry, SpawnConfig} from './opencode-credentials-v2.js' import type {Logger, SupervisedStatusRef} from './opencode-server.js' - import {chmod, mkdir, mkdtemp, chown as nodeChown, rm} from 'node:fs/promises' import path from 'node:path' import process from 'node:process' @@ -40,7 +42,7 @@ import { SYSTEMATIC_V2_PLUGIN_ID, verifyPermissionRules, } from '@fro-bot/runtime/workspace-v2' - +import {MAX_CONFIG_FILE_BYTES, readOptionalBoundedFile, readOptionalSecretBlob} from './config.js' import { AGENT_GID, AGENT_HOME, @@ -53,6 +55,13 @@ import { AGENT_XDG_STATE_HOME, OPENCODE_EXECUTABLE_PATH, } from './identity.js' +import { + modelProviderId, + parseAuthBlob, + provisionCredentials, + spawnConfigFromMergedConfig, + verifyCredentialGate, +} from './opencode-credentials-v2.js' /** * Where per-spawn database directories live. Root-owned and never agent-writable: the service creates it, so a @@ -75,6 +84,9 @@ export const WORKSPACE_V2_EXPECTED_PLUGIN_IDS: readonly string[] = [SYSTEMATIC_V const OPENCODE_PORT = 54_321 +/** Where the entrypoint reads the root-only provider credential blob from when `WORKSPACE_OPENCODE_AUTH_FILE` is unset. */ +export const DEFAULT_WORKSPACE_AUTH_FILE = '/run/workspace-agent/secrets/workspace_opencode_auth' + // ── Child environment ────────────────────────────────────────────────────────── const PROXY_ENV_NAMES = ['HTTPS_PROXY', 'HTTP_PROXY', 'NO_PROXY', 'https_proxy', 'http_proxy', 'no_proxy'] as const @@ -109,6 +121,8 @@ export function buildOpencodeV2Env(parentEnv: NodeJS.ProcessEnv, databasePath: s XDG_STATE_HOME: AGENT_XDG_STATE_HOME, TMPDIR: AGENT_TMPDIR, OPENCODE_DB: databasePath, + // The egress allowlist blocks models.opencode.ai (403); the bundled snapshot is the floor, so the fetch is noise. + OPENCODE_DISABLE_MODELS_FETCH: '1', } for (const name of PROXY_ENV_NAMES) copyEnvIfSet(parentEnv, env, name) @@ -188,6 +202,17 @@ export interface RunSupervisedOpencodeV2Deps { readonly verifyRules: VerifyRulesFn /** Returns the database path for the next spawn. Default: `prepareFreshDatabase()`. */ readonly prepareDatabase: () => Promise + /** + * Returns the raw provider credential blob, or null when none is provisioned (a clone-only deployment). May throw + * for an unreadable file. Default: the hardened root-only secret read of `WORKSPACE_OPENCODE_AUTH_FILE`. + */ + readonly readAuthBlob: () => string | null + /** + * The model and the inline-authenticated providers the NEXT spawn will see. Called once per spawn, after the server + * is up, because the file is agent-writable and a later spawn may load a different one. May throw for an unreadable + * or malformed config. Default: the merged `opencode.json` the entrypoint wrote. + */ + readonly readSpawnConfig: () => SpawnConfig } export interface RunSupervisedOpencodeV2Options { @@ -221,6 +246,31 @@ export interface RunSupervisedOpencodeV2Options { readonly deps?: Partial } +/** The merged config the entrypoint writes and the server loads as its global config. */ +export const MERGED_CONFIG_PATH = path.join(AGENT_XDG_CONFIG_HOME, 'opencode', 'opencode.json') + +/** + * The effective model and the providers that carry an inline credential setting, read from the merged config the spawn + * loads (base + `WORKSPACE_OPENCODE_CONFIG` overlay + the `WORKSPACE_OPENCODE_MODEL` that wins when non-blank, all + * resolved by `merge-config.mjs`). Reading the file the server loads, rather than recomputing the rule from the + * environment, leaves one source that cannot drift from the server's. Absent file: no model, no inline auth. The file + * is agent-writable, so it is opened without following symlinks and size-capped; the answer only selects which + * providers the gate checks, it grants nothing, and it carries provider ids, never a value. + */ +export function readSpawnConfigFromFile(configPath: string = MERGED_CONFIG_PATH): SpawnConfig { + const raw = readOptionalBoundedFile(configPath, MAX_CONFIG_FILE_BYTES) + if (raw === null) return {authenticatedProviders: []} + const parsed = spawnConfigFromMergedConfig(raw) + if (!parsed.success) throw new Error(parsed.error.message) + return parsed.data +} + +/** `WORKSPACE_OPENCODE_AUTH_FILE` when set and non-blank, else the path the entrypoint defaults to. */ +function authFilePath(env: NodeJS.ProcessEnv): string { + const configured = env.WORKSPACE_OPENCODE_AUTH_FILE?.trim() ?? '' + return configured === '' ? DEFAULT_WORKSPACE_AUTH_FILE : configured +} + /** The runtime logger takes `warning` and `debug`; the workspace logger takes `warn` and has no debug level. */ function toRuntimeLogger(logger: Logger): RuntimeLogger { return { @@ -296,6 +346,8 @@ export async function runSupervisedOpencodeV2(options: RunSupervisedOpencodeV2Op startServer = startOpenCodeV2Server, verifyRules = verifyPermissionRules, prepareDatabase = async () => prepareFreshDatabase(), + readAuthBlob = () => readOptionalSecretBlob(authFilePath(parentEnv)), + readSpawnConfig = () => readSpawnConfigFromFile(), } = options.deps ?? {} const runtimeLogger = toRuntimeLogger(logger) @@ -309,6 +361,32 @@ export async function runSupervisedOpencodeV2(options: RunSupervisedOpencodeV2Op return } + // The credential blob is a static root-only secret, so an unreadable or malformed one is not a transient failure + // either: nothing is spawned, and the status says why. Errors name the provider id or the file path, never a key. + let keyEntries: readonly ProviderKeyEntry[] = [] + try { + const rawBlob = readAuthBlob() + if (rawBlob !== null) { + const parsed = parseAuthBlob(rawBlob) + if (!parsed.success) { + credentials.clear() + statusRef.status = 'degraded' + logger.error('opencode-server-v2: provider credential blob is unusable', { + kind: parsed.error.kind, + message: parsed.error.message, + }) + return + } + keyEntries = parsed.data + } + } catch (error) { + credentials.clear() + statusRef.status = 'degraded' + logger.error('opencode-server-v2: cannot read the provider credential blob', { + message: error instanceof Error ? error.message : 'unknown', + }) + return + } let attempt = 1 let backoffMs = initialBackoffMs // One listener for the whole lifetime, however many respawns: it aborts a start in flight and wakes a ready wait. @@ -356,6 +434,50 @@ export async function runSupervisedOpencodeV2(options: RunSupervisedOpencodeV2Op return true } + /** + * Provisions the keys into this spawn's credential store, then gates on the model it will use. Runs once per spawn, + * after the server is up, and reads the merged config fresh each time: the file is agent-writable, so a replacement + * spawn can load a different model than the one before it, and the gate must check the one this server loaded. + * + * The gate checks the provider of the effective model (from the overlay or the env, whichever the merge chose). With + * no model (a clone-only deployment) there is no request it could fail, so it checks the providers provisioned + * instead, and with neither it has nothing to check and does not block readiness. A provider whose merged config + * carries an inline credential setting needs no stored connection. + */ + const credentialStep = async (serving: OpenCodeV2ServerHandle): Promise> => { + let spawnConfig: SpawnConfig + try { + spawnConfig = readSpawnConfig() + } catch (error) { + return { + success: false, + error: { + kind: 'model-config-invalid', + message: `Cannot read the effective model from the merged config: ${error instanceof Error ? error.message : 'unknown'}`, + }, + } + } + const modelProvider = modelProviderId(spawnConfig.model) + const requiredProviders = modelProvider === undefined ? keyEntries.map(entry => entry.providerId) : [modelProvider] + if (requiredProviders.length === 0) { + logger.info('opencode-server-v2: no model or provider credentials configured, credential gate skipped') + return {success: true, data: []} + } + const request = {handle: serving, requestTimeoutMs, ...(signal === undefined ? {} : {signal})} + // The fresh database has no credentials (V2 only imports auth.json through a migration a fresh database skips), so + // every spawn stores them again. Then the gate reads them back: a server that cannot authenticate its model's + // provider never goes ready. + if (keyEntries.length > 0) { + const provisioned = await provisionCredentials({...request, entries: keyEntries, logger}) + if (!provisioned.success) return provisioned + } + return verifyCredentialGate({ + ...request, + providers: requiredProviders, + configuredAuth: spawnConfig.authenticatedProviders, + }) + } + try { while (attempt <= maxAttempts) { if (isAborted()) return @@ -448,6 +570,26 @@ export async function runSupervisedOpencodeV2(options: RunSupervisedOpencodeV2Op } } + if (handle !== undefined) { + const serving = handle + const gated = await credentialStep(serving) + if (!gated.success && isAborted()) { + // The service is stopping: the request was cut short by the abort, not refused. A clean stop, not a failure. + await stopServer(handle) + return + } + if (!gated.success) { + logger.error('opencode-server-v2: credential gate failed', { + attempt, + kind: gated.error.kind, + message: gated.error.message, + ...(gated.error.providers === undefined ? {} : {providers: gated.error.providers}), + }) + await stopServer(handle) + handle = undefined + } + } + if (handle !== undefined) { if (isAborted()) { await stopServer(handle) diff --git a/deploy/README.md b/deploy/README.md index a5e659d6fc..67a3327186 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -564,7 +564,7 @@ A major of 2 or higher selects the V2 path; the value must equal the pin's `vers **What a V2 image differs in.** The binary is the stock `opencode-linux-*-musl` release. `@fro.bot/systematic` and `@fro.bot/opencode-plugin` are installed at image build and loaded from the config content the workspace agent passes at each spawn; nothing installs at boot, so readiness needs no network and no provider. The base config (`/usr/local/share/fro-bot/opencode.base.json`) is `{"update":"disable"}` with no plugin entry, and `/etc/opencode/opencode.json` is `deploy/managed-config/opencode-v2.json`. The `task` reuse guard is the fro-bot plugin, not `no-task-reuse.mjs`. -**Readiness and the gateway's choice of loop.** The workspace agent's `/readyz` returns `{"ready":true,"opencode":"ready","opencodeVersion":""}`. On V2 it turns 200 only after both plugins (`systematic`, `fro-bot.opencode-plugin`) are active and the permission gate passes. The gateway reads `opencodeVersion` on every run: absent runs the V1 loop, a major of 2 or higher runs the V2 loop (`packages/gateway/src/execute/run-core-v2.ts`), and an unreadable value is not ready. +**Readiness and the gateway's choice of loop.** The workspace agent's `/readyz` returns `{"ready":true,"opencode":"ready","opencodeVersion":""}`. On V2 it turns 200 only after both plugins (`systematic`, `fro-bot.opencode-plugin`) are active, the permission gate passes, and the credential gate passes: the supervisor provisions the `workspace-opencode-auth` blob into the spawn's credential store and refuses readiness (`credential-missing` in the workspace log) when the effective model (from `WORKSPACE_OPENCODE_MODEL`, or a `model` in the `WORKSPACE_OPENCODE_CONFIG` overlay) names a provider with no credential. A deployment with no model configured (clone-only) skips that check. The gateway reads `opencodeVersion` on every run: absent runs the V1 loop, a major of 2 or higher runs the V2 loop (`packages/gateway/src/execute/run-core-v2.ts`), and an unreadable value is not ready. **No session history across spawns.** Each OpenCode V2 spawn deletes and recreates its database (`/var/lib/workspace-agent/opencode-v2-db`), so a workspace restart drops all OpenCode session state. The gateway keeps no OpenCode session history of its own. diff --git a/deploy/scripts/fake-anthropic-provider.mjs b/deploy/scripts/fake-anthropic-provider.mjs new file mode 100644 index 0000000000..24cb491378 --- /dev/null +++ b/deploy/scripts/fake-anthropic-provider.mjs @@ -0,0 +1,87 @@ +// fake-anthropic-provider.mjs — a no-network, Anthropic-compatible model endpoint for the V2 workspace smoke test. +// Pure ESM, no dependencies, no build step. Run INSIDE the workspace container (loopback only): +// +// node fake-anthropic-provider.mjs +// +// What it proves for fro-bot/agent#1992: a model request from the V2 server carries the provisioned provider key. It +// answers 401 unless `x-api-key` equals (the header the Anthropic provider sends, and the one V1 sent), +// so a request with no credential, or with the key in a different header, fails the turn instead of passing. +// +// `GET /__hits` reports what each model request carried, as verdicts only (the key value is never echoed back): +// {"requests":[{"path":"/v1/messages?beta=true","xApiKey":"match|mismatch|absent","authorization":"present|absent"}]} + +import http from 'node:http' +import process from 'node:process' +import {fileURLToPath} from 'node:url' + +/** @param {http.ServerResponse} res @param {string} event @param {unknown} data */ +function frame(res, event, data) { + res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`) +} + +/** + * @param {{port: number, expectedKey: string, host?: string}} options + * @returns {Promise<{server: http.Server, port: number, requests: () => {path: string, xApiKey: string, authorization: string}[]}>} + */ +export async function startFakeAnthropicProvider({port, expectedKey, host = '127.0.0.1'}) { + const requests = [] + const server = http.createServer((req, res) => { + req.resume() + if (req.method === 'GET' && req.url === '/__hits') { + res.writeHead(200, {'content-type': 'application/json'}) + res.end(JSON.stringify({requests})) + return + } + if (req.method !== 'POST' || !(req.url ?? '').startsWith('/v1/messages')) { + res.writeHead(404).end() + return + } + const supplied = req.headers['x-api-key'] + const xApiKey = supplied === undefined ? 'absent' : supplied === expectedKey ? 'match' : 'mismatch' + requests.push({ + path: req.url ?? '', + xApiKey, + authorization: req.headers.authorization === undefined ? 'absent' : 'present', + }) + if (xApiKey !== 'match') { + res.writeHead(401, {'content-type': 'application/json'}) + res.end(JSON.stringify({type: 'error', error: {type: 'authentication_error', message: 'invalid x-api-key'}})) + return + } + res.writeHead(200, {'content-type': 'text/event-stream', 'cache-control': 'no-cache'}) + frame(res, 'message_start', { + type: 'message_start', + message: { + id: 'msg_fake', + type: 'message', + role: 'assistant', + model: 'claude-sonnet-4-6', + content: [], + stop_reason: null, + usage: {input_tokens: 1, output_tokens: 1}, + }, + }) + frame(res, 'content_block_start', {type: 'content_block_start', index: 0, content_block: {type: 'text', text: ''}}) + frame(res, 'content_block_delta', {type: 'content_block_delta', index: 0, delta: {type: 'text_delta', text: 'pong'}}) + frame(res, 'content_block_stop', {type: 'content_block_stop', index: 0}) + frame(res, 'message_delta', {type: 'message_delta', delta: {stop_reason: 'end_turn'}, usage: {output_tokens: 1}}) + frame(res, 'message_stop', {type: 'message_stop'}) + res.end() + }) + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(port, host, resolve) + }) + return {server, port: server.address().port, requests: () => requests} +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + const [, , portArg, expectedKey] = process.argv + const port = Number(portArg) + if (!Number.isInteger(port) || port <= 0 || expectedKey === undefined || expectedKey === '') { + process.stderr.write('usage: fake-anthropic-provider.mjs \n') + process.exit(2) + } + await startFakeAnthropicProvider({port, expectedKey}) + process.stdout.write(`fake-anthropic-provider: listening on 127.0.0.1:${port}\n`) +} diff --git a/deploy/scripts/fake-anthropic-provider.test.mjs b/deploy/scripts/fake-anthropic-provider.test.mjs new file mode 100644 index 0000000000..9c90921a6b --- /dev/null +++ b/deploy/scripts/fake-anthropic-provider.test.mjs @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict' +import {test} from 'node:test' + +import {startFakeAnthropicProvider} from './fake-anthropic-provider.mjs' + +const KEY = 'sk-test-fixture-key' + +async function withProvider(run) { + const provider = await startFakeAnthropicProvider({port: 0, expectedKey: KEY}) + const base = `http://127.0.0.1:${provider.port}` + try { + await run(base, provider) + } finally { + provider.server.closeAllConnections() + await new Promise(resolve => provider.server.close(resolve)) + } +} + +test('answers a request that carries the key in x-api-key with a streamed message', async () => { + await withProvider(async base => { + const response = await fetch(`${base}/v1/messages?beta=true`, {method: 'POST', headers: {'x-api-key': KEY}, body: '{}'}) + + assert.equal(response.status, 200) + assert.match(await response.text(), /message_stop/) + }) +}) + +test('answers 401 when the key is absent, wrong, or in the authorization header instead', async () => { + await withProvider(async base => { + for (const headers of [{}, {'x-api-key': 'wrong'}, {authorization: `Bearer ${KEY}`}]) { + const response = await fetch(`${base}/v1/messages`, {method: 'POST', headers, body: '{}'}) + assert.equal(response.status, 401) + } + }) +}) + +test('reports verdicts per request and never echoes a key', async () => { + await withProvider(async base => { + await fetch(`${base}/v1/messages`, {method: 'POST', headers: {'x-api-key': KEY}, body: '{}'}) + await fetch(`${base}/v1/messages`, {method: 'POST', headers: {authorization: 'Bearer secret-bearer'}, body: '{}'}) + await fetch(`${base}/v1/messages`, {method: 'POST', headers: {'x-api-key': 'secret-wrong'}, body: '{}'}) + + const text = await (await fetch(`${base}/__hits`)).text() + const {requests} = JSON.parse(text) + + assert.deepEqual( + requests.map(({xApiKey, authorization}) => [xApiKey, authorization]), + [ + ['match', 'absent'], + ['absent', 'present'], + ['mismatch', 'absent'], + ], + ) + assert.ok(!text.includes(KEY) && !text.includes('secret-bearer') && !text.includes('secret-wrong')) + }) +}) + +test('answers 404 for anything that is not the messages route', async () => { + await withProvider(async base => { + assert.equal((await fetch(`${base}/v1/models`)).status, 404) + }) +}) diff --git a/deploy/scripts/provision-agent-config.mjs b/deploy/scripts/provision-agent-config.mjs index ec8b0a0bb3..c15beed802 100644 --- a/deploy/scripts/provision-agent-config.mjs +++ b/deploy/scripts/provision-agent-config.mjs @@ -108,9 +108,12 @@ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { if (result.authProvisioned) { process.stderr.write('workspace-entrypoint: auth: provisioned\n') } + // What this checked: WORKSPACE_OPENCODE_CONFIG was a non-empty string and was merged into the on-disk opencode.json. + // It says nothing about whether the server loaded a provider from it or holds a credential for it (fro-bot/agent#1992 + // went unnoticed behind the old wording, `applied`). On V2 the credential gate in the supervisor answers that. process.stderr.write( `workspace-entrypoint: opencode config: model=${modelRaw.trim() !== '' ? 'set' : 'default'}, ` + - `provider-overlay=${overlayRaw.trim() !== '' ? 'applied' : 'none'}\n`, + `provider-overlay=${overlayRaw.trim() !== '' ? 'merged-into-config-file' : 'none'}\n`, ) process.exit(0) } diff --git a/deploy/scripts/provision-agent-config.test.mjs b/deploy/scripts/provision-agent-config.test.mjs index df29e49e5c..63192dcca1 100644 --- a/deploy/scripts/provision-agent-config.test.mjs +++ b/deploy/scripts/provision-agent-config.test.mjs @@ -3,6 +3,8 @@ import {test} from 'node:test' import {mkdtemp, writeFile, readFile, rm, stat, open} from 'node:fs/promises' import {tmpdir} from 'node:os' import {join} from 'node:path' +import {spawnSync} from 'node:child_process' +import {fileURLToPath} from 'node:url' import {provisionAgentConfig} from './provision-agent-config.mjs' @@ -156,3 +158,43 @@ test('rejects a malformed overlay and writes nothing', async () => { await rm(root, {recursive: true, force: true}) } }) + +const SCRIPT = fileURLToPath(new URL('./provision-agent-config.mjs', import.meta.url)) + +async function runCli({overlay, model}) { + const root = await makeTempRoot() + try { + const baseConfigPath = join(root, 'base.json') + await writeFile(baseConfigPath, JSON.stringify(BASE_CONFIG)) + const result = spawnSync( + process.execPath, + [SCRIPT, baseConfigPath, join(root, 'auth.json'), join(root, 'opencode.json')], + { + input: '', + encoding: 'utf8', + env: {PATH: process.env.PATH, WORKSPACE_OPENCODE_CONFIG: overlay, WORKSPACE_OPENCODE_MODEL: model}, + }, + ) + return {status: result.status, stderr: result.stderr} + } finally { + await rm(root, {recursive: true, force: true}) + } +} + +test('the startup marker says the overlay was merged into the config file, not that a provider loaded', async () => { + const {status, stderr} = await runCli({ + overlay: JSON.stringify({provider: {anthropic: {options: {baseURL: 'https://cliproxy.fro.bot/v1'}}}}), + model: 'anthropic/claude-sonnet-4-6', + }) + + assert.equal(status, 0) + assert.match(stderr, /provider-overlay=merged-into-config-file/) + assert.doesNotMatch(stderr, /provider-overlay=applied/) +}) + +test('the startup marker reports none when no overlay is set', async () => { + const {status, stderr} = await runCli({overlay: '', model: ''}) + + assert.equal(status, 0) + assert.match(stderr, /provider-overlay=none/) +}) diff --git a/docs/solutions/best-practices/workspace-executor-opencode-provisioning-best-practices-2026-06-01.md b/docs/solutions/best-practices/workspace-executor-opencode-provisioning-best-practices-2026-06-01.md index 61b869329d..0270990f58 100644 --- a/docs/solutions/best-practices/workspace-executor-opencode-provisioning-best-practices-2026-06-01.md +++ b/docs/solutions/best-practices/workspace-executor-opencode-provisioning-best-practices-2026-06-01.md @@ -79,7 +79,7 @@ Validate the auth blob too: require a JSON object with ≥1 provider entry, each ### 5. Fail-soft when absent, fail-fast when malformed -The same image serves clone-only deployments, so missing auth/config must **boot** (the mention turn then surfaces OpenCode's own auth error). A *malformed* secret or overlay must **exit at startup** with a clear message, not defer to a cryptic turn-time failure. Emit startup markers so an operator can read the state in `docker logs`: `auth: provisioned` / `auth: absent`, `provider-overlay=applied` / `provider-overlay=none`. +The same image serves clone-only deployments, so missing auth/config must **boot** (the mention turn then surfaces OpenCode's own auth error). A *malformed* secret or overlay must **exit at startup** with a clear message, not defer to a cryptic turn-time failure. Emit startup markers so an operator can read the state in `docker logs`: `auth: provisioned` / `auth: absent`, `provider-overlay=merged-into-config-file` / `provider-overlay=none` (the former only says the overlay string was non-empty and merged into the config file; it does not prove a provider loaded or holds a credential). ### 6. Mind POSIX `set -eu` in the entrypoint diff --git a/packages/runtime/src/agent/gateway-routes-v2.test.ts b/packages/runtime/src/agent/gateway-routes-v2.test.ts new file mode 100644 index 0000000000..880639c590 --- /dev/null +++ b/packages/runtime/src/agent/gateway-routes-v2.test.ts @@ -0,0 +1,187 @@ +import type {V2GatewayRoute} from './gateway-routes-v2.js' +import type {RemoteOpenCodeV2Client} from './remote-client-v2.js' + +import {describe, expect, it} from 'vitest' + +import {isGatewayRouteV2, routeMatches, V2_GATEWAY_ROUTES} from './gateway-routes-v2.js' +import {createRemoteOpenCodeV2Client} from './remote-client-v2.js' + +/** + * The drift guard for the workspace bearer proxy's allowlist (fro-bot/agent#1992). The gateway reaches the V2 server + * through `createRemoteOpenCodeV2Client` and nothing else, and the proxy forwards only `V2_GATEWAY_ROUTES`. This test + * calls EVERY method of the real client against a recording fetch and requires each request to be allowlisted. The + * `CALLS` record is typed by the client's method names, so adding a client method fails type-checking here until it + * is listed, and listing it makes its route fail this test until it is allowlisted. + */ + +interface Seen { + readonly method: string + readonly pathname: string +} + +type ClientCalls = { + readonly [K in Exclude]: (client: RemoteOpenCodeV2Client) => Promise +} + +const DIRECTORY = '/workspace/repos/acme/widget' + +const CALLS: ClientCalls = { + createSession: async client => client.createSession({directory: DIRECTORY}), + getSession: async client => client.getSession({sessionId: 'ses_abc123'}), + prompt: async client => client.prompt({sessionId: 'ses_abc123', text: 'hi', directory: DIRECTORY}), + interrupt: async client => client.interrupt({sessionId: 'ses_abc123'}), + subscribeEvents: async client => { + const subscribed = await client.subscribeEvents({directory: DIRECTORY}) + if (subscribed.success) await subscribed.data[Symbol.asyncIterator]().return?.() + }, + replyPermission: async client => + client.replyPermission({sessionId: 'ses_abc123', requestId: 'per_abc123', decision: 'once'}), + replyForm: async client => client.replyForm({sessionId: 'ses_abc123', formId: 'frm_abc123', answer: {}}), + cancelForm: async client => client.cancelForm({sessionId: 'ses_abc123', formId: 'frm_abc123', message: 'no'}), + listSessionInbox: async client => client.listSessionInbox({sessionId: 'ses_abc123'}), + listShells: async client => client.listShells({directory: DIRECTORY}), + getShell: async client => client.getShell({directory: DIRECTORY, shellId: 'shl_abc123'}), + deleteShell: async client => client.deleteShell({directory: DIRECTORY, shellId: 'shl_abc123'}), +} + +async function recordedRequests(call: (client: RemoteOpenCodeV2Client) => Promise): Promise { + const seen: Seen[] = [] + const client = createRemoteOpenCodeV2Client({ + baseUrl: 'http://workspace:9200', + fetch: async (input, init) => { + seen.push({method: init?.method ?? 'GET', pathname: new URL(String(input)).pathname}) + return new Response('{"data":{}}', {status: 200, headers: {'content-type': 'text/event-stream'}}) + }, + }) + await call(client).catch(() => undefined) + return seen +} + +/** + * The allowlist entries no exercised request matches. Each exercised request is judged against ITS OWN entry (method plus + * segment pattern), never against the whole list: with the whole list, an unused `GET /api/credential` would pass + * because an exercised `GET /api/session` happens to match some other GET entry. + */ +function unexercisedRoutes(routes: readonly V2GatewayRoute[], exercised: ReadonlySet): string[] { + const requests = [...exercised].map(entry => entry.split(' ') as [string, string]) + return routes + .filter(route => !requests.some(([method, pathname]) => routeMatches(route, method, pathname))) + .map(route => `${route.method} ${route.path}`) +} + +describe('V2_GATEWAY_ROUTES drift guard', () => { + it.each(Object.entries(CALLS))('allowlists every route RemoteOpenCodeV2Client.%s can call', async (_name, call) => { + // #when the real client method runs against a recording fetch + const seen = await recordedRequests(call) + + // #then it made a request, and the proxy allowlist covers each one + expect(seen.length).toBeGreaterThan(0) + for (const request of seen) { + expect(isGatewayRouteV2(request.method, request.pathname), `${request.method} ${request.pathname}`).toBe(true) + } + }) + + it('allowlists the session.active and session.list routes', async () => { + const seen = [ + ...(await recordedRequests(async client => client.session.active())), + ...(await recordedRequests(async client => client.session.list({parentID: 'ses_abc123', limit: 5}))), + ] + expect(seen.map(request => `${request.method} ${request.pathname}`)).toEqual([ + 'GET /api/session/active', + 'GET /api/session', + ]) + for (const request of seen) expect(isGatewayRouteV2(request.method, request.pathname)).toBe(true) + }) + + it('has no route the client cannot call: every allowlisted route is exercised above', async () => { + // #given every request the client methods make + const exercised = new Set() + for (const call of Object.values(CALLS)) { + for (const request of await recordedRequests(call)) { + exercised.add(`${request.method} ${request.pathname}`) + } + } + exercised.add('GET /api/session/active') + exercised.add('GET /api/session') + + // #then every allowlist entry is the one some request actually matches, so a route nobody calls cannot linger + expect(unexercisedRoutes(V2_GATEWAY_ROUTES, exercised)).toEqual([]) + }) + + it('fails on an unexercised entry that shares a method with an exercised one', () => { + // #given the real exercised set and one extra allowlisted route that no client method calls + const exercised = new Set(['GET /api/session', 'GET /api/session/active', 'GET /api/session/ses_abc123']) + const withExtra: readonly V2GatewayRoute[] = [ + {method: 'GET', path: '/api/session'}, + {method: 'GET', path: '/api/session/active'}, + {method: 'GET', path: '/api/session/:sessionId'}, + {method: 'GET', path: '/api/credential'}, + ] + + // #then the guard names it, even though `GET /api/session` matches another GET entry + expect(unexercisedRoutes(withExtra, exercised)).toEqual(['GET /api/credential']) + }) + + it('also fails on an extra entry whose method nobody uses', () => { + const exercised = new Set(['GET /api/session/ses_abc123/inbox']) + expect( + unexercisedRoutes( + [ + {method: 'GET', path: '/api/session/:sessionId/inbox'}, + {method: 'DELETE', path: '/api/session/:sessionId/inbox'}, + {method: 'GET', path: '/api/session/:sessionId/:anything'}, + ], + exercised, + ), + ).toEqual(['DELETE /api/session/:sessionId/inbox']) + }) +}) + +describe('isGatewayRouteV2', () => { + it.each([ + ['GET', '/api/session/active'], + ['GET', '/api/session'], + ['POST', '/api/session'], + ['GET', '/api/session/ses_abc-123'], + ['POST', '/api/session/ses_abc/prompt'], + ['POST', '/api/session/ses_abc/interrupt'], + ['GET', '/api/event'], + ['POST', '/api/session/ses_abc/permission/per_1/reply'], + ['POST', '/api/session/ses_abc/form/frm_1/reply'], + ['DELETE', '/api/session/ses_abc/form/frm_1'], + ['GET', '/api/session/ses_abc/inbox'], + ['GET', '/api/shell'], + ['GET', '/api/shell/shl_1'], + ['DELETE', '/api/shell/shl_1'], + ])('matches %s %s', (method, pathname) => { + expect(isGatewayRouteV2(method, pathname)).toBe(true) + }) + + it.each([ + ['DELETE', '/api/session/ses_abc'], + ['PUT', '/api/session'], + ['PATCH', '/api/session/ses_abc'], + ['POST', '/api/session/active'], + ['GET', '/api/info'], + ['GET', '/api/credential'], + ['POST', '/api/credential'], + ['GET', '/api/integration/anthropic'], + ['DELETE', '/api/debug/location'], + ['POST', '/api/location/reload'], + ['GET', '/api/session/ses_abc/prompt'], + ['POST', '/api/session/ses_abc/shell'], + ['GET', '/api/session/ses_abc/extra/segments/here'], + ['GET', '/api/session/ses%5Fabc'], + ['GET', '/api/session/ses abc'], + ['GET', '/api/session/ses;abc'], + ['GET', '/api/session/..'], + ['GET', '/API/session'], + ['GET', '/api/Session'], + ['GET', '/api/session/'], + ['GET', 'api/session'], + ['GET', `/api/session/${'a'.repeat(129)}`], + ['get', '/api/session'], + ])('refuses %s %s', (method, pathname) => { + expect(isGatewayRouteV2(method, pathname)).toBe(false) + }) +}) diff --git a/packages/runtime/src/agent/gateway-routes-v2.ts b/packages/runtime/src/agent/gateway-routes-v2.ts new file mode 100644 index 0000000000..5f7d2621d9 --- /dev/null +++ b/packages/runtime/src/agent/gateway-routes-v2.ts @@ -0,0 +1,64 @@ +/** + * The OpenCode V2 routes the gateway may reach through the workspace's `:9200` bearer proxy (fro-bot/agent#1992). + * + * The proxy replaces the gateway bearer with the server's privileged Basic credential, so it forwards ONLY these + * routes; everything else (credential storage, integration setup, config, debug) is answered 404 without a call to the + * server. The list is exactly what `createRemoteOpenCodeV2Client` sends, and `gateway-routes-v2.test.ts` runs every + * method of that client and fails when one produces a request that is not listed here, so a new client route cannot ship + * without being allowlisted on purpose. + * + * It lives in the runtime because both sides may depend on the runtime and neither may depend on the other: the + * gateway builds the requests (through the client) and the workspace agent's proxy filters them. + */ + +export interface V2GatewayRoute { + readonly method: 'DELETE' | 'GET' | 'POST' + /** `/api/...`; a `:name` segment matches exactly one canonical identifier segment. */ + readonly path: string +} + +export const V2_GATEWAY_ROUTES: readonly V2GatewayRoute[] = [ + {method: 'GET', path: '/api/event'}, + {method: 'GET', path: '/api/session'}, + {method: 'POST', path: '/api/session'}, + {method: 'GET', path: '/api/session/active'}, + {method: 'GET', path: '/api/session/:sessionId'}, + {method: 'POST', path: '/api/session/:sessionId/prompt'}, + {method: 'POST', path: '/api/session/:sessionId/interrupt'}, + {method: 'GET', path: '/api/session/:sessionId/inbox'}, + {method: 'POST', path: '/api/session/:sessionId/permission/:requestId/reply'}, + {method: 'POST', path: '/api/session/:sessionId/form/:formId/reply'}, + {method: 'DELETE', path: '/api/session/:sessionId/form/:formId'}, + {method: 'GET', path: '/api/shell'}, + {method: 'GET', path: '/api/shell/:shellId'}, + {method: 'DELETE', path: '/api/shell/:shellId'}, +] + +/** One identifier segment: what OpenCode ids are made of. Nothing that could be decoded, split or truncated by a router. */ +const PARAM_SEGMENT = /^[\w-]{1,128}$/ + +/** + * True when `method` and `pathname` match this one route: the same method, the same number of segments, each literal + * segment equal and each `:name` segment one identifier. `pathname` is the canonical path of an origin-form target. + */ +export function routeMatches(route: V2GatewayRoute, method: string, pathname: string): boolean { + if (!pathname.startsWith('/') || route.method !== method) return false + const expected = route.path.split('/').slice(1) + const actual = pathname.split('/').slice(1) + return ( + expected.length === actual.length && + expected.every((segment, index) => + segment.startsWith(':') ? PARAM_SEGMENT.test(actual[index] as string) : segment === actual[index], + ) + ) +} + +/** + * True when `method` and `pathname` are an allowlisted route. `pathname` must already be the canonical path of an + * origin-form target (no query, no fragment): this compares the raw segments exactly and never decodes anything, so a + * segment carrying `%`, `;`, `\`, `.` or any other character outside the identifier alphabet matches no parameter, and a + * literal segment matches only its exact, lower-case spelling. + */ +export function isGatewayRouteV2(method: string, pathname: string): boolean { + return V2_GATEWAY_ROUTES.some(route => routeMatches(route, method, pathname)) +} diff --git a/packages/runtime/src/agent/workspace-v2.ts b/packages/runtime/src/agent/workspace-v2.ts index 9e38b82d21..6d30ab13d8 100644 --- a/packages/runtime/src/agent/workspace-v2.ts +++ b/packages/runtime/src/agent/workspace-v2.ts @@ -14,6 +14,10 @@ export { export type {Logger} from '../shared/logger.js' +export {isGatewayRouteV2, V2_GATEWAY_ROUTES} from './gateway-routes-v2.js' + +export type {V2GatewayRoute} from './gateway-routes-v2.js' + export {verifyPermissionRules} from './permission-gate-v2.js' export type {AgentListSource, PermissionGateError, PermissionGateExpectations} from './permission-gate-v2.js'