From 22e03edc2790a6ea452f3cd66e169f572578e8d4 Mon Sep 17 00:00:00 2001 From: "Marcus R. Brown" Date: Wed, 28 Oct 2026 20:59:00 -0700 Subject: [PATCH 1/4] refactor(workspace): run the workspace agent on OpenCode V2 when the image selects it --- apps/workspace-agent/AGENTS.md | 15 + apps/workspace-agent/src/main-v2.test.ts | 281 ++++++++ apps/workspace-agent/src/main.ts | 109 ++- .../src/opencode-generation.test.ts | 58 ++ .../src/opencode-generation.ts | 44 ++ .../src/opencode-instance-v2.test.ts | 151 ++++ .../src/opencode-instance-v2.ts | 69 ++ .../src/opencode-proxy-v2.test.ts | 430 ++++++++++++ apps/workspace-agent/src/opencode-proxy-v2.ts | 333 +++++++++ .../opencode-server-v2.integration.test.ts | 449 ++++++++++++ .../src/opencode-server-v2.test.ts | 645 ++++++++++++++++++ .../workspace-agent/src/opencode-server-v2.ts | 480 +++++++++++++ .../src/server-opencode-version.test.ts | 70 ++ apps/workspace-agent/src/server.ts | 16 +- apps/workspace-agent/src/types.ts | 8 + apps/workspace-agent/tsconfig.json | 4 +- apps/workspace-agent/tsdown.config.ts | 6 + deploy/managed-config/opencode-v2.json | 4 + deploy/scripts/install-opencode-v2-plugins.sh | 72 ++ .../install-opencode-v2-plugins.test.mjs | 209 ++++++ deploy/scripts/v2-image-smoke.mjs | 128 ++++ deploy/scripts/v2-image-smoke.test.mjs | 35 + deploy/scripts/workspace-v2-image.test.mjs | 147 ++++ deploy/workspace.Dockerfile | 65 +- packages/runtime/package.json | 5 + packages/runtime/src/agent/server-v2.test.ts | 42 ++ packages/runtime/src/agent/server-v2.ts | 41 +- .../runtime/src/agent/workspace-v2.test.ts | 32 + packages/runtime/src/agent/workspace-v2.ts | 37 + packages/runtime/src/shared/constants.ts | 12 + 30 files changed, 3963 insertions(+), 34 deletions(-) create mode 100644 apps/workspace-agent/src/main-v2.test.ts create mode 100644 apps/workspace-agent/src/opencode-generation.test.ts create mode 100644 apps/workspace-agent/src/opencode-generation.ts create mode 100644 apps/workspace-agent/src/opencode-instance-v2.test.ts create mode 100644 apps/workspace-agent/src/opencode-instance-v2.ts create mode 100644 apps/workspace-agent/src/opencode-proxy-v2.test.ts create mode 100644 apps/workspace-agent/src/opencode-proxy-v2.ts create mode 100644 apps/workspace-agent/src/opencode-server-v2.integration.test.ts create mode 100644 apps/workspace-agent/src/opencode-server-v2.test.ts create mode 100644 apps/workspace-agent/src/opencode-server-v2.ts create mode 100644 apps/workspace-agent/src/server-opencode-version.test.ts create mode 100644 deploy/managed-config/opencode-v2.json create mode 100755 deploy/scripts/install-opencode-v2-plugins.sh create mode 100644 deploy/scripts/install-opencode-v2-plugins.test.mjs create mode 100644 deploy/scripts/v2-image-smoke.mjs create mode 100644 deploy/scripts/v2-image-smoke.test.mjs create mode 100644 deploy/scripts/workspace-v2-image.test.mjs create mode 100644 packages/runtime/src/agent/workspace-v2.test.ts create mode 100644 packages/runtime/src/agent/workspace-v2.ts diff --git a/apps/workspace-agent/AGENTS.md b/apps/workspace-agent/AGENTS.md index 98e66a7acb..a29c3b3ed2 100644 --- a/apps/workspace-agent/AGENTS.md +++ b/apps/workspace-agent/AGENTS.md @@ -137,6 +137,10 @@ src/ ├── recover.ts /recover/preview and /recover: fingerprint preview, quarantine-and-replace mutation ├── 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-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 ├── git-safety.ts Sealed, credential-free git invocation profiles for git against an EXISTING checkout ├── git-stream.ts pack-objects | index-pack streaming primitive (runPackStream) @@ -150,6 +154,17 @@ src/ └── types.ts Request/response types (shared contract with gateway workspace-api) ``` +## OpenCode V2 (stock 2.x images) + +`deploy/workspace.Dockerfile` bakes `WORKSPACE_IMAGE_OPENCODE_VERSION` (the `OPENCODE_VERSION` that chose the binary). `main.ts` reads it through `readOpencodeGeneration`: major 2 or higher injects the V2 runner, proxy and disposer; unset or older keeps the V1 ones exactly. A value that is set but is not a version exits 1 before any bind. The same value is reported as `opencodeVersion` on `/healthz` and `/readyz`, and the gateway picks its run loop from it. The image pin is the only switch; there is no operator setting. + +- **Spawn** goes through `startOpenCodeV2Server` (`@fro-bot/runtime/workspace-v2`): `serve --stdio` with a held stdin, an explicit port, a per-spawn Basic password, readiness on `/api/agent`, and the plugin gate (Systematic `systematic` and `fro-bot.opencode-plugin` must be `active`). The child runs as uid/gid 10001 with an environment built from an allowlist. +- **Plugins are installed at image build time** by `deploy/scripts/install-opencode-v2-plugins.sh` (bounded, as uid 10001) and listed in the config content passed at spawn. Nothing installs at boot. +- **Every spawn gets a fresh database** (`prepareFreshDatabase`, under the root-owned `/var/lib/workspace-agent/opencode-v2-db`), so V2 has no execution claim to resume after a restart. +- **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. + ## Build ```bash diff --git a/apps/workspace-agent/src/main-v2.test.ts b/apps/workspace-agent/src/main-v2.test.ts new file mode 100644 index 0000000000..29660988c3 --- /dev/null +++ b/apps/workspace-agent/src/main-v2.test.ts @@ -0,0 +1,281 @@ +/** + * Tests for the V2 selector in main.ts. The image bakes WORKSPACE_IMAGE_OPENCODE_VERSION; major 2 or above injects the + * V2 supervised runner, proxy and disposer, anything else leaves startup exactly as the V1 tests pin it. + */ + +import type {CreateAppFn, ExitFn} from './main.js' +import type {CreateInstanceDisposerV2Options} from './opencode-instance-v2.js' +import type {OpencodeProxyV2Options} from './opencode-proxy-v2.js' +import type {OpencodeProxyHandle, OpencodeProxyOptions} from './opencode-proxy.js' +import type {RunSupervisedOpencodeV2Options} from './opencode-server-v2.js' +import type {RunSupervisedOpencodeOptions} from './opencode-server.js' +import type {ServerDeps} from './server.js' + +import http from 'node:http' +import process from 'node:process' + +import {Hono} from 'hono' +import {afterEach, describe, expect, it, vi} from 'vitest' + +import {startWorkspaceAgent} from './main.js' + +const noopReconcile = async (): Promise => {} + +function makeFakeProxy(callLog: string[]): OpencodeProxyHandle { + const server = new http.Server() + return { + server, + listen: async () => { + callLog.push('proxy.listen') + }, + close: async () => { + callLog.push('proxy.close') + }, + } +} + +function fakeServeFn(callLog: string[]) { + return vi.fn((_options: unknown, cb?: (info: {address: string; family: string; port: number}) => void) => { + callLog.push('serve') + const server = new http.Server() + // After serveFn returns, as the real bind does, so the resolved server is the one startup holds. + queueMicrotask(() => cb?.({address: '0.0.0.0', family: 'IPv4', port: 9100})) + return server + }) +} + +function makeExitFn(exitLog: string[]): ExitFn { + return code => { + exitLog.push(`exit(${code})`) + throw new Error(`exitFn(${code})`) + } +} + +interface Harness { + readonly callLog: string[] + readonly v1Runner: ReturnType + readonly v2Runner: ReturnType + readonly v1Proxy: ReturnType + readonly v2Proxy: ReturnType + readonly v1Disposer: ReturnType + readonly v2Disposer: ReturnType + readonly appDeps: {value?: ServerDeps} + readonly deps: Parameters[0] +} + +function harness( + env: NodeJS.ProcessEnv, + extra: Partial[0]>> = {}, +): Harness { + const callLog: string[] = [] + const appDeps: {value?: ServerDeps} = {} + const v1Runner = vi.fn(async (_options: RunSupervisedOpencodeOptions) => { + callLog.push('runV1') + }) + const v2Runner = vi.fn(async (_options: RunSupervisedOpencodeV2Options) => { + callLog.push('runV2') + }) + const v1Proxy = vi.fn((_options: OpencodeProxyOptions) => { + callLog.push('proxyV1') + return makeFakeProxy(callLog) + }) + const v2Proxy = vi.fn((_options: OpencodeProxyV2Options) => { + callLog.push('proxyV2') + return makeFakeProxy(callLog) + }) + const v1Disposer = vi.fn(() => async () => ({kind: 'skipped' as const})) + const v2Disposer = vi.fn((_options: CreateInstanceDisposerV2Options) => async () => ({kind: 'skipped' as const})) + const createAppFn: CreateAppFn = deps => { + appDeps.value = deps + return new Hono() + } + return { + callLog, + v1Runner, + v2Runner, + v1Proxy, + v2Proxy, + v1Disposer, + v2Disposer, + appDeps, + deps: { + env, + serveFn: fakeServeFn(callLog), + readSecretFn: () => 'gateway-bearer', + reconcileUpdateJournalsFn: noopReconcile, + reconcileRecoveryJournalsFn: noopReconcile, + createAppFn, + runSupervisedOpencodeFn: v1Runner, + createOpencodeProxyFn: v1Proxy, + createInstanceDisposerFn: v1Disposer, + runSupervisedOpencodeV2Fn: v2Runner, + createOpencodeProxyV2Fn: v2Proxy, + createInstanceDisposerV2Fn: v2Disposer, + ...extra, + }, + } +} + +async function terminate(h: Harness): Promise { + vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + h.callLog.push(`process.exit(${code ?? 0})`) + }) as never) + process.emit('SIGTERM') + for (let i = 0; i < 6; i++) await new Promise(resolve => setTimeout(resolve, 0)) + return h.callLog +} + +describe('startWorkspaceAgent — OpenCode generation selector', () => { + afterEach(() => { + process.removeAllListeners('SIGTERM') + process.removeAllListeners('SIGINT') + vi.restoreAllMocks() + }) + + describe('V1 (variable unset or a V1 version)', () => { + it('uses the V1 runner, proxy and disposer when the image sets nothing, and reports no version', async () => { + // #given an image that predates the variable + const h = harness({}) + + // #when + await startWorkspaceAgent(h.deps) + + // #then exactly the V1 trio is wired + expect(h.v1Runner).toHaveBeenCalledTimes(1) + expect(h.v1Proxy).toHaveBeenCalledTimes(1) + expect(h.v1Disposer).toHaveBeenCalledTimes(1) + expect(h.v2Runner).not.toHaveBeenCalled() + expect(h.v2Proxy).not.toHaveBeenCalled() + expect(h.v2Disposer).not.toHaveBeenCalled() + expect(h.appDeps.value).toBeDefined() + expect(h.appDeps.value).not.toHaveProperty('opencodeVersion') + }) + + it('keeps the V1 trio for a V1 image version and reports that version', async () => { + // #given + const h = harness({WORKSPACE_IMAGE_OPENCODE_VERSION: '1.19.4+harness.5bde2635'}) + + // #when + await startWorkspaceAgent(h.deps) + + // #then + expect(h.v1Runner).toHaveBeenCalledTimes(1) + expect(h.v2Runner).not.toHaveBeenCalled() + expect(h.appDeps.value?.opencodeVersion).toBe('1.19.4+harness.5bde2635') + }) + }) + + describe('V2 (major 2 or above)', () => { + it('uses the V2 runner, proxy and disposer, and reports the version', async () => { + // #given an image that installed stock 2.2.0 + const h = harness({WORKSPACE_IMAGE_OPENCODE_VERSION: '2.2.0', WORKSPACE_OPENCODE_READY_TIMEOUT_MS: '7000'}) + + // #when + await startWorkspaceAgent(h.deps) + + // #then exactly the V2 trio is wired + expect(h.v2Runner).toHaveBeenCalledTimes(1) + expect(h.v2Proxy).toHaveBeenCalledTimes(1) + expect(h.v2Disposer).toHaveBeenCalledTimes(1) + expect(h.v1Runner).not.toHaveBeenCalled() + expect(h.v1Proxy).not.toHaveBeenCalled() + expect(h.v1Disposer).not.toHaveBeenCalled() + expect(h.appDeps.value?.opencodeVersion).toBe('2.2.0') + expect(h.v2Runner.mock.calls[0]?.[0]).toMatchObject({rootDir: '/workspace/repos', readyTimeoutMs: 7000}) + expect(h.v2Proxy.mock.calls[0]?.[0]).toMatchObject({ + token: 'gateway-bearer', + upstreamUrl: 'http://127.0.0.1:54321', + }) + expect(h.v2Disposer.mock.calls[0]?.[0]).toMatchObject({upstreamUrl: 'http://127.0.0.1:54321'}) + }) + + it('hands the runner, the proxy and the disposer the same credential holder', async () => { + // #given + const h = harness({WORKSPACE_IMAGE_OPENCODE_VERSION: '2.2.0'}) + + // #when + await startWorkspaceAgent(h.deps) + + // #then one holder: what the runner publishes is what the proxy and the disposer read + const runnerHolder = (h.v2Runner.mock.calls[0]?.[0] as RunSupervisedOpencodeV2Options).credentials + const proxyHolder = (h.v2Proxy.mock.calls[0]?.[0] as OpencodeProxyV2Options).credentials + const disposerHolder = (h.v2Disposer.mock.calls[0]?.[0] as CreateInstanceDisposerV2Options).credentials + expect(proxyHolder).toBe(runnerHolder) + expect(disposerHolder).toBe(runnerHolder) + }) + + it('keeps the startup order: serve, proxy created, proxy listening, and only then the runner', async () => { + // #given + const h = harness({WORKSPACE_IMAGE_OPENCODE_VERSION: '2.2.0'}) + + // #when + await startWorkspaceAgent(h.deps) + + // #then the unprivileged server never runs before both control ports are bound + expect(h.callLog).toEqual(['serve', 'proxyV2', 'proxy.listen', 'runV2']) + }) + + it('lets the V2 disposer run only while the supervisor reports ready', async () => { + // #given + const h = harness({WORKSPACE_IMAGE_OPENCODE_VERSION: '2.2.0'}) + await startWorkspaceAgent(h.deps) + const options = h.v2Disposer.mock.calls[0]?.[0] as CreateInstanceDisposerV2Options + + // #then the readiness predicate follows the status ref the app and the runner share + const statusRef = (h.v2Runner.mock.calls[0]?.[0] as RunSupervisedOpencodeV2Options).statusRef + statusRef.status = 'starting' + expect(options.isRunning?.()).toBe(false) + statusRef.status = 'ready' + expect(options.isRunning?.()).toBe(true) + }) + }) + + describe('a value that is set but is not a version', () => { + it('refuses to start and binds nothing, rather than guess a generation', async () => { + // #given an operator override that names no version + const exitLog: string[] = [] + const h = harness({WORKSPACE_IMAGE_OPENCODE_VERSION: 'latest'}, {exitFn: makeExitFn(exitLog)}) + + // #when / #then + await expect(startWorkspaceAgent(h.deps)).rejects.toThrow('exitFn(1)') + expect(exitLog).toEqual(['exit(1)']) + expect(h.callLog).toEqual([]) + expect(h.v1Runner).not.toHaveBeenCalled() + expect(h.v2Runner).not.toHaveBeenCalled() + }) + }) + + describe('shutdown', () => { + it('waits for the V2 supervisor to finish its teardown before it closes the proxy and exits', async () => { + // #given a V2 supervisor whose teardown takes a moment after the abort, and main.ts as the only SIGTERM handler + process.removeAllListeners('SIGTERM') + let finishTeardown: () => void = () => undefined + const h = harness({WORKSPACE_IMAGE_OPENCODE_VERSION: '2.2.0'}) + const slowRunner = vi.fn(async (options: RunSupervisedOpencodeV2Options): Promise => { + h.callLog.push('runV2') + await new Promise(resolve => options.signal?.addEventListener('abort', () => resolve(), {once: true})) + h.callLog.push('teardown.start') + await new Promise(resolve => { + finishTeardown = resolve + }) + h.callLog.push('teardown.done') + }) + await startWorkspaceAgent({...h.deps, runSupervisedOpencodeV2Fn: slowRunner}) + + // #when SIGTERM arrives + const pending = terminate(h) + await new Promise(resolve => setTimeout(resolve, 30)) + + // #then the proxy is still open while sessions are being interrupted + expect(h.callLog).toContain('teardown.start') + expect(h.callLog).not.toContain('proxy.close') + expect(h.callLog).not.toContain('process.exit(0)') + + // #and once the teardown is done the service finishes closing + finishTeardown() + const log = await pending + await new Promise(resolve => setTimeout(resolve, 30)) + expect(log.indexOf('teardown.done')).toBeLessThan(log.indexOf('proxy.close')) + }) + }) +}) diff --git a/apps/workspace-agent/src/main.ts b/apps/workspace-agent/src/main.ts index 73cf10736b..4c06b04909 100644 --- a/apps/workspace-agent/src/main.ts +++ b/apps/workspace-agent/src/main.ts @@ -13,8 +13,11 @@ import type {AddressInfo} from 'node:net' import type {ServerType} from '@hono/node-server' +import type {CreateInstanceDisposerV2Options} from './opencode-instance-v2.js' import type {CreateInstanceDisposerOptions, InstanceDisposer} from './opencode-instance.js' +import type {OpencodeProxyV2Options} from './opencode-proxy-v2.js' import type {OpencodeProxyHandle, OpencodeProxyOptions} from './opencode-proxy.js' +import type {RunSupervisedOpencodeV2Options} from './opencode-server-v2.js' import type {RunSupervisedOpencodeOptions} from './opencode-server.js' import type {ReconcileRecoveryJournalsOnStartupDeps} from './recover.js' import type {ProxyListeningRef, ServerDeps} from './server.js' @@ -27,8 +30,12 @@ import {serve} from '@hono/node-server' import {asyncCleanupAllAskpassDirs} from './clone.js' import {readReadyTimeoutMs, readSecret, readUpdateNetworkConfig} from './config.js' +import {readOpencodeGeneration} from './opencode-generation.js' +import {createInstanceDisposerV2} from './opencode-instance-v2.js' import {createInstanceDisposer} from './opencode-instance.js' +import {createOpencodeProxyV2} from './opencode-proxy-v2.js' import {createOpencodeProxy} from './opencode-proxy.js' +import {createUpstreamCredentials, runSupervisedOpencodeV2} from './opencode-server-v2.js' import {runSupervisedOpencode} from './opencode-server.js' import {reconcileRecoveryJournalsOnStartup} from './recover.js' import {createApp} from './server.js' @@ -85,6 +92,15 @@ export type CreateOpencodeProxyFn = (options: OpencodeProxyOptions) => OpencodeP /** Supervisor runner function. */ export type RunSupervisedOpencodeFn = (options: RunSupervisedOpencodeOptions) => Promise +/** V2 factory for the OpenCode proxy: bearer in, the current server's Basic credential out. */ +export type CreateOpencodeProxyV2Fn = (options: OpencodeProxyV2Options) => OpencodeProxyHandle + +/** V2 supervisor runner function. */ +export type RunSupervisedOpencodeV2Fn = (options: RunSupervisedOpencodeV2Options) => Promise + +/** V2 factory for the best-effort location evictor. */ +export type CreateInstanceDisposerV2Fn = (options: CreateInstanceDisposerV2Options) => InstanceDisposer + /** Secret reader function. */ export type ReadSecretFn = (name: string) => string @@ -162,6 +178,15 @@ export interface WorkspaceAgentDeps { * assert which upstream and readiness predicate startup wires, without a real OpenCode. */ readonly createInstanceDisposerFn?: CreateInstanceDisposerFn + /** + * V2 supervised runner. Defaults to the real `runSupervisedOpencodeV2`. Used instead of `runSupervisedOpencodeFn` + * when the image's `WORKSPACE_IMAGE_OPENCODE_VERSION` has major version 2 or above. + */ + readonly runSupervisedOpencodeV2Fn?: RunSupervisedOpencodeV2Fn + /** V2 proxy factory. Defaults to the real `createOpencodeProxyV2`. Used instead of `createOpencodeProxyFn` on V2. */ + readonly createOpencodeProxyV2Fn?: CreateOpencodeProxyV2Fn + /** V2 instance-disposer factory. Defaults to the real `createInstanceDisposerV2`. Used on V2 only. */ + readonly createInstanceDisposerV2Fn?: CreateInstanceDisposerV2Fn } /** @@ -236,6 +261,9 @@ export async function startWorkspaceAgent(deps: WorkspaceAgentDeps = {}): Promis reconcileRecoveryJournalsFn = reconcileRecoveryJournalsOnStartup, createAppFn = createApp, createInstanceDisposerFn = createInstanceDisposer, + runSupervisedOpencodeV2Fn = runSupervisedOpencodeV2, + createOpencodeProxyV2Fn = createOpencodeProxyV2, + createInstanceDisposerV2Fn = createInstanceDisposerV2, } = deps // Supervisor writes all status transitions here; /healthz and /readyz read it. @@ -260,6 +288,20 @@ export async function startWorkspaceAgent(deps: WorkspaceAgentDeps = {}): Promis // Read env before any server bind: fail-fast if WORKSPACE_OPENCODE_READY_TIMEOUT_MS is malformed. const opencodeReadyTimeoutMs = readReadyTimeoutMs(env) + // Which OpenCode the image carries: the one value the image bakes selects the whole V1 or V2 trio (runner, proxy, + // disposer), and is reported on /healthz and /readyz so the gateway picks its run loop from the workspace. A value + // that is set but is not a version is fatal: guessing would run one generation's spawn flags on the other's binary. + const generation = readOpencodeGeneration(env) + if (!generation.success) { + console.error('workspace-agent: cannot start — invalid OpenCode image version', {message: generation.error}) + return exitFn(1) + } + const isV2 = generation.data.kind === 'v2' + const opencodeVersion = generation.data.version + // One holder for the whole process on V2: the runner publishes the ready server's credential, the proxy and the + // disposer read it. Unused on V1. + const upstreamCredentials = createUpstreamCredentials() + // Read the control-API bearer once, before any server bind, and reuse it for both the Hono // app's auth middleware (every route but /healthz and /readyz) and the 9200 OpenCode proxy // below — never read WORKSPACE_OPENCODE_TOKEN a second time. @@ -314,10 +356,17 @@ export async function startWorkspaceAgent(deps: WorkspaceAgentDeps = {}): Promis logger: opencodeLogger, // After a successful recovery, drop OpenCode's cached instance for the replaced checkout. Only // while the supervisor reports it ready: before that (or while it is down) there is no instance. - instanceDisposer: createInstanceDisposerFn({ - upstreamUrl: OPENCODE_UPSTREAM_URL, - isRunning: () => opencodeStatus.status === 'ready', - }), + instanceDisposer: isV2 + ? createInstanceDisposerV2Fn({ + upstreamUrl: OPENCODE_UPSTREAM_URL, + credentials: upstreamCredentials, + isRunning: () => opencodeStatus.status === 'ready', + }) + : createInstanceDisposerFn({ + upstreamUrl: OPENCODE_UPSTREAM_URL, + isRunning: () => opencodeStatus.status === 'ready', + }), + ...(opencodeVersion === undefined ? {} : {opencodeVersion}), }) // Bind :9100 and WAIT for the first of three outcomes before doing anything else that could @@ -381,11 +430,18 @@ export async function startWorkspaceAgent(deps: WorkspaceAgentDeps = {}): Promis try { // Reuses the `token` read once, above, before any server bind — see that comment. - proxy = createOpencodeProxyFn({ - token, - upstreamUrl: OPENCODE_UPSTREAM_URL, - logger: opencodeLogger, - }) + proxy = isV2 + ? createOpencodeProxyV2Fn({ + token, + upstreamUrl: OPENCODE_UPSTREAM_URL, + logger: opencodeLogger, + credentials: upstreamCredentials, + }) + : createOpencodeProxyFn({ + token, + upstreamUrl: OPENCODE_UPSTREAM_URL, + logger: opencodeLogger, + }) } catch (error) { const message = error instanceof Error ? error.message : String(error) console.error('workspace-agent: cannot start proxy', {message}) @@ -444,15 +500,26 @@ export async function startWorkspaceAgent(deps: WorkspaceAgentDeps = {}): Promis // Spawned only now — AFTER :9100 is confirmed listening and the :9200 bind attempt has // settled — so an unprivileged OpenCode process (uid 10001) can never win a race to bind // either control port first. - const opencodeServerPromise = runSupervisedOpencodeFn({ - rootDir: WORKSPACE_REPOS_ROOT, - logger: opencodeLogger, - statusRef: opencodeStatus, - signal: opencodeController.signal, - hostname: OPENCODE_HOSTNAME, - port: OPENCODE_PORT, - readyTimeoutMs: opencodeReadyTimeoutMs, - }).catch((error: unknown) => { + const opencodeRun = isV2 + ? runSupervisedOpencodeV2Fn({ + rootDir: WORKSPACE_REPOS_ROOT, + logger: opencodeLogger, + statusRef: opencodeStatus, + credentials: upstreamCredentials, + signal: opencodeController.signal, + port: OPENCODE_PORT, + readyTimeoutMs: opencodeReadyTimeoutMs, + }) + : runSupervisedOpencodeFn({ + rootDir: WORKSPACE_REPOS_ROOT, + logger: opencodeLogger, + statusRef: opencodeStatus, + signal: opencodeController.signal, + hostname: OPENCODE_HOSTNAME, + port: OPENCODE_PORT, + readyTimeoutMs: opencodeReadyTimeoutMs, + }) + const opencodeServerPromise = opencodeRun.catch((error: unknown) => { // Unexpected supervisor crash (should not happen — supervisor catches internally). opencodeStatus.status = 'down' const message = error instanceof Error ? error.message : String(error) @@ -483,10 +550,16 @@ export async function startWorkspaceAgent(deps: WorkspaceAgentDeps = {}): Promis return Promise.resolve() } + // V2 only: the abort starts an interrupt-then-close teardown that outlives the abort call, so the service waits + // for it (inside the drain window) before it closes the proxy and exits. V1's abort kills the process group + // synchronously, so it needs no wait and none is added. + const opencodeStopped: Promise = isV2 ? opencodeServerPromise : Promise.resolve() + asyncCleanupAllAskpassDirs() .catch(() => { // Best-effort }) + .finally(async () => opencodeStopped) .finally(() => { cleanupProxy() .catch(() => { diff --git a/apps/workspace-agent/src/opencode-generation.test.ts b/apps/workspace-agent/src/opencode-generation.test.ts new file mode 100644 index 0000000000..0743c77103 --- /dev/null +++ b/apps/workspace-agent/src/opencode-generation.test.ts @@ -0,0 +1,58 @@ +import {describe, expect, it} from 'vitest' + +import {IMAGE_OPENCODE_VERSION_ENV, readOpencodeGeneration} from './opencode-generation.js' + +describe('readOpencodeGeneration', () => { + it('names the image-baked variable, outside the operator-facing WORKSPACE_OPENCODE_* settings', () => { + // #then an operator setting in compose is not mistaken for the image pin + expect(IMAGE_OPENCODE_VERSION_ENV).toBe('WORKSPACE_IMAGE_OPENCODE_VERSION') + }) + + it.each([undefined, '', ' '])('selects V1 when the image sets no version (%j)', value => { + // #given an image that predates the variable, or leaves it blank + const result = readOpencodeGeneration({[IMAGE_OPENCODE_VERSION_ENV]: value}) + + // #then it behaves exactly as before + expect(result).toEqual({success: true, data: {kind: 'v1'}}) + }) + + it.each(['1.19.4+harness.5bde2635', '1.19.4', '1.99.99'])( + 'selects V1 for the V1 version %s and keeps it for reporting', + version => { + // #given a V1 image that bakes its own version + const result = readOpencodeGeneration({[IMAGE_OPENCODE_VERSION_ENV]: version}) + + // #then + expect(result).toEqual({success: true, data: {kind: 'v1', version}}) + }, + ) + + it.each(['2.2.0', '2.0.0', '2.2.0-rc.1', '3.0.0', '10.4.1'])('selects V2 for major 2 or higher (%s)', version => { + // #when + const result = readOpencodeGeneration({[IMAGE_OPENCODE_VERSION_ENV]: version}) + + // #then + expect(result).toEqual({success: true, data: {kind: 'v2', version}}) + }) + + it('trims surrounding whitespace before it reads the version', () => { + // #then + expect(readOpencodeGeneration({[IMAGE_OPENCODE_VERSION_ENV]: ' 2.2.0\n'})).toEqual({ + success: true, + data: {kind: 'v2', version: '2.2.0'}, + }) + }) + + it.each(['v2.2.0', '2', '2.2', 'latest', '02.2.0', '2.2.0 extra', '-1.0.0', '2.x.0'])( + 'refuses a value that is set but is not a version (%s), instead of guessing a generation', + value => { + // #when + const result = readOpencodeGeneration({[IMAGE_OPENCODE_VERSION_ENV]: value}) + + // #then a wrong guess would run the V1 spawn flags against a V2 binary, or the reverse + expect(result.success).toBe(false) + if (result.success) return + expect(result.error).toContain(IMAGE_OPENCODE_VERSION_ENV) + }, + ) +}) diff --git a/apps/workspace-agent/src/opencode-generation.ts b/apps/workspace-agent/src/opencode-generation.ts new file mode 100644 index 0000000000..685367ea03 --- /dev/null +++ b/apps/workspace-agent/src/opencode-generation.ts @@ -0,0 +1,44 @@ +/** + * Which OpenCode generation this image runs, read from the one value the image bakes. + * + * `deploy/workspace.Dockerfile` sets `WORKSPACE_IMAGE_OPENCODE_VERSION` to the OPENCODE_VERSION it installed. A major + * version of 2 or above selects the V2 supervised runner, proxy and disposer in `main.ts`; anything older, or no + * value at all, selects the V1 ones. The image pin is the only switch. There is deliberately no operator setting + * that could disagree with the binary, which is why the name sits outside the operator-facing + * `WORKSPACE_OPENCODE_*` settings. + * + * The same value is reported on `/readyz` so the gateway picks its run loop from the workspace it attaches to, + * never from a setting of its own. + */ + +export const IMAGE_OPENCODE_VERSION_ENV = 'WORKSPACE_IMAGE_OPENCODE_VERSION' + +/** A plain result shape: this module needs nothing from a result library. */ +export type GenerationResult = + {readonly success: true; readonly data: OpencodeGeneration} | {readonly success: false; readonly error: string} + +export type OpencodeGeneration = + /** `version` is absent for an image that does not set the variable. */ + {readonly kind: 'v1'; readonly version?: string} | {readonly kind: 'v2'; readonly version: string} + +/** + * `..` with an optional `-prerelease` or `+build` tail (`1.19.4+harness.5bde2635` is the V1 + * harness form). No leading `v` and no leading zeros: the Dockerfile passes OPENCODE_VERSION as is. + */ +const VERSION_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:[-+][0-9A-Z.+-]+)?$/i + +export function readOpencodeGeneration(env: NodeJS.ProcessEnv): GenerationResult { + const raw = env[IMAGE_OPENCODE_VERSION_ENV]?.trim() + if (raw === undefined || raw === '') return {success: true, data: {kind: 'v1'}} + + const match = VERSION_PATTERN.exec(raw) + if (match === null) { + return { + success: false, + error: `${IMAGE_OPENCODE_VERSION_ENV} is set but is not an OpenCode version (expected e.g. 2.2.0)`, + } + } + + const major = Number(match[1]) + return {success: true, data: major >= 2 ? {kind: 'v2', version: raw} : {kind: 'v1', version: raw}} +} diff --git a/apps/workspace-agent/src/opencode-instance-v2.test.ts b/apps/workspace-agent/src/opencode-instance-v2.test.ts new file mode 100644 index 0000000000..dfacfb32c5 --- /dev/null +++ b/apps/workspace-agent/src/opencode-instance-v2.test.ts @@ -0,0 +1,151 @@ +import {describe, expect, it, vi} from 'vitest' + +import {createInstanceDisposerV2, DEFAULT_INSTANCE_DISPOSE_TIMEOUT_MS_V2} from './opencode-instance-v2.js' +import {createUpstreamCredentials} from './opencode-server-v2.js' + +function credentialed(value = 'Basic c2VjcmV0'): ReturnType { + const credentials = createUpstreamCredentials() + credentials.set(() => value) + return credentials +} + +describe('createInstanceDisposerV2', () => { + it('evicts the location with the narrow debug endpoint, authenticated as the current server', async () => { + // #given + const fetchFn = vi.fn(async (_url: URL | string, _init?: RequestInit) => new Response(null, {status: 204})) + const dispose = createInstanceDisposerV2({ + upstreamUrl: 'http://127.0.0.1:54321', + credentials: credentialed(), + fetchFn: fetchFn as unknown as typeof fetch, + }) + + // #when + const outcome = await dispose('/workspace/repos/acme/widget') + + // #then the request is DELETE /api/debug/location with the deepObject query P3 measured, and Basic auth + expect(outcome).toEqual({kind: 'disposed'}) + const [url, init] = fetchFn.mock.calls[0] as [URL, RequestInit] + expect(url.pathname).toBe('/api/debug/location') + expect(url.searchParams.get('location[directory]')).toBe('/workspace/repos/acme/widget') + expect(init.method).toBe('DELETE') + expect(new Headers(init.headers).get('authorization')).toBe('Basic c2VjcmV0') + }) + + it('never uses the V1 dispose route or the wider location reload', async () => { + // #given + const fetchFn = vi.fn(async (_url: URL | string, _init?: RequestInit) => new Response(null, {status: 204})) + const dispose = createInstanceDisposerV2({ + upstreamUrl: 'http://127.0.0.1:54321', + credentials: credentialed(), + fetchFn: fetchFn as unknown as typeof fetch, + }) + + // #when + await dispose('/workspace/repos/a/b') + + // #then reload rebuilds every loaded location and cancels pending permissions (P3), which is wider than needed + const [url] = fetchFn.mock.calls[0] as [URL] + expect(url.pathname).not.toContain('/instance/dispose') + expect(url.pathname).not.toContain('/reload') + }) + + it('round-trips a path with characters that need encoding', async () => { + // #given + const fetchFn = vi.fn(async (_url: URL | string, _init?: RequestInit) => new Response(null, {status: 204})) + const dispose = createInstanceDisposerV2({ + upstreamUrl: 'http://127.0.0.1:54321', + credentials: credentialed(), + fetchFn: fetchFn as unknown as typeof fetch, + }) + + // #when + await dispose('/workspace/repos/o/r with space&x=1') + + // #then + const [url] = fetchFn.mock.calls[0] as [URL] + expect(url.searchParams.get('location[directory]')).toBe('/workspace/repos/o/r with space&x=1') + }) + + it('skips without a request while no server is running', async () => { + // #given + const fetchFn = vi.fn() + const dispose = createInstanceDisposerV2({ + upstreamUrl: 'http://127.0.0.1:54321', + credentials: credentialed(), + isRunning: () => false, + fetchFn, + }) + + // #then + expect(await dispose('/workspace/repos/a/b')).toEqual({kind: 'skipped'}) + expect(fetchFn).not.toHaveBeenCalled() + }) + + it('skips without a request while the holder has no credential', async () => { + // #given a server that has not published a credential, so an unauthenticated request would only 401 + const fetchFn = vi.fn() + const dispose = createInstanceDisposerV2({ + upstreamUrl: 'http://127.0.0.1:54321', + credentials: createUpstreamCredentials(), + fetchFn, + }) + + // #then + expect(await dispose('/workspace/repos/a/b')).toEqual({kind: 'skipped'}) + expect(fetchFn).not.toHaveBeenCalled() + }) + + it('reports a non-2xx status as an http error, and an upstream 401 as such', async () => { + // #given + const dispose = createInstanceDisposerV2({ + upstreamUrl: 'http://127.0.0.1:54321', + credentials: credentialed(), + fetchFn: async () => new Response('{}', {status: 401}), + }) + + // #then the status is reported and nothing about the credential is + const outcome = await dispose('/workspace/repos/a/b') + expect(outcome).toEqual({kind: 'failed', reason: 'http-error', status: 401}) + expect(JSON.stringify(outcome)).not.toContain('c2VjcmV0') + }) + + it('reports a network error without throwing', async () => { + // #given + const dispose = createInstanceDisposerV2({ + upstreamUrl: 'http://127.0.0.1:54321', + credentials: credentialed(), + fetchFn: async () => { + throw new TypeError('fetch failed') + }, + }) + + // #then + expect(await dispose('/workspace/repos/a/b')).toEqual({kind: 'failed', reason: 'network-error'}) + }) + + it('times out a request that never answers', async () => { + // #given + vi.useFakeTimers() + try { + const dispose = createInstanceDisposerV2({ + upstreamUrl: 'http://127.0.0.1:54321', + credentials: credentialed(), + fetchFn: (async (_url: URL | string, init?: RequestInit) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => + reject(Object.assign(new Error('aborted'), {name: 'AbortError'})), + ) + })) as unknown as typeof fetch, + }) + + // #when + const pending = dispose('/workspace/repos/a/b') + await vi.advanceTimersByTimeAsync(DEFAULT_INSTANCE_DISPOSE_TIMEOUT_MS_V2) + + // #then + expect(await pending).toEqual({kind: 'failed', reason: 'timeout'}) + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/apps/workspace-agent/src/opencode-instance-v2.ts b/apps/workspace-agent/src/opencode-instance-v2.ts new file mode 100644 index 0000000000..07c67c5797 --- /dev/null +++ b/apps/workspace-agent/src/opencode-instance-v2.ts @@ -0,0 +1,69 @@ +/** + * Best-effort disposal of a stock OpenCode V2 server's cached location: the sibling of `opencode-instance.ts`. + * + * Probe P3 (v2.2.0): after a checkout is swapped in place, `fs/find` already returned the new tree within a few + * milliseconds with no action at all, so neither refresh is required for correctness. Where a disposer is still + * wired, this uses the narrower of the two that work: `DELETE /api/debug/location?location[directory]=` evicts + * one location. `POST /api/location/reload` rebuilds every loaded location and cancels pending permissions and + * forms, which is wider than a recovery should ever be. The V1 route (`/instance/dispose`) does not exist on V2. + * + * The call goes straight to the loopback upstream, not through the :9200 proxy, and it must authenticate as the + * server that is running now, so it reads the same credential holder the proxy reads. It is purely advisory (the + * recovery has already succeeded), so every failure is a value for the caller to log, never a throw. + */ + +import type {InstanceDisposeOutcome, InstanceDisposer} from './opencode-instance.js' +import type {UpstreamCredentials} from './opencode-server-v2.js' + +/** Short on purpose: the caller still holds the per-repo mutex while it waits. */ +export const DEFAULT_INSTANCE_DISPOSE_TIMEOUT_MS_V2 = 5000 + +export interface CreateInstanceDisposerV2Options { + /** Loopback URL of the OpenCode server, e.g. `http://127.0.0.1:54321`. */ + readonly upstreamUrl: string + /** The credential of the server that is ready now. Without one no request is sent. */ + readonly credentials: UpstreamCredentials + /** When provided and false, no request is sent and the outcome is `skipped`. */ + readonly isRunning?: () => boolean + readonly timeoutMs?: number + /** Test seam; defaults to the global `fetch`. */ + readonly fetchFn?: typeof fetch +} + +export function createInstanceDisposerV2(options: CreateInstanceDisposerV2Options): InstanceDisposer { + const { + upstreamUrl, + credentials, + isRunning, + timeoutMs = DEFAULT_INSTANCE_DISPOSE_TIMEOUT_MS_V2, + fetchFn = fetch, + } = options + + return async (directory): Promise => { + if (isRunning !== undefined && !isRunning()) return {kind: 'skipped'} + const authorization = credentials.authorization() + // No server has published a credential, so there is no server this request could usefully reach. + if (authorization === undefined) return {kind: 'skipped'} + + // The query is a deepObject (`location[directory]=`); `searchParams` percent-encodes any path so it round-trips. + const url = new URL('/api/debug/location', upstreamUrl) + url.searchParams.set('location[directory]', directory) + + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), timeoutMs) + try { + const response = await fetchFn(url, {method: 'DELETE', headers: {authorization}, signal: controller.signal}) + // The body is empty (204); release the connection without buffering anything. + await response.body?.cancel().catch(() => {}) + if (response.status >= 200 && response.status < 300) return {kind: 'disposed'} + return {kind: 'failed', reason: 'http-error', status: response.status} + } catch (error) { + return { + kind: 'failed', + reason: error instanceof Error && error.name === 'AbortError' ? 'timeout' : 'network-error', + } + } finally { + clearTimeout(timer) + } + } +} diff --git a/apps/workspace-agent/src/opencode-proxy-v2.test.ts b/apps/workspace-agent/src/opencode-proxy-v2.test.ts new file mode 100644 index 0000000000..fe9545c5d4 --- /dev/null +++ b/apps/workspace-agent/src/opencode-proxy-v2.test.ts @@ -0,0 +1,430 @@ +/** + * Tests for opencode-proxy-v2.ts: the bearer-to-Basic reverse proxy for a stock OpenCode V2 server. + * + * The upstream is a real local http stub that behaves like `serve --stdio` in the ways the proxy depends on (probe + * P14): Basic auth with a per-spawn password, an identical 401 body for every auth failure, `x-opencode-directory` + * selecting the location, and an uncompressed SSE stream with a heartbeat comment. Requests use `http.request` for + * precise connection control. + */ + +import type {AddressInfo} from 'node:net' +import type {Logger} from './opencode-server.js' + +import {Buffer} from 'node:buffer' +import http from 'node:http' + +import {afterEach, describe, expect, it} from 'vitest' + +import {createOpencodeProxyV2} from './opencode-proxy-v2.js' +import {createUpstreamCredentials} from './opencode-server-v2.js' + +const TOKEN = 'gateway-bearer-token-xyz' + +interface LogLine { + readonly level: 'error' | 'info' | 'warn' + readonly message: string + readonly meta?: Record +} + +function makeLogger(): {logger: Logger; lines: LogLine[]} { + const lines: LogLine[] = [] + const record = + (level: LogLine['level']) => + (message: string, meta?: Record): void => { + lines.push({level, message, ...(meta === undefined ? {} : {meta})}) + } + return {logger: {info: record('info'), warn: record('warn'), error: record('error')}, lines} +} + +interface Seen { + readonly method: string | undefined + readonly url: string | undefined + readonly headers: http.IncomingHttpHeaders + readonly body: string +} + +interface Upstream { + readonly url: string + /** The password the server currently holds. Changing it simulates a respawn. */ + password: string + readonly seen: Seen[] + readonly close: () => Promise + readonly openSse: () => number +} + +async function startUpstream(initialPassword: string): Promise { + const seen: Seen[] = [] + let sseOpen = 0 + const state = {password: initialPassword} + const server = http.createServer((req, res) => { + const chunks: Buffer[] = [] + req.on('data', (chunk: Buffer) => chunks.push(chunk)) + req.on('end', () => { + seen.push({method: req.method, url: req.url, headers: req.headers, body: Buffer.concat(chunks).toString('utf8')}) + const expected = `Basic ${Buffer.from(`opencode:${state.password}`).toString('base64')}` + if (req.headers.authorization !== expected) { + // The same body for every failure, and no WWW-Authenticate header (P14). + const body = JSON.stringify({_tag: 'UnauthorizedError', message: 'Authentication required'}) + res.writeHead(401, {'content-type': 'application/json', 'content-length': String(Buffer.byteLength(body))}) + res.end(body) + return + } + if (req.url === '/api/event') { + sseOpen += 1 + res.writeHead(200, {'content-type': 'text/event-stream', 'cache-control': 'no-cache, no-transform'}) + res.write(': heartbeat\n\n') + res.write('data: {"type":"server.connected"}\n\n') + res.once('close', () => { + sseOpen -= 1 + }) + return + } + if (req.url === '/api/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) + }) + }) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() as AddressInfo + return { + url: `http://127.0.0.1:${address.port}`, + get password() { + return state.password + }, + set password(value: string) { + state.password = value + }, + seen, + openSse: () => sseOpen, + close: async () => { + server.closeAllConnections() + await new Promise(resolve => server.close(() => resolve())) + }, + } +} + +interface Reply { + readonly status: number + readonly headers: http.IncomingHttpHeaders + readonly body: string +} + +async function request( + base: string, + options: {method?: string; path: string; headers?: Record; body?: string}, +): Promise { + const parsed = new URL(base) + return new Promise((resolve, reject) => { + const req = http.request( + { + hostname: parsed.hostname, + port: parsed.port, + path: options.path, + method: options.method ?? 'GET', + headers: {connection: 'close', ...options.headers}, + }, + res => { + const chunks: Buffer[] = [] + res.on('data', (chunk: Buffer) => chunks.push(chunk)) + res.on('end', () => + resolve({status: res.statusCode ?? 0, headers: res.headers, body: Buffer.concat(chunks).toString('utf8')}), + ) + res.on('error', reject) + }, + ) + req.on('error', reject) + req.end(options.body) + }) +} + +const BEARER = {authorization: `Bearer ${TOKEN}`} + +function basic(password: string): string { + return `Basic ${Buffer.from(`opencode:${password}`).toString('base64')}` +} + +describe('opencode-proxy-v2', () => { + const cleanups: (() => Promise)[] = [] + afterEach(async () => { + for (const cleanup of cleanups.splice(0).reverse()) await cleanup() + }) + + async function setup(options: {password?: string; credentialed?: boolean; timeoutMs?: number} = {}) { + const upstream = await startUpstream(options.password ?? 'pw-one') + const credentials = createUpstreamCredentials() + if (options.credentialed !== false) credentials.set(() => basic(upstream.password)) + const {logger, lines} = makeLogger() + const proxy = createOpencodeProxyV2({ + token: TOKEN, + upstreamUrl: upstream.url, + logger, + credentials, + timeoutMs: options.timeoutMs, + }) + await proxy.listen(0, '127.0.0.1') + const url = `http://127.0.0.1:${(proxy.server.address() as AddressInfo).port}` + cleanups.push(async () => upstream.close()) + cleanups.push(async () => { + proxy.server.closeAllConnections() + await proxy.close() + }) + return {upstream, credentials, proxy, url, lines} + } + + it('turns the caller bearer into the server Basic credential and drops the bearer', async () => { + // #given + const {upstream, url} = await setup() + + // #when a request carries the gateway bearer + const reply = await request(url, {path: '/api/info', headers: BEARER}) + + // #then the server saw Basic auth with its password, never the bearer + expect(reply.status).toBe(200) + expect(upstream.seen[0]?.headers.authorization).toBe(basic('pw-one')) + expect(JSON.stringify(upstream.seen)).not.toContain(TOKEN) + }) + + it('passes x-opencode-directory through untouched', async () => { + // #given + const {upstream, url} = await setup() + + // #when + const reply = await request(url, { + path: '/api/session', + headers: {...BEARER, 'x-opencode-directory': '%2Fworkspace%2Frepos%2Facme%2Fwidget'}, + }) + + // #then the location the gateway chose is the location the server used + expect((JSON.parse(reply.body) as {directory: string}).directory).toBe('%2Fworkspace%2Frepos%2Facme%2Fwidget') + expect(upstream.seen[0]?.headers['x-opencode-directory']).toBe('%2Fworkspace%2Frepos%2Facme%2Fwidget') + }) + + it('forwards the method, path, query and body', async () => { + // #given + const {upstream, url} = await setup() + + // #when + await request(url, { + method: 'POST', + path: '/api/session/ses_1/prompt?x=1', + headers: {...BEARER, 'content-type': 'application/json', 'content-length': '13'}, + body: '{"text":"hi"}', + }) + + // #then + expect(upstream.seen[0]).toMatchObject({ + method: 'POST', + url: '/api/session/ses_1/prompt?x=1', + body: '{"text":"hi"}', + }) + }) + + it('answers 401 and forwards nothing when the bearer is missing or wrong', async () => { + // #given + 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')}}) + + // #then the fixed body, and the server never saw a request + for (const reply of [missing, wrong, basicScheme]) { + expect(reply.status).toBe(401) + expect(reply.body).toBe('Unauthorized\n') + } + expect(upstream.seen).toHaveLength(0) + }) + + it('never logs the bearer or the server credential', async () => { + // #given + 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}) + + // #then + const serialized = JSON.stringify(lines) + expect(serialized).not.toContain(TOKEN) + expect(serialized).not.toContain('wrong-token-value') + expect(serialized).not.toContain(Buffer.from('opencode:pw-one').toString('base64')) + expect(serialized).not.toContain('pw-one') + }) + + it('answers 503 and forwards nothing while no server is ready', async () => { + // #given a proxy whose server has not published a credential yet + const {upstream, url} = await setup({credentialed: false}) + + // #when + const reply = await request(url, {path: '/api/info', headers: BEARER}) + + // #then a request is never sent unauthenticated to a server that is not the current one + expect(reply.status).toBe(503) + expect(reply.body).toBe('Service Unavailable\n') + expect(upstream.seen).toHaveLength(0) + }) + + 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) + credentials.clear() + upstream.password = 'pw-two' + credentials.set(() => basic('pw-two')) + + // #when + const reply = await request(url, {path: '/api/info', headers: BEARER}) + + // #then the proxy read the holder again and the request succeeds + expect(reply.status).toBe(200) + expect(upstream.seen.at(-1)?.headers.authorization).toBe(basic('pw-two')) + }) + + it('reports an upstream 401 as an authentication failure, not as a missing server or a bad bearer', async () => { + // #given a holder that is stale: the server was respawned and the holder was not updated + const {upstream, credentials, url, lines} = await setup({password: 'pw-one'}) + credentials.set(() => basic('pw-one')) + upstream.password = 'pw-rotated' + + // #when + const reply = await request(url, {path: '/api/info', 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) + expect(reply.body).toBe('Upstream Authentication Failed\n') + expect(reply.headers['x-fro-bot-proxy-error']).toBe('upstream-auth') + const failure = lines.find(line => line.message.includes('rejected the credential')) + expect(failure?.level).toBe('error') + expect(JSON.stringify(lines)).not.toContain('pw-one') + }) + + it('answers 502 when the server is not listening', async () => { + // #given an upstream that is gone + const {upstream, url} = await setup() + await upstream.close() + + // #when + const reply = await request(url, {path: '/api/info', headers: BEARER}) + + // #then + expect(reply.status).toBe(502) + expect(reply.body).toBe('Bad Gateway\n') + }) + + it('returns a 504 for a stalled non-streaming request, with a 30s default replaced by the option', async () => { + // #given an upstream that accepts and never answers + const {url} = await setup({timeoutMs: 80}) + + // #when + const reply = await request(url, {path: '/api/slow', headers: BEARER}) + + // #then + expect(reply.status).toBe(504) + }) + + it('streams SSE without compression, delivers the heartbeat comment, and is exempt from the timeout', async () => { + // #given a short inactivity timeout + const {upstream, url} = await setup({timeoutMs: 60}) + const received: string[] = [] + let contentType: string | undefined + let contentEncoding: string | undefined + const parsed = new URL(url) + + // #when a client holds the event stream open well past the timeout + const connection = http.request( + { + hostname: parsed.hostname, + port: parsed.port, + path: '/api/event', + headers: {...BEARER, accept: 'text/event-stream', 'accept-encoding': 'gzip, deflate, br'}, + }, + res => { + contentType = res.headers['content-type'] + contentEncoding = res.headers['content-encoding'] + res.on('data', (chunk: Buffer) => received.push(chunk.toString('utf8'))) + }, + ) + connection.end() + await new Promise(resolve => setTimeout(resolve, 250)) + + // #then the stream is intact: first events arrived, the connection is still open, and nothing was compressed + expect(contentType).toBe('text/event-stream') + expect(contentEncoding).toBeUndefined() + expect(received.join('')).toContain(': heartbeat') + expect(received.join('')).toContain('server.connected') + expect(upstream.openSse()).toBe(1) + + // #and a departed client releases the upstream stream + connection.destroy() + await new Promise(resolve => setTimeout(resolve, 150)) + expect(upstream.openSse()).toBe(0) + }) + + describe('teardown logging', () => { + it('logs an interrupt with the session id, the status the caller saw, and no directory', async () => { + // #given + const {url, lines} = await setup() + + // #when + await request(url, { + method: 'POST', + path: '/api/session/ses_abc123/interrupt', + headers: {...BEARER, 'x-opencode-directory': '%2Fworkspace%2Frepos%2Fsecret-org%2Fsecret-repo'}, + }) + await new Promise(resolve => setTimeout(resolve, 20)) + + // #then + const line = lines.find(l => l.message === 'opencode-proxy-v2: session interrupt forwarded') + expect(line?.meta).toMatchObject({sessionId: 'ses_abc123', status: 200}) + expect(JSON.stringify(lines)).not.toContain('secret-repo') + }) + + it('logs session delete, location evict and location reload', async () => { + // #given + const {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: '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') + }) + + it('does not log a forwarded line for an unauthorised caller', async () => { + // #given + const {url, lines} = await setup() + + // #when + await request(url, {method: 'POST', path: '/api/session/ses_abc123/interrupt'}) + + // #then + expect(lines.some(line => line.message.includes('forwarded'))).toBe(false) + }) + + it('ignores ordinary requests and a session id that is not a plain identifier', async () => { + // #given + const {url, lines} = await setup() + + // #when + await request(url, {method: 'POST', path: '/api/session', headers: BEARER}) + await request(url, {method: 'POST', path: '/api/session/..%2Fescape/interrupt', headers: BEARER}) + await new Promise(resolve => setTimeout(resolve, 20)) + + // #then + expect(lines.filter(line => line.message.includes('forwarded'))).toEqual([]) + }) + }) +}) diff --git a/apps/workspace-agent/src/opencode-proxy-v2.ts b/apps/workspace-agent/src/opencode-proxy-v2.ts new file mode 100644 index 0000000000..3fc067bf9c --- /dev/null +++ b/apps/workspace-agent/src/opencode-proxy-v2.ts @@ -0,0 +1,333 @@ +/** + * Bearer-to-Basic reverse proxy for a stock OpenCode V2 server: the sibling of `opencode-proxy.ts`. + * + * This is the SOLE sandbox-net-reachable OpenCode entry point. The raw server is loopback-bound. + * + * What differs from the V1 proxy (all measured against v2.2.0, probe P14): + * - The server demands Basic auth with a per-spawn password. The caller presents the gateway's bearer; the proxy + * checks it and replaces it with the credential of the server that is ready right now, read from the holder on + * every request, never captured. A respawn changes the password, so a captured value would answer 401 forever. + * - While no server is ready the holder is empty and the proxy answers 503 without forwarding. + * - Every upstream 401 has the same body, so the proxy cannot tell a stale password from a missing one. It reports + * any upstream 401 as an upstream authentication failure (502 plus `x-fro-bot-proxy-error: upstream-auth`), + * which the caller can tell apart from its own wrong bearer (401) and from a server that is down (502 without + * the header). Passing the 401 through would tell the gateway its own token is wrong. + * - `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. + * + * SECURITY INVARIANTS: + * 1. The bearer is compared with `timingSafeEqual`, length-checked first, and is never logged or echoed. + * 2. The upstream credential is never logged and never sent to the caller. + * 3. A request with a missing or wrong bearer is answered 401 and never forwarded. + * 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. + */ + +import type {OpencodeProxyHandle} from './opencode-proxy.js' +import type {UpstreamCredentials} from './opencode-server-v2.js' +import type {Logger} from './opencode-server.js' + +import {Buffer} from 'node:buffer' +import {timingSafeEqual} from 'node:crypto' +import http from 'node:http' +import process from 'node:process' +import {URL} from 'node:url' + +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' + +/** 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' + +/** Default inactivity timeout: 30 seconds. */ +const PROXY_TIMEOUT_DEFAULT_MS = 30_000 + +/** Same contract as the V1 proxy: fail-soft to the default for a missing or invalid value. */ +function readProxyTimeoutMs(): number { + const raw = process.env.WORKSPACE_PROXY_TIMEOUT_MS + if (raw === undefined || raw === '') return PROXY_TIMEOUT_DEFAULT_MS + const trimmed = raw.trim() + if (trimmed === '') return PROXY_TIMEOUT_DEFAULT_MS + const parsed = Number.parseInt(trimmed, 10) + if (Number.isInteger(parsed) === false || String(parsed) !== trimmed || parsed <= 0) return PROXY_TIMEOUT_DEFAULT_MS + return parsed +} + +/** A session id is a plain identifier; anything else in that path segment is not logged. */ +const SESSION_ID_PATTERN = /^[\w-]{1,128}$/ + +interface TeardownRequest { + readonly message: string + readonly sessionId?: string +} + +/** + * 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. + */ +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'} + } + return undefined +} + +export interface OpencodeProxyV2Options { + /** Expected bearer token. Compared with `timingSafeEqual`. Never logged. */ + readonly token: string + /** Upstream loopback URL, e.g. http://127.0.0.1:54321 */ + readonly upstreamUrl: string + readonly logger: Logger + /** The credential of the server that is ready now. Read on every request. */ + readonly credentials: UpstreamCredentials + /** + * Inactivity timeout in milliseconds for non-SSE upstream requests. Defaults to `WORKSPACE_PROXY_TIMEOUT_MS`, or + * 30000 if unset. + */ + readonly timeoutMs?: number +} + +/** Create the V2 proxy. The handle is the V1 handle, so `main.ts` drives either the same way. */ +export function createOpencodeProxyV2(options: OpencodeProxyV2Options): OpencodeProxyHandle { + const {token, upstreamUrl, logger, credentials} = options + const inactivityTimeoutMs = options.timeoutMs === undefined ? readProxyTimeoutMs() : options.timeoutMs + + const expectedBuf = Buffer.from(token) + const upstream = new URL(upstreamUrl) + + const reply = ( + res: http.ServerResponse, + status: number, + body: string, + extraHeaders: Record = {}, + ): void => { + res.writeHead(status, { + 'Content-Type': 'text/plain', + 'Content-Length': String(Buffer.byteLength(body)), + ...extraHeaders, + }) + res.end(body) + } + + const server = http.createServer((req, res) => { + // --- Caller auth: the gateway bearer --- + const authHeader = req.headers.authorization + let authorized = false + if (typeof authHeader === 'string' && authHeader.startsWith('Bearer ')) { + const presentedBuf = Buffer.from(authHeader.slice('Bearer '.length)) + // Guard length before timingSafeEqual (requires same-length buffers). + if (presentedBuf.length === expectedBuf.length) { + authorized = timingSafeEqual(presentedBuf, expectedBuf) + } + } + if (authorized === false) { + // Never log the Authorization header value or the url (the query can carry a directory). + logger.warn('opencode-proxy-v2: unauthorized request', {method: req.method}) + reply(res, 401, UNAUTHORIZED_BODY) + return + } + + // --- Upstream credential: the server that is ready now --- + const upstreamAuthorization = credentials.authorization() + if (upstreamAuthorization === undefined) { + logger.warn('opencode-proxy-v2: no server is ready, request not forwarded', {method: req.method}) + reply(res, 503, SERVICE_UNAVAILABLE_BODY, {'Retry-After': '1'}) + return + } + + // --- Teardown logging --- + // After auth, so an unauthorised caller can never produce a "forwarded" line. Logged when the response closes, + // so the status is the one the caller actually saw. + const teardown = classifyTeardown(req.method, req.url) + if (teardown !== undefined) { + const startedAtMs = Date.now() + res.once('close', () => { + logger.info(teardown.message, { + ...(teardown.sessionId === undefined ? {} : {sessionId: teardown.sessionId}), + ...(res.headersSent ? {status: res.statusCode} : {}), + durationMs: Date.now() - startedAtMs, + }) + }) + } + + // --- Forward --- + // The caller's bearer and Host are replaced; every other header (x-opencode-directory included) is kept. + const forwardHeaders = Object.fromEntries( + Object.entries(req.headers).filter(([key]) => key !== 'authorization' && key !== 'host'), + ) + const forwardOptions: http.RequestOptions = { + hostname: upstream.hostname, + port: upstream.port === '' ? undefined : Number(upstream.port), + path: req.url, + method: req.method, + headers: {...forwardHeaders, authorization: upstreamAuthorization, host: upstream.host}, + } + + // --- Inactivity timer: armed on request start, reset per non-SSE data chunk, cancelled for SSE --- + let inactivityTimer: ReturnType | undefined + let settled = false + const reqRef: {current: http.ClientRequest | undefined} = {current: undefined} + // SSE intent: the request's Accept until response headers arrive, then the response's Content-Type. + let responseIsSSE: boolean | undefined + + const armTimer = (): void => { + clearTimeout(inactivityTimer) + inactivityTimer = setTimeout(() => { + if (settled === true) return + settled = true + logger.warn('opencode-proxy-v2: upstream inactivity timeout', {timeoutMs: inactivityTimeoutMs}) + reqRef.current?.destroy() + if (res.headersSent === false) { + reply(res, 504, GATEWAY_TIMEOUT_BODY) + } else { + res.destroy() + } + }, inactivityTimeoutMs) + } + + const cancelTimer = (): void => { + clearTimeout(inactivityTimer) + inactivityTimer = undefined + } + + const upstreamReq = http.request(forwardOptions, upstreamRes => { + // A 401 here means the server rejected the credential the holder supplied: the holder is stale, or this is + // not the server that was spawned. Say so, instead of passing a 401 the gateway would read as its own. + if (upstreamRes.statusCode === 401) { + settled = true + cancelTimer() + upstreamRes.resume() + logger.error('opencode-proxy-v2: the OpenCode server rejected the credential the proxy holds', { + method: req.method, + }) + if (res.headersSent === false) + reply(res, 502, UPSTREAM_AUTH_FAILED_BODY, {[PROXY_ERROR_HEADER]: 'upstream-auth'}) + else res.destroy() + return + } + + // SSE is a long-lived intentional stream and must never be timed out. + const rawContentType = upstreamRes.headers['content-type'] + const contentType = Array.isArray(rawContentType) ? rawContentType.join(',') : (rawContentType ?? '') + const isSSE = contentType.includes('text/event-stream') + + if (isSSE === true) { + cancelTimer() + } else { + // Reset on each chunk so a slow-but-progressing response is not killed. Headers alone do not cancel the + // timer: a server can send headers fast and then stall mid-body forever. + upstreamRes.on('data', () => { + armTimer() + }) + upstreamRes.on('end', () => { + settled = true + cancelTimer() + }) + upstreamRes.on('close', () => { + settled = true + cancelTimer() + }) + upstreamRes.on('error', () => { + settled = true + cancelTimer() + }) + } + + // `pipe` ends `res` only when the upstream ends; an upstream that dies mid-response would leave the caller waiting. + upstreamRes.on('error', () => { + res.destroy() + }) + upstreamRes.on('close', () => { + if (upstreamRes.complete === false) res.destroy() + }) + + responseIsSSE = isSSE + res.writeHead(upstreamRes.statusCode ?? 502, upstreamRes.headers) + // Pipe without buffering: SSE streams through. + upstreamRes.pipe(res, {end: true}) + }) + + reqRef.current = upstreamReq + armTimer() + + // An SSE stream has no end and no inactivity timer, so a departed caller must release the upstream request. + res.once('close', () => { + const isSSE = responseIsSSE ?? (req.headers.accept ?? '').includes('text/event-stream') + if (isSSE === true && res.writableFinished === false) upstreamReq.destroy() + }) + + upstreamReq.on('error', (err: Error) => { + settled = true + cancelTimer() + logger.error('opencode-proxy-v2: upstream error', {message: err.message}) + if (res.headersSent === false) reply(res, 502, BAD_GATEWAY_BODY) + else res.destroy() + }) + + // Pipe a request body upstream; end bodyless requests at once (keep-alive streams never close on their own). + const hasBody = + req.method !== 'GET' && + req.method !== 'HEAD' && + (Number(req.headers['content-length'] ?? 0) > 0 || req.headers['transfer-encoding'] !== undefined) + if (hasBody === true) { + req.pipe(upstreamReq, {end: true}) + } else { + upstreamReq.end() + } + }) + + return { + server, + + async listen(port: number, hostname: string): Promise { + return new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(port, hostname, () => { + server.off('error', reject) + logger.info('opencode-proxy-v2: listening', {port, hostname}) + resolve() + }) + }) + }, + + async close(): Promise { + return new Promise((resolve, reject) => { + server.close(err => { + if (err !== undefined && err !== null) reject(err) + else resolve() + }) + }) + }, + } +} diff --git a/apps/workspace-agent/src/opencode-server-v2.integration.test.ts b/apps/workspace-agent/src/opencode-server-v2.integration.test.ts new file mode 100644 index 0000000000..d7487efcd9 --- /dev/null +++ b/apps/workspace-agent/src/opencode-server-v2.integration.test.ts @@ -0,0 +1,449 @@ +/** + * The V2 supervised runner against the real stock OpenCode V2 binary. Skipped unless `FRO_BOT_OPENCODE_V2_BIN` names an + * executable. The binary must be the pinned stock release, verified against `packages/runtime/src/shared/ + * opencode-v2-pin.json` before use (this test checks the version it reports; the digest check belongs to the download): + * + * curl -fsSL -o oc.zip https://github.com/anomalyco/opencode/releases/download/v2.2.0/opencode-darwin-arm64.zip + * shasum -a 256 oc.zip # must equal assets["opencode-darwin-arm64.zip"] in opencode-v2-pin.json + * unzip oc.zip && FRO_BOT_OPENCODE_V2_BIN=$PWD/opencode bun run test apps/workspace-agent/src/opencode-server-v2.integration + * + * It needs the npm registry once: the two plugins are installed into a temporary home by the same script the image + * build runs (`deploy/scripts/install-opencode-v2-plugins.sh`, about a minute each), and every server this test starts + * is then given a registry that refuses connections, so a plugin that loads was found in the cache and installed + * nothing at boot. + * + * 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. + */ + +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 {execFileSync, spawnSync} from 'node:child_process' +import {chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync} from 'node:fs' +import http from 'node:http' +import net from 'node:net' +import os from 'node:os' +import path from 'node:path' +import process from 'node:process' +import {DatabaseSync} from 'node:sqlite' +import {fileURLToPath} from 'node:url' + +import { + FRO_BOT_PLUGIN_ID, + FRO_BOT_PLUGIN_VERSION, + startOpenCodeV2Server, + SYSTEMATIC_V2_PLUGIN_ID, + SYSTEMATIC_V2_VERSION, +} from '@fro-bot/runtime/workspace-v2' +import {afterAll, beforeAll, describe, expect, it, vi} from 'vitest' + +import {createInstanceDisposerV2} from './opencode-instance-v2.js' +import {createOpencodeProxyV2} from './opencode-proxy-v2.js' +import { + createUpstreamCredentials, + prepareFreshDatabase, + runSupervisedOpencodeV2, + WORKSPACE_V2_PLUGIN_SPECS, +} from './opencode-server-v2.js' + +const BINARY = process.env.FRO_BOT_OPENCODE_V2_BIN +const hasBinary = BINARY !== undefined && BINARY !== '' && existsSync(BINARY) +const PIN = JSON.parse( + readFileSync( + fileURLToPath(new URL('../../../packages/runtime/src/shared/opencode-v2-pin.json', import.meta.url)), + 'utf8', + ), +) as {version: string} +const INSTALL_SCRIPT = fileURLToPath(new URL('../../../deploy/scripts/install-opencode-v2-plugins.sh', import.meta.url)) + +const GATEWAY_BEARER = 'integration-gateway-bearer' +const RUNNER_TIMEOUT_MS = 180_000 + +const lines: string[] = [] +const logger: Logger = { + info: message => lines.push(`info ${message}`), + warn: message => lines.push(`warn ${message}`), + error: message => lines.push(`error ${message}`), +} + +interface Sandbox { + readonly root: string + readonly home: string + readonly reposRoot: string + readonly repo: string + readonly dbBase: string +} + +let box: Sandbox +let provider: HeldProvider | undefined + +interface HeldProvider { + readonly baseURL: string + readonly requests: () => number + 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 + 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'}]})) + 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`, + ) + }) + 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, + stop: async () => { + server.closeAllConnections() + await new Promise(resolve => server.close(() => resolve())) + }, + } +} + +async function freePort(): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer() + server.once('error', reject) + server.listen(0, '127.0.0.1', () => { + const {port} = server.address() as AddressInfo + server.close(() => resolve(port)) + }) + }) +} + +function createSandbox(): Sandbox { + const root = mkdtempSync(path.join(os.tmpdir(), 'workspace-v2-it-')) + const home = path.join(root, 'home') + // The layout the image has (identity.ts), which the install script also uses. + for (const dir of ['.config/opencode', '.local/share', '.cache/tmp', '.local/state']) { + mkdirSync(path.join(home, dir), {recursive: true}) + } + const reposRoot = path.join(root, 'repos') + const repo = path.join(reposRoot, 'acme', 'widget') + mkdirSync(repo, {recursive: true}) + const git = (...args: string[]): void => { + execFileSync('git', args, { + cwd: repo, + env: { + ...process.env, + GIT_AUTHOR_NAME: 't', + GIT_AUTHOR_EMAIL: 't@x', + GIT_COMMITTER_NAME: 't', + GIT_COMMITTER_EMAIL: 't@x', + }, + }) + } + git('init', '-q', '-b', 'main') + writeFileSync(path.join(repo, 'a.txt'), 'hello\n') + git('add', '.') + git('commit', '-qm', 'init') + return {root, home, reposRoot, repo, dbBase: path.join(root, 'db')} +} + +/** Installs both plugins exactly as the image build does, into the sandbox home. */ +function installPlugins(sandbox: Sandbox): void { + const bin = path.join(sandbox.root, 'bin') + mkdirSync(bin) + // The image has busybox `timeout`; this machine may not. The bound itself is pinned by the script's own tests. + writeFileSync(path.join(bin, 'timeout'), '#!/bin/sh\nshift 3\nexec "$@"\n') + chmodSync(path.join(bin, 'timeout'), 0o755) + const result = spawnSync('/bin/sh', [INSTALL_SCRIPT, ...WORKSPACE_V2_PLUGIN_SPECS], { + encoding: 'utf8', + env: { + PATH: `${bin}:${process.env.PATH}`, + INSTALL_PATH: `${bin}:${process.env.PATH}`, + OPENCODE_BIN: BINARY as string, + AGENT_HOME: sandbox.home, + DROP_PRIVILEGES: '', + }, + 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). + writeFileSync( + path.join(sandbox.home, '.config', 'opencode', 'opencode.json'), + JSON.stringify({ + $schema: 'https://opencode.ai/config.json', + 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}}}, + }, + }, + model: 'fake/fixture-model', + }), + ) +} + +/** The sandbox environment for a server: home and XDG in the sandbox, and a registry that refuses connections. */ +function sandboxEnv(base: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const refused = 'http://127.0.0.1:9' + return { + ...base, + HOME: box.home, + XDG_CONFIG_HOME: path.join(box.home, '.config'), + XDG_DATA_HOME: path.join(box.home, '.local', 'share'), + XDG_CACHE_HOME: path.join(box.home, '.cache'), + XDG_STATE_HOME: path.join(box.home, '.local', 'state'), + TMPDIR: path.join(box.home, '.cache', 'tmp'), + HTTPS_PROXY: refused, + HTTP_PROXY: refused, + // 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 + readonly credentials: ReturnType + readonly proxyUrl: string + readonly upstreamUrl: string + readonly databasePaths: string[] + readonly startErrors: OpenCodeV2StartError[] + readonly stop: () => Promise +} + +/** 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'} + const credentials = createUpstreamCredentials() + const controller = new AbortController() + const databasePaths: string[] = [] + const startErrors: OpenCodeV2StartError[] = [] + const port = await freePort() + + const run = runSupervisedOpencodeV2({ + rootDir: box.reposRoot, + logger, + statusRef, + credentials, + signal: controller.signal, + binaryPath: BINARY, + port, + readyTimeoutMs: 90_000, + maxAttempts: 1, + parentEnv: {}, + deps: { + prepareDatabase: async () => { + const dbPath = await prepareFreshDatabase({ + baseDir: box.dbBase, + uid: process.getuid?.() ?? 0, + gid: process.getgid?.() ?? 0, + }) + databasePaths.push(dbPath) + return dbPath + }, + // Everything else is the real thing; only the identity and the home are the sandbox's, because this process is + // not root and the real home is not ours to touch. + startServer: async (options: StartOpenCodeV2ServerOptions, log) => { + // Drop the agent identity (not ours to take), keep everything else. + const sandboxed: StartOpenCodeV2ServerOptions = {...options, env: sandboxEnv(options.env ?? {})} + delete (sandboxed as {uid?: number}).uid + delete (sandboxed as {gid?: number}).gid + const started = await startOpenCodeV2Server(sandboxed, log) + if (!started.success) startErrors.push(started.error) + return started + }, + }, + }) + + const proxy = createOpencodeProxyV2({ + token: GATEWAY_BEARER, + upstreamUrl: `http://127.0.0.1:${port}`, + logger, + credentials, + }) + await proxy.listen(0, '127.0.0.1') + const proxyUrl = `http://127.0.0.1:${(proxy.server.address() as AddressInfo).port}` + + await vi.waitFor( + () => { + if (statusRef.status === 'degraded') throw new Error(`supervisor degraded: ${JSON.stringify(startErrors)}`) + expect(statusRef.status).toBe('ready') + }, + {timeout: 120_000, interval: 250}, + ) + + return { + statusRef, + credentials, + proxyUrl, + upstreamUrl: `http://127.0.0.1:${port}`, + databasePaths, + startErrors, + stop: async () => { + controller.abort() + await run + proxy.server.closeAllConnections() + await proxy.close() + }, + } +} + +async function viaProxy(workspace: RunningWorkspace, pathname: string, init: RequestInit = {}): Promise { + return fetch(`${workspace.proxyUrl}${pathname}`, { + ...init, + headers: { + authorization: `Bearer ${GATEWAY_BEARER}`, + 'x-opencode-directory': encodeURIComponent(box.repo), + 'content-type': 'application/json', + ...init.headers, + }, + }) +} + +interface SessionRow { + readonly time_suspended: number | null +} + +function readClaim(dbPath: string, id: string): number | null | undefined { + const db = new DatabaseSync(dbPath) + try { + const row = db.prepare('SELECT time_suspended FROM session_v2 WHERE id = ?').get(id) as SessionRow | undefined + return row === undefined ? undefined : row.time_suspended + } finally { + db.close() + } +} + +describe.skipIf(!hasBinary)('the workspace V2 runner against the real stock binary', () => { + beforeAll(async () => { + const reported = execFileSync(BINARY as string, ['--version'], {encoding: 'utf8'}).trim() + if (!reported.includes(PIN.version)) { + throw new Error(`FRO_BOT_OPENCODE_V2_BIN reports "${reported}", not the pinned ${PIN.version}`) + } + box = createSandbox() + provider = await startHeldProvider() + installPlugins(box) + }, 300_000) + + afterAll(async () => { + await provider?.stop() + if (box !== undefined) rmSync(box.root, {recursive: true, force: true}) + }) + + it( + 'loads both cached plugins with no registry, passes both gates, and serves through the bearer proxy', + {timeout: RUNNER_TIMEOUT_MS}, + async () => { + // #given the plugins installed by the image's script and a registry that refuses connections + const workspace = await startWorkspace() + try { + // #then the supervisor only reports ready after the plugin gate and the permission gate passed + expect(workspace.statusRef.status).toBe('ready') + expect(workspace.startErrors).toEqual([]) + expect(lines).not.toContain('error opencode-server-v2: permission gate failed') + + // #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) + const listPlugins = async (): Promise => { + const response = await viaProxy(workspace, '/api/plugin', { + headers: {'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 ?? '') + } + expect(await listPlugins()).toEqual(expect.arrayContaining([SYSTEMATIC_V2_PLUGIN_ID, FRO_BOT_PLUGIN_ID])) + expect(WORKSPACE_V2_PLUGIN_SPECS).toEqual([ + `@fro.bot/systematic@${SYSTEMATIC_V2_VERSION}`, + `@fro.bot/opencode-plugin@${FRO_BOT_PLUGIN_VERSION}`, + ]) + + // #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 SSE stream opens through the proxy with the server.connected frame first + const stream = await viaProxy(workspace, '/api/event', {headers: {accept: 'text/event-stream'}}) + expect(stream.headers.get('content-type')).toContain('text/event-stream') + expect(stream.headers.get('content-encoding')).toBeNull() + const reader = stream.body?.getReader() + const first = await reader?.read() + expect(new TextDecoder().decode(first?.value as Uint8Array | undefined)).toMatch(/heartbeat|server\.connected/) + await reader?.cancel() + + // #and the evict endpoint accepts the narrow location call with the live credential + const dispose = createInstanceDisposerV2({ + upstreamUrl: workspace.upstreamUrl, + credentials: workspace.credentials, + }) + expect(await dispose(box.repo)).toEqual({kind: 'disposed'}) + + // #and the database is the fresh one the supervisor made + expect(workspace.databasePaths).toHaveLength(1) + expect(existsSync(workspace.databasePaths[0] as string)).toBe(true) + } finally { + await workspace.stop() + } + }, + ) + + it( + 'starts the next spawn on an empty database, and leaves no claim after a shutdown with a turn in flight', + {timeout: RUNNER_TIMEOUT_MS}, + async () => { + // #given a first spawn with a session and a turn held at the provider + const first = await startWorkspace() + const firstDb = first.databasePaths[0] as string + let sessionId: string + try { + const created = (await ( + await viaProxy(first, '/api/session', { + method: 'POST', + body: JSON.stringify({location: {directory: box.repo}}), + }) + ).json()) as {data: {id: string}} + sessionId = created.data.id + const prompt = await viaProxy(first, `/api/session/${sessionId}/prompt`, { + method: 'POST', + 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(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 + await first.stop() + } + + // #then the saved database holds no execution claim, so nothing is left for a later server to resume + expect(readClaim(firstDb, sessionId)).toBeNull() + + // #when the next spawn starts + const second = await startWorkspace() + try { + // #then it is on a different database, and the first spawn's session is not there + const secondDb = second.databasePaths[0] as string + expect(secondDb).not.toBe(firstDb) + expect(existsSync(firstDb)).toBe(false) + const old = await viaProxy(second, `/api/session/${sessionId}`) + expect(old.status).toBe(404) + } finally { + await second.stop() + } + }, + ) +}) diff --git a/apps/workspace-agent/src/opencode-server-v2.test.ts b/apps/workspace-agent/src/opencode-server-v2.test.ts new file mode 100644 index 0000000000..b29a3abc4b --- /dev/null +++ b/apps/workspace-agent/src/opencode-server-v2.test.ts @@ -0,0 +1,645 @@ +import type { + OpenCodeV2ServerHandle, + OpenCodeV2StartError, + StartOpenCodeV2ServerOptions, +} from '@fro-bot/runtime/workspace-v2' +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 os from 'node:os' +import path from 'node:path' +import process from 'node:process' + +import {FRO_BOT_PLUGIN_ID, SYSTEMATIC_V2_PLUGIN_ID} from '@fro-bot/runtime/workspace-v2' +import {afterEach, describe, expect, it, vi} from 'vitest' + +import { + buildOpencodeV2Env, + createUpstreamCredentials, + prepareFreshDatabase, + runSupervisedOpencodeV2, +} from './opencode-server-v2.js' + +type StartResult = Awaited['startServer']>>> + +interface CapturingLogger { + readonly logger: Logger + readonly serialized: () => string +} + +function createCapturingLogger(): CapturingLogger { + 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), + } +} + +interface FakeHandle { + readonly handle: OpenCodeV2ServerHandle + readonly shutdown: ReturnType + readonly exit: (code?: number) => void + readonly fetchPaths: string[] +} + +function createHandle(options: { + authorization: string + activeSessions?: readonly string[] | 'fail' + agentsBody?: unknown +}): 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 => { + resolveExit = resolve + }) + const fetchPaths: string[] = [] + const shutdown = vi.fn(async (_options?: unknown) => { + resolveExit({code: 0, signal: null}) + return { + interrupts: {stopped: [], notRunning: [], unconfirmed: []}, + exit: {outcome: 'graceful' as const, code: 0, signal: null}, + } + }) + const agentsBody = options.agentsBody ?? { + data: [{id: 'build', permissions: [{action: 'external_directory', resource: '*', effect: 'ask'}]}], + } + const handle: OpenCodeV2ServerHandle = { + url: 'http://127.0.0.1:54321', + pid: 4242, + directory: '/workspace/repos', + fetch: async pathname => { + fetchPaths.push(pathname) + if (pathname === '/api/session/active') { + if (options.activeSessions === 'fail') throw new Error('unreachable') + const active = options.activeSessions ?? [] + return new Response(JSON.stringify({data: Object.fromEntries(active.map(id => [id, {type: 'running'}]))}), { + status: 200, + }) + } + if (pathname === '/api/agent') return new Response(JSON.stringify(agentsBody), {status: 200}) + return new Response('{}', {status: 404}) + }, + authorizationHeader: () => options.authorization, + exited, + registerOwnedSession: () => undefined, + shutdown, + } + return { + handle, + shutdown, + exit: (code = 1) => resolveExit({code, signal: null}), + fetchPaths, + } +} + +type Mutable = {-readonly [K in keyof T]: T[K]} + +const OK_GATE = async () => ({success: true as const, data: {checkedAgents: ['build']}}) + +function scriptedStart( + results: readonly (FakeHandle | OpenCodeV2StartError)[], + startOptions: StartOpenCodeV2ServerOptions[], +): NonNullable['startServer']> { + let call = 0 + return async (startOpts): Promise => { + startOptions.push(startOpts) + const next = results[Math.min(call, results.length - 1)] + call += 1 + if (next === undefined) throw new Error('no scripted result') + if ('handle' in next) return {success: true, data: next.handle} + return {success: false, error: next} + } +} + +describe('buildOpencodeV2Env', () => { + it('constructs the child environment from an allowlist, never from the parent', () => { + // #given a service environment full of things the agent must never see + const parent: NodeJS.ProcessEnv = { + WORKSPACE_OPENCODE_TOKEN: 'bearer-secret', + OPENCODE_PASSWORD: 'stale-password', + OPENCODE_SERVER_PASSWORD: 'server-secret', + OPENCODE_CONFIG_CONTENT: '{"leak":true}', + GITHUB_TOKEN: 'ghs_secret', + AWS_SECRET_ACCESS_KEY: 'aws-secret', + NODE_OPTIONS: '--require /evil.js', + SSH_AUTH_SOCK: '/tmp/agent.sock', + HTTPS_PROXY: 'http://mitmproxy:8080', + NO_PROXY: 'workspace', + NODE_EXTRA_CA_CERTS: '/etc/ssl/certs/ca-certificates.crt', + LANG: 'en_US.UTF-8', + } + + // #when + const env = buildOpencodeV2Env(parent, '/var/lib/workspace-agent/opencode-v2-db/spawn-a/opencode.db') + + // #then only the named operational values arrive + expect(env.HTTPS_PROXY).toBe('http://mitmproxy:8080') + expect(env.NO_PROXY).toBe('workspace') + expect(env.NODE_EXTRA_CA_CERTS).toBe('/etc/ssl/certs/ca-certificates.crt') + expect(env.LANG).toBe('en_US.UTF-8') + for (const name of [ + 'WORKSPACE_OPENCODE_TOKEN', + 'OPENCODE_PASSWORD', + 'OPENCODE_SERVER_PASSWORD', + 'OPENCODE_CONFIG_CONTENT', + 'GITHUB_TOKEN', + 'AWS_SECRET_ACCESS_KEY', + 'NODE_OPTIONS', + 'SSH_AUTH_SOCK', + ]) { + expect(env).not.toHaveProperty(name) + } + expect(env.PATH).toBe('/usr/local/bin:/usr/bin:/bin') + expect(env.HOME).toBe('/home/opencode') + }) + + it('points the database at the fresh path and uses the V2 file-watcher flag, not the V1 spelling', () => { + // #when + const env = buildOpencodeV2Env({}, '/db/spawn-a/opencode.db') + + // #then + expect(env.OPENCODE_DB).toBe('/db/spawn-a/opencode.db') + expect(env.OPENCODE_DISABLE_FILEWATCHER).toBe('true') + expect(env).not.toHaveProperty('OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER') + expect(env).not.toHaveProperty('OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS') + }) + + it('lets the deployer override the file-watcher flag, as V1 images allowed', () => { + // #when + const env = buildOpencodeV2Env({OPENCODE_DISABLE_FILEWATCHER: 'false'}, '/db/opencode.db') + + // #then + expect(env.OPENCODE_DISABLE_FILEWATCHER).toBe('false') + }) +}) + +describe('prepareFreshDatabase', () => { + const roots: string[] = [] + afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, {recursive: true, force: true}) + }) + + function baseDir(): string { + const root = mkdtempSync(path.join(os.tmpdir(), 'ws-v2-db-')) + roots.push(root) + return path.join(root, 'opencode-v2-db') + } + + it('hands each spawn its own empty directory and removes what the previous spawn left', async () => { + // #given a previous spawn that left a database behind + const dir = baseDir() + const uid = process.getuid?.() ?? 0 + const gid = process.getgid?.() ?? 0 + const first = await prepareFreshDatabase({baseDir: dir, uid, gid}) + writeFileSync(first, 'sessions from the first spawn') + writeFileSync(`${first}-wal`, 'wal') + + // #when the next spawn is prepared + const second = await prepareFreshDatabase({baseDir: dir, uid, gid}) + + // #then nothing persists: a new path, an empty directory, and the old files are gone + expect(second).not.toBe(first) + expect(path.basename(second)).toBe('opencode.db') + expect(readdirSync(path.dirname(second))).toEqual([]) + expect(readdirSync(dir)).toEqual([path.basename(path.dirname(second))]) + }) + + it('makes the directory owned by the agent identity and unreadable by others', async () => { + // #given + const dir = baseDir() + const uid = process.getuid?.() ?? 0 + const gid = process.getgid?.() ?? 0 + + // #when + const dbPath = await prepareFreshDatabase({baseDir: dir, uid, gid}) + + // #then the per-spawn directory is 0700 for the agent, and the root-owned parent can only be traversed + const spawnStat = statSync(path.dirname(dbPath)) + expect(spawnStat.uid).toBe(uid) + expect(spawnStat.mode & 0o777).toBe(0o700) + expect(statSync(dir).mode & 0o777).toBe(0o711) + }) + + it('passes the agent uid and gid to chown', async () => { + // #given an injected chown, because the test process may not own files as uid 10001 + const dir = baseDir() + const chown = vi.fn(async () => undefined) + + // #when + await prepareFreshDatabase({baseDir: dir, uid: 10_001, gid: 10_001, chown}) + + // #then + expect(chown).toHaveBeenCalledTimes(1) + expect(chown).toHaveBeenCalledWith(expect.stringContaining(dir), 10_001, 10_001) + }) +}) + +describe('createUpstreamCredentials', () => { + it('starts empty, serves the current value per call, and clears', () => { + // #given + const credentials = createUpstreamCredentials() + expect(credentials.authorization()).toBeUndefined() + + // #when + let current = 'Basic first' + credentials.set(() => current) + expect(credentials.authorization()).toBe('Basic first') + current = 'Basic second' + + // #then the holder reads the provider each time, so a rotated value is picked up + expect(credentials.authorization()).toBe('Basic second') + credentials.clear() + expect(credentials.authorization()).toBeUndefined() + }) + + it('does not expose the credential when the holder is serialised or logged', () => { + // #given a holder with a credential + const credentials = createUpstreamCredentials() + credentials.set(() => 'Basic c2VjcmV0') + + // #then + expect(JSON.stringify(credentials)).not.toContain('c2VjcmV0') + expect(String(JSON.stringify({credentials}))).not.toContain('c2VjcmV0') + }) +}) + +describe('runSupervisedOpencodeV2', () => { + const NO_BACKOFF = {initialBackoffMs: 0, maxBackoffMs: 0} + + function setup(overrides: Partial = {}) { + const capture = createCapturingLogger() + const credentials = createUpstreamCredentials() + const statusRef: SupervisedStatusRef = {status: 'starting'} + const startOptions: StartOpenCodeV2ServerOptions[] = [] + const dbPaths: string[] = [] + let dbCounter = 0 + const options: Mutable = { + rootDir: '/workspace/repos', + logger: capture.logger, + statusRef, + credentials, + parentEnv: {HTTPS_PROXY: 'http://mitmproxy:8080'}, + ...NO_BACKOFF, + maxAttempts: 3, + ...overrides, + deps: { + prepareDatabase: async () => { + dbCounter += 1 + const dbPath = `/db/spawn-${dbCounter}/opencode.db` + dbPaths.push(dbPath) + return dbPath + }, + verifyRules: OK_GATE, + ...overrides.deps, + }, + } + return {options, capture, credentials, statusRef, startOptions, dbPaths} + } + + it('spawns as the agent identity with V2 config, both plugin ids expected, and a fresh database', async () => { + // #given a server that starts + const fake = createHandle({authorization: 'Basic one'}) + const {options, startOptions, dbPaths, statusRef} = setup() + const controller = new AbortController() + options.deps = {...options.deps, startServer: scriptedStart([fake], startOptions)} + + // #when the supervisor runs until it is stopped + const done = runSupervisedOpencodeV2({...options, signal: controller.signal}) + await vi.waitFor(() => expect(statusRef.status).toBe('ready')) + + // #then the spawn carries everything the plan requires + const [start] = startOptions + expect(start?.uid).toBe(10_001) + expect(start?.gid).toBe(10_001) + expect(start?.binaryPath).toBe('/usr/local/bin/opencode') + expect(start?.directory).toBe('/workspace/repos') + expect(start?.port).toBe(54_321) + expect(start?.expectedPluginIds).toEqual([SYSTEMATIC_V2_PLUGIN_ID, FRO_BOT_PLUGIN_ID]) + expect(start?.config?.plugins).toEqual(['@fro.bot/systematic@3.27.0', '@fro.bot/opencode-plugin@0.1.0']) + expect(start?.config?.permissions).toEqual( + expect.arrayContaining([{action: 'external_directory', resource: '*', effect: 'ask'}]), + ) + expect(start?.env?.OPENCODE_DB).toBe(dbPaths[0]) + expect(start?.env?.HTTPS_PROXY).toBe('http://mitmproxy:8080') + + controller.abort() + await done + }) + + it('stores the credential before readiness flips to ready', async () => { + // #given a status ref that records what the proxy would see at the moment of each transition + const fake = createHandle({authorization: 'Basic one'}) + const {options, startOptions, credentials} = setup() + 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, startServer: scriptedStart([fake], startOptions)} + + // #when + const done = runSupervisedOpencodeV2({...options, statusRef, signal: controller.signal}) + await vi.waitFor(() => expect(status).toBe('ready')) + + // #then a request admitted the instant /readyz says ready already has the credential + expect(seenAtReady).toEqual(['Basic one']) + controller.abort() + await done + }) + + it('runs the permission gate against the effective rules before it reports ready', async () => { + // #given a gate that refuses + const fake = createHandle({authorization: 'Basic one'}) + const verifyRules = vi.fn(async () => ({ + success: false as const, + error: {kind: 'rules-missing' as const, message: 'external_directory is allow', violations: []}, + })) + const {options, startOptions, statusRef, credentials} = setup({maxAttempts: 1}) + options.deps = {...options.deps, startServer: scriptedStart([fake], startOptions), verifyRules} + + // #when + await runSupervisedOpencodeV2(options) + + // #then the server is stopped, never ready, and no credential was published for the proxy + expect(verifyRules).toHaveBeenCalledTimes(1) + expect(fake.shutdown).toHaveBeenCalledTimes(1) + expect(statusRef.status).toBe('degraded') + expect(credentials.authorization()).toBeUndefined() + }) + + it('reads the agent list from the spawned server', async () => { + // #given a gate that calls the list source it is given + const fake = createHandle({authorization: 'Basic one'}) + const verifyRules = vi.fn(async (source: {listAgents: () => Promise}) => { + await source.listAgents() + return {success: true as const, data: {checkedAgents: ['build']}} + }) + const {options, startOptions, statusRef} = setup() + const controller = new AbortController() + options.deps = {...options.deps, startServer: scriptedStart([fake], startOptions), verifyRules} + + // #when + const done = runSupervisedOpencodeV2({...options, signal: controller.signal}) + await vi.waitFor(() => expect(statusRef.status).toBe('ready')) + + // #then + expect(fake.fetchPaths).toContain('/api/agent') + controller.abort() + await done + }) + + it('respawns after an unexpected exit with a new credential and an empty database', async () => { + // #given two servers with different passwords + const first = createHandle({authorization: 'Basic first'}) + const second = createHandle({authorization: 'Basic second'}) + const {options, startOptions, dbPaths, credentials, statusRef} = setup() + const controller = new AbortController() + options.deps = {...options.deps, startServer: scriptedStart([first, second], startOptions)} + + const done = runSupervisedOpencodeV2({...options, signal: controller.signal}) + await vi.waitFor(() => expect(credentials.authorization()).toBe('Basic first')) + await vi.waitFor(() => expect(statusRef.status).toBe('ready')) + + // #when the first server dies + first.exit(1) + + // #then readiness drops and the stale credential goes with it, then the new server's credential arrives + await vi.waitFor(() => expect(credentials.authorization()).toBe('Basic second')) + await vi.waitFor(() => expect(statusRef.status).toBe('ready')) + expect(startOptions).toHaveLength(2) + expect(dbPaths).toHaveLength(2) + expect(new Set(dbPaths).size).toBe(2) + expect(startOptions[1]?.env?.OPENCODE_DB).toBe(dbPaths[1]) + expect(startOptions[0]?.env?.OPENCODE_DB).not.toBe(startOptions[1]?.env?.OPENCODE_DB) + + controller.abort() + await done + }) + + it('fails closed between a server dying and the next one being ready', async () => { + // #given a backoff before the respawn, and a second start that never completes + const first = createHandle({authorization: 'Basic first'}) + const {options, startOptions, credentials, statusRef} = setup({initialBackoffMs: 200, maxBackoffMs: 200}) + const controller = new AbortController() + const never = new Promise(() => undefined) + let call = 0 + options.deps = { + ...options.deps, + startServer: async startOpts => { + startOptions.push(startOpts) + call += 1 + return call === 1 ? {success: true, data: first.handle} : never + }, + } + const done = runSupervisedOpencodeV2({...options, signal: controller.signal}) + await vi.waitFor(() => expect(statusRef.status).toBe('ready')) + // The first server ran past the stable window, so the respawn is a normal one with a backoff. + expect(credentials.authorization()).toBe('Basic first') + + // #when + first.exit(1) + + // #then during the backoff, before any new spawn exists, no request can be forwarded with the dead password + await vi.waitFor(() => expect(statusRef.status).toBe('starting')) + expect(startOptions).toHaveLength(1) + expect(credentials.authorization()).toBeUndefined() + controller.abort() + done.catch(() => undefined) + }) + + it('goes degraded when every attempt fails to start, with the failure kind logged and no credential ever set', async () => { + // #given + const failure: OpenCodeV2StartError = {kind: 'plugin-gate', message: 'Required OpenCode plugin(s) not active: x'} + const {options, startOptions, statusRef, credentials, capture} = setup() + options.deps = {...options.deps, startServer: scriptedStart([failure], startOptions)} + + // #when + await runSupervisedOpencodeV2(options) + + // #then every attempt used a fresh database, and the supervisor stopped trying + expect(startOptions).toHaveLength(3) + expect(statusRef.status).toBe('degraded') + expect(credentials.authorization()).toBeUndefined() + expect(capture.serialized()).toContain('plugin-gate') + }) + + it('does not spawn at all when the config cannot be built', async () => { + // #given an exception directory with a wildcard, which the config builder refuses + const {options, startOptions, statusRef} = setup({allowedExternalDirectories: ['/srv/*']}) + options.deps = { + ...options.deps, + startServer: scriptedStart([createHandle({authorization: 'Basic x'})], startOptions), + } + + // #when + await runSupervisedOpencodeV2(options) + + // #then + expect(startOptions).toHaveLength(0) + expect(statusRef.status).toBe('degraded') + }) + + it('treats a database that cannot be prepared as a failed attempt', async () => { + // #given + const {options, startOptions, statusRef} = setup({maxAttempts: 2}) + options.deps = { + ...options.deps, + prepareDatabase: async () => { + throw new Error('EACCES') + }, + startServer: scriptedStart([createHandle({authorization: 'Basic x'})], startOptions), + } + + // #when + await runSupervisedOpencodeV2(options) + + // #then no server is ever started on a database the supervisor did not create + expect(startOptions).toHaveLength(0) + expect(statusRef.status).toBe('degraded') + }) + + it('interrupts the sessions that are active, then shuts the server down, on abort', async () => { + // #given a ready server with two running sessions + const fake = createHandle({authorization: 'Basic one', activeSessions: ['ses_a', 'ses_b']}) + const {options, startOptions, statusRef, credentials} = setup() + const controller = new AbortController() + options.deps = {...options.deps, startServer: scriptedStart([fake], startOptions)} + const done = runSupervisedOpencodeV2({...options, signal: controller.signal}) + await vi.waitFor(() => expect(statusRef.status).toBe('ready')) + + // #when the service aborts + controller.abort() + await done + + // #then every active session was handed to the shutdown for a user-reason interrupt, and the credential is gone + expect(fake.shutdown).toHaveBeenCalledTimes(1) + expect(fake.shutdown).toHaveBeenCalledWith({ownedSessionIds: ['ses_a', 'ses_b']}) + expect(credentials.authorization()).toBeUndefined() + }) + + it('does not abort the signal it gave the spawn once the server is running, so its own shutdown cannot pre-empt the interrupts', async () => { + // #given a ready server. The runtime handle shuts itself down, with no sessions to interrupt, the moment the + // signal it was started with aborts; that first shutdown is the one every later call returns. + const fake = createHandle({authorization: 'Basic one', activeSessions: ['ses_a']}) + const {options, startOptions, statusRef} = setup() + const controller = new AbortController() + options.deps = {...options.deps, startServer: scriptedStart([fake], startOptions)} + const done = runSupervisedOpencodeV2({...options, signal: controller.signal}) + await vi.waitFor(() => expect(statusRef.status).toBe('ready')) + + // #when the service aborts + controller.abort() + await done + + // #then the spawn's signal was left alone, and the interrupts went through the supervisor's own shutdown call + expect(startOptions[0]?.signal?.aborted).toBe(false) + expect(fake.shutdown).toHaveBeenCalledWith({ownedSessionIds: ['ses_a']}) + }) + + it('still shuts down when the active sessions cannot be listed', async () => { + // #given a server that does not answer the active list + const fake = createHandle({authorization: 'Basic one', activeSessions: 'fail'}) + const {options, startOptions, statusRef} = setup() + const controller = new AbortController() + options.deps = {...options.deps, startServer: scriptedStart([fake], startOptions)} + const done = runSupervisedOpencodeV2({...options, signal: controller.signal}) + await vi.waitFor(() => expect(statusRef.status).toBe('ready')) + + // #when + controller.abort() + await done + + // #then the process is still stopped, with nothing to interrupt + expect(fake.shutdown).toHaveBeenCalledWith({ownedSessionIds: []}) + }) + + it('stops a start that is still in flight when the service aborts', async () => { + // #given a start that waits for the signal it was given + const {options, startOptions} = setup() + const controller = new AbortController() + options.deps = { + ...options.deps, + startServer: async startOpts => { + startOptions.push(startOpts) + await new Promise(resolve => startOpts.signal?.addEventListener('abort', () => resolve(), {once: true})) + return {success: false, error: {kind: 'aborted', message: 'cancelled'}} + }, + } + const done = runSupervisedOpencodeV2({...options, signal: controller.signal}) + await vi.waitFor(() => expect(startOptions).toHaveLength(1)) + + // #when + controller.abort() + await done + + // #then the supervisor did not retry + expect(startOptions).toHaveLength(1) + }) + + it('does not start when the signal is already aborted', async () => { + // #given + const {options, startOptions} = setup() + const controller = new AbortController() + controller.abort() + options.deps = { + ...options.deps, + startServer: scriptedStart([createHandle({authorization: 'Basic x'})], startOptions), + } + + // #when + await runSupervisedOpencodeV2({...options, signal: controller.signal}) + + // #then + expect(startOptions).toHaveLength(0) + }) + + it('restores the respawn budget after a stable run', async () => { + // #given servers that each ran past the stable window before dying, and a budget of two attempts + const handles = [1, 2, 3].map(n => createHandle({authorization: `Basic ${n}`})) + const {options, startOptions, statusRef} = setup({maxAttempts: 2, stableReadyResetMs: 0}) + const controller = new AbortController() + options.deps = {...options.deps, startServer: scriptedStart(handles, startOptions)} + const done = runSupervisedOpencodeV2({...options, signal: controller.signal}) + + // #when each server dies in turn + await vi.waitFor(() => expect(startOptions).toHaveLength(1)) + handles[0]?.exit() + await vi.waitFor(() => expect(startOptions).toHaveLength(2)) + handles[1]?.exit() + + // #then a third spawn happens: the budget of two was reset by the stable runs + await vi.waitFor(() => expect(startOptions).toHaveLength(3)) + await vi.waitFor(() => expect(statusRef.status).toBe('ready')) + controller.abort() + await done + }) + + it('never writes the server credential to a log line', async () => { + // #given a credential that would be recognisable + const fake = createHandle({authorization: 'Basic TOPSECRETCREDENTIAL'}) + const {options, startOptions, statusRef, capture} = setup() + const controller = new AbortController() + options.deps = {...options.deps, startServer: scriptedStart([fake], startOptions)} + const done = runSupervisedOpencodeV2({...options, signal: controller.signal}) + await vi.waitFor(() => expect(statusRef.status).toBe('ready')) + + // #when the whole lifecycle ran + controller.abort() + await done + + // #then + expect(capture.serialized()).not.toContain('TOPSECRETCREDENTIAL') + }) +}) diff --git a/apps/workspace-agent/src/opencode-server-v2.ts b/apps/workspace-agent/src/opencode-server-v2.ts new file mode 100644 index 0000000000..1f08b2d2e6 --- /dev/null +++ b/apps/workspace-agent/src/opencode-server-v2.ts @@ -0,0 +1,480 @@ +/** + * OpenCode V2 server lifecycle for the workspace-agent: the sibling of `opencode-server.ts`, selected by `main.ts` + * when the image carries a stock OpenCode with major version 2 or higher. + * + * Spawn, password, readiness, plugin gate and shutdown come from `@fro-bot/runtime/workspace-v2` + * (`startOpenCodeV2Server`). This module adds what only the workspace needs: + * + * - the child runs as the unprivileged agent identity with an environment built from an allowlist; + * - every spawn gets a fresh database directory, so nothing a session wrote survives a respawn and V2 has no + * 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; + * - on shutdown every session still active is interrupted (reason `user`) before stdin closes. + * + * SECURITY INVARIANTS: + * 1. The server binds 127.0.0.1 only. The :9200 bearer proxy is the sole externally reachable surface. + * 2. The server password lives inside the runtime handle and the holder's provider function. It is never logged, + * never put in the environment of anything but the spawn, and never serialised with the holder. + * 3. A failed start, a failed gate or an unexpected exit clears the credential at once, so the proxy fails closed + * instead of forwarding with a password the next server does not hold. + * 4. Bounded respawn: max attempts, with a stable-run reset, as in the V1 supervisor. + */ + +import type {OpenCodeV2ServerHandle, Logger as RuntimeLogger} from '@fro-bot/runtime/workspace-v2' +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' +import {setTimeout as sleep} from 'node:timers/promises' + +import { + buildWorkspaceConfigV2, + FRO_BOT_PLUGIN_ID, + FRO_BOT_PLUGIN_PACKAGE, + FRO_BOT_PLUGIN_VERSION, + startOpenCodeV2Server, + SYSTEMATIC_PACKAGE, + SYSTEMATIC_V2_PLUGIN_ID, + SYSTEMATIC_V2_VERSION, + verifyPermissionRules, +} from '@fro-bot/runtime/workspace-v2' + +import { + AGENT_GID, + AGENT_HOME, + AGENT_TMPDIR, + AGENT_UID, + AGENT_USERNAME, + AGENT_XDG_CACHE_HOME, + AGENT_XDG_CONFIG_HOME, + AGENT_XDG_DATA_HOME, + AGENT_XDG_STATE_HOME, + OPENCODE_EXECUTABLE_PATH, +} from './identity.js' + +/** + * Where per-spawn database directories live. Root-owned and never agent-writable: the service creates it, so a + * path the agent can influence is never the target of a root-privileged `rm` or `chown`. The parent + * `/var/lib/workspace-agent` is created by deploy/workspace.Dockerfile next to the service's own home. + */ +export const WORKSPACE_OPENCODE_V2_DB_DIR = '/var/lib/workspace-agent/opencode-v2-db' + +/** The file name inside a spawn directory (probe P15: V2 names it `opencode.db` on the release channel). */ +const DATABASE_FILE_NAME = 'opencode.db' + +/** The plugin specs the image installs at build time, in the order the config lists them. */ +export const WORKSPACE_V2_PLUGIN_SPECS: readonly string[] = [ + `${SYSTEMATIC_PACKAGE}@${SYSTEMATIC_V2_VERSION}`, + `${FRO_BOT_PLUGIN_PACKAGE}@${FRO_BOT_PLUGIN_VERSION}`, +] + +/** The ids `GET /api/plugin` must list active after spawn (probe P13: matched by id, never by presence). */ +export const WORKSPACE_V2_EXPECTED_PLUGIN_IDS: readonly string[] = [SYSTEMATIC_V2_PLUGIN_ID, FRO_BOT_PLUGIN_ID] + +const OPENCODE_PORT = 54_321 + +// ── Child environment ────────────────────────────────────────────────────────── + +const PROXY_ENV_NAMES = ['HTTPS_PROXY', 'HTTP_PROXY', 'NO_PROXY', 'https_proxy', 'http_proxy', 'no_proxy'] as const +const CA_BUNDLE_ENV_NAMES = ['NODE_EXTRA_CA_CERTS', 'SSL_CERT_FILE', 'GIT_SSL_CAINFO'] as const +const LOCALE_ENV_NAMES = ['LANG', 'LANGUAGE', 'LC_ALL', 'LC_CTYPE'] as const + +/** Trusted PATH for the child and its tools: the locations deploy/workspace.Dockerfile installs binaries at. */ +const TRUSTED_OPENCODE_PATH = '/usr/local/bin:/usr/bin:/bin' + +function copyEnvIfSet(source: NodeJS.ProcessEnv, target: Record, name: string): void { + const value = source[name] + if (value !== undefined) target[name] = value +} + +/** + * Builds the V2 child environment from an allowlist, never by subtracting from `parentEnv`. The runtime spawn then + * adds the password, the config content and the project-config and auto-update switches itself. + * + * `OPENCODE_DISABLE_FILEWATCHER` is the V2 spelling of the V1 flag the image bakes; nothing consumes file-change + * events in this container, so it defaults on, and a deployer may still override it. V2 has no environment flag for + * background subagents (they are native), so no V1 flag is carried over. + */ +export function buildOpencodeV2Env(parentEnv: NodeJS.ProcessEnv, databasePath: string): NodeJS.ProcessEnv { + const env: Record = { + HOME: AGENT_HOME, + USER: AGENT_USERNAME, + LOGNAME: AGENT_USERNAME, + PATH: TRUSTED_OPENCODE_PATH, + XDG_DATA_HOME: AGENT_XDG_DATA_HOME, + XDG_CONFIG_HOME: AGENT_XDG_CONFIG_HOME, + XDG_CACHE_HOME: AGENT_XDG_CACHE_HOME, + XDG_STATE_HOME: AGENT_XDG_STATE_HOME, + TMPDIR: AGENT_TMPDIR, + OPENCODE_DB: databasePath, + OPENCODE_DISABLE_FILEWATCHER: parentEnv.OPENCODE_DISABLE_FILEWATCHER ?? 'true', + } + + for (const name of PROXY_ENV_NAMES) copyEnvIfSet(parentEnv, env, name) + for (const name of CA_BUNDLE_ENV_NAMES) copyEnvIfSet(parentEnv, env, name) + for (const name of LOCALE_ENV_NAMES) copyEnvIfSet(parentEnv, env, name) + + return env +} + +// ── Fresh database per spawn ─────────────────────────────────────────────────── + +export interface PrepareFreshDatabaseOptions { + /** Defaults to `WORKSPACE_OPENCODE_V2_DB_DIR`. */ + readonly baseDir?: string + /** Defaults to the agent uid (identity.ts). */ + readonly uid?: number + /** Defaults to the agent gid (identity.ts). */ + readonly gid?: number + /** Test seam: the real `chown` needs privilege the test process does not have for uid 10001. */ + readonly chown?: (target: string, uid: number, gid: number) => Promise +} + +/** + * Gives the next spawn an empty database directory and returns the database path inside it. + * + * Everything under `baseDir` is removed first, so no spawn inherits what an earlier one wrote. The base directory + * is recreated root-owned and mode 0711: the agent can traverse it to reach its own directory but can neither list + * nor modify it. The per-spawn directory is created by `mkdtemp` under that root-owned parent, so the path the + * `chown` acts on cannot be swapped for a symlink by the agent. + */ +export async function prepareFreshDatabase(options: PrepareFreshDatabaseOptions = {}): Promise { + const {baseDir = WORKSPACE_OPENCODE_V2_DB_DIR, uid = AGENT_UID, gid = AGENT_GID, chown = nodeChown} = options + await rm(baseDir, {recursive: true, force: true}) + await mkdir(baseDir, {recursive: true, mode: 0o711}) + // `mkdir` applies the umask; the mode the agent needs is exact. + await chmod(baseDir, 0o711) + const spawnDir = await mkdtemp(path.join(baseDir, 'spawn-')) + await chown(spawnDir, uid, gid) + await chmod(spawnDir, 0o700) + return path.join(spawnDir, DATABASE_FILE_NAME) +} + +// ── Credential holder ────────────────────────────────────────────────────────── + +/** + * The Basic credential of the server that is currently ready, read by the proxy on every request. It stores a + * provider function, not a string, so serialising the holder (or a log line that includes it) cannot leak it, and + * a rotated value is picked up without re-wiring the proxy. + */ +export interface UpstreamCredentials { + /** The `Authorization` header value to inject, or undefined while no server is ready. */ + readonly authorization: () => string | undefined + readonly set: (provider: () => string) => void + readonly clear: () => void +} + +export function createUpstreamCredentials(): UpstreamCredentials { + let provider: (() => string) | undefined + return { + authorization: () => provider?.(), + set: next => { + provider = next + }, + clear: () => { + provider = undefined + }, + } +} + +// ── Supervisor ───────────────────────────────────────────────────────────────── + +type StartServerFn = typeof startOpenCodeV2Server +type VerifyRulesFn = typeof verifyPermissionRules + +export interface RunSupervisedOpencodeV2Deps { + readonly startServer: StartServerFn + readonly verifyRules: VerifyRulesFn + /** Returns the database path for the next spawn. Default: `prepareFreshDatabase()`. */ + readonly prepareDatabase: () => Promise +} + +export interface RunSupervisedOpencodeV2Options { + /** The repos root, used as the server's working directory and the location the gates ask about. */ + readonly rootDir: string + readonly logger: Logger + /** Mutable status reference: the supervisor writes transitions here. */ + readonly statusRef: SupervisedStatusRef + /** Published to the proxy on every spawn, before `statusRef` flips to ready. */ + readonly credentials: UpstreamCredentials + /** Aborting stops the supervisor and runs the interrupt-then-close shutdown. */ + readonly signal?: AbortSignal + /** Service environment the allowlisted child environment is sourced from. Default: `process.env`. */ + readonly parentEnv?: NodeJS.ProcessEnv + /** Default: 54321, where the :9200 proxy expects the upstream. */ + readonly port?: number + /** Per-phase readiness bound for each attempt (listen, `/api/info`, plugin activation). Default 60000. */ + readonly readyTimeoutMs?: number + /** Absolute OpenCode executable. Default: `/usr/local/bin/opencode`. */ + readonly binaryPath?: string + /** Literal absolute directories outside the checkout that stay reachable without a prompt. Default none. */ + readonly allowedExternalDirectories?: readonly string[] + /** Maximum spawn attempts (initial plus respawns). Default 4. */ + readonly maxAttempts?: number + readonly initialBackoffMs?: number + readonly maxBackoffMs?: number + /** Uptime after ready that counts as a stable run and resets the attempt budget. Default 60000. */ + readonly stableReadyResetMs?: number + readonly deps?: Partial +} + +/** The runtime logger takes `warning` and `debug`; the workspace logger takes `warn` and has no debug level. */ +function toRuntimeLogger(logger: Logger): RuntimeLogger { + return { + debug: () => undefined, + info: (message, context) => logger.info(message, context), + warning: (message, context) => logger.warn(message, context), + error: (message, context) => logger.error(message, context), + } +} + +/** Bound on the active-session listing a shutdown does before it interrupts. */ +const ACTIVE_LISTING_TIMEOUT_MS = 5000 + +/** + * Every session still active on this spawn. The database is fresh per spawn, so every session on the server belongs + * to this run. Fail-soft: a listing that cannot be read leaves the shutdown with nothing to interrupt, and the + * process is still stopped. + */ +async function listActiveSessionIds(handle: OpenCodeV2ServerHandle, logger: Logger): Promise { + try { + const response = await handle.fetch('/api/session/active', {signal: AbortSignal.timeout(ACTIVE_LISTING_TIMEOUT_MS)}) + if (!response.ok) { + logger.warn('opencode-server-v2: could not list active sessions for shutdown', {status: response.status}) + return [] + } + const body = (await response.json()) as {data?: unknown} + if (typeof body.data !== 'object' || body.data === null || Array.isArray(body.data)) return [] + return Object.keys(body.data) + } catch { + logger.warn('opencode-server-v2: could not list active sessions for shutdown') + return [] + } +} + +export async function runSupervisedOpencodeV2(options: RunSupervisedOpencodeV2Options): Promise { + const { + rootDir, + logger, + statusRef, + credentials, + signal, + parentEnv = process.env, + port = OPENCODE_PORT, + readyTimeoutMs = 60_000, + binaryPath = OPENCODE_EXECUTABLE_PATH, + allowedExternalDirectories = [], + maxAttempts = 4, + initialBackoffMs = 1000, + maxBackoffMs = 10_000, + stableReadyResetMs = 60_000, + } = options + const { + startServer = startOpenCodeV2Server, + verifyRules = verifyPermissionRules, + prepareDatabase = async () => prepareFreshDatabase(), + } = options.deps ?? {} + + const runtimeLogger = toRuntimeLogger(logger) + + // The config is the same for every attempt, and a config that cannot be built is not a transient failure. + const built = buildWorkspaceConfigV2({plugins: WORKSPACE_V2_PLUGIN_SPECS, allowedExternalDirectories}, runtimeLogger) + if (!built.success) { + credentials.clear() + statusRef.status = 'degraded' + logger.error('opencode-server-v2: cannot build the workspace config', {message: built.error}) + 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. + // `startAbort` exists ONLY while a start is in flight. The signal given to the runtime spawn governs the server for + // its whole life, and aborting it after the start runs the handle's own shutdown with no sessions to interrupt; + // that shutdown is memoized, so it would win over the supervisor's interrupt-then-close. Once the server is + // running, only the supervisor stops it. + let startAbort: AbortController | undefined + let wake: () => void = () => undefined + const aborted = new Promise(resolve => { + wake = resolve + }) + const onAbort = (): void => { + startAbort?.abort() + wake() + } + signal?.addEventListener('abort', onAbort, {once: true}) + + /** Interrupt what is running, then close stdin and confirm the exit. Never throws. */ + const stopServer = async (handle: OpenCodeV2ServerHandle): Promise => { + credentials.clear() + const ownedSessionIds = await listActiveSessionIds(handle, logger) + try { + const result = await handle.shutdown({ownedSessionIds}) + logger.info('opencode-server-v2: server stopped', { + outcome: result.exit.outcome, + interrupted: result.interrupts.stopped.length, + unconfirmed: result.interrupts.unconfirmed.length, + }) + } catch (error) { + logger.error('opencode-server-v2: shutdown failed', {message: error instanceof Error ? error.message : 'unknown'}) + } + } + + // A function, not a repeated property read: TypeScript would otherwise narrow `signal.aborted` across the awaits. + const isAborted = (): boolean => signal?.aborted === true + + /** Waits out the backoff, abortably. Returns false when the supervisor was aborted. */ + const backoff = async (): Promise => { + if (backoffMs > 0 && !isAborted()) { + await sleep(backoffMs, undefined, {signal}).catch(() => undefined) + } + if (isAborted()) return false + backoffMs = Math.min(backoffMs * 2, maxBackoffMs) + return true + } + + try { + while (attempt <= maxAttempts) { + if (isAborted()) return + + // Fail closed before every spawn: not ready, and no credential for the proxy to forward with. + statusRef.status = 'starting' + credentials.clear() + logger.info('opencode-server-v2: supervisor spawning', {rootDir, attempt, maxAttempts}) + + let handle: OpenCodeV2ServerHandle | undefined + try { + const databasePath = await prepareDatabase() + const startController = new AbortController() + startAbort = startController + const started = await startServer( + { + binaryPath, + directory: rootDir, + expectedPluginIds: WORKSPACE_V2_EXPECTED_PLUGIN_IDS, + config: built.data.content, + env: buildOpencodeV2Env(parentEnv, databasePath), + port, + uid: AGENT_UID, + gid: AGENT_GID, + timeouts: { + listenTimeoutMs: readyTimeoutMs, + infoTimeoutMs: readyTimeoutMs, + agentReadyTimeoutMs: readyTimeoutMs, + // Teardown has to finish inside main.ts's 25s drain: listing 5s + interrupts 8s + exit 5s + kill 2s. + requestTimeoutMs: 5000, + interruptSettleTimeoutMs: 8000, + exitTimeoutMs: 5000, + killConfirmTimeoutMs: 2000, + }, + signal: startController.signal, + }, + runtimeLogger, + ) + startAbort = undefined + if (started.success) { + handle = started.data + } else if (isAborted()) { + return + } else { + logger.warn('opencode-server-v2: start failed', { + attempt, + kind: started.error.kind, + message: started.error.message, + ...(started.error.plugins === undefined ? {} : {plugins: started.error.plugins}), + }) + } + } catch (error) { + startAbort = undefined + logger.error('opencode-server-v2: start threw', { + attempt, + message: error instanceof Error ? error.message : 'unknown', + }) + } + + if (handle !== undefined) { + const serving = handle + // The effective rules are the source of truth: a server whose rules do not hold never serves. + const gate = await verifyRules( + { + listAgents: async () => { + const response = await serving.fetch('/api/agent') + if (!response.ok) throw new Error(`HTTP ${response.status}`) + return response.json() + }, + }, + built.data.expectations, + runtimeLogger, + ) + if (!gate.success) { + logger.error('opencode-server-v2: permission gate failed', { + attempt, + kind: gate.error.kind, + message: gate.error.message, + }) + await stopServer(handle) + handle = undefined + } + } + + if (handle !== undefined) { + if (isAborted()) { + await stopServer(handle) + return + } + // Credential first, status second: a request admitted the moment /readyz reports ready must carry it. + credentials.set(handle.authorizationHeader) + statusRef.status = 'ready' + const readyAt = Date.now() + logger.info('opencode-server-v2: ready', {attempt, pid: handle.pid}) + + const outcome = await Promise.race([ + handle.exited.then(() => 'exited' as const), + aborted.then(() => 'aborted' as const), + ]) + if (outcome === 'aborted') { + await stopServer(handle) + return + } + + // Unexpected exit: not ready, and the dead server's password goes with it. + credentials.clear() + statusRef.status = 'starting' + const wasStable = Date.now() - readyAt >= stableReadyResetMs + if (wasStable) { + logger.info('opencode-server-v2: stable run detected — resetting respawn budget', {attempt}) + attempt = 1 + backoffMs = initialBackoffMs + } + if (attempt < maxAttempts) { + logger.warn('opencode-server-v2: server exited after becoming ready — respawning', {attempt, maxAttempts}) + if (!(await backoff())) return + attempt++ + continue + } + statusRef.status = 'degraded' + logger.error('opencode-server-v2: supervisor exhausted attempts after post-ready exit', {attempt, maxAttempts}) + return + } + + // This attempt did not produce a serving server. + if (attempt < maxAttempts) { + logger.warn('opencode-server-v2: attempt failed, will respawn', { + attempt, + nextAttempt: attempt + 1, + maxAttempts, + }) + if (!(await backoff())) return + attempt++ + continue + } + statusRef.status = 'degraded' + logger.error('opencode-server-v2: supervisor exhausted all attempts', {attempt, maxAttempts}) + return + } + } finally { + signal?.removeEventListener('abort', onAbort) + } +} diff --git a/apps/workspace-agent/src/server-opencode-version.test.ts b/apps/workspace-agent/src/server-opencode-version.test.ts new file mode 100644 index 0000000000..4410ce4d7c --- /dev/null +++ b/apps/workspace-agent/src/server-opencode-version.test.ts @@ -0,0 +1,70 @@ +/** + * The control API reports the OpenCode version the image runs, so the gateway chooses its run loop from the + * workspace it attaches to (choice A). The field is optional and additive: a workspace without it answers exactly + * as before. + */ + +import {describe, expect, it} from 'vitest' + +import {createApp} from './server.js' + +async function get(path: string, deps: Parameters[0]): Promise<{status: number; body: unknown}> { + const app = createApp(deps) + const response = await app.request(path) + return {status: response.status, body: await response.json()} +} + +describe('OpenCode version on the control API', () => { + it('reports the version on /readyz when ready', async () => { + // #given a ready V2 workspace + const result = await get('/readyz', { + auth: {kind: 'disabled-for-tests'}, + opencodeStatus: {status: 'ready'}, + proxyListening: {listening: true}, + opencodeVersion: '2.2.0', + }) + + // #then the gateway can read the generation from the same response that tells it the workspace is ready + expect(result).toEqual({status: 200, body: {ready: true, opencode: 'ready', opencodeVersion: '2.2.0'}}) + }) + + it('reports the version on /readyz while not ready, so the gateway can pick a loop before it is', async () => { + // #given a V2 workspace that is still starting + const result = await get('/readyz', { + auth: {kind: 'disabled-for-tests'}, + opencodeStatus: {status: 'starting'}, + proxyListening: {listening: true}, + opencodeVersion: '2.2.0', + }) + + // #then + expect(result).toEqual({status: 503, body: {ready: false, opencode: 'starting', opencodeVersion: '2.2.0'}}) + }) + + it('reports the version on /healthz, which needs no auth', async () => { + // #given + const result = await get('/healthz', { + auth: {kind: 'bearer', token: 'tok'}, + opencodeStatus: {status: 'ready'}, + opencodeVersion: '2.2.0', + }) + + // #then + expect(result).toEqual({status: 200, body: {ok: true, opencode: 'ready', opencodeVersion: '2.2.0'}}) + }) + + it('leaves the wire shape unchanged when the image reports no version', async () => { + // #given a workspace from an image that predates the variable + const ready = await get('/readyz', { + auth: {kind: 'disabled-for-tests'}, + opencodeStatus: {status: 'ready'}, + proxyListening: {listening: true}, + }) + const health = await get('/healthz', {auth: {kind: 'disabled-for-tests'}, opencodeStatus: {status: 'ready'}}) + + // #then no new key appears, not even as undefined + expect(ready.body).toEqual({ready: true, opencode: 'ready'}) + expect(Object.keys(ready.body as object)).toEqual(['ready', 'opencode']) + expect(health.body).toEqual({ok: true, opencode: 'ready'}) + }) +}) diff --git a/apps/workspace-agent/src/server.ts b/apps/workspace-agent/src/server.ts index 9684d0e182..941e150450 100644 --- a/apps/workspace-agent/src/server.ts +++ b/apps/workspace-agent/src/server.ts @@ -293,6 +293,11 @@ export interface ServerDeps { readonly instanceDisposer?: InstanceDisposer /** OpenCode server readiness reference. When absent, opencode field is omitted from /healthz. */ readonly opencodeStatus?: OpencodeStatusRef + /** + * The OpenCode version this image runs, reported on /healthz and /readyz so the gateway can choose its run loop + * from the workspace it attaches to. Omitted from the responses when absent. + */ + readonly opencodeVersion?: string /** * Bearer proxy listening reference. When present, /readyz requires BOTH * opencodeStatus === 'ready' AND proxyListening.listening === true. @@ -335,10 +340,13 @@ export function createApp(deps: ServerDeps): Hono { logger, instanceDisposer, opencodeStatus, + opencodeVersion, proxyListening, auth, } = deps const app = new Hono() + // Spread only when set: the wire shape of an image that reports nothing must not gain a key. + const versionField = opencodeVersion === undefined ? {} : {opencodeVersion} // Control-API bearer check — every route except /healthz and /readyz. Registered before any // route so it runs (and can short-circuit with 401) before body parsing, JSON parsing, or @@ -377,7 +385,9 @@ export function createApp(deps: ServerDeps): Hono { // GET /healthz — liveness probe (always 200; clone-only signal) app.get('/healthz', c => { const body: HealthzResponse = - opencodeStatus === undefined ? {ok: true} : {ok: true, opencode: opencodeStatus.status} + opencodeStatus === undefined + ? {ok: true, ...versionField} + : {ok: true, opencode: opencodeStatus.status, ...versionField} return c.json(body, 200) }) @@ -395,13 +405,13 @@ export function createApp(deps: ServerDeps): Hono { // When proxyListening is absent (legacy/clone-only mode), falls back to opencode-only check. app.get('/readyz', c => { if (opencodeStatus === undefined) { - const body: ReadyzResponse = {ready: false, opencode: 'unknown'} + const body: ReadyzResponse = {ready: false, opencode: 'unknown', ...versionField} return c.json(body, 503) } const opencodeReady = opencodeStatus.status === 'ready' const proxyReady = proxyListening === undefined || proxyListening.listening === true const isReady = opencodeReady === true && proxyReady === true - const body: ReadyzResponse = {ready: isReady, opencode: opencodeStatus.status} + const body: ReadyzResponse = {ready: isReady, opencode: opencodeStatus.status, ...versionField} return c.json(body, isReady === true ? 200 : 503) }) diff --git a/apps/workspace-agent/src/types.ts b/apps/workspace-agent/src/types.ts index 494d21665e..c599d3f13f 100644 --- a/apps/workspace-agent/src/types.ts +++ b/apps/workspace-agent/src/types.ts @@ -312,6 +312,8 @@ export interface HealthzResponse { readonly ok: true /** OpenCode server readiness. Present when the server lifecycle is managed. */ readonly opencode?: 'ready' | 'starting' | 'down' | 'degraded' + /** The OpenCode version the image runs. Absent when the image does not report one. See `/readyz`. */ + readonly opencodeVersion?: string } /** GET /readyz response. */ @@ -322,6 +324,12 @@ export interface ReadyzResponse { * 'degraded' = retries exhausted, clone API still alive, /readyz returns 503. */ readonly opencode: 'ready' | 'starting' | 'down' | 'degraded' | 'unknown' + /** + * The OpenCode version this image runs (`WORKSPACE_IMAGE_OPENCODE_VERSION`, set by the Dockerfile). The gateway + * picks its V1 or V2 run loop from it: major 2 or above is V2. Absent from an image that predates the variable, + * which the gateway treats as V1. Present while not ready too, so the choice does not wait for readiness. + */ + readonly opencodeVersion?: string } /** POST /recover/preview request body. */ diff --git a/apps/workspace-agent/tsconfig.json b/apps/workspace-agent/tsconfig.json index 34fc849657..33302bede6 100644 --- a/apps/workspace-agent/tsconfig.json +++ b/apps/workspace-agent/tsconfig.json @@ -2,9 +2,9 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "tsBuildInfoFile": "./node_modules/.cache/tsconfig.tsbuildinfo", - "rootDir": "src", + "rootDir": "../../", "outDir": "./dist" }, - "include": ["src/**/*.ts"], + "include": ["src/**/*.ts", "../../packages/runtime/src/**/*.ts"], "exclude": ["dist", "node_modules"] } diff --git a/apps/workspace-agent/tsdown.config.ts b/apps/workspace-agent/tsdown.config.ts index 821d5c8183..e2a8a56460 100644 --- a/apps/workspace-agent/tsdown.config.ts +++ b/apps/workspace-agent/tsdown.config.ts @@ -42,5 +42,11 @@ export default defineConfig({ entry: [path.join(dirname, 'src/main.ts')], format: 'esm', outDir: 'dist', + // An entry bundle with no type consumer. Declaration output would also have to resolve the runtime subpath's + // TypeScript source as declarations, which it cannot. + dts: false, + // The V2 runner imports `@fro-bot/runtime/workspace-v2`. Runtime is a TypeScript-source workspace package, so it + // is bundled in (as the gateway does), never left as a bare import the production image could not resolve. + noExternal: id => id === '@fro-bot/runtime' || id.startsWith('@fro-bot/runtime/'), plugins: [bundleSymbolGuardPlugin()], }) diff --git a/deploy/managed-config/opencode-v2.json b/deploy/managed-config/opencode-v2.json new file mode 100644 index 0000000000..06542060f1 --- /dev/null +++ b/deploy/managed-config/opencode-v2.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://opencode.ai/config.json", + "update": "disable" +} diff --git a/deploy/scripts/install-opencode-v2-plugins.sh b/deploy/scripts/install-opencode-v2-plugins.sh new file mode 100755 index 0000000000..2505a3d607 --- /dev/null +++ b/deploy/scripts/install-opencode-v2-plugins.sh @@ -0,0 +1,72 @@ +#!/bin/sh +# Installs OpenCode V2 plugins into the agent's cache at IMAGE BUILD time, so the workspace never installs at boot. +# +# Usage: install-opencode-v2-plugins.sh ... (each an exact @fro.bot/systematic@x.y.z or +# @fro.bot/opencode-plugin@x.y.z) +# +# Why this exists (probe P10, v2.2.0): +# - `opencode plugin add` has no timeout. A hung registry hangs it forever, so every install runs under `timeout`. +# - A plugin that is not cached is installed by the server at boot, and a hung boot install leaves `GET /api/agent` +# answering 503 for as long as it hangs. The image therefore carries every plugin before it ever starts. +# - `plugin add` writes the global config. The workspace entrypoint owns that file and rewrites it at start, so any +# plugin entry in it would be lost. The plugins are listed in the config content the workspace agent passes at +# spawn (the server finds them in the cache by spec), and the file `plugin add` wrote is removed here. +# +# The install runs as the agent uid (10001): the cache it fills is the one the server reads as that uid, and the +# service identity (root) never writes into the agent's home. Run on a Linux build as root; the privilege drop is +# `setpriv`, as in deploy/workspace-entrypoint.sh. +# +# Test seams (deploy/scripts/install-opencode-v2-plugins.test.mjs): OPENCODE_BIN, AGENT_HOME, DROP_PRIVILEGES +# (empty means no privilege drop), INSTALL_TIMEOUT_SECONDS and INSTALL_PATH (the PATH the install sees). + +set -eu + +OPENCODE_BIN="${OPENCODE_BIN:-/usr/local/bin/opencode}" +AGENT_HOME="${AGENT_HOME:-/home/opencode}" +INSTALL_TIMEOUT_SECONDS="${INSTALL_TIMEOUT_SECONDS:-300}" +DROP_PRIVILEGES="${DROP_PRIVILEGES-setpriv --reuid=10001 --regid=10001 --clear-groups}" +INSTALL_PATH="${INSTALL_PATH:-/usr/local/bin:/usr/bin:/bin}" + +fail() { + echo "install-opencode-v2-plugins: $*" >&2 + exit 1 +} + +case "${INSTALL_TIMEOUT_SECONDS}" in + '' | *[!0-9]*) fail "INSTALL_TIMEOUT_SECONDS must be a whole number of seconds, got: ${INSTALL_TIMEOUT_SECONDS}" ;; +esac + +[ "$#" -gt 0 ] || fail "no plugin specs given" + +# Validate every spec before installing any: an exact version of one of the two fro.bot packages, nothing else. +# No range, no tag, no git or file spec: a floating spec would make the image depend on the registry's state. +for spec in "$@"; do + printf '%s' "${spec}" | grep -Eq '^@fro\.bot/(systematic|opencode-plugin)@[0-9]+\.[0-9]+\.[0-9]+$' || + fail "refusing plugin spec: ${spec} (expected @fro.bot/systematic@x.y.z or @fro.bot/opencode-plugin@x.y.z)" +done + +for spec in "$@"; do + echo "install-opencode-v2-plugins: installing ${spec} (bound ${INSTALL_TIMEOUT_SECONDS}s)" >&2 + # `env -i` rebuilds the environment from nothing: the install sees only the agent's home and XDG roots. + # ${DROP_PRIVILEGES} is intentionally unquoted so it splits into the command and its flags. + # shellcheck disable=SC2086 + ${DROP_PRIVILEGES} env -i \ + HOME="${AGENT_HOME}" \ + PATH="${INSTALL_PATH}" \ + XDG_CONFIG_HOME="${AGENT_HOME}/.config" \ + XDG_DATA_HOME="${AGENT_HOME}/.local/share" \ + XDG_CACHE_HOME="${AGENT_HOME}/.cache" \ + XDG_STATE_HOME="${AGENT_HOME}/.local/state" \ + timeout -s KILL "${INSTALL_TIMEOUT_SECONDS}" "${OPENCODE_BIN}" plugin add "${spec}" || + fail "installing ${spec} failed or exceeded ${INSTALL_TIMEOUT_SECONDS}s" + + # A zero exit that left nothing behind would ship an image that installs at boot. + cache_dir="${AGENT_HOME}/.cache/opencode/npm/${spec}" + if [ ! -d "${cache_dir}" ] || [ -z "$(ls -A "${cache_dir}" 2>/dev/null)" ]; then + fail "${spec} is not in the cache at ${cache_dir} after a successful install" + fi +done + +# The entrypoint owns the global config; see the header. +rm -f "${AGENT_HOME}/.config/opencode/opencode.json" +echo "install-opencode-v2-plugins: done" >&2 diff --git a/deploy/scripts/install-opencode-v2-plugins.test.mjs b/deploy/scripts/install-opencode-v2-plugins.test.mjs new file mode 100644 index 0000000000..4a6ea9d2c8 --- /dev/null +++ b/deploy/scripts/install-opencode-v2-plugins.test.mjs @@ -0,0 +1,209 @@ +// Tests for deploy/scripts/install-opencode-v2-plugins.sh, the image-build step that puts the V2 plugins in the +// agent's cache so nothing installs at boot. Run with `node --test deploy/scripts/*.test.mjs`. +// +// The real `opencode` and `setpriv` are not available here, so the script's seams are used: OPENCODE_BIN points at a +// recording stand-in, DROP_PRIVILEGES is empty, and a `timeout` shim on PATH records the bound it was given and then +// runs the command. What is pinned is the script's own contract: exact specs only, the bound, a scrubbed environment, +// fail-closed on any failure, and a cache that must exist afterwards. + +import assert from 'node:assert/strict' +import {spawnSync} from 'node:child_process' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import {test} from 'node:test' +import {fileURLToPath} from 'node:url' + +const SCRIPT = fileURLToPath(new URL('./install-opencode-v2-plugins.sh', import.meta.url)) +const SYSTEMATIC = '@fro.bot/systematic@3.27.0' +const FRO_BOT = '@fro.bot/opencode-plugin@0.1.0' + +/** + * Builds a sandbox: a fake agent home, a fake `opencode` that records its call and (unless told to fail) installs the + * spec into the cache the way the real one does, and a `timeout` shim. + */ +function sandbox({failOn, skipInstall} = {}) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'v2-plugins-')) + const home = path.join(root, 'home') + const bin = path.join(root, 'bin') + fs.mkdirSync(path.join(home, '.config', 'opencode'), {recursive: true}) + fs.mkdirSync(bin) + const log = path.join(root, 'calls.log') + + const fakeOpencode = path.join(bin, 'opencode') + fs.writeFileSync( + fakeOpencode, + `#!/bin/sh +{ + echo "args: $*" + echo "env: HOME=$HOME XDG_CACHE_HOME=$XDG_CACHE_HOME XDG_CONFIG_HOME=$XDG_CONFIG_HOME" + echo "leaked: \${LEAKED_SECRET:-none}" +} >> "${log}" +[ "$1" = plugin ] && [ "$2" = add ] || exit 64 +spec="$3" +if [ "$spec" = "${failOn ?? ''}" ]; then echo "install failed" >&2; exit 1; fi +${skipInstall === true ? ': # a command that exits 0 without installing anything' : `mkdir -p "$XDG_CACHE_HOME/opencode/npm/$spec/1700000000000/node_modules"`} +printf '{"plugins":["%s"]}' "$spec" > "$XDG_CONFIG_HOME/opencode/opencode.json" +`, + {mode: 0o755}, + ) + + fs.writeFileSync( + path.join(bin, 'timeout'), + `#!/bin/sh +echo "timeout: $1 $2 $3" >> "${log}" +shift 3 +exec "$@" +`, + {mode: 0o755}, + ) + + return { + root, + home, + log, + fakeOpencode, + run(args, env = {}) { + return spawnSync('/bin/sh', [SCRIPT, ...args], { + encoding: 'utf8', + env: { + PATH: `${bin}:${process.env.PATH}`, + INSTALL_PATH: `${bin}:${process.env.PATH}`, + OPENCODE_BIN: fakeOpencode, + AGENT_HOME: home, + DROP_PRIVILEGES: '', + LEAKED_SECRET: 'must-not-reach-the-install', + ...env, + }, + }) + }, + calls: () => (fs.existsSync(log) ? fs.readFileSync(log, 'utf8') : ''), + } +} + +test('installs each pinned spec with `plugin add`, in the order given, under a bound', () => { + // #given + const box = sandbox() + + // #when + const result = box.run([SYSTEMATIC, FRO_BOT]) + + // #then + assert.equal(result.status, 0, result.stderr) + const calls = box.calls() + assert.ok(calls.indexOf(`args: plugin add ${SYSTEMATIC}`) < calls.indexOf(`args: plugin add ${FRO_BOT}`)) + assert.equal([...calls.matchAll(/^timeout: -s KILL 300$/gm)].length, 2, 'every install runs under `timeout -s KILL 300`') +}) + +test('runs the install with a scrubbed environment rooted at the agent home', () => { + // #given a build environment holding something that must not reach the install + const box = sandbox() + + // #when + const result = box.run([SYSTEMATIC]) + + // #then HOME and the XDG roots are the agent's, and nothing else from the caller came along + assert.equal(result.status, 0, result.stderr) + const calls = box.calls() + assert.match(calls, new RegExp(`HOME=${box.home.replaceAll('/', '\\/')} `)) + assert.match(calls, new RegExp(`XDG_CACHE_HOME=${box.home.replaceAll('/', '\\/')}/\\.cache`)) + assert.match(calls, /leaked: none/) +}) + +test('honours a different bound', () => { + // #given + const box = sandbox() + + // #when + const result = box.run([SYSTEMATIC], {INSTALL_TIMEOUT_SECONDS: '45'}) + + // #then + assert.equal(result.status, 0, result.stderr) + assert.match(box.calls(), /^timeout: -s KILL 45$/m) +}) + +test('refuses a bound that is not a number, before anything is installed', () => { + // #given + const box = sandbox() + + // #when + const result = box.run([SYSTEMATIC], {INSTALL_TIMEOUT_SECONDS: '5; rm -rf /'}) + + // #then + assert.notEqual(result.status, 0) + assert.equal(box.calls(), '') +}) + +for (const spec of [ + '@fro.bot/systematic@latest', + '@fro.bot/systematic@^3.27.0', + '@fro.bot/systematic@3', + '@fro.bot/systematic', + '@fro.bot/opencode-plugin@>=0.1.0', + '@evil/plugin@1.0.0', + 'git+https://example.com/x.git', + '/tmp/local-plugin', + '@fro.bot/systematic@3.27.0; touch /tmp/pwned', +]) { + test(`refuses ${JSON.stringify(spec)}: only the two fro.bot packages at an exact version`, () => { + // #given + const box = sandbox() + + // #when + const result = box.run([spec]) + + // #then nothing ran + assert.notEqual(result.status, 0) + assert.equal(box.calls(), '') + }) +} + +test('refuses to run with no specs', () => { + // #then an empty invocation must not look like a successful install + const box = sandbox() + assert.notEqual(box.run([]).status, 0) +}) + +test('stops at the first failed install and fails the build', () => { + // #given the first install fails + const box = sandbox({failOn: SYSTEMATIC}) + + // #when + const result = box.run([SYSTEMATIC, FRO_BOT]) + + // #then the second install never started, and the build fails + assert.notEqual(result.status, 0) + assert.doesNotMatch(box.calls(), new RegExp(`plugin add ${FRO_BOT.replaceAll('/', '\\/')}`)) +}) + +test('fails when an install exits 0 but the cache holds nothing for the spec', () => { + // #given an install that claims success and installs nothing + const box = sandbox({skipInstall: true}) + + // #when + const result = box.run([SYSTEMATIC]) + + // #then a silent no-op would ship an image that installs at boot, so it fails the build + assert.notEqual(result.status, 0) + assert.match(result.stderr, /not in the cache/) +}) + +test('removes the global config that `plugin add` wrote, so the entrypoint owns that file', () => { + // #given + const box = sandbox() + + // #when + const result = box.run([SYSTEMATIC, FRO_BOT]) + + // #then plugins load from the config content, and the entrypoint writes the only global config + assert.equal(result.status, 0, result.stderr) + assert.equal(fs.existsSync(path.join(box.home, '.config', 'opencode', 'opencode.json')), false) +}) + +test('uses setpriv to drop to the agent uid and gid by default', () => { + // #given the script text, because setpriv is not available to run here + const source = fs.readFileSync(SCRIPT, 'utf8') + + // #then the default privilege drop is the numeric agent identity, with supplementary groups cleared + assert.match(source, /setpriv --reuid=10001 --regid=10001 --clear-groups/) +}) diff --git a/deploy/scripts/v2-image-smoke.mjs b/deploy/scripts/v2-image-smoke.mjs new file mode 100644 index 0000000000..3788a6d094 --- /dev/null +++ b/deploy/scripts/v2-image-smoke.mjs @@ -0,0 +1,128 @@ +// v2-image-smoke.mjs — the in-container half of the V2 workspace image smoke test. +// +// Run INSIDE the built image, as uid 10001 with no network: +// +// docker run --rm --network none --user 10001:10001 \ +// -e HOME=/home/opencode -e XDG_CONFIG_HOME=/home/opencode/.config -e XDG_DATA_HOME=/home/opencode/.local/share \ +// -e XDG_CACHE_HOME=/home/opencode/.cache -e XDG_STATE_HOME=/home/opencode/.local/state \ +// -v "$PWD/deploy/scripts/v2-image-smoke.mjs:/smoke.mjs:ro" --entrypoint node "$IMAGE" /smoke.mjs \ +// '@fro.bot/systematic@3.27.0' '@fro.bot/opencode-plugin@0.1.0' +// +// What it proves that nothing else here can, because Docker is what builds the image: +// 1. the musl stock binary starts on Alpine as the agent uid (`serve --stdio`, held stdin, explicit port); +// 2. with no network, both plugins load FROM THE IMAGE'S CACHE, so nothing installs at boot; +// 3. both are listed `active` under the ids the workspace agent's plugin gate expects. +// +// It is the same sequence the workspace agent's supervisor runs (readiness on /api/agent, then /api/plugin), minus the +// permission gate, which needs the TypeScript config builder and is pinned by the supervisor's own tests and by the +// real-binary test. Plain Node ESM, no dependencies, so it needs nothing in the image beyond Node. + +import {spawn} from 'node:child_process' +import {randomBytes} from 'node:crypto' +import process from 'node:process' +import {fileURLToPath} from 'node:url' + +const BINARY = process.env.OPENCODE_BIN ?? '/usr/local/bin/opencode' +// SMOKE_EXPECT_UID is a seam for running this script outside the image; the image run never sets it. +const EXPECTED_UID = Number(process.env.SMOKE_EXPECT_UID ?? 10_001) +const PORT = Number(process.env.SMOKE_PORT ?? 54_399) +const BOUND_MS = Number(process.env.SMOKE_BOUND_MS ?? 120_000) +/** The ids `GET /api/plugin` must list active. Keep in step with `WORKSPACE_V2_EXPECTED_PLUGIN_IDS` in the agent. */ +export const EXPECTED_PLUGIN_IDS = ['systematic', 'fro-bot.opencode-plugin'] + +/** + * The expected ids that are not listed `active` in a `GET /api/plugin` body (`{data: [{id?, state: {status}}]}`). + * Matched by id, never by presence of some entry (a failed plugin is still an entry). + */ +export function findInactivePlugins(body, expectedIds) { + const entries = Array.isArray(body?.data) ? body.data : [] + return expectedIds.filter(id => !entries.some(entry => entry?.id === id && entry?.state?.status === 'active')) +} + +async function get(pathname, authorization) { + const response = await fetch(`http://127.0.0.1:${PORT}${pathname}`, { + headers: {authorization}, + signal: AbortSignal.timeout(10_000), + }) + return {status: response.status, body: await response.json().catch(() => undefined)} +} + +async function waitFor(label, attempt, deadline) { + let last = 'no response' + while (Date.now() < deadline) { + try { + const result = await attempt() + if (result.done) return result.value + last = result.note + } catch (error) { + last = error instanceof Error ? error.message : String(error) + } + await new Promise(resolve => setTimeout(resolve, 250)) + } + throw new Error(`${label} did not succeed within ${BOUND_MS}ms (${last})`) +} + +async function main(specs) { + if (specs.length === 0) throw new Error('usage: v2-image-smoke.mjs ...') + if (process.getuid?.() !== EXPECTED_UID) { + throw new Error(`run this as uid ${EXPECTED_UID}, the identity the workspace agent drops OpenCode to`) + } + + const password = randomBytes(24).toString('base64url') + const authorization = `Basic ${Buffer.from(`opencode:${password}`).toString('base64')}` + const child = spawn(BINARY, ['serve', '--stdio', '--hostname', '127.0.0.1', '--port', String(PORT)], { + // A held stdin pipe: with `ignore` the server exits after about 150 ms. + stdio: ['pipe', 'ignore', 'inherit'], + env: { + PATH: process.env.PATH, + HOME: process.env.HOME, + XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME, + XDG_DATA_HOME: process.env.XDG_DATA_HOME, + XDG_CACHE_HOME: process.env.XDG_CACHE_HOME, + XDG_STATE_HOME: process.env.XDG_STATE_HOME, + OPENCODE_PASSWORD: password, + OPENCODE_DISABLE_PROJECT_CONFIG: '1', + OPENCODE_DISABLE_AUTOUPDATE: '1', + OPENCODE_DISABLE_MODELS_FETCH: '1', + OPENCODE_CONFIG_CONTENT: JSON.stringify({update: 'disable', plugins: specs}), + }, + }) + const exited = new Promise(resolve => child.once('exit', (code, signal) => resolve({code, signal}))) + child.stdin.on('error', () => undefined) + + const deadline = Date.now() + BOUND_MS + try { + await waitFor( + '/api/info', + async () => { + const result = await get('/api/info', authorization) + return result.status === 200 ? {done: true, value: true} : {done: false, note: `HTTP ${result.status}`} + }, + deadline, + ) + // 503 here means plugins are still activating; a hung boot-time install would stay 503 until the bound. + await waitFor( + '/api/agent (plugin activation)', + async () => { + const result = await get('/api/agent', authorization) + return result.status === 200 ? {done: true, value: true} : {done: false, note: `HTTP ${result.status}`} + }, + deadline, + ) + const plugins = await get('/api/plugin', authorization) + const inactive = findInactivePlugins(plugins.body, EXPECTED_PLUGIN_IDS) + if (inactive.length > 0) throw new Error(`plugins not active: ${inactive.join(', ')}`) + console.log(`v2-image-smoke: ok (uid ${process.getuid()}, plugins active: ${EXPECTED_PLUGIN_IDS.join(', ')})`) + } finally { + child.stdin.end() + const result = await Promise.race([exited, new Promise(resolve => setTimeout(() => resolve('timeout'), 10_000))]) + if (result === 'timeout') child.kill('SIGKILL') + } +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + main(process.argv.slice(2)).catch(error => { + console.error(`v2-image-smoke: FAILED: ${error instanceof Error ? error.message : String(error)}`) + process.exit(1) + }) +} diff --git a/deploy/scripts/v2-image-smoke.test.mjs b/deploy/scripts/v2-image-smoke.test.mjs new file mode 100644 index 0000000000..263cf79765 --- /dev/null +++ b/deploy/scripts/v2-image-smoke.test.mjs @@ -0,0 +1,35 @@ +// Tests for the pure part of deploy/scripts/v2-image-smoke.mjs. The rest of that script needs the built image. +import assert from 'node:assert/strict' +import fs from 'node:fs' +import {test} from 'node:test' + +import {EXPECTED_PLUGIN_IDS, findInactivePlugins} from './v2-image-smoke.mjs' + +const active = id => ({id, source: {type: 'package'}, state: {status: 'active'}}) + +test('reports nothing when every expected plugin is active', () => { + assert.deepEqual(findInactivePlugins({data: EXPECTED_PLUGIN_IDS.map(active)}, EXPECTED_PLUGIN_IDS), []) +}) + +test('names a plugin that is listed failed, and one that is not listed at all', () => { + const body = {data: [active('systematic'), {id: 'fro-bot.opencode-plugin', state: {status: 'failed', error: 'x'}}]} + assert.deepEqual(findInactivePlugins(body, EXPECTED_PLUGIN_IDS), ['fro-bot.opencode-plugin']) + assert.deepEqual(findInactivePlugins({data: [active('systematic')]}, EXPECTED_PLUGIN_IDS), ['fro-bot.opencode-plugin']) +}) + +test('does not count an entry without an id, or another plugin being active', () => { + const body = {data: [{state: {status: 'active'}}, active('opencode.agent')]} + assert.deepEqual(findInactivePlugins(body, EXPECTED_PLUGIN_IDS), EXPECTED_PLUGIN_IDS) +}) + +test('treats an unreadable body as nothing active', () => { + assert.deepEqual(findInactivePlugins(undefined, EXPECTED_PLUGIN_IDS), EXPECTED_PLUGIN_IDS) + assert.deepEqual(findInactivePlugins({data: 'nope'}, EXPECTED_PLUGIN_IDS), EXPECTED_PLUGIN_IDS) +}) + +test('expects the same ids the workspace agent gates on', () => { + // #then the smoke test and the supervisor cannot drift apart + const constants = fs.readFileSync(new URL('../../packages/runtime/src/shared/constants.ts', import.meta.url), 'utf8') + const id = name => new RegExp(`export const ${name} = '([^']+)'`).exec(constants)?.[1] + assert.deepEqual(EXPECTED_PLUGIN_IDS, [id('SYSTEMATIC_V2_PLUGIN_ID'), id('FRO_BOT_PLUGIN_ID')]) +}) diff --git a/deploy/scripts/workspace-v2-image.test.mjs b/deploy/scripts/workspace-v2-image.test.mjs new file mode 100644 index 0000000000..946d589a1a --- /dev/null +++ b/deploy/scripts/workspace-v2-image.test.mjs @@ -0,0 +1,147 @@ +// Static checks on the V2 branch of deploy/workspace.Dockerfile. Docker is not run here, so these pin what can be +// read from the files: the plugin versions match the constants the workspace agent expects, the plugins are +// installed at build time and never at boot, the V1 outputs are the ones V1 had, and the image carries the version the +// workspace agent selects on. Run with `node --test deploy/scripts/*.test.mjs`. +// +// What these cannot show, and the V2 image smoke test (deploy/scripts/v2-image-smoke.mjs) must: that the built image +// starts as uid 10001 with no network, loads both plugins, and passes the permission gate. + +import assert from 'node:assert/strict' +import fs from 'node:fs' +import {test} from 'node:test' + +const REPO = new URL('../../', import.meta.url) +const read = path => fs.readFileSync(new URL(path, REPO), 'utf8') + +const dockerfile = read('deploy/workspace.Dockerfile') +const constants = read('packages/runtime/src/shared/constants.ts') +const generation = read('apps/workspace-agent/src/opencode-generation.ts') +const entrypoint = read('deploy/workspace-entrypoint.sh') +const managedV2 = JSON.parse(read('deploy/managed-config/opencode-v2.json')) + +/** The value of `export const NAME = ''` in constants.ts. */ +function constant(name) { + const match = new RegExp(`export const ${name} = '([^']+)'`).exec(constants) + assert.ok(match, `constants.ts has no ${name}`) + return match[1] +} + +/** The default of `ARG NAME=` in the Dockerfile. */ +function argDefault(name) { + const match = new RegExp(`^ARG ${name}=(\\S+)$`, 'm').exec(dockerfile) + assert.ok(match, `Dockerfile has no ARG ${name}`) + return match[1] +} + +const V2_VERSION_ARM = '[2-9].*|[1-9][0-9]*.*)' + +test('the plugin versions the image installs are the ones the workspace agent expects', () => { + // #then the build-time ARG defaults equal the runtime constants, so the specs the server is told to load are the + // specs that were cached + assert.equal(argDefault('SYSTEMATIC_V2_VERSION'), constant('SYSTEMATIC_V2_VERSION')) + assert.equal(argDefault('FRO_BOT_PLUGIN_VERSION'), constant('FRO_BOT_PLUGIN_VERSION')) +}) + +test('the V2 pins are exact versions, and the V1 Systematic default is untouched by them', () => { + // #then + assert.match(constant('SYSTEMATIC_V2_VERSION'), /^\d+\.\d+\.\d+$/) + assert.match(constant('FRO_BOT_PLUGIN_VERSION'), /^\d+\.\d+\.\d+$/) + assert.equal(argDefault('SYSTEMATIC_VERSION'), constant('DEFAULT_SYSTEMATIC_VERSION')) + assert.notEqual(constant('SYSTEMATIC_V2_VERSION'), constant('DEFAULT_SYSTEMATIC_VERSION')) +}) + +test('the plugins are installed at build time by the bounded script, behind the V2 version test', () => { + // #given the RUN that installs them + const run = /RUN set -euo pipefail[^\n]*\n(?:[^\n]*\\\n)*?[^\n]*install-opencode-v2-plugins\.sh[^\n]*\n(?:[^\n]*\\\n)*[^\n]*\n/.exec( + dockerfile, + ) + assert.ok(run, 'the plugin install RUN is missing') + + // #then it is skipped for harness and V1 builds, and passes exactly the two pinned specs + assert.match(run[0], /\*harness\*\) exit 0/) + assert.ok(run[0].includes(V2_VERSION_ARM)) + assert.ok(run[0].includes('"@fro.bot/systematic@${SYSTEMATIC_V2_VERSION}"')) + assert.ok(run[0].includes('"@fro.bot/opencode-plugin@${FRO_BOT_PLUGIN_VERSION}"')) +}) + +test('the install runs after the agent account exists and after the binary is installed', () => { + // #then + const account = dockerfile.indexOf('adduser -D -H -u 10001') + const binary = dockerfile.indexOf('opencode --version') + const plugins = dockerfile.indexOf('&& sh /usr/local/lib/workspace-scripts/install-opencode-v2-plugins.sh') + assert.ok(account > 0 && binary > 0 && plugins > 0) + assert.ok(account < plugins, 'plugins install before the agent account exists') + assert.ok(binary < plugins, 'plugins install before the binary') +}) + +test('nothing installs a plugin at boot', () => { + // #then the entrypoint and the workspace agent never run `plugin add` or an npm install + assert.doesNotMatch(entrypoint, /plugin add|npm install|bun add/) + const supervisor = read('apps/workspace-agent/src/opencode-server-v2.ts') + assert.doesNotMatch(supervisor, /plugin add|'install'|"install"/) +}) + +test('the image carries the version the workspace agent selects on, from the same build argument as the binary', () => { + // #then the ENV name is the one main.ts reads, and its value is the OPENCODE_VERSION that chose the binary + const name = /export const IMAGE_OPENCODE_VERSION_ENV = '([^']+)'/.exec(generation)?.[1] + assert.ok(name, 'opencode-generation.ts has no IMAGE_OPENCODE_VERSION_ENV') + assert.match(dockerfile, new RegExp(`^ENV ${name}=\\$\\{OPENCODE_VERSION\\}$`, 'm')) +}) + +test('the V2 file-watcher flag uses the V2 spelling, beside the V1 flag the V1 child still gets', () => { + // #then + assert.match(dockerfile, /^ENV OPENCODE_DISABLE_FILEWATCHER=true$/m) + assert.match(dockerfile, /^ENV OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER=true$/m) + assert.match(dockerfile, /^ENV OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true$/m) +}) + +test('the V1 base config is still written exactly as before for V1 and harness builds', () => { + // #then the V1 printf, with the V1 key and the V1 Systematic version, is the non-V2 arm + assert.ok( + dockerfile.includes( + `printf '{\\n "$schema": "https://opencode.ai/config.json",\\n "autoupdate": false,\\n "plugin": ["@fro.bot/systematic@%s"]\\n}\\n' "\${SYSTEMATIC_VERSION}"`, + ), + ) +}) + +test('the V2 base and managed configs carry no plugin entry: the plugins load from the config content', () => { + // #then V2's plugin key is `plugins` and lives in the content; neither root-owned file lists one + assert.deepEqual(Object.keys(managedV2).sort(), ['$schema', 'update']) + assert.equal(managedV2.update, 'disable') + assert.ok( + dockerfile.includes( + `printf '{\\n "$schema": "https://opencode.ai/config.json",\\n "update": "disable"\\n}\\n'`, + ), + ) +}) + +test('the V2 managed config is selected only for V2 and is root-owned and not agent-writable', () => { + // #then + assert.match(dockerfile, /COPY deploy\/managed-config\/opencode-v2\.json \/usr\/local\/share\/fro-bot\/opencode-managed\.v2\.json/) + assert.match(dockerfile, /cp \/usr\/local\/share\/fro-bot\/opencode-managed\.v2\.json \/etc\/opencode\/opencode\.json/) + assert.match( + dockerfile, + /chown 0:0 \/usr\/local\/share\/fro-bot\/opencode-managed\.v2\.json \/etc\/opencode\/opencode\.json/, + ) + assert.match( + dockerfile, + /chmod 0644 \/usr\/local\/share\/fro-bot\/opencode-managed\.v2\.json \/etc\/opencode\/opencode\.json/, + ) +}) + +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 + assert.ok(arms >= 5, `expected the V2 arm in at least five version tests, found ${arms}`) +}) + +test('the build stage has the runtime source the V2 runner is bundled from', () => { + // #then + assert.match(dockerfile, /^COPY packages\/runtime\/ packages\/runtime\/$/m) +}) + +test('the workspace agent expects the same plugin ids the V2 image is smoke-tested for', () => { + // #then + assert.equal(constant('SYSTEMATIC_V2_PLUGIN_ID'), 'systematic') + assert.equal(constant('FRO_BOT_PLUGIN_ID'), 'fro-bot.opencode-plugin') +}) diff --git a/deploy/workspace.Dockerfile b/deploy/workspace.Dockerfile index 5b8951e0ab..121fa2bfe6 100644 --- a/deploy/workspace.Dockerfile +++ b/deploy/workspace.Dockerfile @@ -98,8 +98,11 @@ COPY packages/opencode-plugin/package.json packages/opencode-plugin/package.json # devDependencies and breaks the typecheck. RUN bun install --frozen-lockfile -# Source for the package we actually build +# Source for the package we actually build. The V2 runner imports @fro-bot/runtime/workspace-v2, a +# TypeScript-source workspace package that the build type-checks and bundles in, so its source comes too +# (deploy/gateway.Dockerfile does the same). COPY apps/workspace-agent/ apps/workspace-agent/ +COPY packages/runtime/ packages/runtime/ RUN bun run --filter @fro-bot/workspace-agent build @@ -366,6 +369,27 @@ RUN set -euo pipefail \ && rm -f "/tmp/${oc_asset}.tar.gz" /tmp/SHA256SUMS \ && opencode --version +# Stock V2 (same version test as above): install the Systematic and fro-bot plugins into the agent's cache NOW, at +# build time, as uid 10001 and under a bound. The server then finds them by spec at boot with no registry access, +# which is what the --network none smoke test proves. An unbounded boot-time install would hang `GET /api/agent` +# for as long as the registry hangs (probe P10). The versions must equal SYSTEMATIC_V2_VERSION and +# FRO_BOT_PLUGIN_VERSION in packages/runtime/src/shared/constants.ts; deploy/scripts/workspace-v2-image.test.mjs +# keeps them in step, and the plugin gate refuses a server whose plugins differ from what the workspace expects. +# Harness and V1 builds skip this step entirely. +ARG SYSTEMATIC_V2_VERSION=3.27.0 +ARG FRO_BOT_PLUGIN_VERSION=0.1.0 +COPY deploy/scripts/install-opencode-v2-plugins.sh /usr/local/lib/workspace-scripts/install-opencode-v2-plugins.sh +RUN set -euo pipefail \ + && case "${OPENCODE_VERSION}" in \ + *[!0-9A-Za-z.+_-]*) echo "OPENCODE_VERSION contains disallowed characters: ${OPENCODE_VERSION}" >&2; exit 1 ;; \ + *harness*) exit 0 ;; \ + [2-9].*|[1-9][0-9]*.*) : ;; \ + *) exit 0 ;; \ + esac \ + && sh /usr/local/lib/workspace-scripts/install-opencode-v2-plugins.sh \ + "@fro.bot/systematic@${SYSTEMATIC_V2_VERSION}" \ + "@fro.bot/opencode-plugin@${FRO_BOT_PLUGIN_VERSION}" + # Disable OpenCode's file watcher by default: nothing in this container consumes # file-change events, so the watcher is pure overhead. A deployer can still # override this through compose. Accepted consequence: OpenCode's cached VCS @@ -379,6 +403,15 @@ ENV OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER=true # still override this through compose. ENV OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true +# The V2 spelling of the file-watcher switch above (V2 has no environment flag for background subagents; they are +# native). The V1 launch spec copies only the two EXPERIMENTAL_ names, so a V1 child never sees this one. +ENV OPENCODE_DISABLE_FILEWATCHER=true + +# Which OpenCode this image carries. The workspace agent selects its V1 or V2 supervised runner, proxy and disposer +# from it (major 2 or above is V2) and reports it on /readyz, where the gateway picks its run loop. It is the same +# build argument that selected the binary above, so the two cannot disagree; it is not an operator setting. +ENV WORKSPACE_IMAGE_OPENCODE_VERSION=${OPENCODE_VERSION} + # Base workspace OpenCode config: declare the Systematic plugin and disable # autoupdate. The model and provider block are NOT baked — the entrypoint # overlays them at runtime from WORKSPACE_OPENCODE_MODEL and @@ -392,9 +425,23 @@ ENV OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true # near. The entrypoint's agent-uid subprocess reads this file (world-readable # is sufficient — no write access is needed) and writes the merged result to # the agent's own $XDG_CONFIG_HOME. +# +# Stock V2 gets a base without a plugin entry: the Systematic and fro-bot plugins are listed in the config content the +# workspace agent passes at spawn (and installed at build time above), and `plugin` is the V1 key. The entrypoint +# rewrites the global file it builds from this base at every start, so a plugin listed only there would not survive. RUN mkdir -p /usr/local/share/fro-bot \ - && printf '{\n "$schema": "https://opencode.ai/config.json",\n "autoupdate": false,\n "plugin": ["@fro.bot/systematic@%s"]\n}\n' "${SYSTEMATIC_VERSION}" \ - > /usr/local/share/fro-bot/opencode.base.json \ + && case "${OPENCODE_VERSION}" in \ + *harness*) v2=0 ;; \ + [2-9].*|[1-9][0-9]*.*) v2=1 ;; \ + *) v2=0 ;; \ + esac \ + && if [ "${v2}" = 1 ]; then \ + printf '{\n "$schema": "https://opencode.ai/config.json",\n "update": "disable"\n}\n' \ + > /usr/local/share/fro-bot/opencode.base.json; \ + else \ + printf '{\n "$schema": "https://opencode.ai/config.json",\n "autoupdate": false,\n "plugin": ["@fro.bot/systematic@%s"]\n}\n' "${SYSTEMATIC_VERSION}" \ + > /usr/local/share/fro-bot/opencode.base.json; \ + fi \ && chown 0:0 /usr/local/share/fro-bot/opencode.base.json \ && chmod 0644 /usr/local/share/fro-bot/opencode.base.json @@ -411,6 +458,18 @@ COPY deploy/managed-config/opencode.json /etc/opencode/opencode.json RUN chown 0:0 /usr/local/lib/fro-bot/plugins/no-task-reuse.mjs /etc/opencode/opencode.json \ && chmod 0644 /usr/local/lib/fro-bot/plugins/no-task-reuse.mjs /etc/opencode/opencode.json +# Stock V2 replaces the managed config with its V2 form (deploy/managed-config/opencode-v2.json). The reuse guard is +# the fro-bot plugin there, loaded from the config content, so the V1 plugin file above stays in the image but is +# not referenced. Root-owned and 0644 like the V1 file. +COPY deploy/managed-config/opencode-v2.json /usr/local/share/fro-bot/opencode-managed.v2.json +RUN case "${OPENCODE_VERSION}" in \ + *harness*) : ;; \ + [2-9].*|[1-9][0-9]*.*) cp /usr/local/share/fro-bot/opencode-managed.v2.json /etc/opencode/opencode.json ;; \ + *) : ;; \ + esac \ + && chown 0:0 /usr/local/share/fro-bot/opencode-managed.v2.json /etc/opencode/opencode.json \ + && chmod 0644 /usr/local/share/fro-bot/opencode-managed.v2.json /etc/opencode/opencode.json + # Production node_modules + bundled entrypoint (mirror gateway.Dockerfile layout). COPY --from=build /workspace/node_modules ./node_modules COPY --from=build /workspace/apps/workspace-agent/package.json ./apps/workspace-agent/package.json diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 1342aa1726..9709a3629d 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -9,6 +9,11 @@ "import": "./src/index.ts", "default": "./src/index.ts" }, + "./workspace-v2": { + "types": "./src/agent/workspace-v2.ts", + "import": "./src/agent/workspace-v2.ts", + "default": "./src/agent/workspace-v2.ts" + }, "./session-v2": { "types": "./src/session/v2.ts", "import": "./src/session/v2.ts", diff --git a/packages/runtime/src/agent/server-v2.test.ts b/packages/runtime/src/agent/server-v2.test.ts index 4320c984ed..d3cee0158b 100644 --- a/packages/runtime/src/agent/server-v2.test.ts +++ b/packages/runtime/src/agent/server-v2.test.ts @@ -383,6 +383,48 @@ describe('startOpenCodeV2Server', () => { expect(errorOf(result)?.kind).toBe('spawn-failed') }) + it('runs the child as the uid and gid it is given', async () => { + // #given the current identity, which an unprivileged process may always switch to + const created = fixture() + const uid = process.getuid?.() + const gid = process.getgid?.() + if (uid === undefined || gid === undefined) return + + // #when started with that uid and gid + const result = await start(created, {uid, gid}) + + // #then the server runs + expect(result.success).toBe(true) + }) + + it('refuses to start when the child cannot take the uid it was asked to drop to', async () => { + // #given an unprivileged parent asked to run the child as a different uid. A start that silently ignored + // the option would run the server as the caller, which is exactly what a privilege drop must never do. + const uid = process.getuid?.() + if (uid === undefined || uid === 0) return + const created = fixture() + + // #when + const result = await start(created, {uid: uid + 1, gid: process.getgid?.() ?? 0}) + + // #then the spawn fails instead of falling back to the caller's identity + expect(result.success).toBe(false) + expect(errorOf(result)?.kind).toBe('spawn-failed') + }) + + it('rejects a uid without a gid, and a gid without a uid', async () => { + // #given a half-specified identity + const created = fixture() + + // #when + const uidOnly = await start(created, {uid: 1234}) + const gidOnly = await start(created, {gid: 1234}) + + // #then neither spawns + expect(errorOf(uidOnly)?.kind).toBe('invalid-options') + expect(errorOf(gidOnly)?.kind).toBe('invalid-options') + }) + it('rejects a listening line that advertises a different port, and kills the child', async () => { // #given a server that advertises a port other than the one it was told to bind const created = fixture({advertisePort: 4096}) diff --git a/packages/runtime/src/agent/server-v2.ts b/packages/runtime/src/agent/server-v2.ts index f828556c01..b628e74258 100644 --- a/packages/runtime/src/agent/server-v2.ts +++ b/packages/runtime/src/agent/server-v2.ts @@ -165,6 +165,12 @@ export interface StartOpenCodeV2ServerOptions { readonly env?: NodeJS.ProcessEnv /** Default: a free loopback port picked here. */ readonly port?: number + /** + * Run the child as this uid and gid (POSIX). The workspace service is root and the server must not be. Both or + * neither: when the spawn cannot take the identity it fails (`spawn-failed`), never falling back to the caller's. + */ + readonly uid?: number + readonly gid?: number readonly timeouts?: Partial /** * The caller's deadline, honoured for the server's whole life. Aborting during startup fails the start. Aborting @@ -366,6 +372,10 @@ export async function startOpenCodeV2Server( if (options.port !== undefined && !isValidPort(options.port)) { return err(startError('invalid-options', 'The OpenCode V2 server port is out of range')) } + // A uid without a gid would keep the caller's group, which is half a privilege drop. + if ((options.uid === undefined) !== (options.gid === undefined)) { + return err(startError('invalid-options', 'The OpenCode V2 server uid and gid must be given together')) + } // Per spawn, never reused and never logged. 256 random bits. const password = randomBytes(32).toString('base64url') @@ -404,14 +414,29 @@ export async function startOpenCodeV2Server( // A self-update would replace the digest-verified binary mid-run. childEnv.OPENCODE_DISABLE_AUTOUPDATE = '1' - const child = spawn(options.binaryPath, ['serve', '--stdio', '--hostname', LOOPBACK, '--port', String(port)], { - cwd: options.directory, - env: childEnv, - // A held pipe: with 'ignore' the server exits after about 150 ms (P15). stderr is not captured because the - // server may echo config content, which can hold provider credentials. - stdio: ['pipe', 'pipe', 'ignore'], - detached: process.platform !== 'win32', - }) + let child: ChildProcess + try { + child = spawn(options.binaryPath, ['serve', '--stdio', '--hostname', LOOPBACK, '--port', String(port)], { + cwd: options.directory, + env: childEnv, + // A held pipe: with 'ignore' the server exits after about 150 ms (P15). stderr is not captured because the + // server may echo config content, which can hold provider credentials. + stdio: ['pipe', 'pipe', 'ignore'], + detached: process.platform !== 'win32', + ...(options.uid === undefined ? {} : {uid: options.uid}), + ...(options.gid === undefined ? {} : {gid: options.gid}), + }) + } catch (error) { + // Node throws synchronously for a uid or gid the process may not take (EPERM). The password is in the env + // that was passed, so only the code is reported. + const code = (error as NodeJS.ErrnoException).code + const failure = startError( + 'spawn-failed', + `Failed to spawn the OpenCode V2 binary${code === undefined ? '' : ` (${code})`}`, + ) + logger.warning('OpenCode V2 server start failed', {kind: failure.kind}) + return err(failure) + } const state = trackChild(child) // The server closing its end of the pipe at exit raises EPIPE on a late write; there is nothing to do about it. child.stdin?.on('error', () => undefined) diff --git a/packages/runtime/src/agent/workspace-v2.test.ts b/packages/runtime/src/agent/workspace-v2.test.ts new file mode 100644 index 0000000000..3a86123ecd --- /dev/null +++ b/packages/runtime/src/agent/workspace-v2.test.ts @@ -0,0 +1,32 @@ +import {describe, expect, it} from 'vitest' + +import {DEFAULT_SYSTEMATIC_VERSION} from '../shared/constants.js' +import * as workspaceV2 from './workspace-v2.js' + +describe('workspace V2 entry', () => { + it('exposes the spawn, config and gate pieces the workspace agent needs', () => { + // #then the subpath carries each runtime piece by name, so a consumer never imports the full barrel + expect(typeof workspaceV2.startOpenCodeV2Server).toBe('function') + expect(typeof workspaceV2.buildWorkspaceConfigV2).toBe('function') + expect(typeof workspaceV2.verifyPermissionRules).toBe('function') + expect(workspaceV2.DEFAULT_OPENCODE_V2_TIMEOUTS.pollIntervalMs).toBeGreaterThan(0) + }) + + it('pins Systematic for V2 at exactly 3.27.0, the first release that loads there', () => { + // #then the V2 pin is exact, with no range, and names the plugin id the V2 build registers + expect(workspaceV2.SYSTEMATIC_V2_VERSION).toBe('3.27.0') + expect(workspaceV2.SYSTEMATIC_V2_PLUGIN_ID).toBe('systematic') + expect(workspaceV2.SYSTEMATIC_PACKAGE).toBe('@fro.bot/systematic') + }) + + it('leaves the V1 Systematic default untouched', () => { + // #then the V1 default is the version V1 images and Action runs install + expect(DEFAULT_SYSTEMATIC_VERSION).toBe('3.26.4') + }) + + it('pins the fro-bot plugin at an exact version', () => { + expect(workspaceV2.FRO_BOT_PLUGIN_VERSION).toMatch(/^\d+\.\d+\.\d+$/) + expect(workspaceV2.FRO_BOT_PLUGIN_PACKAGE).toBe('@fro.bot/opencode-plugin') + expect(workspaceV2.FRO_BOT_PLUGIN_ID).toBe('fro-bot.opencode-plugin') + }) +}) diff --git a/packages/runtime/src/agent/workspace-v2.ts b/packages/runtime/src/agent/workspace-v2.ts new file mode 100644 index 0000000000..cfcbe787f6 --- /dev/null +++ b/packages/runtime/src/agent/workspace-v2.ts @@ -0,0 +1,37 @@ +/** + * The OpenCode V2 pieces the workspace agent runs on, published as the `@fro-bot/runtime/workspace-v2` subpath so the + * agent skips the full barrel (and with it the V1 SDK). + */ + +export { + FRO_BOT_PLUGIN_ID, + FRO_BOT_PLUGIN_PACKAGE, + FRO_BOT_PLUGIN_VERSION, + SYSTEMATIC_PACKAGE, + SYSTEMATIC_V2_PLUGIN_ID, + SYSTEMATIC_V2_VERSION, +} from '../shared/constants.js' + +export type {Logger} from '../shared/logger.js' + +export {verifyPermissionRules} from './permission-gate-v2.js' + +export type {AgentListSource, PermissionGateError, PermissionGateExpectations} from './permission-gate-v2.js' + +export type {BuiltConfigV2} from './permission-rules-v2.js' + +export {DEFAULT_OPENCODE_V2_TIMEOUTS, startOpenCodeV2Server} from './server-v2.js' + +export type { + OpenCodeV2ExitInfo, + OpenCodeV2ServerHandle, + OpenCodeV2ShutdownOptions, + OpenCodeV2ShutdownResult, + OpenCodeV2StartError, + OpenCodeV2Timeouts, + StartOpenCodeV2ServerOptions, +} from './server-v2.js' + +export {buildWorkspaceConfigV2} from './workspace-config-v2.js' + +export type {WorkspaceConfigV2Inputs} from './workspace-config-v2.js' diff --git a/packages/runtime/src/shared/constants.ts b/packages/runtime/src/shared/constants.ts index 61651022f7..915bb3961f 100644 --- a/packages/runtime/src/shared/constants.ts +++ b/packages/runtime/src/shared/constants.ts @@ -59,9 +59,21 @@ export const DEFAULT_MODEL = { export const DEFAULT_OPENCODE_VERSION = '1.19.4+harness.5bde2635' export const DEFAULT_SYSTEMATIC_VERSION = '3.27.1' +// Systematic for OpenCode V2. 3.27.0 is the first release that loads there, so the V2 path pins it exactly and +// DEFAULT_SYSTEMATIC_VERSION stays the V1 default. The plugin id is the `id` of the object the package's default +// export carries (`systematicPlugin = {id: "systematic", ...}` in `@fro.bot/systematic@3.27.0` `dist/index.js`); `GET /api/plugin` lists it +// under that id. Dockerfile ARG SYSTEMATIC_V2_VERSION in deploy/workspace.Dockerfile must equal this version, and +// deploy/scripts/workspace-v2-image.test.mjs keeps them in step. +export const SYSTEMATIC_PACKAGE = '@fro.bot/systematic' +export const SYSTEMATIC_V2_VERSION = '3.27.0' +export const SYSTEMATIC_V2_PLUGIN_ID = 'systematic' + // The fro-bot OpenCode V2 plugin (packages/opencode-plugin); the package duplicates the id and a test keeps them equal. +// The version is the one published to npm, installed at image build time. Dockerfile ARG FRO_BOT_PLUGIN_VERSION must +// equal it. export const FRO_BOT_PLUGIN_PACKAGE = '@fro.bot/opencode-plugin' export const FRO_BOT_PLUGIN_ID = 'fro-bot.opencode-plugin' +export const FRO_BOT_PLUGIN_VERSION = '0.1.0' // Retry configuration lives in the Action layer's src/features/agent/retry.ts, // colocated with the retry logic that consumes the values. From 06a89148e0a62d17a633820e561543febfe1bd85 Mon Sep 17 00:00:00 2001 From: "Marcus R. Brown" Date: Wed, 28 Oct 2026 21:08:45 -0700 Subject: [PATCH 2/4] fix(workspace): leave the V2 file watcher at its default --- apps/workspace-agent/src/opencode-server-v2.test.ts | 10 +++++----- apps/workspace-agent/src/opencode-server-v2.ts | 7 +++---- deploy/scripts/workspace-v2-image.test.mjs | 4 ++-- deploy/workspace.Dockerfile | 4 ---- 4 files changed, 10 insertions(+), 15 deletions(-) diff --git a/apps/workspace-agent/src/opencode-server-v2.test.ts b/apps/workspace-agent/src/opencode-server-v2.test.ts index b29a3abc4b..402d916ed2 100644 --- a/apps/workspace-agent/src/opencode-server-v2.test.ts +++ b/apps/workspace-agent/src/opencode-server-v2.test.ts @@ -158,23 +158,23 @@ describe('buildOpencodeV2Env', () => { expect(env.HOME).toBe('/home/opencode') }) - it('points the database at the fresh path and uses the V2 file-watcher flag, not the V1 spelling', () => { + it('points the database at the fresh path and leaves the file watcher at its default', () => { // #when const env = buildOpencodeV2Env({}, '/db/spawn-a/opencode.db') // #then expect(env.OPENCODE_DB).toBe('/db/spawn-a/opencode.db') - expect(env.OPENCODE_DISABLE_FILEWATCHER).toBe('true') + expect(env).not.toHaveProperty('OPENCODE_DISABLE_FILEWATCHER') expect(env).not.toHaveProperty('OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER') expect(env).not.toHaveProperty('OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS') }) - it('lets the deployer override the file-watcher flag, as V1 images allowed', () => { + it('does not forward a parent file-watcher flag: it is not on the allowlist', () => { // #when - const env = buildOpencodeV2Env({OPENCODE_DISABLE_FILEWATCHER: 'false'}, '/db/opencode.db') + const env = buildOpencodeV2Env({OPENCODE_DISABLE_FILEWATCHER: 'true'}, '/db/opencode.db') // #then - expect(env.OPENCODE_DISABLE_FILEWATCHER).toBe('false') + expect(env).not.toHaveProperty('OPENCODE_DISABLE_FILEWATCHER') }) }) diff --git a/apps/workspace-agent/src/opencode-server-v2.ts b/apps/workspace-agent/src/opencode-server-v2.ts index 1f08b2d2e6..aa5786bd95 100644 --- a/apps/workspace-agent/src/opencode-server-v2.ts +++ b/apps/workspace-agent/src/opencode-server-v2.ts @@ -93,9 +93,9 @@ function copyEnvIfSet(source: NodeJS.ProcessEnv, target: Record, * Builds the V2 child environment from an allowlist, never by subtracting from `parentEnv`. The runtime spawn then * adds the password, the config content and the project-config and auto-update switches itself. * - * `OPENCODE_DISABLE_FILEWATCHER` is the V2 spelling of the V1 flag the image bakes; nothing consumes file-change - * events in this container, so it defaults on, and a deployer may still override it. V2 has no environment flag for - * background subagents (they are native), so no V1 flag is carried over. + * The file watcher stays at V2's default: probe P3 found no difference with it on or off, so + * `OPENCODE_DISABLE_FILEWATCHER` is neither set nor defaulted here, and the allowlist does not forward it from the + * parent. V2 has no environment flag for background subagents (they are native), so no V1 flag is carried over. */ export function buildOpencodeV2Env(parentEnv: NodeJS.ProcessEnv, databasePath: string): NodeJS.ProcessEnv { const env: Record = { @@ -109,7 +109,6 @@ export function buildOpencodeV2Env(parentEnv: NodeJS.ProcessEnv, databasePath: s XDG_STATE_HOME: AGENT_XDG_STATE_HOME, TMPDIR: AGENT_TMPDIR, OPENCODE_DB: databasePath, - OPENCODE_DISABLE_FILEWATCHER: parentEnv.OPENCODE_DISABLE_FILEWATCHER ?? 'true', } for (const name of PROXY_ENV_NAMES) copyEnvIfSet(parentEnv, env, name) diff --git a/deploy/scripts/workspace-v2-image.test.mjs b/deploy/scripts/workspace-v2-image.test.mjs index 946d589a1a..ff88b49138 100644 --- a/deploy/scripts/workspace-v2-image.test.mjs +++ b/deploy/scripts/workspace-v2-image.test.mjs @@ -88,9 +88,9 @@ test('the image carries the version the workspace agent selects on, from the sam assert.match(dockerfile, new RegExp(`^ENV ${name}=\\$\\{OPENCODE_VERSION\\}$`, 'm')) }) -test('the V2 file-watcher flag uses the V2 spelling, beside the V1 flag the V1 child still gets', () => { +test('the image leaves the V2 file watcher at its default and keeps the V1 flags the V1 child still gets', () => { // #then - assert.match(dockerfile, /^ENV OPENCODE_DISABLE_FILEWATCHER=true$/m) + assert.doesNotMatch(dockerfile, /OPENCODE_DISABLE_FILEWATCHER/) assert.match(dockerfile, /^ENV OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER=true$/m) assert.match(dockerfile, /^ENV OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true$/m) }) diff --git a/deploy/workspace.Dockerfile b/deploy/workspace.Dockerfile index 121fa2bfe6..772c9560c4 100644 --- a/deploy/workspace.Dockerfile +++ b/deploy/workspace.Dockerfile @@ -403,10 +403,6 @@ ENV OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER=true # still override this through compose. ENV OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true -# The V2 spelling of the file-watcher switch above (V2 has no environment flag for background subagents; they are -# native). The V1 launch spec copies only the two EXPERIMENTAL_ names, so a V1 child never sees this one. -ENV OPENCODE_DISABLE_FILEWATCHER=true - # Which OpenCode this image carries. The workspace agent selects its V1 or V2 supervised runner, proxy and disposer # from it (major 2 or above is V2) and reports it on /readyz, where the gateway picks its run loop. It is the same # build argument that selected the binary above, so the two cannot disagree; it is not an operator setting. From efcfc618875e5f2d1848cff315cf4517983e80b4 Mon Sep 17 00:00:00 2001 From: "Marcus R. Brown" Date: Wed, 28 Oct 2026 21:11:16 -0700 Subject: [PATCH 3/4] refactor(workspace): use the default Systematic pin for V2 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. --- .../src/opencode-server-v2.integration.test.ts | 4 ++-- .../workspace-agent/src/opencode-server-v2.test.ts | 7 +++++-- apps/workspace-agent/src/opencode-server-v2.ts | 4 ++-- .../scripts/install-opencode-v2-plugins.test.mjs | 6 +++--- deploy/scripts/v2-image-smoke.mjs | 2 +- deploy/scripts/workspace-v2-image.test.mjs | 14 +++++++------- deploy/workspace.Dockerfile | 10 +++++----- packages/runtime/src/agent/workspace-v2.test.ts | 13 ++++--------- packages/runtime/src/agent/workspace-v2.ts | 2 +- packages/runtime/src/shared/constants.ts | 11 +++++------ 10 files changed, 35 insertions(+), 38 deletions(-) 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 d7487efcd9..1781c117b2 100644 --- a/apps/workspace-agent/src/opencode-server-v2.integration.test.ts +++ b/apps/workspace-agent/src/opencode-server-v2.integration.test.ts @@ -32,11 +32,11 @@ import {DatabaseSync} from 'node:sqlite' import {fileURLToPath} from 'node:url' import { + DEFAULT_SYSTEMATIC_VERSION, FRO_BOT_PLUGIN_ID, FRO_BOT_PLUGIN_VERSION, startOpenCodeV2Server, SYSTEMATIC_V2_PLUGIN_ID, - SYSTEMATIC_V2_VERSION, } from '@fro-bot/runtime/workspace-v2' import {afterAll, beforeAll, describe, expect, it, vi} from 'vitest' @@ -366,7 +366,7 @@ describe.skipIf(!hasBinary)('the workspace V2 runner against the real stock bina } expect(await listPlugins()).toEqual(expect.arrayContaining([SYSTEMATIC_V2_PLUGIN_ID, FRO_BOT_PLUGIN_ID])) expect(WORKSPACE_V2_PLUGIN_SPECS).toEqual([ - `@fro.bot/systematic@${SYSTEMATIC_V2_VERSION}`, + `@fro.bot/systematic@${DEFAULT_SYSTEMATIC_VERSION}`, `@fro.bot/opencode-plugin@${FRO_BOT_PLUGIN_VERSION}`, ]) diff --git a/apps/workspace-agent/src/opencode-server-v2.test.ts b/apps/workspace-agent/src/opencode-server-v2.test.ts index 402d916ed2..92314a2d46 100644 --- a/apps/workspace-agent/src/opencode-server-v2.test.ts +++ b/apps/workspace-agent/src/opencode-server-v2.test.ts @@ -11,7 +11,7 @@ import os from 'node:os' import path from 'node:path' import process from 'node:process' -import {FRO_BOT_PLUGIN_ID, SYSTEMATIC_V2_PLUGIN_ID} from '@fro-bot/runtime/workspace-v2' +import {DEFAULT_SYSTEMATIC_VERSION, FRO_BOT_PLUGIN_ID, SYSTEMATIC_V2_PLUGIN_ID} from '@fro-bot/runtime/workspace-v2' import {afterEach, describe, expect, it, vi} from 'vitest' import { @@ -320,7 +320,10 @@ describe('runSupervisedOpencodeV2', () => { expect(start?.directory).toBe('/workspace/repos') expect(start?.port).toBe(54_321) expect(start?.expectedPluginIds).toEqual([SYSTEMATIC_V2_PLUGIN_ID, FRO_BOT_PLUGIN_ID]) - expect(start?.config?.plugins).toEqual(['@fro.bot/systematic@3.27.0', '@fro.bot/opencode-plugin@0.1.0']) + expect(start?.config?.plugins).toEqual([ + `@fro.bot/systematic@${DEFAULT_SYSTEMATIC_VERSION}`, + '@fro.bot/opencode-plugin@0.1.0', + ]) expect(start?.config?.permissions).toEqual( expect.arrayContaining([{action: 'external_directory', resource: '*', effect: 'ask'}]), ) diff --git a/apps/workspace-agent/src/opencode-server-v2.ts b/apps/workspace-agent/src/opencode-server-v2.ts index aa5786bd95..e66964a86e 100644 --- a/apps/workspace-agent/src/opencode-server-v2.ts +++ b/apps/workspace-agent/src/opencode-server-v2.ts @@ -31,13 +31,13 @@ import {setTimeout as sleep} from 'node:timers/promises' import { buildWorkspaceConfigV2, + DEFAULT_SYSTEMATIC_VERSION, FRO_BOT_PLUGIN_ID, FRO_BOT_PLUGIN_PACKAGE, FRO_BOT_PLUGIN_VERSION, startOpenCodeV2Server, SYSTEMATIC_PACKAGE, SYSTEMATIC_V2_PLUGIN_ID, - SYSTEMATIC_V2_VERSION, verifyPermissionRules, } from '@fro-bot/runtime/workspace-v2' @@ -66,7 +66,7 @@ const DATABASE_FILE_NAME = 'opencode.db' /** The plugin specs the image installs at build time, in the order the config lists them. */ export const WORKSPACE_V2_PLUGIN_SPECS: readonly string[] = [ - `${SYSTEMATIC_PACKAGE}@${SYSTEMATIC_V2_VERSION}`, + `${SYSTEMATIC_PACKAGE}@${DEFAULT_SYSTEMATIC_VERSION}`, `${FRO_BOT_PLUGIN_PACKAGE}@${FRO_BOT_PLUGIN_VERSION}`, ] diff --git a/deploy/scripts/install-opencode-v2-plugins.test.mjs b/deploy/scripts/install-opencode-v2-plugins.test.mjs index 4a6ea9d2c8..52be096a4c 100644 --- a/deploy/scripts/install-opencode-v2-plugins.test.mjs +++ b/deploy/scripts/install-opencode-v2-plugins.test.mjs @@ -15,7 +15,7 @@ import {test} from 'node:test' import {fileURLToPath} from 'node:url' const SCRIPT = fileURLToPath(new URL('./install-opencode-v2-plugins.sh', import.meta.url)) -const SYSTEMATIC = '@fro.bot/systematic@3.27.0' +const SYSTEMATIC = '@fro.bot/systematic@3.27.1' const FRO_BOT = '@fro.bot/opencode-plugin@0.1.0' /** @@ -136,14 +136,14 @@ test('refuses a bound that is not a number, before anything is installed', () => for (const spec of [ '@fro.bot/systematic@latest', - '@fro.bot/systematic@^3.27.0', + '@fro.bot/systematic@^3.27.1', '@fro.bot/systematic@3', '@fro.bot/systematic', '@fro.bot/opencode-plugin@>=0.1.0', '@evil/plugin@1.0.0', 'git+https://example.com/x.git', '/tmp/local-plugin', - '@fro.bot/systematic@3.27.0; touch /tmp/pwned', + '@fro.bot/systematic@3.27.1; touch /tmp/pwned', ]) { test(`refuses ${JSON.stringify(spec)}: only the two fro.bot packages at an exact version`, () => { // #given diff --git a/deploy/scripts/v2-image-smoke.mjs b/deploy/scripts/v2-image-smoke.mjs index 3788a6d094..dcf6e9da7b 100644 --- a/deploy/scripts/v2-image-smoke.mjs +++ b/deploy/scripts/v2-image-smoke.mjs @@ -6,7 +6,7 @@ // -e HOME=/home/opencode -e XDG_CONFIG_HOME=/home/opencode/.config -e XDG_DATA_HOME=/home/opencode/.local/share \ // -e XDG_CACHE_HOME=/home/opencode/.cache -e XDG_STATE_HOME=/home/opencode/.local/state \ // -v "$PWD/deploy/scripts/v2-image-smoke.mjs:/smoke.mjs:ro" --entrypoint node "$IMAGE" /smoke.mjs \ -// '@fro.bot/systematic@3.27.0' '@fro.bot/opencode-plugin@0.1.0' +// '@fro.bot/systematic@3.27.1' '@fro.bot/opencode-plugin@0.1.0' // // What it proves that nothing else here can, because Docker is what builds the image: // 1. the musl stock binary starts on Alpine as the agent uid (`serve --stdio`, held stdin, explicit port); diff --git a/deploy/scripts/workspace-v2-image.test.mjs b/deploy/scripts/workspace-v2-image.test.mjs index ff88b49138..f9fd0cabd2 100644 --- a/deploy/scripts/workspace-v2-image.test.mjs +++ b/deploy/scripts/workspace-v2-image.test.mjs @@ -38,16 +38,16 @@ const V2_VERSION_ARM = '[2-9].*|[1-9][0-9]*.*)' test('the plugin versions the image installs are the ones the workspace agent expects', () => { // #then the build-time ARG defaults equal the runtime constants, so the specs the server is told to load are the // specs that were cached - assert.equal(argDefault('SYSTEMATIC_V2_VERSION'), constant('SYSTEMATIC_V2_VERSION')) + assert.equal(argDefault('SYSTEMATIC_VERSION'), constant('DEFAULT_SYSTEMATIC_VERSION')) assert.equal(argDefault('FRO_BOT_PLUGIN_VERSION'), constant('FRO_BOT_PLUGIN_VERSION')) }) -test('the V2 pins are exact versions, and the V1 Systematic default is untouched by them', () => { - // #then - assert.match(constant('SYSTEMATIC_V2_VERSION'), /^\d+\.\d+\.\d+$/) +test('the plugin pins are exact versions, and V2 has no Systematic pin of its own', () => { + // #then V1 and V2 share the one Systematic pin that Renovate manages + assert.match(constant('DEFAULT_SYSTEMATIC_VERSION'), /^\d+\.\d+\.\d+$/) assert.match(constant('FRO_BOT_PLUGIN_VERSION'), /^\d+\.\d+\.\d+$/) - assert.equal(argDefault('SYSTEMATIC_VERSION'), constant('DEFAULT_SYSTEMATIC_VERSION')) - assert.notEqual(constant('SYSTEMATIC_V2_VERSION'), constant('DEFAULT_SYSTEMATIC_VERSION')) + assert.doesNotMatch(constants, /SYSTEMATIC_V2_VERSION/) + assert.doesNotMatch(dockerfile, /SYSTEMATIC_V2_VERSION/) }) test('the plugins are installed at build time by the bounded script, behind the V2 version test', () => { @@ -60,7 +60,7 @@ test('the plugins are installed at build time by the bounded script, behind the // #then it is skipped for harness and V1 builds, and passes exactly the two pinned specs assert.match(run[0], /\*harness\*\) exit 0/) assert.ok(run[0].includes(V2_VERSION_ARM)) - assert.ok(run[0].includes('"@fro.bot/systematic@${SYSTEMATIC_V2_VERSION}"')) + assert.ok(run[0].includes('"@fro.bot/systematic@${SYSTEMATIC_VERSION}"')) assert.ok(run[0].includes('"@fro.bot/opencode-plugin@${FRO_BOT_PLUGIN_VERSION}"')) }) diff --git a/deploy/workspace.Dockerfile b/deploy/workspace.Dockerfile index 772c9560c4..fe90c89cc9 100644 --- a/deploy/workspace.Dockerfile +++ b/deploy/workspace.Dockerfile @@ -372,11 +372,11 @@ RUN set -euo pipefail \ # Stock V2 (same version test as above): install the Systematic and fro-bot plugins into the agent's cache NOW, at # build time, as uid 10001 and under a bound. The server then finds them by spec at boot with no registry access, # which is what the --network none smoke test proves. An unbounded boot-time install would hang `GET /api/agent` -# for as long as the registry hangs (probe P10). The versions must equal SYSTEMATIC_V2_VERSION and -# FRO_BOT_PLUGIN_VERSION in packages/runtime/src/shared/constants.ts; deploy/scripts/workspace-v2-image.test.mjs -# keeps them in step, and the plugin gate refuses a server whose plugins differ from what the workspace expects. +# for as long as the registry hangs (probe P10). Systematic installs at SYSTEMATIC_VERSION (declared above; V1 and V2 +# share it), and the versions must equal DEFAULT_SYSTEMATIC_VERSION and FRO_BOT_PLUGIN_VERSION in +# packages/runtime/src/shared/constants.ts; deploy/scripts/workspace-v2-image.test.mjs keeps them in step, and the +# plugin gate refuses a server whose plugins differ from what the workspace expects. # Harness and V1 builds skip this step entirely. -ARG SYSTEMATIC_V2_VERSION=3.27.0 ARG FRO_BOT_PLUGIN_VERSION=0.1.0 COPY deploy/scripts/install-opencode-v2-plugins.sh /usr/local/lib/workspace-scripts/install-opencode-v2-plugins.sh RUN set -euo pipefail \ @@ -387,7 +387,7 @@ RUN set -euo pipefail \ *) exit 0 ;; \ esac \ && sh /usr/local/lib/workspace-scripts/install-opencode-v2-plugins.sh \ - "@fro.bot/systematic@${SYSTEMATIC_V2_VERSION}" \ + "@fro.bot/systematic@${SYSTEMATIC_VERSION}" \ "@fro.bot/opencode-plugin@${FRO_BOT_PLUGIN_VERSION}" # Disable OpenCode's file watcher by default: nothing in this container consumes diff --git a/packages/runtime/src/agent/workspace-v2.test.ts b/packages/runtime/src/agent/workspace-v2.test.ts index 3a86123ecd..a4624dec0c 100644 --- a/packages/runtime/src/agent/workspace-v2.test.ts +++ b/packages/runtime/src/agent/workspace-v2.test.ts @@ -1,6 +1,5 @@ import {describe, expect, it} from 'vitest' -import {DEFAULT_SYSTEMATIC_VERSION} from '../shared/constants.js' import * as workspaceV2 from './workspace-v2.js' describe('workspace V2 entry', () => { @@ -12,18 +11,14 @@ describe('workspace V2 entry', () => { expect(workspaceV2.DEFAULT_OPENCODE_V2_TIMEOUTS.pollIntervalMs).toBeGreaterThan(0) }) - it('pins Systematic for V2 at exactly 3.27.0, the first release that loads there', () => { - // #then the V2 pin is exact, with no range, and names the plugin id the V2 build registers - expect(workspaceV2.SYSTEMATIC_V2_VERSION).toBe('3.27.0') + it('installs Systematic for V2 at the shared default, an exact version with no range', () => { + // #then V1 and V2 share one Systematic pin, and the V2 build registers the plugin under the expected id + expect(workspaceV2.DEFAULT_SYSTEMATIC_VERSION).toMatch(/^\d+\.\d+\.\d+$/) + expect(workspaceV2).not.toHaveProperty('SYSTEMATIC_V2_VERSION') expect(workspaceV2.SYSTEMATIC_V2_PLUGIN_ID).toBe('systematic') expect(workspaceV2.SYSTEMATIC_PACKAGE).toBe('@fro.bot/systematic') }) - it('leaves the V1 Systematic default untouched', () => { - // #then the V1 default is the version V1 images and Action runs install - expect(DEFAULT_SYSTEMATIC_VERSION).toBe('3.26.4') - }) - it('pins the fro-bot plugin at an exact version', () => { expect(workspaceV2.FRO_BOT_PLUGIN_VERSION).toMatch(/^\d+\.\d+\.\d+$/) expect(workspaceV2.FRO_BOT_PLUGIN_PACKAGE).toBe('@fro.bot/opencode-plugin') diff --git a/packages/runtime/src/agent/workspace-v2.ts b/packages/runtime/src/agent/workspace-v2.ts index cfcbe787f6..9e38b82d21 100644 --- a/packages/runtime/src/agent/workspace-v2.ts +++ b/packages/runtime/src/agent/workspace-v2.ts @@ -4,12 +4,12 @@ */ export { + DEFAULT_SYSTEMATIC_VERSION, FRO_BOT_PLUGIN_ID, FRO_BOT_PLUGIN_PACKAGE, FRO_BOT_PLUGIN_VERSION, SYSTEMATIC_PACKAGE, SYSTEMATIC_V2_PLUGIN_ID, - SYSTEMATIC_V2_VERSION, } from '../shared/constants.js' export type {Logger} from '../shared/logger.js' diff --git a/packages/runtime/src/shared/constants.ts b/packages/runtime/src/shared/constants.ts index 915bb3961f..c30e840a42 100644 --- a/packages/runtime/src/shared/constants.ts +++ b/packages/runtime/src/shared/constants.ts @@ -59,13 +59,12 @@ export const DEFAULT_MODEL = { export const DEFAULT_OPENCODE_VERSION = '1.19.4+harness.5bde2635' export const DEFAULT_SYSTEMATIC_VERSION = '3.27.1' -// Systematic for OpenCode V2. 3.27.0 is the first release that loads there, so the V2 path pins it exactly and -// DEFAULT_SYSTEMATIC_VERSION stays the V1 default. The plugin id is the `id` of the object the package's default -// export carries (`systematicPlugin = {id: "systematic", ...}` in `@fro.bot/systematic@3.27.0` `dist/index.js`); `GET /api/plugin` lists it -// under that id. Dockerfile ARG SYSTEMATIC_V2_VERSION in deploy/workspace.Dockerfile must equal this version, and -// deploy/scripts/workspace-v2-image.test.mjs keeps them in step. +// Systematic for OpenCode V2. 3.27.x loads on both V1 and V2, so the V2 path installs DEFAULT_SYSTEMATIC_VERSION too +// and there is no separate V2 pin. The plugin id is the `id` of the object the package's default export carries +// (`systematicPlugin = {id: "systematic", ...}` in `@fro.bot/systematic` `dist/index.js`); `GET /api/plugin` lists it +// under that id. Dockerfile ARG SYSTEMATIC_VERSION in deploy/workspace.Dockerfile must equal DEFAULT_SYSTEMATIC_VERSION, +// and deploy/scripts/workspace-v2-image.test.mjs keeps them in step. export const SYSTEMATIC_PACKAGE = '@fro.bot/systematic' -export const SYSTEMATIC_V2_VERSION = '3.27.0' export const SYSTEMATIC_V2_PLUGIN_ID = 'systematic' // The fro-bot OpenCode V2 plugin (packages/opencode-plugin); the package duplicates the id and a test keeps them equal. From 11c40c3fe352abe7c76fea09513848dae83aa3fb Mon Sep 17 00:00:00 2001 From: "Marcus R. Brown" Date: Wed, 28 Oct 2026 23:41:59 -0700 Subject: [PATCH 4/4] fix(workspace): bound the V2 permission-gate request and honour cancellation after database preparation --- .../src/opencode-server-v2.test.ts | 143 +++++++++++++++++- .../workspace-agent/src/opencode-server-v2.ts | 38 ++++- 2 files changed, 175 insertions(+), 6 deletions(-) diff --git a/apps/workspace-agent/src/opencode-server-v2.test.ts b/apps/workspace-agent/src/opencode-server-v2.test.ts index 92314a2d46..b069d9bd3b 100644 --- a/apps/workspace-agent/src/opencode-server-v2.test.ts +++ b/apps/workspace-agent/src/opencode-server-v2.test.ts @@ -11,7 +11,12 @@ import os from 'node:os' import path from 'node:path' import process from 'node:process' -import {DEFAULT_SYSTEMATIC_VERSION, FRO_BOT_PLUGIN_ID, SYSTEMATIC_V2_PLUGIN_ID} from '@fro-bot/runtime/workspace-v2' +import { + DEFAULT_SYSTEMATIC_VERSION, + FRO_BOT_PLUGIN_ID, + SYSTEMATIC_V2_PLUGIN_ID, + verifyPermissionRules, +} from '@fro-bot/runtime/workspace-v2' import {afterEach, describe, expect, it, vi} from 'vitest' import { @@ -52,6 +57,8 @@ function createHandle(options: { authorization: string activeSessions?: readonly string[] | 'fail' agentsBody?: unknown + /** Replaces the `/api/agent` answer, to stall the response headers or the body. */ + agentResponse?: () => Promise }): 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 => { @@ -81,7 +88,11 @@ function createHandle(options: { status: 200, }) } - if (pathname === '/api/agent') return new Response(JSON.stringify(agentsBody), {status: 200}) + if (pathname === '/api/agent') { + return options.agentResponse === undefined + ? new Response(JSON.stringify(agentsBody), {status: 200}) + : options.agentResponse() + } return new Response('{}', {status: 404}) }, authorizationHeader: () => options.authorization, @@ -268,6 +279,28 @@ describe('createUpstreamCredentials', () => { }) }) +function recordStatuses(): {statusRef: SupervisedStatusRef; seen: SupervisedStatusRef['status'][]} { + const seen: SupervisedStatusRef['status'][] = [] + let status: SupervisedStatusRef['status'] = 'starting' + return { + seen, + statusRef: { + get status() { + return status + }, + set status(value) { + status = value + seen.push(value) + }, + }, + } +} + +function stalledBody(): Response { + // Headers arrive; the body never produces a byte and never closes. + return new Response(new ReadableStream({start: () => undefined}), {status: 200}) +} + describe('runSupervisedOpencodeV2', () => { const NO_BACKOFF = {initialBackoffMs: 0, maxBackoffMs: 0} @@ -513,6 +546,112 @@ describe('runSupervisedOpencodeV2', () => { expect(statusRef.status).toBe('degraded') }) + it('does not spawn when the service aborts while the database is being prepared', async () => { + // #given a database preparation that is still pending when the service aborts + const {options, startOptions} = setup() + const controller = new AbortController() + let finishPreparing: (databasePath: string) => void = () => undefined + const preparing = new Promise(resolve => { + finishPreparing = resolve + }) + const startServer = vi.fn(scriptedStart([createHandle({authorization: 'Basic x'})], startOptions)) + options.deps = {...options.deps, prepareDatabase: async () => preparing, startServer} + const done = runSupervisedOpencodeV2({...options, signal: controller.signal}) + controller.abort() + + // #when the preparation completes after the abort + finishPreparing('/db/spawn-1/opencode.db') + await done + + // #then the supervisor returned without starting a server during shutdown + expect(startServer).not.toHaveBeenCalled() + }) + + describe('permission-gate request', () => { + const REAL_GATE = {verifyRules: verifyPermissionRules} + + it('shuts the server down and never reports ready when the response headers stall', async () => { + // #given an /api/agent response that never arrives + const fake = createHandle({ + authorization: 'Basic one', + agentResponse: async () => new Promise(() => {}), + }) + const {options, startOptions, credentials, capture} = setup({maxAttempts: 1, requestTimeoutMs: 50}) + const {statusRef, seen} = recordStatuses() + options.deps = {...options.deps, ...REAL_GATE, startServer: scriptedStart([fake], startOptions)} + + // #when the supervisor runs + await runSupervisedOpencodeV2({...options, statusRef}) + + // #then the request was bounded, the handle shut down, and nothing was ever published as ready + expect(fake.shutdown).toHaveBeenCalledTimes(1) + expect(seen).not.toContain('ready') + expect(statusRef.status).toBe('degraded') + expect(credentials.authorization()).toBeUndefined() + expect(capture.serialized()).toContain('agent-list-unavailable') + }) + + it('shuts the server down and never reports ready when the response body stalls', async () => { + // #given headers that arrive and a body that never completes + const fake = createHandle({authorization: 'Basic one', agentResponse: async () => stalledBody()}) + const {options, startOptions, credentials} = setup({maxAttempts: 1, requestTimeoutMs: 50}) + const {statusRef, seen} = recordStatuses() + options.deps = {...options.deps, ...REAL_GATE, startServer: scriptedStart([fake], startOptions)} + + // #when + await runSupervisedOpencodeV2({...options, statusRef}) + + // #then the timeout stays in force through the body read + expect(fake.shutdown).toHaveBeenCalledTimes(1) + expect(seen).not.toContain('ready') + expect(statusRef.status).toBe('degraded') + expect(credentials.authorization()).toBeUndefined() + }) + + it('hands the request an abort signal so a real connection is released', async () => { + // #given a handle that records the signal its /api/agent request carries + let received: AbortSignal | undefined + const fake = createHandle({authorization: 'Basic one'}) + const handle: OpenCodeV2ServerHandle = { + ...fake.handle, + fetch: async (pathname, init) => { + if (pathname === '/api/agent') received = init?.signal ?? undefined + return fake.handle.fetch(pathname, init) + }, + } + const {options, startOptions} = setup({maxAttempts: 1}) + const controller = new AbortController() + options.deps = {...options.deps, ...REAL_GATE, startServer: scriptedStart([{...fake, handle}], startOptions)} + const done = runSupervisedOpencodeV2({...options, signal: controller.signal}) + await vi.waitFor(() => expect(received).toBeInstanceOf(AbortSignal)) + + // #when / #then + controller.abort() + await done + }) + + it('shuts the server down when the service aborts during a stalled request', async () => { + // #given a stalled request with a timeout far longer than the test + const fake = createHandle({authorization: 'Basic one', agentResponse: async () => stalledBody()}) + const {options, startOptions, credentials} = setup({maxAttempts: 3, requestTimeoutMs: 60_000}) + const {statusRef, seen} = recordStatuses() + const controller = new AbortController() + options.deps = {...options.deps, ...REAL_GATE, startServer: scriptedStart([fake], startOptions)} + const done = runSupervisedOpencodeV2({...options, statusRef, signal: controller.signal}) + await vi.waitFor(() => expect(fake.fetchPaths).toContain('/api/agent')) + + // #when the service aborts + controller.abort() + await done + + // #then the request was cancelled, the handle shut down once, and no second server was started + expect(fake.shutdown).toHaveBeenCalledTimes(1) + expect(startOptions).toHaveLength(1) + expect(seen).not.toContain('ready') + expect(credentials.authorization()).toBeUndefined() + }) + }) + it('interrupts the sessions that are active, then shuts the server down, on abort', async () => { // #given a ready server with two running sessions const fake = createHandle({authorization: 'Basic one', activeSessions: ['ses_a', 'ses_b']}) diff --git a/apps/workspace-agent/src/opencode-server-v2.ts b/apps/workspace-agent/src/opencode-server-v2.ts index e66964a86e..cd330f7660 100644 --- a/apps/workspace-agent/src/opencode-server-v2.ts +++ b/apps/workspace-agent/src/opencode-server-v2.ts @@ -206,6 +206,8 @@ export interface RunSupervisedOpencodeV2Options { readonly port?: number /** Per-phase readiness bound for each attempt (listen, `/api/info`, plugin activation). Default 60000. */ readonly readyTimeoutMs?: number + /** Bound on each runtime request and on the permission-gate read, headers and body together. Default 5000. */ + readonly requestTimeoutMs?: number /** Absolute OpenCode executable. Default: `/usr/local/bin/opencode`. */ readonly binaryPath?: string /** Literal absolute directories outside the checkout that stay reachable without a prompt. Default none. */ @@ -232,6 +234,25 @@ function toRuntimeLogger(logger: Logger): RuntimeLogger { /** Bound on the active-session listing a shutdown does before it interrupts. */ const ACTIVE_LISTING_TIMEOUT_MS = 5000 +/** Per-request bound for the runtime's own calls and for the permission-gate read. */ +const REQUEST_TIMEOUT_MS = 5000 + +/** + * Settles with `work`, or rejects as soon as `signal` aborts, whichever comes first. A response whose body stalls + * after the headers arrived is covered even when the transport underneath ignores the signal. + */ +async function untilAborted(signal: AbortSignal, work: Promise): Promise { + return new Promise((resolve, reject) => { + const onAbort = (): void => reject(signal.reason) + if (signal.aborted) { + onAbort() + return + } + signal.addEventListener('abort', onAbort, {once: true}) + work.then(resolve, reject).finally(() => signal.removeEventListener('abort', onAbort)) + }) +} + /** * Every session still active on this spawn. The database is fresh per spawn, so every session on the server belongs * to this run. Fail-soft: a listing that cannot be read leaves the shutdown with nothing to interrupt, and the @@ -263,6 +284,7 @@ export async function runSupervisedOpencodeV2(options: RunSupervisedOpencodeV2Op parentEnv = process.env, port = OPENCODE_PORT, readyTimeoutMs = 60_000, + requestTimeoutMs = REQUEST_TIMEOUT_MS, binaryPath = OPENCODE_EXECUTABLE_PATH, allowedExternalDirectories = [], maxAttempts = 4, @@ -346,6 +368,8 @@ export async function runSupervisedOpencodeV2(options: RunSupervisedOpencodeV2Op let handle: OpenCodeV2ServerHandle | undefined try { const databasePath = await prepareDatabase() + // An abort during preparation found no start in flight to cancel, so nothing else stops this attempt. + if (isAborted()) return const startController = new AbortController() startAbort = startController const started = await startServer( @@ -363,7 +387,7 @@ export async function runSupervisedOpencodeV2(options: RunSupervisedOpencodeV2Op infoTimeoutMs: readyTimeoutMs, agentReadyTimeoutMs: readyTimeoutMs, // Teardown has to finish inside main.ts's 25s drain: listing 5s + interrupts 8s + exit 5s + kill 2s. - requestTimeoutMs: 5000, + requestTimeoutMs, interruptSettleTimeoutMs: 8000, exitTimeoutMs: 5000, killConfirmTimeoutMs: 2000, @@ -399,9 +423,15 @@ export async function runSupervisedOpencodeV2(options: RunSupervisedOpencodeV2Op const gate = await verifyRules( { listAgents: async () => { - const response = await serving.fetch('/api/agent') - if (!response.ok) throw new Error(`HTTP ${response.status}`) - return response.json() + // One bound for headers and body, joined with service cancellation: the handle's raw fetch has none. + const bound = AbortSignal.timeout(requestTimeoutMs) + const gateSignal = signal === undefined ? bound : AbortSignal.any([signal, bound]) + const read = async (): Promise => { + const response = await serving.fetch('/api/agent', {signal: gateSignal}) + if (!response.ok) throw new Error(`HTTP ${response.status}`) + return response.json() + } + return untilAborted(gateSignal, read()) }, }, built.data.expectations,