Skip to content

refactor(workspace): run the workspace agent on OpenCode V2 when the image selects it - #1983

Merged
marcusrbrown merged 4 commits into
mainfrom
feat/opencode-v2-workspace
Oct 29, 2026
Merged

marcusrbrown merged 4 commits into
mainfrom
feat/opencode-v2-workspace

Conversation

@marcusrbrown

@marcusrbrown marcusrbrown commented Oct 29, 2026 •

Copy link
Copy Markdown
Collaborator

The workspace agent and its image can now run on OpenCode V2 (#1805) when the image is built with a V2 OPENCODE_VERSION. The default build stays on the V1 harness and behaves exactly as before. Switching the deployed image is a separate step.

Selection

  • The image sets WORKSPACE_IMAGE_OPENCODE_VERSION from the same OPENCODE_VERSION build argument that picks the binary.
  • Major version 2 or higher selects the V2 runner, proxy and disposer.
  • Unset or older keeps V1, unchanged.
  • Not a version stops the agent with exit 1 before it binds anything.
  • /readyz and /healthz report the value as opencodeVersion, and omit the key when it's unset. This is the field the gateway will read to choose its V1 or V2 run loop, so the image pin stays the only switch. The gateway side lands separately; until then the gateway runs its V1 loop, so a V2 image must not be deployed yet.

V2 runner and proxy

  • Starting. Each spawn gets its own database directory, so no session state carries over between spawns. The config comes from the V2 workspace builder:

    • access outside the checkout asks through Discord approval;
    • .env is denied;
    • session_move and worktree_create are denied.

    The runner spawns through the runtime V2 server lifecycle as uid 10001. If it can't take that identity, the spawn fails instead of running as root. It then requires both plugins, systematic and fro-bot.opencode-plugin, to be active, and runs the structural permission gate.

  • Stopping. Owned sessions are interrupted before stdin closes. A cancel that arrives while a start is still in flight aborts only that start, so it can't leave an execution claim behind.

  • Proxy. It turns the gateway's Bearer token into the server's Basic auth on each request, passes x-opencode-directory through, and streams SSE uncompressed.

  • File watcher. It stays at V2's default.

Image

The V2 branch of deploy/workspace.Dockerfile:

  • installs the digest-verified stock musl binary;
  • installs Systematic (SYSTEMATIC_VERSION, the existing pin, now 3.27.1) and @fro.bot/opencode-plugin@0.1.0 into the agent's cache at build time, as uid 10001, using the bounded deploy/scripts/install-opencode-v2-plugins.sh. Nothing is installed at boot;
  • writes a V2 base config with no plugin key, plus a root-owned V2 managed config;
  • leaves harness and V1 builds skipping all of this, with their old configs.

Verification

  • Real binary. The integration test against stock v2.2.0 (digest-verified):

    • installed both plugins with the image's script while the registry was refused;
    • passed both the plugin gate and the permission gate;
    • ran the Bearer proxy and SSE;
    • shut down with a turn in flight and left no claim;
    • started the next spawn on an empty database.

    It's gated on FRO_BOT_OPENCODE_V2_BIN, so CI skips it. It also caught the abort-during-start claim leak, which is now fixed and has its own test.

  • Docker, local, arm64. docker build --build-arg OPENCODE_VERSION=2.2.0 succeeded. In the image:

    • opencode --version is stock v2.2.0;
    • WORKSPACE_IMAGE_OPENCODE_VERSION=2.2.0;
    • the V2 configs are correct;
    • both plugin caches are owned by uid 10001.

    A --network none --user 10001:10001 run of deploy/scripts/v2-image-smoke.mjs reached /api/info and /api/agent, with both plugins active. The V1 default build is covered by CI's existing workspace smoke test.

  • Tests and gates.

    • About 150 tests across 11 files.
    • 33 production and Dockerfile mutations, all killed.
    • Drift tests pin the Dockerfile ARG defaults to the runtime constants.
    • check-types, lint, test, test:scripts, build and node --test deploy/scripts/*.test.mjs all pass.

Systematic 3.27.x supports both V1 and V2, so the V2 path installs DEFAULT_SYSTEMATIC_VERSION (Dockerfile ARG SYSTEMATIC_VERSION) instead of a separate SYSTEMATIC_V2_VERSION pin that Renovate does not manage.
@marcusrbrown
marcusrbrown requested a review from fro-bot as a code owner October 29, 2026 04:37

test('every version test in the runtime stage and the V2 stage uses the same V2 arm', () => {
// #then a drifted pattern would send one step down the V1 path and another down the V2 path
const arms = dockerfile.split(V2_VERSION_ARM).length - 1

@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

Two V2 lifecycle issues need correction before merge.

Blocking issues

Priority Location Issue and required fix
P2 opencode-server-v2.ts:399–405 Bound and cancel the permission-gate request. serving.fetch('/api/agent') and body consumption have no timeout or cancellation signal; the handle exposes raw fetch, so requestTimeoutMs does not apply. Since startAbort is already cleared, a stalled response prevents retry and shutdown until the service's 25-second forced exit. Apply a timeout combined with service cancellation through body consumption, and shut down the acquired handle on failure/cancellation.
P2 opencode-server-v2.ts:348–351 Recheck cancellation after database preparation. An abort during await prepareDatabase() fires while startAbort is undefined. Preparation then creates a fresh, unaborted controller and starts another server during shutdown. Startup can exceed the 25-second drain. Check isAborted() immediately after preparation and return before spawning.

Non-blocking concerns

  • V2 rollout prerequisite: the gateway currently has no opencodeVersion consumer and still attaches through the existing client/run loop. The description's claim that it already selects V2 from /readyz is premature. Track the gateway wiring and correct that claim before switching the deployed image.

Missing tests

  • Stall the permission-gate response headers or body; verify timeout/cancellation shuts down the handle and leaves readiness false.
  • Abort while database preparation is deferred; resolve preparation and verify startServer is never called.
  • Automate the V2 image smoke path in CI, including normal entrypoint startup and /readyz. Current image CI builds the default V1 image; the real-binary integration test is opt-in.

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

The default V1 path limits immediate exposure. V2 startup failures can prevent graceful shutdown and force container termination. No concrete authentication bypass was identified.


Run Summary
Field Value
Event pull_request
Repository fro-bot/agent
Run ID 40818772970
Cache hit
Session ses_e7a0a22aeffea37Va7CgDdZ5ze
Review Read-only source/test inspection; both lifecycle findings independently validated
Checks No tests, build, lint, or type-check commands run
Delivery Response-file review; inline annotations unsupported, so findings link to exact lines
Workflow limitation Packaged synthesis helper inaccessible; findings verified directly against source

@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: PASS

Both previous blockers are resolved in 11c40c3: the permission-gate read is bounded and cancellable through body consumption, and cancellation after database preparation prevents spawning. Regression tests cover both fixes. The description now correctly identifies gateway support as a separate rollout prerequisite.

Blocking issues

None

Non-blocking concerns

None

Missing tests

  • Automated V2 image smoke coverage remains absent: current image CI exercises V1, while V2 verification is manual/opt-in. Add normal-entrypoint and /readyz coverage before production rollout; this remains non-blocking for the staged implementation.

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

The V2 lifecycle and image path remain a substantial change, but the default V1 selection is preserved. No additional blocking correctness, security, or API-contract issue was identified in the follow-up.


Run Summary
Field Value
Event pull_request
Repository fro-bot/agent
Run ID 40866251093
Cache hit
Session ses_e7a0a22aeffea37Va7CgDdZ5ze
Scope Re-review of efcfc61 → 11c40c3, including both prior blockers and regression tests
Checks Source/test inspection; no test suites, build, lint, or type-check commands run
Probe Checked a possible pre-aborted-helper rejection. Reproduced in isolation, but excluded after independent validation found no reachable production cancellation path.

Exact minimal probe command:

node --input-type=module -e 'async function untilAborted(signal, work) { return new Promise((resolve, reject) => { const onAbort = () => reject(signal.reason); if (signal.aborted) { onAbort(); return; } signal.addEventListener("abort", onAbort, {once:true}); work.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort)); }); } const controller = new AbortController(); controller.abort(); process.on("unhandledRejection", error => { process.stdout.write(`UNHANDLED: ${error.name}\n`); }); try { await untilAborted(controller.signal, fetch("http://127.0.0.1:54321/api/agent", {signal:controller.signal})); } catch (error) { process.stdout.write(`GATE CAUGHT: ${error.name}\n`); } await new Promise(resolve => setTimeout(resolve, 20));'

@marcusrbrown
marcusrbrown merged commit 2ea634a into main Oct 29, 2026
15 checks passed
@marcusrbrown
marcusrbrown deleted the feat/opencode-v2-workspace branch October 29, 2026 13:18
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.

3 participants