Skip to content

fix(workspace): provision provider credentials into each OpenCode V2 spawn - #1993

Open
marcusrbrown wants to merge 2 commits into
mainfrom
fix/v2-spawn-credentials
Open

marcusrbrown wants to merge 2 commits into
mainfrom
fix/v2-spawn-credentials

Conversation

@marcusrbrown

Copy link
Copy Markdown
Collaborator

Fixes #1992.

On OpenCode V2, every model call went out without a key and got back a 401. OpenCode V2 imports auth.json in only one place, migration m39. Each spawn gets a fresh database, and a fresh database is bootstrapped by marking every migration applied without running it. So m39 never ran and the credential table stayed empty. I reproduced it on stock v2.2.0 with a fake provider: three requests arrived with no x-api-key and no authorization, and the database had 0 credential rows.

Fix

  • Provision credentials. After the permission gate, the supervisor reads the root-only credential blob (the same workspace-opencode-auth secret V1 uses). It sends each API-key entry to POST /api/credential over loopback, authenticated with that spawn's Basic credential and bounded by a timeout.
    • The key only ever appears in that request body. It stays out of argv, env, OPENCODE_CONFIG_CONTENT, logs, errors and /readyz.
    • The route echoes the stored value back, so that response body is discarded unread.
    • Errors name the provider id and HTTP status only.
  • Fail loudly. After provisioning, GET /api/integration/<provider> must show a credential or env connection for the configured model's provider. If no model is configured, every provisioned provider is checked. If neither a model nor a blob exists, nothing is checked, so clone-only deployments still boot.
    • Otherwise the spawn stops with credential-missing and readiness never turns ready. Bad credentials now fail at spawn instead of at the first model call.
    • That route returns only connection ids and labels.
  • Other changes.
    • OPENCODE_DISABLE_MODELS_FETCH=1 is now in the V2 env, so models.opencode.ai is no longer fetched; it was blocked by egress. The bundled catalog is used.
    • The startup marker now says provider-overlay=merged-into-config-file. It describes what was actually checked; applied claimed more than that.
    • V2's anthropic provider sends x-api-key, the same as V1, so the provider overlay needed no change.

Bearer proxy

Upstream GET /api/credential returns stored values in plaintext. Once the store holds a key, anyone with the gateway bearer could read, create or delete provider keys through :9200. The proxy therefore returns 404 for api/credential, with no upstream call, on every method and sub-path. It runs after the bearer check.

The path is percent-decoded up to three rounds. It's split on / and \, empty, . and ;param segments are dropped, .. is resolved, and the comparison ignores case. On the real binary, /api/CREDENTIAL, /api/%63redential, /api//credential and /api/credential;x=1 all reached the credential routes. A path that can't be decoded, or still changes after three rounds, is refused.

Provisioning goes straight to loopback, not through the proxy. With a key provisioned on the real binary, none of these returned it: /api/provider, /api/model, /api/integration, /api/config, the session and message routes, /api/mcp, /api/skill, /api/command and /api/project.

Effective model

The gate reads model from the merged opencode.json the spawn actually loads. That file already applies the env-over-overlay rule, so a model set only through the overlay is gated too. The file is writable by the agent, so root opens it with O_NOFOLLOW and a 512 KiB cap, and parse errors never quote its contents.

An abort during provisioning or the gate counts as a clean stop. It doesn't log a gate failure and doesn't go degraded.

CI

The V2 leg of the Workspace Image Smoke Test now sends a real model request. Before, it only checked readiness, which needs no provider. The job name is unchanged.

  • With a credential: the image starts through its entrypoint with a dummy credential blob and an overlay pointing baseURL at a fake Anthropic-compatible provider running inside the container (--network none). One prompt goes through :9200. The fake must see the dummy key in x-api-key, the key must not appear in the logs, and :9200 must return 404 for /api/credential and three alternative spellings of it.
  • Without a credential: a configured model with no credential must never reach /readyz 200, and the provider must get zero requests.

I ran both steps locally against the fixed image and the unfixed one. Both pass on the fixed image and fail on the unfixed image: xApiKey: absent and /readyz 200.

Verification

  • Unit tests: cover blob parsing, provisioning, the gate, and that no secret appears in logs or errors, with each production change revert-checked.
  • Real-binary tests (stock v2.2.0, digest-verified): the integration test now gets its key only through the blob, not inline. One prompt reaches the fake with x-api-key on every request. With no credential, the spawn stays not-ready and the provider is never called.
  • Gates: check-types, lint, build (dist/ unchanged), test:scripts, deploy/scripts (152).

…spawn

OpenCode V2 imports auth.json only through a migration that a fresh per-spawn database skips, so every spawn started with an empty credential store and each model call went out with no key (#1992).

The supervisor now reads the root-only credential blob, POSTs each API-key entry to /api/credential over loopback after the permission gate, and reads GET /api/integration/<provider> back. A configured model whose provider has no credential never turns ready (credential-missing). Also sets OPENCODE_DISABLE_MODELS_FETCH=1, rewords the provider-overlay startup marker, and extends the V2 workspace image smoke test with a no-network fake Anthropic provider.
…te the effective model

- The bearer proxy answers 404 without forwarding for the /api/credential group in any spelling (credential.list returns stored keys in the clear).
- The credential gate checks the provider of the effective model read from the merged opencode.json the spawn loads, so an overlay-only model is covered.
- A service abort during provisioning or the gate is a clean stop, not a gate failure.
- The V2 CI credential smoke step installs its cleanup trap before docker run and asserts the :9200 proxy refuses /api/credential.
@marcusrbrown
marcusrbrown requested a review from fro-bot as a code owner October 30, 2026 17:08
@fro-bot fro-bot added the agent: working Agent is currently working on this label Oct 30, 2026

@fro-bot fro-bot left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: CONDITIONAL

Blocking issues

Priority Location Issue and required change
P1 opencode-proxy-v2.ts:121–140 Credential-route denial is bypassable. Both http://localhost/api/credential and /api/credential;ignored/../info pass this filter and are forwarded unchanged with privileged Basic authentication. The pinned upstream router strips the absolute URL’s authority and truncates paths at a literal semicolon, respectively, routing both to /api/credential, which returns stored keys. Reject non-origin-form targets and make authorization and forwarding use consistent upstream path semantics.
P2 opencode-credentials-v2.ts:203–205 The gate rejects valid config-authenticated providers. With a configured model and provider.<id>.options.apiKey supplied through the overlay, OpenCode can authenticate without a stored credential or environment connection. Integration connection listings omit inline settings, so this gate stops every spawn despite usable authentication. Account for effective provider authentication settings without exposing their values; retain refusal for genuinely missing authentication. The previous workspace integration fixture used precisely this inline-key configuration.
P2 opencode-server-v2.ts:392–404 The effective model is cached across respawns. The agent-writable config is read before the restart loop. If its model changes from provider A to B, a replacement server loads B while the gate still verifies A; readiness can pass with no credential for B. Resolve the effective model and required providers for each fresh spawn, ideally from that server’s effective configuration.

Non-blocking concerns

None

Missing tests

  • Raw-HTTP absolute-form and semicolon-plus-dot-segment targets, asserting rejection without upstream forwarding; include the pinned real server’s routing behavior.
  • Inline-key-only provider configuration reaching readiness while genuinely missing authentication remains refused.
  • A model-provider change between spawns, verifying that the replacement checks the new provider.
  • Post-header stalls during integration JSON consumption and credential-response cancellation, exercising the documented whole-request timeout.

Risk assessment (HIGH): likelihood of regression, security exposure, or blast radius

The proxy bypass exposes all provisioned provider keys to a bearer holder. The gate also introduces availability regressions for valid provider configurations and can falsely report readiness after respawn.

Verification: bun run - executed one inline raw-HTTP proxy probe. Direct /api/credential returned 404; both bypass targets returned the stub upstream’s 200 and arrived with privileged Basic authentication. Protected-route matching was verified against the pinned Effect 4.0.1 router source. No build, lint, type-check, or test-suite commands were run.


Run Summary
Field Value
Event pull_request
Repository fro-bot/agent
Run ID 41096194981
Cache hit
Session ses_e72333843ffeeExlF6wIsRQshY
Scope 17 changed files; relevant callers and pinned upstream source
Delivery Response-file reviews support a body and verdict, so file-specific findings are line-linked above
Review tooling Automated reviewer-return validation unavailable: helper access denied; findings independently checked against source

@fro-bot fro-bot removed the agent: working Agent is currently working on this label Oct 30, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

OpenCode V2 workspace starts with an empty credential store, so every run fails with provider-auth-unavailable

2 participants