diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c52578b45..a330ff866 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -232,6 +232,26 @@ jobs: - run: pnpm install --frozen-lockfile + - name: Require signed OpenCode compatibility registry + shell: bash + env: + NEXT_PUBLIC_COVEN_OPENCODE_SCHEMA_REGISTRY_URL: ${{ secrets.OPENCODE_SCHEMA_REGISTRY_URL }} + NEXT_PUBLIC_COVEN_OPENCODE_SCHEMA_REGISTRY_PUBLIC_KEY: ${{ secrets.OPENCODE_SCHEMA_REGISTRY_PUBLIC_KEY }} + NEXT_PUBLIC_COVEN_OPENCODE_SCHEMA_REGISTRY_PUBLIC_KEYS: ${{ secrets.OPENCODE_SCHEMA_REGISTRY_PUBLIC_KEYS }} + NEXT_PUBLIC_COVEN_OPENCODE_SCHEMA_REGISTRY_CHECKPOINT: ${{ secrets.OPENCODE_SCHEMA_REGISTRY_CHECKPOINT }} + run: | + node scripts/check-opencode-registry-release.mjs + { + echo "NEXT_PUBLIC_COVEN_OPENCODE_SCHEMA_REGISTRY_URL=$NEXT_PUBLIC_COVEN_OPENCODE_SCHEMA_REGISTRY_URL" + echo "NEXT_PUBLIC_COVEN_OPENCODE_SCHEMA_REGISTRY_PUBLIC_KEY<> "$GITHUB_ENV" + # Decode the App Store Connect API key onto the runner for the custom # macOS release script. The path is exported into the env for later # steps. Runs only on the macOS leg. diff --git a/docs/opencode-compatibility-registry.md b/docs/opencode-compatibility-registry.md new file mode 100644 index 000000000..cd96aa441 --- /dev/null +++ b/docs/opencode-compatibility-registry.md @@ -0,0 +1,37 @@ +# OpenCode compatibility registry + +Coven Cave can update OpenCode JSON event schemas independently of an app release only when its packaged build includes a trusted registry configuration. The registry publishes signed, versioned JSON bundles; Cave embeds the corresponding Ed25519 **public** key and accepts a bundle only after signature, expiry, schema, and monotonic-sequence validation. + +## Release configuration + +Every desktop release must provide these GitHub Actions secrets: + +- `OPENCODE_SCHEMA_REGISTRY_URL` — canonical HTTPS URL for the signed OpenCode bundle. +- `OPENCODE_SCHEMA_REGISTRY_PUBLIC_KEY` — PEM-encoded Ed25519 public key that verifies that bundle. + +- `OPENCODE_SCHEMA_REGISTRY_CHECKPOINT` — compact JSON with the current bundle's `sequence` and lowercase SHA-256 `payloadHash` of its canonical unsigned payload. + +For a rotation release, replace the single-key secret with `OPENCODE_SCHEMA_REGISTRY_PUBLIC_KEYS`: a JSON object of one to four `{ "key-id": "PEM" }` entries containing the active key and the retiring key. The release workflow maps this to `NEXT_PUBLIC_COVEN_OPENCODE_SCHEMA_REGISTRY_PUBLIC_KEYS`; it is an alternative to the single-key setting, not an additional trust source. + +The release workflow maps these values to `NEXT_PUBLIC_COVEN_OPENCODE_SCHEMA_REGISTRY_URL`, `NEXT_PUBLIC_COVEN_OPENCODE_SCHEMA_REGISTRY_PUBLIC_KEY`, and `NEXT_PUBLIC_COVEN_OPENCODE_SCHEMA_REGISTRY_CHECKPOINT`, then runs `scripts/check-opencode-registry-release.mjs` before packaging. They are public verification material, intentionally compiled into the desktop application. A release fails closed if a value is missing, the URL is non-HTTPS or contains credentials, the key is not Ed25519, or the checkpoint is malformed. + +Development and test processes may inject `COVEN_OPENCODE_SCHEMA_REGISTRY_URL` and `COVEN_OPENCODE_SCHEMA_REGISTRY_PUBLIC_KEY` instead. Without a configured registry, the source-trusted built-in profile is the offline/development baseline; it does not provide independently deployed schema recovery and must not be used to ship a desktop release. + +## Publishing and rotation + +The registry publisher owns the private signing key; it must never be placed in Cave, the release workflow, logs, or issue text. Publish an immutable bundle for each increasing `sequence`, with canonical RFC 3339 UTC timestamps and the detached Ed25519 signature over the bundle payload. Cave rejects rewrites at an existing sequence and lower sequences even after a cache entry expires. Before every Cave release, set the checkpoint to the current signed bundle. On a cache-reset client, Cave rejects a lower sequence and a different payload at the checkpoint sequence, so an old CDN response cannot become the initial trusted parser. + +## Signature canonicalization (format 1) + +Format 1 signs the UTF-8 bytes of the unsigned bundle: remove the top-level `signature` member, then serialize recursively with no whitespace. Arrays retain their original order. Object member names are sorted lexicographically by ECMAScript UTF-16 code-unit order. Strings and primitive values use ECMAScript `JSON.stringify` escaping; object separators are `,` and `:`, with no trailing newline. No transport encoding or pretty-printing is signed. The detached Ed25519 signature is standard base64 over exactly those bytes. + +This representation is frozen for format 1. A changed canonicalization algorithm, signed-member set, or escaping rule requires a new bundle format and an explicit verifier; publishers must not silently reinterpret format 1. + +The following byte-level vector is used by Cave's conformance test and should be reproduced by non-Node registry publishers before deployment: + +```text +input unsigned value: { "z":"last", "runtime":"opencode", "number":0, "nested":{ "unicode":"é", "quote":"\\\"", "line":"a\nb" }, "array":[true,2,null] } +canonical UTF-8 text: {"array":[true,2,null],"nested":{"line":"a\\nb","quote":"\\\"","unicode":"é"},"number":0,"runtime":"opencode","z":"last"} +``` + +To rotate a key, publish a Cave release carrying an active-plus-previous keyring before publishing bundles signed only by the new key. New bundles include the signed `keyId`; Cave stores that verified signer alongside its cache, so an offline client can continue using a valid prior-key bundle during the overlap. Keep the prior key in the packaged keyring for one release window (and no more than the bundle expiry), then remove it in a later release after the registry has served the new key successfully. Emergency revocation removes the compromised key in a new release and intentionally falls back to the bundled parser for any cache that only it signed. Record the registry endpoint, public-key fingerprint, owner, rotation date, and retirement date in the release checklist. diff --git a/next.config.ts b/next.config.ts index aa53d2ae3..8bac0c8e6 100644 --- a/next.config.ts +++ b/next.config.ts @@ -53,6 +53,10 @@ const nextConfig: NextConfig = { "./test-results/**/*", "./tests/**/*", "./src/**/*.test.*", + // Server routes are compiled into `.next/server`; source files are not + // runtime inputs. Guard against a dynamic child-process dependency + // causing NFT to copy the entire checkout into the desktop sidecar. + "./src/**/*", "./apps/**/*.test.*", "./apps/ios/**/build/**/*", ], diff --git a/scripts/cave-home-migration-windows.test.ts b/scripts/cave-home-migration-windows.test.ts index f63b735de..2bd183e3f 100644 --- a/scripts/cave-home-migration-windows.test.ts +++ b/scripts/cave-home-migration-windows.test.ts @@ -116,19 +116,25 @@ try { const candidateFailureCave = path.join(candidateFailureHome, "cave"); await mkdir(candidateFailureCave, { recursive: true }); let candidateAttempts = 0; + let candidateClock = 0; const candidates = new Set(); const persistentEperm = async (candidate: string) => { candidateAttempts += 1; + candidateClock += 100; candidates.add(candidate); const error = new Error("injected persistent Windows EPERM") as NodeJS.ErrnoException; error.code = "EPERM"; throw error; }; await assert.rejects( - migrateCaveHome({ lockTimeoutMs: retryTimeoutMs, lockCandidateRename: persistentEperm }), + migrateCaveHome({ + lockTimeoutMs: retryTimeoutMs, + lockNow: () => candidateClock, + lockCandidateRename: persistentEperm, + }), (error) => error?.code === "ETIMEDOUT", ); - assert.ok(candidateAttempts >= 2); + assert.ok(candidateAttempts >= 2, "the injected monotonic clock permits the intended retry sequence independent of CI scheduling"); assert.equal(candidates.size, 1, "candidate retries reuse one directory on Windows"); assert.equal( (await readdir(candidateFailureCave)).some((name) => name.startsWith(".migration.lock.candidate-")), @@ -150,7 +156,7 @@ try { let reclaimAttempts = 0; await assert.rejects( migrateCaveHome({ - lockTimeoutMs: retryTimeoutMs, + lockTimeoutMs: 150, lockFenceRename: async () => { reclaimAttempts += 1; const error = new Error("injected persistent Windows reclaim EPERM") as NodeJS.ErrnoException; diff --git a/scripts/check-opencode-registry-release.mjs b/scripts/check-opencode-registry-release.mjs new file mode 100644 index 000000000..863e45375 --- /dev/null +++ b/scripts/check-opencode-registry-release.mjs @@ -0,0 +1,52 @@ +// Release-only guard for the OpenCode compatibility registry. The public key +// is intentionally embedded in the desktop build (it verifies, never signs); +// the private Ed25519 key remains solely in the registry publishing service. +import { createPublicKey } from "node:crypto"; + +const url = process.env.NEXT_PUBLIC_COVEN_OPENCODE_SCHEMA_REGISTRY_URL; +const publicKey = process.env.NEXT_PUBLIC_COVEN_OPENCODE_SCHEMA_REGISTRY_PUBLIC_KEY; +const publicKeys = process.env.NEXT_PUBLIC_COVEN_OPENCODE_SCHEMA_REGISTRY_PUBLIC_KEYS; +const checkpoint = process.env.NEXT_PUBLIC_COVEN_OPENCODE_SCHEMA_REGISTRY_CHECKPOINT; + +function fail(message) { + console.error(`::error::${message}`); + process.exitCode = 1; +} +let keyring; +try { + keyring = publicKeys + ? JSON.parse(publicKeys) + : publicKey + ? { legacy: publicKey } + : null; +} catch { + keyring = null; +} +if (!url || !keyring || typeof keyring !== "object" || Array.isArray(keyring) || !checkpoint) { + fail("OpenCode compatibility registry URL, Ed25519 public key/keyring, and immutable sequence checkpoint must be configured for every desktop release."); +} else { + try { + const registryUrl = new URL(url); + // This value is compiled into every desktop artifact. Reject URL userinfo + // rather than accidentally distributing a publisher credential embedded + // in an otherwise-valid HTTPS endpoint. + if (registryUrl.protocol !== "https:" || registryUrl.username || registryUrl.password) { + throw new Error("registry URL must use HTTPS without credentials"); + } + const entries = Object.entries(keyring); + if (!entries.length || entries.length > 4) throw new Error("registry keyring must contain one to four keys"); + for (const [id, pem] of entries) { + if (!/^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(id) || typeof pem !== "string") throw new Error("registry keyring has an invalid key id"); + if (createPublicKey(pem).asymmetricKeyType !== "ed25519") throw new Error("registry key must be Ed25519"); + } + const parsedCheckpoint = JSON.parse(checkpoint); + if (!parsedCheckpoint || typeof parsedCheckpoint !== "object" || Array.isArray(parsedCheckpoint) + || Object.keys(parsedCheckpoint).length !== 2 + || !Number.isSafeInteger(parsedCheckpoint.sequence) || parsedCheckpoint.sequence < 1 + || typeof parsedCheckpoint.payloadHash !== "string" || !/^[a-f0-9]{64}$/.test(parsedCheckpoint.payloadHash)) { + throw new Error("registry checkpoint must contain a sequence and SHA-256 payload hash"); + } + } catch (error) { + fail(`Invalid OpenCode compatibility registry configuration: ${error instanceof Error ? error.message : "unknown error"}`); + } +} diff --git a/scripts/check-opencode-registry-release.test.mjs b/scripts/check-opencode-registry-release.test.mjs new file mode 100644 index 000000000..4d8927313 --- /dev/null +++ b/scripts/check-opencode-registry-release.test.mjs @@ -0,0 +1,39 @@ +import assert from "node:assert/strict"; +import { generateKeyPairSync } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { readFile } from "node:fs/promises"; + +const workflow = await readFile(new URL("../.github/workflows/release.yml", import.meta.url), "utf8"); +const guard = await readFile(new URL("./check-opencode-registry-release.mjs", import.meta.url), "utf8"); +const docs = await readFile(new URL("../docs/opencode-compatibility-registry.md", import.meta.url), "utf8"); + +assert.match(workflow, /Require signed OpenCode compatibility registry[\s\S]*?NEXT_PUBLIC_COVEN_OPENCODE_SCHEMA_REGISTRY_URL[\s\S]*?check-opencode-registry-release\.mjs/); +assert.match(workflow, /NEXT_PUBLIC_COVEN_OPENCODE_SCHEMA_REGISTRY_PUBLIC_KEYS/); +assert.match(workflow, /NEXT_PUBLIC_COVEN_OPENCODE_SCHEMA_REGISTRY_CHECKPOINT/); +assert.match(guard, /registry URL must use HTTPS without credentials/); +assert.match(guard, /asymmetricKeyType !== "ed25519"/); +assert.match(guard, /keyring must contain one to four keys/); +assert.match(guard, /payloadHash/); +assert.match(docs, /rotation/i); +assert.match(docs, /built-in profile/i); +assert.match(docs, /Signature canonicalization \(format 1\)/); +assert.match(docs, /canonical UTF-8 text/); + +// A registry endpoint is public build metadata, so an HTTPS URL that embeds +// basic-auth userinfo must fail without echoing its credential into CI output. +const { publicKey } = generateKeyPairSync("ed25519"); +const credential = "publisher-token-must-not-leak"; +const credentialed = spawnSync(process.execPath, ["scripts/check-opencode-registry-release.mjs"], { + cwd: new URL("..", import.meta.url), + encoding: "utf8", + env: { + ...process.env, + NEXT_PUBLIC_COVEN_OPENCODE_SCHEMA_REGISTRY_URL: `https://publisher:${credential}@registry.example/opencode.json`, + NEXT_PUBLIC_COVEN_OPENCODE_SCHEMA_REGISTRY_PUBLIC_KEY: publicKey.export({ type: "spki", format: "pem" }).toString(), + NEXT_PUBLIC_COVEN_OPENCODE_SCHEMA_REGISTRY_CHECKPOINT: JSON.stringify({ sequence: 1, payloadHash: "a".repeat(64) }), + }, +}); +assert.notEqual(credentialed.status, 0, "credentialed registry URLs must be rejected before packaging"); +assert.match(credentialed.stderr, /without credentials/); +assert.doesNotMatch(credentialed.stderr, new RegExp(credential)); +console.log("check-opencode-registry-release.test.mjs: ok"); diff --git a/scripts/git-hooks-pre-commit.test.mjs b/scripts/git-hooks-pre-commit.test.mjs index bde220ade..9a35ca0b0 100644 --- a/scripts/git-hooks-pre-commit.test.mjs +++ b/scripts/git-hooks-pre-commit.test.mjs @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { chmodSync, cpSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { chmodSync, cpSync, existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -7,6 +7,12 @@ import { spawnSync } from "node:child_process"; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const hookSource = path.join(root, "scripts", "git-hooks", "pre-commit"); +// On Windows `bash` may resolve to the WSL launcher, which is not a usable +// shell when WSL is uninstalled or partially configured. Prefer Git Bash when +// available; the hook itself still executes under the same Bash implementation +// used by Git for Windows and GitHub's Windows runners. +const gitBash = "C:\\Program Files\\Git\\bin\\bash.exe"; +const bashCommand = process.platform === "win32" && existsSync(gitBash) ? gitBash : "bash"; function run(cmd, args, cwd) { const result = spawnSync(cmd, args, { cwd, encoding: "utf8" }); @@ -32,7 +38,7 @@ function stagedRepo({ filePath, content }) { } function runHook(repo) { - return run("bash", ["scripts/git-hooks/pre-commit"], repo); + return run(bashCommand, ["scripts/git-hooks/pre-commit"], repo); } { diff --git a/scripts/run-tests.mjs b/scripts/run-tests.mjs index 4dd00c998..4f9f2604d 100644 --- a/scripts/run-tests.mjs +++ b/scripts/run-tests.mjs @@ -102,6 +102,7 @@ export const SUITES = { "scripts/codemods/tokenize-tsx-design.test.mjs", "scripts/eslint/design-system-plugin.test.mjs", "scripts/bundle-budget.test.mjs", + "scripts/check-opencode-registry-release.test.mjs", "src/components/open-coven-tools-update.test.ts", "src/lib/opencoven-tools-state.test.ts", "src/lib/opencoven-install-job-observer.test.ts", @@ -475,6 +476,8 @@ export const SUITES = { "src/lib/slash-model.test.ts", "src/lib/opencode-models.test.ts", "src/lib/opencode-bin.test.ts", + "src/lib/chat-tool-events.test.ts", + "src/lib/opencode-compatibility.test.ts", "src/lib/opencode-stream.test.ts", "src/lib/use-runtime-model-options.test.ts", "src/lib/slash-skill.test.ts", @@ -1026,6 +1029,8 @@ export const SUITES = { "src/app/api/chat/send/harness-routing-model-capabilities.test.ts", "src/app/api/chat/send/harness-routing-copilot-jsonl.test.ts", "src/app/api/chat/send/harness-routing-opencode.test.ts", + "src/app/api/chat/send/chat-send-capabilities.test.ts", + "src/app/api/chat/send/route-opencode.integration.test.ts", "src/app/api/chat/send/offline-queue.test.ts", "src/app/api/chat/send/first-turn-stub.test.ts", "src/app/api/onboarding/status/route.test.ts", @@ -1288,6 +1293,11 @@ const STRIP_TYPES_MJS = new Set([ // Tests whose import graph reaches the "@/..." path alias and therefore need // the alias-resolving loader (`scripts/test-alias-register.mjs`). const ALIAS_LOADER = new Set([ + "src/lib/opencode-compatibility.test.ts", + "src/lib/opencode-stream.test.ts", + "src/lib/cave-conversations.test.ts", + "src/app/api/chat/send/chat-send-capabilities.test.ts", + "src/app/api/chat/send/route-opencode.integration.test.ts", "src/lib/familiar-workspace-sessions.test.ts", "src/lib/use-projects-scope-transition.test.ts", "scripts/cave-home-migration-windows.test.ts", diff --git a/src/app/api/chat/conversation/[id]/route.test.ts b/src/app/api/chat/conversation/[id]/route.test.ts index 3cbed2b42..6d8e9c495 100644 --- a/src/app/api/chat/conversation/[id]/route.test.ts +++ b/src/app/api/chat/conversation/[id]/route.test.ts @@ -63,7 +63,7 @@ function paramsFor(id: string) { return { params: Promise.resolve({ id }) }; } -const { DELETE } = await import("./route.ts"); +const { DELETE, GET } = await import("./route.ts"); const { PUT, POST } = await import("./route.ts"); function writeReq(bodyObj: unknown) { @@ -164,6 +164,13 @@ test("PUT strips client-forged assistant telemetry (usage/cost/tools/reasoning)" costUsd: 42, tools: [{ id: "t", name: "shell", status: "ok" }], reasoning: "fake", + progress: [{ + id: "opencode-compatibility", + label: "Forged OpenCode compatibility warning", + detail: "client-controlled text", + status: "error", + createdAt: "2026-07-25T00:00:00.000Z", + }], }, ], }), @@ -174,7 +181,7 @@ test("PUT strips client-forged assistant telemetry (usage/cost/tools/reasoning)" const asst = json.conversation.turns.find((t: any) => t.role === "assistant"); assert.ok(asst, "assistant turn persisted"); assert.equal(asst.text, "totally real answer", "text preserved"); - for (const f of ["usage", "costUsd", "tools", "reasoning"]) { + for (const f of ["usage", "costUsd", "tools", "reasoning", "progress"]) { assert.equal(f in asst, false, `harness-owned ${f} stripped from client write`); } }); @@ -193,3 +200,29 @@ test("PUT rejects an over-long turn with 413", async () => { assert.equal(json.ok, false); assert.match(json.error, /too long/); }); + +test("GET preserves persisted OpenCode compatibility diagnostics", async () => { + writeConversation("sess-opencode-diagnostic", [ + { + id: "assistant-diagnostic", + role: "assistant", + text: "Reply preserved safely.", + createdAt: "2026-07-25T00:00:00.000Z", + progress: [{ + id: "opencode-compatibility", + label: "OpenCode compatibility notice", + detail: "unrecognized event", + status: "error", + createdAt: "2026-07-25T00:00:00.000Z", + }], + }, + ]); + const res = await GET(new Request("http://test/api/chat/conversation/sess-opencode-diagnostic"), paramsFor("sess-opencode-diagnostic")); + const json = await res.json(); + assert.equal(res.status, 200); + assert.deepEqual( + json.conversation.turns[0].progress, + [{ id: "opencode-compatibility", label: "OpenCode compatibility notice", detail: "unrecognized event", status: "error", createdAt: "2026-07-25T00:00:00.000Z" }], + "stored compatibility diagnostics survive the conversation API reload path", + ); +}); diff --git a/src/app/api/chat/conversation/[id]/route.ts b/src/app/api/chat/conversation/[id]/route.ts index 8d7835866..3bc68f134 100644 --- a/src/app/api/chat/conversation/[id]/route.ts +++ b/src/app/api/chat/conversation/[id]/route.ts @@ -71,6 +71,26 @@ function normalizeTurn(input: unknown): ChatTurn | null { const now = new Date().toISOString(); const usage = normalizeTurnUsage(value.usage); const costUsd = parseCostUsd(value.costUsd); + const progress = Array.isArray(value.progress) + ? value.progress.flatMap((entry) => { + if (!entry || typeof entry !== "object") return []; + const item = entry as NonNullable[number]; + if ( + typeof item.id !== "string" + || typeof item.label !== "string" + || typeof item.createdAt !== "string" + || (item.status !== "running" && item.status !== "done" && item.status !== "error") + ) return []; + return [{ + id: item.id, + label: item.label, + ...(typeof item.detail === "string" ? { detail: item.detail } : {}), + status: item.status, + createdAt: item.createdAt, + ...(typeof item.durationMs === "number" && Number.isFinite(item.durationMs) ? { durationMs: item.durationMs } : {}), + }]; + }) + : undefined; return { id: typeof value.id === "string" && value.id.trim() ? value.id : crypto.randomUUID(), role: value.role, @@ -78,6 +98,7 @@ function normalizeTurn(input: unknown): ChatTurn | null { ...(Array.isArray(value.attachments) ? { attachments: value.attachments } : {}), ...(typeof value.reasoning === "string" ? { reasoning: value.reasoning } : {}), ...(Array.isArray(value.tools) ? { tools: value.tools } : {}), + ...(progress?.length ? { progress } : {}), ...(typeof value.parentId === "string" || value.parentId === null ? { parentId: value.parentId } : {}), diff --git a/src/app/api/chat/send/chat-send-capabilities.test.ts b/src/app/api/chat/send/chat-send-capabilities.test.ts new file mode 100644 index 000000000..f8f94f378 --- /dev/null +++ b/src/app/api/chat/send/chat-send-capabilities.test.ts @@ -0,0 +1,152 @@ +// @ts-nocheck +import assert from "node:assert/strict"; +import { + openCodeCapabilityProbeCacheable, + openCodeCapabilityProbeScope, + openCodeCapabilityProbeTimeoutMs, + openCodeProbeCleanupGraceMs, + openCodeProbeSpawnOptions, + openCodeProbeTreeKillCommand, + openCodeRunCapabilities, + parseOpenCodeRunCapabilitiesHelp, +} from "./chat-send-capabilities.ts"; + +assert.equal(openCodeCapabilityProbeTimeoutMs("linux"), 2_500, "non-Windows capability probes retain the short bounded deadline"); +assert.equal(openCodeCapabilityProbeTimeoutMs("win32"), 6_000, "Windows PowerShell/npm launchers receive a bounded cold-start allowance"); +assert.equal(openCodeProbeCleanupGraceMs(), 1_000, "timed-out probe cleanup has a short final deadline so chat can fall back"); +assert.equal(openCodeCapabilityProbeCacheable("linux"), false, "POSIX clients are reprobed rather than reusing a same-version help contract"); +assert.equal(openCodeCapabilityProbeCacheable("win32"), false, "Windows launcher shims are reprobed rather than reusing stale downstream capability evidence"); +assert.notEqual( + openCodeCapabilityProbeScope("opal"), + openCodeCapabilityProbeScope("moss"), + "capability evidence from one familiar's scoped environment cannot be reused for another familiar", +); +assert.equal(openCodeCapabilityProbeScope(), "default", "the unscoped probe cache has a stable explicit scope"); +assert.deepEqual(openCodeProbeSpawnOptions("linux"), { detached: true }, "POSIX OpenCode probes create an isolated process group that timeout cleanup can terminate"); +assert.deepEqual(openCodeProbeSpawnOptions("win32"), { detached: false }, "Windows probes rely on taskkill's explicit process-tree cleanup rather than a detached process group"); +const firstCapabilities = await openCodeRunCapabilities("probe-fixture", async () => ({ + helpProbe: { complete: true, output: " --format Output format: text, json\n" }, + versionProbe: { complete: true, output: "opencode 1.0.0" }, +})); +const replacementCapabilities = await openCodeRunCapabilities("probe-fixture", async () => ({ + helpProbe: { complete: true, output: " --format Output format: text\n" }, + versionProbe: { complete: true, output: "opencode 1.0.0" }, +})); +assert.equal(firstCapabilities.json, true); +assert.equal(replacementCapabilities.json, false, "an in-place same-version CLI replacement is reprobed before Cave chooses JSON argv"); +assert.deepEqual( + openCodeProbeTreeKillCommand(4242, "win32"), + { command: "taskkill.exe", args: ["/PID", "4242", "/T", "/F"] }, + "timed-out Windows probes terminate their launcher tree rather than only PowerShell", +); +assert.equal(openCodeProbeTreeKillCommand(4242, "linux"), null, "non-Windows probes retain process-local termination"); + +const capabilities = parseOpenCodeRunCapabilitiesHelp(` + --structured-output Output format: text, json-v3 + --event-stream Emit structured lifecycle events + --no-color Disable terminal color + --permission Set tool permission policy +`, "3.0.0"); + +assert.deepEqual( + capabilities.noValueOptions, + ["--event-stream", "--no-color"], + "only declared flags without a value placeholder can satisfy a signed no-value launch requirement", +); +assert.equal(capabilities.structuredOutputs?.[0]?.option, "--structured-output"); +assert.deepEqual(capabilities.structuredOutputs?.[0]?.values, ["json-v3"]); + +const valueTaking = parseOpenCodeRunCapabilitiesHelp(` + --format Output format: text, json + --event-stream MODE Configure lifecycle frame mode + --no-color Disable terminal color +`, "3.1.0"); +assert.deepEqual( + valueTaking.noValueOptions, + ["--no-color"], + "an unbracketed positional token after a flag is ambiguous and cannot be launched without a value", +); +const proseOperand = parseOpenCodeRunCapabilitiesHelp(` + --format Prints output when enabled +`, "3.1.0"); +assert.equal(proseOperand.json, false, "an operand-looking token in an option description never confirms a value-taking format flag"); +assert.deepEqual(proseOperand.valueOptions, [], "only the option syntax column can prove that OpenCode accepts an argv value"); + +const currentYargsHelp = parseOpenCodeRunCapabilitiesHelp(` + --format Select output format [string] [choices: "default", "json"] + -s, --session Resume a named session [string] + -m, --model Select a model [string] + --event-stream Configure event framing + [string] +`, "1.18.5"); +assert.equal(currentYargsHelp.json, true, "current yargs choices confirm JSON output without treating prose as syntax"); +assert.equal(currentYargsHelp.session, true, "current yargs inline string metadata confirms native session support"); +assert.equal(currentYargsHelp.model, true, "current yargs inline string metadata confirms model forwarding support"); +assert.deepEqual(currentYargsHelp.valueOptions, ["--format", "--session", "--model", "--event-stream"], "inline and wrapped yargs type annotations confirm value-taking options"); +assert.deepEqual(currentYargsHelp.noValueOptions, [], "inline or wrapped yargs value annotations never become evidence for a valueless launch flag"); +assert.equal(currentYargsHelp.endOfOptions, false, "a conventional delimiter is not assumed when current help does not document it"); + +const documentedDelimiter = parseOpenCodeRunCapabilitiesHelp(` + --format Output format: text, json + -- End of options +`, "future"); +assert.equal(documentedDelimiter.endOfOptions, true, "only a dedicated documented delimiter row permits the route to insert --"); + +const toolEventsOutput = parseOpenCodeRunCapabilitiesHelp(` + --format Output format: text, json + --include-tool-events Include tool lifecycle frames in JSON output + --tool-events Emit tool lifecycle frames in JSON output +`, "3.1.1"); +assert.deepEqual( + toolEventsOutput.noValueOptions, + ["--include-tool-events", "--tool-events"], + "declared output-only tool-event switches can be independently confirmed before a signed schema forwards them", +); + +const booleanJson = parseOpenCodeRunCapabilitiesHelp(` + --json Emit JSON events + --event-json-v2 Emit JSON v2 events + --format Output format: text +`, "3.2.0"); +assert.deepEqual( + booleanJson.structuredOutputs, + [], + "a boolean JSON switch is never mis-recorded as an option that accepts the literal json value", +); +assert.deepEqual( + booleanJson.structuredSwitches, + [ + { option: "--json", protocols: ["json"] }, + { option: "--event-json-v2", protocols: ["json-v2"] }, + ], + "explicit valueless JSON switches retain their independently probed launch contract", +); +assert.deepEqual(booleanJson.protocols, ["json", "json-v2"]); + +const proseOnlyJson = parseOpenCodeRunCapabilitiesHelp(` + --format Select an output format; use --json for JSON output + --json Emit JSON events +`, "3.3.0"); +assert.deepEqual( + proseOnlyJson.structuredOutputs, + [], + "a JSON mention in a value-taking option's prose is not treated as an accepted format value", +); +assert.deepEqual(proseOnlyJson.protocols, ["json"], "only the independently declared boolean JSON switch contributes a protocol"); + +const malformedEnumStartedAt = Date.now(); +parseOpenCodeRunCapabilitiesHelp(`--format <${"item,".repeat(12_000)}`, "3.3.1"); +assert.ok(Date.now() - malformedEnumStartedAt < 1_000, "malformed format enums are scanned in bounded linear time"); + +const resumeCapabilities = parseOpenCodeRunCapabilitiesHelp(` + --format Output format: text, json + --resume Resume the most recent session + --session Resume a named session +`, "3.4.0"); +assert.equal(resumeCapabilities.session, true); +assert.deepEqual(resumeCapabilities.valueOptions, ["--format", "--session"], "only session options with an explicit argument can receive Cave's native session id"); + +assert.equal(openCodeCapabilityProbeCacheable("win32"), false, "Windows re-probes every turn because a shim target can change in place"); +assert.equal(openCodeCapabilityProbeCacheable("linux"), false, "POSIX re-probes every turn because a same-version shim can change its help contract"); + +console.log("chat-send-capabilities.test.ts: ok"); diff --git a/src/app/api/chat/send/chat-send-capabilities.ts b/src/app/api/chat/send/chat-send-capabilities.ts index 67d992411..8936d7fbb 100644 --- a/src/app/api/chat/send/chat-send-capabilities.ts +++ b/src/app/api/chat/send/chat-send-capabilities.ts @@ -7,12 +7,116 @@ import { } from "@/lib/harness-adapters"; import { harnessSpawnEnv } from "@/lib/harness-spawn-env"; import { openCodeLaunch, openCodeSpawnEnv, writeOpenCodeLaunchInput } from "@/lib/opencode-bin"; +import type { OpenCodeRunCapabilities } from "@/lib/opencode-compatibility"; let modelFlagProbe: Promise | null = null; let permissionFlagProbe: Promise | null = null; let addDirFlagProbe: Promise | null = null; let hermesModelFlagProbe: Promise | null = null; let openCodeModelFlagProbe: Promise | null = null; +const DEFAULT_CAPABILITY_PROBE_TIMEOUT_MS = 2_500; +const WINDOWS_CAPABILITY_PROBE_TIMEOUT_MS = 6_000; +const OPENCODE_PROBE_CLEANUP_GRACE_MS = 1_000; + +/** PowerShell/npm shims can be delayed by cold start or Defender scanning. */ +export function openCodeCapabilityProbeTimeoutMs(platform: NodeJS.Platform = process.platform): number { + return platform === "win32" ? WINDOWS_CAPABILITY_PROBE_TIMEOUT_MS : DEFAULT_CAPABILITY_PROBE_TIMEOUT_MS; +} + +/** Cleanup remains best-effort: a hung launcher must never block chat fallback. */ +export function openCodeProbeCleanupGraceMs(): number { + return OPENCODE_PROBE_CLEANUP_GRACE_MS; +} + +/** The help text is the executable contract, and an installed OpenCode may + * change that contract without changing its version or PATH entry. Re-probe + * every turn rather than launching from stale argv evidence. */ +export function openCodeCapabilityProbeCacheable(platform: NodeJS.Platform = process.platform): boolean { + void platform; + return false; +} + +/** Capability output can be configured per familiar through its scoped + * environment. Never reuse one familiar's help-derived argv contract for + * another, even when both resolve the same launcher path and version. */ +export function openCodeCapabilityProbeScope(familiarId?: string): string { + return familiarId ?? "default"; +} + +/** POSIX probes need their own process group so a timed-out launcher cannot + * leave an OpenCode child running after the capability fallback has returned. */ +export function openCodeProbeSpawnOptions( + platform: NodeJS.Platform = process.platform, +): { detached: boolean } { + return { detached: platform !== "win32" }; +} + +/** `taskkill /T` is required because killing the PowerShell launcher alone + * leaves its opencode(.cmd) child running on Windows. */ +export function openCodeProbeTreeKillCommand( + pid: number | undefined, + platform: NodeJS.Platform = process.platform, +): { command: string; args: string[] } | null { + if (platform !== "win32" || typeof pid !== "number" || !Number.isSafeInteger(pid) || pid <= 0) return null; + const processId: number = pid; + return { command: "taskkill.exe", args: ["/PID", String(processId), "/T", "/F"] }; +} + +function terminateProbeProcessTree(child: ChildProcessWithoutNullStreams): Promise { + const treeKill = openCodeProbeTreeKillCommand(child.pid); + if (!treeKill) { + return new Promise((resolve) => { + const pid = child.pid; + let timer: ReturnType | undefined; + const finish = () => { + if (timer) clearTimeout(timer); + resolve(); + }; + child.once("close", finish); + try { + // Probes start detached on POSIX, so the negative PID terminates the + // launcher and every descendant rather than leaking a helper process. + if (typeof pid === "number") process.kill(-pid, "SIGTERM"); + else child.kill("SIGTERM"); + } catch { + try { child.kill("SIGTERM"); } catch { /* Best effort. */ } + } + timer = setTimeout(() => { + try { if (typeof pid === "number") process.kill(-pid, "SIGKILL"); } catch { /* exited */ } + finish(); + }, openCodeProbeCleanupGraceMs()); + }); + } + return new Promise((resolve) => { + let settled = false; + let killer: ReturnType | null = null; + const finish = () => { + if (settled) return; + settled = true; + clearTimeout(deadline); + resolve(); + }; + // `taskkill` is itself an external process. If Windows is wedged while + // walking the tree, do not turn a bounded capability probe into an + // indefinitely blocked chat request. + const deadline = setTimeout(() => { + try { killer?.kill("SIGTERM"); } catch { /* Best effort. */ } + try { child.kill("SIGTERM"); } catch { /* Best effort. */ } + finish(); + }, openCodeProbeCleanupGraceMs()); + try { + killer = spawn(treeKill.command, treeKill.args, { stdio: "ignore", windowsHide: true }); + killer.once("error", () => { + try { child.kill("SIGTERM"); } catch { /* Best-effort fallback. */ } + finish(); + }); + killer.once("close", finish); + } catch { + try { child.kill("SIGTERM"); } catch { /* Best-effort fallback. */ } + finish(); + } + }); +} function probeHelp( command: string, @@ -33,6 +137,7 @@ function probeHelp( const child = spawn(command, args, { env, stdio: input === undefined ? ["ignore", "pipe", "pipe"] : ["pipe", "pipe", "pipe"], + ...openCodeProbeSpawnOptions(), }) as ChildProcessWithoutNullStreams; if (input !== undefined) writeOpenCodeLaunchInput(child, { command, args, input }); child.stdout.on("data", (chunk) => (output += chunk.toString())); @@ -44,7 +149,7 @@ function probeHelp( // The capability is unsupported when the probe cannot complete. } done(false); - }, 2500); + }, openCodeCapabilityProbeTimeoutMs()); child.on("close", () => { clearTimeout(timeout); done(matches(output)); @@ -59,6 +164,261 @@ function probeHelp( }); } +type ProbeOutput = { output: string; complete: boolean }; + +function probeOutput( + command: string, + args: string[], + env = harnessSpawnEnv(), + input?: string, + timeoutMs = openCodeCapabilityProbeTimeoutMs(), +): Promise { + return new Promise((resolve) => { + let output = ""; + const MAX_PROBE_OUTPUT = 64 * 1024; + let settled = false; + const done = (complete: boolean) => { + if (settled) return; + settled = true; + resolve({ output, complete }); + }; + try { + const child = spawn(command, args, { + env, + stdio: input === undefined ? ["ignore", "pipe", "pipe"] : ["pipe", "pipe", "pipe"], + ...openCodeProbeSpawnOptions(), + }) as ChildProcessWithoutNullStreams; + if (input !== undefined) writeOpenCodeLaunchInput(child, { command, args, input }); + let overflowed = false; + const append = (chunk: Buffer) => { + if (output.length >= MAX_PROBE_OUTPUT) { overflowed = true; return; } + output += chunk.toString().slice(0, MAX_PROBE_OUTPUT - output.length); + if (output.length >= MAX_PROBE_OUTPUT) overflowed = true; + }; + child.stdout.on("data", append); + child.stderr.on("data", append); + let timedOut = false; + let timeout: ReturnType | undefined; + let exitResolved = false; + let resolveExit!: () => void; + const exited = new Promise((resolve) => { resolveExit = resolve; }); + const markExited = () => { + if (exitResolved) return; + exitResolved = true; + resolveExit(); + }; + child.on("close", (code) => { + if (timeout) clearTimeout(timeout); + markExited(); + if (!timedOut) done(code === 0 && !overflowed); + }); + child.on("error", () => { + if (timeout) clearTimeout(timeout); + markExited(); + if (!timedOut) done(false); + }); + timeout = setTimeout(() => { + timedOut = true; + // Do not resolve a timed-out probe until the entire Windows launcher + // tree has exited when possible; otherwise orphaned children are still + // cleaned up best-effort without making the chat request hang. + const cleanupDeadline = setTimeout(() => done(false), openCodeProbeCleanupGraceMs()); + void terminateProbeProcessTree(child).finally(() => { + void exited.then(() => { + clearTimeout(cleanupDeadline); + done(false); + }); + }); + }, timeoutMs); + } catch { + done(false); + } + }); +} + +function hasRunOption(help: string, flag: string): boolean { + // Only option-definition lines count. Mentions in examples, migration notes, + // or another command's help text are not evidence that `opencode run` takes + // this flag. + return new RegExp(`^\\s*(?:-[A-Za-z],?\\s+)?${flag}\\b(?:\\s|=|,|$)`, "m").test(help); +} + +function optionStanza(help: string, option: string): string { + return help.match(new RegExp(`^\\s*(?:-[A-Za-z],?\\s+)?${option}\\b[^\\n]*(?:\\n(?!\\s*(?:-[A-Za-z],?\\s+)?--)[^\\n]*){0,2}`, "im"))?.[0] ?? ""; +} + +type OptionSyntax = { declaration: string; synopsis: string }; + +function optionSyntax(help: string, option: string): OptionSyntax | null { + const lines = optionStanza(help, option).split(/\r?\n/); + const declarationLine = lines.find((line) => line.includes(option)); + if (!declarationLine) return null; + const optionAt = declarationLine.indexOf(option); + const trailing = optionAt >= 0 ? declarationLine.slice(optionAt + option.length) : ""; + // Current yargs output may put its typed annotation after the description + // on the same row (`--session Resume… [string]`), rather than wrapping it + // onto a continuation. Capture only the exact yargs grammar at line end; + // arbitrary bracketed prose remains outside the argv contract. + const inlineYargsAnnotation = trailing.match(/(?:^|\s)(\[(?:string|number|boolean|array|count)\](?:\s+\[(?:choices?|default):[^\]\r\n]*\])*)\s*$/i)?.[1]; + const syntaxColumn = inlineYargsAnnotation + ? trailing.slice(0, trailing.lastIndexOf(inlineYargsAnnotation)) + : trailing; + // Help renderers conventionally begin the description in a second column. + // Keep that prose out of argv capability evidence. + const descriptionAt = syntaxColumn.search(/\s{2,}/); + const declaration = (descriptionAt >= 0 ? syntaxColumn.slice(0, descriptionAt) : syntaxColumn).trim(); + // yargs wraps an option's type and choices onto an indented continuation, + // for example: `[string] [choices: "text", "json"]`. Only that exact + // annotation grammar is syntax; arbitrary wrapped prose remains ignored. + const yargsAnnotations = lines.filter((line) => /^\s*\[(?:string|number|boolean|array|count)\](?:\s+\[(?:choices?|default):[^\]\r\n]*\])*\s*$/i.test(line)); + if (inlineYargsAnnotation) yargsAnnotations.unshift(inlineYargsAnnotation); + return { declaration, synopsis: [declaration, ...yargsAnnotations].join(" ") }; +} + +function optionTakesExplicitValue(help: string, option: string): boolean { + // Do not infer a value from prose such as "Emit JSON". We only forward an + // argv value after the option synopsis itself declares one. Bare positional + // words are deliberately ambiguous (for example `--event-stream MODE`). + const syntax = optionSyntax(help, option); + return syntax !== null && /<[^>\n]+>|\[[^\]\n]+\]|=\S+/.test(syntax.synopsis); +} + +/** Extract bracketed enum bodies in one pass. Help output is runtime-provided, + * so this deliberately avoids nested quantified regexes on the chat path. */ +function bracketEnumerations(text: string): string[] { + const closingFor: Record = { "<": ">", "[": "]", "{": "}" }; + const enumerations: string[] = []; + for (let index = 0; index < text.length; index++) { + const closing = closingFor[text[index]]; + if (!closing) continue; + const start = index + 1; + while (index < text.length && text[index] !== closing && text[index] !== "\n" && text[index] !== "\r") index++; + if (text[index] === closing) { + const enumeration = text.slice(start, index); + if (enumeration.includes(",") || enumeration.includes("|")) enumerations.push(enumeration); + } + } + return enumerations; +} + +function advertisedStructuredOutputs(help: string): Array<{ option: string; values: string[] }> { + return declaredRunOptions(help).flatMap((option) => { + if (!optionTakesExplicitValue(help, option)) return []; + const stanza = optionStanza(help, option); + const syntax = optionSyntax(help, option); + if (!syntax) return []; + // JSON in arbitrary prose is not an accepted option value. Restrict the + // evidence to an explicit enum in the synopsis or to an option-local + // `format:`/`values:`/`choices:` metadata list. + const enumerations = [ + ...bracketEnumerations(syntax.synopsis), + ...[...stanza.matchAll(/\b(?:output\s+)?(?:format|values?|choices?)\s*:\s*([^\r\n]+)/gi)].map((match) => match[1]), + ]; + const values = [...new Set(enumerations.flatMap((enumeration) => enumeration.match(/\bjson(?:[._-][a-z0-9]+)*\b/gi) ?? []).map((value) => value.toLowerCase()))]; + return values.length ? [{ option, values }] : []; + }); +} + +function declaredRunOptions(help: string): string[] { + return [...new Set([...help.matchAll(/^\s*(?:-[A-Za-z],?\s+)?(--[A-Za-z][A-Za-z0-9-]*)\b/gm)].map((match) => match[1]))]; +} + +function declaredNoValueRunOptions(help: string, options: string[]): string[] { + return options.filter((option) => { + // A valueless option is either alone or followed by a conventional + // two-space help-description column. A single following token (for + // example `--event-stream MODE`) is ambiguous and therefore unsupported. + // Wrapped yargs `[string]` continuations count as value syntax too. + const syntax = optionSyntax(help, option); + return syntax !== null && syntax.declaration === "" && !optionTakesExplicitValue(help, option); + }); +} + +function documentsEndOfOptionsDelimiter(help: string): boolean { + // A prose mention of `--` is not argv evidence. Accept only a dedicated + // option-definition row that names the conventional delimiter and explains + // its semantics, so legacy clients retain their normal positional launch. + return /^\s*--\s{2,}(?:end(?:\s+of)?\s+(?:options|arguments)|stop\s+(?:option|argument)\s+parsing)\b/im.test(help); +} + +function jsonProtocolForSwitch(option: string): string | null { + const marker = option.slice(2).toLowerCase().split("-"); + const jsonAt = marker.findIndex((part) => part === "json"); + if (jsonAt < 0) return null; + const suffix = marker.slice(jsonAt + 1); + return suffix.length ? `json-${suffix.join("-")}` : "json"; +} + +function advertisedStructuredSwitches(options: string[], noValueOptions: string[]): Array<{ option: string; protocols: string[] }> { + const valueless = new Set(noValueOptions); + return options.flatMap((option) => { + const protocol = valueless.has(option) ? jsonProtocolForSwitch(option) : null; + return protocol ? [{ option, protocols: [protocol] }] : []; + }); +} + +type OpenCodeRunContractProbe = { helpProbe: ProbeOutput; versionProbe: ProbeOutput }; + +async function probeOpenCodeRunContract(env: NodeJS.ProcessEnv): Promise { + const helpLaunch = openCodeLaunch(["run", "--help"]); + const versionLaunch = openCodeLaunch(["--version"]); + const [helpProbe, versionProbe] = await Promise.all([ + probeOutput(helpLaunch.command, helpLaunch.args, env, helpLaunch.input), + probeOutput(versionLaunch.command, versionLaunch.args, env, versionLaunch.input), + ]); + return { helpProbe, versionProbe }; +} + +function advertisedFormatProtocols( + outputs: Array<{ option: string; values: string[] }>, + switches: Array<{ option: string; protocols: string[] }>, +): string[] { + // A protocol marker is useful only when the CLI advertises it as an output + // format or an explicit valueless JSON switch. Do not derive it from version + // strings or arbitrary help prose. + return [...new Set([...outputs.flatMap((output) => output.values), ...switches.flatMap((output) => output.protocols)])]; +} + +/** + * Convert a complete `opencode run --help` response into the bounded + * capability contract consumed by schema selection and plain-mode launching. + * Exported for fixtures so resume-only clients remain covered without spawning + * an installed runtime. + */ +export function parseOpenCodeRunCapabilitiesHelp(help: string, version: string | null): OpenCodeRunCapabilities { + const options = declaredRunOptions(help); + const valueOptions = options.filter((option) => optionTakesExplicitValue(help, option)); + const noValueOptions = declaredNoValueRunOptions(help, options); + const structuredOutputs = advertisedStructuredOutputs(help); + const structuredSwitches = advertisedStructuredSwitches(options, noValueOptions); + const protocols = advertisedFormatProtocols(structuredOutputs, structuredSwitches); + const json = protocols.some((protocol) => protocol === "json" || protocol.startsWith("json-") || protocol.startsWith("json_")); + return { + version, + // Only accept JSON when an option explicitly documents either its value + // syntax or a valueless JSON switch. A stray "JSON" in a banner or + // another option's description cannot make us launch unsupported argv. + json, + model: valueOptions.includes("--model"), + // A bare --resume can mean "resume latest". Cave has a stable native id + // to forward, so it is resumable only when the synopsis documents an + // argument rather than merely mentioning the option. + session: (options.includes("--session") && valueOptions.includes("--session")) + || (options.includes("--resume") && valueOptions.includes("--resume")), + // The documented format value is an independently observed protocol + // marker. Future formats (for example json-v2) must be explicitly + // advertised and selected by a matching schema; we never infer them + // from the installed version string. + protocols, + options, + valueOptions, + noValueOptions, + endOfOptions: documentsEndOfOptionsDelimiter(help), + structuredSwitches, + structuredOutputs, + }; +} + /** Capability probes are cached because old Coven CLIs reject unknown flags. */ export function covenRunSupportsModel(): Promise { const { command, fixedArgs } = covenLaunchCommand(); @@ -108,3 +468,27 @@ export function openCodeRunSupportsModel(): Promise { launch.input, )); } + +/** + * Discover the installed client's usable surface from its own help output. + * The version is retained for support diagnostics only; it never gates a + * schema because vendors can backport or change protocol behavior. + */ +export async function openCodeRunCapabilities( + familiarId?: string, + probeRunContract: (env: NodeJS.ProcessEnv) => Promise = probeOpenCodeRunContract, +): Promise { + // Probe the exact scoped environment used for this chat turn. A version + // string alone is not a safe cache key: package managers and shims can + // replace a CLI in place while preserving both PATH and `--version`. + const env = openCodeSpawnEnv(familiarId); + const { helpProbe, versionProbe } = await probeRunContract(env); + const version = versionProbe.complete + ? versionProbe.output.match(/\b\d+(?:\.\d+){1,3}(?:[-+][\w.-]+)?\b/)?.[0] ?? null + : null; + // Partial, timed-out, non-zero, or oversized help is never capability + // evidence. Re-probe on the next turn instead of risking unsupported argv. + return !helpProbe.complete + ? { version, json: false, model: false, session: false, protocols: [], options: [], valueOptions: [], noValueOptions: [], endOfOptions: false, structuredSwitches: [], structuredOutputs: [] } + : parseOpenCodeRunCapabilitiesHelp(helpProbe.output, version); +} diff --git a/src/app/api/chat/send/harness-routing-copilot-jsonl.test.ts b/src/app/api/chat/send/harness-routing-copilot-jsonl.test.ts index 5eb67c86a..e2c9a9c71 100644 --- a/src/app/api/chat/send/harness-routing-copilot-jsonl.test.ts +++ b/src/app/api/chat/send/harness-routing-copilot-jsonl.test.ts @@ -86,8 +86,8 @@ assert.match( ); assert.match( chatRoute, - /const harnessSessionId = grokDirect\s*\?\s*grokSessionId\s*:\s*hermesDirect && hermesApi\s*\?\s*!result\.is_error && hermesResponseId\s*\?\s*hermesResponseId\s*:\s*existingConversation\?\.harnessSessionId \?\? null\s*:\s*sessionId;/, - "failed Grok or Hermes resumes must retain their prior native continuation id", + /const harnessSessionId = grokDirect\s*\?\s*grokSessionId\s*:\s*openCodeDirect[\s\S]*?:\s*hermesDirect && hermesApi\s*\?\s*!result\.is_error && hermesResponseId\s*\?\s*hermesResponseId\s*:\s*existingConversation\?\.harnessSessionId \?\? null\s*:\s*sessionId;/, + "failed Grok, OpenCode, or Hermes resumes must retain their prior native continuation id", ); assert.match( chatRoute, diff --git a/src/app/api/chat/send/harness-routing-host-session.test.ts b/src/app/api/chat/send/harness-routing-host-session.test.ts index 862bea905..5926bf3a0 100644 --- a/src/app/api/chat/send/harness-routing-host-session.test.ts +++ b/src/app/api/chat/send/harness-routing-host-session.test.ts @@ -386,18 +386,18 @@ assert.match( // Native (coven) path: same stable-identity contract. assert.match( chatRoute, - /const resumeTarget = body\.startNewConversation && !existingConversation\s*\? null\s*:\s*body\.sessionId\s*\? existingConversation\?\.harnessSessionId \?\? body\.sessionId/, - "A reserved Board conversation starts fresh once, then resumes with the harness's latest session id", + /const resumeTarget = body\.startNewConversation && !existingConversation[\s\S]*?body\.sessionId[\s\S]*?openCodeDirect[\s\S]*?existingConversation\?\.harnessSessionId \?\? body\.sessionId/, + "OpenCode preserves a submitted native session token when no Cave transcript is recorded", ); assert.match( chatRoute, - /const finalSessionId = body\.sessionId \?\? sessionId/, - "Transcripts persist under the stable conversation id across resumed turns", + /const finalSessionId = body\.sessionId && !openCodeUnrecordedResume\s*\? body\.sessionId\s*:\s*sessionId/, + "An unrecorded native OpenCode resume is persisted under a new stable Cave id", ); assert.match( chatRoute, - /const announcedId = body\.sessionId \?\? sessionId/, - "The client is always told the stable conversation id, never the rotated harness id", + /const announcedId = body\.sessionId && !openCodeUnrecordedResume\s*\? body\.sessionId\s*:\s*sessionId/, + "The client is told a new stable Cave id when it resumes an unrecorded native session", ); assert.match( chatRoute, diff --git a/src/app/api/chat/send/harness-routing-opencode.test.ts b/src/app/api/chat/send/harness-routing-opencode.test.ts index c07eefd0c..2d5245239 100644 --- a/src/app/api/chat/send/harness-routing-opencode.test.ts +++ b/src/app/api/chat/send/harness-routing-opencode.test.ts @@ -12,13 +12,38 @@ assert.match( ); assert.match( route, - /const a = \["run", "--format", "json"\];[\s\S]*?a\.push\("--session", resumeSessionId\);[\s\S]*?a\.push\("--model", forwardModel\);/, - "OpenCode forwards resume session and selected model to its non-interactive JSON command", + /resolveOpenCodeCompatibility\(await openCodeRunCapabilities\(body\.familiarId\)\)/, + "OpenCode capability discovery uses the same familiar scope as its launched runtime", ); assert.match( route, - /const ev = parseOpenCodeRunEvent\(JSON\.parse\(line\)\);[\s\S]*?announceSession\(ev\.sessionId\);/, - "the first structured OpenCode event persists its minted session id", + /const a = \["run"\];[\s\S]*?openCodeCompatibility\?\.mode === "structured"[\s\S]*?const launch = openCodeCompatibility\.schema!\.launch;[\s\S]*?a\.push\([\s\S]*?launch\.structuredOutput\.option,[\s\S]*?launch\.requiredFlags[\s\S]*?launch\.sessionOption[\s\S]*?options\.includes\("--session"\)[\s\S]*?options\.includes\("--resume"\)[\s\S]*?if \(forwardModel\)/, + "OpenCode uses selected structured syntax and only a help-confirmed plain-mode resume option rather than a version threshold", +); +assert.match( + route, + /import \{ StringDecoder \} from "node:string_decoder";[\s\S]*?const openCodeStdoutDecoder = openCodeDirect \? new StringDecoder\("utf8"\) : null;[\s\S]*?openCodeStdoutDecoder \? openCodeStdoutDecoder\.write\(data\)[\s\S]*?openCodeStdoutDecoder\?\.end\(\)/, + "OpenCode stdout uses a streaming UTF-8 decoder and flushes its final bytes before parsing JSONL", +); +assert.match( + route, + /const openCodeEndOfOptionsSupported = Boolean\([\s\S]*?capabilities\.endOfOptions[\s\S]*?mode === "plain"[\s\S]*?launch\.endOfOptions === true[\s\S]*?const openCodePromptNeedsDelimiter = openCodeDirect && harnessPrompt\.startsWith\("--"\);[\s\S]*?if \(openCodePromptNeedsDelimiter && !openCodeEndOfOptionsSupported\)[\s\S]*?status: 400[\s\S]*?if \(openCodeEndOfOptionsSupported\) a\.push\("--"\);/, + "OpenCode emits the end-of-options delimiter only when both the selected schema and help probe confirm it, refusing an unsafe flag-shaped prompt otherwise", +); +assert.match( + route, + /const valueOptions = openCodeCompatibility\?\.capabilities\.valueOptions \?\? \[\];[\s\S]*?options\.includes\("--session"\) && valueOptions\.includes\("--session"\)[\s\S]*?options\.includes\("--resume"\) && valueOptions\.includes\("--resume"\)/, + "plain-mode OpenCode resumes only through an explicitly argument-taking session option", +); +assert.match( + route, + /\.\.\.\(launch\.structuredOutput\.value === undefined \? \[\] : \[launch\.structuredOutput\.value\]\)/, + "a signed valueless structured switch is forwarded without inventing a json argument", +); +assert.match( + route, + /let openCodeNativeResumeUsed = false;[\s\S]*?openCodeNativeResumeUsed = false;[\s\S]*?openCodeNativeResumeUsed = true;[\s\S]*?let openCodeSessionId: string \| null = null;[\s\S]*?onSession: \(nativeSessionId\) => \{[\s\S]*?openCodeSessionId = nativeSessionId;[\s\S]*?if \(!sessionId\) announceSession\(nativeSessionId\);[\s\S]*?openCodeSessionId \?\? \(openCodeNativeResumeUsed/, + "OpenCode preserves a native token only when this attempt actually resumed it, while event tokens remain separate from Cave's stable session id", ); assert.match( route, @@ -57,8 +82,53 @@ assert.match( ); assert.match( route, - /openCodeDirect && forwardModel[\s\S]*?modelApplicationFromRun\([\s\S]*?isError: result\.is_error === true,[\s\S]*?errorText: \[\.\.\.stderrTail, \.\.\.stdoutErrTail\]\.join\("\\n"\)/, - "OpenCode marks model-specific failed runs as rejected instead of confirming the forwarded model", + /if \(resumeFailed && body\.sessionId\) \{[\s\S]*?sessionId = null;[\s\S]*?openCodeSessionId = null;[\s\S]*?await runAttempt\(buildArgs\(null, retry\.prompt\)(?:, retry\.prompt)?\)/, + "a fresh OpenCode resume retry clears the stale native token before launching without --session", +); +assert.match( + route, + /openCodeDirect && forwardModel[\s\S]*?modelApplicationFromRun\([\s\S]*?isError: result\.is_error === true,[\s\S]*?errorText: openCodeModelRejected \? "model unavailable" : \[\.\.\.stderrTail, \.\.\.stdoutErrTail\]\.join\("\\n"\)/, + "OpenCode marks model-specific failed runs as rejected without retaining raw JSON error messages", +); +assert.match( + route, + /onError: \(ev\) => \{[\s\S]*?openCodeModelRejected \|\|= modelRejectionInError\(ev\.message\);[\s\S]*?resumeFailed \|\|= RESUME_ERR_RE\.test\(ev\.message\);[\s\S]*?recordStdoutErrorTail\("OpenCode reported an error event", true\)/, + "structured OpenCode errors retain only safe classifications while a missing native session triggers the fresh-session recovery", +); +assert.match( + route, + /import \{[\s\S]*?quarantineOpenCodeSchema,[\s\S]*?onOther: \(ev, rawEvent\) => \{[\s\S]*?quarantineOpenCodeSchema\(openCodeCompatibility\?\.schema\)/, + "an unknown OpenCode envelope quarantines its schema for future turns without replaying the current tool-capable request", +); +assert.match( + route, + /openCodeCompatibilityHealthNoticeSent[\s\S]*?openCodeProtocolQuarantineNoticeSent/, + "registry-health and parser-quarantine diagnostics are independently surfaced in the affected turn", +); +assert.match( + route, + /compatibility registry is unavailable; continuing in plain chat without tool activity/, + "an unavailable expired registry accurately reports plain fallback rather than a parser that is not active", +); +assert.doesNotMatch( + route, + /openCodeStructuredIncompatibility|structured-stream-quarantined/, + "an incompatible structured request is never replayed as an unbounded plain retry", +); +assert.doesNotMatch( + route, + /const openCodePlainFallback = openCodeDirect && openCodeCompatibility\?\.mode === "plain";[\s\S]*?if \(openCodePlainFallback && RESUME_ERR_RE\.test\(line\)\) \{[\s\S]*?resumeFailed = true;[\s\S]*?return;/, + "unframed plain OpenCode output never discards assistant text merely because it resembles a resume failure", +); +assert.match( + route, + /existingConversation\?\.harnessSessionId \?\? body\.sessionId[\s\S]*?openCodeUnrecordedResume[\s\S]*?announceSession\(crypto\.randomUUID\(\)\)[\s\S]*?not recorded locally and this client cannot resume it; starting a fresh chat/, + "an unrecorded OpenCode resume token is attempted when supported or visibly restarted when it is not", +); +assert.match( + route, + /import \{ handleOpenCodeJsonLine \} from "@\/lib\/opencode-stream";[\s\S]*?handleOpenCodeJsonLine\(line, openCodeCompatibility\?\.schema,/, + "the route uses the behavioral JSONL handler, whose lifecycle-frame behavior is covered by its focused test", ); assert.match( route, @@ -67,8 +137,88 @@ assert.match( ); assert.match( route, - /ev\.kind === "error"[\s\S]*?recordStdoutErrorTail\(ev\.message, true\)/, - "structured OpenCode errors retain model-rejection details even when they lack generic error keywords", + /onError: \(ev\) => \{[\s\S]*?openCodeModelRejected \|\|= modelRejectionInError\(ev\.message\);[\s\S]*?recordStdoutErrorTail\("OpenCode reported an error event", true\)/, + "structured OpenCode errors retain model-rejection state without retaining provider-controlled details", +); +assert.match( + route, + /const tailBlock = !openCodeDirect && tailSource\.length/, + "OpenCode stderr never becomes assistant-visible or persisted empty-response diagnostics", +); +assert.match( + route, + /openCodeCompatibility\?\.mode === "plain"[\s\S]*?assistant_chunk/, + "clients without structured output fall back to plain assistant text instead of dropping a reply", +); +assert.match( + route, + /const harnessSessionId = grokDirect[\s\S]*?: openCodeDirect[\s\S]*?openCodeSessionId \?\? \(openCodeNativeResumeUsed[\s\S]*?existingConversation\?\.harnessSessionId[\s\S]*?: undefined\)/, + "a plain OpenCode turn retains a native id only when that token was actually used for the current launch", +); +assert.match( + route, + /else if \(openCodeDirect && existingConversation && !openCodeNativeResumeUsed\) \{[\s\S]*?delete conv\.harnessSessionId/, + "a fresh OpenCode compatibility fallback clears the obsolete native token from the persisted conversation", +); +assert.match( + route, + /const openCodeNativeResumeSupported = openCodeCompatibility\?\.mode === "structured"[\s\S]*?schema\?\.launch\.sessionOption[\s\S]*?const openCodeFreshSessionForCompatibility = Boolean\([\s\S]*?!openCodeNativeResumeSupported[\s\S]*?buildResumeRetryPrompt\(harnessPrompt, existingConversation\)/, + "OpenCode replays Cave context when the selected schema cannot launch a native resume", +); +assert.match( + route, + /onToolStart: \(ev\) => \{[\s\S]*?envelopeToolUse[\s\S]*?onToolEnd: \(ev\) => \{[\s\S]*?envelopeToolResult/, + "split tool lifecycle frames preserve the stable bubble id across progress and result", +); +assert.match( + route, + /onTool: \(ev\) => \{[\s\S]*?envelopeToolUse[\s\S]*?consumePendingEnvelopeResult\(ev\.id\)[\s\S]*?return;[\s\S]*?envelopeToolResult/, + "a reordered split result settles a combined terminal tool frame with the first terminal outcome", +); +assert.match( + route, + /opencode-compatibility[\s\S]*?unrecognized event[\s\S]*?redactedOpenCodeEventFingerprint\(rawEvent\)/, + "unknown future event shapes surface a safe visible diagnostic", +); +assert.match( + route, + /persistedOpenCodeDiagnostics[\s\S]*?id === "opencode-compatibility"[\s\S]*?progress: persistedOpenCodeDiagnostics/, + "safe OpenCode compatibility diagnostics persist with the completed assistant turn", +); +assert.match( + route, + /const quarantineOpenCodeProtocol[\s\S]*?recordStdoutErrorTail\("OpenCode emitted a malformed JSON event", true\)[\s\S]*?quarantineOpenCodeSchema\(openCodeCompatibility\?\.schema\)[\s\S]*?const handleOpenCodeLine[\s\S]*?onMalformedJson: \(\) => \{[\s\S]*?quarantineOpenCodeProtocol\(/, + "malformed structured OpenCode events quarantine future structured launches without copying raw payloads into text or diagnostics", +); +assert.match( + route, + /let openCodeStructuredProtocolQuarantined = false;[\s\S]*?openCodeStructuredProtocolQuarantined = true/, + "a malformed or unknown frame latches the active structured stream into quarantine", +); +assert.match( + route, + /if \(openCodeStructuredProtocolQuarantined\) \{[\s\S]*?never allow later frames to create tools, results, or sessions[\s\S]*?handleOpenCodeJsonLine\(line, openCodeCompatibility\?\.schema, \{[\s\S]*?onText: \(ev\)/, + "the current stream disables structured tool/session callbacks after quarantine while retaining only schema-validated text", +); +assert.match( + route, + /const MAX_OPENCODE_JSONL_FRAME_BYTES = 256 \* 1024;[\s\S]*?let discardingOpenCodeFrame = false;[\s\S]*?Buffer\.byteLength\(jsonBuf, "utf8"\) > MAX_OPENCODE_JSONL_FRAME_BYTES[\s\S]*?discardingOpenCodeFrame = true;[\s\S]*?oversized-jsonl-event/, + "unterminated OpenCode JSONL frames are bounded, discarded through their newline, and quarantined without retaining provider payloads", +); +assert.match( + route, + /openCodeCompatibility\?\.mode === "plain"[\s\S]*?Buffer\.byteLength\(jsonBuf, "utf8"\) > MAX_OPENCODE_JSONL_FRAME_BYTES[\s\S]*?const plainChunk = jsonBuf;[\s\S]*?jsonBuf = "";[\s\S]*?handleLine\(plainChunk\)/, + "plain OpenCode compatibility mode flushes oversized partial stdout instead of buffering it indefinitely", +); +assert.match( + route, + /const handleOpenCodeLine = \(line: string\) => \{[\s\S]*?const plainText = resolveBackspaces\(stripAnsi\(line\)\);[\s\S]*?if \(openCodeCompatibility\?\.mode === "plain"\)[\s\S]*?return;[\s\S]*?permission requested[\s\S]*?plainText\.trim\(\)/, + "plain OpenCode fallback preserves unframed assistant text while structured JSON filters the known control notice", +); +assert.doesNotMatch( + capabilities, + /openCodeCapabilitiesProbe/, + "OpenCode must not retain capability evidence that could be stale after an in-place same-version CLI upgrade; chat-send-capabilities tests this behavior with two probes", ); console.log("opencode harness routing tests passed"); diff --git a/src/app/api/chat/send/route-opencode.integration.test.ts b/src/app/api/chat/send/route-opencode.integration.test.ts new file mode 100644 index 000000000..13451c402 --- /dev/null +++ b/src/app/api/chat/send/route-opencode.integration.test.ts @@ -0,0 +1,156 @@ +// @ts-nocheck +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import path from "node:path"; + +// This test runs the actual route against a temporary OpenCode command shim. +// It deliberately covers the boundary the source-shape test cannot: capability +// probing, selected argv, JSONL dispatch, SSE output, and persisted resume id. +// The route correctly rejects project roots outside the local home directory. +// Keep this fixture below that root on Linux as well as Windows so it reaches +// the OpenCode launch path instead of the project-scope guard. +const home = await mkdtemp(path.join(homedir(), "cave-opencode-route-")); +const bin = path.join(home, "bin"); +const familiarWorkspace = path.join(home, "familiars", "opal"); +await mkdir(bin, { recursive: true }); +await mkdir(familiarWorkspace, { recursive: true }); + +const previousHome = process.env.COVEN_HOME; +const previousCaveHome = process.env.COVEN_CAVE_HOME; +const previousPath = process.env.PATH; +const previousOpenCodeTestMode = process.env.OPENCODE_TEST_MODE; +process.env.COVEN_HOME = home; +process.env.COVEN_CAVE_HOME = path.join(home, "cave"); +process.env.PATH = `${bin}${path.delimiter}${previousPath ?? ""}`; + +const executable = process.platform === "win32" ? "opencode.cmd" : "opencode"; +const expectedReply = process.platform === "win32" ? "route reply" : "split 😀"; +const launcher = process.platform === "win32" + ? [ + "@echo off", + "if \"%~1\"==\"--version\" if \"%OPENCODE_TEST_MODE%\"==\"plain\" (echo 1.2.4& exit /b 0)", + "if \"%~1\"==\"--version\" (echo 1.2.3& exit /b 0)", + "if \"%~1\"==\"run\" if \"%~2\"==\"--help\" (", + " if \"%OPENCODE_TEST_MODE%\"==\"plain\" (echo --format ^ Output format: text, json-v2& exit /b 0)", + " echo --format ^ Output format: text, json", + " echo --session ^ Session to continue", + " exit /b 0", + ")", + "if not \"%~1\"==\"run\" exit /b 9", + "if \"%OPENCODE_TEST_MODE%\"==\"plain\" (echo permission requested by a fictional assistant; auto-rejecting is only a phrase& echo const value = 1;& echo.& echo return value;& echo Session not found in the documentation.& echo ```coven:attachment& echo {\"path\":\"/not-an-attachment\"}& echo ```& exit /b 0)", + "if not \"%~2\"==\"--format\" exit /b 9", + "if not \"%~3\"==\"json\" exit /b 9", + "if \"%~4\"==\"--\" exit /b 9", + "echo permission requested ... auto-rejecting", + "echo {\"type\":\"text\",\"sessionID\":\"native_opencode_session\",\"part\":{\"type\":\"text\",\"text\":\"route reply\"}}", + "exit /b 0", + ].join("\r\n") + : [ + "#!/bin/sh", + "if [ \"$1\" = \"--version\" ]; then if [ \"$OPENCODE_TEST_MODE\" = \"plain\" ]; then echo 1.2.4; else echo 1.2.3; fi; exit 0; fi", + "if [ \"$1\" = \"run\" ] && [ \"$2\" = \"--help\" ]; then", + " if [ \"$OPENCODE_TEST_MODE\" = \"plain\" ]; then printf '%s\\n' ' --format Output format: text, json-v2'; else printf '%s\\n' ' --format Output format: text, json' ' --session Session to continue'; fi", + " exit 0", + "fi", + "if [ \"$1\" != \"run\" ]; then exit 9; fi", + "if [ \"$OPENCODE_TEST_MODE\" = \"plain\" ]; then printf 'permission requested by a fictional assistant; auto-rejecting is only a phrase\\n const value = 1;\\n\\n return value;\\nSession not found in the documentation.\\n```coven:attachment\\n{\"path\":\"/not-an-attachment\"}\\n```\\n'; exit 0; fi", + "if [ \"$2\" != \"--format\" ] || [ \"$3\" != \"json\" ] || [ \"$4\" = \"--\" ]; then exit 9; fi", + "printf '%s\\n' 'permission requested ... auto-rejecting'", + "printf '%s' '{\"type\":\"text\",\"sessionID\":\"native_opencode_session\",\"part\":{\"type\":\"text\",\"text\":\"split '", + "sleep 0.05", + "printf '\\360\\237'", + "sleep 0.05", + "printf '\\230\\200\"}}\\n'", + ].join("\n"); +await writeFile(path.join(bin, executable), launcher, { mode: 0o755 }); + +try { + // Other route modules can initialize Cave's augmented PATH before this + // fixture installs its shim. Reset that process-local cache so the probe + // and spawned turn resolve the same temporary OpenCode executable. + const { refreshCovenBin } = await import("@/lib/coven-bin"); + refreshCovenBin(); + const { saveConfig } = await import("@/lib/cave-config"); + const { loadConversation } = await import("@/lib/cave-conversations"); + const { createProject } = await import("@/lib/cave-projects"); + const { grantProjectToFamiliar } = await import("@/lib/project-permissions"); + const { POST } = await import("./route.ts"); + await saveConfig({ familiars: { opal: { harness: "opencode" } } }); + const project = await createProject({ name: "Route fixture", root: familiarWorkspace }); + await grantProjectToFamiliar({ familiarId: "opal", projectId: project.id, source: "human", access: "write" }); + + const response = await POST(new Request("http://localhost/api/chat/send", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ familiarId: "opal", prompt: "--format text", projectRoot: familiarWorkspace }), + })); + assert.equal(response.status, 200, await response.clone().text()); + const body = await response.text(); + assert.doesNotMatch(body, /empty response/i, "a legacy OpenCode help surface keeps the compatible positional prompt launch without an unprobed delimiter"); + assert.doesNotMatch(body, /opencode-compatibility/i, "a current OpenCode permission control notice does not quarantine the selected JSON schema"); + assert.match(body, new RegExp(`"kind":"assistant_chunk","text":"${expectedReply}\\\\n"`), "the route preserves selected OpenCode JSON text when UTF-8 spans stdout chunks"); + const done = body + .split("\n") + .filter((line) => line.startsWith("data: ")) + .map((line) => JSON.parse(line.slice("data: ".length))) + .findLast((event) => event.kind === "done"); + assert.ok(done, "the route completes the SSE stream"); + const sessionId = done.sessionId; + assert.equal(typeof sessionId, "string"); + assert.notEqual(sessionId, "native_opencode_session", "Cave keeps its stable conversation id separate from OpenCode's native resume id"); + const conversation = await loadConversation(sessionId); + assert.equal(conversation?.harnessSessionId, "native_opencode_session", "the route persists the native OpenCode session id separately from Cave's stable id"); + + // A future JSON format with no signed parser must fall back to plain chat + // without turning source-code indentation or blank lines into data loss. + process.env.OPENCODE_TEST_MODE = "plain"; + const plainResponse = await POST(new Request("http://localhost/api/chat/send", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ familiarId: "opal", prompt: "plain fallback", projectRoot: familiarWorkspace }), + })); + assert.equal(plainResponse.status, 200, await plainResponse.clone().text()); + const plainBody = await plainResponse.text(); + assert.match( + plainBody, + /"kind":"assistant_chunk","text":"permission requested by a fictional assistant; auto-rejecting is only a phrase\\n"[\s\S]*?"kind":"assistant_chunk","text":" const value = 1;\\n"[\s\S]*?"kind":"assistant_chunk","text":"\\n"[\s\S]*?"kind":"assistant_chunk","text":" return value;\\n"/, + "plain OpenCode fallback preserves all assistant text, including lines that resemble the unframed control notice", + ); + const plainDone = plainBody + .split("\n") + .filter((line) => line.startsWith("data: ")) + .map((line) => JSON.parse(line.slice("data: ".length))) + .findLast((event) => event.kind === "done"); + const plainConversation = await loadConversation(plainDone.sessionId); + assert.equal( + plainConversation?.turns.at(-1)?.text, + "permission requested by a fictional assistant; auto-rejecting is only a phrase\n const value = 1;\n\n return value;\nSession not found in the documentation.\n```coven:attachment\n{\"path\":\"/not-an-attachment\"}\n```\n", + "reload preserves plain-fallback indentation and trailing newline instead of trimming the persisted assistant turn", + ); + + // A resumed plain-fallback request still has no protocol boundary. Text that + // happens to quote a resume error remains assistant content; it must neither + // disappear nor trigger a retry that replaces the reply with a generic error. + const quotedResumeResponse = await POST(new Request("http://localhost/api/chat/send", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ familiarId: "opal", prompt: "quote a resume error", sessionId: plainDone.sessionId, projectRoot: familiarWorkspace }), + })); + assert.equal(quotedResumeResponse.status, 200, await quotedResumeResponse.clone().text()); + const quotedResumeBody = await quotedResumeResponse.text(); + assert.match(quotedResumeBody, /Session not found in the documentation\./, "plain fallback preserves assistant text that resembles a resume failure"); + assert.doesNotMatch(quotedResumeBody, /No assistant text returned/, "quoted resume-failure text does not become a synthetic empty-response error"); +} finally { + if (previousHome === undefined) delete process.env.COVEN_HOME; + else process.env.COVEN_HOME = previousHome; + if (previousCaveHome === undefined) delete process.env.COVEN_CAVE_HOME; + else process.env.COVEN_CAVE_HOME = previousCaveHome; + if (previousPath === undefined) delete process.env.PATH; + else process.env.PATH = previousPath; + if (previousOpenCodeTestMode === undefined) delete process.env.OPENCODE_TEST_MODE; + else process.env.OPENCODE_TEST_MODE = previousOpenCodeTestMode; + await rm(home, { recursive: true, force: true }); +} + +console.log("route-opencode.integration.test.ts: ok"); diff --git a/src/app/api/chat/send/route.ts b/src/app/api/chat/send/route.ts index a8ef03d1c..e5dfa12fc 100644 --- a/src/app/api/chat/send/route.ts +++ b/src/app/api/chat/send/route.ts @@ -1,5 +1,6 @@ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; import { homedir } from "node:os"; +import { StringDecoder } from "node:string_decoder"; import { resolveBackspaces, stripAnsi } from "@/lib/ansi"; import { bindingFor, @@ -63,6 +64,12 @@ import { } from "@/lib/grok-build"; import { grokLaunchCommand } from "@/lib/grok-bin"; import { openCodeLaunch, openCodeSpawnEnv, writeOpenCodeLaunchInput } from "@/lib/opencode-bin"; +import { + quarantineOpenCodeSchema, + redactedOpenCodeEventFingerprint, + resolveOpenCodeCompatibility, +} from "@/lib/opencode-compatibility"; +import { handleOpenCodeJsonLine } from "@/lib/opencode-stream"; import { parseOpenCodeRunEvent } from "@/lib/opencode-stream"; import { HermesSseDecoder, @@ -122,6 +129,7 @@ import { cleanModelId, modelApplicationForHarness, modelApplicationFromRun, + modelRejectionInError, resolveChatModelState, type ChatModelState, } from "@/lib/chat-model-state"; @@ -176,7 +184,7 @@ import { covenRunSupportsModel, hermesChatSupportsModel, covenRunSupportsPermission, - openCodeRunSupportsModel, + openCodeRunCapabilities, } from "./chat-send-capabilities"; import { buildPromptWithResponseControls, @@ -941,6 +949,21 @@ export async function POST(req: Request) { // harness uses coven run's capability probe. const hermesDirect = !sshRuntime && binding.harness === "hermes"; const openCodeDirect = !sshRuntime && binding.harness === "opencode"; +// Cave's Read-only control is a security promise, not a prompt hint. + // OpenCode's one-shot CLI exposes no read-only/sandbox flag, so do not even + // run its capability probes with the familiar-scoped credentials here. + if (openCodeDirect && body.permissionMode === "read") { + return new Response( + JSON.stringify({ + ok: false, + error: "OpenCode does not support Cave's Read-only mode yet. Switch Access to Full access to run it.", + }), + { status: 501, headers: { "content-type": "application/json" } }, + ); + } + const openCodeCompatibility = openCodeDirect + ? await resolveOpenCodeCompatibility(await openCodeRunCapabilities(body.familiarId)) + : null; // Tool activity from Hermes is only reliable over its documented structured // API. The quiet CLI mode intentionally hides terminal tool previews, so it // remains an explicit plain-text fallback when no API server is configured. @@ -955,7 +978,7 @@ export async function POST(req: Request) { hermesDirect ? hermesApi !== null || await hermesChatSupportsModel() : openCodeDirect - ? await openCodeRunSupportsModel() + ? openCodeCompatibility?.capabilities.model ?? false : binding.harness === "grok" || (binding.harness !== "openclaw" && (await covenRunSupportsModel())); // Grok and OpenCode are direct integrations, so neither may wait on coven @@ -1015,20 +1038,7 @@ export async function POST(req: Request) { ); } await ensureAdapterManifestScaffold(binding.harness); - // Cave's Read-only control is a security promise, not a prompt hint. - // OpenCode's one-shot CLI exposes no read-only/sandbox flag, so spawning it - // directly would let its configured permissions write to the workspace. - // Refuse this combination until OpenCode offers an enforceable equivalent. - if (openCodeDirect && body.permissionMode === "read") { - return new Response( - JSON.stringify({ - ok: false, - error: "OpenCode does not support Cave's Read-only mode yet. Switch Access to Full access to run it.", - }), - { status: 501, headers: { "content-type": "application/json" } }, - ); - } - // The Responses API does not expose a documented, enforceable equivalent of +// The Responses API does not expose a documented, enforceable equivalent of // Cave's read-only sandbox. Do not downgrade that security promise to a // prompt merely because a familiar opted into the structured transport. if (hermesDirect && hermesApi && body.permissionMode === "read") { @@ -1247,6 +1257,20 @@ export async function POST(req: Request) { // back to the boundary block ("listed above") and only exists when the // conversation's previous turn strayed out of the granted roots. const harnessPrompt = buildPromptWithBoundaryReminder(scopedPrompt, body.sessionId); + // A selected schema may opt into the delimiter, but the installed client + // must also document it before Cave sends an otherwise unsupported flag. + const openCodeEndOfOptionsSupported = Boolean( + openCodeDirect + && openCodeCompatibility?.capabilities.endOfOptions + && (openCodeCompatibility?.mode === "plain" || openCodeCompatibility?.schema?.launch.endOfOptions === true), + ); + const openCodePromptNeedsDelimiter = openCodeDirect && harnessPrompt.startsWith("--"); + if (openCodePromptNeedsDelimiter && !openCodeEndOfOptionsSupported) { + return new Response( + JSON.stringify({ ok: false, error: "This OpenCode client does not document support for prompts that begin with '--'. Start the prompt with text or update OpenCode." }), + { status: 400, headers: { "content-type": "application/json" } }, + ); + } if (binding.harness === "openclaw" && !sshRuntime) { return openClawChatResponse({ @@ -1318,6 +1342,9 @@ export async function POST(req: Request) { // UUID for new native sessions so a stopped first turn can still be saved // and resumed instead of disappearing with the unreceived end frame. let grokSessionHint: string | null = null; + // Set only when the current OpenCode attempt forwards a native token. + // Fresh compatibility fallback must not retain a prior session by accident. + let openCodeNativeResumeUsed = false; // `promptOverride` lets the transparent resume-retry (below) prime a fresh // harness session with replayed conversation history — without it the retry // forks a context-free session and the familiar loses the thread. @@ -1387,11 +1414,44 @@ export async function POST(req: Request) { }); } if (openCodeDirect) { - // OpenCode owns its durable session store. JSON mode is its documented - // non-interactive event protocol and includes the minted session id. - const a = ["run", "--format", "json"]; - if (resumeSessionId) a.push("--session", resumeSessionId); + // Reset for each attempt: a later fresh-session retry must not preserve + // the native token from an earlier failed compatibility launch. + openCodeNativeResumeUsed = false; + // The selected schema is capability based, never a version threshold. + // If JSON is unavailable, preserve plain chat instead of launching with + // an unsupported flag and losing the whole reply. + const a = ["run"]; + if (openCodeCompatibility?.mode === "structured") { + const launch = openCodeCompatibility.schema!.launch; + a.push( + launch.structuredOutput.option, + ...(launch.structuredOutput.value === undefined ? [] : [launch.structuredOutput.value]), + ...launch.requiredFlags, + ); + if (resumeSessionId && launch.sessionOption) { + a.push(launch.sessionOption, resumeSessionId); + openCodeNativeResumeUsed = true; + } + } else if (resumeSessionId) { + // Plain output can still resume a native session. Forward only the + // exact argument-taking option confirmed by `run --help`; never guess + // `--session` or feed an ID to a valueless "resume latest" switch. + const options = openCodeCompatibility?.capabilities.options ?? []; + const valueOptions = openCodeCompatibility?.capabilities.valueOptions ?? []; + const sessionOption = options.includes("--session") && valueOptions.includes("--session") + ? "--session" + : options.includes("--resume") && valueOptions.includes("--resume") + ? "--resume" + : null; + if (sessionOption) { + a.push(sessionOption, resumeSessionId); + openCodeNativeResumeUsed = true; + } + } if (forwardModel) a.push("--model", forwardModel); + // Prompts are untrusted data. Insert the delimiter only after both the + // selected launch schema and `run --help` confirmed it is supported. + if (openCodeEndOfOptionsSupported) a.push("--"); a.push(prompt); return a; } @@ -1418,7 +1478,12 @@ export async function POST(req: Request) { const resumeTarget = body.startNewConversation && !existingConversation ? null : body.sessionId - ? existingConversation?.harnessSessionId ?? body.sessionId + ? openCodeDirect + // If the Cave transcript was removed, the submitted token may still be + // a valid native OpenCode session. Preserve it for a help-confirmed + // resume rather than silently creating a context-free chat. + ? existingConversation?.harnessSessionId ?? body.sessionId + : existingConversation?.harnessSessionId ?? body.sessionId : null; // Grok deliberately refuses to change a resumed session's sandbox. Persist // the profile used for the previous native session and transparently start a @@ -1433,9 +1498,35 @@ export async function POST(req: Request) { const grokSandboxRetry = grokFreshSessionForSandbox ? buildResumeRetryPrompt(harnessPrompt, existingConversation) : null; + const openCodeNativeResumeSupported = openCodeCompatibility?.mode === "structured" + ? Boolean(openCodeCompatibility.schema?.launch.sessionOption) + : Boolean( + openCodeCompatibility?.capabilities.valueOptions?.includes("--session") + || openCodeCompatibility?.capabilities.valueOptions?.includes("--resume"), + ); + const openCodeUnrecordedResume = Boolean( + openCodeDirect && body.sessionId && !existingConversation && !openCodeNativeResumeSupported, + ); + // A client which no longer advertises a native resume option, or a prior + // turn which never received a native OpenCode id, must start fresh with + // bounded Cave transcript replay. Plain output can still safely resume when + // its exact option was confirmed by the capability probe. + const openCodeFreshSessionForCompatibility = Boolean( + openCodeDirect && body.sessionId && ( + openCodeUnrecordedResume + || Boolean(existingConversation && ( + !openCodeNativeResumeSupported + || !existingConversation.harnessSessionId + )) + ), + ); + const openCodeSessionUnavailable = !openCodeNativeResumeSupported; + const openCodeCompatibilityRetry = openCodeFreshSessionForCompatibility + ? buildResumeRetryPrompt(harnessPrompt, existingConversation) + : null; const args = buildArgs( - grokFreshSessionForSandbox ? null : resumeTarget, - grokSandboxRetry?.prompt, + grokFreshSessionForSandbox || openCodeFreshSessionForCompatibility ? null : resumeTarget, + grokSandboxRetry?.prompt ?? openCodeCompatibilityRetry?.prompt, ); // Resume failures from common harnesses. Codex emits @@ -1456,6 +1547,10 @@ export async function POST(req: Request) { const RESUME_ERR_RE = /thread\/resume failed|no rollout found|code\s*-32600|Session ID \S+ is already in use|No conversation found with session ID|session\s+\S+\s+not found in local store|No session, task, or name matched|Session not found\b/i; + // A first structured OpenCode frame can establish Cave's stable chat id + // before the child-run registry is installed. Keep this binding available + // to `announceSession` without putting it in the temporal dead zone. + let runHandle!: ChatRunHandle; const stream = new ReadableStream({ start: async (controller) => { let closed = false; @@ -1474,13 +1569,27 @@ export async function POST(req: Request) { } }; let runBuffer: RunBufferHandle | null = null; + // Compatibility notices are deliberately value-free and must survive + // transcript reloads; the live SSE buffer alone expires after two + // minutes. Keep only this narrowly-scoped subset of progress rows. + const persistedOpenCodeDiagnostics: NonNullable = []; const pushProgress = ( id: string, label: string, status: "running" | "done" | "error", detail?: string, durationMs?: number, - ) => + ) => { + if (id === "opencode-compatibility") { + persistedOpenCodeDiagnostics.push({ + id, + label, + status, + createdAt: new Date().toISOString(), + ...(detail ? { detail } : {}), + ...(durationMs != null ? { durationMs } : {}), + }); + } push({ kind: "progress", id, @@ -1489,6 +1598,7 @@ export async function POST(req: Request) { ...(detail ? { detail } : {}), ...(durationMs != null ? { durationMs } : {}), }); + }; const heartbeat = startChatSseHeartbeat(controller, () => closed || req.signal.aborted); const close = () => { if (closed) return; @@ -1528,6 +1638,10 @@ export async function POST(req: Request) { // Keep it separately so the next Grok turn resumes the actual CLI // session rather than Cave's conversation id. let grokSessionId: string | null = null; + // OpenCode's native session is distinct from Cave's stable conversation + // ID on resumed turns. Always retain the latest event-provided native ID + // for the next CLI resume, while only announcing the stable Cave ID. + let openCodeSessionId: string | null = null; // Responses API ids rotate per turn like other harness session ids, but // Cave's `sessionId` remains the stable conversation identity. Preserve // the latest response id separately so follow-ups send @@ -1562,6 +1676,10 @@ export async function POST(req: Request) { let assistantFilter = new AssistantFilter({ passthrough: rawStdoutHarness }); let assistantText = ""; let jsonBuf = ""; + // A protocol frame is control-plane data, not assistant text. Bound an + // unterminated/malformed OpenCode JSONL frame so a client regression or + // a tool dumping one huge value cannot retain unbounded route memory. + const MAX_OPENCODE_JSONL_FRAME_BYTES = 256 * 1024; let result: { duration_ms?: number; is_error?: boolean; @@ -1602,6 +1720,23 @@ export async function POST(req: Request) { // `coven run` at startup, so the turn ends with no assistant text. // Detected from stderr; healed (manifest quarantined) and retried below. let adapterConflict: BuiltinAdapterConflict | null = null; + let openCodeCompatibilityHealthNoticeSent = false; + let openCodeProtocolQuarantineNoticeSent = false; + let openCodeStructuredProtocolQuarantined = false; + let openCodeModelRejected = false; + const quarantineOpenCodeProtocol = ( + label: string, + detail: "malformed-json-event" | "oversized-jsonl-event", + ) => { + // Structured-mode stdout can contain arbitrary tool payloads. Keep the + // error tail and persisted diagnostic value-free. + recordStdoutErrorTail("OpenCode emitted a malformed JSON event", true); + openCodeStructuredProtocolQuarantined = true; + quarantineOpenCodeSchema(openCodeCompatibility?.schema); + if (openCodeProtocolQuarantineNoticeSent) return; + openCodeProtocolQuarantineNoticeSent = true; + pushProgress("opencode-compatibility", label, "error", detail); + }; // Model parity: the harness echoes its resolved model on the init/system // stream event. Capturing it lets the application state render honestly as @@ -1619,13 +1754,17 @@ export async function POST(req: Request) { // turns the harness mints a fresh internal id, which must not // leak out as a "new session" (it fragmented every continued // chat into one sidebar entry per turn). - const announcedId = body.sessionId ?? sessionId; + // An unrecorded resume token is not a Cave conversation identity. Its + // unsupported fallback gets a new stable Cave id before spawning. + const announcedId = body.sessionId && !openCodeUnrecordedResume + ? body.sessionId + : sessionId; // A new chat registered with only the client runId (body.sessionId is // null until the harness mints the id) — late-key the run so // /api/chat/stop and the sessions-list liveness probe reach it by // conversation id. runHandle is declared later in this scope but is // always initialized before the stream handlers that call announce. - addChatRunKeys(runHandle, [announcedId]); + if (runHandle) addChatRunKeys(runHandle, [announcedId]); // First-turn visibility (cave-0g2x): persist a stub conversation with // the pending user turn as soon as the id exists, so the sessions // list can surface this chat during its entire first turn (and after @@ -1657,6 +1796,19 @@ export async function POST(req: Request) { ).catch(() => undefined); }; + // Formatted OpenCode output carries no native session id. Cave still + // needs a stable conversation identity so a plain-mode first response + // is persisted and visible after reload; native resume remains disabled + // below and falls back to recent-context replay. + if (openCodeUnrecordedResume) { + announceSession(crypto.randomUUID()); + } else if (openCodeDirect && !sessionId) { + // Cave owns the conversation identity in both structured and plain + // modes. Structured frames may announce a different native OpenCode + // token; retain that separately rather than exposing it as the chat id. + announceSession(crypto.randomUUID()); + } + // Hermes's `-Q` mode reserves stdout for the reply and writes the // resumable id to stderr as `session_id: `. Buffer stderr because // Node can split that short line across data events. @@ -1807,22 +1959,56 @@ export async function POST(req: Request) { return; } } catch { - recordStdoutErrorTail(resolveBackspaces(stripAnsi(line))); + /* not valid JSON after all — fall through to the error tail */ } + recordStdoutErrorTail(resolveBackspaces(stripAnsi(line))); }; const handleOpenCodeLine = (line: string) => { - try { - const ev = parseOpenCodeRunEvent(JSON.parse(line)); - if (!ev) return; - if (ev.sessionId && !sessionId) announceSession(ev.sessionId); - if (ev.kind === "text") { + // Current OpenCode can write a human-oriented permission control + // notice to stdout. Only structured mode has framing sufficient to + // recognize it without risking a valid assistant-text loss. + const plainText = resolveBackspaces(stripAnsi(line)); + if (openCodeCompatibility?.mode === "plain") { + // Plain stdout has no framing that can distinguish OpenCode's control + // notice from an assistant reply that happens to contain the same + // words. Preserve every line rather than silently dropping valid text. + const text = `${plainText}\n`; + assistantText += text; + push({ kind: "assistant_chunk", text }); + return; + } + // Structured JSON supplies a protocol boundary, so this known CLI + // control line cannot be mistaken for assistant content. + if (/^permission requested\b[\s\S]*\bauto-rejecting\b/i.test(plainText.trim())) return; + // Current OpenCode writes this human-oriented permission control + // notice to stdout even in `--format json` mode before it rejects the + // request. It is neither an event nor assistant output; parsing it as + // JSON would incorrectly quarantine an otherwise compatible stream. + if (openCodeStructuredProtocolQuarantined) { + // The schema has already proven incompatible in this stream. Keep + // only text that still satisfies its explicit envelope/kind contract; + // never allow later frames to create tools, results, or sessions. + handleOpenCodeJsonLine(line, openCodeCompatibility?.schema, { + onText: (ev) => { + const text = ev.text.endsWith("\n") ? ev.text : `${ev.text}\n`; + assistantText += text; + push({ kind: "assistant_chunk", text }); + }, + }); + return; + } + handleOpenCodeJsonLine(line, openCodeCompatibility?.schema, { + onSession: (nativeSessionId) => { + openCodeSessionId = nativeSessionId; + if (!sessionId) announceSession(nativeSessionId); + }, + onText: (ev) => { const text = ev.text.endsWith("\n") ? ev.text : `${ev.text}\n`; assistantText += text; push({ kind: "assistant_chunk", text }); - return; - } - if (ev.kind === "tool") { + }, + onTool: (ev) => { boundarySentinel?.observe(ev.name, ev.input); const started = toolTracker.envelopeToolUse( ev.id, @@ -1831,24 +2017,84 @@ export async function POST(req: Request) { assistantText.length, ); if (started) push({ kind: "tool_use", ...started }); + // A split terminal result can arrive before this combined + // terminal snapshot. Preserve the first terminal outcome. + const reorderedEnd = toolTracker.consumePendingEnvelopeResult(ev.id); + if (reorderedEnd) { + push({ kind: "tool_use", ...reorderedEnd }); + return; + } const ended = toolTracker.envelopeToolResult( ev.id, typeof ev.output === "string" ? ev.output : formatToolInputValue(ev.output), ev.isError, ); if (ended) push({ kind: "tool_use", ...ended }); - return; - } - if (ev.kind === "error") { - // This is an explicit error envelope, so preserve even messages - // such as "Selected model is unavailable" that do not match the - // generic stderr-like keyword filter. - recordStdoutErrorTail(ev.message, true); + }, + onToolStart: (ev) => { + boundarySentinel?.observe(ev.name, ev.input); + const started = toolTracker.envelopeToolUse( + ev.id, + ev.name, + formatToolInputValue(ev.input), + assistantText.length, + ); + if (started) push({ kind: "tool_use", ...started }); + const reorderedProgress = toolTracker.consumePendingEnvelopeProgress(ev.id); + if (reorderedProgress) push({ kind: "tool_use", ...reorderedProgress }); + const reorderedEnd = toolTracker.consumePendingEnvelopeResult(ev.id); + if (reorderedEnd) push({ kind: "tool_use", ...reorderedEnd }); + }, + onToolProgress: (ev) => { + const progress = toolTracker.envelopeToolProgress( + ev.id, + typeof ev.output === "string" ? ev.output : formatToolInputValue(ev.output), + ); + if (progress) push({ kind: "tool_use", ...progress }); + }, + onToolEnd: (ev) => { + const ended = toolTracker.envelopeToolResult( + ev.id, + typeof ev.output === "string" ? ev.output : formatToolInputValue(ev.output), + ev.isError, + ); + if (ended) push({ kind: "tool_use", ...ended }); + }, + onError: (ev) => { + // This is an explicit error envelope, so retain its error state + // even if its message does not match the generic stderr filter. + // Error envelopes are provider-controlled and can repeat prompts, + // paths, command output, or credentials. Retain only the model + // rejection classification needed for response metadata; never + // copy their message into a persisted/user-visible diagnostic. + openCodeModelRejected ||= modelRejectionInError(ev.message); + resumeFailed ||= RESUME_ERR_RE.test(ev.message); + recordStdoutErrorTail("OpenCode reported an error event", true); result = { ...result, is_error: true }; - } - } catch { - recordStdoutErrorTail(resolveBackspaces(stripAnsi(line))); - } + }, + onOther: (ev, rawEvent) => { + // Do not treat arbitrary `text`/`content` on an unknown envelope + // as assistant output: tool progress and provider errors often + // carry those fields and may contain secrets or file contents. + openCodeStructuredProtocolQuarantined = true; + quarantineOpenCodeSchema(openCodeCompatibility?.schema); + if (!openCodeProtocolQuarantineNoticeSent) { + openCodeProtocolQuarantineNoticeSent = true; + pushProgress( + "opencode-compatibility", + "OpenCode sent an unrecognized event; future turns will use safe plain chat until a compatible schema is available", + "error", + `${ev.diagnostic ?? "unknown-event"}:${redactedOpenCodeEventFingerprint(rawEvent)}`, + ); + } + }, + onMalformedJson: () => { + quarantineOpenCodeProtocol( + "OpenCode sent a malformed event; future turns will use safe plain chat until a compatible schema is available", + "malformed-json-event", + ); + }, + }); }; const handleLine = (rawLine: string) => { @@ -1856,8 +2102,30 @@ export async function POST(req: Request) { // and a trailing \r would both fail the endsWith("}") JSON sniff and // leak into bubble text. const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine; - if (!line) return; - if (RESUME_ERR_RE.test(line)) resumeFailed = true; + // Plain fallback is ordinary assistant text, so preserve empty lines; + // structured JSONL control frames can still ignore blank transport rows. + if (!line && !(openCodeDirect && openCodeCompatibility?.mode === "plain")) return; + if (openCodeDirect && !openCodeCompatibilityHealthNoticeSent && openCodeCompatibility?.diagnostic) { + openCodeCompatibilityHealthNoticeSent = true; + const diagnostic = openCodeCompatibility.diagnostic === "json-format-unavailable" + ? "This OpenCode client does not advertise JSON events; continuing without tool activity" + : openCodeCompatibility.diagnostic === "no-compatible-schema" + ? "This OpenCode client has no verified tool-event schema; continuing without tool activity" + : openCodeCompatibility.diagnostic === "schema-quarantined" + ? "OpenCode's structured event protocol was quarantined; continuing without tool activity" + : openCodeCompatibility.diagnostic === "cached-schema-unavailable" + ? "OpenCode's compatibility registry is unavailable; continuing in plain chat without tool activity" + : openCodeCompatibility.bundleSource === "built-in" + ? "OpenCode schema refresh was not trusted; using the shipped compatible parser" + : "OpenCode schema refresh was not trusted; using the last known compatible parser"; + pushProgress("opencode-compatibility", diagnostic, "error", openCodeCompatibility.diagnostic); + } + const openCodePlainFallback = openCodeDirect && openCodeCompatibility?.mode === "plain"; + // Plain OpenCode has no structured error envelope, so stdout cannot + // distinguish a real resume failure from an assistant reply quoting it. + // Preserve every line rather than converting ordinary reply text into + // a dropped retry signal. + if (!openCodePlainFallback && RESUME_ERR_RE.test(line)) resumeFailed = true; const isJson = !hermesDirect && line.startsWith("{") && line.endsWith("}"); if (copilotStream) { handleCopilotLine(line, isJson); @@ -2029,8 +2297,8 @@ export async function POST(req: Request) { /* ignore */ } }; - const runHandle: ChatRunHandle = registerChatRun( - [body.runId, body.sessionId], + runHandle = registerChatRun( + [body.runId, body.sessionId, sessionId], killCurrentChild, ); let detachKillTimer: ReturnType | null = null; @@ -2295,13 +2563,17 @@ export async function POST(req: Request) { if (hermesApi) return runHermesApiAttempt(apiPrompt); return new Promise((resolve) => { const attemptStartedAt = Date.now(); + let discardingOpenCodeFrame = false; pushProgress( "harness-start", `Starting ${binding.harness}`, "running", sshRuntime ? `${sshRuntime.host}:${sshRuntime.cwd}` - : familiarCwd ?? cwd, + // OpenCode compatibility diagnostics must not expose a local + // workspace path. Other harnesses retain their existing launch + // location detail. + : openCodeDirect ? undefined : familiarCwd ?? cwd, ); const child = sshRuntime ? (() => { @@ -2359,14 +2631,68 @@ export async function POST(req: Request) { }; req.signal.addEventListener("abort", onAbort, { once: true }); - child.stdout.on("data", (data: Buffer) => { - jsonBuf += data.toString("utf8"); + // Child-process chunks are arbitrary bytes, not UTF-8 character + // boundaries. Preserve a split code point until its remaining bytes + // arrive so JSONL text/tool payloads cannot silently gain U+FFFD. + const openCodeStdoutDecoder = openCodeDirect ? new StringDecoder("utf8") : null; + const handleStdoutChunk = (decodedChunk: string) => { + let chunk = decodedChunk; + // Once an unterminated frame crosses the cap, discard through its + // newline before looking for another frame. Parsing its tail could + // turn provider data into a misleading compatibility event. + if (discardingOpenCodeFrame) { + const newline = chunk.indexOf("\n"); + if (newline < 0) return; + discardingOpenCodeFrame = false; + chunk = chunk.slice(newline + 1); + } + jsonBuf += chunk; let idx; while ((idx = jsonBuf.indexOf("\n")) >= 0) { const line = jsonBuf.slice(0, idx); jsonBuf = jsonBuf.slice(idx + 1); + if ( + openCodeDirect + && openCodeCompatibility?.mode === "structured" + && Buffer.byteLength(line, "utf8") > MAX_OPENCODE_JSONL_FRAME_BYTES + ) { + quarantineOpenCodeProtocol( + "OpenCode sent an oversized event; future turns will use safe plain chat until a compatible schema is available", + "oversized-jsonl-event", + ); + continue; + } handleLine(line); } + // Plain compatibility mode is ordinary assistant text, not a + // JSONL control frame. Flush a long partial line in bounded + // chunks so a hostile/no-newline client cannot retain it forever. + if ( + openCodeDirect + && openCodeCompatibility?.mode === "plain" + && Buffer.byteLength(jsonBuf, "utf8") > MAX_OPENCODE_JSONL_FRAME_BYTES + ) { + const plainChunk = jsonBuf; + jsonBuf = ""; + handleLine(plainChunk); + } + if ( + openCodeDirect + && openCodeCompatibility?.mode === "structured" + && Buffer.byteLength(jsonBuf, "utf8") > MAX_OPENCODE_JSONL_FRAME_BYTES + ) { + jsonBuf = ""; + discardingOpenCodeFrame = true; + quarantineOpenCodeProtocol( + "OpenCode sent an oversized event; future turns will use safe plain chat until a compatible schema is available", + "oversized-jsonl-event", + ); + } + }; + + child.stdout.on("data", (data: Buffer) => { + const chunk = openCodeStdoutDecoder ? openCodeStdoutDecoder.write(data) : data.toString("utf8"); + if (chunk) handleStdoutChunk(chunk); }); child.stderr.on("data", (data: Buffer) => { @@ -2385,11 +2711,17 @@ export async function POST(req: Request) { }); child.on("error", (err: NodeJS.ErrnoException) => { + // OpenCode launch errors can include the PowerShell shim's absolute + // path (and platform error details). Keep compatibility diagnostics + // value-free rather than surfacing local filesystem information. + const launchError = openCodeDirect + ? "OpenCode failed to start. Check its installation and try again." + : err.message; pushProgress( "harness-start", `${binding.harness} failed to start`, "error", - err.message, + launchError, Date.now() - attemptStartedAt, ); if (err.code === "ENOENT") { @@ -2410,7 +2742,7 @@ export async function POST(req: Request) { : "Coven CLI not found on PATH. Open Setup to install it, then try again.", }); } else { - push({ kind: "error", message: err.message }); + push({ kind: "error", message: launchError }); } req.signal.removeEventListener("abort", onAbort); resolve(); @@ -2418,6 +2750,8 @@ export async function POST(req: Request) { }); child.on("close", (code) => { + const trailingOpenCodeText = openCodeStdoutDecoder?.end(); + if (trailingOpenCodeText) handleStdoutChunk(trailingOpenCodeText); captureHermesSessionFromStderr("", true); // OpenCode normally emits a JSON error envelope, but older CLI // builds can exit non-zero with only stderr. Do not mistake that @@ -2446,6 +2780,39 @@ export async function POST(req: Request) { // First attempt — uses --continue if body.sessionId was set. }; const turnSpawnStartMs = Date.now(); + // A compatibility decision is meaningful even when the CLI exits before + // stdout. Announce it before spawning instead of relying on handleLine. + if (openCodeDirect && !openCodeCompatibilityHealthNoticeSent && openCodeCompatibility?.diagnostic) { + openCodeCompatibilityHealthNoticeSent = true; + const diagnostic = openCodeCompatibility.diagnostic === "json-format-unavailable" + ? "This OpenCode client does not advertise JSON events; continuing without tool activity" + : openCodeCompatibility.diagnostic === "no-compatible-schema" + ? "This OpenCode client has no verified tool-event schema; continuing without tool activity" + : openCodeCompatibility.diagnostic === "schema-quarantined" + ? "OpenCode's structured event protocol was quarantined; continuing without tool activity" + : openCodeCompatibility.diagnostic === "cached-schema-unavailable" + ? "OpenCode's compatibility registry is unavailable; continuing in plain chat without tool activity" + : openCodeCompatibility.bundleSource === "built-in" + ? "OpenCode schema refresh was not trusted; using the shipped compatible parser" + : "OpenCode schema refresh was not trusted; using the last known compatible parser"; + pushProgress("opencode-compatibility", diagnostic, "error", openCodeCompatibility.diagnostic); + } + if (openCodeFreshSessionForCompatibility) { + pushProgress( + "opencode-compatibility", + openCodeUnrecordedResume + ? "This OpenCode session is not recorded locally and this client cannot resume it; starting a fresh chat" + : openCodeCompatibilityRetry?.replayedHistory + ? openCodeSessionUnavailable + ? "This OpenCode client cannot resume sessions; replaying recent context in a fresh chat" + : "This conversation has no resumable OpenCode session; replaying recent context in a fresh chat" + : openCodeSessionUnavailable + ? "This OpenCode client cannot resume sessions; starting a fresh chat" + : "This conversation has no resumable OpenCode session; starting a fresh chat", + "error", + "session-unavailable", + ); + } if (hermesNeedsContextReplay) { const replay = buildResumeRetryPrompt(harnessPrompt, existingConversation); pushProgress( @@ -2486,6 +2853,7 @@ export async function POST(req: Request) { jsonBuf = ""; result = {}; toolTracker = new ToolCallTracker(); + openCodeModelRejected = false; copilotText.reset(); stderrTail.length = 0; stdoutErrTail.length = 0; @@ -2520,6 +2888,11 @@ export async function POST(req: Request) { "running", ); sessionId = null; + // The failed resumed stream can echo the stale native token before its + // error frame. This retry deliberately omits --session, so retaining + // that token would persist it again if the fresh process exits before + // announcing a replacement session. + openCodeSessionId = null; hermesResponseId = null; hermesPreviousResponseId = null; assistantFilter = new AssistantFilter({ passthrough: rawStdoutHarness }); @@ -2527,6 +2900,7 @@ export async function POST(req: Request) { jsonBuf = ""; result = {}; toolTracker = new ToolCallTracker(); + openCodeModelRejected = false; copilotText.reset(); stderrTail.length = 0; stdoutErrTail.length = 0; @@ -2570,7 +2944,10 @@ export async function POST(req: Request) { const durMs = result.duration_ms; const durSuffix = durMs != null ? ` in ${durMs}ms` : ""; const tailSource = stderrTail.length ? stderrTail : stdoutErrTail; - const tailBlock = tailSource.length + // OpenCode stderr can include request bodies, provider diagnostics, or + // local paths. It is useful for other harnesses' existing recovery + // guidance, but must never become assistant-visible/persisted text. + const tailBlock = !openCodeDirect && tailSource.length ? `\n\n\`\`\`\n${tailSource.slice(-5).join("\n")}\n\`\`\`` : ""; const diagnostic = result.is_error @@ -2632,10 +3009,19 @@ export async function POST(req: Request) { // persisted assistant turn so they survive reload. `cleanedAssistantText` // is the marker-free text that gets persisted (the client also strips // markers from the live-streamed text for parity — see chat-view). + // Plain compatibility fallback has no structured boundary that permits + // us to normalize provider text. Preserve its leading indentation and + // trailing blank lines in the durable transcript as well as the live + // stream; other harnesses retain their established trim behavior. + const assistantTextForPersistence = openCodeDirect && openCodeCompatibility?.mode === "plain" + ? assistantText + : assistantText.trim(); const { text: cleanedAssistantText, attachments: agentAttachments } = - parseAgentAttachments(assistantText.trim(), { - allowedRoots: sshRuntime ? [] : [familiarCwd ?? cwd, ...grantedProjectRoots], - }); + openCodeDirect && openCodeCompatibility?.mode === "plain" + ? { text: assistantTextForPersistence, attachments: [] } + : parseAgentAttachments(assistantTextForPersistence, { + allowedRoots: sshRuntime ? [] : [familiarCwd ?? cwd, ...grantedProjectRoots], + }); for (const attachment of agentAttachments) { push({ kind: "attachment", attachment }); } @@ -2651,10 +3037,17 @@ export async function POST(req: Request) { // access session. const harnessSessionId = grokDirect ? grokSessionId - : hermesDirect && hermesApi - ? !result.is_error && hermesResponseId - ? hermesResponseId - : existingConversation?.harnessSessionId ?? null + : openCodeDirect + // Plain output has no new native id to announce, but a token is + // reusable only when this attempt actually forwarded it. A fresh + // compatibility fallback must invalidate an older native session. + ? openCodeSessionId ?? (openCodeNativeResumeUsed + ? existingConversation?.harnessSessionId ?? (!existingConversation ? body.sessionId : undefined) + : undefined) + : hermesDirect && hermesApi + ? !result.is_error && hermesResponseId + ? hermesResponseId + : existingConversation?.harnessSessionId ?? null : sessionId; // OpenCode's JSON event protocol does not echo the selected model. Its // direct argv proves the selection was forwarded, while a successful @@ -2665,7 +3058,7 @@ export async function POST(req: Request) { modelApplicationFromRun({ confirmedModel: forwardModel, isError: result.is_error === true, - errorText: [...stderrTail, ...stdoutErrTail].join("\n"), + errorText: openCodeModelRejected ? "model unavailable" : [...stderrTail, ...stdoutErrTail].join("\n"), }), ); if (!result.is_error) responseMetadata.confirmedModel = forwardModel; @@ -2685,7 +3078,9 @@ export async function POST(req: Request) { modelState.applicationState = application.state; modelState.reason = application.reason; } - const finalSessionId = body.sessionId ?? sessionId; + const finalSessionId = body.sessionId && !openCodeUnrecordedResume + ? body.sessionId + : sessionId; if (finalSessionId) { pushProgress("save-transcript", "Saving transcript", "running"); await recordSessionFamiliar(finalSessionId, body.familiarId); @@ -2740,6 +3135,7 @@ export async function POST(req: Request) { ...(result.usage ? { usage: result.usage } : {}), ...(result.costUsd !== undefined ? { costUsd: result.costUsd } : {}), ...(persistedTools ? { tools: persistedTools } : {}), + ...(persistedOpenCodeDiagnostics.length ? { progress: persistedOpenCodeDiagnostics } : {}), parentId: userTurnId, responseMetadata, }; @@ -2768,6 +3164,12 @@ export async function POST(req: Request) { const reportedPrUrl = latestPrUrlFromText(cleanedAssistantText); if (reportedPrUrl) conv.prUrl = reportedPrUrl; if (harnessSessionId) conv.harnessSessionId = harnessSessionId; + else if (openCodeDirect && existingConversation && !openCodeNativeResumeUsed) { + // A fresh compatibility fallback intentionally did not use the + // prior native session. Persist that invalidation so a later client + // upgrade cannot silently resume context that excludes this turn. + delete conv.harnessSessionId; + } if (grokDirect && grokSessionId) conv.grokSandboxProfile = grokSandboxProfile; conv.turns.push(userTurn, assistantTurn); conv.activeLeafId = assistantTurnId; diff --git a/src/lib/cave-conversations.test.ts b/src/lib/cave-conversations.test.ts index 11f19264a..4acc1a8a1 100644 --- a/src/lib/cave-conversations.test.ts +++ b/src/lib/cave-conversations.test.ts @@ -5,8 +5,10 @@ import path from "node:path"; import { tmpdir } from "node:os"; const previousHome = process.env.HOME; +const previousCovenHome = process.env.COVEN_HOME; const home = await mkdtemp(path.join(tmpdir(), "cave-conversations-")); process.env.HOME = home; +process.env.COVEN_HOME = path.join(home, ".coven"); const { deleteConversation, @@ -15,6 +17,7 @@ const { loadConversation, saveConversation, } = await import("./cave-conversations.ts"); +const { mapConversationHistoryTurns } = await import("./chat-turn-state.ts"); assert.equal(isSafeConversationSessionId("session-1"), true); assert.equal(isSafeConversationSessionId("019e-a-valid-thread"), true); @@ -25,6 +28,48 @@ assert.equal(isSafeConversationSessionId("."), false); assert.equal(isSafeConversationSessionId(".."), false); assert.equal(isSafeConversationSessionId(""), false); +assert.deepEqual( + mapConversationHistoryTurns([{ + id: "turn-progress", + role: "assistant", + text: "Safe reply", + createdAt: "2026-07-25T00:00:00.000Z", + progress: [{ + id: "opencode-compatibility", + label: "OpenCode compatibility notice", + detail: "unrecognized event", + status: "error", + createdAt: "2026-07-25T00:00:00.000Z", + }], + }]), + [{ + id: "turn-progress", + parentId: undefined, + role: "assistant", + text: "Safe reply", + attachments: undefined, + reasoning: undefined, + tools: undefined, + progress: [{ + id: "opencode-compatibility", + label: "OpenCode compatibility notice", + detail: "unrecognized event", + status: "error", + createdAt: "2026-07-25T00:00:00.000Z", + }], + durationMs: undefined, + usage: undefined, + costUsd: undefined, + responseMetadata: undefined, + error: undefined, + lifecycle: undefined, + createdAt: "2026-07-25T00:00:00.000Z", + origin: undefined, + voiceCallId: undefined, + }], + "persisted compatibility diagnostics round-trip into the client transcript after reload", +); + await saveConversation({ sessionId: "delete-me", familiarId: "charm", @@ -467,6 +512,11 @@ if (previousHome === undefined) { } else { process.env.HOME = previousHome; } +if (previousCovenHome === undefined) { + delete process.env.COVEN_HOME; +} else { + process.env.COVEN_HOME = previousCovenHome; +} await rm(home, { recursive: true, force: true }); console.log("cave-conversations.test.ts: ok"); diff --git a/src/lib/cave-conversations.ts b/src/lib/cave-conversations.ts index e36fad3d0..324058dba 100644 --- a/src/lib/cave-conversations.ts +++ b/src/lib/cave-conversations.ts @@ -39,6 +39,15 @@ export type ChatTurn = { * through whole, so the field round-trips for free. */ textOffset?: number; }>; + /** Safe, user-visible run status retained with the assistant reply. */ + progress?: Array<{ + id: string; + label: string; + detail?: string; + status: "running" | "done" | "error"; + createdAt: string; + durationMs?: number; + }>; createdAt: string; durationMs?: number; isError?: boolean; diff --git a/src/lib/chat-tool-events.test.ts b/src/lib/chat-tool-events.test.ts new file mode 100644 index 000000000..25aca1afb --- /dev/null +++ b/src/lib/chat-tool-events.test.ts @@ -0,0 +1,87 @@ +// @ts-nocheck +import assert from "node:assert/strict"; +import { MAX_RECORDED_TOOL_EVENTS, MAX_SETTLED_ENVELOPE_IDS, ToolCallTracker, capLiveToolPayload, toPersistedTools } from "./chat-tool-events.ts"; + +const tracker = new ToolCallTracker(() => 1_000); +assert.equal(tracker.envelopeToolResult("call_1", "late terminal output", false), null); +assert.equal(tracker.envelopeToolResult("call_1", "duplicate before start", true), null); +const started = tracker.envelopeToolUse("call_1", "bash", '{"command":"pwd"}', 4); +assert.deepEqual(started, { id: "call_1", name: "bash", input: '{"command":"pwd"}', status: "running" }); +const settled = tracker.consumePendingEnvelopeResult("call_1"); +assert.equal(settled?.id, "call_1"); +assert.equal(settled?.status, "ok"); +assert.equal(settled?.output, "late terminal output"); +assert.equal(settled?.status, "ok", "duplicate results before their start do not replace the first terminal frame"); +assert.equal(tracker.snapshot().length, 1, "reordered events reconcile to one persisted bubble"); +assert.equal(tracker.envelopeToolUse("call_1", "bash"), null, "duplicate starts do not duplicate the persisted bubble"); +assert.equal(tracker.envelopeToolResult("call_1", "duplicate", false), null, "duplicate results do not overwrite the settled bubble"); +assert.deepEqual( + toPersistedTools(tracker.snapshot(), 0), + [{ id: "call_1", name: "bash", input: '{"command":"pwd"}', output: "late terminal output", status: "ok", durationMs: 0, textOffset: 4 }], + "reconciled OpenCode calls persist their stable id and terminal output for reload/resume", +); + +const progress = new ToolCallTracker(() => 1_000); +assert.equal(progress.envelopeToolProgress("progress_1", "queued"), null, "progress before a start never creates a nameless bubble"); +assert.ok(progress.envelopeToolUse("progress_1", "read")); +assert.deepEqual( + progress.consumePendingEnvelopeProgress("progress_1"), + { id: "progress_1", name: "read", output: "queued", status: "running" }, + "reordered progress updates the later start under its stable id", +); +assert.deepEqual( + progress.envelopeToolProgress("progress_1", "halfway"), + { id: "progress_1", name: "read", output: "halfway", status: "running" }, + "progress after a start updates the same running bubble without settling it", +); +assert.deepEqual( + progress.snapshot(), + [{ id: "progress_1", name: "read", input: undefined, output: "halfway", status: "running" }], + "a progress-only lifecycle remains explicitly running until a result arrives", +); +assert.deepEqual( + toPersistedTools(progress.snapshot(), 0), + [{ id: "progress_1", name: "read", output: "halfway\n[tool did not settle before the turn ended]", status: "error" }], + "a progress-only lifecycle is recovered as a terminal error instead of persisting an infinite spinner", +); + +const oversized = new ToolCallTracker(() => 1_000); +assert.equal(oversized.envelopeToolResult("call_large", "x".repeat(100_000), false), null); +const largeStart = oversized.envelopeToolUse("call_large", "read"); +assert.ok(largeStart, "a bounded deferred result still reconciles once its start arrives"); +const largeEnd = oversized.consumePendingEnvelopeResult("call_large"); +assert.ok((largeEnd?.output.length ?? 0) <= 16_100, "reordered results are capped before tracker and SSE retention"); +assert.ok( + new TextEncoder().encode(capLiveToolPayload("😀".repeat(10_000), 16_000) ?? "").byteLength <= 16_000, + "unicode tool payloads respect byte rather than UTF-16 code-unit caps", +); + +const linkedHook = new ToolCallTracker(() => 1_000); +linkedHook.hookStart("read"); +assert.equal(linkedHook.envelopeToolUse("hook-linked", "read", "x".repeat(100_000)), null); +const linkedHookInput = linkedHook.snapshot()[0]?.input; +assert.ok( + new TextEncoder().encode(linkedHookInput ?? "").byteLength <= 8_000, + "linking an OpenCode envelope to a hook call preserves the live input cap", +); + +const terminalWindow = new ToolCallTracker(() => 1_000); +for (let index = 0; index <= MAX_SETTLED_ENVELOPE_IDS; index += 1) { + const id = `settled-${index}`; + assert.ok(terminalWindow.envelopeToolUse(id, "read")); + assert.ok(terminalWindow.envelopeToolResult(id, "ok", false)); +} +assert.equal(terminalWindow.envelopeToolUse(`settled-${MAX_SETTLED_ENVELOPE_IDS}`, "read"), null, "recent terminal ids still suppress retransmitted starts"); +assert.ok(terminalWindow.envelopeToolUse("settled-0", "read"), "the bounded terminal-id window evicts only the oldest completed id"); +terminalWindow.hookEnd("never-started", undefined, false); +assert.ok(terminalWindow.envelopeToolUse("after-empty-hook-end", "never-started"), "a terminal hook without a start does not retain an empty per-name queue"); + +const recordedWindow = new ToolCallTracker(() => 1_000); +for (let index = 0; index <= MAX_RECORDED_TOOL_EVENTS; index += 1) { + const id = `recorded-${index}`; + assert.ok(recordedWindow.envelopeToolUse(id, "read")); + assert.ok(recordedWindow.envelopeToolResult(id, "ok", false)); +} +assert.equal(recordedWindow.snapshot().length, MAX_RECORDED_TOOL_EVENTS, "a long-running runtime cannot retain unbounded settled tool records"); + +console.log("chat-tool-events.test.ts: ok"); diff --git a/src/lib/chat-tool-events.ts b/src/lib/chat-tool-events.ts index cfe6ad431..afa9992bb 100644 --- a/src/lib/chat-tool-events.ts +++ b/src/lib/chat-tool-events.ts @@ -34,6 +34,34 @@ export type ToolStreamEvent = { * (mirrors the chat UI's textOffset for chronological interleaving). */ export type RecordedToolEvent = ToolStreamEvent & { textOffset?: number }; +/** Live tool payloads are bounded before SSE, tracker retention, and + * persistence. A local CLI is still an untrusted producer. */ +export const LIVE_TOOL_INPUT_CAP = 8_000; +export const LIVE_TOOL_OUTPUT_CAP = 16_000; +const MAX_PENDING_TOOL_RESULTS = 100; +const MAX_PENDING_TOOL_RESULT_BYTES = 64_000; +const PENDING_TOOL_RESULT_TTL_MS = 60_000; +const MAX_OPEN_ENVELOPE_CALLS = 200; +/** Keep a bounded recent window to suppress terminal-frame retransmits. */ +export const MAX_SETTLED_ENVELOPE_IDS = 512; +/** A malicious or runaway runtime must not grow the persisted event index forever. */ +export const MAX_RECORDED_TOOL_EVENTS = 512; + +function utf8Bytes(value: string | undefined): number { + return value === undefined ? 0 : new TextEncoder().encode(value).byteLength; +} + +export function capLiveToolPayload(value: string | undefined, cap: number): string | undefined { + if (value === undefined || utf8Bytes(value) <= cap) return value; + const suffix = "\n[tool payload truncated]"; + const budget = Math.max(0, cap - utf8Bytes(suffix)); + let end = Math.min(value.length, budget); + // UTF-16 code-unit offsets are not byte offsets. Trim to a UTF-8 boundary + // so a hostile unicode payload cannot exceed the advertised byte cap. + while (end > 0 && utf8Bytes(value.slice(0, end)) > budget) end -= 1; + return `${value.slice(0, end)}${suffix}`; +} + /** Pretty-print a raw JSON payload string; fall back to the raw text. */ export function formatToolPayload(raw: string): string | undefined { if (!raw) return undefined; @@ -104,6 +132,12 @@ export class ToolCallTracker { private byEnvelopeId = new Map(); /** Envelope ids whose calls were already settled (dedup tool_result). */ private settledEnvelopeIds = new Set(); + /** Terminal envelopes can precede starts after a reconnect or CLI flush. */ + private pendingEnvelopeResults = new Map(); + private pendingEnvelopeResultBytes = 0; + /** Progress can race a start frame, but never creates a nameless bubble. */ + private pendingEnvelopeProgress = new Map(); + private pendingEnvelopeProgressBytes = 0; /** Final state of every call this tracker has emitted, by stream id — * insertion-ordered, so snapshot() preserves call order for persistence. */ private recorded = new Map(); @@ -122,23 +156,72 @@ export class ToolCallTracker { return q; } + private dropPendingEnvelopeResult(id: string): void { + const pending = this.pendingEnvelopeResults.get(id); + if (!pending) return; + this.pendingEnvelopeResults.delete(id); + this.pendingEnvelopeResultBytes -= pending.bytes; + } + + private prunePendingEnvelopeResults(): void { + const oldestAllowed = this.now() - PENDING_TOOL_RESULT_TTL_MS; + for (const [id, pending] of this.pendingEnvelopeResults) { + if (pending.receivedAt >= oldestAllowed) break; + this.dropPendingEnvelopeResult(id); + } + } + + private dropPendingEnvelopeProgress(id: string): void { + const pending = this.pendingEnvelopeProgress.get(id); + if (!pending) return; + this.pendingEnvelopeProgress.delete(id); + this.pendingEnvelopeProgressBytes -= pending.bytes; + } + + private prunePendingEnvelopeProgress(): void { + const oldestAllowed = this.now() - PENDING_TOOL_RESULT_TTL_MS; + for (const [id, pending] of this.pendingEnvelopeProgress) { + if (pending.receivedAt >= oldestAllowed) break; + this.dropPendingEnvelopeProgress(id); + } + } + private settle(call: OpenCall): void { const q = this.open.get(call.name); if (q) { const idx = q.indexOf(call); if (idx >= 0) q.splice(idx, 1); + if (q.length === 0) this.open.delete(call.name); } if (call.envelopeId) { this.byEnvelopeId.delete(call.envelopeId); - this.settledEnvelopeIds.add(call.envelopeId); + this.rememberSettledEnvelopeId(call.envelopeId); } } + private rememberSettledEnvelopeId(id: string): void { + // Set iteration preserves insertion order. Refresh a retransmitted id and + // evict the oldest terminal id before the tracker can grow for a long run. + this.settledEnvelopeIds.delete(id); + while (this.settledEnvelopeIds.size >= MAX_SETTLED_ENVELOPE_IDS) { + const oldest = this.settledEnvelopeIds.values().next().value; + if (oldest === undefined) break; + this.settledEnvelopeIds.delete(oldest); + } + this.settledEnvelopeIds.add(id); + } + private record(ev: ToolStreamEvent, textOffset?: number): void { + const bounded: ToolStreamEvent = { + ...ev, + ...(ev.input !== undefined ? { input: capLiveToolPayload(ev.input, LIVE_TOOL_INPUT_CAP) } : {}), + ...(ev.output !== undefined ? { output: capLiveToolPayload(ev.output, LIVE_TOOL_OUTPUT_CAP) } : {}), + }; const prev = this.recorded.get(ev.id); if (!prev) { + if (this.recorded.size >= MAX_RECORDED_TOOL_EVENTS) return; this.recorded.set(ev.id, { - ...ev, + ...bounded, ...(textOffset !== undefined ? { textOffset } : {}), }); return; @@ -147,8 +230,8 @@ export class ToolCallTracker { // original input win (mirrors the chat UI's upsert-by-id semantics). this.recorded.set(ev.id, { ...prev, - ...ev, - input: prev.input ?? ev.input, + ...bounded, + input: prev.input ?? bounded.input, ...(prev.textOffset !== undefined ? { textOffset: prev.textOffset } : textOffset !== undefined @@ -194,10 +277,10 @@ export class ToolCallTracker { /** post_tool_use hook line: the OLDEST open hook-started call completed. */ hookEnd(name: string, output: string | undefined, isError: boolean): ToolStreamEvent { - const queue = this.queueFor(name); + const queue = this.open.get(name); // FIFO pairing: a post matches the oldest open pre of the same name. // Fall back to the oldest envelope-only call (post-hook-only harnesses). - const call = queue.find((c) => c.hookStarted) ?? queue[0]; + const call = queue?.find((c) => c.hookStarted) ?? queue?.[0]; const status = isError ? "error" : "ok"; if (!call) { // Post without any open call: surface it anyway under a fresh id. @@ -219,7 +302,10 @@ export class ToolCallTracker { * linked to the hook's id instead, so later tool_result blocks dedup). */ envelopeToolUse(id: string, name: string, input?: string, textOffset?: number): ToolStreamEvent | null { - if (this.byEnvelopeId.has(id) || this.settledEnvelopeIds.has(id)) return null; + this.prunePendingEnvelopeResults(); + this.prunePendingEnvelopeProgress(); + if (utf8Bytes(id) > 512 || utf8Bytes(name) > 512 || this.byEnvelopeId.has(id) || this.settledEnvelopeIds.has(id)) return null; + if (this.byEnvelopeId.size >= MAX_OPEN_ENVELOPE_CALLS) return null; const queue = this.queueFor(name); // A hook pre may have surfaced this call already under a minted id. Link // the native id to the oldest unlinked hook call rather than emitting a @@ -230,7 +316,10 @@ export class ToolCallTracker { this.byEnvelopeId.set(id, hookCall); const prev = this.recorded.get(hookCall.id); if (prev && prev.input === undefined && input !== undefined) { - this.recorded.set(hookCall.id, { ...prev, input }); + this.recorded.set(hookCall.id, { + ...prev, + input: capLiveToolPayload(input, LIVE_TOOL_INPUT_CAP), + }); } return null; } @@ -244,11 +333,57 @@ export class ToolCallTracker { }; queue.push(call); this.byEnvelopeId.set(id, call); - const ev: ToolStreamEvent = { id, name, input, status: "running" }; + const ev: ToolStreamEvent = { id, name, input: capLiveToolPayload(input, LIVE_TOOL_INPUT_CAP), status: "running" }; this.record(ev, textOffset); return ev; } + /** Settle a result that arrived before its start, once the id is known. */ + consumePendingEnvelopeResult(toolUseId: string): ToolStreamEvent | null { + this.prunePendingEnvelopeResults(); + const pending = this.pendingEnvelopeResults.get(toolUseId); + if (!pending) return null; + this.dropPendingEnvelopeResult(toolUseId); + return this.envelopeToolResult(toolUseId, pending.output, pending.isError); + } + + /** Apply the most recent reordered progress update after a tool start. */ + consumePendingEnvelopeProgress(toolUseId: string): ToolStreamEvent | null { + this.prunePendingEnvelopeProgress(); + const pending = this.pendingEnvelopeProgress.get(toolUseId); + if (!pending) return null; + this.dropPendingEnvelopeProgress(toolUseId); + return this.envelopeToolProgress(toolUseId, pending.output); + } + + /** + * A nonterminal tool update shares the running bubble's stable id. Progress + * without a start is retained briefly for a genuine reordered stream, but + * cannot create a nameless or permanently running bubble on its own. + */ + envelopeToolProgress(toolUseId: string, output: string | undefined): ToolStreamEvent | null { + this.prunePendingEnvelopeProgress(); + if (utf8Bytes(toolUseId) > 512 || this.settledEnvelopeIds.has(toolUseId)) return null; + const boundedOutput = capLiveToolPayload(output, LIVE_TOOL_OUTPUT_CAP); + const pendingBytes = utf8Bytes(boundedOutput); + const call = this.byEnvelopeId.get(toolUseId); + if (!call) { + if (pendingBytes > MAX_PENDING_TOOL_RESULT_BYTES) return null; + if (this.pendingEnvelopeProgress.has(toolUseId)) this.dropPendingEnvelopeProgress(toolUseId); + while (this.pendingEnvelopeProgress.size >= MAX_PENDING_TOOL_RESULTS || (this.pendingEnvelopeProgressBytes + pendingBytes > MAX_PENDING_TOOL_RESULT_BYTES && this.pendingEnvelopeProgress.size)) { + const oldest = this.pendingEnvelopeProgress.keys().next().value; + if (!oldest) break; + this.dropPendingEnvelopeProgress(oldest); + } + this.pendingEnvelopeProgress.set(toolUseId, { output: boundedOutput, bytes: pendingBytes, receivedAt: this.now() }); + this.pendingEnvelopeProgressBytes += pendingBytes; + return null; + } + const ev: ToolStreamEvent = { id: call.id, name: call.name, output: boundedOutput, status: "running" }; + this.record(ev); + return ev; + } + /** * Updates an already-announced native tool call as its input becomes * available. Responses-style protocols may announce an empty function call @@ -295,22 +430,49 @@ export class ToolCallTracker { /** * stream-json `tool_result` block (from the follow-up user message). * Returns null when the matching call was already settled by a post hook - * (hook output + duration win) or was never announced. + * (hook output + duration win). A result that arrives before its start is + * retained by native id until the start frame arrives, avoiding a silently + * stuck/missing bubble after a reconnect or reordered JSONL flush. */ envelopeToolResult( toolUseId: string, output: string | undefined, isError: boolean, ): ToolStreamEvent | null { - if (this.settledEnvelopeIds.has(toolUseId)) return null; + this.prunePendingEnvelopeResults(); + if (utf8Bytes(toolUseId) > 512 || this.settledEnvelopeIds.has(toolUseId)) return null; + const boundedOutput = capLiveToolPayload(output, LIVE_TOOL_OUTPUT_CAP); + const pendingBytes = utf8Bytes(boundedOutput); const call = this.byEnvelopeId.get(toolUseId); - if (!call) return null; + if (!call) { + // A malformed stream must not turn arbitrary unmatched ids into an + // unbounded in-memory buffer. Retain a small FIFO window for genuine + // reorderings; the start event remains the authority for rendering. + // The first terminal frame wins just like an already-settled result; + // retransmits before the start frame must not replace its output. + if (this.pendingEnvelopeResults.has(toolUseId)) return null; + if (this.pendingEnvelopeResults.size >= MAX_PENDING_TOOL_RESULTS) { + const oldest = this.pendingEnvelopeResults.keys().next().value; + if (oldest) { + this.dropPendingEnvelopeResult(oldest); + } + } + while (this.pendingEnvelopeResultBytes + pendingBytes > MAX_PENDING_TOOL_RESULT_BYTES && this.pendingEnvelopeResults.size) { + const oldest = this.pendingEnvelopeResults.keys().next().value; + if (!oldest) break; + this.dropPendingEnvelopeResult(oldest); + } + if (pendingBytes > MAX_PENDING_TOOL_RESULT_BYTES) return null; + this.pendingEnvelopeResults.set(toolUseId, { output: boundedOutput, isError, bytes: pendingBytes, receivedAt: this.now() }); + this.pendingEnvelopeResultBytes += pendingBytes; + return null; + } const durationMs = this.now() - call.startedAt; this.settle(call); const ev: ToolStreamEvent = { id: call.id, name: call.name, - output, + output: boundedOutput, status: isError ? "error" : "ok", durationMs, }; diff --git a/src/lib/chat-turn-state.ts b/src/lib/chat-turn-state.ts index 05c0cfd2e..8b84784e8 100644 --- a/src/lib/chat-turn-state.ts +++ b/src/lib/chat-turn-state.ts @@ -73,6 +73,7 @@ export type ConversationHistoryTurn = { attachments?: ChatAttachment[]; reasoning?: string; tools?: ToolEvent[]; + progress?: ProgressEvent[]; durationMs?: number; isError?: boolean; usage?: TurnUsage; @@ -108,6 +109,7 @@ export function mapConversationHistoryTurns(rawTurns: ConversationHistoryTurn[]) attachments: turn.attachments, reasoning: turn.reasoning, tools: turn.tools, + progress: turn.progress ? turn.progress.map((progress) => ({ ...progress })) : undefined, durationMs: turn.durationMs, usage: turn.usage, costUsd: turn.costUsd, diff --git a/src/lib/opencode-bin.test.ts b/src/lib/opencode-bin.test.ts index a5fd2ed99..e4df8aff7 100644 --- a/src/lib/opencode-bin.test.ts +++ b/src/lib/opencode-bin.test.ts @@ -1,6 +1,6 @@ // @ts-nocheck import assert from "node:assert/strict"; -import { openCodeCommand, openCodeLaunch, openCodeNeedsTmpRuntimeDir } from "./opencode-bin.ts"; +import { openCodeCommand, openCodeLaunch, openCodeNeedsTmpRuntimeDir, preferOpenCodeLaunchPath } from "./opencode-bin.ts"; assert.equal(openCodeCommand(), "opencode", "OpenCode uses the same executable name on all desktop platforms"); @@ -26,4 +26,16 @@ assert.equal(openCodeNeedsTmpRuntimeDir("linux", { WSL_DISTRO_NAME: "Ubuntu", XD assert.equal(openCodeNeedsTmpRuntimeDir("darwin", {}), true, "headless macOS receives OpenCode's /tmp fallback"); assert.equal(openCodeNeedsTmpRuntimeDir("darwin", { XDG_RUNTIME_DIR: "/var/folders/runtime" }), false, "native macOS preserves a valid runtime directory"); + +assert.equal( + preferOpenCodeLaunchPath("C:\\older;C:\\fallback", "C:\\fresh;C:\\older", "win32"), + "C:\\fresh;C:\\older;C:\\fallback", + "OpenCode keeps the user launch PATH ahead of discovered harness fallbacks", +); +assert.equal( + preferOpenCodeLaunchPath("/fallback:/usr/bin", "/fresh:/usr/bin", "linux"), + "/fresh:/usr/bin:/fallback", + "POSIX launch entries retain their order while scoped fallbacks remain available", +); + console.log("opencode-bin.test.ts: ok"); diff --git a/src/lib/opencode-bin.ts b/src/lib/opencode-bin.ts index 49bf41ce1..8a4dd2c31 100644 --- a/src/lib/opencode-bin.ts +++ b/src/lib/opencode-bin.ts @@ -70,6 +70,23 @@ export function openCodeNeedsTmpRuntimeDir( return isWsl || (platform !== "win32" && !env.XDG_RUNTIME_DIR); } +/** Prefer the process launch PATH while retaining the scoped harness fallback. + * This keeps a freshly updated user npm shim ahead of Cave's discovered CLI + * directories without importing that dynamic discovery into the sidecar. */ +export function preferOpenCodeLaunchPath( + scopedPath: string | undefined, + launchPath: string | undefined = process.env.PATH ?? process.env.Path, + platform: NodeJS.Platform = process.platform, +): string | undefined { + const delimiter = platform === "win32" ? ";" : ":"; + const seen = new Set(); + const normalize = (entry: string) => platform === "win32" ? entry.toLowerCase() : entry; + const entries = [launchPath, scopedPath] + .flatMap((pathValue) => pathValue?.split(delimiter) ?? []) + .filter((entry) => entry && !seen.has(normalize(entry)) && (seen.add(normalize(entry)), true)); + return entries.length ? entries.join(delimiter) : undefined; +} + /** * Preserve the familiar's scoped vault environment while making WSL's CLI * runnable outside a login session. The snap/node launcher rejects the absent @@ -78,6 +95,11 @@ export function openCodeNeedsTmpRuntimeDir( */ export function openCodeSpawnEnv(familiarId?: string | null): NodeJS.ProcessEnv { const env = harnessSpawnEnv(familiarId); + // OpenCode is direct user-selected tooling: preserve its scoped fallback, + // but order the actual launch PATH first so a newly updated npm shim cannot + // be shadowed by an older discovered global copy. + const launchPath = preferOpenCodeLaunchPath(env.PATH); + if (launchPath) env.PATH = launchPath; if (openCodeNeedsTmpRuntimeDir(process.platform, env)) { env.XDG_RUNTIME_DIR = "/tmp"; } diff --git a/src/lib/opencode-compatibility.test.ts b/src/lib/opencode-compatibility.test.ts new file mode 100644 index 000000000..64efdc03c --- /dev/null +++ b/src/lib/opencode-compatibility.test.ts @@ -0,0 +1,1137 @@ +// @ts-nocheck +import assert from "node:assert/strict"; +import { generateKeyPairSync, sign } from "node:crypto"; +import { mkdir, mkdtemp, readFile, rm, utimes, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { + BUILTIN_OPENCODE_SCHEMA_BUNDLE, + loadOpenCodeSchemaBundle, + openCodeSchemaBundlePayloadHash, + openCodeSchemaBundleSigningPayload, + redactedOpenCodeEventFingerprint, + resolveOpenCodeCompatibility, + quarantineOpenCodeSchema, + selectOpenCodeSchema, + isOpenCodeSchemaBundle, +} from "./opencode-compatibility.ts"; + +const now = Date.parse("2026-07-24T12:00:00.000Z"); +const { privateKey, publicKey } = generateKeyPairSync("ed25519"); +const publicPem = publicKey.export({ type: "spki", format: "pem" }).toString(); +const canonicalizationVector = { + z: "last", + runtime: "opencode", + number: 0, + nested: { unicode: "é", quote: "\"", line: "a\nb" }, + array: [true, 2, null], + signature: { algorithm: "ed25519", value: "excluded-from-payload" }, +}; +assert.equal( + openCodeSchemaBundleSigningPayload(canonicalizationVector), + `{"array":[true,2,null],"nested":{"line":"a\\nb","quote":"\\"","unicode":"é"},"number":0,"runtime":"opencode","z":"last"}`, + "format-1 signing bytes match the documented cross-publisher canonicalization vector", +); +const unsigned = { + format: 1, + runtime: "opencode", + sequence: 2, + issuedAt: "2026-07-24T00:00:00.000Z", + expiresAt: "2026-12-24T00:00:00.000Z", + schemas: BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas, +}; +const signed = { + ...unsigned, + signature: { + algorithm: "ed25519" as const, + value: sign(null, Buffer.from(openCodeSchemaBundleSigningPayload(unsigned)), privateKey).toString("base64"), + }, +}; +const cacheFile = path.join(await mkdtemp(path.join(tmpdir(), "cave-opencode-schema-")), "bundle.json"); + +const remote = await loadOpenCodeSchemaBundle({ + cacheFile, + publicKey: publicPem, + url: "https://registry.invalid/opencode.json", + now: () => now, + fetch: async () => new Response(JSON.stringify(signed), { status: 200 }), +}); +assert.equal(remote.source, "remote"); +assert.equal(remote.bundle.sequence, 2); +assert.match(await readFile(cacheFile, "utf8"), /"sequence": 2/, "accepted bundles are atomically cached"); + +const checkpointReplayFile = path.join(await mkdtemp(path.join(tmpdir(), "cave-opencode-schema-checkpoint-replay-")), "bundle.json"); +const checkpointReplay = await loadOpenCodeSchemaBundle({ + cacheFile: checkpointReplayFile, + publicKey: publicPem, + url: "https://registry.invalid/opencode.json", + checkpoint: { sequence: 2, payloadHash: "0".repeat(64) }, + now: () => now, + fetch: async () => new Response(JSON.stringify(signed), { status: 200 }), +}); +assert.equal(checkpointReplay.source, "built-in", "a release checkpoint rejects a same-sequence payload rewrite on first use"); +assert.equal(checkpointReplay.diagnostic, "schema-registry-refresh-rejected"); +const checkpointAcceptedFile = path.join(await mkdtemp(path.join(tmpdir(), "cave-opencode-schema-checkpoint-accepted-")), "bundle.json"); +const checkpointAccepted = await loadOpenCodeSchemaBundle({ + cacheFile: checkpointAcceptedFile, + publicKey: publicPem, + url: "https://registry.invalid/opencode.json", + checkpoint: { sequence: 2, payloadHash: openCodeSchemaBundlePayloadHash(signed) }, + now: () => now, + fetch: async () => new Response(JSON.stringify(signed), { status: 200 }), +}); +assert.equal(checkpointAccepted.source, "remote", "a release checkpoint admits exactly its canonical signed payload"); +const unsignedCheckpointAdvance = { ...unsigned, sequence: 3 }; +const signedCheckpointAdvance = { + ...unsignedCheckpointAdvance, + signature: { + algorithm: "ed25519" as const, + value: sign(null, Buffer.from(openCodeSchemaBundleSigningPayload(unsignedCheckpointAdvance)), privateKey).toString("base64"), + }, +}; +const advancedCheckpoint = { sequence: 3, payloadHash: openCodeSchemaBundlePayloadHash(signedCheckpointAdvance) }; +const checkpointStaleCache = await loadOpenCodeSchemaBundle({ + cacheFile: checkpointAcceptedFile, + publicKey: publicPem, + url: "https://registry.invalid/opencode.json", + checkpoint: advancedCheckpoint, + now: () => now, + fetch: async () => { throw new Error("offline"); }, +}); +assert.equal(checkpointStaleCache.source, "built-in", "a release checkpoint never continues to select a lower-sequence cache after an upgrade"); +assert.equal(checkpointStaleCache.diagnostic, "schema-registry-refresh-rejected"); +const checkpointUpgrade = await loadOpenCodeSchemaBundle({ + cacheFile: checkpointAcceptedFile, + publicKey: publicPem, + url: "https://registry.invalid/opencode.json", + checkpoint: advancedCheckpoint, + now: () => now + 60_001, + fetch: async () => new Response(JSON.stringify(signedCheckpointAdvance), { status: 200 }), +}); +assert.equal(checkpointUpgrade.source, "remote", "a later signed checkpoint replaces an older cache after a release upgrade"); + +const unsignedAnchorSequence4 = { ...unsigned, sequence: 4 }; +const signedAnchorSequence4 = { + ...unsignedAnchorSequence4, + signature: { + algorithm: "ed25519" as const, + value: sign(null, Buffer.from(openCodeSchemaBundleSigningPayload(unsignedAnchorSequence4)), privateKey).toString("base64"), + }, +}; +const durableAnchorCacheFile = path.join(await mkdtemp(path.join(tmpdir(), "cave-opencode-schema-durable-anchor-")), "bundle.json"); +const anchoredSequence4 = await loadOpenCodeSchemaBundle({ + cacheFile: durableAnchorCacheFile, + publicKey: publicPem, + url: "https://registry.invalid/opencode.json", + now: () => now, + fetch: async () => new Response(JSON.stringify(signedAnchorSequence4), { status: 200 }), +}); +assert.equal(anchoredSequence4.bundle.sequence, 4); +await rm(durableAnchorCacheFile, { force: true }); +await rm(`${durableAnchorCacheFile}.trust`, { force: true }); +const recoveredFromAnchor = await loadOpenCodeSchemaBundle({ + cacheFile: durableAnchorCacheFile, + publicKey: publicPem, + url: "https://registry.invalid/opencode.json", + now: () => now + 7 * 60 * 60 * 1000, + fetch: async () => new Response(JSON.stringify(signedCheckpointAdvance), { status: 200 }), +}); +assert.equal(recoveredFromAnchor.bundle.sequence, 4, "the independent trust anchor rejects an older signed registry replay after both cache records are lost"); +assert.equal(recoveredFromAnchor.source, "cache"); +assert.equal(recoveredFromAnchor.diagnostic, "schema-registry-refresh-rejected"); + +// A writer may be suspended after its lock lease is reclaimed. Simulate that +// old owner resuming after sequence 4 was committed and replacing every +// mutable record with its stale sequence 3 snapshot. The append-only anchor +// journal must still retain sequence 4 as the irreversible floor. +await writeFile(durableAnchorCacheFile, JSON.stringify({ checkedAt: now, bundle: signedCheckpointAdvance })); +await writeFile(`${durableAnchorCacheFile}.trust`, JSON.stringify({ checkedAt: now, bundle: signedCheckpointAdvance })); +await writeFile(`${durableAnchorCacheFile}.anchor`, JSON.stringify({ checkedAt: now, bundle: signedCheckpointAdvance })); +const staleWriterAnchorRecovery = await loadOpenCodeSchemaBundle({ + cacheFile: durableAnchorCacheFile, + publicKey: publicPem, + url: "https://registry.invalid/opencode.json", + now: () => now + 8 * 60 * 60 * 1000, + fetch: async () => new Response(JSON.stringify(signedCheckpointAdvance), { status: 200 }), +}); +assert.equal( + staleWriterAnchorRecovery.bundle.sequence, + 4, + "a reclaimed stale writer cannot roll the append-only trust anchor back after a newer commit", +); + +const overfullJournalFile = path.join(await mkdtemp(path.join(tmpdir(), "cave-opencode-schema-overfull-journal-")), "bundle.json"); +const overfullJournalDirectory = `${overfullJournalFile}.anchor.journal`; +await mkdir(overfullJournalDirectory, { recursive: true }); +await Promise.all(Array.from({ length: 33 }, (_, index) => writeFile(path.join(overfullJournalDirectory, `junk-${index}`), "x"))); +let overfullJournalFetched = false; +const overfullJournal = await loadOpenCodeSchemaBundle({ + cacheFile: overfullJournalFile, + publicKey: publicPem, + url: "https://registry.invalid/opencode.json", + now: () => now, + fetch: async () => { + overfullJournalFetched = true; + return new Response(JSON.stringify(signed), { status: 200 }); + }, +}); +assert.equal(overfullJournal.source, "built-in", "an overfull trust journal fails closed without scanning an unbounded directory"); +assert.equal(overfullJournal.diagnostic, "schema-registry-refresh-rejected"); +assert.equal(overfullJournalFetched, false, "an overfull trust journal is rejected before network refresh"); + +const unsignedSignedRollback = { ...unsigned, sequence: 1 }; +const signedRollback = { + ...unsignedSignedRollback, + signature: { + algorithm: "ed25519" as const, + value: sign(null, Buffer.from(openCodeSchemaBundleSigningPayload(unsignedSignedRollback)), privateKey).toString("base64"), + }, +}; +const firstUseReplayFile = path.join(await mkdtemp(path.join(tmpdir(), "cave-opencode-schema-first-use-replay-")), "bundle.json"); +const firstUseReplay = await loadOpenCodeSchemaBundle({ + cacheFile: firstUseReplayFile, + publicKey: publicPem, + url: "https://registry.invalid/opencode.json", + now: () => now, + fetch: async () => new Response(JSON.stringify(signedRollback), { status: 200 }), +}); +assert.equal(firstUseReplay.source, "built-in", "a fresh install rejects a signed historical sequence-one registry replay"); +assert.equal(firstUseReplay.diagnostic, "schema-registry-refresh-rejected"); +await writeFile(firstUseReplayFile, JSON.stringify({ checkedAt: now, bundle: signedRollback }), "utf8"); +const cachedGenesisReplay = await loadOpenCodeSchemaBundle({ + cacheFile: firstUseReplayFile, + publicKey: publicPem, + url: "https://registry.invalid/opencode.json", + now: () => now, + fetch: async () => { throw new Error("offline"); }, +}); +assert.equal(cachedGenesisReplay.source, "built-in", "a copied signed historical genesis cache cannot bypass first-use pinning"); +assert.equal(cachedGenesisReplay.diagnostic, "schema-registry-refresh-rejected"); +const unsignedGenesis = { ...BUILTIN_OPENCODE_SCHEMA_BUNDLE }; +const signedGenesis = { + ...unsignedGenesis, + signature: { + algorithm: "ed25519" as const, + value: sign(null, Buffer.from(openCodeSchemaBundleSigningPayload(unsignedGenesis)), privateKey).toString("base64"), + }, +}; +const firstUseGenesisFile = path.join(await mkdtemp(path.join(tmpdir(), "cave-opencode-schema-first-use-genesis-")), "bundle.json"); +const firstUseGenesis = await loadOpenCodeSchemaBundle({ + cacheFile: firstUseGenesisFile, + publicKey: publicPem, + url: "https://registry.invalid/opencode.json", + now: () => now, + fetch: async () => new Response(JSON.stringify(signedGenesis), { status: 200 }), +}); +assert.equal(firstUseGenesis.source, "remote", "only the shipped sequence-one registry genesis payload is admissible on first use"); +assert.equal(firstUseGenesis.bundle.sequence, 1); +await writeFile(cacheFile, "{corrupted-primary-cache", "utf8"); +const corruptCacheRollback = await loadOpenCodeSchemaBundle({ + cacheFile, + publicKey: publicPem, + url: "https://registry.invalid/opencode.json", + now: () => now + 7 * 60 * 60 * 1000, + fetch: async () => new Response(JSON.stringify(signedRollback), { status: 200 }), +}); +assert.equal(corruptCacheRollback.bundle.sequence, 2, "a verified sidecar preserves the highest accepted sequence after primary-cache corruption"); +assert.equal(corruptCacheRollback.diagnostic, "schema-registry-refresh-rejected", "a signed rollback remains rejected after primary-cache corruption"); +assert.equal( + (JSON.parse(await readFile(`${cacheFile}.trust`, "utf8")) as { bundle: { sequence: number } }).bundle.sequence, + 2, + "the durable trust floor is not overwritten by the rejected rollback", +); + +const equalSequenceConflictFile = path.join(await mkdtemp(path.join(tmpdir(), "cave-opencode-schema-equal-conflict-")), "bundle.json"); +const unsignedEqualSequenceRewrite = { ...unsigned, expiresAt: "2026-11-24T00:00:00.000Z" }; +const signedEqualSequenceRewrite = { + ...unsignedEqualSequenceRewrite, + signature: { + algorithm: "ed25519" as const, + value: sign(null, Buffer.from(openCodeSchemaBundleSigningPayload(unsignedEqualSequenceRewrite)), privateKey).toString("base64"), + }, +}; +await writeFile(equalSequenceConflictFile, JSON.stringify({ checkedAt: now, bundle: signed })); +await writeFile(`${equalSequenceConflictFile}.trust`, JSON.stringify({ checkedAt: now, bundle: signedEqualSequenceRewrite })); +const equalSequenceConflict = await loadOpenCodeSchemaBundle({ + cacheFile: equalSequenceConflictFile, + publicKey: publicPem, + url: "https://registry.invalid/opencode.json", + now: () => now, + fetch: async () => { throw new Error("offline"); }, +}); +assert.equal(equalSequenceConflict.source, "built-in", "conflicting equal-sequence cache records fail closed while offline"); +assert.equal(equalSequenceConflict.diagnostic, "schema-registry-refresh-rejected"); +const equalSequenceConflictRemote = await loadOpenCodeSchemaBundle({ + cacheFile: equalSequenceConflictFile, + publicKey: publicPem, + url: "https://registry.invalid/opencode.json", + now: () => now + 60_001, + // This is one of the conflicting payloads. It remains unsafe because the + // alternate, same-sequence local payload is still validly signed. + fetch: async () => new Response(JSON.stringify(signed), { status: 200 }), +}); +assert.equal(equalSequenceConflictRemote.source, "built-in", "a remote response cannot resolve conflicting equal-sequence cache records"); +assert.equal(equalSequenceConflictRemote.diagnostic, "schema-registry-refresh-rejected"); +assert.equal( + openCodeSchemaBundleSigningPayload((JSON.parse(await readFile(equalSequenceConflictFile, "utf8")) as { bundle: typeof signed }).bundle), + openCodeSchemaBundleSigningPayload(signed), + "a rejected remote rewrite does not overwrite the primary conflict record", +); +assert.equal( + openCodeSchemaBundleSigningPayload((JSON.parse(await readFile(`${equalSequenceConflictFile}.trust`, "utf8")) as { bundle: typeof signedEqualSequenceRewrite }).bundle), + openCodeSchemaBundleSigningPayload(signedEqualSequenceRewrite), + "a rejected remote rewrite preserves the conflicting trust sidecar for investigation", +); + +const lateConflictFile = path.join(await mkdtemp(path.join(tmpdir(), "cave-opencode-schema-late-conflict-")), "bundle.json"); +await writeFile(lateConflictFile, JSON.stringify({ checkedAt: now - 7 * 60 * 60 * 1000, bundle: signed })); +const lateConflict = await loadOpenCodeSchemaBundle({ + cacheFile: lateConflictFile, + publicKey: publicPem, + url: "https://registry.invalid/opencode.json", + now: () => now, + fetch: async () => { + await writeFile(`${lateConflictFile}.trust`, JSON.stringify({ checkedAt: now, bundle: signedEqualSequenceRewrite })); + throw new Error("registry unavailable after concurrent cache mutation"); + }, +}); +assert.equal(lateConflict.source, "built-in", "a cache conflict introduced during refresh fails closed instead of rejecting compatibility resolution"); +assert.equal(lateConflict.diagnostic, "schema-registry-refresh-rejected"); + +const offline = await loadOpenCodeSchemaBundle({ + cacheFile, + publicKey: publicPem, + url: "https://registry.invalid/opencode.json", + now: () => now + 7 * 60 * 60 * 1000, + fetch: async () => { throw new Error("offline"); }, +}); +assert.equal(offline.source, "cache"); +assert.equal(offline.diagnostic, "schema-registry-refresh-rejected", "the first stale-cache refresh failure remains visible while preserving the verified parser"); + +await writeFile(cacheFile, JSON.stringify({ checkedAt: now + 365 * 24 * 60 * 60 * 1000, bundle: signed })); +const futureCheckedAt = await loadOpenCodeSchemaBundle({ + cacheFile, + publicKey: publicPem, + url: "https://registry.invalid/opencode.json", + now: () => now + 8 * 60 * 60 * 1000, + fetch: async () => new Response(JSON.stringify(signed), { status: 200 }), +}); +assert.equal(futureCheckedAt.source, "remote", "an unsigned future cache timestamp cannot suppress registry refreshes"); + +const rollback = { ...signed, sequence: 1 }; +const rejectedRollback = await loadOpenCodeSchemaBundle({ + cacheFile, + publicKey: publicPem, + url: "https://registry.invalid/opencode.json", + now: () => now + 14 * 60 * 60 * 1000, + fetch: async () => new Response(JSON.stringify(rollback), { status: 200 }), +}); +assert.equal(rejectedRollback.source, "cache"); +assert.equal(rejectedRollback.diagnostic, "schema-registry-refresh-rejected", "rollback refreshes never displace the last known good parser and remain visible"); + +const plain = await resolveOpenCodeCompatibility({ version: "9.9.9", json: false, model: true, session: true, protocols: [] }); +assert.equal(plain.mode, "plain"); +assert.equal(plain.diagnostic, "json-format-unavailable", "capabilities, not version thresholds, decide fallback"); +const structured = await resolveOpenCodeCompatibility({ version: null, json: true, model: false, session: true, protocols: ["json"] }); +assert.equal(structured.mode, "structured"); +assert.equal(structured.schema?.id, "opencode-run-json-v1", "new schemas are chosen by observed capabilities, not a version threshold"); +const missingSession = await resolveOpenCodeCompatibility({ version: "1.2.3", json: true, model: true, session: false, protocols: ["json"] }); +assert.equal(missingSession.mode, "plain"); +assert.equal(missingSession.diagnostic, "no-compatible-schema", "an envelope that cannot be distinguished by observed capabilities fails closed"); + +const resumeOnlyCapabilities = { + version: "1.2.3", + json: true, + model: false, + session: true, + protocols: ["json"], + options: ["--format", "--resume"], + valueOptions: ["--format", "--resume"], + structuredOutputs: [{ option: "--format", values: ["json"] }], +}; +assert.equal(resumeOnlyCapabilities.session, true, "resume-only help advertises native session support"); +assert.deepEqual(resumeOnlyCapabilities.options, ["--format", "--resume"], "the concrete resume option is retained for plain-mode launch"); +assert.equal(resumeOnlyCapabilities.options?.includes("--session"), false); +assert.equal( + selectOpenCodeSchema( + [BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0]], + { + version: "future", + json: true, + model: false, + session: true, + protocols: ["json"], + options: ["--format", "--session"], + valueOptions: ["--format"], + structuredOutputs: [{ option: "--format", values: ["json"] }], + }, + ), + null, + "a resume flag without a documented argument cannot select a schema that forwards Cave's native session id", +); + +const offlineBaseline = await resolveOpenCodeCompatibility( + { version: "current", json: true, model: false, session: true, protocols: ["json"] }, + { + cacheFile: path.join(await mkdtemp(path.join(tmpdir(), "cave-opencode-schema-baseline-")), "bundle.json"), + publicKey: publicPem, + url: "https://registry.invalid/opencode.json", + now: () => now, + fetch: async () => { throw new Error("offline"); }, + }, +); +assert.equal(offlineBaseline.mode, "structured", "a first offline launch keeps the shipped matching parser usable"); +assert.equal(offlineBaseline.bundleSource, "built-in"); +assert.equal(offlineBaseline.diagnostic, "schema-registry-refresh-rejected", "the built-in recovery remains visible to the user"); + +const expiredOfflineBaseline = await resolveOpenCodeCompatibility( + { version: "current", json: true, model: false, session: true, protocols: ["json"] }, + { now: () => Date.parse("2031-01-01T00:00:00.000Z") }, +); +assert.equal(expiredOfflineBaseline.mode, "plain", "an expired built-in schema never parses a fresh offline install"); +assert.equal(expiredOfflineBaseline.diagnostic, "cached-schema-unavailable", "expired built-in fallback remains visible to the user"); + +const broadSchema = { ...BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0], id: "broad", requires: { json: true as const } }; +const protocolV2Schema = { + ...BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0], + id: "opencode-run-json-v2", + priority: 1, + requires: { json: true as const, session: true, protocol: "json-v2" }, + launch: { ...BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0].launch, structuredOutput: { option: "--format" as const, value: "json-v2" } }, +}; +assert.equal( + selectOpenCodeSchema([BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0], protocolV2Schema], { version: "current", json: true, model: false, session: true, protocols: ["json-v2"] })?.id, + "opencode-run-json-v2", + "same-flag schema variants select only through their advertised protocol marker", +); +assert.equal( + selectOpenCodeSchema([BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0], protocolV2Schema], { version: "current", json: true, model: false, session: true, protocols: ["json", "json-v2"] })?.id, + "opencode-run-json-v2", + "a signed priority selects a protocol when a client advertises multiple supported formats", +); +assert.equal( + selectOpenCodeSchema([broadSchema, BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0]], { version: "current", json: true, model: false, session: true, protocols: ["json"] })?.id, + "opencode-run-json-v1", + "the most specific schema wins independently of registry ordering", +); + +assert.equal(isOpenCodeSchemaBundle({ ...unsigned, schemas: [] }, now), false, "empty signed bundles cannot replace a working parser set"); +assert.equal( + isOpenCodeSchemaBundle({ + ...unsigned, + schemas: [{ + ...BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0], + id: "split-lifecycle-only", + eventTypes: { + ...BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0].eventTypes, + ignored: [], + toolComplete: [], + }, + }], + }, now), + true, + "schemas may omit lifecycle-only and combined-tool categories when split frames describe the protocol", +); +assert.equal( + selectOpenCodeSchema( + [BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[1]], + { version: "current", json: true, model: true, session: true, protocols: ["json-legacy"] }, + )?.id, + "opencode-run-json-legacy", + "a legacy protocol remains compatible when a newer client adds session or model flags", +); +assert.equal( + isOpenCodeSchemaBundle({ + ...unsigned, + schemas: [{ + ...BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0], + id: "text-only", + eventTypes: { + ignored: [], + text: ["message"], + toolStart: [], + toolEnd: [], + toolComplete: [], + error: [], + }, + }], + }, now), + true, + "a signed text-only protocol may explicitly retire every tool and error envelope", +); +assert.equal( + isOpenCodeSchemaBundle({ + ...unsigned, + schemas: [{ + ...BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0], + id: "no-text-contract", + eventTypes: { + ...BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0].eventTypes, + text: [], + }, + }], + }, now), + false, + "a structured schema without a trusted assistant-text category is rejected", +); +assert.equal( + isOpenCodeSchemaBundle({ + ...unsigned, + schemas: [{ + ...BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0], + launch: { + ...BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0].launch, + requiredFlags: ["--share"], + }, + }], + }, now), + false, + "a signed parser registry cannot add OpenCode flags that publish or otherwise alter a conversation", +); +assert.equal( + isOpenCodeSchemaBundle({ + ...unsigned, + schemas: [{ + ...BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0], + eventTypes: { + ...BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0].eventTypes, + toolStart: ["tool_use"], + toolComplete: ["tool_use"], + }, + }], + }, now), + false, + "a signed schema cannot assign one tool label to incompatible lifecycle states", +); +assert.equal( + isOpenCodeSchemaBundle({ + ...unsigned, + schemas: [{ + ...BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0], + eventTypes: { + ...BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0].eventTypes, + toolStart: ["tool_phase"], + toolEnd: ["tool_phase"], + }, + }], + }, now), + false, + "a signed schema cannot overlap start and end lifecycle labels", +); +assert.equal(isOpenCodeSchemaBundle({ ...unsigned, unexpected: true }, now), false, "unknown format-1 bundle fields fail closed"); +assert.equal(isOpenCodeSchemaBundle({ + ...unsigned, + signature: { algorithm: "ed25519", value: "signature", unexpected: true }, +}, now), false, "unknown signature fields fail closed"); +assert.equal(isOpenCodeSchemaBundle({ + ...unsigned, + schemas: [{ ...BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0], unexpected: true }], +}, now), false, "unknown schema fields fail closed"); +assert.equal(isOpenCodeSchemaBundle({ + ...unsigned, + schemas: [{ + ...BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0], + launch: { ...BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0].launch, requiredCapability: "model" }, + }], +}, now), false, "unknown launch constraints cannot be silently ignored"); +const protocolOnlySchema = { + ...BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0], + id: "protocol-only", + requires: { json: true as const, protocol: "json" }, + launch: { ...BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0].launch, sessionOption: undefined }, +}; +const sessionOnlySchema = { + ...BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0], + id: "session-only", + requires: { json: true as const, session: true }, +}; +assert.equal( + isOpenCodeSchemaBundle({ ...unsigned, schemas: [protocolOnlySchema, sessionOnlySchema] }, now), + false, + "distinct equal-specificity requirements that match one client are rejected before caching", +); +assert.equal( + isOpenCodeSchemaBundle({ ...unsigned, schemas: [{ ...BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0], shape: undefined }] }, now), + false, + "a selected schema must declare its parseable envelope and field aliases", +); +assert.equal( + isOpenCodeSchemaBundle({ + ...unsigned, + schemas: [{ + ...BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0], + shape: { + ...BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0].shape, + payloadKind: { field: "type", text: ["text"], tool: ["text"] }, + }, + }], + }, now), + false, + "a signed schema must distinguish text and tool payload kinds before rendering either", +); +assert.equal( + isOpenCodeSchemaBundle({ + ...unsigned, + schemas: [{ + ...BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0], + id: "nested-discriminator", + shape: { + ...BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0].shape, + envelope: [["event", "payload"]], + discriminator: { envelope: ["event", "payload"], field: "event" }, + }, + }], + }, now), + true, + "a signed schema can relocate the event discriminator into a bounded nested envelope", +); +const structuredSwitchCapabilities = { + version: "3.0.0", + json: true, + model: false, + session: false, + protocols: ["json-v3"], + options: ["--structured-output", "--event-stream"], + noValueOptions: ["--event-stream"], + structuredOutputs: [{ option: "--structured-output", values: ["json-v3"] }], +}; +const structuredSwitchSchema = { + ...BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0], + id: "structured-switch", + requires: { json: true as const, session: false, protocol: "json-v3" }, + launch: { structuredOutput: { option: "--structured-output", value: "json-v3" }, requiredFlags: ["--event-stream"] }, +}; +assert.equal( + isOpenCodeSchemaBundle({ ...unsigned, schemas: [structuredSwitchSchema] }, now), + true, + "a signed schema may use bounded output/event switches", +); +assert.equal( + selectOpenCodeSchema([structuredSwitchSchema], structuredSwitchCapabilities)?.id, + "structured-switch", + "a structured switch is selected only after its option and JSON value are observed in run help", +); +const booleanStructuredCapabilities = { + version: "3.1.0", + json: true, + model: false, + session: false, + protocols: ["json-v2"], + options: ["--event-json-v2"], + noValueOptions: ["--event-json-v2"], + structuredOutputs: [], + structuredSwitches: [{ option: "--event-json-v2", protocols: ["json-v2"] }], +}; +const partialRemoteUnsigned = { ...unsigned, sequence: 10, schemas: [protocolV2Schema] }; +const partialRemote = { + ...partialRemoteUnsigned, + signature: { + algorithm: "ed25519" as const, + value: sign(null, Buffer.from(openCodeSchemaBundleSigningPayload(partialRemoteUnsigned)), privateKey).toString("base64"), + }, +}; +const legacyAfterPartialRemote = await resolveOpenCodeCompatibility( + { + version: "legacy", + json: true, + model: false, + session: true, + protocols: ["json"], + options: ["--format", "--session"], + valueOptions: ["--format", "--session"], + structuredOutputs: [{ option: "--format", values: ["json"] }], + }, + { + cacheFile: path.join(await mkdtemp(path.join(tmpdir(), "cave-opencode-schema-partial-")), "bundle.json"), + publicKey: publicPem, + url: "https://registry.invalid/opencode.json", + now: () => now, + fetch: async () => new Response(JSON.stringify(partialRemote), { status: 200 }), + }, +); +assert.equal(legacyAfterPartialRemote.mode, "structured"); +assert.equal(legacyAfterPartialRemote.schema?.id, "opencode-run-json-v1", "a partial remote registry retains matching compiled baseline coverage"); +assert.equal(legacyAfterPartialRemote.bundleSource, "built-in"); +const broadRemoteSchema = { + ...BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0], + id: "remote-json-only", + requires: { json: true as const }, + launch: { ...BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0].launch, sessionOption: undefined }, +}; +const broadRemoteUnsigned = { ...unsigned, sequence: 12, schemas: [broadRemoteSchema] }; +const broadRemote = { + ...broadRemoteUnsigned, + signature: { + algorithm: "ed25519" as const, + value: sign(null, Buffer.from(openCodeSchemaBundleSigningPayload(broadRemoteUnsigned)), privateKey).toString("base64"), + }, +}; +const currentAfterBroadRemote = await resolveOpenCodeCompatibility( + { + version: "current", json: true, model: false, session: true, protocols: ["json"], + options: ["--format", "--session"], valueOptions: ["--format", "--session"], + structuredOutputs: [{ option: "--format", values: ["json"] }], + }, + { + cacheFile: path.join(await mkdtemp(path.join(tmpdir(), "cave-opencode-schema-broad-remote-")), "bundle.json"), + publicKey: publicPem, + url: "https://registry.invalid/opencode.json", + now: () => now, + fetch: async () => new Response(JSON.stringify(broadRemote), { status: 200 }), + }, +); +assert.equal(currentAfterBroadRemote.schema?.id, "opencode-run-json-v1", "a broad remote schema cannot preempt the more-specific built-in current-client profile"); +assert.equal(currentAfterBroadRemote.bundleSource, "built-in"); +const retiredPartialUnsigned = { ...partialRemoteUnsigned, sequence: 11, retiredSchemaIds: ["opencode-run-json-v1"] }; +const retiredPartial = { + ...retiredPartialUnsigned, + signature: { + algorithm: "ed25519" as const, + value: sign(null, Buffer.from(openCodeSchemaBundleSigningPayload(retiredPartialUnsigned)), privateKey).toString("base64"), + }, +}; +const retiredLegacy = await resolveOpenCodeCompatibility( + { + version: "legacy", json: true, model: false, session: true, protocols: ["json"], + options: ["--format", "--session"], valueOptions: ["--format", "--session"], + structuredOutputs: [{ option: "--format", values: ["json"] }], + }, + { + cacheFile: path.join(await mkdtemp(path.join(tmpdir(), "cave-opencode-schema-retired-")), "bundle.json"), + publicKey: publicPem, + url: "https://registry.invalid/opencode.json", + now: () => now, + fetch: async () => new Response(JSON.stringify(retiredPartial), { status: 200 }), + }, +); +assert.equal(retiredLegacy.mode, "plain", "a signed retirement can explicitly disable an unsafe compiled baseline profile"); +const booleanStructuredSchema = { + ...BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0], + id: "boolean-structured-switch", + requires: { json: true as const, protocol: "json-v2" }, + launch: { structuredOutput: { option: "--event-json-v2" }, requiredFlags: [] }, +}; +assert.equal(isOpenCodeSchemaBundle({ ...unsigned, schemas: [booleanStructuredSchema] }, now), true); +assert.equal( + selectOpenCodeSchema([booleanStructuredSchema], booleanStructuredCapabilities)?.id, + "boolean-structured-switch", + "a valueless structured switch is selected only after that exact switch is observed in run help", +); +const framingSchema = { + ...structuredSwitchSchema, + id: "structured-framing", + launch: { ...structuredSwitchSchema.launch, requiredFlags: ["--no-color", "--event-stream"] }, +}; +assert.equal( + isOpenCodeSchemaBundle({ ...unsigned, schemas: [framingSchema] }, now), + false, + "a registry schema cannot add even a harmless-looking flag outside the audited framing allowlist", +); +const toolEventsSchema = { + ...BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0], + id: "json-with-tool-events", + priority: 1, + launch: { ...BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0].launch, requiredFlags: ["--include-tool-events"] }, +}; +const toolEventsCapabilities = { + version: "future", + json: true, + model: false, + session: true, + protocols: ["json"], + options: ["--format", "--session", "--include-tool-events"], + valueOptions: ["--format", "--session"], + noValueOptions: ["--include-tool-events"], + structuredOutputs: [{ option: "--format", values: ["json"] }], +}; +assert.equal( + isOpenCodeSchemaBundle({ ...unsigned, schemas: [BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0], toolEventsSchema] }, now), + true, + "a versioned schema may require a locally audited output-only tool-event flag alongside the baseline", +); +assert.equal( + selectOpenCodeSchema([BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0], toolEventsSchema], toolEventsCapabilities)?.id, + "json-with-tool-events", + "a help-confirmed tool-event flag makes its parser more specific than the plain JSON baseline", +); +const shortToolEventsSchema = { + ...toolEventsSchema, + id: "json-with-short-tool-events", + launch: { ...toolEventsSchema.launch, requiredFlags: ["--tool-events"] }, +}; +assert.equal( + isOpenCodeSchemaBundle({ ...unsigned, schemas: [shortToolEventsSchema] }, now), + true, + "a versioned schema may add a documented neutral tool-event output flag within the audited grammar", +); +assert.equal( + selectOpenCodeSchema([shortToolEventsSchema], { + ...toolEventsCapabilities, + options: ["--format", "--session", "--tool-events"], + noValueOptions: ["--tool-events"], + })?.id, + "json-with-short-tool-events", + "a safe registry flag remains gated on the exact valueless option advertised by run help", +); +assert.equal( + selectOpenCodeSchema([toolEventsSchema], { + ...toolEventsCapabilities, + options: ["--format", "--session"], + noValueOptions: [], + }), + null, + "a schema requiring tool-event output is never selected until that exact valueless flag is declared by run help", +); +assert.equal( + isOpenCodeSchemaBundle({ + ...unsigned, + schemas: [{ + ...toolEventsSchema, + launch: { ...toolEventsSchema.launch, requiredFlags: ["--include-tool-input"] }, + }], + }, now), + false, + "a signed registry cannot treat arbitrary tool-data switches as output-only flags", +); +assert.equal( + isOpenCodeSchemaBundle({ ...unsigned, issuedAt: 0 }, now), + false, + "signed schema timestamps must be strings rather than coercible values", +); +assert.equal(isOpenCodeSchemaBundle({ + ...unsigned, + schemas: [{ + ...BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0], + requires: { json: true, session: true, futureCapability: true }, + }], +}, now), false, "unknown requirement keys cannot bypass schema matching"); +assert.equal(isOpenCodeSchemaBundle({ + ...unsigned, + schemas: [{ + ...BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0], + eventTypes: { ...BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0].eventTypes, text: [] }, + }], +}, now), false, "a structured schema must retain a trusted text mapping"); +assert.equal(isOpenCodeSchemaBundle({ + ...unsigned, + schemas: [{ + ...BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0], + launch: { ...BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0].launch, requiredFlags: ["--auto"] }, + }], +}, now), false, "a signed registry cannot add permission-affecting launch flags"); +assert.equal(isOpenCodeSchemaBundle({ + ...unsigned, + schemas: [{ + ...BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0], + eventTypes: { ignored: [], text: ["text"], toolStart: [], toolEnd: [], toolComplete: [], error: [] }, + }], +}, now), true, "a signed registry bundle from before tool-progress support remains valid"); + +const previousKey = generateKeyPairSync("ed25519"); +const nextKey = generateKeyPairSync("ed25519"); +const previousPem = previousKey.publicKey.export({ type: "spki", format: "pem" }).toString(); +const nextPem = nextKey.publicKey.export({ type: "spki", format: "pem" }).toString(); +const rotationUnsigned = { ...unsigned, sequence: 9, keyId: "previous" }; +const rotationSigned = { + ...rotationUnsigned, + signature: { + algorithm: "ed25519" as const, + value: sign(null, Buffer.from(openCodeSchemaBundleSigningPayload(rotationUnsigned)), previousKey.privateKey).toString("base64"), + }, +}; +const rotationCache = path.join(await mkdtemp(path.join(tmpdir(), "cave-opencode-schema-rotation-")), "bundle.json"); +const keyring = { current: nextPem, previous: previousPem }; +const rotated = await loadOpenCodeSchemaBundle({ + cacheFile: rotationCache, + publicKeys: keyring, + url: "https://registry.invalid/opencode.json", + now: () => now, + fetch: async () => new Response(JSON.stringify(rotationSigned), { status: 200 }), +}); +assert.equal(rotated.source, "remote"); +assert.equal(JSON.parse(await readFile(rotationCache, "utf8")).verifiedKeyId, "previous", "cache records the signed key used during overlap"); +const rotatedOffline = await loadOpenCodeSchemaBundle({ + cacheFile: rotationCache, + publicKeys: keyring, + url: "https://registry.invalid/opencode.json", + now: () => now + 7 * 60 * 60 * 1000, + fetch: async () => { throw new Error("offline"); }, +}); +assert.equal(rotatedOffline.bundle.sequence, 9, "an overlapping keyring preserves an old-key verified cache while offline"); + +const ed448 = generateKeyPairSync("ed448"); +const ed448PublicPem = ed448.publicKey.export({ type: "spki", format: "pem" }).toString(); +const ed448Signature = sign(null, Buffer.from(openCodeSchemaBundleSigningPayload(unsigned)), ed448.privateKey).toString("base64"); +assert.equal( + (await import("./opencode-compatibility.ts")).verifyOpenCodeSchemaBundle({ + ...unsigned, + signature: { algorithm: "ed25519", value: ed448Signature }, + }, ed448PublicPem, now), + false, + "an Ed448 key cannot be relabeled as an Ed25519 registry key", +); +assert.equal( + selectOpenCodeSchema([broadSchema, { ...broadSchema, id: "broad-duplicate" }], { version: "current", json: true, model: false, session: true, protocols: ["json"] }), + null, + "equally-specific overlapping schemas fail closed instead of depending on array order", +); + +const secretShape = redactedOpenCodeEventFingerprint({ + type: "future.event", + prompt: "do not persist this prompt", + part: { input: { token: "secret", path: "C:/private" } }, +}); +const changedSecretShape = redactedOpenCodeEventFingerprint({ + type: "future.event", + prompt: "a different prompt", + part: { input: { token: "another-secret", path: "/other" } }, +}); +assert.equal(secretShape, changedSecretShape, "diagnostic fingerprints are value-free"); +assert.equal( + redactedOpenCodeEventFingerprint({ type: "future.event", part: { input: { "C:/private/token": "secret" } } }), + redactedOpenCodeEventFingerprint({ type: "future.event", part: { input: { "/other/credential": "other-secret" } } }), + "diagnostic fingerprints do not retain untrusted payload keys", +); +assert.match(secretShape, /^[a-f0-9]{16}$/); + +assert.equal( + // Node's permissive base64 decoder accepts this suffix, but registry input must not. + (await import("./opencode-compatibility.ts")).verifyOpenCodeSchemaBundle({ + ...signed, + signature: { ...signed.signature, value: `${signed.signature.value}!` }, + }, publicPem, now), + false, + "malformed signature encodings are rejected before verification", +); + +const oversized = await loadOpenCodeSchemaBundle({ + cacheFile, + publicKey: publicPem, + url: "https://registry.invalid/opencode.json", + now: () => now + 21 * 60 * 60 * 1000, + fetch: async () => new Response("x".repeat(300 * 1024), { status: 200 }), +}); +assert.equal(oversized.source, "cache"); +assert.equal(oversized.diagnostic, "schema-registry-refresh-rejected", "oversized refreshes preserve the cache and report the rejected update"); +assert.equal((JSON.parse(await readFile(cacheFile, "utf8")) as { bundle: { sequence: number } }).bundle.sequence, 2); + +const oversizedCacheFile = path.join(await mkdtemp(path.join(tmpdir(), "cave-opencode-schema-oversized-cache-")), "bundle.json"); +await writeFile(oversizedCacheFile, "x".repeat(300 * 1024)); +const oversizedCache = await loadOpenCodeSchemaBundle({ + cacheFile: oversizedCacheFile, + publicKey: publicPem, + url: "https://registry.invalid/opencode.json", + now: () => now, + fetch: async () => { throw new Error("offline"); }, +}); +assert.equal(oversizedCache.source, "built-in", "an oversized local cache is never read into memory or selected"); +assert.equal(oversizedCache.diagnostic, "schema-registry-refresh-rejected"); + +const stalled = await loadOpenCodeSchemaBundle({ + cacheFile: path.join(path.dirname(cacheFile), "stalled-body.json"), + publicKey: publicPem, + url: "https://registry.invalid/opencode.json", + now: () => now, + refreshTimeoutMs: 10, + fetch: async () => new Response(new ReadableStream({ start() {} }), { status: 200 }), +}); +assert.equal(stalled.source, "built-in", "a response body that stalls after headers fails closed within the refresh deadline"); +assert.equal(stalled.diagnostic, "schema-registry-refresh-rejected"); + +const unsignedSequence3 = { ...unsigned, sequence: 3 }; +const signedSequence3 = { + ...unsignedSequence3, + signature: { + algorithm: "ed25519" as const, + value: sign(null, Buffer.from(openCodeSchemaBundleSigningPayload(unsignedSequence3)), privateKey).toString("base64"), + }, +}; +const staleLock = `${cacheFile}.lock`; +await writeFile(staleLock, `${process.pid}:reused-owner-token`); +await utimes(staleLock, new Date(0), new Date(0)); +const recoveredLock = await loadOpenCodeSchemaBundle({ + cacheFile, + publicKey: publicPem, + url: "https://registry.invalid/opencode.json", + now: () => now + 28 * 60 * 60 * 1000, + fetch: async () => new Response(JSON.stringify(signedSequence3), { status: 200 }), +}); +assert.equal(recoveredLock.bundle.sequence, 3, "a stale writer lock cannot permanently block cache recovery after a crash or PID reuse"); + +const writerWaitFile = path.join(await mkdtemp(path.join(tmpdir(), "cave-opencode-schema-writer-wait-")), "bundle.json"); +await writeFile(writerWaitFile, JSON.stringify({ checkedAt: now, bundle: signedRollback })); +const writerWaitUnsigned = { ...unsigned, sequence: 3 }; +const writerWaitBundle = { + ...writerWaitUnsigned, + signature: { + algorithm: "ed25519" as const, + value: sign(null, Buffer.from(openCodeSchemaBundleSigningPayload(writerWaitUnsigned)), privateKey).toString("base64"), + }, +}; +await writeFile(`${writerWaitFile}.lock`, "concurrent-writer"); +const writerCommit = new Promise((resolve) => setTimeout(() => { + void (async () => { + await writeFile(writerWaitFile, JSON.stringify({ checkedAt: now + 7 * 60 * 60 * 1000, bundle: writerWaitBundle })); + await rm(`${writerWaitFile}.lock`, { force: true }); + resolve(); + })(); +}, 20)); +const waitedForWriter = await loadOpenCodeSchemaBundle({ + cacheFile: writerWaitFile, + publicKey: publicPem, + url: "https://registry.invalid/opencode.json", + now: () => now + 7 * 60 * 60 * 1000, + fetch: async () => new Response(JSON.stringify(signed), { status: 200 }), +}); +await writerCommit; +assert.equal(waitedForWriter.bundle.sequence, 3, "a lock loser waits for a concurrent newer verified cache write before selecting a parser"); + +const unresolvedWriterFile = path.join(await mkdtemp(path.join(tmpdir(), "cave-opencode-schema-unresolved-writer-")), "bundle.json"); +await writeFile(unresolvedWriterFile, JSON.stringify({ checkedAt: now, bundle: signedRollback })); +await writeFile(`${unresolvedWriterFile}.lock`, "concurrent-writer"); +const unresolvedWriter = await resolveOpenCodeCompatibility( + { version: "current", json: true, model: false, session: true, protocols: ["json"] }, + { + cacheFile: unresolvedWriterFile, + publicKey: publicPem, + url: "https://registry.invalid/opencode.json", + now: () => now + 7 * 60 * 60 * 1000, + fetch: async () => new Response(JSON.stringify(signed), { status: 200 }), + }, +); +assert.equal(unresolvedWriter.mode, "plain", "a lock loser never selects an uncommitted older remote schema"); +assert.equal(unresolvedWriter.diagnostic, "schema-registry-refresh-rejected"); + +const rollbackRaceFile = path.join(await mkdtemp(path.join(tmpdir(), "cave-opencode-schema-rollback-race-")), "bundle.json"); +await writeFile(rollbackRaceFile, JSON.stringify({ checkedAt: now, bundle: signed })); +const rollbackRace = await loadOpenCodeSchemaBundle({ + cacheFile: rollbackRaceFile, + publicKey: publicPem, + url: "https://registry.invalid/opencode.json", + now: () => now + 7 * 60 * 60 * 1000, + fetch: async () => { + // Simulate another process accepting a newer contract after this process + // captured sequence 2 but before its sequence 2 response reaches the + // monotonic check. + await writeFile(rollbackRaceFile, JSON.stringify({ checkedAt: now + 7 * 60 * 60 * 1000, bundle: signedSequence3 })); + return new Response(JSON.stringify(signed), { status: 200 }); + }, +}); +assert.equal(rollbackRace.bundle.sequence, 3, "a rejected concurrent rollback re-reads and selects the newer verified cache"); +assert.equal(rollbackRace.source, "cache"); +assert.equal(rollbackRace.diagnostic, "schema-registry-refresh-rejected"); + +const concurrentCacheFile = path.join(await mkdtemp(path.join(tmpdir(), "cave-opencode-schema-concurrent-")), "bundle.json"); +await writeFile(concurrentCacheFile, JSON.stringify({ checkedAt: now, bundle: signed })); +let fetchCalls = 0; +let releaseRefresh: (() => void) | undefined; +let markFetchStarted: (() => void) | undefined; +const delayedResponse = new Promise((resolve) => { releaseRefresh = resolve; }); +const fetchStarted = new Promise((resolve) => { markFetchStarted = resolve; }); +const concurrentSource = { + cacheFile: concurrentCacheFile, + publicKey: publicPem, + url: "https://registry.invalid/opencode.json", + now: () => now + 7 * 60 * 60 * 1000, + fetch: async () => { + fetchCalls += 1; + markFetchStarted?.(); + await delayedResponse; + return new Response(JSON.stringify(signedSequence3), { status: 200 }); + }, +}; +const staleA = loadOpenCodeSchemaBundle(concurrentSource); +const staleB = loadOpenCodeSchemaBundle(concurrentSource); +await fetchStarted; +assert.equal(fetchCalls, 1, "concurrent stale readers coalesce to one registry request"); +releaseRefresh?.(); +const [resolvedA, resolvedB] = await Promise.all([ + staleA, + staleB, +]); +assert.equal(resolvedA.bundle.sequence, 3); +assert.equal(resolvedB.bundle.sequence, 3, "concurrent stale readers receive the one verified refresh result"); +assert.equal((JSON.parse(await readFile(concurrentCacheFile, "utf8")) as { bundle: { sequence: number } }).bundle.sequence, 3); + +await writeFile(cacheFile, JSON.stringify({ checkedAt: now, bundle: signed })); +const expiredRemoteWithLiveBaseline = await resolveOpenCodeCompatibility( + { version: "2.0.0", json: true, model: true, session: true, protocols: ["json"] }, + { + cacheFile, + publicKey: publicPem, + url: "https://registry.invalid/opencode.json", + now: () => Date.parse("2027-01-01T00:00:00.000Z"), + fetch: async () => { throw new Error("offline"); }, + }, +); +assert.equal(expiredRemoteWithLiveBaseline.mode, "plain", "an expired remote contract never revives the older compiled parser while refresh is unavailable"); +assert.equal(expiredRemoteWithLiveBaseline.diagnostic, "cached-schema-unavailable"); + +const expiredRemoteOnly = await resolveOpenCodeCompatibility( + { version: "2.0.0", json: true, model: true, session: true, protocols: ["json"] }, + { + cacheFile, + publicKey: publicPem, + url: "https://registry.invalid/opencode.json", + now: () => Date.parse("2031-01-01T00:00:00.000Z"), + fetch: async () => { throw new Error("offline"); }, + }, +); +assert.equal(expiredRemoteOnly.mode, "plain", "an expired remote cache never extends an expired shipped parser"); +assert.equal(expiredRemoteOnly.diagnostic, "cached-schema-unavailable"); + +const expiredSigned = { ...signedSequence3, expiresAt: "2026-12-24T00:00:00.000Z", signature: { ...signedSequence3.signature } }; +expiredSigned.signature.value = sign(null, Buffer.from(openCodeSchemaBundleSigningPayload(expiredSigned)), privateKey).toString("base64"); +const rewrittenExpiredSequence = { ...expiredSigned, expiresAt: "2032-12-24T00:00:00.000Z", signature: { ...expiredSigned.signature } }; +rewrittenExpiredSequence.signature.value = sign(null, Buffer.from(openCodeSchemaBundleSigningPayload(rewrittenExpiredSequence)), privateKey).toString("base64"); +await writeFile(cacheFile, JSON.stringify({ checkedAt: now, bundle: expiredSigned })); +const expiredRewrite = await loadOpenCodeSchemaBundle({ + cacheFile, + publicKey: publicPem, + url: "https://registry.invalid/opencode.json", + now: () => Date.parse("2031-01-01T00:00:00.000Z"), + fetch: async () => new Response(JSON.stringify(rewrittenExpiredSequence), { status: 200 }), +}); +assert.equal(expiredRewrite.source, "built-in", "an expired cache still anchors immutable sequence identity"); +assert.equal(expiredRewrite.diagnostic, "cached-schema-unavailable"); +assert.equal((JSON.parse(await readFile(cacheFile, "utf8")) as { bundle: { expiresAt: string } }).bundle.expiresAt, expiredSigned.expiresAt, "a same-sequence rewrite never replaces expired cache metadata"); + +quarantineOpenCodeSchema(BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0]); +const quarantined = await resolveOpenCodeCompatibility( + { version: "current", json: true, model: false, session: true, protocols: ["json"] }, + { + cacheFile: path.join(await mkdtemp(path.join(tmpdir(), "cave-opencode-schema-quarantine-")), "bundle.json"), + publicKey: publicPem, + url: "https://registry.invalid/opencode.json", + now: () => now, + fetch: async () => { throw new Error("offline"); }, + }, +); +assert.equal(quarantined.mode, "plain", "an incompatible structured profile is never relaunched in structured mode"); +assert.equal(quarantined.diagnostic, "schema-quarantined", "the next turn receives a stable safe-fallback diagnostic"); + +const repairedSchema = { + ...BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0], + shape: { ...BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0].shape, text: ["text", "content", "body"] }, +}; +const repairedUnsigned = { ...unsigned, sequence: 99, schemas: [repairedSchema] }; +const repairedBundle = { + ...repairedUnsigned, + signature: { + algorithm: "ed25519" as const, + value: sign(null, Buffer.from(openCodeSchemaBundleSigningPayload(repairedUnsigned)), privateKey).toString("base64"), + }, +}; +const repaired = await resolveOpenCodeCompatibility( + { version: "current", json: true, model: false, session: true, protocols: ["json"] }, + { + cacheFile: path.join(await mkdtemp(path.join(tmpdir(), "cave-opencode-schema-repaired-")), "bundle.json"), + publicKey: publicPem, + url: "https://registry.invalid/opencode.json", + now: () => now, + fetch: async () => new Response(JSON.stringify(repairedBundle), { status: 200 }), + }, +); +assert.equal(repaired.mode, "structured", "a signed schema revision with the same id clears the obsolete quarantine"); + +console.log("opencode-compatibility.test.ts: ok"); diff --git a/src/lib/opencode-compatibility.ts b/src/lib/opencode-compatibility.ts new file mode 100644 index 000000000..70f9891ce --- /dev/null +++ b/src/lib/opencode-compatibility.ts @@ -0,0 +1,1376 @@ +import { createHash, createPublicKey, randomBytes, verify } from "node:crypto"; +import * as registryFs from "node:fs/promises"; +import { homedir } from "node:os"; + +type RegistryFs = typeof registryFs; + +function requireRegistryFs(): RegistryFs { + return registryFs; +} + +export type OpenCodeRunCapabilities = { + version: string | null; + json: boolean; + model: boolean; + session: boolean; + /** Explicit, documented structured-output format values from `run --help`. */ + protocols: string[]; + /** Declared `run` option names and structured-output option/value pairs. */ + options?: string[]; + /** Declared run options whose synopsis explicitly accepts an argument. */ + valueOptions?: string[]; + /** Declared run flags that take no value and are safe to forward verbatim. */ + noValueOptions?: string[]; + /** Whether `run --help` explicitly documents `--` as an option delimiter. */ + endOfOptions?: boolean; + /** Declared valueless switches that explicitly request a JSON protocol. */ + structuredSwitches?: Array<{ option: string; protocols: string[] }>; + structuredOutputs?: Array<{ option: string; values: string[] }>; +}; + +/** A direct field or a bounded two-segment envelope path. */ +export type OpenCodeEnvelopePath = string | [string, string]; +export type OpenCodeRegistryKeyring = Record; +/** Immutable release checkpoint used to prevent first-use registry replays. */ +export type OpenCodeRegistryCheckpoint = { sequence: number; payloadHash: string }; + +export type OpenCodeEventSchema = { + id: string; + /** Signed tie-breaker for clients that advertise multiple valid protocols. */ + priority?: number; + /** A schema is selected only when every advertised requirement is met. */ + requires: { json: true; session?: boolean; model?: boolean; protocol?: string }; + eventTypes: { + /** Documented lifecycle/status frames that carry no renderable content. */ + ignored: string[]; + text: string[]; + toolStart: string[]; + /** Nonterminal output/status updates for an already-announced tool call. + * Optional so pre-progress signed registry bundles remain valid. */ + toolProgress?: string[]; + toolEnd: string[]; + toolComplete: string[]; + error: string[]; + }; + /** + * Bounded, declarative parser contract for a compatible JSON envelope. + * Values are direct field aliases only — arbitrary JSON paths or executable + * selectors are deliberately not accepted from the remote registry. + */ + shape: { + envelope: OpenCodeEnvelopePath[]; + /** The bounded location and alias that identifies the event kind. */ + discriminator: { + envelope: OpenCodeEnvelopePath; + field: string; + }; + /** Envelope(s) explicitly trusted to carry assistant text. */ + textEnvelope?: OpenCodeEnvelopePath[]; + /** Envelope(s) explicitly trusted to carry tool input/output/state. */ + toolEnvelope?: OpenCodeEnvelopePath[]; + /** Envelope(s) that carry the stable tool-call id; defaults to payload/root. */ + idEnvelope?: OpenCodeEnvelopePath[]; + /** Declares the payload's own kind discriminator before it may render. */ + payloadKind: { + field: string; + text: string[]; + tool: string[]; + }; + sessionId: string[]; + id: string[]; + name: string[]; + text: string[]; + state: string[]; + input: string[]; + output: string[]; + error: string[]; + status: string[]; + terminalStates: string[]; + errorStates: string[]; + }; + /** Bounded argv contract, confirmed against the installed client's help. */ + launch: { + structuredOutput: { option: string; value?: string }; + sessionOption?: "--session" | "--resume"; + requiredFlags: string[]; + /** May use `--` only when the installed CLI documents the delimiter. */ + endOfOptions?: true; + }; +}; + +export type OpenCodeSchemaBundle = { + format: 1; + runtime: "opencode"; + sequence: number; + issuedAt: string; + expiresAt: string; + /** Signed registry-key identifier; required when a keyring has more than one key. */ + keyId?: string; + /** Signed, explicit retirement of compiled baseline schema IDs. */ + retiredSchemaIds?: string[]; + schemas: OpenCodeEventSchema[]; + signature?: { algorithm: "ed25519"; value: string }; +}; + +export type OpenCodeCompatibility = { + mode: "structured" | "plain"; + capabilities: OpenCodeRunCapabilities; + schema?: OpenCodeEventSchema; + bundleSource: "built-in" | "cache" | "remote"; + diagnostic?: OpenCodeCompatibilityDiagnostic; +}; + +/** These stable codes are intentionally safe to render or log. Never include event payloads. */ +export type OpenCodeCompatibilityDiagnostic = + | "json-format-unavailable" + | "no-compatible-schema" + | "schema-quarantined" + | "schema-registry-refresh-rejected" + | "cached-schema-unavailable"; + +type LoadedOpenCodeSchemaBundle = { + bundle: OpenCodeSchemaBundle; + source: "built-in" | "cache" | "remote"; + diagnostic?: "schema-registry-refresh-rejected" | "cached-schema-unavailable"; + /** A verified-but-expired remote contract existed, so the built-in parser is unsafe. */ + remoteSchemaRequired?: boolean; +}; + +/** A value-free event-shape identifier for diagnostics. It deliberately keeps + * field names and primitive kinds, but never prompt text, paths, tool input, + * output, credentials, or an unknown payload's values. */ +export function redactedOpenCodeEventFingerprint(value: unknown): string { + // Event payloads are open-ended maps: a provider can put a path, prompt, or + // credential in either a value *or a key*. Only retain a small, fixed set of + // transport-envelope fields, and represent input/output maps as opaque. + // This keeps the fingerprint useful for envelope evolution without turning + // diagnostics into a side channel for user data. + const safeEnvelopeKeys = new Set([ + "type", "sessionID", "sessionId", "session_id", "part", "data", "state", + "id", "callID", "callId", "toolCallId", "tool_call_id", "tool", "name", "status", + ]); + const payloadKeys = new Set(["input", "output", "error", "prompt", "text", "content"]); + const shape = (input: unknown, depth = 0): unknown => { + if (depth >= 3) return Array.isArray(input) ? "array" : typeof input; + if (Array.isArray(input)) return input.length ? [shape(input[0], depth + 1)] : ["empty"]; + if (!isRecord(input)) return typeof input; + const entries = Object.entries(input) + .filter(([key]) => safeEnvelopeKeys.has(key) || payloadKeys.has(key)) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, child]) => [key, payloadKeys.has(key) ? "redacted" : shape(child, depth + 1)]); + return entries.length ? Object.fromEntries(entries) : "object"; + }; + return createHash("sha256").update(JSON.stringify(shape(value))).digest("hex").slice(0, 16); +} + +const MAX_SCHEMA_BUNDLE_BYTES = 256 * 1024; +// Cached records wrap a verified bundle with timestamps/key metadata and are +// pretty-printed for recoverability. Keep their read cap independently +// bounded, but large enough that every accepted remote bundle remains usable +// after the wrapper is written. +const MAX_SCHEMA_CACHE_RECORD_BYTES = 512 * 1024; +const MAX_TRUST_ANCHOR_JOURNAL_ENTRIES = 16; +// A journal is local mutable state. Never enumerate an attacker-created +// directory without a ceiling: the cache reader runs on every compatibility +// decision and must fail closed rather than retain an unbounded filename list. +const MAX_TRUST_ANCHOR_JOURNAL_DIRECTORY_ENTRIES = MAX_TRUST_ANCHOR_JOURNAL_ENTRIES * 2; +const CACHE_TTL_MS = 6 * 60 * 60 * 1000; +const CACHE_FILE = "opencode-schema-bundle-v1.json"; +const REFRESH_TIMEOUT_MS = 5_000; +const CACHE_LOCK_STALE_MS = 30_000; +const CACHE_LOCK_WAIT_MS = 500; +const CACHE_LOCK_POLL_MS = 20; +const REFRESH_FAILURE_BACKOFF_MS = 60_000; +class CacheWriterPendingError extends Error {} +/** + * Two locally persisted records at one sequence must name exactly one signed + * payload. Treat a disagreement as a durable trust failure: returning + * `null` would make a subsequent remote response appear to be first use and + * allow either side of the rewrite to win. + */ +class CacheTrustConflictError extends Error {} +const refreshFlights = new Map>(); +const refreshRetryAt = new Map(); +// A selected schema that emits an unknown or malformed frame is unsafe for +// further structured launches in this process. Keep the bounded quarantine by +// schema identity so a registry update with a new profile can recover without +// replaying the incompatible turn (which may already have run tools). +const quarantinedSchemaRevisions = new Map(); +const MAX_QUARANTINED_SCHEMA_IDS = 64; +/** + * The release ships sequence 1 as an immutable registry genesis contract. + * A remote registry may advance it, but a fresh install must never accept a + * different signed history at or below this floor just because it has no + * writable cache yet. + */ +export const OPENCODE_REGISTRY_GENESIS_SEQUENCE = 1; + +export function quarantineOpenCodeSchema(schema: OpenCodeEventSchema | undefined): void { + if (!schema) return; + const revision = createHash("sha256").update(stableJson(schema)).digest("hex"); + if (quarantinedSchemaRevisions.get(schema.id) === revision) return; + if (quarantinedSchemaRevisions.size >= MAX_QUARANTINED_SCHEMA_IDS) { + const oldest = quarantinedSchemaRevisions.keys().next(); + if (!oldest.done) quarantinedSchemaRevisions.delete(oldest.value); + } + quarantinedSchemaRevisions.set(schema.id, revision); +} + +/** + * Shipped schemas remain usable offline. Selection is capability-based: a + * version string is recorded for support, but never used as a compatibility + * threshold. Remote schemas can be added without releasing Cave. + */ +export const BUILTIN_OPENCODE_SCHEMA_BUNDLE: OpenCodeSchemaBundle = { + format: 1, + runtime: "opencode", + sequence: OPENCODE_REGISTRY_GENESIS_SEQUENCE, + issuedAt: "2026-07-24T00:00:00.000Z", + expiresAt: "2030-01-01T00:00:00.000Z", + schemas: [ + { + id: "opencode-run-json-v1", + requires: { json: true, session: true, protocol: "json" }, + eventTypes: { + ignored: ["step_start", "step_finish", "reasoning"], + text: ["text"], + // Current `run --format json` emits only terminal `tool_use` frames + // with a `part` envelope. Keep old split/root labels in the separately + // selected legacy profile so an evolved stream fails closed instead of + // being mistaken for trusted tool activity. + toolStart: [], + toolProgress: [], + toolEnd: [], + toolComplete: ["tool_use"], + error: ["error"], + }, + shape: { + envelope: ["part", "data", "root"], + discriminator: { envelope: "root", field: "type" }, + textEnvelope: ["part", "data"], + toolEnvelope: ["part"], + payloadKind: { field: "type", text: ["text"], tool: ["tool"] }, + sessionId: ["sessionID", "sessionId", "session_id"], + id: ["id", "callID", "callId", "toolCallId", "tool_call_id"], + name: ["tool", "name"], text: ["text", "content"], state: ["state"], input: ["input"], output: ["output"], error: ["error"], status: ["status"], + terminalStates: ["completed", "complete", "error", "failed", "cancelled", "canceled", "aborted", "interrupted", "timeout", "timed_out"], + errorStates: ["error", "failed", "cancelled", "canceled", "aborted", "interrupted", "timeout", "timed_out"], + }, + launch: { structuredOutput: { option: "--format", value: "json" }, sessionOption: "--session", requiredFlags: [] }, + }, + { + // Earlier and preview clients used generic tool envelopes. Their + // `json` format is not distinguishable from the current envelope by + // flags alone, so it must advertise this separate protocol marker + // before structured parsing is allowed. A client that only says + // `--format json` safely uses plain output until a verified schema can + // prove its envelope contract. + id: "opencode-run-json-legacy", + requires: { json: true, protocol: "json-legacy" }, + eventTypes: { + ignored: ["step_start", "step_finish", "reasoning"], + text: ["message", "assistant_text"], + toolStart: ["tool"], + toolProgress: [], + toolEnd: ["tool_output"], + toolComplete: ["tool_call"], + error: ["error", "failed"], + }, + shape: { + envelope: ["part", "data", "root"], + discriminator: { envelope: "root", field: "type" }, + textEnvelope: ["part", "data"], + toolEnvelope: ["data", "root"], + payloadKind: { field: "type", text: ["text"], tool: ["tool"] }, + sessionId: ["sessionID", "sessionId", "session_id"], + id: ["id", "callID", "callId", "toolCallId", "tool_call_id"], + name: ["tool", "name"], text: ["text", "content"], state: ["state"], input: ["input"], output: ["output"], error: ["error"], status: ["status"], + terminalStates: ["completed", "complete", "error", "failed", "cancelled", "canceled", "aborted", "interrupted", "timeout", "timed_out"], + errorStates: ["error", "failed", "cancelled", "canceled", "aborted", "interrupted", "timeout", "timed_out"], + }, + launch: { structuredOutput: { option: "--format", value: "json-legacy" }, requiredFlags: [] }, + }, + ], +}; + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function hasBoundedAliases(value: unknown): value is string[] { + return Array.isArray(value) + && value.length > 0 + && value.length <= 16 + && value.every((alias) => typeof alias === "string" && /^[A-Za-z][A-Za-z0-9_-]{0,79}$/.test(alias)); +} + +function hasValidEnvelopePath(value: unknown): value is OpenCodeEnvelopePath { + if (typeof value === "string") return value === "root" || /^[A-Za-z][A-Za-z0-9_-]{0,79}$/.test(value); + return Array.isArray(value) + && value.length === 2 + && value.every((segment) => typeof segment === "string" && /^[A-Za-z][A-Za-z0-9_-]{0,79}$/.test(segment)); +} + +function hasValidShape(value: unknown): boolean { + if (!isRecord(value)) return false; + const aliasKeys = ["sessionId", "id", "name", "text", "state", "input", "output", "error", "status", "terminalStates", "errorStates"]; + const envelopeFields = (fields: unknown): fields is OpenCodeEnvelopePath[] => + Array.isArray(fields) + && fields.length > 0 + && fields.length <= 4 + && fields.every(hasValidEnvelopePath); + if (!Object.keys(value).every((key) => key === "envelope" || key === "textEnvelope" || key === "toolEnvelope" || key === "idEnvelope" || key === "payloadKind" || key === "discriminator" || aliasKeys.includes(key))) return false; + if (!envelopeFields(value.envelope) + || (value.textEnvelope !== undefined && !envelopeFields(value.textEnvelope)) + || (value.toolEnvelope !== undefined && !envelopeFields(value.toolEnvelope)) + || (value.idEnvelope !== undefined && !envelopeFields(value.idEnvelope))) return false; + if (!isRecord(value.discriminator) + || !Object.keys(value.discriminator).every((key) => key === "envelope" || key === "field") + || !hasValidEnvelopePath(value.discriminator.envelope) + || typeof value.discriminator.field !== "string" + || !/^[A-Za-z][A-Za-z0-9_-]{0,79}$/.test(value.discriminator.field)) return false; + if (!isRecord(value.payloadKind)) return false; + const payloadKind = value.payloadKind; + if (!Object.keys(payloadKind).every((key) => key === "field" || key === "text" || key === "tool") + || typeof payloadKind.field !== "string" + || !/^[A-Za-z][A-Za-z0-9_-]{0,79}$/.test(payloadKind.field)) return false; + const textKinds = payloadKind.text; + const toolKinds = payloadKind.tool; + if (!hasBoundedAliases(textKinds) || !hasBoundedAliases(toolKinds)) return false; + if (textKinds.some((kind) => toolKinds.includes(kind))) return false; + return aliasKeys.every((key) => hasBoundedAliases(value[key])); +} + +const SAFE_STRUCTURED_LAUNCH_OPTION = /^--[a-z0-9-]*(?:format|output|json|event|stream)[a-z0-9-]*$/i; +const UNSAFE_STRUCTURED_LAUNCH_OPTIONS = new Set([ + "--auto", "--permission", "--sandbox", "--skip-permissions", "--dangerously-skip-permissions", "--trust-all-tools", "--yolo", +]); +// Format 1 can add only output-event switches. This constrained grammar and +// denylist keep a signed registry from widening tool permissions or execution +// policy, while allowing a newly documented neutral flag (for example +// `--tool-events`) without requiring an app release. Every flag is separately +// confirmed as a declared *valueless* `opencode run` option before selection. +const SAFE_STRUCTURED_REQUIRED_FLAG = /^--(?:(?:include|emit|output|show)-)?(?:(?:tool-(?:event|events|stream|streams))|(?:(?:event|events|stream|streams)(?:-[a-z0-9]+)*))$/i; +const MAX_SAFE_STRUCTURED_REQUIRED_FLAGS = 4; + +function safeStructuredLaunchOption(value: unknown): value is string { + return typeof value === "string" + && value.length <= 80 + && SAFE_STRUCTURED_LAUNCH_OPTION.test(value) + && !UNSAFE_STRUCTURED_LAUNCH_OPTIONS.has(value.toLowerCase()); +} + +function hasValidLaunch(value: unknown, requires: Record): boolean { + if (!isRecord(value) || !Array.isArray(value.requiredFlags)) return false; + if (!Object.keys(value).every((key) => key === "structuredOutput" || key === "sessionOption" || key === "requiredFlags" || key === "endOfOptions")) return false; + const structuredOutput = value.structuredOutput; + if (!isRecord(structuredOutput)) return false; + if (!Object.keys(structuredOutput).every((key) => key === "option" || key === "value")) return false; + if (!safeStructuredLaunchOption(structuredOutput.option)) return false; + if (structuredOutput.value !== undefined && (typeof structuredOutput.value !== "string" || !/^[a-z0-9][a-z0-9._-]{0,63}$/i.test(structuredOutput.value))) return false; + if (structuredOutput.value !== undefined && structuredOutput.value !== (typeof requires.protocol === "string" ? requires.protocol : "json")) return false; + // A switch without a value must still be tied to a protocol-specific + // requirement; otherwise a signed schema could turn any valueless CLI flag + // into a structured-output request. + if (structuredOutput.value === undefined && (typeof requires.protocol !== "string" || !structuredOutput.option.toLowerCase().includes("json"))) return false; + if (value.sessionOption !== undefined && value.sessionOption !== "--session" && value.sessionOption !== "--resume") return false; + if (value.endOfOptions !== undefined && value.endOfOptions !== true) return false; + if (requires.session === true && value.sessionOption === undefined) return false; + return value.requiredFlags.length <= MAX_SAFE_STRUCTURED_REQUIRED_FLAGS + && value.requiredFlags.every((flag) => typeof flag === "string" && SAFE_STRUCTURED_REQUIRED_FLAG.test(flag) && !UNSAFE_STRUCTURED_LAUNCH_OPTIONS.has(flag.toLowerCase())) + && new Set(value.requiredFlags).size === value.requiredFlags.length + && !value.requiredFlags.includes(structuredOutput.option); +} + +function isEventSchema(value: unknown): value is OpenCodeEventSchema { + if (!isRecord(value) || typeof value.id !== "string" || value.id.length === 0 || value.id.length > 128 || !isRecord(value.eventTypes) || !isRecord(value.requires)) return false; + if (!Object.keys(value).every((key) => key === "id" || key === "priority" || key === "requires" || key === "eventTypes" || key === "shape" || key === "launch")) return false; + if (value.priority !== undefined && (typeof value.priority !== "number" || !Number.isSafeInteger(value.priority) || value.priority < 0 || value.priority > 100)) return false; + if (!hasValidShape(value.shape)) return false; + if (!hasValidLaunch(value.launch, value.requires)) return false; + if (!Object.keys(value.requires).every((key) => key === "json" || key === "session" || key === "model" || key === "protocol")) return false; + if (value.requires.json !== true || (value.requires.session !== undefined && typeof value.requires.session !== "boolean") || (value.requires.model !== undefined && typeof value.requires.model !== "boolean") || (value.requires.protocol !== undefined && (typeof value.requires.protocol !== "string" || !/^[a-z0-9][a-z0-9._-]{0,63}$/i.test(value.requires.protocol)))) return false; + const requiredEventKeys: Array = ["ignored", "text", "toolStart", "toolEnd", "toolComplete", "error"]; + // `isRecord` deliberately narrows external JSON to unknown values. Keep that + // boundary while validating, then use the internal shape for the duplicate + // label checks below. + const eventTypes = value.eventTypes as unknown as OpenCodeEventSchema["eventTypes"]; + const optionalProgressEventKeys: Array = + eventTypes.toolProgress === undefined ? [] : ["toolProgress"]; + const eventKeys = [...requiredEventKeys, ...optionalProgressEventKeys]; + if ( + !Object.keys(eventTypes).every((key) => requiredEventKeys.includes(key as keyof OpenCodeEventSchema["eventTypes"]) || key === "toolProgress") + || !requiredEventKeys.every((key) => Object.hasOwn(eventTypes, key)) + || !eventKeys.every((key) => { + const labels = eventTypes[key]; + // Text is the only universal structured-output contract. Every other + // category is protocol-shape optional: a text-only client has no tool + // frames, and a split-lifecycle client has no combined completion frame. + // Empty arrays are authoritative retirements, not invitations to use the + // built-in aliases. + const mayBeEmpty = key !== "text"; + return Array.isArray(labels) + && (mayBeEmpty || labels.length > 0) + && labels.length <= 32 + && labels.every((type: unknown) => typeof type === "string" && type.length > 0 && type.length <= 80); + })) return false; + // Event dispatch is category-first, so a label can never safely represent + // two phases. Reject every cross-category overlap, including start/end, + // instead of relying on parser order to resolve a publisher mistake. + const assignedLabels = new Set(); + for (const key of eventKeys) { + for (const label of eventTypes[key] as string[]) { + if (assignedLabels.has(label)) return false; + assignedLabels.add(label); + } + } + return true; +} + +function schemaMatches(schema: OpenCodeEventSchema, capabilities: OpenCodeRunCapabilities): boolean { + if (!capabilities.json) return false; + // Capability requirements are one-way: a client gaining an unrelated flag + // must not stop matching a known envelope. Explicit exclusions would need a + // separately audited contract rather than overloading `false` here. + if (schema.requires.session === true && !capabilities.session) return false; + if (schema.requires.model === true && !capabilities.model) return false; + // Older callers/tests did not carry protocol markers; their documented JSON + // surface is the v1 `json` protocol. New probes always populate this list. + const protocols = capabilities.protocols ?? (capabilities.json ? ["json"] : []); + if (schema.requires.protocol !== undefined && !protocols.includes(schema.requires.protocol)) return false; + const structuredOutputs = capabilities.structuredOutputs ?? [{ option: "--format", values: protocols }]; + const structuredSwitches = capabilities.structuredSwitches ?? []; + // Older programmatic callers predate per-option argument evidence; live + // probes always supply it. Preserve their documented option contract while + // refusing a current probe that marks an option valueless. + const valueOptions = new Set(capabilities.valueOptions ?? capabilities.options ?? ["--format", "--output", "--session", "--resume", "--model"]); + if (schema.launch.structuredOutput.value === undefined) { + if (!structuredSwitches.some((output) => output.option === schema.launch.structuredOutput.option && output.protocols.includes(schema.requires.protocol ?? "json"))) return false; + } else if (!valueOptions.has(schema.launch.structuredOutput.option) || !structuredOutputs.some((output) => output.option === schema.launch.structuredOutput.option && output.values.includes(schema.launch.structuredOutput.value!))) return false; + const options = new Set(capabilities.options ?? ["--format", "--output", "--session", "--resume", "--model"]); + if (!options.has(schema.launch.structuredOutput.option)) return false; + if (schema.launch.sessionOption && (!options.has(schema.launch.sessionOption) || !valueOptions.has(schema.launch.sessionOption))) return false; + const noValueOptions = new Set(capabilities.noValueOptions ?? []); + if (schema.launch.requiredFlags.some((flag) => !options.has(flag) || !noValueOptions.has(flag))) return false; + return true; +} + +function schemaSpecificity(schema: OpenCodeEventSchema): number { + // A confirmed output-only flag is also a capability discriminator. This + // lets a remote schema safely supersede a baseline JSON parser only for a + // client that advertises the additional event stream it needs, while the + // baseline remains available to otherwise-compatible older clients. + return Number(schema.requires.session === true) + + Number(schema.requires.model === true) + + Number(schema.requires.protocol !== undefined) + + schema.launch.requiredFlags.length; +} + +function schemaPriority(schema: OpenCodeEventSchema): number { + return schema.priority ?? 0; +} + +/** + * Select the most specific matching schema rather than trusting registry + * order. Signed priority selects a client that advertises multiple documented + * protocols; an equal-priority tie still fails closed instead of guessing. + */ +export function selectOpenCodeSchema( + schemas: OpenCodeEventSchema[], + capabilities: OpenCodeRunCapabilities, +): OpenCodeEventSchema | null { + const matches = schemas.filter((schema) => schemaMatches(schema, capabilities)); + if (!matches.length) return null; + const specificity = Math.max(...matches.map(schemaSpecificity)); + const mostSpecific = matches.filter((schema) => schemaSpecificity(schema) === specificity); + const priority = Math.max(...mostSpecific.map(schemaPriority)); + const preferred = mostSpecific.filter((schema) => schemaPriority(schema) === priority); + return preferred.length === 1 ? preferred[0] : null; +} + +const CANONICAL_RFC3339_UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; + +function parseCanonicalTimestamp(value: unknown): number | null { + if (typeof value !== "string" || !CANONICAL_RFC3339_UTC.test(value)) return null; + const timestamp = Date.parse(value); + return Number.isFinite(timestamp) && new Date(timestamp).toISOString() === value ? timestamp : null; +} + +function requirementsOverlap(left: OpenCodeEventSchema, right: OpenCodeEventSchema): boolean { + const compatible = (a: T | undefined, b: T | undefined) => a === undefined || b === undefined || a === b; + return compatible(left.requires.session === true ? true : undefined, right.requires.session === true ? true : undefined) + && compatible(left.requires.model === true ? true : undefined, right.requires.model === true ? true : undefined) + && compatible(left.requires.protocol, right.requires.protocol); +} + +export function isOpenCodeSchemaBundle( + value: unknown, + now = Date.now(), + options: { allowExpired?: boolean } = {}, +): value is OpenCodeSchemaBundle { + if (!isRecord(value) || value.format !== 1 || value.runtime !== "opencode") return false; + if (!Object.keys(value).every((key) => key === "format" || key === "runtime" || key === "sequence" || key === "issuedAt" || key === "expiresAt" || key === "keyId" || key === "retiredSchemaIds" || key === "schemas" || key === "signature")) return false; + if (typeof value.sequence !== "number" || !Number.isSafeInteger(value.sequence) || value.sequence < 1 || !Array.isArray(value.schemas) || value.schemas.length === 0 || value.schemas.length > 64 || !value.schemas.every(isEventSchema)) return false; + if (value.keyId !== undefined && (typeof value.keyId !== "string" || !/^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(value.keyId))) return false; + if (value.retiredSchemaIds !== undefined && ( + !Array.isArray(value.retiredSchemaIds) + || value.retiredSchemaIds.length > BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas.length + || !value.retiredSchemaIds.every((id) => typeof id === "string" && BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas.some((schema) => schema.id === id)) + || new Set(value.retiredSchemaIds).size !== value.retiredSchemaIds.length + )) return false; + if (value.signature !== undefined && (!isRecord(value.signature) + || !Object.keys(value.signature).every((key) => key === "algorithm" || key === "value") + || value.signature.algorithm !== "ed25519" + || typeof value.signature.value !== "string")) return false; + const issuedAt = parseCanonicalTimestamp(value.issuedAt); + const expiresAt = parseCanonicalTimestamp(value.expiresAt); + if (issuedAt === null || expiresAt === null || issuedAt > now || expiresAt <= issuedAt || (!options.allowExpired && expiresAt <= now)) return false; + // A duplicated requirement profile would be an ambiguous same-specificity + // selection for the corresponding client capabilities. Reject it at the + // signed-bundle boundary, before it can affect a chat turn. + const ids = new Set(); + for (let index = 0; index < value.schemas.length; index += 1) { + const schema = value.schemas[index]; + if (ids.has(schema.id)) return false; + for (const prior of value.schemas.slice(0, index)) { + if ( + schemaSpecificity(schema) === schemaSpecificity(prior) + && schemaPriority(schema) === schemaPriority(prior) + && requirementsOverlap(schema, prior) + ) return false; + } + ids.add(schema.id); + } + return true; +} + +function stableJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`; + if (!isRecord(value)) return JSON.stringify(value); + return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(",")}}`; +} + +/** Canonical, payload-only representation used by the detached signature. */ +export function openCodeSchemaBundleSigningPayload(bundle: OpenCodeSchemaBundle): string { + const { signature: _signature, ...unsigned } = bundle; + return stableJson(unsigned); +} + +export function openCodeSchemaBundlePayloadHash(bundle: OpenCodeSchemaBundle): string { + return createHash("sha256").update(openCodeSchemaBundleSigningPayload(bundle), "utf8").digest("hex"); +} + +function meetsOpenCodeRegistryGenesisFloor(bundle: OpenCodeSchemaBundle): boolean { + if (bundle.sequence < OPENCODE_REGISTRY_GENESIS_SEQUENCE) return false; + // Sequence 1 is a pinned genesis payload, not a registry-controlled slot. + // This blocks a historical, still-valid signature from defining first-use + // behavior after the cache has been cleared or corrupted. + return bundle.sequence !== OPENCODE_REGISTRY_GENESIS_SEQUENCE + || openCodeSchemaBundleSigningPayload(bundle) === openCodeSchemaBundleSigningPayload(BUILTIN_OPENCODE_SCHEMA_BUNDLE); +} + +function meetsOpenCodeRegistryCheckpoint( + bundle: OpenCodeSchemaBundle, + checkpoint: OpenCodeRegistryCheckpoint | undefined, +): boolean { + if (!checkpoint) return true; + if (bundle.sequence < checkpoint.sequence) return false; + return bundle.sequence !== checkpoint.sequence + || openCodeSchemaBundlePayloadHash(bundle) === checkpoint.payloadHash; +} + +export function verifyOpenCodeSchemaBundle( + bundle: unknown, + publicKey: string | OpenCodeRegistryKeyring, + now = Date.now(), + options: { allowExpired?: boolean } = {}, +): bundle is OpenCodeSchemaBundle { + if (!isOpenCodeSchemaBundle(bundle, now, options) || !bundle.signature || bundle.signature.algorithm !== "ed25519") return false; + try { + // Buffer's base64 decoder silently ignores malformed trailing characters. + // Require the registry's encoding to round-trip before verification. + const encoded = bundle.signature.value; + if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(encoded)) return false; + const signature = Buffer.from(encoded, "base64"); + if (!signature.length || signature.toString("base64") !== encoded) return false; + const keyring = typeof publicKey === "string" ? { legacy: publicKey } : publicKey; + const entries = Object.entries(keyring).filter(([id, pem]) => /^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(id) && typeof pem === "string"); + if (!entries.length || entries.length > 4) return false; + // `keyId` is signed because it is part of the canonical unsigned payload. + // Legacy single-key caches remain readable during the staged migration. + const candidates = bundle.keyId === undefined + ? entries + : entries.filter(([id]) => id === bundle.keyId); + return candidates.some(([, pem]) => { + const key = createPublicKey(pem); + return key.asymmetricKeyType === "ed25519" + && verify(null, Buffer.from(openCodeSchemaBundleSigningPayload(bundle)), key, signature); + }); + } catch { + return false; + } +} + +type CachedBundle = { checkedAt: number; bundle: OpenCodeSchemaBundle; verifiedKeyId?: string }; + +/** Runtime-only path construction deliberately avoids importing coven-paths: + * its dynamic filesystem joins are application inputs to Turbopack's tracer. */ +function localPathChild(parent: string, child: string): string { + const separator = process.platform === "win32" ? "\\" : "/"; + return `${parent.replace(/[\\/]+$/, "")}${separator}${child}`; +} + +function cachePath(): string { + // The registry cache is mutable per-user state, never an application asset. + const covenRoot = process.env.COVEN_HOME || localPathChild(homedir(), ".coven"); + const caveRoot = process.env.COVEN_CAVE_HOME || localPathChild(covenRoot, "cave"); + return localPathChild(caveRoot, CACHE_FILE); +} + +function localPathParent(file: string): string { + const slash = Math.max(file.lastIndexOf("/"), file.lastIndexOf("\\")); + return slash > 0 ? file.slice(0, slash) : "."; +} + +function cacheTrustPath(file: string): string { + return `${file}.trust`; +} + +/** + * This is deliberately separate from the replaceable parser cache and its + * recovery sidecar. It retains only verified signed high-water state, so a + * damaged cache cannot make an already accepted remote sequence replayable. + */ +function cacheTrustAnchorPath(file: string): string { + return `${file}.anchor`; +} + +/** + * Immutable sequence-keyed anchor records. A lock lease may be reclaimed + * while a suspended writer is between a token check and an atomic replace; + * distinct journal names ensure that writer can never replace a newer + * high-water record when it resumes. + */ +function cacheTrustAnchorJournalPath(anchorFile: string): string { + return `${anchorFile}.journal`; +} + +function cacheTrustAnchorJournalEntryPath(anchorFile: string, bundle: OpenCodeSchemaBundle): string { + return `${cacheTrustAnchorJournalPath(anchorFile)}/${bundle.sequence}-${openCodeSchemaBundlePayloadHash(bundle)}.json`; +} + +function newestAnchorJournalEntries(entries: string[]): string[] { + return entries + .filter((entry) => /^\d{1,16}-[a-f0-9]{64}\.json$/.test(entry)) + .sort((left, right) => { + const leftSequence = Number(left.slice(0, left.indexOf("-"))); + const rightSequence = Number(right.slice(0, right.indexOf("-"))); + if (leftSequence !== rightSequence) return rightSequence > leftSequence ? 1 : -1; + return right.localeCompare(left); + }) + .slice(0, MAX_TRUST_ANCHOR_JOURNAL_ENTRIES); +} + +/** + * `readdir` materializes an entire directory before the existing retention + * logic can trim it. Use the streaming directory handle instead and treat an + * overfull journal as a trust failure; selecting a partial listing could miss + * its true high-water sequence and permit a downgrade. + */ +async function boundedTrustAnchorJournalEntries(anchorFile: string): Promise { + let directory: Awaited>; + try { + directory = await requireRegistryFs().opendir(/* turbopackIgnore: true */ cacheTrustAnchorJournalPath(anchorFile)); + } catch { + return []; + } + const entries: string[] = []; + let count = 0; + try { + for await (const entry of directory) { + count += 1; + if (count > MAX_TRUST_ANCHOR_JOURNAL_DIRECTORY_ENTRIES) { + throw new CacheTrustConflictError("too many schema trust-anchor journal entries"); + } + if (entry.isFile()) entries.push(entry.name); + } + return entries; + } finally { + await directory.close().catch(() => undefined); + } +} + +/** + * Registry cache paths are per-user runtime state. Keep the atomic writer on + * the same lazy filesystem boundary as the reader: importing the shared + * writer statically makes Next trace dynamic cache paths as app assets. + */ +async function writeRegistryJsonAtomic(file: string, value: unknown): Promise { + const fs = requireRegistryFs(); + const temporary = `${file}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`; + try { + await fs.writeFile(/* turbopackIgnore: true */ temporary, JSON.stringify(value, null, 2)); + for (let attempt = 0; ; attempt += 1) { + try { + await fs.rename(/* turbopackIgnore: true */ temporary, file); + return; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code ?? ""; + if (!(code === "EACCES" || code === "EBUSY" || code === "EPERM") || attempt >= 6) throw error; + await new Promise((resolve) => setTimeout(resolve, Math.min(50, 2 ** (attempt + 1)))); + } + } + } catch (error) { + await fs.rm(/* turbopackIgnore: true */ temporary, { force: true }).catch(() => undefined); + throw error; + } +} + +/** + * The cache is writable local state, so do not let a corrupt or maliciously + * enlarged file bypass the registry response limit and exhaust process memory + * before signature verification. Reading from one open handle also bounds the + * bytes consumed if the pathname is atomically replaced while it is read. + */ +async function readBoundedCacheFile(file: string): Promise { + let handle: Awaited> | null = null; + try { + handle = await requireRegistryFs().open(/* turbopackIgnore: true */ file, "r"); + const bytes = Buffer.allocUnsafe(MAX_SCHEMA_CACHE_RECORD_BYTES + 1); + const { bytesRead } = await handle.read(bytes, 0, bytes.length, 0); + if (bytesRead > MAX_SCHEMA_CACHE_RECORD_BYTES) return null; + return bytes.toString("utf8", 0, bytesRead); + } catch { + return null; + } finally { + await handle?.close().catch(() => undefined); + } +} + +/** + * Expired bundles are never selected for parsing, but their verified identity + * remains a trust floor. Without it, expiry would create a downgrade window + * where a signed lower sequence (or rewritten equal sequence) could replace + * the durable cache. + */ +async function readTrustedCacheRecord(file: string, publicKey: string | OpenCodeRegistryKeyring, now: number): Promise { + try { + const raw = await readBoundedCacheFile(file); + if (raw === null) return null; + const cached = JSON.parse(raw) as CachedBundle; + if ( + !isRecord(cached) + || typeof cached.checkedAt !== "number" + || !Number.isFinite(cached.checkedAt) + || (cached.verifiedKeyId !== undefined && (typeof cached.verifiedKeyId !== "string" || cached.bundle.keyId !== cached.verifiedKeyId)) + || !meetsOpenCodeRegistryGenesisFloor(cached.bundle) + || !verifyOpenCodeSchemaBundle(cached.bundle, publicKey, now, { allowExpired: true }) + ) return null; + return cached; + } catch { + return null; + } +} + +async function readResponseTextLimited(response: Response): Promise { + const contentLength = Number(response.headers.get("content-length")); + if (Number.isFinite(contentLength) && contentLength > MAX_SCHEMA_BUNDLE_BYTES) throw new Error("schema bundle too large"); + if (!response.body) { + const raw = await response.text(); + if (Buffer.byteLength(raw, "utf8") > MAX_SCHEMA_BUNDLE_BYTES) throw new Error("schema bundle too large"); + return raw; + } + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let bytes = 0; + try { + for (;;) { + const next = await reader.read(); + if (next.done) break; + bytes += next.value.byteLength; + if (bytes > MAX_SCHEMA_BUNDLE_BYTES) throw new Error("schema bundle too large"); + chunks.push(next.value); + } + } finally { + await reader.cancel().catch(() => undefined); + } + return Buffer.concat(chunks).toString("utf8"); +} + +async function fetchSchemaBundle(url: string, fetcher: typeof fetch, timeoutMs = REFRESH_TIMEOUT_MS): Promise { + const controller = new AbortController(); + let timeout: ReturnType | undefined; + let response: Response | undefined; + let deadlineElapsed = false; + const deadline = new Promise((_, reject) => { + timeout = setTimeout(() => { + deadlineElapsed = true; + controller.abort(); + void response?.body?.cancel().catch(() => undefined); + reject(new Error("schema registry refresh timed out")); + }, timeoutMs); + }); + try { + // The deadline covers response headers *and* body consumption. Test and + // embedding fetch shims are not required to honor AbortSignal, so race the + // full operation as well as aborting the platform fetch/body stream. + return await Promise.race([ + (async () => { + response = await fetcher(url, { headers: { accept: "application/json" }, signal: controller.signal }); + if (deadlineElapsed) throw new Error("schema registry refresh timed out"); + if (!response.ok) throw new Error("untrusted schema bundle"); + return readResponseTextLimited(response); + })(), + deadline, + ]); + } finally { + if (timeout) clearTimeout(timeout); + } +} + +/** + * Keep a failed lock acquisition fail-closed for the cache: the caller can + * still use its verified remote bundle for this turn, but it never races a + * peer into replacing a newer last-known-good cache with an older sequence. + */ +async function staleLockCanBeReclaimed(lock: string): Promise { + try { + const info = await requireRegistryFs().stat(/* turbopackIgnore: true */ lock); + // A PID is not a durable process identity: after a crash it can be reused + // by an unrelated long-running process. The lock is therefore a bounded + // lease, not a liveness claim. Release ownership remains token-checked, + // and atomic cache writes/sequence checks preserve last-known-good state + // if an unusually slow writer overlaps the next lease holder. + return Date.now() - info.mtimeMs >= CACHE_LOCK_STALE_MS; + } catch { + return false; + } +} + +async function readCachedTrustState( + file: string, + publicKey: string | OpenCodeRegistryKeyring, + now: number, + checkpoint?: OpenCodeRegistryCheckpoint, + trustAnchorFile = cacheTrustAnchorPath(file), +): Promise { + const [primary, backup, anchor, journal] = await Promise.all([ + readTrustedCacheRecord(file, publicKey, now), + readTrustedCacheRecord(cacheTrustPath(file), publicKey, now), + readTrustedCacheRecord(trustAnchorFile, publicKey, now), + readTrustedAnchorJournal(trustAnchorFile, publicKey, now), + ]); + const candidates = [primary, backup, anchor, ...journal].filter((cached): cached is CachedBundle => cached !== null); + const trusted = candidates.filter((cached) => meetsOpenCodeRegistryCheckpoint(cached.bundle, checkpoint)); + if (!trusted.length) return null; + const highestSequence = Math.max(...trusted.map((cached) => cached.bundle.sequence)); + const highest = trusted.filter((cached) => cached.bundle.sequence === highestSequence); + // A same-sequence mutation is not made trustworthy by duplicating it into + // another local record. A remote response cannot resolve this: it might be + // either conflicting payload (or a third rewrite), so fail closed before it + // is considered for this turn or written over the forensic evidence. + if (new Set(highest.map((cached) => openCodeSchemaBundleSigningPayload(cached.bundle))).size !== 1) { + throw new CacheTrustConflictError("conflicting schema cache records"); + } + return highest[0]; +} + +async function readTrustedAnchorJournal( + anchorFile: string, + publicKey: string | OpenCodeRegistryKeyring, + now: number, +): Promise { + const entries = await boundedTrustAnchorJournalEntries(anchorFile); + // A stale writer can leave a lower immutable record after a newer writer + // commits. Resolve only a bounded newest window; valid registry sequences + // are safe integers, so filename ordering is an exact high-water ordering. + const anchorEntries = newestAnchorJournalEntries(entries); + return (await Promise.all(anchorEntries.map((entry) => readTrustedCacheRecord( + `${cacheTrustAnchorJournalPath(anchorFile)}/${entry}`, + publicKey, + now, + )))).filter((cached): cached is CachedBundle => cached !== null); +} + +async function pruneTrustAnchorJournal(anchorFile: string): Promise { + const entries = await boundedTrustAnchorJournalEntries(anchorFile); + const retained = new Set(newestAnchorJournalEntries(entries)); + await Promise.all(entries + .filter((entry) => /^\d{1,16}-[a-f0-9]{64}\.json$/.test(entry) && !retained.has(entry)) + .map((entry) => requireRegistryFs().rm(/* turbopackIgnore: true */ `${cacheTrustAnchorJournalPath(anchorFile)}/${entry}`, { force: true }).catch(() => undefined))); +} + +async function writeVerifiedCache( + file: string, + trustAnchorFile: string, + publicKey: string | OpenCodeRegistryKeyring, + now: number, + cached: CachedBundle, + assertLockOwner?: () => Promise, +): Promise { + await assertLockOwner?.(); + const priorAnchor = await readCachedTrustState(file, publicKey, now, undefined, trustAnchorFile); + if (priorAnchor && priorAnchor.bundle.sequence > cached.bundle.sequence) throw new Error("schema rollback"); + if ( + priorAnchor + && priorAnchor.bundle.sequence === cached.bundle.sequence + && openCodeSchemaBundleSigningPayload(priorAnchor.bundle) !== openCodeSchemaBundleSigningPayload(cached.bundle) + ) throw new Error("schema sequence rewritten"); + // Persist the independent signed high-water anchor first. It is append-only + // by sequence/payload hash, so a writer whose lease is reclaimed cannot + // overwrite a newer anchor after it resumes. The mutable primary/backup + // records may still be replaced by that stale writer, but journal selection + // retains the highest verified sequence. + await requireRegistryFs().mkdir(/* turbopackIgnore: true */ cacheTrustAnchorJournalPath(trustAnchorFile), { recursive: true }); + await writeRegistryJsonAtomic(cacheTrustAnchorJournalEntryPath(trustAnchorFile, cached.bundle), cached); + await pruneTrustAnchorJournal(trustAnchorFile); + // A stale lock can be reclaimed when an original writer is suspended by a + // filesystem stall. Recheck before every replace so that old owner cannot + // subsequently overwrite the new owner's higher sequence. + await assertLockOwner?.(); + await writeRegistryJsonAtomic(cacheTrustPath(file), cached); + await assertLockOwner?.(); + await writeRegistryJsonAtomic(file, cached); +} + +async function readVerifiedCache( + file: string, + publicKey: string | OpenCodeRegistryKeyring, + now: number, + checkpoint?: OpenCodeRegistryCheckpoint, + trustAnchorFile = cacheTrustAnchorPath(file), +): Promise { + const cached = await readCachedTrustState(file, publicKey, now, checkpoint, trustAnchorFile); + if ( + !cached + // The wrapper is not signed; a future timestamp must not pin an old, + // otherwise valid bundle and suppress all subsequent registry refreshes. + || cached.checkedAt > now + || !verifyOpenCodeSchemaBundle(cached.bundle, publicKey, now) + ) return null; + return cached; +} + +async function releaseCacheLock(lock: string, ownerToken: string): Promise { + try { + // Never unlink a lock that was reclaimed and replaced after an unusually + // slow filesystem operation. The unique token makes release ownership + // explicit across processes and antivirus/filesystem stalls. + if ((await requireRegistryFs().readFile(/* turbopackIgnore: true */ lock, "utf8")) !== ownerToken) return; + await requireRegistryFs().rm(/* turbopackIgnore: true */ lock, { force: true }); + } catch { + // A concurrent stale-lock recovery or shutdown already cleaned it up. + } +} + +async function withCacheWriteLock(file: string, callback: (assertOwner: () => Promise) => Promise): Promise { + const lock = `${file}.lock`; + let handle: Awaited> | null = null; + const ownerToken = `${process.pid}:${randomBytes(16).toString("hex")}`; + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + const acquired = await requireRegistryFs().open(/* turbopackIgnore: true */ lock, "wx", 0o600); + try { + await acquired.writeFile(ownerToken); + } catch (error) { + await acquired.close().catch(() => undefined); + await requireRegistryFs().rm(/* turbopackIgnore: true */ lock, { force: true }).catch(() => undefined); + throw error; + } + handle = acquired; + break; + } catch { + if (attempt || !(await staleLockCanBeReclaimed(lock))) return null; + // Move rather than unlink the stale lock: a competing refresher cannot + // delete a newly acquired lock between its stale check and this cleanup. + const stale = `${lock}.${process.pid}.${randomBytes(6).toString("hex")}.stale`; + try { + await requireRegistryFs().rename(/* turbopackIgnore: true */ lock, stale); + await requireRegistryFs().rm(/* turbopackIgnore: true */ stale, { force: true }); + } catch { + return null; + } + } + } + if (!handle) return null; + const assertOwner = async () => { + try { + if ((await requireRegistryFs().readFile(/* turbopackIgnore: true */ lock, "utf8")) !== ownerToken) { + throw new CacheWriterPendingError("schema cache lock ownership changed"); + } + } catch (error) { + if (error instanceof CacheWriterPendingError) throw error; + throw new CacheWriterPendingError("schema cache lock ownership changed"); + } + }; + try { + return await callback(assertOwner); + } finally { + await handle.close().catch(() => undefined); + await releaseCacheLock(lock, ownerToken); + } +} + +/** Wait briefly for a competing writer before choosing an older remote reply. */ +async function waitForConcurrentCacheWriter( + file: string, + publicKeys: OpenCodeRegistryKeyring, + now: number, + minimumSequence: number, + checkpoint?: OpenCodeRegistryCheckpoint, + trustAnchorFile = cacheTrustAnchorPath(file), +): Promise { + const lock = `${file}.lock`; + const deadline = Date.now() + CACHE_LOCK_WAIT_MS; + let latest: CachedBundle | null = null; + for (;;) { + latest = await readVerifiedCache(file, publicKeys, now, checkpoint, trustAnchorFile); + if (latest && latest.bundle.sequence >= minimumSequence) return latest; + try { + await requireRegistryFs().stat(/* turbopackIgnore: true */ lock); + } catch { + // Lock release follows the atomic write, so one final verified read sees + // the owner's result without trusting its in-progress payload. + return await readVerifiedCache(file, publicKeys, now, checkpoint, trustAnchorFile); + } + if (Date.now() >= deadline) return latest; + await new Promise((resolve) => setTimeout(resolve, CACHE_LOCK_POLL_MS)); + } +} + +export type OpenCodeSchemaBundleSource = { + url?: string; + publicKey?: string; + /** Bounded active-plus-previous keyring for staged registry-key rotation. */ + publicKeys?: OpenCodeRegistryKeyring; + /** Release-pinned minimum registry sequence and canonical payload hash. */ + checkpoint?: OpenCodeRegistryCheckpoint; + /** Independent durable high-water record; defaults beside the cache file. */ + trustAnchorFile?: string; + fetch?: typeof fetch; + now?: () => number; + cacheFile?: string; + /** Test-only bounded deadline for the complete fetch and body read. */ + refreshTimeoutMs?: number; +}; + +// Release builds inject these public values at compile time. A packaged +// process treats them as its immutable trust anchor; mutable COVEN_* values +// are for explicit test/developer configuration only. +const PACKAGED_REGISTRY_URL = process.env.NEXT_PUBLIC_COVEN_OPENCODE_SCHEMA_REGISTRY_URL; +const PACKAGED_REGISTRY_PUBLIC_KEY = process.env.NEXT_PUBLIC_COVEN_OPENCODE_SCHEMA_REGISTRY_PUBLIC_KEY; +const PACKAGED_REGISTRY_PUBLIC_KEYS = process.env.NEXT_PUBLIC_COVEN_OPENCODE_SCHEMA_REGISTRY_PUBLIC_KEYS; +const PACKAGED_REGISTRY_CHECKPOINT = process.env.NEXT_PUBLIC_COVEN_OPENCODE_SCHEMA_REGISTRY_CHECKPOINT; + +function parseKeyring(value: string | undefined): OpenCodeRegistryKeyring | undefined { + if (!value) return undefined; + try { + const parsed = JSON.parse(value) as unknown; + if (!isRecord(parsed)) return undefined; + const entries = Object.entries(parsed).filter(([id, pem]) => /^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(id) && typeof pem === "string"); + return entries.length > 0 && entries.length <= 4 && entries.length === Object.keys(parsed).length + ? Object.fromEntries(entries) as OpenCodeRegistryKeyring + : undefined; + } catch { + return undefined; + } +} + +function registryKeyring(source: OpenCodeSchemaBundleSource): OpenCodeRegistryKeyring | undefined { + if (source.publicKeys) return source.publicKeys; + if (source.publicKey) return { legacy: source.publicKey }; + const configured = parseKeyring(PACKAGED_REGISTRY_PUBLIC_KEYS) + ?? (process.env.NODE_ENV === "production" ? undefined : parseKeyring(process.env.COVEN_OPENCODE_SCHEMA_REGISTRY_PUBLIC_KEYS)); + if (configured) return configured; + const single = PACKAGED_REGISTRY_PUBLIC_KEY + ?? (process.env.NODE_ENV === "production" ? undefined : process.env.COVEN_OPENCODE_SCHEMA_REGISTRY_PUBLIC_KEY); + return single ? { legacy: single } : undefined; +} + +function parseRegistryCheckpoint(value: string | undefined): OpenCodeRegistryCheckpoint | undefined { + if (!value) return undefined; + try { + const parsed = JSON.parse(value) as unknown; + return isRecord(parsed) + && Object.keys(parsed).length === 2 + && typeof parsed.sequence === "number" + && Number.isSafeInteger(parsed.sequence) + && parsed.sequence >= OPENCODE_REGISTRY_GENESIS_SEQUENCE + && typeof parsed.payloadHash === "string" + && /^[a-f0-9]{64}$/.test(parsed.payloadHash) + ? { sequence: parsed.sequence, payloadHash: parsed.payloadHash } + : undefined; + } catch { + return undefined; + } +} + +function registryUrl(source: OpenCodeSchemaBundleSource): string | undefined { + return source.url + ?? PACKAGED_REGISTRY_URL + ?? (process.env.NODE_ENV === "production" ? undefined : process.env.COVEN_OPENCODE_SCHEMA_REGISTRY_URL); +} + +function registryCheckpoint(source: OpenCodeSchemaBundleSource): OpenCodeRegistryCheckpoint | undefined { + if (source.checkpoint) return source.checkpoint; + return parseRegistryCheckpoint(PACKAGED_REGISTRY_CHECKPOINT) + ?? (process.env.NODE_ENV === "production" ? undefined : parseRegistryCheckpoint(process.env.COVEN_OPENCODE_SCHEMA_REGISTRY_CHECKPOINT)); +} + +function schemaRefreshKey(file: string, trustAnchorFile: string, url: string, publicKeys: OpenCodeRegistryKeyring, checkpoint: OpenCodeRegistryCheckpoint | undefined): string { + return createHash("sha256").update(`${file}\0${trustAnchorFile}\0${url}\0${stableJson(publicKeys)}\0${stableJson(checkpoint ?? null)}`).digest("hex"); +} + +async function refreshOpenCodeSchemaBundle( + file: string, + url: string, + publicKeys: OpenCodeRegistryKeyring, + checkpoint: OpenCodeRegistryCheckpoint | undefined, + trustAnchorFile: string, + fetcher: typeof fetch, + now: number, + refreshTimeoutMs?: number, +): Promise { + const raw = await fetchSchemaBundle(url, fetcher, refreshTimeoutMs); + const remote = JSON.parse(raw) as unknown; + if (!verifyOpenCodeSchemaBundle(remote, publicKeys, now)) throw new Error("invalid schema signature"); + if (!meetsOpenCodeRegistryGenesisFloor(remote)) throw new Error("schema registry genesis mismatch"); + if (!meetsOpenCodeRegistryCheckpoint(remote, checkpoint)) throw new Error("schema registry checkpoint mismatch"); + if (Object.keys(publicKeys).length > 1 && remote.keyId === undefined) throw new Error("missing schema signing key id"); + const cachedTrust = await readCachedTrustState(file, publicKeys, now, checkpoint, trustAnchorFile); + if (cachedTrust && remote.sequence < cachedTrust.bundle.sequence) throw new Error("schema rollback"); + await requireRegistryFs().mkdir(/* turbopackIgnore: true */ localPathParent(file), { recursive: true }); + const writeResult = await withCacheWriteLock(file, async (assertLockOwner) => { + // Re-read after acquiring the lock. Another process may have refreshed + // while this request was in flight, and the cache must never move back. + const currentTrust = await readCachedTrustState(file, publicKeys, now, checkpoint, trustAnchorFile); + if (currentTrust && remote.sequence < currentTrust.bundle.sequence) throw new Error("schema rollback"); + if (currentTrust && remote.sequence === currentTrust.bundle.sequence) { + if (openCodeSchemaBundleSigningPayload(remote) !== openCodeSchemaBundleSigningPayload(currentTrust.bundle)) throw new Error("schema sequence rewritten"); + // The just-verified remote payload is byte-for-byte the same signed + // contract, so it is safe to refresh only the unsigned cache freshness. + await writeVerifiedCache(file, trustAnchorFile, publicKeys, now, { checkedAt: now, bundle: remote, verifiedKeyId: remote.keyId }, assertLockOwner); + return { bundle: remote, source: "remote" as const }; + } + await writeVerifiedCache(file, trustAnchorFile, publicKeys, now, { checkedAt: now, bundle: remote, verifiedKeyId: remote.keyId }, assertLockOwner); + return { bundle: remote, source: "remote" as const }; + }); + if (writeResult) return writeResult; + // A concurrent writer can have installed a newer sequence while this + // request held an older verified response. Never select that older parser + // for this turn merely because the lock was busy. + const current = await waitForConcurrentCacheWriter(file, publicKeys, now, remote.sequence, checkpoint, trustAnchorFile); + if (current && current.bundle.sequence >= remote.sequence) { + return { bundle: current.bundle, source: "cache" }; + } + // Selecting the fetched payload here could race a still-running writer with + // a newer sequence. Keep the protocol boundary fail-closed until that + // writer commits or a later refresh can make an ordered decision. + throw new CacheWriterPendingError("schema cache writer did not settle"); +} + +function startSchemaRefresh( + key: string, + refresh: () => Promise, + now: number, +): Promise { + const running = refreshFlights.get(key); + if (running) return running; + const flight = refresh() + .then((result) => { + refreshRetryAt.delete(key); + return result; + }) + .catch((error) => { + refreshRetryAt.set(key, now + REFRESH_FAILURE_BACKOFF_MS); + throw error; + }) + .finally(() => refreshFlights.delete(key)); + refreshFlights.set(key, flight); + return flight; +} + +/** + * Read a last-known-good schema bundle and refresh it within a bounded + * deadline. A remote bundle is accepted only with a configured Ed25519 key, + * valid dates, a monotonic sequence, and a valid signature. Failed refreshes + * never replace the old cache and are surfaced as a value-free diagnostic. + */ +export async function loadOpenCodeSchemaBundle(source: OpenCodeSchemaBundleSource = {}): Promise { + const now = source.now?.() ?? Date.now(); + const file = source.cacheFile ?? cachePath(); + const trustAnchorFile = source.trustAnchorFile ?? cacheTrustAnchorPath(file); + const url = registryUrl(source); + const publicKeys = registryKeyring(source); + const checkpoint = registryCheckpoint(source); + if (!url || !publicKeys) return { bundle: BUILTIN_OPENCODE_SCHEMA_BUNDLE, source: "built-in" }; + // Packaged releases require a current checkpoint before their first remote + // fetch. A missing build-time value fails safely to the bundled parser; + // explicit source injection remains available for tests and local dev. + if (process.env.NODE_ENV === "production" && source.checkpoint === undefined && !checkpoint) { + return { bundle: BUILTIN_OPENCODE_SCHEMA_BUNDLE, source: "built-in", diagnostic: "schema-registry-refresh-rejected" }; + } + + let cached: CachedBundle | null; + let cacheTrust: CachedBundle | null; + try { + cached = await readVerifiedCache(file, publicKeys, now, checkpoint, trustAnchorFile); + // An expired signed cache cannot parse a turn, but it records that this + // client previously trusted a newer registry contract. Do not silently + // regress to the compiled parser if refresh fails; a first offline launch + // without any cache can still use that source-trusted baseline. + cacheTrust = cached ?? await readCachedTrustState(file, publicKeys, now, checkpoint, trustAnchorFile); + } catch (error) { + if (error instanceof CacheTrustConflictError) { + return { + bundle: BUILTIN_OPENCODE_SCHEMA_BUNDLE, + source: "built-in", + diagnostic: "schema-registry-refresh-rejected", + remoteSchemaRequired: true, + }; + } + throw error; + } + const cacheFresh = cached && now - cached.checkedAt < CACHE_TTL_MS; + if (cacheFresh && cached) return { bundle: cached.bundle, source: "cache" }; + const key = schemaRefreshKey(file, trustAnchorFile, url, publicKeys, checkpoint); + if ((refreshRetryAt.get(key) ?? 0) > now) { + return cached + ? { bundle: cached.bundle, source: "cache", diagnostic: "schema-registry-refresh-rejected" } + : cacheTrust + ? { bundle: BUILTIN_OPENCODE_SCHEMA_BUNDLE, source: "built-in", diagnostic: "cached-schema-unavailable", remoteSchemaRequired: true } + : { bundle: BUILTIN_OPENCODE_SCHEMA_BUNDLE, source: "built-in", diagnostic: "schema-registry-refresh-rejected" }; + } + const refresh = startSchemaRefresh( + key, + () => refreshOpenCodeSchemaBundle(file, url, publicKeys, checkpoint, trustAnchorFile, source.fetch ?? fetch, now, source.refreshTimeoutMs), + now, + ); + try { + return await refresh; + } catch (error) { + if (error instanceof CacheWriterPendingError || error instanceof CacheTrustConflictError) { + return { + bundle: BUILTIN_OPENCODE_SCHEMA_BUNDLE, + source: "built-in", + diagnostic: "schema-registry-refresh-rejected", + remoteSchemaRequired: true, + }; + } + // Another process may have installed a newer verified contract after this + // invocation captured `cached` but before its refresh lost the cache lock + // to a rollback rejection. Re-read first so this turn cannot regress to + // the stale parser that initiated the race. + let current: CachedBundle | null; + try { + current = await readVerifiedCache(file, publicKeys, now, checkpoint, trustAnchorFile); + } catch (currentError) { + if (currentError instanceof CacheTrustConflictError) { + return { + bundle: BUILTIN_OPENCODE_SCHEMA_BUNDLE, + source: "built-in", + diagnostic: "schema-registry-refresh-rejected", + remoteSchemaRequired: true, + }; + } + throw currentError; + } + if (current && (!cached || current.bundle.sequence >= cached.bundle.sequence)) { + return { bundle: current.bundle, source: "cache", diagnostic: "schema-registry-refresh-rejected" }; + } + // A verified stale bundle is still valid for its own expiry window. Keep + // using it, but surface this turn's rejected refresh instead of hiding the + // first recovery failure until the retry backoff path runs. + if (cached) return { bundle: cached.bundle, source: "cache", diagnostic: "schema-registry-refresh-rejected" }; + return cacheTrust + ? { bundle: BUILTIN_OPENCODE_SCHEMA_BUNDLE, source: "built-in", diagnostic: "cached-schema-unavailable", remoteSchemaRequired: true } + : { bundle: BUILTIN_OPENCODE_SCHEMA_BUNDLE, source: "built-in", diagnostic: "schema-registry-refresh-rejected" }; + } +} + +export async function resolveOpenCodeCompatibility( + capabilities: OpenCodeRunCapabilities, + source?: OpenCodeSchemaBundleSource, +): Promise { + if (!capabilities.json) { + return { + mode: "plain", + capabilities, + bundleSource: "built-in", + diagnostic: "json-format-unavailable", + }; + } + const loaded = await loadOpenCodeSchemaBundle(source); + // A verified remote schema that has expired proves this client may require a + // newer envelope than the compiled baseline. Do not revive that baseline + // merely because refresh is temporarily offline or rejected. + if (loaded.remoteSchemaRequired) { + return { + mode: "plain", + capabilities, + bundleSource: loaded.source, + diagnostic: loaded.diagnostic ?? "cached-schema-unavailable", + }; + } + // The compiled baseline remains a safe first-launch fallback while it is + // within its own explicit validity window. Once that baseline expires, do + // not extend an old parser merely because a remote cache is unavailable. + if ( + loaded.source === "built-in" + && Date.parse(BUILTIN_OPENCODE_SCHEMA_BUNDLE.expiresAt) <= (source?.now?.() ?? Date.now()) + ) { + return { + mode: "plain", + capabilities, + bundleSource: loaded.source, + diagnostic: loaded.diagnostic ?? "cached-schema-unavailable", + }; + } + // Remote registries publish additive compatibility profiles by default. + // Select their schemas and non-retired compiled baselines together, so a + // broad remote profile cannot preempt a more-specific known-safe parser. + // A remote replacement must therefore win the same specificity/priority + // comparison as every other candidate, rather than merely appearing first. + const remoteSchemas = loaded.source === "built-in" ? new Set() : new Set(loaded.bundle.schemas); + const remoteById = new Map(loaded.bundle.schemas.map((schema) => [schema.id, schema])); + const candidates = loaded.source === "built-in" + ? loaded.bundle.schemas + : [ + ...loaded.bundle.schemas, + ...BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas.filter((builtIn) => { + if (loaded.bundle.retiredSchemaIds?.includes(builtIn.id)) return false; + const remoteRevision = remoteById.get(builtIn.id); + // Reusing a baseline id is an explicit signed revision. A distinct + // remote profile must instead win globally by specificity/priority. + return !remoteRevision || schemaSpecificity(remoteRevision) < schemaSpecificity(builtIn); + }), + ]; + const schema = selectOpenCodeSchema(candidates, capabilities); + if (!schema) { + return { + mode: "plain", + capabilities, + bundleSource: loaded.source, + diagnostic: "no-compatible-schema", + }; + } + const schemaRevision = createHash("sha256").update(stableJson(schema)).digest("hex"); + if (quarantinedSchemaRevisions.get(schema.id) === schemaRevision) { + return { + mode: "plain", + capabilities, + bundleSource: loaded.source, + diagnostic: "schema-quarantined", + }; + } + return { + mode: "structured", + capabilities, + schema, + bundleSource: schema && remoteSchemas.has(schema) ? loaded.source : "built-in", + // The shipped parser is a source-trusted offline baseline. A failed + // registry refresh must not remove otherwise compatible tool activity, + // but callers still surface the value-free recovery state. + diagnostic: loaded.diagnostic, + }; +} diff --git a/src/lib/opencode-stream.test.ts b/src/lib/opencode-stream.test.ts index 99332d976..206c2cd5b 100644 --- a/src/lib/opencode-stream.test.ts +++ b/src/lib/opencode-stream.test.ts @@ -1,18 +1,153 @@ // @ts-nocheck import assert from "node:assert/strict"; -import { parseOpenCodeRunEvent } from "./opencode-stream.ts"; +import { BUILTIN_OPENCODE_SCHEMA_BUNDLE } from "./opencode-compatibility.ts"; +import { handleOpenCodeJsonLine, parseOpenCodeRunEvent } from "./opencode-stream.ts"; assert.deepEqual( parseOpenCodeRunEvent({ type: "text", sessionID: "ses_123", part: { text: "Hello" } }), { kind: "text", sessionId: "ses_123", text: "Hello" }, ); +{ + const sessions: string[] = []; + const text: string[] = []; + const toolIds: string[] = []; + const diagnostics: string[] = []; + handleOpenCodeJsonLine( + JSON.stringify({ type: "text", sessionID: "ses_live", part: { type: "text", text: "live reply" } }), + BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0], + { onSession: (id) => sessions.push(id), onText: (event) => text.push(event.text), onTool: (event) => toolIds.push(event.id), onOther: (event) => diagnostics.push(event.diagnostic ?? "other") }, + ); + handleOpenCodeJsonLine( + JSON.stringify({ type: "tool_use", sessionID: "ses_live", part: { type: "tool", id: "tool_live", tool: "Read", state: { output: "ok" } } }), + BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0], + { onSession: (id) => sessions.push(id), onText: (event) => text.push(event.text), onTool: (event) => toolIds.push(event.id), onOther: (event) => diagnostics.push(event.diagnostic ?? "other") }, + ); + handleOpenCodeJsonLine( + JSON.stringify({ type: "text", sessionID: "ses_hijack", text: "hostile root text" }), + BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0], + { onSession: (id) => sessions.push(id), onText: (event) => text.push(event.text), onTool: (event) => toolIds.push(event.id), onOther: (event) => diagnostics.push(event.diagnostic ?? "other") }, + ); + assert.deepEqual(sessions, ["ses_live", "ses_live"], "JSONL dispatch adopts a native session only after a frame matches the selected schema"); + assert.deepEqual(text, ["live reply"], "only schema-authorized text reaches the route callback"); + assert.deepEqual(toolIds, ["tool_live"], "terminal tool frames retain their upstream call id"); + assert.deepEqual(diagnostics, ["malformed-event"], "hostile frames reach the diagnostic path instead of assistant text"); +} +assert.deepEqual( + parseOpenCodeRunEvent( + { type: "text", sessionID: "ses_123", text: "provider-controlled root field" }, + BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0], + ), + { kind: "other", sessionId: "ses_123", diagnostic: "malformed-event" }, + "a text label cannot promote root payload fields unless its signed profile explicitly authorizes a root text envelope", +); +assert.deepEqual( + parseOpenCodeRunEvent( + { type: "text", sessionID: "ses_123", part: { type: "tool", text: "provider-controlled tool output", id: "prt_hostile" } }, + BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0], + ), + { kind: "other", sessionId: "ses_123", diagnostic: "malformed-event" }, + "a known root text label cannot render a payload whose signed kind is tool", +); +assert.deepEqual( + parseOpenCodeRunEvent( + { type: "step_start", sessionID: "ses_123", part: { id: "step_1" } }, + BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0], + ), + { kind: "ignore", sessionId: "ses_123" }, + "current OpenCode step-start frames are lifecycle metadata, not compatibility failures", +); +assert.deepEqual( + parseOpenCodeRunEvent( + { type: "step_start", sessionID: "ses_without_part_id", part: {} }, + BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0], + ), + { kind: "ignore", sessionId: "ses_without_part_id" }, + "a signed lifecycle frame retains an announced session even when it has no call id", +); +assert.deepEqual( + parseOpenCodeRunEvent( + { type: "step_finish", sessionID: "ses_123", part: { id: "step_1" } }, + BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0], + ), + { kind: "ignore", sessionId: "ses_123" }, + "current OpenCode step-finish frames are lifecycle metadata, not compatibility failures", +); +assert.deepEqual( + parseOpenCodeRunEvent( + { type: "reasoning", sessionID: "ses_123", part: { text: "private chain of thought" } }, + BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0], + ), + { kind: "ignore" }, + "current OpenCode reasoning frames are lifecycle metadata and never leak as assistant text", +); +assert.deepEqual( + parseOpenCodeRunEvent( + { type: "step_finish", sessionID: "ses_future", part: { type: "text", text: "Do not lose this reply" } }, + BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0], + ), + { kind: "text", sessionId: "ses_future", text: "Do not lose this reply", diagnostic: "unknown-event" }, + "a future lifecycle label that carries a schema-authorized text payload preserves the reply and quarantines the stale profile", +); +assert.deepEqual( + parseOpenCodeRunEvent( + { type: "step_finish", sessionID: "ses_future_tool", part: { type: "tool", id: "future_tool", tool: "Read", state: { output: "done" } } }, + BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0], + ), + { kind: "other", sessionId: "ses_future_tool", diagnostic: "unknown-event" }, + "a lifecycle label repurposed for a tool payload is quarantined instead of silently dropping activity", +); +{ + const sessions: string[] = []; + handleOpenCodeJsonLine( + JSON.stringify({ type: "step_start", sessionID: "ses_injected", part: { id: "step_1" } }), + BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0], + { onSession: (id) => sessions.push(id) }, + ); + assert.deepEqual(sessions, ["ses_injected"], "a signed lifecycle frame preserves the native session for an interrupted-run resume"); +} +assert.deepEqual( + parseOpenCodeRunEvent( + { session: "ses_mapped", payload: { event: "reply", type: "text", body: "A registry-mapped reply" } }, + { + ...BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0], + id: "mapped-envelope", + eventTypes: { ...BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0].eventTypes, text: ["reply"] }, + shape: { + ...BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0].shape, + envelope: ["payload"], + discriminator: { envelope: "payload", field: "event" }, + textEnvelope: ["payload"], + sessionId: ["session"], + text: ["body"], + }, + }, + ), + { kind: "text", sessionId: "ses_mapped", text: "A registry-mapped reply" }, + "a signed schema can adapt a renamed envelope and session/text fields without code changes", +); assert.deepEqual( parseOpenCodeRunEvent({ type: "tool_use", sessionID: "ses_123", part: { id: "prt_1", tool: "bash", state: { input: { command: "pwd" }, output: "ok", status: "completed" } } }), { kind: "tool", sessionId: "ses_123", id: "prt_1", name: "bash", input: { command: "pwd" }, output: "ok", isError: false }, ); +assert.deepEqual( + parseOpenCodeRunEvent( + { type: "tool_use", sessionID: "ses_123", id: "root_tool", tool: "bash", state: { input: { command: "pwd" }, output: "secret", status: "completed" } }, + BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0], + ), + { kind: "other", sessionId: "ses_123", diagnostic: "malformed-event" }, + "the current v1 profile requires the documented part envelope for tool activity", +); +assert.deepEqual( + parseOpenCodeRunEvent( + { type: "tool_use", sessionID: "ses_123", part: { type: "text", id: "prt_hostile", tool: "bash", state: { output: "provider-controlled output" } } }, + BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0], + ), + { kind: "other", sessionId: "ses_123", diagnostic: "malformed-event" }, + "a known root tool label cannot render a payload whose signed kind is text", +); assert.deepEqual( parseOpenCodeRunEvent({ type: "text" }), - { kind: "other", sessionId: undefined }, + { kind: "other", sessionId: undefined, diagnostic: "malformed-event" }, "malformed events never produce assistant text", ); assert.deepEqual( @@ -24,4 +159,197 @@ assert.deepEqual( { kind: "error", sessionId: "ses_123", message: "Selected model is unavailable" }, "OpenCode nests command errors under error.data.message", ); +{ + const sessions: string[] = []; + const errors: string[] = []; + handleOpenCodeJsonLine( + JSON.stringify({ type: "error", sessionID: "ses_untrusted_error", error: { message: "session failed" } }), + BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0], + { onSession: (id) => sessions.push(id), onError: (event) => errors.push(event.message) }, + ); + assert.deepEqual(errors, ["session failed"], "a declared error frame still reaches failure handling"); + assert.deepEqual(sessions, [], "a provider-controlled error envelope cannot overwrite the native resume token"); +} +assert.deepEqual( + parseOpenCodeRunEvent( + { type: "tool_start", sessionId: "ses_123", data: { id: "tool_1", name: "Read", state: { input: { path: "README.md" } } } }, + BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0], + ), + { kind: "other", sessionId: "ses_123", diagnostic: "unknown-event" }, + "undocumented split lifecycle labels cannot become trusted v1 activity", +); +assert.deepEqual( + parseOpenCodeRunEvent( + { type: "tool_result", session_id: "ses_123", data: { id: "tool_1", state: { output: "ok" } } }, + BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0], + ), + { kind: "other", sessionId: "ses_123", diagnostic: "unknown-event" }, + "undocumented terminal labels cannot settle a trusted v1 bubble", +); +assert.deepEqual( + parseOpenCodeRunEvent( + { type: "tool", sessionID: "ses_123", callID: "call_1", tool: "bash", state: { input: { command: "pwd" }, status: "running" } }, + BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0], + ), + { kind: "other", sessionId: "ses_123", diagnostic: "unknown-event" }, + "undocumented root tool labels cannot create a trusted v1 bubble", +); +assert.deepEqual( + parseOpenCodeRunEvent( + { type: "tool_use", sessionID: "ses_123", part: { type: "tool", id: "prt_failed", tool: "bash", state: { input: { command: "false" }, error: "permission denied", status: "error" } } }, + BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0], + ), + { kind: "tool", sessionId: "ses_123", id: "prt_failed", name: "bash", input: { command: "false" }, output: "permission denied", isError: true }, + "terminal tool failures preserve a safe partial error output", +); +assert.deepEqual( + parseOpenCodeRunEvent( + { type: "tool_use", sessionID: "ses_123", part: { type: "tool", id: "prt_null_error", tool: "bash", state: { input: { command: "pwd" }, output: "ok", error: null, status: "completed" } } }, + BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0], + ), + { kind: "tool", sessionId: "ses_123", id: "prt_null_error", name: "bash", input: { command: "pwd" }, output: "ok", isError: false }, + "a null error payload is a successful terminal result unless the status itself is an error", +); +assert.deepEqual( + parseOpenCodeRunEvent( + { type: "tool_use", sessionID: "ses_123", part: { type: "tool", id: "prt_false_error", tool: "bash", state: { input: { command: "pwd" }, output: "ok", error: false, status: "completed" } } }, + BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0], + ), + { kind: "tool", sessionId: "ses_123", id: "prt_false_error", name: "bash", input: { command: "pwd" }, output: "ok", isError: false }, + "a false error marker is a successful terminal result unless the status itself is an error", +); +assert.deepEqual( + parseOpenCodeRunEvent({ type: "tool_use", part: { id: "prt_statusless", tool: "bash", state: { input: { command: "pwd" }, output: "ok" } } }), + { kind: "tool", sessionId: undefined, id: "prt_statusless", name: "bash", input: { command: "pwd" }, output: "ok", isError: false }, + "legacy terminal snapshots preserve output even when status is omitted", +); +assert.deepEqual( + parseOpenCodeRunEvent({ type: "tool_use", part: { tool: "Read" } }), + { kind: "other", sessionId: undefined, diagnostic: "malformed-event" }, + "missing tool ids never create random, non-resumable bubbles", +); +assert.deepEqual( + parseOpenCodeRunEvent({ type: "future_text_delta", data: { text: "Still show this reply" } }), + { kind: "other", sessionId: undefined, diagnostic: "unknown-event" }, + "unknown envelopes never promote arbitrary payload text into assistant output", +); +assert.deepEqual( + parseOpenCodeRunEvent( + { type: "future_text_delta", sessionID: "ses_future", part: { type: "text", text: "Still show this trusted reply" } }, + BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0], + ), + { kind: "text", sessionId: "ses_future", text: "Still show this trusted reply", diagnostic: "unknown-event" }, + "an unknown label retains text only when the signed text envelope and payload kind both validate", +); +{ + const sessions: string[] = []; + const text: string[] = []; + const diagnostics: string[] = []; + handleOpenCodeJsonLine( + JSON.stringify({ type: "future_text_delta", sessionID: "ses_future", part: { type: "text", text: "trusted reply" } }), + BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0], + { onSession: (id) => sessions.push(id), onText: (event) => text.push(event.text), onOther: (event) => diagnostics.push(event.diagnostic ?? "other") }, + ); + assert.deepEqual(text, ["trusted reply"], "a trusted unknown-label text frame reaches the live assistant transcript"); + assert.deepEqual(diagnostics, ["unknown-event"], "the same evolved label still creates one compatibility diagnostic"); + assert.deepEqual(sessions, [], "an unknown text label cannot overwrite the persisted native resume token"); +} +assert.deepEqual( + parseOpenCodeRunEvent(["future", "envelope"]), + { kind: "other", diagnostic: "malformed-event" }, + "valid JSON with an unsupported envelope still produces one compatibility diagnostic", +); +assert.deepEqual( + parseOpenCodeRunEvent( + { type: "assistant_text", session_id: "ses_legacy", data: { type: "text", content: "Older client reply" } }, + BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[1], + ), + { kind: "text", sessionId: "ses_legacy", text: "Older client reply" }, + "the legacy capability profile keeps a prior text envelope compatible", +); +assert.deepEqual( + parseOpenCodeRunEvent( + { type: "tool_call", sessionId: "ses_legacy", data: { type: "tool", toolCallId: "legacy_1", name: "Read", input: { path: "README.md" }, state: { status: "running" } } }, + BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[1], + ), + { kind: "tool_start", sessionId: "ses_legacy", id: "legacy_1", name: "Read", input: { path: "README.md" } }, + "the legacy profile keeps stable ids for a split tool lifecycle", +); +const progressSchema = { + ...BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[1], + id: "opencode-progress-v2", + eventTypes: { + ...BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[1].eventTypes, + toolProgress: ["tool_progress"], + }, +}; +assert.deepEqual( + parseOpenCodeRunEvent( + { type: "tool_progress", sessionId: "ses_progress", data: { type: "tool", toolCallId: "progress_1", state: { output: "read 50%" } } }, + progressSchema, + ), + { kind: "tool_progress", sessionId: "ses_progress", id: "progress_1", output: "read 50%" }, + "a signed schema can identify a nonterminal progress frame without treating it as a start or result", +); +{ + const progress: string[] = []; + handleOpenCodeJsonLine( + JSON.stringify({ type: "tool_progress", data: { type: "tool", toolCallId: "progress_dispatch", state: { output: "working" } } }), + progressSchema, + { onToolProgress: (event) => progress.push(`${event.id}:${String(event.output)}`) }, + ); + assert.deepEqual(progress, ["progress_dispatch:working"], "JSONL dispatch forwards declared tool progress separately from terminal results"); +} +const shapedSchema = { + ...BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0], + id: "opencode-run-json-shaped-v2", + eventTypes: { + ...BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0].eventTypes, + toolComplete: ["tool"], + }, + shape: { + envelope: [["event", "payload"]], + discriminator: { envelope: ["event", "payload"], field: "event" }, + payloadKind: { field: "kind", text: ["text"], tool: ["tool"] }, + sessionId: ["session"], + id: ["call_id"], + name: ["tool_name"], + state: ["phase"], + status: ["state"], + input: ["arguments"], + output: ["result"], + error: ["failure"], + terminalStates: ["done", "failed"], + errorStates: ["failed"], + }, +}; +assert.deepEqual( + parseOpenCodeRunEvent( + { event: { payload: { event: "tool", kind: "tool", session: "ses_v2", call_id: "call_v2", tool_name: "Read", phase: { state: "done", arguments: { path: "README.md" }, result: "ok" } } } }, + shapedSchema, + ), + { kind: "tool", sessionId: "ses_v2", id: "call_v2", name: "Read", input: { path: "README.md" }, output: "ok", isError: false }, + "a signed schema resolves envelope-native sessions as well as bounded future fields and terminal states", +); +const rootIdSchema = { + ...BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0], + id: "opencode-root-id-v2", + eventTypes: { + ...BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0].eventTypes, + toolComplete: ["tool"], + }, + shape: { + ...BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0].shape, + envelope: ["part"], + idEnvelope: ["root"], + }, +}; +assert.deepEqual( + parseOpenCodeRunEvent( + { type: "tool", callID: "root_call_1", part: { type: "tool", tool: "Read", state: { input: { path: "README.md" }, output: "ok", status: "completed" } } }, + rootIdSchema, + ), + { kind: "tool", sessionId: undefined, id: "root_call_1", name: "Read", input: { path: "README.md" }, output: "ok", isError: false }, + "a signed schema can read a root tool ID while retaining a nested payload envelope", +); console.log("opencode-stream.test.ts: ok"); diff --git a/src/lib/opencode-stream.ts b/src/lib/opencode-stream.ts index 73635f51e..580375e38 100644 --- a/src/lib/opencode-stream.ts +++ b/src/lib/opencode-stream.ts @@ -1,8 +1,26 @@ +import type { OpenCodeEnvelopePath, OpenCodeEventSchema } from "@/lib/opencode-compatibility"; + export type OpenCodeRunEvent = - | { kind: "text"; sessionId?: string; text: string } + | { kind: "ignore"; sessionId?: string } + | { kind: "text"; sessionId?: string; text: string; diagnostic?: "unknown-event" } + | { kind: "tool_start"; sessionId?: string; id: string; name: string; input: unknown } + | { kind: "tool_progress"; sessionId?: string; id: string; output: unknown } + | { kind: "tool_end"; sessionId?: string; id: string; output: unknown; isError: boolean } | { kind: "tool"; sessionId?: string; id: string; name: string; input: unknown; output: unknown; isError: boolean } | { kind: "error"; sessionId?: string; message: string } - | { kind: "other"; sessionId?: string }; + | { kind: "other"; sessionId?: string; diagnostic?: "unknown-event" | "malformed-event" }; + +export type OpenCodeJsonLineHandlers = { + onSession?: (sessionId: string) => void; + onText?: (event: Extract) => void; + onTool?: (event: Extract) => void; + onToolStart?: (event: Extract) => void; + onToolProgress?: (event: Extract) => void; + onToolEnd?: (event: Extract) => void; + onError?: (event: Extract) => void; + onOther?: (event: Extract, rawEvent: unknown) => void; + onMalformedJson?: () => void; +}; function record(value: unknown): Record | null { return value && typeof value === "object" && !Array.isArray(value) @@ -10,39 +28,280 @@ function record(value: unknown): Record | null { : null; } +function stringAt(recordValue: Record | null, ...keys: string[]): string | undefined { + for (const key of keys) if (typeof recordValue?.[key] === "string") return recordValue[key] as string; + return undefined; +} + +type ShapeAlias = Exclude, "envelope" | "textEnvelope" | "toolEnvelope" | "idEnvelope" | "payloadKind" | "discriminator">; + +function shapeAliases(schema: OpenCodeEventSchema | undefined, key: ShapeAlias, defaults: string[]): string[] { + const aliases = schema?.shape?.[key]; + return aliases?.length ? aliases : defaults; +} + +function valueAt(recordValue: Record | null, keys: string[]): unknown { + for (const key of keys) if (recordValue && Object.hasOwn(recordValue, key)) return recordValue[key]; + return undefined; +} + +function envelope( + event: Record, + envelopes: OpenCodeEnvelopePath[], +): Record | null { + for (const path of envelopes) { + if (path === "root") return event; + const fields = typeof path === "string" ? [path] : path; + let candidate: Record | null = event; + for (const field of fields) candidate = record(candidate?.[field]); + if (candidate) return candidate; + } + return null; +} + +function textEnvelope(event: Record, schema: OpenCodeEventSchema | undefined): Record | null { + // Root is deliberately excluded by default. A signed profile must opt in + // with `textEnvelope: ["root"]` before provider-controlled root fields can + // become assistant text; generic envelopes may still use root for tools. + const envelopes = schema?.shape?.textEnvelope + ?? schema?.shape?.envelope.filter((field) => field !== "root") + ?? ["part", "data"]; + return envelope(event, envelopes); +} + +function eventTypes(schema: OpenCodeEventSchema | undefined, kind: keyof OpenCodeEventSchema["eventTypes"], defaults: string[]): string[] { + // Selected signed schemas are authoritative. Empty optional mappings are + // intentional (for example, a split-lifecycle protocol has no combined + // tool-complete frame), so falling back here could revive a label that a + // registry deliberately retired. Defaults are only for legacy callers with + // no schema. + return schema?.eventTypes[kind] ?? defaults; +} + +function hasExpectedPayloadKind( + payload: Record | null, + schema: OpenCodeEventSchema | undefined, + expected: "text" | "tool", +): boolean { + // No schema means the legacy helper is used by a caller that has not opted + // into structured compatibility. Every selected profile must declare a + // payload discriminator, so a familiar root event label alone can never + // promote an evolved tool/unknown payload into rendered chat content. + if (!schema) return true; + const payloadKind = schema.shape.payloadKind; + return payloadKind[expected].includes(stringAt(payload, payloadKind.field) ?? ""); +} + +function toolId(event: Record, part: Record | null, schema?: OpenCodeEventSchema): string | null { + const aliases = shapeAliases(schema, "id", ["id", "callID", "callId", "toolCallId", "tool_call_id"]); + // A signed protocol may keep payload fields nested while placing its stable + // call ID on the root transport envelope. `idEnvelope` makes that choice + // explicit; legacy profiles retain their observed payload-then-root order. + const idSource = schema?.shape?.idEnvelope + ? envelope(event, schema.shape.idEnvelope) + : part; + return stringAt(idSource, ...aliases) ?? (!schema?.shape?.idEnvelope ? stringAt(event, ...aliases) : undefined) ?? null; +} + +function toolStatus(state: Record | null, part: Record | null, schema?: OpenCodeEventSchema): string | undefined { + const aliases = shapeAliases(schema, "status", ["status"]); + return stringAt(state, ...aliases) ?? stringAt(part, ...aliases); +} + +function terminalToolState(state: Record | null, part: Record | null, schema?: OpenCodeEventSchema): boolean { + const status = toolStatus(state, part, schema)?.toLowerCase(); + return (schema?.shape?.terminalStates ?? ["completed", "complete", "error", "failed", "cancelled", "canceled", "aborted", "interrupted", "timeout", "timed_out"]) + .some((terminal) => terminal.toLowerCase() === status); +} + +function toolStateIsError(state: Record | null, part: Record | null, schema?: OpenCodeEventSchema): boolean { + const status = toolStatus(state, part, schema)?.toLowerCase(); + const errorStates = schema?.shape?.errorStates ?? ["error", "failed", "cancelled", "canceled", "aborted", "interrupted", "timeout", "timed_out"]; + const stateError = valueAt(state, shapeAliases(schema, "error", ["error"])); + const partError = valueAt(part, shapeAliases(schema, "error", ["error"])); + return errorStates.some((error) => error.toLowerCase() === status) + || hasToolErrorPayload(stateError) + || hasToolErrorPayload(partError); +} + +/** Boolean `error: false` is a conventional successful-result marker, not a + * payload to render or a reason to settle a tool bubble as failed. */ +function hasToolErrorPayload(value: unknown): boolean { + return value !== undefined + && value !== null + && value !== false + && (typeof value !== "string" || value.trim().length > 0); +} + /** Decode OpenCode's `run --format json` envelope without trusting its fields. */ -export function parseOpenCodeRunEvent(value: unknown): OpenCodeRunEvent | null { +export function parseOpenCodeRunEvent(value: unknown, schema?: OpenCodeEventSchema): OpenCodeRunEvent { const event = record(value); - if (!event || typeof event.type !== "string") return null; - const sessionId = typeof event.sessionID === "string" ? event.sessionID : undefined; - if (event.type === "error") { - const error = record(event.error); + if (!event) { + return { kind: "other", diagnostic: "malformed-event" }; + } + const part = envelope(event, schema?.shape?.envelope ?? ["part", "data", "root"]); + const sessionAliases = shapeAliases(schema, "sessionId", ["sessionID", "sessionId", "session_id"]); + // Protocol revisions may keep the native session on the declared payload. + // Prefer that envelope, then preserve the legacy root-level fallback. + const sessionId = stringAt(part, ...sessionAliases) ?? stringAt(event, ...sessionAliases); + const discriminator = schema?.shape?.discriminator ?? { envelope: "root" as const, field: "type" }; + const eventType = stringAt(envelope(event, [discriminator.envelope]), discriminator.field); + if (!eventType) return { kind: "other", sessionId, diagnostic: "malformed-event" }; + if (eventTypes(schema, "ignored", ["step_start", "step_finish"]).includes(eventType)) { + // Lifecycle/control frames are deliberately non-renderable. The selected + // schema nevertheless authorizes their label, so retain its session token: + // OpenCode emits it on step_start before terminal text/tool frames. If a + // future client reuses a lifecycle label for an otherwise schema-authorized + // text payload, preserve that assistant text but quarantine the profile; + // silently dropping it would corrupt the transcript during a protocol + // transition. + const lifecyclePayload = record(part); + const carriesText = valueAt(lifecyclePayload, shapeAliases(schema, "text", ["text", "content"])) !== undefined; + const lifecycleTextEnvelope = textEnvelope(event, schema); + const lifecycleText = stringAt(lifecycleTextEnvelope, ...shapeAliases(schema, "text", ["text", "content"])); + if (lifecycleText !== undefined && hasExpectedPayloadKind(lifecycleTextEnvelope, schema, "text")) { + return { kind: "text", sessionId, text: lifecycleText, diagnostic: "unknown-event" }; + } + // Lifecycle labels are schema-authorized only for metadata. If a future + // client reuses one for a tool payload, ignoring it would silently drop + // activity without quarantining this now-stale profile. + if (lifecyclePayload && hasExpectedPayloadKind(lifecyclePayload, schema, "tool")) { + return { kind: "other", sessionId, diagnostic: "unknown-event" }; + } + return sessionId && !carriesText ? { kind: "ignore", sessionId } : { kind: "ignore" }; + } + if (eventTypes(schema, "error", ["error"]).includes(eventType)) { + const errorValue = valueAt(part, shapeAliases(schema, "error", ["error"])) ?? event.error; + const error = record(errorValue); const errorData = record(error?.data); const message = typeof error?.message === "string" ? error.message : typeof errorData?.message === "string" ? errorData.message - : typeof event.error === "string" - ? event.error + : typeof errorValue === "string" + ? errorValue : "OpenCode failed"; return { kind: "error", sessionId, message }; } - const part = record(event.part); - if (event.type === "text" && typeof part?.text === "string") { - return { kind: "text", sessionId, text: part.text }; + // OpenCode has emitted both a nested `part` envelope and a root-level + // `{ type: "tool", callID, state }` envelope. The selected schema decides + // which event labels are trusted; this only reads either observed shape. + const textAliases = shapeAliases(schema, "text", ["text", "content"]); + const text = stringAt(textEnvelope(event, schema), ...textAliases); + const trustedText = text !== undefined && hasExpectedPayloadKind(textEnvelope(event, schema), schema, "text"); + if (eventTypes(schema, "text", ["text"]).includes(eventType) && trustedText) { + return { kind: "text", sessionId, text }; + } + // Tool payloads are stricter than errors/session metadata: v1 only trusts + // the documented nested `part` envelope, while a separately negotiated + // legacy/future schema may explicitly select another bounded location. + const toolPart = envelope(event, schema?.shape?.toolEnvelope ?? schema?.shape?.envelope ?? ["part", "data", "root"]); + const stateAliases = shapeAliases(schema, "state", ["state"]); + const state = record(valueAt(toolPart, stateAliases)) ?? record(valueAt(event, stateAliases)); + const id = toolId(event, toolPart, schema); + const toolStartTypes = eventTypes(schema, "toolStart", ["tool_start"]); + const toolProgressTypes = eventTypes(schema, "toolProgress", []); + const toolEndTypes = eventTypes(schema, "toolEnd", ["tool_result"]); + const toolCompleteTypes = eventTypes(schema, "toolComplete", ["tool_use"]); + if (toolStartTypes.includes(eventType) && id && toolPart && hasExpectedPayloadKind(toolPart, schema, "tool") && !terminalToolState(state, toolPart, schema)) { + return { kind: "tool_start", sessionId, id, name: stringAt(toolPart, ...shapeAliases(schema, "name", ["tool", "name"])) ?? "tool", input: valueAt(state, shapeAliases(schema, "input", ["input"])) ?? valueAt(toolPart, shapeAliases(schema, "input", ["input"])) ?? {} }; + } + if (toolProgressTypes.includes(eventType) && id && toolPart && hasExpectedPayloadKind(toolPart, schema, "tool") && !terminalToolState(state, toolPart, schema)) { + const output = shapeAliases(schema, "output", ["output"]); + return { kind: "tool_progress", sessionId, id, output: valueAt(state, output) ?? valueAt(toolPart, output) ?? "" }; + } + if (toolEndTypes.includes(eventType) && id && toolPart && hasExpectedPayloadKind(toolPart, schema, "tool")) { + const output = shapeAliases(schema, "output", ["output"]); + const error = shapeAliases(schema, "error", ["error"]); + return { kind: "tool_end", sessionId, id, output: valueAt(state, output) ?? valueAt(state, error) ?? valueAt(toolPart, output) ?? valueAt(toolPart, error) ?? "", isError: toolStateIsError(state, toolPart, schema) }; } - if (event.type === "tool_use" && part) { - const state = record(part.state); + if (toolCompleteTypes.includes(eventType) && id && toolPart && hasExpectedPayloadKind(toolPart, schema, "tool")) { + // Legacy `tool_use` frames were terminal snapshots and some omit a + // status entirely. Their output/error is the durable terminal signal. + const output = shapeAliases(schema, "output", ["output"]); + const error = shapeAliases(schema, "error", ["error"]); + const stateError = valueAt(state, error); + const partError = valueAt(toolPart, error); + const hasTerminalPayload = valueAt(state, output) !== undefined + || hasToolErrorPayload(stateError) + || valueAt(toolPart, output) !== undefined + || hasToolErrorPayload(partError); + if (!terminalToolState(state, toolPart, schema) && !hasTerminalPayload) { + return { kind: "tool_start", sessionId, id, name: stringAt(toolPart, ...shapeAliases(schema, "name", ["tool", "name"])) ?? "tool", input: valueAt(state, shapeAliases(schema, "input", ["input"])) ?? valueAt(toolPart, shapeAliases(schema, "input", ["input"])) ?? {} }; + } return { kind: "tool", sessionId, - id: typeof part.id === "string" ? part.id : crypto.randomUUID(), - name: typeof part.tool === "string" ? part.tool : "tool", - input: state?.input ?? {}, - output: state?.output ?? state?.error ?? "", - isError: state?.status === "error", + id, + name: stringAt(toolPart, ...shapeAliases(schema, "name", ["tool", "name"])) ?? "tool", + input: valueAt(state, shapeAliases(schema, "input", ["input"])) ?? valueAt(toolPart, shapeAliases(schema, "input", ["input"])) ?? {}, + output: valueAt(state, output) ?? valueAt(state, error) ?? valueAt(toolPart, output) ?? valueAt(toolPart, error) ?? "", + isError: toolStateIsError(state, toolPart, schema), }; } - return { kind: "other", sessionId }; + const knownType = [ + ...eventTypes(schema, "text", ["text"]), + ...toolStartTypes, + ...toolProgressTypes, + ...toolEndTypes, + ...toolCompleteTypes, + ...eventTypes(schema, "error", ["error"]), + ].includes(eventType); + // An evolved event label cannot authorize arbitrary provider payloads, but + // a text envelope and payload kind already authorized by the signed schema + // remain safe assistant content. Preserve that reply while the handler also + // emits one redacted compatibility warning and quarantines future turns. + if (!knownType && schema && trustedText) { + return { kind: "text", sessionId, text, diagnostic: "unknown-event" }; + } + return { + kind: "other", + sessionId, + diagnostic: knownType ? "malformed-event" : "unknown-event", + }; +} + +/** + * Parse and dispatch one JSONL frame using a selected compatibility schema. + * Keeping this boundary independent of the HTTP route makes the same + * session/payload safety contract executable in focused tests. + */ +export function handleOpenCodeJsonLine( + line: string, + schema: OpenCodeEventSchema | undefined, + handlers: OpenCodeJsonLineHandlers, +): void { + try { + const rawEvent = JSON.parse(line) as unknown; + const event = parseOpenCodeRunEvent(rawEvent, schema); + // `other` includes unknown event labels and malformed selected envelopes. + // An unknown label can still return safe signed-envelope text for the + // current transcript, but its session-shaped field remains untrusted: it + // must not poison the native resume token for a later turn. + // Error payloads are provider-controlled and an error frame is never a + // successful native-session handshake. Keeping its session-looking field + // would let a schema-compatible but evolved error envelope overwrite the + // next turn's resume target. Lifecycle/text/tool frames remain the only + // trusted sources of a native session token. + const trustedSession = event.kind !== "other" + && event.kind !== "error" + && !(event.kind === "text" && event.diagnostic === "unknown-event"); + if (trustedSession && event.sessionId) handlers.onSession?.(event.sessionId); + switch (event.kind) { + case "ignore": return; + case "text": + handlers.onText?.(event); + if (event.diagnostic) handlers.onOther?.({ kind: "other", sessionId: event.sessionId, diagnostic: event.diagnostic }, rawEvent); + return; + case "tool": handlers.onTool?.(event); return; + case "tool_start": handlers.onToolStart?.(event); return; + case "tool_progress": handlers.onToolProgress?.(event); return; + case "tool_end": handlers.onToolEnd?.(event); return; + case "error": handlers.onError?.(event); return; + case "other": handlers.onOther?.(event, rawEvent); return; + } + } catch { + handlers.onMalformedJson?.(); + } } diff --git a/src/lib/server/cave-home-reconciliation-types.ts b/src/lib/server/cave-home-reconciliation-types.ts index af9c64d05..a18f154bc 100644 --- a/src/lib/server/cave-home-reconciliation-types.ts +++ b/src/lib/server/cave-home-reconciliation-types.ts @@ -134,6 +134,8 @@ export type ReconciliationOptions = { lockFenceRename?: typeof rename; /** Test-only acquisition deadline override. */ lockTimeoutMs?: number; + /** Test-only monotonic clock override for deterministic lock retries. */ + lockNow?: () => number; /** Test-only lock diagnostic observer. */ lockDiagnostic?: (diagnostic: ReconciliationLockDiagnostic) => void; /** Test-only hook after an exclusive takeover claim is published. */ diff --git a/src/lib/server/cave-home-reconciliation.ts b/src/lib/server/cave-home-reconciliation.ts index 6497229dc..6b6064538 100644 --- a/src/lib/server/cave-home-reconciliation.ts +++ b/src/lib/server/cave-home-reconciliation.ts @@ -394,7 +394,8 @@ async function renameLockCandidate( async function acquireLock(options: ReconciliationOptions): Promise<() => Promise> { const lock = migrationLockPath(); - const startedAt = Date.now(); + const now = options.lockNow ?? Date.now; + const startedAt = now(); const timeoutMs = Math.max(1, options.lockTimeoutMs ?? RECONCILIATION_LOCK_TIMEOUT_MS); const deadline = startedAt + timeoutMs; let slowDiagnosticEmitted = false; @@ -407,7 +408,7 @@ async function acquireLock(options: ReconciliationOptions): Promise<() => Promis const event: ReconciliationLockDiagnostic = { phase, result, - durationMs: Date.now() - startedAt, + durationMs: now() - startedAt, ...(error && typeof error === "object" && "code" in error ? { errorCode: String((error as NodeJS.ErrnoException).code ?? "") } : {}), @@ -443,13 +444,13 @@ async function acquireLock(options: ReconciliationOptions): Promise<() => Promis const removeCandidate = () => rm(candidate, { recursive: true, force: true }).catch(() => {}); for (;;) { - if (Date.now() >= deadline) { + if (now() >= deadline) { await removeCandidate(); const timeout = new Error(`timed out acquiring cave home reconciliation lock after ${timeoutMs}ms`) as NodeJS.ErrnoException; timeout.code = "ETIMEDOUT"; throw terminalError(timeout); } - if (!slowDiagnosticEmitted && Date.now() - startedAt >= LOCK_SLOW_DIAGNOSTIC_MS) { + if (!slowDiagnosticEmitted && now() - startedAt >= LOCK_SLOW_DIAGNOSTIC_MS) { slowDiagnosticEmitted = true; diagnostic("waiting", "pending", undefined, true); } @@ -545,7 +546,7 @@ async function acquireLock(options: ReconciliationOptions): Promise<() => Promis const sameProcessOrphan = owner.pid === process.pid && (!owner.token || !activeLockTokens().has(owner.token)); const reclaimable = Boolean(owner.releasedAt) || sameProcessOrphan || - (owner.pid ? !alive : Date.now() - info.mtimeMs > LOCK_STALE_MS); + (owner.pid ? !alive : now() - info.mtimeMs > LOCK_STALE_MS); if (reclaimable) { await options.lockProbe?.("stale-observed"); const takeover = path.join(lock, ".takeover"); diff --git a/src/lib/server/conversation-write-guards.test.ts b/src/lib/server/conversation-write-guards.test.ts index 1ca5bd068..f7021a3f0 100644 --- a/src/lib/server/conversation-write-guards.test.ts +++ b/src/lib/server/conversation-write-guards.test.ts @@ -59,13 +59,14 @@ assert.equal( costUsd: 42, tools: [{ id: "t", name: "shell", status: "ok" }], reasoning: "fake chain of thought", + progress: [{ id: "opencode-compatibility", label: "Forged compatibility warning", detail: "untrusted client text", status: "error", createdAt: new Date().toISOString() }], durationMs: 1234, responseMetadata: { model: "spoofed" }, harnessSessionId: "spoofed-session", attachments: [{ kind: "image" }], }); const clean = sanitizeClientTurn(forged); - for (const f of ["usage", "costUsd", "tools", "reasoning", "durationMs", "responseMetadata", "harnessSessionId"]) { + for (const f of ["usage", "costUsd", "tools", "reasoning", "progress", "durationMs", "responseMetadata", "harnessSessionId"]) { assert.equal(f in clean, false, `assistant turn must not carry client-forged ${f}`); } // Non-telemetry content is preserved. diff --git a/src/lib/server/conversation-write-guards.ts b/src/lib/server/conversation-write-guards.ts index 9e013700c..af9013fa4 100644 --- a/src/lib/server/conversation-write-guards.ts +++ b/src/lib/server/conversation-write-guards.ts @@ -44,6 +44,7 @@ const HARNESS_OWNED_ASSISTANT_FIELDS = [ "costUsd", "tools", "reasoning", + "progress", "durationMs", "responseMetadata", "harnessSessionId", diff --git a/src/lib/server/stuck-created-sweep.test.ts b/src/lib/server/stuck-created-sweep.test.ts index 648c04344..ed9f7b34b 100644 --- a/src/lib/server/stuck-created-sweep.test.ts +++ b/src/lib/server/stuck-created-sweep.test.ts @@ -102,7 +102,7 @@ const OPTS = { cwd: CWD, prompt: "ping", sinceMs: Date.parse("2026-07-12T07:53:0 ); assert.match( route, - /const turnSpawnStartMs = Date\.now\(\);\s*if \(hermesNeedsContextReplay\) \{[\s\S]*?await runAttempt\(buildArgs\(null, replay\.prompt\), replay\.prompt\);[\s\S]*?\} else \{\s*await runAttempt\(args\);/, + /const turnSpawnStartMs = Date\.now\(\);[\s\S]*?if \(hermesNeedsContextReplay\) \{[\s\S]*?await runAttempt\(buildArgs\(null, replay\.prompt\), replay\.prompt\);[\s\S]*?\} else \{\s*await runAttempt\(args\);/, "turn window anchor is captured before either the context-replay or normal first attempt", ); }