Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/auto-release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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' &&
Expand Down
186 changes: 184 additions & 2 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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'
Expand Down
4 changes: 3 additions & 1 deletion apps/workspace-agent/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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/<provider of the effective model>` 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

Expand Down
51 changes: 49 additions & 2 deletions apps/workspace-agent/src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading
Loading