From 2e5a24e17769f206fb350a719bf7c85eadb348c7 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 31 Aug 2026 10:43:34 -0500 Subject: [PATCH] feat(runner): add qualified OpenCode runtime (#12588) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Paperclip Runner provides a durable execution boundary for supported providers. > - The current production runtime supports Codex but cannot execute OpenCode sessions. > - OpenCode needs a qualified transport, strict input mapping, and normalized events. > - This pull request adds the OpenCode runtime as one isolated provider unit. > - The benefit is a reviewable provider expansion that does not weaken the existing Codex path. ## Linked Issues or Issue Description **Subsystem affected** Cross-cutting: packages/paperclip-runner and the Codex-local adapter configuration contract. **Problem or motivation** Paperclip Runner has provider-neutral contracts, but the production backend factory cannot start a qualified OpenCode session. This blocks OpenCode from using the durable runner path. **Proposed solution** Add the qualified OpenCode app-server proxy, driver, MCP bridge, backend, fixtures, and factory wiring. Keep existing Codex behavior unchanged. **Alternatives considered** Keeping OpenCode only on the direct adapter path would avoid this runtime work, but it would not provide durable runner recovery or normalized provider events. **Roadmap alignment** ROADMAP.md does not list a conflicting provider-runtime project. This change extends the existing Paperclip Runner architecture. ## What Changed - Added the qualified OpenCode app-server proxy and input queue. - Added collaboration-mode and provider-event normalization. - Added the OpenCode MCP bridge and native session backend. - Added strict fixtures and focused unit coverage. - Added only the package exports and adapter configuration required by this runtime. - Kept deferred SDK, lab, eval, and public package surfaces out of this change. ## Verification - GitHub Actions is the authoritative verification environment for this PR. - Run the package type checks and focused OpenCode tests in CI. - Run repository typecheck, test, build, security, and policy gates through the stack-aware workflow. - Local tests were not run because this checkout is resource constrained. ## Risks - OpenCode protocol changes could affect event normalization or recovery. - The driver fails closed on malformed input and unsupported runtime behavior. - Existing Codex selection remains unchanged unless the stored provider is OpenCode. > For core feature work, check [ROADMAP.md](ROADMAP.md) first and discuss it in #dev before opening the PR. Feature PRs that overlap with planned core work may need to be redirected. ## Model Used OpenAI Codex, GPT-5.6, with repository tools, code execution, and parallel agent review. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either linked existing issues or described the issue in-PR following the relevant issue template - [x] I have not referenced internal or instance-local Paperclip issues or links - [x] My branch name describes the change and contains no internal Paperclip ticket id - [ ] I have run tests locally and they pass — GitHub Actions is authoritative for this resource-constrained checkout - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge ## Stack - Position: 1 of 4 - Base: master - Next: additional qualified provider runtimes --- .../codex-local/src/ui/build-config.test.ts | 40 + .../codex-local/src/ui/build-config.ts | 34 +- packages/paperclip-runner/README.md | 16 +- packages/paperclip-runner/package.json | 4 +- .../protocol/fixtures/questions/opencode.json | 43 + .../paperclip-runner/protocol/manifest.json | 6 + .../backends/native-backend-factory.test.ts | 47 +- .../src/backends/native-backend-factory.ts | 20 + .../src/backends/opencode-native-backend.ts | 32 + .../src/cli/opencode-app-server-proxy.test.ts | 23 + .../src/cli/opencode-app-server-proxy.ts | 340 +++ .../opencode-proxy-collaboration-mode.test.ts | 44 + .../cli/opencode-proxy-collaboration-mode.ts | 41 + .../src/cli/opencode-proxy-events.test.ts | 30 + .../src/cli/opencode-proxy-events.ts | 15 + .../src/cli/opencode-proxy-input.test.ts | 59 + .../src/cli/opencode-proxy-input.ts | 1 + .../src/cli/opencode-proxy-task-envelope.ts | 38 + .../src/cli/serial-input-queue.ts | 7 +- .../provider-trace-file-sink.test.ts | 62 + .../src/contracts/provider-trace-file-sink.ts | 248 +++ .../src/drivers/opencode/mcp-bridge.test.ts | 182 ++ .../src/drivers/opencode/mcp-bridge.ts | 356 ++++ .../opencode/opencode-server-driver.test.ts | 920 ++++++++ .../opencode/opencode-server-driver.ts | 1855 +++++++++++++++++ packages/paperclip-runner/src/index.ts | 5 + .../test/acpx-codex-package-contract.test.mjs | 4 +- .../test/fixtures/fake-opencode-server.mjs | 307 +++ ...heartbeat-stale-queue-invalidation.test.ts | 34 +- 29 files changed, 4783 insertions(+), 30 deletions(-) create mode 100644 packages/paperclip-runner/protocol/fixtures/questions/opencode.json create mode 100644 packages/paperclip-runner/src/backends/opencode-native-backend.ts create mode 100644 packages/paperclip-runner/src/cli/opencode-app-server-proxy.test.ts create mode 100644 packages/paperclip-runner/src/cli/opencode-app-server-proxy.ts create mode 100644 packages/paperclip-runner/src/cli/opencode-proxy-collaboration-mode.test.ts create mode 100644 packages/paperclip-runner/src/cli/opencode-proxy-collaboration-mode.ts create mode 100644 packages/paperclip-runner/src/cli/opencode-proxy-events.test.ts create mode 100644 packages/paperclip-runner/src/cli/opencode-proxy-events.ts create mode 100644 packages/paperclip-runner/src/cli/opencode-proxy-input.test.ts create mode 100644 packages/paperclip-runner/src/cli/opencode-proxy-input.ts create mode 100644 packages/paperclip-runner/src/cli/opencode-proxy-task-envelope.ts create mode 100644 packages/paperclip-runner/src/contracts/provider-trace-file-sink.test.ts create mode 100644 packages/paperclip-runner/src/contracts/provider-trace-file-sink.ts create mode 100644 packages/paperclip-runner/src/drivers/opencode/mcp-bridge.test.ts create mode 100644 packages/paperclip-runner/src/drivers/opencode/mcp-bridge.ts create mode 100644 packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts create mode 100644 packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts create mode 100644 packages/paperclip-runner/test/fixtures/fake-opencode-server.mjs diff --git a/packages/adapters/codex-local/src/ui/build-config.test.ts b/packages/adapters/codex-local/src/ui/build-config.test.ts index 54e86b44796..97201f40f08 100644 --- a/packages/adapters/codex-local/src/ui/build-config.test.ts +++ b/packages/adapters/codex-local/src/ui/build-config.test.ts @@ -106,4 +106,44 @@ describe("buildPaperclipRunnerConfig", () => { expect(config).not.toHaveProperty(unsupportedKey); } }); + + it("builds a bounded OpenCode runner profile from schema values", () => { + const config = buildPaperclipRunnerConfig(makeValues({ + adapterType: "paperclip_runner", + model: "", + codexEngine: "acp", + dangerouslyBypassSandbox: true, + adapterSchemaValues: { + provider: "opencode", + opencodePermissionMode: "ask", + lifecycleMode: "warm", + idleTimeoutMs: 45_000, + }, + })); + + expect(config).toMatchObject({ + provider: "opencode", + model: "openrouter/deepseek/deepseek-v4-flash-0731", + opencodePermissionMode: "ask", + lifecycleMode: "warm", + idleTimeoutMs: 45_000, + }); + expect(config).not.toHaveProperty("engine"); + expect(config).not.toHaveProperty("dangerouslyBypassApprovalsAndSandbox"); + }); + + it("falls back to safe OpenCode defaults for invalid schema values", () => { + expect(buildPaperclipRunnerConfig(makeValues({ + adapterSchemaValues: { + provider: "opencode", + opencodePermissionMode: "unrestricted", + lifecycleMode: "forever", + idleTimeoutMs: -1, + }, + }))).toMatchObject({ + provider: "opencode", + opencodePermissionMode: "allow", + lifecycleMode: "per_turn", + }); + }); }); diff --git a/packages/adapters/codex-local/src/ui/build-config.ts b/packages/adapters/codex-local/src/ui/build-config.ts index af647cb1f08..f5090d136d2 100644 --- a/packages/adapters/codex-local/src/ui/build-config.ts +++ b/packages/adapters/codex-local/src/ui/build-config.ts @@ -61,7 +61,15 @@ export function buildCodexLocalConfig(v: CreateConfigValues): Record { const config = buildCodexLocalConfig(v); for (const unsupportedKey of [ @@ -82,5 +90,27 @@ export function buildPaperclipRunnerConfig(v: CreateConfigValues): Record 0 + ? configuredIdleTimeoutMs + : 300_000; + return { + ...config, + provider, + model: v.model || "openrouter/deepseek/deepseek-v4-flash-0731", + opencodePermissionMode: openCodePermissionMode( + schemaValues.opencodePermissionMode, + ), + lifecycleMode, + ...(lifecycleMode === "warm" ? { idleTimeoutMs } : {}), + }; } diff --git a/packages/paperclip-runner/README.md b/packages/paperclip-runner/README.md index 01a443d8dd4..0d8fb43da6d 100644 --- a/packages/paperclip-runner/README.md +++ b/packages/paperclip-runner/README.md @@ -20,7 +20,10 @@ The package also publishes the canonical semantic action declarations and their input and output schemas. Its package-local dispatcher projects only bound, run-authorized actions and emits redacted semantic receipts. -The first and only installed provider is Codex. Dynamic semantic tools remain +The production-selected provider remains Codex. The package also contains the +qualified OpenCode 1.18.17 driver, authenticated loopback MCP bridge, and +runnerd-compatible proxy, but this slice does not authorize the server to +select OpenCode for a fresh native run. Dynamic semantic tools remain undiscoverable unless the hidden server coordinator projects one of the five same-task read bindings for an already persisted native Codex run. Catalog membership alone does not grant authority. The server can now create and start @@ -36,8 +39,8 @@ The package has two initial public surfaces: - `@paperclipai/paperclip-runner/testing` adds Node-only fixture loading and a provider-neutral semantic conformance kit for deterministic test adapters. -No SDK, browser, React, eval, live-console, lab, or provider-experiment entry -point is exported. The package remains private in this wave. The server route +No SDK, browser, React, eval, live-console, or lab entry point is exported. The +package remains private in this wave. The server route at `/api/runner/v1/connect/:runId` has no authority until the hidden coordinator registers an exact existing run binding. Fresh native starts are rejected unless the instance `enableNativeRunner` flag is enabled. Existing direct @@ -49,6 +52,13 @@ an installed server does not depend on a separate system Rust installation or a manually copied binary. `pnpm-lock.yaml` remains under the repository's existing lockfile process. +The `paperclip-runner-opencode-proxy` binary adapts the pinned OpenCode server +protocol to the normalized harness contract. It admits only a bounded provider +environment, uses an authenticated loopback MCP endpoint, validates dynamic +tool inputs, keeps terminal completion tools private to the runner boundary, +and preserves exact provider session identity for recovery. The backend factory +requires an explicit runtime directory before constructing this provider. + The package also builds `paperclip-runner-acpx-sidecar`. This bounded v2 stdin/stdout bridge admits the qualified Codex ACPX profile only. It validates the exact model, session identity, tool catalog, structured input, and terminal diff --git a/packages/paperclip-runner/package.json b/packages/paperclip-runner/package.json index 6dfc54abee6..d844a3976e2 100644 --- a/packages/paperclip-runner/package.json +++ b/packages/paperclip-runner/package.json @@ -8,6 +8,7 @@ }, "type": "module", "bin": { + "paperclip-runner-opencode-proxy": "./dist/cli/opencode-app-server-proxy.js", "paperclip-runner-acpx-sidecar": "./dist/cli/acpx-runtime-sidecar.js" }, "exports": { @@ -58,7 +59,8 @@ "@agentclientprotocol/codex-acp": "1.6.2", "acpx": "0.13.1", "ajv": "^8.20.0", - "json-schema-to-ts": "^3.1.1" + "json-schema-to-ts": "^3.1.1", + "opencode-ai": "1.18.17" }, "devDependencies": { "@types/node": "^24.0.0", diff --git a/packages/paperclip-runner/protocol/fixtures/questions/opencode.json b/packages/paperclip-runner/protocol/fixtures/questions/opencode.json new file mode 100644 index 00000000000..e450b722aa4 --- /dev/null +++ b/packages/paperclip-runner/protocol/fixtures/questions/opencode.json @@ -0,0 +1,43 @@ +{ + "schema": "paperclip.question_adapter_fixture.v1", + "adapter": "opencode", + "nativeRequest": { + "type": "question.asked", + "properties": { + "id": "request-1", + "sessionID": "session-1", + "title": "Deployment input", + "questions": [{ + "question": "Where should we deploy?", + "header": "Environment", + "required": true, + "options": [ + { "label": "Staging", "description": "Deploy to staging first." }, + { "label": "Production", "description": "Deploy directly to production." } + ], + "multiple": false + }] + } + }, + "canonicalQuestionSet": { + "schema": "paperclip.question_set.v1", + "title": "Deployment input", + "submitLabel": "Submit answers", + "questions": [{ + "id": "environment", + "header": "Environment", + "prompt": "Where should we deploy?", + "required": true, + "answerMode": "single_select", + "options": [ + { "id": "option-1", "label": "Staging", "description": "Deploy to staging first." }, + { "id": "option-2", "label": "Production", "description": "Deploy directly to production." } + ] + }] + }, + "canonicalResponse": { + "schema": "paperclip.question_response.v1", + "answers": { "environment": { "selectedOptionIds": ["option-1"] } } + }, + "nativeResponse": { "answers": [["Staging"]] } +} diff --git a/packages/paperclip-runner/protocol/manifest.json b/packages/paperclip-runner/protocol/manifest.json index aded16edaee..a0a0dffce48 100644 --- a/packages/paperclip-runner/protocol/manifest.json +++ b/packages/paperclip-runner/protocol/manifest.json @@ -187,6 +187,12 @@ "expectation": "accept", "compatibilityCase": "codex-structured-input" }, + { + "path": "fixtures/questions/opencode.json", + "sha256": "7e5f753d04321f78a38a4362b2a4c7aeacbb4736d4db72a699f23c245884da72", + "expectation": "accept", + "compatibilityCase": "opencode-structured-input" + }, { "path": "fixtures/replay/duplicate-event.json", "sha256": "779c146966d2825ffcde88bfff8b1fb238c162d40c578980ad3bd1a7def405d7", diff --git a/packages/paperclip-runner/src/backends/native-backend-factory.test.ts b/packages/paperclip-runner/src/backends/native-backend-factory.test.ts index 01914acf9c0..79c0e4db6a7 100644 --- a/packages/paperclip-runner/src/backends/native-backend-factory.test.ts +++ b/packages/paperclip-runner/src/backends/native-backend-factory.test.ts @@ -93,6 +93,23 @@ function acpxExecution(agent: "codex" | "pi" = "codex"): NativeExecutionInput { }; } +function opencodeExecution(): NativeExecutionInput { + return { + ...execution(), + session: { + normalizedSessionId: "session", + driverKind: "opencode_server", + protocolVersion: 1, + lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null }, + }, + provider: { + kind: "opencode", + model: "openrouter/model", + permissionMode: "allow", + }, + }; +} + describe("native backend factory", () => { it("constructs the Codex backend without starting its transport", async () => { const backend = createNativeSessionBackend(execution(), { @@ -111,17 +128,27 @@ describe("native backend factory", () => { }); }); - it("fails closed when a deferred provider reaches the factory", () => { + it("requires an explicit runtime root for OpenCode", () => { expect(() => - createNativeSessionBackend( - execution({ - kind: "opencode", - model: "openrouter/model", - }), - ), - ).toThrow( - "Native backend for opencode is not included in the Codex-first runner", - ); + createNativeSessionBackend(opencodeExecution()), + ).toThrow("OpenCode native backend requires an instance runtime directory"); + }); + + it("constructs the OpenCode backend without starting its process", async () => { + const backend = createNativeSessionBackend(opencodeExecution(), { + opencodeRuntimeDirectory: "/runtime", + }); + + await expect(backend.descriptor()).resolves.toMatchObject({ + kind: "runner", + name: "opencode_server", + version: "1.18.17", + capabilities: { + resume: true, + interruption: true, + dynamicTools: true, + }, + }); }); it("constructs the qualified Codex ACPX backend without starting ACPX", async () => { diff --git a/packages/paperclip-runner/src/backends/native-backend-factory.ts b/packages/paperclip-runner/src/backends/native-backend-factory.ts index 8148034ade5..76003ef48da 100644 --- a/packages/paperclip-runner/src/backends/native-backend-factory.ts +++ b/packages/paperclip-runner/src/backends/native-backend-factory.ts @@ -12,6 +12,7 @@ import { createCodexAcpxNativeSessionBackend, type CodexAcpxNativeSessionBackendOptions, } from "./codex-acpx-native-backend.js"; +import { createOpenCodeNativeSessionBackend } from "./opencode-native-backend.js"; export interface NativeBackendFactoryOptions extends Omit< CodexNativeSessionBackendOptions, @@ -24,6 +25,9 @@ export interface NativeBackendFactoryOptions extends Omit< acpxEnvironment?: NodeJS.ProcessEnv; acpxManagedCodexCredentialSourcePath?: string; acpxDynamicToolHandler?: CodexAcpxNativeSessionBackendOptions["dynamicToolHandler"]; + opencodeRuntimeDirectory?: string; + opencodeEnvironment?: NodeJS.ProcessEnv; + opencodeCommand?: string; } /** @@ -35,6 +39,22 @@ export function createNativeSessionBackend( input: NativeExecutionInput, options: NativeBackendFactoryOptions = {}, ): NativeSessionBackend { + if (input.provider.kind === "opencode") { + if (!options.opencodeRuntimeDirectory?.trim()) { + throw new Error( + "OpenCode native backend requires an instance runtime directory", + ); + } + return createOpenCodeNativeSessionBackend(input, { + runtimeDirectory: options.opencodeRuntimeDirectory, + environment: options.opencodeEnvironment, + command: options.opencodeCommand, + runnerInstanceId: options.runnerInstanceId, + onSpawn: options.onSpawn, + dynamicTools: options.dynamicTools, + dynamicToolHandler: options.dynamicToolHandler, + }); + } if (input.provider.kind === "acpx") { if (input.provider.agent !== "codex") { throw new Error( diff --git a/packages/paperclip-runner/src/backends/opencode-native-backend.ts b/packages/paperclip-runner/src/backends/opencode-native-backend.ts new file mode 100644 index 00000000000..4023ed06dad --- /dev/null +++ b/packages/paperclip-runner/src/backends/opencode-native-backend.ts @@ -0,0 +1,32 @@ +import { createCodexTaskEnvelope } from "../contracts/codex.js"; +import type { NativeExecutionInput } from "../contracts/native-execution.js"; +import type { NativeSessionBackend } from "../contracts/native-session-backend.js"; +import { OpenCodeServerDriver, type OpenCodeServerDriverOptions } from "../drivers/opencode/opencode-server-driver.js"; +import { HarnessDriverBackend } from "./harness-driver-backend.js"; +import { nativeSystemInstructions, nativeTaskConstraints } from "./runtime-context.js"; + +export function createOpenCodeNativeSessionBackend( + input: NativeExecutionInput, + options: Omit, +): NativeSessionBackend { + if (input.provider.kind !== "opencode" || !input.provider.model) { + throw new Error("OpenCode native backend requires a persisted OpenCode provider/model selection"); + } + return new HarnessDriverBackend(new OpenCodeServerDriver({ + ...options, + systemInstructions: nativeSystemInstructions(input), + runtimeContext: "runtimeContext" in input ? input.runtimeContext : null, + model: input.provider.model, + permissionMode: input.provider.permissionMode ?? "allow", + taskEnvelope: createCodexTaskEnvelope({ + objective: input.completionContract.contract.objective, + contractRevision: input.completionContract.contract.revision, + criteria: input.completionContract.contract.criteria, + constraints: [ + "Work only inside the supplied working directory.", + ...nativeTaskConstraints(input), + "Return one semantic completion result through paperclip_finish or paperclip_block.", + ], + }), + })); +} diff --git a/packages/paperclip-runner/src/cli/opencode-app-server-proxy.test.ts b/packages/paperclip-runner/src/cli/opencode-app-server-proxy.test.ts new file mode 100644 index 00000000000..2a5f028cd4d --- /dev/null +++ b/packages/paperclip-runner/src/cli/opencode-app-server-proxy.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; + +import { openCodeProxyTaskEnvelope } from "./opencode-proxy-task-envelope.js"; + +describe("OpenCode runnerd proxy task envelope", () => { + it("uses the durable completion-contract binding instead of a demo revision", () => { + expect(openCodeProxyTaskEnvelope({ + baseInstructions: "Complete only this task.", + completionContract: { + revision: "17", + criterionIds: ["objective", "verification"], + }, + })).toMatchObject({ + completionContract: { + revision: "17", + criteria: [ + { id: "objective" }, + { id: "verification" }, + ], + }, + }); + }); +}); diff --git a/packages/paperclip-runner/src/cli/opencode-app-server-proxy.ts b/packages/paperclip-runner/src/cli/opencode-app-server-proxy.ts new file mode 100644 index 00000000000..8aad11eb2cc --- /dev/null +++ b/packages/paperclip-runner/src/cli/opencode-app-server-proxy.ts @@ -0,0 +1,340 @@ +#!/usr/bin/env node +import { createInterface } from "node:readline"; +import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { resolve } from "node:path"; + +import type { HarnessRuntimeRequestResolution, HarnessSession, PersistedHarnessSession } from "../contracts/harness-driver.js"; +import { OpenCodeServerDriver } from "../drivers/opencode/opencode-server-driver.js"; +import { parseNativeRuntimeContext } from "../contracts/runtime-context.js"; +import { openCodeProxyTaskEnvelope } from "./opencode-proxy-task-envelope.js"; +import { + shouldAnnounceOpenCodeProxyTurn, + shouldForwardOpenCodeProxyItem, +} from "./opencode-proxy-events.js"; +import { enqueueOpenCodeProxyInput } from "./opencode-proxy-input.js"; +import { + assertOpenCodeProxyCollaborationMode, + openCodeProxyCollaborationModes, +} from "./opencode-proxy-collaboration-mode.js"; + +type RpcMessage = { id?: string | number; method?: string; params?: unknown; result?: unknown; error?: unknown }; + +const input = createInterface({ input: process.stdin, crlfDelay: Infinity }); +const pending = new Map(); +let nextServerRequestId = 1; +let driver: OpenCodeServerDriver | null = null; +let session: HarnessSession | null = null; +let eventPump: Promise | null = null; +let cwd = ""; +let activeModel = ""; +let activeTurnId: string | null = null; +const announcedTurnIds = new Set(); + +function openCodeCommand(): string { + const configured = process.env.PAPERCLIP_OPENCODE_COMMAND?.trim(); + if (configured && configured !== "opencode") return configured; + try { + return createRequire(import.meta.url).resolve("opencode-ai/bin/opencode.exe"); + } catch { + return configured || "opencode"; + } +} + +function send(value: unknown): void { + process.stdout.write(`${JSON.stringify(value)}\n`); +} + +function requestController(method: string, params: unknown): Promise { + const id = `opencode-${nextServerRequestId++}`; + send({ id, method, params }); + return new Promise((resolveValue, reject) => pending.set(id, { resolve: resolveValue, reject })); +} + +function record(value: unknown): Record { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : {}; +} + +function text(value: unknown, fallback = ""): string { + return typeof value === "string" ? value : fallback; +} + +function runtimeDirectory(): string { + const configured = process.env.PAPERCLIP_OPENCODE_RUNTIME_DIR?.trim(); + if (!configured) throw new Error("PAPERCLIP_OPENCODE_RUNTIME_DIR is required"); + return resolve(configured); +} + +async function open(params: Record, resume: boolean): Promise> { + if (session) return threadResponse(session.ids().providerSessionId ?? session.ids().driverSessionId); + cwd = resolve(text(params.cwd, process.cwd())); + const model = text(params.model); + if (!model.includes("/")) throw new Error("OpenCode proxy requires model in provider/model form"); + activeModel = model; + const dynamicTools = Array.isArray(params.dynamicTools) ? params.dynamicTools.map(record) : []; + const runtimeContextPath = process.env.PAPERCLIP_NATIVE_RUNTIME_CONTEXT_PATH?.trim(); + const runtimeContext = runtimeContextPath + ? parseNativeRuntimeContext(JSON.parse(readFileSync(runtimeContextPath, "utf8"))) + : null; + driver = new OpenCodeServerDriver({ + model, + command: openCodeCommand(), + runtimeDirectory: runtimeDirectory(), + environment: process.env, + runnerInstanceId: process.env.PAPERCLIP_RUNNER_INSTANCE_ID ?? "paperclip-runnerd-opencode", + taskEnvelope: openCodeProxyTaskEnvelope(params), + systemInstructions: text(params.baseInstructions, "Complete only the supplied task."), + runtimeContext, + dynamicTools, + dynamicToolHandler: async (call) => requestController("item/tool/call", { + threadId: session?.ids().driverSessionId ?? "opening", + turnId: call.turnId, + callId: call.callId, + tool: call.tool, + arguments: call.arguments, + }), + onDiagnostic: (message) => process.stderr.write(`[opencode] ${message}\n`), + // runnerd gives this proxy its own process group. Keep `opencode serve` in + // that same group so runnerd's TERM/KILL fallback cannot orphan it. + isolateProcessGroup: false, + }); + if (resume) { + const threadId = text(params.threadId); + const snapshot: PersistedHarnessSession = { + driverKind: "opencode_server", + driverSessionId: threadId, + providerSessionId: threadId, + runId: process.env.PAPERCLIP_RUN_ID ?? "runnerd-run", + normalizedSessionId: process.env.PAPERCLIP_NORMALIZED_SESSION_ID ?? threadId, + activeTurnId: null, + lastSourceSequence: 0, + }; + const recovered = await driver.recoverSession(snapshot); + if (!recovered.recovered || !recovered.session) throw new Error(recovered.reason ?? "OpenCode recovery failed"); + session = recovered.session; + } else { + session = await driver.openSession({ + runId: process.env.PAPERCLIP_RUN_ID ?? "runnerd-run", + normalizedSessionId: process.env.PAPERCLIP_NORMALIZED_SESSION_ID ?? `runnerd-${Date.now()}`, + workingDirectory: cwd, + }); + } + eventPump = pumpEvents(session); + void eventPump.catch((error) => failProxy(error)); + return threadResponse(session.ids().providerSessionId ?? session.ids().driverSessionId); +} + +function threadResponse(id: string): Record { + return { thread: { id, sessionId: id, cwd }, model: activeModel }; +} + +function announceTurnStarted(opened: HarnessSession, turnId: string): void { + if (!shouldAnnounceOpenCodeProxyTurn(announcedTurnIds, turnId)) return; + send({ + method: "turn/started", + params: { + threadId: opened.ids().driverSessionId, + turnId, + turn: { id: turnId, status: "inProgress" }, + }, + }); +} + +async function pumpEvents(opened: HarnessSession): Promise { + for await (const event of opened.events()) { + const payload = record(event.payload); + if (event.eventType === "turn.started") { + if (typeof event.turnId === "string") announceTurnStarted(opened, event.turnId); + } else if (event.eventType === "item.started" || event.eventType === "item.delta" || event.eventType === "item.completed") { + // The inner OpenCode driver publishes session-scoped model metadata as + // an item before any turn exists. The outer Codex protocol facade emits + // its own model item, so forwarding this unbound duplicate would quite + // correctly trip the facade's strict turn-binding validator. + if (!shouldForwardOpenCodeProxyItem({ turnId: event.turnId, kind: payload.kind })) continue; + const assistantText = payload.kind === "text" && typeof payload.text === "string" + ? payload.text + : null; + const method = event.eventType === "item.delta" && assistantText !== null + ? "item/agentMessage/delta" + : event.eventType.replace(".", "/"); + const providerItem = record(payload.item); + const item = assistantText === null + ? payload.item ?? { id: event.itemId, type: payload.kind, text: payload.text } + : { ...providerItem, id: event.itemId, type: "agentMessage", text: assistantText }; + send({ + method, + params: { + threadId: opened.ids().driverSessionId, + turnId: event.turnId, + itemId: event.itemId, + ...payload, + item, + ...(method === "item/agentMessage/delta" ? { delta: assistantText } : {}), + }, + }); + if (payload.kind === "usage") { + const usage = record(payload.usage); + const cache = record(usage.cache); + send({ method: "thread/tokenUsage/updated", params: { + threadId: opened.ids().driverSessionId, + turnId: event.turnId, + tokenUsage: { total: { + inputTokens: usage.inputTokens ?? usage.input ?? 0, + outputTokens: usage.outputTokens ?? usage.output ?? 0, + cachedInputTokens: usage.cachedInputTokens ?? cache.read ?? 0, + reasoningTokens: usage.reasoningTokens ?? usage.reasoning ?? 0, + costUsd: usage.costUsd ?? usage.cost ?? null, + } }, + } }); + } + } else if (event.eventType === "run.result.proposed") { + send({ + method: "paperclip/runResult", + params: { + threadId: opened.ids().driverSessionId, + turnId: event.turnId, + itemId: event.itemId ?? "semantic-result", + result: payload, + }, + }); + } else if (event.eventType === "runtime_request.created") { + const request = record(payload.request); + const requestId = text(request.requestId); + const turnId = text(request.turnId, text(event.turnId, activeTurnId ?? "")); + if (!requestId || !turnId || !opened.resolveRuntimeRequest) { + throw new Error("OpenCode runtime request is not resolvable"); + } + // Keep consuming OpenCode SSE while the controller waits for the user. + // If the underlying provider disappears, the stream can then fail the + // proxy and runnerd will expire the still-pending canonical request. + void (async () => { + const controllerResponse = record(await requestController("paperclip/runtimeRequest", { + request, + })); + await opened.resolveRuntimeRequest!({ + requestId, + turnId, + resolution: record(controllerResponse.resolution) as HarnessRuntimeRequestResolution, + }); + })().catch((error) => failProxy(error)); + } else if (["turn.completed", "turn.failed", "turn.interrupted", "turn.cancelled"].includes(event.eventType)) { + const status = event.eventType.slice("turn.".length); + send({ method: "turn/completed", params: { threadId: opened.ids().driverSessionId, turnId: event.turnId, turn: { id: event.turnId, status } } }); + activeTurnId = null; + } else if (event.eventType === "harness.diagnostic") { + send({ method: "warning", params: { threadId: opened.ids().driverSessionId, message: payload.message ?? payload.code } }); + } + } +} + +async function handle(message: RpcMessage): Promise { + if (message.method === undefined && message.id !== undefined) { + const waiter = pending.get(String(message.id)); + if (!waiter) return; + pending.delete(String(message.id)); + if (message.error !== undefined) waiter.reject(new Error(JSON.stringify(message.error))); + else { + const contentItems = record(message.result).contentItems; + waiter.resolve(Array.isArray(contentItems) ? contentItems[0] ?? message.result : message.result); + } + return; + } + if (!message.method || message.id === undefined) return; + const params = record(message.params); + let result: unknown; + switch (message.method) { + case "initialize": + result = { user: { sessionId: "opencode" }, serverInfo: { name: "opencode", version: "1.18.17" } }; + break; + case "thread/start": result = await open(params, false); break; + case "thread/resume": result = await open(params, true); break; + case "collaborationMode/list": + result = openCodeProxyCollaborationModes(activeModel); + break; + case "turn/start": { + if (!session) throw new Error("OpenCode thread is not open"); + assertOpenCodeProxyCollaborationMode(params); + const inputItems = Array.isArray(params.input) ? params.input.map(record) : []; + const messageText = inputItems.map((entry) => text(entry.text)).filter(Boolean).join("\n"); + const turn = await session.startTurn({ message: { role: "user", text: messageText } }); + activeTurnId = turn.turnId; + // OpenCode normally publishes session/turn startup over SSE, but a fast + // completion can make the synchronous prompt response the only place the + // authoritative turn id is observed. Emit the normalized notification + // after this request's JSON-RPC response when SSE did not already announce + // it. runnerd buffers notifications received while awaiting a response, + // so replaying an earlier SSE announcement would violate strict turn + // binding at the outer driver. + queueMicrotask(() => { + announceTurnStarted(session!, turn.turnId); + }); + result = { turn: { id: turn.turnId, status: "inProgress" } }; + break; + } + case "turn/interrupt": + if (!session?.interrupt) throw new Error("OpenCode interruption is unavailable"); + await session.interrupt({ turnId: text(params.turnId, activeTurnId ?? "") }); + result = true; + break; + case "thread/read": + if (!session) throw new Error("OpenCode thread is not open"); + result = { thread: { id: session.ids().driverSessionId, cwd, turns: activeTurnId ? [{ id: activeTurnId, status: "inProgress" }] : [] }, transcript: await session.read?.() }; + break; + default: throw new Error(`Unsupported OpenCode proxy method ${message.method}`); + } + send({ id: message.id, result }); +} + +let pendingInput = Promise.resolve(); +let bootstrapFailure: Error | null = null; +input.on("line", (line) => { + if (!line.trim()) return; + let message: RpcMessage; + try { message = JSON.parse(line) as RpcMessage; } + catch (error) { process.stderr.write(`Invalid JSON-RPC input: ${String(error)}\n`); return; } + pendingInput = enqueueOpenCodeProxyInput( + pendingInput, + async () => { + if (bootstrapFailure) { + throw new Error( + `OpenCode provider bootstrap failed before ${message.method ?? "the dependent command"}: ${bootstrapFailure.message}`, + ); + } + await handle(message); + }, + (error) => { + const normalized = error instanceof Error ? error : new Error(String(error)); + if ( + message.method === "initialize" + || message.method === "thread/start" + || message.method === "thread/resume" + ) { + bootstrapFailure ??= normalized; + } + send({ + id: message.id, + error: { code: -32000, message: normalized.message }, + }); + }, + ); +}); + +let shutdownPromise: Promise | null = null; +function failProxy(error: unknown): void { + process.stderr.write(`[opencode] fatal proxy error: ${error instanceof Error ? error.message : String(error)}\n`); + void shutdown(1); +} + +function shutdown(exitCode = 0): Promise { + shutdownPromise ??= (async () => { + for (const waiter of pending.values()) waiter.reject(new Error("OpenCode proxy is shutting down")); + pending.clear(); + await session?.close({ reason: "proxy_shutdown", force: true }).catch(() => {}); + await eventPump?.catch(() => {}); + })().finally(() => process.exit(exitCode)); + return shutdownPromise; +} + +input.on("close", () => { void pendingInput.then(() => shutdown()); }); +process.on("SIGTERM", () => { void shutdown(); }); +process.on("SIGINT", () => { void shutdown(); }); diff --git a/packages/paperclip-runner/src/cli/opencode-proxy-collaboration-mode.test.ts b/packages/paperclip-runner/src/cli/opencode-proxy-collaboration-mode.test.ts new file mode 100644 index 00000000000..a065eb02ef9 --- /dev/null +++ b/packages/paperclip-runner/src/cli/opencode-proxy-collaboration-mode.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; + +import { + assertOpenCodeProxyCollaborationMode, + openCodeProxyCollaborationModes, +} from "./opencode-proxy-collaboration-mode.js"; + +describe("OpenCode runnerd proxy collaboration mode", () => { + it("advertises proxy-owned planning against the resolved model", () => { + expect( + openCodeProxyCollaborationModes( + "openrouter/deepseek/deepseek-v4-flash-0731", + ), + ).toEqual({ + data: [ + { + name: "Plan", + mode: "plan", + model: "openrouter/deepseek/deepseek-v4-flash-0731", + reasoning_effort: null, + }, + ], + }); + }); + + it("accepts the negotiated plan payload on turn/start", () => { + expect(() => + assertOpenCodeProxyCollaborationMode({ + collaborationMode: { + mode: "plan", + settings: { model: "openrouter/example" }, + }, + }), + ).not.toThrow(); + }); + + it("rejects collaboration modes the proxy does not implement", () => { + expect(() => + assertOpenCodeProxyCollaborationMode({ + collaborationMode: { mode: "pair-programming" }, + }), + ).toThrow("Unsupported OpenCode proxy collaboration mode pair-programming"); + }); +}); diff --git a/packages/paperclip-runner/src/cli/opencode-proxy-collaboration-mode.ts b/packages/paperclip-runner/src/cli/opencode-proxy-collaboration-mode.ts new file mode 100644 index 00000000000..0118baf55f9 --- /dev/null +++ b/packages/paperclip-runner/src/cli/opencode-proxy-collaboration-mode.ts @@ -0,0 +1,41 @@ +function record(value: unknown): Record { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : {}; +} + +/** + * OpenCode does not expose Codex app-server collaboration presets itself, but + * the Paperclip proxy implements planning semantics through its task envelope, + * read-only permission profile, and Paperclip runtime tools. Advertise that + * proxy-owned capability so runnerd can negotiate planning mode without + * pretending OpenCode supplied a native Codex preset. + */ +export function openCodeProxyCollaborationModes(model: string) { + const resolvedModel = model.trim(); + if (!resolvedModel) { + throw new Error( + "OpenCode collaboration modes are unavailable before thread/start resolves a model", + ); + } + return { + data: [ + { + name: "Plan", + mode: "plan", + model: resolvedModel, + reasoning_effort: null, + }, + ], + }; +} + +export function assertOpenCodeProxyCollaborationMode(params: unknown): void { + const collaborationMode = record(record(params).collaborationMode); + if (Object.keys(collaborationMode).length === 0) return; + if (collaborationMode.mode !== "plan") { + throw new Error( + `Unsupported OpenCode proxy collaboration mode ${String(collaborationMode.mode)}`, + ); + } +} diff --git a/packages/paperclip-runner/src/cli/opencode-proxy-events.test.ts b/packages/paperclip-runner/src/cli/opencode-proxy-events.test.ts new file mode 100644 index 00000000000..4a0953869b6 --- /dev/null +++ b/packages/paperclip-runner/src/cli/opencode-proxy-events.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; + +import { + shouldAnnounceOpenCodeProxyTurn, + shouldForwardOpenCodeProxyItem, +} from "./opencode-proxy-events.js"; + +describe("OpenCode runnerd proxy event boundary", () => { + it("drops only the duplicate session-scoped model item", () => { + expect( + shouldForwardOpenCodeProxyItem({ kind: "model" }), + ).toBe(false); + expect( + shouldForwardOpenCodeProxyItem({ + turnId: "turn-1", + kind: "model", + }), + ).toBe(true); + expect( + shouldForwardOpenCodeProxyItem({ kind: "agentMessage" }), + ).toBe(true); + }); + + it("announces one outer turn when SSE wins the response race", () => { + const announced = new Set(); + expect(shouldAnnounceOpenCodeProxyTurn(announced, "turn-1")).toBe(true); + expect(shouldAnnounceOpenCodeProxyTurn(announced, "turn-1")).toBe(false); + expect([...announced]).toEqual(["turn-1"]); + }); +}); diff --git a/packages/paperclip-runner/src/cli/opencode-proxy-events.ts b/packages/paperclip-runner/src/cli/opencode-proxy-events.ts new file mode 100644 index 00000000000..ef1426cdacf --- /dev/null +++ b/packages/paperclip-runner/src/cli/opencode-proxy-events.ts @@ -0,0 +1,15 @@ +export function shouldForwardOpenCodeProxyItem(input: { + turnId?: string; + kind?: unknown; +}): boolean { + return !(input.turnId === undefined && input.kind === "model"); +} + +export function shouldAnnounceOpenCodeProxyTurn( + announcedTurnIds: Set, + turnId: string, +): boolean { + if (announcedTurnIds.has(turnId)) return false; + announcedTurnIds.add(turnId); + return true; +} diff --git a/packages/paperclip-runner/src/cli/opencode-proxy-input.test.ts b/packages/paperclip-runner/src/cli/opencode-proxy-input.test.ts new file mode 100644 index 00000000000..76a60362577 --- /dev/null +++ b/packages/paperclip-runner/src/cli/opencode-proxy-input.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; + +import { enqueueOpenCodeProxyInput } from "./opencode-proxy-input.js"; + +describe("OpenCode proxy input sequencing", () => { + it("drains requests in order before EOF shutdown", async () => { + let releaseFirst!: () => void; + const firstBlocked = new Promise((resolve) => { + releaseFirst = resolve; + }); + const events: string[] = []; + let pending = Promise.resolve(); + pending = enqueueOpenCodeProxyInput( + pending, + async () => { + events.push("initialize:start"); + await firstBlocked; + events.push("initialize:complete"); + }, + () => events.push("initialize:error"), + ); + pending = enqueueOpenCodeProxyInput( + pending, + async () => { + events.push("thread:start"); + }, + () => events.push("thread:error"), + ); + const shutdown = pending.then(() => { + events.push("shutdown"); + }); + + await Promise.resolve(); + expect(events).toEqual(["initialize:start"]); + releaseFirst(); + await shutdown; + expect(events).toEqual([ + "initialize:start", + "initialize:complete", + "thread:start", + "shutdown", + ]); + }); + + it("reports a failed queued request without rejecting the drain", async () => { + const events: string[] = []; + let pending = Promise.resolve(); + pending = enqueueOpenCodeProxyInput( + pending, + async () => { + throw new Error("initialize failed"); + }, + (error) => events.push((error as Error).message), + ); + + await pending; + expect(events).toEqual(["initialize failed"]); + }); +}); diff --git a/packages/paperclip-runner/src/cli/opencode-proxy-input.ts b/packages/paperclip-runner/src/cli/opencode-proxy-input.ts new file mode 100644 index 00000000000..2099c381aa4 --- /dev/null +++ b/packages/paperclip-runner/src/cli/opencode-proxy-input.ts @@ -0,0 +1 @@ +export { enqueueSerialInput as enqueueOpenCodeProxyInput } from "./serial-input-queue.js"; diff --git a/packages/paperclip-runner/src/cli/opencode-proxy-task-envelope.ts b/packages/paperclip-runner/src/cli/opencode-proxy-task-envelope.ts new file mode 100644 index 00000000000..0b760234087 --- /dev/null +++ b/packages/paperclip-runner/src/cli/opencode-proxy-task-envelope.ts @@ -0,0 +1,38 @@ +import { createCodexTaskEnvelope } from "../contracts/codex.js"; + +function record(value: unknown): Record { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? value as Record + : {}; +} + +function text(value: unknown, fallback = ""): string { + return typeof value === "string" ? value : fallback; +} + +export function openCodeProxyTaskEnvelope(params: Record) { + const completionContract = record(params.completionContract); + const revision = text(completionContract.revision).trim(); + const criterionIds = Array.isArray(completionContract.criterionIds) + ? completionContract.criterionIds + .filter((value): value is string => typeof value === "string" && value.trim().length > 0) + .map((value) => value.trim()) + : []; + return createCodexTaskEnvelope({ + objective: "Complete the provider turn supplied by Paperclip Runner.", + ...(revision && criterionIds.length > 0 + ? { + contractRevision: revision, + criteria: criterionIds.map((id) => ({ + id, + requirement: `Satisfy the Paperclip completion criterion ${id}.`, + })), + } + : {}), + constraints: [ + text(params.baseInstructions, "Complete only the supplied task."), + "Work only inside the supplied working directory.", + "Use Paperclip MCP tools for semantic operations.", + ], + }); +} diff --git a/packages/paperclip-runner/src/cli/serial-input-queue.ts b/packages/paperclip-runner/src/cli/serial-input-queue.ts index b927b8ece05..576445a6ae4 100644 --- a/packages/paperclip-runner/src/cli/serial-input-queue.ts +++ b/packages/paperclip-runner/src/cli/serial-input-queue.ts @@ -17,5 +17,10 @@ export function enqueueSerialInput( // Diagnostics must not poison the queue or skip later input frames. } }; - return pending.catch(reportError).then(operation).catch(reportError); + return pending + .then(operation, (error) => { + reportError(error); + return operation(); + }) + .catch(reportError); } diff --git a/packages/paperclip-runner/src/contracts/provider-trace-file-sink.test.ts b/packages/paperclip-runner/src/contracts/provider-trace-file-sink.test.ts new file mode 100644 index 00000000000..405a28edd77 --- /dev/null +++ b/packages/paperclip-runner/src/contracts/provider-trace-file-sink.test.ts @@ -0,0 +1,62 @@ +import { chmod, mkdtemp, readFile, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { createProviderTraceFileSink } from "./provider-trace-file-sink.js"; + +const roots: string[] = []; + +afterEach(async () => { + const { rm } = await import("node:fs/promises"); + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +describe("ProviderTraceFileSink", () => { + it("hardens an existing file before it appends provider frames", async () => { + const root = await mkdtemp(join(tmpdir(), "paperclip-provider-trace-mode-")); + roots.push(root); + const tracePath = join(root, "provider.ndjson"); + await writeFile(tracePath, "", { mode: 0o644 }); + await chmod(tracePath, 0o644); + + const sink = await createProviderTraceFileSink({ + path: tracePath, + provider: "opencode", + channel: "test", + }); + + expect(statMode(await stat(tracePath))).toBe(0o600); + await sink?.finish(); + }); + + it("redacts registered credentials before raw frames reach disk", async () => { + const root = await mkdtemp(join(tmpdir(), "paperclip-provider-trace-secret-")); + roots.push(root); + const tracePath = join(root, "provider.ndjson"); + const sink = await createProviderTraceFileSink({ + path: tracePath, + provider: "opencode", + channel: "test", + }); + sink?.addSensitiveValues(["provider-secret", "bridge-secret"]); + sink?.frame({ + direction: "provider_stderr", + raw: "credential=provider-secret authorization=bridge-secret", + transport: "process_stderr", + }); + await sink?.finish(); + + const trace = await readFile(tracePath, "utf8"); + expect(trace).not.toContain("provider-secret"); + expect(trace).not.toContain("bridge-secret"); + const frame = JSON.parse(trace.trim().split("\n")[0]!) as Record; + expect(Buffer.from(String(frame.rawBase64), "base64").toString("utf8")) + .toBe("credential=[REDACTED] authorization=[REDACTED]"); + }); +}); + +function statMode(value: Awaited>): number { + return value.mode & 0o777; +} diff --git a/packages/paperclip-runner/src/contracts/provider-trace-file-sink.ts b/packages/paperclip-runner/src/contracts/provider-trace-file-sink.ts new file mode 100644 index 00000000000..aff5932900b --- /dev/null +++ b/packages/paperclip-runner/src/contracts/provider-trace-file-sink.ts @@ -0,0 +1,248 @@ +import { createHash } from "node:crypto"; +import { open, type FileHandle } from "node:fs/promises"; + +const DEFAULT_MAX_BYTES = 64 * 1024 * 1024; +const MAX_PENDING_RECORDS = 1_024; + +type TraceRecord = Record; +type ProviderTraceDirection = + | "client_to_provider" + | "provider_to_client" + | "provider_stderr"; +type ProviderTraceDisposition = + | "mapped" + | "generic" + | "ignored" + | "rejected" + | "operator_only"; +type ProviderTraceFieldMapping = { + inputPath?: string; + outputPath?: string; + action: "copied" | "renamed" | "normalized" | "derived" | "dropped" | "redacted"; + reason?: string; +}; + +/** + * A bounded, fire-and-forget file spool for native providers implemented in + * TypeScript. Provider execution never awaits writes on its hot path; a slow + * or failed sidecar becomes an incomplete trace instead of run backpressure. + */ +export class ProviderTraceFileSink { + readonly #file: FileHandle; + readonly #provider: string; + readonly #channel: string; + readonly #maxBytes: number; + readonly #sensitiveValues = new Set(); + readonly #queue: string[] = []; + #nextFrameId: number; + #nextDebugSequence: number; + #capturedBytes: number; + #draining: Promise | null = null; + #closed = false; + #truncated = false; + #incompleteReason: string | null = null; + + constructor(input: { + file: FileHandle; + provider: string; + channel: string; + maxBytes?: number; + nextFrameId?: number; + nextDebugSequence?: number; + capturedBytes?: number; + }) { + this.#file = input.file; + this.#provider = input.provider; + this.#channel = input.channel; + this.#maxBytes = input.maxBytes ?? DEFAULT_MAX_BYTES; + this.#nextFrameId = input.nextFrameId ?? 1; + this.#nextDebugSequence = input.nextDebugSequence ?? 1; + this.#capturedBytes = input.capturedBytes ?? 0; + } + + addSensitiveValues(values: readonly (string | undefined)[]): void { + for (const value of values) { + if (value) this.#sensitiveValues.add(value); + } + } + + frame(input: { + direction: ProviderTraceDirection; + raw: Uint8Array | string; + transport: string; + nativeMethod?: string; + }): number | null { + if (this.#closed) return null; + const raw = this.#redact( + typeof input.raw === "string" ? Buffer.from(input.raw) : Buffer.from(input.raw), + ); + if (this.#capturedBytes + raw.byteLength > this.#maxBytes) { + this.#truncated = true; + return null; + } + const frameId = this.#nextFrameId++; + this.#capturedBytes += raw.byteLength; + this.#append({ + kind: "frame", + schema: "paperclip.provider_trace_frame.v1", + frameId, + timestamp: new Date().toISOString(), + direction: input.direction, + transport: input.transport, + provider: this.#provider, + ...(input.nativeMethod ? { nativeMethod: input.nativeMethod } : {}), + byteLength: raw.byteLength, + digest: `sha256:${createHash("sha256").update(raw).digest("hex")}`, + rawBase64: raw.toString("base64"), + }); + return frameId; + } + + interpretation(input: { + frameId: number; + stage: string; + ruleId: string; + disposition: ProviderTraceDisposition; + emittedEventIds?: string[]; + droppedFields?: string[]; + fieldMappings?: ProviderTraceFieldMapping[]; + reason: string; + }): void { + if (this.#closed) return; + this.#append({ + kind: "interpretation", + schema: "paperclip.provider_trace_interpretation.v1", + frameId: input.frameId, + stage: input.stage, + ruleId: input.ruleId, + disposition: input.disposition, + emittedEventIds: input.emittedEventIds ?? [], + droppedFields: input.droppedFields ?? [], + fieldMappings: input.fieldMappings ?? [], + reason: input.reason, + }); + } + + async finish(input: { reason?: string | null } = {}): Promise { + if (this.#closed) { + await this.#draining; + return; + } + const reason = input.reason ?? this.#incompleteReason; + const status = reason + ? "incomplete" + : this.#truncated + ? "truncated" + : "complete"; + const acknowledgedDebugSequence = this.#nextDebugSequence - 1; + this.#append({ + kind: "trace_status", + status, + acknowledgedDebugSequence, + reason: reason ?? (this.#truncated ? "provider_trace_max_bytes_exceeded" : null), + }); + this.#closed = true; + await this.#draining; + await this.#file.close().catch(() => undefined); + } + + #append(value: TraceRecord): void { + const record = { + ...value, + debugChannel: this.#channel, + debugSequence: this.#nextDebugSequence++, + }; + if (this.#queue.length >= MAX_PENDING_RECORDS) { + this.#incompleteReason ??= "trace_sidecar_spool_full"; + return; + } + this.#queue.push(`${JSON.stringify(record)}\n`); + if (this.#draining === null) this.#scheduleDrain(); + } + + #scheduleDrain(): void { + this.#draining = this.#drain().finally(() => { + this.#draining = null; + if (this.#queue.length > 0) this.#scheduleDrain(); + }); + } + + async #drain(): Promise { + while (this.#queue.length > 0) { + const line = this.#queue.shift()!; + try { + await this.#file.appendFile(line, { encoding: "utf8" }); + } catch { + this.#incompleteReason ??= "trace_sidecar_write_failed"; + this.#queue.length = 0; + return; + } + } + } + + #redact(raw: Buffer): Buffer { + if (this.#sensitiveValues.size === 0) return raw; + let value = raw.toString("utf8"); + for (const sensitive of [...this.#sensitiveValues].sort( + (left, right) => right.length - left.length, + )) { + value = value.split(sensitive).join("[REDACTED]"); + } + return Buffer.from(value, "utf8"); + } +} + +export async function createProviderTraceFileSink(input: { + path?: string; + provider: string; + channel: string; + maxBytes?: string | number; +}): Promise { + if (!input.path) return null; + const maxBytes = typeof input.maxBytes === "number" + ? input.maxBytes + : Number.parseInt(input.maxBytes ?? "", 10); + // Keep one descriptor for the sink lifetime. This prevents a path swap + // between permission hardening, replay inspection, and later appends. + const file = await open(input.path, "a+", 0o600); + let existing: string; + try { + // Opening an existing file preserves its prior mode. Tighten it before + // reading or appending so no provider frame has a readable exposure window. + await file.chmod(0o600); + existing = await file.readFile({ encoding: "utf8" }); + } catch (error) { + await file.close().catch(() => undefined); + throw error; + } + let nextFrameId = 1; + let nextDebugSequence = 1; + let capturedBytes = 0; + for (const line of existing.split("\n")) { + if (!line.trim()) continue; + try { + const entry = JSON.parse(line) as Record; + if (entry.kind === "frame") { + nextFrameId = Math.max(nextFrameId, Number(entry.frameId) + 1 || 1); + capturedBytes += Number(entry.byteLength) || 0; + } + if (entry.debugChannel === input.channel) { + nextDebugSequence = Math.max( + nextDebugSequence, + Number(entry.debugSequence) + 1 || 1, + ); + } + } catch { + // The server-side integrity pass will classify a malformed prior record. + } + } + return new ProviderTraceFileSink({ + file, + provider: input.provider, + channel: input.channel, + maxBytes: Number.isSafeInteger(maxBytes) && maxBytes > 0 ? maxBytes : DEFAULT_MAX_BYTES, + nextFrameId, + nextDebugSequence, + capturedBytes, + }); +} diff --git a/packages/paperclip-runner/src/drivers/opencode/mcp-bridge.test.ts b/packages/paperclip-runner/src/drivers/opencode/mcp-bridge.test.ts new file mode 100644 index 00000000000..6539a14b52a --- /dev/null +++ b/packages/paperclip-runner/src/drivers/opencode/mcp-bridge.test.ts @@ -0,0 +1,182 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { canonicalOpenCodeMcpToolName, startOpenCodeMcpBridge, type OpenCodeMcpBridge } from "./mcp-bridge.js"; + +const bridges: OpenCodeMcpBridge[] = []; + +afterEach(async () => { + await Promise.all(bridges.splice(0).map((bridge) => bridge.close())); +}); + +async function rpc( + bridge: OpenCodeMcpBridge, + body: Record, + secret = bridge.secret, +) { + return fetch(bridge.url, { + method: "POST", + headers: { + Authorization: `Bearer ${secret}`, + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + }, + body: JSON.stringify({ jsonrpc: "2.0", ...body }), + }); +} + +describe("OpenCode MCP bridge", () => { + it("binds to loopback, requires authentication, and exposes only its closed catalog", async () => { + const bridge = await startOpenCodeMcpBridge({ + secret: "session-secret", + tools: [{ name: "documents.read", description: "Read", inputSchema: { type: "object" } }], + handler: async () => ({ ok: true }), + }); + bridges.push(bridge); + expect(new URL(bridge.url).hostname).toBe("127.0.0.1"); + expect((await rpc(bridge, { id: 1, method: "tools/list" }, "wrong")).status).toBe(401); + const listed = await rpc(bridge, { id: 2, method: "tools/list" }); + expect(await listed.json()).toMatchObject({ + result: { tools: [ + { name: "documents.read" }, + { name: "paperclip_finish" }, + { name: "paperclip_block" }, + ] }, + }); + }); + + it("admits runner-private extension operations without advertising them to the model", async () => { + const handler = vi.fn(async ({ tool }) => ({ decision: tool === "__paperclip_permission" ? "accept" : "decline" })); + const bridge = await startOpenCodeMcpBridge({ + tools: [{ name: "documents.read", inputSchema: { type: "object" } }], + privateTools: [{ name: "__paperclip_permission", inputSchema: { type: "object" } }], + handler, + }); + bridges.push(bridge); + const listed = await (await rpc(bridge, { id: 1, method: "tools/list" })).json() as { result: { tools: Array<{ name: string }> } }; + expect(listed.result.tools.map((tool) => tool.name)).not.toContain("__paperclip_permission"); + expect(await (await rpc(bridge, { + id: "permission-1", + method: "tools/call", + params: { name: "__paperclip_permission", arguments: {} }, + })).json()).toMatchObject({ result: { content: [{ text: "{\"decision\":\"accept\"}" }] } }); + expect(handler).toHaveBeenCalledWith(expect.objectContaining({ tool: "__paperclip_permission" })); + }); + + it("maps prefixed names and replays identical duplicate calls exactly once", async () => { + const handler = vi.fn(async () => ({ value: 7 })); + const bridge = await startOpenCodeMcpBridge({ + tools: [{ name: "documents.read", inputSchema: { type: "object" } }], + handler, + }); + bridges.push(bridge); + const request = { id: "call-1", method: "tools/call", params: { name: "paperclip_documents.read", arguments: { id: "doc" } } }; + expect(await (await rpc(bridge, request)).json()).toMatchObject({ result: { content: [{ text: "{\"value\":7}" }] } }); + expect(await (await rpc(bridge, request)).json()).toMatchObject({ result: { content: [{ text: "{\"value\":7}" }] } }); + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith(expect.objectContaining({ tool: "documents.read", callId: "call-1", arguments: { id: "doc" } })); + }); + + it("fails conflicting duplicate identities and handler errors closed", async () => { + const bridge = await startOpenCodeMcpBridge({ + tools: [{ name: "documents.read", inputSchema: { type: "object" } }], + handler: async () => { throw new Error("denied"); }, + }); + bridges.push(bridge); + const failed = await rpc(bridge, { id: "call-2", method: "tools/call", params: { name: "documents.read", arguments: { id: "a" } } }); + expect(await failed.json()).toMatchObject({ result: { isError: true, content: [{ text: "denied" }] } }); + const conflict = await rpc(bridge, { id: "call-2", method: "tools/call", params: { name: "documents.read", arguments: { id: "b" } } }); + expect(await conflict.json()).toMatchObject({ result: { isError: true, content: [{ text: "Duplicate call identity conflict." }] } }); + }); + + it("normalizes all supported OpenCode MCP prefixes", () => { + expect(canonicalOpenCodeMcpToolName("paperclip_documents.read")).toBe("documents.read"); + expect(canonicalOpenCodeMcpToolName("paperclip__documents.read")).toBe("documents.read"); + expect(canonicalOpenCodeMcpToolName("paperclip.documents.read")).toBe("documents.read"); + expect(canonicalOpenCodeMcpToolName("paperclip_paperclip_finish")).toBe("paperclip_finish"); + }); + + it("validates inputs before dispatch", async () => { + const handler = vi.fn(async () => ({ ok: true })); + const bridge = await startOpenCodeMcpBridge({ + tools: [{ + name: "documents.read", + inputSchema: { type: "object", required: ["id"], properties: { id: { type: "string" } }, additionalProperties: false }, + }], + handler, + }); + bridges.push(bridge); + const response = await rpc(bridge, { + id: "invalid", + method: "tools/call", + params: { name: "documents.read", arguments: { id: 9 } }, + }); + expect(await response.json()).toMatchObject({ result: { isError: true } }); + expect(handler).not.toHaveBeenCalled(); + }); + + it("times out and cancels in-flight calls", async () => { + const observed = vi.fn(); + const bridge = await startOpenCodeMcpBridge({ + tools: [{ name: "documents.read", inputSchema: { type: "object" } }], + timeoutMs: 20, + handler: ({ signal }) => new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => { observed(); reject(new Error("aborted")); }, { once: true }); + }), + }); + bridges.push(bridge); + const response = await rpc(bridge, { + id: "slow-call", + method: "tools/call", + params: { name: "documents.read", arguments: {} }, + }); + expect(await response.json()).toMatchObject({ result: { isError: true, content: [{ text: "Paperclip tool call timed out" }] } }); + expect(observed).toHaveBeenCalledOnce(); + }); + + it("allows human-gated private calls to outlive ordinary tool timeouts", async () => { + const bridge = await startOpenCodeMcpBridge({ + tools: [{ name: "documents.read", inputSchema: { type: "object" } }], + privateTools: [{ name: "__paperclip_runtime_input", inputSchema: { type: "object" } }], + timeoutMs: 20, + privateToolTimeoutMs: 200, + handler: async ({ tool }) => { + await new Promise((resolve) => setTimeout(resolve, 50)); + return { tool, resolved: true }; + }, + }); + bridges.push(bridge); + const response = await rpc(bridge, { + id: "human-input", + method: "tools/call", + params: { name: "__paperclip_runtime_input", arguments: {} }, + }); + expect(await response.json()).toMatchObject({ + result: { content: [{ text: expect.stringContaining("\"resolved\":true") }] }, + }); + }); + + it("honors MCP cancellation notifications", async () => { + const started = vi.fn(); + const bridge = await startOpenCodeMcpBridge({ + tools: [{ name: "documents.read", inputSchema: { type: "object" } }], + handler: ({ signal }) => new Promise((_resolve, reject) => { + started(); + signal.addEventListener("abort", () => reject(new Error("handler aborted")), { once: true }); + }), + }); + bridges.push(bridge); + const pending = rpc(bridge, { + id: "cancel-me", + method: "tools/call", + params: { name: "documents.read", arguments: {} }, + }); + await vi.waitFor(() => expect(started).toHaveBeenCalledOnce()); + expect((await rpc(bridge, { + method: "notifications/cancelled", + params: { requestId: "cancel-me", reason: "turn stopped" }, + })).status).toBe(202); + expect(await (await pending).json()).toMatchObject({ + result: { isError: true, content: [{ text: "Paperclip tool call cancelled" }] }, + }); + }); +}); diff --git a/packages/paperclip-runner/src/drivers/opencode/mcp-bridge.ts b/packages/paperclip-runner/src/drivers/opencode/mcp-bridge.ts new file mode 100644 index 00000000000..79ce6770375 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/opencode/mcp-bridge.ts @@ -0,0 +1,356 @@ +import { randomBytes, timingSafeEqual } from "node:crypto"; +import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; +import { Ajv2020, type ValidateFunction } from "ajv/dist/2020.js"; + +import { + PRP_BLOCK_RESULT_PROVIDER_INPUT_SCHEMA, + PRP_BLOCK_TOOL_NAME, + PRP_COMPLETION_RESULT_PROVIDER_INPUT_SCHEMA, + PRP_COMPLETION_TOOL_NAME, +} from "../../contracts/completion-result.js"; + +export interface OpenCodeMcpToolDefinition { + name: string; + description?: string; + inputSchema: Record; +} + +export interface OpenCodeMcpCall { + tool: string; + callId: string; + arguments: unknown; + signal: AbortSignal; +} + +export interface OpenCodeMcpBridgeOptions { + tools?: readonly Readonly>[]; + /** Runner-owned operations callable only by a trusted harness extension and never advertised to the model. */ + privateTools?: readonly Readonly>[]; + handler: (call: OpenCodeMcpCall) => Promise; + timeoutMs?: number; + /** Human-gated private operations may remain pending much longer than model tool calls. */ + privateToolTimeoutMs?: number; + maxBodyBytes?: number; + secret?: string; +} + +export interface OpenCodeMcpBridge { + url: string; + secret: string; + close(): Promise; +} + +const DEFAULT_TIMEOUT_MS = 30_000; +const DEFAULT_MAX_BODY_BYTES = 1_048_576; + +export function canonicalOpenCodeMcpToolName(name: string): string { + const trimmed = name.trim(); + if (trimmed === PRP_COMPLETION_TOOL_NAME || trimmed === PRP_BLOCK_TOOL_NAME) return trimmed; + for (const prefix of ["paperclip__", "paperclip_", "paperclip."]) { + if (trimmed.startsWith(prefix)) return trimmed.slice(prefix.length); + } + return trimmed; +} + +export async function startOpenCodeMcpBridge( + options: OpenCodeMcpBridgeOptions, +): Promise { + const secret = options.secret ?? randomBytes(32).toString("base64url"); + const tools = normalizeTools(options.tools ?? []); + const privateTools = normalizeTools(options.privateTools ?? [], false); + const admittedTools = [...tools, ...privateTools.filter((candidate) => !tools.some((tool) => tool.name === candidate.name))]; + const validators = compileValidators(admittedTools); + const calls = new Map }>(); + const controllers = new Map(); + const server = createServer((request, response) => { + void handleRequest(request, response, { + secret, + tools, + admittedTools, + validators, + calls, + controllers, + handler: options.handler, + timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS, + privateToolTimeoutMs: options.privateToolTimeoutMs ?? options.timeoutMs ?? DEFAULT_TIMEOUT_MS, + privateToolNames: new Set(privateTools.map((tool) => tool.name)), + maxBodyBytes: options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES, + }); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + server.off("error", reject); + resolve(); + }); + }); + const address = server.address(); + if (address === null || typeof address === "string") { + await closeServer(server); + throw new Error("OpenCode MCP bridge failed to bind a loopback port"); + } + return { + url: `http://127.0.0.1:${address.port}/mcp`, + secret, + close: async () => { + for (const controller of controllers.values()) controller.abort(); + await closeServer(server); + }, + }; +} + +function normalizeTools( + input: readonly Readonly>[], + includeTerminalTools = true, +): OpenCodeMcpToolDefinition[] { + const tools: OpenCodeMcpToolDefinition[] = []; + const seen = new Set(); + for (const raw of input) { + const name = typeof raw.name === "string" ? canonicalOpenCodeMcpToolName(raw.name) : ""; + if (!name || seen.has(name)) continue; + const inputSchema = isRecord(raw.inputSchema) ? structuredClone(raw.inputSchema) : { type: "object" }; + tools.push({ + name, + ...(typeof raw.description === "string" ? { description: raw.description } : {}), + inputSchema, + }); + seen.add(name); + } + if (includeTerminalTools) for (const terminal of [ + { name: PRP_COMPLETION_TOOL_NAME, description: "Return the semantic completion result.", inputSchema: PRP_COMPLETION_RESULT_PROVIDER_INPUT_SCHEMA }, + { name: PRP_BLOCK_TOOL_NAME, description: "Return the semantic blocked result.", inputSchema: PRP_BLOCK_RESULT_PROVIDER_INPUT_SCHEMA }, + ]) { + if (!seen.has(terminal.name)) tools.push(terminal); + } + return tools; +} + +function compileValidators(tools: readonly OpenCodeMcpToolDefinition[]): Map { + const ajv = new Ajv2020({ allErrors: true, strict: false }); + return new Map(tools.map((tool) => [tool.name, ajv.compile(tool.inputSchema)])); +} + +async function handleRequest( + request: IncomingMessage, + response: ServerResponse, + context: { + secret: string; + tools: OpenCodeMcpToolDefinition[]; + admittedTools: OpenCodeMcpToolDefinition[]; + validators: Map; + calls: Map }>; + controllers: Map; + handler: OpenCodeMcpBridgeOptions["handler"]; + timeoutMs: number; + privateToolTimeoutMs: number; + privateToolNames: ReadonlySet; + maxBodyBytes: number; + }, +): Promise { + setSecurityHeaders(response); + if (!authorized(request.headers.authorization, context.secret)) { + response.statusCode = 401; + response.end(); + return; + } + if (request.method === "GET") { + response.statusCode = 405; + response.setHeader("Allow", "POST, DELETE"); + response.end(); + return; + } + if (request.method === "DELETE") { + response.statusCode = 204; + response.end(); + return; + } + if (request.method !== "POST" || request.url !== "/mcp") { + response.statusCode = 404; + response.end(); + return; + } + let message: Record; + try { + message = parseMessage(await readBody(request, context.maxBodyBytes)); + } catch (error) { + writeRpc(response, null, undefined, rpcError(-32700, safeError(error))); + return; + } + const id = message.id ?? null; + const method = typeof message.method === "string" ? message.method : ""; + if (method === "notifications/cancelled") { + const params = isRecord(message.params) ? message.params : {}; + const requestId = params.requestId; + if (typeof requestId === "string" || typeof requestId === "number") { + context.controllers.get(String(requestId))?.abort(); + } + response.statusCode = 202; + response.end(); + return; + } + if (method === "notifications/initialized") { + response.statusCode = 202; + response.end(); + return; + } + if (method === "initialize") { + response.setHeader("Mcp-Session-Id", randomBytes(16).toString("hex")); + writeRpc(response, id, { + protocolVersion: "2025-03-26", + capabilities: { tools: { listChanged: false } }, + serverInfo: { name: "paperclip-runner", version: "1" }, + }); + return; + } + if (method === "ping") { + writeRpc(response, id, {}); + return; + } + if (method === "tools/list") { + writeRpc(response, id, { tools: context.tools }); + return; + } + if (method !== "tools/call") { + writeRpc(response, id, undefined, rpcError(-32601, "Method not found")); + return; + } + const params = isRecord(message.params) ? message.params : {}; + const rawName = typeof params.name === "string" ? params.name : ""; + const tool = canonicalOpenCodeMcpToolName(rawName); + if (!context.admittedTools.some((entry) => entry.name === tool)) { + writeRpc(response, id, { isError: true, content: [{ type: "text", text: "Unsupported tool." }] }); + return; + } + const callId = typeof id === "string" || typeof id === "number" ? String(id) : randomBytes(12).toString("hex"); + const args = params.arguments ?? {}; + const validator = context.validators.get(tool); + if (validator === undefined || !validator(args)) { + const detail = validator?.errors?.map((error) => `${error.instancePath || "/"} ${error.message ?? "is invalid"}`).join("; "); + writeRpc(response, id, { + isError: true, + content: [{ type: "text", text: `Invalid tool input${detail ? `: ${detail}` : "."}` }], + }); + return; + } + const fingerprint = canonicalJson({ tool, args }); + const existing = context.calls.get(callId); + if (existing && existing.fingerprint !== fingerprint) { + writeRpc(response, id, { isError: true, content: [{ type: "text", text: "Duplicate call identity conflict." }] }); + return; + } + const controller = existing === undefined ? new AbortController() : undefined; + const execution = existing?.promise ?? withCancellationAndTimeout( + context.handler({ tool, callId, arguments: structuredClone(args), signal: controller!.signal }), + controller!, + context.privateToolNames.has(tool) ? context.privateToolTimeoutMs : context.timeoutMs, + ); + if (!existing) { + if (context.calls.size >= 2_048) context.calls.delete(context.calls.keys().next().value!); + context.calls.set(callId, { fingerprint, promise: execution }); + context.controllers.set(callId, controller!); + void execution.finally(() => context.controllers.delete(callId)).catch(() => undefined); + } + try { + const result = await execution; + writeRpc(response, id, { content: [{ type: "text", text: safeJson(result) }] }); + } catch (error) { + writeRpc(response, id, { isError: true, content: [{ type: "text", text: safeError(error) }] }); + } +} + +function authorized(value: string | undefined, secret: string): boolean { + if (!value?.startsWith("Bearer ")) return false; + const supplied = Buffer.from(value.slice(7)); + const expected = Buffer.from(secret); + return supplied.length === expected.length && timingSafeEqual(supplied, expected); +} + +function readBody(request: IncomingMessage, maxBytes: number): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let size = 0; + request.on("data", (chunk: Buffer) => { + size += chunk.length; + if (size > maxBytes) { + reject(new Error("MCP request exceeded the retained payload limit")); + request.destroy(); + return; + } + chunks.push(chunk); + }); + request.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); + request.on("error", reject); + }); +} + +function parseMessage(body: string): Record { + const parsed: unknown = JSON.parse(body); + if (!isRecord(parsed) || parsed.jsonrpc !== "2.0") throw new Error("Invalid JSON-RPC request"); + return parsed; +} + +function writeRpc(response: ServerResponse, id: unknown, result?: unknown, error?: unknown): void { + response.statusCode = 200; + response.setHeader("Content-Type", "application/json"); + response.end(JSON.stringify({ jsonrpc: "2.0", id, ...(error === undefined ? { result } : { error }) })); +} + +function rpcError(code: number, message: string) { + return { code, message }; +} + +function setSecurityHeaders(response: ServerResponse): void { + response.setHeader("Cache-Control", "no-store"); + response.setHeader("X-Content-Type-Options", "nosniff"); +} + +function closeServer(server: Server): Promise { + return new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); +} + +function withCancellationAndTimeout( + promise: Promise, + controller: AbortController, + timeoutMs: number, +): Promise { + return new Promise((resolve, reject) => { + let settled = false; + const finish = (callback: () => void) => { + if (settled) return; + settled = true; + clearTimeout(timer); + controller.signal.removeEventListener("abort", onAbort); + callback(); + }; + const onAbort = () => finish(() => reject(new Error("Paperclip tool call cancelled"))); + const timer = setTimeout(() => { + finish(() => reject(new Error("Paperclip tool call timed out"))); + controller.abort(); + }, timeoutMs); + timer.unref?.(); + controller.signal.addEventListener("abort", onAbort, { once: true }); + promise.then( + (value) => finish(() => resolve(value)), + (error) => finish(() => reject(error)), + ); + }); +} + +function safeError(error: unknown): string { + return (error instanceof Error ? error.message : String(error)).slice(0, 2_000); +} + +function safeJson(value: unknown): string { + const serialized = JSON.stringify(value); + return (serialized ?? "null").slice(0, 64 * 1024); +} + +function canonicalJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; + if (isRecord(value)) return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(",")}}`; + return JSON.stringify(value) ?? "undefined"; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts new file mode 100644 index 00000000000..b5abaa4fb2a --- /dev/null +++ b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts @@ -0,0 +1,920 @@ +import { chmod, lstat, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import { + NATIVE_RUNTIME_ASSET_SCHEMA, + PAPERCLIP_EXECUTION_PROMPT, + PAPERCLIP_EXECUTION_PROMPT_REVISION, + canonicalNativeRuntimeContextDigest, + nativeRuntimePromptDigest, + type NativeRuntimeContextSnapshot, +} from "../../contracts/runtime-context.js"; +import { OpenCodeServerDriver, openCodeServerDriverInternals } from "./opencode-server-driver.js"; + +const roots: string[] = []; +const fixture = resolve("test/fixtures/fake-opencode-server.mjs"); + +function runtimeContext(skillRoot: string, instructionRoot: string): NativeRuntimeContextSnapshot { + const digest = "0".repeat(64); + const value = { + prompt: { + revision: PAPERCLIP_EXECUTION_PROMPT_REVISION, + text: PAPERCLIP_EXECUTION_PROMPT, + digest: nativeRuntimePromptDigest(), + }, + instructions: { + entryPath: "AGENTS.md", + bundle: { + schema: NATIVE_RUNTIME_ASSET_SCHEMA, + digest, + manifestDigest: digest, + rootPath: instructionRoot, + fileCount: 2, + totalBytes: 2, + }, + }, + skills: [{ + key: "company/assigned", + runtimeName: "assigned", + versionId: "version-1", + bundle: { + schema: NATIVE_RUNTIME_ASSET_SCHEMA, + digest, + manifestDigest: digest, + rootPath: skillRoot, + fileCount: 2, + totalBytes: 2, + }, + }], + mcp: { assignmentSetId: "assigned", digest, bindingId: "binding" }, + } satisfies Omit; + return { ...value, aggregateDigest: canonicalNativeRuntimeContextDigest(value) }; +} + +async function makeWritable(root: string): Promise { + const info = await lstat(root).catch(() => null); + if (!info) return; + if (info.isDirectory()) { + await chmod(root, 0o700); + for (const entry of await readdir(root)) await makeWritable(join(root, entry)); + } else { + await chmod(root, 0o600); + } +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map(async (root) => { + await makeWritable(root); + await rm(root, { recursive: true, force: true }); + })); +}); + +describe("OpenCodeServerDriver", () => { + it("advertises within-turn plans as unsupported", async () => { + const driver = new OpenCodeServerDriver({ + model: "openrouter/deepseek/deepseek-v4-flash-0731", + runtimeDirectory: "/tmp/paperclip-opencode-capabilities", + command: fixture, + }); + const descriptor = await driver.descriptor(); + expect(descriptor.capabilities.typedEventFamilies + .find((family) => family.family === "plan")) + .toMatchObject({ availability: "unsupported" }); + }); + + it("starts an authenticated isolated server, creates a session, streams usage, aborts, and cleans up", async () => { + await chmod(fixture, 0o755); + const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-driver-")); + const workspace = await mkdtemp(join(tmpdir(), "paperclip-opencode-workspace-")); + roots.push(root, workspace); + const tracePath = join(root, "provider-trace.ndjson"); + const spawns: Array<{ pid: number; processGroupId: number | null }> = []; + const diagnostics: string[] = []; + const driver = new OpenCodeServerDriver({ + model: "openrouter/deepseek/deepseek-v4-flash-0731", + runtimeDirectory: root, + command: fixture, + environment: { + PATH: process.env.PATH, + OPENROUTER_API_KEY: "test-openrouter-key", + PAPERCLIP_API_KEY: "must-not-leak", + UNRELATED_SECRET: "must-not-leak", + PAPERCLIP_PROVIDER_TRACE_PATH: tracePath, + PAPERCLIP_PROVIDER_TRACE_MAX_BYTES: String(64 * 1024 * 1024), + }, + onSpawn: async (meta) => { spawns.push(meta); }, + onDiagnostic: (message) => diagnostics.push(message), + }); + const session = await driver.openSession({ runId: "run-1", normalizedSessionId: "normalized/1", workingDirectory: workspace }); + expect(session.ids()).toMatchObject({ providerSessionId: "ses_fake_1" }); + expect(spawns).toHaveLength(1); + const turn = await session.startTurn({ message: { role: "user", text: "finish" } }); + const events = []; + for await (const event of session.events()) events.push(event); + expect(events.map((event) => event.eventType)).toContain("turn.completed"); + expect(events).toContainEqual(expect.objectContaining({ eventType: "run.result.proposed" })); + expect(events.filter((event) => event.eventType === "item.delta")).toHaveLength(1); + expect(events.find((event) => event.eventType === "item.delta")?.payload.text).toBe("done [guide](guide.md)"); + expect(events.find( + (event) => event.eventType === "item.completed" && event.payload.kind === "agentMessage", + )?.payload).toMatchObject({ + channel: "final", + providerPhase: "final_answer", + text: "done [guide](guide.md)", + }); + expect(events.filter( + (event) => event.eventType === "item.completed" && event.payload.kind === "usage", + )).toHaveLength(1); + expect(events.find((event) => event.eventType === "workspace.change.updated")?.payload) + .toMatchObject({ schema: "paperclip.workspace.diff.v1", source: "harness_reported", totals: { files: 1 } }); + expect(events.find((event) => event.eventType === "workspace.diff.recorded")?.payload) + .toMatchObject({ schema: "paperclip.workspace.diff.v1", complete: true }); + expect(events.find((event) => event.eventType === "workspace.file.referenced")?.payload) + .toMatchObject({ schema: "paperclip.workspace.file_reference.v1", path: "guide.md" }); + expect(JSON.stringify(events)).not.toContain("submitted prompt must not become assistant text"); + expect(await session.usage()).toMatchObject({ + input: 3, + output: 2, + costUsd: 0.001, + provider: "openrouter", + driverVersion: "1.18.17", + }); + await session.interrupt?.({ turnId: turn.turnId }); + const snapshot = await session.snapshot(); + expect(snapshot.driverKind).toBe("opencode_server"); + const sessionRoot = join(root, "normalized_1"); + expect((await stat(sessionRoot)).mode & 0o777).toBe(0o700); + expect((await stat(join(sessionRoot, "config", "opencode", "opencode.json"))).mode & 0o777).toBe(0o600); + const config = await readFile(join(sessionRoot, "config", "opencode", "opencode.json"), "utf8"); + await session.close({ reason: "test" }); + const recovered = await driver.recoverSession?.(snapshot); + expect(recovered).toMatchObject({ recovered: true }); + await expect(recovered?.session?.read?.()).resolves.toMatchObject({ sessionId: "ses_fake_1", messages: [] }); + await recovered?.session?.close({ reason: "recovery-test" }); + + const traceEntries = (await readFile(tracePath, "utf8")) + .trim() + .split("\n") + .map((line) => JSON.parse(line) as Record); + const traceFrames = traceEntries.filter((entry) => entry.kind === "frame"); + expect(traceFrames.map((entry) => entry.direction)).toEqual( + expect.arrayContaining(["client_to_provider", "provider_to_client", "provider_stderr"]), + ); + expect(traceFrames.some((entry) => entry.nativeMethod === "SSE /event")).toBe(true); + expect(traceEntries.some( + (entry) => entry.stage === "typescript_opencode_driver_normalization" + && Array.isArray(entry.emittedEventIds) + && entry.emittedEventIds.length > 0, + )).toBe(true); + expect(traceEntries.at(-1)).toMatchObject({ + kind: "trace_status", + debugChannel: "typescript_opencode_native", + status: "complete", + }); + + const environment = JSON.parse(await readFile(join(sessionRoot, "data", "fake-environment.json"), "utf8")); + const mcpEvidence = JSON.parse(await readFile(join(sessionRoot, "data", "fake-mcp-evidence.json"), "utf8")); + expect(environment.keys).toContain("OPENROUTER_API_KEY"); + expect(environment.keys).not.toContain("PAPERCLIP_API_KEY"); + expect(environment.keys).not.toContain("UNRELATED_SECRET"); + expect(environment.keys).not.toContain("PAPERCLIP_PROVIDER_TRACE_PATH"); + expect(environment.projectConfigDisabled).toBe("true"); + expect(mcpEvidence.tools).toEqual(expect.arrayContaining(["paperclip_finish", "paperclip_block"])); + expect(config).toContain("openrouter/deepseek/deepseek-v4-flash-0731"); + expect(config).toContain('"*": "allow"'); + expect(config).toContain('"external_directory": "deny"'); + expect(events.some((event) => event.eventType === "runtime_request.created")).toBe(false); + expect(config).not.toContain("test-openrouter-key"); + expect(diagnostics.join("\n")).not.toContain("test-openrouter-key"); + expect(diagnostics.join("\n")).toContain("[REDACTED]"); + }); + + it("maps OpenCode's normal abort error to cancellation without a false provider failure notice", async () => { + await chmod(fixture, 0o755); + const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-abort-")); + const workspace = await mkdtemp(join(tmpdir(), "paperclip-opencode-abort-workspace-")); + roots.push(root, workspace); + const driver = new OpenCodeServerDriver({ + model: "openrouter/deepseek/deepseek-v4-flash-0731", + runtimeDirectory: root, + command: fixture, + environment: { PATH: process.env.PATH, OPENROUTER_API_KEY: "fixture-key" }, + }); + const session = await driver.openSession({ + runId: "run-aborted", + normalizedSessionId: "aborted", + workingDirectory: workspace, + }); + await session.startTurn({ message: { role: "user", text: "session-aborted" } }); + const events = []; + for await (const event of session.events()) events.push(event); + expect(events.map((event) => event.eventType)).toContain("turn.cancelled"); + expect(events.map((event) => event.eventType)).not.toContain("turn.failed"); + expect(events.map((event) => event.eventType)).not.toContain("provider.notice.recorded"); + await session.close({ reason: "test" }); + }); + + it("keeps OpenCode in an outer supervisor process group when requested", async () => { + await chmod(fixture, 0o755); + const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-driver-")); + const workspace = await mkdtemp(join(tmpdir(), "paperclip-opencode-workspace-")); + roots.push(root, workspace); + let processGroupId: number | null | undefined; + const driver = new OpenCodeServerDriver({ + model: "openrouter/deepseek/deepseek-v4-flash-0731", + runtimeDirectory: root, + command: fixture, + environment: { PATH: process.env.PATH, OPENROUTER_API_KEY: "fixture-key" }, + isolateProcessGroup: false, + onSpawn: async (meta) => { processGroupId = meta.processGroupId; }, + }); + const session = await driver.openSession({ runId: "run-supervised", normalizedSessionId: "supervised", workingDirectory: workspace }); + expect(processGroupId).toBeNull(); + await session.close({ reason: "test" }); + }); + + it("uses the native system channel, selected skill tree, instruction root, and assigned MCP gateway", async () => { + await chmod(fixture, 0o755); + const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-context-")); + const workspace = await mkdtemp(join(tmpdir(), "paperclip-opencode-context-workspace-")); + const skillRoot = join(root, "skill-source"); + const instructionRoot = join(root, "instruction-source"); + roots.push(root, workspace); + await Promise.all([ + mkdir(join(skillRoot, "references"), { recursive: true }), + mkdir(instructionRoot, { recursive: true }), + ]); + await Promise.all([ + writeFile(join(skillRoot, "SKILL.md"), "# Assigned\nRead references/support.md\n"), + writeFile(join(skillRoot, "references", "support.md"), "skill support\n"), + writeFile(join(instructionRoot, "AGENTS.md"), "Read sibling.md\n"), + writeFile(join(instructionRoot, "sibling.md"), "instruction sibling\n"), + ]); + const systemInstructions = `${PAPERCLIP_EXECUTION_PROMPT}\n\nRead sibling.md\n\nRead-only instruction sibling root: ${instructionRoot}`; + let submittedPrompt: Record | null = null; + const driver = new OpenCodeServerDriver({ + model: "openrouter/deepseek/deepseek-v4-flash-0731", + runtimeDirectory: root, + command: fixture, + systemInstructions, + runtimeContext: runtimeContext(skillRoot, instructionRoot), + environment: { + PATH: process.env.PATH, + OPENROUTER_API_KEY: "fixture-key", + PAPERCLIP_NATIVE_MCP_NAME: "paperclip-assigned", + PAPERCLIP_NATIVE_MCP_URL: "https://paperclip.example/mcp", + PAPERCLIP_NATIVE_MCP_TOKEN: "x".repeat(40), + }, + fetch: async (input, init) => { + if (String(input).endsWith("/prompt_async")) submittedPrompt = JSON.parse(String(init?.body)); + return fetch(input, init); + }, + }); + const session = await driver.openSession({ + runId: "run-context", + normalizedSessionId: "context", + workingDirectory: workspace, + }); + await session.startTurn({ message: { role: "user", text: "finish" } }); + for await (const event of session.events()) { + if (event.eventType === "turn.completed") break; + } + expect(submittedPrompt).toMatchObject({ + system: systemInstructions, + tools: { question: true }, + }); + expect(JSON.stringify(submittedPrompt?.parts ?? null)).not.toContain(PAPERCLIP_EXECUTION_PROMPT); + const sessionRoot = join(root, "context"); + const isolatedHomes = (await readdir(sessionRoot)).filter((entry) => entry.startsWith("home-")); + expect(isolatedHomes).toHaveLength(1); + await expect(readFile(join(sessionRoot, isolatedHomes[0]!, ".claude", "skills", "assigned", "references", "support.md"), "utf8")) + .resolves.toBe("skill support\n"); + const config = JSON.parse(await readFile(join(sessionRoot, "config", "opencode", "opencode.json"), "utf8")); + expect(config).toMatchObject({ + instructions: [], + plugin: [], + tools: { question: true }, + permission: { external_directory: { "*": "deny", [`${instructionRoot}/**`]: "allow" } }, + mcp: { + paperclip: { enabled: true }, + "paperclip-assigned": { url: "https://paperclip.example/mcp", enabled: true }, + }, + }); + await expect(readFile(join(instructionRoot, "sibling.md"), "utf8")).resolves.toBe("instruction sibling\n"); + await session.close({ reason: "test" }); + }); + + it("sends only the authoritative wake envelope when resuming an existing provider session", async () => { + await chmod(fixture, 0o755); + const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-resume-context-")); + const workspace = await mkdtemp(join(tmpdir(), "paperclip-opencode-resume-workspace-")); + roots.push(root, workspace); + let submittedPrompt: Record | null = null; + const driver = new OpenCodeServerDriver({ + model: "openrouter/deepseek/deepseek-v4-flash-0731", + runtimeDirectory: root, + command: fixture, + systemInstructions: "large original system context", + environment: { PATH: process.env.PATH, OPENROUTER_API_KEY: "fixture-key" }, + fetch: async (input, init) => { + if (String(input).endsWith("/prompt_async")) submittedPrompt = JSON.parse(String(init?.body)); + return fetch(input, init); + }, + }); + const seed = await driver.openSession({ + runId: "run-seed", + normalizedSessionId: "resume-context", + workingDirectory: workspace, + }); + const snapshot = await seed.snapshot(); + await seed.close({ reason: "seed complete" }); + + const recovered = await driver.recoverSession?.(snapshot); + expect(recovered).toMatchObject({ recovered: true }); + await recovered!.session!.startTurn({ + message: { role: "user", text: "{\"interactionResponses\":[{\"answer\":\"friendly\"}]}" }, + }); + for await (const event of recovered!.session!.events()) { + if (event.eventType === "turn.completed") break; + } + + expect(submittedPrompt).toMatchObject({ + providerID: "openrouter", + modelID: "deepseek/deepseek-v4-flash-0731", + tools: { question: true }, + parts: [{ type: "text", text: "{\"interactionResponses\":[{\"answer\":\"friendly\"}]}" }], + }); + expect(submittedPrompt).not.toHaveProperty("system"); + await recovered!.session!.close({ reason: "recovery-test" }); + }); + + it("normalizes native question.asked events and replies with ordered OpenCode answer arrays", async () => { + await chmod(fixture, 0o755); + const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-question-")); + const workspace = await mkdtemp(join(tmpdir(), "paperclip-opencode-question-workspace-")); + roots.push(root, workspace); + const driver = new OpenCodeServerDriver({ + model: "openrouter/deepseek/deepseek-v4-flash-0731", + runtimeDirectory: root, + command: fixture, + environment: { PATH: process.env.PATH, OPENROUTER_API_KEY: "fixture-key" }, + }); + const session = await driver.openSession({ + runId: "run-native-question", + normalizedSessionId: "native-question", + workingDirectory: workspace, + }); + const { turnId } = await session.startTurn({ message: { role: "user", text: "native-question" } }); + const iterator = session.events()[Symbol.asyncIterator](); + let requestEvent: Awaited>["value"] | null = null; + for (let count = 0; count < 30; count += 1) { + const event = await iterator.next(); + if (event.done) break; + if (event.value.eventType === "runtime_request.created") { + requestEvent = event.value; + break; + } + } + expect(requestEvent?.payload).toMatchObject({ + request: { + schema: "paperclip.runtime_request.v2", + requestKind: "runtime", + type: "input", + input: { + schema: "paperclip.question_set.v1", + questions: [ + { id: "environment", answerMode: "single_select", required: false, customAnswer: { enabled: true } }, + { id: "regions", answerMode: "multi_select", required: false }, + ], + }, + }, + }); + await new Promise((resolveDelay) => setTimeout(resolveDelay, 20)); + expect(session.pendingRuntimeRequests?.()).toHaveLength(1); + await session.resolveRuntimeRequest?.({ + requestId: "question-native-1", + turnId, + resolution: { + action: "submit", + response: { + schema: "paperclip.question_response.v1", + answers: { + environment: { customText: "Canary" }, + regions: { selectedOptionIds: ["option-1", "option-2"] }, + }, + }, + }, + }); + const reply = JSON.parse(await readFile(join(root, "native-question", "data", "fake-question-reply.json"), "utf8")); + expect(reply).toEqual({ + url: expect.stringContaining(`directory=${encodeURIComponent(workspace)}`), + body: { answers: [["Canary"], ["US", "EU"]] }, + }); + const terminalQuestionEvents = (await session.transcript?.())?.events.filter((event) => + event.payload.requestId === "question-native-1" + && ["runtime_request.resolved", "runtime_request.cancelled"].includes(event.eventType) + ) ?? []; + expect(terminalQuestionEvents).toHaveLength(1); + expect(terminalQuestionEvents[0]?.payload).toMatchObject({ + action: "submit", + response: { + schema: "paperclip.question_response.v1", + answers: { + environment: { customText: "Canary" }, + regions: { selectedOptionIds: ["option-1", "option-2"] }, + }, + }, + }); + await session.close({ reason: "test" }); + }); + + it("hands a native question to the durable interaction path without touching permissions", async () => { + await chmod(fixture, 0o755); + const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-question-handoff-")); + const workspace = await mkdtemp(join(tmpdir(), "paperclip-opencode-question-handoff-workspace-")); + roots.push(root, workspace); + const driver = new OpenCodeServerDriver({ + model: "openrouter/deepseek/deepseek-v4-flash-0731", + runtimeDirectory: root, + command: fixture, + environment: { PATH: process.env.PATH, OPENROUTER_API_KEY: "fixture-key" }, + }); + const session = await driver.openSession({ + runId: "run-native-question-handoff", + normalizedSessionId: "native-question-handoff", + workingDirectory: workspace, + }); + const { turnId } = await session.startTurn({ message: { role: "user", text: "native-question" } }); + const iterator = session.events()[Symbol.asyncIterator](); + let created: Awaited>["value"] | null = null; + for (let count = 0; count < 30; count += 1) { + const next = await iterator.next(); + if (next.done) break; + if (next.value.eventType === "runtime_request.created") { + created = next.value; + break; + } + } + expect(created).toMatchObject({ + payload: { request: { requestId: "question-native-1", type: "input" } }, + }); + + const firstHandoff = session.handoffRuntimeRequest?.({ + requestId: "question-native-1", + turnId, + reason: "durable_handoff", + signal: new AbortController().signal, + }); + expect(firstHandoff?.result).toBe("handed_off"); + await firstHandoff?.cleanup; + const repeatedHandoff = session.handoffRuntimeRequest?.({ + requestId: "question-native-1", + turnId, + reason: "durable_handoff", + signal: new AbortController().signal, + }); + expect(repeatedHandoff?.result).toBe("already_settled"); + await repeatedHandoff?.cleanup; + + let expired: Awaited>["value"] | null = null; + for (let count = 0; count < 20; count += 1) { + const next = await iterator.next(); + if (next.done) break; + if (next.value.eventType === "runtime_request.expired") { + expired = next.value; + break; + } + } + expect(expired).toMatchObject({ + payload: { + requestId: "question-native-1", + reason: "durable_handoff", + replayAllowed: false, + requestType: "input", + request: { + schema: "paperclip.runtime_request.v2", + requestId: "question-native-1", + type: "input", + }, + }, + }); + expect(session.pendingRuntimeRequests?.()).toEqual([]); + const terminal = (await session.transcript?.())?.events.filter((event) => + event.payload.requestId === "question-native-1" + && ["runtime_request.resolved", "runtime_request.cancelled", "runtime_request.expired"].includes(event.eventType) + ); + expect(terminal).toHaveLength(1); + await session.close({ reason: "handoff test complete" }); + }); + + it("recovers a missed pending question from the list endpoint and rejects it", async () => { + await chmod(fixture, 0o755); + const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-question-recovery-")); + const workspace = await mkdtemp(join(tmpdir(), "paperclip-opencode-question-recovery-workspace-")); + roots.push(root, workspace); + const normalizedSessionId = "question-recovery"; + const driver = new OpenCodeServerDriver({ + model: "openrouter/deepseek/deepseek-v4-flash-0731", + runtimeDirectory: root, + command: fixture, + environment: { PATH: process.env.PATH, OPENROUTER_API_KEY: "fixture-key" }, + }); + const seed = await driver.openSession({ + runId: "run-question-recovery", + normalizedSessionId, + workingDirectory: workspace, + }); + const snapshot = await seed.snapshot(); + await seed.close({ reason: "simulate reconnect" }); + const dataRoot = join(root, normalizedSessionId, "data"); + await mkdir(dataRoot, { recursive: true }); + await writeFile(join(dataRoot, "fake-pending-question.json"), JSON.stringify({ + id: "question-native-1", + sessionID: "ses_fake_1", + questions: [{ + id: "environment", + header: "Environment", + question: "Where should we deploy?", + options: [{ label: "Staging" }, { label: "Production" }], + custom: true, + }], + })); + + const recovered = await driver.recoverSession?.({ + ...snapshot, + activeTurnId: "turn-recovered-question", + }); + expect(recovered).toMatchObject({ recovered: true }); + const session = recovered!.session!; + const iterator = session.events()[Symbol.asyncIterator](); + let created: Awaited>["value"] | null = null; + for (let count = 0; count < 20; count += 1) { + const next = await iterator.next(); + if (next.done) break; + if (next.value.eventType === "runtime_request.created") { + created = next.value; + break; + } + } + expect(created).toMatchObject({ + payload: { request: { requestId: "question-native-1", type: "input" } }, + }); + await session.resolveRuntimeRequest?.({ + requestId: "question-native-1", + turnId: "turn-recovered-question", + resolution: { action: "decline" }, + }); + expect(session.pendingRuntimeRequests?.()).toEqual([]); + await session.close({ reason: "recovery test complete" }); + }); + + it.each([ + { style: "legacy", action: "accept" as const, reply: "once" }, + { style: "v2", action: "accept_for_session" as const, reply: "always" }, + { style: "v2", action: "decline" as const, reply: "reject" }, + ])("normalizes $style permission events and maps $action to $reply", async ({ style, action, reply }) => { + await chmod(fixture, 0o755); + const root = await mkdtemp(join(tmpdir(), `paperclip-opencode-permission-${style}-`)); + const workspace = await mkdtemp(join(tmpdir(), `paperclip-opencode-permission-${style}-workspace-`)); + roots.push(root, workspace); + const driver = new OpenCodeServerDriver({ + model: "openrouter/deepseek/deepseek-v4-flash-0731", + permissionMode: "ask", + runtimeDirectory: root, + command: fixture, + environment: { PATH: process.env.PATH, OPENROUTER_API_KEY: "fixture-key" }, + }); + const normalizedSessionId = `native-permission-${style}-${action}`; + const session = await driver.openSession({ + runId: `run-${normalizedSessionId}`, + normalizedSessionId, + workingDirectory: workspace, + }); + const { turnId } = await session.startTurn({ + message: { role: "user", text: `native-permission-${style}` }, + }); + const iterator = session.events()[Symbol.asyncIterator](); + let created: Awaited>["value"] | null = null; + for (let count = 0; count < 30; count += 1) { + const next = await iterator.next(); + if (next.done) break; + if (next.value.eventType === "runtime_request.created") { + created = next.value; + break; + } + } + expect(created).toMatchObject({ + payload: { + request: { + requestId: "permission-native-1", + type: "permission", + actions: ["accept", "accept_for_session", "decline", "cancel"], + }, + }, + }); + await session.resolveRuntimeRequest?.({ + requestId: "permission-native-1", + turnId, + resolution: { action }, + }); + const persisted = JSON.parse( + await readFile(join(root, normalizedSessionId, "data", "fake-permission-reply.json"), "utf8"), + ); + expect(persisted).toEqual({ + url: expect.stringContaining(`directory=${encodeURIComponent(workspace)}`), + body: { reply }, + }); + expect(session.pendingRuntimeRequests?.()).toEqual([]); + await session.close({ reason: "permission test complete" }); + }); + + it.each(["allow", "ask", "deny"] as const)("pins the %s permission mode while retaining external-directory denial", async (permissionMode) => { + await chmod(fixture, 0o755); + const root = await mkdtemp(join(tmpdir(), `paperclip-opencode-mode-${permissionMode}-`)); + const workspace = await mkdtemp(join(tmpdir(), `paperclip-opencode-mode-${permissionMode}-workspace-`)); + roots.push(root, workspace); + const driver = new OpenCodeServerDriver({ + model: "openrouter/deepseek/deepseek-v4-flash-0731", + permissionMode, + runtimeDirectory: root, + command: fixture, + environment: { PATH: process.env.PATH, OPENROUTER_API_KEY: "fixture-key" }, + }); + const session = await driver.openSession({ runId: `run-${permissionMode}`, normalizedSessionId: permissionMode, workingDirectory: workspace }); + const config = JSON.parse(await readFile(join(root, permissionMode, "config", "opencode", "opencode.json"), "utf8")); + expect(config.permission).toMatchObject({ "*": permissionMode, external_directory: "deny" }); + expect(config.permission.paperclip_finish).toBeUndefined(); + expect(config.permission["paperclip_*"]).toBe("allow"); + await session.close({ reason: "permission mode test complete" }); + }); + + it("clears a stale active turn that already has a persisted terminal fingerprint", async () => { + await chmod(fixture, 0o755); + const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-stale-turn-")); + const workspace = await mkdtemp(join(tmpdir(), "paperclip-opencode-stale-workspace-")); + roots.push(root, workspace); + const driver = new OpenCodeServerDriver({ + model: "openrouter/deepseek/deepseek-v4-flash-0731", + runtimeDirectory: root, + command: fixture, + environment: { PATH: process.env.PATH, OPENROUTER_API_KEY: "fixture-key" }, + }); + const seed = await driver.openSession({ + runId: "run-stale-turn", + normalizedSessionId: "stale-turn", + workingDirectory: workspace, + }); + const snapshot = await seed.snapshot(); + await seed.close({ reason: "seed complete" }); + + const recoveryDriver = new OpenCodeServerDriver({ + model: "openrouter/deepseek/deepseek-v4-flash-0731", + runtimeDirectory: root, + command: fixture, + environment: { PATH: process.env.PATH, OPENROUTER_API_KEY: "fixture-key" }, + }); + const recovery = await recoveryDriver.recoverSession?.({ + ...snapshot, + activeTurnId: "turn-already-terminal", + terminalTurns: [{ turnId: "turn-already-terminal", fingerprint: "terminal-fingerprint" }], + }); + expect(recovery).toMatchObject({ recovered: true }); + await expect(recovery!.session!.snapshot()).resolves.toMatchObject({ activeTurnId: null }); + await recovery!.session!.close({ reason: "recovery-test" }); + }); + + it("validates provider/model form", async () => { + const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-driver-")); + roots.push(root); + const driver = new OpenCodeServerDriver({ model: "bad", runtimeDirectory: root }); + await expect(driver.validateConfig?.({ model: "bad" })).resolves.toMatchObject({ ok: false }); + await expect(driver.validateConfig?.({ model: "openrouter/model" })).resolves.toMatchObject({ ok: true }); + }); + + it("normalizes a structured block result", async () => { + await chmod(fixture, 0o755); + const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-driver-")); + const workspace = await mkdtemp(join(tmpdir(), "paperclip-opencode-workspace-")); + roots.push(root, workspace); + const driver = new OpenCodeServerDriver({ + model: "openrouter/deepseek/deepseek-v4-flash-0731", + runtimeDirectory: root, + command: fixture, + environment: { PATH: process.env.PATH, OPENROUTER_API_KEY: "fixture-key" }, + }); + const session = await driver.openSession({ runId: "run-block", normalizedSessionId: "block", workingDirectory: workspace }); + await session.startTurn({ message: { role: "user", text: "block-result" } }); + const events = []; + for await (const event of session.events()) events.push(event); + expect(events.find((event) => event.eventType === "run.result.proposed")?.payload) + .toMatchObject({ reportedWorkDisposition: "blocked", blocker: { reasonCode: "test_dependency" } }); + await session.close({ reason: "test" }); + }); + + it("designates the substantive pre-finish response instead of a trailing acknowledgement", async () => { + await chmod(fixture, 0o755); + const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-driver-")); + const workspace = await mkdtemp(join(tmpdir(), "paperclip-opencode-workspace-")); + roots.push(root, workspace); + const driver = new OpenCodeServerDriver({ + model: "openrouter/deepseek/deepseek-v4-flash-0731", + runtimeDirectory: root, + command: fixture, + environment: { PATH: process.env.PATH, OPENROUTER_API_KEY: "fixture-key" }, + }); + const session = await driver.openSession({ + runId: "run-final-selection", + normalizedSessionId: "final-selection", + workingDirectory: workspace, + }); + await session.startTurn({ message: { role: "user", text: "text-before-finish" } }); + const events = []; + for await (const event of session.events()) events.push(event); + const finalMessages = events.filter( + (event) => event.eventType === "item.completed" + && event.payload.kind === "agentMessage" + && event.payload.channel === "final", + ); + expect(finalMessages).toHaveLength(1); + expect(finalMessages[0]?.payload.text).toBe("Substantive answer before finish."); + expect(events.filter((event) => event.eventType === "item.delta").map((event) => event.payload.text)) + .toEqual(expect.arrayContaining(["Substantive answer before finish.", "Done."])); + await session.close({ reason: "test" }); + }); + + it("does not promote opening commentary to the final response when work follows it", async () => { + await chmod(fixture, 0o755); + const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-driver-")); + const workspace = await mkdtemp(join(tmpdir(), "paperclip-opencode-workspace-")); + roots.push(root, workspace); + const driver = new OpenCodeServerDriver({ + model: "openrouter/deepseek/deepseek-v4-flash-0731", + runtimeDirectory: root, + command: fixture, + environment: { PATH: process.env.PATH, OPENROUTER_API_KEY: "fixture-key" }, + }); + const session = await driver.openSession({ + runId: "run-commentary-only", + normalizedSessionId: "commentary-only", + workingDirectory: workspace, + }); + await session.startTurn({ message: { role: "user", text: "commentary-only-before-work" } }); + const events = []; + for await (const event of session.events()) events.push(event); + expect(events.filter( + (event) => event.eventType === "item.completed" + && event.payload.kind === "agentMessage" + && event.payload.channel === "final", + )).toHaveLength(0); + expect(events.filter((event) => event.eventType === "item.delta").map((event) => event.payload.text)) + .toContain("I will inspect the workspace first."); + expect(events).toContainEqual(expect.objectContaining({ eventType: "run.result.proposed" })); + await session.close({ reason: "test" }); + }); + + it("correlates the final response to the native message containing the semantic tool", async () => { + await chmod(fixture, 0o755); + const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-driver-")); + const workspace = await mkdtemp(join(tmpdir(), "paperclip-opencode-workspace-")); + roots.push(root, workspace); + const driver = new OpenCodeServerDriver({ + model: "openrouter/deepseek/deepseek-v4-flash-0731", + runtimeDirectory: root, + command: fixture, + environment: { PATH: process.env.PATH, OPENROUTER_API_KEY: "fixture-key" }, + }); + const session = await driver.openSession({ + runId: "run-correlated-final-selection", + normalizedSessionId: "correlated-final-selection", + workingDirectory: workspace, + }); + await session.startTurn({ message: { role: "user", text: "correlated-final-message" } }); + const events = []; + for await (const event of session.events()) events.push(event); + const finalMessages = events.filter( + (event) => event.eventType === "item.completed" + && event.payload.kind === "agentMessage" + && event.payload.channel === "final", + ); + expect(finalMessages).toHaveLength(1); + expect(finalMessages[0]?.payload.text).toBe("Correlated substantive final answer."); + expect(events.filter((event) => event.eventType === "item.delta").map((event) => event.payload.text)) + .toEqual(expect.arrayContaining([ + "I will write the result now.", + "Correlated substantive final answer.", + "Done.", + ])); + await session.close({ reason: "test" }); + }); + + it("prefers a substantive post-tool answer over schema commentary in the tool message", async () => { + await chmod(fixture, 0o755); + const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-driver-")); + const workspace = await mkdtemp(join(tmpdir(), "paperclip-opencode-workspace-")); + roots.push(root, workspace); + const driver = new OpenCodeServerDriver({ + model: "openrouter/deepseek/deepseek-v4-flash-0731", + runtimeDirectory: root, + command: fixture, + environment: { PATH: process.env.PATH, OPENROUTER_API_KEY: "fixture-key" }, + }); + const session = await driver.openSession({ + runId: "run-post-tool-final-selection", + normalizedSessionId: "post-tool-final-selection", + workingDirectory: workspace, + }); + await session.startTurn({ message: { role: "user", text: "final-after-tool-commentary" } }); + const events = []; + for await (const event of session.events()) events.push(event); + const finalMessages = events.filter( + (event) => event.eventType === "item.completed" + && event.payload.kind === "agentMessage" + && event.payload.channel === "final", + ); + expect(finalMessages).toHaveLength(1); + expect(finalMessages[0]?.payload.text) + .toBe("This is the complete substantive answer emitted after the accepted completion tool call."); + await session.close({ reason: "test" }); + }); + + it("rejects malformed and oversized SSE frames", async () => { + const stream = (value: string) => new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(value)); + controller.close(); + }, + }); + const consume = async (value: string) => { + for await (const _event of openCodeServerDriverInternals.parseSse(stream(value))) { + // Consume the complete stream so parse failures are observed. + } + }; + await expect(consume("data: {bad json}\n\n")).rejects.toThrow(); + await expect(consume(`data: ${"x".repeat(1_048_577)}`)).rejects.toThrow("payload limit"); + }); + + it("restarts OpenCode when session startup fails transiently", async () => { + await chmod(fixture, 0o755); + const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-driver-")); + const workspace = await mkdtemp(join(tmpdir(), "paperclip-opencode-workspace-")); + roots.push(root, workspace); + let failSessionCreate = true; + const spawns: number[] = []; + const driver = new OpenCodeServerDriver({ + model: "openrouter/deepseek/deepseek-v4-flash-0731", + runtimeDirectory: root, + command: fixture, + environment: { PATH: process.env.PATH, OPENROUTER_API_KEY: "fixture-key" }, + fetch: async (input, init) => { + if (failSessionCreate && String(input).endsWith("/session") && init?.method === "POST") { + failSessionCreate = false; + throw new Error("transient session initialization failure"); + } + return fetch(input, init); + }, + onSpawn: async ({ pid }) => { spawns.push(pid); }, + }); + const session = await driver.openSession({ runId: "run-retry", normalizedSessionId: "retry", workingDirectory: workspace }); + expect(session.ids().providerSessionId).toBe("ses_fake_1"); + expect(spawns).toHaveLength(2); + await session.close({ reason: "test" }); + }); + + it("reports startup exit details with stderr captured after spawn and redacted", async () => { + await chmod(fixture, 0o755); + const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-driver-")); + const workspace = await mkdtemp(join(tmpdir(), "paperclip-opencode-workspace-")); + roots.push(root, workspace); + const exitingFixture = join(root, "exit-before-health.mjs"); + await writeFile( + exitingFixture, + "#!/usr/bin/env node\nprocess.stderr.write(`credential=${process.env.OPENROUTER_API_KEY}\\nauthorization=super-secret-opencode-token\\n`);\nprocess.exit(17);\n", + { mode: 0o755 }, + ); + const driver = new OpenCodeServerDriver({ + model: "openrouter/deepseek/deepseek-v4-flash-0731", + runtimeDirectory: root, + command: exitingFixture, + environment: { + PATH: process.env.PATH, + OPENROUTER_API_KEY: "fixture-key", + }, + }); + const error = await driver.openSession({ + runId: "run-startup-exit", + normalizedSessionId: "startup-exit", + workingDirectory: workspace, + }).then( + () => "provider unexpectedly started", + (cause: unknown) => String(cause), + ); + expect(error).toContain("provider_process_exited"); + expect(error).toContain("provider=opencode"); + expect(error).toContain("stage=health"); + expect(error).toContain("[REDACTED]"); + expect(error).not.toContain("fixture-key"); + expect(error).not.toContain("super-secret-opencode-token"); + }); +}); diff --git a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts new file mode 100644 index 00000000000..c23dfc13c13 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts @@ -0,0 +1,1855 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { createServer } from "node:net"; +import { dirname, join, resolve } from "node:path"; + +import { + CODEX_SKILLLESS_BASE_INSTRUCTIONS, + createCodexTaskEnvelope, + type CodexTaskEnvelope, +} from "../../contracts/codex.js"; +import { + PRP_BLOCK_TOOL_NAME, + PRP_COMPLETION_TOOL_NAME, +} from "../../contracts/completion-result.js"; +import type { + HarnessDriver, + HarnessDriverConfigValidation, + HarnessDriverDescriptor, + HarnessSession, + HarnessSessionRecoveryResult, + HarnessTranscriptSnapshot, + OpenHarnessSessionInput, + PersistedHarnessSession, + HarnessRuntimeRequest, + HarnessRuntimeRequestHandoff, + HarnessRuntimeRequestResolution, + PaperclipQuestion, + PaperclipQuestionResponse, + PaperclipQuestionSet, +} from "../../contracts/harness-driver.js"; +import { + PAPERCLIP_QUESTION_SET_SCHEMA, + PAPERCLIP_RUNTIME_REQUEST_SCHEMA_V2, + harnessRuntimeInputExpiredOutcome, + harnessRuntimeRequestOutcome, + parseHarnessRuntimeRequestResolution, + parsePaperclipQuestionSet, +} from "../../contracts/harness-driver.js"; +import type { NativeSessionCapabilities, NativeUserMessage } from "../../contracts/types.js"; +import type { NativeRuntimeContextSnapshot } from "../../contracts/runtime-context.js"; +import { + createProviderTraceFileSink, + type ProviderTraceFileSink, +} from "../../contracts/provider-trace-file-sink.js"; +import type { PrpEvent, PrpStructuredRunResult } from "../../protocol/replay-contract.js"; +import { validatePrpStructuredRunResult } from "../../protocol/replay-contract.js"; +import { paperclipWorkspaceFileReferencesFromText } from "../../live/workspace-file-reference.js"; +import { + canonicalOpenCodeDisplayToolName, + canonicalProviderEventsFromOpenCodePart, + providerFamilyCapabilities, +} from "../../provider-events.js"; +import { canonicalOpenCodeMcpToolName, startOpenCodeMcpBridge, type OpenCodeMcpBridge } from "./mcp-bridge.js"; +import { nativeMcpLaunchBinding } from "../native-mcp.js"; +import { materializeNativeRuntimeSkills } from "../runtime-context-materializer.js"; + +export const OPENCODE_SERVER_DRIVER_KIND = "opencode_server" as const; +export const QUALIFIED_OPENCODE_VERSION = "1.18.17" as const; +export const QUALIFIED_OPENCODE_MODEL = "openrouter/deepseek/deepseek-v4-flash-0731" as const; + +type DynamicToolHandler = (call: { + tool: string; + callId: string; + threadId: string; + turnId: string; + arguments: unknown; +}) => Promise; + +export interface OpenCodeServerDriverOptions { + model: string; + permissionMode?: "allow" | "ask" | "deny"; + taskEnvelope?: CodexTaskEnvelope; + runnerInstanceId?: string; + command?: string; + runtimeDirectory: string; + systemInstructions?: string; + runtimeContext?: NativeRuntimeContextSnapshot | null; + environment?: NodeJS.ProcessEnv; + dynamicTools?: readonly Readonly>[]; + dynamicToolHandler?: DynamicToolHandler; + onSpawn?: (meta: { pid: number; processGroupId: number | null; startedAt: string }) => Promise; + /** Keep false when an outer supervisor already owns the process group. */ + isolateProcessGroup?: boolean; + onDiagnostic?: (message: string) => void; + fetch?: typeof globalThis.fetch; + now?: () => Date; +} + +interface OpenCodeRuntime { + baseUrl: string; + authHeader: string; + version: string; + permissionMode: "allow" | "ask" | "deny"; + process: ChildProcess; + bridge: OpenCodeMcpBridge; + trace: ProviderTraceFileSink | null; + sensitiveValues: readonly string[]; + close(input?: { finalizeTrace?: boolean; reason?: string | null }): Promise; +} + +const CAPABILITIES: NativeSessionCapabilities = { + resume: true, + typedEvents: true, + typedEventFamilies: providerFamilyCapabilities({ + tool_execution: "available", + research: "available", + delegation: "available", + context: "available", + artifact: "policy_disabled", + provider_notice: "available", + }), + steering: false, + interruption: true, + structuredResult: true, + read: true, + reconciliation: true, + usage: true, + dynamicTools: true, + runtimeRequestResolution: true, + runtimeRequestHandoff: true, + goals: false, + threadLineage: false, + unsupported: ["steering", "goals", "threadLineage"], +}; + +export class OpenCodeServerDriver implements HarnessDriver { + readonly #options: OpenCodeServerDriverOptions; + + constructor(options: OpenCodeServerDriverOptions) { + this.#options = options; + } + + async descriptor(): Promise { + return { + kind: OPENCODE_SERVER_DRIVER_KIND, + displayName: "OpenCode server", + version: QUALIFIED_OPENCODE_VERSION, + protocolVersion: "http+sse/v1", + runtimeContextCapabilities: { instructions: "native", skills: "native", mcp: "native" }, + capabilities: structuredClone(CAPABILITIES), + }; + } + + async validateConfig(config: unknown): Promise { + const candidate = isRecord(config) ? config : {}; + const issues = []; + const model = typeof candidate.model === "string" ? candidate.model.trim() : ""; + if (!validModel(model)) issues.push({ path: "model", code: "invalid_model", message: "OpenCode model must use provider/model form." }); + const command = typeof candidate.command === "string" ? candidate.command.trim() : "opencode"; + if (!command) issues.push({ path: "command", code: "invalid_command", message: "OpenCode command cannot be empty." }); + const permissionMode = candidate.permissionMode ?? "allow"; + if (permissionMode !== "allow" && permissionMode !== "ask" && permissionMode !== "deny") { + issues.push({ path: "permissionMode", code: "invalid_permission_mode", message: "OpenCode permission mode must be allow, ask, or deny." }); + } + return issues.length === 0 + ? { ok: true, config: { model, command, permissionMode }, issues: [] } + : { ok: false, config: null, issues }; + } + + async openSession(input: OpenHarnessSessionInput): Promise { + return this.#open(input, null); + } + + async recoverSession(snapshot: PersistedHarnessSession): Promise { + if ( + snapshot.driverKind !== OPENCODE_SERVER_DRIVER_KIND + || !snapshot.runId + || !snapshot.normalizedSessionId + || !snapshot.driverSessionId + ) return { recovered: false, reason: "persisted OpenCode session identity is incomplete" }; + try { + const session = await this.#open({ + runId: snapshot.runId, + normalizedSessionId: snapshot.normalizedSessionId, + workingDirectory: await this.#readWorkspace(snapshot.normalizedSessionId), + }, snapshot); + return { recovered: true, session }; + } catch (error) { + return { recovered: false, reason: redact(String(error), [this.#options.environment?.OPENROUTER_API_KEY]) }; + } + } + + async #readWorkspace(normalizedSessionId: string): Promise { + const raw = await readFile(join(sessionRoot(this.#options.runtimeDirectory, normalizedSessionId), "workspace"), "utf8"); + return raw.trim(); + } + + async #open(input: OpenHarnessSessionInput, snapshot: PersistedHarnessSession | null): Promise { + const cwd = validateWorkspace(input.workingDirectory); + const root = sessionRoot(this.#options.runtimeDirectory, input.normalizedSessionId); + await mkdir(root, { recursive: true, mode: 0o700 }); + await writeFile(join(root, "workspace"), `${cwd}\n`, { mode: 0o600 }); + const trace = await createProviderTraceFileSink({ + path: this.#options.environment?.PAPERCLIP_PROVIDER_TRACE_PATH, + provider: "opencode", + channel: "typescript_opencode_native", + maxBytes: this.#options.environment?.PAPERCLIP_PROVIDER_TRACE_MAX_BYTES, + }); + let lastError: unknown = null; + for (let attempt = 1; attempt <= 3; attempt += 1) { + let session: OpenCodeHarnessSession | null = null; + let runtime: OpenCodeRuntime | null = null; + try { + runtime = await startRuntime({ + options: this.#options, + root, + cwd, + trace, + dispatch: (call) => { + if (session === null) throw new Error("OpenCode session is not ready for tool calls"); + return session.dispatchTool(call); + }, + }); + const fetcher = this.#options.fetch ?? globalThis.fetch; + let providerSessionId = snapshot?.providerSessionId ?? snapshot?.driverSessionId ?? null; + if (providerSessionId !== null) { + const existing = await api(fetcher, runtime, `/session/${encodeURIComponent(providerSessionId)}`); + if (!isRecord(existing) || text(existing.id) !== providerSessionId) throw new Error("OpenCode resumed a different session"); + } else { + const created = await api(fetcher, runtime, "/session", { + method: "POST", + body: JSON.stringify({ title: `Paperclip ${input.runId}` }), + }); + providerSessionId = text(record(created).id); + if (!providerSessionId) throw new Error("OpenCode session creation omitted its id"); + } + session = new OpenCodeHarnessSession({ + runtime, + fetcher, + runId: input.runId, + normalizedSessionId: input.normalizedSessionId, + providerSessionId, + workingDirectory: cwd, + runnerInstanceId: this.#options.runnerInstanceId ?? `paperclip-opencode-${input.runId}`, + model: this.#options.model, + taskEnvelope: this.#options.taskEnvelope ?? createCodexTaskEnvelope({ objective: "Complete the supplied task." }), + systemInstructions: this.#options.systemInstructions ?? CODEX_SKILLLESS_BASE_INSTRUCTIONS, + dynamicToolHandler: this.#options.dynamicToolHandler, + snapshot, + now: this.#options.now ?? (() => new Date()), + }); + session.startEventPump(); + return session; + } catch (error) { + lastError = error; + await runtime?.close({ finalizeTrace: false }); + if (attempt === 3 || !retryableOpenCodeStartupError(error)) { + await trace?.finish({ reason: "opencode_session_start_failed" }); + throw error; + } + this.#options.onDiagnostic?.(`OpenCode session startup attempt ${attempt} failed; retrying.`); + await new Promise((resolveDelay) => setTimeout(resolveDelay, 100 * attempt)); + } + } + throw lastError; + } +} + +class OpenCodeHarnessSession implements HarnessSession { + readonly #runtime: OpenCodeRuntime; + readonly #fetch: typeof globalThis.fetch; + #runId: string; + readonly #normalizedSessionId: string; + readonly #providerSessionId: string; + readonly #workingDirectory: string; + readonly #runnerInstanceId: string; + readonly #model: string; + readonly #taskEnvelope: CodexTaskEnvelope; + readonly #systemInstructions: string; + readonly #dynamicToolHandler?: DynamicToolHandler; + readonly #now: () => Date; + readonly #events = new AsyncQueue(); + readonly #transcript: PrpEvent[] = []; + readonly #terminalTurns = new Map(); + readonly #seenProviderEvents = new Set(); + readonly #messageRoles = new Map(); + readonly #pendingMessageParts = new Map>>(); + readonly #partText = new Map(); + readonly #completedTextPartIds = new Set(); + readonly #completedTextParts: Array<{ + partId: string; + messageId: string | null; + text: string; + item: Record; + observedSourceSeq: number; + }> = []; + readonly #messageUsageFingerprints = new Map(); + readonly #workspaceChangesByTurn = new Map>(); + readonly #emittedFileReferences = new Set(); + readonly #pendingRuntimeRequests = new Map[]; + submittedResponse?: PaperclipQuestionResponse; + submittedAction?: "accept" | "accept_for_session" | "decline" | "cancel"; + settling?: boolean; + }>(); + #activeTraceFrameId: number | null = null; + #activeTraceEmittedEventIds: string[] = []; + #sourceSequence: number; + #activeTurnId: string | null; + #result: PrpStructuredRunResult | null; + #resultFingerprint: string | null; + #resultCallId: string | null; + #resultTurnId: string | null; + #semanticResultTextBoundary: number | null = null; + #semanticResultProviderMessageId: string | null = null; + #lastNonTerminalToolSourceSeq = 0; + #usage: Record | null = null; + #sendFullContext: boolean; + #closed = false; + #abort = new AbortController(); + + constructor(input: { + runtime: OpenCodeRuntime; + fetcher: typeof globalThis.fetch; + runId: string; + normalizedSessionId: string; + providerSessionId: string; + workingDirectory: string; + runnerInstanceId: string; + model: string; + taskEnvelope: CodexTaskEnvelope; + systemInstructions: string; + dynamicToolHandler?: DynamicToolHandler; + snapshot: PersistedHarnessSession | null; + now: () => Date; + }) { + this.#runtime = input.runtime; + this.#fetch = input.fetcher; + this.#runId = input.runId; + this.#sourceSequence = 0; + this.#normalizedSessionId = input.normalizedSessionId; + this.#providerSessionId = input.providerSessionId; + this.#workingDirectory = input.workingDirectory; + this.#runnerInstanceId = input.runnerInstanceId; + this.#model = input.model; + this.#taskEnvelope = input.taskEnvelope; + this.#systemInstructions = input.systemInstructions; + this.#dynamicToolHandler = input.dynamicToolHandler; + this.#now = input.now; + this.#sendFullContext = input.snapshot === null; + this.#sourceSequence = input.snapshot?.lastSourceSequence ?? 0; + this.#activeTurnId = input.snapshot?.activeTurnId ?? null; + const restored = input.snapshot?.semanticResult ?? null; + this.#result = restored?.result ?? null; + this.#resultFingerprint = restored?.fingerprint ?? null; + this.#resultCallId = restored?.callId ?? null; + this.#resultTurnId = restored?.turnId ?? null; + for (const terminal of input.snapshot?.terminalTurns ?? []) this.#terminalTurns.set(terminal.turnId, terminal.fingerprint); + if (this.#activeTurnId && this.#terminalTurns.has(this.#activeTurnId)) { + this.#activeTurnId = null; + } + this.#emit(input.snapshot ? "session.resumed" : "session.started", { + driverSessionId: input.providerSessionId, + providerSessionId: input.providerSessionId, + context: { + protocolVersion: "http+sse/v1", + opencodeVersion: input.runtime.version, + model: input.model, + modelProvider: input.model.split("/", 1)[0], + workingDirectory: input.workingDirectory, + environmentKeys: sanitizedEnvironmentKeys(), + permissionMode: input.runtime.permissionMode, + }, + }); + this.#emit("item.completed", { + kind: "model", + text: `${input.model} (OpenCode ${input.runtime.version})`, + model: { name: input.model, provider: input.model.split("/", 1)[0], opencodeVersion: input.runtime.version }, + }, { itemId: `${input.providerSessionId}:model` }); + } + + ids() { + return { driverSessionId: this.#providerSessionId, providerSessionId: this.#providerSessionId, displayId: this.#providerSessionId }; + } + + attachRun(input: { runId: string }): void { + if (this.#activeTurnId !== null) throw new Error("opencode_run_attach_busy"); + if (!input.runId) throw new Error("opencode_run_attach_invalid"); + this.#runId = input.runId; + this.#result = null; + this.#resultFingerprint = null; + this.#resultCallId = null; + this.#resultTurnId = null; + this.#semanticResultTextBoundary = null; + this.#semanticResultProviderMessageId = null; + this.#lastNonTerminalToolSourceSeq = 0; + this.#completedTextPartIds.clear(); + this.#completedTextParts.length = 0; + this.#terminalTurns.clear(); + this.#sendFullContext = false; + this.#emit("run.attached", { runId: input.runId, sameSession: true }); + } + + events(): AsyncIterable { return this.#events; } + + startEventPump(): void { + void this.#recoverPendingRuntimeRequests() + .catch((error) => this.#emit("harness.diagnostic", { + code: "opencode_runtime_request_recovery_failed", + message: redact(String(error), this.#runtime.sensitiveValues), + })) + .finally(() => this.#pumpEvents()); + } + + async startTurn(input: { message: NativeUserMessage }): Promise<{ turnId: string }> { + if (this.#activeTurnId !== null) throw new Error("OpenCode session already has an active turn"); + const turnId = `turn-${randomBytes(12).toString("hex")}`; + this.#activeTurnId = turnId; + this.#emit("turn.submitted", { envelopeSchema: this.#taskEnvelope.schema, text: input.message.text }); + this.#emit("turn.accepted", { turnId }, { turnId }); + this.#emit("turn.started", { status: "inProgress" }, { turnId }); + const [providerID, ...modelParts] = this.#model.split("/"); + const modelID = modelParts.join("/"); + // A resumed OpenCode provider session already retains the original system + // instructions and task envelope in its conversation. Repeating both on + // every Paperclip continuation can overflow smaller context windows and + // OpenCode then completes with `finish: unknown` and zero tokens. The + // native model envelope still carries the authoritative wake delta, + // interaction responses, completion contract, and current issue context. + const prompt = this.#sendFullContext + ? JSON.stringify({ task: this.#taskEnvelope, message: input.message.text }) + : input.message.text; + await api(this.#fetch, this.#runtime, `/session/${encodeURIComponent(this.#providerSessionId)}/prompt_async`, { + method: "POST", + body: JSON.stringify({ + // OpenCode 1.18's PromptPayload carries the provider and model at the + // top level. Older examples used a nested `model` object; 1.18 + // silently ignores that shape and falls back to the configured model, + // which can turn `openrouter/deepseek/...` into a duplicated provider + // lookup. Keep Paperclip's persisted provider/model form, but adapt it + // at this HTTP boundary. + providerID, + modelID, + // This exact OpenCode version passed the native-question conformance + // suite. question.asked is adapted into PRP v2 and its reply/reject API + // remains private to this driver. + tools: { question: true }, + ...(this.#sendFullContext ? { system: this.#systemInstructions } : {}), + parts: [{ type: "text", text: prompt }], + }), + }); + this.#sendFullContext = false; + return { turnId }; + } + + async interrupt(input: { turnId?: string; reason?: string }): Promise { + if (input.turnId && this.#activeTurnId && input.turnId !== this.#activeTurnId) throw new Error("stale OpenCode turn"); + await api(this.#fetch, this.#runtime, `/session/${encodeURIComponent(this.#providerSessionId)}/abort`, { method: "POST" }); + } + + pendingRuntimeRequests(): HarnessRuntimeRequest[] { + return [...this.#pendingRuntimeRequests.values()].map(({ request }) => structuredClone(request)); + } + + async resolveRuntimeRequest(input: { + requestId: string; + turnId: string; + resolution: HarnessRuntimeRequestResolution; + }): Promise { + const pending = this.#pendingRuntimeRequests.get(input.requestId); + if (!pending) throw new Error(`OpenCode request ${input.requestId} is no longer pending`); + if (pending.request.turnId !== input.turnId || this.#activeTurnId !== input.turnId) { + throw new Error(`OpenCode request ${input.requestId} belongs to a stale turn`); + } + const resolution = parseHarnessRuntimeRequestResolution( + pending.request.requestKind, + input.resolution, + pending.request.input, + ); + if (pending.settling) throw new Error(`OpenCode request ${input.requestId} is already settling`); + pending.settling = true; + const submit = async (operation: Promise) => { + try { + await operation; + } catch (error) { + if (this.#pendingRuntimeRequests.get(input.requestId) === pending) pending.settling = false; + throw error; + } + }; + const workspace = `directory=${encodeURIComponent(this.#workingDirectory)}`; + if (pending.request.requestKind === "permission_approval") { + const action = resolution.action === "accept" || resolution.action === "accept_for_session" + ? resolution.action + : resolution.action === "decline" || resolution.action === "cancel" + ? resolution.action + : "decline"; + pending.submittedAction = action; + await submit(api( + this.#fetch, + this.#runtime, + `/permission/${encodeURIComponent(input.requestId)}/reply?${workspace}`, + { + method: "POST", + body: JSON.stringify({ + reply: action === "accept" ? "once" : action === "accept_for_session" ? "always" : "reject", + }), + }, + )); + if (!this.#pendingRuntimeRequests.delete(input.requestId)) return; + this.#emit("runtime_request.resolved", harnessRuntimeRequestOutcome(pending.request, { action }), { + turnId: input.turnId, + itemId: pending.request.itemId, + }); + return; + } + const base = `/question/${encodeURIComponent(input.requestId)}`; + if (resolution.action === "submit" && "response" in resolution) { + // OpenCode can broadcast question.replied before the reply HTTP request + // returns. Retain the canonical response before crossing that boundary + // so the racing terminal event carries the same durable answer record. + pending.submittedResponse = structuredClone(resolution.response); + await submit(api(this.#fetch, this.#runtime, `${base}/reply?${workspace}`, { + method: "POST", + body: JSON.stringify({ answers: openCodeAnswers(pending, resolution.response) }), + })); + } else if (resolution.action === "submit" && "answers" in resolution) { + await submit(api(this.#fetch, this.#runtime, `${base}/reply?${workspace}`, { + method: "POST", + body: JSON.stringify({ answers: pending.nativeQuestions.map((question, index) => + resolution.answers[openCodeQuestionId(question, index)]?.answers ?? [], + ) }), + })); + } else { + await submit(api(this.#fetch, this.#runtime, `${base}/reject?${workspace}`, { method: "POST" })); + } + // question.replied/question.rejected can race the HTTP response on the SSE + // stream. The first terminal fact wins; the echo must not emit a duplicate. + if (!this.#pendingRuntimeRequests.delete(input.requestId)) return; + this.#emit("runtime_request.resolved", harnessRuntimeRequestOutcome(pending.request, { + action: resolution.action, + ...(resolution.action === "submit" && "response" in resolution + ? { response: resolution.response } + : {}), + }), { turnId: input.turnId, itemId: pending.request.itemId }); + } + + handoffRuntimeRequest(input: { + requestId: string; + turnId: string; + reason: "durable_handoff"; + signal: AbortSignal; + }): HarnessRuntimeRequestHandoff { + if (input.signal.aborted) { + return { result: "already_settled", cleanup: Promise.resolve() }; + } + const pending = this.#pendingRuntimeRequests.get(input.requestId); + if ( + !pending + || pending.request.input === undefined + || pending.request.turnId !== input.turnId + || this.#activeTurnId !== input.turnId + || pending.settling + || pending.submittedResponse !== undefined + || pending.submittedAction !== undefined + ) return { result: "already_settled", cleanup: Promise.resolve() }; + if (!this.#pendingRuntimeRequests.delete(input.requestId)) { + return { result: "already_settled", cleanup: Promise.resolve() }; + } + this.#emit( + "runtime_request.expired", + harnessRuntimeInputExpiredOutcome(pending.request, input.reason), + { turnId: input.turnId, itemId: pending.request.itemId }, + ); + const workspace = `directory=${encodeURIComponent(this.#workingDirectory)}`; + const cleanup = Promise.allSettled([ + api( + this.#fetch, + this.#runtime, + `/question/${encodeURIComponent(input.requestId)}/reject?${workspace}`, + { method: "POST" }, + ), + api( + this.#fetch, + this.#runtime, + `/session/${encodeURIComponent(this.#providerSessionId)}/abort`, + { method: "POST" }, + ), + ]).then(() => undefined); + return { result: "handed_off", cleanup }; + } + + async read(): Promise> { + const messages = await api(this.#fetch, this.#runtime, `/session/${encodeURIComponent(this.#providerSessionId)}/message`); + return { sessionId: this.#providerSessionId, messages }; + } + + async reconcile(): Promise> { + const status = await api(this.#fetch, this.#runtime, "/session/status"); + return { sessionId: this.#providerSessionId, status: record(status)[this.#providerSessionId] ?? null }; + } + + async usage(): Promise | null> { return this.#usage === null ? null : structuredClone(this.#usage); } + + async transcript(): Promise { + return { + schema: "paperclip-runner/harness-transcript/v1", + complete: true, + eventCount: this.#transcript.length, + events: structuredClone(this.#transcript), + omissionReason: null, + }; + } + + async snapshot(): Promise { + return { + driverKind: OPENCODE_SERVER_DRIVER_KIND, + driverSessionId: this.#providerSessionId, + providerSessionId: this.#providerSessionId, + runId: this.#runId, + normalizedSessionId: this.#normalizedSessionId, + activeTurnId: this.#activeTurnId, + semanticResult: this.#result && this.#resultFingerprint && this.#resultTurnId + ? { result: structuredClone(this.#result), fingerprint: this.#resultFingerprint, callId: this.#resultCallId, turnId: this.#resultTurnId } + : null, + terminalTurns: [...this.#terminalTurns].map(([turnId, fingerprint]) => ({ turnId, fingerprint })), + pendingRuntimeRequests: this.pendingRuntimeRequests(), + lastSourceSequence: this.#sourceSequence, + }; + } + + async close(): Promise { + if (this.#closed) return; + this.#closed = true; + this.#abort.abort(); + for (const { request } of this.#pendingRuntimeRequests.values()) { + this.#emit( + request.input === undefined ? "runtime_request.cancelled" : "runtime_request.expired", + request.input === undefined + ? harnessRuntimeRequestOutcome(request, { reason: "session_closed" }) + : harnessRuntimeInputExpiredOutcome(request, "provider_process_lost"), + { turnId: request.turnId, itemId: request.itemId }, + ); + } + this.#pendingRuntimeRequests.clear(); + this.#events.close(); + await this.#runtime.close(); + } + + async dispatchTool(call: { tool: string; callId: string; arguments: unknown }): Promise { + const tool = canonicalOpenCodeMcpToolName(call.tool); + const turnId = this.#activeTurnId; + if (turnId === null) throw new Error("OpenCode tool call is not bound to an active turn"); + this.#emit("item.started", { + kind: "dynamicToolCall", + item: { type: "tool_call", id: call.callId, name: tool, arguments: call.arguments }, + }, { turnId, itemId: call.callId }); + if (tool === PRP_COMPLETION_TOOL_NAME || tool === PRP_BLOCK_TOOL_NAME) { + const validation = validatePrpStructuredRunResult(call.arguments); + if (!validation.ok) throw new Error("Invalid semantic result"); + if ( + (tool === PRP_BLOCK_TOOL_NAME && validation.result.reportedWorkDisposition !== "blocked") + || (tool === PRP_COMPLETION_TOOL_NAME && validation.result.reportedWorkDisposition === "blocked") + ) throw new Error("Semantic result disposition does not match the terminal tool"); + if (validation.result.completionClaim.contractRevision !== this.#taskEnvelope.completionContract.revision) { + throw new Error("Semantic result completion contract revision does not match"); + } + const fingerprint = canonicalJson(validation.result); + if (this.#resultFingerprint && this.#resultFingerprint !== fingerprint) throw new Error("A different semantic result was already committed"); + if (!this.#resultFingerprint) { + this.#result = structuredClone(validation.result); + this.#resultFingerprint = fingerprint; + this.#resultCallId = call.callId; + this.#resultTurnId = turnId; + this.#semanticResultTextBoundary = this.#completedTextParts.length; + this.#emit("run.result.proposed", validation.result, { turnId, itemId: call.callId }); + } + this.#emit("item.completed", { + kind: "dynamicToolCall", + item: { type: "tool_result", id: call.callId, tool_use_id: call.callId, result: "Semantic completion accepted." }, + }, { turnId, itemId: call.callId }); + return { accepted: true }; + } + if (!this.#dynamicToolHandler) throw new Error("Unsupported Paperclip operation"); + try { + const result = await this.#dynamicToolHandler({ + tool, + callId: call.callId, + threadId: this.#providerSessionId, + turnId, + arguments: call.arguments, + }); + this.#emit("item.completed", { + kind: "dynamicToolCall", + item: { type: "tool_result", id: call.callId, tool_use_id: call.callId, result }, + }, { turnId, itemId: call.callId }); + return result; + } catch (error) { + this.#emit("item.completed", { + kind: "dynamicToolCall", + item: { type: "tool_result", id: call.callId, tool_use_id: call.callId, error: redact(String(error)), is_error: true }, + }, { turnId, itemId: call.callId }); + throw error; + } + } + + async #recoverPendingRuntimeRequests(): Promise { + await this.#recoverPendingQuestions(); + const value = await api( + this.#fetch, + this.#runtime, + `/permission?directory=${encodeURIComponent(this.#workingDirectory)}`, + ); + const pending = Array.isArray(value) + ? value + : Array.isArray(record(value).permissions) + ? record(value).permissions as unknown[] + : []; + for (const entry of pending) this.#acceptPermission(record(entry), "permission.recovered"); + } + + async #recoverPendingQuestions(): Promise { + const value = await api( + this.#fetch, + this.#runtime, + `/question?directory=${encodeURIComponent(this.#workingDirectory)}`, + ); + const pending = Array.isArray(value) ? value : Array.isArray(record(value).questions) ? record(value).questions as unknown[] : []; + for (const entry of pending) { + const question = record(entry); + const sessionId = text(question.sessionID, text(question.sessionId)); + if (sessionId && sessionId !== this.#providerSessionId) continue; + this.#acceptQuestion(question); + } + } + + #acceptQuestion(properties: Record): void { + const turnId = this.#activeTurnId; + if (!turnId) return; + const requestId = text(properties.id, text(properties.requestID, text(properties.requestId))); + const nativeQuestions = Array.isArray(properties.questions) ? properties.questions.map(record).slice(0, 64) : []; + if (!requestId || nativeQuestions.length === 0) { + this.#emit("harness.diagnostic", { + code: "runtime_input_rejected", + adapter: "opencode-server", + reason: "OpenCode emitted a question event without a request id or supported questions.", + }, { turnId, ...(requestId ? { itemId: requestId } : {}) }); + return; + } + if (this.#pendingRuntimeRequests.has(requestId)) return; + let input: PaperclipQuestionSet; + try { + input = normalizeOpenCodeQuestionSet(nativeQuestions, properties); + } catch { + this.#emit("harness.diagnostic", { + code: "runtime_input_rejected", + adapter: "opencode-server", + reason: "OpenCode emitted a malformed or ambiguous question set.", + }, { turnId, itemId: requestId }); + return; + } + const request: HarnessRuntimeRequest = { + requestId, + requestKind: "user_input", + method: "question.asked", + turnId, + itemId: requestId, + status: "pending", + prompt: "OpenCode requests user input.", + details: {}, + input, + origin: { adapter: "opencode-server", provider: "opencode", method: "question.asked" }, + }; + this.#pendingRuntimeRequests.set(requestId, { request, nativeQuestions }); + this.#emit("runtime_request.created", { + request: { + schema: PAPERCLIP_RUNTIME_REQUEST_SCHEMA_V2, + requestKind: "runtime", + requestId, + type: "input", + status: "pending", + prompt: request.prompt, + input, + origin: request.origin, + turnId, + itemId: request.itemId, + }, + }, { turnId, itemId: request.itemId }); + } + + #acceptPermission(properties: Record, method: string): void { + const turnId = this.#activeTurnId; + if (!turnId) return; + const permission = isRecord(properties.permission) + ? properties.permission + : isRecord(properties.request) + ? properties.request + : properties; + const requestId = text( + permission.id, + text(permission.requestID, text(permission.requestId, text(permission.permissionID))), + ); + if (!requestId || this.#pendingRuntimeRequests.has(requestId)) return; + const title = text(permission.title, text(permission.permission, text(permission.tool, "requested operation"))); + const request: HarnessRuntimeRequest = { + requestId, + requestKind: "permission_approval", + method, + turnId, + itemId: requestId, + status: "pending", + prompt: `OpenCode requests permission for ${title}.`.slice(0, 4000), + details: bounded(permission), + origin: { adapter: "opencode-server", provider: "opencode", method }, + }; + this.#pendingRuntimeRequests.set(requestId, { request, nativeQuestions: [] }); + this.#emit("runtime_request.created", { + request: { + schema: PAPERCLIP_RUNTIME_REQUEST_SCHEMA_V2, + requestKind: "runtime", + requestId, + type: "permission", + status: "pending", + prompt: request.prompt, + actions: ["accept", "accept_for_session", "decline", "cancel"], + details: request.details, + origin: request.origin, + turnId, + itemId: request.itemId, + }, + }, { turnId, itemId: request.itemId }); + } + + async #pumpEvents(): Promise { + let attempts = 0; + while (!this.#closed && !this.#abort.signal.aborted) { + try { + const response = await this.#fetch(`${this.#runtime.baseUrl}/event`, { + headers: { Authorization: this.#runtime.authHeader, Accept: "text/event-stream" }, + signal: this.#abort.signal, + }); + if (!response.ok || !response.body) throw new Error(`OpenCode event stream returned HTTP ${response.status}`); + const outboundFrameId = this.#runtime.trace?.frame({ + direction: "client_to_provider", + raw: "", + transport: "http_sse", + nativeMethod: "GET /event", + }); + if (outboundFrameId) { + this.#runtime.trace?.interpretation({ + frameId: outboundFrameId, + stage: "typescript_opencode_http_transport", + ruleId: "opencode.http.GET_event", + disposition: "operator_only", + reason: "Opened the OpenCode server-sent event stream", + }); + } + for await (const frame of parseSseFrames(response.body)) { + if (this.#closed) return; + const frameId = this.#runtime.trace?.frame({ + direction: "provider_to_client", + raw: frame.raw, + transport: "http_sse", + nativeMethod: "SSE /event", + }) ?? null; + let event: unknown; + try { + event = JSON.parse(frame.data); + if (frameId) { + this.#runtime.trace?.interpretation({ + frameId, + stage: "typescript_opencode_sse_parse", + ruleId: "opencode.sse.json", + disposition: "mapped", + fieldMappings: [{ + inputPath: "$raw.data", + outputPath: "$providerEvent", + action: "normalized", + reason: "Decoded the exact SSE data payload as an OpenCode event", + }], + reason: "OpenCode SSE frame parsed as JSON", + }); + } + } catch (error) { + if (frameId) { + this.#runtime.trace?.interpretation({ + frameId, + stage: "typescript_opencode_sse_parse", + ruleId: "opencode.sse.invalid_json", + disposition: "rejected", + reason: `OpenCode SSE data was not valid JSON: ${String(error).slice(0, 400)}`, + }); + } + throw error; + } + this.#mapProviderEvent(event, frameId); + } + throw new Error("OpenCode event stream closed before the session became terminal"); + } catch (error) { + if (this.#closed || this.#abort.signal.aborted) return; + attempts += 1; + if (attempts > 3) { + this.#emit("harness.diagnostic", { code: "opencode_sse_failed", message: redact(String(error), this.#runtime.sensitiveValues) }); + this.#events.fail(error); + return; + } + await new Promise((resolve) => setTimeout(resolve, 50 * attempts)); + } + } + } + + #mapProviderEvent(value: unknown, traceFrameId: number | null = null): void { + this.#activeTraceFrameId = traceFrameId; + this.#activeTraceEmittedEventIds = []; + let rejected: unknown = null; + try { + this.#mapProviderEventValue(value); + } catch (error) { + rejected = error; + throw error; + } finally { + if (traceFrameId) { + const providerType = text(record(value).type, "unknown"); + this.#runtime.trace?.interpretation({ + frameId: traceFrameId, + stage: "typescript_opencode_driver_normalization", + ruleId: `opencode.normalize.${providerType.replace(/[^A-Za-z0-9_.-]/g, "_")}`, + disposition: rejected + ? "rejected" + : this.#activeTraceEmittedEventIds.length > 0 + ? "mapped" + : "ignored", + emittedEventIds: [...this.#activeTraceEmittedEventIds], + fieldMappings: this.#activeTraceEmittedEventIds.length > 0 + ? [{ + inputPath: "properties", + outputPath: "prpEvent.payload", + action: "normalized", + reason: "Mapped OpenCode event fields into canonical PRP payloads", + }] + : [], + reason: rejected + ? `OpenCode event normalization failed: ${String(rejected).slice(0, 400)}` + : this.#activeTraceEmittedEventIds.length > 0 + ? "OpenCode event emitted one or more canonical PRP events" + : "OpenCode event was observed but produced no canonical PRP event", + }); + } + this.#activeTraceFrameId = null; + this.#activeTraceEmittedEventIds = []; + } + } + + #mapProviderEventValue(value: unknown): void { + const event = record(value); + const properties = record(event.properties); + const type = text(event.type); + const eventId = text(event.id, canonicalJson(value)); + if (this.#seenProviderEvents.has(eventId)) return; + this.#seenProviderEvents.add(eventId); + if (this.#seenProviderEvents.size > 10_000) this.#seenProviderEvents.delete(this.#seenProviderEvents.values().next().value!); + const sessionId = text(properties.sessionID, text(record(properties.info).sessionID)); + if (sessionId && sessionId !== this.#providerSessionId) return; + const turnId = this.#activeTurnId; + if (type === "question.asked" && turnId) { + this.#acceptQuestion(properties); + return; + } + if ((type === "permission.updated" || type === "permission.asked" || type === "permission.v2.asked") && turnId) { + this.#acceptPermission(properties, type); + return; + } + if ((type === "permission.replied" || type === "permission.v2.replied") && turnId) { + const permission = isRecord(properties.permission) + ? properties.permission + : isRecord(properties.request) + ? properties.request + : properties; + const requestId = text( + permission.id, + text(permission.requestID, text(permission.requestId, text(permission.permissionID))), + ); + const pending = this.#pendingRuntimeRequests.get(requestId); + if (!pending || pending.request.requestKind !== "permission_approval") return; + this.#pendingRuntimeRequests.delete(requestId); + const nativeReply = text( + properties.reply, + text(properties.response, text(permission.reply, text(permission.response))), + ); + const action = pending.submittedAction + ?? (nativeReply === "always" || nativeReply === "always_allow" + ? "accept_for_session" + : nativeReply === "once" || nativeReply === "allow" + ? "accept" + : "decline"); + this.#emit("runtime_request.resolved", harnessRuntimeRequestOutcome(pending.request, { action }), { + turnId, + itemId: pending.request.itemId, + }); + return; + } + if ((type === "question.replied" || type === "question.rejected") && turnId) { + const requestId = text(properties.id, text(properties.requestID, text(properties.requestId))); + const pending = this.#pendingRuntimeRequests.get(requestId); + if (!pending) return; + this.#pendingRuntimeRequests.delete(requestId); + this.#emit( + type === "question.replied" ? "runtime_request.resolved" : "runtime_request.cancelled", + harnessRuntimeRequestOutcome( + pending.request, + type === "question.replied" + ? { action: "submit", response: pending.submittedResponse } + : { reason: "provider_rejected" }, + ), + { turnId, itemId: pending.request.itemId }, + ); + return; + } + if (type === "message.part.updated" && turnId) { + const part = record(properties.part); + const messageId = text(part.messageID, text(part.messageId)); + if (!messageId) return; + const role = this.#messageRoles.get(messageId); + if (role === "assistant") this.#emitAssistantPart(part, turnId); + else if (role === undefined) { + const pending = this.#pendingMessageParts.get(messageId) ?? []; + if (pending.length < 100) pending.push(part); + this.#pendingMessageParts.set(messageId, pending); + } + return; + } + if (type === "message.updated" && turnId) { + const info = record(properties.info); + const messageId = text(info.id, text(info.messageID, text(info.messageId))); + const role = text(info.role); + if (messageId && role) { + this.#messageRoles.set(messageId, role); + const pending = this.#pendingMessageParts.get(messageId) ?? []; + this.#pendingMessageParts.delete(messageId); + if (role === "assistant") for (const part of pending) this.#emitAssistantPart(part, turnId); + } + const tokens = record(info.tokens); + if (role === "assistant" && (Object.keys(tokens).length > 0 || typeof info.cost === "number")) { + this.#usage = bounded({ + ...tokens, + costUsd: info.cost ?? null, + model: this.#model, + provider: this.#model.split("/", 1)[0], + driverVersion: this.#runtime.version, + }); + const usageFingerprint = canonicalJson(this.#usage); + const hasMeaningfulUsage = hasPositiveNumber(tokens) + || (typeof info.cost === "number" && info.cost > 0); + if ( + hasMeaningfulUsage + && this.#messageUsageFingerprints.get(messageId) !== usageFingerprint + ) { + this.#messageUsageFingerprints.set(messageId, usageFingerprint); + this.#emit("item.completed", { kind: "usage", usage: this.#usage }, { turnId, itemId: `${turnId}:usage` }); + } + } + return; + } + if ((type === "session.idle" || (type === "session.status" && text(record(properties.status).type) === "idle")) && turnId) { + const fingerprint = canonicalJson({ status: "completed", semanticResult: this.#resultFingerprint }); + this.#terminalTurns.set(turnId, fingerprint); + this.#emitSettledFinalAgentMessage(turnId); + const workspace = this.#workspaceChangesByTurn.get(turnId); + if (workspace !== undefined) this.#emit("workspace.diff.recorded", { ...workspace, complete: true }, { turnId, itemId: `${turnId}:workspace` }); + this.#activeTurnId = null; + this.#emit("turn.completed", { status: "completed" }, { turnId }); + this.#events.close(); + return; + } + if (type === "session.error" && turnId) { + const providerError = record(properties.error); + const providerErrorData = record(providerError.data); + if ( + text(providerError.name) === "MessageAbortedError" + && text(providerErrorData.message, text(providerError.message)) === "Aborted" + ) { + // OpenCode reports its normal /abort control path as session.error. That + // endpoint is also how Paperclip parks a provider turn after a durable + // governed interaction is created, so presenting it as a provider + // failure produces a false red error immediately above a healthy wait + // card. Preserve the provider fact as a cancelled terminal event; the + // native session loop independently commits the authoritative yielded + // result when this abort followed a governed wait. + this.#activeTurnId = null; + this.#emit("turn.cancelled", { + status: "cancelled", + error: bounded(properties.error ?? properties), + }, { turnId }); + this.#terminalTurns.set(turnId, canonicalJson({ status: "cancelled" })); + this.#events.close(); + return; + } + this.#emit("provider.notice.recorded", { + schema: "paperclip.provider.notice.v1", + noticeId: `${turnId}:session-error`, + severity: "error", + category: "session_error", + scope: "turn", + recoverable: false, + userActionable: true, + summary: redact(text(record(properties.error).message, "OpenCode session failed."), this.#runtime.sensitiveValues).slice(0, 4000), + }, { turnId, itemId: `${turnId}:session-error` }); + this.#activeTurnId = null; + this.#emit("turn.failed", { status: "failed", error: bounded(properties.error ?? properties) }, { turnId }); + this.#terminalTurns.set(turnId, canonicalJson({ status: "failed" })); + this.#events.close(); + } + } + + #emitAssistantPart(part: Record, turnId: string): void { + const partId = text(part.id, `${turnId}:part`); + const partType = text(part.type, "unknown"); + const messageId = text(part.messageID, text(part.messageId)) || null; + const canonicalToolName = canonicalOpenCodeMcpToolName( + canonicalOpenCodeDisplayToolName( + text(part.tool, text(part.name)), + text(part.callID, text(part.callId)), + ), + ); + if ( + ["tool", "tool-call", "tool_call"].includes(partType) + && [PRP_COMPLETION_TOOL_NAME, PRP_BLOCK_TOOL_NAME].includes( + canonicalToolName as typeof PRP_COMPLETION_TOOL_NAME, + ) + && messageId + ) { + // OpenCode may report the terminal MCP call before it marks the text + // part from the same assistant message complete. Correlating by native + // message identity selects that response while excluding both earlier + // commentary messages and later acknowledgement-only messages. + this.#semanticResultProviderMessageId = messageId; + } + for (const canonical of canonicalProviderEventsFromOpenCodePart(part)) { + this.#emit(canonical.eventType, canonical.payload, { turnId, itemId: canonical.itemId }); + } + if ( + ["tool", "tool-call", "tool_call"].includes(partType) + && ![PRP_COMPLETION_TOOL_NAME, PRP_BLOCK_TOOL_NAME].includes( + canonicalToolName as typeof PRP_COMPLETION_TOOL_NAME, + ) + ) { + this.#lastNonTerminalToolSourceSeq = this.#sourceSequence; + } + if (partType === "patch") { + const paths = Array.isArray(part.files) ? part.files : []; + const files = paths.slice(0, 2_000).flatMap((value): Record[] => { + const path = text(value).replaceAll("\\", "/"); + if (!path || path.startsWith("/") || path.split("/").includes("..")) return []; + return [{ path, operation: "modify", previousPath: null, additions: null, deletions: null, binary: false, diff: null }]; + }); + if (files.length > 0) { + const previous = this.#workspaceChangesByTurn.get(turnId); + const payload = { + schema: "paperclip.workspace.diff.v1", + changeSetId: `${turnId}:workspace`, + revision: Number(record(previous).revision ?? 0) + 1, + source: "harness_reported", + complete: false, + files, + totals: { files: files.length, additions: null, deletions: null }, + patchArtifactRef: null, + }; + this.#workspaceChangesByTurn.set(turnId, payload); + this.#emit("workspace.change.updated", payload, { turnId, itemId: `${turnId}:workspace` }); + } + } + const content = text(part.text, text(part.output)); + const previous = this.#partText.get(partId) ?? ""; + this.#partText.set(partId, content); + if (partType === "text" && content.length > 0) { + for (const reference of paperclipWorkspaceFileReferencesFromText( + this.#workingDirectory, + content, + turnId, + )) { + if (this.#emittedFileReferences.has(reference.referenceId)) continue; + this.#emittedFileReferences.add(reference.referenceId); + this.#emit("workspace.file.referenced", { ...reference }, { turnId, itemId: reference.referenceId }); + } + } + const delta = content.startsWith(previous) ? content.slice(previous.length) : content; + if (delta) { + this.#emit("item.delta", { kind: partType, text: delta, item: bounded(part) }, { turnId, itemId: partId }); + } + const completedAt = record(part.time).end; + if ( + partType === "text" + && content.trim().length > 0 + && Number.isFinite(completedAt) + && !this.#completedTextPartIds.has(partId) + ) { + this.#completedTextPartIds.add(partId); + this.#completedTextParts.push({ + partId, + messageId, + text: content, + item: bounded({ ...part, type: "agentMessage", phase: "final_answer", text: content }), + observedSourceSeq: this.#sourceSequence, + }); + } + } + + #emitSettledFinalAgentMessage(turnId: string): void { + // OpenCode labels every assistant text part as plain `text`; unlike Codex, + // it does not provide commentary/final-answer channels. A single native + // assistant message can therefore contain an opening progress note, many + // work tools, and the terminal MCP call. Do not promote that opening note + // into the settled response merely because it shares the terminal call's + // message id. Only text observed after the last non-terminal work tool can + // be a final response. When no such text exists, emit no final agentMessage + // and let the accepted semantic summary resolve the durable reply. + const afterLastWorkTool = (part: { observedSourceSeq: number }) => + part.observedSourceSeq > this.#lastNonTerminalToolSourceSeq; + const eligibleText = this.#completedTextParts.filter(afterLastWorkTool); + // OpenCode can place terminal-tool troubleshooting text in the same + // native message as paperclip_finish, then emit the actual answer in a + // later message. It can also append a terse acknowledgement after a real + // answer. With no native commentary/final channel, prefer the most + // substantive eligible text and use structural correlation/order only as + // tie breakers. This does not cap, truncate, or regex-filter model prose. + const selected = eligibleText.reduce<(typeof eligibleText)[number] | null>((best, candidate) => { + if (best === null) return candidate; + const candidateLength = candidate.text.trim().length; + const bestLength = best.text.trim().length; + if (candidateLength !== bestLength) return candidateLength > bestLength ? candidate : best; + const candidateCorrelated = candidate.messageId === this.#semanticResultProviderMessageId; + const bestCorrelated = best.messageId === this.#semanticResultProviderMessageId; + if (candidateCorrelated !== bestCorrelated) return candidateCorrelated ? candidate : best; + const candidateBeforeResult = this.#semanticResultTextBoundary !== null + && this.#completedTextParts.indexOf(candidate) < this.#semanticResultTextBoundary; + const bestBeforeResult = this.#semanticResultTextBoundary !== null + && this.#completedTextParts.indexOf(best) < this.#semanticResultTextBoundary; + if (candidateBeforeResult !== bestBeforeResult) return candidateBeforeResult ? candidate : best; + return candidate.observedSourceSeq >= best.observedSourceSeq ? candidate : best; + }, null); + if (!selected) return; + this.#emit("item.completed", { + kind: "agentMessage", + channel: "final", + providerPhase: "final_answer", + text: selected.text, + item: selected.item, + }, { turnId, itemId: selected.partId }); + } + + #emit(eventType: PrpEvent["eventType"], payload: Record, refs: { turnId?: string; itemId?: string } = {}): void { + const sourceSeq = ++this.#sourceSequence; + const event: PrpEvent = { + schema: "paperclip.prp.event.v1", + sourceEventId: `${this.#runnerInstanceId}:${this.#runId}:${sourceSeq}`, + sourceSeq, + sourceInstanceId: this.#runnerInstanceId, + sourceKind: "runner", + runId: this.#runId, + normalizedSessionId: this.#normalizedSessionId, + ...(refs.turnId ? { turnId: refs.turnId } : {}), + ...(refs.itemId ? { itemId: refs.itemId } : {}), + eventType, + schemaVersion: 1, + priority: eventType === "run.result.proposed" ? 0 : 1, + emittedAt: this.#now().toISOString(), + payload, + }; + if (this.#activeTraceFrameId !== null) { + this.#activeTraceEmittedEventIds.push(event.sourceEventId); + } + this.#transcript.push(structuredClone(event)); + this.#events.push(event); + } +} + +async function startRuntime(input: { + options: OpenCodeServerDriverOptions; + root: string; + cwd: string; + trace: ProviderTraceFileSink | null; + dispatch: (call: { tool: string; callId: string; arguments: unknown }) => Promise; +}): Promise { + const port = await reservePort(); + const password = randomBytes(32).toString("base64url"); + const username = "paperclip"; + const authHeader = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`; + const configHome = join(input.root, "config"); + const dataHome = join(input.root, "data"); + const cacheHome = join(input.root, "cache"); + await Promise.all([ + mkdir(join(configHome, "opencode"), { recursive: true, mode: 0o700 }), + mkdir(dataHome, { recursive: true, mode: 0o700 }), + mkdir(cacheHome, { recursive: true, mode: 0o700 }), + ]); + // HOME contains a read-only skill snapshot, whose destination must be fresh. + // Keep OpenCode's XDG data stable for provider-session recovery while giving + // every launch (including retries and recovery) a new isolated HOME. + const isolatedHome = await mkdtemp(join(input.root, "home-")); + await chmod(isolatedHome, 0o700); + try { + await materializeNativeRuntimeSkills(input.options.runtimeContext ?? null, join(isolatedHome, ".claude", "skills")); + } catch (error) { + await rm(isolatedHome, { recursive: true, force: true }).catch(() => undefined); + throw error; + } + const bridge = await startOpenCodeMcpBridge({ + tools: input.options.dynamicTools, + handler: input.dispatch, + }).catch(async (error) => { + await rm(isolatedHome, { recursive: true, force: true }).catch(() => undefined); + throw error; + }); + const assignedMcp = nativeMcpLaunchBinding(input.options.environment ?? process.env); + input.trace?.addSensitiveValues([ + password, + authHeader, + bridge.secret, + assignedMcp?.token, + input.options.environment?.OPENROUTER_API_KEY, + ]); + const instructionRoot = input.options.runtimeContext?.instructions.bundle.rootPath; + const config = { + $schema: "https://opencode.ai/config.json", + model: input.options.model, + small_model: input.options.model, + share: "disabled", + // The configured entry is already composed exactly once into the session + // system prompt; siblings remain available through the read-only root. + instructions: [], + plugin: [], + tools: { + question: true, + }, + permission: { + "*": input.options.permissionMode ?? "allow", + question: "allow", + "paperclip_*": "allow", + "mcp__paperclip__*": "allow", + external_directory: instructionRoot + ? { "*": "deny", [`${instructionRoot}/**`]: "allow" } + : "deny", + }, + mcp: { + paperclip: { + type: "remote", + url: bridge.url, + enabled: true, + oauth: false, + headers: { Authorization: `Bearer ${bridge.secret}` }, + timeout: 30_000, + }, + ...(assignedMcp ? { + [assignedMcp.name]: { + type: "remote", + url: assignedMcp.url, + enabled: true, + oauth: false, + headers: { Authorization: `Bearer ${assignedMcp.token}` }, + timeout: 30_000, + }, + } : {}), + }, + }; + await writeFile(join(configHome, "opencode", "opencode.json"), `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 }); + const environment = sanitizedEnvironment(input.options.environment ?? process.env, { + HOME: isolatedHome, + XDG_CONFIG_HOME: configHome, + XDG_DATA_HOME: dataHome, + XDG_CACHE_HOME: cacheHome, + OPENCODE_DISABLE_PROJECT_CONFIG: "true", + OPENCODE_SERVER_USERNAME: username, + OPENCODE_SERVER_PASSWORD: password, + }); + const isolateProcessGroup = input.options.isolateProcessGroup ?? true; + const child = spawn(input.options.command ?? "opencode", ["serve", "--hostname", "127.0.0.1", "--port", String(port)], { + cwd: input.cwd, + env: environment, + stdio: ["ignore", "ignore", "pipe"], + detached: globalThis.process.platform !== "win32" && isolateProcessGroup, + }); + let diagnostics = ""; + child.stderr?.on("data", (chunk) => { + const raw = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)); + const redactedDiagnostic = redact(raw.toString("utf8"), [ + password, + input.options.environment?.OPENROUTER_API_KEY, + ]); + diagnostics = `${diagnostics}${redactedDiagnostic}`.slice(-8_192); + const frameId = input.trace?.frame({ + direction: "provider_stderr", + raw, + transport: "process_stderr", + nativeMethod: "opencode serve stderr", + }); + if (frameId) { + input.trace?.interpretation({ + frameId, + stage: "typescript_opencode_process_transport", + ruleId: "opencode.stderr", + disposition: "operator_only", + reason: "OpenCode stderr is retained only in the restricted trace sidecar", + }); + } + input.options.onDiagnostic?.(redactedDiagnostic); + }); + try { + await new Promise((resolve, reject) => { + child.once("spawn", resolve); + child.once("error", reject); + }); + if (child.pid) await input.options.onSpawn?.({ + pid: child.pid, + processGroupId: globalThis.process.platform === "win32" || !isolateProcessGroup ? null : child.pid, + startedAt: new Date().toISOString(), + }); + const baseUrl = `http://127.0.0.1:${port}`; + const health = await waitForHealth( + baseUrl, + authHeader, + input.options.fetch ?? globalThis.fetch, + child, + () => diagnostics, + input.trace, + ); + const version = text(record(health).version); + if (!/^\d+\.\d+\.\d+$/.test(version)) throw new Error("OpenCode health response omitted a semantic version"); + const qualifiedComparison = compareVersion(version, QUALIFIED_OPENCODE_VERSION); + if (qualifiedComparison !== 0) { + throw new Error( + `OpenCode ${version} is not the question-conformance-qualified ${QUALIFIED_OPENCODE_VERSION}`, + ); + } + return { + baseUrl, + authHeader, + version, + permissionMode: input.options.permissionMode ?? "allow", + process: child, + bridge, + trace: input.trace, + sensitiveValues: [password, input.options.environment?.OPENROUTER_API_KEY].filter((value): value is string => Boolean(value)), + close: async (closeInput = {}) => { + await bridge.close().catch(() => {}); + if (child.exitCode === null && child.signalCode === null && child.pid) { + try { + if (globalThis.process.platform === "win32" || !isolateProcessGroup) child.kill("SIGTERM"); + else globalThis.process.kill(-child.pid, "SIGTERM"); + } catch { child.kill("SIGTERM"); } + } + await waitForExit(child, 2_000); + if (child.exitCode === null && child.signalCode === null && child.pid) { + try { + if (globalThis.process.platform === "win32" || !isolateProcessGroup) child.kill("SIGKILL"); + else globalThis.process.kill(-child.pid, "SIGKILL"); + } catch { child.kill("SIGKILL"); } + } + await rm(join(configHome, "opencode", "opencode.json"), { force: true }).catch(() => undefined); + await rm(isolatedHome, { recursive: true, force: true }).catch(() => undefined); + if (closeInput.finalizeTrace !== false) { + await input.trace?.finish({ reason: closeInput.reason ?? null }); + } + }, + }; + } catch (error) { + await bridge.close().catch(() => {}); + child.kill("SIGKILL"); + await rm(join(configHome, "opencode", "opencode.json"), { force: true }).catch(() => undefined); + await rm(isolatedHome, { recursive: true, force: true }).catch(() => undefined); + throw error; + } +} + +export function normalizeOpenCodeQuestionSet( + nativeQuestions: Record[], + metadata: Record = {}, +): PaperclipQuestionSet { + const questions = nativeQuestions.map((question, index): PaperclipQuestion => { + const options = (Array.isArray(question.options) ? question.options : []).map(record).slice(0, 128).map((option, optionIndex) => ({ + id: text(option.id, `option-${optionIndex + 1}`).slice(0, 160), + label: text(option.label, text(option.value, `Option ${optionIndex + 1}`)).slice(0, 1_000), + ...(text(option.description) ? { description: text(option.description).slice(0, 4_000) } : {}), + })); + return { + id: openCodeQuestionId(question, index), + ...(text(question.header) ? { header: text(question.header).slice(0, 1_000) } : {}), + prompt: text(question.question, text(question.prompt, `Question ${index + 1}`)).slice(0, 4_000), + ...(text(question.description) ? { helpText: text(question.description).slice(0, 4_000) } : {}), + required: question.required === true, + answerMode: options.length === 0 ? "text" : question.multiple === true ? "multi_select" : "single_select", + ...(options.length > 0 ? { options } : {}), + ...(question.custom === true || question.allowCustom === true + ? { customAnswer: { enabled: true, label: "Other", placeholder: "Enter another answer" } } + : {}), + }; + }); + return parsePaperclipQuestionSet({ + schema: PAPERCLIP_QUESTION_SET_SCHEMA, + title: text(metadata.title, "OpenCode needs your input").slice(0, 1_000), + ...(text(metadata.description) ? { description: text(metadata.description).slice(0, 4_000) } : {}), + submitLabel: text(metadata.submitLabel, "Submit answers").slice(0, 200), + questions, + }); +} + +function openCodeAnswers( + pending: { request: HarnessRuntimeRequest; nativeQuestions: Record[] }, + response: PaperclipQuestionResponse, +): string[][] { + return pending.nativeQuestions.map((nativeQuestion, index) => { + const questionId = openCodeQuestionId(nativeQuestion, index); + const question = pending.request.input?.questions.find((candidate) => candidate.id === questionId); + const answer = response.answers[questionId]; + if (!question || !answer) return []; + const values = (answer.selectedOptionIds ?? []).map((optionId) => + question.options?.find((option) => option.id === optionId)?.label, + ).filter((value): value is string => typeof value === "string"); + if (answer.text !== undefined) values.push(answer.text); + if (answer.customText !== undefined) values.push(answer.customText); + return values; + }); +} + +function openCodeQuestionId(question: Record, index: number): string { + const nativeId = text(question.id).trim(); + if (nativeId) return nativeId.slice(0, 160); + const header = text(question.header).trim().toLowerCase() + .replace(/[^a-z0-9._:-]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 160); + return header || `question-${index + 1}`; +} + +async function api(fetcher: typeof globalThis.fetch, runtime: OpenCodeRuntime, path: string, init: RequestInit = {}): Promise { + const timeout = AbortSignal.timeout(20_000); + const signal = init.signal ? AbortSignal.any([init.signal, timeout]) : timeout; + const method = String(init.method ?? "GET").toUpperCase(); + const requestRaw = typeof init.body === "string" + ? init.body + : init.body instanceof Uint8Array + ? init.body + : ""; + const requestFrameId = runtime.trace?.frame({ + direction: "client_to_provider", + raw: requestRaw, + transport: "http_json", + nativeMethod: `${method} ${path}`, + }); + if (requestFrameId) { + runtime.trace?.interpretation({ + frameId: requestFrameId, + stage: "typescript_opencode_http_transport", + ruleId: `opencode.http.${method}.${safeTraceRulePath(path)}`, + disposition: "operator_only", + reason: "Sent an exact HTTP request body to the OpenCode app server", + }); + } + let response: Response; + try { + response = await fetcher(`${runtime.baseUrl}${path}`, { + ...init, + signal, + headers: { Authorization: runtime.authHeader, "Content-Type": "application/json", ...init.headers }, + }); + } catch (error) { + throw new Error(`OpenCode API ${path} request failed: ${redact(String(error), runtime.sensitiveValues)}`); + } + const responseRaw = await response.text(); + const responseFrameId = runtime.trace?.frame({ + direction: "provider_to_client", + raw: responseRaw, + transport: "http_json", + nativeMethod: `${method} ${path} ${response.status}`, + }); + if (!response.ok) { + if (responseFrameId) { + runtime.trace?.interpretation({ + frameId: responseFrameId, + stage: "typescript_opencode_http_parse", + ruleId: `opencode.http.error.${response.status}`, + disposition: "rejected", + reason: `OpenCode API returned HTTP ${response.status}`, + }); + } + throw new Error(`OpenCode API ${path} returned HTTP ${response.status}: ${redact(responseRaw, runtime.sensitiveValues)}`); + } + if (response.status === 204 || !responseRaw) { + if (responseFrameId) { + runtime.trace?.interpretation({ + frameId: responseFrameId, + stage: "typescript_opencode_http_parse", + ruleId: "opencode.http.empty_success", + disposition: "operator_only", + reason: "OpenCode API returned a successful empty response", + }); + } + return null; + } + try { + const parsed = JSON.parse(responseRaw) as unknown; + if (responseFrameId) { + runtime.trace?.interpretation({ + frameId: responseFrameId, + stage: "typescript_opencode_http_parse", + ruleId: "opencode.http.json_success", + disposition: "operator_only", + fieldMappings: [{ + inputPath: "$raw", + outputPath: "$response", + action: "normalized", + reason: "Decoded the exact OpenCode HTTP response body as JSON", + }], + reason: "OpenCode HTTP response parsed as JSON", + }); + } + return parsed; + } catch (error) { + if (responseFrameId) { + runtime.trace?.interpretation({ + frameId: responseFrameId, + stage: "typescript_opencode_http_parse", + ruleId: "opencode.http.invalid_json", + disposition: "rejected", + reason: `OpenCode response was not valid JSON: ${String(error).slice(0, 400)}`, + }); + } + throw error; + } +} + +function retryableOpenCodeStartupError(error: unknown): boolean { + const message = String(error); + return message.includes("request failed") + || message.includes("did not become healthy") + || message.includes("exited during startup") + || message.includes("provider_initialize_timeout") + || message.includes("provider_process_exited") + || message.includes("HTTP 408") + || message.includes("HTTP 425") + || message.includes("HTTP 429") + || /HTTP 5\d\d/.test(message); +} + +async function waitForHealth( + baseUrl: string, + authHeader: string, + fetcher: typeof globalThis.fetch, + process: ChildProcess, + diagnostics: () => string, + trace: ProviderTraceFileSink | null, +): Promise { + const deadline = Date.now() + 10_000; + while (Date.now() < deadline) { + if (process.exitCode !== null || process.signalCode !== null) { + const detail = redact(diagnostics()); + throw new Error( + `provider_process_exited: provider=opencode stage=health exitCode=${process.exitCode ?? "null"} signal=${process.signalCode ?? "null"}${detail ? ` stderrTail=${detail}` : ""}`, + ); + } + try { + const requestFrameId = trace?.frame({ + direction: "client_to_provider", + raw: "", + transport: "http_json", + nativeMethod: "GET /global/health", + }); + if (requestFrameId) { + trace?.interpretation({ + frameId: requestFrameId, + stage: "typescript_opencode_http_transport", + ruleId: "opencode.http.GET_global_health", + disposition: "operator_only", + reason: "Probed the local OpenCode app-server health endpoint", + }); + } + const response = await fetcher(`${baseUrl}/global/health`, { + headers: { Authorization: authHeader }, + signal: AbortSignal.timeout(1_000), + }); + const raw = await response.text(); + const responseFrameId = trace?.frame({ + direction: "provider_to_client", + raw, + transport: "http_json", + nativeMethod: `GET /global/health ${response.status}`, + }); + if (response.ok) { + const parsed = JSON.parse(raw) as unknown; + if (responseFrameId) { + trace?.interpretation({ + frameId: responseFrameId, + stage: "typescript_opencode_http_parse", + ruleId: "opencode.http.health_success", + disposition: "operator_only", + reason: "OpenCode health response parsed successfully", + }); + } + return parsed; + } + } catch { /* server is still starting */ } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + const detail = redact(diagnostics()); + throw new Error( + `provider_initialize_timeout: provider=opencode stage=health${detail ? ` stderrTail=${detail}` : ""}`, + ); +} + +async function reservePort(): Promise { + const server = createServer(); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => resolve()); + }); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Unable to reserve OpenCode port"); + const port = address.port; + await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); + return port; +} + +async function* parseSseFrames( + stream: ReadableStream, +): AsyncIterable<{ raw: string; data: string }> { + const decoder = new TextDecoder(); + let buffer = ""; + for await (const chunk of stream as unknown as AsyncIterable) { + buffer += decoder.decode(chunk, { stream: true }); + if (buffer.length > 1_048_576) throw new Error("OpenCode SSE event exceeded the retained payload limit"); + let boundary: RegExpExecArray | null; + while ((boundary = /\r?\n\r?\n/.exec(buffer)) !== null) { + const rawFrame = buffer.slice(0, boundary.index); + const raw = buffer.slice(0, boundary.index + boundary[0].length); + const frame = rawFrame.replaceAll("\r", ""); + buffer = buffer.slice(boundary.index + boundary[0].length); + const data = frame.split("\n").filter((line) => line.startsWith("data:")).map((line) => line.slice(5).trimStart()).join("\n"); + if (!data || data === "[DONE]") continue; + yield { raw, data }; + } + } +} + +async function* parseSse(stream: ReadableStream): AsyncIterable { + for await (const frame of parseSseFrames(stream)) yield JSON.parse(frame.data); +} + +function sanitizedEnvironment(source: NodeJS.ProcessEnv, overrides: Record): NodeJS.ProcessEnv { + const allowed = ["PATH", "LANG", "LC_ALL", "LC_CTYPE", "TZ", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "ALL_PROXY", "SSL_CERT_FILE", "SSL_CERT_DIR", "OPENROUTER_API_KEY"]; + const result: NodeJS.ProcessEnv = {}; + for (const key of allowed) if (source[key] !== undefined) result[key] = source[key]; + return { ...result, ...overrides }; +} + +function sanitizedEnvironmentKeys(): string[] { + return ["PATH", "LANG", "LC_ALL", "LC_CTYPE", "TZ", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "ALL_PROXY", "SSL_CERT_FILE", "SSL_CERT_DIR", "OPENROUTER_API_KEY"]; +} + +function sessionRoot(runtimeDirectory: string, normalizedSessionId: string): string { + const safe = normalizedSessionId.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 120); + if (!safe || safe === "." || safe === "..") throw new Error("Invalid normalized OpenCode session id"); + return join(resolve(runtimeDirectory), safe); +} + +function validateWorkspace(value: string): string { + const cwd = resolve(value); + if (!value.trim() || cwd === dirname(cwd)) throw new Error("OpenCode working directory must not be a filesystem root"); + return cwd; +} + +function validModel(value: string): boolean { + const slash = value.indexOf("/"); + return slash > 0 && slash < value.length - 1; +} + +function compareVersion(left: string, right: string): number { + const a = left.split(".").map(Number); + const b = right.split(".").map(Number); + for (let index = 0; index < 3; index += 1) { + if (a[index] !== b[index]) return (a[index] ?? 0) - (b[index] ?? 0); + } + return 0; +} + +function bounded(value: unknown): Record { + const serialized = JSON.stringify(value); + if (!serialized || serialized.length > 64 * 1024) return { omitted: true, reason: "payload_limit" }; + const parsed = JSON.parse(serialized) as unknown; + return isRecord(parsed) ? parsed : { value: parsed }; +} + +function record(value: unknown): Record { return isRecord(value) ? value : {}; } +function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } +function text(value: unknown, fallback = ""): string { return typeof value === "string" ? value : fallback; } +function hasPositiveNumber(value: unknown): boolean { + if (typeof value === "number") return Number.isFinite(value) && value > 0; + if (Array.isArray(value)) return value.some(hasPositiveNumber); + if (isRecord(value)) return Object.values(value).some(hasPositiveNumber); + return false; +} +function safeTraceRulePath(value: string): string { + return value + .replace(/\/[A-Za-z0-9_-]{12,}/g, "/:id") + .replace(/[^A-Za-z0-9_.:/-]/g, "_") + .replaceAll("/", "_") + .slice(0, 120); +} +function redact(value: string, sensitiveValues: readonly (string | undefined)[] = []): string { + let redacted = value; + for (const sensitive of sensitiveValues) { + if (sensitive && sensitive.length >= 4) redacted = redacted.split(sensitive).join("[REDACTED]"); + } + return redacted.replace(/(OPENROUTER_API_KEY|authorization|password|token|secret)\s*[:=]\s*[^\s,}\]]+/gi, "$1=[REDACTED]").slice(0, 8_192); +} +function canonicalJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; + if (isRecord(value)) return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(",")}}`; + return JSON.stringify(value) ?? "undefined"; +} + +async function waitForExit(process: ChildProcess, timeoutMs: number): Promise { + if (process.exitCode !== null || process.signalCode !== null) return; + await new Promise((resolve) => { + const timer = setTimeout(resolve, timeoutMs); + timer.unref?.(); + process.once("exit", () => { clearTimeout(timer); resolve(); }); + }); +} + +export const openCodeServerDriverInternals = { parseSse }; + +class AsyncQueue implements AsyncIterable { + #items: T[] = []; + #waiters: Array<{ resolve: (result: IteratorResult) => void; reject: (error: unknown) => void }> = []; + #closed = false; + #error: unknown = null; + push(item: T): void { + if (this.#closed) return; + const waiter = this.#waiters.shift(); + if (waiter) waiter.resolve({ done: false, value: item }); + else this.#items.push(item); + } + close(): void { + if (this.#closed) return; + this.#closed = true; + for (const waiter of this.#waiters.splice(0)) waiter.resolve({ done: true, value: undefined }); + } + fail(error: unknown): void { + if (this.#closed) return; + this.#closed = true; + this.#error = error; + for (const waiter of this.#waiters.splice(0)) waiter.reject(error); + } + [Symbol.asyncIterator](): AsyncIterator { + return { + next: () => { + const item = this.#items.shift(); + if (item !== undefined) return Promise.resolve({ done: false, value: item }); + if (this.#error !== null) return Promise.reject(this.#error); + if (this.#closed) return Promise.resolve({ done: true, value: undefined }); + return new Promise((resolve, reject) => this.#waiters.push({ resolve, reject })); + }, + }; + } +} diff --git a/packages/paperclip-runner/src/index.ts b/packages/paperclip-runner/src/index.ts index c7c9d706e33..61de133458e 100644 --- a/packages/paperclip-runner/src/index.ts +++ b/packages/paperclip-runner/src/index.ts @@ -11,10 +11,15 @@ export * from "./contracts/question-set.js"; export * from "./contracts/runtime-context.js"; export * from "./contracts/types.js"; export * from "./backends/harness-driver-backend.js"; +export { createOpenCodeNativeSessionBackend } from "./backends/opencode-native-backend.js"; export { createNativeSessionBackend, type NativeBackendFactoryOptions, } from "./backends/native-backend-factory.js"; +export { + OpenCodeServerDriver, + type OpenCodeServerDriverOptions, +} from "./drivers/opencode/opencode-server-driver.js"; export * from "./native-session-runtime.js"; export { DurablePrpControlPlane, diff --git a/packages/paperclip-runner/test/acpx-codex-package-contract.test.mjs b/packages/paperclip-runner/test/acpx-codex-package-contract.test.mjs index 114185371f4..9dbcf8f5b02 100644 --- a/packages/paperclip-runner/test/acpx-codex-package-contract.test.mjs +++ b/packages/paperclip-runner/test/acpx-codex-package-contract.test.mjs @@ -37,9 +37,11 @@ test("the runner pins only the Codex ACPX production dependencies", () => { ); }); -test("the package exposes only the reviewed Codex ACPX sidecar binary", () => { +test("the package exposes only the reviewed provider transport binaries", () => { assert.deepEqual(runnerPackage.bin, { "paperclip-runner-acpx-sidecar": "./dist/cli/acpx-runtime-sidecar.js", + "paperclip-runner-opencode-proxy": + "./dist/cli/opencode-app-server-proxy.js", }); }); diff --git a/packages/paperclip-runner/test/fixtures/fake-opencode-server.mjs b/packages/paperclip-runner/test/fixtures/fake-opencode-server.mjs new file mode 100644 index 00000000000..e54c6628177 --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/fake-opencode-server.mjs @@ -0,0 +1,307 @@ +#!/usr/bin/env node +import { createServer } from "node:http"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +const args = process.argv.slice(2); +if (process.env.OPENROUTER_API_KEY) process.stderr.write(`fixture credential=${process.env.OPENROUTER_API_KEY}\n`); +const port = Number(args[args.indexOf("--port") + 1]); +const username = process.env.OPENCODE_SERVER_USERNAME ?? "opencode"; +const password = process.env.OPENCODE_SERVER_PASSWORD ?? ""; +const expectedAuth = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`; +const session = { id: "ses_fake_1", title: "Paperclip fake" }; +const clients = new Set(); +let eventConnections = 0; +const runtimeConfig = JSON.parse(await readFile(join(process.env.XDG_CONFIG_HOME, "opencode", "opencode.json"), "utf8")); +const mcp = runtimeConfig.mcp?.paperclip; +let mcpRequestId = 1; +const mcpEvidence = { tools: [], calls: [] }; + +function nativeQuestion() { + return { + id: "question-native-1", + sessionID: session.id, + questions: [ + { + id: "environment", + header: "Environment", + question: "Where should we deploy?", + options: [ + { label: "Staging", description: "Deploy to staging first." }, + { label: "Production", description: "Deploy directly to production." } + ], + custom: true + }, + { + id: "regions", + header: "Regions", + question: "Which regions?", + options: [{ label: "US" }, { label: "EU" }], + multiple: true + } + ] + }; +} + +function nativePermission(style) { + return style === "legacy" + ? { + id: "permission-native-1", + type: "tool", + pattern: "echo OK", + sessionID: session.id, + messageID: "message-permission", + callID: "call-permission", + title: "Run validation command", + metadata: {}, + time: { created: Date.now() }, + } + : { + id: "permission-native-1", + sessionID: session.id, + permission: "bash", + patterns: ["echo OK"], + always: ["echo *"], + metadata: {}, + tool: { messageID: "message-permission", callID: "call-permission" }, + }; +} + +let pendingQuestion = await readFile(join(process.env.XDG_DATA_HOME, "fake-pending-question.json"), "utf8") + .then((value) => JSON.parse(value)) + .catch(() => null); +let pendingPermission = await readFile(join(process.env.XDG_DATA_HOME, "fake-pending-permission.json"), "utf8") + .then((value) => JSON.parse(value)) + .catch(() => null); +let permissionStyle = "v2"; + +await mkdir(process.env.XDG_DATA_HOME, { recursive: true }); +await writeFile(join(process.env.XDG_DATA_HOME, "fake-environment.json"), JSON.stringify({ + keys: Object.keys(process.env).sort(), + home: process.env.HOME, + configHome: process.env.XDG_CONFIG_HOME, + projectConfigDisabled: process.env.OPENCODE_DISABLE_PROJECT_CONFIG, +})); + +function json(response, status, value) { + response.statusCode = status; + response.setHeader("Content-Type", "application/json"); + response.end(JSON.stringify(value)); +} + +function emit(value) { + const frame = `data: ${JSON.stringify(value)}\n\n`; + for (const response of clients) response.write(frame); +} + +async function mcpRequest(method, params) { + const response = await fetch(mcp.url, { + method: "POST", + headers: { ...mcp.headers, "Content-Type": "application/json", Accept: "application/json, text/event-stream" }, + body: JSON.stringify({ jsonrpc: "2.0", id: mcpRequestId++, method, ...(params ? { params } : {}) }), + }); + return response.json(); +} + +async function callFirstPaperclipTool() { + if (!mcp?.url) return; + await mcpRequest("initialize", { protocolVersion: "2025-03-26", capabilities: {}, clientInfo: { name: "fake-opencode", version: "1" } }); + const listed = await mcpRequest("tools/list"); + mcpEvidence.tools = (listed.result?.tools ?? []).map((entry) => entry.name); + const tool = listed.result?.tools?.find((entry) => + !["paperclip_finish", "paperclip_block"].includes(entry.name) + && (entry.inputSchema?.required?.length ?? 0) === 0 + ); + if (tool) mcpEvidence.calls.push(await mcpRequest("tools/call", { name: `paperclip_${tool.name}`, arguments: {} })); + await writeFile(join(process.env.XDG_DATA_HOME, "fake-mcp-evidence.json"), JSON.stringify(mcpEvidence)); +} + +async function callTerminalTool(promptBody) { + const prompt = JSON.parse(promptBody.parts?.[0]?.text ?? "{}"); + const blocked = String(prompt.message ?? "").includes("block-result"); + const result = { + schema: "paperclip.run_result.v1", + reportedWorkDisposition: blocked ? "blocked" : "done", + summary: blocked ? "Fake provider is blocked." : "Fake provider completed the task.", + completionClaim: { + contractRevision: prompt.task?.completionContract?.revision ?? "codex-demo-v1", + objectiveSatisfied: !blocked, + criteria: (prompt.task?.completionContract?.criteria ?? []).map((criterion) => ({ + criterionId: criterion.id, + status: blocked ? "unknown" : "satisfied", + evidenceRefs: [], + })), + remainingWork: blocked ? [{ description: "Waiting on a test dependency.", blocksCompletion: true }] : [], + }, + evidence: [], + verification: [], + attentionRequests: blocked ? [{ kind: "blocker", summary: "Waiting on a test dependency." }] : [], + artifacts: [], + ...(blocked ? { blocker: { + reasonCode: "test_dependency", + owner: { kind: "external", name: "fixture" }, + unblockAction: "Release the fixture dependency.", + scope: "current_track", + } } : {}), + }; + return mcpRequest("tools/call", { + name: blocked ? "paperclip_block" : "paperclip_finish", + arguments: result, + }); +} + +const server = createServer(async (request, response) => { + if (request.headers.authorization !== expectedAuth) return json(response, 401, { error: "unauthorized" }); + if (request.url === "/global/health") return json(response, 200, { healthy: true, version: "1.18.17" }); + if (request.url === "/event") { + eventConnections += 1; + response.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache" }); + response.write(`data: ${JSON.stringify({ type: "server.connected", properties: {} })}\n\n`); + if (eventConnections === 1) { + response.end(); + return; + } + clients.add(response); + request.on("close", () => clients.delete(response)); + return; + } + if (request.method === "POST" && request.url === "/session") return json(response, 200, session); + if (request.method === "GET" && request.url === `/session/${session.id}`) return json(response, 200, session); + if (request.method === "GET" && request.url === `/session/${session.id}/message`) return json(response, 200, []); + if (request.method === "GET" && request.url === "/session/status") return json(response, 200, { [session.id]: { type: "idle" } }); + if (request.method === "GET" && request.url?.startsWith("/question?")) return json(response, 200, pendingQuestion ? [pendingQuestion] : []); + if (request.method === "GET" && request.url?.startsWith("/permission?")) return json(response, 200, pendingPermission ? [pendingPermission] : []); + if (request.method === "POST" && request.url?.startsWith("/question/question-native-1/reply?")) { + const chunks = []; + request.on("data", (chunk) => chunks.push(chunk)); + request.on("end", async () => { + const body = JSON.parse(Buffer.concat(chunks).toString("utf8")); + await writeFile(join(process.env.XDG_DATA_HOME, "fake-question-reply.json"), JSON.stringify({ url: request.url, body })); + const repliedQuestion = pendingQuestion; + pendingQuestion = null; + emit({ type: "question.replied", id: "event-question-replied", properties: repliedQuestion }); + setTimeout(() => { + json(response, 200, true); + emit({ type: "session.idle", id: "event-question-idle", properties: { sessionID: session.id } }); + }, 10); + }); + return; + } + if (request.method === "POST" && request.url?.startsWith("/question/question-native-1/reject?")) { + const rejectedQuestion = pendingQuestion; + pendingQuestion = null; + emit({ type: "question.rejected", id: "event-question-rejected", properties: rejectedQuestion }); + setTimeout(() => { + json(response, 200, true); + emit({ type: "session.idle", id: "event-question-rejected-idle", properties: { sessionID: session.id } }); + }, 10); + return; + } + if (request.method === "POST" && request.url?.startsWith("/permission/permission-native-1/reply?")) { + const chunks = []; + request.on("data", (chunk) => chunks.push(chunk)); + request.on("end", async () => { + const body = JSON.parse(Buffer.concat(chunks).toString("utf8")); + await writeFile(join(process.env.XDG_DATA_HOME, "fake-permission-reply.json"), JSON.stringify({ url: request.url, body })); + pendingPermission = null; + emit({ + type: permissionStyle === "legacy" ? "permission.replied" : "permission.v2.replied", + id: "event-permission-replied", + properties: permissionStyle === "legacy" + ? { sessionID: session.id, permissionID: "permission-native-1", response: body.reply } + : { sessionID: session.id, requestID: "permission-native-1", reply: body.reply }, + }); + setTimeout(() => { + json(response, 200, true); + emit({ type: "session.idle", id: "event-permission-idle", properties: { sessionID: session.id } }); + }, 10); + }); + return; + } + if (request.method === "POST" && request.url === `/session/${session.id}/abort`) return json(response, 200, true); + if (request.method === "POST" && request.url === `/session/${session.id}/prompt_async`) { + const chunks = []; + request.on("data", (chunk) => chunks.push(chunk)); + request.on("end", () => { + const promptPayload = JSON.parse(Buffer.concat(chunks).toString("utf8")); + if (promptPayload.providerID !== "openrouter" || promptPayload.modelID !== "deepseek/deepseek-v4-flash-0731" || "model" in promptPayload) { + return json(response, 400, { error: "OpenCode 1.18 prompt model fields must be top-level" }); + } + json(response, 204, null); + setTimeout(async () => { + await callFirstPaperclipTool(); + const parsedPrompt = JSON.parse(promptPayload.parts?.[0]?.text ?? "{}"); + if (String(parsedPrompt.message ?? "").includes("native-question")) { + pendingQuestion = nativeQuestion(); + emit({ type: "question.asked", id: "event-question-native-1", properties: pendingQuestion }); + emit({ type: "question.asked", id: "event-question-native-1", properties: pendingQuestion }); + return; + } + if (String(parsedPrompt.message ?? "").includes("native-permission")) { + permissionStyle = String(parsedPrompt.message ?? "").includes("legacy") ? "legacy" : "v2"; + pendingPermission = nativePermission(permissionStyle); + emit({ + type: permissionStyle === "legacy" ? "permission.updated" : "permission.v2.asked", + id: `event-permission-${permissionStyle}`, + properties: pendingPermission, + }); + return; + } + if (String(parsedPrompt.message ?? "").includes("session-aborted")) { + emit({ + type: "session.error", + id: "event-session-aborted", + properties: { + sessionID: session.id, + error: { name: "MessageAbortedError", data: { message: "Aborted" } }, + }, + }); + return; + } + const textBeforeFinish = String(parsedPrompt.message ?? "").includes("text-before-finish"); + const correlatedFinal = String(parsedPrompt.message ?? "").includes("correlated-final-message"); + const finalAfterToolCommentary = String(parsedPrompt.message ?? "").includes("final-after-tool-commentary"); + const commentaryOnlyBeforeWork = String(parsedPrompt.message ?? "").includes("commentary-only-before-work"); + emit({ type: "message.updated", id: "event-user", properties: { sessionID: session.id, info: { id: "message-user", sessionID: session.id, role: "user" } } }); + emit({ type: "message.part.updated", id: "event-user-part", properties: { sessionID: session.id, part: { id: "part-user", messageID: "message-user", type: "text", text: "submitted prompt must not become assistant text" } } }); + emit({ type: "message.updated", id: "event-assistant", properties: { sessionID: session.id, info: { id: "message-assistant", sessionID: session.id, role: "assistant" } } }); + emit({ type: "message.part.updated", id: "event-patch", properties: { sessionID: session.id, part: { id: "part-patch", messageID: "message-assistant", type: "patch", files: ["src/index.ts"] } } }); + if (commentaryOnlyBeforeWork) { + emit({ type: "message.part.updated", id: "event-opening-commentary", properties: { sessionID: session.id, part: { id: "part-opening-commentary", messageID: "message-assistant", type: "text", text: "I will inspect the workspace first.", time: { start: 1, end: 2 } } } }); + emit({ type: "message.part.updated", id: "event-work-tool", properties: { sessionID: session.id, part: { id: "part-work-tool", messageID: "message-assistant", type: "tool", tool: "bash", state: { status: "completed", input: { command: "pwd" }, output: "/workspace" } } } }); + await callTerminalTool(promptPayload); + } else if (finalAfterToolCommentary) { + emit({ type: "message.part.updated", id: "event-tool-commentary", properties: { sessionID: session.id, part: { id: "part-tool-commentary", messageID: "message-assistant", type: "text", text: "The schema might need another value; I will try again.", time: { start: 1, end: 2 } } } }); + await callTerminalTool(promptPayload); + emit({ type: "message.part.updated", id: "event-tool-part", properties: { sessionID: session.id, part: { id: "part-tool", messageID: "message-assistant", type: "tool", tool: "paperclip_paperclip_finish", state: { status: "completed", output: "accepted" } } } }); + emit({ type: "message.updated", id: "event-post-tool-final-message", properties: { sessionID: session.id, info: { id: "message-post-tool-final", sessionID: session.id, role: "assistant" } } }); + emit({ type: "message.part.updated", id: "event-post-tool-final", properties: { sessionID: session.id, part: { id: "part-post-tool-final", messageID: "message-post-tool-final", type: "text", text: "This is the complete substantive answer emitted after the accepted completion tool call.", time: { start: 3, end: 4 } } } }); + } else if (correlatedFinal) { + emit({ type: "message.part.updated", id: "event-commentary", properties: { sessionID: session.id, part: { id: "part-commentary", messageID: "message-assistant", type: "text", text: "I will write the result now.", time: { start: 1, end: 2 } } } }); + await callTerminalTool(promptPayload); + emit({ type: "message.updated", id: "event-final-message", properties: { sessionID: session.id, info: { id: "message-final", sessionID: session.id, role: "assistant" } } }); + emit({ type: "message.part.updated", id: "event-finish-part", properties: { sessionID: session.id, part: { id: "part-finish", messageID: "message-final", type: "tool", tool: "paperclip_paperclip_finish", state: { status: "completed", output: "accepted" } } } }); + emit({ type: "message.part.updated", id: "event-correlated-answer", properties: { sessionID: session.id, part: { id: "part-correlated-answer", messageID: "message-final", type: "text", text: "Correlated substantive final answer.", time: { start: 3, end: 4 } } } }); + emit({ type: "message.part.updated", id: "event-duplicate-finish", properties: { sessionID: session.id, part: { id: "part-duplicate-finish", messageID: "message-final", type: "tool", tool: "unknown", callID: "functions.paperclip_paperclip_finish:24", state: { status: "error", error: "Tool execution aborted", metadata: { interrupted: true } } } } }); + emit({ type: "message.updated", id: "event-trailing-message", properties: { sessionID: session.id, info: { id: "message-trailing", sessionID: session.id, role: "assistant" } } }); + emit({ type: "message.part.updated", id: "event-trailing-ack", properties: { sessionID: session.id, part: { id: "part-trailing-ack", messageID: "message-trailing", type: "text", text: "Done.", time: { start: 5, end: 6 } } } }); + } else if (textBeforeFinish) { + emit({ type: "message.part.updated", id: "event-answer", properties: { sessionID: session.id, part: { id: "part-answer", messageID: "message-assistant", type: "text", text: "Substantive answer before finish.", time: { start: 1, end: 2 } } } }); + await callTerminalTool(promptPayload); + emit({ type: "message.part.updated", id: "event-ack", properties: { sessionID: session.id, part: { id: "part-ack", messageID: "message-assistant", type: "text", text: "Done.", time: { start: 3, end: 4 } } } }); + } else { + await callTerminalTool(promptPayload); + emit({ type: "message.part.updated", id: "event-1", properties: { sessionID: session.id, part: { id: "part-1", messageID: "message-assistant", type: "text", text: "done [guide](guide.md)", time: { start: 1, end: 2 } } } }); + emit({ type: "message.part.updated", id: "event-1", properties: { sessionID: session.id, part: { id: "part-1", messageID: "message-assistant", type: "text", text: "done" } } }); + } + emit({ type: "message.updated", id: "event-2", properties: { info: { id: "message-assistant", sessionID: session.id, role: "assistant", tokens: { input: 3, output: 2 }, cost: 0.001 } } }); + emit({ type: "session.idle", id: "event-3", properties: { sessionID: session.id } }); + }, 100); + }); + return; + } + json(response, 404, { error: "not found" }); +}); + +server.listen(port, "127.0.0.1"); +for (const signal of ["SIGTERM", "SIGINT"]) process.on(signal, () => server.close(() => process.exit(0))); diff --git a/server/src/__tests__/heartbeat-stale-queue-invalidation.test.ts b/server/src/__tests__/heartbeat-stale-queue-invalidation.test.ts index 177678074f0..7896cd4fd38 100644 --- a/server/src/__tests__/heartbeat-stale-queue-invalidation.test.ts +++ b/server/src/__tests__/heartbeat-stale-queue-invalidation.test.ts @@ -488,12 +488,17 @@ describeEmbeddedPostgres("heartbeat stale queued-run invalidation", () => { await heartbeat.resumeQueuedRuns(); await waitForCondition(async () => { - const run = await db - .select({ status: heartbeatRuns.status }) - .from(heartbeatRuns) - .where(eq(heartbeatRuns.id, runId)) - .then((rows) => rows[0] ?? null); - return run?.status === "cancelled"; + const [run, wakeup] = await Promise.all([ + db.select({ status: heartbeatRuns.status }) + .from(heartbeatRuns) + .where(eq(heartbeatRuns.id, runId)) + .then((rows) => rows[0] ?? null), + db.select({ status: agentWakeupRequests.status }) + .from(agentWakeupRequests) + .where(eq(agentWakeupRequests.id, wakeupRequestId)) + .then((rows) => rows[0] ?? null), + ]); + return run?.status === "cancelled" && wakeup?.status === "skipped"; }); const [run, wakeup, issue] = await Promise.all([ @@ -580,12 +585,17 @@ describeEmbeddedPostgres("heartbeat stale queued-run invalidation", () => { await heartbeat.resumeQueuedRuns(); await waitForCondition(async () => { - const run = await db - .select({ status: heartbeatRuns.status }) - .from(heartbeatRuns) - .where(eq(heartbeatRuns.id, runId)) - .then((rows) => rows[0] ?? null); - return run?.status === "cancelled"; + const [run, wakeup] = await Promise.all([ + db.select({ status: heartbeatRuns.status }) + .from(heartbeatRuns) + .where(eq(heartbeatRuns.id, runId)) + .then((rows) => rows[0] ?? null), + db.select({ status: agentWakeupRequests.status }) + .from(agentWakeupRequests) + .where(eq(agentWakeupRequests.id, wakeupRequestId)) + .then((rows) => rows[0] ?? null), + ]); + return run?.status === "cancelled" && wakeup?.status === "skipped"; }); const [run, wakeup, issue] = await Promise.all([