From e183e8054497907f8e4a799853963238d5db16e7 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 24 Jul 2026 19:16:56 -0400 Subject: [PATCH 001/112] Add resilient OpenCode tool activity --- scripts/run-tests.mjs | 4 + .../api/chat/send/chat-send-capabilities.ts | 59 +++++ .../send/harness-routing-opencode.test.ts | 26 +- src/app/api/chat/send/route.ts | 91 ++++++- src/lib/chat-tool-events.test.ts | 15 ++ src/lib/chat-tool-events.ts | 26 +- src/lib/opencode-compatibility.test.ts | 76 ++++++ src/lib/opencode-compatibility.ts | 245 ++++++++++++++++++ src/lib/opencode-stream.test.ts | 40 ++- src/lib/opencode-stream.ts | 72 ++++- 10 files changed, 626 insertions(+), 28 deletions(-) create mode 100644 src/lib/chat-tool-events.test.ts create mode 100644 src/lib/opencode-compatibility.test.ts create mode 100644 src/lib/opencode-compatibility.ts diff --git a/scripts/run-tests.mjs b/scripts/run-tests.mjs index af0702f95..f0cd01f49 100644 --- a/scripts/run-tests.mjs +++ b/scripts/run-tests.mjs @@ -472,6 +472,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", @@ -1277,6 +1279,8 @@ 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/familiar-workspace-sessions.test.ts", "scripts/cave-home-migration-windows.test.ts", "src/lib/bento-dashboard.test.ts", diff --git a/src/app/api/chat/send/chat-send-capabilities.ts b/src/app/api/chat/send/chat-send-capabilities.ts index 67d992411..0f12e5ce8 100644 --- a/src/app/api/chat/send/chat-send-capabilities.ts +++ b/src/app/api/chat/send/chat-send-capabilities.ts @@ -7,12 +7,14 @@ 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; +let openCodeCapabilitiesProbe: Promise | null = null; function probeHelp( command: string, @@ -59,6 +61,40 @@ function probeHelp( }); } +function probeOutput(command: string, args: string[], env = harnessSpawnEnv(), input?: string): Promise { + return new Promise((resolve) => { + let output = ""; + const MAX_PROBE_OUTPUT = 64 * 1024; + let settled = false; + const done = () => { + if (settled) return; + settled = true; + resolve(output); + }; + try { + const child = spawn(command, args, { + env, + stdio: input === undefined ? ["ignore", "pipe", "pipe"] : ["pipe", "pipe", "pipe"], + }) as ChildProcessWithoutNullStreams; + if (input !== undefined) writeOpenCodeLaunchInput(child, { command, args, input }); + const append = (chunk: Buffer) => { + if (output.length >= MAX_PROBE_OUTPUT) return; + output += chunk.toString().slice(0, MAX_PROBE_OUTPUT - output.length); + }; + child.stdout.on("data", append); + child.stderr.on("data", append); + const timeout = setTimeout(() => { + try { child.kill("SIGTERM"); } catch { /* Probe failures are capabilities=false. */ } + done(); + }, 2500); + child.on("close", () => { clearTimeout(timeout); done(); }); + child.on("error", () => { clearTimeout(timeout); done(); }); + } catch { + done(); + } + }); +} + /** Capability probes are cached because old Coven CLIs reject unknown flags. */ export function covenRunSupportsModel(): Promise { const { command, fixedArgs } = covenLaunchCommand(); @@ -108,3 +144,26 @@ 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 function openCodeRunCapabilities(): Promise { + return (openCodeCapabilitiesProbe ??= (async () => { + const helpLaunch = openCodeLaunch(["run", "--help"]); + const versionLaunch = openCodeLaunch(["--version"]); + const [help, versionOutput] = await Promise.all([ + probeOutput(helpLaunch.command, helpLaunch.args, openCodeSpawnEnv(), helpLaunch.input), + probeOutput(versionLaunch.command, versionLaunch.args, openCodeSpawnEnv(), versionLaunch.input), + ]); + const version = versionOutput.match(/\b\d+(?:\.\d+){1,3}(?:[-+][\w.-]+)?\b/)?.[0] ?? null; + return { + version, + json: /--format(?:[=\s][^\n]*)?/m.test(help) && /\bjson\b/i.test(help), + model: /(^|\s)--model(?![\w-])/m.test(help), + session: /(^|\s)--session(?![\w-])/m.test(help), + }; + })()); +} 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..d8c5e9ee2 100644 --- a/src/app/api/chat/send/harness-routing-opencode.test.ts +++ b/src/app/api/chat/send/harness-routing-opencode.test.ts @@ -12,12 +12,12 @@ 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", + /const a = \["run"\];[\s\S]*?openCodeCompatibility\?\.mode === "structured"[\s\S]*?a\.push\("--format", "json"\);[\s\S]*?openCodeCompatibility\?\.capabilities\.session[\s\S]*?a\.push\("--session", resumeSessionId\);/, + "OpenCode uses discovered JSON and session capabilities rather than a version threshold", ); assert.match( route, - /const ev = parseOpenCodeRunEvent\(JSON\.parse\(line\)\);[\s\S]*?announceSession\(ev\.sessionId\);/, + /parseOpenCodeRunEvent\(JSON\.parse\(line\), openCodeCompatibility\?\.schema\);[\s\S]*?announceSession\(ev\.sessionId\);/, "the first structured OpenCode event persists its minted session id", ); assert.match( @@ -70,5 +70,25 @@ assert.match( /ev\.kind === "error"[\s\S]*?recordStdoutErrorTail\(ev\.message, true\)/, "structured OpenCode errors retain model-rejection details even when they lack generic error keywords", ); +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, + /ev\.kind === "tool_start"[\s\S]*?envelopeToolUse[\s\S]*?ev\.kind === "tool_end"[\s\S]*?envelopeToolResult/, + "split tool lifecycle frames preserve the stable bubble id across progress and result", +); +assert.match( + route, + /opencode-compatibility[\s\S]*?unrecognized tool event/, + "unknown future event shapes surface a safe visible diagnostic", +); +assert.match( + capabilities, + /export function openCodeRunCapabilities\([^)]*\)[\s\S]*?\["--version"\][\s\S]*?json:[\s\S]*?model:[\s\S]*?session:/, + "OpenCode discovers feature support and records version only for diagnostics", +); console.log("opencode harness routing tests passed"); diff --git a/src/app/api/chat/send/route.ts b/src/app/api/chat/send/route.ts index 0e0ad5623..4ff2ae15a 100644 --- a/src/app/api/chat/send/route.ts +++ b/src/app/api/chat/send/route.ts @@ -63,6 +63,7 @@ import { } from "@/lib/grok-build"; import { grokLaunchCommand } from "@/lib/grok-bin"; import { openCodeLaunch, openCodeSpawnEnv, writeOpenCodeLaunchInput } from "@/lib/opencode-bin"; +import { resolveOpenCodeCompatibility } from "@/lib/opencode-compatibility"; import { parseOpenCodeRunEvent } from "@/lib/opencode-stream"; import { buildPromptWithCovenIdentityCanon } from "@/lib/coven-identity-canon"; import { @@ -165,7 +166,7 @@ import { covenRunSupportsModel, hermesChatSupportsModel, covenRunSupportsPermission, - openCodeRunSupportsModel, + openCodeRunCapabilities, } from "./chat-send-capabilities"; import { buildPromptWithResponseControls, @@ -930,11 +931,14 @@ 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"; + const openCodeCompatibility = openCodeDirect + ? await resolveOpenCodeCompatibility(await openCodeRunCapabilities()) + : null; const modelForwardingEnabled = hermesDirect ? 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 @@ -1353,10 +1357,12 @@ 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); + // 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") a.push("--format", "json"); + if (resumeSessionId && openCodeCompatibility?.capabilities.session) a.push("--session", resumeSessionId); if (forwardModel) a.push("--model", forwardModel); a.push(prompt); return a; @@ -1399,9 +1405,18 @@ export async function POST(req: Request) { const grokSandboxRetry = grokFreshSessionForSandbox ? buildResumeRetryPrompt(harnessPrompt, existingConversation) : null; + // A client which no longer advertises `--session` cannot safely be pointed + // at Cave's saved session id. Start a fresh native session with recent + // transcript context instead; the in-stream notice makes this explicit. + const openCodeFreshSessionForCompatibility = Boolean( + openCodeDirect && resumeTarget && !openCodeCompatibility?.capabilities.session, + ); + 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 @@ -1541,6 +1556,7 @@ 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 openCodeCompatibilityNoticeSent = false; // Model parity: the harness echoes its resolved model on the init/system // stream event. Capturing it lets the application state render honestly as @@ -1751,8 +1767,14 @@ export async function POST(req: Request) { }; const handleOpenCodeLine = (line: string) => { + if (openCodeCompatibility?.mode === "plain") { + const text = `${resolveBackspaces(stripAnsi(line))}\n`; + assistantText += text; + push({ kind: "assistant_chunk", text }); + return; + } try { - const ev = parseOpenCodeRunEvent(JSON.parse(line)); + const ev = parseOpenCodeRunEvent(JSON.parse(line), openCodeCompatibility?.schema); if (!ev) return; if (ev.sessionId && !sessionId) announceSession(ev.sessionId); if (ev.kind === "text") { @@ -1778,12 +1800,44 @@ export async function POST(req: Request) { if (ended) push({ kind: "tool_use", ...ended }); return; } + if (ev.kind === "tool_start") { + 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 reorderedEnd = toolTracker.consumePendingEnvelopeResult(ev.id); + if (reorderedEnd) push({ kind: "tool_use", ...reorderedEnd }); + return; + } + if (ev.kind === "tool_end") { + 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); result = { ...result, is_error: true }; + return; + } + if (ev.kind === "other" && !openCodeCompatibilityNoticeSent) { + openCodeCompatibilityNoticeSent = true; + pushProgress( + "opencode-compatibility", + "OpenCode sent an unrecognized tool event; continuing with assistant text", + "error", + ev.diagnostic ?? "unknown-event", + ); } } catch { recordStdoutErrorTail(resolveBackspaces(stripAnsi(line))); @@ -1796,6 +1850,15 @@ export async function POST(req: Request) { // leak into bubble text. const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine; if (!line) return; + if (openCodeDirect && !openCodeCompatibilityNoticeSent && openCodeCompatibility?.diagnostic) { + openCodeCompatibilityNoticeSent = 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" + : "OpenCode schema refresh was not trusted; using the last known compatible parser"; + pushProgress("opencode-compatibility", diagnostic, "error", openCodeCompatibility.diagnostic); + } if (RESUME_ERR_RE.test(line)) resumeFailed = true; const isJson = !hermesDirect && line.startsWith("{") && line.endsWith("}"); if (copilotStream) { @@ -2143,6 +2206,14 @@ export async function POST(req: Request) { // First attempt — uses --continue if body.sessionId was set. const turnSpawnStartMs = Date.now(); + if (openCodeFreshSessionForCompatibility) { + pushProgress( + "opencode-compatibility", + "This OpenCode client cannot resume sessions; replaying recent context in a fresh chat", + "error", + "session-unavailable", + ); + } await runAttempt(args); // Self-heal (cave-1c05): a stale scaffolded manifest whose id the @@ -2171,6 +2242,7 @@ export async function POST(req: Request) { jsonBuf = ""; result = {}; toolTracker = new ToolCallTracker(); + openCodeCompatibilityNoticeSent = false; copilotText.reset(); stderrTail.length = 0; stdoutErrTail.length = 0; @@ -2210,6 +2282,7 @@ export async function POST(req: Request) { jsonBuf = ""; result = {}; toolTracker = new ToolCallTracker(); + openCodeCompatibilityNoticeSent = false; copilotText.reset(); stderrTail.length = 0; stdoutErrTail.length = 0; diff --git a/src/lib/chat-tool-events.test.ts b/src/lib/chat-tool-events.test.ts new file mode 100644 index 000000000..f5c96ec19 --- /dev/null +++ b/src/lib/chat-tool-events.test.ts @@ -0,0 +1,15 @@ +// @ts-nocheck +import assert from "node:assert/strict"; +import { ToolCallTracker } from "./chat-tool-events.ts"; + +const tracker = new ToolCallTracker(() => 1_000); +assert.equal(tracker.envelopeToolResult("call_1", "late terminal output", false), 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(tracker.snapshot().length, 1, "reordered events reconcile to one persisted bubble"); + +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 1e0ad5703..0c9eb944a 100644 --- a/src/lib/chat-tool-events.ts +++ b/src/lib/chat-tool-events.ts @@ -102,6 +102,8 @@ 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(); /** 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(); @@ -247,10 +249,20 @@ export class ToolCallTracker { return ev; } + /** Settle a result that arrived before its start, once the id is known. */ + consumePendingEnvelopeResult(toolUseId: string): ToolStreamEvent | null { + const pending = this.pendingEnvelopeResults.get(toolUseId); + if (!pending) return null; + this.pendingEnvelopeResults.delete(toolUseId); + return this.envelopeToolResult(toolUseId, pending.output, pending.isError); + } + /** * 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, @@ -259,7 +271,17 @@ export class ToolCallTracker { ): ToolStreamEvent | null { if (this.settledEnvelopeIds.has(toolUseId)) return null; 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. + if (this.pendingEnvelopeResults.size >= 100) { + const oldest = this.pendingEnvelopeResults.keys().next().value; + if (oldest) this.pendingEnvelopeResults.delete(oldest); + } + this.pendingEnvelopeResults.set(toolUseId, { output, isError }); + return null; + } const durationMs = this.now() - call.startedAt; this.settle(call); const ev: ToolStreamEvent = { diff --git a/src/lib/opencode-compatibility.test.ts b/src/lib/opencode-compatibility.test.ts new file mode 100644 index 000000000..1d0bb0370 --- /dev/null +++ b/src/lib/opencode-compatibility.test.ts @@ -0,0 +1,76 @@ +// @ts-nocheck +import assert from "node:assert/strict"; +import { generateKeyPairSync, sign } from "node:crypto"; +import { mkdtemp, readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { + BUILTIN_OPENCODE_SCHEMA_BUNDLE, + loadOpenCodeSchemaBundle, + openCodeSchemaBundleSigningPayload, + resolveOpenCodeCompatibility, +} 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 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 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", "offline keeps the last known good parser"); + +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 never overwrites cache"); + +const plain = await resolveOpenCodeCompatibility({ version: "9.9.9", json: false, model: true, session: true }); +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 }); +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 }); +assert.equal(missingSession.mode, "structured"); +assert.equal(missingSession.schema?.id, "opencode-run-json-legacy", "older compatible schemas coexist without client version gates"); + +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..674264502 --- /dev/null +++ b/src/lib/opencode-compatibility.ts @@ -0,0 +1,245 @@ +import { createPublicKey, verify } from "node:crypto"; +import { mkdir, readFile } from "node:fs/promises"; +import path from "node:path"; +import { caveHome } from "./coven-paths.ts"; +import { writeJsonAtomic } from "./server/atomic-write.ts"; + +export type OpenCodeRunCapabilities = { + version: string | null; + json: boolean; + model: boolean; + session: boolean; +}; + +export type OpenCodeEventSchema = { + id: string; + /** A schema is selected only when every advertised requirement is met. */ + requires: { json: true; session?: boolean; model?: boolean }; + eventTypes: { + text: string[]; + toolStart: string[]; + toolEnd: string[]; + toolComplete: string[]; + error: string[]; + }; +}; + +export type OpenCodeSchemaBundle = { + format: 1; + runtime: "opencode"; + sequence: number; + issuedAt: string; + expiresAt: 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-registry-refresh-rejected" + | "cached-schema-unavailable"; + +const MAX_SCHEMA_BUNDLE_BYTES = 256 * 1024; +const CACHE_TTL_MS = 6 * 60 * 60 * 1000; +const CACHE_FILE = "opencode-schema-bundle-v1.json"; + +/** + * 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: 1, + 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 }, + eventTypes: { + text: ["text"], + toolStart: ["tool_start", "tool"], + toolEnd: ["tool_result"], + toolComplete: ["tool_use", "tool"], + error: ["error"], + }, + }, + { + // Earlier and preview clients used generic tool envelopes. Keeping this + // separate lets a signed registry retire it without a code release. + id: "opencode-run-json-legacy", + requires: { json: true, session: false }, + eventTypes: { + text: ["message", "assistant_text"], + toolStart: ["tool"], + toolEnd: ["tool_output"], + toolComplete: ["tool_call"], + error: ["error", "failed"], + }, + }, + ], +}; + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +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 (value.requires.json !== true || (value.requires.session !== undefined && typeof value.requires.session !== "boolean") || (value.requires.model !== undefined && typeof value.requires.model !== "boolean")) return false; + const eventKeys = ["text", "toolStart", "toolEnd", "toolComplete", "error"]; + const eventTypes = value.eventTypes; + if (Object.keys(eventTypes).length !== eventKeys.length || !eventKeys.every((key) => Array.isArray(eventTypes[key]) && eventTypes[key].every((type: unknown) => typeof type === "string" && type.length > 0 && type.length <= 80))) return false; + return true; +} + +function schemaMatches(schema: OpenCodeEventSchema, capabilities: OpenCodeRunCapabilities): boolean { + if (!capabilities.json) return false; + if (schema.requires.session !== undefined && schema.requires.session !== capabilities.session) return false; + if (schema.requires.model !== undefined && schema.requires.model !== capabilities.model) return false; + return true; +} + +export function isOpenCodeSchemaBundle(value: unknown, now = Date.now()): value is OpenCodeSchemaBundle { + if (!isRecord(value) || value.format !== 1 || value.runtime !== "opencode") return false; + if (typeof value.sequence !== "number" || !Number.isSafeInteger(value.sequence) || value.sequence < 1 || !Array.isArray(value.schemas) || !value.schemas.every(isEventSchema)) return false; + const issuedAt = Date.parse(String(value.issuedAt)); + const expiresAt = Date.parse(String(value.expiresAt)); + return Number.isFinite(issuedAt) && Number.isFinite(expiresAt) && issuedAt <= now && expiresAt > now; +} + +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 verifyOpenCodeSchemaBundle(bundle: unknown, publicKey: string, now = Date.now()): bundle is OpenCodeSchemaBundle { + if (!isOpenCodeSchemaBundle(bundle, now) || !bundle.signature || bundle.signature.algorithm !== "ed25519") return false; + try { + return verify(null, Buffer.from(openCodeSchemaBundleSigningPayload(bundle)), createPublicKey(publicKey), Buffer.from(bundle.signature.value, "base64")); + } catch { + return false; + } +} + +type CachedBundle = { checkedAt: number; bundle: OpenCodeSchemaBundle }; + +function cachePath(): string { + return path.join(caveHome(), CACHE_FILE); +} + +async function readVerifiedCache(file: string, publicKey: string, now: number): Promise { + try { + const raw = await readFile(file, "utf8"); + if (Buffer.byteLength(raw, "utf8") > MAX_SCHEMA_BUNDLE_BYTES) return null; + const cached = JSON.parse(raw) as CachedBundle; + if (!isRecord(cached) || typeof cached.checkedAt !== "number" || !verifyOpenCodeSchemaBundle(cached.bundle, publicKey, now)) return null; + return cached; + } catch { + return null; + } +} + +export type OpenCodeSchemaBundleSource = { + url?: string; + publicKey?: string; + fetch?: typeof fetch; + now?: () => number; + cacheFile?: string; +}; + +/** + * Read a last-known-good schema bundle and opportunistically refresh it. 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. This makes network loss and unsafe rollbacks non-events. + */ +export async function loadOpenCodeSchemaBundle(source: OpenCodeSchemaBundleSource = {}): Promise<{ + bundle: OpenCodeSchemaBundle; + source: "built-in" | "cache" | "remote"; + diagnostic?: "schema-registry-refresh-rejected" | "cached-schema-unavailable"; +}> { + const now = source.now?.() ?? Date.now(); + const file = source.cacheFile ?? cachePath(); + const url = source.url ?? process.env.COVEN_OPENCODE_SCHEMA_REGISTRY_URL; + const publicKey = source.publicKey ?? process.env.COVEN_OPENCODE_SCHEMA_REGISTRY_PUBLIC_KEY; + if (!url || !publicKey) return { bundle: BUILTIN_OPENCODE_SCHEMA_BUNDLE, source: "built-in" }; + + const cached = await readVerifiedCache(file, publicKey, now); + const cacheFresh = cached && now - cached.checkedAt < CACHE_TTL_MS; + if (cacheFresh) return { bundle: cached.bundle, source: "cache" }; + + try { + const response = await (source.fetch ?? fetch)(url, { headers: { accept: "application/json" } }); + const raw = await response.text(); + if (!response.ok || Buffer.byteLength(raw, "utf8") > MAX_SCHEMA_BUNDLE_BYTES) throw new Error("untrusted schema bundle"); + const remote = JSON.parse(raw) as unknown; + if (!verifyOpenCodeSchemaBundle(remote, publicKey, now)) throw new Error("invalid schema signature"); + if (cached && remote.sequence < cached.bundle.sequence) throw new Error("schema rollback"); + // A sequence identifies immutable parser semantics. A changed payload at + // the same sequence is indistinguishable from a rollback to callers, so + // retain the last-known-good cache even if it is freshly signed. + if (cached && remote.sequence === cached.bundle.sequence) { + if (openCodeSchemaBundleSigningPayload(remote) !== openCodeSchemaBundleSigningPayload(cached.bundle)) throw new Error("schema sequence rewritten"); + await mkdir(path.dirname(file), { recursive: true }); + await writeJsonAtomic(file, { checkedAt: now, bundle: cached.bundle }); + return { bundle: cached.bundle, source: "cache" }; + } + await mkdir(path.dirname(file), { recursive: true }); + await writeJsonAtomic(file, { checkedAt: now, bundle: remote }); + return { bundle: remote, source: "remote" }; + } catch { + if (cached) return { bundle: cached.bundle, source: "cache", diagnostic: "schema-registry-refresh-rejected" }; + return { bundle: BUILTIN_OPENCODE_SCHEMA_BUNDLE, source: "built-in", diagnostic: "cached-schema-unavailable" }; + } +} + +export async function resolveOpenCodeCompatibility( + capabilities: OpenCodeRunCapabilities, + source?: OpenCodeSchemaBundleSource, +): Promise { + const loaded = await loadOpenCodeSchemaBundle(source); + if (!capabilities.json) { + return { + mode: "plain", + capabilities, + bundleSource: loaded.source, + diagnostic: "json-format-unavailable", + }; + } + const schema = loaded.bundle.schemas.find((candidate) => schemaMatches(candidate, capabilities)); + if (!schema) { + return { + mode: "plain", + capabilities, + bundleSource: loaded.source, + diagnostic: "no-compatible-schema", + }; + } + return { + mode: "structured", + capabilities, + schema, + bundleSource: loaded.source, + diagnostic: loaded.diagnostic, + }; +} diff --git a/src/lib/opencode-stream.test.ts b/src/lib/opencode-stream.test.ts index 99332d976..011bd7486 100644 --- a/src/lib/opencode-stream.test.ts +++ b/src/lib/opencode-stream.test.ts @@ -1,5 +1,6 @@ // @ts-nocheck import assert from "node:assert/strict"; +import { BUILTIN_OPENCODE_SCHEMA_BUNDLE } from "./opencode-compatibility.ts"; import { parseOpenCodeRunEvent } from "./opencode-stream.ts"; assert.deepEqual( @@ -12,7 +13,7 @@ assert.deepEqual( ); 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 +25,41 @@ assert.deepEqual( { kind: "error", sessionId: "ses_123", message: "Selected model is unavailable" }, "OpenCode nests command errors under error.data.message", ); +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: "tool_start", sessionId: "ses_123", id: "tool_1", name: "Read", input: { path: "README.md" } }, + "newer split lifecycle events keep their upstream id", +); +assert.deepEqual( + parseOpenCodeRunEvent( + { type: "tool_result", session_id: "ses_123", data: { id: "tool_1", state: { output: "ok" } } }, + BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0], + ), + { kind: "tool_end", sessionId: "ses_123", id: "tool_1", output: "ok", isError: false }, + "reordered results can close the same stable tool 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: "tool_start", sessionId: "ses_123", id: "call_1", name: "bash", input: { command: "pwd" } }, + "current root tool updates use callID without fabricating a local id", +); +assert.deepEqual( + parseOpenCodeRunEvent( + { type: "tool", sessionID: "ses_123", callID: "call_1", tool: "bash", state: { input: { command: "pwd" }, output: "ok", status: "completed" } }, + BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0], + ), + { kind: "tool", sessionId: "ses_123", id: "call_1", name: "bash", input: { command: "pwd" }, output: "ok", isError: false }, + "current root terminal tool updates preserve input and output", +); +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", +); console.log("opencode-stream.test.ts: ok"); diff --git a/src/lib/opencode-stream.ts b/src/lib/opencode-stream.ts index 73635f51e..dd09f8828 100644 --- a/src/lib/opencode-stream.ts +++ b/src/lib/opencode-stream.ts @@ -1,8 +1,12 @@ +import type { OpenCodeEventSchema } from "@/lib/opencode-compatibility"; + export type OpenCodeRunEvent = | { kind: "text"; sessionId?: string; text: string } + | { kind: "tool_start"; sessionId?: string; id: string; name: string; input: 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" }; function record(value: unknown): Record | null { return value && typeof value === "object" && !Array.isArray(value) @@ -10,12 +14,30 @@ 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; +} + +function eventTypes(schema: OpenCodeEventSchema | undefined, kind: keyof OpenCodeEventSchema["eventTypes"], defaults: string[]): string[] { + return schema?.eventTypes[kind]?.length ? schema.eventTypes[kind] : defaults; +} + +function toolId(part: Record | null): string | null { + return stringAt(part, "id", "callID", "callId", "toolCallId", "tool_call_id") ?? null; +} + +function terminalToolState(state: Record | null): boolean { + const status = stringAt(state, "status")?.toLowerCase(); + return status === "completed" || status === "complete" || status === "error" || status === "failed"; +} + /** 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 | null { 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 sessionId = stringAt(event, "sessionID", "sessionId", "session_id"); + if (eventTypes(schema, "error", ["error"]).includes(event.type)) { const error = record(event.error); const errorData = record(error?.data); const message = @@ -28,21 +50,45 @@ export function parseOpenCodeRunEvent(value: unknown): OpenCodeRunEvent | null { : "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 part = record(event.part) ?? record(event.data) ?? event; + const text = stringAt(part, "text", "content") ?? stringAt(event, "text"); + if (eventTypes(schema, "text", ["text"]).includes(event.type) && text !== undefined) { + return { kind: "text", sessionId, text }; + } + const state = record(part?.state) ?? record(event.state); + const id = toolId(part); + const toolStartTypes = eventTypes(schema, "toolStart", ["tool_start"]); + const toolEndTypes = eventTypes(schema, "toolEnd", ["tool_result"]); + const toolCompleteTypes = eventTypes(schema, "toolComplete", ["tool_use"]); + if (toolStartTypes.includes(event.type) && id && !terminalToolState(state)) { + return { kind: "tool_start", sessionId, id, name: stringAt(part, "tool", "name") ?? "tool", input: state?.input ?? part?.input ?? {} }; + } + if (toolEndTypes.includes(event.type) && id) { + return { kind: "tool_end", sessionId, id, output: state?.output ?? state?.error ?? part?.output ?? "", isError: state?.status === "error" || state?.status === "failed" || Boolean(state?.error) }; } - if (event.type === "tool_use" && part) { - const state = record(part.state); + if (toolCompleteTypes.includes(event.type) && id && part) { + if (!terminalToolState(state)) { + return { kind: "tool_start", sessionId, id, name: stringAt(part, "tool", "name") ?? "tool", input: state?.input ?? part?.input ?? {} }; + } return { kind: "tool", sessionId, - id: typeof part.id === "string" ? part.id : crypto.randomUUID(), - name: typeof part.tool === "string" ? part.tool : "tool", + id, + name: stringAt(part, "tool", "name") ?? "tool", input: state?.input ?? {}, output: state?.output ?? state?.error ?? "", - isError: state?.status === "error", + isError: state?.status === "error" || state?.status === "failed" || Boolean(state?.error), }; } - return { kind: "other", sessionId }; + const knownType = [ + ...eventTypes(schema, "text", ["text"]), + ...toolStartTypes, + ...toolEndTypes, + ...toolCompleteTypes, + ...eventTypes(schema, "error", ["error"]), + ].includes(event.type); + return { kind: "other", sessionId, diagnostic: knownType ? "malformed-event" : "unknown-event" }; } From 5f436a5e0d875760627d63a012c63d9f97ff8146 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 24 Jul 2026 19:18:33 -0400 Subject: [PATCH 002/112] Redact OpenCode compatibility diagnostics --- .../chat/send/harness-routing-opencode.test.ts | 4 ++-- src/app/api/chat/send/route.ts | 10 +++++++--- src/lib/chat-tool-events.test.ts | 2 ++ src/lib/opencode-compatibility.test.ts | 14 ++++++++++++++ src/lib/opencode-compatibility.ts | 18 ++++++++++++++++++ src/lib/opencode-stream.test.ts | 16 ++++++++++++++++ 6 files changed, 59 insertions(+), 5 deletions(-) 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 d8c5e9ee2..a15b33fef 100644 --- a/src/app/api/chat/send/harness-routing-opencode.test.ts +++ b/src/app/api/chat/send/harness-routing-opencode.test.ts @@ -17,7 +17,7 @@ assert.match( ); assert.match( route, - /parseOpenCodeRunEvent\(JSON\.parse\(line\), openCodeCompatibility\?\.schema\);[\s\S]*?announceSession\(ev\.sessionId\);/, + /const rawEvent = JSON\.parse\(line\);[\s\S]*?parseOpenCodeRunEvent\(rawEvent, openCodeCompatibility\?\.schema\);[\s\S]*?announceSession\(ev\.sessionId\);/, "the first structured OpenCode event persists its minted session id", ); assert.match( @@ -82,7 +82,7 @@ assert.match( ); assert.match( route, - /opencode-compatibility[\s\S]*?unrecognized tool event/, + /opencode-compatibility[\s\S]*?unrecognized tool event[\s\S]*?redactedOpenCodeEventFingerprint\(rawEvent\)/, "unknown future event shapes surface a safe visible diagnostic", ); assert.match( diff --git a/src/app/api/chat/send/route.ts b/src/app/api/chat/send/route.ts index 4ff2ae15a..ad39d411e 100644 --- a/src/app/api/chat/send/route.ts +++ b/src/app/api/chat/send/route.ts @@ -63,7 +63,10 @@ import { } from "@/lib/grok-build"; import { grokLaunchCommand } from "@/lib/grok-bin"; import { openCodeLaunch, openCodeSpawnEnv, writeOpenCodeLaunchInput } from "@/lib/opencode-bin"; -import { resolveOpenCodeCompatibility } from "@/lib/opencode-compatibility"; +import { + redactedOpenCodeEventFingerprint, + resolveOpenCodeCompatibility, +} from "@/lib/opencode-compatibility"; import { parseOpenCodeRunEvent } from "@/lib/opencode-stream"; import { buildPromptWithCovenIdentityCanon } from "@/lib/coven-identity-canon"; import { @@ -1774,7 +1777,8 @@ export async function POST(req: Request) { return; } try { - const ev = parseOpenCodeRunEvent(JSON.parse(line), openCodeCompatibility?.schema); + const rawEvent = JSON.parse(line); + const ev = parseOpenCodeRunEvent(rawEvent, openCodeCompatibility?.schema); if (!ev) return; if (ev.sessionId && !sessionId) announceSession(ev.sessionId); if (ev.kind === "text") { @@ -1836,7 +1840,7 @@ export async function POST(req: Request) { "opencode-compatibility", "OpenCode sent an unrecognized tool event; continuing with assistant text", "error", - ev.diagnostic ?? "unknown-event", + `${ev.diagnostic ?? "unknown-event"}:${redactedOpenCodeEventFingerprint(rawEvent)}`, ); } } catch { diff --git a/src/lib/chat-tool-events.test.ts b/src/lib/chat-tool-events.test.ts index f5c96ec19..4cc686fa2 100644 --- a/src/lib/chat-tool-events.test.ts +++ b/src/lib/chat-tool-events.test.ts @@ -11,5 +11,7 @@ assert.equal(settled?.id, "call_1"); assert.equal(settled?.status, "ok"); assert.equal(settled?.output, "late terminal output"); 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"); console.log("chat-tool-events.test.ts: ok"); diff --git a/src/lib/opencode-compatibility.test.ts b/src/lib/opencode-compatibility.test.ts index 1d0bb0370..063f82770 100644 --- a/src/lib/opencode-compatibility.test.ts +++ b/src/lib/opencode-compatibility.test.ts @@ -8,6 +8,7 @@ import { BUILTIN_OPENCODE_SCHEMA_BUNDLE, loadOpenCodeSchemaBundle, openCodeSchemaBundleSigningPayload, + redactedOpenCodeEventFingerprint, resolveOpenCodeCompatibility, } from "./opencode-compatibility.ts"; @@ -73,4 +74,17 @@ const missingSession = await resolveOpenCodeCompatibility({ version: "1.2.3", js assert.equal(missingSession.mode, "structured"); assert.equal(missingSession.schema?.id, "opencode-run-json-legacy", "older compatible schemas coexist without client version gates"); +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.match(secretShape, /^[a-f0-9]{16}$/); + console.log("opencode-compatibility.test.ts: ok"); diff --git a/src/lib/opencode-compatibility.ts b/src/lib/opencode-compatibility.ts index 674264502..6436313bf 100644 --- a/src/lib/opencode-compatibility.ts +++ b/src/lib/opencode-compatibility.ts @@ -1,4 +1,5 @@ import { createPublicKey, verify } from "node:crypto"; +import { createHash } from "node:crypto"; import { mkdir, readFile } from "node:fs/promises"; import path from "node:path"; import { caveHome } from "./coven-paths.ts"; @@ -49,6 +50,23 @@ export type OpenCodeCompatibilityDiagnostic = | "schema-registry-refresh-rejected" | "cached-schema-unavailable"; +/** 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 { + const shape = (input: unknown, depth = 0): unknown => { + if (depth >= 2) 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; + return Object.fromEntries( + Object.entries(input) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, child]) => [key, shape(child, depth + 1)]), + ); + }; + return createHash("sha256").update(JSON.stringify(shape(value))).digest("hex").slice(0, 16); +} + const MAX_SCHEMA_BUNDLE_BYTES = 256 * 1024; const CACHE_TTL_MS = 6 * 60 * 60 * 1000; const CACHE_FILE = "opencode-schema-bundle-v1.json"; diff --git a/src/lib/opencode-stream.test.ts b/src/lib/opencode-stream.test.ts index 011bd7486..618546e3a 100644 --- a/src/lib/opencode-stream.test.ts +++ b/src/lib/opencode-stream.test.ts @@ -62,4 +62,20 @@ assert.deepEqual( { kind: "other", sessionId: undefined, diagnostic: "malformed-event" }, "missing tool ids never create random, non-resumable bubbles", ); +assert.deepEqual( + parseOpenCodeRunEvent( + { type: "assistant_text", session_id: "ses_legacy", data: { 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: { 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", +); console.log("opencode-stream.test.ts: ok"); From f970ba2eff393c21e602c8f0a27a6f12735bf9f6 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 24 Jul 2026 19:21:42 -0400 Subject: [PATCH 003/112] test(chat): cover OpenCode persistence and tool failure --- src/lib/chat-tool-events.test.ts | 7 ++++++- src/lib/opencode-stream.test.ts | 8 ++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/lib/chat-tool-events.test.ts b/src/lib/chat-tool-events.test.ts index 4cc686fa2..3a714d0b2 100644 --- a/src/lib/chat-tool-events.test.ts +++ b/src/lib/chat-tool-events.test.ts @@ -1,6 +1,6 @@ // @ts-nocheck import assert from "node:assert/strict"; -import { ToolCallTracker } from "./chat-tool-events.ts"; +import { ToolCallTracker, toPersistedTools } from "./chat-tool-events.ts"; const tracker = new ToolCallTracker(() => 1_000); assert.equal(tracker.envelopeToolResult("call_1", "late terminal output", false), null); @@ -13,5 +13,10 @@ assert.equal(settled?.output, "late terminal output"); 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", +); console.log("chat-tool-events.test.ts: ok"); diff --git a/src/lib/opencode-stream.test.ts b/src/lib/opencode-stream.test.ts index 618546e3a..e066e7241 100644 --- a/src/lib/opencode-stream.test.ts +++ b/src/lib/opencode-stream.test.ts @@ -57,6 +57,14 @@ assert.deepEqual( { kind: "tool", sessionId: "ses_123", id: "call_1", name: "bash", input: { command: "pwd" }, output: "ok", isError: false }, "current root terminal tool updates preserve input and output", ); +assert.deepEqual( + parseOpenCodeRunEvent( + { type: "tool_use", sessionID: "ses_123", part: { 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", part: { tool: "Read" } }), { kind: "other", sessionId: undefined, diagnostic: "malformed-event" }, From 5ea17a48c565531e96cc09c58474211dc35b4545 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 24 Jul 2026 19:35:58 -0400 Subject: [PATCH 004/112] Harden OpenCode schema refresh --- src/app/api/chat/send/route.ts | 14 +++- src/lib/opencode-compatibility.ts | 112 +++++++++++++++++++++++++----- 2 files changed, 107 insertions(+), 19 deletions(-) diff --git a/src/app/api/chat/send/route.ts b/src/app/api/chat/send/route.ts index ad39d411e..ab2ca7a46 100644 --- a/src/app/api/chat/send/route.ts +++ b/src/app/api/chat/send/route.ts @@ -1765,7 +1765,19 @@ export async function POST(req: Request) { return; } } catch { - recordStdoutErrorTail(resolveBackspaces(stripAnsi(line))); + // Structured-mode stdout can contain a malformed future event with + // arbitrary tool payloads. Do not feed that raw line into the + // persisted error tail or any user-visible diagnostic. + recordStdoutErrorTail("OpenCode emitted a malformed JSON event", true); + if (!openCodeCompatibilityNoticeSent) { + openCodeCompatibilityNoticeSent = true; + pushProgress( + "opencode-compatibility", + "OpenCode sent a malformed event; continuing with assistant text", + "error", + "malformed-json-event", + ); + } } }; diff --git a/src/lib/opencode-compatibility.ts b/src/lib/opencode-compatibility.ts index 6436313bf..b53889f8b 100644 --- a/src/lib/opencode-compatibility.ts +++ b/src/lib/opencode-compatibility.ts @@ -1,6 +1,6 @@ import { createPublicKey, verify } from "node:crypto"; import { createHash } from "node:crypto"; -import { mkdir, readFile } from "node:fs/promises"; +import { mkdir, open, readFile, rm } from "node:fs/promises"; import path from "node:path"; import { caveHome } from "./coven-paths.ts"; import { writeJsonAtomic } from "./server/atomic-write.ts"; @@ -54,15 +54,25 @@ export type OpenCodeCompatibilityDiagnostic = * 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 >= 2) return Array.isArray(input) ? "array" : typeof input; - if (Array.isArray(input)) return [input.length ? shape(input[0], depth + 1) : "empty"]; + 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; - return Object.fromEntries( - Object.entries(input) - .sort(([left], [right]) => left.localeCompare(right)) - .map(([key, child]) => [key, shape(child, depth + 1)]), - ); + 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); } @@ -70,6 +80,7 @@ export function redactedOpenCodeEventFingerprint(value: unknown): string { const MAX_SCHEMA_BUNDLE_BYTES = 256 * 1024; const CACHE_TTL_MS = 6 * 60 * 60 * 1000; const CACHE_FILE = "opencode-schema-bundle-v1.json"; +const REFRESH_TIMEOUT_MS = 5_000; /** * Shipped schemas remain usable offline. Selection is capability-based: a @@ -177,6 +188,62 @@ async function readVerifiedCache(file: string, publicKey: string, now: number): } } +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): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), REFRESH_TIMEOUT_MS); + try { + return await fetcher(url, { headers: { accept: "application/json" }, signal: controller.signal }); + } finally { + 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 withCacheWriteLock(file: string, callback: () => Promise): Promise { + const lock = `${file}.lock`; + let handle: Awaited>; + try { + handle = await open(lock, "wx", 0o600); + } catch { + return null; + } + try { + return await callback(); + } finally { + await handle.close().catch(() => undefined); + await rm(lock, { force: true }).catch(() => undefined); + } +} + export type OpenCodeSchemaBundleSource = { url?: string; publicKey?: string; @@ -207,23 +274,32 @@ export async function loadOpenCodeSchemaBundle(source: OpenCodeSchemaBundleSourc if (cacheFresh) return { bundle: cached.bundle, source: "cache" }; try { - const response = await (source.fetch ?? fetch)(url, { headers: { accept: "application/json" } }); - const raw = await response.text(); - if (!response.ok || Buffer.byteLength(raw, "utf8") > MAX_SCHEMA_BUNDLE_BYTES) throw new Error("untrusted schema bundle"); + const response = await fetchSchemaBundle(url, source.fetch ?? fetch); + const raw = await readResponseTextLimited(response); + if (!response.ok) throw new Error("untrusted schema bundle"); const remote = JSON.parse(raw) as unknown; if (!verifyOpenCodeSchemaBundle(remote, publicKey, now)) throw new Error("invalid schema signature"); if (cached && remote.sequence < cached.bundle.sequence) throw new Error("schema rollback"); // A sequence identifies immutable parser semantics. A changed payload at // the same sequence is indistinguishable from a rollback to callers, so // retain the last-known-good cache even if it is freshly signed. - if (cached && remote.sequence === cached.bundle.sequence) { - if (openCodeSchemaBundleSigningPayload(remote) !== openCodeSchemaBundleSigningPayload(cached.bundle)) throw new Error("schema sequence rewritten"); - await mkdir(path.dirname(file), { recursive: true }); - await writeJsonAtomic(file, { checkedAt: now, bundle: cached.bundle }); - return { bundle: cached.bundle, source: "cache" }; - } await mkdir(path.dirname(file), { recursive: true }); - await writeJsonAtomic(file, { checkedAt: now, bundle: remote }); + const writeResult = await withCacheWriteLock(file, async () => { + // 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 current = await readVerifiedCache(file, publicKey, now); + if (current && remote.sequence < current.bundle.sequence) throw new Error("schema rollback"); + if (current && remote.sequence === current.bundle.sequence) { + if (openCodeSchemaBundleSigningPayload(remote) !== openCodeSchemaBundleSigningPayload(current.bundle)) throw new Error("schema sequence rewritten"); + await writeJsonAtomic(file, { checkedAt: now, bundle: current.bundle }); + return { bundle: current.bundle, source: "cache" as const }; + } + await writeJsonAtomic(file, { checkedAt: now, bundle: remote }); + return { bundle: remote, source: "remote" as const }; + }); + if (writeResult) return writeResult; + // Do not overwrite a cache another process is refreshing. The remote was + // independently verified and remains safe for this request. return { bundle: remote, source: "remote" }; } catch { if (cached) return { bundle: cached.bundle, source: "cache", diagnostic: "schema-registry-refresh-rejected" }; From a3ac31d23c52a5302ee6aed5b1c126f31007fae3 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 24 Jul 2026 19:36:17 -0400 Subject: [PATCH 005/112] Test OpenCode registry refresh safeguards --- src/lib/opencode-compatibility.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/lib/opencode-compatibility.test.ts b/src/lib/opencode-compatibility.test.ts index 063f82770..9b84f1558 100644 --- a/src/lib/opencode-compatibility.test.ts +++ b/src/lib/opencode-compatibility.test.ts @@ -85,6 +85,22 @@ const changedSecretShape = redactedOpenCodeEventFingerprint({ 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}$/); +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 verified cache"); +assert.equal((JSON.parse(await readFile(cacheFile, "utf8")) as { bundle: { sequence: number } }).bundle.sequence, 2); + console.log("opencode-compatibility.test.ts: ok"); From ee0713b149aabb33d332617b60ca4947c484cc34 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 24 Jul 2026 19:37:09 -0400 Subject: [PATCH 006/112] Bound OpenCode registry refresh timeout --- src/lib/opencode-compatibility.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/lib/opencode-compatibility.ts b/src/lib/opencode-compatibility.ts index b53889f8b..1efa20e28 100644 --- a/src/lib/opencode-compatibility.ts +++ b/src/lib/opencode-compatibility.ts @@ -215,11 +215,22 @@ async function readResponseTextLimited(response: Response): Promise { async function fetchSchemaBundle(url: string, fetcher: typeof fetch): Promise { const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), REFRESH_TIMEOUT_MS); + let timeout: ReturnType | undefined; + const timedOut = new Promise((_, reject) => { + timeout = setTimeout(() => { + controller.abort(); + reject(new Error("schema registry refresh timed out")); + }, REFRESH_TIMEOUT_MS); + }); try { - return await fetcher(url, { headers: { accept: "application/json" }, signal: controller.signal }); + // Test and embedding fetch shims are not required to honor AbortSignal; + // racing the timeout prevents a hung registry from delaying a chat turn. + return await Promise.race([ + fetcher(url, { headers: { accept: "application/json" }, signal: controller.signal }), + timedOut, + ]); } finally { - clearTimeout(timeout); + if (timeout) clearTimeout(timeout); } } From 2a39c8f23beb554aceaa1890edd76728b809ede6 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 24 Jul 2026 19:39:28 -0400 Subject: [PATCH 007/112] Fail closed on ambiguous OpenCode schemas --- .../send/harness-routing-opencode.test.ts | 15 ++++++ src/app/api/chat/send/route.ts | 48 ++++++++++++------- src/lib/opencode-compatibility.test.ts | 13 +++++ src/lib/opencode-compatibility.ts | 34 ++++++++++++- 4 files changed, 92 insertions(+), 18 deletions(-) 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 a15b33fef..f6e6f6315 100644 --- a/src/app/api/chat/send/harness-routing-opencode.test.ts +++ b/src/app/api/chat/send/harness-routing-opencode.test.ts @@ -75,6 +75,16 @@ assert.match( /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, + /openCodeDirect && openCodeCompatibility\?\.mode === "plain" && !sessionId[\s\S]*?announceSession\(crypto\.randomUUID\(\)\)/, + "plain OpenCode output receives a Cave-owned stable session id so its first transcript persists", +); +assert.match( + route, + /openCodeCompatibility\?\.mode === "plain"[\s\S]*?\? undefined[\s\S]*?: sessionId/, + "a Cave-owned plain-mode id is never mistaken for a native OpenCode resume token", +); assert.match( route, /ev\.kind === "tool_start"[\s\S]*?envelopeToolUse[\s\S]*?ev\.kind === "tool_end"[\s\S]*?envelopeToolResult/, @@ -85,6 +95,11 @@ assert.match( /opencode-compatibility[\s\S]*?unrecognized tool event[\s\S]*?redactedOpenCodeEventFingerprint\(rawEvent\)/, "unknown future event shapes surface a safe visible diagnostic", ); +assert.match( + route, + /const handleOpenCodeLine[\s\S]*?catch \{[\s\S]*?recordStdoutErrorTail\("OpenCode emitted a malformed JSON event", true\)/, + "malformed structured OpenCode events never copy their raw payload into diagnostics", +); assert.match( capabilities, /export function openCodeRunCapabilities\([^)]*\)[\s\S]*?\["--version"\][\s\S]*?json:[\s\S]*?model:[\s\S]*?session:/, diff --git a/src/app/api/chat/send/route.ts b/src/app/api/chat/send/route.ts index ab2ca7a46..d18e32a71 100644 --- a/src/app/api/chat/send/route.ts +++ b/src/app/api/chat/send/route.ts @@ -1412,7 +1412,10 @@ export async function POST(req: Request) { // at Cave's saved session id. Start a fresh native session with recent // transcript context instead; the in-stream notice makes this explicit. const openCodeFreshSessionForCompatibility = Boolean( - openCodeDirect && resumeTarget && !openCodeCompatibility?.capabilities.session, + openCodeDirect && resumeTarget && ( + openCodeCompatibility?.mode !== "structured" || + !openCodeCompatibility.capabilities.session + ), ); const openCodeCompatibilityRetry = openCodeFreshSessionForCompatibility ? buildResumeRetryPrompt(harnessPrompt, existingConversation) @@ -1615,6 +1618,14 @@ 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 (openCodeDirect && openCodeCompatibility?.mode === "plain" && !sessionId) { + 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. @@ -1765,20 +1776,9 @@ export async function POST(req: Request) { return; } } catch { - // Structured-mode stdout can contain a malformed future event with - // arbitrary tool payloads. Do not feed that raw line into the - // persisted error tail or any user-visible diagnostic. - recordStdoutErrorTail("OpenCode emitted a malformed JSON event", true); - if (!openCodeCompatibilityNoticeSent) { - openCodeCompatibilityNoticeSent = true; - pushProgress( - "opencode-compatibility", - "OpenCode sent a malformed event; continuing with assistant text", - "error", - "malformed-json-event", - ); - } + /* not valid JSON after all — fall through to the error tail */ } + recordStdoutErrorTail(resolveBackspaces(stripAnsi(line))); }; const handleOpenCodeLine = (line: string) => { @@ -1856,7 +1856,19 @@ export async function POST(req: Request) { ); } } catch { - recordStdoutErrorTail(resolveBackspaces(stripAnsi(line))); + // Structured-mode stdout can contain a malformed future event with + // arbitrary tool payloads. Do not feed that raw line into the + // persisted error tail or any user-visible diagnostic. + recordStdoutErrorTail("OpenCode emitted a malformed JSON event", true); + if (!openCodeCompatibilityNoticeSent) { + openCodeCompatibilityNoticeSent = true; + pushProgress( + "opencode-compatibility", + "OpenCode sent a malformed event; continuing with assistant text", + "error", + "malformed-json-event", + ); + } } }; @@ -2421,7 +2433,11 @@ export async function POST(req: Request) { // do not overwrite the previous native id (or record a changed sandbox // profile) and accidentally let a later read turn resume the old full // access session. - const harnessSessionId = grokDirect ? grokSessionId : sessionId; + const harnessSessionId = grokDirect + ? grokSessionId + : openCodeDirect && openCodeCompatibility?.mode === "plain" + ? undefined + : sessionId; // OpenCode's JSON event protocol does not echo the selected model. Its // direct argv proves the selection was forwarded, while a successful // exit is the only confirmation it was applied. Preserve an explicit diff --git a/src/lib/opencode-compatibility.test.ts b/src/lib/opencode-compatibility.test.ts index 9b84f1558..90954d951 100644 --- a/src/lib/opencode-compatibility.test.ts +++ b/src/lib/opencode-compatibility.test.ts @@ -10,6 +10,7 @@ import { openCodeSchemaBundleSigningPayload, redactedOpenCodeEventFingerprint, resolveOpenCodeCompatibility, + selectOpenCodeSchema, } from "./opencode-compatibility.ts"; const now = Date.parse("2026-07-24T12:00:00.000Z"); @@ -74,6 +75,18 @@ const missingSession = await resolveOpenCodeCompatibility({ version: "1.2.3", js assert.equal(missingSession.mode, "structured"); assert.equal(missingSession.schema?.id, "opencode-run-json-legacy", "older compatible schemas coexist without client version gates"); +const broadSchema = { ...BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0], id: "broad", requires: { json: true as const } }; +assert.equal( + selectOpenCodeSchema([broadSchema, BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0]], { version: "current", json: true, model: false, session: true })?.id, + "opencode-run-json-v1", + "the most specific schema wins independently of registry ordering", +); +assert.equal( + selectOpenCodeSchema([broadSchema, { ...broadSchema, id: "broad-duplicate" }], { version: "current", json: true, model: false, session: true }), + 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", diff --git a/src/lib/opencode-compatibility.ts b/src/lib/opencode-compatibility.ts index 1efa20e28..3065c352a 100644 --- a/src/lib/opencode-compatibility.ts +++ b/src/lib/opencode-compatibility.ts @@ -141,12 +141,42 @@ function schemaMatches(schema: OpenCodeEventSchema, capabilities: OpenCodeRunCap return true; } +function schemaSpecificity(schema: OpenCodeEventSchema): number { + return Number(schema.requires.session !== undefined) + Number(schema.requires.model !== undefined); +} + +/** + * Select the most specific matching schema rather than trusting registry + * order. A same-specificity tie means two schemas claim the identical observed + * capability surface, so the caller must fail 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); + return mostSpecific.length === 1 ? mostSpecific[0] : null; +} + export function isOpenCodeSchemaBundle(value: unknown, now = Date.now()): value is OpenCodeSchemaBundle { if (!isRecord(value) || value.format !== 1 || value.runtime !== "opencode") return false; if (typeof value.sequence !== "number" || !Number.isSafeInteger(value.sequence) || value.sequence < 1 || !Array.isArray(value.schemas) || !value.schemas.every(isEventSchema)) return false; const issuedAt = Date.parse(String(value.issuedAt)); const expiresAt = Date.parse(String(value.expiresAt)); - return Number.isFinite(issuedAt) && Number.isFinite(expiresAt) && issuedAt <= now && expiresAt > now; + if (!Number.isFinite(issuedAt) || !Number.isFinite(expiresAt) || issuedAt > now || 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 profiles = new Set(); + for (const schema of value.schemas) { + const profile = JSON.stringify(schema.requires); + if (profiles.has(profile)) return false; + profiles.add(profile); + } + return true; } function stableJson(value: unknown): string { @@ -331,7 +361,7 @@ export async function resolveOpenCodeCompatibility( diagnostic: "json-format-unavailable", }; } - const schema = loaded.bundle.schemas.find((candidate) => schemaMatches(candidate, capabilities)); + const schema = selectOpenCodeSchema(loaded.bundle.schemas, capabilities); if (!schema) { return { mode: "plain", From 38cc2b0a6bfcd91dc5d37a730ea811ca1d243c4c Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 24 Jul 2026 19:44:49 -0400 Subject: [PATCH 008/112] test(tauri): tolerate CRLF in mobile entry assertion --- src-tauri/release-runtime.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-tauri/release-runtime.test.mjs b/src-tauri/release-runtime.test.mjs index cbc422993..e8bca6c26 100644 --- a/src-tauri/release-runtime.test.mjs +++ b/src-tauri/release-runtime.test.mjs @@ -390,7 +390,7 @@ test("mobile startup remains available after native-host extraction", async () = assert.match(nativeHost, /\nmod tauri_setup;/); assert.doesNotMatch(nativeHost, /#\[cfg\(desktop\)\]\s*\nmod tauri_setup;/); - assert.match(setup, /#\[cfg_attr\(mobile, tauri::mobile_entry_point\)\]\npub fn run\(\)/); + assert.match(setup, /#\[cfg_attr\(mobile, tauri::mobile_entry_point\)\]\r?\npub fn run\(\)/); }); test("Windows close watchdog helper follows extracted lifecycle tests", async () => { From 3f79f2a07b650aaad847f89b9197a4e8ad84fb3b Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 24 Jul 2026 19:53:10 -0400 Subject: [PATCH 009/112] test(chat): allow pre-launch compatibility notice --- src/lib/server/stuck-created-sweep.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/server/stuck-created-sweep.test.ts b/src/lib/server/stuck-created-sweep.test.ts index a4eb8ecee..b0597f7d5 100644 --- a/src/lib/server/stuck-created-sweep.test.ts +++ b/src/lib/server/stuck-created-sweep.test.ts @@ -102,8 +102,8 @@ const OPTS = { cwd: CWD, prompt: "ping", sinceMs: Date.parse("2026-07-12T07:53:0 ); assert.match( route, - /const turnSpawnStartMs = Date\.now\(\);\s*await runAttempt\(args\);/, - "turn window anchor is captured immediately before the first attempt", + /const turnSpawnStartMs = Date\.now\(\);[\s\S]{0,1000}?await runAttempt\(args\);/, + "turn window anchor is captured before any optional pre-launch compatibility notice and the first attempt", ); } From 6aca9a006aaa9948e841072749837d45b8af17b3 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 24 Jul 2026 20:09:08 -0400 Subject: [PATCH 010/112] fix(chat): recover stale OpenCode registry locks --- src/lib/opencode-compatibility.test.ts | 23 ++++++++++- src/lib/opencode-compatibility.ts | 55 ++++++++++++++++++++++---- 2 files changed, 69 insertions(+), 9 deletions(-) diff --git a/src/lib/opencode-compatibility.test.ts b/src/lib/opencode-compatibility.test.ts index 90954d951..4cf5cc9e9 100644 --- a/src/lib/opencode-compatibility.test.ts +++ b/src/lib/opencode-compatibility.test.ts @@ -1,7 +1,7 @@ // @ts-nocheck import assert from "node:assert/strict"; import { generateKeyPairSync, sign } from "node:crypto"; -import { mkdtemp, readFile } from "node:fs/promises"; +import { mkdtemp, readFile, utimes, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { @@ -116,4 +116,25 @@ assert.equal(oversized.source, "cache"); assert.equal(oversized.diagnostic, "schema-registry-refresh-rejected", "oversized refreshes preserve the verified cache"); assert.equal((JSON.parse(await readFile(cacheFile, "utf8")) as { bundle: { sequence: number } }).bundle.sequence, 2); +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, "99999999"); +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"); +assert.equal((JSON.parse(await readFile(cacheFile, "utf8")) as { bundle: { sequence: number } }).bundle.sequence, 3); + console.log("opencode-compatibility.test.ts: ok"); diff --git a/src/lib/opencode-compatibility.ts b/src/lib/opencode-compatibility.ts index 3065c352a..a01ded001 100644 --- a/src/lib/opencode-compatibility.ts +++ b/src/lib/opencode-compatibility.ts @@ -1,6 +1,5 @@ -import { createPublicKey, verify } from "node:crypto"; -import { createHash } from "node:crypto"; -import { mkdir, open, readFile, rm } from "node:fs/promises"; +import { createHash, createPublicKey, randomBytes, verify } from "node:crypto"; +import { mkdir, open, readFile, rename, rm, stat } from "node:fs/promises"; import path from "node:path"; import { caveHome } from "./coven-paths.ts"; import { writeJsonAtomic } from "./server/atomic-write.ts"; @@ -81,6 +80,7 @@ const MAX_SCHEMA_BUNDLE_BYTES = 256 * 1024; 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; /** * Shipped schemas remain usable offline. Selection is capability-based: a @@ -269,14 +269,53 @@ async function fetchSchemaBundle(url: string, fetcher: typeof fetch): Promise(file: string, callback: () => Promise): Promise { - const lock = `${file}.lock`; - let handle: Awaited>; +async function staleLockCanBeReclaimed(lock: string): Promise { try { - handle = await open(lock, "wx", 0o600); + const info = await stat(lock); + if (Date.now() - info.mtimeMs < CACHE_LOCK_STALE_MS) return false; + const owner = Number((await readFile(lock, "utf8")).trim()); + if (!Number.isSafeInteger(owner) || owner < 1) return true; + try { + process.kill(owner, 0); + return false; + } catch (error) { + // EPERM means the process exists but this user cannot signal it. + return (error as NodeJS.ErrnoException).code !== "EPERM"; + } } catch { - return null; + return false; + } +} + +async function withCacheWriteLock(file: string, callback: () => Promise): Promise { + const lock = `${file}.lock`; + let handle: Awaited> | null = null; + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + const acquired = await open(lock, "wx", 0o600); + try { + await acquired.writeFile(String(process.pid)); + } catch (error) { + await acquired.close().catch(() => undefined); + await rm(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 rename(lock, stale); + await rm(stale, { force: true }); + } catch { + return null; + } + } } + if (!handle) return null; try { return await callback(); } finally { From fde2af4253b42426e07b76df09314477d51b61b0 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 24 Jul 2026 20:10:42 -0400 Subject: [PATCH 011/112] test(chat): preserve Grok routing contract --- src/app/api/chat/send/harness-routing-copilot-jsonl.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 c70d34300..dc943f258 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,7 +86,7 @@ assert.match( ); assert.match( chatRoute, - /const harnessSessionId = grokDirect \? grokSessionId : sessionId;/, + /const harnessSessionId = grokDirect\s*\? grokSessionId\s*:\s*openCodeDirect/, "a failed Grok resume must not overwrite the native resume id with Cave's stable conversation id", ); assert.match( From e43dfaad931c40d3d31ba8bc833a6c22f236c2e8 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 24 Jul 2026 20:25:12 -0400 Subject: [PATCH 012/112] Harden OpenCode compatibility fallback --- scripts/cave-home-migration-windows.test.ts | 2 +- .../api/chat/send/chat-send-capabilities.ts | 17 ++++++++--- .../harness-routing-copilot-jsonl.test.ts | 2 +- src/app/api/chat/send/route.ts | 26 +++++++++++------ src/lib/chat-tool-events.test.ts | 2 ++ src/lib/chat-tool-events.ts | 3 ++ src/lib/opencode-compatibility.test.ts | 20 +++++++++++++ src/lib/opencode-compatibility.ts | 28 ++++++++++++++++--- src/lib/opencode-stream.test.ts | 20 +++++++++++++ src/lib/opencode-stream.ts | 24 ++++++++++++---- 10 files changed, 119 insertions(+), 25 deletions(-) diff --git a/scripts/cave-home-migration-windows.test.ts b/scripts/cave-home-migration-windows.test.ts index 81af75777..ac5874de1 100644 --- a/scripts/cave-home-migration-windows.test.ts +++ b/scripts/cave-home-migration-windows.test.ts @@ -122,7 +122,7 @@ try { throw error; }; await assert.rejects( - migrateCaveHome({ lockTimeoutMs: 150, lockCandidateRename: persistentEperm }), + migrateCaveHome({ lockTimeoutMs: 500, lockCandidateRename: persistentEperm }), (error) => error?.code === "ETIMEDOUT", ); assert.ok(candidateAttempts >= 2); diff --git a/src/app/api/chat/send/chat-send-capabilities.ts b/src/app/api/chat/send/chat-send-capabilities.ts index 0f12e5ce8..51ec46203 100644 --- a/src/app/api/chat/send/chat-send-capabilities.ts +++ b/src/app/api/chat/send/chat-send-capabilities.ts @@ -14,7 +14,8 @@ let permissionFlagProbe: Promise | null = null; let addDirFlagProbe: Promise | null = null; let hermesModelFlagProbe: Promise | null = null; let openCodeModelFlagProbe: Promise | null = null; -let openCodeCapabilitiesProbe: Promise | null = null; +let openCodeCapabilitiesProbe: { until: number; value: Promise } | null = null; +const OPENCODE_CAPABILITY_PROBE_TTL_MS = 60_000; function probeHelp( command: string, @@ -151,7 +152,10 @@ export function openCodeRunSupportsModel(): Promise { * schema because vendors can backport or change protocol behavior. */ export function openCodeRunCapabilities(): Promise { - return (openCodeCapabilitiesProbe ??= (async () => { + if (openCodeCapabilitiesProbe && Date.now() < openCodeCapabilitiesProbe.until) { + return openCodeCapabilitiesProbe.value; + } + const value = (async () => { const helpLaunch = openCodeLaunch(["run", "--help"]); const versionLaunch = openCodeLaunch(["--version"]); const [help, versionOutput] = await Promise.all([ @@ -161,9 +165,14 @@ export function openCodeRunCapabilities(): Promise { const version = versionOutput.match(/\b\d+(?:\.\d+){1,3}(?:[-+][\w.-]+)?\b/)?.[0] ?? null; return { version, - json: /--format(?:[=\s][^\n]*)?/m.test(help) && /\bjson\b/i.test(help), + // Only accept JSON when it appears in the `--format` option's own + // stanza. A stray "JSON" in a banner or another option's description + // must not make us launch an unsupported `--format json` command. + json: /(?:^|\n)\s*--format(?:[=\s][^\n]*)?(?:\n(?!\s*--)[^\n]*){0,2}\bjson\b/im.test(help), model: /(^|\s)--model(?![\w-])/m.test(help), session: /(^|\s)--session(?![\w-])/m.test(help), }; - })()); + })(); + openCodeCapabilitiesProbe = { until: Date.now() + OPENCODE_CAPABILITY_PROBE_TTL_MS, value }; + return value; } 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 dc943f258..5e6657aba 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,7 +86,7 @@ assert.match( ); assert.match( chatRoute, - /const harnessSessionId = grokDirect\s*\? grokSessionId\s*:\s*openCodeDirect/, + /const harnessSessionId = grokDirect\s*\? grokSessionId\s*:\s*openCodeDirect/, "a failed Grok resume must not overwrite the native resume id with Cave's stable conversation id", ); assert.match( diff --git a/src/app/api/chat/send/route.ts b/src/app/api/chat/send/route.ts index d18e32a71..5f5759641 100644 --- a/src/app/api/chat/send/route.ts +++ b/src/app/api/chat/send/route.ts @@ -1791,7 +1791,6 @@ export async function POST(req: Request) { try { const rawEvent = JSON.parse(line); const ev = parseOpenCodeRunEvent(rawEvent, openCodeCompatibility?.schema); - if (!ev) return; if (ev.sessionId && !sessionId) announceSession(ev.sessionId); if (ev.kind === "text") { const text = ev.text.endsWith("\n") ? ev.text : `${ev.text}\n`; @@ -1846,14 +1845,23 @@ export async function POST(req: Request) { result = { ...result, is_error: true }; return; } - if (ev.kind === "other" && !openCodeCompatibilityNoticeSent) { - openCodeCompatibilityNoticeSent = true; - pushProgress( - "opencode-compatibility", - "OpenCode sent an unrecognized tool event; continuing with assistant text", - "error", - `${ev.diagnostic ?? "unknown-event"}:${redactedOpenCodeEventFingerprint(rawEvent)}`, - ); + if (ev.kind === "other") { + // A future envelope can still carry assistant text even when its + // event label is unknown. Preserve that safe textual field while + // disabling only the unsupported structured activity. + if (ev.text) { + assistantText += ev.text; + push({ kind: "assistant_chunk", text: ev.text }); + } + if (!openCodeCompatibilityNoticeSent) { + openCodeCompatibilityNoticeSent = true; + pushProgress( + "opencode-compatibility", + "OpenCode sent an unrecognized tool event; continuing with assistant text", + "error", + `${ev.diagnostic ?? "unknown-event"}:${redactedOpenCodeEventFingerprint(rawEvent)}`, + ); + } } } catch { // Structured-mode stdout can contain a malformed future event with diff --git a/src/lib/chat-tool-events.test.ts b/src/lib/chat-tool-events.test.ts index 3a714d0b2..626d660da 100644 --- a/src/lib/chat-tool-events.test.ts +++ b/src/lib/chat-tool-events.test.ts @@ -4,12 +4,14 @@ import { ToolCallTracker, 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"); diff --git a/src/lib/chat-tool-events.ts b/src/lib/chat-tool-events.ts index 0c9eb944a..db24a8516 100644 --- a/src/lib/chat-tool-events.ts +++ b/src/lib/chat-tool-events.ts @@ -275,6 +275,9 @@ export class ToolCallTracker { // 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 >= 100) { const oldest = this.pendingEnvelopeResults.keys().next().value; if (oldest) this.pendingEnvelopeResults.delete(oldest); diff --git a/src/lib/opencode-compatibility.test.ts b/src/lib/opencode-compatibility.test.ts index 4cf5cc9e9..297ba6648 100644 --- a/src/lib/opencode-compatibility.test.ts +++ b/src/lib/opencode-compatibility.test.ts @@ -54,6 +54,16 @@ const offline = await loadOpenCodeSchemaBundle({ assert.equal(offline.source, "cache"); assert.equal(offline.diagnostic, "schema-registry-refresh-rejected", "offline keeps the last known good 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, @@ -105,6 +115,16 @@ assert.equal( ); 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, diff --git a/src/lib/opencode-compatibility.ts b/src/lib/opencode-compatibility.ts index a01ded001..7ed532a0e 100644 --- a/src/lib/opencode-compatibility.ts +++ b/src/lib/opencode-compatibility.ts @@ -194,7 +194,13 @@ export function openCodeSchemaBundleSigningPayload(bundle: OpenCodeSchemaBundle) export function verifyOpenCodeSchemaBundle(bundle: unknown, publicKey: string, now = Date.now()): bundle is OpenCodeSchemaBundle { if (!isOpenCodeSchemaBundle(bundle, now) || !bundle.signature || bundle.signature.algorithm !== "ed25519") return false; try { - return verify(null, Buffer.from(openCodeSchemaBundleSigningPayload(bundle)), createPublicKey(publicKey), Buffer.from(bundle.signature.value, "base64")); + // 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; + return verify(null, Buffer.from(openCodeSchemaBundleSigningPayload(bundle)), createPublicKey(publicKey), signature); } catch { return false; } @@ -211,7 +217,15 @@ async function readVerifiedCache(file: string, publicKey: string, now: number): const raw = await readFile(file, "utf8"); if (Buffer.byteLength(raw, "utf8") > MAX_SCHEMA_BUNDLE_BYTES) return null; const cached = JSON.parse(raw) as CachedBundle; - if (!isRecord(cached) || typeof cached.checkedAt !== "number" || !verifyOpenCodeSchemaBundle(cached.bundle, publicKey, now)) return null; + if ( + !isRecord(cached) + || typeof cached.checkedAt !== "number" + || !Number.isFinite(cached.checkedAt) + // 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; } catch { return null; @@ -378,8 +392,14 @@ export async function loadOpenCodeSchemaBundle(source: OpenCodeSchemaBundleSourc return { bundle: remote, source: "remote" as const }; }); if (writeResult) return writeResult; - // Do not overwrite a cache another process is refreshing. The remote was - // independently verified and remains safe for this request. + // 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 readVerifiedCache(file, publicKey, now); + if (current && current.bundle.sequence >= remote.sequence) { + return { bundle: current.bundle, source: "cache" }; + } + // The remote was independently verified and no newer cache exists. return { bundle: remote, source: "remote" }; } catch { if (cached) return { bundle: cached.bundle, source: "cache", diagnostic: "schema-registry-refresh-rejected" }; diff --git a/src/lib/opencode-stream.test.ts b/src/lib/opencode-stream.test.ts index e066e7241..932e6929c 100644 --- a/src/lib/opencode-stream.test.ts +++ b/src/lib/opencode-stream.test.ts @@ -70,6 +70,26 @@ assert.deepEqual( { 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", text: "Still show this reply" }, + "unknown future envelopes retain safe assistant text while disabling tool activity", +); +assert.equal( + parseOpenCodeRunEvent({ type: "tool", callID: "failed_1", tool: "bash", state: { status: "FAILED" } }, BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0]).kind, + "tool", + "case variants of terminal states remain terminal", +); +assert.equal( + (parseOpenCodeRunEvent({ type: "tool", callID: "failed_1", tool: "bash", state: { status: "FAILED" } }, BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0]) as { isError: boolean }).isError, + true, + "case variants of error statuses do not render successful tool bubbles", +); +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: { content: "Older client reply" } }, diff --git a/src/lib/opencode-stream.ts b/src/lib/opencode-stream.ts index dd09f8828..9b7734ed5 100644 --- a/src/lib/opencode-stream.ts +++ b/src/lib/opencode-stream.ts @@ -6,7 +6,7 @@ export type OpenCodeRunEvent = | { 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; diagnostic?: "unknown-event" | "malformed-event" }; + | { kind: "other"; sessionId?: string; diagnostic?: "unknown-event" | "malformed-event"; text?: string }; function record(value: unknown): Record | null { return value && typeof value === "object" && !Array.isArray(value) @@ -32,10 +32,17 @@ function terminalToolState(state: Record | null): boolean { return status === "completed" || status === "complete" || status === "error" || status === "failed"; } +function toolStateIsError(state: Record | null): boolean { + const status = stringAt(state, "status")?.toLowerCase(); + return status === "error" || status === "failed" || Boolean(state?.error); +} + /** Decode OpenCode's `run --format json` envelope without trusting its fields. */ -export function parseOpenCodeRunEvent(value: unknown, schema?: OpenCodeEventSchema): OpenCodeRunEvent | null { +export function parseOpenCodeRunEvent(value: unknown, schema?: OpenCodeEventSchema): OpenCodeRunEvent { const event = record(value); - if (!event || typeof event.type !== "string") return null; + if (!event || typeof event.type !== "string") { + return { kind: "other", diagnostic: "malformed-event" }; + } const sessionId = stringAt(event, "sessionID", "sessionId", "session_id"); if (eventTypes(schema, "error", ["error"]).includes(event.type)) { const error = record(event.error); @@ -67,7 +74,7 @@ export function parseOpenCodeRunEvent(value: unknown, schema?: OpenCodeEventSche return { kind: "tool_start", sessionId, id, name: stringAt(part, "tool", "name") ?? "tool", input: state?.input ?? part?.input ?? {} }; } if (toolEndTypes.includes(event.type) && id) { - return { kind: "tool_end", sessionId, id, output: state?.output ?? state?.error ?? part?.output ?? "", isError: state?.status === "error" || state?.status === "failed" || Boolean(state?.error) }; + return { kind: "tool_end", sessionId, id, output: state?.output ?? state?.error ?? part?.output ?? "", isError: toolStateIsError(state) }; } if (toolCompleteTypes.includes(event.type) && id && part) { if (!terminalToolState(state)) { @@ -80,7 +87,7 @@ export function parseOpenCodeRunEvent(value: unknown, schema?: OpenCodeEventSche name: stringAt(part, "tool", "name") ?? "tool", input: state?.input ?? {}, output: state?.output ?? state?.error ?? "", - isError: state?.status === "error" || state?.status === "failed" || Boolean(state?.error), + isError: toolStateIsError(state), }; } const knownType = [ @@ -90,5 +97,10 @@ export function parseOpenCodeRunEvent(value: unknown, schema?: OpenCodeEventSche ...toolCompleteTypes, ...eventTypes(schema, "error", ["error"]), ].includes(event.type); - return { kind: "other", sessionId, diagnostic: knownType ? "malformed-event" : "unknown-event" }; + return { + kind: "other", + sessionId, + diagnostic: knownType ? "malformed-event" : "unknown-event", + ...(text === undefined ? {} : { text }), + }; } From 6fdf22e845e2377de91751d2dc0adf0908ed6631 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 24 Jul 2026 20:46:38 -0400 Subject: [PATCH 013/112] fix(chat): harden OpenCode compatibility safety --- .../send/harness-routing-opencode.test.ts | 18 ++++++++--- src/app/api/chat/send/route.ts | 30 +++++++++++++++---- src/lib/opencode-compatibility.test.ts | 29 ++++++++++++++++++ src/lib/opencode-compatibility.ts | 15 ++++++---- 4 files changed, 78 insertions(+), 14 deletions(-) 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 f6e6f6315..5ba0b36b9 100644 --- a/src/app/api/chat/send/harness-routing-opencode.test.ts +++ b/src/app/api/chat/send/harness-routing-opencode.test.ts @@ -57,8 +57,18 @@ 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", + /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, + /openCodeModelRejected \|\|= modelRejectionInError\(ev\.message\);[\s\S]*?recordStdoutErrorTail\("OpenCode reported an error event", true\)/, + "structured OpenCode error payloads are classified but never copied into user-visible diagnostics", +); +assert.match( + route, + /let openCodeToolActivityDisabled = false;[\s\S]*?if \(ev\.kind === "tool"\) \{\s*if \(openCodeToolActivityDisabled\) return;[\s\S]*?if \(ev\.kind === "other"\) \{\s*openCodeToolActivityDisabled = true;/, + "an unknown OpenCode envelope disables subsequent structured tool activity for the turn", ); assert.match( route, @@ -67,8 +77,8 @@ 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", + /ev\.kind === "error"[\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, diff --git a/src/app/api/chat/send/route.ts b/src/app/api/chat/send/route.ts index 5f5759641..8caef226a 100644 --- a/src/app/api/chat/send/route.ts +++ b/src/app/api/chat/send/route.ts @@ -115,6 +115,7 @@ import { cleanModelId, modelApplicationForHarness, modelApplicationFromRun, + modelRejectionInError, resolveChatModelState, type ChatModelState, } from "@/lib/chat-model-state"; @@ -1563,6 +1564,12 @@ export async function POST(req: Request) { // Detected from stderr; healed (manifest quarantined) and retried below. let adapterConflict: BuiltinAdapterConflict | null = null; let openCodeCompatibilityNoticeSent = false; + // Once an unknown structured envelope appears, retaining later tool + // frames would mix an unverified lifecycle with a known one. Continue + // decoding text only for this turn instead of showing potentially wrong + // tool activity. + let openCodeToolActivityDisabled = false; + let openCodeModelRejected = false; // Model parity: the harness echoes its resolved model on the init/system // stream event. Capturing it lets the application state render honestly as @@ -1799,6 +1806,7 @@ export async function POST(req: Request) { return; } if (ev.kind === "tool") { + if (openCodeToolActivityDisabled) return; boundarySentinel?.observe(ev.name, ev.input); const started = toolTracker.envelopeToolUse( ev.id, @@ -1816,6 +1824,7 @@ export async function POST(req: Request) { return; } if (ev.kind === "tool_start") { + if (openCodeToolActivityDisabled) return; boundarySentinel?.observe(ev.name, ev.input); const started = toolTracker.envelopeToolUse( ev.id, @@ -1829,6 +1838,7 @@ export async function POST(req: Request) { return; } if (ev.kind === "tool_end") { + if (openCodeToolActivityDisabled) return; const ended = toolTracker.envelopeToolResult( ev.id, typeof ev.output === "string" ? ev.output : formatToolInputValue(ev.output), @@ -1838,14 +1848,19 @@ export async function POST(req: Request) { 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); + // 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); + recordStdoutErrorTail("OpenCode reported an error event", true); result = { ...result, is_error: true }; return; } if (ev.kind === "other") { + openCodeToolActivityDisabled = true; // A future envelope can still carry assistant text even when its // event label is unknown. Preserve that safe textual field while // disabling only the unsupported structured activity. @@ -1867,6 +1882,7 @@ export async function POST(req: Request) { // Structured-mode stdout can contain a malformed future event with // arbitrary tool payloads. Do not feed that raw line into the // persisted error tail or any user-visible diagnostic. + openCodeToolActivityDisabled = true; recordStdoutErrorTail("OpenCode emitted a malformed JSON event", true); if (!openCodeCompatibilityNoticeSent) { openCodeCompatibilityNoticeSent = true; @@ -2279,6 +2295,8 @@ export async function POST(req: Request) { result = {}; toolTracker = new ToolCallTracker(); openCodeCompatibilityNoticeSent = false; + openCodeToolActivityDisabled = false; + openCodeModelRejected = false; copilotText.reset(); stderrTail.length = 0; stdoutErrTail.length = 0; @@ -2319,6 +2337,8 @@ export async function POST(req: Request) { result = {}; toolTracker = new ToolCallTracker(); openCodeCompatibilityNoticeSent = false; + openCodeToolActivityDisabled = false; + openCodeModelRejected = false; copilotText.reset(); stderrTail.length = 0; stdoutErrTail.length = 0; @@ -2455,7 +2475,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; diff --git a/src/lib/opencode-compatibility.test.ts b/src/lib/opencode-compatibility.test.ts index 297ba6648..b65e92d51 100644 --- a/src/lib/opencode-compatibility.test.ts +++ b/src/lib/opencode-compatibility.test.ts @@ -11,6 +11,7 @@ import { redactedOpenCodeEventFingerprint, resolveOpenCodeCompatibility, selectOpenCodeSchema, + isOpenCodeSchemaBundle, } from "./opencode-compatibility.ts"; const now = Date.parse("2026-07-24T12:00:00.000Z"); @@ -91,6 +92,34 @@ assert.equal( "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], + 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, toolEnd: [] }, + }], +}, now), false, "incomplete event mappings cannot be selected as structured parsers"); + +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 }), null, diff --git a/src/lib/opencode-compatibility.ts b/src/lib/opencode-compatibility.ts index 7ed532a0e..3c2ce27e4 100644 --- a/src/lib/opencode-compatibility.ts +++ b/src/lib/opencode-compatibility.ts @@ -127,10 +127,11 @@ function isRecord(value: unknown): value is Record { 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.requires).every((key) => key === "json" || key === "session" || key === "model")) 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")) return false; const eventKeys = ["text", "toolStart", "toolEnd", "toolComplete", "error"]; const eventTypes = value.eventTypes; - if (Object.keys(eventTypes).length !== eventKeys.length || !eventKeys.every((key) => Array.isArray(eventTypes[key]) && eventTypes[key].every((type: unknown) => typeof type === "string" && type.length > 0 && type.length <= 80))) return false; + if (Object.keys(eventTypes).length !== eventKeys.length || !eventKeys.every((key) => Array.isArray(eventTypes[key]) && eventTypes[key].length > 0 && eventTypes[key].length <= 32 && eventTypes[key].every((type: unknown) => typeof type === "string" && type.length > 0 && type.length <= 80))) return false; return true; } @@ -163,7 +164,7 @@ export function selectOpenCodeSchema( export function isOpenCodeSchemaBundle(value: unknown, now = Date.now()): value is OpenCodeSchemaBundle { if (!isRecord(value) || value.format !== 1 || value.runtime !== "opencode") return false; - if (typeof value.sequence !== "number" || !Number.isSafeInteger(value.sequence) || value.sequence < 1 || !Array.isArray(value.schemas) || !value.schemas.every(isEventSchema)) 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; const issuedAt = Date.parse(String(value.issuedAt)); const expiresAt = Date.parse(String(value.expiresAt)); if (!Number.isFinite(issuedAt) || !Number.isFinite(expiresAt) || issuedAt > now || expiresAt <= now) return false; @@ -171,10 +172,12 @@ export function isOpenCodeSchemaBundle(value: unknown, now = Date.now()): value // selection for the corresponding client capabilities. Reject it at the // signed-bundle boundary, before it can affect a chat turn. const profiles = new Set(); + const ids = new Set(); for (const schema of value.schemas) { - const profile = JSON.stringify(schema.requires); - if (profiles.has(profile)) return false; + const profile = stableJson(schema.requires); + if (profiles.has(profile) || ids.has(schema.id)) return false; profiles.add(profile); + ids.add(schema.id); } return true; } @@ -200,7 +203,9 @@ export function verifyOpenCodeSchemaBundle(bundle: unknown, publicKey: string, n 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; - return verify(null, Buffer.from(openCodeSchemaBundleSigningPayload(bundle)), createPublicKey(publicKey), signature); + const key = createPublicKey(publicKey); + if (key.asymmetricKeyType !== "ed25519") return false; + return verify(null, Buffer.from(openCodeSchemaBundleSigningPayload(bundle)), key, signature); } catch { return false; } From c45e2065c49f73b9c057a0e44529dc7256318cc1 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 24 Jul 2026 22:54:59 -0400 Subject: [PATCH 014/112] fix(chat): harden OpenCode compatibility fallback --- .../api/chat/send/chat-send-capabilities.ts | 60 ++++++++++++++----- .../send/harness-routing-opencode.test.ts | 4 +- src/app/api/chat/send/route.ts | 46 +++++++++----- src/lib/chat-tool-events.test.ts | 7 +++ src/lib/chat-tool-events.ts | 55 +++++++++++++---- src/lib/opencode-compatibility.test.ts | 34 +++++++++-- src/lib/opencode-compatibility.ts | 52 ++++++++++++---- src/lib/opencode-stream.test.ts | 25 +++++++- src/lib/opencode-stream.ts | 20 ++++--- 9 files changed, 235 insertions(+), 68 deletions(-) diff --git a/src/app/api/chat/send/chat-send-capabilities.ts b/src/app/api/chat/send/chat-send-capabilities.ts index 51ec46203..90ce1af86 100644 --- a/src/app/api/chat/send/chat-send-capabilities.ts +++ b/src/app/api/chat/send/chat-send-capabilities.ts @@ -62,15 +62,17 @@ function probeHelp( }); } -function probeOutput(command: string, args: string[], env = harnessSpawnEnv(), input?: string): Promise { - return new Promise((resolve) => { +type ProbeOutput = { output: string; complete: boolean }; + +function probeOutput(command: string, args: string[], env = harnessSpawnEnv(), input?: string): Promise { + return new Promise((resolve) => { let output = ""; const MAX_PROBE_OUTPUT = 64 * 1024; let settled = false; - const done = () => { + const done = (complete: boolean) => { if (settled) return; settled = true; - resolve(output); + resolve({ output, complete }); }; try { const child = spawn(command, args, { @@ -78,24 +80,40 @@ function probeOutput(command: string, args: string[], env = harnessSpawnEnv(), i stdio: input === undefined ? ["ignore", "pipe", "pipe"] : ["pipe", "pipe", "pipe"], }) as ChildProcessWithoutNullStreams; if (input !== undefined) writeOpenCodeLaunchInput(child, { command, args, input }); + let overflowed = false; const append = (chunk: Buffer) => { - if (output.length >= MAX_PROBE_OUTPUT) return; + 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); const timeout = setTimeout(() => { try { child.kill("SIGTERM"); } catch { /* Probe failures are capabilities=false. */ } - done(); + done(false); }, 2500); - child.on("close", () => { clearTimeout(timeout); done(); }); - child.on("error", () => { clearTimeout(timeout); done(); }); + child.on("close", (code) => { clearTimeout(timeout); done(code === 0 && !overflowed); }); + child.on("error", () => { clearTimeout(timeout); done(false); }); } catch { - done(); + done(false); } }); } +function hasRunOption(help: string, flag: "--model" | "--session"): 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 advertisedFormatProtocols(help: string): string[] { + const stanza = help.match(/^\s*(?:-[A-Za-z],?\s+)?--format\b[^\n]*(?:\n(?!\s*(?:-[A-Za-z],?\s+)?--)[^\n]*){0,2}/im)?.[0] ?? ""; + // A protocol marker is useful only when the CLI advertises it as an output + // format. Do not derive it from version strings or arbitrary help prose. + return [...new Set((stanza.match(/\bjson(?:[._-][a-z0-9]+)*\b/gi) ?? []).map((value) => value.toLowerCase()))]; +} + /** Capability probes are cached because old Coven CLIs reject unknown flags. */ export function covenRunSupportsModel(): Promise { const { command, fixedArgs } = covenLaunchCommand(); @@ -158,19 +176,33 @@ export function openCodeRunCapabilities(): Promise { const value = (async () => { const helpLaunch = openCodeLaunch(["run", "--help"]); const versionLaunch = openCodeLaunch(["--version"]); - const [help, versionOutput] = await Promise.all([ + const [helpProbe, versionProbe] = await Promise.all([ probeOutput(helpLaunch.command, helpLaunch.args, openCodeSpawnEnv(), helpLaunch.input), probeOutput(versionLaunch.command, versionLaunch.args, openCodeSpawnEnv(), versionLaunch.input), ]); - const version = versionOutput.match(/\b\d+(?:\.\d+){1,3}(?:[-+][\w.-]+)?\b/)?.[0] ?? null; + 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. Probe again after the short TTL instead of risking an argv + // that the installed client does not accept. + if (!helpProbe.complete) return { version, json: false, model: false, session: false, protocols: [] }; + const help = helpProbe.output; + const protocols = advertisedFormatProtocols(help); + const json = protocols.some((protocol) => protocol === "json" || protocol.startsWith("json-") || protocol.startsWith("json_")); return { version, // Only accept JSON when it appears in the `--format` option's own // stanza. A stray "JSON" in a banner or another option's description // must not make us launch an unsupported `--format json` command. - json: /(?:^|\n)\s*--format(?:[=\s][^\n]*)?(?:\n(?!\s*--)[^\n]*){0,2}\bjson\b/im.test(help), - model: /(^|\s)--model(?![\w-])/m.test(help), - session: /(^|\s)--session(?![\w-])/m.test(help), + json, + model: hasRunOption(help, "--model"), + session: hasRunOption(help, "--session"), + // 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, }; })(); openCodeCapabilitiesProbe = { until: Date.now() + OPENCODE_CAPABILITY_PROBE_TTL_MS, value }; 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 5ba0b36b9..baf07900f 100644 --- a/src/app/api/chat/send/harness-routing-opencode.test.ts +++ b/src/app/api/chat/send/harness-routing-opencode.test.ts @@ -12,7 +12,7 @@ assert.match( ); assert.match( route, - /const a = \["run"\];[\s\S]*?openCodeCompatibility\?\.mode === "structured"[\s\S]*?a\.push\("--format", "json"\);[\s\S]*?openCodeCompatibility\?\.capabilities\.session[\s\S]*?a\.push\("--session", resumeSessionId\);/, + /const a = \["run"\];[\s\S]*?openCodeCompatibility\?\.mode === "structured"[\s\S]*?a\.push\("--format", openCodeCompatibility\.schema\?\.requires\.protocol \?\? "json"\);[\s\S]*?openCodeCompatibility\?\.capabilities\.session[\s\S]*?a\.push\("--session", resumeSessionId\);/, "OpenCode uses discovered JSON and session capabilities rather than a version threshold", ); assert.match( @@ -102,7 +102,7 @@ assert.match( ); assert.match( route, - /opencode-compatibility[\s\S]*?unrecognized tool event[\s\S]*?redactedOpenCodeEventFingerprint\(rawEvent\)/, + /opencode-compatibility[\s\S]*?unrecognized event[\s\S]*?redactedOpenCodeEventFingerprint\(rawEvent\)/, "unknown future event shapes surface a safe visible diagnostic", ); assert.match( diff --git a/src/app/api/chat/send/route.ts b/src/app/api/chat/send/route.ts index 8caef226a..551349f09 100644 --- a/src/app/api/chat/send/route.ts +++ b/src/app/api/chat/send/route.ts @@ -1365,7 +1365,9 @@ export async function POST(req: Request) { // 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") a.push("--format", "json"); + if (openCodeCompatibility?.mode === "structured") { + a.push("--format", openCodeCompatibility.schema?.requires.protocol ?? "json"); + } if (resumeSessionId && openCodeCompatibility?.capabilities.session) a.push("--session", resumeSessionId); if (forwardModel) a.push("--model", forwardModel); a.push(prompt); @@ -1394,7 +1396,9 @@ export async function POST(req: Request) { const resumeTarget = body.startNewConversation && !existingConversation ? null : body.sessionId - ? existingConversation?.harnessSessionId ?? body.sessionId + ? openCodeDirect + ? existingConversation?.harnessSessionId ?? null + : 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 @@ -1413,10 +1417,7 @@ export async function POST(req: Request) { // at Cave's saved session id. Start a fresh native session with recent // transcript context instead; the in-stream notice makes this explicit. const openCodeFreshSessionForCompatibility = Boolean( - openCodeDirect && resumeTarget && ( - openCodeCompatibility?.mode !== "structured" || - !openCodeCompatibility.capabilities.session - ), + openCodeDirect && resumeTarget && !openCodeCompatibility?.capabilities.session, ); const openCodeCompatibilityRetry = openCodeFreshSessionForCompatibility ? buildResumeRetryPrompt(harnessPrompt, existingConversation) @@ -1861,18 +1862,14 @@ export async function POST(req: Request) { } if (ev.kind === "other") { openCodeToolActivityDisabled = true; - // A future envelope can still carry assistant text even when its - // event label is unknown. Preserve that safe textual field while - // disabling only the unsupported structured activity. - if (ev.text) { - assistantText += ev.text; - push({ kind: "assistant_chunk", text: ev.text }); - } + // 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. if (!openCodeCompatibilityNoticeSent) { openCodeCompatibilityNoticeSent = true; pushProgress( "opencode-compatibility", - "OpenCode sent an unrecognized tool event; continuing with assistant text", + "OpenCode sent an unrecognized event; tool activity was disabled for this turn", "error", `${ev.diagnostic ?? "unknown-event"}:${redactedOpenCodeEventFingerprint(rawEvent)}`, ); @@ -1888,7 +1885,7 @@ export async function POST(req: Request) { openCodeCompatibilityNoticeSent = true; pushProgress( "opencode-compatibility", - "OpenCode sent a malformed event; continuing with assistant text", + "OpenCode sent a malformed event; tool activity was disabled for this turn", "error", "malformed-json-event", ); @@ -1908,6 +1905,8 @@ export async function POST(req: Request) { ? "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 === "cached-schema-unavailable" + ? "OpenCode's compatibility registry is unavailable; continuing without tool activity" : "OpenCode schema refresh was not trusted; using the last known compatible parser"; pushProgress("opencode-compatibility", diagnostic, "error", openCodeCompatibility.diagnostic); } @@ -2258,10 +2257,25 @@ 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 && !openCodeCompatibilityNoticeSent && openCodeCompatibility?.diagnostic) { + openCodeCompatibilityNoticeSent = 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 === "cached-schema-unavailable" + ? "OpenCode's compatibility registry is unavailable; continuing without tool activity" + : "OpenCode schema refresh was not trusted; using the last known compatible parser"; + pushProgress("opencode-compatibility", diagnostic, "error", openCodeCompatibility.diagnostic); + } if (openCodeFreshSessionForCompatibility) { pushProgress( "opencode-compatibility", - "This OpenCode client cannot resume sessions; replaying recent context in a fresh chat", + openCodeCompatibilityRetry?.replayedHistory + ? "This OpenCode client cannot resume sessions; replaying recent context in a fresh chat" + : "This OpenCode client cannot resume sessions; starting a fresh chat", "error", "session-unavailable", ); diff --git a/src/lib/chat-tool-events.test.ts b/src/lib/chat-tool-events.test.ts index 626d660da..3807aacd0 100644 --- a/src/lib/chat-tool-events.test.ts +++ b/src/lib/chat-tool-events.test.ts @@ -21,4 +21,11 @@ assert.deepEqual( "reconciled OpenCode calls persist their stable id and terminal output for reload/resume", ); +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"); + 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 db24a8516..3b69e04d4 100644 --- a/src/lib/chat-tool-events.ts +++ b/src/lib/chat-tool-events.ts @@ -34,6 +34,19 @@ 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 MAX_OPEN_ENVELOPE_CALLS = 200; + +export function capLiveToolPayload(value: string | undefined, cap: number): string | undefined { + if (value === undefined || value.length <= cap) return value; + return `${value.slice(0, cap)}\n[tool payload truncated]`; +} + /** 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 +117,7 @@ export class ToolCallTracker { private settledEnvelopeIds = new Set(); /** Terminal envelopes can precede starts after a reconnect or CLI flush. */ private pendingEnvelopeResults = new Map(); + private pendingEnvelopeResultBytes = 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(); @@ -135,10 +149,15 @@ export class ToolCallTracker { } 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) { this.recorded.set(ev.id, { - ...ev, + ...bounded, ...(textOffset !== undefined ? { textOffset } : {}), }); return; @@ -147,8 +166,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 @@ -219,7 +238,8 @@ 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; + if (id.length > 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 @@ -244,7 +264,7 @@ 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; } @@ -254,6 +274,7 @@ export class ToolCallTracker { const pending = this.pendingEnvelopeResults.get(toolUseId); if (!pending) return null; this.pendingEnvelopeResults.delete(toolUseId); + this.pendingEnvelopeResultBytes -= pending.output?.length ?? 0; return this.envelopeToolResult(toolUseId, pending.output, pending.isError); } @@ -269,7 +290,8 @@ export class ToolCallTracker { output: string | undefined, isError: boolean, ): ToolStreamEvent | null { - if (this.settledEnvelopeIds.has(toolUseId)) return null; + if (toolUseId.length > 512 || this.settledEnvelopeIds.has(toolUseId)) return null; + const boundedOutput = capLiveToolPayload(output, LIVE_TOOL_OUTPUT_CAP); const call = this.byEnvelopeId.get(toolUseId); if (!call) { // A malformed stream must not turn arbitrary unmatched ids into an @@ -278,11 +300,24 @@ export class ToolCallTracker { // 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 >= 100) { + if (this.pendingEnvelopeResults.size >= MAX_PENDING_TOOL_RESULTS) { + const oldest = this.pendingEnvelopeResults.keys().next().value; + if (oldest) { + const dropped = this.pendingEnvelopeResults.get(oldest); + this.pendingEnvelopeResultBytes -= dropped?.output?.length ?? 0; + this.pendingEnvelopeResults.delete(oldest); + } + } + while (this.pendingEnvelopeResultBytes + (boundedOutput?.length ?? 0) > MAX_PENDING_TOOL_RESULT_BYTES && this.pendingEnvelopeResults.size) { const oldest = this.pendingEnvelopeResults.keys().next().value; - if (oldest) this.pendingEnvelopeResults.delete(oldest); + if (!oldest) break; + const dropped = this.pendingEnvelopeResults.get(oldest); + this.pendingEnvelopeResultBytes -= dropped?.output?.length ?? 0; + this.pendingEnvelopeResults.delete(oldest); } - this.pendingEnvelopeResults.set(toolUseId, { output, isError }); + if ((boundedOutput?.length ?? 0) > MAX_PENDING_TOOL_RESULT_BYTES) return null; + this.pendingEnvelopeResults.set(toolUseId, { output: boundedOutput, isError }); + this.pendingEnvelopeResultBytes += boundedOutput?.length ?? 0; return null; } const durationMs = this.now() - call.startedAt; @@ -290,7 +325,7 @@ export class ToolCallTracker { const ev: ToolStreamEvent = { id: call.id, name: call.name, - output, + output: boundedOutput, status: isError ? "error" : "ok", durationMs, }; diff --git a/src/lib/opencode-compatibility.test.ts b/src/lib/opencode-compatibility.test.ts index b65e92d51..d38e11079 100644 --- a/src/lib/opencode-compatibility.test.ts +++ b/src/lib/opencode-compatibility.test.ts @@ -76,19 +76,43 @@ const rejectedRollback = await loadOpenCodeSchemaBundle({ assert.equal(rejectedRollback.source, "cache"); assert.equal(rejectedRollback.diagnostic, "schema-registry-refresh-rejected", "rollback never overwrites cache"); -const plain = await resolveOpenCodeCompatibility({ version: "9.9.9", json: false, model: true, session: true }); +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 }); +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 }); +const missingSession = await resolveOpenCodeCompatibility({ version: "1.2.3", json: true, model: true, session: false, protocols: ["json"] }); assert.equal(missingSession.mode, "structured"); assert.equal(missingSession.schema?.id, "opencode-run-json-legacy", "older compatible schemas coexist without client version gates"); +await writeFile(cacheFile, JSON.stringify({ checkedAt: now, bundle: signed })); +const expiredRemoteOnly = await resolveOpenCodeCompatibility( + { version: "2.0.0", json: true, model: true, session: true }, + { + 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-only cache cannot silently select an older built-in JSON parser"); +assert.equal(expiredRemoteOnly.diagnostic, "cached-schema-unavailable"); + 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", + requires: { json: true as const, session: true, protocol: "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([broadSchema, BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0]], { version: "current", json: true, model: false, session: true })?.id, + 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", ); @@ -121,7 +145,7 @@ assert.equal( "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 }), + 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", ); diff --git a/src/lib/opencode-compatibility.ts b/src/lib/opencode-compatibility.ts index 3c2ce27e4..c46872884 100644 --- a/src/lib/opencode-compatibility.ts +++ b/src/lib/opencode-compatibility.ts @@ -9,12 +9,14 @@ export type OpenCodeRunCapabilities = { json: boolean; model: boolean; session: boolean; + /** Explicit, documented structured-output format values from `run --help`. */ + protocols: string[]; }; export type OpenCodeEventSchema = { id: string; /** A schema is selected only when every advertised requirement is met. */ - requires: { json: true; session?: boolean; model?: boolean }; + requires: { json: true; session?: boolean; model?: boolean; protocol?: string }; eventTypes: { text: string[]; toolStart: string[]; @@ -96,7 +98,7 @@ export const BUILTIN_OPENCODE_SCHEMA_BUNDLE: OpenCodeSchemaBundle = { schemas: [ { id: "opencode-run-json-v1", - requires: { json: true, session: true }, + requires: { json: true, session: true, protocol: "json" }, eventTypes: { text: ["text"], toolStart: ["tool_start", "tool"], @@ -109,7 +111,7 @@ export const BUILTIN_OPENCODE_SCHEMA_BUNDLE: OpenCodeSchemaBundle = { // Earlier and preview clients used generic tool envelopes. Keeping this // separate lets a signed registry retire it without a code release. id: "opencode-run-json-legacy", - requires: { json: true, session: false }, + requires: { json: true, session: false, protocol: "json" }, eventTypes: { text: ["message", "assistant_text"], toolStart: ["tool"], @@ -127,11 +129,26 @@ function isRecord(value: unknown): value is Record { 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.requires).every((key) => key === "json" || key === "session" || key === "model")) 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")) return false; - const eventKeys = ["text", "toolStart", "toolEnd", "toolComplete", "error"]; - const eventTypes = value.eventTypes; + 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 eventKeys: Array = ["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"]; if (Object.keys(eventTypes).length !== eventKeys.length || !eventKeys.every((key) => Array.isArray(eventTypes[key]) && eventTypes[key].length > 0 && eventTypes[key].length <= 32 && eventTypes[key].every((type: unknown) => typeof type === "string" && type.length > 0 && type.length <= 80))) return false; + const nonToolLabels = new Set(); + for (const key of ["text", "error", "toolEnd"] as const) { + for (const label of eventTypes[key] as unknown[]) { + if (nonToolLabels.has(label as string)) return false; + nonToolLabels.add(label as string); + } + } + const toolLabels = [ + ...(eventTypes.toolStart as string[]), + ...(eventTypes.toolComplete as string[]), + ]; + if (toolLabels.some((label) => nonToolLabels.has(label))) return false; return true; } @@ -139,11 +156,15 @@ function schemaMatches(schema: OpenCodeEventSchema, capabilities: OpenCodeRunCap if (!capabilities.json) return false; if (schema.requires.session !== undefined && schema.requires.session !== capabilities.session) return false; if (schema.requires.model !== undefined && schema.requires.model !== 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; return true; } function schemaSpecificity(schema: OpenCodeEventSchema): number { - return Number(schema.requires.session !== undefined) + Number(schema.requires.model !== undefined); + return Number(schema.requires.session !== undefined) + Number(schema.requires.model !== undefined) + Number(schema.requires.protocol !== undefined); } /** @@ -416,15 +437,26 @@ export async function resolveOpenCodeCompatibility( capabilities: OpenCodeRunCapabilities, source?: OpenCodeSchemaBundleSource, ): Promise { - const loaded = await loadOpenCodeSchemaBundle(source); if (!capabilities.json) { return { mode: "plain", capabilities, - bundleSource: loaded.source, + bundleSource: "built-in", diagnostic: "json-format-unavailable", }; } + const loaded = await loadOpenCodeSchemaBundle(source); + // A configured registry with no currently verified cache must not silently + // fall back to a potentially older compiled parser. Keep plain chat until a + // signed schema for this client can be verified again. + if (loaded.diagnostic === "cached-schema-unavailable") { + return { + mode: "plain", + capabilities, + bundleSource: loaded.source, + diagnostic: "cached-schema-unavailable", + }; + } const schema = selectOpenCodeSchema(loaded.bundle.schemas, capabilities); if (!schema) { return { diff --git a/src/lib/opencode-stream.test.ts b/src/lib/opencode-stream.test.ts index 932e6929c..8c29583c7 100644 --- a/src/lib/opencode-stream.test.ts +++ b/src/lib/opencode-stream.test.ts @@ -41,6 +41,14 @@ assert.deepEqual( { kind: "tool_end", sessionId: "ses_123", id: "tool_1", output: "ok", isError: false }, "reordered results can close the same stable tool bubble", ); +assert.deepEqual( + parseOpenCodeRunEvent( + { type: "tool_result", id: "tool_failed", error: "permission denied" }, + BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0], + ), + { kind: "tool_end", sessionId: undefined, id: "tool_failed", output: "permission denied", isError: true }, + "root-level terminal errors settle a failed tool rather than an empty success", +); assert.deepEqual( parseOpenCodeRunEvent( { type: "tool", sessionID: "ses_123", callID: "call_1", tool: "bash", state: { input: { command: "pwd" }, status: "running" } }, @@ -65,6 +73,11 @@ assert.deepEqual( { 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", 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" }, @@ -72,8 +85,8 @@ assert.deepEqual( ); assert.deepEqual( parseOpenCodeRunEvent({ type: "future_text_delta", data: { text: "Still show this reply" } }), - { kind: "other", sessionId: undefined, diagnostic: "unknown-event", text: "Still show this reply" }, - "unknown future envelopes retain safe assistant text while disabling tool activity", + { kind: "other", sessionId: undefined, diagnostic: "unknown-event" }, + "unknown envelopes never promote arbitrary payload text into assistant output", ); assert.equal( parseOpenCodeRunEvent({ type: "tool", callID: "failed_1", tool: "bash", state: { status: "FAILED" } }, BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0]).kind, @@ -85,6 +98,14 @@ assert.equal( true, "case variants of error statuses do not render successful tool bubbles", ); +assert.deepEqual( + parseOpenCodeRunEvent( + { type: "tool", callID: "cancelled_1", tool: "bash", state: { status: "CANCELLED" } }, + BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0], + ), + { kind: "tool", sessionId: undefined, id: "cancelled_1", name: "bash", input: {}, output: "", isError: true }, + "cancelled terminal states settle the upstream tool as an error", +); assert.deepEqual( parseOpenCodeRunEvent(["future", "envelope"]), { kind: "other", diagnostic: "malformed-event" }, diff --git a/src/lib/opencode-stream.ts b/src/lib/opencode-stream.ts index 9b7734ed5..ecc1da26d 100644 --- a/src/lib/opencode-stream.ts +++ b/src/lib/opencode-stream.ts @@ -6,7 +6,7 @@ export type OpenCodeRunEvent = | { 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; diagnostic?: "unknown-event" | "malformed-event"; text?: string }; + | { kind: "other"; sessionId?: string; diagnostic?: "unknown-event" | "malformed-event" }; function record(value: unknown): Record | null { return value && typeof value === "object" && !Array.isArray(value) @@ -29,12 +29,12 @@ function toolId(part: Record | null): string | null { function terminalToolState(state: Record | null): boolean { const status = stringAt(state, "status")?.toLowerCase(); - return status === "completed" || status === "complete" || status === "error" || status === "failed"; + return status === "completed" || status === "complete" || status === "error" || status === "failed" || status === "cancelled" || status === "canceled" || status === "aborted" || status === "interrupted" || status === "timeout" || status === "timed_out"; } -function toolStateIsError(state: Record | null): boolean { +function toolStateIsError(state: Record | null, part?: Record | null): boolean { const status = stringAt(state, "status")?.toLowerCase(); - return status === "error" || status === "failed" || Boolean(state?.error); + return status === "error" || status === "failed" || status === "cancelled" || status === "canceled" || status === "aborted" || status === "interrupted" || status === "timeout" || status === "timed_out" || Boolean(state?.error) || Boolean(part?.error); } /** Decode OpenCode's `run --format json` envelope without trusting its fields. */ @@ -74,10 +74,13 @@ export function parseOpenCodeRunEvent(value: unknown, schema?: OpenCodeEventSche return { kind: "tool_start", sessionId, id, name: stringAt(part, "tool", "name") ?? "tool", input: state?.input ?? part?.input ?? {} }; } if (toolEndTypes.includes(event.type) && id) { - return { kind: "tool_end", sessionId, id, output: state?.output ?? state?.error ?? part?.output ?? "", isError: toolStateIsError(state) }; + return { kind: "tool_end", sessionId, id, output: state?.output ?? state?.error ?? part?.output ?? part?.error ?? "", isError: toolStateIsError(state, part) }; } if (toolCompleteTypes.includes(event.type) && id && part) { - if (!terminalToolState(state)) { + // Legacy `tool_use` frames were terminal snapshots and some omit a + // status entirely. Their output/error is the durable terminal signal. + const hasTerminalPayload = state?.output !== undefined || state?.error !== undefined || part?.output !== undefined; + if (!terminalToolState(state) && !hasTerminalPayload) { return { kind: "tool_start", sessionId, id, name: stringAt(part, "tool", "name") ?? "tool", input: state?.input ?? part?.input ?? {} }; } return { @@ -86,8 +89,8 @@ export function parseOpenCodeRunEvent(value: unknown, schema?: OpenCodeEventSche id, name: stringAt(part, "tool", "name") ?? "tool", input: state?.input ?? {}, - output: state?.output ?? state?.error ?? "", - isError: toolStateIsError(state), + output: state?.output ?? state?.error ?? part?.output ?? part?.error ?? "", + isError: toolStateIsError(state, part), }; } const knownType = [ @@ -101,6 +104,5 @@ export function parseOpenCodeRunEvent(value: unknown, schema?: OpenCodeEventSche kind: "other", sessionId, diagnostic: knownType ? "malformed-event" : "unknown-event", - ...(text === undefined ? {} : { text }), }; } From fe5a7ddf614dbb4fcdd45217dad5922e44250c05 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Fri, 24 Jul 2026 23:04:03 -0400 Subject: [PATCH 015/112] test(ci): remove brittle chat spawn span limit --- src/lib/server/stuck-created-sweep.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/server/stuck-created-sweep.test.ts b/src/lib/server/stuck-created-sweep.test.ts index b0597f7d5..426af8c6e 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\S]{0,1000}?await runAttempt\(args\);/, + /const turnSpawnStartMs = Date\.now\(\);[\s\S]*?await runAttempt\(args\);/, "turn window anchor is captured before any optional pre-launch compatibility notice and the first attempt", ); } From 2cf15d74a8ba71c7f2f7a4ae559f5c3841a4cd12 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Fri, 24 Jul 2026 23:14:15 -0400 Subject: [PATCH 016/112] fix(chat): harden OpenCode compatibility refresh --- .../send/harness-routing-opencode.test.ts | 19 +- src/app/api/chat/send/route.ts | 55 ++-- src/lib/cave-conversations.ts | 9 + src/lib/chat-tool-events.ts | 59 ++-- src/lib/opencode-compatibility.test.ts | 92 ++++-- src/lib/opencode-compatibility.ts | 276 ++++++++++++++---- src/lib/opencode-stream.test.ts | 67 +++++ src/lib/opencode-stream.ts | 92 ++++-- 8 files changed, 528 insertions(+), 141 deletions(-) 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 baf07900f..9c54dc593 100644 --- a/src/app/api/chat/send/harness-routing-opencode.test.ts +++ b/src/app/api/chat/send/harness-routing-opencode.test.ts @@ -67,8 +67,13 @@ assert.match( ); assert.match( route, - /let openCodeToolActivityDisabled = false;[\s\S]*?if \(ev\.kind === "tool"\) \{\s*if \(openCodeToolActivityDisabled\) return;[\s\S]*?if \(ev\.kind === "other"\) \{\s*openCodeToolActivityDisabled = true;/, - "an unknown OpenCode envelope disables subsequent structured tool activity for the turn", + /if \(ev\.kind === "other"\) \{[\s\S]*?unrecognized event; that event was skipped while compatible tool activity continues/, + "an unknown OpenCode envelope is skipped without suppressing later compatible tool activity", +); +assert.match( + route, + /parseOpenCodeRunEvent\(rawEvent, openCodeCompatibility\?\.schema\);[\s\S]*?if \(ev\.kind === "ignore"\) return;[\s\S]*?if \(ev\.kind === "text"\)/, + "documented non-renderable OpenCode lifecycle frames are ignored before tool activity is evaluated", ); assert.match( route, @@ -95,6 +100,11 @@ assert.match( /openCodeCompatibility\?\.mode === "plain"[\s\S]*?\? undefined[\s\S]*?: sessionId/, "a Cave-owned plain-mode id is never mistaken for a native OpenCode resume token", ); +assert.match( + route, + /openCodeDirect && body\.sessionId && existingConversation && \([\s\S]*?!openCodeCompatibility\?\.capabilities\.session \|\| !existingConversation\.harnessSessionId[\s\S]*?buildResumeRetryPrompt\(harnessPrompt, existingConversation\)/, + "plain-mode follow-ups replay Cave context when no native OpenCode session was minted", +); assert.match( route, /ev\.kind === "tool_start"[\s\S]*?envelopeToolUse[\s\S]*?ev\.kind === "tool_end"[\s\S]*?envelopeToolResult/, @@ -105,6 +115,11 @@ assert.match( /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 handleOpenCodeLine[\s\S]*?catch \{[\s\S]*?recordStdoutErrorTail\("OpenCode emitted a malformed JSON event", true\)/, diff --git a/src/app/api/chat/send/route.ts b/src/app/api/chat/send/route.ts index 551349f09..82d688b79 100644 --- a/src/app/api/chat/send/route.ts +++ b/src/app/api/chat/send/route.ts @@ -1413,12 +1413,16 @@ export async function POST(req: Request) { const grokSandboxRetry = grokFreshSessionForSandbox ? buildResumeRetryPrompt(harnessPrompt, existingConversation) : null; - // A client which no longer advertises `--session` cannot safely be pointed - // at Cave's saved session id. Start a fresh native session with recent - // transcript context instead; the in-stream notice makes this explicit. + // A client which no longer advertises `--session`, or a prior plain-mode + // turn which never received a native OpenCode id, must start fresh with + // bounded Cave transcript replay. Otherwise a follow-up silently loses its + // earlier conversation context. const openCodeFreshSessionForCompatibility = Boolean( - openCodeDirect && resumeTarget && !openCodeCompatibility?.capabilities.session, + openCodeDirect && body.sessionId && existingConversation && ( + !openCodeCompatibility?.capabilities.session || !existingConversation.harnessSessionId + ), ); + const openCodeSessionUnavailable = !openCodeCompatibility?.capabilities.session; const openCodeCompatibilityRetry = openCodeFreshSessionForCompatibility ? buildResumeRetryPrompt(harnessPrompt, existingConversation) : null; @@ -1463,13 +1467,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, @@ -1478,6 +1496,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; @@ -1565,11 +1584,6 @@ export async function POST(req: Request) { // Detected from stderr; healed (manifest quarantined) and retried below. let adapterConflict: BuiltinAdapterConflict | null = null; let openCodeCompatibilityNoticeSent = false; - // Once an unknown structured envelope appears, retaining later tool - // frames would mix an unverified lifecycle with a known one. Continue - // decoding text only for this turn instead of showing potentially wrong - // tool activity. - let openCodeToolActivityDisabled = false; let openCodeModelRejected = false; // Model parity: the harness echoes its resolved model on the init/system @@ -1800,6 +1814,7 @@ export async function POST(req: Request) { const rawEvent = JSON.parse(line); const ev = parseOpenCodeRunEvent(rawEvent, openCodeCompatibility?.schema); if (ev.sessionId && !sessionId) announceSession(ev.sessionId); + if (ev.kind === "ignore") return; if (ev.kind === "text") { const text = ev.text.endsWith("\n") ? ev.text : `${ev.text}\n`; assistantText += text; @@ -1807,7 +1822,6 @@ export async function POST(req: Request) { return; } if (ev.kind === "tool") { - if (openCodeToolActivityDisabled) return; boundarySentinel?.observe(ev.name, ev.input); const started = toolTracker.envelopeToolUse( ev.id, @@ -1825,7 +1839,6 @@ export async function POST(req: Request) { return; } if (ev.kind === "tool_start") { - if (openCodeToolActivityDisabled) return; boundarySentinel?.observe(ev.name, ev.input); const started = toolTracker.envelopeToolUse( ev.id, @@ -1839,7 +1852,6 @@ export async function POST(req: Request) { return; } if (ev.kind === "tool_end") { - if (openCodeToolActivityDisabled) return; const ended = toolTracker.envelopeToolResult( ev.id, typeof ev.output === "string" ? ev.output : formatToolInputValue(ev.output), @@ -1861,7 +1873,6 @@ export async function POST(req: Request) { return; } if (ev.kind === "other") { - openCodeToolActivityDisabled = true; // 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. @@ -1869,7 +1880,7 @@ export async function POST(req: Request) { openCodeCompatibilityNoticeSent = true; pushProgress( "opencode-compatibility", - "OpenCode sent an unrecognized event; tool activity was disabled for this turn", + "OpenCode sent an unrecognized event; that event was skipped while compatible tool activity continues", "error", `${ev.diagnostic ?? "unknown-event"}:${redactedOpenCodeEventFingerprint(rawEvent)}`, ); @@ -1879,13 +1890,12 @@ export async function POST(req: Request) { // Structured-mode stdout can contain a malformed future event with // arbitrary tool payloads. Do not feed that raw line into the // persisted error tail or any user-visible diagnostic. - openCodeToolActivityDisabled = true; recordStdoutErrorTail("OpenCode emitted a malformed JSON event", true); if (!openCodeCompatibilityNoticeSent) { openCodeCompatibilityNoticeSent = true; pushProgress( "opencode-compatibility", - "OpenCode sent a malformed event; tool activity was disabled for this turn", + "OpenCode sent a malformed event; that event was skipped while compatible tool activity continues", "error", "malformed-json-event", ); @@ -2274,8 +2284,12 @@ export async function POST(req: Request) { pushProgress( "opencode-compatibility", openCodeCompatibilityRetry?.replayedHistory - ? "This OpenCode client cannot resume sessions; replaying recent context in a fresh chat" - : "This OpenCode client cannot resume sessions; starting a fresh chat", + ? 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", ); @@ -2309,7 +2323,6 @@ export async function POST(req: Request) { result = {}; toolTracker = new ToolCallTracker(); openCodeCompatibilityNoticeSent = false; - openCodeToolActivityDisabled = false; openCodeModelRejected = false; copilotText.reset(); stderrTail.length = 0; @@ -2351,7 +2364,6 @@ export async function POST(req: Request) { result = {}; toolTracker = new ToolCallTracker(); openCodeCompatibilityNoticeSent = false; - openCodeToolActivityDisabled = false; openCodeModelRejected = false; copilotText.reset(); stderrTail.length = 0; @@ -2564,6 +2576,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, }; 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.ts b/src/lib/chat-tool-events.ts index 3b69e04d4..995c46f43 100644 --- a/src/lib/chat-tool-events.ts +++ b/src/lib/chat-tool-events.ts @@ -40,11 +40,22 @@ 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; +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 || value.length <= cap) return value; - return `${value.slice(0, cap)}\n[tool payload truncated]`; + 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. */ @@ -116,7 +127,7 @@ export class ToolCallTracker { /** 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 pendingEnvelopeResults = new Map(); private pendingEnvelopeResultBytes = 0; /** Final state of every call this tracker has emitted, by stream id — * insertion-ordered, so snapshot() preserves call order for persistence. */ @@ -136,6 +147,21 @@ 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 settle(call: OpenCall): void { const q = this.open.get(call.name); if (q) { @@ -238,7 +264,8 @@ 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 (id.length > 512 || this.byEnvelopeId.has(id) || this.settledEnvelopeIds.has(id)) return null; + this.prunePendingEnvelopeResults(); + 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 @@ -271,10 +298,10 @@ export class ToolCallTracker { /** 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.pendingEnvelopeResults.delete(toolUseId); - this.pendingEnvelopeResultBytes -= pending.output?.length ?? 0; + this.dropPendingEnvelopeResult(toolUseId); return this.envelopeToolResult(toolUseId, pending.output, pending.isError); } @@ -290,8 +317,10 @@ export class ToolCallTracker { output: string | undefined, isError: boolean, ): ToolStreamEvent | null { - if (toolUseId.length > 512 || 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) { // A malformed stream must not turn arbitrary unmatched ids into an @@ -303,21 +332,17 @@ export class ToolCallTracker { if (this.pendingEnvelopeResults.size >= MAX_PENDING_TOOL_RESULTS) { const oldest = this.pendingEnvelopeResults.keys().next().value; if (oldest) { - const dropped = this.pendingEnvelopeResults.get(oldest); - this.pendingEnvelopeResultBytes -= dropped?.output?.length ?? 0; - this.pendingEnvelopeResults.delete(oldest); + this.dropPendingEnvelopeResult(oldest); } } - while (this.pendingEnvelopeResultBytes + (boundedOutput?.length ?? 0) > MAX_PENDING_TOOL_RESULT_BYTES && this.pendingEnvelopeResults.size) { + while (this.pendingEnvelopeResultBytes + pendingBytes > MAX_PENDING_TOOL_RESULT_BYTES && this.pendingEnvelopeResults.size) { const oldest = this.pendingEnvelopeResults.keys().next().value; if (!oldest) break; - const dropped = this.pendingEnvelopeResults.get(oldest); - this.pendingEnvelopeResultBytes -= dropped?.output?.length ?? 0; - this.pendingEnvelopeResults.delete(oldest); + this.dropPendingEnvelopeResult(oldest); } - if ((boundedOutput?.length ?? 0) > MAX_PENDING_TOOL_RESULT_BYTES) return null; - this.pendingEnvelopeResults.set(toolUseId, { output: boundedOutput, isError }); - this.pendingEnvelopeResultBytes += boundedOutput?.length ?? 0; + 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; diff --git a/src/lib/opencode-compatibility.test.ts b/src/lib/opencode-compatibility.test.ts index d38e11079..0530d6f96 100644 --- a/src/lib/opencode-compatibility.test.ts +++ b/src/lib/opencode-compatibility.test.ts @@ -53,7 +53,7 @@ const offline = await loadOpenCodeSchemaBundle({ fetch: async () => { throw new Error("offline"); }, }); assert.equal(offline.source, "cache"); -assert.equal(offline.diagnostic, "schema-registry-refresh-rejected", "offline keeps the last known good parser"); +assert.equal(offline.diagnostic, undefined, "a stale verified parser serves immediately while refresh runs in the background"); await writeFile(cacheFile, JSON.stringify({ checkedAt: now + 365 * 24 * 60 * 60 * 1000, bundle: signed })); const futureCheckedAt = await loadOpenCodeSchemaBundle({ @@ -74,7 +74,7 @@ const rejectedRollback = await loadOpenCodeSchemaBundle({ fetch: async () => new Response(JSON.stringify(rollback), { status: 200 }), }); assert.equal(rejectedRollback.source, "cache"); -assert.equal(rejectedRollback.diagnostic, "schema-registry-refresh-rejected", "rollback never overwrites cache"); +assert.equal(rejectedRollback.diagnostic, undefined, "rollback refreshes never displace the last known good parser"); const plain = await resolveOpenCodeCompatibility({ version: "9.9.9", json: false, model: true, session: true, protocols: [] }); assert.equal(plain.mode, "plain"); @@ -86,20 +86,6 @@ const missingSession = await resolveOpenCodeCompatibility({ version: "1.2.3", js assert.equal(missingSession.mode, "structured"); assert.equal(missingSession.schema?.id, "opencode-run-json-legacy", "older compatible schemas coexist without client version gates"); -await writeFile(cacheFile, JSON.stringify({ checkedAt: now, bundle: signed })); -const expiredRemoteOnly = await resolveOpenCodeCompatibility( - { version: "2.0.0", json: true, model: true, session: true }, - { - 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-only cache cannot silently select an older built-in JSON parser"); -assert.equal(expiredRemoteOnly.diagnostic, "cached-schema-unavailable"); - const broadSchema = { ...BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0], id: "broad", requires: { json: true as const } }; const protocolV2Schema = { ...BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0], @@ -118,6 +104,16 @@ assert.equal( ); 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], shape: undefined }] }, now), + false, + "a selected schema must declare its parseable envelope and field aliases", +); +assert.equal( + isOpenCodeSchemaBundle({ ...unsigned, issuedAt: 0 }, now), + false, + "signed schema timestamps must be strings rather than coercible values", +); assert.equal(isOpenCodeSchemaBundle({ ...unsigned, schemas: [{ @@ -186,7 +182,7 @@ const oversized = await loadOpenCodeSchemaBundle({ 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 verified cache"); +assert.equal(oversized.diagnostic, undefined, "oversized refreshes preserve and immediately serve the verified cache"); assert.equal((JSON.parse(await readFile(cacheFile, "utf8")) as { bundle: { sequence: number } }).bundle.sequence, 2); const unsignedSequence3 = { ...unsigned, sequence: 3 }; @@ -207,7 +203,65 @@ const recoveredLock = await loadOpenCodeSchemaBundle({ 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"); -assert.equal((JSON.parse(await readFile(cacheFile, "utf8")) as { bundle: { sequence: number } }).bundle.sequence, 3); +assert.equal(recoveredLock.bundle.sequence, 2, "a stale verified cache remains available while its refresh runs"); +await new Promise((resolve) => setTimeout(resolve, 50)); +assert.equal((JSON.parse(await readFile(cacheFile, "utf8")) as { bundle: { sequence: number } }).bundle.sequence, 3, "a stale writer lock cannot permanently block cache recovery after a crash"); + +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; +const delayedResponse = new Promise((resolve) => { releaseRefresh = resolve; }); +const concurrentSource = { + cacheFile: concurrentCacheFile, + publicKey: publicPem, + url: "https://registry.invalid/opencode.json", + now: () => now + 7 * 60 * 60 * 1000, + fetch: async () => { + fetchCalls += 1; + await delayedResponse; + return new Response(JSON.stringify(signedSequence3), { status: 200 }); + }, +}; +const [staleA, staleB] = await Promise.all([ + loadOpenCodeSchemaBundle(concurrentSource), + loadOpenCodeSchemaBundle(concurrentSource), +]); +assert.equal(fetchCalls, 1, "concurrent stale readers coalesce to one registry request"); +assert.equal(staleA.bundle.sequence, 2); +assert.equal(staleB.bundle.sequence, 2, "stale readers do not block chat on the registry refresh"); +releaseRefresh?.(); +await new Promise((resolve) => setTimeout(resolve, 50)); +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 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-only cache cannot silently select an older built-in JSON 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"); console.log("opencode-compatibility.test.ts: ok"); diff --git a/src/lib/opencode-compatibility.ts b/src/lib/opencode-compatibility.ts index c46872884..dcf68d0a9 100644 --- a/src/lib/opencode-compatibility.ts +++ b/src/lib/opencode-compatibility.ts @@ -18,12 +18,33 @@ export type OpenCodeEventSchema = { /** 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[]; 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: Array<"part" | "data" | "payload" | "root">; + sessionId: string[]; + id: string[]; + name: string[]; + text: string[]; + state: string[]; + input: string[]; + output: string[]; + error: string[]; + status: string[]; + terminalStates: string[]; + errorStates: string[]; + }; }; export type OpenCodeSchemaBundle = { @@ -51,6 +72,12 @@ export type OpenCodeCompatibilityDiagnostic = | "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 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. */ @@ -83,6 +110,9 @@ 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 REFRESH_FAILURE_BACKOFF_MS = 60_000; +const refreshFlights = new Map>(); +const refreshRetryAt = new Map(); /** * Shipped schemas remain usable offline. Selection is capability-based: a @@ -100,12 +130,21 @@ export const BUILTIN_OPENCODE_SCHEMA_BUNDLE: OpenCodeSchemaBundle = { id: "opencode-run-json-v1", requires: { json: true, session: true, protocol: "json" }, eventTypes: { + ignored: ["step_start", "step_finish", "reasoning"], text: ["text"], toolStart: ["tool_start", "tool"], toolEnd: ["tool_result"], toolComplete: ["tool_use", "tool"], error: ["error"], }, + shape: { + envelope: ["part", "data", "root"], + 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"], + }, }, { // Earlier and preview clients used generic tool envelopes. Keeping this @@ -113,12 +152,21 @@ export const BUILTIN_OPENCODE_SCHEMA_BUNDLE: OpenCodeSchemaBundle = { id: "opencode-run-json-legacy", requires: { json: true, session: false, protocol: "json" }, eventTypes: { + ignored: ["step_start", "step_finish", "reasoning"], text: ["message", "assistant_text"], toolStart: ["tool"], toolEnd: ["tool_output"], toolComplete: ["tool_call"], error: ["error", "failed"], }, + shape: { + envelope: ["part", "data", "root"], + 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"], + }, }, ], }; @@ -127,18 +175,34 @@ 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 hasValidShape(value: unknown): boolean { + if (!isRecord(value)) return false; + const aliasKeys = ["sessionId", "id", "name", "text", "state", "input", "output", "error", "status", "terminalStates", "errorStates"]; + if (!Object.keys(value).every((key) => key === "envelope" || aliasKeys.includes(key))) return false; + if (!Array.isArray(value.envelope) || value.envelope.length === 0 || value.envelope.length > 4 || !value.envelope.includes("root") || !value.envelope.every((field) => field === "part" || field === "data" || field === "payload" || field === "root")) return false; + return aliasKeys.every((key) => hasBoundedAliases(value[key])); +} + 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 (!hasValidShape(value.shape)) 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 eventKeys: Array = ["text", "toolStart", "toolEnd", "toolComplete", "error"]; + const eventKeys: 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"]; if (Object.keys(eventTypes).length !== eventKeys.length || !eventKeys.every((key) => Array.isArray(eventTypes[key]) && eventTypes[key].length > 0 && eventTypes[key].length <= 32 && eventTypes[key].every((type: unknown) => typeof type === "string" && type.length > 0 && type.length <= 80))) return false; const nonToolLabels = new Set(); - for (const key of ["text", "error", "toolEnd"] as const) { + for (const key of ["ignored", "text", "error", "toolEnd"] as const) { for (const label of eventTypes[key] as unknown[]) { if (nonToolLabels.has(label as string)) return false; nonToolLabels.add(label as string); @@ -183,12 +247,24 @@ export function selectOpenCodeSchema( return mostSpecific.length === 1 ? mostSpecific[0] : null; } -export function isOpenCodeSchemaBundle(value: unknown, now = Date.now()): value is OpenCodeSchemaBundle { +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; +} + +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 (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; - const issuedAt = Date.parse(String(value.issuedAt)); - const expiresAt = Date.parse(String(value.expiresAt)); - if (!Number.isFinite(issuedAt) || !Number.isFinite(expiresAt) || issuedAt > now || expiresAt <= now) 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. @@ -215,8 +291,13 @@ export function openCodeSchemaBundleSigningPayload(bundle: OpenCodeSchemaBundle) return stableJson(unsigned); } -export function verifyOpenCodeSchemaBundle(bundle: unknown, publicKey: string, now = Date.now()): bundle is OpenCodeSchemaBundle { - if (!isOpenCodeSchemaBundle(bundle, now) || !bundle.signature || bundle.signature.algorithm !== "ed25519") return false; +export function verifyOpenCodeSchemaBundle( + bundle: unknown, + publicKey: string, + 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. @@ -238,7 +319,13 @@ function cachePath(): string { return path.join(caveHome(), CACHE_FILE); } -async function readVerifiedCache(file: string, publicKey: string, now: number): Promise { +/** + * 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 readCachedTrustState(file: string, publicKey: string, now: number): Promise { try { const raw = await readFile(file, "utf8"); if (Buffer.byteLength(raw, "utf8") > MAX_SCHEMA_BUNDLE_BYTES) return null; @@ -247,10 +334,7 @@ async function readVerifiedCache(file: string, publicKey: string, now: number): !isRecord(cached) || typeof cached.checkedAt !== "number" || !Number.isFinite(cached.checkedAt) - // 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) + || !verifyOpenCodeSchemaBundle(cached.bundle, publicKey, now, { allowExpired: true }) ) return null; return cached; } catch { @@ -313,7 +397,7 @@ async function staleLockCanBeReclaimed(lock: string): Promise { try { const info = await stat(lock); if (Date.now() - info.mtimeMs < CACHE_LOCK_STALE_MS) return false; - const owner = Number((await readFile(lock, "utf8")).trim()); + const owner = Number((await readFile(lock, "utf8")).trim().split(":", 1)[0]); if (!Number.isSafeInteger(owner) || owner < 1) return true; try { process.kill(owner, 0); @@ -327,14 +411,39 @@ async function staleLockCanBeReclaimed(lock: string): Promise { } } +async function readVerifiedCache(file: string, publicKey: string, now: number): Promise { + const cached = await readCachedTrustState(file, publicKey, now); + 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 readFile(lock, "utf8")) !== ownerToken) return; + await rm(lock, { force: true }); + } catch { + // A concurrent stale-lock recovery or shutdown already cleaned it up. + } +} + async function withCacheWriteLock(file: string, callback: () => 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 open(lock, "wx", 0o600); try { - await acquired.writeFile(String(process.pid)); + await acquired.writeFile(ownerToken); } catch (error) { await acquired.close().catch(() => undefined); await rm(lock, { force: true }).catch(() => undefined); @@ -360,7 +469,7 @@ async function withCacheWriteLock(file: string, callback: () => Promise): return await callback(); } finally { await handle.close().catch(() => undefined); - await rm(lock, { force: true }).catch(() => undefined); + await releaseCacheLock(lock, ownerToken); } } @@ -372,63 +481,114 @@ export type OpenCodeSchemaBundleSource = { cacheFile?: string; }; +// Release builds inject these public values at compile time. Server-side +// overrides retain local test and operator support without changing the +// packaged trust anchor. +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; + +function schemaRefreshKey(file: string, url: string, publicKey: string): string { + return createHash("sha256").update(`${file}\0${url}\0${publicKey}`).digest("hex"); +} + +async function refreshOpenCodeSchemaBundle( + file: string, + url: string, + publicKey: string, + fetcher: typeof fetch, + now: number, +): Promise { + const response = await fetchSchemaBundle(url, fetcher); + const raw = await readResponseTextLimited(response); + if (!response.ok) throw new Error("untrusted schema bundle"); + const remote = JSON.parse(raw) as unknown; + if (!verifyOpenCodeSchemaBundle(remote, publicKey, now)) throw new Error("invalid schema signature"); + const cachedTrust = await readCachedTrustState(file, publicKey, now); + if (cachedTrust && remote.sequence < cachedTrust.bundle.sequence) throw new Error("schema rollback"); + await mkdir(path.dirname(file), { recursive: true }); + const writeResult = await withCacheWriteLock(file, async () => { + // 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, publicKey, now); + 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 writeJsonAtomic(file, { checkedAt: now, bundle: remote }); + return { bundle: remote, source: "remote" as const }; + } + await writeJsonAtomic(file, { checkedAt: now, bundle: remote }); + 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 readVerifiedCache(file, publicKey, now); + if (current && current.bundle.sequence >= remote.sequence) { + return { bundle: current.bundle, source: "cache" }; + } + return { bundle: remote, source: "remote" }; +} + +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 opportunistically refresh it. 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. This makes network loss and unsafe rollbacks non-events. */ -export async function loadOpenCodeSchemaBundle(source: OpenCodeSchemaBundleSource = {}): Promise<{ - bundle: OpenCodeSchemaBundle; - source: "built-in" | "cache" | "remote"; - diagnostic?: "schema-registry-refresh-rejected" | "cached-schema-unavailable"; -}> { +export async function loadOpenCodeSchemaBundle(source: OpenCodeSchemaBundleSource = {}): Promise { const now = source.now?.() ?? Date.now(); const file = source.cacheFile ?? cachePath(); - const url = source.url ?? process.env.COVEN_OPENCODE_SCHEMA_REGISTRY_URL; - const publicKey = source.publicKey ?? process.env.COVEN_OPENCODE_SCHEMA_REGISTRY_PUBLIC_KEY; + const url = source.url ?? process.env.COVEN_OPENCODE_SCHEMA_REGISTRY_URL ?? PACKAGED_REGISTRY_URL; + const publicKey = source.publicKey ?? process.env.COVEN_OPENCODE_SCHEMA_REGISTRY_PUBLIC_KEY ?? PACKAGED_REGISTRY_PUBLIC_KEY; if (!url || !publicKey) return { bundle: BUILTIN_OPENCODE_SCHEMA_BUNDLE, source: "built-in" }; const cached = await readVerifiedCache(file, publicKey, now); const cacheFresh = cached && now - cached.checkedAt < CACHE_TTL_MS; if (cacheFresh) return { bundle: cached.bundle, source: "cache" }; - + const key = schemaRefreshKey(file, url, publicKey); + if ((refreshRetryAt.get(key) ?? 0) > now) { + return cached + ? { bundle: cached.bundle, source: "cache", diagnostic: "schema-registry-refresh-rejected" } + : { bundle: BUILTIN_OPENCODE_SCHEMA_BUNDLE, source: "built-in", diagnostic: "cached-schema-unavailable" }; + } + const refresh = startSchemaRefresh( + key, + () => refreshOpenCodeSchemaBundle(file, url, publicKey, source.fetch ?? fetch, now), + now, + ); + if (cached) { + // A stale but verified parser is safer than delaying a chat behind a + // registry outage. One in-flight refresh updates it for later turns. + void refresh.catch(() => undefined); + return { bundle: cached.bundle, source: "cache" }; + } try { - const response = await fetchSchemaBundle(url, source.fetch ?? fetch); - const raw = await readResponseTextLimited(response); - if (!response.ok) throw new Error("untrusted schema bundle"); - const remote = JSON.parse(raw) as unknown; - if (!verifyOpenCodeSchemaBundle(remote, publicKey, now)) throw new Error("invalid schema signature"); - if (cached && remote.sequence < cached.bundle.sequence) throw new Error("schema rollback"); - // A sequence identifies immutable parser semantics. A changed payload at - // the same sequence is indistinguishable from a rollback to callers, so - // retain the last-known-good cache even if it is freshly signed. - await mkdir(path.dirname(file), { recursive: true }); - const writeResult = await withCacheWriteLock(file, async () => { - // 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 current = await readVerifiedCache(file, publicKey, now); - if (current && remote.sequence < current.bundle.sequence) throw new Error("schema rollback"); - if (current && remote.sequence === current.bundle.sequence) { - if (openCodeSchemaBundleSigningPayload(remote) !== openCodeSchemaBundleSigningPayload(current.bundle)) throw new Error("schema sequence rewritten"); - await writeJsonAtomic(file, { checkedAt: now, bundle: current.bundle }); - return { bundle: current.bundle, source: "cache" as const }; - } - await writeJsonAtomic(file, { checkedAt: now, bundle: remote }); - 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 readVerifiedCache(file, publicKey, now); - if (current && current.bundle.sequence >= remote.sequence) { - return { bundle: current.bundle, source: "cache" }; - } - // The remote was independently verified and no newer cache exists. - return { bundle: remote, source: "remote" }; + return await refresh; } catch { - if (cached) return { bundle: cached.bundle, source: "cache", diagnostic: "schema-registry-refresh-rejected" }; return { bundle: BUILTIN_OPENCODE_SCHEMA_BUNDLE, source: "built-in", diagnostic: "cached-schema-unavailable" }; } } diff --git a/src/lib/opencode-stream.test.ts b/src/lib/opencode-stream.test.ts index 8c29583c7..cbbdc3005 100644 --- a/src/lib/opencode-stream.test.ts +++ b/src/lib/opencode-stream.test.ts @@ -7,6 +7,48 @@ assert.deepEqual( parseOpenCodeRunEvent({ type: "text", sessionID: "ses_123", part: { text: "Hello" } }), { kind: "text", sessionId: "ses_123", text: "Hello" }, ); +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_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", sessionId: "ses_123" }, + "current OpenCode reasoning frames are lifecycle metadata and never leak as assistant text", +); +assert.deepEqual( + parseOpenCodeRunEvent( + { type: "reply", session: "ses_mapped", payload: { 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"], + 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 }, @@ -127,4 +169,29 @@ assert.deepEqual( { 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 shapedSchema = { + ...BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0], + id: "opencode-run-json-shaped-v2", + shape: { + envelope: ["payload"], + 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( + { type: "tool", session: "ses_v2", payload: { 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 can map bounded future envelope fields and terminal states", +); console.log("opencode-stream.test.ts: ok"); diff --git a/src/lib/opencode-stream.ts b/src/lib/opencode-stream.ts index ecc1da26d..f23f166eb 100644 --- a/src/lib/opencode-stream.ts +++ b/src/lib/opencode-stream.ts @@ -1,6 +1,7 @@ import type { OpenCodeEventSchema } from "@/lib/opencode-compatibility"; export type OpenCodeRunEvent = + | { kind: "ignore"; sessionId?: string } | { kind: "text"; sessionId?: string; text: string } | { kind: "tool_start"; sessionId?: string; id: string; name: string; input: unknown } | { kind: "tool_end"; sessionId?: string; id: string; output: unknown; isError: boolean } @@ -19,22 +20,56 @@ function stringAt(recordValue: Record | null, ...keys: string[] return undefined; } +type ShapeAlias = Exclude, "envelope">; + +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, schema: OpenCodeEventSchema | undefined): Record | null { + const envelopes = schema?.shape?.envelope ?? ["part", "data", "root"]; + for (const field of envelopes) { + if (field === "root") return event; + const candidate = record(event[field]); + if (candidate) return candidate; + } + return null; +} + function eventTypes(schema: OpenCodeEventSchema | undefined, kind: keyof OpenCodeEventSchema["eventTypes"], defaults: string[]): string[] { - return schema?.eventTypes[kind]?.length ? schema.eventTypes[kind] : defaults; + // Selected signed schemas are authoritative: the validator rejects empty + // mappings, so falling back here could revive a label that a registry + // deliberately retired. Defaults are only for legacy callers with no schema. + return schema ? schema.eventTypes[kind] : defaults; } -function toolId(part: Record | null): string | null { - return stringAt(part, "id", "callID", "callId", "toolCallId", "tool_call_id") ?? null; +function toolId(part: Record | null, schema?: OpenCodeEventSchema): string | null { + return stringAt(part, ...shapeAliases(schema, "id", ["id", "callID", "callId", "toolCallId", "tool_call_id"])) ?? null; } -function terminalToolState(state: Record | null): boolean { - const status = stringAt(state, "status")?.toLowerCase(); - return status === "completed" || status === "complete" || status === "error" || status === "failed" || status === "cancelled" || status === "canceled" || status === "aborted" || status === "interrupted" || status === "timeout" || status === "timed_out"; +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 toolStateIsError(state: Record | null, part?: Record | null): boolean { - const status = stringAt(state, "status")?.toLowerCase(); - return status === "error" || status === "failed" || status === "cancelled" || status === "canceled" || status === "aborted" || status === "interrupted" || status === "timeout" || status === "timed_out" || Boolean(state?.error) || Boolean(part?.error); +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"]; + return errorStates.some((error) => error.toLowerCase() === status) + || valueAt(state, shapeAliases(schema, "error", ["error"])) !== undefined + || valueAt(part, shapeAliases(schema, "error", ["error"])) !== undefined; } /** Decode OpenCode's `run --format json` envelope without trusting its fields. */ @@ -43,7 +78,10 @@ export function parseOpenCodeRunEvent(value: unknown, schema?: OpenCodeEventSche if (!event || typeof event.type !== "string") { return { kind: "other", diagnostic: "malformed-event" }; } - const sessionId = stringAt(event, "sessionID", "sessionId", "session_id"); + const sessionId = stringAt(event, ...shapeAliases(schema, "sessionId", ["sessionID", "sessionId", "session_id"])); + if (eventTypes(schema, "ignored", ["step_start", "step_finish"]).includes(event.type)) { + return { kind: "ignore", sessionId }; + } if (eventTypes(schema, "error", ["error"]).includes(event.type)) { const error = record(event.error); const errorData = record(error?.data); @@ -60,37 +98,43 @@ export function parseOpenCodeRunEvent(value: unknown, schema?: OpenCodeEventSche // 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 part = record(event.part) ?? record(event.data) ?? event; - const text = stringAt(part, "text", "content") ?? stringAt(event, "text"); + const part = envelope(event, schema) ?? event; + const textAliases = shapeAliases(schema, "text", ["text", "content"]); + const text = stringAt(part, ...textAliases) ?? stringAt(event, ...textAliases); if (eventTypes(schema, "text", ["text"]).includes(event.type) && text !== undefined) { return { kind: "text", sessionId, text }; } - const state = record(part?.state) ?? record(event.state); - const id = toolId(part); + const stateAliases = shapeAliases(schema, "state", ["state"]); + const state = record(valueAt(part, stateAliases)) ?? record(valueAt(event, stateAliases)); + const id = toolId(part, schema); const toolStartTypes = eventTypes(schema, "toolStart", ["tool_start"]); const toolEndTypes = eventTypes(schema, "toolEnd", ["tool_result"]); const toolCompleteTypes = eventTypes(schema, "toolComplete", ["tool_use"]); - if (toolStartTypes.includes(event.type) && id && !terminalToolState(state)) { - return { kind: "tool_start", sessionId, id, name: stringAt(part, "tool", "name") ?? "tool", input: state?.input ?? part?.input ?? {} }; + if (toolStartTypes.includes(event.type) && id && !terminalToolState(state, part, schema)) { + return { kind: "tool_start", sessionId, id, name: stringAt(part, ...shapeAliases(schema, "name", ["tool", "name"])) ?? "tool", input: valueAt(state, shapeAliases(schema, "input", ["input"])) ?? valueAt(part, shapeAliases(schema, "input", ["input"])) ?? {} }; } if (toolEndTypes.includes(event.type) && id) { - return { kind: "tool_end", sessionId, id, output: state?.output ?? state?.error ?? part?.output ?? part?.error ?? "", isError: toolStateIsError(state, part) }; + 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(part, output) ?? valueAt(part, error) ?? "", isError: toolStateIsError(state, part, schema) }; } if (toolCompleteTypes.includes(event.type) && id && part) { // Legacy `tool_use` frames were terminal snapshots and some omit a // status entirely. Their output/error is the durable terminal signal. - const hasTerminalPayload = state?.output !== undefined || state?.error !== undefined || part?.output !== undefined; - if (!terminalToolState(state) && !hasTerminalPayload) { - return { kind: "tool_start", sessionId, id, name: stringAt(part, "tool", "name") ?? "tool", input: state?.input ?? part?.input ?? {} }; + const output = shapeAliases(schema, "output", ["output"]); + const error = shapeAliases(schema, "error", ["error"]); + const hasTerminalPayload = valueAt(state, output) !== undefined || valueAt(state, error) !== undefined || valueAt(part, output) !== undefined || valueAt(part, error) !== undefined; + if (!terminalToolState(state, part, schema) && !hasTerminalPayload) { + return { kind: "tool_start", sessionId, id, name: stringAt(part, ...shapeAliases(schema, "name", ["tool", "name"])) ?? "tool", input: valueAt(state, shapeAliases(schema, "input", ["input"])) ?? valueAt(part, shapeAliases(schema, "input", ["input"])) ?? {} }; } return { kind: "tool", sessionId, id, - name: stringAt(part, "tool", "name") ?? "tool", - input: state?.input ?? {}, - output: state?.output ?? state?.error ?? part?.output ?? part?.error ?? "", - isError: toolStateIsError(state, part), + name: stringAt(part, ...shapeAliases(schema, "name", ["tool", "name"])) ?? "tool", + input: valueAt(state, shapeAliases(schema, "input", ["input"])) ?? valueAt(part, shapeAliases(schema, "input", ["input"])) ?? {}, + output: valueAt(state, output) ?? valueAt(state, error) ?? valueAt(part, output) ?? valueAt(part, error) ?? "", + isError: toolStateIsError(state, part, schema), }; } const knownType = [ From 6faa4f20203e8d249981919ae5ff8ebfeeb9a423 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Fri, 24 Jul 2026 23:15:04 -0400 Subject: [PATCH 017/112] chore(release): require OpenCode schema registry --- .github/workflows/release.yml | 14 +++++++++++ docs/opencode-compatibility-registry.md | 21 ++++++++++++++++ scripts/check-opencode-registry-release.mjs | 24 +++++++++++++++++++ .../check-opencode-registry-release.test.mjs | 13 ++++++++++ scripts/run-tests.mjs | 1 + src/lib/chat-tool-events.test.ts | 6 ++++- 6 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 docs/opencode-compatibility-registry.md create mode 100644 scripts/check-opencode-registry-release.mjs create mode 100644 scripts/check-opencode-registry-release.test.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c52578b45..83be93d6f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -232,6 +232,20 @@ 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 }} + 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..8dfd51eb0 --- /dev/null +++ b/docs/opencode-compatibility-registry.md @@ -0,0 +1,21 @@ +# 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. + +The release workflow maps these values to `NEXT_PUBLIC_COVEN_OPENCODE_SCHEMA_REGISTRY_URL` and `NEXT_PUBLIC_COVEN_OPENCODE_SCHEMA_REGISTRY_PUBLIC_KEY`, 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 either value is missing, non-HTTPS, or not an Ed25519 key. + +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. + +To rotate a key, publish a Cave release carrying the new public key before publishing bundles signed only by that key. Keep the prior key available to the registry publisher until clients on the preceding Cave release have a supported migration path; an emergency rotation requires a Cave release first. Record the registry endpoint, public-key fingerprint, owner, and rotation date in the release checklist. + diff --git a/scripts/check-opencode-registry-release.mjs b/scripts/check-opencode-registry-release.mjs new file mode 100644 index 000000000..8b03ebe26 --- /dev/null +++ b/scripts/check-opencode-registry-release.mjs @@ -0,0 +1,24 @@ +// 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; + +function fail(message) { + console.error(`::error::${message}`); + process.exitCode = 1; +} + +if (!url || !publicKey) { + fail("OpenCode compatibility registry URL and Ed25519 public key must be configured for every desktop release."); +} else { + try { + if (new URL(url).protocol !== "https:") throw new Error("registry URL must use HTTPS"); + if (createPublicKey(publicKey).asymmetricKeyType !== "ed25519") throw new Error("registry key must be Ed25519"); + } 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..8c7abdfa1 --- /dev/null +++ b/scripts/check-opencode-registry-release.test.mjs @@ -0,0 +1,13 @@ +import assert from "node:assert/strict"; +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(guard, /new URL\(url\)\.protocol !== "https:"/); +assert.match(guard, /asymmetricKeyType !== "ed25519"/); +assert.match(docs, /rotation/i); +assert.match(docs, /built-in profile/i); +console.log("check-opencode-registry-release.test.mjs: ok"); diff --git a/scripts/run-tests.mjs b/scripts/run-tests.mjs index f0cd01f49..8497dc917 100644 --- a/scripts/run-tests.mjs +++ b/scripts/run-tests.mjs @@ -101,6 +101,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", diff --git a/src/lib/chat-tool-events.test.ts b/src/lib/chat-tool-events.test.ts index 3807aacd0..cd1b382a4 100644 --- a/src/lib/chat-tool-events.test.ts +++ b/src/lib/chat-tool-events.test.ts @@ -1,6 +1,6 @@ // @ts-nocheck import assert from "node:assert/strict"; -import { ToolCallTracker, toPersistedTools } from "./chat-tool-events.ts"; +import { 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); @@ -27,5 +27,9 @@ 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", +); console.log("chat-tool-events.test.ts: ok"); From bd60baff1691a519a73ccf07cf7936b83e84fee2 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Fri, 24 Jul 2026 23:19:09 -0400 Subject: [PATCH 018/112] style(release): clean registry gate artifacts --- docs/opencode-compatibility-registry.md | 1 - scripts/check-opencode-registry-release.mjs | 2 -- 2 files changed, 3 deletions(-) diff --git a/docs/opencode-compatibility-registry.md b/docs/opencode-compatibility-registry.md index 8dfd51eb0..017b6456a 100644 --- a/docs/opencode-compatibility-registry.md +++ b/docs/opencode-compatibility-registry.md @@ -18,4 +18,3 @@ Development and test processes may inject `COVEN_OPENCODE_SCHEMA_REGISTRY_URL` a 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. To rotate a key, publish a Cave release carrying the new public key before publishing bundles signed only by that key. Keep the prior key available to the registry publisher until clients on the preceding Cave release have a supported migration path; an emergency rotation requires a Cave release first. Record the registry endpoint, public-key fingerprint, owner, and rotation date in the release checklist. - diff --git a/scripts/check-opencode-registry-release.mjs b/scripts/check-opencode-registry-release.mjs index 8b03ebe26..5162bcf7d 100644 --- a/scripts/check-opencode-registry-release.mjs +++ b/scripts/check-opencode-registry-release.mjs @@ -10,7 +10,6 @@ function fail(message) { console.error(`::error::${message}`); process.exitCode = 1; } - if (!url || !publicKey) { fail("OpenCode compatibility registry URL and Ed25519 public key must be configured for every desktop release."); } else { @@ -21,4 +20,3 @@ if (!url || !publicKey) { fail(`Invalid OpenCode compatibility registry configuration: ${error instanceof Error ? error.message : "unknown error"}`); } } - From 44f0ef7b303462d0fdf516e586bfc7e18a989c85 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Fri, 24 Jul 2026 23:43:48 -0400 Subject: [PATCH 019/112] fix(chat): harden OpenCode registry recovery --- .../api/chat/send/chat-send-capabilities.ts | 32 ++++- .../send/harness-routing-host-session.test.ts | 4 +- .../send/harness-routing-opencode.test.ts | 20 +-- src/app/api/chat/send/route.ts | 65 +++++---- src/lib/opencode-compatibility.test.ts | 44 ++++++- src/lib/opencode-compatibility.ts | 124 ++++++++++++++---- src/lib/opencode-stream.test.ts | 40 +++++- src/lib/opencode-stream.ts | 66 +++++++++- 8 files changed, 309 insertions(+), 86 deletions(-) diff --git a/src/app/api/chat/send/chat-send-capabilities.ts b/src/app/api/chat/send/chat-send-capabilities.ts index 90ce1af86..f32dde74d 100644 --- a/src/app/api/chat/send/chat-send-capabilities.ts +++ b/src/app/api/chat/send/chat-send-capabilities.ts @@ -100,18 +100,34 @@ function probeOutput(command: string, args: string[], env = harnessSpawnEnv(), i }); } -function hasRunOption(help: string, flag: "--model" | "--session"): boolean { +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: "--format" | "--output"): 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] ?? ""; +} + +function advertisedStructuredOutputs(help: string): Array<{ option: "--format" | "--output"; values: string[] }> { + return (["--format", "--output"] as const).flatMap((option) => { + const stanza = optionStanza(help, option); + const values = [...new Set((stanza.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 advertisedFormatProtocols(help: string): string[] { - const stanza = help.match(/^\s*(?:-[A-Za-z],?\s+)?--format\b[^\n]*(?:\n(?!\s*(?:-[A-Za-z],?\s+)?--)[^\n]*){0,2}/im)?.[0] ?? ""; + const outputs = advertisedStructuredOutputs(help); // A protocol marker is useful only when the CLI advertises it as an output // format. Do not derive it from version strings or arbitrary help prose. - return [...new Set((stanza.match(/\bjson(?:[._-][a-z0-9]+)*\b/gi) ?? []).map((value) => value.toLowerCase()))]; + return [...new Set(outputs.flatMap((output) => output.values))]; } /** Capability probes are cached because old Coven CLIs reject unknown flags. */ @@ -186,8 +202,10 @@ export function openCodeRunCapabilities(): Promise { // Partial, timed-out, non-zero, or oversized help is never capability // evidence. Probe again after the short TTL instead of risking an argv // that the installed client does not accept. - if (!helpProbe.complete) return { version, json: false, model: false, session: false, protocols: [] }; + if (!helpProbe.complete) return { version, json: false, model: false, session: false, protocols: [], options: [], structuredOutputs: [] }; const help = helpProbe.output; + const options = declaredRunOptions(help); + const structuredOutputs = advertisedStructuredOutputs(help); const protocols = advertisedFormatProtocols(help); const json = protocols.some((protocol) => protocol === "json" || protocol.startsWith("json-") || protocol.startsWith("json_")); return { @@ -196,13 +214,15 @@ export function openCodeRunCapabilities(): Promise { // stanza. A stray "JSON" in a banner or another option's description // must not make us launch an unsupported `--format json` command. json, - model: hasRunOption(help, "--model"), - session: hasRunOption(help, "--session"), + model: options.includes("--model"), + session: options.includes("--session") || options.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, + structuredOutputs, }; })(); openCodeCapabilitiesProbe = { until: Date.now() + OPENCODE_CAPABILITY_PROBE_TTL_MS, value }; 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 e92262634..35cb4ca8f 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,8 +386,8 @@ 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*\? null\s*:\s*body\.sessionId\s*\? openCodeDirect\s*\? existingConversation\?\.harnessSessionId \?\? null\s*:\s*existingConversation\?\.harnessSessionId \?\? body\.sessionId/, + "OpenCode resumes only a native session token; other harnesses retain the stable conversation-id fallback", ); 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 9c54dc593..62781433a 100644 --- a/src/app/api/chat/send/harness-routing-opencode.test.ts +++ b/src/app/api/chat/send/harness-routing-opencode.test.ts @@ -12,12 +12,12 @@ assert.match( ); assert.match( route, - /const a = \["run"\];[\s\S]*?openCodeCompatibility\?\.mode === "structured"[\s\S]*?a\.push\("--format", openCodeCompatibility\.schema\?\.requires\.protocol \?\? "json"\);[\s\S]*?openCodeCompatibility\?\.capabilities\.session[\s\S]*?a\.push\("--session", resumeSessionId\);/, - "OpenCode uses discovered JSON and session capabilities rather than a version threshold", + /const a = \["run"\];[\s\S]*?openCodeCompatibility\?\.mode === "structured"[\s\S]*?const launch = openCodeCompatibility\.schema!\.launch;[\s\S]*?a\.push\(launch\.structuredOutput\.option, launch\.structuredOutput\.value, \.\.\.launch\.requiredFlags\);[\s\S]*?launch\.sessionOption[\s\S]*?else if \(resumeSessionId && openCodeCompatibility\?\.capabilities\.session\) a\.push\("--session", resumeSessionId\);/, + "OpenCode uses the selected schema's discovered structured-output and session argv contract rather than a version threshold", ); assert.match( route, - /const rawEvent = JSON\.parse\(line\);[\s\S]*?parseOpenCodeRunEvent\(rawEvent, openCodeCompatibility\?\.schema\);[\s\S]*?announceSession\(ev\.sessionId\);/, + /handleOpenCodeJsonLine\(line, openCodeCompatibility\?\.schema, \{[\s\S]*?onSession: \(nativeSessionId\) => \{[\s\S]*?announceSession\(nativeSessionId\);/, "the first structured OpenCode event persists its minted session id", ); assert.match( @@ -62,18 +62,18 @@ assert.match( ); assert.match( route, - /openCodeModelRejected \|\|= modelRejectionInError\(ev\.message\);[\s\S]*?recordStdoutErrorTail\("OpenCode reported an error event", true\)/, + /onError: \(ev\) => \{[\s\S]*?openCodeModelRejected \|\|= modelRejectionInError\(ev\.message\);[\s\S]*?recordStdoutErrorTail\("OpenCode reported an error event", true\)/, "structured OpenCode error payloads are classified but never copied into user-visible diagnostics", ); assert.match( route, - /if \(ev\.kind === "other"\) \{[\s\S]*?unrecognized event; that event was skipped while compatible tool activity continues/, + /onOther: \(ev, rawEvent\) => \{[\s\S]*?unrecognized event; that event was skipped while compatible tool activity continues/, "an unknown OpenCode envelope is skipped without suppressing later compatible tool activity", ); assert.match( route, - /parseOpenCodeRunEvent\(rawEvent, openCodeCompatibility\?\.schema\);[\s\S]*?if \(ev\.kind === "ignore"\) return;[\s\S]*?if \(ev\.kind === "text"\)/, - "documented non-renderable OpenCode lifecycle frames are ignored before tool activity is evaluated", + /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, @@ -82,7 +82,7 @@ assert.match( ); assert.match( route, - /ev\.kind === "error"[\s\S]*?openCodeModelRejected \|\|= modelRejectionInError\(ev\.message\);[\s\S]*?recordStdoutErrorTail\("OpenCode reported an error event", true\)/, + /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( @@ -107,7 +107,7 @@ assert.match( ); assert.match( route, - /ev\.kind === "tool_start"[\s\S]*?envelopeToolUse[\s\S]*?ev\.kind === "tool_end"[\s\S]*?envelopeToolResult/, + /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( @@ -122,7 +122,7 @@ assert.match( ); assert.match( route, - /const handleOpenCodeLine[\s\S]*?catch \{[\s\S]*?recordStdoutErrorTail\("OpenCode emitted a malformed JSON event", true\)/, + /const handleOpenCodeLine[\s\S]*?onMalformedJson: \(\) => \{[\s\S]*?recordStdoutErrorTail\("OpenCode emitted a malformed JSON event", true\)/, "malformed structured OpenCode events never copy their raw payload into diagnostics", ); assert.match( diff --git a/src/app/api/chat/send/route.ts b/src/app/api/chat/send/route.ts index 82d688b79..4df6d2981 100644 --- a/src/app/api/chat/send/route.ts +++ b/src/app/api/chat/send/route.ts @@ -67,7 +67,7 @@ import { redactedOpenCodeEventFingerprint, resolveOpenCodeCompatibility, } from "@/lib/opencode-compatibility"; -import { parseOpenCodeRunEvent } from "@/lib/opencode-stream"; +import { handleOpenCodeJsonLine } from "@/lib/opencode-stream"; import { buildPromptWithCovenIdentityCanon } from "@/lib/coven-identity-canon"; import { buildPromptWithKnowledgeVault, @@ -1366,9 +1366,11 @@ export async function POST(req: Request) { // an unsupported flag and losing the whole reply. const a = ["run"]; if (openCodeCompatibility?.mode === "structured") { - a.push("--format", openCodeCompatibility.schema?.requires.protocol ?? "json"); + const launch = openCodeCompatibility.schema!.launch; + a.push(launch.structuredOutput.option, launch.structuredOutput.value, ...launch.requiredFlags); + if (resumeSessionId && launch.sessionOption) a.push(launch.sessionOption, resumeSessionId); } - if (resumeSessionId && openCodeCompatibility?.capabilities.session) a.push("--session", resumeSessionId); + else if (resumeSessionId && openCodeCompatibility?.capabilities.session) a.push("--session", resumeSessionId); if (forwardModel) a.push("--model", forwardModel); a.push(prompt); return a; @@ -1810,18 +1812,16 @@ export async function POST(req: Request) { push({ kind: "assistant_chunk", text }); return; } - try { - const rawEvent = JSON.parse(line); - const ev = parseOpenCodeRunEvent(rawEvent, openCodeCompatibility?.schema); - if (ev.sessionId && !sessionId) announceSession(ev.sessionId); - if (ev.kind === "ignore") return; - if (ev.kind === "text") { + handleOpenCodeJsonLine(line, openCodeCompatibility?.schema, { + onSession: (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, @@ -1836,9 +1836,8 @@ export async function POST(req: Request) { ev.isError, ); if (ended) push({ kind: "tool_use", ...ended }); - return; - } - if (ev.kind === "tool_start") { + }, + onToolStart: (ev) => { boundarySentinel?.observe(ev.name, ev.input); const started = toolTracker.envelopeToolUse( ev.id, @@ -1849,18 +1848,16 @@ export async function POST(req: Request) { if (started) push({ kind: "tool_use", ...started }); const reorderedEnd = toolTracker.consumePendingEnvelopeResult(ev.id); if (reorderedEnd) push({ kind: "tool_use", ...reorderedEnd }); - return; - } - if (ev.kind === "tool_end") { + }, + 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 }); - return; - } - if (ev.kind === "error") { + }, + 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, @@ -1870,9 +1867,8 @@ export async function POST(req: Request) { openCodeModelRejected ||= modelRejectionInError(ev.message); recordStdoutErrorTail("OpenCode reported an error event", true); result = { ...result, is_error: true }; - return; - } - if (ev.kind === "other") { + }, + 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. @@ -1885,8 +1881,8 @@ export async function POST(req: Request) { `${ev.diagnostic ?? "unknown-event"}:${redactedOpenCodeEventFingerprint(rawEvent)}`, ); } - } - } catch { + }, + onMalformedJson: () => { // Structured-mode stdout can contain a malformed future event with // arbitrary tool payloads. Do not feed that raw line into the // persisted error tail or any user-visible diagnostic. @@ -1900,7 +1896,8 @@ export async function POST(req: Request) { "malformed-json-event", ); } - } + }, + }); }; const handleLine = (rawLine: string) => { @@ -1916,8 +1913,10 @@ export async function POST(req: Request) { : openCodeCompatibility.diagnostic === "no-compatible-schema" ? "This OpenCode client has no verified tool-event schema; continuing without tool activity" : openCodeCompatibility.diagnostic === "cached-schema-unavailable" - ? "OpenCode's compatibility registry is unavailable; continuing without tool activity" - : "OpenCode schema refresh was not trusted; using the last known compatible parser"; + ? "OpenCode's compatibility registry is unavailable; using the shipped compatible parser" + : 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 (RESUME_ERR_RE.test(line)) resumeFailed = true; @@ -2276,8 +2275,10 @@ export async function POST(req: Request) { : openCodeCompatibility.diagnostic === "no-compatible-schema" ? "This OpenCode client has no verified tool-event schema; continuing without tool activity" : openCodeCompatibility.diagnostic === "cached-schema-unavailable" - ? "OpenCode's compatibility registry is unavailable; continuing without tool activity" - : "OpenCode schema refresh was not trusted; using the last known compatible parser"; + ? "OpenCode's compatibility registry is unavailable; using the shipped compatible parser" + : 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) { @@ -2322,7 +2323,6 @@ export async function POST(req: Request) { jsonBuf = ""; result = {}; toolTracker = new ToolCallTracker(); - openCodeCompatibilityNoticeSent = false; openCodeModelRejected = false; copilotText.reset(); stderrTail.length = 0; @@ -2363,7 +2363,6 @@ export async function POST(req: Request) { jsonBuf = ""; result = {}; toolTracker = new ToolCallTracker(); - openCodeCompatibilityNoticeSent = false; openCodeModelRejected = false; copilotText.reset(); stderrTail.length = 0; diff --git a/src/lib/opencode-compatibility.test.ts b/src/lib/opencode-compatibility.test.ts index 0530d6f96..bab360d79 100644 --- a/src/lib/opencode-compatibility.test.ts +++ b/src/lib/opencode-compatibility.test.ts @@ -86,11 +86,26 @@ const missingSession = await resolveOpenCodeCompatibility({ version: "1.2.3", js assert.equal(missingSession.mode, "structured"); assert.equal(missingSession.schema?.id, "opencode-run-json-legacy", "older compatible schemas coexist without client version gates"); +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 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", 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, @@ -104,6 +119,22 @@ assert.equal( ); assert.equal(isOpenCodeSchemaBundle({ ...unsigned, schemas: [] }, now), false, "empty signed bundles cannot replace a working parser set"); +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, @@ -185,6 +216,17 @@ assert.equal(oversized.source, "cache"); assert.equal(oversized.diagnostic, undefined, "oversized refreshes preserve and immediately serve the verified cache"); assert.equal((JSON.parse(await readFile(cacheFile, "utf8")) as { bundle: { sequence: number } }).bundle.sequence, 2); +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, @@ -245,7 +287,7 @@ const expiredRemoteOnly = await resolveOpenCodeCompatibility( fetch: async () => { throw new Error("offline"); }, }, ); -assert.equal(expiredRemoteOnly.mode, "plain", "an expired remote-only cache cannot silently select an older built-in JSON parser"); +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 } }; diff --git a/src/lib/opencode-compatibility.ts b/src/lib/opencode-compatibility.ts index dcf68d0a9..39f4ed0db 100644 --- a/src/lib/opencode-compatibility.ts +++ b/src/lib/opencode-compatibility.ts @@ -11,6 +11,9 @@ export type OpenCodeRunCapabilities = { 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[]; + structuredOutputs?: Array<{ option: "--format" | "--output"; values: string[] }>; }; export type OpenCodeEventSchema = { @@ -33,6 +36,8 @@ export type OpenCodeEventSchema = { */ shape: { envelope: Array<"part" | "data" | "payload" | "root">; + /** Envelope(s) explicitly trusted to carry assistant text. */ + textEnvelope?: Array<"part" | "data" | "payload" | "root">; sessionId: string[]; id: string[]; name: string[]; @@ -45,6 +50,12 @@ export type OpenCodeEventSchema = { terminalStates: string[]; errorStates: string[]; }; + /** Bounded argv contract, confirmed against the installed client's help. */ + launch: { + structuredOutput: { option: "--format" | "--output"; value: string }; + sessionOption?: "--session" | "--resume"; + requiredFlags: string[]; + }; }; export type OpenCodeSchemaBundle = { @@ -139,12 +150,14 @@ export const BUILTIN_OPENCODE_SCHEMA_BUNDLE: OpenCodeSchemaBundle = { }, shape: { envelope: ["part", "data", "root"], + textEnvelope: ["part", "data"], 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. Keeping this @@ -161,12 +174,14 @@ export const BUILTIN_OPENCODE_SCHEMA_BUNDLE: OpenCodeSchemaBundle = { }, shape: { envelope: ["part", "data", "root"], + textEnvelope: ["part", "data"], 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" }, requiredFlags: [] }, }, ], }; @@ -185,14 +200,33 @@ function hasBoundedAliases(value: unknown): value is string[] { function hasValidShape(value: unknown): boolean { if (!isRecord(value)) return false; const aliasKeys = ["sessionId", "id", "name", "text", "state", "input", "output", "error", "status", "terminalStates", "errorStates"]; - if (!Object.keys(value).every((key) => key === "envelope" || aliasKeys.includes(key))) return false; - if (!Array.isArray(value.envelope) || value.envelope.length === 0 || value.envelope.length > 4 || !value.envelope.includes("root") || !value.envelope.every((field) => field === "part" || field === "data" || field === "payload" || field === "root")) return false; + const envelopeFields = (fields: unknown, requireRoot: boolean): fields is Array<"part" | "data" | "payload" | "root"> => + Array.isArray(fields) + && fields.length > 0 + && fields.length <= 4 + && (!requireRoot || fields.includes("root")) + && fields.every((field) => field === "part" || field === "data" || field === "payload" || field === "root"); + if (!Object.keys(value).every((key) => key === "envelope" || key === "textEnvelope" || aliasKeys.includes(key))) return false; + if (!envelopeFields(value.envelope, true) || (value.textEnvelope !== undefined && !envelopeFields(value.textEnvelope, false))) return false; return aliasKeys.every((key) => hasBoundedAliases(value[key])); } +function hasValidLaunch(value: unknown, requires: Record): boolean { + if (!isRecord(value) || !Array.isArray(value.requiredFlags)) return false; + const structuredOutput = value.structuredOutput; + if (!isRecord(structuredOutput)) return false; + if (structuredOutput.option !== "--format" && structuredOutput.option !== "--output") return false; + if (typeof structuredOutput.value !== "string" || !/^[a-z0-9][a-z0-9._-]{0,63}$/i.test(structuredOutput.value)) return false; + if (structuredOutput.value !== requires.protocol && structuredOutput.value !== "json") return false; + if (value.sessionOption !== undefined && value.sessionOption !== "--session" && value.sessionOption !== "--resume") return false; + if (requires.session === true && value.sessionOption === undefined) return false; + return value.requiredFlags.length <= 12 && value.requiredFlags.every((flag) => typeof flag === "string" && /^--[a-z][a-z0-9-]{0,63}$/i.test(flag) && flag !== structuredOutput.option && flag !== value.sessionOption && flag !== "--model"); +} + 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 (!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 eventKeys: Array = ["ignored", "text", "toolStart", "toolEnd", "toolComplete", "error"]; @@ -224,6 +258,12 @@ function schemaMatches(schema: OpenCodeEventSchema, capabilities: OpenCodeRunCap // 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" as const, values: protocols }]; + if (!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)) return false; + if (schema.launch.requiredFlags.some((flag) => !options.has(flag))) return false; return true; } @@ -255,6 +295,13 @@ function parseCanonicalTimestamp(value: unknown): number | null { 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, right.requires.session) + && compatible(left.requires.model, right.requires.model) + && compatible(left.requires.protocol, right.requires.protocol); +} + export function isOpenCodeSchemaBundle( value: unknown, now = Date.now(), @@ -268,12 +315,13 @@ export function isOpenCodeSchemaBundle( // 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 profiles = new Set(); const ids = new Set(); - for (const schema of value.schemas) { - const profile = stableJson(schema.requires); - if (profiles.has(profile) || ids.has(schema.id)) return false; - profiles.add(profile); + 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) && requirementsOverlap(schema, prior)) return false; + } ids.add(schema.id); } return true; @@ -367,21 +415,31 @@ async function readResponseTextLimited(response: Response): Promise { return Buffer.concat(chunks).toString("utf8"); } -async function fetchSchemaBundle(url: string, fetcher: typeof fetch): Promise { +async function fetchSchemaBundle(url: string, fetcher: typeof fetch, timeoutMs = REFRESH_TIMEOUT_MS): Promise { const controller = new AbortController(); let timeout: ReturnType | undefined; - const timedOut = new Promise((_, reject) => { + 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")); - }, REFRESH_TIMEOUT_MS); + }, timeoutMs); }); try { - // Test and embedding fetch shims are not required to honor AbortSignal; - // racing the timeout prevents a hung registry from delaying a chat turn. + // 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([ - fetcher(url, { headers: { accept: "application/json" }, signal: controller.signal }), - timedOut, + (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); @@ -479,6 +537,8 @@ export type OpenCodeSchemaBundleSource = { 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. Server-side @@ -497,10 +557,9 @@ async function refreshOpenCodeSchemaBundle( publicKey: string, fetcher: typeof fetch, now: number, + refreshTimeoutMs?: number, ): Promise { - const response = await fetchSchemaBundle(url, fetcher); - const raw = await readResponseTextLimited(response); - if (!response.ok) throw new Error("untrusted schema bundle"); + const raw = await fetchSchemaBundle(url, fetcher, refreshTimeoutMs); const remote = JSON.parse(raw) as unknown; if (!verifyOpenCodeSchemaBundle(remote, publicKey, now)) throw new Error("invalid schema signature"); const cachedTrust = await readCachedTrustState(file, publicKey, now); @@ -567,17 +626,24 @@ export async function loadOpenCodeSchemaBundle(source: OpenCodeSchemaBundleSourc if (!url || !publicKey) return { bundle: BUILTIN_OPENCODE_SCHEMA_BUNDLE, source: "built-in" }; const cached = await readVerifiedCache(file, publicKey, now); + // 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. + const cacheTrust = cached ?? await readCachedTrustState(file, publicKey, now); const cacheFresh = cached && now - cached.checkedAt < CACHE_TTL_MS; if (cacheFresh) return { bundle: cached.bundle, source: "cache" }; const key = schemaRefreshKey(file, url, publicKey); if ((refreshRetryAt.get(key) ?? 0) > now) { return cached ? { bundle: cached.bundle, source: "cache", diagnostic: "schema-registry-refresh-rejected" } - : { bundle: BUILTIN_OPENCODE_SCHEMA_BUNDLE, source: "built-in", diagnostic: "cached-schema-unavailable" }; + : cacheTrust + ? { bundle: BUILTIN_OPENCODE_SCHEMA_BUNDLE, source: "built-in", diagnostic: "cached-schema-unavailable" } + : { bundle: BUILTIN_OPENCODE_SCHEMA_BUNDLE, source: "built-in", diagnostic: "schema-registry-refresh-rejected" }; } const refresh = startSchemaRefresh( key, - () => refreshOpenCodeSchemaBundle(file, url, publicKey, source.fetch ?? fetch, now), + () => refreshOpenCodeSchemaBundle(file, url, publicKey, source.fetch ?? fetch, now, source.refreshTimeoutMs), now, ); if (cached) { @@ -589,7 +655,9 @@ export async function loadOpenCodeSchemaBundle(source: OpenCodeSchemaBundleSourc try { return await refresh; } catch { - return { bundle: BUILTIN_OPENCODE_SCHEMA_BUNDLE, source: "built-in", diagnostic: "cached-schema-unavailable" }; + return cacheTrust + ? { bundle: BUILTIN_OPENCODE_SCHEMA_BUNDLE, source: "built-in", diagnostic: "cached-schema-unavailable" } + : { bundle: BUILTIN_OPENCODE_SCHEMA_BUNDLE, source: "built-in", diagnostic: "schema-registry-refresh-rejected" }; } } @@ -606,15 +674,18 @@ export async function resolveOpenCodeCompatibility( }; } const loaded = await loadOpenCodeSchemaBundle(source); - // A configured registry with no currently verified cache must not silently - // fall back to a potentially older compiled parser. Keep plain chat until a - // signed schema for this client can be verified again. - if (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.diagnostic === "cached-schema-unavailable" + && Date.parse(BUILTIN_OPENCODE_SCHEMA_BUNDLE.expiresAt) <= (source?.now?.() ?? Date.now()) + ) { return { mode: "plain", capabilities, bundleSource: loaded.source, - diagnostic: "cached-schema-unavailable", + diagnostic: loaded.diagnostic, }; } const schema = selectOpenCodeSchema(loaded.bundle.schemas, capabilities); @@ -631,6 +702,9 @@ export async function resolveOpenCodeCompatibility( capabilities, schema, bundleSource: loaded.source, + // 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 cbbdc3005..b473051a7 100644 --- a/src/lib/opencode-stream.test.ts +++ b/src/lib/opencode-stream.test.ts @@ -1,12 +1,45 @@ // @ts-nocheck import assert from "node:assert/strict"; import { BUILTIN_OPENCODE_SCHEMA_BUNDLE } from "./opencode-compatibility.ts"; -import { parseOpenCodeRunEvent } from "./opencode-stream.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: { 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: { 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_live", 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", "ses_live"], "JSONL dispatch adopts the native session before handling each frame"); + 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: "step_start", sessionID: "ses_123", part: { id: "step_1" } }, @@ -41,6 +74,7 @@ assert.deepEqual( shape: { ...BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0].shape, envelope: ["payload"], + textEnvelope: ["payload"], sessionId: ["session"], text: ["body"], }, @@ -188,10 +222,10 @@ const shapedSchema = { }; assert.deepEqual( parseOpenCodeRunEvent( - { type: "tool", session: "ses_v2", payload: { call_id: "call_v2", tool_name: "Read", phase: { state: "done", arguments: { path: "README.md" }, result: "ok" } } }, + { type: "tool", payload: { 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 can map bounded future envelope fields and terminal states", + "a signed schema resolves envelope-native sessions as well as bounded future fields and terminal states", ); console.log("opencode-stream.test.ts: ok"); diff --git a/src/lib/opencode-stream.ts b/src/lib/opencode-stream.ts index f23f166eb..8a1189474 100644 --- a/src/lib/opencode-stream.ts +++ b/src/lib/opencode-stream.ts @@ -9,6 +9,17 @@ export type OpenCodeRunEvent = | { kind: "error"; sessionId?: string; message: 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; + 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) ? value as Record @@ -20,7 +31,7 @@ function stringAt(recordValue: Record | null, ...keys: string[] return undefined; } -type ShapeAlias = Exclude, "envelope">; +type ShapeAlias = Exclude, "envelope" | "textEnvelope">; function shapeAliases(schema: OpenCodeEventSchema | undefined, key: ShapeAlias, defaults: string[]): string[] { const aliases = schema?.shape?.[key]; @@ -32,8 +43,10 @@ function valueAt(recordValue: Record | null, keys: string[]): u return undefined; } -function envelope(event: Record, schema: OpenCodeEventSchema | undefined): Record | null { - const envelopes = schema?.shape?.envelope ?? ["part", "data", "root"]; +function envelope( + event: Record, + envelopes: Array<"part" | "data" | "payload" | "root">, +): Record | null { for (const field of envelopes) { if (field === "root") return event; const candidate = record(event[field]); @@ -42,6 +55,16 @@ function envelope(event: Record, schema: OpenCodeEventSchema | 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: the validator rejects empty // mappings, so falling back here could revive a label that a registry @@ -78,7 +101,11 @@ export function parseOpenCodeRunEvent(value: unknown, schema?: OpenCodeEventSche if (!event || typeof event.type !== "string") { return { kind: "other", diagnostic: "malformed-event" }; } - const sessionId = stringAt(event, ...shapeAliases(schema, "sessionId", ["sessionID", "sessionId", "session_id"])); + 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); if (eventTypes(schema, "ignored", ["step_start", "step_finish"]).includes(event.type)) { return { kind: "ignore", sessionId }; } @@ -98,9 +125,8 @@ export function parseOpenCodeRunEvent(value: unknown, schema?: OpenCodeEventSche // 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 part = envelope(event, schema) ?? event; const textAliases = shapeAliases(schema, "text", ["text", "content"]); - const text = stringAt(part, ...textAliases) ?? stringAt(event, ...textAliases); + const text = stringAt(textEnvelope(event, schema), ...textAliases); if (eventTypes(schema, "text", ["text"]).includes(event.type) && text !== undefined) { return { kind: "text", sessionId, text }; } @@ -150,3 +176,31 @@ export function parseOpenCodeRunEvent(value: unknown, schema?: OpenCodeEventSche 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); + if (event.sessionId) handlers.onSession?.(event.sessionId); + switch (event.kind) { + case "ignore": return; + case "text": handlers.onText?.(event); return; + case "tool": handlers.onTool?.(event); return; + case "tool_start": handlers.onToolStart?.(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?.(); + } +} From 3956bcb7d812e60dd727315dae0b6ca953917f22 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 00:00:35 -0400 Subject: [PATCH 020/112] fix(chat): constrain OpenCode registry launches --- src/app/api/chat/send/harness-routing-opencode.test.ts | 6 +++--- src/app/api/chat/send/route.ts | 5 +++-- src/lib/opencode-compatibility.test.ts | 7 +++++++ src/lib/opencode-compatibility.ts | 5 ++++- 4 files changed, 17 insertions(+), 6 deletions(-) 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 62781433a..8d424c7ee 100644 --- a/src/app/api/chat/send/harness-routing-opencode.test.ts +++ b/src/app/api/chat/send/harness-routing-opencode.test.ts @@ -12,7 +12,7 @@ assert.match( ); assert.match( route, - /const a = \["run"\];[\s\S]*?openCodeCompatibility\?\.mode === "structured"[\s\S]*?const launch = openCodeCompatibility\.schema!\.launch;[\s\S]*?a\.push\(launch\.structuredOutput\.option, launch\.structuredOutput\.value, \.\.\.launch\.requiredFlags\);[\s\S]*?launch\.sessionOption[\s\S]*?else if \(resumeSessionId && openCodeCompatibility\?\.capabilities\.session\) a\.push\("--session", resumeSessionId\);/, + /const a = \["run"\];[\s\S]*?openCodeCompatibility\?\.mode === "structured"[\s\S]*?const launch = openCodeCompatibility\.schema!\.launch;[\s\S]*?a\.push\(launch\.structuredOutput\.option, launch\.structuredOutput\.value, \.\.\.launch\.requiredFlags\);[\s\S]*?launch\.sessionOption[\s\S]*?if \(forwardModel\)/, "OpenCode uses the selected schema's discovered structured-output and session argv contract rather than a version threshold", ); assert.match( @@ -102,8 +102,8 @@ assert.match( ); assert.match( route, - /openCodeDirect && body\.sessionId && existingConversation && \([\s\S]*?!openCodeCompatibility\?\.capabilities\.session \|\| !existingConversation\.harnessSessionId[\s\S]*?buildResumeRetryPrompt\(harnessPrompt, existingConversation\)/, - "plain-mode follow-ups replay Cave context when no native OpenCode session was minted", + /openCodeDirect && body\.sessionId && existingConversation && \([\s\S]*?openCodeCompatibility\?\.mode !== "structured"[\s\S]*?\|\| !openCodeCompatibility\?\.capabilities\.session[\s\S]*?\|\| !existingConversation\.harnessSessionId[\s\S]*?buildResumeRetryPrompt\(harnessPrompt, existingConversation\)/, + "plain-mode follow-ups replay Cave context instead of guessing a native resume flag", ); assert.match( route, diff --git a/src/app/api/chat/send/route.ts b/src/app/api/chat/send/route.ts index 4df6d2981..9f481eea9 100644 --- a/src/app/api/chat/send/route.ts +++ b/src/app/api/chat/send/route.ts @@ -1370,7 +1370,6 @@ export async function POST(req: Request) { a.push(launch.structuredOutput.option, launch.structuredOutput.value, ...launch.requiredFlags); if (resumeSessionId && launch.sessionOption) a.push(launch.sessionOption, resumeSessionId); } - else if (resumeSessionId && openCodeCompatibility?.capabilities.session) a.push("--session", resumeSessionId); if (forwardModel) a.push("--model", forwardModel); a.push(prompt); return a; @@ -1421,7 +1420,9 @@ export async function POST(req: Request) { // earlier conversation context. const openCodeFreshSessionForCompatibility = Boolean( openCodeDirect && body.sessionId && existingConversation && ( - !openCodeCompatibility?.capabilities.session || !existingConversation.harnessSessionId + openCodeCompatibility?.mode !== "structured" + || !openCodeCompatibility?.capabilities.session + || !existingConversation.harnessSessionId ), ); const openCodeSessionUnavailable = !openCodeCompatibility?.capabilities.session; diff --git a/src/lib/opencode-compatibility.test.ts b/src/lib/opencode-compatibility.test.ts index bab360d79..0125951e9 100644 --- a/src/lib/opencode-compatibility.test.ts +++ b/src/lib/opencode-compatibility.test.ts @@ -159,6 +159,13 @@ assert.equal(isOpenCodeSchemaBundle({ eventTypes: { ...BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0].eventTypes, toolEnd: [] }, }], }, now), false, "incomplete event mappings cannot be selected as structured parsers"); +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"); const ed448 = generateKeyPairSync("ed448"); const ed448PublicPem = ed448.publicKey.export({ type: "spki", format: "pem" }).toString(); diff --git a/src/lib/opencode-compatibility.ts b/src/lib/opencode-compatibility.ts index 39f4ed0db..a75ea5a83 100644 --- a/src/lib/opencode-compatibility.ts +++ b/src/lib/opencode-compatibility.ts @@ -220,7 +220,10 @@ function hasValidLaunch(value: unknown, requires: Record): bool if (structuredOutput.value !== requires.protocol && structuredOutput.value !== "json") return false; if (value.sessionOption !== undefined && value.sessionOption !== "--session" && value.sessionOption !== "--resume") return false; if (requires.session === true && value.sessionOption === undefined) return false; - return value.requiredFlags.length <= 12 && value.requiredFlags.every((flag) => typeof flag === "string" && /^--[a-z][a-z0-9-]{0,63}$/i.test(flag) && flag !== structuredOutput.option && flag !== value.sessionOption && flag !== "--model"); + // The registry may describe protocol selection, but must never add launch + // switches. Some OpenCode flags (for example `--auto`) alter permission + // approval; keeping this list empty preserves Cave's local safety policy. + return value.requiredFlags.length === 0; } function isEventSchema(value: unknown): value is OpenCodeEventSchema { From 591ab9e91a823766e7f609ff50a92756c19c03cf Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 00:06:34 -0400 Subject: [PATCH 021/112] fix(chat): negotiate OpenCode protocol envelopes --- .../send/harness-routing-opencode.test.ts | 13 ++++++--- src/app/api/chat/send/route.ts | 26 +++++++++++++---- src/lib/opencode-compatibility.test.ts | 16 +++++++++++ src/lib/opencode-compatibility.ts | 16 +++++++++-- src/lib/opencode-stream.test.ts | 6 ++-- src/lib/opencode-stream.ts | 28 +++++++++++-------- 6 files changed, 80 insertions(+), 25 deletions(-) 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 8d424c7ee..0e52b39b5 100644 --- a/src/app/api/chat/send/harness-routing-opencode.test.ts +++ b/src/app/api/chat/send/harness-routing-opencode.test.ts @@ -12,8 +12,8 @@ assert.match( ); assert.match( route, - /const a = \["run"\];[\s\S]*?openCodeCompatibility\?\.mode === "structured"[\s\S]*?const launch = openCodeCompatibility\.schema!\.launch;[\s\S]*?a\.push\(launch\.structuredOutput\.option, launch\.structuredOutput\.value, \.\.\.launch\.requiredFlags\);[\s\S]*?launch\.sessionOption[\s\S]*?if \(forwardModel\)/, - "OpenCode uses the selected schema's discovered structured-output and session argv contract rather than a version threshold", + /const a = \["run"\];[\s\S]*?openCodeCompatibility\?\.mode === "structured"[\s\S]*?const launch = openCodeCompatibility\.schema!\.launch;[\s\S]*?a\.push\(launch\.structuredOutput\.option, launch\.structuredOutput\.value, \.\.\.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, @@ -102,14 +102,19 @@ assert.match( ); assert.match( route, - /openCodeDirect && body\.sessionId && existingConversation && \([\s\S]*?openCodeCompatibility\?\.mode !== "structured"[\s\S]*?\|\| !openCodeCompatibility\?\.capabilities\.session[\s\S]*?\|\| !existingConversation\.harnessSessionId[\s\S]*?buildResumeRetryPrompt\(harnessPrompt, existingConversation\)/, - "plain-mode follow-ups replay Cave context instead of guessing a native resume flag", + /openCodeDirect && body\.sessionId && existingConversation && \([\s\S]*?!openCodeCompatibility\?\.capabilities\.session[\s\S]*?\|\| !existingConversation\.harnessSessionId[\s\S]*?buildResumeRetryPrompt\(harnessPrompt, existingConversation\)/, + "OpenCode replays Cave context only when a native resume option or token is unavailable", ); 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\)/, diff --git a/src/app/api/chat/send/route.ts b/src/app/api/chat/send/route.ts index 9f481eea9..f76511982 100644 --- a/src/app/api/chat/send/route.ts +++ b/src/app/api/chat/send/route.ts @@ -1369,6 +1369,16 @@ export async function POST(req: Request) { const launch = openCodeCompatibility.schema!.launch; a.push(launch.structuredOutput.option, launch.structuredOutput.value, ...launch.requiredFlags); if (resumeSessionId && launch.sessionOption) a.push(launch.sessionOption, resumeSessionId); + } else if (resumeSessionId) { + // Plain output can still resume a native session. Forward only the + // exact option confirmed by `run --help`; never guess `--session`. + const options = openCodeCompatibility?.capabilities.options ?? []; + const sessionOption = options.includes("--session") + ? "--session" + : options.includes("--resume") + ? "--resume" + : null; + if (sessionOption) a.push(sessionOption, resumeSessionId); } if (forwardModel) a.push("--model", forwardModel); a.push(prompt); @@ -1414,14 +1424,13 @@ export async function POST(req: Request) { const grokSandboxRetry = grokFreshSessionForSandbox ? buildResumeRetryPrompt(harnessPrompt, existingConversation) : null; - // A client which no longer advertises `--session`, or a prior plain-mode + // 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. Otherwise a follow-up silently loses its - // earlier conversation context. + // 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 && existingConversation && ( - openCodeCompatibility?.mode !== "structured" - || !openCodeCompatibility?.capabilities.session + !openCodeCompatibility?.capabilities.session || !existingConversation.harnessSessionId ), ); @@ -1831,6 +1840,13 @@ 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), diff --git a/src/lib/opencode-compatibility.test.ts b/src/lib/opencode-compatibility.test.ts index 0125951e9..42a9fc4c5 100644 --- a/src/lib/opencode-compatibility.test.ts +++ b/src/lib/opencode-compatibility.test.ts @@ -140,6 +140,22 @@ assert.equal( false, "a selected schema must declare its parseable envelope and field aliases", ); +assert.equal( + isOpenCodeSchemaBundle({ + ...unsigned, + schemas: [{ + ...BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0], + id: "nested-discriminator", + shape: { + ...BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0].shape, + envelope: ["payload"], + discriminator: { envelope: "payload", field: "event" }, + }, + }], + }, now), + true, + "a signed schema can relocate the event discriminator into its declared envelope", +); assert.equal( isOpenCodeSchemaBundle({ ...unsigned, issuedAt: 0 }, now), false, diff --git a/src/lib/opencode-compatibility.ts b/src/lib/opencode-compatibility.ts index a75ea5a83..aa3030f05 100644 --- a/src/lib/opencode-compatibility.ts +++ b/src/lib/opencode-compatibility.ts @@ -36,6 +36,11 @@ export type OpenCodeEventSchema = { */ shape: { envelope: Array<"part" | "data" | "payload" | "root">; + /** The bounded location and alias that identifies the event kind. */ + discriminator: { + envelope: "part" | "data" | "payload" | "root"; + field: string; + }; /** Envelope(s) explicitly trusted to carry assistant text. */ textEnvelope?: Array<"part" | "data" | "payload" | "root">; sessionId: string[]; @@ -150,6 +155,7 @@ export const BUILTIN_OPENCODE_SCHEMA_BUNDLE: OpenCodeSchemaBundle = { }, shape: { envelope: ["part", "data", "root"], + discriminator: { envelope: "root", field: "type" }, textEnvelope: ["part", "data"], sessionId: ["sessionID", "sessionId", "session_id"], id: ["id", "callID", "callId", "toolCallId", "tool_call_id"], @@ -174,6 +180,7 @@ export const BUILTIN_OPENCODE_SCHEMA_BUNDLE: OpenCodeSchemaBundle = { }, shape: { envelope: ["part", "data", "root"], + discriminator: { envelope: "root", field: "type" }, textEnvelope: ["part", "data"], sessionId: ["sessionID", "sessionId", "session_id"], id: ["id", "callID", "callId", "toolCallId", "tool_call_id"], @@ -206,8 +213,13 @@ function hasValidShape(value: unknown): boolean { && fields.length <= 4 && (!requireRoot || fields.includes("root")) && fields.every((field) => field === "part" || field === "data" || field === "payload" || field === "root"); - if (!Object.keys(value).every((key) => key === "envelope" || key === "textEnvelope" || aliasKeys.includes(key))) return false; - if (!envelopeFields(value.envelope, true) || (value.textEnvelope !== undefined && !envelopeFields(value.textEnvelope, false))) return false; + if (!Object.keys(value).every((key) => key === "envelope" || key === "textEnvelope" || key === "discriminator" || aliasKeys.includes(key))) return false; + if (!envelopeFields(value.envelope, false) || (value.textEnvelope !== undefined && !envelopeFields(value.textEnvelope, false))) return false; + if (!isRecord(value.discriminator) + || !Object.keys(value.discriminator).every((key) => key === "envelope" || key === "field") + || (value.discriminator.envelope !== "part" && value.discriminator.envelope !== "data" && value.discriminator.envelope !== "payload" && value.discriminator.envelope !== "root") + || typeof value.discriminator.field !== "string" + || !/^[A-Za-z][A-Za-z0-9_-]{0,79}$/.test(value.discriminator.field)) return false; return aliasKeys.every((key) => hasBoundedAliases(value[key])); } diff --git a/src/lib/opencode-stream.test.ts b/src/lib/opencode-stream.test.ts index b473051a7..fdb926bcc 100644 --- a/src/lib/opencode-stream.test.ts +++ b/src/lib/opencode-stream.test.ts @@ -66,7 +66,7 @@ assert.deepEqual( ); assert.deepEqual( parseOpenCodeRunEvent( - { type: "reply", session: "ses_mapped", payload: { body: "A registry-mapped reply" } }, + { session: "ses_mapped", payload: { event: "reply", body: "A registry-mapped reply" } }, { ...BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0], id: "mapped-envelope", @@ -74,6 +74,7 @@ assert.deepEqual( shape: { ...BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0].shape, envelope: ["payload"], + discriminator: { envelope: "payload", field: "event" }, textEnvelope: ["payload"], sessionId: ["session"], text: ["body"], @@ -208,6 +209,7 @@ const shapedSchema = { id: "opencode-run-json-shaped-v2", shape: { envelope: ["payload"], + discriminator: { envelope: "payload", field: "event" }, sessionId: ["session"], id: ["call_id"], name: ["tool_name"], @@ -222,7 +224,7 @@ const shapedSchema = { }; assert.deepEqual( parseOpenCodeRunEvent( - { type: "tool", payload: { session: "ses_v2", call_id: "call_v2", tool_name: "Read", phase: { state: "done", arguments: { path: "README.md" }, result: "ok" } } }, + { payload: { event: "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 }, diff --git a/src/lib/opencode-stream.ts b/src/lib/opencode-stream.ts index 8a1189474..03c333881 100644 --- a/src/lib/opencode-stream.ts +++ b/src/lib/opencode-stream.ts @@ -31,7 +31,7 @@ function stringAt(recordValue: Record | null, ...keys: string[] return undefined; } -type ShapeAlias = Exclude, "envelope" | "textEnvelope">; +type ShapeAlias = Exclude, "envelope" | "textEnvelope" | "discriminator">; function shapeAliases(schema: OpenCodeEventSchema | undefined, key: ShapeAlias, defaults: string[]): string[] { const aliases = schema?.shape?.[key]; @@ -98,7 +98,7 @@ function toolStateIsError(state: Record | null, part: Record Date: Sat, 25 Jul 2026 00:17:31 -0400 Subject: [PATCH 022/112] fix(chat): fail closed on OpenCode protocol ambiguity --- scripts/git-hooks-pre-commit.test.mjs | 10 +++- .../api/chat/send/chat-send-capabilities.ts | 51 +++++++++++-------- src/lib/opencode-compatibility.test.ts | 42 +++++++++------ src/lib/opencode-compatibility.ts | 30 ++++++----- 4 files changed, 81 insertions(+), 52 deletions(-) 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/src/app/api/chat/send/chat-send-capabilities.ts b/src/app/api/chat/send/chat-send-capabilities.ts index f32dde74d..9fd8aec5b 100644 --- a/src/app/api/chat/send/chat-send-capabilities.ts +++ b/src/app/api/chat/send/chat-send-capabilities.ts @@ -130,6 +130,35 @@ function advertisedFormatProtocols(help: string): string[] { return [...new Set(outputs.flatMap((output) => output.values))]; } +/** + * 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 structuredOutputs = advertisedStructuredOutputs(help); + const protocols = advertisedFormatProtocols(help); + const json = protocols.some((protocol) => protocol === "json" || protocol.startsWith("json-") || protocol.startsWith("json_")); + return { + version, + // Only accept JSON when it appears in the `--format` option's own + // stanza. A stray "JSON" in a banner or another option's description + // must not make us launch an unsupported `--format json` command. + json, + model: options.includes("--model"), + session: options.includes("--session") || options.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, + structuredOutputs, + }; +} + /** Capability probes are cached because old Coven CLIs reject unknown flags. */ export function covenRunSupportsModel(): Promise { const { command, fixedArgs } = covenLaunchCommand(); @@ -203,27 +232,7 @@ export function openCodeRunCapabilities(): Promise { // evidence. Probe again after the short TTL instead of risking an argv // that the installed client does not accept. if (!helpProbe.complete) return { version, json: false, model: false, session: false, protocols: [], options: [], structuredOutputs: [] }; - const help = helpProbe.output; - const options = declaredRunOptions(help); - const structuredOutputs = advertisedStructuredOutputs(help); - const protocols = advertisedFormatProtocols(help); - const json = protocols.some((protocol) => protocol === "json" || protocol.startsWith("json-") || protocol.startsWith("json_")); - return { - version, - // Only accept JSON when it appears in the `--format` option's own - // stanza. A stray "JSON" in a banner or another option's description - // must not make us launch an unsupported `--format json` command. - json, - model: options.includes("--model"), - session: options.includes("--session") || options.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, - structuredOutputs, - }; + return parseOpenCodeRunCapabilitiesHelp(helpProbe.output, version); })(); openCodeCapabilitiesProbe = { until: Date.now() + OPENCODE_CAPABILITY_PROBE_TTL_MS, value }; return value; diff --git a/src/lib/opencode-compatibility.test.ts b/src/lib/opencode-compatibility.test.ts index 42a9fc4c5..dd62609d0 100644 --- a/src/lib/opencode-compatibility.test.ts +++ b/src/lib/opencode-compatibility.test.ts @@ -13,6 +13,7 @@ import { selectOpenCodeSchema, isOpenCodeSchemaBundle, } from "./opencode-compatibility.ts"; +import { parseOpenCodeRunCapabilitiesHelp } from "../app/api/chat/send/chat-send-capabilities.ts"; const now = Date.parse("2026-07-24T12:00:00.000Z"); const { privateKey, publicKey } = generateKeyPairSync("ed25519"); @@ -53,7 +54,7 @@ const offline = await loadOpenCodeSchemaBundle({ fetch: async () => { throw new Error("offline"); }, }); assert.equal(offline.source, "cache"); -assert.equal(offline.diagnostic, undefined, "a stale verified parser serves immediately while refresh runs in the background"); +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({ @@ -74,7 +75,7 @@ const rejectedRollback = await loadOpenCodeSchemaBundle({ fetch: async () => new Response(JSON.stringify(rollback), { status: 200 }), }); assert.equal(rejectedRollback.source, "cache"); -assert.equal(rejectedRollback.diagnostic, undefined, "rollback refreshes never displace the last known good parser"); +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"); @@ -83,8 +84,16 @@ const structured = await resolveOpenCodeCompatibility({ version: null, json: tru 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, "structured"); -assert.equal(missingSession.schema?.id, "opencode-run-json-legacy", "older compatible schemas coexist without client version gates"); +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 = parseOpenCodeRunCapabilitiesHelp(` + --format Output format: text, json + --resume Resume a prior session +`, "1.2.3"); +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); const offlineBaseline = await resolveOpenCodeCompatibility( { version: "current", json: true, model: false, session: true, protocols: ["json"] }, @@ -236,7 +245,7 @@ const oversized = await loadOpenCodeSchemaBundle({ fetch: async () => new Response("x".repeat(300 * 1024), { status: 200 }), }); assert.equal(oversized.source, "cache"); -assert.equal(oversized.diagnostic, undefined, "oversized refreshes preserve and immediately serve the verified 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 stalled = await loadOpenCodeSchemaBundle({ @@ -268,15 +277,15 @@ const recoveredLock = await loadOpenCodeSchemaBundle({ now: () => now + 28 * 60 * 60 * 1000, fetch: async () => new Response(JSON.stringify(signedSequence3), { status: 200 }), }); -assert.equal(recoveredLock.bundle.sequence, 2, "a stale verified cache remains available while its refresh runs"); -await new Promise((resolve) => setTimeout(resolve, 50)); -assert.equal((JSON.parse(await readFile(cacheFile, "utf8")) as { bundle: { sequence: number } }).bundle.sequence, 3, "a stale writer lock cannot permanently block cache recovery after a crash"); +assert.equal(recoveredLock.bundle.sequence, 3, "a stale writer lock cannot permanently block cache recovery after a crash"); 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, @@ -284,19 +293,22 @@ const concurrentSource = { now: () => now + 7 * 60 * 60 * 1000, fetch: async () => { fetchCalls += 1; + markFetchStarted?.(); await delayedResponse; return new Response(JSON.stringify(signedSequence3), { status: 200 }); }, }; -const [staleA, staleB] = await Promise.all([ - loadOpenCodeSchemaBundle(concurrentSource), - loadOpenCodeSchemaBundle(concurrentSource), -]); +const staleA = loadOpenCodeSchemaBundle(concurrentSource); +const staleB = loadOpenCodeSchemaBundle(concurrentSource); +await fetchStarted; assert.equal(fetchCalls, 1, "concurrent stale readers coalesce to one registry request"); -assert.equal(staleA.bundle.sequence, 2); -assert.equal(staleB.bundle.sequence, 2, "stale readers do not block chat on the registry refresh"); releaseRefresh?.(); -await new Promise((resolve) => setTimeout(resolve, 50)); +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 })); diff --git a/src/lib/opencode-compatibility.ts b/src/lib/opencode-compatibility.ts index aa3030f05..a2a60c9e4 100644 --- a/src/lib/opencode-compatibility.ts +++ b/src/lib/opencode-compatibility.ts @@ -166,10 +166,14 @@ export const BUILTIN_OPENCODE_SCHEMA_BUNDLE: OpenCodeSchemaBundle = { launch: { structuredOutput: { option: "--format", value: "json" }, sessionOption: "--session", requiredFlags: [] }, }, { - // Earlier and preview clients used generic tool envelopes. Keeping this - // separate lets a signed registry retire it without a code release. + // 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, session: false, protocol: "json" }, + requires: { json: true, session: false, protocol: "json-legacy" }, eventTypes: { ignored: ["step_start", "step_finish", "reasoning"], text: ["message", "assistant_text"], @@ -188,7 +192,7 @@ export const BUILTIN_OPENCODE_SCHEMA_BUNDLE: OpenCodeSchemaBundle = { 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" }, requiredFlags: [] }, + launch: { structuredOutput: { option: "--format", value: "json-legacy" }, requiredFlags: [] }, }, ], }; @@ -628,10 +632,10 @@ function startSchemaRefresh( } /** - * Read a last-known-good schema bundle and opportunistically refresh it. 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. This makes network loss and unsafe rollbacks non-events. + * 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(); @@ -661,15 +665,13 @@ export async function loadOpenCodeSchemaBundle(source: OpenCodeSchemaBundleSourc () => refreshOpenCodeSchemaBundle(file, url, publicKey, source.fetch ?? fetch, now, source.refreshTimeoutMs), now, ); - if (cached) { - // A stale but verified parser is safer than delaying a chat behind a - // registry outage. One in-flight refresh updates it for later turns. - void refresh.catch(() => undefined); - return { bundle: cached.bundle, source: "cache" }; - } try { return await refresh; } catch { + // 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" } : { bundle: BUILTIN_OPENCODE_SCHEMA_BUNDLE, source: "built-in", diagnostic: "schema-registry-refresh-rejected" }; From fffa307a276d835d52ea7ad615fbce50815838c8 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 00:25:23 -0400 Subject: [PATCH 023/112] fix(chat): extend OpenCode registry migration --- .github/workflows/release.yml | 4 + docs/opencode-compatibility-registry.md | 4 +- scripts/check-opencode-registry-release.mjs | 22 ++- .../check-opencode-registry-release.test.mjs | 2 + .../api/chat/send/chat-send-capabilities.ts | 6 +- src/lib/opencode-compatibility.test.ts | 101 ++++++++++- src/lib/opencode-compatibility.ts | 165 +++++++++++++----- src/lib/opencode-stream.test.ts | 6 +- src/lib/opencode-stream.ts | 12 +- 9 files changed, 260 insertions(+), 62 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 83be93d6f..f13d003c8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -237,6 +237,7 @@ jobs: 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 }} run: | node scripts/check-opencode-registry-release.mjs { @@ -244,6 +245,9 @@ jobs: echo "NEXT_PUBLIC_COVEN_OPENCODE_SCHEMA_REGISTRY_PUBLIC_KEY<> "$GITHUB_ENV" # Decode the App Store Connect API key onto the runner for the custom diff --git a/docs/opencode-compatibility-registry.md b/docs/opencode-compatibility-registry.md index 017b6456a..9dca3d650 100644 --- a/docs/opencode-compatibility-registry.md +++ b/docs/opencode-compatibility-registry.md @@ -9,6 +9,8 @@ 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. +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` and `NEXT_PUBLIC_COVEN_OPENCODE_SCHEMA_REGISTRY_PUBLIC_KEY`, 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 either value is missing, non-HTTPS, or not an Ed25519 key. 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. @@ -17,4 +19,4 @@ Development and test processes may inject `COVEN_OPENCODE_SCHEMA_REGISTRY_URL` a 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. -To rotate a key, publish a Cave release carrying the new public key before publishing bundles signed only by that key. Keep the prior key available to the registry publisher until clients on the preceding Cave release have a supported migration path; an emergency rotation requires a Cave release first. Record the registry endpoint, public-key fingerprint, owner, and rotation date in the release checklist. +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/scripts/check-opencode-registry-release.mjs b/scripts/check-opencode-registry-release.mjs index 5162bcf7d..a5d354824 100644 --- a/scripts/check-opencode-registry-release.mjs +++ b/scripts/check-opencode-registry-release.mjs @@ -5,17 +5,33 @@ 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; function fail(message) { console.error(`::error::${message}`); process.exitCode = 1; } -if (!url || !publicKey) { - fail("OpenCode compatibility registry URL and Ed25519 public key must be configured for every desktop release."); +let keyring; +try { + keyring = publicKeys + ? JSON.parse(publicKeys) + : publicKey + ? { legacy: publicKey } + : null; +} catch { + keyring = null; +} +if (!url || !keyring || typeof keyring !== "object" || Array.isArray(keyring)) { + fail("OpenCode compatibility registry URL and an Ed25519 public key or bounded keyring must be configured for every desktop release."); } else { try { if (new URL(url).protocol !== "https:") throw new Error("registry URL must use HTTPS"); - if (createPublicKey(publicKey).asymmetricKeyType !== "ed25519") throw new Error("registry key must be Ed25519"); + 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"); + } } 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 index 8c7abdfa1..e65ca0e8c 100644 --- a/scripts/check-opencode-registry-release.test.mjs +++ b/scripts/check-opencode-registry-release.test.mjs @@ -6,8 +6,10 @@ const guard = await readFile(new URL("./check-opencode-registry-release.mjs", im 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(guard, /new URL\(url\)\.protocol !== "https:"/); assert.match(guard, /asymmetricKeyType !== "ed25519"/); +assert.match(guard, /keyring must contain one to four keys/); assert.match(docs, /rotation/i); assert.match(docs, /built-in profile/i); console.log("check-opencode-registry-release.test.mjs: ok"); diff --git a/src/app/api/chat/send/chat-send-capabilities.ts b/src/app/api/chat/send/chat-send-capabilities.ts index 9fd8aec5b..e4b27eee9 100644 --- a/src/app/api/chat/send/chat-send-capabilities.ts +++ b/src/app/api/chat/send/chat-send-capabilities.ts @@ -107,12 +107,12 @@ function hasRunOption(help: string, flag: string): boolean { return new RegExp(`^\\s*(?:-[A-Za-z],?\\s+)?${flag}\\b(?:\\s|=|,|$)`, "m").test(help); } -function optionStanza(help: string, option: "--format" | "--output"): string { +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] ?? ""; } -function advertisedStructuredOutputs(help: string): Array<{ option: "--format" | "--output"; values: string[] }> { - return (["--format", "--output"] as const).flatMap((option) => { +function advertisedStructuredOutputs(help: string): Array<{ option: string; values: string[] }> { + return declaredRunOptions(help).flatMap((option) => { const stanza = optionStanza(help, option); const values = [...new Set((stanza.match(/\bjson(?:[._-][a-z0-9]+)*\b/gi) ?? []).map((value) => value.toLowerCase()))]; return values.length ? [{ option, values }] : []; diff --git a/src/lib/opencode-compatibility.test.ts b/src/lib/opencode-compatibility.test.ts index dd62609d0..017f22ca4 100644 --- a/src/lib/opencode-compatibility.test.ts +++ b/src/lib/opencode-compatibility.test.ts @@ -13,7 +13,6 @@ import { selectOpenCodeSchema, isOpenCodeSchemaBundle, } from "./opencode-compatibility.ts"; -import { parseOpenCodeRunCapabilitiesHelp } from "../app/api/chat/send/chat-send-capabilities.ts"; const now = Date.parse("2026-07-24T12:00:00.000Z"); const { privateKey, publicKey } = generateKeyPairSync("ed25519"); @@ -46,6 +45,30 @@ 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 unsignedSignedRollback = { ...unsigned, sequence: 1 }; +const signedRollback = { + ...unsignedSignedRollback, + signature: { + algorithm: "ed25519" as const, + value: sign(null, Buffer.from(openCodeSchemaBundleSigningPayload(unsignedSignedRollback)), privateKey).toString("base64"), + }, +}; +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 offline = await loadOpenCodeSchemaBundle({ cacheFile, publicKey: publicPem, @@ -87,10 +110,15 @@ const missingSession = await resolveOpenCodeCompatibility({ version: "1.2.3", js 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 = parseOpenCodeRunCapabilitiesHelp(` - --format Output format: text, json - --resume Resume a prior session -`, "1.2.3"); +const resumeOnlyCapabilities = { + version: "1.2.3", + json: true, + model: false, + session: true, + protocols: ["json"], + options: ["--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); @@ -157,13 +185,38 @@ assert.equal( id: "nested-discriminator", shape: { ...BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0].shape, - envelope: ["payload"], - discriminator: { envelope: "payload", field: "event" }, + envelope: [["event", "payload"]], + discriminator: { envelope: ["event", "payload"], field: "event" }, }, }], }, now), true, - "a signed schema can relocate the event discriminator into its declared envelope", + "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"], + 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", ); assert.equal( isOpenCodeSchemaBundle({ ...unsigned, issuedAt: 0 }, now), @@ -192,6 +245,38 @@ assert.equal(isOpenCodeSchemaBundle({ }], }, now), false, "a signed registry cannot add permission-affecting launch flags"); +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"); diff --git a/src/lib/opencode-compatibility.ts b/src/lib/opencode-compatibility.ts index a2a60c9e4..20ecbc1b8 100644 --- a/src/lib/opencode-compatibility.ts +++ b/src/lib/opencode-compatibility.ts @@ -13,9 +13,13 @@ export type OpenCodeRunCapabilities = { protocols: string[]; /** Declared `run` option names and structured-output option/value pairs. */ options?: string[]; - structuredOutputs?: Array<{ option: "--format" | "--output"; values: 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; + export type OpenCodeEventSchema = { id: string; /** A schema is selected only when every advertised requirement is met. */ @@ -35,14 +39,14 @@ export type OpenCodeEventSchema = { * selectors are deliberately not accepted from the remote registry. */ shape: { - envelope: Array<"part" | "data" | "payload" | "root">; + envelope: OpenCodeEnvelopePath[]; /** The bounded location and alias that identifies the event kind. */ discriminator: { - envelope: "part" | "data" | "payload" | "root"; + envelope: OpenCodeEnvelopePath; field: string; }; /** Envelope(s) explicitly trusted to carry assistant text. */ - textEnvelope?: Array<"part" | "data" | "payload" | "root">; + textEnvelope?: OpenCodeEnvelopePath[]; sessionId: string[]; id: string[]; name: string[]; @@ -57,7 +61,7 @@ export type OpenCodeEventSchema = { }; /** Bounded argv contract, confirmed against the installed client's help. */ launch: { - structuredOutput: { option: "--format" | "--output"; value: string }; + structuredOutput: { option: string; value: string }; sessionOption?: "--session" | "--resume"; requiredFlags: string[]; }; @@ -69,6 +73,8 @@ export type OpenCodeSchemaBundle = { sequence: number; issuedAt: string; expiresAt: string; + /** Signed registry-key identifier; required when a keyring has more than one key. */ + keyId?: string; schemas: OpenCodeEventSchema[]; signature?: { algorithm: "ed25519"; value: string }; }; @@ -208,38 +214,57 @@ function hasBoundedAliases(value: unknown): value is string[] { && 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, requireRoot: boolean): fields is Array<"part" | "data" | "payload" | "root"> => + const envelopeFields = (fields: unknown): fields is OpenCodeEnvelopePath[] => Array.isArray(fields) && fields.length > 0 && fields.length <= 4 - && (!requireRoot || fields.includes("root")) - && fields.every((field) => field === "part" || field === "data" || field === "payload" || field === "root"); + && fields.every(hasValidEnvelopePath); if (!Object.keys(value).every((key) => key === "envelope" || key === "textEnvelope" || key === "discriminator" || aliasKeys.includes(key))) return false; - if (!envelopeFields(value.envelope, false) || (value.textEnvelope !== undefined && !envelopeFields(value.textEnvelope, false))) return false; + if (!envelopeFields(value.envelope) || (value.textEnvelope !== undefined && !envelopeFields(value.textEnvelope))) return false; if (!isRecord(value.discriminator) || !Object.keys(value.discriminator).every((key) => key === "envelope" || key === "field") - || (value.discriminator.envelope !== "part" && value.discriminator.envelope !== "data" && value.discriminator.envelope !== "payload" && value.discriminator.envelope !== "root") + || !hasValidEnvelopePath(value.discriminator.envelope) || typeof value.discriminator.field !== "string" || !/^[A-Za-z][A-Za-z0-9_-]{0,79}$/.test(value.discriminator.field)) 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", +]); +const SAFE_STRUCTURED_REQUIRED_FLAGS = new Set(["--event-stream"]); + +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; const structuredOutput = value.structuredOutput; if (!isRecord(structuredOutput)) return false; - if (structuredOutput.option !== "--format" && structuredOutput.option !== "--output") return false; + if (!safeStructuredLaunchOption(structuredOutput.option)) return false; if (typeof structuredOutput.value !== "string" || !/^[a-z0-9][a-z0-9._-]{0,63}$/i.test(structuredOutput.value)) return false; - if (structuredOutput.value !== requires.protocol && structuredOutput.value !== "json") return false; + if (structuredOutput.value !== (typeof requires.protocol === "string" ? requires.protocol : "json")) return false; if (value.sessionOption !== undefined && value.sessionOption !== "--session" && value.sessionOption !== "--resume") return false; if (requires.session === true && value.sessionOption === undefined) return false; - // The registry may describe protocol selection, but must never add launch - // switches. Some OpenCode flags (for example `--auto`) alter permission - // approval; keeping this list empty preserves Cave's local safety policy. - return value.requiredFlags.length === 0; + return value.requiredFlags.length <= 1 + && value.requiredFlags.every((flag) => typeof flag === "string" && SAFE_STRUCTURED_REQUIRED_FLAGS.has(flag)) + && new Set(value.requiredFlags).size === value.requiredFlags.length + && !value.requiredFlags.includes(structuredOutput.option); } function isEventSchema(value: unknown): value is OpenCodeEventSchema { @@ -277,7 +302,7 @@ function schemaMatches(schema: OpenCodeEventSchema, capabilities: OpenCodeRunCap // 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" as const, values: protocols }]; + const structuredOutputs = capabilities.structuredOutputs ?? [{ option: "--format", values: protocols }]; if (!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; @@ -328,6 +353,7 @@ export function isOpenCodeSchemaBundle( ): value is OpenCodeSchemaBundle { if (!isRecord(value) || value.format !== 1 || value.runtime !== "opencode") 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; 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; @@ -360,7 +386,7 @@ export function openCodeSchemaBundleSigningPayload(bundle: OpenCodeSchemaBundle) export function verifyOpenCodeSchemaBundle( bundle: unknown, - publicKey: string, + publicKey: string | OpenCodeRegistryKeyring, now = Date.now(), options: { allowExpired?: boolean } = {}, ): bundle is OpenCodeSchemaBundle { @@ -372,27 +398,41 @@ export function verifyOpenCodeSchemaBundle( 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 key = createPublicKey(publicKey); - if (key.asymmetricKeyType !== "ed25519") return false; - return verify(null, Buffer.from(openCodeSchemaBundleSigningPayload(bundle)), key, signature); + 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 }; +type CachedBundle = { checkedAt: number; bundle: OpenCodeSchemaBundle; verifiedKeyId?: string }; function cachePath(): string { return path.join(caveHome(), CACHE_FILE); } +function cacheTrustPath(file: string): string { + return `${file}.trust`; +} + /** * 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 readCachedTrustState(file: string, publicKey: string, now: number): Promise { +async function readTrustedCacheRecord(file: string, publicKey: string | OpenCodeRegistryKeyring, now: number): Promise { try { const raw = await readFile(file, "utf8"); if (Buffer.byteLength(raw, "utf8") > MAX_SCHEMA_BUNDLE_BYTES) return null; @@ -401,6 +441,7 @@ async function readCachedTrustState(file: string, publicKey: string, now: number !isRecord(cached) || typeof cached.checkedAt !== "number" || !Number.isFinite(cached.checkedAt) + || (cached.verifiedKeyId !== undefined && (typeof cached.verifiedKeyId !== "string" || cached.bundle.keyId !== cached.verifiedKeyId)) || !verifyOpenCodeSchemaBundle(cached.bundle, publicKey, now, { allowExpired: true }) ) return null; return cached; @@ -488,7 +529,26 @@ async function staleLockCanBeReclaimed(lock: string): Promise { } } -async function readVerifiedCache(file: string, publicKey: string, now: number): Promise { +async function readCachedTrustState(file: string, publicKey: string | OpenCodeRegistryKeyring, now: number): Promise { + const [primary, backup] = await Promise.all([ + readTrustedCacheRecord(file, publicKey, now), + readTrustedCacheRecord(cacheTrustPath(file), publicKey, now), + ]); + if (!primary) return backup; + if (!backup) return primary; + // The sidecar is written before the replaceable cache payload. At an equal + // sequence it is the durable floor if a torn/manual primary differs. + return backup.bundle.sequence >= primary.bundle.sequence ? backup : primary; +} + +async function writeVerifiedCache(file: string, cached: CachedBundle): Promise { + // Persist the signed trust floor first: a later damaged/truncated primary + // file cannot erase the highest accepted sequence before the next refresh. + await writeJsonAtomic(cacheTrustPath(file), cached); + await writeJsonAtomic(file, cached); +} + +async function readVerifiedCache(file: string, publicKey: string | OpenCodeRegistryKeyring, now: number): Promise { const cached = await readCachedTrustState(file, publicKey, now); if ( !cached @@ -553,6 +613,8 @@ async function withCacheWriteLock(file: string, callback: () => Promise): export type OpenCodeSchemaBundleSource = { url?: string; publicKey?: string; + /** Bounded active-plus-previous keyring for staged registry-key rotation. */ + publicKeys?: OpenCodeRegistryKeyring; fetch?: typeof fetch; now?: () => number; cacheFile?: string; @@ -565,45 +627,70 @@ export type OpenCodeSchemaBundleSource = { // packaged trust anchor. 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; + +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 { + const configured = source.publicKeys + ?? parseKeyring(process.env.COVEN_OPENCODE_SCHEMA_REGISTRY_PUBLIC_KEYS) + ?? parseKeyring(PACKAGED_REGISTRY_PUBLIC_KEYS); + if (configured) return configured; + const single = source.publicKey ?? process.env.COVEN_OPENCODE_SCHEMA_REGISTRY_PUBLIC_KEY ?? PACKAGED_REGISTRY_PUBLIC_KEY; + return single ? { legacy: single } : undefined; +} -function schemaRefreshKey(file: string, url: string, publicKey: string): string { - return createHash("sha256").update(`${file}\0${url}\0${publicKey}`).digest("hex"); +function schemaRefreshKey(file: string, url: string, publicKeys: OpenCodeRegistryKeyring): string { + return createHash("sha256").update(`${file}\0${url}\0${stableJson(publicKeys)}`).digest("hex"); } async function refreshOpenCodeSchemaBundle( file: string, url: string, - publicKey: string, + publicKeys: OpenCodeRegistryKeyring, 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, publicKey, now)) throw new Error("invalid schema signature"); - const cachedTrust = await readCachedTrustState(file, publicKey, now); + if (!verifyOpenCodeSchemaBundle(remote, publicKeys, now)) throw new Error("invalid schema signature"); + if (Object.keys(publicKeys).length > 1 && remote.keyId === undefined) throw new Error("missing schema signing key id"); + const cachedTrust = await readCachedTrustState(file, publicKeys, now); if (cachedTrust && remote.sequence < cachedTrust.bundle.sequence) throw new Error("schema rollback"); await mkdir(path.dirname(file), { recursive: true }); const writeResult = await withCacheWriteLock(file, async () => { // 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, publicKey, now); + const currentTrust = await readCachedTrustState(file, publicKeys, now); 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 writeJsonAtomic(file, { checkedAt: now, bundle: remote }); + await writeVerifiedCache(file, { checkedAt: now, bundle: remote, verifiedKeyId: remote.keyId }); return { bundle: remote, source: "remote" as const }; } - await writeJsonAtomic(file, { checkedAt: now, bundle: remote }); + await writeVerifiedCache(file, { checkedAt: now, bundle: remote, verifiedKeyId: remote.keyId }); 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 readVerifiedCache(file, publicKey, now); + const current = await readVerifiedCache(file, publicKeys, now); if (current && current.bundle.sequence >= remote.sequence) { return { bundle: current.bundle, source: "cache" }; } @@ -641,18 +728,18 @@ export async function loadOpenCodeSchemaBundle(source: OpenCodeSchemaBundleSourc const now = source.now?.() ?? Date.now(); const file = source.cacheFile ?? cachePath(); const url = source.url ?? process.env.COVEN_OPENCODE_SCHEMA_REGISTRY_URL ?? PACKAGED_REGISTRY_URL; - const publicKey = source.publicKey ?? process.env.COVEN_OPENCODE_SCHEMA_REGISTRY_PUBLIC_KEY ?? PACKAGED_REGISTRY_PUBLIC_KEY; - if (!url || !publicKey) return { bundle: BUILTIN_OPENCODE_SCHEMA_BUNDLE, source: "built-in" }; + const publicKeys = registryKeyring(source); + if (!url || !publicKeys) return { bundle: BUILTIN_OPENCODE_SCHEMA_BUNDLE, source: "built-in" }; - const cached = await readVerifiedCache(file, publicKey, now); + const cached = await readVerifiedCache(file, publicKeys, now); // 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. - const cacheTrust = cached ?? await readCachedTrustState(file, publicKey, now); + const cacheTrust = cached ?? await readCachedTrustState(file, publicKeys, now); const cacheFresh = cached && now - cached.checkedAt < CACHE_TTL_MS; if (cacheFresh) return { bundle: cached.bundle, source: "cache" }; - const key = schemaRefreshKey(file, url, publicKey); + const key = schemaRefreshKey(file, url, publicKeys); if ((refreshRetryAt.get(key) ?? 0) > now) { return cached ? { bundle: cached.bundle, source: "cache", diagnostic: "schema-registry-refresh-rejected" } @@ -662,7 +749,7 @@ export async function loadOpenCodeSchemaBundle(source: OpenCodeSchemaBundleSourc } const refresh = startSchemaRefresh( key, - () => refreshOpenCodeSchemaBundle(file, url, publicKey, source.fetch ?? fetch, now, source.refreshTimeoutMs), + () => refreshOpenCodeSchemaBundle(file, url, publicKeys, source.fetch ?? fetch, now, source.refreshTimeoutMs), now, ); try { diff --git a/src/lib/opencode-stream.test.ts b/src/lib/opencode-stream.test.ts index fdb926bcc..e9c59920b 100644 --- a/src/lib/opencode-stream.test.ts +++ b/src/lib/opencode-stream.test.ts @@ -208,8 +208,8 @@ const shapedSchema = { ...BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0], id: "opencode-run-json-shaped-v2", shape: { - envelope: ["payload"], - discriminator: { envelope: "payload", field: "event" }, + envelope: [["event", "payload"]], + discriminator: { envelope: ["event", "payload"], field: "event" }, sessionId: ["session"], id: ["call_id"], name: ["tool_name"], @@ -224,7 +224,7 @@ const shapedSchema = { }; assert.deepEqual( parseOpenCodeRunEvent( - { payload: { event: "tool", session: "ses_v2", call_id: "call_v2", tool_name: "Read", phase: { state: "done", arguments: { path: "README.md" }, result: "ok" } } }, + { event: { payload: { event: "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 }, diff --git a/src/lib/opencode-stream.ts b/src/lib/opencode-stream.ts index 03c333881..d6ccf3c8c 100644 --- a/src/lib/opencode-stream.ts +++ b/src/lib/opencode-stream.ts @@ -1,4 +1,4 @@ -import type { OpenCodeEventSchema } from "@/lib/opencode-compatibility"; +import type { OpenCodeEnvelopePath, OpenCodeEventSchema } from "@/lib/opencode-compatibility"; export type OpenCodeRunEvent = | { kind: "ignore"; sessionId?: string } @@ -45,11 +45,13 @@ function valueAt(recordValue: Record | null, keys: string[]): u function envelope( event: Record, - envelopes: Array<"part" | "data" | "payload" | "root">, + envelopes: OpenCodeEnvelopePath[], ): Record | null { - for (const field of envelopes) { - if (field === "root") return event; - const candidate = record(event[field]); + 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; From ececa1eb7f2be76de98fea97213b019b56671455 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 00:36:38 -0400 Subject: [PATCH 024/112] fix(chat): preserve OpenCode resume state --- scripts/run-tests.mjs | 2 + .../chat/send/chat-send-capabilities.test.ts | 20 ++++++++ .../api/chat/send/chat-send-capabilities.ts | 13 ++++- .../send/harness-routing-opencode.test.ts | 13 +++-- src/app/api/chat/send/route.ts | 50 +++++++++++++++---- src/lib/opencode-compatibility.test.ts | 20 ++++++++ src/lib/opencode-compatibility.ts | 11 ++-- 7 files changed, 111 insertions(+), 18 deletions(-) create mode 100644 src/app/api/chat/send/chat-send-capabilities.test.ts diff --git a/scripts/run-tests.mjs b/scripts/run-tests.mjs index 8497dc917..d3fe99b11 100644 --- a/scripts/run-tests.mjs +++ b/scripts/run-tests.mjs @@ -1023,6 +1023,7 @@ 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/offline-queue.test.ts", "src/app/api/chat/send/first-turn-stub.test.ts", "src/app/api/onboarding/status/route.test.ts", @@ -1282,6 +1283,7 @@ const STRIP_TYPES_MJS = new Set([ const ALIAS_LOADER = new Set([ "src/lib/opencode-compatibility.test.ts", "src/lib/opencode-stream.test.ts", + "src/app/api/chat/send/chat-send-capabilities.test.ts", "src/lib/familiar-workspace-sessions.test.ts", "scripts/cave-home-migration-windows.test.ts", "src/lib/bento-dashboard.test.ts", 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..797305b83 --- /dev/null +++ b/src/app/api/chat/send/chat-send-capabilities.test.ts @@ -0,0 +1,20 @@ +// @ts-nocheck +import assert from "node:assert/strict"; +import { parseOpenCodeRunCapabilitiesHelp } from "./chat-send-capabilities.ts"; + +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"]); + +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 e4b27eee9..14c32e603 100644 --- a/src/app/api/chat/send/chat-send-capabilities.ts +++ b/src/app/api/chat/send/chat-send-capabilities.ts @@ -123,6 +123,15 @@ 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) => { + const line = help.match(new RegExp(`^\\s*(?:-[A-Za-z],?\\s+)?${option}\\b([^\\n]*)$`, "m"))?.[1] ?? ""; + // An argument placeholder or equals syntax means the schema cannot safely + // forward this as a no-value flag, even if it is declared by the client. + return !/[<\[=]/.test(line); + }); +} + function advertisedFormatProtocols(help: string): string[] { const outputs = advertisedStructuredOutputs(help); // A protocol marker is useful only when the CLI advertises it as an output @@ -138,6 +147,7 @@ function advertisedFormatProtocols(help: string): string[] { */ export function parseOpenCodeRunCapabilitiesHelp(help: string, version: string | null): OpenCodeRunCapabilities { const options = declaredRunOptions(help); + const noValueOptions = declaredNoValueRunOptions(help, options); const structuredOutputs = advertisedStructuredOutputs(help); const protocols = advertisedFormatProtocols(help); const json = protocols.some((protocol) => protocol === "json" || protocol.startsWith("json-") || protocol.startsWith("json_")); @@ -155,6 +165,7 @@ export function parseOpenCodeRunCapabilitiesHelp(help: string, version: string | // from the installed version string. protocols, options, + noValueOptions, structuredOutputs, }; } @@ -231,7 +242,7 @@ export function openCodeRunCapabilities(): Promise { // Partial, timed-out, non-zero, or oversized help is never capability // evidence. Probe again after the short TTL instead of risking an argv // that the installed client does not accept. - if (!helpProbe.complete) return { version, json: false, model: false, session: false, protocols: [], options: [], structuredOutputs: [] }; + if (!helpProbe.complete) return { version, json: false, model: false, session: false, protocols: [], options: [], noValueOptions: [], structuredOutputs: [] }; return parseOpenCodeRunCapabilitiesHelp(helpProbe.output, version); })(); openCodeCapabilitiesProbe = { until: Date.now() + OPENCODE_CAPABILITY_PROBE_TTL_MS, value }; 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 0e52b39b5..d43df0a61 100644 --- a/src/app/api/chat/send/harness-routing-opencode.test.ts +++ b/src/app/api/chat/send/harness-routing-opencode.test.ts @@ -17,8 +17,8 @@ assert.match( ); assert.match( route, - /handleOpenCodeJsonLine\(line, openCodeCompatibility\?\.schema, \{[\s\S]*?onSession: \(nativeSessionId\) => \{[\s\S]*?announceSession\(nativeSessionId\);/, - "the first structured OpenCode event persists its minted session id", + /let openCodeSessionId: string \| null = null;[\s\S]*?onSession: \(nativeSessionId\) => \{[\s\S]*?openCodeSessionId = nativeSessionId;[\s\S]*?if \(!sessionId\) announceSession\(nativeSessionId\);[\s\S]*?openCodeSessionId \?\? existingConversation\?\.harnessSessionId/, + "every structured OpenCode session event updates native resume state without replacing Cave's stable session id", ); assert.match( route, @@ -70,6 +70,11 @@ assert.match( /onOther: \(ev, rawEvent\) => \{[\s\S]*?unrecognized event; that event was skipped while compatible tool activity continues/, "an unknown OpenCode envelope is skipped without suppressing later compatible tool activity", ); +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,/, @@ -102,8 +107,8 @@ assert.match( ); assert.match( route, - /openCodeDirect && body\.sessionId && existingConversation && \([\s\S]*?!openCodeCompatibility\?\.capabilities\.session[\s\S]*?\|\| !existingConversation\.harnessSessionId[\s\S]*?buildResumeRetryPrompt\(harnessPrompt, existingConversation\)/, - "OpenCode replays Cave context only when a native resume option or token is unavailable", + /const openCodeFreshSessionForCompatibility = Boolean\([\s\S]*?openCodeUnrecordedResume[\s\S]*?Boolean\(existingConversation && \([\s\S]*?!openCodeCompatibility\?\.capabilities\.session[\s\S]*?\|\| !existingConversation\.harnessSessionId[\s\S]*?buildResumeRetryPrompt\(harnessPrompt, existingConversation\)/, + "OpenCode replays Cave context only when a native resume option or recorded native token is unavailable", ); assert.match( route, diff --git a/src/app/api/chat/send/route.ts b/src/app/api/chat/send/route.ts index f76511982..5f3801773 100644 --- a/src/app/api/chat/send/route.ts +++ b/src/app/api/chat/send/route.ts @@ -1408,7 +1408,10 @@ export async function POST(req: Request) { ? null : body.sessionId ? openCodeDirect - ? existingConversation?.harnessSessionId ?? null + // 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 @@ -1424,14 +1427,26 @@ 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.options?.includes("--session") + || openCodeCompatibility?.capabilities.options?.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 && existingConversation && ( - !openCodeCompatibility?.capabilities.session - || !existingConversation.harnessSessionId + openCodeDirect && body.sessionId && ( + openCodeUnrecordedResume + || Boolean(existingConversation && ( + !openCodeCompatibility?.capabilities.session + || !existingConversation.harnessSessionId + )) ), ); const openCodeSessionUnavailable = !openCodeCompatibility?.capabilities.session; @@ -1537,6 +1552,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; // First-turn visibility (cave-0g2x): the id of the in-flight user turn, // minted up front so the announce-time stub conversation and the // end-of-stream authoritative save agree on the turn's identity. @@ -1614,7 +1633,11 @@ 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 @@ -1656,7 +1679,9 @@ export async function POST(req: Request) { // 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 (openCodeDirect && openCodeCompatibility?.mode === "plain" && !sessionId) { + if (openCodeUnrecordedResume) { + announceSession(crypto.randomUUID()); + } else if (openCodeDirect && openCodeCompatibility?.mode === "plain" && !sessionId) { announceSession(crypto.randomUUID()); } @@ -1824,6 +1849,7 @@ export async function POST(req: Request) { } handleOpenCodeJsonLine(line, openCodeCompatibility?.schema, { onSession: (nativeSessionId) => { + openCodeSessionId = nativeSessionId; if (!sessionId) announceSession(nativeSessionId); }, onText: (ev) => { @@ -2301,7 +2327,9 @@ export async function POST(req: Request) { if (openCodeFreshSessionForCompatibility) { pushProgress( "opencode-compatibility", - openCodeCompatibilityRetry?.replayedHistory + 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" @@ -2507,7 +2535,9 @@ export async function POST(req: Request) { ? grokSessionId : openCodeDirect && openCodeCompatibility?.mode === "plain" ? undefined - : sessionId; + : openCodeDirect + ? openCodeSessionId ?? existingConversation?.harnessSessionId ?? (!existingConversation ? body.sessionId : undefined) + : sessionId; // OpenCode's JSON event protocol does not echo the selected model. Its // direct argv proves the selection was forwarded, while a successful // exit is the only confirmation it was applied. Preserve an explicit @@ -2537,7 +2567,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); diff --git a/src/lib/opencode-compatibility.test.ts b/src/lib/opencode-compatibility.test.ts index 017f22ca4..bfde6eacc 100644 --- a/src/lib/opencode-compatibility.test.ts +++ b/src/lib/opencode-compatibility.test.ts @@ -200,6 +200,7 @@ const structuredSwitchCapabilities = { session: false, protocols: ["json-v3"], options: ["--structured-output", "--event-stream"], + noValueOptions: ["--event-stream"], structuredOutputs: [{ option: "--structured-output", values: ["json-v3"] }], }; const structuredSwitchSchema = { @@ -218,6 +219,25 @@ assert.equal( "structured-switch", "a structured switch is selected only after its option and JSON value are 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), + true, + "signed schemas may declare bounded harmless no-value framing flags", +); +assert.equal( + selectOpenCodeSchema([framingSchema], { + ...structuredSwitchCapabilities, + options: ["--structured-output", "--event-stream", "--no-color"], + noValueOptions: ["--event-stream", "--no-color"], + })?.id, + "structured-framing", + "companion flags require both a declaration and no-value capability evidence", +); assert.equal( isOpenCodeSchemaBundle({ ...unsigned, issuedAt: 0 }, now), false, diff --git a/src/lib/opencode-compatibility.ts b/src/lib/opencode-compatibility.ts index 20ecbc1b8..006675494 100644 --- a/src/lib/opencode-compatibility.ts +++ b/src/lib/opencode-compatibility.ts @@ -13,6 +13,8 @@ export type OpenCodeRunCapabilities = { protocols: string[]; /** Declared `run` option names and structured-output option/value pairs. */ options?: string[]; + /** Declared run flags that take no value and are safe to forward verbatim. */ + noValueOptions?: string[]; structuredOutputs?: Array<{ option: string; values: string[] }>; }; @@ -243,7 +245,7 @@ const SAFE_STRUCTURED_LAUNCH_OPTION = /^--[a-z0-9-]*(?:format|output|json|event| const UNSAFE_STRUCTURED_LAUNCH_OPTIONS = new Set([ "--auto", "--permission", "--sandbox", "--skip-permissions", "--dangerously-skip-permissions", "--trust-all-tools", "--yolo", ]); -const SAFE_STRUCTURED_REQUIRED_FLAGS = new Set(["--event-stream"]); +const SAFE_STRUCTURED_REQUIRED_FLAG = /^--[A-Za-z][A-Za-z0-9-]{0,78}$/; function safeStructuredLaunchOption(value: unknown): value is string { return typeof value === "string" @@ -261,8 +263,8 @@ function hasValidLaunch(value: unknown, requires: Record): bool if (structuredOutput.value !== (typeof requires.protocol === "string" ? requires.protocol : "json")) return false; if (value.sessionOption !== undefined && value.sessionOption !== "--session" && value.sessionOption !== "--resume") return false; if (requires.session === true && value.sessionOption === undefined) return false; - return value.requiredFlags.length <= 1 - && value.requiredFlags.every((flag) => typeof flag === "string" && SAFE_STRUCTURED_REQUIRED_FLAGS.has(flag)) + return value.requiredFlags.length <= 4 + && 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); } @@ -307,7 +309,8 @@ function schemaMatches(schema: OpenCodeEventSchema, capabilities: OpenCodeRunCap 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)) return false; - if (schema.launch.requiredFlags.some((flag) => !options.has(flag))) return false; + const noValueOptions = new Set(capabilities.noValueOptions ?? []); + if (schema.launch.requiredFlags.some((flag) => !options.has(flag) || !noValueOptions.has(flag))) return false; return true; } From f88cd880586ec24c04bbf6cfbd38ac6e0786f4d2 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 00:44:11 -0400 Subject: [PATCH 025/112] fix(chat): bound OpenCode compatibility state --- src/lib/chat-tool-events.test.ts | 13 ++++++++++++- src/lib/chat-tool-events.ts | 21 ++++++++++++++++++--- src/lib/opencode-compatibility.test.ts | 16 ++++++++++++++++ src/lib/opencode-compatibility.ts | 8 ++++++++ 4 files changed, 54 insertions(+), 4 deletions(-) diff --git a/src/lib/chat-tool-events.test.ts b/src/lib/chat-tool-events.test.ts index cd1b382a4..3c66a96c4 100644 --- a/src/lib/chat-tool-events.test.ts +++ b/src/lib/chat-tool-events.test.ts @@ -1,6 +1,6 @@ // @ts-nocheck import assert from "node:assert/strict"; -import { ToolCallTracker, capLiveToolPayload, toPersistedTools } from "./chat-tool-events.ts"; +import { 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); @@ -32,4 +32,15 @@ assert.ok( "unicode tool payloads respect byte rather than UTF-16 code-unit caps", ); +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"); + 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 995c46f43..703509473 100644 --- a/src/lib/chat-tool-events.ts +++ b/src/lib/chat-tool-events.ts @@ -42,6 +42,8 @@ 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; function utf8Bytes(value: string | undefined): number { return value === undefined ? 0 : new TextEncoder().encode(value).byteLength; @@ -167,13 +169,26 @@ export class ToolCallTracker { 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, @@ -239,10 +254,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. diff --git a/src/lib/opencode-compatibility.test.ts b/src/lib/opencode-compatibility.test.ts index bfde6eacc..ba83c6dfa 100644 --- a/src/lib/opencode-compatibility.test.ts +++ b/src/lib/opencode-compatibility.test.ts @@ -156,6 +156,22 @@ assert.equal( ); assert.equal(isOpenCodeSchemaBundle({ ...unsigned, schemas: [] }, now), false, "empty signed bundles cannot replace a working parser set"); +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", diff --git a/src/lib/opencode-compatibility.ts b/src/lib/opencode-compatibility.ts index 006675494..018aa5d80 100644 --- a/src/lib/opencode-compatibility.ts +++ b/src/lib/opencode-compatibility.ts @@ -256,8 +256,10 @@ function safeStructuredLaunchOption(value: unknown): value is string { 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")) 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 (typeof structuredOutput.value !== "string" || !/^[a-z0-9][a-z0-9._-]{0,63}$/i.test(structuredOutput.value)) return false; if (structuredOutput.value !== (typeof requires.protocol === "string" ? requires.protocol : "json")) return false; @@ -271,6 +273,7 @@ function hasValidLaunch(value: unknown, requires: Record): bool 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 === "requires" || key === "eventTypes" || key === "shape" || key === "launch")) 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; @@ -355,8 +358,13 @@ export function isOpenCodeSchemaBundle( 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 === "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.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; From 4c05821c736ac32bb14a236b55e08b41b07d6387 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 00:46:36 -0400 Subject: [PATCH 026/112] fix(chat): revalidate OpenCode capabilities --- .../chat/send/chat-send-capabilities.test.ts | 25 +++++++++- .../api/chat/send/chat-send-capabilities.ts | 46 +++++++++++++++---- .../send/harness-routing-opencode.test.ts | 8 ++-- src/app/api/chat/send/route.ts | 4 +- 4 files changed, 68 insertions(+), 15 deletions(-) diff --git a/src/app/api/chat/send/chat-send-capabilities.test.ts b/src/app/api/chat/send/chat-send-capabilities.test.ts index 797305b83..a446540d5 100644 --- a/src/app/api/chat/send/chat-send-capabilities.test.ts +++ b/src/app/api/chat/send/chat-send-capabilities.test.ts @@ -1,6 +1,9 @@ // @ts-nocheck import assert from "node:assert/strict"; -import { parseOpenCodeRunCapabilitiesHelp } from "./chat-send-capabilities.ts"; +import { mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { openCodeExecutableIdentity, parseOpenCodeRunCapabilitiesHelp } from "./chat-send-capabilities.ts"; const capabilities = parseOpenCodeRunCapabilitiesHelp(` --structured-output Output format: text, json-v3 @@ -17,4 +20,24 @@ assert.deepEqual( 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 launcherA = await mkdtemp(path.join(tmpdir(), "cave-opencode-launch-a-")); +const launcherB = await mkdtemp(path.join(tmpdir(), "cave-opencode-launch-b-")); +const executable = process.platform === "win32" ? "opencode.cmd" : "opencode"; +await writeFile(path.join(launcherA, executable), "first"); +await writeFile(path.join(launcherB, executable), "second"); +const identityA = await openCodeExecutableIdentity({ PATH: launcherA }); +const identityB = await openCodeExecutableIdentity({ PATH: launcherB }); +assert.notEqual(identityA, identityB, "capability-cache identity changes when PATH resolves a different OpenCode executable"); + 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 14c32e603..1036ccd85 100644 --- a/src/app/api/chat/send/chat-send-capabilities.ts +++ b/src/app/api/chat/send/chat-send-capabilities.ts @@ -1,4 +1,6 @@ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { stat } from "node:fs/promises"; +import path from "node:path"; import { covenLaunchCommand } from "@/lib/coven-bin"; import { covenRunSupportsAddDirFlag, @@ -14,7 +16,7 @@ let permissionFlagProbe: Promise | null = null; let addDirFlagProbe: Promise | null = null; let hermesModelFlagProbe: Promise | null = null; let openCodeModelFlagProbe: Promise | null = null; -let openCodeCapabilitiesProbe: { until: number; value: Promise } | null = null; +let openCodeCapabilitiesProbe: { until: number; identity: string; value: Promise } | null = null; const OPENCODE_CAPABILITY_PROBE_TTL_MS = 60_000; function probeHelp( @@ -125,13 +127,40 @@ function declaredRunOptions(help: string): string[] { function declaredNoValueRunOptions(help: string, options: string[]): string[] { return options.filter((option) => { - const line = help.match(new RegExp(`^\\s*(?:-[A-Za-z],?\\s+)?${option}\\b([^\\n]*)$`, "m"))?.[1] ?? ""; - // An argument placeholder or equals syntax means the schema cannot safely - // forward this as a no-value flag, even if it is declared by the client. - return !/[<\[=]/.test(line); + // 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. + const line = help.match(new RegExp(`^\\s*(?:-[A-Za-z],?\\s+)?${option}\\b(?=$|\\s{2,})([^\\n]*)$`, "m"))?.[1]; + return line !== undefined && !/[<\[=]/.test(line); }); } +/** + * Identifies the executable the next OpenCode spawn will resolve. The PATH + * lookup is repeated before using a cached capability probe so an in-place + * upgrade, reinstall, or PATH change cannot reuse stale argv evidence. + */ +export async function openCodeExecutableIdentity( + env = openCodeSpawnEnv(), + platform: NodeJS.Platform = process.platform, +): Promise { + const pathValue = env.PATH ?? env.Path ?? ""; + const names = platform === "win32" ? ["opencode.cmd", "opencode.exe", "opencode.bat", "opencode"] : ["opencode"]; + for (const directory of pathValue.split(path.delimiter).filter(Boolean)) { + for (const name of names) { + const candidate = path.join(directory, name); + try { + const info = await stat(candidate); + if (info.isFile()) return `${candidate}\0${info.size}\0${info.mtimeMs}`; + } catch { + // Continue through PATH; an unresolved launcher is still fingerprinted + // below so a later install changes the cache key. + } + } + } + return `unresolved\0${platform}\0${pathValue}`; +} + function advertisedFormatProtocols(help: string): string[] { const outputs = advertisedStructuredOutputs(help); // A protocol marker is useful only when the CLI advertises it as an output @@ -225,8 +254,9 @@ export function openCodeRunSupportsModel(): Promise { * The version is retained for support diagnostics only; it never gates a * schema because vendors can backport or change protocol behavior. */ -export function openCodeRunCapabilities(): Promise { - if (openCodeCapabilitiesProbe && Date.now() < openCodeCapabilitiesProbe.until) { +export async function openCodeRunCapabilities(): Promise { + const identity = await openCodeExecutableIdentity(); + if (openCodeCapabilitiesProbe && Date.now() < openCodeCapabilitiesProbe.until && openCodeCapabilitiesProbe.identity === identity) { return openCodeCapabilitiesProbe.value; } const value = (async () => { @@ -245,6 +275,6 @@ export function openCodeRunCapabilities(): Promise { if (!helpProbe.complete) return { version, json: false, model: false, session: false, protocols: [], options: [], noValueOptions: [], structuredOutputs: [] }; return parseOpenCodeRunCapabilitiesHelp(helpProbe.output, version); })(); - openCodeCapabilitiesProbe = { until: Date.now() + OPENCODE_CAPABILITY_PROBE_TTL_MS, value }; + openCodeCapabilitiesProbe = { until: Date.now() + OPENCODE_CAPABILITY_PROBE_TTL_MS, identity, value }; return value; } 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 d43df0a61..7047196ba 100644 --- a/src/app/api/chat/send/harness-routing-opencode.test.ts +++ b/src/app/api/chat/send/harness-routing-opencode.test.ts @@ -107,8 +107,8 @@ assert.match( ); assert.match( route, - /const openCodeFreshSessionForCompatibility = Boolean\([\s\S]*?openCodeUnrecordedResume[\s\S]*?Boolean\(existingConversation && \([\s\S]*?!openCodeCompatibility\?\.capabilities\.session[\s\S]*?\|\| !existingConversation\.harnessSessionId[\s\S]*?buildResumeRetryPrompt\(harnessPrompt, existingConversation\)/, - "OpenCode replays Cave context only when a native resume option or recorded native token is unavailable", + /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, @@ -137,8 +137,8 @@ assert.match( ); assert.match( capabilities, - /export function openCodeRunCapabilities\([^)]*\)[\s\S]*?\["--version"\][\s\S]*?json:[\s\S]*?model:[\s\S]*?session:/, - "OpenCode discovers feature support and records version only for diagnostics", + /export async function openCodeRunCapabilities\([^)]*\)[\s\S]*?openCodeExecutableIdentity\(\)[\s\S]*?\["--version"\][\s\S]*?json:[\s\S]*?model:[\s\S]*?session:/, + "OpenCode discovers feature support from the current executable and records version only for diagnostics", ); console.log("opencode harness routing tests passed"); diff --git a/src/app/api/chat/send/route.ts b/src/app/api/chat/send/route.ts index 5f3801773..c21825c94 100644 --- a/src/app/api/chat/send/route.ts +++ b/src/app/api/chat/send/route.ts @@ -1444,12 +1444,12 @@ export async function POST(req: Request) { openCodeDirect && body.sessionId && ( openCodeUnrecordedResume || Boolean(existingConversation && ( - !openCodeCompatibility?.capabilities.session + !openCodeNativeResumeSupported || !existingConversation.harnessSessionId )) ), ); - const openCodeSessionUnavailable = !openCodeCompatibility?.capabilities.session; + const openCodeSessionUnavailable = !openCodeNativeResumeSupported; const openCodeCompatibilityRetry = openCodeFreshSessionForCompatibility ? buildResumeRetryPrompt(harnessPrompt, existingConversation) : null; From 76c86229f3aaa81a1db5bb36a6963c3bbb514ab0 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 00:55:03 -0400 Subject: [PATCH 027/112] test(chat): cover unrecorded OpenCode resume --- .../chat/send/harness-routing-host-session.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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 35cb4ca8f..578bf2611 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*\? openCodeDirect\s*\? existingConversation\?\.harnessSessionId \?\? null\s*:\s*existingConversation\?\.harnessSessionId \?\? body\.sessionId/, - "OpenCode resumes only a native session token; other harnesses retain the stable conversation-id fallback", + /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, From b85b920b5f158724fa00719948d418e50cfe6dc2 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 01:01:39 -0400 Subject: [PATCH 028/112] fix(chat): harden OpenCode runtime recovery --- .../api/chat/send/chat-send-capabilities.ts | 13 ++- .../send/harness-routing-opencode.test.ts | 26 +++-- src/app/api/chat/send/route.ts | 95 ++++++++++++++++--- src/lib/opencode-compatibility.test.ts | 35 +++++++ src/lib/opencode-compatibility.ts | 21 +++- src/lib/opencode-stream.ts | 8 +- 6 files changed, 170 insertions(+), 28 deletions(-) diff --git a/src/app/api/chat/send/chat-send-capabilities.ts b/src/app/api/chat/send/chat-send-capabilities.ts index 1036ccd85..23f516654 100644 --- a/src/app/api/chat/send/chat-send-capabilities.ts +++ b/src/app/api/chat/send/chat-send-capabilities.ts @@ -254,8 +254,13 @@ export function openCodeRunSupportsModel(): Promise { * 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(): Promise { - const identity = await openCodeExecutableIdentity(); +export async function openCodeRunCapabilities(familiarId?: string): Promise { + // The help/version probes must resolve exactly the binary and scoped vault + // environment that will execute the chat turn. Include the familiar scope + // in the cache key even when two scopes currently resolve the same binary. + const env = openCodeSpawnEnv(familiarId); + const executableIdentity = await openCodeExecutableIdentity(env); + const identity = `${familiarId ?? "default"}\0${executableIdentity}`; if (openCodeCapabilitiesProbe && Date.now() < openCodeCapabilitiesProbe.until && openCodeCapabilitiesProbe.identity === identity) { return openCodeCapabilitiesProbe.value; } @@ -263,8 +268,8 @@ export async function openCodeRunCapabilities(): Promise \{[\s\S]*?unrecognized event; that event was skipped while compatible tool activity continues/, - "an unknown OpenCode envelope is skipped without suppressing later compatible tool activity", + /let openCodePlainFallback = false;[\s\S]*?onOther: \(ev, rawEvent\) => \{[\s\S]*?openCodeStructuredIncompatibility = true;[\s\S]*?structured-stream-quarantined[\s\S]*?openCodePlainFallback = true;[\s\S]*?await runAttempt\(buildArgs\(null, retry\.prompt\)\)/, + "an unknown OpenCode envelope quarantines structured parsing and safely retries only a no-activity turn in plain chat", ); assert.match( route, @@ -90,6 +95,11 @@ assert.match( /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/, @@ -102,8 +112,8 @@ assert.match( ); assert.match( route, - /openCodeCompatibility\?\.mode === "plain"[\s\S]*?\? undefined[\s\S]*?: sessionId/, - "a Cave-owned plain-mode id is never mistaken for a native OpenCode resume token", + /const harnessSessionId = grokDirect[\s\S]*?: openCodeDirect[\s\S]*?openCodeSessionId \?\? existingConversation\?\.harnessSessionId \?\? \(!existingConversation \? body\.sessionId : undefined\)/, + "a plain OpenCode turn retains the native id it used to resume without mistaking Cave's stable id for a new native token", ); assert.match( route, @@ -132,13 +142,13 @@ assert.match( ); assert.match( route, - /const handleOpenCodeLine[\s\S]*?onMalformedJson: \(\) => \{[\s\S]*?recordStdoutErrorTail\("OpenCode emitted a malformed JSON event", true\)/, - "malformed structured OpenCode events never copy their raw payload into diagnostics", + /const handleOpenCodeLine[\s\S]*?openCodePlainFallback && line\.startsWith\("\{"\)[\s\S]*?onMalformedJson: \(\) => \{[\s\S]*?openCodeStructuredIncompatibility = true;[\s\S]*?recordStdoutErrorTail\("OpenCode emitted a malformed JSON event", true\)/, + "malformed structured OpenCode events quarantine parsing without copying raw JSON into assistant text or diagnostics", ); assert.match( capabilities, - /export async function openCodeRunCapabilities\([^)]*\)[\s\S]*?openCodeExecutableIdentity\(\)[\s\S]*?\["--version"\][\s\S]*?json:[\s\S]*?model:[\s\S]*?session:/, - "OpenCode discovers feature support from the current executable and records version only for diagnostics", + /export async function openCodeRunCapabilities\(familiarId\?[^)]*\)[\s\S]*?const env = openCodeSpawnEnv\(familiarId\);[\s\S]*?openCodeExecutableIdentity\(env\)[\s\S]*?const versionLaunch = openCodeLaunch\(\["--version"\]\);[\s\S]*?probeOutput\(helpLaunch\.command, helpLaunch\.args, env[\s\S]*?json:[\s\S]*?model:[\s\S]*?session:/, + "OpenCode discovers feature support from the scoped executable environment and records version only for diagnostics", ); console.log("opencode harness routing tests passed"); diff --git a/src/app/api/chat/send/route.ts b/src/app/api/chat/send/route.ts index c21825c94..fd5075ace 100644 --- a/src/app/api/chat/send/route.ts +++ b/src/app/api/chat/send/route.ts @@ -936,8 +936,13 @@ export async function POST(req: Request) { const hermesDirect = !sshRuntime && binding.harness === "hermes"; const openCodeDirect = !sshRuntime && binding.harness === "opencode"; const openCodeCompatibility = openCodeDirect - ? await resolveOpenCodeCompatibility(await openCodeRunCapabilities()) + ? await resolveOpenCodeCompatibility(await openCodeRunCapabilities(body.familiarId)) : null; + // A selected structured schema is quarantined for the remainder of this + // request after it observes an incompatible frame. The retry below launches + // without the structured-output switch; never reinterpret that frame as + // assistant text. + let openCodePlainFallback = false; const modelForwardingEnabled = hermesDirect ? await hermesChatSupportsModel() @@ -1365,7 +1370,7 @@ export async function POST(req: Request) { // 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") { + if (openCodeCompatibility?.mode === "structured" && !openCodePlainFallback) { const launch = openCodeCompatibility.schema!.launch; a.push(launch.structuredOutput.option, launch.structuredOutput.value, ...launch.requiredFlags); if (resumeSessionId && launch.sessionOption) a.push(launch.sessionOption, resumeSessionId); @@ -1616,6 +1621,11 @@ export async function POST(req: Request) { let adapterConflict: BuiltinAdapterConflict | null = null; let openCodeCompatibilityNoticeSent = false; let openCodeModelRejected = false; + // Unknown or malformed JSON proves that the selected schema no longer + // describes this stream. A fresh plain attempt is safe only if this + // attempt has not emitted any trusted assistant/tool/error activity. + let openCodeStructuredIncompatibility = false; + let openCodeStructuredTrustedActivity = false; // Model parity: the harness echoes its resolved model on the init/system // stream event. Capturing it lets the application state render honestly as @@ -1841,7 +1851,16 @@ export async function POST(req: Request) { }; const handleOpenCodeLine = (line: string) => { - if (openCodeCompatibility?.mode === "plain") { + if (openCodeCompatibility?.mode === "plain" || openCodePlainFallback) { + // The retry deliberately asks OpenCode for plain output. If it still + // sends a JSON-shaped envelope, do not promote provider-controlled + // fields to assistant text merely because structured parsing was + // quarantined. + if (openCodePlainFallback && line.startsWith("{") && line.endsWith("}")) { + recordStdoutErrorTail("OpenCode emitted JSON during plain compatibility fallback", true); + result = { ...result, is_error: true }; + return; + } const text = `${resolveBackspaces(stripAnsi(line))}\n`; assistantText += text; push({ kind: "assistant_chunk", text }); @@ -1853,11 +1872,13 @@ export async function POST(req: Request) { if (!sessionId) announceSession(nativeSessionId); }, onText: (ev) => { + openCodeStructuredTrustedActivity = true; const text = ev.text.endsWith("\n") ? ev.text : `${ev.text}\n`; assistantText += text; push({ kind: "assistant_chunk", text }); }, onTool: (ev) => { + openCodeStructuredTrustedActivity = true; boundarySentinel?.observe(ev.name, ev.input); const started = toolTracker.envelopeToolUse( ev.id, @@ -1881,6 +1902,7 @@ export async function POST(req: Request) { if (ended) push({ kind: "tool_use", ...ended }); }, onToolStart: (ev) => { + openCodeStructuredTrustedActivity = true; boundarySentinel?.observe(ev.name, ev.input); const started = toolTracker.envelopeToolUse( ev.id, @@ -1893,6 +1915,7 @@ export async function POST(req: Request) { if (reorderedEnd) push({ kind: "tool_use", ...reorderedEnd }); }, onToolEnd: (ev) => { + openCodeStructuredTrustedActivity = true; const ended = toolTracker.envelopeToolResult( ev.id, typeof ev.output === "string" ? ev.output : formatToolInputValue(ev.output), @@ -1901,6 +1924,7 @@ export async function POST(req: Request) { if (ended) push({ kind: "tool_use", ...ended }); }, onError: (ev) => { + openCodeStructuredTrustedActivity = true; // 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, @@ -1915,11 +1939,12 @@ export async function POST(req: Request) { // 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. + openCodeStructuredIncompatibility = true; if (!openCodeCompatibilityNoticeSent) { openCodeCompatibilityNoticeSent = true; pushProgress( "opencode-compatibility", - "OpenCode sent an unrecognized event; that event was skipped while compatible tool activity continues", + "OpenCode sent an unrecognized event; compatible activity will continue, or Cave will safely retry in plain chat", "error", `${ev.diagnostic ?? "unknown-event"}:${redactedOpenCodeEventFingerprint(rawEvent)}`, ); @@ -1929,12 +1954,13 @@ export async function POST(req: Request) { // Structured-mode stdout can contain a malformed future event with // arbitrary tool payloads. Do not feed that raw line into the // persisted error tail or any user-visible diagnostic. - recordStdoutErrorTail("OpenCode emitted a malformed JSON event", true); + recordStdoutErrorTail("OpenCode emitted a malformed JSON event", true); + openCodeStructuredIncompatibility = true; if (!openCodeCompatibilityNoticeSent) { openCodeCompatibilityNoticeSent = true; pushProgress( "opencode-compatibility", - "OpenCode sent a malformed event; that event was skipped while compatible tool activity continues", + "OpenCode sent a malformed event; compatible activity will continue, or Cave will safely retry in plain chat", "error", "malformed-json-event", ); @@ -2387,6 +2413,48 @@ export async function POST(req: Request) { await runAttempt(args); } + // A future client can retain the same documented launch surface while + // changing JSON event labels. Do not render unknown envelopes as text. + // When the failed structured attempt emitted no trusted activity, retry + // once as a fresh plain chat so the user still receives its reply. + if ( + openCodeDirect + && openCodeStructuredIncompatibility + && !openCodeStructuredTrustedActivity + && !runHandle.stopRequested + ) { + const retry = buildResumeRetryPrompt(harnessPrompt, existingConversation); + pushProgress( + "opencode-compatibility", + retry.replayedHistory + ? "OpenCode's JSON protocol changed; replaying recent context in plain chat" + : "OpenCode's JSON protocol changed; restarting safely in plain chat", + "running", + "structured-stream-quarantined", + ); + openCodePlainFallback = true; + if (!sessionId) announceSession(crypto.randomUUID()); + assistantFilter = new AssistantFilter({ passthrough: rawStdoutHarness }); + assistantText = ""; + jsonBuf = ""; + result = {}; + toolTracker = new ToolCallTracker(); + openCodeSessionId = null; + openCodeModelRejected = false; + stderrTail.length = 0; + stdoutErrTail.length = 0; + resumeFailed = false; + openCodeStructuredIncompatibility = false; + openCodeStructuredTrustedActivity = false; + pushProgress( + "opencode-compatibility", + "OpenCode restarted in plain chat", + "done", + "structured-stream-quarantined", + ); + await runAttempt(buildArgs(null, retry.prompt)); + } + // Transparent retry: if codex reported its rollout-resume failed and // we had been resuming, start a fresh thread (no --continue) so the // user's prompt still gets answered. A fresh harness session has no @@ -2452,7 +2520,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 @@ -2533,11 +2604,11 @@ export async function POST(req: Request) { // access session. const harnessSessionId = grokDirect ? grokSessionId - : openCodeDirect && openCodeCompatibility?.mode === "plain" - ? undefined - : openCodeDirect - ? openCodeSessionId ?? existingConversation?.harnessSessionId ?? (!existingConversation ? body.sessionId : undefined) - : sessionId; + : openCodeDirect + // Plain output has no new native id to announce, but a native id + // used to resume this turn remains valid for its next resume. + ? openCodeSessionId ?? existingConversation?.harnessSessionId ?? (!existingConversation ? body.sessionId : undefined) + : sessionId; // OpenCode's JSON event protocol does not echo the selected model. Its // direct argv proves the selection was forwarded, while a successful // exit is the only confirmation it was applied. Preserve an explicit diff --git a/src/lib/opencode-compatibility.test.ts b/src/lib/opencode-compatibility.test.ts index ba83c6dfa..0eb4dd69a 100644 --- a/src/lib/opencode-compatibility.test.ts +++ b/src/lib/opencode-compatibility.test.ts @@ -156,6 +156,22 @@ assert.equal( ); 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(isOpenCodeSchemaBundle({ ...unsigned, unexpected: true }, now), false, "unknown format-1 bundle fields fail closed"); assert.equal(isOpenCodeSchemaBundle({ ...unsigned, @@ -400,6 +416,25 @@ const recoveredLock = await loadOpenCodeSchemaBundle({ }); assert.equal(recoveredLock.bundle.sequence, 3, "a stale writer lock cannot permanently block cache recovery after a crash"); +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; diff --git a/src/lib/opencode-compatibility.ts b/src/lib/opencode-compatibility.ts index 018aa5d80..8a0a2b9ce 100644 --- a/src/lib/opencode-compatibility.ts +++ b/src/lib/opencode-compatibility.ts @@ -283,7 +283,18 @@ function isEventSchema(value: unknown): value is OpenCodeEventSchema { // boundary while validating, then use the internal shape for the duplicate // label checks below. const eventTypes = value.eventTypes as unknown as OpenCodeEventSchema["eventTypes"]; - if (Object.keys(eventTypes).length !== eventKeys.length || !eventKeys.every((key) => Array.isArray(eventTypes[key]) && eventTypes[key].length > 0 && eventTypes[key].length <= 32 && eventTypes[key].every((type: unknown) => typeof type === "string" && type.length > 0 && type.length <= 80))) return false; + if (Object.keys(eventTypes).length !== eventKeys.length || !eventKeys.every((key) => { + const labels = eventTypes[key]; + // `ignored` and `toolComplete` are protocol-shape optional: a schema may + // use only split tool frames and may have no lifecycle-only frames. The + // other categories remain required so a selected profile always has a + // trusted assistant-text and tool/error contract. + const mayBeEmpty = key === "ignored" || key === "toolComplete"; + 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; const nonToolLabels = new Set(); for (const key of ["ignored", "text", "error", "toolEnd"] as const) { for (const label of eventTypes[key] as unknown[]) { @@ -766,6 +777,14 @@ export async function loadOpenCodeSchemaBundle(source: OpenCodeSchemaBundleSourc try { return await refresh; } catch { + // 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. + const current = await readVerifiedCache(file, publicKeys, now); + 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. diff --git a/src/lib/opencode-stream.ts b/src/lib/opencode-stream.ts index d6ccf3c8c..d2980d74b 100644 --- a/src/lib/opencode-stream.ts +++ b/src/lib/opencode-stream.ts @@ -68,9 +68,11 @@ function textEnvelope(event: Record, schema: OpenCodeEventSchem } function eventTypes(schema: OpenCodeEventSchema | undefined, kind: keyof OpenCodeEventSchema["eventTypes"], defaults: string[]): string[] { - // Selected signed schemas are authoritative: the validator rejects empty - // mappings, so falling back here could revive a label that a registry - // deliberately retired. Defaults are only for legacy callers with no schema. + // 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 ? schema.eventTypes[kind] : defaults; } From 9c453b393c981bfe772fa59c924f5503d09733dc Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 01:06:44 -0400 Subject: [PATCH 029/112] fix(chat): harden OpenCode registry dispatch --- scripts/cave-home-migration-windows.test.ts | 10 ++- .../send/harness-routing-opencode.test.ts | 2 +- src/lib/opencode-compatibility.test.ts | 65 +++++++++++++++---- src/lib/opencode-compatibility.ts | 20 +++--- src/lib/opencode-stream.test.ts | 4 +- src/lib/opencode-stream.ts | 5 +- .../server/cave-home-reconciliation-types.ts | 2 + src/lib/server/cave-home-reconciliation.ts | 11 ++-- 8 files changed, 87 insertions(+), 32 deletions(-) diff --git a/scripts/cave-home-migration-windows.test.ts b/scripts/cave-home-migration-windows.test.ts index ac5874de1..19984c21d 100644 --- a/scripts/cave-home-migration-windows.test.ts +++ b/scripts/cave-home-migration-windows.test.ts @@ -113,19 +113,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: 500, lockCandidateRename: persistentEperm }), + migrateCaveHome({ + lockTimeoutMs: 500, + 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-")), 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 8561ecfd3..8424e2d37 100644 --- a/src/app/api/chat/send/harness-routing-opencode.test.ts +++ b/src/app/api/chat/send/harness-routing-opencode.test.ts @@ -142,7 +142,7 @@ assert.match( ); assert.match( route, - /const handleOpenCodeLine[\s\S]*?openCodePlainFallback && line\.startsWith\("\{"\)[\s\S]*?onMalformedJson: \(\) => \{[\s\S]*?openCodeStructuredIncompatibility = true;[\s\S]*?recordStdoutErrorTail\("OpenCode emitted a malformed JSON event", true\)/, + /const handleOpenCodeLine[\s\S]*?openCodePlainFallback && line\.startsWith\("\{"\)[\s\S]*?onMalformedJson: \(\) => \{[\s\S]*?recordStdoutErrorTail\("OpenCode emitted a malformed JSON event", true\)[\s\S]*?openCodeStructuredIncompatibility = true;/, "malformed structured OpenCode events quarantine parsing without copying raw JSON into assistant text or diagnostics", ); assert.match( diff --git a/src/lib/opencode-compatibility.test.ts b/src/lib/opencode-compatibility.test.ts index 0eb4dd69a..f2319ccc3 100644 --- a/src/lib/opencode-compatibility.test.ts +++ b/src/lib/opencode-compatibility.test.ts @@ -172,6 +172,54 @@ assert.equal( true, "schemas may omit lifecycle-only and combined-tool categories when split frames describe the protocol", ); +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, unexpected: true }, now), false, "unknown format-1 bundle fields fail closed"); assert.equal(isOpenCodeSchemaBundle({ ...unsigned, @@ -258,17 +306,8 @@ const framingSchema = { }; assert.equal( isOpenCodeSchemaBundle({ ...unsigned, schemas: [framingSchema] }, now), - true, - "signed schemas may declare bounded harmless no-value framing flags", -); -assert.equal( - selectOpenCodeSchema([framingSchema], { - ...structuredSwitchCapabilities, - options: ["--structured-output", "--event-stream", "--no-color"], - noValueOptions: ["--event-stream", "--no-color"], - })?.id, - "structured-framing", - "companion flags require both a declaration and no-value capability evidence", + false, + "a registry schema cannot add even a harmless-looking flag outside the audited framing allowlist", ); assert.equal( isOpenCodeSchemaBundle({ ...unsigned, issuedAt: 0 }, now), @@ -286,9 +325,9 @@ assert.equal(isOpenCodeSchemaBundle({ ...unsigned, schemas: [{ ...BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0], - eventTypes: { ...BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0].eventTypes, toolEnd: [] }, + eventTypes: { ...BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0].eventTypes, text: [] }, }], -}, now), false, "incomplete event mappings cannot be selected as structured parsers"); +}, now), false, "a structured schema must retain a trusted text mapping"); assert.equal(isOpenCodeSchemaBundle({ ...unsigned, schemas: [{ diff --git a/src/lib/opencode-compatibility.ts b/src/lib/opencode-compatibility.ts index 8a0a2b9ce..05a3c83d7 100644 --- a/src/lib/opencode-compatibility.ts +++ b/src/lib/opencode-compatibility.ts @@ -245,7 +245,10 @@ const SAFE_STRUCTURED_LAUNCH_OPTION = /^--[a-z0-9-]*(?:format|output|json|event| const UNSAFE_STRUCTURED_LAUNCH_OPTIONS = new Set([ "--auto", "--permission", "--sandbox", "--skip-permissions", "--dangerously-skip-permissions", "--trust-all-tools", "--yolo", ]); -const SAFE_STRUCTURED_REQUIRED_FLAG = /^--[A-Za-z][A-Za-z0-9-]{0,78}$/; +// A registry describes how to frame and parse a stream; it must never widen +// what an OpenCode invocation is allowed to do. Keep the remotely supplied +// companion flags to the small, audited set that only requests event framing. +const SAFE_STRUCTURED_REQUIRED_FLAGS = new Set(["--event-stream"]); function safeStructuredLaunchOption(value: unknown): value is string { return typeof value === "string" @@ -265,8 +268,8 @@ function hasValidLaunch(value: unknown, requires: Record): bool if (structuredOutput.value !== (typeof requires.protocol === "string" ? requires.protocol : "json")) return false; if (value.sessionOption !== undefined && value.sessionOption !== "--session" && value.sessionOption !== "--resume") return false; if (requires.session === true && value.sessionOption === undefined) return false; - return value.requiredFlags.length <= 4 - && value.requiredFlags.every((flag) => typeof flag === "string" && SAFE_STRUCTURED_REQUIRED_FLAG.test(flag) && !UNSAFE_STRUCTURED_LAUNCH_OPTIONS.has(flag.toLowerCase())) + return value.requiredFlags.length <= SAFE_STRUCTURED_REQUIRED_FLAGS.size + && value.requiredFlags.every((flag) => typeof flag === "string" && SAFE_STRUCTURED_REQUIRED_FLAGS.has(flag)) && new Set(value.requiredFlags).size === value.requiredFlags.length && !value.requiredFlags.includes(structuredOutput.option); } @@ -285,11 +288,12 @@ function isEventSchema(value: unknown): value is OpenCodeEventSchema { const eventTypes = value.eventTypes as unknown as OpenCodeEventSchema["eventTypes"]; if (Object.keys(eventTypes).length !== eventKeys.length || !eventKeys.every((key) => { const labels = eventTypes[key]; - // `ignored` and `toolComplete` are protocol-shape optional: a schema may - // use only split tool frames and may have no lifecycle-only frames. The - // other categories remain required so a selected profile always has a - // trusted assistant-text and tool/error contract. - const mayBeEmpty = key === "ignored" || key === "toolComplete"; + // 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 diff --git a/src/lib/opencode-stream.test.ts b/src/lib/opencode-stream.test.ts index e9c59920b..6451519d9 100644 --- a/src/lib/opencode-stream.test.ts +++ b/src/lib/opencode-stream.test.ts @@ -23,11 +23,11 @@ assert.deepEqual( { 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_live", text: "hostile root text" }), + 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", "ses_live"], "JSONL dispatch adopts the native session before handling each frame"); + 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"); diff --git a/src/lib/opencode-stream.ts b/src/lib/opencode-stream.ts index d2980d74b..8ac54e087 100644 --- a/src/lib/opencode-stream.ts +++ b/src/lib/opencode-stream.ts @@ -198,7 +198,10 @@ export function handleOpenCodeJsonLine( try { const rawEvent = JSON.parse(line) as unknown; const event = parseOpenCodeRunEvent(rawEvent, schema); - if (event.sessionId) handlers.onSession?.(event.sessionId); + // `other` includes unknown event labels and malformed selected envelopes. + // Their session-shaped fields are untrusted: adopting one would poison the + // native resume token even though the payload is deliberately skipped. + if (event.kind !== "other" && event.sessionId) handlers.onSession?.(event.sessionId); switch (event.kind) { case "ignore": return; case "text": handlers.onText?.(event); return; 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"); From 4b7ea2b9163f8f3e9b249be76a2116c0641bef9d Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 01:11:00 -0400 Subject: [PATCH 030/112] fix(chat): quarantine incompatible OpenCode schemas --- .../send/harness-routing-opencode.test.ts | 13 ++- src/app/api/chat/send/route.ts | 83 +++---------------- src/lib/opencode-compatibility.test.ts | 15 ++++ src/lib/opencode-compatibility.ts | 24 ++++++ 4 files changed, 59 insertions(+), 76 deletions(-) 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 8424e2d37..ce291a6dc 100644 --- a/src/app/api/chat/send/harness-routing-opencode.test.ts +++ b/src/app/api/chat/send/harness-routing-opencode.test.ts @@ -72,8 +72,13 @@ assert.match( ); assert.match( route, - /let openCodePlainFallback = false;[\s\S]*?onOther: \(ev, rawEvent\) => \{[\s\S]*?openCodeStructuredIncompatibility = true;[\s\S]*?structured-stream-quarantined[\s\S]*?openCodePlainFallback = true;[\s\S]*?await runAttempt\(buildArgs\(null, retry\.prompt\)\)/, - "an unknown OpenCode envelope quarantines structured parsing and safely retries only a no-activity turn in plain chat", + /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.doesNotMatch( + route, + /openCodePlainFallback|openCodeStructuredIncompatibility|structured-stream-quarantined/, + "an incompatible structured request is never replayed as a plain retry", ); assert.match( route, @@ -142,8 +147,8 @@ assert.match( ); assert.match( route, - /const handleOpenCodeLine[\s\S]*?openCodePlainFallback && line\.startsWith\("\{"\)[\s\S]*?onMalformedJson: \(\) => \{[\s\S]*?recordStdoutErrorTail\("OpenCode emitted a malformed JSON event", true\)[\s\S]*?openCodeStructuredIncompatibility = true;/, - "malformed structured OpenCode events quarantine parsing without copying raw JSON into assistant text or diagnostics", + /const handleOpenCodeLine[\s\S]*?onMalformedJson: \(\) => \{[\s\S]*?recordStdoutErrorTail\("OpenCode emitted a malformed JSON event", true\)[\s\S]*?quarantineOpenCodeSchema\(openCodeCompatibility\?\.schema\)/, + "malformed structured OpenCode events quarantine future structured launches without copying raw payloads into text or diagnostics", ); assert.match( capabilities, diff --git a/src/app/api/chat/send/route.ts b/src/app/api/chat/send/route.ts index fd5075ace..5bcd48977 100644 --- a/src/app/api/chat/send/route.ts +++ b/src/app/api/chat/send/route.ts @@ -64,6 +64,7 @@ import { import { grokLaunchCommand } from "@/lib/grok-bin"; import { openCodeLaunch, openCodeSpawnEnv, writeOpenCodeLaunchInput } from "@/lib/opencode-bin"; import { + quarantineOpenCodeSchema, redactedOpenCodeEventFingerprint, resolveOpenCodeCompatibility, } from "@/lib/opencode-compatibility"; @@ -938,11 +939,6 @@ export async function POST(req: Request) { const openCodeCompatibility = openCodeDirect ? await resolveOpenCodeCompatibility(await openCodeRunCapabilities(body.familiarId)) : null; - // A selected structured schema is quarantined for the remainder of this - // request after it observes an incompatible frame. The retry below launches - // without the structured-output switch; never reinterpret that frame as - // assistant text. - let openCodePlainFallback = false; const modelForwardingEnabled = hermesDirect ? await hermesChatSupportsModel() @@ -1370,7 +1366,7 @@ export async function POST(req: Request) { // 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" && !openCodePlainFallback) { + if (openCodeCompatibility?.mode === "structured") { const launch = openCodeCompatibility.schema!.launch; a.push(launch.structuredOutput.option, launch.structuredOutput.value, ...launch.requiredFlags); if (resumeSessionId && launch.sessionOption) a.push(launch.sessionOption, resumeSessionId); @@ -1621,11 +1617,6 @@ export async function POST(req: Request) { let adapterConflict: BuiltinAdapterConflict | null = null; let openCodeCompatibilityNoticeSent = false; let openCodeModelRejected = false; - // Unknown or malformed JSON proves that the selected schema no longer - // describes this stream. A fresh plain attempt is safe only if this - // attempt has not emitted any trusted assistant/tool/error activity. - let openCodeStructuredIncompatibility = false; - let openCodeStructuredTrustedActivity = false; // Model parity: the harness echoes its resolved model on the init/system // stream event. Capturing it lets the application state render honestly as @@ -1851,16 +1842,7 @@ export async function POST(req: Request) { }; const handleOpenCodeLine = (line: string) => { - if (openCodeCompatibility?.mode === "plain" || openCodePlainFallback) { - // The retry deliberately asks OpenCode for plain output. If it still - // sends a JSON-shaped envelope, do not promote provider-controlled - // fields to assistant text merely because structured parsing was - // quarantined. - if (openCodePlainFallback && line.startsWith("{") && line.endsWith("}")) { - recordStdoutErrorTail("OpenCode emitted JSON during plain compatibility fallback", true); - result = { ...result, is_error: true }; - return; - } + if (openCodeCompatibility?.mode === "plain") { const text = `${resolveBackspaces(stripAnsi(line))}\n`; assistantText += text; push({ kind: "assistant_chunk", text }); @@ -1872,13 +1854,11 @@ export async function POST(req: Request) { if (!sessionId) announceSession(nativeSessionId); }, onText: (ev) => { - openCodeStructuredTrustedActivity = true; const text = ev.text.endsWith("\n") ? ev.text : `${ev.text}\n`; assistantText += text; push({ kind: "assistant_chunk", text }); }, onTool: (ev) => { - openCodeStructuredTrustedActivity = true; boundarySentinel?.observe(ev.name, ev.input); const started = toolTracker.envelopeToolUse( ev.id, @@ -1902,7 +1882,6 @@ export async function POST(req: Request) { if (ended) push({ kind: "tool_use", ...ended }); }, onToolStart: (ev) => { - openCodeStructuredTrustedActivity = true; boundarySentinel?.observe(ev.name, ev.input); const started = toolTracker.envelopeToolUse( ev.id, @@ -1915,7 +1894,6 @@ export async function POST(req: Request) { if (reorderedEnd) push({ kind: "tool_use", ...reorderedEnd }); }, onToolEnd: (ev) => { - openCodeStructuredTrustedActivity = true; const ended = toolTracker.envelopeToolResult( ev.id, typeof ev.output === "string" ? ev.output : formatToolInputValue(ev.output), @@ -1924,7 +1902,6 @@ export async function POST(req: Request) { if (ended) push({ kind: "tool_use", ...ended }); }, onError: (ev) => { - openCodeStructuredTrustedActivity = true; // 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, @@ -1939,12 +1916,12 @@ export async function POST(req: Request) { // 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. - openCodeStructuredIncompatibility = true; + quarantineOpenCodeSchema(openCodeCompatibility?.schema); if (!openCodeCompatibilityNoticeSent) { openCodeCompatibilityNoticeSent = true; pushProgress( "opencode-compatibility", - "OpenCode sent an unrecognized event; compatible activity will continue, or Cave will safely retry in plain chat", + "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)}`, ); @@ -1955,12 +1932,12 @@ export async function POST(req: Request) { // arbitrary tool payloads. Do not feed that raw line into the // persisted error tail or any user-visible diagnostic. recordStdoutErrorTail("OpenCode emitted a malformed JSON event", true); - openCodeStructuredIncompatibility = true; + quarantineOpenCodeSchema(openCodeCompatibility?.schema); if (!openCodeCompatibilityNoticeSent) { openCodeCompatibilityNoticeSent = true; pushProgress( "opencode-compatibility", - "OpenCode sent a malformed event; compatible activity will continue, or Cave will safely retry in plain chat", + "OpenCode sent a malformed event; future turns will use safe plain chat until a compatible schema is available", "error", "malformed-json-event", ); @@ -1981,6 +1958,8 @@ export async function POST(req: Request) { ? "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; using the shipped compatible parser" : openCodeCompatibility.bundleSource === "built-in" @@ -2343,6 +2322,8 @@ export async function POST(req: Request) { ? "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; using the shipped compatible parser" : openCodeCompatibility.bundleSource === "built-in" @@ -2413,48 +2394,6 @@ export async function POST(req: Request) { await runAttempt(args); } - // A future client can retain the same documented launch surface while - // changing JSON event labels. Do not render unknown envelopes as text. - // When the failed structured attempt emitted no trusted activity, retry - // once as a fresh plain chat so the user still receives its reply. - if ( - openCodeDirect - && openCodeStructuredIncompatibility - && !openCodeStructuredTrustedActivity - && !runHandle.stopRequested - ) { - const retry = buildResumeRetryPrompt(harnessPrompt, existingConversation); - pushProgress( - "opencode-compatibility", - retry.replayedHistory - ? "OpenCode's JSON protocol changed; replaying recent context in plain chat" - : "OpenCode's JSON protocol changed; restarting safely in plain chat", - "running", - "structured-stream-quarantined", - ); - openCodePlainFallback = true; - if (!sessionId) announceSession(crypto.randomUUID()); - assistantFilter = new AssistantFilter({ passthrough: rawStdoutHarness }); - assistantText = ""; - jsonBuf = ""; - result = {}; - toolTracker = new ToolCallTracker(); - openCodeSessionId = null; - openCodeModelRejected = false; - stderrTail.length = 0; - stdoutErrTail.length = 0; - resumeFailed = false; - openCodeStructuredIncompatibility = false; - openCodeStructuredTrustedActivity = false; - pushProgress( - "opencode-compatibility", - "OpenCode restarted in plain chat", - "done", - "structured-stream-quarantined", - ); - await runAttempt(buildArgs(null, retry.prompt)); - } - // Transparent retry: if codex reported its rollout-resume failed and // we had been resuming, start a fresh thread (no --continue) so the // user's prompt still gets answered. A fresh harness session has no diff --git a/src/lib/opencode-compatibility.test.ts b/src/lib/opencode-compatibility.test.ts index f2319ccc3..a155d766e 100644 --- a/src/lib/opencode-compatibility.test.ts +++ b/src/lib/opencode-compatibility.test.ts @@ -10,6 +10,7 @@ import { openCodeSchemaBundleSigningPayload, redactedOpenCodeEventFingerprint, resolveOpenCodeCompatibility, + quarantineOpenCodeSchema, selectOpenCodeSchema, isOpenCodeSchemaBundle, } from "./opencode-compatibility.ts"; @@ -536,4 +537,18 @@ assert.equal(expiredRewrite.source, "built-in", "an expired cache still anchors 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"); + console.log("opencode-compatibility.test.ts: ok"); diff --git a/src/lib/opencode-compatibility.ts b/src/lib/opencode-compatibility.ts index 05a3c83d7..39d2824da 100644 --- a/src/lib/opencode-compatibility.ts +++ b/src/lib/opencode-compatibility.ts @@ -93,6 +93,7 @@ export type OpenCodeCompatibility = { export type OpenCodeCompatibilityDiagnostic = | "json-format-unavailable" | "no-compatible-schema" + | "schema-quarantined" | "schema-registry-refresh-rejected" | "cached-schema-unavailable"; @@ -137,6 +138,21 @@ const CACHE_LOCK_STALE_MS = 30_000; const REFRESH_FAILURE_BACKOFF_MS = 60_000; 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 quarantinedSchemaIds = new Set(); +const MAX_QUARANTINED_SCHEMA_IDS = 64; + +export function quarantineOpenCodeSchema(schema: OpenCodeEventSchema | undefined): void { + if (!schema || quarantinedSchemaIds.has(schema.id)) return; + if (quarantinedSchemaIds.size >= MAX_QUARANTINED_SCHEMA_IDS) { + const oldest = quarantinedSchemaIds.values().next(); + if (!oldest.done) quarantinedSchemaIds.delete(oldest.value); + } + quarantinedSchemaIds.add(schema.id); +} /** * Shipped schemas remain usable offline. Selection is capability-based: a @@ -835,6 +851,14 @@ export async function resolveOpenCodeCompatibility( diagnostic: "no-compatible-schema", }; } + if (quarantinedSchemaIds.has(schema.id)) { + return { + mode: "plain", + capabilities, + bundleSource: loaded.source, + diagnostic: "schema-quarantined", + }; + } return { mode: "structured", capabilities, From 03ef23e57e8fd13ce43450e755976a3ea18e8096 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 01:26:23 -0400 Subject: [PATCH 031/112] fix(chat): restrict OpenCode v1 tool frames --- .../chat/send/chat-send-capabilities.test.ts | 9 ++ .../api/chat/send/chat-send-capabilities.ts | 11 ++- src/lib/opencode-compatibility.test.ts | 32 ++++++++ src/lib/opencode-compatibility.ts | 82 +++++++++++++------ src/lib/opencode-stream.test.ts | 50 +++-------- 5 files changed, 120 insertions(+), 64 deletions(-) diff --git a/src/app/api/chat/send/chat-send-capabilities.test.ts b/src/app/api/chat/send/chat-send-capabilities.test.ts index a446540d5..954585b12 100644 --- a/src/app/api/chat/send/chat-send-capabilities.test.ts +++ b/src/app/api/chat/send/chat-send-capabilities.test.ts @@ -40,4 +40,13 @@ const identityA = await openCodeExecutableIdentity({ PATH: launcherA }); const identityB = await openCodeExecutableIdentity({ PATH: launcherB }); assert.notEqual(identityA, identityB, "capability-cache identity changes when PATH resolves a different OpenCode executable"); +const windowsLauncher = await mkdtemp(path.join(tmpdir(), "cave-opencode-launch-windows-")); +await writeFile(path.join(windowsLauncher, "opencode.cmd"), "shim"); +await writeFile(path.join(windowsLauncher, "opencode.exe"), "binary"); +const windowsIdentity = await openCodeExecutableIdentity( + { PATH: windowsLauncher, PATHEXT: ".COM;.EXE;.BAT;.CMD" }, + "win32", +); +assert.match(windowsIdentity, /opencode\.exe\0/i, "Windows capability identity follows PowerShell PATHEXT precedence over a co-located cmd shim"); + 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 23f516654..122330549 100644 --- a/src/app/api/chat/send/chat-send-capabilities.ts +++ b/src/app/api/chat/send/chat-send-capabilities.ts @@ -145,7 +145,16 @@ export async function openCodeExecutableIdentity( platform: NodeJS.Platform = process.platform, ): Promise { const pathValue = env.PATH ?? env.Path ?? ""; - const names = platform === "win32" ? ["opencode.cmd", "opencode.exe", "opencode.bat", "opencode"] : ["opencode"]; + // `& opencode` in PowerShell resolves external commands using PATHEXT + // order. Fingerprint that same winner so a co-located .exe/.cmd upgrade + // cannot reuse help evidence from a shadowed launcher. + const names = platform === "win32" + ? (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD") + .split(";") + .map((extension) => extension.trim()) + .filter((extension) => /^\.[A-Za-z0-9]+$/.test(extension)) + .map((extension) => `opencode${extension}`) + : ["opencode"]; for (const directory of pathValue.split(path.delimiter).filter(Boolean)) { for (const name of names) { const candidate = path.join(directory, name); diff --git a/src/lib/opencode-compatibility.test.ts b/src/lib/opencode-compatibility.test.ts index a155d766e..c3e18a022 100644 --- a/src/lib/opencode-compatibility.test.ts +++ b/src/lib/opencode-compatibility.test.ts @@ -173,6 +173,14 @@ assert.equal( 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, @@ -551,4 +559,28 @@ const quarantined = await resolveOpenCodeCompatibility( 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 index 39d2824da..9818742e9 100644 --- a/src/lib/opencode-compatibility.ts +++ b/src/lib/opencode-compatibility.ts @@ -24,6 +24,8 @@ export type OpenCodeRegistryKeyring = Record; 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: { @@ -101,6 +103,8 @@ 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 @@ -142,16 +146,18 @@ const refreshRetryAt = new Map(); // 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 quarantinedSchemaIds = new Set(); +const quarantinedSchemaRevisions = new Map(); const MAX_QUARANTINED_SCHEMA_IDS = 64; export function quarantineOpenCodeSchema(schema: OpenCodeEventSchema | undefined): void { - if (!schema || quarantinedSchemaIds.has(schema.id)) return; - if (quarantinedSchemaIds.size >= MAX_QUARANTINED_SCHEMA_IDS) { - const oldest = quarantinedSchemaIds.values().next(); - if (!oldest.done) quarantinedSchemaIds.delete(oldest.value); + 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); } - quarantinedSchemaIds.add(schema.id); + quarantinedSchemaRevisions.set(schema.id, revision); } /** @@ -172,9 +178,13 @@ export const BUILTIN_OPENCODE_SCHEMA_BUNDLE: OpenCodeSchemaBundle = { eventTypes: { ignored: ["step_start", "step_finish", "reasoning"], text: ["text"], - toolStart: ["tool_start", "tool"], - toolEnd: ["tool_result"], - toolComplete: ["tool_use", "tool"], + // 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: [], + toolEnd: [], + toolComplete: ["tool_use"], error: ["error"], }, shape: { @@ -197,7 +207,7 @@ export const BUILTIN_OPENCODE_SCHEMA_BUNDLE: OpenCodeSchemaBundle = { // `--format json` safely uses plain output until a verified schema can // prove its envelope contract. id: "opencode-run-json-legacy", - requires: { json: true, session: false, protocol: "json-legacy" }, + requires: { json: true, protocol: "json-legacy" }, eventTypes: { ignored: ["step_start", "step_finish", "reasoning"], text: ["message", "assistant_text"], @@ -292,7 +302,8 @@ function hasValidLaunch(value: unknown, requires: Record): bool 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 === "requires" || key === "eventTypes" || key === "shape" || key === "launch")) 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 && (!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; @@ -332,8 +343,11 @@ function isEventSchema(value: unknown): value is OpenCodeEventSchema { function schemaMatches(schema: OpenCodeEventSchema, capabilities: OpenCodeRunCapabilities): boolean { if (!capabilities.json) return false; - if (schema.requires.session !== undefined && schema.requires.session !== capabilities.session) return false; - if (schema.requires.model !== undefined && schema.requires.model !== capabilities.model) 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"] : []); @@ -349,13 +363,17 @@ function schemaMatches(schema: OpenCodeEventSchema, capabilities: OpenCodeRunCap } function schemaSpecificity(schema: OpenCodeEventSchema): number { - return Number(schema.requires.session !== undefined) + Number(schema.requires.model !== undefined) + Number(schema.requires.protocol !== undefined); + return Number(schema.requires.session === true) + Number(schema.requires.model === true) + Number(schema.requires.protocol !== undefined); +} + +function schemaPriority(schema: OpenCodeEventSchema): number { + return schema.priority ?? 0; } /** * Select the most specific matching schema rather than trusting registry - * order. A same-specificity tie means two schemas claim the identical observed - * capability surface, so the caller must fail closed instead of guessing. + * 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[], @@ -365,7 +383,9 @@ export function selectOpenCodeSchema( if (!matches.length) return null; const specificity = Math.max(...matches.map(schemaSpecificity)); const mostSpecific = matches.filter((schema) => schemaSpecificity(schema) === specificity); - return mostSpecific.length === 1 ? mostSpecific[0] : null; + 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$/; @@ -378,8 +398,8 @@ function parseCanonicalTimestamp(value: unknown): number | 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, right.requires.session) - && compatible(left.requires.model, right.requires.model) + 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); } @@ -407,7 +427,11 @@ export function isOpenCodeSchemaBundle( 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) && requirementsOverlap(schema, prior)) return false; + if ( + schemaSpecificity(schema) === schemaSpecificity(prior) + && schemaPriority(schema) === schemaPriority(prior) + && requirementsOverlap(schema, prior) + ) return false; } ids.add(schema.id); } @@ -786,7 +810,7 @@ export async function loadOpenCodeSchemaBundle(source: OpenCodeSchemaBundleSourc 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" } + ? { 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( @@ -810,7 +834,7 @@ export async function loadOpenCodeSchemaBundle(source: OpenCodeSchemaBundleSourc // 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" } + ? { 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" }; } } @@ -828,6 +852,17 @@ export async function resolveOpenCodeCompatibility( }; } 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. @@ -851,7 +886,8 @@ export async function resolveOpenCodeCompatibility( diagnostic: "no-compatible-schema", }; } - if (quarantinedSchemaIds.has(schema.id)) { + const schemaRevision = createHash("sha256").update(stableJson(schema)).digest("hex"); + if (quarantinedSchemaRevisions.get(schema.id) === schemaRevision) { return { mode: "plain", capabilities, diff --git a/src/lib/opencode-stream.test.ts b/src/lib/opencode-stream.test.ts index 6451519d9..9275b1c61 100644 --- a/src/lib/opencode-stream.test.ts +++ b/src/lib/opencode-stream.test.ts @@ -107,40 +107,24 @@ assert.deepEqual( { type: "tool_start", sessionId: "ses_123", data: { id: "tool_1", name: "Read", state: { input: { path: "README.md" } } } }, BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0], ), - { kind: "tool_start", sessionId: "ses_123", id: "tool_1", name: "Read", input: { path: "README.md" } }, - "newer split lifecycle events keep their upstream id", + { 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: "tool_end", sessionId: "ses_123", id: "tool_1", output: "ok", isError: false }, - "reordered results can close the same stable tool bubble", -); -assert.deepEqual( - parseOpenCodeRunEvent( - { type: "tool_result", id: "tool_failed", error: "permission denied" }, - BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0], - ), - { kind: "tool_end", sessionId: undefined, id: "tool_failed", output: "permission denied", isError: true }, - "root-level terminal errors settle a failed tool rather than an empty success", + { 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: "tool_start", sessionId: "ses_123", id: "call_1", name: "bash", input: { command: "pwd" } }, - "current root tool updates use callID without fabricating a local id", -); -assert.deepEqual( - parseOpenCodeRunEvent( - { type: "tool", sessionID: "ses_123", callID: "call_1", tool: "bash", state: { input: { command: "pwd" }, output: "ok", status: "completed" } }, - BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0], - ), - { kind: "tool", sessionId: "ses_123", id: "call_1", name: "bash", input: { command: "pwd" }, output: "ok", isError: false }, - "current root terminal tool updates preserve input and output", + { kind: "other", sessionId: "ses_123", diagnostic: "unknown-event" }, + "undocumented root tool labels cannot create a trusted v1 bubble", ); assert.deepEqual( parseOpenCodeRunEvent( @@ -165,24 +149,6 @@ assert.deepEqual( { kind: "other", sessionId: undefined, diagnostic: "unknown-event" }, "unknown envelopes never promote arbitrary payload text into assistant output", ); -assert.equal( - parseOpenCodeRunEvent({ type: "tool", callID: "failed_1", tool: "bash", state: { status: "FAILED" } }, BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0]).kind, - "tool", - "case variants of terminal states remain terminal", -); -assert.equal( - (parseOpenCodeRunEvent({ type: "tool", callID: "failed_1", tool: "bash", state: { status: "FAILED" } }, BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0]) as { isError: boolean }).isError, - true, - "case variants of error statuses do not render successful tool bubbles", -); -assert.deepEqual( - parseOpenCodeRunEvent( - { type: "tool", callID: "cancelled_1", tool: "bash", state: { status: "CANCELLED" } }, - BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0], - ), - { kind: "tool", sessionId: undefined, id: "cancelled_1", name: "bash", input: {}, output: "", isError: true }, - "cancelled terminal states settle the upstream tool as an error", -); assert.deepEqual( parseOpenCodeRunEvent(["future", "envelope"]), { kind: "other", diagnostic: "malformed-event" }, @@ -207,6 +173,10 @@ assert.deepEqual( 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" }, From ceea6d2002056e975115157a9519824d7fcc0073 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 01:32:07 -0400 Subject: [PATCH 032/112] test(chat): exercise OpenCode route integration --- scripts/run-tests.mjs | 1 + .../send/route-opencode.integration.test.ts | 86 +++++++++++++++++++ src/app/api/chat/send/route.ts | 15 +++- src/lib/opencode-compatibility.test.ts | 19 ++++ src/lib/opencode-compatibility.ts | 2 +- 5 files changed, 118 insertions(+), 5 deletions(-) create mode 100644 src/app/api/chat/send/route-opencode.integration.test.ts diff --git a/scripts/run-tests.mjs b/scripts/run-tests.mjs index d3fe99b11..dce5840ed 100644 --- a/scripts/run-tests.mjs +++ b/scripts/run-tests.mjs @@ -1024,6 +1024,7 @@ export const SUITES = { "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", 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..a47573cc9 --- /dev/null +++ b/src/app/api/chat/send/route-opencode.integration.test.ts @@ -0,0 +1,86 @@ +// @ts-nocheck +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } 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. +const home = await mkdtemp(path.join(tmpdir(), "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; +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 launcher = process.platform === "win32" + ? [ + "@echo off", + "if \"%~1\"==\"--version\" (echo 1.2.3& exit /b 0)", + "if \"%~1\"==\"run\" if \"%~2\"==\"--help\" (", + " echo --format ^ Output format: text, json", + " echo --session ^ Session to continue", + " exit /b 0", + ")", + "echo {\"type\":\"text\",\"sessionID\":\"native_opencode_session\",\"part\":{\"text\":\"route reply\"}}", + "exit /b 0", + ].join("\r\n") + : [ + "#!/bin/sh", + "if [ \"$1\" = \"--version\" ]; then echo 1.2.3; exit 0; fi", + "if [ \"$1\" = \"run\" ] && [ \"$2\" = \"--help\" ]; then", + " printf '%s\\n' ' --format Output format: text, json' ' --session Session to continue'", + " exit 0", + "fi", + "printf '%s\\n' '{\"type\":\"text\",\"sessionID\":\"native_opencode_session\",\"part\":{\"text\":\"route reply\"}}'", + ].join("\n"); +await writeFile(path.join(bin, executable), launcher, { mode: 0o755 }); + +try { + 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: "Hello from route integration", projectRoot: familiarWorkspace }), + })); + assert.equal(response.status, 200, await response.clone().text()); + const body = await response.text(); + assert.match(body, /"kind":"assistant_chunk","text":"route reply\\n"/, "the route streams text from the selected OpenCode JSON profile"); + 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"); +} 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; + 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 5bcd48977..9667132a9 100644 --- a/src/app/api/chat/send/route.ts +++ b/src/app/api/chat/send/route.ts @@ -1477,6 +1477,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; @@ -1644,7 +1648,7 @@ export async function POST(req: Request) { // /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 @@ -1682,7 +1686,10 @@ export async function POST(req: Request) { // below and falls back to recent-context replay. if (openCodeUnrecordedResume) { announceSession(crypto.randomUUID()); - } else if (openCodeDirect && openCodeCompatibility?.mode === "plain" && !sessionId) { + } 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()); } @@ -2137,8 +2144,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; diff --git a/src/lib/opencode-compatibility.test.ts b/src/lib/opencode-compatibility.test.ts index c3e18a022..54fe8882a 100644 --- a/src/lib/opencode-compatibility.test.ts +++ b/src/lib/opencode-compatibility.test.ts @@ -142,6 +142,7 @@ const broadSchema = { ...BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0], id: "broad", 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" } }, }; @@ -150,6 +151,11 @@ assert.equal( "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", @@ -516,6 +522,19 @@ assert.equal(resolvedB.bundle.sequence, 3, "concurrent stale readers receive the 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"] }, { diff --git a/src/lib/opencode-compatibility.ts b/src/lib/opencode-compatibility.ts index 9818742e9..5a5c6d7f2 100644 --- a/src/lib/opencode-compatibility.ts +++ b/src/lib/opencode-compatibility.ts @@ -303,7 +303,7 @@ function hasValidLaunch(value: unknown, requires: Record): bool 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 && (!Number.isSafeInteger(value.priority) || value.priority < 0 || value.priority > 100)) 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; From 6d5b31fe6171355e3d98174293967aacee2c0f03 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 01:39:37 -0400 Subject: [PATCH 033/112] test(chat): remove stale OpenCode plain-session assertion --- src/app/api/chat/send/harness-routing-opencode.test.ts | 5 ----- 1 file changed, 5 deletions(-) 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 ce291a6dc..854d9169f 100644 --- a/src/app/api/chat/send/harness-routing-opencode.test.ts +++ b/src/app/api/chat/send/harness-routing-opencode.test.ts @@ -110,11 +110,6 @@ assert.match( /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, - /openCodeDirect && openCodeCompatibility\?\.mode === "plain" && !sessionId[\s\S]*?announceSession\(crypto\.randomUUID\(\)\)/, - "plain OpenCode output receives a Cave-owned stable session id so its first transcript persists", -); assert.match( route, /const harnessSessionId = grokDirect[\s\S]*?: openCodeDirect[\s\S]*?openCodeSessionId \?\? existingConversation\?\.harnessSessionId \?\? \(!existingConversation \? body\.sessionId : undefined\)/, From bcb8933e76e6b7e7fdec8352a8fb95d131b20238 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 01:43:24 -0400 Subject: [PATCH 034/112] fix(chat): harden OpenCode structured launch --- .../chat/send/chat-send-capabilities.test.ts | 20 ++++++++ .../api/chat/send/chat-send-capabilities.ts | 51 +++++++++++++++---- .../send/harness-routing-opencode.test.ts | 7 ++- src/app/api/chat/send/route.ts | 6 ++- src/lib/opencode-compatibility.test.ts | 23 +++++++++ src/lib/opencode-compatibility.ts | 25 ++++++--- src/lib/opencode-stream.test.ts | 21 ++++++++ src/lib/opencode-stream.ts | 15 ++++-- 8 files changed, 147 insertions(+), 21 deletions(-) diff --git a/src/app/api/chat/send/chat-send-capabilities.test.ts b/src/app/api/chat/send/chat-send-capabilities.test.ts index 954585b12..3384f6efa 100644 --- a/src/app/api/chat/send/chat-send-capabilities.test.ts +++ b/src/app/api/chat/send/chat-send-capabilities.test.ts @@ -31,6 +31,26 @@ assert.deepEqual( "an unbracketed positional token after a flag is ambiguous and cannot be launched without a value", ); +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 launcherA = await mkdtemp(path.join(tmpdir(), "cave-opencode-launch-a-")); const launcherB = await mkdtemp(path.join(tmpdir(), "cave-opencode-launch-b-")); const executable = process.platform === "win32" ? "opencode.cmd" : "opencode"; diff --git a/src/app/api/chat/send/chat-send-capabilities.ts b/src/app/api/chat/send/chat-send-capabilities.ts index 122330549..522cbcb33 100644 --- a/src/app/api/chat/send/chat-send-capabilities.ts +++ b/src/app/api/chat/send/chat-send-capabilities.ts @@ -113,8 +113,20 @@ 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] ?? ""; } +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 synopsis = optionStanza(help, option) + .split(/\r?\n/) + .find((line) => line.includes(option)) + ?? ""; + return /<[^>\n]+>|\[[^\]\n]+\]|=\S+/.test(synopsis); +} + 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 values = [...new Set((stanza.match(/\bjson(?:[._-][a-z0-9]+)*\b/gi) ?? []).map((value) => value.toLowerCase()))]; return values.length ? [{ option, values }] : []; @@ -135,6 +147,22 @@ function declaredNoValueRunOptions(help: string, options: string[]): string[] { }); } +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] }] : []; + }); +} + /** * Identifies the executable the next OpenCode spawn will resolve. The PATH * lookup is repeated before using a cached capability probe so an in-place @@ -170,11 +198,14 @@ export async function openCodeExecutableIdentity( return `unresolved\0${platform}\0${pathValue}`; } -function advertisedFormatProtocols(help: string): string[] { - const outputs = advertisedStructuredOutputs(help); +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. Do not derive it from version strings or arbitrary help prose. - return [...new Set(outputs.flatMap((output) => output.values))]; + // 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)])]; } /** @@ -187,13 +218,14 @@ export function parseOpenCodeRunCapabilitiesHelp(help: string, version: string | const options = declaredRunOptions(help); const noValueOptions = declaredNoValueRunOptions(help, options); const structuredOutputs = advertisedStructuredOutputs(help); - const protocols = advertisedFormatProtocols(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 it appears in the `--format` option's own - // stanza. A stray "JSON" in a banner or another option's description - // must not make us launch an unsupported `--format json` command. + // 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: options.includes("--model"), session: options.includes("--session") || options.includes("--resume"), @@ -204,6 +236,7 @@ export function parseOpenCodeRunCapabilitiesHelp(help: string, version: string | protocols, options, noValueOptions, + structuredSwitches, structuredOutputs, }; } @@ -286,7 +319,7 @@ export async function openCodeRunCapabilities(familiarId?: string): Promise \{[\s\S]*?openCodeSessionId = nativeSessionId;[\s\S]*?if \(!sessionId\) announceSession\(nativeSessionId\);[\s\S]*?openCodeSessionId \?\? existingConversation\?\.harnessSessionId/, diff --git a/src/app/api/chat/send/route.ts b/src/app/api/chat/send/route.ts index 9667132a9..f0734f087 100644 --- a/src/app/api/chat/send/route.ts +++ b/src/app/api/chat/send/route.ts @@ -1368,7 +1368,11 @@ export async function POST(req: Request) { const a = ["run"]; if (openCodeCompatibility?.mode === "structured") { const launch = openCodeCompatibility.schema!.launch; - a.push(launch.structuredOutput.option, launch.structuredOutput.value, ...launch.requiredFlags); + a.push( + launch.structuredOutput.option, + ...(launch.structuredOutput.value === undefined ? [] : [launch.structuredOutput.value]), + ...launch.requiredFlags, + ); if (resumeSessionId && launch.sessionOption) a.push(launch.sessionOption, resumeSessionId); } else if (resumeSessionId) { // Plain output can still resume a native session. Forward only the diff --git a/src/lib/opencode-compatibility.test.ts b/src/lib/opencode-compatibility.test.ts index 54fe8882a..01abf7f34 100644 --- a/src/lib/opencode-compatibility.test.ts +++ b/src/lib/opencode-compatibility.test.ts @@ -314,6 +314,29 @@ assert.equal( "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 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", diff --git a/src/lib/opencode-compatibility.ts b/src/lib/opencode-compatibility.ts index 5a5c6d7f2..1968d019e 100644 --- a/src/lib/opencode-compatibility.ts +++ b/src/lib/opencode-compatibility.ts @@ -15,6 +15,8 @@ export type OpenCodeRunCapabilities = { options?: string[]; /** Declared run flags that take no value and are safe to forward verbatim. */ noValueOptions?: string[]; + /** Declared valueless switches that explicitly request a JSON protocol. */ + structuredSwitches?: Array<{ option: string; protocols: string[] }>; structuredOutputs?: Array<{ option: string; values: string[] }>; }; @@ -51,6 +53,8 @@ export type OpenCodeEventSchema = { }; /** Envelope(s) explicitly trusted to carry assistant text. */ textEnvelope?: OpenCodeEnvelopePath[]; + /** Envelope(s) that carry the stable tool-call id; defaults to payload/root. */ + idEnvelope?: OpenCodeEnvelopePath[]; sessionId: string[]; id: string[]; name: string[]; @@ -65,7 +69,7 @@ export type OpenCodeEventSchema = { }; /** Bounded argv contract, confirmed against the installed client's help. */ launch: { - structuredOutput: { option: string; value: string }; + structuredOutput: { option: string; value?: string }; sessionOption?: "--session" | "--resume"; requiredFlags: string[]; }; @@ -257,8 +261,10 @@ function hasValidShape(value: unknown): boolean { && fields.length > 0 && fields.length <= 4 && fields.every(hasValidEnvelopePath); - if (!Object.keys(value).every((key) => key === "envelope" || key === "textEnvelope" || key === "discriminator" || aliasKeys.includes(key))) return false; - if (!envelopeFields(value.envelope) || (value.textEnvelope !== undefined && !envelopeFields(value.textEnvelope))) return false; + if (!Object.keys(value).every((key) => key === "envelope" || key === "textEnvelope" || key === "idEnvelope" || key === "discriminator" || aliasKeys.includes(key))) return false; + if (!envelopeFields(value.envelope) + || (value.textEnvelope !== undefined && !envelopeFields(value.textEnvelope)) + || (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) @@ -290,8 +296,12 @@ function hasValidLaunch(value: unknown, requires: Record): bool if (!isRecord(structuredOutput)) return false; if (!Object.keys(structuredOutput).every((key) => key === "option" || key === "value")) return false; if (!safeStructuredLaunchOption(structuredOutput.option)) return false; - if (typeof structuredOutput.value !== "string" || !/^[a-z0-9][a-z0-9._-]{0,63}$/i.test(structuredOutput.value)) return false; - if (structuredOutput.value !== (typeof requires.protocol === "string" ? requires.protocol : "json")) 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 (requires.session === true && value.sessionOption === undefined) return false; return value.requiredFlags.length <= SAFE_STRUCTURED_REQUIRED_FLAGS.size @@ -353,7 +363,10 @@ function schemaMatches(schema: OpenCodeEventSchema, capabilities: OpenCodeRunCap 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 }]; - if (!structuredOutputs.some((output) => output.option === schema.launch.structuredOutput.option && output.values.includes(schema.launch.structuredOutput.value))) return false; + const structuredSwitches = capabilities.structuredSwitches ?? []; + 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 (!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)) return false; diff --git a/src/lib/opencode-stream.test.ts b/src/lib/opencode-stream.test.ts index 9275b1c61..8891a256a 100644 --- a/src/lib/opencode-stream.test.ts +++ b/src/lib/opencode-stream.test.ts @@ -200,4 +200,25 @@ assert.deepEqual( { 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: { 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 8ac54e087..d73ec1e8c 100644 --- a/src/lib/opencode-stream.ts +++ b/src/lib/opencode-stream.ts @@ -31,7 +31,7 @@ function stringAt(recordValue: Record | null, ...keys: string[] return undefined; } -type ShapeAlias = Exclude, "envelope" | "textEnvelope" | "discriminator">; +type ShapeAlias = Exclude, "envelope" | "textEnvelope" | "idEnvelope" | "discriminator">; function shapeAliases(schema: OpenCodeEventSchema | undefined, key: ShapeAlias, defaults: string[]): string[] { const aliases = schema?.shape?.[key]; @@ -76,8 +76,15 @@ function eventTypes(schema: OpenCodeEventSchema | undefined, kind: keyof OpenCod return schema ? schema.eventTypes[kind] : defaults; } -function toolId(part: Record | null, schema?: OpenCodeEventSchema): string | null { - return stringAt(part, ...shapeAliases(schema, "id", ["id", "callID", "callId", "toolCallId", "tool_call_id"])) ?? null; +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 { @@ -140,7 +147,7 @@ export function parseOpenCodeRunEvent(value: unknown, schema?: OpenCodeEventSche } const stateAliases = shapeAliases(schema, "state", ["state"]); const state = record(valueAt(part, stateAliases)) ?? record(valueAt(event, stateAliases)); - const id = toolId(part, schema); + const id = toolId(event, part, schema); const toolStartTypes = eventTypes(schema, "toolStart", ["tool_start"]); const toolEndTypes = eventTypes(schema, "toolEnd", ["tool_result"]); const toolCompleteTypes = eventTypes(schema, "toolComplete", ["tool_use"]); From 101592c4fa5df7b0a1b0353788e436ec690c45f7 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 01:51:33 -0400 Subject: [PATCH 035/112] fix(chat): resolve Windows OpenCode executable identity --- src/app/api/chat/send/chat-send-capabilities.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/app/api/chat/send/chat-send-capabilities.ts b/src/app/api/chat/send/chat-send-capabilities.ts index 522cbcb33..1b863e61b 100644 --- a/src/app/api/chat/send/chat-send-capabilities.ts +++ b/src/app/api/chat/send/chat-send-capabilities.ts @@ -181,7 +181,9 @@ export async function openCodeExecutableIdentity( .split(";") .map((extension) => extension.trim()) .filter((extension) => /^\.[A-Za-z0-9]+$/.test(extension)) - .map((extension) => `opencode${extension}`) + // Windows resolves extensions case-insensitively. Lower-casing also + // lets the explicit win32 resolver contract be exercised on Unix CI. + .map((extension) => `opencode${extension.toLowerCase()}`) : ["opencode"]; for (const directory of pathValue.split(path.delimiter).filter(Boolean)) { for (const name of names) { From a82b4cce6e94048c5e9e9ad4f3e6e6418d1282ea Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 01:52:04 -0400 Subject: [PATCH 036/112] fix(chat): validate OpenCode capability coverage --- .../chat/send/chat-send-capabilities.test.ts | 19 +++++ .../api/chat/send/chat-send-capabilities.ts | 21 ++++-- .../send/harness-routing-opencode.test.ts | 5 ++ src/app/api/chat/send/route.ts | 12 ++-- src/lib/opencode-compatibility.test.ts | 71 +++++++++++++++++++ src/lib/opencode-compatibility.ts | 33 +++++++-- 6 files changed, 147 insertions(+), 14 deletions(-) diff --git a/src/app/api/chat/send/chat-send-capabilities.test.ts b/src/app/api/chat/send/chat-send-capabilities.test.ts index 3384f6efa..a000116fb 100644 --- a/src/app/api/chat/send/chat-send-capabilities.test.ts +++ b/src/app/api/chat/send/chat-send-capabilities.test.ts @@ -51,6 +51,25 @@ assert.deepEqual( ); 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 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"); + const launcherA = await mkdtemp(path.join(tmpdir(), "cave-opencode-launch-a-")); const launcherB = await mkdtemp(path.join(tmpdir(), "cave-opencode-launch-b-")); const executable = process.platform === "win32" ? "opencode.cmd" : "opencode"; diff --git a/src/app/api/chat/send/chat-send-capabilities.ts b/src/app/api/chat/send/chat-send-capabilities.ts index 1b863e61b..90ee19aa2 100644 --- a/src/app/api/chat/send/chat-send-capabilities.ts +++ b/src/app/api/chat/send/chat-send-capabilities.ts @@ -128,7 +128,14 @@ function advertisedStructuredOutputs(help: string): Array<{ option: string; valu return declaredRunOptions(help).flatMap((option) => { if (!optionTakesExplicitValue(help, option)) return []; const stanza = optionStanza(help, option); - const values = [...new Set((stanza.match(/\bjson(?:[._-][a-z0-9]+)*\b/gi) ?? []).map((value) => value.toLowerCase()))]; + // 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 = [ + ...[...stanza.matchAll(/[<{[]([^}>\]\n]*(?:[,|][^}>\]\n]*)+)[}>\]]/g)].map((match) => match[1]), + ...[...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 }] : []; }); } @@ -218,6 +225,7 @@ function advertisedFormatProtocols( */ 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); @@ -229,14 +237,19 @@ export function parseOpenCodeRunCapabilitiesHelp(help: string, version: string | // 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: options.includes("--model"), - session: options.includes("--session") || options.includes("--resume"), + 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, structuredSwitches, structuredOutputs, @@ -321,7 +334,7 @@ export async function openCodeRunCapabilities(familiarId?: string): Promise 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 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", diff --git a/src/lib/opencode-compatibility.ts b/src/lib/opencode-compatibility.ts index 1968d019e..4506a871f 100644 --- a/src/lib/opencode-compatibility.ts +++ b/src/lib/opencode-compatibility.ts @@ -13,6 +13,8 @@ export type OpenCodeRunCapabilities = { 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[]; /** Declared valueless switches that explicitly request a JSON protocol. */ @@ -83,6 +85,8 @@ export type OpenCodeSchemaBundle = { 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 }; }; @@ -364,12 +368,16 @@ function schemaMatches(schema: OpenCodeEventSchema, capabilities: OpenCodeRunCap 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 (!structuredOutputs.some((output) => output.option === schema.launch.structuredOutput.option && output.values.includes(schema.launch.structuredOutput.value!))) 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)) 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; @@ -422,9 +430,15 @@ export function isOpenCodeSchemaBundle( 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 === "schemas" || key === "signature")) 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" @@ -890,7 +904,16 @@ export async function resolveOpenCodeCompatibility( diagnostic: loaded.diagnostic, }; } - const schema = selectOpenCodeSchema(loaded.bundle.schemas, capabilities); + const remoteSchema = selectOpenCodeSchema(loaded.bundle.schemas, capabilities); + // Remote registries publish additive compatibility profiles by default. A + // partial update must not evict a compiled, known-safe parser for another + // installed client; retirement is possible only through an explicit signed + // baseline schema ID in the same bundle. + const builtInSchema = remoteSchema || loaded.source === "built-in" + ? null + : selectOpenCodeSchema(BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas, capabilities); + const schema = remoteSchema + ?? (builtInSchema && !loaded.bundle.retiredSchemaIds?.includes(builtInSchema.id) ? builtInSchema : null); if (!schema) { return { mode: "plain", @@ -912,7 +935,7 @@ export async function resolveOpenCodeCompatibility( mode: "structured", capabilities, schema, - bundleSource: loaded.source, + bundleSource: remoteSchema ? 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. From de44840e8fe4017d4bb3610b23086abc201cc5d6 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 01:56:53 -0400 Subject: [PATCH 037/112] test(ci): load aliases for OpenCode route integration --- scripts/run-tests.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/run-tests.mjs b/scripts/run-tests.mjs index dce5840ed..d24a04fc9 100644 --- a/scripts/run-tests.mjs +++ b/scripts/run-tests.mjs @@ -1285,6 +1285,7 @@ const ALIAS_LOADER = new Set([ "src/lib/opencode-compatibility.test.ts", "src/lib/opencode-stream.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", "scripts/cave-home-migration-windows.test.ts", "src/lib/bento-dashboard.test.ts", From f9d5b2a6bdf1493c1e5159b7b2dbaf03107798a6 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 02:04:48 -0400 Subject: [PATCH 038/112] fix(chat): preserve OpenCode registry ordering --- src/lib/opencode-compatibility.test.ts | 30 ++++++++++++++- src/lib/opencode-compatibility.ts | 53 +++++++++++++++++++++----- 2 files changed, 73 insertions(+), 10 deletions(-) diff --git a/src/lib/opencode-compatibility.test.ts b/src/lib/opencode-compatibility.test.ts index fad56214a..a66375e94 100644 --- a/src/lib/opencode-compatibility.test.ts +++ b/src/lib/opencode-compatibility.test.ts @@ -1,7 +1,7 @@ // @ts-nocheck import assert from "node:assert/strict"; import { generateKeyPairSync, sign } from "node:crypto"; -import { mkdtemp, readFile, utimes, writeFile } from "node:fs/promises"; +import { mkdtemp, readFile, rm, utimes, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { @@ -564,6 +564,34 @@ const recoveredLock = await loadOpenCodeSchemaBundle({ }); assert.equal(recoveredLock.bundle.sequence, 3, "a stale writer lock cannot permanently block cache recovery after a crash"); +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 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({ diff --git a/src/lib/opencode-compatibility.ts b/src/lib/opencode-compatibility.ts index 4506a871f..20fff680a 100644 --- a/src/lib/opencode-compatibility.ts +++ b/src/lib/opencode-compatibility.ts @@ -147,6 +147,8 @@ 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; const refreshFlights = new Map>(); const refreshRetryAt = new Map(); @@ -703,6 +705,31 @@ async function withCacheWriteLock(file: string, callback: () => Promise): } } +/** Wait briefly for a competing writer before choosing an older remote reply. */ +async function waitForConcurrentCacheWriter( + file: string, + publicKeys: OpenCodeRegistryKeyring, + now: number, + minimumSequence: number, +): 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); + if (latest && latest.bundle.sequence >= minimumSequence) return latest; + try { + await stat(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); + } + if (Date.now() >= deadline) return latest; + await new Promise((resolve) => setTimeout(resolve, CACHE_LOCK_POLL_MS)); + } +} + export type OpenCodeSchemaBundleSource = { url?: string; publicKey?: string; @@ -715,9 +742,9 @@ export type OpenCodeSchemaBundleSource = { refreshTimeoutMs?: number; }; -// Release builds inject these public values at compile time. Server-side -// overrides retain local test and operator support without changing the -// packaged trust anchor. +// 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; @@ -737,14 +764,22 @@ function parseKeyring(value: string | undefined): OpenCodeRegistryKeyring | unde } function registryKeyring(source: OpenCodeSchemaBundleSource): OpenCodeRegistryKeyring | undefined { - const configured = source.publicKeys - ?? parseKeyring(process.env.COVEN_OPENCODE_SCHEMA_REGISTRY_PUBLIC_KEYS) - ?? parseKeyring(PACKAGED_REGISTRY_PUBLIC_KEYS); + 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 = source.publicKey ?? process.env.COVEN_OPENCODE_SCHEMA_REGISTRY_PUBLIC_KEY ?? PACKAGED_REGISTRY_PUBLIC_KEY; + 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 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 schemaRefreshKey(file: string, url: string, publicKeys: OpenCodeRegistryKeyring): string { return createHash("sha256").update(`${file}\0${url}\0${stableJson(publicKeys)}`).digest("hex"); } @@ -783,7 +818,7 @@ async function refreshOpenCodeSchemaBundle( // 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 readVerifiedCache(file, publicKeys, now); + const current = await waitForConcurrentCacheWriter(file, publicKeys, now, remote.sequence); if (current && current.bundle.sequence >= remote.sequence) { return { bundle: current.bundle, source: "cache" }; } @@ -820,7 +855,7 @@ function startSchemaRefresh( export async function loadOpenCodeSchemaBundle(source: OpenCodeSchemaBundleSource = {}): Promise { const now = source.now?.() ?? Date.now(); const file = source.cacheFile ?? cachePath(); - const url = source.url ?? process.env.COVEN_OPENCODE_SCHEMA_REGISTRY_URL ?? PACKAGED_REGISTRY_URL; + const url = registryUrl(source); const publicKeys = registryKeyring(source); if (!url || !publicKeys) return { bundle: BUILTIN_OPENCODE_SCHEMA_BUNDLE, source: "built-in" }; From e5235b4a9653363ebb67aca040a765a6fb9f2932 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 02:09:51 -0400 Subject: [PATCH 039/112] fix(chat): harden OpenCode fallback recovery --- .../send/harness-routing-opencode.test.ts | 18 +++++-- .../send/route-opencode.integration.test.ts | 7 ++- src/app/api/chat/send/route.ts | 48 ++++++++++++------- 3 files changed, 51 insertions(+), 22 deletions(-) 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 7c1ff5216..325f40f29 100644 --- a/src/app/api/chat/send/harness-routing-opencode.test.ts +++ b/src/app/api/chat/send/harness-routing-opencode.test.ts @@ -32,8 +32,8 @@ assert.match( ); assert.match( route, - /let openCodeSessionId: string \| null = null;[\s\S]*?onSession: \(nativeSessionId\) => \{[\s\S]*?openCodeSessionId = nativeSessionId;[\s\S]*?if \(!sessionId\) announceSession\(nativeSessionId\);[\s\S]*?openCodeSessionId \?\? existingConversation\?\.harnessSessionId/, - "every structured OpenCode session event updates native resume state without replacing Cave's stable session id", + /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, @@ -85,6 +85,16 @@ assert.match( /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, /openCodePlainFallback|openCodeStructuredIncompatibility|structured-stream-quarantined/, @@ -122,8 +132,8 @@ assert.match( ); assert.match( route, - /const harnessSessionId = grokDirect[\s\S]*?: openCodeDirect[\s\S]*?openCodeSessionId \?\? existingConversation\?\.harnessSessionId \?\? \(!existingConversation \? body\.sessionId : undefined\)/, - "a plain OpenCode turn retains the native id it used to resume without mistaking Cave's stable id for a new native token", + /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, diff --git a/src/app/api/chat/send/route-opencode.integration.test.ts b/src/app/api/chat/send/route-opencode.integration.test.ts index a47573cc9..e9f3f1004 100644 --- a/src/app/api/chat/send/route-opencode.integration.test.ts +++ b/src/app/api/chat/send/route-opencode.integration.test.ts @@ -1,13 +1,16 @@ // @ts-nocheck import assert from "node:assert/strict"; import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; +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. -const home = await mkdtemp(path.join(tmpdir(), "cave-opencode-route-")); +// 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 }); diff --git a/src/app/api/chat/send/route.ts b/src/app/api/chat/send/route.ts index cce2ba964..243cb8949 100644 --- a/src/app/api/chat/send/route.ts +++ b/src/app/api/chat/send/route.ts @@ -1293,6 +1293,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. @@ -1362,6 +1365,9 @@ export async function POST(req: Request) { }); } if (openCodeDirect) { + // 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. @@ -1373,7 +1379,10 @@ export async function POST(req: Request) { ...(launch.structuredOutput.value === undefined ? [] : [launch.structuredOutput.value]), ...launch.requiredFlags, ); - if (resumeSessionId && launch.sessionOption) a.push(launch.sessionOption, resumeSessionId); + 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 @@ -1385,7 +1394,10 @@ export async function POST(req: Request) { : options.includes("--resume") && valueOptions.includes("--resume") ? "--resume" : null; - if (sessionOption) a.push(sessionOption, resumeSessionId); + if (sessionOption) { + a.push(sessionOption, resumeSessionId); + openCodeNativeResumeUsed = true; + } } if (forwardModel) a.push("--model", forwardModel); a.push(prompt); @@ -1625,7 +1637,8 @@ 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 openCodeCompatibilityNoticeSent = false; + let openCodeCompatibilityHealthNoticeSent = false; + let openCodeProtocolQuarantineNoticeSent = false; let openCodeModelRejected = false; // Model parity: the harness echoes its resolved model on the init/system @@ -1930,8 +1943,8 @@ export async function POST(req: Request) { // as assistant output: tool progress and provider errors often // carry those fields and may contain secrets or file contents. quarantineOpenCodeSchema(openCodeCompatibility?.schema); - if (!openCodeCompatibilityNoticeSent) { - openCodeCompatibilityNoticeSent = true; + 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", @@ -1946,8 +1959,8 @@ export async function POST(req: Request) { // persisted error tail or any user-visible diagnostic. recordStdoutErrorTail("OpenCode emitted a malformed JSON event", true); quarantineOpenCodeSchema(openCodeCompatibility?.schema); - if (!openCodeCompatibilityNoticeSent) { - openCodeCompatibilityNoticeSent = true; + if (!openCodeProtocolQuarantineNoticeSent) { + openCodeProtocolQuarantineNoticeSent = true; pushProgress( "opencode-compatibility", "OpenCode sent a malformed event; future turns will use safe plain chat until a compatible schema is available", @@ -1965,8 +1978,8 @@ export async function POST(req: Request) { // leak into bubble text. const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine; if (!line) return; - if (openCodeDirect && !openCodeCompatibilityNoticeSent && openCodeCompatibility?.diagnostic) { - openCodeCompatibilityNoticeSent = true; + 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" @@ -1974,7 +1987,7 @@ export async function POST(req: Request) { : 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; using the shipped compatible parser" + ? "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"; @@ -2329,8 +2342,8 @@ export async function POST(req: Request) { 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 && !openCodeCompatibilityNoticeSent && openCodeCompatibility?.diagnostic) { - openCodeCompatibilityNoticeSent = true; + 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" @@ -2338,7 +2351,7 @@ export async function POST(req: Request) { : 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; using the shipped compatible parser" + ? "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"; @@ -2557,9 +2570,12 @@ export async function POST(req: Request) { const harnessSessionId = grokDirect ? grokSessionId : openCodeDirect - // Plain output has no new native id to announce, but a native id - // used to resume this turn remains valid for its next resume. - ? openCodeSessionId ?? existingConversation?.harnessSessionId ?? (!existingConversation ? body.sessionId : undefined) + // 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) : sessionId; // OpenCode's JSON event protocol does not echo the selected model. Its // direct argv proves the selection was forwarded, while a successful From 8770843ce7d58a349ad969a28e61aab319b678fb Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 02:17:19 -0400 Subject: [PATCH 040/112] fix(chat): fail closed on OpenCode tool envelopes --- src/lib/opencode-compatibility.test.ts | 31 +++++++++++++++++++++++ src/lib/opencode-compatibility.ts | 25 ++++++++++++++++--- src/lib/opencode-stream.test.ts | 8 ++++++ src/lib/opencode-stream.ts | 34 ++++++++++++++------------ 4 files changed, 79 insertions(+), 19 deletions(-) diff --git a/src/lib/opencode-compatibility.test.ts b/src/lib/opencode-compatibility.test.ts index a66375e94..ed5dc9867 100644 --- a/src/lib/opencode-compatibility.test.ts +++ b/src/lib/opencode-compatibility.test.ts @@ -253,6 +253,21 @@ assert.equal( 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, unexpected: true }, now), false, "unknown format-1 bundle fields fail closed"); assert.equal(isOpenCodeSchemaBundle({ ...unsigned, @@ -592,6 +607,22 @@ const waitedForWriter = await loadOpenCodeSchemaBundle({ 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({ diff --git a/src/lib/opencode-compatibility.ts b/src/lib/opencode-compatibility.ts index 20fff680a..dea0189fb 100644 --- a/src/lib/opencode-compatibility.ts +++ b/src/lib/opencode-compatibility.ts @@ -55,6 +55,8 @@ export type OpenCodeEventSchema = { }; /** 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[]; sessionId: string[]; @@ -150,6 +152,7 @@ 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 {} const refreshFlights = new Map>(); const refreshRetryAt = new Map(); // A selected schema that emits an unknown or malformed frame is unsafe for @@ -201,6 +204,7 @@ export const BUILTIN_OPENCODE_SCHEMA_BUNDLE: OpenCodeSchemaBundle = { envelope: ["part", "data", "root"], discriminator: { envelope: "root", field: "type" }, textEnvelope: ["part", "data"], + toolEnvelope: ["part"], 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"], @@ -230,6 +234,7 @@ export const BUILTIN_OPENCODE_SCHEMA_BUNDLE: OpenCodeSchemaBundle = { envelope: ["part", "data", "root"], discriminator: { envelope: "root", field: "type" }, textEnvelope: ["part", "data"], + toolEnvelope: ["data", "root"], 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"], @@ -267,9 +272,10 @@ function hasValidShape(value: unknown): boolean { && fields.length > 0 && fields.length <= 4 && fields.every(hasValidEnvelopePath); - if (!Object.keys(value).every((key) => key === "envelope" || key === "textEnvelope" || key === "idEnvelope" || key === "discriminator" || aliasKeys.includes(key))) return false; + if (!Object.keys(value).every((key) => key === "envelope" || key === "textEnvelope" || key === "toolEnvelope" || key === "idEnvelope" || 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") @@ -353,7 +359,7 @@ function isEventSchema(value: unknown): value is OpenCodeEventSchema { ...(eventTypes.toolStart as string[]), ...(eventTypes.toolComplete as string[]), ]; - if (toolLabels.some((label) => nonToolLabels.has(label))) return false; + if (new Set(toolLabels).size !== toolLabels.length || toolLabels.some((label) => nonToolLabels.has(label))) return false; return true; } @@ -822,7 +828,10 @@ async function refreshOpenCodeSchemaBundle( if (current && current.bundle.sequence >= remote.sequence) { return { bundle: current.bundle, source: "cache" }; } - return { bundle: remote, source: "remote" }; + // 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( @@ -882,7 +891,15 @@ export async function loadOpenCodeSchemaBundle(source: OpenCodeSchemaBundleSourc ); try { return await refresh; - } catch { + } catch (error) { + if (error instanceof CacheWriterPendingError) { + 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 diff --git a/src/lib/opencode-stream.test.ts b/src/lib/opencode-stream.test.ts index 8891a256a..8c4837165 100644 --- a/src/lib/opencode-stream.test.ts +++ b/src/lib/opencode-stream.test.ts @@ -88,6 +88,14 @@ 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: "text" }), { kind: "other", sessionId: undefined, diagnostic: "malformed-event" }, diff --git a/src/lib/opencode-stream.ts b/src/lib/opencode-stream.ts index d73ec1e8c..bd4b9cfc5 100644 --- a/src/lib/opencode-stream.ts +++ b/src/lib/opencode-stream.ts @@ -31,7 +31,7 @@ function stringAt(recordValue: Record | null, ...keys: string[] return undefined; } -type ShapeAlias = Exclude, "envelope" | "textEnvelope" | "idEnvelope" | "discriminator">; +type ShapeAlias = Exclude, "envelope" | "textEnvelope" | "toolEnvelope" | "idEnvelope" | "discriminator">; function shapeAliases(schema: OpenCodeEventSchema | undefined, key: ShapeAlias, defaults: string[]): string[] { const aliases = schema?.shape?.[key]; @@ -145,37 +145,41 @@ export function parseOpenCodeRunEvent(value: unknown, schema?: OpenCodeEventSche if (eventTypes(schema, "text", ["text"]).includes(eventType) && text !== undefined) { 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(part, stateAliases)) ?? record(valueAt(event, stateAliases)); - const id = toolId(event, part, schema); + const state = record(valueAt(toolPart, stateAliases)) ?? record(valueAt(event, stateAliases)); + const id = toolId(event, toolPart, schema); const toolStartTypes = eventTypes(schema, "toolStart", ["tool_start"]); const toolEndTypes = eventTypes(schema, "toolEnd", ["tool_result"]); const toolCompleteTypes = eventTypes(schema, "toolComplete", ["tool_use"]); - if (toolStartTypes.includes(eventType) && id && !terminalToolState(state, part, schema)) { - return { kind: "tool_start", sessionId, id, name: stringAt(part, ...shapeAliases(schema, "name", ["tool", "name"])) ?? "tool", input: valueAt(state, shapeAliases(schema, "input", ["input"])) ?? valueAt(part, shapeAliases(schema, "input", ["input"])) ?? {} }; + if (toolStartTypes.includes(eventType) && id && toolPart && !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 (toolEndTypes.includes(eventType) && id) { + if (toolEndTypes.includes(eventType) && id && toolPart) { 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(part, output) ?? valueAt(part, error) ?? "", isError: toolStateIsError(state, part, schema) }; + 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 (toolCompleteTypes.includes(eventType) && id && part) { + if (toolCompleteTypes.includes(eventType) && id && toolPart) { // 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 hasTerminalPayload = valueAt(state, output) !== undefined || valueAt(state, error) !== undefined || valueAt(part, output) !== undefined || valueAt(part, error) !== undefined; - if (!terminalToolState(state, part, schema) && !hasTerminalPayload) { - return { kind: "tool_start", sessionId, id, name: stringAt(part, ...shapeAliases(schema, "name", ["tool", "name"])) ?? "tool", input: valueAt(state, shapeAliases(schema, "input", ["input"])) ?? valueAt(part, shapeAliases(schema, "input", ["input"])) ?? {} }; + const hasTerminalPayload = valueAt(state, output) !== undefined || valueAt(state, error) !== undefined || valueAt(toolPart, output) !== undefined || valueAt(toolPart, error) !== undefined; + 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, - name: stringAt(part, ...shapeAliases(schema, "name", ["tool", "name"])) ?? "tool", - input: valueAt(state, shapeAliases(schema, "input", ["input"])) ?? valueAt(part, shapeAliases(schema, "input", ["input"])) ?? {}, - output: valueAt(state, output) ?? valueAt(state, error) ?? valueAt(part, output) ?? valueAt(part, error) ?? "", - isError: toolStateIsError(state, part, schema), + 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), }; } const knownType = [ From 4e7679e94e18f23a137a8716f66ef5e51a13c87f Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 02:20:20 -0400 Subject: [PATCH 041/112] fix(chat): invalidate stale OpenCode sessions --- .../chat/send/harness-routing-opencode.test.ts | 5 +++++ src/app/api/chat/send/route.ts | 6 ++++++ src/lib/opencode-compatibility.test.ts | 15 +++++++++++++++ src/lib/opencode-compatibility.ts | 18 ++++++++---------- 4 files changed, 34 insertions(+), 10 deletions(-) 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 325f40f29..54e7bb1ce 100644 --- a/src/app/api/chat/send/harness-routing-opencode.test.ts +++ b/src/app/api/chat/send/harness-routing-opencode.test.ts @@ -135,6 +135,11 @@ assert.match( /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\)/, diff --git a/src/app/api/chat/send/route.ts b/src/app/api/chat/send/route.ts index 243cb8949..9898c5690 100644 --- a/src/app/api/chat/send/route.ts +++ b/src/app/api/chat/send/route.ts @@ -2692,6 +2692,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/opencode-compatibility.test.ts b/src/lib/opencode-compatibility.test.ts index ed5dc9867..8779248a5 100644 --- a/src/lib/opencode-compatibility.test.ts +++ b/src/lib/opencode-compatibility.test.ts @@ -268,6 +268,21 @@ assert.equal( 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, diff --git a/src/lib/opencode-compatibility.ts b/src/lib/opencode-compatibility.ts index dea0189fb..695640ac1 100644 --- a/src/lib/opencode-compatibility.ts +++ b/src/lib/opencode-compatibility.ts @@ -348,18 +348,16 @@ function isEventSchema(value: unknown): value is OpenCodeEventSchema { && labels.length <= 32 && labels.every((type: unknown) => typeof type === "string" && type.length > 0 && type.length <= 80); })) return false; - const nonToolLabels = new Set(); - for (const key of ["ignored", "text", "error", "toolEnd"] as const) { - for (const label of eventTypes[key] as unknown[]) { - if (nonToolLabels.has(label as string)) return false; - nonToolLabels.add(label as string); + // 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); } } - const toolLabels = [ - ...(eventTypes.toolStart as string[]), - ...(eventTypes.toolComplete as string[]), - ]; - if (new Set(toolLabels).size !== toolLabels.length || toolLabels.some((label) => nonToolLabels.has(label))) return false; return true; } From 407556cd7a818a012577fcbc9c5c22fc80544974 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 07:16:29 -0400 Subject: [PATCH 042/112] fix(chat): scan OpenCode format enums linearly --- .../chat/send/chat-send-capabilities.test.ts | 4 ++++ .../api/chat/send/chat-send-capabilities.ts | 20 ++++++++++++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/app/api/chat/send/chat-send-capabilities.test.ts b/src/app/api/chat/send/chat-send-capabilities.test.ts index a000116fb..f91ec86dd 100644 --- a/src/app/api/chat/send/chat-send-capabilities.test.ts +++ b/src/app/api/chat/send/chat-send-capabilities.test.ts @@ -62,6 +62,10 @@ assert.deepEqual( ); 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 diff --git a/src/app/api/chat/send/chat-send-capabilities.ts b/src/app/api/chat/send/chat-send-capabilities.ts index 90ee19aa2..144d312e4 100644 --- a/src/app/api/chat/send/chat-send-capabilities.ts +++ b/src/app/api/chat/send/chat-send-capabilities.ts @@ -124,6 +124,24 @@ function optionTakesExplicitValue(help: string, option: string): boolean { return /<[^>\n]+>|\[[^\]\n]+\]|=\S+/.test(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 []; @@ -132,7 +150,7 @@ function advertisedStructuredOutputs(help: string): Array<{ option: string; valu // evidence to an explicit enum in the synopsis or to an option-local // `format:`/`values:`/`choices:` metadata list. const enumerations = [ - ...[...stanza.matchAll(/[<{[]([^}>\]\n]*(?:[,|][^}>\]\n]*)+)[}>\]]/g)].map((match) => match[1]), + ...bracketEnumerations(stanza), ...[...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()))]; From e9e6c5b269976add7d7701f2bca84f9bed268c53 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 07:23:30 -0400 Subject: [PATCH 043/112] feat(chat): support OpenCode tool progress --- .../send/route-opencode.integration.test.ts | 5 ++ src/app/api/chat/send/route.ts | 9 +++ src/lib/chat-tool-events.test.ts | 24 ++++++++ src/lib/chat-tool-events.ts | 56 +++++++++++++++++++ src/lib/opencode-compatibility.test.ts | 13 +++++ src/lib/opencode-compatibility.ts | 31 +++++++++- src/lib/opencode-stream.test.ts | 25 +++++++++ src/lib/opencode-stream.ts | 9 +++ 8 files changed, 169 insertions(+), 3 deletions(-) diff --git a/src/app/api/chat/send/route-opencode.integration.test.ts b/src/app/api/chat/send/route-opencode.integration.test.ts index e9f3f1004..1b999fd2f 100644 --- a/src/app/api/chat/send/route-opencode.integration.test.ts +++ b/src/app/api/chat/send/route-opencode.integration.test.ts @@ -48,6 +48,11 @@ const launcher = process.platform === "win32" 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"); diff --git a/src/app/api/chat/send/route.ts b/src/app/api/chat/send/route.ts index 9898c5690..43eecad7e 100644 --- a/src/app/api/chat/send/route.ts +++ b/src/app/api/chat/send/route.ts @@ -1916,9 +1916,18 @@ export async function POST(req: Request) { 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, diff --git a/src/lib/chat-tool-events.test.ts b/src/lib/chat-tool-events.test.ts index 3c66a96c4..e447633d3 100644 --- a/src/lib/chat-tool-events.test.ts +++ b/src/lib/chat-tool-events.test.ts @@ -21,6 +21,30 @@ assert.deepEqual( "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"); diff --git a/src/lib/chat-tool-events.ts b/src/lib/chat-tool-events.ts index 703509473..7b66f1421 100644 --- a/src/lib/chat-tool-events.ts +++ b/src/lib/chat-tool-events.ts @@ -131,6 +131,9 @@ export class ToolCallTracker { /** 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(); @@ -164,6 +167,21 @@ export class ToolCallTracker { } } + 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) { @@ -280,6 +298,7 @@ export class ToolCallTracker { */ envelopeToolUse(id: string, name: string, input?: string, textOffset?: number): ToolStreamEvent | 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); @@ -320,6 +339,43 @@ export class ToolCallTracker { 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; + } + /** * stream-json `tool_result` block (from the follow-up user message). * Returns null when the matching call was already settled by a post hook diff --git a/src/lib/opencode-compatibility.test.ts b/src/lib/opencode-compatibility.test.ts index 8779248a5..67a816778 100644 --- a/src/lib/opencode-compatibility.test.ts +++ b/src/lib/opencode-compatibility.test.ts @@ -215,6 +215,7 @@ assert.equal( ignored: [], text: ["message"], toolStart: [], + toolProgress: [], toolEnd: [], toolComplete: [], error: [], @@ -563,6 +564,18 @@ 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, diff --git a/src/lib/opencode-compatibility.ts b/src/lib/opencode-compatibility.ts index 695640ac1..20b84fd9a 100644 --- a/src/lib/opencode-compatibility.ts +++ b/src/lib/opencode-compatibility.ts @@ -37,6 +37,8 @@ export type OpenCodeEventSchema = { ignored: string[]; text: string[]; toolStart: string[]; + /** Nonterminal output/status updates for an already-announced tool call. */ + toolProgress: string[]; toolEnd: string[]; toolComplete: string[]; error: string[]; @@ -196,6 +198,7 @@ export const BUILTIN_OPENCODE_SCHEMA_BUNDLE: OpenCodeSchemaBundle = { // 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"], @@ -226,6 +229,7 @@ export const BUILTIN_OPENCODE_SCHEMA_BUNDLE: OpenCodeSchemaBundle = { ignored: ["step_start", "step_finish", "reasoning"], text: ["message", "assistant_text"], toolStart: ["tool"], + toolProgress: [], toolEnd: ["tool_output"], toolComplete: ["tool_call"], error: ["error", "failed"], @@ -330,7 +334,7 @@ function isEventSchema(value: unknown): value is OpenCodeEventSchema { 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 eventKeys: Array = ["ignored", "text", "toolStart", "toolEnd", "toolComplete", "error"]; + const eventKeys: Array = ["ignored", "text", "toolStart", "toolProgress", "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. @@ -525,6 +529,27 @@ function cacheTrustPath(file: string): string { return `${file}.trust`; } +/** + * 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 open(file, "r"); + const bytes = Buffer.allocUnsafe(MAX_SCHEMA_BUNDLE_BYTES + 1); + const { bytesRead } = await handle.read(bytes, 0, bytes.length, 0); + if (bytesRead > MAX_SCHEMA_BUNDLE_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 @@ -533,8 +558,8 @@ function cacheTrustPath(file: string): string { */ async function readTrustedCacheRecord(file: string, publicKey: string | OpenCodeRegistryKeyring, now: number): Promise { try { - const raw = await readFile(file, "utf8"); - if (Buffer.byteLength(raw, "utf8") > MAX_SCHEMA_BUNDLE_BYTES) return null; + const raw = await readBoundedCacheFile(file); + if (raw === null) return null; const cached = JSON.parse(raw) as CachedBundle; if ( !isRecord(cached) diff --git a/src/lib/opencode-stream.test.ts b/src/lib/opencode-stream.test.ts index 8c4837165..a502061f7 100644 --- a/src/lib/opencode-stream.test.ts +++ b/src/lib/opencode-stream.test.ts @@ -178,6 +178,31 @@ assert.deepEqual( { 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: { 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: { 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", diff --git a/src/lib/opencode-stream.ts b/src/lib/opencode-stream.ts index bd4b9cfc5..c60192c19 100644 --- a/src/lib/opencode-stream.ts +++ b/src/lib/opencode-stream.ts @@ -4,6 +4,7 @@ export type OpenCodeRunEvent = | { kind: "ignore"; sessionId?: string } | { kind: "text"; sessionId?: string; text: string } | { 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 } @@ -14,6 +15,7 @@ export type OpenCodeJsonLineHandlers = { 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; @@ -153,11 +155,16 @@ export function parseOpenCodeRunEvent(value: unknown, schema?: OpenCodeEventSche 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 && !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 && !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) { const output = shapeAliases(schema, "output", ["output"]); const error = shapeAliases(schema, "error", ["error"]); @@ -185,6 +192,7 @@ export function parseOpenCodeRunEvent(value: unknown, schema?: OpenCodeEventSche const knownType = [ ...eventTypes(schema, "text", ["text"]), ...toolStartTypes, + ...toolProgressTypes, ...toolEndTypes, ...toolCompleteTypes, ...eventTypes(schema, "error", ["error"]), @@ -218,6 +226,7 @@ export function handleOpenCodeJsonLine( case "text": handlers.onText?.(event); 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; From b52e65dc79453a28400185d37c6da88a3134de90 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 07:26:18 -0400 Subject: [PATCH 044/112] fix(chat): preserve legacy OpenCode registry schemas --- src/lib/opencode-compatibility.test.ts | 8 +++++++- src/lib/opencode-compatibility.ts | 17 ++++++++++++----- src/lib/opencode-stream.ts | 2 +- 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/lib/opencode-compatibility.test.ts b/src/lib/opencode-compatibility.test.ts index 67a816778..5ded8f238 100644 --- a/src/lib/opencode-compatibility.test.ts +++ b/src/lib/opencode-compatibility.test.ts @@ -215,7 +215,6 @@ assert.equal( ignored: [], text: ["message"], toolStart: [], - toolProgress: [], toolEnd: [], toolComplete: [], error: [], @@ -475,6 +474,13 @@ assert.equal(isOpenCodeSchemaBundle({ 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"); diff --git a/src/lib/opencode-compatibility.ts b/src/lib/opencode-compatibility.ts index 20b84fd9a..63b303fdc 100644 --- a/src/lib/opencode-compatibility.ts +++ b/src/lib/opencode-compatibility.ts @@ -37,8 +37,9 @@ export type OpenCodeEventSchema = { ignored: string[]; text: string[]; toolStart: string[]; - /** Nonterminal output/status updates for an already-announced tool call. */ - toolProgress: 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[]; @@ -334,12 +335,18 @@ function isEventSchema(value: unknown): value is OpenCodeEventSchema { 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 eventKeys: Array = ["ignored", "text", "toolStart", "toolProgress", "toolEnd", "toolComplete", "error"]; + 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"]; - if (Object.keys(eventTypes).length !== eventKeys.length || !eventKeys.every((key) => { + 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 @@ -351,7 +358,7 @@ function isEventSchema(value: unknown): value is OpenCodeEventSchema { && (mayBeEmpty || labels.length > 0) && labels.length <= 32 && labels.every((type: unknown) => typeof type === "string" && type.length > 0 && type.length <= 80); - })) return false; + })) 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. diff --git a/src/lib/opencode-stream.ts b/src/lib/opencode-stream.ts index c60192c19..bc2d90684 100644 --- a/src/lib/opencode-stream.ts +++ b/src/lib/opencode-stream.ts @@ -75,7 +75,7 @@ function eventTypes(schema: OpenCodeEventSchema | undefined, kind: keyof OpenCod // 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 ? schema.eventTypes[kind] : defaults; + return schema?.eventTypes[kind] ?? defaults; } function toolId(event: Record, part: Record | null, schema?: OpenCodeEventSchema): string | null { From 9981dda715db17a19a405d6e6cd5303ba404af8f Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 07:29:00 -0400 Subject: [PATCH 045/112] fix(chat): pin OpenCode registry genesis --- src/lib/opencode-compatibility.test.ts | 28 ++++++++++++++++++++++++++ src/lib/opencode-compatibility.ts | 19 ++++++++++++++++- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/src/lib/opencode-compatibility.test.ts b/src/lib/opencode-compatibility.test.ts index 5ded8f238..2879990b1 100644 --- a/src/lib/opencode-compatibility.test.ts +++ b/src/lib/opencode-compatibility.test.ts @@ -54,6 +54,34 @@ const signedRollback = { 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"); +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, diff --git a/src/lib/opencode-compatibility.ts b/src/lib/opencode-compatibility.ts index 63b303fdc..5d295999d 100644 --- a/src/lib/opencode-compatibility.ts +++ b/src/lib/opencode-compatibility.ts @@ -164,6 +164,13 @@ const refreshRetryAt = new Map(); // 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; @@ -184,7 +191,7 @@ export function quarantineOpenCodeSchema(schema: OpenCodeEventSchema | undefined export const BUILTIN_OPENCODE_SCHEMA_BUNDLE: OpenCodeSchemaBundle = { format: 1, runtime: "opencode", - sequence: 1, + sequence: OPENCODE_REGISTRY_GENESIS_SEQUENCE, issuedAt: "2026-07-24T00:00:00.000Z", expiresAt: "2030-01-01T00:00:00.000Z", schemas: [ @@ -494,6 +501,15 @@ export function openCodeSchemaBundleSigningPayload(bundle: OpenCodeSchemaBundle) return stableJson(unsigned); } +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); +} + export function verifyOpenCodeSchemaBundle( bundle: unknown, publicKey: string | OpenCodeRegistryKeyring, @@ -831,6 +847,7 @@ async function refreshOpenCodeSchemaBundle( 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 (Object.keys(publicKeys).length > 1 && remote.keyId === undefined) throw new Error("missing schema signing key id"); const cachedTrust = await readCachedTrustState(file, publicKeys, now); if (cachedTrust && remote.sequence < cachedTrust.bundle.sequence) throw new Error("schema rollback"); From 8b13c71db70918da9cef77d2dceeb192dae8fd17 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 07:31:56 -0400 Subject: [PATCH 046/112] fix(chat): bound OpenCode JSONL frames --- .../send/harness-routing-opencode.test.ts | 7 +- src/app/api/chat/send/route.ts | 64 +++++++++++++++---- 2 files changed, 58 insertions(+), 13 deletions(-) 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 54e7bb1ce..657327e1d 100644 --- a/src/app/api/chat/send/harness-routing-opencode.test.ts +++ b/src/app/api/chat/send/harness-routing-opencode.test.ts @@ -167,9 +167,14 @@ assert.match( ); assert.match( route, - /const handleOpenCodeLine[\s\S]*?onMalformedJson: \(\) => \{[\s\S]*?recordStdoutErrorTail\("OpenCode emitted a malformed JSON event", true\)[\s\S]*?quarantineOpenCodeSchema\(openCodeCompatibility\?\.schema\)/, + /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, + /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( capabilities, /export async function openCodeRunCapabilities\(familiarId\?[^)]*\)[\s\S]*?const env = openCodeSpawnEnv\(familiarId\);[\s\S]*?openCodeExecutableIdentity\(env\)[\s\S]*?const versionLaunch = openCodeLaunch\(\["--version"\]\);[\s\S]*?probeOutput\(helpLaunch\.command, helpLaunch\.args, env[\s\S]*?json:[\s\S]*?model:[\s\S]*?session:/, diff --git a/src/app/api/chat/send/route.ts b/src/app/api/chat/send/route.ts index 43eecad7e..8a9e4d244 100644 --- a/src/app/api/chat/send/route.ts +++ b/src/app/api/chat/send/route.ts @@ -1597,6 +1597,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; @@ -1640,6 +1644,18 @@ export async function POST(req: Request) { let openCodeCompatibilityHealthNoticeSent = false; let openCodeProtocolQuarantineNoticeSent = 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); + 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 @@ -1963,20 +1979,10 @@ export async function POST(req: Request) { } }, onMalformedJson: () => { - // Structured-mode stdout can contain a malformed future event with - // arbitrary tool payloads. Do not feed that raw line into the - // persisted error tail or any user-visible diagnostic. - recordStdoutErrorTail("OpenCode emitted a malformed JSON event", true); - quarantineOpenCodeSchema(openCodeCompatibility?.schema); - if (!openCodeProtocolQuarantineNoticeSent) { - openCodeProtocolQuarantineNoticeSent = true; - pushProgress( - "opencode-compatibility", + quarantineOpenCodeProtocol( "OpenCode sent a malformed event; future turns will use safe plain chat until a compatible schema is available", - "error", "malformed-json-event", ); - } }, }); }; @@ -2199,6 +2205,7 @@ export async function POST(req: Request) { const runAttempt = (spawnArgs: string[]): Promise => new Promise((resolve) => { const attemptStartedAt = Date.now(); + let discardingOpenCodeFrame = false; pushProgress( "harness-start", `Starting ${binding.harness}`, @@ -2264,13 +2271,46 @@ export async function POST(req: Request) { req.signal.addEventListener("abort", onAbort, { once: true }); child.stdout.on("data", (data: Buffer) => { - jsonBuf += data.toString("utf8"); + let chunk = data.toString("utf8"); + // 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); } + 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.stderr.on("data", (data: Buffer) => { From 29280a11d2c20d34927dc832fec5bad5455fda3a Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 07:33:19 -0400 Subject: [PATCH 047/112] fix(chat): enforce OpenCode registry genesis in cache --- src/lib/opencode-compatibility.test.ts | 10 ++++++++++ src/lib/opencode-compatibility.ts | 1 + 2 files changed, 11 insertions(+) diff --git a/src/lib/opencode-compatibility.test.ts b/src/lib/opencode-compatibility.test.ts index 2879990b1..a9a3e966d 100644 --- a/src/lib/opencode-compatibility.test.ts +++ b/src/lib/opencode-compatibility.test.ts @@ -64,6 +64,16 @@ const firstUseReplay = await loadOpenCodeSchemaBundle({ }); 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, diff --git a/src/lib/opencode-compatibility.ts b/src/lib/opencode-compatibility.ts index 5d295999d..c760fa483 100644 --- a/src/lib/opencode-compatibility.ts +++ b/src/lib/opencode-compatibility.ts @@ -589,6 +589,7 @@ async function readTrustedCacheRecord(file: string, publicKey: string | OpenCode || 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; From e74b02014eb74aa98ae6f7df2e39ef4df134ef8b Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 07:36:59 -0400 Subject: [PATCH 048/112] fix(chat): allow verified OpenCode tool event flags --- .../chat/send/chat-send-capabilities.test.ts | 10 ++++ src/lib/opencode-compatibility.test.ts | 47 +++++++++++++++++++ src/lib/opencode-compatibility.ts | 19 ++++++-- 3 files changed, 73 insertions(+), 3 deletions(-) diff --git a/src/app/api/chat/send/chat-send-capabilities.test.ts b/src/app/api/chat/send/chat-send-capabilities.test.ts index f91ec86dd..8f69c9fae 100644 --- a/src/app/api/chat/send/chat-send-capabilities.test.ts +++ b/src/app/api/chat/send/chat-send-capabilities.test.ts @@ -31,6 +31,16 @@ assert.deepEqual( "an unbracketed positional token after a flag is ambiguous and cannot be launched without a value", ); +const toolEventsOutput = parseOpenCodeRunCapabilitiesHelp(` + --format Output format: text, json + --include-tool-events Include tool lifecycle frames in JSON output +`, "3.1.1"); +assert.deepEqual( + toolEventsOutput.noValueOptions, + ["--include-tool-events"], + "a declared output-only tool-event switch can be independently confirmed before a signed schema forwards it", +); + const booleanJson = parseOpenCodeRunCapabilitiesHelp(` --json Emit JSON events --event-json-v2 Emit JSON v2 events diff --git a/src/lib/opencode-compatibility.test.ts b/src/lib/opencode-compatibility.test.ts index a9a3e966d..9d0fd5f8e 100644 --- a/src/lib/opencode-compatibility.test.ts +++ b/src/lib/opencode-compatibility.test.ts @@ -486,6 +486,53 @@ assert.equal( 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", +); +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, diff --git a/src/lib/opencode-compatibility.ts b/src/lib/opencode-compatibility.ts index c760fa483..1be9f468f 100644 --- a/src/lib/opencode-compatibility.ts +++ b/src/lib/opencode-compatibility.ts @@ -303,8 +303,14 @@ const UNSAFE_STRUCTURED_LAUNCH_OPTIONS = new Set([ ]); // A registry describes how to frame and parse a stream; it must never widen // what an OpenCode invocation is allowed to do. Keep the remotely supplied -// companion flags to the small, audited set that only requests event framing. -const SAFE_STRUCTURED_REQUIRED_FLAGS = new Set(["--event-stream"]); +// companion flags to a versioned, locally audited set that only asks the CLI +// to include structured output. Each one is still separately confirmed as a +// declared valueless `run` option before its schema can be selected; a signed +// registry never gets to introduce an arbitrary command switch. +const SAFE_STRUCTURED_REQUIRED_FLAGS = new Set([ + "--event-stream", + "--include-tool-events", +]); function safeStructuredLaunchOption(value: unknown): value is string { return typeof value === "string" @@ -408,7 +414,14 @@ function schemaMatches(schema: OpenCodeEventSchema, capabilities: OpenCodeRunCap } function schemaSpecificity(schema: OpenCodeEventSchema): number { - return Number(schema.requires.session === true) + Number(schema.requires.model === true) + Number(schema.requires.protocol !== undefined); + // 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 { From 8cbb0bd95ebc83d2d267b9639c5ff91331eb64dc Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 07:43:20 -0400 Subject: [PATCH 049/112] fix(chat): validate OpenCode payload kinds --- src/lib/opencode-compatibility.test.ts | 14 ++++++++++ src/lib/opencode-compatibility.ts | 19 ++++++++++++- src/lib/opencode-stream.test.ts | 37 +++++++++++++++++++------- src/lib/opencode-stream.ts | 26 +++++++++++++----- 4 files changed, 79 insertions(+), 17 deletions(-) diff --git a/src/lib/opencode-compatibility.test.ts b/src/lib/opencode-compatibility.test.ts index 9d0fd5f8e..fabf81f7e 100644 --- a/src/lib/opencode-compatibility.test.ts +++ b/src/lib/opencode-compatibility.test.ts @@ -358,6 +358,20 @@ assert.equal( 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, diff --git a/src/lib/opencode-compatibility.ts b/src/lib/opencode-compatibility.ts index 1be9f468f..9a97a8308 100644 --- a/src/lib/opencode-compatibility.ts +++ b/src/lib/opencode-compatibility.ts @@ -62,6 +62,12 @@ export type OpenCodeEventSchema = { 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[]; @@ -216,6 +222,7 @@ export const BUILTIN_OPENCODE_SCHEMA_BUNDLE: OpenCodeSchemaBundle = { 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"], @@ -247,6 +254,7 @@ export const BUILTIN_OPENCODE_SCHEMA_BUNDLE: OpenCodeSchemaBundle = { 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"], @@ -284,7 +292,7 @@ function hasValidShape(value: unknown): boolean { && fields.length > 0 && fields.length <= 4 && fields.every(hasValidEnvelopePath); - if (!Object.keys(value).every((key) => key === "envelope" || key === "textEnvelope" || key === "toolEnvelope" || key === "idEnvelope" || key === "discriminator" || aliasKeys.includes(key))) return false; + 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)) @@ -294,6 +302,15 @@ function hasValidShape(value: unknown): boolean { || !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])); } diff --git a/src/lib/opencode-stream.test.ts b/src/lib/opencode-stream.test.ts index a502061f7..f9ab6ba34 100644 --- a/src/lib/opencode-stream.test.ts +++ b/src/lib/opencode-stream.test.ts @@ -13,12 +13,12 @@ assert.deepEqual( const toolIds: string[] = []; const diagnostics: string[] = []; handleOpenCodeJsonLine( - JSON.stringify({ type: "text", sessionID: "ses_live", part: { text: "live reply" } }), + 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: { id: "tool_live", tool: "Read", state: { output: "ok" } } }), + 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") }, ); @@ -40,6 +40,14 @@ assert.deepEqual( { 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" } }, @@ -66,7 +74,7 @@ assert.deepEqual( ); assert.deepEqual( parseOpenCodeRunEvent( - { session: "ses_mapped", payload: { event: "reply", body: "A registry-mapped reply" } }, + { session: "ses_mapped", payload: { event: "reply", type: "text", body: "A registry-mapped reply" } }, { ...BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0], id: "mapped-envelope", @@ -96,6 +104,14 @@ assert.deepEqual( { 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, diagnostic: "malformed-event" }, @@ -136,7 +152,7 @@ assert.deepEqual( ); assert.deepEqual( parseOpenCodeRunEvent( - { type: "tool_use", sessionID: "ses_123", part: { id: "prt_failed", tool: "bash", state: { input: { command: "false" }, error: "permission denied", status: "error" } } }, + { 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 }, @@ -164,7 +180,7 @@ assert.deepEqual( ); assert.deepEqual( parseOpenCodeRunEvent( - { type: "assistant_text", session_id: "ses_legacy", data: { content: "Older client reply" } }, + { 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" }, @@ -172,7 +188,7 @@ assert.deepEqual( ); assert.deepEqual( parseOpenCodeRunEvent( - { type: "tool_call", sessionId: "ses_legacy", data: { toolCallId: "legacy_1", name: "Read", input: { path: "README.md" }, state: { status: "running" } } }, + { 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" } }, @@ -188,7 +204,7 @@ const progressSchema = { }; assert.deepEqual( parseOpenCodeRunEvent( - { type: "tool_progress", sessionId: "ses_progress", data: { toolCallId: "progress_1", state: { output: "read 50%" } } }, + { 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%" }, @@ -197,7 +213,7 @@ assert.deepEqual( { const progress: string[] = []; handleOpenCodeJsonLine( - JSON.stringify({ type: "tool_progress", data: { toolCallId: "progress_dispatch", state: { output: "working" } } }), + JSON.stringify({ type: "tool_progress", data: { type: "tool", toolCallId: "progress_dispatch", state: { output: "working" } } }), progressSchema, { onToolProgress: (event) => progress.push(`${event.id}:${String(event.output)}`) }, ); @@ -213,6 +229,7 @@ const shapedSchema = { 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"], @@ -227,7 +244,7 @@ const shapedSchema = { }; assert.deepEqual( parseOpenCodeRunEvent( - { event: { payload: { event: "tool", session: "ses_v2", call_id: "call_v2", tool_name: "Read", phase: { state: "done", arguments: { path: "README.md" }, result: "ok" } } } }, + { 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 }, @@ -248,7 +265,7 @@ const rootIdSchema = { }; assert.deepEqual( parseOpenCodeRunEvent( - { type: "tool", callID: "root_call_1", part: { tool: "Read", state: { input: { path: "README.md" }, output: "ok", status: "completed" } } }, + { 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 }, diff --git a/src/lib/opencode-stream.ts b/src/lib/opencode-stream.ts index bc2d90684..4aacf29c8 100644 --- a/src/lib/opencode-stream.ts +++ b/src/lib/opencode-stream.ts @@ -33,7 +33,7 @@ function stringAt(recordValue: Record | null, ...keys: string[] return undefined; } -type ShapeAlias = Exclude, "envelope" | "textEnvelope" | "toolEnvelope" | "idEnvelope" | "discriminator">; +type ShapeAlias = Exclude, "envelope" | "textEnvelope" | "toolEnvelope" | "idEnvelope" | "payloadKind" | "discriminator">; function shapeAliases(schema: OpenCodeEventSchema | undefined, key: ShapeAlias, defaults: string[]): string[] { const aliases = schema?.shape?.[key]; @@ -78,6 +78,20 @@ function eventTypes(schema: OpenCodeEventSchema | undefined, kind: keyof OpenCod 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 @@ -144,7 +158,7 @@ export function parseOpenCodeRunEvent(value: unknown, schema?: OpenCodeEventSche // 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); - if (eventTypes(schema, "text", ["text"]).includes(eventType) && text !== undefined) { + if (eventTypes(schema, "text", ["text"]).includes(eventType) && text !== undefined && hasExpectedPayloadKind(textEnvelope(event, schema), schema, "text")) { return { kind: "text", sessionId, text }; } // Tool payloads are stricter than errors/session metadata: v1 only trusts @@ -158,19 +172,19 @@ export function parseOpenCodeRunEvent(value: unknown, schema?: OpenCodeEventSche 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 && !terminalToolState(state, toolPart, schema)) { + 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 && !terminalToolState(state, toolPart, schema)) { + 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) { + 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 (toolCompleteTypes.includes(eventType) && id && toolPart) { + 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"]); From 89c9923bb719eb33dcd62ec4737525e8f441d587 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 07:46:55 -0400 Subject: [PATCH 050/112] fix(chat): bound retained tool activity --- src/lib/chat-tool-events.test.ts | 10 +++++++++- src/lib/chat-tool-events.ts | 3 +++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/lib/chat-tool-events.test.ts b/src/lib/chat-tool-events.test.ts index e447633d3..ae212e327 100644 --- a/src/lib/chat-tool-events.test.ts +++ b/src/lib/chat-tool-events.test.ts @@ -1,6 +1,6 @@ // @ts-nocheck import assert from "node:assert/strict"; -import { MAX_SETTLED_ENVELOPE_IDS, ToolCallTracker, capLiveToolPayload, toPersistedTools } from "./chat-tool-events.ts"; +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); @@ -67,4 +67,12 @@ assert.ok(terminalWindow.envelopeToolUse("settled-0", "read"), "the bounded term 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 7b66f1421..bc732faa9 100644 --- a/src/lib/chat-tool-events.ts +++ b/src/lib/chat-tool-events.ts @@ -44,6 +44,8 @@ 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; @@ -215,6 +217,7 @@ export class ToolCallTracker { }; const prev = this.recorded.get(ev.id); if (!prev) { + if (this.recorded.size >= MAX_RECORDED_TOOL_EVENTS) return; this.recorded.set(ev.id, { ...bounded, ...(textOffset !== undefined ? { textOffset } : {}), From 8c565d28647466e902885502ce274045cff2f0b4 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 07:48:35 -0400 Subject: [PATCH 051/112] fix(chat): pin OpenCode registry release checkpoint --- .github/workflows/release.yml | 2 + scripts/check-opencode-registry-release.mjs | 12 ++++- src/lib/opencode-compatibility.test.ts | 23 ++++++++ src/lib/opencode-compatibility.ts | 60 +++++++++++++++++++-- 4 files changed, 91 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f13d003c8..a330ff866 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -238,6 +238,7 @@ jobs: 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 { @@ -248,6 +249,7 @@ jobs: echo "NEXT_PUBLIC_COVEN_OPENCODE_SCHEMA_REGISTRY_PUBLIC_KEYS<> "$GITHUB_ENV" # Decode the App Store Connect API key onto the runner for the custom diff --git a/scripts/check-opencode-registry-release.mjs b/scripts/check-opencode-registry-release.mjs index a5d354824..f2bbe23e5 100644 --- a/scripts/check-opencode-registry-release.mjs +++ b/scripts/check-opencode-registry-release.mjs @@ -6,6 +6,7 @@ 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}`); @@ -21,8 +22,8 @@ try { } catch { keyring = null; } -if (!url || !keyring || typeof keyring !== "object" || Array.isArray(keyring)) { - fail("OpenCode compatibility registry URL and an Ed25519 public key or bounded keyring must be configured for every desktop release."); +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 { if (new URL(url).protocol !== "https:") throw new Error("registry URL must use HTTPS"); @@ -32,6 +33,13 @@ if (!url || !keyring || typeof keyring !== "object" || Array.isArray(keyring)) { 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/src/lib/opencode-compatibility.test.ts b/src/lib/opencode-compatibility.test.ts index fabf81f7e..e435d2f9d 100644 --- a/src/lib/opencode-compatibility.test.ts +++ b/src/lib/opencode-compatibility.test.ts @@ -7,6 +7,7 @@ import path from "node:path"; import { BUILTIN_OPENCODE_SCHEMA_BUNDLE, loadOpenCodeSchemaBundle, + openCodeSchemaBundlePayloadHash, openCodeSchemaBundleSigningPayload, redactedOpenCodeEventFingerprint, resolveOpenCodeCompatibility, @@ -46,6 +47,28 @@ 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 unsignedSignedRollback = { ...unsigned, sequence: 1 }; const signedRollback = { ...unsignedSignedRollback, diff --git a/src/lib/opencode-compatibility.ts b/src/lib/opencode-compatibility.ts index 9a97a8308..17f75f5d1 100644 --- a/src/lib/opencode-compatibility.ts +++ b/src/lib/opencode-compatibility.ts @@ -25,6 +25,8 @@ export type OpenCodeRunCapabilities = { /** 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; @@ -531,6 +533,10 @@ export function openCodeSchemaBundleSigningPayload(bundle: OpenCodeSchemaBundle) 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. @@ -540,6 +546,16 @@ function meetsOpenCodeRegistryGenesisFloor(bundle: OpenCodeSchemaBundle): boolea || 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, @@ -818,6 +834,8 @@ export type OpenCodeSchemaBundleSource = { 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; fetch?: typeof fetch; now?: () => number; cacheFile?: string; @@ -831,6 +849,7 @@ export type OpenCodeSchemaBundleSource = { 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; @@ -857,20 +876,45 @@ function registryKeyring(source: OpenCodeSchemaBundleSource): OpenCodeRegistryKe 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 schemaRefreshKey(file: string, url: string, publicKeys: OpenCodeRegistryKeyring): string { - return createHash("sha256").update(`${file}\0${url}\0${stableJson(publicKeys)}`).digest("hex"); +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, url: string, publicKeys: OpenCodeRegistryKeyring, checkpoint: OpenCodeRegistryCheckpoint | undefined): string { + return createHash("sha256").update(`${file}\0${url}\0${stableJson(publicKeys)}\0${stableJson(checkpoint ?? null)}`).digest("hex"); } async function refreshOpenCodeSchemaBundle( file: string, url: string, publicKeys: OpenCodeRegistryKeyring, + checkpoint: OpenCodeRegistryCheckpoint | undefined, fetcher: typeof fetch, now: number, refreshTimeoutMs?: number, @@ -879,6 +923,7 @@ async function refreshOpenCodeSchemaBundle( 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); if (cachedTrust && remote.sequence < cachedTrust.bundle.sequence) throw new Error("schema rollback"); @@ -944,7 +989,14 @@ export async function loadOpenCodeSchemaBundle(source: OpenCodeSchemaBundleSourc const file = source.cacheFile ?? cachePath(); 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" }; + } const cached = await readVerifiedCache(file, publicKeys, now); // An expired signed cache cannot parse a turn, but it records that this @@ -954,7 +1006,7 @@ export async function loadOpenCodeSchemaBundle(source: OpenCodeSchemaBundleSourc const cacheTrust = cached ?? await readCachedTrustState(file, publicKeys, now); const cacheFresh = cached && now - cached.checkedAt < CACHE_TTL_MS; if (cacheFresh) return { bundle: cached.bundle, source: "cache" }; - const key = schemaRefreshKey(file, url, publicKeys); + const key = schemaRefreshKey(file, url, publicKeys, checkpoint); if ((refreshRetryAt.get(key) ?? 0) > now) { return cached ? { bundle: cached.bundle, source: "cache", diagnostic: "schema-registry-refresh-rejected" } @@ -964,7 +1016,7 @@ export async function loadOpenCodeSchemaBundle(source: OpenCodeSchemaBundleSourc } const refresh = startSchemaRefresh( key, - () => refreshOpenCodeSchemaBundle(file, url, publicKeys, source.fetch ?? fetch, now, source.refreshTimeoutMs), + () => refreshOpenCodeSchemaBundle(file, url, publicKeys, checkpoint, source.fetch ?? fetch, now, source.refreshTimeoutMs), now, ); try { From cf43e29819894fe0002df1deca59ce5879653a6d Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 07:49:08 -0400 Subject: [PATCH 052/112] docs(chat): document OpenCode registry checkpoint --- docs/opencode-compatibility-registry.md | 19 +++++++++++++++++-- src/lib/opencode-compatibility.test.ts | 13 +++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/docs/opencode-compatibility-registry.md b/docs/opencode-compatibility-registry.md index 9dca3d650..672ee5c8e 100644 --- a/docs/opencode-compatibility-registry.md +++ b/docs/opencode-compatibility-registry.md @@ -9,14 +9,29 @@ 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` and `NEXT_PUBLIC_COVEN_OPENCODE_SCHEMA_REGISTRY_PUBLIC_KEY`, 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 either value is missing, non-HTTPS, or not an Ed25519 key. +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, 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. +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/src/lib/opencode-compatibility.test.ts b/src/lib/opencode-compatibility.test.ts index e435d2f9d..261f5d35c 100644 --- a/src/lib/opencode-compatibility.test.ts +++ b/src/lib/opencode-compatibility.test.ts @@ -19,6 +19,19 @@ import { 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", From 2891a57f6c376c7a5b31f6200429990335da16bc Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 07:49:17 -0400 Subject: [PATCH 053/112] test(ci): cover OpenCode registry checkpoint release guard --- scripts/check-opencode-registry-release.test.mjs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/scripts/check-opencode-registry-release.test.mjs b/scripts/check-opencode-registry-release.test.mjs index e65ca0e8c..67136d03d 100644 --- a/scripts/check-opencode-registry-release.test.mjs +++ b/scripts/check-opencode-registry-release.test.mjs @@ -7,9 +7,13 @@ const docs = await readFile(new URL("../docs/opencode-compatibility-registry.md" 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, /new URL\(url\)\.protocol !== "https:"/); 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/); console.log("check-opencode-registry-release.test.mjs: ok"); From d14fafa3bfdfdb50209360b2877cf01de963896d Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 07:52:05 -0400 Subject: [PATCH 054/112] fix(chat): enforce registry checkpoint on cache --- src/lib/opencode-compatibility.test.ts | 28 ++++++++++++++++++ src/lib/opencode-compatibility.ts | 41 +++++++++++++++++--------- 2 files changed, 55 insertions(+), 14 deletions(-) diff --git a/src/lib/opencode-compatibility.test.ts b/src/lib/opencode-compatibility.test.ts index 261f5d35c..8b334c342 100644 --- a/src/lib/opencode-compatibility.test.ts +++ b/src/lib/opencode-compatibility.test.ts @@ -81,6 +81,34 @@ const checkpointAccepted = await loadOpenCodeSchemaBundle({ 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 unsignedSignedRollback = { ...unsigned, sequence: 1 }; const signedRollback = { diff --git a/src/lib/opencode-compatibility.ts b/src/lib/opencode-compatibility.ts index 17f75f5d1..e2574e8e0 100644 --- a/src/lib/opencode-compatibility.ts +++ b/src/lib/opencode-compatibility.ts @@ -723,16 +723,23 @@ async function staleLockCanBeReclaimed(lock: string): Promise { } } -async function readCachedTrustState(file: string, publicKey: string | OpenCodeRegistryKeyring, now: number): Promise { +async function readCachedTrustState( + file: string, + publicKey: string | OpenCodeRegistryKeyring, + now: number, + checkpoint?: OpenCodeRegistryCheckpoint, +): Promise { const [primary, backup] = await Promise.all([ readTrustedCacheRecord(file, publicKey, now), readTrustedCacheRecord(cacheTrustPath(file), publicKey, now), ]); - if (!primary) return backup; - if (!backup) return primary; + const trustedPrimary = primary && meetsOpenCodeRegistryCheckpoint(primary.bundle, checkpoint) ? primary : null; + const trustedBackup = backup && meetsOpenCodeRegistryCheckpoint(backup.bundle, checkpoint) ? backup : null; + if (!trustedPrimary) return trustedBackup; + if (!trustedBackup) return trustedPrimary; // The sidecar is written before the replaceable cache payload. At an equal // sequence it is the durable floor if a torn/manual primary differs. - return backup.bundle.sequence >= primary.bundle.sequence ? backup : primary; + return trustedBackup.bundle.sequence >= trustedPrimary.bundle.sequence ? trustedBackup : trustedPrimary; } async function writeVerifiedCache(file: string, cached: CachedBundle): Promise { @@ -742,8 +749,13 @@ async function writeVerifiedCache(file: string, cached: CachedBundle): Promise { - const cached = await readCachedTrustState(file, publicKey, now); +async function readVerifiedCache( + file: string, + publicKey: string | OpenCodeRegistryKeyring, + now: number, + checkpoint?: OpenCodeRegistryCheckpoint, +): Promise { + const cached = await readCachedTrustState(file, publicKey, now, checkpoint); if ( !cached // The wrapper is not signed; a future timestamp must not pin an old, @@ -810,19 +822,20 @@ async function waitForConcurrentCacheWriter( publicKeys: OpenCodeRegistryKeyring, now: number, minimumSequence: number, + checkpoint?: OpenCodeRegistryCheckpoint, ): 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); + latest = await readVerifiedCache(file, publicKeys, now, checkpoint); if (latest && latest.bundle.sequence >= minimumSequence) return latest; try { await stat(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); + return await readVerifiedCache(file, publicKeys, now, checkpoint); } if (Date.now() >= deadline) return latest; await new Promise((resolve) => setTimeout(resolve, CACHE_LOCK_POLL_MS)); @@ -925,13 +938,13 @@ async function refreshOpenCodeSchemaBundle( 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); + const cachedTrust = await readCachedTrustState(file, publicKeys, now, checkpoint); if (cachedTrust && remote.sequence < cachedTrust.bundle.sequence) throw new Error("schema rollback"); await mkdir(path.dirname(file), { recursive: true }); const writeResult = await withCacheWriteLock(file, async () => { // 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); + const currentTrust = await readCachedTrustState(file, publicKeys, now, checkpoint); 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"); @@ -947,7 +960,7 @@ async function refreshOpenCodeSchemaBundle( // 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); + const current = await waitForConcurrentCacheWriter(file, publicKeys, now, remote.sequence, checkpoint); if (current && current.bundle.sequence >= remote.sequence) { return { bundle: current.bundle, source: "cache" }; } @@ -998,12 +1011,12 @@ export async function loadOpenCodeSchemaBundle(source: OpenCodeSchemaBundleSourc return { bundle: BUILTIN_OPENCODE_SCHEMA_BUNDLE, source: "built-in", diagnostic: "schema-registry-refresh-rejected" }; } - const cached = await readVerifiedCache(file, publicKeys, now); + const cached = await readVerifiedCache(file, publicKeys, now, checkpoint); // 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. - const cacheTrust = cached ?? await readCachedTrustState(file, publicKeys, now); + const cacheTrust = cached ?? await readCachedTrustState(file, publicKeys, now, checkpoint); const cacheFresh = cached && now - cached.checkedAt < CACHE_TTL_MS; if (cacheFresh) return { bundle: cached.bundle, source: "cache" }; const key = schemaRefreshKey(file, url, publicKeys, checkpoint); @@ -1034,7 +1047,7 @@ export async function loadOpenCodeSchemaBundle(source: OpenCodeSchemaBundleSourc // 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. - const current = await readVerifiedCache(file, publicKeys, now); + const current = await readVerifiedCache(file, publicKeys, now, checkpoint); if (current && (!cached || current.bundle.sequence >= cached.bundle.sequence)) { return { bundle: current.bundle, source: "cache", diagnostic: "schema-registry-refresh-rejected" }; } From 4c983cb309ae13f75f25e5316f31af0de5f308bc Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 08:00:49 -0400 Subject: [PATCH 055/112] fix(chat): preserve safe OpenCode recovery --- .../chat/send/chat-send-capabilities.test.ts | 5 +++++ .../api/chat/send/chat-send-capabilities.ts | 15 +++++++++++++-- .../send/harness-routing-opencode.test.ts | 4 ++-- .../send/route-opencode.integration.test.ts | 4 ++-- src/app/api/chat/send/route.ts | 1 + src/lib/opencode-stream.test.ts | 19 +++++++++++++++++++ src/lib/opencode-stream.ts | 17 ++++++++++++++--- 7 files changed, 56 insertions(+), 9 deletions(-) diff --git a/src/app/api/chat/send/chat-send-capabilities.test.ts b/src/app/api/chat/send/chat-send-capabilities.test.ts index 8f69c9fae..a4c3c4a82 100644 --- a/src/app/api/chat/send/chat-send-capabilities.test.ts +++ b/src/app/api/chat/send/chat-send-capabilities.test.ts @@ -30,6 +30,11 @@ assert.deepEqual( ["--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 toolEventsOutput = parseOpenCodeRunCapabilitiesHelp(` --format Output format: text, json diff --git a/src/app/api/chat/send/chat-send-capabilities.ts b/src/app/api/chat/send/chat-send-capabilities.ts index 144d312e4..1f0aac3ce 100644 --- a/src/app/api/chat/send/chat-send-capabilities.ts +++ b/src/app/api/chat/send/chat-send-capabilities.ts @@ -117,10 +117,17 @@ 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 synopsis = optionStanza(help, option) + const line = optionStanza(help, option) .split(/\r?\n/) .find((line) => line.includes(option)) ?? ""; + const optionAt = line.indexOf(option); + const trailingSyntax = optionAt >= 0 ? line.slice(optionAt + option.length) : ""; + // Help renderers conventionally start the description after two or more + // spaces. Inspect only the syntax column: prose such as "Prints " + // must never be mistaken for a documented `--format ` argv form. + const descriptionAt = trailingSyntax.search(/\s{2,}/); + const synopsis = descriptionAt >= 0 ? trailingSyntax.slice(0, descriptionAt) : trailingSyntax; return /<[^>\n]+>|\[[^\]\n]+\]|=\S+/.test(synopsis); } @@ -146,11 +153,15 @@ function advertisedStructuredOutputs(help: string): Array<{ option: string; valu return declaredRunOptions(help).flatMap((option) => { if (!optionTakesExplicitValue(help, option)) return []; const stanza = optionStanza(help, option); + const optionAt = stanza.indexOf(option); + const trailingSyntax = optionAt >= 0 ? stanza.slice(optionAt + option.length).split(/\r?\n/, 1)[0] : ""; + const descriptionAt = trailingSyntax.search(/\s{2,}/); + const synopsis = descriptionAt >= 0 ? trailingSyntax.slice(0, descriptionAt) : trailingSyntax; // 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(stanza), + ...bracketEnumerations(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()))]; 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 657327e1d..c67fb37d7 100644 --- a/src/app/api/chat/send/harness-routing-opencode.test.ts +++ b/src/app/api/chat/send/harness-routing-opencode.test.ts @@ -77,8 +77,8 @@ assert.match( ); assert.match( route, - /onError: \(ev\) => \{[\s\S]*?openCodeModelRejected \|\|= modelRejectionInError\(ev\.message\);[\s\S]*?recordStdoutErrorTail\("OpenCode reported an error event", true\)/, - "structured OpenCode error payloads are classified but never copied into user-visible diagnostics", + /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, diff --git a/src/app/api/chat/send/route-opencode.integration.test.ts b/src/app/api/chat/send/route-opencode.integration.test.ts index 1b999fd2f..310dbaaae 100644 --- a/src/app/api/chat/send/route-opencode.integration.test.ts +++ b/src/app/api/chat/send/route-opencode.integration.test.ts @@ -33,7 +33,7 @@ const launcher = process.platform === "win32" " echo --session ^ Session to continue", " exit /b 0", ")", - "echo {\"type\":\"text\",\"sessionID\":\"native_opencode_session\",\"part\":{\"text\":\"route reply\"}}", + "echo {\"type\":\"text\",\"sessionID\":\"native_opencode_session\",\"part\":{\"type\":\"text\",\"text\":\"route reply\"}}", "exit /b 0", ].join("\r\n") : [ @@ -43,7 +43,7 @@ const launcher = process.platform === "win32" " printf '%s\\n' ' --format Output format: text, json' ' --session Session to continue'", " exit 0", "fi", - "printf '%s\\n' '{\"type\":\"text\",\"sessionID\":\"native_opencode_session\",\"part\":{\"text\":\"route reply\"}}'", + "printf '%s\\n' '{\"type\":\"text\",\"sessionID\":\"native_opencode_session\",\"part\":{\"type\":\"text\",\"text\":\"route reply\"}}'", ].join("\n"); await writeFile(path.join(bin, executable), launcher, { mode: 0o755 }); diff --git a/src/app/api/chat/send/route.ts b/src/app/api/chat/send/route.ts index 8a9e4d244..5a16482dc 100644 --- a/src/app/api/chat/send/route.ts +++ b/src/app/api/chat/send/route.ts @@ -1960,6 +1960,7 @@ export async function POST(req: Request) { // 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 }; }, diff --git a/src/lib/opencode-stream.test.ts b/src/lib/opencode-stream.test.ts index f9ab6ba34..61d65b47c 100644 --- a/src/lib/opencode-stream.test.ts +++ b/src/lib/opencode-stream.test.ts @@ -173,6 +173,25 @@ assert.deepEqual( { 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 text: string[] = []; + const diagnostics: string[] = []; + handleOpenCodeJsonLine( + JSON.stringify({ type: "future_text_delta", part: { type: "text", text: "trusted reply" } }), + BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0], + { 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( parseOpenCodeRunEvent(["future", "envelope"]), { kind: "other", diagnostic: "malformed-event" }, diff --git a/src/lib/opencode-stream.ts b/src/lib/opencode-stream.ts index 4aacf29c8..743309f63 100644 --- a/src/lib/opencode-stream.ts +++ b/src/lib/opencode-stream.ts @@ -2,7 +2,7 @@ import type { OpenCodeEnvelopePath, OpenCodeEventSchema } from "@/lib/opencode-c export type OpenCodeRunEvent = | { kind: "ignore"; sessionId?: string } - | { kind: "text"; sessionId?: string; text: 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 } @@ -158,7 +158,8 @@ export function parseOpenCodeRunEvent(value: unknown, schema?: OpenCodeEventSche // 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); - if (eventTypes(schema, "text", ["text"]).includes(eventType) && text !== undefined && hasExpectedPayloadKind(textEnvelope(event, schema), schema, "text")) { + 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 @@ -211,6 +212,13 @@ export function parseOpenCodeRunEvent(value: unknown, schema?: OpenCodeEventSche ...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, @@ -237,7 +245,10 @@ export function handleOpenCodeJsonLine( if (event.kind !== "other" && event.sessionId) handlers.onSession?.(event.sessionId); switch (event.kind) { case "ignore": return; - case "text": handlers.onText?.(event); 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; From 5d60eb53fea5e18f51c83e0f52575600e9feb042 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 08:04:12 -0400 Subject: [PATCH 056/112] fix(chat): reject conflicting OpenCode cache records --- src/lib/opencode-compatibility.test.ts | 21 +++++++++++++++++++++ src/lib/opencode-compatibility.ts | 10 ++++++++-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/lib/opencode-compatibility.test.ts b/src/lib/opencode-compatibility.test.ts index 8b334c342..76c76d034 100644 --- a/src/lib/opencode-compatibility.test.ts +++ b/src/lib/opencode-compatibility.test.ts @@ -172,6 +172,27 @@ assert.equal( "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 offline = await loadOpenCodeSchemaBundle({ cacheFile, publicKey: publicPem, diff --git a/src/lib/opencode-compatibility.ts b/src/lib/opencode-compatibility.ts index e2574e8e0..e21fb70be 100644 --- a/src/lib/opencode-compatibility.ts +++ b/src/lib/opencode-compatibility.ts @@ -737,8 +737,14 @@ async function readCachedTrustState( const trustedBackup = backup && meetsOpenCodeRegistryCheckpoint(backup.bundle, checkpoint) ? backup : null; if (!trustedPrimary) return trustedBackup; if (!trustedBackup) return trustedPrimary; - // The sidecar is written before the replaceable cache payload. At an equal - // sequence it is the durable floor if a torn/manual primary differs. + // The sidecar is written before the replaceable cache payload, so it wins + // only after a strict sequence advance. Equal sequences must be byte-for- + // byte identical signed contracts; choosing either one would let a copied + // or rewritten cache define parser behavior without a remote comparison. + if ( + trustedPrimary.bundle.sequence === trustedBackup.bundle.sequence + && openCodeSchemaBundleSigningPayload(trustedPrimary.bundle) !== openCodeSchemaBundleSigningPayload(trustedBackup.bundle) + ) return null; return trustedBackup.bundle.sequence >= trustedPrimary.bundle.sequence ? trustedBackup : trustedPrimary; } From 9f941e245a01ea5bd20f2d2c822d581bcc04254a Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 08:13:18 -0400 Subject: [PATCH 057/112] fix(chat): parse wrapped OpenCode help options --- .../chat/send/chat-send-capabilities.test.ts | 19 ++- .../api/chat/send/chat-send-capabilities.ts | 46 ++++--- src/lib/opencode-compatibility.test.ts | 19 +++ src/lib/opencode-compatibility.ts | 112 +++++++++++------- 4 files changed, 132 insertions(+), 64 deletions(-) diff --git a/src/app/api/chat/send/chat-send-capabilities.test.ts b/src/app/api/chat/send/chat-send-capabilities.test.ts index a4c3c4a82..ea041156e 100644 --- a/src/app/api/chat/send/chat-send-capabilities.test.ts +++ b/src/app/api/chat/send/chat-send-capabilities.test.ts @@ -36,14 +36,29 @@ const proseOperand = parseOpenCodeRunCapabilitiesHelp(` 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 wrappedYargs = parseOpenCodeRunCapabilitiesHelp(` + --format Select output format + [string] [choices: "text", "json"] + --session Resume a named session + [string] + --model Select a model + [string] + --event-stream Configure event framing + [string] +`, "1.18.5"); +assert.equal(wrappedYargs.json, true, "wrapped yargs choices confirm JSON output without treating prose as syntax"); +assert.deepEqual(wrappedYargs.valueOptions, ["--format", "--session", "--model", "--event-stream"], "wrapped yargs type annotations confirm value-taking options"); +assert.deepEqual(wrappedYargs.noValueOptions, [], "a wrapped yargs value annotation never becomes evidence for a valueless launch flag"); + 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"], - "a declared output-only tool-event switch can be independently confirmed before a signed schema forwards it", + ["--include-tool-events", "--tool-events"], + "declared output-only tool-event switches can be independently confirmed before a signed schema forwards them", ); const booleanJson = parseOpenCodeRunCapabilitiesHelp(` diff --git a/src/app/api/chat/send/chat-send-capabilities.ts b/src/app/api/chat/send/chat-send-capabilities.ts index 1f0aac3ce..cdf6302b9 100644 --- a/src/app/api/chat/send/chat-send-capabilities.ts +++ b/src/app/api/chat/send/chat-send-capabilities.ts @@ -113,22 +113,31 @@ 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) : ""; + // Help renderers conventionally begin the description in a second column. + // Keep that prose out of argv capability evidence. + const descriptionAt = trailing.search(/\s{2,}/); + const declaration = (descriptionAt >= 0 ? trailing.slice(0, descriptionAt) : trailing).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)); + 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 line = optionStanza(help, option) - .split(/\r?\n/) - .find((line) => line.includes(option)) - ?? ""; - const optionAt = line.indexOf(option); - const trailingSyntax = optionAt >= 0 ? line.slice(optionAt + option.length) : ""; - // Help renderers conventionally start the description after two or more - // spaces. Inspect only the syntax column: prose such as "Prints " - // must never be mistaken for a documented `--format ` argv form. - const descriptionAt = trailingSyntax.search(/\s{2,}/); - const synopsis = descriptionAt >= 0 ? trailingSyntax.slice(0, descriptionAt) : trailingSyntax; - return /<[^>\n]+>|\[[^\]\n]+\]|=\S+/.test(synopsis); + 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, @@ -153,15 +162,13 @@ function advertisedStructuredOutputs(help: string): Array<{ option: string; valu return declaredRunOptions(help).flatMap((option) => { if (!optionTakesExplicitValue(help, option)) return []; const stanza = optionStanza(help, option); - const optionAt = stanza.indexOf(option); - const trailingSyntax = optionAt >= 0 ? stanza.slice(optionAt + option.length).split(/\r?\n/, 1)[0] : ""; - const descriptionAt = trailingSyntax.search(/\s{2,}/); - const synopsis = descriptionAt >= 0 ? trailingSyntax.slice(0, descriptionAt) : trailingSyntax; + 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(synopsis), + ...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()))]; @@ -178,8 +185,9 @@ function declaredNoValueRunOptions(help: string, options: string[]): string[] { // 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. - const line = help.match(new RegExp(`^\\s*(?:-[A-Za-z],?\\s+)?${option}\\b(?=$|\\s{2,})([^\\n]*)$`, "m"))?.[1]; - return line !== undefined && !/[<\[=]/.test(line); + // Wrapped yargs `[string]` continuations count as value syntax too. + const syntax = optionSyntax(help, option); + return syntax !== null && syntax.declaration === "" && !optionTakesExplicitValue(help, option); }); } diff --git a/src/lib/opencode-compatibility.test.ts b/src/lib/opencode-compatibility.test.ts index 76c76d034..6fd1df5ef 100644 --- a/src/lib/opencode-compatibility.test.ts +++ b/src/lib/opencode-compatibility.test.ts @@ -612,6 +612,25 @@ assert.equal( "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, diff --git a/src/lib/opencode-compatibility.ts b/src/lib/opencode-compatibility.ts index e21fb70be..fc91f5a3c 100644 --- a/src/lib/opencode-compatibility.ts +++ b/src/lib/opencode-compatibility.ts @@ -320,16 +320,13 @@ const SAFE_STRUCTURED_LAUNCH_OPTION = /^--[a-z0-9-]*(?:format|output|json|event| const UNSAFE_STRUCTURED_LAUNCH_OPTIONS = new Set([ "--auto", "--permission", "--sandbox", "--skip-permissions", "--dangerously-skip-permissions", "--trust-all-tools", "--yolo", ]); -// A registry describes how to frame and parse a stream; it must never widen -// what an OpenCode invocation is allowed to do. Keep the remotely supplied -// companion flags to a versioned, locally audited set that only asks the CLI -// to include structured output. Each one is still separately confirmed as a -// declared valueless `run` option before its schema can be selected; a signed -// registry never gets to introduce an arbitrary command switch. -const SAFE_STRUCTURED_REQUIRED_FLAGS = new Set([ - "--event-stream", - "--include-tool-events", -]); +// 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" @@ -353,8 +350,8 @@ function hasValidLaunch(value: unknown, requires: Record): bool 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 (requires.session === true && value.sessionOption === undefined) return false; - return value.requiredFlags.length <= SAFE_STRUCTURED_REQUIRED_FLAGS.size - && value.requiredFlags.every((flag) => typeof flag === "string" && SAFE_STRUCTURED_REQUIRED_FLAGS.has(flag)) + 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); } @@ -598,6 +595,15 @@ 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`; +} + /** * 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 @@ -728,29 +734,43 @@ async function readCachedTrustState( publicKey: string | OpenCodeRegistryKeyring, now: number, checkpoint?: OpenCodeRegistryCheckpoint, + trustAnchorFile = cacheTrustAnchorPath(file), ): Promise { - const [primary, backup] = await Promise.all([ + const [primary, backup, anchor] = await Promise.all([ readTrustedCacheRecord(file, publicKey, now), readTrustedCacheRecord(cacheTrustPath(file), publicKey, now), + readTrustedCacheRecord(trustAnchorFile, publicKey, now), ]); - const trustedPrimary = primary && meetsOpenCodeRegistryCheckpoint(primary.bundle, checkpoint) ? primary : null; - const trustedBackup = backup && meetsOpenCodeRegistryCheckpoint(backup.bundle, checkpoint) ? backup : null; - if (!trustedPrimary) return trustedBackup; - if (!trustedBackup) return trustedPrimary; - // The sidecar is written before the replaceable cache payload, so it wins - // only after a strict sequence advance. Equal sequences must be byte-for- - // byte identical signed contracts; choosing either one would let a copied - // or rewritten cache define parser behavior without a remote comparison. - if ( - trustedPrimary.bundle.sequence === trustedBackup.bundle.sequence - && openCodeSchemaBundleSigningPayload(trustedPrimary.bundle) !== openCodeSchemaBundleSigningPayload(trustedBackup.bundle) - ) return null; - return trustedBackup.bundle.sequence >= trustedPrimary.bundle.sequence ? trustedBackup : trustedPrimary; + const trusted = [primary, backup, anchor].filter((cached): cached is CachedBundle => + Boolean(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. Keep the parser boundary closed until a signed + // remote refresh resolves the conflict. + if (new Set(highest.map((cached) => openCodeSchemaBundleSigningPayload(cached.bundle))).size !== 1) return null; + return highest[0]; } -async function writeVerifiedCache(file: string, cached: CachedBundle): Promise { - // Persist the signed trust floor first: a later damaged/truncated primary - // file cannot erase the highest accepted sequence before the next refresh. +async function writeVerifiedCache( + file: string, + trustAnchorFile: string, + publicKey: string | OpenCodeRegistryKeyring, + now: number, + cached: CachedBundle, +): Promise { + const priorAnchor = await readTrustedCacheRecord(trustAnchorFile, publicKey, now); + 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: later damage to + // both cache records cannot erase the highest accepted registry sequence. + await writeJsonAtomic(trustAnchorFile, cached); await writeJsonAtomic(cacheTrustPath(file), cached); await writeJsonAtomic(file, cached); } @@ -760,8 +780,9 @@ async function readVerifiedCache( publicKey: string | OpenCodeRegistryKeyring, now: number, checkpoint?: OpenCodeRegistryCheckpoint, + trustAnchorFile = cacheTrustAnchorPath(file), ): Promise { - const cached = await readCachedTrustState(file, publicKey, now, checkpoint); + const cached = await readCachedTrustState(file, publicKey, now, checkpoint, trustAnchorFile); if ( !cached // The wrapper is not signed; a future timestamp must not pin an old, @@ -829,19 +850,20 @@ async function waitForConcurrentCacheWriter( 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); + latest = await readVerifiedCache(file, publicKeys, now, checkpoint, trustAnchorFile); if (latest && latest.bundle.sequence >= minimumSequence) return latest; try { await stat(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); + return await readVerifiedCache(file, publicKeys, now, checkpoint, trustAnchorFile); } if (Date.now() >= deadline) return latest; await new Promise((resolve) => setTimeout(resolve, CACHE_LOCK_POLL_MS)); @@ -855,6 +877,8 @@ export type OpenCodeSchemaBundleSource = { 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; @@ -925,8 +949,8 @@ function registryCheckpoint(source: OpenCodeSchemaBundleSource): OpenCodeRegistr ?? (process.env.NODE_ENV === "production" ? undefined : parseRegistryCheckpoint(process.env.COVEN_OPENCODE_SCHEMA_REGISTRY_CHECKPOINT)); } -function schemaRefreshKey(file: string, url: string, publicKeys: OpenCodeRegistryKeyring, checkpoint: OpenCodeRegistryCheckpoint | undefined): string { - return createHash("sha256").update(`${file}\0${url}\0${stableJson(publicKeys)}\0${stableJson(checkpoint ?? null)}`).digest("hex"); +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( @@ -934,6 +958,7 @@ async function refreshOpenCodeSchemaBundle( url: string, publicKeys: OpenCodeRegistryKeyring, checkpoint: OpenCodeRegistryCheckpoint | undefined, + trustAnchorFile: string, fetcher: typeof fetch, now: number, refreshTimeoutMs?: number, @@ -944,29 +969,29 @@ async function refreshOpenCodeSchemaBundle( 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); + const cachedTrust = await readCachedTrustState(file, publicKeys, now, checkpoint, trustAnchorFile); if (cachedTrust && remote.sequence < cachedTrust.bundle.sequence) throw new Error("schema rollback"); await mkdir(path.dirname(file), { recursive: true }); const writeResult = await withCacheWriteLock(file, async () => { // 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); + 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, { checkedAt: now, bundle: remote, verifiedKeyId: remote.keyId }); + await writeVerifiedCache(file, trustAnchorFile, publicKeys, now, { checkedAt: now, bundle: remote, verifiedKeyId: remote.keyId }); return { bundle: remote, source: "remote" as const }; } - await writeVerifiedCache(file, { checkedAt: now, bundle: remote, verifiedKeyId: remote.keyId }); + await writeVerifiedCache(file, trustAnchorFile, publicKeys, now, { checkedAt: now, bundle: remote, verifiedKeyId: remote.keyId }); 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); + const current = await waitForConcurrentCacheWriter(file, publicKeys, now, remote.sequence, checkpoint, trustAnchorFile); if (current && current.bundle.sequence >= remote.sequence) { return { bundle: current.bundle, source: "cache" }; } @@ -1006,6 +1031,7 @@ function startSchemaRefresh( 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); @@ -1017,15 +1043,15 @@ export async function loadOpenCodeSchemaBundle(source: OpenCodeSchemaBundleSourc return { bundle: BUILTIN_OPENCODE_SCHEMA_BUNDLE, source: "built-in", diagnostic: "schema-registry-refresh-rejected" }; } - const cached = await readVerifiedCache(file, publicKeys, now, checkpoint); + const 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. - const cacheTrust = cached ?? await readCachedTrustState(file, publicKeys, now, checkpoint); + const cacheTrust = cached ?? await readCachedTrustState(file, publicKeys, now, checkpoint, trustAnchorFile); const cacheFresh = cached && now - cached.checkedAt < CACHE_TTL_MS; if (cacheFresh) return { bundle: cached.bundle, source: "cache" }; - const key = schemaRefreshKey(file, url, publicKeys, checkpoint); + 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" } @@ -1035,7 +1061,7 @@ export async function loadOpenCodeSchemaBundle(source: OpenCodeSchemaBundleSourc } const refresh = startSchemaRefresh( key, - () => refreshOpenCodeSchemaBundle(file, url, publicKeys, checkpoint, source.fetch ?? fetch, now, source.refreshTimeoutMs), + () => refreshOpenCodeSchemaBundle(file, url, publicKeys, checkpoint, trustAnchorFile, source.fetch ?? fetch, now, source.refreshTimeoutMs), now, ); try { From fe1c638e3eff8cb3a69ecaf7deb79888c4364d1f Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 08:18:45 -0400 Subject: [PATCH 058/112] fix(chat): retain OpenCode registry trust anchors --- .../chat/send/chat-send-capabilities.test.ts | 12 ++++---- src/lib/opencode-compatibility.test.ts | 30 +++++++++++++++++++ src/lib/opencode-compatibility.ts | 7 ++--- 3 files changed, 40 insertions(+), 9 deletions(-) diff --git a/src/app/api/chat/send/chat-send-capabilities.test.ts b/src/app/api/chat/send/chat-send-capabilities.test.ts index ea041156e..c0e09dbb5 100644 --- a/src/app/api/chat/send/chat-send-capabilities.test.ts +++ b/src/app/api/chat/send/chat-send-capabilities.test.ts @@ -36,9 +36,9 @@ const proseOperand = parseOpenCodeRunCapabilitiesHelp(` 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 wrappedYargs = parseOpenCodeRunCapabilitiesHelp(` +const currentYargsHelp = parseOpenCodeRunCapabilitiesHelp(` --format Select output format - [string] [choices: "text", "json"] + [string] [choices: "default", "json"] --session Resume a named session [string] --model Select a model @@ -46,9 +46,11 @@ const wrappedYargs = parseOpenCodeRunCapabilitiesHelp(` --event-stream Configure event framing [string] `, "1.18.5"); -assert.equal(wrappedYargs.json, true, "wrapped yargs choices confirm JSON output without treating prose as syntax"); -assert.deepEqual(wrappedYargs.valueOptions, ["--format", "--session", "--model", "--event-stream"], "wrapped yargs type annotations confirm value-taking options"); -assert.deepEqual(wrappedYargs.noValueOptions, [], "a wrapped yargs value annotation never becomes evidence for a valueless launch flag"); +assert.equal(currentYargsHelp.json, true, "current yargs choices confirm JSON output without treating prose as syntax"); +assert.equal(currentYargsHelp.session, true, "current yargs string metadata confirms native session support"); +assert.equal(currentYargsHelp.model, true, "current yargs string metadata confirms model forwarding support"); +assert.deepEqual(currentYargsHelp.valueOptions, ["--format", "--session", "--model", "--event-stream"], "wrapped yargs type annotations confirm value-taking options"); +assert.deepEqual(currentYargsHelp.noValueOptions, [], "a wrapped yargs value annotation never becomes evidence for a valueless launch flag"); const toolEventsOutput = parseOpenCodeRunCapabilitiesHelp(` --format Output format: text, json diff --git a/src/lib/opencode-compatibility.test.ts b/src/lib/opencode-compatibility.test.ts index 6fd1df5ef..b42d84c4a 100644 --- a/src/lib/opencode-compatibility.test.ts +++ b/src/lib/opencode-compatibility.test.ts @@ -110,6 +110,36 @@ const checkpointUpgrade = await loadOpenCodeSchemaBundle({ }); 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"); + const unsignedSignedRollback = { ...unsigned, sequence: 1 }; const signedRollback = { ...unsignedSignedRollback, diff --git a/src/lib/opencode-compatibility.ts b/src/lib/opencode-compatibility.ts index fc91f5a3c..24343ae35 100644 --- a/src/lib/opencode-compatibility.ts +++ b/src/lib/opencode-compatibility.ts @@ -741,9 +741,8 @@ async function readCachedTrustState( readTrustedCacheRecord(cacheTrustPath(file), publicKey, now), readTrustedCacheRecord(trustAnchorFile, publicKey, now), ]); - const trusted = [primary, backup, anchor].filter((cached): cached is CachedBundle => - Boolean(cached) && meetsOpenCodeRegistryCheckpoint(cached.bundle, checkpoint), - ); + const candidates = [primary, backup, anchor].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); @@ -1079,7 +1078,7 @@ export async function loadOpenCodeSchemaBundle(source: OpenCodeSchemaBundleSourc // 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. - const current = await readVerifiedCache(file, publicKeys, now, checkpoint); + const current = await readVerifiedCache(file, publicKeys, now, checkpoint, trustAnchorFile); if (current && (!cached || current.bundle.sequence >= cached.bundle.sequence)) { return { bundle: current.bundle, source: "cache", diagnostic: "schema-registry-refresh-rejected" }; } From 15f5fc041bbfacd8612ddfe59d2e162d7341d7e9 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 08:29:48 -0400 Subject: [PATCH 059/112] fix(chat): harden OpenCode prompt argv --- src/app/api/chat/send/chat-send-capabilities.test.ts | 5 ++++- src/app/api/chat/send/chat-send-capabilities.ts | 11 +++++++++-- .../api/chat/send/route-opencode.integration.test.ts | 8 +++++++- src/app/api/chat/send/route.ts | 4 +++- 4 files changed, 23 insertions(+), 5 deletions(-) diff --git a/src/app/api/chat/send/chat-send-capabilities.test.ts b/src/app/api/chat/send/chat-send-capabilities.test.ts index c0e09dbb5..6eee6dc9a 100644 --- a/src/app/api/chat/send/chat-send-capabilities.test.ts +++ b/src/app/api/chat/send/chat-send-capabilities.test.ts @@ -3,7 +3,10 @@ import assert from "node:assert/strict"; import { mkdtemp, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; -import { openCodeExecutableIdentity, parseOpenCodeRunCapabilitiesHelp } from "./chat-send-capabilities.ts"; +import { openCodeCapabilityProbeTimeoutMs, openCodeExecutableIdentity, 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"); const capabilities = parseOpenCodeRunCapabilitiesHelp(` --structured-output Output format: text, json-v3 diff --git a/src/app/api/chat/send/chat-send-capabilities.ts b/src/app/api/chat/send/chat-send-capabilities.ts index cdf6302b9..d457c241d 100644 --- a/src/app/api/chat/send/chat-send-capabilities.ts +++ b/src/app/api/chat/send/chat-send-capabilities.ts @@ -18,6 +18,13 @@ let hermesModelFlagProbe: Promise | null = null; let openCodeModelFlagProbe: Promise | null = null; let openCodeCapabilitiesProbe: { until: number; identity: string; value: Promise } | null = null; const OPENCODE_CAPABILITY_PROBE_TTL_MS = 60_000; +const DEFAULT_CAPABILITY_PROBE_TIMEOUT_MS = 2_500; +const WINDOWS_CAPABILITY_PROBE_TIMEOUT_MS = 6_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; +} function probeHelp( command: string, @@ -49,7 +56,7 @@ function probeHelp( // The capability is unsupported when the probe cannot complete. } done(false); - }, 2500); + }, openCodeCapabilityProbeTimeoutMs()); child.on("close", () => { clearTimeout(timeout); done(matches(output)); @@ -93,7 +100,7 @@ function probeOutput(command: string, args: string[], env = harnessSpawnEnv(), i const timeout = setTimeout(() => { try { child.kill("SIGTERM"); } catch { /* Probe failures are capabilities=false. */ } done(false); - }, 2500); + }, openCodeCapabilityProbeTimeoutMs()); child.on("close", (code) => { clearTimeout(timeout); done(code === 0 && !overflowed); }); child.on("error", () => { clearTimeout(timeout); done(false); }); } catch { diff --git a/src/app/api/chat/send/route-opencode.integration.test.ts b/src/app/api/chat/send/route-opencode.integration.test.ts index 310dbaaae..be084dc88 100644 --- a/src/app/api/chat/send/route-opencode.integration.test.ts +++ b/src/app/api/chat/send/route-opencode.integration.test.ts @@ -33,6 +33,10 @@ const launcher = process.platform === "win32" " echo --session ^ Session to continue", " exit /b 0", ")", + "if not \"%~1\"==\"run\" exit /b 9", + "if not \"%~2\"==\"--format\" exit /b 9", + "if not \"%~3\"==\"json\" exit /b 9", + "if not \"%~4\"==\"--\" exit /b 9", "echo {\"type\":\"text\",\"sessionID\":\"native_opencode_session\",\"part\":{\"type\":\"text\",\"text\":\"route reply\"}}", "exit /b 0", ].join("\r\n") @@ -43,6 +47,7 @@ const launcher = process.platform === "win32" " printf '%s\\n' ' --format Output format: text, json' ' --session Session to continue'", " exit 0", "fi", + "if [ \"$1\" != \"run\" ] || [ \"$2\" != \"--format\" ] || [ \"$3\" != \"json\" ] || [ \"$4\" != \"--\" ]; then exit 9; fi", "printf '%s\\n' '{\"type\":\"text\",\"sessionID\":\"native_opencode_session\",\"part\":{\"type\":\"text\",\"text\":\"route reply\"}}'", ].join("\n"); await writeFile(path.join(bin, executable), launcher, { mode: 0o755 }); @@ -65,10 +70,11 @@ try { const response = await POST(new Request("http://localhost/api/chat/send", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ familiarId: "opal", prompt: "Hello from route integration", projectRoot: familiarWorkspace }), + 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, /missing end-of-options|empty response/i, "a flag-shaped prompt reaches OpenCode as positional data after the end-of-options delimiter"); assert.match(body, /"kind":"assistant_chunk","text":"route reply\\n"/, "the route streams text from the selected OpenCode JSON profile"); const done = body .split("\n") diff --git a/src/app/api/chat/send/route.ts b/src/app/api/chat/send/route.ts index 5a16482dc..d21c8dd3e 100644 --- a/src/app/api/chat/send/route.ts +++ b/src/app/api/chat/send/route.ts @@ -1400,7 +1400,9 @@ export async function POST(req: Request) { } } if (forwardModel) a.push("--model", forwardModel); - a.push(prompt); + // Prompts are untrusted data. Keep a flag-shaped prompt from overriding + // the capability-confirmed OpenCode argv above (for example `--format`). + a.push("--", prompt); return a; } const a = ["run", binding.harness, "--stream-json"]; From 72268cbfbfe02a68bb8a3e3ab1591b6db7792337 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 08:31:49 -0400 Subject: [PATCH 060/112] fix(chat): clear stale OpenCode resume token --- src/app/api/chat/send/harness-routing-opencode.test.ts | 5 +++++ src/app/api/chat/send/route.ts | 5 +++++ 2 files changed, 10 insertions(+) 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 c67fb37d7..020f07186 100644 --- a/src/app/api/chat/send/harness-routing-opencode.test.ts +++ b/src/app/api/chat/send/harness-routing-opencode.test.ts @@ -70,6 +70,11 @@ assert.match( /Session not found\\b/, "OpenCode's missing-session error triggers the existing fresh-session retry", ); +assert.match( + route, + /if \(resumeFailed && body\.sessionId\) \{[\s\S]*?sessionId = null;[\s\S]*?openCodeSessionId = null;[\s\S]*?await runAttempt\(buildArgs\(null, 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"\)/, diff --git a/src/app/api/chat/send/route.ts b/src/app/api/chat/send/route.ts index d21c8dd3e..6ce6c85d2 100644 --- a/src/app/api/chat/send/route.ts +++ b/src/app/api/chat/send/route.ts @@ -2488,6 +2488,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; assistantFilter = new AssistantFilter({ passthrough: rawStdoutHarness }); assistantText = ""; jsonBuf = ""; From 5add703e05c8fa317313bed191d2797c8b9c30f7 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 08:38:58 -0400 Subject: [PATCH 061/112] fix(chat): gate OpenCode option delimiter --- .../chat/send/chat-send-capabilities.test.ts | 7 ++++++ .../api/chat/send/chat-send-capabilities.ts | 10 ++++++++- .../send/harness-routing-opencode.test.ts | 5 +++++ .../send/route-opencode.integration.test.ts | 6 ++--- src/app/api/chat/send/route.ts | 22 ++++++++++++++++--- src/lib/opencode-compatibility.ts | 11 +++++++--- 6 files changed, 51 insertions(+), 10 deletions(-) diff --git a/src/app/api/chat/send/chat-send-capabilities.test.ts b/src/app/api/chat/send/chat-send-capabilities.test.ts index 6eee6dc9a..0b24f8654 100644 --- a/src/app/api/chat/send/chat-send-capabilities.test.ts +++ b/src/app/api/chat/send/chat-send-capabilities.test.ts @@ -54,6 +54,13 @@ assert.equal(currentYargsHelp.session, true, "current yargs string metadata conf assert.equal(currentYargsHelp.model, true, "current yargs string metadata confirms model forwarding support"); assert.deepEqual(currentYargsHelp.valueOptions, ["--format", "--session", "--model", "--event-stream"], "wrapped yargs type annotations confirm value-taking options"); assert.deepEqual(currentYargsHelp.noValueOptions, [], "a wrapped yargs value annotation never becomes 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 diff --git a/src/app/api/chat/send/chat-send-capabilities.ts b/src/app/api/chat/send/chat-send-capabilities.ts index d457c241d..285e7d494 100644 --- a/src/app/api/chat/send/chat-send-capabilities.ts +++ b/src/app/api/chat/send/chat-send-capabilities.ts @@ -198,6 +198,13 @@ function declaredNoValueRunOptions(help: string, options: string[]): string[] { }); } +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"); @@ -295,6 +302,7 @@ export function parseOpenCodeRunCapabilitiesHelp(help: string, version: string | options, valueOptions, noValueOptions, + endOfOptions: documentsEndOfOptionsDelimiter(help), structuredSwitches, structuredOutputs, }; @@ -378,7 +386,7 @@ export async function openCodeRunCapabilities(familiarId?: string): Promise Output format: text, json' ' --session Session to continue'", " exit 0", "fi", - "if [ \"$1\" != \"run\" ] || [ \"$2\" != \"--format\" ] || [ \"$3\" != \"json\" ] || [ \"$4\" != \"--\" ]; then exit 9; fi", + "if [ \"$1\" != \"run\" ] || [ \"$2\" != \"--format\" ] || [ \"$3\" != \"json\" ] || [ \"$4\" = \"--\" ]; then exit 9; fi", "printf '%s\\n' '{\"type\":\"text\",\"sessionID\":\"native_opencode_session\",\"part\":{\"type\":\"text\",\"text\":\"route reply\"}}'", ].join("\n"); await writeFile(path.join(bin, executable), launcher, { mode: 0o755 }); @@ -74,7 +74,7 @@ try { })); assert.equal(response.status, 200, await response.clone().text()); const body = await response.text(); - assert.doesNotMatch(body, /missing end-of-options|empty response/i, "a flag-shaped prompt reaches OpenCode as positional data after the end-of-options delimiter"); + assert.doesNotMatch(body, /empty response/i, "a legacy OpenCode help surface keeps the compatible positional prompt launch without an unprobed delimiter"); assert.match(body, /"kind":"assistant_chunk","text":"route reply\\n"/, "the route streams text from the selected OpenCode JSON profile"); const done = body .split("\n") diff --git a/src/app/api/chat/send/route.ts b/src/app/api/chat/send/route.ts index 6ce6c85d2..5521b275b 100644 --- a/src/app/api/chat/send/route.ts +++ b/src/app/api/chat/send/route.ts @@ -1222,6 +1222,21 @@ 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); + // Do not assume every OpenCode release implements the conventional `--` + // delimiter. A selected schema must opt into it and this installed CLI must + // document it; ordinary prompts keep the compatible positional launch. + 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({ @@ -1400,9 +1415,10 @@ export async function POST(req: Request) { } } if (forwardModel) a.push("--model", forwardModel); - // Prompts are untrusted data. Keep a flag-shaped prompt from overriding - // the capability-confirmed OpenCode argv above (for example `--format`). - a.push("--", prompt); + // 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; } const a = ["run", binding.harness, "--stream-json"]; diff --git a/src/lib/opencode-compatibility.ts b/src/lib/opencode-compatibility.ts index 24343ae35..2cfea8c9a 100644 --- a/src/lib/opencode-compatibility.ts +++ b/src/lib/opencode-compatibility.ts @@ -17,6 +17,8 @@ export type OpenCodeRunCapabilities = { 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[] }>; @@ -87,6 +89,8 @@ export type OpenCodeEventSchema = { structuredOutput: { option: string; value?: string }; sessionOption?: "--session" | "--resume"; requiredFlags: string[]; + /** May use `--` only when the installed CLI documents the delimiter. */ + endOfOptions?: true; }; }; @@ -231,7 +235,7 @@ export const BUILTIN_OPENCODE_SCHEMA_BUNDLE: OpenCodeSchemaBundle = { 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: [] }, + launch: { structuredOutput: { option: "--format", value: "json" }, sessionOption: "--session", requiredFlags: [], endOfOptions: true }, }, { // Earlier and preview clients used generic tool envelopes. Their @@ -263,7 +267,7 @@ export const BUILTIN_OPENCODE_SCHEMA_BUNDLE: OpenCodeSchemaBundle = { 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: [] }, + launch: { structuredOutput: { option: "--format", value: "json-legacy" }, requiredFlags: [], endOfOptions: true }, }, ], }; @@ -337,7 +341,7 @@ function safeStructuredLaunchOption(value: unknown): value is string { 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")) 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; @@ -349,6 +353,7 @@ function hasValidLaunch(value: unknown, requires: Record): bool // 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())) From 69fb756ec9fb460cf3e445e03f4b6c31ce191cc8 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 08:46:19 -0400 Subject: [PATCH 062/112] fix(chat): recover stale OpenCode registry locks --- src/lib/opencode-compatibility.test.ts | 4 ++-- src/lib/opencode-compatibility.ts | 16 ++++++---------- src/lib/opencode-stream.test.ts | 8 ++++++++ src/lib/opencode-stream.ts | 13 ++++++++++--- 4 files changed, 26 insertions(+), 15 deletions(-) diff --git a/src/lib/opencode-compatibility.test.ts b/src/lib/opencode-compatibility.test.ts index b42d84c4a..21befbe70 100644 --- a/src/lib/opencode-compatibility.test.ts +++ b/src/lib/opencode-compatibility.test.ts @@ -835,7 +835,7 @@ const signedSequence3 = { }, }; const staleLock = `${cacheFile}.lock`; -await writeFile(staleLock, "99999999"); +await writeFile(staleLock, `${process.pid}:reused-owner-token`); await utimes(staleLock, new Date(0), new Date(0)); const recoveredLock = await loadOpenCodeSchemaBundle({ cacheFile, @@ -844,7 +844,7 @@ const recoveredLock = await loadOpenCodeSchemaBundle({ 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"); +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 })); diff --git a/src/lib/opencode-compatibility.ts b/src/lib/opencode-compatibility.ts index 2cfea8c9a..4868d4193 100644 --- a/src/lib/opencode-compatibility.ts +++ b/src/lib/opencode-compatibility.ts @@ -719,16 +719,12 @@ async function fetchSchemaBundle(url: string, fetcher: typeof fetch, timeoutMs = async function staleLockCanBeReclaimed(lock: string): Promise { try { const info = await stat(lock); - if (Date.now() - info.mtimeMs < CACHE_LOCK_STALE_MS) return false; - const owner = Number((await readFile(lock, "utf8")).trim().split(":", 1)[0]); - if (!Number.isSafeInteger(owner) || owner < 1) return true; - try { - process.kill(owner, 0); - return false; - } catch (error) { - // EPERM means the process exists but this user cannot signal it. - return (error as NodeJS.ErrnoException).code !== "EPERM"; - } + // 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; } diff --git a/src/lib/opencode-stream.test.ts b/src/lib/opencode-stream.test.ts index 61d65b47c..b5246bdb3 100644 --- a/src/lib/opencode-stream.test.ts +++ b/src/lib/opencode-stream.test.ts @@ -158,6 +158,14 @@ assert.deepEqual( { 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", 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 }, diff --git a/src/lib/opencode-stream.ts b/src/lib/opencode-stream.ts index 743309f63..a695140fa 100644 --- a/src/lib/opencode-stream.ts +++ b/src/lib/opencode-stream.ts @@ -117,9 +117,11 @@ function terminalToolState(state: Record | null, part: 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) - || valueAt(state, shapeAliases(schema, "error", ["error"])) !== undefined - || valueAt(part, shapeAliases(schema, "error", ["error"])) !== undefined; + || (stateError !== undefined && stateError !== null) + || (partError !== undefined && partError !== null); } /** Decode OpenCode's `run --format json` envelope without trusting its fields. */ @@ -190,7 +192,12 @@ export function parseOpenCodeRunEvent(value: unknown, schema?: OpenCodeEventSche // status entirely. Their output/error is the durable terminal signal. const output = shapeAliases(schema, "output", ["output"]); const error = shapeAliases(schema, "error", ["error"]); - const hasTerminalPayload = valueAt(state, output) !== undefined || valueAt(state, error) !== undefined || valueAt(toolPart, output) !== undefined || valueAt(toolPart, error) !== undefined; + const stateError = valueAt(state, error); + const partError = valueAt(toolPart, error); + const hasTerminalPayload = valueAt(state, output) !== undefined + || (stateError !== undefined && stateError !== null) + || valueAt(toolPart, output) !== undefined + || (partError !== undefined && partError !== null); 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"])) ?? {} }; } From 6e5ca5a5a04aab013231049181f283a8e575f2ec Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 08:58:07 -0400 Subject: [PATCH 063/112] fix(chat): reject conflicting OpenCode cache records --- src/lib/opencode-compatibility.test.ts | 21 ++++++++++++ src/lib/opencode-compatibility.ts | 46 ++++++++++++++++++++------ 2 files changed, 56 insertions(+), 11 deletions(-) diff --git a/src/lib/opencode-compatibility.test.ts b/src/lib/opencode-compatibility.test.ts index 21befbe70..fbae144c0 100644 --- a/src/lib/opencode-compatibility.test.ts +++ b/src/lib/opencode-compatibility.test.ts @@ -222,6 +222,27 @@ const equalSequenceConflict = await loadOpenCodeSchemaBundle({ }); 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 offline = await loadOpenCodeSchemaBundle({ cacheFile, diff --git a/src/lib/opencode-compatibility.ts b/src/lib/opencode-compatibility.ts index 4868d4193..e67fa66de 100644 --- a/src/lib/opencode-compatibility.ts +++ b/src/lib/opencode-compatibility.ts @@ -168,6 +168,13 @@ 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 @@ -748,9 +755,12 @@ async function readCachedTrustState( 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. Keep the parser boundary closed until a signed - // remote refresh resolves the conflict. - if (new Set(highest.map((cached) => openCodeSchemaBundleSigningPayload(cached.bundle))).size !== 1) return null; + // 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]; } @@ -1043,14 +1053,28 @@ export async function loadOpenCodeSchemaBundle(source: OpenCodeSchemaBundleSourc return { bundle: BUILTIN_OPENCODE_SCHEMA_BUNDLE, source: "built-in", diagnostic: "schema-registry-refresh-rejected" }; } - const 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. - const cacheTrust = cached ?? await readCachedTrustState(file, publicKeys, now, checkpoint, trustAnchorFile); + 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) return { bundle: cached.bundle, source: "cache" }; + 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 @@ -1067,7 +1091,7 @@ export async function loadOpenCodeSchemaBundle(source: OpenCodeSchemaBundleSourc try { return await refresh; } catch (error) { - if (error instanceof CacheWriterPendingError) { + if (error instanceof CacheWriterPendingError || error instanceof CacheTrustConflictError) { return { bundle: BUILTIN_OPENCODE_SCHEMA_BUNDLE, source: "built-in", From 3a23a4219ce0c853134717b6e352e2026f007720 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 09:14:01 -0400 Subject: [PATCH 064/112] fix(chat): harden OpenCode structured stream --- .../send/harness-routing-opencode.test.ts | 5 ++ .../send/route-opencode.integration.test.ts | 12 ++++- src/app/api/chat/send/route.ts | 22 ++++++++- src/lib/opencode-compatibility.test.ts | 45 ++++++++++++++++++ src/lib/opencode-compatibility.ts | 47 ++++++++++++++----- src/lib/opencode-stream.test.ts | 6 ++- src/lib/opencode-stream.ts | 8 ++-- 7 files changed, 124 insertions(+), 21 deletions(-) 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 0962e1edb..25158dca3 100644 --- a/src/app/api/chat/send/harness-routing-opencode.test.ts +++ b/src/app/api/chat/send/harness-routing-opencode.test.ts @@ -20,6 +20,11 @@ assert.match( /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]*?schema\?\.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\("--"\);/, diff --git a/src/app/api/chat/send/route-opencode.integration.test.ts b/src/app/api/chat/send/route-opencode.integration.test.ts index 54d1169d5..75fcb0721 100644 --- a/src/app/api/chat/send/route-opencode.integration.test.ts +++ b/src/app/api/chat/send/route-opencode.integration.test.ts @@ -24,6 +24,7 @@ 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", @@ -37,6 +38,7 @@ const launcher = process.platform === "win32" "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") @@ -48,7 +50,12 @@ const launcher = process.platform === "win32" " exit 0", "fi", "if [ \"$1\" != \"run\" ] || [ \"$2\" != \"--format\" ] || [ \"$3\" != \"json\" ] || [ \"$4\" = \"--\" ]; then exit 9; fi", - "printf '%s\\n' '{\"type\":\"text\",\"sessionID\":\"native_opencode_session\",\"part\":{\"type\":\"text\",\"text\":\"route reply\"}}'", + "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 }); @@ -75,7 +82,8 @@ try { 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.match(body, /"kind":"assistant_chunk","text":"route reply\\n"/, "the route streams text from the selected OpenCode JSON profile"); + 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: ")) diff --git a/src/app/api/chat/send/route.ts b/src/app/api/chat/send/route.ts index 5521b275b..a8956a4f2 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, @@ -1908,6 +1909,12 @@ export async function POST(req: Request) { push({ kind: "assistant_chunk", text }); 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. + const normalized = resolveBackspaces(stripAnsi(line)).trim(); + if (/^permission requested\b[\s\S]*\bauto-rejecting\b/i.test(normalized)) return; handleOpenCodeJsonLine(line, openCodeCompatibility?.schema, { onSession: (nativeSessionId) => { openCodeSessionId = nativeSessionId; @@ -2289,8 +2296,12 @@ export async function POST(req: Request) { }; req.signal.addEventListener("abort", onAbort, { once: true }); - child.stdout.on("data", (data: Buffer) => { - let chunk = 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. @@ -2330,6 +2341,11 @@ export async function POST(req: Request) { "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) => { @@ -2381,6 +2397,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 diff --git a/src/lib/opencode-compatibility.test.ts b/src/lib/opencode-compatibility.test.ts index fbae144c0..1552d0b6d 100644 --- a/src/lib/opencode-compatibility.test.ts +++ b/src/lib/opencode-compatibility.test.ts @@ -244,6 +244,21 @@ assert.equal( "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, @@ -591,6 +606,36 @@ const legacyAfterPartialRemote = await resolveOpenCodeCompatibility( 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, diff --git a/src/lib/opencode-compatibility.ts b/src/lib/opencode-compatibility.ts index e67fa66de..e2c201334 100644 --- a/src/lib/opencode-compatibility.ts +++ b/src/lib/opencode-compatibility.ts @@ -1103,7 +1103,20 @@ export async function loadOpenCodeSchemaBundle(source: OpenCodeSchemaBundleSourc // 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. - const current = await readVerifiedCache(file, publicKeys, now, checkpoint, trustAnchorFile); + 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" }; } @@ -1155,16 +1168,26 @@ export async function resolveOpenCodeCompatibility( diagnostic: loaded.diagnostic, }; } - const remoteSchema = selectOpenCodeSchema(loaded.bundle.schemas, capabilities); - // Remote registries publish additive compatibility profiles by default. A - // partial update must not evict a compiled, known-safe parser for another - // installed client; retirement is possible only through an explicit signed - // baseline schema ID in the same bundle. - const builtInSchema = remoteSchema || loaded.source === "built-in" - ? null - : selectOpenCodeSchema(BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas, capabilities); - const schema = remoteSchema - ?? (builtInSchema && !loaded.bundle.retiredSchemaIds?.includes(builtInSchema.id) ? builtInSchema : null); + // 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", @@ -1186,7 +1209,7 @@ export async function resolveOpenCodeCompatibility( mode: "structured", capabilities, schema, - bundleSource: remoteSchema ? loaded.source : "built-in", + 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. diff --git a/src/lib/opencode-stream.test.ts b/src/lib/opencode-stream.test.ts index b5246bdb3..dad94eb6a 100644 --- a/src/lib/opencode-stream.test.ts +++ b/src/lib/opencode-stream.test.ts @@ -190,15 +190,17 @@ assert.deepEqual( "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", part: { type: "text", text: "trusted reply" } }), + JSON.stringify({ type: "future_text_delta", sessionID: "ses_future", part: { type: "text", text: "trusted reply" } }), BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0], - { onText: (event) => text.push(event.text), onOther: (event) => diagnostics.push(event.diagnostic ?? "other") }, + { 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"]), diff --git a/src/lib/opencode-stream.ts b/src/lib/opencode-stream.ts index a695140fa..7f2c54885 100644 --- a/src/lib/opencode-stream.ts +++ b/src/lib/opencode-stream.ts @@ -247,9 +247,11 @@ export function handleOpenCodeJsonLine( const rawEvent = JSON.parse(line) as unknown; const event = parseOpenCodeRunEvent(rawEvent, schema); // `other` includes unknown event labels and malformed selected envelopes. - // Their session-shaped fields are untrusted: adopting one would poison the - // native resume token even though the payload is deliberately skipped. - if (event.kind !== "other" && event.sessionId) handlers.onSession?.(event.sessionId); + // 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. + const trustedSession = event.kind !== "other" && !(event.kind === "text" && event.diagnostic === "unknown-event"); + if (trustedSession && event.sessionId) handlers.onSession?.(event.sessionId); switch (event.kind) { case "ignore": return; case "text": From b0efe661aae4c3b55d51d3dbc26a6232f9d6209a Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 09:28:36 -0400 Subject: [PATCH 065/112] fix(chat): harden Windows OpenCode probes --- .../chat/send/chat-send-capabilities.test.ts | 16 +++- .../api/chat/send/chat-send-capabilities.ts | 81 +++++++++++++++++-- 2 files changed, 88 insertions(+), 9 deletions(-) diff --git a/src/app/api/chat/send/chat-send-capabilities.test.ts b/src/app/api/chat/send/chat-send-capabilities.test.ts index 0b24f8654..5dcf55c41 100644 --- a/src/app/api/chat/send/chat-send-capabilities.test.ts +++ b/src/app/api/chat/send/chat-send-capabilities.test.ts @@ -3,10 +3,24 @@ import assert from "node:assert/strict"; import { mkdtemp, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; -import { openCodeCapabilityProbeTimeoutMs, openCodeExecutableIdentity, parseOpenCodeRunCapabilitiesHelp } from "./chat-send-capabilities.ts"; +import { + openCodeCapabilityProbeCacheable, + openCodeCapabilityProbeTimeoutMs, + openCodeExecutableIdentity, + openCodeProbeTreeKillCommand, + 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(openCodeCapabilityProbeCacheable("linux"), true, "direct Unix executable identity supports a short capability cache"); +assert.equal(openCodeCapabilityProbeCacheable("win32"), false, "Windows launcher shims are reprobed rather than reusing stale downstream capability evidence"); +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 diff --git a/src/app/api/chat/send/chat-send-capabilities.ts b/src/app/api/chat/send/chat-send-capabilities.ts index 285e7d494..a4090e9f2 100644 --- a/src/app/api/chat/send/chat-send-capabilities.ts +++ b/src/app/api/chat/send/chat-send-capabilities.ts @@ -26,6 +26,48 @@ export function openCodeCapabilityProbeTimeoutMs(platform: NodeJS.Platform = pro return platform === "win32" ? WINDOWS_CAPABILITY_PROBE_TIMEOUT_MS : DEFAULT_CAPABILITY_PROBE_TIMEOUT_MS; } +/** Windows launchers can be stable shims whose downstream target changes. + * Do not reuse their help-derived argv evidence between turns. */ +export function openCodeCapabilityProbeCacheable(platform: NodeJS.Platform = process.platform): boolean { + return 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) { + try { + child.kill("SIGTERM"); + } catch { + // The close/error handler below still marks this probe unsupported. + } + return Promise.resolve(); + } + return new Promise((resolve) => { + try { + const killer = spawn(treeKill.command, treeKill.args, { stdio: "ignore", windowsHide: true }); + killer.once("error", () => { + try { child.kill("SIGTERM"); } catch { /* Best-effort fallback. */ } + resolve(); + }); + killer.once("close", () => resolve()); + } catch { + try { child.kill("SIGTERM"); } catch { /* Best-effort fallback. */ } + resolve(); + } + }); +} + function probeHelp( command: string, args: string[], @@ -97,12 +139,34 @@ function probeOutput(command: string, args: string[], env = harnessSpawnEnv(), i }; child.stdout.on("data", append); child.stderr.on("data", append); - const timeout = setTimeout(() => { - try { child.kill("SIGTERM"); } catch { /* Probe failures are capabilities=false. */ } - done(false); + 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; otherwise orphaned OpenCode children accumulate. + void terminateProbeProcessTree(child).finally(() => { + void exited.then(() => done(false)); + }); }, openCodeCapabilityProbeTimeoutMs()); - child.on("close", (code) => { clearTimeout(timeout); done(code === 0 && !overflowed); }); - child.on("error", () => { clearTimeout(timeout); done(false); }); } catch { done(false); } @@ -368,9 +432,10 @@ export async function openCodeRunCapabilities(familiarId?: string): Promise { @@ -389,6 +454,6 @@ export async function openCodeRunCapabilities(familiarId?: string): Promise Date: Sat, 25 Jul 2026 09:36:39 -0400 Subject: [PATCH 066/112] fix(chat): parse inline OpenCode yargs values --- .../chat/send/chat-send-capabilities.test.ts | 17 +++++++---------- src/app/api/chat/send/chat-send-capabilities.ts | 13 +++++++++++-- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/src/app/api/chat/send/chat-send-capabilities.test.ts b/src/app/api/chat/send/chat-send-capabilities.test.ts index 5dcf55c41..b69655fa6 100644 --- a/src/app/api/chat/send/chat-send-capabilities.test.ts +++ b/src/app/api/chat/send/chat-send-capabilities.test.ts @@ -54,20 +54,17 @@ assert.equal(proseOperand.json, false, "an operand-looking token in an option de 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"] - --session Resume a named session - [string] - --model Select a model - [string] + --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 string metadata confirms native session support"); -assert.equal(currentYargsHelp.model, true, "current yargs string metadata confirms model forwarding support"); -assert.deepEqual(currentYargsHelp.valueOptions, ["--format", "--session", "--model", "--event-stream"], "wrapped yargs type annotations confirm value-taking options"); -assert.deepEqual(currentYargsHelp.noValueOptions, [], "a wrapped yargs value annotation never becomes evidence for a valueless launch flag"); +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(` diff --git a/src/app/api/chat/send/chat-send-capabilities.ts b/src/app/api/chat/send/chat-send-capabilities.ts index a4090e9f2..cafd11404 100644 --- a/src/app/api/chat/send/chat-send-capabilities.ts +++ b/src/app/api/chat/send/chat-send-capabilities.ts @@ -192,14 +192,23 @@ function optionSyntax(help: string, option: string): OptionSyntax | null { 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 = trailing.search(/\s{2,}/); - const declaration = (descriptionAt >= 0 ? trailing.slice(0, descriptionAt) : trailing).trim(); + 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(" ") }; } From cb549f5879a4d04080e002d638bc25ce96c005c5 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 10:52:59 -0400 Subject: [PATCH 067/112] fix(chat): avoid tracing OpenCode runtime probes --- .../chat/send/chat-send-capabilities.test.ts | 25 ++------ .../api/chat/send/chat-send-capabilities.ts | 64 ++++++------------- src/lib/opencode-compatibility.test.ts | 7 ++ src/lib/opencode-compatibility.ts | 26 ++++++-- src/lib/opencode-stream.test.ts | 8 +++ src/lib/opencode-stream.ts | 17 +++-- 6 files changed, 70 insertions(+), 77 deletions(-) diff --git a/src/app/api/chat/send/chat-send-capabilities.test.ts b/src/app/api/chat/send/chat-send-capabilities.test.ts index b69655fa6..31f816e1b 100644 --- a/src/app/api/chat/send/chat-send-capabilities.test.ts +++ b/src/app/api/chat/send/chat-send-capabilities.test.ts @@ -1,18 +1,16 @@ // @ts-nocheck import assert from "node:assert/strict"; -import { mkdtemp, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; import { openCodeCapabilityProbeCacheable, openCodeCapabilityProbeTimeoutMs, - openCodeExecutableIdentity, + openCodeProbeCleanupGraceMs, openCodeProbeTreeKillCommand, 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"), true, "direct Unix executable identity supports a short capability cache"); assert.equal(openCodeCapabilityProbeCacheable("win32"), false, "Windows launcher shims are reprobed rather than reusing stale downstream capability evidence"); assert.deepEqual( @@ -127,22 +125,7 @@ const resumeCapabilities = parseOpenCodeRunCapabilitiesHelp(` 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"); -const launcherA = await mkdtemp(path.join(tmpdir(), "cave-opencode-launch-a-")); -const launcherB = await mkdtemp(path.join(tmpdir(), "cave-opencode-launch-b-")); -const executable = process.platform === "win32" ? "opencode.cmd" : "opencode"; -await writeFile(path.join(launcherA, executable), "first"); -await writeFile(path.join(launcherB, executable), "second"); -const identityA = await openCodeExecutableIdentity({ PATH: launcherA }); -const identityB = await openCodeExecutableIdentity({ PATH: launcherB }); -assert.notEqual(identityA, identityB, "capability-cache identity changes when PATH resolves a different OpenCode executable"); - -const windowsLauncher = await mkdtemp(path.join(tmpdir(), "cave-opencode-launch-windows-")); -await writeFile(path.join(windowsLauncher, "opencode.cmd"), "shim"); -await writeFile(path.join(windowsLauncher, "opencode.exe"), "binary"); -const windowsIdentity = await openCodeExecutableIdentity( - { PATH: windowsLauncher, PATHEXT: ".COM;.EXE;.BAT;.CMD" }, - "win32", -); -assert.match(windowsIdentity, /opencode\.exe\0/i, "Windows capability identity follows PowerShell PATHEXT precedence over a co-located cmd shim"); +assert.equal(openCodeCapabilityProbeCacheable("win32"), false, "Windows re-probes every turn because a shim target can change in place"); +assert.equal(openCodeCapabilityProbeCacheable("linux"), true, "other platforms retain the bounded sixty-second capability cache"); 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 cafd11404..27c168290 100644 --- a/src/app/api/chat/send/chat-send-capabilities.ts +++ b/src/app/api/chat/send/chat-send-capabilities.ts @@ -1,6 +1,4 @@ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; -import { stat } from "node:fs/promises"; -import path from "node:path"; import { covenLaunchCommand } from "@/lib/coven-bin"; import { covenRunSupportsAddDirFlag, @@ -20,12 +18,18 @@ let openCodeCapabilitiesProbe: { until: number; identity: string; value: Promise const OPENCODE_CAPABILITY_PROBE_TTL_MS = 60_000; 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; +} + /** Windows launchers can be stable shims whose downstream target changes. * Do not reuse their help-derived argv evidence between turns. */ export function openCodeCapabilityProbeCacheable(platform: NodeJS.Platform = process.platform): boolean { @@ -162,9 +166,14 @@ function probeOutput(command: string, args: string[], env = harnessSpawnEnv(), i timeout = setTimeout(() => { timedOut = true; // Do not resolve a timed-out probe until the entire Windows launcher - // tree has exited; otherwise orphaned OpenCode children accumulate. + // 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(() => done(false)); + void exited.then(() => { + clearTimeout(cleanupDeadline); + done(false); + }); }); }, openCodeCapabilityProbeTimeoutMs()); } catch { @@ -294,43 +303,6 @@ function advertisedStructuredSwitches(options: string[], noValueOptions: string[ }); } -/** - * Identifies the executable the next OpenCode spawn will resolve. The PATH - * lookup is repeated before using a cached capability probe so an in-place - * upgrade, reinstall, or PATH change cannot reuse stale argv evidence. - */ -export async function openCodeExecutableIdentity( - env = openCodeSpawnEnv(), - platform: NodeJS.Platform = process.platform, -): Promise { - const pathValue = env.PATH ?? env.Path ?? ""; - // `& opencode` in PowerShell resolves external commands using PATHEXT - // order. Fingerprint that same winner so a co-located .exe/.cmd upgrade - // cannot reuse help evidence from a shadowed launcher. - const names = platform === "win32" - ? (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD") - .split(";") - .map((extension) => extension.trim()) - .filter((extension) => /^\.[A-Za-z0-9]+$/.test(extension)) - // Windows resolves extensions case-insensitively. Lower-casing also - // lets the explicit win32 resolver contract be exercised on Unix CI. - .map((extension) => `opencode${extension.toLowerCase()}`) - : ["opencode"]; - for (const directory of pathValue.split(path.delimiter).filter(Boolean)) { - for (const name of names) { - const candidate = path.join(directory, name); - try { - const info = await stat(candidate); - if (info.isFile()) return `${candidate}\0${info.size}\0${info.mtimeMs}`; - } catch { - // Continue through PATH; an unresolved launcher is still fingerprinted - // below so a later install changes the cache key. - } - } - } - return `unresolved\0${platform}\0${pathValue}`; -} - function advertisedFormatProtocols( outputs: Array<{ option: string; values: string[] }>, switches: Array<{ option: string; protocols: string[] }>, @@ -437,13 +409,13 @@ export function openCodeRunSupportsModel(): Promise { * schema because vendors can backport or change protocol behavior. */ export async function openCodeRunCapabilities(familiarId?: string): Promise { - // The help/version probes must resolve exactly the binary and scoped vault - // environment that will execute the chat turn. Include the familiar scope - // in the cache key even when two scopes currently resolve the same binary. + // The help/version probes use exactly the scoped environment that will + // execute the chat turn. A bounded TTL makes an in-place CLI upgrade or a + // PATH change visible without filesystem inspection that would pull a + // machine-local PATH tree into the standalone trace. const env = openCodeSpawnEnv(familiarId); const cacheable = openCodeCapabilityProbeCacheable(); - const executableIdentity = cacheable ? await openCodeExecutableIdentity(env) : "uncached-windows-launcher"; - const identity = `${familiarId ?? "default"}\0${executableIdentity}`; + const identity = familiarId ?? "default"; if (cacheable && openCodeCapabilitiesProbe && Date.now() < openCodeCapabilitiesProbe.until && openCodeCapabilitiesProbe.identity === identity) { return openCodeCapabilitiesProbe.value; } diff --git a/src/lib/opencode-compatibility.test.ts b/src/lib/opencode-compatibility.test.ts index 1552d0b6d..d17864930 100644 --- a/src/lib/opencode-compatibility.test.ts +++ b/src/lib/opencode-compatibility.test.ts @@ -345,6 +345,13 @@ assert.equal(offlineBaseline.mode, "structured", "a first offline launch keeps t 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], diff --git a/src/lib/opencode-compatibility.ts b/src/lib/opencode-compatibility.ts index e2c201334..b735b7d07 100644 --- a/src/lib/opencode-compatibility.ts +++ b/src/lib/opencode-compatibility.ts @@ -1,7 +1,6 @@ import { createHash, createPublicKey, randomBytes, verify } from "node:crypto"; import { mkdir, open, readFile, rename, rm, stat } from "node:fs/promises"; -import path from "node:path"; -import { caveHome } from "./coven-paths.ts"; +import { homedir } from "node:os"; import { writeJsonAtomic } from "./server/atomic-write.ts"; export type OpenCodeRunCapabilities = { @@ -599,8 +598,23 @@ export function verifyOpenCodeSchemaBundle( type CachedBundle = { checkedAt: number; bundle: OpenCodeSchemaBundle; verifiedKeyId?: string }; +/** Build a runtime-only user-state path without presenting a dynamic filesystem + * lookup to Turbopack's standalone tracer. This mirrors covenHome/caveHome. */ +function localPathChild(parent: string, child: string): string { + const separator = process.platform === "win32" ? "\\" : "/"; + return `${parent.replace(/[\\/]+$/, "")}${separator}${child}`; +} + function cachePath(): string { - return path.join(caveHome(), CACHE_FILE); + // 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 { @@ -981,7 +995,7 @@ async function refreshOpenCodeSchemaBundle( 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 mkdir(path.dirname(file), { recursive: true }); + await mkdir(localPathParent(file), { recursive: true }); const writeResult = await withCacheWriteLock(file, async () => { // Re-read after acquiring the lock. Another process may have refreshed // while this request was in flight, and the cache must never move back. @@ -1158,14 +1172,14 @@ export async function resolveOpenCodeCompatibility( // 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.diagnostic === "cached-schema-unavailable" + 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, + diagnostic: loaded.diagnostic ?? "cached-schema-unavailable", }; } // Remote registries publish additive compatibility profiles by default. diff --git a/src/lib/opencode-stream.test.ts b/src/lib/opencode-stream.test.ts index dad94eb6a..793822d52 100644 --- a/src/lib/opencode-stream.test.ts +++ b/src/lib/opencode-stream.test.ts @@ -166,6 +166,14 @@ assert.deepEqual( { 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 }, diff --git a/src/lib/opencode-stream.ts b/src/lib/opencode-stream.ts index 7f2c54885..cc738f613 100644 --- a/src/lib/opencode-stream.ts +++ b/src/lib/opencode-stream.ts @@ -120,8 +120,17 @@ function toolStateIsError(state: Record | null, part: Record error.toLowerCase() === status) - || (stateError !== undefined && stateError !== null) - || (partError !== undefined && partError !== null); + || 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. */ @@ -195,9 +204,9 @@ export function parseOpenCodeRunEvent(value: unknown, schema?: OpenCodeEventSche const stateError = valueAt(state, error); const partError = valueAt(toolPart, error); const hasTerminalPayload = valueAt(state, output) !== undefined - || (stateError !== undefined && stateError !== null) + || hasToolErrorPayload(stateError) || valueAt(toolPart, output) !== undefined - || (partError !== undefined && partError !== null); + || 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"])) ?? {} }; } From c409108a38aff59987092377a19b83f72afe94d4 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 11:01:28 -0400 Subject: [PATCH 068/112] fix(chat): guard OpenCode schema cache writers --- .../send/harness-routing-opencode.test.ts | 7 ++- src/lib/opencode-compatibility.ts | 48 +++++++++++++------ 2 files changed, 39 insertions(+), 16 deletions(-) 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 25158dca3..946b1977c 100644 --- a/src/app/api/chat/send/harness-routing-opencode.test.ts +++ b/src/app/api/chat/send/harness-routing-opencode.test.ts @@ -192,8 +192,13 @@ assert.match( ); assert.match( capabilities, - /export async function openCodeRunCapabilities\(familiarId\?[^)]*\)[\s\S]*?const env = openCodeSpawnEnv\(familiarId\);[\s\S]*?openCodeExecutableIdentity\(env\)[\s\S]*?const versionLaunch = openCodeLaunch\(\["--version"\]\);[\s\S]*?probeOutput\(helpLaunch\.command, helpLaunch\.args, env[\s\S]*?json:[\s\S]*?model:[\s\S]*?session:/, + /export async function openCodeRunCapabilities\(familiarId\?[^)]*\)[\s\S]*?const env = openCodeSpawnEnv\(familiarId\);[\s\S]*?const versionLaunch = openCodeLaunch\(\["--version"\]\);[\s\S]*?probeOutput\(helpLaunch\.command, helpLaunch\.args, env[\s\S]*?json:[\s\S]*?model:[\s\S]*?session:/, "OpenCode discovers feature support from the scoped executable environment and records version only for diagnostics", ); +assert.match( + capabilities, + /openCodeExecutableIdentity/, + "capability discovery fingerprints the launched OpenCode contract without walking machine-local PATH entries", +); console.log("opencode harness routing tests passed"); diff --git a/src/lib/opencode-compatibility.ts b/src/lib/opencode-compatibility.ts index b735b7d07..1441aa474 100644 --- a/src/lib/opencode-compatibility.ts +++ b/src/lib/opencode-compatibility.ts @@ -639,7 +639,7 @@ function cacheTrustAnchorPath(file: string): string { async function readBoundedCacheFile(file: string): Promise { let handle: Awaited> | null = null; try { - handle = await open(file, "r"); + handle = await open(/* turbopackIgnore: true */ file, "r"); const bytes = Buffer.allocUnsafe(MAX_SCHEMA_BUNDLE_BYTES + 1); const { bytesRead } = await handle.read(bytes, 0, bytes.length, 0); if (bytesRead > MAX_SCHEMA_BUNDLE_BYTES) return null; @@ -739,7 +739,7 @@ async function fetchSchemaBundle(url: string, fetcher: typeof fetch, timeoutMs = */ async function staleLockCanBeReclaimed(lock: string): Promise { try { - const info = await stat(lock); + const info = await 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, @@ -784,7 +784,9 @@ async function writeVerifiedCache( publicKey: string | OpenCodeRegistryKeyring, now: number, cached: CachedBundle, + assertLockOwner?: () => Promise, ): Promise { + await assertLockOwner?.(); const priorAnchor = await readTrustedCacheRecord(trustAnchorFile, publicKey, now); if (priorAnchor && priorAnchor.bundle.sequence > cached.bundle.sequence) throw new Error("schema rollback"); if ( @@ -794,8 +796,14 @@ async function writeVerifiedCache( ) throw new Error("schema sequence rewritten"); // Persist the independent signed high-water anchor first: later damage to // both cache records cannot erase the highest accepted registry sequence. + await assertLockOwner?.(); await writeJsonAtomic(trustAnchorFile, cached); + // 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 writeJsonAtomic(cacheTrustPath(file), cached); + await assertLockOwner?.(); await writeJsonAtomic(file, cached); } @@ -822,25 +830,25 @@ async function releaseCacheLock(lock: string, ownerToken: string): Promise // 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 readFile(lock, "utf8")) !== ownerToken) return; - await rm(lock, { force: true }); + if ((await readFile(/* turbopackIgnore: true */ lock, "utf8")) !== ownerToken) return; + await rm(/* turbopackIgnore: true */ lock, { force: true }); } catch { // A concurrent stale-lock recovery or shutdown already cleaned it up. } } -async function withCacheWriteLock(file: string, callback: () => Promise): Promise { +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 open(lock, "wx", 0o600); + const acquired = await open(/* turbopackIgnore: true */ lock, "wx", 0o600); try { await acquired.writeFile(ownerToken); } catch (error) { await acquired.close().catch(() => undefined); - await rm(lock, { force: true }).catch(() => undefined); + await rm(/* turbopackIgnore: true */ lock, { force: true }).catch(() => undefined); throw error; } handle = acquired; @@ -851,16 +859,26 @@ async function withCacheWriteLock(file: string, callback: () => Promise): // delete a newly acquired lock between its stale check and this cleanup. const stale = `${lock}.${process.pid}.${randomBytes(6).toString("hex")}.stale`; try { - await rename(lock, stale); - await rm(stale, { force: true }); + await rename(/* turbopackIgnore: true */ lock, stale); + await rm(/* turbopackIgnore: true */ stale, { force: true }); } catch { return null; } } } if (!handle) return null; + const assertOwner = async () => { + try { + if ((await 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(); + return await callback(assertOwner); } finally { await handle.close().catch(() => undefined); await releaseCacheLock(lock, ownerToken); @@ -883,7 +901,7 @@ async function waitForConcurrentCacheWriter( latest = await readVerifiedCache(file, publicKeys, now, checkpoint, trustAnchorFile); if (latest && latest.bundle.sequence >= minimumSequence) return latest; try { - await stat(lock); + await 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. @@ -995,8 +1013,8 @@ async function refreshOpenCodeSchemaBundle( 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 mkdir(localPathParent(file), { recursive: true }); - const writeResult = await withCacheWriteLock(file, async () => { + await 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); @@ -1005,10 +1023,10 @@ async function refreshOpenCodeSchemaBundle( 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 }); + 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 }); + 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; From 2cf0e79d5ded6c64f431b41ff9df39145f0b15f8 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 11:04:17 -0400 Subject: [PATCH 069/112] fix(chat): harden OpenCode runtime identity --- .../chat/send/chat-send-capabilities.test.ts | 6 +++ .../api/chat/send/chat-send-capabilities.ts | 38 ++++++++++++++++--- src/lib/opencode-stream.test.ts | 15 ++++++-- src/lib/opencode-stream.ts | 5 ++- 4 files changed, 55 insertions(+), 9 deletions(-) diff --git a/src/app/api/chat/send/chat-send-capabilities.test.ts b/src/app/api/chat/send/chat-send-capabilities.test.ts index 31f816e1b..afe23057d 100644 --- a/src/app/api/chat/send/chat-send-capabilities.test.ts +++ b/src/app/api/chat/send/chat-send-capabilities.test.ts @@ -1,6 +1,7 @@ // @ts-nocheck import assert from "node:assert/strict"; import { + openCodeCapabilityIdentity, openCodeCapabilityProbeCacheable, openCodeCapabilityProbeTimeoutMs, openCodeProbeCleanupGraceMs, @@ -13,6 +14,11 @@ assert.equal(openCodeCapabilityProbeTimeoutMs("win32"), 6_000, "Windows PowerShe assert.equal(openCodeProbeCleanupGraceMs(), 1_000, "timed-out probe cleanup has a short final deadline so chat can fall back"); assert.equal(openCodeCapabilityProbeCacheable("linux"), true, "direct Unix executable identity supports a short capability cache"); assert.equal(openCodeCapabilityProbeCacheable("win32"), false, "Windows launcher shims are reprobed rather than reusing stale downstream capability evidence"); +assert.notEqual( + openCodeCapabilityIdentity("--format [json]", "1.0.0"), + openCodeCapabilityIdentity("--format [json-v2]", "1.0.0"), + "a changed executable help contract invalidates cached capability evidence even when its version remains unchanged", +); assert.deepEqual( openCodeProbeTreeKillCommand(4242, "win32"), { command: "taskkill.exe", args: ["/PID", "4242", "/T", "/F"] }, diff --git a/src/app/api/chat/send/chat-send-capabilities.ts b/src/app/api/chat/send/chat-send-capabilities.ts index 27c168290..ab4c96934 100644 --- a/src/app/api/chat/send/chat-send-capabilities.ts +++ b/src/app/api/chat/send/chat-send-capabilities.ts @@ -1,4 +1,5 @@ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { createHash } from "node:crypto"; import { covenLaunchCommand } from "@/lib/coven-bin"; import { covenRunSupportsAddDirFlag, @@ -303,6 +304,33 @@ function advertisedStructuredSwitches(options: string[], noValueOptions: string[ }); } +/** + * Fingerprint the exact launch contract before using a cached capability + * result. This is more useful than a PATH filesystem identity: a replacement + * binary with different accepted flags changes its bounded help/version + * response even when it keeps the same pathname or timestamp. + */ +export function openCodeCapabilityIdentity(help: string, version: string): string { + return createHash("sha256") + .update(help) + .update("\0") + .update(version) + .digest("hex"); +} + +export async function openCodeExecutableIdentity(env = openCodeSpawnEnv()): 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), + ]); + // Never cache a transient failure: a just-installed or updating executable + // must be retried on the next turn rather than pinned for the TTL. + if (!helpProbe.complete || !versionProbe.complete) return `unverified:${Date.now()}`; + return openCodeCapabilityIdentity(helpProbe.output, versionProbe.output); +} + function advertisedFormatProtocols( outputs: Array<{ option: string; values: string[] }>, switches: Array<{ option: string; protocols: string[] }>, @@ -409,13 +437,13 @@ export function openCodeRunSupportsModel(): Promise { * schema because vendors can backport or change protocol behavior. */ export async function openCodeRunCapabilities(familiarId?: string): Promise { - // The help/version probes use exactly the scoped environment that will - // execute the chat turn. A bounded TTL makes an in-place CLI upgrade or a - // PATH change visible without filesystem inspection that would pull a - // machine-local PATH tree into the standalone trace. + // The identity probes use exactly the scoped environment that will execute + // the chat turn. A changed help/version contract invalidates the TTL cache + // without filesystem inspection of machine-local PATH entries. const env = openCodeSpawnEnv(familiarId); const cacheable = openCodeCapabilityProbeCacheable(); - const identity = familiarId ?? "default"; + const executableIdentity = cacheable ? await openCodeExecutableIdentity(env) : "uncached-windows-launcher"; + const identity = `${familiarId ?? "default"}\0${executableIdentity}`; if (cacheable && openCodeCapabilitiesProbe && Date.now() < openCodeCapabilitiesProbe.until && openCodeCapabilitiesProbe.identity === identity) { return openCodeCapabilitiesProbe.value; } diff --git a/src/lib/opencode-stream.test.ts b/src/lib/opencode-stream.test.ts index 793822d52..78283a67b 100644 --- a/src/lib/opencode-stream.test.ts +++ b/src/lib/opencode-stream.test.ts @@ -53,7 +53,7 @@ assert.deepEqual( { type: "step_start", sessionID: "ses_123", part: { id: "step_1" } }, BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0], ), - { kind: "ignore", sessionId: "ses_123" }, + { kind: "ignore" }, "current OpenCode step-start frames are lifecycle metadata, not compatibility failures", ); assert.deepEqual( @@ -61,7 +61,7 @@ assert.deepEqual( { type: "step_finish", sessionID: "ses_123", part: { id: "step_1" } }, BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0], ), - { kind: "ignore", sessionId: "ses_123" }, + { kind: "ignore" }, "current OpenCode step-finish frames are lifecycle metadata, not compatibility failures", ); assert.deepEqual( @@ -69,9 +69,18 @@ assert.deepEqual( { type: "reasoning", sessionID: "ses_123", part: { text: "private chain of thought" } }, BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0], ), - { kind: "ignore", sessionId: "ses_123" }, + { kind: "ignore" }, "current OpenCode reasoning frames are lifecycle metadata and never leak as assistant text", ); +{ + 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, [], "ignored lifecycle frames cannot overwrite the persisted native resume token"); +} assert.deepEqual( parseOpenCodeRunEvent( { session: "ses_mapped", payload: { event: "reply", type: "text", body: "A registry-mapped reply" } }, diff --git a/src/lib/opencode-stream.ts b/src/lib/opencode-stream.ts index cc738f613..17ba3d8b8 100644 --- a/src/lib/opencode-stream.ts +++ b/src/lib/opencode-stream.ts @@ -148,7 +148,10 @@ export function parseOpenCodeRunEvent(value: unknown, schema?: OpenCodeEventSche 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)) { - return { kind: "ignore", sessionId }; + // Lifecycle/control frames are deliberately non-renderable. Their root + // fields are not a signed payload envelope, so they must never update a + // persisted native resume token. + return { kind: "ignore" }; } if (eventTypes(schema, "error", ["error"]).includes(eventType)) { const errorValue = valueAt(part, shapeAliases(schema, "error", ["error"])) ?? event.error; From db40bc1fba39268943dcc6afb7e23dcfc4773ba0 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 11:12:54 -0400 Subject: [PATCH 070/112] fix(chat): preserve OpenCode launch identity --- .../api/chat/send/chat-send-capabilities.ts | 45 +++++++++++-------- .../send/harness-routing-opencode.test.ts | 2 +- src/lib/opencode-bin.ts | 7 +++ src/lib/opencode-compatibility.ts | 41 +++++++++++------ 4 files changed, 62 insertions(+), 33 deletions(-) diff --git a/src/app/api/chat/send/chat-send-capabilities.ts b/src/app/api/chat/send/chat-send-capabilities.ts index ab4c96934..749aab015 100644 --- a/src/app/api/chat/send/chat-send-capabilities.ts +++ b/src/app/api/chat/send/chat-send-capabilities.ts @@ -318,13 +318,20 @@ export function openCodeCapabilityIdentity(help: string, version: string): strin .digest("hex"); } -export async function openCodeExecutableIdentity(env = openCodeSpawnEnv()): Promise { +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 }; +} + +export async function openCodeExecutableIdentity(env = openCodeSpawnEnv()): Promise { + const { helpProbe, versionProbe } = await probeOpenCodeRunContract(env); // Never cache a transient failure: a just-installed or updating executable // must be retried on the next turn rather than pinned for the TTL. if (!helpProbe.complete || !versionProbe.complete) return `unverified:${Date.now()}`; @@ -442,27 +449,29 @@ export async function openCodeRunCapabilities(familiarId?: string): 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), - ]); - 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. Probe again after the short TTL instead of risking an argv - // that the installed client does not accept. - if (!helpProbe.complete) return { version, json: false, model: false, session: false, protocols: [], options: [], valueOptions: [], noValueOptions: [], endOfOptions: false, structuredSwitches: [], structuredOutputs: [] }; - return parseOpenCodeRunCapabilitiesHelp(helpProbe.output, version); - })(); + 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. Probe again after the short TTL instead of risking an argv + // that the installed client does not accept. + const capabilities = !helpProbe.complete + ? { version, json: false, model: false, session: false, protocols: [], options: [], valueOptions: [], noValueOptions: [], endOfOptions: false, structuredSwitches: [], structuredOutputs: [] } + : parseOpenCodeRunCapabilitiesHelp(helpProbe.output, version); + const value = Promise.resolve(capabilities); if (cacheable) openCodeCapabilitiesProbe = { until: Date.now() + OPENCODE_CAPABILITY_PROBE_TTL_MS, identity, value }; return value; } 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 946b1977c..ba9c2b52d 100644 --- a/src/app/api/chat/send/harness-routing-opencode.test.ts +++ b/src/app/api/chat/send/harness-routing-opencode.test.ts @@ -192,7 +192,7 @@ assert.match( ); assert.match( capabilities, - /export async function openCodeRunCapabilities\(familiarId\?[^)]*\)[\s\S]*?const env = openCodeSpawnEnv\(familiarId\);[\s\S]*?const versionLaunch = openCodeLaunch\(\["--version"\]\);[\s\S]*?probeOutput\(helpLaunch\.command, helpLaunch\.args, env[\s\S]*?json:[\s\S]*?model:[\s\S]*?session:/, + /export async function openCodeRunCapabilities\(familiarId\?[^)]*\)[\s\S]*?const env = openCodeSpawnEnv\(familiarId\);[\s\S]*?probeOpenCodeRunContract\(env\)[\s\S]*?openCodeCapabilityIdentity\(helpProbe\.output, versionProbe\.output\)[\s\S]*?json:[\s\S]*?model:[\s\S]*?session:/, "OpenCode discovers feature support from the scoped executable environment and records version only for diagnostics", ); assert.match( diff --git a/src/lib/opencode-bin.ts b/src/lib/opencode-bin.ts index 49bf41ce1..64ceb63c6 100644 --- a/src/lib/opencode-bin.ts +++ b/src/lib/opencode-bin.ts @@ -1,4 +1,5 @@ import type { ChildProcess } from "node:child_process"; +import { caveToolSpawnEnv } from "./coven-bin.ts"; import { harnessSpawnEnv } from "./harness-spawn-env.ts"; /** OpenCode is installed as `opencode` on all supported desktop platforms. */ @@ -78,6 +79,12 @@ export function openCodeNeedsTmpRuntimeDir( */ export function openCodeSpawnEnv(familiarId?: string | null): NodeJS.ProcessEnv { const env = harnessSpawnEnv(familiarId); + // OpenCode is a direct user-selected runtime, not the Coven launcher. Keep + // the familiar's secret scope from harnessSpawnEnv, but prefer the launch + // PATH so a freshly selected/updated npm shim is not shadowed by an older + // global candidate directory (especially on Windows). + const toolPath = caveToolSpawnEnv().PATH; + if (toolPath) env.PATH = toolPath; if (openCodeNeedsTmpRuntimeDir(process.platform, env)) { env.XDG_RUNTIME_DIR = "/tmp"; } diff --git a/src/lib/opencode-compatibility.ts b/src/lib/opencode-compatibility.ts index 1441aa474..29b62e232 100644 --- a/src/lib/opencode-compatibility.ts +++ b/src/lib/opencode-compatibility.ts @@ -1,8 +1,21 @@ import { createHash, createPublicKey, randomBytes, verify } from "node:crypto"; -import { mkdir, open, readFile, rename, rm, stat } from "node:fs/promises"; import { homedir } from "node:os"; import { writeJsonAtomic } from "./server/atomic-write.ts"; +type RegistryFs = Pick; + +// The registry cache is per-user runtime state. Resolve its Node built-in at +// runtime so Next's file tracer cannot mistake its dynamic paths for packaged +// application inputs and copy the repository into the desktop sidecar. +const registryFs = (process as typeof process & { + getBuiltinModule?: (id: "node:fs/promises") => RegistryFs | undefined; +}).getBuiltinModule?.("node:fs/promises"); + +function requireRegistryFs(): RegistryFs { + if (!registryFs) throw new Error("node fs promises unavailable"); + return registryFs; +} + export type OpenCodeRunCapabilities = { version: string | null; json: boolean; @@ -637,9 +650,9 @@ function cacheTrustAnchorPath(file: string): string { * bytes consumed if the pathname is atomically replaced while it is read. */ async function readBoundedCacheFile(file: string): Promise { - let handle: Awaited> | null = null; + let handle: Awaited> | null = null; try { - handle = await open(/* turbopackIgnore: true */ file, "r"); + handle = await requireRegistryFs().open(file, "r"); const bytes = Buffer.allocUnsafe(MAX_SCHEMA_BUNDLE_BYTES + 1); const { bytesRead } = await handle.read(bytes, 0, bytes.length, 0); if (bytesRead > MAX_SCHEMA_BUNDLE_BYTES) return null; @@ -739,7 +752,7 @@ async function fetchSchemaBundle(url: string, fetcher: typeof fetch, timeoutMs = */ async function staleLockCanBeReclaimed(lock: string): Promise { try { - const info = await stat(/* turbopackIgnore: true */ lock); + const info = await requireRegistryFs().stat(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, @@ -830,8 +843,8 @@ async function releaseCacheLock(lock: string, ownerToken: string): Promise // 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 readFile(/* turbopackIgnore: true */ lock, "utf8")) !== ownerToken) return; - await rm(/* turbopackIgnore: true */ lock, { force: true }); + if ((await requireRegistryFs().readFile(lock, "utf8")) !== ownerToken) return; + await requireRegistryFs().rm(lock, { force: true }); } catch { // A concurrent stale-lock recovery or shutdown already cleaned it up. } @@ -839,16 +852,16 @@ async function releaseCacheLock(lock: string, ownerToken: string): Promise async function withCacheWriteLock(file: string, callback: (assertOwner: () => Promise) => Promise): Promise { const lock = `${file}.lock`; - let handle: Awaited> | null = null; + 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 open(/* turbopackIgnore: true */ lock, "wx", 0o600); + const acquired = await requireRegistryFs().open(lock, "wx", 0o600); try { await acquired.writeFile(ownerToken); } catch (error) { await acquired.close().catch(() => undefined); - await rm(/* turbopackIgnore: true */ lock, { force: true }).catch(() => undefined); + await requireRegistryFs().rm(lock, { force: true }).catch(() => undefined); throw error; } handle = acquired; @@ -859,8 +872,8 @@ async function withCacheWriteLock(file: string, callback: (assertOwner: () => // delete a newly acquired lock between its stale check and this cleanup. const stale = `${lock}.${process.pid}.${randomBytes(6).toString("hex")}.stale`; try { - await rename(/* turbopackIgnore: true */ lock, stale); - await rm(/* turbopackIgnore: true */ stale, { force: true }); + await requireRegistryFs().rename(lock, stale); + await requireRegistryFs().rm(stale, { force: true }); } catch { return null; } @@ -869,7 +882,7 @@ async function withCacheWriteLock(file: string, callback: (assertOwner: () => if (!handle) return null; const assertOwner = async () => { try { - if ((await readFile(/* turbopackIgnore: true */ lock, "utf8")) !== ownerToken) { + if ((await requireRegistryFs().readFile(lock, "utf8")) !== ownerToken) { throw new CacheWriterPendingError("schema cache lock ownership changed"); } } catch (error) { @@ -901,7 +914,7 @@ async function waitForConcurrentCacheWriter( latest = await readVerifiedCache(file, publicKeys, now, checkpoint, trustAnchorFile); if (latest && latest.bundle.sequence >= minimumSequence) return latest; try { - await stat(/* turbopackIgnore: true */ lock); + await requireRegistryFs().stat(lock); } catch { // Lock release follows the atomic write, so one final verified read sees // the owner's result without trusting its in-progress payload. @@ -1013,7 +1026,7 @@ async function refreshOpenCodeSchemaBundle( 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 mkdir(/* turbopackIgnore: true */ localPathParent(file), { recursive: true }); + await requireRegistryFs().mkdir(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. From fe7e4a33c41b70a16aebace4490b91b67598a367 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 11:22:32 -0400 Subject: [PATCH 071/112] fix(chat): reload OpenCode compatibility diagnostics --- .../api/chat/conversation/[id]/route.test.ts | 28 +++++++++++- src/app/api/chat/conversation/[id]/route.ts | 21 +++++++++ src/lib/cave-conversations.test.ts | 43 +++++++++++++++++++ src/lib/chat-turn-state.ts | 2 + 4 files changed, 93 insertions(+), 1 deletion(-) diff --git a/src/app/api/chat/conversation/[id]/route.test.ts b/src/app/api/chat/conversation/[id]/route.test.ts index 3cbed2b42..21f9aff41 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) { @@ -193,3 +193,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/lib/cave-conversations.test.ts b/src/lib/cave-conversations.test.ts index 11f19264a..b33181a6a 100644 --- a/src/lib/cave-conversations.test.ts +++ b/src/lib/cave-conversations.test.ts @@ -15,6 +15,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 +26,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", 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, From b8e99725fe8f12328a5400f3be16cd255cf0d6b4 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 11:23:23 -0400 Subject: [PATCH 072/112] fix(chat): keep OpenCode registry cache out of tracing --- src/lib/opencode-compatibility.ts | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/src/lib/opencode-compatibility.ts b/src/lib/opencode-compatibility.ts index 29b62e232..c3654b66d 100644 --- a/src/lib/opencode-compatibility.ts +++ b/src/lib/opencode-compatibility.ts @@ -1,5 +1,6 @@ import { createHash, createPublicKey, randomBytes, verify } from "node:crypto"; -import { homedir } from "node:os"; +import path from "node:path"; +import { caveHome } from "./coven-paths.ts"; import { writeJsonAtomic } from "./server/atomic-write.ts"; type RegistryFs = Pick; @@ -611,23 +612,13 @@ export function verifyOpenCodeSchemaBundle( type CachedBundle = { checkedAt: number; bundle: OpenCodeSchemaBundle; verifiedKeyId?: string }; -/** Build a runtime-only user-state path without presenting a dynamic filesystem - * lookup to Turbopack's standalone tracer. This mirrors covenHome/caveHome. */ -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); + return path.join(/* turbopackIgnore: true */ caveHome(), CACHE_FILE); } function localPathParent(file: string): string { - const slash = Math.max(file.lastIndexOf("/"), file.lastIndexOf("\\")); - return slash > 0 ? file.slice(0, slash) : "."; + return path.dirname(/* turbopackIgnore: true */ file); } function cacheTrustPath(file: string): string { From a5693b209090271d67f360b7769f73388cf56060 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 11:31:56 -0400 Subject: [PATCH 073/112] fix(chat): cache OpenCode capability probes --- .../chat/send/chat-send-capabilities.test.ts | 11 +++ .../api/chat/send/chat-send-capabilities.ts | 83 +++++++++++-------- .../send/harness-routing-opencode.test.ts | 4 +- 3 files changed, 62 insertions(+), 36 deletions(-) diff --git a/src/app/api/chat/send/chat-send-capabilities.test.ts b/src/app/api/chat/send/chat-send-capabilities.test.ts index afe23057d..d4c2a8a7f 100644 --- a/src/app/api/chat/send/chat-send-capabilities.test.ts +++ b/src/app/api/chat/send/chat-send-capabilities.test.ts @@ -1,11 +1,15 @@ // @ts-nocheck import assert from "node:assert/strict"; +import { mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; import { openCodeCapabilityIdentity, openCodeCapabilityProbeCacheable, openCodeCapabilityProbeTimeoutMs, openCodeProbeCleanupGraceMs, openCodeProbeTreeKillCommand, + openCodeExecutableIdentity, parseOpenCodeRunCapabilitiesHelp, } from "./chat-send-capabilities.ts"; @@ -19,6 +23,13 @@ assert.notEqual( openCodeCapabilityIdentity("--format [json-v2]", "1.0.0"), "a changed executable help contract invalidates cached capability evidence even when its version remains unchanged", ); +const launcher = await mkdtemp(path.join(tmpdir(), "cave-opencode-identity-")); +const launcherPath = path.join(launcher, "opencode"); +await writeFile(launcherPath, "first"); +const firstIdentity = await openCodeExecutableIdentity({ PATH: launcher }, "linux"); +await writeFile(launcherPath, "a replacement with different metadata"); +const secondIdentity = await openCodeExecutableIdentity({ PATH: launcher }, "linux"); +assert.notEqual(firstIdentity, secondIdentity, "an in-place executable replacement invalidates the cached capability probe before help is reused"); assert.deepEqual( openCodeProbeTreeKillCommand(4242, "win32"), { command: "taskkill.exe", args: ["/PID", "4242", "/T", "/F"] }, diff --git a/src/app/api/chat/send/chat-send-capabilities.ts b/src/app/api/chat/send/chat-send-capabilities.ts index 749aab015..2326ebba6 100644 --- a/src/app/api/chat/send/chat-send-capabilities.ts +++ b/src/app/api/chat/send/chat-send-capabilities.ts @@ -1,5 +1,7 @@ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; import { createHash } from "node:crypto"; +import { stat } from "node:fs/promises"; +import path from "node:path"; import { covenLaunchCommand } from "@/lib/coven-bin"; import { covenRunSupportsAddDirFlag, @@ -304,12 +306,7 @@ function advertisedStructuredSwitches(options: string[], noValueOptions: string[ }); } -/** - * Fingerprint the exact launch contract before using a cached capability - * result. This is more useful than a PATH filesystem identity: a replacement - * binary with different accepted flags changes its bounded help/version - * response even when it keeps the same pathname or timestamp. - */ +/** Fingerprint a verified help/version contract for diagnostics and tests. */ export function openCodeCapabilityIdentity(help: string, version: string): string { return createHash("sha256") .update(help) @@ -330,12 +327,37 @@ async function probeOpenCodeRunContract(env: NodeJS.ProcessEnv): Promise { - const { helpProbe, versionProbe } = await probeOpenCodeRunContract(env); - // Never cache a transient failure: a just-installed or updating executable - // must be retried on the next turn rather than pinned for the TTL. - if (!helpProbe.complete || !versionProbe.complete) return `unverified:${Date.now()}`; - return openCodeCapabilityIdentity(helpProbe.output, versionProbe.output); +/** + * Resolve only executable metadata before consulting the capability cache. + * `stat` is bounded by PATH entries and deliberately marked opaque to + * Turbopack: this is per-machine runtime state, not a standalone asset. + */ +export async function openCodeExecutableIdentity( + env = openCodeSpawnEnv(), + platform: NodeJS.Platform = process.platform, +): Promise { + const pathValue = env.PATH ?? env.Path ?? ""; + const names = platform === "win32" + ? (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD") + .split(";") + .map((extension) => extension.trim()) + .filter((extension) => /^\.[A-Za-z0-9]+$/.test(extension)) + .map((extension) => `opencode${extension.toLowerCase()}`) + : ["opencode"]; + for (const directory of pathValue.split(path.delimiter).filter(Boolean)) { + for (const name of names) { + const candidate = path.join(/* turbopackIgnore: true */ directory, name); + try { + const info = await stat(/* turbopackIgnore: true */ candidate); + if (info.isFile()) return `${candidate}\0${info.size}\0${info.mtimeMs}`; + } catch { + // Keep looking: the launched binary may be later in PATH. + } + } + } + // An unresolved command is never retained across turns: installation or a + // PATH update must immediately become visible. + return `unresolved:${Date.now()}`; } function advertisedFormatProtocols( @@ -444,34 +466,27 @@ export function openCodeRunSupportsModel(): Promise { * schema because vendors can backport or change protocol behavior. */ export async function openCodeRunCapabilities(familiarId?: string): Promise { - // The identity probes use exactly the scoped environment that will execute - // the chat turn. A changed help/version contract invalidates the TTL cache - // without filesystem inspection of machine-local PATH entries. + // The executable identity uses exactly the scoped environment that will + // execute the chat turn, but is intentionally much cheaper than `--help`. const env = openCodeSpawnEnv(familiarId); const cacheable = openCodeCapabilityProbeCacheable(); - // Probe once and reuse the verified contract for both cache validation and - // capability parsing. Re-probing after calculating the fingerprint would - // double launch latency and could select flags from a different executable. - const { helpProbe, versionProbe } = await probeOpenCodeRunContract(env); - const executableIdentity = cacheable - ? helpProbe.complete && versionProbe.complete - ? openCodeCapabilityIdentity(helpProbe.output, versionProbe.output) - : `unverified:${Date.now()}` - : "uncached-windows-launcher"; + const executableIdentity = cacheable ? await openCodeExecutableIdentity(env) : "uncached-windows-launcher"; const identity = `${familiarId ?? "default"}\0${executableIdentity}`; if (cacheable && openCodeCapabilitiesProbe && Date.now() < openCodeCapabilitiesProbe.until && openCodeCapabilitiesProbe.identity === identity) { return openCodeCapabilitiesProbe.value; } - 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. Probe again after the short TTL instead of risking an argv - // that the installed client does not accept. - const capabilities = !helpProbe.complete - ? { version, json: false, model: false, session: false, protocols: [], options: [], valueOptions: [], noValueOptions: [], endOfOptions: false, structuredSwitches: [], structuredOutputs: [] } - : parseOpenCodeRunCapabilitiesHelp(helpProbe.output, version); - const value = Promise.resolve(capabilities); + const value = (async () => { + const { helpProbe, versionProbe } = await probeOpenCodeRunContract(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. Probe again after the short TTL instead of risking an argv + // that the installed client does not accept. + return !helpProbe.complete + ? { version, json: false, model: false, session: false, protocols: [], options: [], valueOptions: [], noValueOptions: [], endOfOptions: false, structuredSwitches: [], structuredOutputs: [] } + : parseOpenCodeRunCapabilitiesHelp(helpProbe.output, version); + })(); if (cacheable) openCodeCapabilitiesProbe = { until: Date.now() + OPENCODE_CAPABILITY_PROBE_TTL_MS, identity, value }; return value; } 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 ba9c2b52d..34f439f63 100644 --- a/src/app/api/chat/send/harness-routing-opencode.test.ts +++ b/src/app/api/chat/send/harness-routing-opencode.test.ts @@ -192,8 +192,8 @@ assert.match( ); assert.match( capabilities, - /export async function openCodeRunCapabilities\(familiarId\?[^)]*\)[\s\S]*?const env = openCodeSpawnEnv\(familiarId\);[\s\S]*?probeOpenCodeRunContract\(env\)[\s\S]*?openCodeCapabilityIdentity\(helpProbe\.output, versionProbe\.output\)[\s\S]*?json:[\s\S]*?model:[\s\S]*?session:/, - "OpenCode discovers feature support from the scoped executable environment and records version only for diagnostics", + /export async function openCodeRunCapabilities\(familiarId\?[^)]*\)[\s\S]*?const env = openCodeSpawnEnv\(familiarId\);[\s\S]*?await openCodeExecutableIdentity\(env\)[\s\S]*?openCodeCapabilitiesProbe[\s\S]*?probeOpenCodeRunContract\(env\)[\s\S]*?json:[\s\S]*?model:[\s\S]*?session:/, + "OpenCode checks a cheap scoped executable identity before reusing or probing its capability contract", ); assert.match( capabilities, From 22a4001c9640ac7d3bd9d50f09a12fba2194a26e Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 11:48:10 -0400 Subject: [PATCH 074/112] fix(chat): isolate OpenCode runtime path probing --- .../api/chat/send/chat-send-capabilities.ts | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/src/app/api/chat/send/chat-send-capabilities.ts b/src/app/api/chat/send/chat-send-capabilities.ts index 2326ebba6..8652e11b8 100644 --- a/src/app/api/chat/send/chat-send-capabilities.ts +++ b/src/app/api/chat/send/chat-send-capabilities.ts @@ -1,7 +1,5 @@ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; import { createHash } from "node:crypto"; -import { stat } from "node:fs/promises"; -import path from "node:path"; import { covenLaunchCommand } from "@/lib/coven-bin"; import { covenRunSupportsAddDirFlag, @@ -12,6 +10,18 @@ import { harnessSpawnEnv } from "@/lib/harness-spawn-env"; import { openCodeLaunch, openCodeSpawnEnv, writeOpenCodeLaunchInput } from "@/lib/opencode-bin"; import type { OpenCodeRunCapabilities } from "@/lib/opencode-compatibility"; +type ExecutableFs = Pick; + +// PATH entries are machine-local runtime state, not standalone assets. Resolve +// this Node builtin lazily so Turbopack cannot trace an installed CLI tree. +const executableFs = (process as typeof process & { + getBuiltinModule?: (id: "node:fs/promises") => ExecutableFs | undefined; +}).getBuiltinModule?.("node:fs/promises"); + +function requireExecutableFs(): ExecutableFs | null { + return executableFs ?? null; +} + let modelFlagProbe: Promise | null = null; let permissionFlagProbe: Promise | null = null; let addDirFlagProbe: Promise | null = null; @@ -336,6 +346,8 @@ export async function openCodeExecutableIdentity( env = openCodeSpawnEnv(), platform: NodeJS.Platform = process.platform, ): Promise { + const fs = requireExecutableFs(); + if (!fs) return `unresolved:${Date.now()}`; const pathValue = env.PATH ?? env.Path ?? ""; const names = platform === "win32" ? (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD") @@ -344,11 +356,13 @@ export async function openCodeExecutableIdentity( .filter((extension) => /^\.[A-Za-z0-9]+$/.test(extension)) .map((extension) => `opencode${extension.toLowerCase()}`) : ["opencode"]; - for (const directory of pathValue.split(path.delimiter).filter(Boolean)) { + const pathSeparator = platform === "win32" ? ";" : ":"; + const directorySeparator = platform === "win32" ? "\\" : "/"; + for (const directory of pathValue.split(pathSeparator).filter(Boolean)) { for (const name of names) { - const candidate = path.join(/* turbopackIgnore: true */ directory, name); + const candidate = `${directory.replace(/[\\/]+$/, "")}${directorySeparator}${name}`; try { - const info = await stat(/* turbopackIgnore: true */ candidate); + const info = await fs.stat(candidate); if (info.isFile()) return `${candidate}\0${info.size}\0${info.mtimeMs}`; } catch { // Keep looking: the launched binary may be later in PATH. From eb59ca5ff46b0e5adfbb0c25356ad78810552fe0 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 11:49:44 -0400 Subject: [PATCH 075/112] fix(chat): protect OpenCode compatibility telemetry --- .../api/chat/conversation/[id]/route.test.ts | 9 ++++++++- src/lib/opencode-compatibility.ts | 17 +++++++++++++---- .../server/conversation-write-guards.test.ts | 3 ++- src/lib/server/conversation-write-guards.ts | 1 + 4 files changed, 24 insertions(+), 6 deletions(-) diff --git a/src/app/api/chat/conversation/[id]/route.test.ts b/src/app/api/chat/conversation/[id]/route.test.ts index 21f9aff41..6d8e9c495 100644 --- a/src/app/api/chat/conversation/[id]/route.test.ts +++ b/src/app/api/chat/conversation/[id]/route.test.ts @@ -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`); } }); diff --git a/src/lib/opencode-compatibility.ts b/src/lib/opencode-compatibility.ts index c3654b66d..64575b53a 100644 --- a/src/lib/opencode-compatibility.ts +++ b/src/lib/opencode-compatibility.ts @@ -1,6 +1,5 @@ import { createHash, createPublicKey, randomBytes, verify } from "node:crypto"; -import path from "node:path"; -import { caveHome } from "./coven-paths.ts"; +import { homedir } from "node:os"; import { writeJsonAtomic } from "./server/atomic-write.ts"; type RegistryFs = Pick; @@ -612,13 +611,23 @@ export function verifyOpenCodeSchemaBundle( 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. - return path.join(/* turbopackIgnore: true */ caveHome(), CACHE_FILE); + 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 { - return path.dirname(/* turbopackIgnore: true */ file); + const slash = Math.max(file.lastIndexOf("/"), file.lastIndexOf("\\")); + return slash > 0 ? file.slice(0, slash) : "."; } function cacheTrustPath(file: string): string { 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", From f5b360db9a6c5405e8244aa826f8f0fe6691c38e Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 12:00:45 -0400 Subject: [PATCH 076/112] fix(chat): degrade quarantined OpenCode streams --- .../chat/send/harness-routing-opencode.test.ts | 10 ++++++++++ src/app/api/chat/send/route.ts | 16 ++++++++++++++++ src/lib/opencode-compatibility.ts | 9 +++++++-- 3 files changed, 33 insertions(+), 2 deletions(-) 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 34f439f63..7ed6a8d7f 100644 --- a/src/app/api/chat/send/harness-routing-opencode.test.ts +++ b/src/app/api/chat/send/harness-routing-opencode.test.ts @@ -185,6 +185,16 @@ assert.match( /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/, diff --git a/src/app/api/chat/send/route.ts b/src/app/api/chat/send/route.ts index a8956a4f2..16ef5d217 100644 --- a/src/app/api/chat/send/route.ts +++ b/src/app/api/chat/send/route.ts @@ -1662,6 +1662,7 @@ export async function POST(req: Request) { let adapterConflict: BuiltinAdapterConflict | null = null; let openCodeCompatibilityHealthNoticeSent = false; let openCodeProtocolQuarantineNoticeSent = false; + let openCodeStructuredProtocolQuarantined = false; let openCodeModelRejected = false; const quarantineOpenCodeProtocol = ( label: string, @@ -1670,6 +1671,7 @@ export async function POST(req: Request) { // 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; @@ -1915,6 +1917,19 @@ export async function POST(req: Request) { // JSON would incorrectly quarantine an otherwise compatible stream. const normalized = resolveBackspaces(stripAnsi(line)).trim(); if (/^permission requested\b[\s\S]*\bauto-rejecting\b/i.test(normalized)) return; + 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; @@ -1993,6 +2008,7 @@ export async function POST(req: Request) { // 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; diff --git a/src/lib/opencode-compatibility.ts b/src/lib/opencode-compatibility.ts index 64575b53a..3329ef6ed 100644 --- a/src/lib/opencode-compatibility.ts +++ b/src/lib/opencode-compatibility.ts @@ -172,6 +172,11 @@ export function redactedOpenCodeEventFingerprint(value: unknown): string { } 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 CACHE_TTL_MS = 6 * 60 * 60 * 1000; const CACHE_FILE = "opencode-schema-bundle-v1.json"; const REFRESH_TIMEOUT_MS = 5_000; @@ -653,9 +658,9 @@ async function readBoundedCacheFile(file: string): Promise { let handle: Awaited> | null = null; try { handle = await requireRegistryFs().open(file, "r"); - const bytes = Buffer.allocUnsafe(MAX_SCHEMA_BUNDLE_BYTES + 1); + const bytes = Buffer.allocUnsafe(MAX_SCHEMA_CACHE_RECORD_BYTES + 1); const { bytesRead } = await handle.read(bytes, 0, bytes.length, 0); - if (bytesRead > MAX_SCHEMA_BUNDLE_BYTES) return null; + if (bytesRead > MAX_SCHEMA_CACHE_RECORD_BYTES) return null; return bytes.toString("utf8", 0, bytesRead); } catch { return null; From f8888f9c6a36120d099999ab0b2005e101a430bd Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 12:14:05 -0400 Subject: [PATCH 077/112] fix(chat): make OpenCode trust anchors append-only --- src/lib/opencode-compatibility.test.ts | 20 +++++ src/lib/opencode-compatibility.ts | 114 ++++++++++++++++++++++--- 2 files changed, 123 insertions(+), 11 deletions(-) diff --git a/src/lib/opencode-compatibility.test.ts b/src/lib/opencode-compatibility.test.ts index d17864930..f69490133 100644 --- a/src/lib/opencode-compatibility.test.ts +++ b/src/lib/opencode-compatibility.test.ts @@ -140,6 +140,26 @@ assert.equal(recoveredFromAnchor.bundle.sequence, 4, "the independent trust anch 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 unsignedSignedRollback = { ...unsigned, sequence: 1 }; const signedRollback = { ...unsignedSignedRollback, diff --git a/src/lib/opencode-compatibility.ts b/src/lib/opencode-compatibility.ts index 3329ef6ed..3aef82a78 100644 --- a/src/lib/opencode-compatibility.ts +++ b/src/lib/opencode-compatibility.ts @@ -1,8 +1,7 @@ import { createHash, createPublicKey, randomBytes, verify } from "node:crypto"; import { homedir } from "node:os"; -import { writeJsonAtomic } from "./server/atomic-write.ts"; -type RegistryFs = Pick; +type RegistryFs = Pick; // The registry cache is per-user runtime state. Resolve its Node built-in at // runtime so Next's file tracer cannot mistake its dynamic paths for packaged @@ -177,6 +176,7 @@ const MAX_SCHEMA_BUNDLE_BYTES = 256 * 1024; // 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; const CACHE_TTL_MS = 6 * 60 * 60 * 1000; const CACHE_FILE = "opencode-schema-bundle-v1.json"; const REFRESH_TIMEOUT_MS = 5_000; @@ -648,6 +648,58 @@ 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); +} + +/** + * 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() as RegistryFs & Pick; + const temporary = `${file}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`; + try { + await fs.writeFile(temporary, JSON.stringify(value, null, 2)); + for (let attempt = 0; ; attempt += 1) { + try { + await fs.rename(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(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 @@ -776,12 +828,13 @@ async function readCachedTrustState( checkpoint?: OpenCodeRegistryCheckpoint, trustAnchorFile = cacheTrustAnchorPath(file), ): Promise { - const [primary, backup, anchor] = await Promise.all([ + 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].filter((cached): cached is CachedBundle => cached !== null); + 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)); @@ -796,6 +849,41 @@ async function readCachedTrustState( return highest[0]; } +async function readTrustedAnchorJournal( + anchorFile: string, + publicKey: string | OpenCodeRegistryKeyring, + now: number, +): Promise { + let entries: string[]; + try { + entries = await requireRegistryFs().readdir(cacheTrustAnchorJournalPath(anchorFile)); + } catch { + return []; + } + // 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 { + let entries: string[]; + try { + entries = await requireRegistryFs().readdir(cacheTrustAnchorJournalPath(anchorFile)); + } catch { + return; + } + 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(`${cacheTrustAnchorJournalPath(anchorFile)}/${entry}`, { force: true }).catch(() => undefined))); +} + async function writeVerifiedCache( file: string, trustAnchorFile: string, @@ -805,24 +893,28 @@ async function writeVerifiedCache( assertLockOwner?: () => Promise, ): Promise { await assertLockOwner?.(); - const priorAnchor = await readTrustedCacheRecord(trustAnchorFile, publicKey, now); + 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: later damage to - // both cache records cannot erase the highest accepted registry sequence. - await assertLockOwner?.(); - await writeJsonAtomic(trustAnchorFile, cached); + // 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(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 writeJsonAtomic(cacheTrustPath(file), cached); + await writeRegistryJsonAtomic(cacheTrustPath(file), cached); await assertLockOwner?.(); - await writeJsonAtomic(file, cached); + await writeRegistryJsonAtomic(file, cached); } async function readVerifiedCache( From a6e7b0e8b8bd984099966d54072232ec219c52f9 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 12:32:01 -0400 Subject: [PATCH 078/112] fix(chat): bound OpenCode runtime identity probes --- .../chat/send/chat-send-capabilities.test.ts | 29 ++++--- .../api/chat/send/chat-send-capabilities.ts | 82 +++++++++---------- 2 files changed, 59 insertions(+), 52 deletions(-) diff --git a/src/app/api/chat/send/chat-send-capabilities.test.ts b/src/app/api/chat/send/chat-send-capabilities.test.ts index d4c2a8a7f..fa0c54df3 100644 --- a/src/app/api/chat/send/chat-send-capabilities.test.ts +++ b/src/app/api/chat/send/chat-send-capabilities.test.ts @@ -1,12 +1,10 @@ // @ts-nocheck import assert from "node:assert/strict"; -import { mkdtemp, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; import { openCodeCapabilityIdentity, openCodeCapabilityProbeCacheable, openCodeCapabilityProbeTimeoutMs, + openCodeExecutableIdentityLookupTimeoutMs, openCodeProbeCleanupGraceMs, openCodeProbeTreeKillCommand, openCodeExecutableIdentity, @@ -23,13 +21,24 @@ assert.notEqual( openCodeCapabilityIdentity("--format [json-v2]", "1.0.0"), "a changed executable help contract invalidates cached capability evidence even when its version remains unchanged", ); -const launcher = await mkdtemp(path.join(tmpdir(), "cave-opencode-identity-")); -const launcherPath = path.join(launcher, "opencode"); -await writeFile(launcherPath, "first"); -const firstIdentity = await openCodeExecutableIdentity({ PATH: launcher }, "linux"); -await writeFile(launcherPath, "a replacement with different metadata"); -const secondIdentity = await openCodeExecutableIdentity({ PATH: launcher }, "linux"); -assert.notEqual(firstIdentity, secondIdentity, "an in-place executable replacement invalidates the cached capability probe before help is reused"); +const firstIdentity = await openCodeExecutableIdentity( + { PATH: "/first-runtime" }, + "linux", + async () => ({ complete: true, output: "opencode 1.0.0" }), +); +const secondIdentity = await openCodeExecutableIdentity( + { PATH: "/replacement-runtime" }, + "linux", + async () => ({ complete: true, output: "opencode 1.1.0" }), +); +assert.notEqual(firstIdentity, secondIdentity, "a changed executable identity invalidates cached capability evidence before help is reused"); +const unresolvedIdentity = await openCodeExecutableIdentity( + { PATH: "/unreachable" }, + "linux", + async () => ({ complete: false, output: "" }), +); +assert.match(unresolvedIdentity, /^unresolved:/, "a failed runtime identity probe is never cached"); +assert.equal(openCodeExecutableIdentityLookupTimeoutMs(), 750, "runtime identity probes have a short bounded deadline"); assert.deepEqual( openCodeProbeTreeKillCommand(4242, "win32"), { command: "taskkill.exe", args: ["/PID", "4242", "/T", "/F"] }, diff --git a/src/app/api/chat/send/chat-send-capabilities.ts b/src/app/api/chat/send/chat-send-capabilities.ts index 8652e11b8..97b80dc8f 100644 --- a/src/app/api/chat/send/chat-send-capabilities.ts +++ b/src/app/api/chat/send/chat-send-capabilities.ts @@ -10,18 +10,6 @@ import { harnessSpawnEnv } from "@/lib/harness-spawn-env"; import { openCodeLaunch, openCodeSpawnEnv, writeOpenCodeLaunchInput } from "@/lib/opencode-bin"; import type { OpenCodeRunCapabilities } from "@/lib/opencode-compatibility"; -type ExecutableFs = Pick; - -// PATH entries are machine-local runtime state, not standalone assets. Resolve -// this Node builtin lazily so Turbopack cannot trace an installed CLI tree. -const executableFs = (process as typeof process & { - getBuiltinModule?: (id: "node:fs/promises") => ExecutableFs | undefined; -}).getBuiltinModule?.("node:fs/promises"); - -function requireExecutableFs(): ExecutableFs | null { - return executableFs ?? null; -} - let modelFlagProbe: Promise | null = null; let permissionFlagProbe: Promise | null = null; let addDirFlagProbe: Promise | null = null; @@ -32,6 +20,7 @@ const OPENCODE_CAPABILITY_PROBE_TTL_MS = 60_000; const DEFAULT_CAPABILITY_PROBE_TIMEOUT_MS = 2_500; const WINDOWS_CAPABILITY_PROBE_TIMEOUT_MS = 6_000; const OPENCODE_PROBE_CLEANUP_GRACE_MS = 1_000; +const OPENCODE_IDENTITY_PROBE_TIMEOUT_MS = 750; /** PowerShell/npm shims can be delayed by cold start or Defender scanning. */ export function openCodeCapabilityProbeTimeoutMs(platform: NodeJS.Platform = process.platform): number { @@ -49,6 +38,10 @@ export function openCodeCapabilityProbeCacheable(platform: NodeJS.Platform = pro return platform !== "win32"; } +export function openCodeExecutableIdentityLookupTimeoutMs(): number { + return OPENCODE_IDENTITY_PROBE_TIMEOUT_MS; +} + /** `taskkill /T` is required because killing the PowerShell launcher alone * leaves its opencode(.cmd) child running on Windows. */ export function openCodeProbeTreeKillCommand( @@ -132,7 +125,13 @@ function probeHelp( type ProbeOutput = { output: string; complete: boolean }; -function probeOutput(command: string, args: string[], env = harnessSpawnEnv(), input?: string): Promise { +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; @@ -188,7 +187,7 @@ function probeOutput(command: string, args: string[], env = harnessSpawnEnv(), i done(false); }); }); - }, openCodeCapabilityProbeTimeoutMs()); + }, timeoutMs); } catch { done(false); } @@ -327,6 +326,19 @@ export function openCodeCapabilityIdentity(help: string, version: string): strin type OpenCodeRunContractProbe = { helpProbe: ProbeOutput; versionProbe: ProbeOutput }; +type OpenCodeVersionProbe = (env: NodeJS.ProcessEnv) => Promise; + +function probeOpenCodeVersion(env: NodeJS.ProcessEnv): Promise { + const launch = openCodeLaunch(["--version"]); + return probeOutput( + launch.command, + launch.args, + env, + launch.input, + OPENCODE_IDENTITY_PROBE_TIMEOUT_MS, + ); +} + async function probeOpenCodeRunContract(env: NodeJS.ProcessEnv): Promise { const helpLaunch = openCodeLaunch(["run", "--help"]); const versionLaunch = openCodeLaunch(["--version"]); @@ -338,39 +350,25 @@ async function probeOpenCodeRunContract(env: NodeJS.ProcessEnv): Promise { - const fs = requireExecutableFs(); - if (!fs) return `unresolved:${Date.now()}`; - const pathValue = env.PATH ?? env.Path ?? ""; - const names = platform === "win32" - ? (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD") - .split(";") - .map((extension) => extension.trim()) - .filter((extension) => /^\.[A-Za-z0-9]+$/.test(extension)) - .map((extension) => `opencode${extension.toLowerCase()}`) - : ["opencode"]; - const pathSeparator = platform === "win32" ? ";" : ":"; - const directorySeparator = platform === "win32" ? "\\" : "/"; - for (const directory of pathValue.split(pathSeparator).filter(Boolean)) { - for (const name of names) { - const candidate = `${directory.replace(/[\\/]+$/, "")}${directorySeparator}${name}`; - try { - const info = await fs.stat(candidate); - if (info.isFile()) return `${candidate}\0${info.size}\0${info.mtimeMs}`; - } catch { - // Keep looking: the launched binary may be later in PATH. - } - } + void _platform; + const result = await versionProbe(env); + if (result.complete && result.output.trim()) { + return openCodeCapabilityIdentity( + `${env.PATH ?? env.Path ?? ""}\0${env.PATHEXT ?? ""}`, + result.output.trim(), + ); } - // An unresolved command is never retained across turns: installation or a - // PATH update must immediately become visible. + // An unresolved version is never retained across turns: installation or a + // PATH update becomes visible immediately without keeping failed evidence. return `unresolved:${Date.now()}`; } From 0efaa0cffe00113a710fd61a007fce9e432eb7cb Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 12:56:09 -0400 Subject: [PATCH 079/112] test(chat): load OpenCode conversation persistence test aliases --- scripts/run-tests.mjs | 1 + src/lib/cave-conversations.test.ts | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/scripts/run-tests.mjs b/scripts/run-tests.mjs index 656af7199..1fd064382 100644 --- a/scripts/run-tests.mjs +++ b/scripts/run-tests.mjs @@ -1289,6 +1289,7 @@ const STRIP_TYPES_MJS = new Set([ 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", diff --git a/src/lib/cave-conversations.test.ts b/src/lib/cave-conversations.test.ts index b33181a6a..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, @@ -510,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"); From 477e50a4285bc5b83d84d835f424d261989d22fe Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 13:13:59 -0400 Subject: [PATCH 080/112] fix(opencode): isolate registry cache runtime I/O --- src/lib/opencode-compatibility.ts | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/src/lib/opencode-compatibility.ts b/src/lib/opencode-compatibility.ts index 3aef82a78..866f1b85f 100644 --- a/src/lib/opencode-compatibility.ts +++ b/src/lib/opencode-compatibility.ts @@ -1,14 +1,21 @@ import { createHash, createPublicKey, randomBytes, verify } from "node:crypto"; import { homedir } from "node:os"; -type RegistryFs = Pick; - -// The registry cache is per-user runtime state. Resolve its Node built-in at -// runtime so Next's file tracer cannot mistake its dynamic paths for packaged -// application inputs and copy the repository into the desktop sidecar. -const registryFs = (process as typeof process & { - getBuiltinModule?: (id: "node:fs/promises") => RegistryFs | undefined; -}).getBuiltinModule?.("node:fs/promises"); +type RegistryFs = Record Promise>; + +// The registry cache is per-user runtime state, never a packaged input. Keep +// Node's filesystem module behind a runtime-only boundary: TurboPack/NFT +// otherwise follows its dynamic cache paths and traces the entire repository. +const registryFs = (() => { + try { + const loadBuiltin = Function("return process.getBuiltinModule")() as + | ((id: string) => RegistryFs | undefined) + | undefined; + return loadBuiltin?.(["node:fs", "promises"].join("/")); + } catch { + return undefined; + } +})(); function requireRegistryFs(): RegistryFs { if (!registryFs) throw new Error("node fs promises unavailable"); From 739e338f32ddacbc7f2e6922ae16615a3113bbfc Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 13:28:12 -0400 Subject: [PATCH 081/112] fix(opencode): retain lifecycle session tokens --- src/lib/opencode-stream.test.ts | 6 +++--- src/lib/opencode-stream.ts | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/lib/opencode-stream.test.ts b/src/lib/opencode-stream.test.ts index 78283a67b..88ae2bf9f 100644 --- a/src/lib/opencode-stream.test.ts +++ b/src/lib/opencode-stream.test.ts @@ -53,7 +53,7 @@ assert.deepEqual( { type: "step_start", sessionID: "ses_123", part: { id: "step_1" } }, BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0], ), - { kind: "ignore" }, + { kind: "ignore", sessionId: "ses_123" }, "current OpenCode step-start frames are lifecycle metadata, not compatibility failures", ); assert.deepEqual( @@ -61,7 +61,7 @@ assert.deepEqual( { type: "step_finish", sessionID: "ses_123", part: { id: "step_1" } }, BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0], ), - { kind: "ignore" }, + { kind: "ignore", sessionId: "ses_123" }, "current OpenCode step-finish frames are lifecycle metadata, not compatibility failures", ); assert.deepEqual( @@ -79,7 +79,7 @@ assert.deepEqual( BUILTIN_OPENCODE_SCHEMA_BUNDLE.schemas[0], { onSession: (id) => sessions.push(id) }, ); - assert.deepEqual(sessions, [], "ignored lifecycle frames cannot overwrite the persisted native resume token"); + assert.deepEqual(sessions, ["ses_injected"], "a signed lifecycle frame preserves the native session for an interrupted-run resume"); } assert.deepEqual( parseOpenCodeRunEvent( diff --git a/src/lib/opencode-stream.ts b/src/lib/opencode-stream.ts index 17ba3d8b8..bd6cd65af 100644 --- a/src/lib/opencode-stream.ts +++ b/src/lib/opencode-stream.ts @@ -148,10 +148,10 @@ export function parseOpenCodeRunEvent(value: unknown, schema?: OpenCodeEventSche 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. Their root - // fields are not a signed payload envelope, so they must never update a - // persisted native resume token. - return { kind: "ignore" }; + // 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. + return typeof record(part)?.id === "string" ? { kind: "ignore", sessionId } : { kind: "ignore" }; } if (eventTypes(schema, "error", ["error"]).includes(eventType)) { const errorValue = valueAt(part, shapeAliases(schema, "error", ["error"])) ?? event.error; From 92689b78c016039d2e6fa52bead090a29643036b Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 13:55:08 -0400 Subject: [PATCH 082/112] fix(chat): harden OpenCode prompt and capability contract --- .../api/chat/send/chat-send-capabilities.ts | 21 +++++++++++++------ .../send/harness-routing-opencode.test.ts | 6 +++--- src/app/api/chat/send/route.ts | 10 ++++----- 3 files changed, 23 insertions(+), 14 deletions(-) diff --git a/src/app/api/chat/send/chat-send-capabilities.ts b/src/app/api/chat/send/chat-send-capabilities.ts index 97b80dc8f..99e24b2fe 100644 --- a/src/app/api/chat/send/chat-send-capabilities.ts +++ b/src/app/api/chat/send/chat-send-capabilities.ts @@ -483,10 +483,6 @@ export async function openCodeRunCapabilities(familiarId?: string): Promise { const { helpProbe, versionProbe } = await probeOpenCodeRunContract(env); const version = versionProbe.complete @@ -495,10 +491,23 @@ export async function openCodeRunCapabilities(familiarId?: string): Promise Date: Sat, 25 Jul 2026 14:04:59 -0400 Subject: [PATCH 083/112] fix(opencode): honor delimiter and capability cache --- src/app/api/chat/send/chat-send-capabilities.ts | 6 +++++- .../api/chat/send/harness-routing-opencode.test.ts | 2 +- src/lib/opencode-compatibility.ts | 12 ++++++++---- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/app/api/chat/send/chat-send-capabilities.ts b/src/app/api/chat/send/chat-send-capabilities.ts index 99e24b2fe..bbfa20fbf 100644 --- a/src/app/api/chat/send/chat-send-capabilities.ts +++ b/src/app/api/chat/send/chat-send-capabilities.ts @@ -15,7 +15,7 @@ let permissionFlagProbe: Promise | null = null; let addDirFlagProbe: Promise | null = null; let hermesModelFlagProbe: Promise | null = null; let openCodeModelFlagProbe: Promise | null = null; -let openCodeCapabilitiesProbe: { until: number; identity: string; value: Promise } | null = null; +let openCodeCapabilitiesProbe: { until: number; executableIdentity: string; identity: string; value: Promise } | null = null; const OPENCODE_CAPABILITY_PROBE_TTL_MS = 60_000; const DEFAULT_CAPABILITY_PROBE_TIMEOUT_MS = 2_500; const WINDOWS_CAPABILITY_PROBE_TIMEOUT_MS = 6_000; @@ -483,6 +483,9 @@ export async function openCodeRunCapabilities(familiarId?: string): Promise { const { helpProbe, versionProbe } = await probeOpenCodeRunContract(env); const version = versionProbe.complete @@ -503,6 +506,7 @@ export async function openCodeRunCapabilities(familiarId?: string): Promise Promise>; // otherwise follows its dynamic cache paths and traces the entire repository. const registryFs = (() => { try { - const loadBuiltin = Function("return process.getBuiltinModule")() as + // Avoid `Function` here: Turbopack treats dynamic code evaluation as an + // unconstrained module boundary and traces the whole project into the + // standalone sidecar. The computed member and module names keep this + // runtime-only without presenting the filesystem builtin to its tracer. + const loadBuiltin = (process as typeof process & Record)["getBuiltin" + "Module"] as | ((id: string) => RegistryFs | undefined) | undefined; return loadBuiltin?.(["node:fs", "promises"].join("/")); @@ -266,7 +270,7 @@ export const BUILTIN_OPENCODE_SCHEMA_BUNDLE: OpenCodeSchemaBundle = { 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: [], endOfOptions: true }, + launch: { structuredOutput: { option: "--format", value: "json" }, sessionOption: "--session", requiredFlags: [] }, }, { // Earlier and preview clients used generic tool envelopes. Their @@ -298,7 +302,7 @@ export const BUILTIN_OPENCODE_SCHEMA_BUNDLE: OpenCodeSchemaBundle = { 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: [], endOfOptions: true }, + launch: { structuredOutput: { option: "--format", value: "json-legacy" }, requiredFlags: [] }, }, ], }; @@ -687,7 +691,7 @@ function newestAnchorJournalEntries(entries: string[]): string[] { * writer statically makes Next trace dynamic cache paths as app assets. */ async function writeRegistryJsonAtomic(file: string, value: unknown): Promise { - const fs = requireRegistryFs() as RegistryFs & Pick; + const fs = requireRegistryFs(); const temporary = `${file}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`; try { await fs.writeFile(temporary, JSON.stringify(value, null, 2)); From 8c4f392550c349fcf6350f13456bc8da329847b7 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 14:07:23 -0400 Subject: [PATCH 084/112] fix(chat): guard nullable OpenCode compatibility --- src/app/api/chat/send/route.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/app/api/chat/send/route.ts b/src/app/api/chat/send/route.ts index 50d47f398..536f71dc1 100644 --- a/src/app/api/chat/send/route.ts +++ b/src/app/api/chat/send/route.ts @@ -1223,13 +1223,12 @@ 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 signed selected profile is authoritative for its launch contract. The - // current OpenCode profile explicitly consumes yargs's conventional `--` - // delimiter even though its help omits a standalone delimiter row. + // 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.schema?.launch.endOfOptions === true - || (openCodeCompatibility.mode === "plain" && openCodeCompatibility.capabilities.endOfOptions)), + && openCodeCompatibility?.capabilities.endOfOptions + && (openCodeCompatibility.mode === "plain" || openCodeCompatibility.schema?.launch.endOfOptions === true), ); const openCodePromptNeedsDelimiter = openCodeDirect && harnessPrompt.startsWith("--"); if (openCodePromptNeedsDelimiter && !openCodeEndOfOptionsSupported) { From ea5eb39102fe75f6c19fe5b655da74210274c67b Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 14:02:12 -0400 Subject: [PATCH 085/112] fix(opencode): retain lifecycle session aliases --- src/lib/opencode-stream.test.ts | 8 ++++++++ src/lib/opencode-stream.ts | 4 +++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/lib/opencode-stream.test.ts b/src/lib/opencode-stream.test.ts index 88ae2bf9f..d8ffd5ed8 100644 --- a/src/lib/opencode-stream.test.ts +++ b/src/lib/opencode-stream.test.ts @@ -56,6 +56,14 @@ assert.deepEqual( { 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" } }, diff --git a/src/lib/opencode-stream.ts b/src/lib/opencode-stream.ts index bd6cd65af..1dd168c68 100644 --- a/src/lib/opencode-stream.ts +++ b/src/lib/opencode-stream.ts @@ -151,7 +151,9 @@ export function parseOpenCodeRunEvent(value: unknown, schema?: OpenCodeEventSche // 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. - return typeof record(part)?.id === "string" ? { kind: "ignore", sessionId } : { kind: "ignore" }; + const lifecyclePayload = record(part); + const carriesText = valueAt(lifecyclePayload, shapeAliases(schema, "text", ["text", "content"])) !== undefined; + 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; From 8129741211ef1d76b612e10e4ddb993e954af663 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 14:12:45 -0400 Subject: [PATCH 086/112] fix(opencode): keep registry cache out of tracing --- src/lib/opencode-compatibility.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/lib/opencode-compatibility.ts b/src/lib/opencode-compatibility.ts index 7a1d9d987..879f839e1 100644 --- a/src/lib/opencode-compatibility.ts +++ b/src/lib/opencode-compatibility.ts @@ -1,4 +1,5 @@ import { createHash, createPublicKey, randomBytes, verify } from "node:crypto"; +import { createRequire } from "node:module"; import { homedir } from "node:os"; type RegistryFs = Record Promise>; @@ -8,14 +9,11 @@ type RegistryFs = Record Promise>; // otherwise follows its dynamic cache paths and traces the entire repository. const registryFs = (() => { try { - // Avoid `Function` here: Turbopack treats dynamic code evaluation as an - // unconstrained module boundary and traces the whole project into the - // standalone sidecar. The computed member and module names keep this - // runtime-only without presenting the filesystem builtin to its tracer. - const loadBuiltin = (process as typeof process & Record)["getBuiltin" + "Module"] as - | ((id: string) => RegistryFs | undefined) - | undefined; - return loadBuiltin?.(["node:fs", "promises"].join("/")); + // Keep the runtime-only builtin opaque to Turbopack's static evaluator; + // otherwise it sees filesystem calls with runtime paths and copies the + // repository into the desktop sidecar. + const fsPromisesBuiltin = Buffer.from("bm9kZTpmcy9wcm9taXNlcw==", "base64").toString("utf8"); + return createRequire(import.meta.url)(fsPromisesBuiltin) as RegistryFs; } catch { return undefined; } From 89439754241f75280b19922e3bb33ffa229ac092 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 14:15:04 -0400 Subject: [PATCH 087/112] fix(chat): guard OpenCode delimiter profile --- src/app/api/chat/send/harness-routing-opencode.test.ts | 2 +- src/app/api/chat/send/route.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 3f9f1a512..97e81225f 100644 --- a/src/app/api/chat/send/harness-routing-opencode.test.ts +++ b/src/app/api/chat/send/harness-routing-opencode.test.ts @@ -27,7 +27,7 @@ assert.match( ); assert.match( route, - /const openCodeEndOfOptionsSupported = Boolean\([\s\S]*?capabilities\.endOfOptions[\s\S]*?mode === "plain"[\s\S]*?schema\?\.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\("--"\);/, + /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( diff --git a/src/app/api/chat/send/route.ts b/src/app/api/chat/send/route.ts index 536f71dc1..229235531 100644 --- a/src/app/api/chat/send/route.ts +++ b/src/app/api/chat/send/route.ts @@ -1228,7 +1228,7 @@ export async function POST(req: Request) { const openCodeEndOfOptionsSupported = Boolean( openCodeDirect && openCodeCompatibility?.capabilities.endOfOptions - && (openCodeCompatibility.mode === "plain" || openCodeCompatibility.schema?.launch.endOfOptions === true), + && (openCodeCompatibility?.mode === "plain" || openCodeCompatibility?.schema?.launch.endOfOptions === true), ); const openCodePromptNeedsDelimiter = openCodeDirect && harnessPrompt.startsWith("--"); if (openCodePromptNeedsDelimiter && !openCodeEndOfOptionsSupported) { From 49a56f0bfb0e57df6086274b9e21b9cd4bc639f5 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 14:28:55 -0400 Subject: [PATCH 088/112] fix(opencode): bound plain stdout buffering --- .../api/chat/send/harness-routing-opencode.test.ts | 5 +++++ src/app/api/chat/send/route.ts | 12 ++++++++++++ 2 files changed, 17 insertions(+) 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 97e81225f..80080dab7 100644 --- a/src/app/api/chat/send/harness-routing-opencode.test.ts +++ b/src/app/api/chat/send/harness-routing-opencode.test.ts @@ -200,6 +200,11 @@ assert.match( /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( capabilities, /export async function openCodeRunCapabilities\(familiarId\?[^)]*\)[\s\S]*?const env = openCodeSpawnEnv\(familiarId\);[\s\S]*?await openCodeExecutableIdentity\(env\)[\s\S]*?probeOpenCodeRunContract\(env\)[\s\S]*?json:[\s\S]*?model:[\s\S]*?session:[\s\S]*?contractIdentity[\s\S]*?openCodeCapabilityIdentity\(helpProbe\.output, versionProbe\.output\)/, diff --git a/src/app/api/chat/send/route.ts b/src/app/api/chat/send/route.ts index 229235531..b961626bf 100644 --- a/src/app/api/chat/send/route.ts +++ b/src/app/api/chat/send/route.ts @@ -2344,6 +2344,18 @@ export async function POST(req: Request) { } 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" From e101c867d361b40c8baa099b26a446d3f0ba0043 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 14:39:20 -0400 Subject: [PATCH 089/112] fix(opencode): filter plain permission notices --- src/app/api/chat/send/harness-routing-opencode.test.ts | 5 +++++ src/app/api/chat/send/route.ts | 9 ++++++--- 2 files changed, 11 insertions(+), 3 deletions(-) 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 80080dab7..0811700b2 100644 --- a/src/app/api/chat/send/harness-routing-opencode.test.ts +++ b/src/app/api/chat/send/harness-routing-opencode.test.ts @@ -205,6 +205,11 @@ assert.match( /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 normalized = resolveBackspaces\(stripAnsi\(line\)\)\.trim\(\);[\s\S]*?permission requested[\s\S]*?if \(openCodeCompatibility\?\.mode === "plain"\)/, + "plain OpenCode fallback filters permission control notices before rendering assistant text", +); assert.match( capabilities, /export async function openCodeRunCapabilities\(familiarId\?[^)]*\)[\s\S]*?const env = openCodeSpawnEnv\(familiarId\);[\s\S]*?await openCodeExecutableIdentity\(env\)[\s\S]*?probeOpenCodeRunContract\(env\)[\s\S]*?json:[\s\S]*?model:[\s\S]*?session:[\s\S]*?contractIdentity[\s\S]*?openCodeCapabilityIdentity\(helpProbe\.output, versionProbe\.output\)/, diff --git a/src/app/api/chat/send/route.ts b/src/app/api/chat/send/route.ts index b961626bf..d716e14b9 100644 --- a/src/app/api/chat/send/route.ts +++ b/src/app/api/chat/send/route.ts @@ -1904,8 +1904,13 @@ export async function POST(req: Request) { }; const handleOpenCodeLine = (line: string) => { + // Current OpenCode writes this human-oriented permission control + // notice to stdout even in plain fallback mode. It is neither an + // event nor assistant output and must never enter the transcript. + const normalized = resolveBackspaces(stripAnsi(line)).trim(); + if (/^permission requested\b[\s\S]*\bauto-rejecting\b/i.test(normalized)) return; if (openCodeCompatibility?.mode === "plain") { - const text = `${resolveBackspaces(stripAnsi(line))}\n`; + const text = `${normalized}\n`; assistantText += text; push({ kind: "assistant_chunk", text }); return; @@ -1914,8 +1919,6 @@ export async function POST(req: Request) { // 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. - const normalized = resolveBackspaces(stripAnsi(line)).trim(); - if (/^permission requested\b[\s\S]*\bauto-rejecting\b/i.test(normalized)) return; if (openCodeStructuredProtocolQuarantined) { // The schema has already proven incompatible in this stream. Keep // only text that still satisfies its explicit envelope/kind contract; From 9bdb779f87e844f915689e3b944fc99d60763bec Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 14:56:08 -0400 Subject: [PATCH 090/112] fix(opencode): avoid traced Coven launcher path --- src/lib/opencode-bin.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/lib/opencode-bin.ts b/src/lib/opencode-bin.ts index 64ceb63c6..f4dd9705d 100644 --- a/src/lib/opencode-bin.ts +++ b/src/lib/opencode-bin.ts @@ -1,5 +1,4 @@ import type { ChildProcess } from "node:child_process"; -import { caveToolSpawnEnv } from "./coven-bin.ts"; import { harnessSpawnEnv } from "./harness-spawn-env.ts"; /** OpenCode is installed as `opencode` on all supported desktop platforms. */ @@ -79,12 +78,9 @@ export function openCodeNeedsTmpRuntimeDir( */ export function openCodeSpawnEnv(familiarId?: string | null): NodeJS.ProcessEnv { const env = harnessSpawnEnv(familiarId); - // OpenCode is a direct user-selected runtime, not the Coven launcher. Keep - // the familiar's secret scope from harnessSpawnEnv, but prefer the launch - // PATH so a freshly selected/updated npm shim is not shadowed by an older - // global candidate directory (especially on Windows). - const toolPath = caveToolSpawnEnv().PATH; - if (toolPath) env.PATH = toolPath; + // OpenCode is a direct user-selected runtime. Keep the familiar's scoped + // PATH rather than importing Coven's dynamic tool discovery, which would + // make standalone packaging trace machine-local filesystem state. if (openCodeNeedsTmpRuntimeDir(process.platform, env)) { env.XDG_RUNTIME_DIR = "/tmp"; } From 5ca546a2066c58357fce72c2faf19d6f73666781 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 15:00:53 -0400 Subject: [PATCH 091/112] fix(opencode): prevent registry cache tracing --- src/lib/opencode-compatibility.ts | 59 ++++++++++++------------------- 1 file changed, 23 insertions(+), 36 deletions(-) diff --git a/src/lib/opencode-compatibility.ts b/src/lib/opencode-compatibility.ts index 879f839e1..893286146 100644 --- a/src/lib/opencode-compatibility.ts +++ b/src/lib/opencode-compatibility.ts @@ -1,26 +1,13 @@ import { createHash, createPublicKey, randomBytes, verify } from "node:crypto"; -import { createRequire } from "node:module"; +import * as registryFs from "node:fs/promises"; import { homedir } from "node:os"; -type RegistryFs = Record Promise>; - -// The registry cache is per-user runtime state, never a packaged input. Keep -// Node's filesystem module behind a runtime-only boundary: TurboPack/NFT -// otherwise follows its dynamic cache paths and traces the entire repository. -const registryFs = (() => { - try { - // Keep the runtime-only builtin opaque to Turbopack's static evaluator; - // otherwise it sees filesystem calls with runtime paths and copies the - // repository into the desktop sidecar. - const fsPromisesBuiltin = Buffer.from("bm9kZTpmcy9wcm9taXNlcw==", "base64").toString("utf8"); - return createRequire(import.meta.url)(fsPromisesBuiltin) as RegistryFs; - } catch { - return undefined; - } -})(); +type RegistryFs = typeof registryFs; +// Every cache pathname below is user-controlled runtime state. Calls that +// consume one carry a `turbopackIgnore` annotation so static output tracing +// never mistakes the cache directory for a deployable application subtree. function requireRegistryFs(): RegistryFs { - if (!registryFs) throw new Error("node fs promises unavailable"); return registryFs; } @@ -692,10 +679,10 @@ async function writeRegistryJsonAtomic(file: string, value: unknown): Promise undefined); + await fs.rm(/* turbopackIgnore: true */ temporary, { force: true }).catch(() => undefined); throw error; } } @@ -718,7 +705,7 @@ async function writeRegistryJsonAtomic(file: string, value: unknown): Promise { let handle: Awaited> | null = null; try { - handle = await requireRegistryFs().open(file, "r"); + 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; @@ -818,7 +805,7 @@ async function fetchSchemaBundle(url: string, fetcher: typeof fetch, timeoutMs = */ async function staleLockCanBeReclaimed(lock: string): Promise { try { - const info = await requireRegistryFs().stat(lock); + 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, @@ -865,7 +852,7 @@ async function readTrustedAnchorJournal( ): Promise { let entries: string[]; try { - entries = await requireRegistryFs().readdir(cacheTrustAnchorJournalPath(anchorFile)); + entries = await requireRegistryFs().readdir(/* turbopackIgnore: true */ cacheTrustAnchorJournalPath(anchorFile)); } catch { return []; } @@ -883,14 +870,14 @@ async function readTrustedAnchorJournal( async function pruneTrustAnchorJournal(anchorFile: string): Promise { let entries: string[]; try { - entries = await requireRegistryFs().readdir(cacheTrustAnchorJournalPath(anchorFile)); + entries = await requireRegistryFs().readdir(/* turbopackIgnore: true */ cacheTrustAnchorJournalPath(anchorFile)); } catch { return; } 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(`${cacheTrustAnchorJournalPath(anchorFile)}/${entry}`, { force: true }).catch(() => undefined))); + .map((entry) => requireRegistryFs().rm(/* turbopackIgnore: true */ `${cacheTrustAnchorJournalPath(anchorFile)}/${entry}`, { force: true }).catch(() => undefined))); } async function writeVerifiedCache( @@ -914,7 +901,7 @@ async function writeVerifiedCache( // 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(cacheTrustAnchorJournalPath(trustAnchorFile), { recursive: true }); + 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 @@ -949,8 +936,8 @@ async function releaseCacheLock(lock: string, ownerToken: string): Promise // 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(lock, "utf8")) !== ownerToken) return; - await requireRegistryFs().rm(lock, { force: true }); + 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. } @@ -962,12 +949,12 @@ async function withCacheWriteLock(file: string, callback: (assertOwner: () => const ownerToken = `${process.pid}:${randomBytes(16).toString("hex")}`; for (let attempt = 0; attempt < 2; attempt += 1) { try { - const acquired = await requireRegistryFs().open(lock, "wx", 0o600); + 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(lock, { force: true }).catch(() => undefined); + await requireRegistryFs().rm(/* turbopackIgnore: true */ lock, { force: true }).catch(() => undefined); throw error; } handle = acquired; @@ -978,8 +965,8 @@ async function withCacheWriteLock(file: string, callback: (assertOwner: () => // 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(lock, stale); - await requireRegistryFs().rm(stale, { force: true }); + await requireRegistryFs().rename(/* turbopackIgnore: true */ lock, stale); + await requireRegistryFs().rm(/* turbopackIgnore: true */ stale, { force: true }); } catch { return null; } @@ -988,7 +975,7 @@ async function withCacheWriteLock(file: string, callback: (assertOwner: () => if (!handle) return null; const assertOwner = async () => { try { - if ((await requireRegistryFs().readFile(lock, "utf8")) !== ownerToken) { + if ((await requireRegistryFs().readFile(/* turbopackIgnore: true */ lock, "utf8")) !== ownerToken) { throw new CacheWriterPendingError("schema cache lock ownership changed"); } } catch (error) { @@ -1020,7 +1007,7 @@ async function waitForConcurrentCacheWriter( latest = await readVerifiedCache(file, publicKeys, now, checkpoint, trustAnchorFile); if (latest && latest.bundle.sequence >= minimumSequence) return latest; try { - await requireRegistryFs().stat(lock); + 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. @@ -1132,7 +1119,7 @@ async function refreshOpenCodeSchemaBundle( 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(localPathParent(file), { recursive: true }); + 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. From 0b4640a62fb22d4c62e434b554ede4b9a5e4c81a Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 15:17:59 -0400 Subject: [PATCH 092/112] fix(opencode): prefer user launch path --- src/lib/opencode-bin.test.ts | 14 +++++++++++++- src/lib/opencode-bin.ts | 25 ++++++++++++++++++++++--- 2 files changed, 35 insertions(+), 4 deletions(-) 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 f4dd9705d..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,9 +95,11 @@ export function openCodeNeedsTmpRuntimeDir( */ export function openCodeSpawnEnv(familiarId?: string | null): NodeJS.ProcessEnv { const env = harnessSpawnEnv(familiarId); - // OpenCode is a direct user-selected runtime. Keep the familiar's scoped - // PATH rather than importing Coven's dynamic tool discovery, which would - // make standalone packaging trace machine-local filesystem state. + // 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"; } From dc4e1d10b0464cc440d2bb56512db885dd3bb6f6 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 15:26:39 -0400 Subject: [PATCH 093/112] fix(opencode): avoid tracing registry cache --- src/lib/opencode-compatibility.ts | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/src/lib/opencode-compatibility.ts b/src/lib/opencode-compatibility.ts index 893286146..5972c86f9 100644 --- a/src/lib/opencode-compatibility.ts +++ b/src/lib/opencode-compatibility.ts @@ -1,13 +1,26 @@ import { createHash, createPublicKey, randomBytes, verify } from "node:crypto"; -import * as registryFs from "node:fs/promises"; +import { createRequire } from "node:module"; import { homedir } from "node:os"; -type RegistryFs = typeof registryFs; +type RegistryFs = Record Promise>; + +// The registry cache is per-user runtime state, never a packaged input. Keep +// Node's filesystem module behind a runtime-only boundary: TurboPack/NFT +// otherwise follows its dynamic cache paths and traces the entire repository. +const registryFs = (() => { + try { + // Keep the runtime-only builtin opaque to Turbopack's static evaluator; + // otherwise it sees filesystem calls with runtime paths and copies the + // repository into the desktop sidecar. + const fsPromisesBuiltin = Buffer.from("bm9kZTpmcy9wcm9taXNlcw==", "base64").toString("utf8"); + return createRequire(import.meta.url)(fsPromisesBuiltin) as RegistryFs; + } catch { + return undefined; + } +})(); -// Every cache pathname below is user-controlled runtime state. Calls that -// consume one carry a `turbopackIgnore` annotation so static output tracing -// never mistakes the cache directory for a deployable application subtree. function requireRegistryFs(): RegistryFs { + if (!registryFs) throw new Error("node fs promises unavailable"); return registryFs; } From 4bbefbc4fcaaa35abc3a1c31db03df3c6db49415 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 16:01:09 -0400 Subject: [PATCH 094/112] fix(chat): terminate timed-out OpenCode probe trees --- .../api/chat/send/chat-send-capabilities.ts | 28 +++++++++++++++---- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/src/app/api/chat/send/chat-send-capabilities.ts b/src/app/api/chat/send/chat-send-capabilities.ts index bbfa20fbf..6cb159e3a 100644 --- a/src/app/api/chat/send/chat-send-capabilities.ts +++ b/src/app/api/chat/send/chat-send-capabilities.ts @@ -56,12 +56,27 @@ export function openCodeProbeTreeKillCommand( function terminateProbeProcessTree(child: ChildProcessWithoutNullStreams): Promise { const treeKill = openCodeProbeTreeKillCommand(child.pid); if (!treeKill) { - try { - child.kill("SIGTERM"); - } catch { - // The close/error handler below still marks this probe unsupported. - } - return Promise.resolve(); + 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) => { try { @@ -97,6 +112,7 @@ function probeHelp( const child = spawn(command, args, { env, stdio: input === undefined ? ["ignore", "pipe", "pipe"] : ["pipe", "pipe", "pipe"], + detached: process.platform !== "win32", }) as ChildProcessWithoutNullStreams; if (input !== undefined) writeOpenCodeLaunchInput(child, { command, args, input }); child.stdout.on("data", (chunk) => (output += chunk.toString())); From 73b073c7be88c3eaa860849261d1484cf1e24ef8 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 16:02:05 -0400 Subject: [PATCH 095/112] fix(opencode): use trace-safe registry cache I/O --- src/lib/opencode-compatibility.ts | 20 ++------------------ 1 file changed, 2 insertions(+), 18 deletions(-) diff --git a/src/lib/opencode-compatibility.ts b/src/lib/opencode-compatibility.ts index 5972c86f9..78c7233ef 100644 --- a/src/lib/opencode-compatibility.ts +++ b/src/lib/opencode-compatibility.ts @@ -1,26 +1,10 @@ import { createHash, createPublicKey, randomBytes, verify } from "node:crypto"; -import { createRequire } from "node:module"; +import * as registryFs from "node:fs/promises"; import { homedir } from "node:os"; -type RegistryFs = Record Promise>; - -// The registry cache is per-user runtime state, never a packaged input. Keep -// Node's filesystem module behind a runtime-only boundary: TurboPack/NFT -// otherwise follows its dynamic cache paths and traces the entire repository. -const registryFs = (() => { - try { - // Keep the runtime-only builtin opaque to Turbopack's static evaluator; - // otherwise it sees filesystem calls with runtime paths and copies the - // repository into the desktop sidecar. - const fsPromisesBuiltin = Buffer.from("bm9kZTpmcy9wcm9taXNlcw==", "base64").toString("utf8"); - return createRequire(import.meta.url)(fsPromisesBuiltin) as RegistryFs; - } catch { - return undefined; - } -})(); +type RegistryFs = typeof registryFs; function requireRegistryFs(): RegistryFs { - if (!registryFs) throw new Error("node fs promises unavailable"); return registryFs; } From 9cc0667f98ba341d5cbc6fed93ce29068323fa91 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 16:04:35 -0400 Subject: [PATCH 096/112] fix(sidecar): exclude compiled source from tracing --- next.config.ts | 4 ++++ 1 file changed, 4 insertions(+) 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/**/*", ], From 52a7ace547435b6b5323386f426abfe3eddab8bf Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 16:48:09 -0400 Subject: [PATCH 097/112] fix(chat): preserve plain OpenCode output Keep indentation and blank lines intact when compatibility falls back to plain OpenCode output, while continuing to filter the known permission control notice. Add route-level coverage for an unsupported future JSON format. Co-authored-by: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> --- .../send/harness-routing-opencode.test.ts | 2 +- .../send/route-opencode.integration.test.ts | 30 +++++++++++++++++-- src/app/api/chat/send/route.ts | 10 ++++--- 3 files changed, 34 insertions(+), 8 deletions(-) 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 0811700b2..9ea146675 100644 --- a/src/app/api/chat/send/harness-routing-opencode.test.ts +++ b/src/app/api/chat/send/harness-routing-opencode.test.ts @@ -207,7 +207,7 @@ assert.match( ); assert.match( route, - /const handleOpenCodeLine = \(line: string\) => \{[\s\S]*?const normalized = resolveBackspaces\(stripAnsi\(line\)\)\.trim\(\);[\s\S]*?permission requested[\s\S]*?if \(openCodeCompatibility\?\.mode === "plain"\)/, + /const handleOpenCodeLine = \(line: string\) => \{[\s\S]*?const plainText = resolveBackspaces\(stripAnsi\(line\)\);[\s\S]*?permission requested[\s\S]*?plainText\.trim\(\)[\s\S]*?if \(openCodeCompatibility\?\.mode === "plain"\)/, "plain OpenCode fallback filters permission control notices before rendering assistant text", ); assert.match( diff --git a/src/app/api/chat/send/route-opencode.integration.test.ts b/src/app/api/chat/send/route-opencode.integration.test.ts index 75fcb0721..7594312f8 100644 --- a/src/app/api/chat/send/route-opencode.integration.test.ts +++ b/src/app/api/chat/send/route-opencode.integration.test.ts @@ -19,6 +19,7 @@ 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 ?? ""}`; @@ -28,13 +29,16 @@ 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 const value = 1;& echo.& echo return value;& exit /b 0)", "if not \"%~2\"==\"--format\" exit /b 9", "if not \"%~3\"==\"json\" exit /b 9", "if \"%~4\"==\"--\" exit /b 9", @@ -44,12 +48,14 @@ const launcher = process.platform === "win32" ].join("\r\n") : [ "#!/bin/sh", - "if [ \"$1\" = \"--version\" ]; then echo 1.2.3; exit 0; fi", + "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", - " printf '%s\\n' ' --format Output format: text, json' ' --session Session to continue'", + " 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\" ] || [ \"$2\" != \"--format\" ] || [ \"$3\" != \"json\" ] || [ \"$4\" = \"--\" ]; then exit 9; fi", + "if [ \"$1\" != \"run\" ]; then exit 9; fi", + "if [ \"$OPENCODE_TEST_MODE\" = \"plain\" ]; then printf ' const value = 1;\\n\\n return value;\\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", @@ -95,6 +101,22 @@ try { 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":" const value = 1;\\n"[\s\S]*?"kind":"assistant_chunk","text":"\\n"[\s\S]*?"kind":"assistant_chunk","text":" return value;\\n"/, + "plain OpenCode fallback preserves leading whitespace and blank assistant lines", + ); } finally { if (previousHome === undefined) delete process.env.COVEN_HOME; else process.env.COVEN_HOME = previousHome; @@ -102,6 +124,8 @@ try { 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 }); } diff --git a/src/app/api/chat/send/route.ts b/src/app/api/chat/send/route.ts index d716e14b9..2cb4c02de 100644 --- a/src/app/api/chat/send/route.ts +++ b/src/app/api/chat/send/route.ts @@ -1907,10 +1907,10 @@ export async function POST(req: Request) { // Current OpenCode writes this human-oriented permission control // notice to stdout even in plain fallback mode. It is neither an // event nor assistant output and must never enter the transcript. - const normalized = resolveBackspaces(stripAnsi(line)).trim(); - if (/^permission requested\b[\s\S]*\bauto-rejecting\b/i.test(normalized)) return; + const plainText = resolveBackspaces(stripAnsi(line)); + if (/^permission requested\b[\s\S]*\bauto-rejecting\b/i.test(plainText.trim())) return; if (openCodeCompatibility?.mode === "plain") { - const text = `${normalized}\n`; + const text = `${plainText}\n`; assistantText += text; push({ kind: "assistant_chunk", text }); return; @@ -2036,7 +2036,9 @@ 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; + // 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" From a60b56f82ee00981828bf16f9f1342bac6b6edc9 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 17:01:28 -0400 Subject: [PATCH 098/112] fix(opencode): terminate timed-out probe trees --- src/app/api/chat/send/chat-send-capabilities.test.ts | 3 +++ src/app/api/chat/send/chat-send-capabilities.ts | 11 ++++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/app/api/chat/send/chat-send-capabilities.test.ts b/src/app/api/chat/send/chat-send-capabilities.test.ts index fa0c54df3..c85872604 100644 --- a/src/app/api/chat/send/chat-send-capabilities.test.ts +++ b/src/app/api/chat/send/chat-send-capabilities.test.ts @@ -6,6 +6,7 @@ import { openCodeCapabilityProbeTimeoutMs, openCodeExecutableIdentityLookupTimeoutMs, openCodeProbeCleanupGraceMs, + openCodeProbeSpawnOptions, openCodeProbeTreeKillCommand, openCodeExecutableIdentity, parseOpenCodeRunCapabilitiesHelp, @@ -16,6 +17,8 @@ assert.equal(openCodeCapabilityProbeTimeoutMs("win32"), 6_000, "Windows PowerShe assert.equal(openCodeProbeCleanupGraceMs(), 1_000, "timed-out probe cleanup has a short final deadline so chat can fall back"); assert.equal(openCodeCapabilityProbeCacheable("linux"), true, "direct Unix executable identity supports a short capability cache"); assert.equal(openCodeCapabilityProbeCacheable("win32"), false, "Windows launcher shims are reprobed rather than reusing stale downstream capability evidence"); +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"); assert.notEqual( openCodeCapabilityIdentity("--format [json]", "1.0.0"), openCodeCapabilityIdentity("--format [json-v2]", "1.0.0"), diff --git a/src/app/api/chat/send/chat-send-capabilities.ts b/src/app/api/chat/send/chat-send-capabilities.ts index 6cb159e3a..c96fd7e91 100644 --- a/src/app/api/chat/send/chat-send-capabilities.ts +++ b/src/app/api/chat/send/chat-send-capabilities.ts @@ -38,6 +38,14 @@ export function openCodeCapabilityProbeCacheable(platform: NodeJS.Platform = pro return platform !== "win32"; } +/** 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" }; +} + export function openCodeExecutableIdentityLookupTimeoutMs(): number { return OPENCODE_IDENTITY_PROBE_TIMEOUT_MS; } @@ -112,7 +120,7 @@ function probeHelp( const child = spawn(command, args, { env, stdio: input === undefined ? ["ignore", "pipe", "pipe"] : ["pipe", "pipe", "pipe"], - detached: process.platform !== "win32", + ...openCodeProbeSpawnOptions(), }) as ChildProcessWithoutNullStreams; if (input !== undefined) writeOpenCodeLaunchInput(child, { command, args, input }); child.stdout.on("data", (chunk) => (output += chunk.toString())); @@ -161,6 +169,7 @@ function probeOutput( 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; From 9ec3a274d0e2c8620f40afb0a2a187310a63cc91 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 17:18:05 -0400 Subject: [PATCH 099/112] fix(chat): cap hook-linked OpenCode tool input --- src/lib/chat-tool-events.test.ts | 9 +++++++++ src/lib/chat-tool-events.ts | 5 ++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/lib/chat-tool-events.test.ts b/src/lib/chat-tool-events.test.ts index ae212e327..25aca1afb 100644 --- a/src/lib/chat-tool-events.test.ts +++ b/src/lib/chat-tool-events.test.ts @@ -56,6 +56,15 @@ assert.ok( "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}`; diff --git a/src/lib/chat-tool-events.ts b/src/lib/chat-tool-events.ts index bc732faa9..bc42d2c9d 100644 --- a/src/lib/chat-tool-events.ts +++ b/src/lib/chat-tool-events.ts @@ -314,7 +314,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; } From a1071b9e4986bf96cf90a46827561890da050420 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 17:50:38 -0400 Subject: [PATCH 100/112] fix(chat): preserve plain OpenCode output --- .../chat/send/route-opencode.integration.test.ts | 8 ++++---- src/app/api/chat/send/route.ts | 13 +++++++++---- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/app/api/chat/send/route-opencode.integration.test.ts b/src/app/api/chat/send/route-opencode.integration.test.ts index 7594312f8..44cd4c06c 100644 --- a/src/app/api/chat/send/route-opencode.integration.test.ts +++ b/src/app/api/chat/send/route-opencode.integration.test.ts @@ -38,7 +38,7 @@ const launcher = process.platform === "win32" " exit /b 0", ")", "if not \"%~1\"==\"run\" exit /b 9", - "if \"%OPENCODE_TEST_MODE%\"==\"plain\" (echo const value = 1;& echo.& echo return value;& exit /b 0)", + "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;& exit /b 0)", "if not \"%~2\"==\"--format\" exit /b 9", "if not \"%~3\"==\"json\" exit /b 9", "if \"%~4\"==\"--\" exit /b 9", @@ -54,7 +54,7 @@ const launcher = process.platform === "win32" " exit 0", "fi", "if [ \"$1\" != \"run\" ]; then exit 9; fi", - "if [ \"$OPENCODE_TEST_MODE\" = \"plain\" ]; then printf ' const value = 1;\\n\\n return value;\\n'; exit 0; 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;\\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 '", @@ -114,8 +114,8 @@ try { const plainBody = await plainResponse.text(); assert.match( plainBody, - /"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 leading whitespace and blank assistant lines", + /"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", ); } finally { if (previousHome === undefined) delete process.env.COVEN_HOME; diff --git a/src/app/api/chat/send/route.ts b/src/app/api/chat/send/route.ts index 2cb4c02de..fcffd32c7 100644 --- a/src/app/api/chat/send/route.ts +++ b/src/app/api/chat/send/route.ts @@ -1904,17 +1904,22 @@ export async function POST(req: Request) { }; const handleOpenCodeLine = (line: string) => { - // Current OpenCode writes this human-oriented permission control - // notice to stdout even in plain fallback mode. It is neither an - // event nor assistant output and must never enter the transcript. + // 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 (/^permission requested\b[\s\S]*\bauto-rejecting\b/i.test(plainText.trim())) return; 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 From c546c9862e10dd38d27b655d30ccdcc1cabf18ff Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 17:59:21 -0400 Subject: [PATCH 101/112] test(chat): cover plain OpenCode fallback --- src/app/api/chat/send/harness-routing-opencode.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 9ea146675..9d9dddfe1 100644 --- a/src/app/api/chat/send/harness-routing-opencode.test.ts +++ b/src/app/api/chat/send/harness-routing-opencode.test.ts @@ -207,8 +207,8 @@ assert.match( ); assert.match( route, - /const handleOpenCodeLine = \(line: string\) => \{[\s\S]*?const plainText = resolveBackspaces\(stripAnsi\(line\)\);[\s\S]*?permission requested[\s\S]*?plainText\.trim\(\)[\s\S]*?if \(openCodeCompatibility\?\.mode === "plain"\)/, - "plain OpenCode fallback filters permission control notices before rendering assistant text", + /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.match( capabilities, From 4310e6e487f269c8f42f593cac09b0dfbf902bec Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 18:07:02 -0400 Subject: [PATCH 102/112] fix(opencode): preserve text on lifecycle schema drift --- src/lib/opencode-stream.test.ts | 8 ++++++++ src/lib/opencode-stream.ts | 11 ++++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/lib/opencode-stream.test.ts b/src/lib/opencode-stream.test.ts index d8ffd5ed8..3808c2313 100644 --- a/src/lib/opencode-stream.test.ts +++ b/src/lib/opencode-stream.test.ts @@ -80,6 +80,14 @@ assert.deepEqual( { 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", +); { const sessions: string[] = []; handleOpenCodeJsonLine( diff --git a/src/lib/opencode-stream.ts b/src/lib/opencode-stream.ts index 1dd168c68..9100d99a0 100644 --- a/src/lib/opencode-stream.ts +++ b/src/lib/opencode-stream.ts @@ -150,9 +150,18 @@ export function parseOpenCodeRunEvent(value: unknown, schema?: OpenCodeEventSche 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. + // 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" }; + } return sessionId && !carriesText ? { kind: "ignore", sessionId } : { kind: "ignore" }; } if (eventTypes(schema, "error", ["error"]).includes(eventType)) { From 939fb4187d05af53ed3f79ca02d2f6b7f118de3b Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 18:29:33 -0400 Subject: [PATCH 103/112] fix(opencode): bound schema trust journal scans Reject overfull local trust-anchor journals before enumeration can consume unbounded memory, preserving a fail-closed compatibility fallback. Co-authored-by: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> --- src/lib/opencode-compatibility.test.ts | 21 +++++++++++- src/lib/opencode-compatibility.ts | 47 +++++++++++++++++++------- 2 files changed, 55 insertions(+), 13 deletions(-) diff --git a/src/lib/opencode-compatibility.test.ts b/src/lib/opencode-compatibility.test.ts index f69490133..64efdc03c 100644 --- a/src/lib/opencode-compatibility.test.ts +++ b/src/lib/opencode-compatibility.test.ts @@ -1,7 +1,7 @@ // @ts-nocheck import assert from "node:assert/strict"; import { generateKeyPairSync, sign } from "node:crypto"; -import { mkdtemp, readFile, rm, utimes, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, utimes, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { @@ -160,6 +160,25 @@ assert.equal( "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, diff --git a/src/lib/opencode-compatibility.ts b/src/lib/opencode-compatibility.ts index 78c7233ef..70f9891ce 100644 --- a/src/lib/opencode-compatibility.ts +++ b/src/lib/opencode-compatibility.ts @@ -170,6 +170,10 @@ const MAX_SCHEMA_BUNDLE_BYTES = 256 * 1024; // 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; @@ -667,6 +671,35 @@ function newestAnchorJournalEntries(entries: string[]): string[] { .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 @@ -847,12 +880,7 @@ async function readTrustedAnchorJournal( publicKey: string | OpenCodeRegistryKeyring, now: number, ): Promise { - let entries: string[]; - try { - entries = await requireRegistryFs().readdir(/* turbopackIgnore: true */ cacheTrustAnchorJournalPath(anchorFile)); - } catch { - return []; - } + 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. @@ -865,12 +893,7 @@ async function readTrustedAnchorJournal( } async function pruneTrustAnchorJournal(anchorFile: string): Promise { - let entries: string[]; - try { - entries = await requireRegistryFs().readdir(/* turbopackIgnore: true */ cacheTrustAnchorJournalPath(anchorFile)); - } catch { - return; - } + 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)) From 6f9ac7383ef9429bac8d0171e379583960fbb1be Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 19:34:53 -0400 Subject: [PATCH 104/112] fix(opencode): scope capability probe cache Avoid reusing help-derived JSON and session capability evidence across familiars, whose scoped launch environments can select different OpenCode contracts even when the launcher path and version match. Co-authored-by: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> --- .../api/chat/send/chat-send-capabilities.test.ts | 7 +++++++ src/app/api/chat/send/chat-send-capabilities.ts | 14 +++++++++++--- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/app/api/chat/send/chat-send-capabilities.test.ts b/src/app/api/chat/send/chat-send-capabilities.test.ts index c85872604..6af95af91 100644 --- a/src/app/api/chat/send/chat-send-capabilities.test.ts +++ b/src/app/api/chat/send/chat-send-capabilities.test.ts @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import { openCodeCapabilityIdentity, openCodeCapabilityProbeCacheable, + openCodeCapabilityProbeScope, openCodeCapabilityProbeTimeoutMs, openCodeExecutableIdentityLookupTimeoutMs, openCodeProbeCleanupGraceMs, @@ -17,6 +18,12 @@ assert.equal(openCodeCapabilityProbeTimeoutMs("win32"), 6_000, "Windows PowerShe assert.equal(openCodeProbeCleanupGraceMs(), 1_000, "timed-out probe cleanup has a short final deadline so chat can fall back"); assert.equal(openCodeCapabilityProbeCacheable("linux"), true, "direct Unix executable identity supports a short capability cache"); 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"); assert.notEqual( diff --git a/src/app/api/chat/send/chat-send-capabilities.ts b/src/app/api/chat/send/chat-send-capabilities.ts index c96fd7e91..831dda99f 100644 --- a/src/app/api/chat/send/chat-send-capabilities.ts +++ b/src/app/api/chat/send/chat-send-capabilities.ts @@ -15,7 +15,7 @@ let permissionFlagProbe: Promise | null = null; let addDirFlagProbe: Promise | null = null; let hermesModelFlagProbe: Promise | null = null; let openCodeModelFlagProbe: Promise | null = null; -let openCodeCapabilitiesProbe: { until: number; executableIdentity: string; identity: string; value: Promise } | null = null; +let openCodeCapabilitiesProbe: { until: number; executableIdentity: string; scope: string; value: Promise } | null = null; const OPENCODE_CAPABILITY_PROBE_TTL_MS = 60_000; const DEFAULT_CAPABILITY_PROBE_TIMEOUT_MS = 2_500; const WINDOWS_CAPABILITY_PROBE_TIMEOUT_MS = 6_000; @@ -38,6 +38,13 @@ export function openCodeCapabilityProbeCacheable(platform: NodeJS.Platform = pro return platform !== "win32"; } +/** 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( @@ -508,7 +515,8 @@ export async function openCodeRunCapabilities(familiarId?: string): Promise { @@ -532,7 +540,7 @@ export async function openCodeRunCapabilities(familiarId?: string): Promise Date: Sat, 25 Jul 2026 19:46:32 -0400 Subject: [PATCH 105/112] fix(opencode): bound Windows probe cleanup --- .../api/chat/send/chat-send-capabilities.ts | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/src/app/api/chat/send/chat-send-capabilities.ts b/src/app/api/chat/send/chat-send-capabilities.ts index 831dda99f..e5430fc80 100644 --- a/src/app/api/chat/send/chat-send-capabilities.ts +++ b/src/app/api/chat/send/chat-send-capabilities.ts @@ -94,16 +94,32 @@ function terminateProbeProcessTree(child: ChildProcessWithoutNullStreams): Promi }); } 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 { - const killer = spawn(treeKill.command, treeKill.args, { stdio: "ignore", windowsHide: true }); + killer = spawn(treeKill.command, treeKill.args, { stdio: "ignore", windowsHide: true }); killer.once("error", () => { try { child.kill("SIGTERM"); } catch { /* Best-effort fallback. */ } - resolve(); + finish(); }); - killer.once("close", () => resolve()); + killer.once("close", finish); } catch { try { child.kill("SIGTERM"); } catch { /* Best-effort fallback. */ } - resolve(); + finish(); } }); } From 4ecabd121db2b8ac8e1e4d7d0f142cbe46a664b3 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 20:04:31 -0400 Subject: [PATCH 106/112] fix(opencode): re-probe capability contract --- .../chat/send/chat-send-capabilities.test.ts | 41 ++----- .../api/chat/send/chat-send-capabilities.ts | 114 ++++-------------- .../send/harness-routing-opencode.test.ts | 11 +- 3 files changed, 37 insertions(+), 129 deletions(-) diff --git a/src/app/api/chat/send/chat-send-capabilities.test.ts b/src/app/api/chat/send/chat-send-capabilities.test.ts index 6af95af91..f8f94f378 100644 --- a/src/app/api/chat/send/chat-send-capabilities.test.ts +++ b/src/app/api/chat/send/chat-send-capabilities.test.ts @@ -1,22 +1,20 @@ // @ts-nocheck import assert from "node:assert/strict"; import { - openCodeCapabilityIdentity, openCodeCapabilityProbeCacheable, openCodeCapabilityProbeScope, openCodeCapabilityProbeTimeoutMs, - openCodeExecutableIdentityLookupTimeoutMs, openCodeProbeCleanupGraceMs, openCodeProbeSpawnOptions, openCodeProbeTreeKillCommand, - openCodeExecutableIdentity, + 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"), true, "direct Unix executable identity supports a short capability cache"); +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"), @@ -26,29 +24,16 @@ assert.notEqual( 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"); -assert.notEqual( - openCodeCapabilityIdentity("--format [json]", "1.0.0"), - openCodeCapabilityIdentity("--format [json-v2]", "1.0.0"), - "a changed executable help contract invalidates cached capability evidence even when its version remains unchanged", -); -const firstIdentity = await openCodeExecutableIdentity( - { PATH: "/first-runtime" }, - "linux", - async () => ({ complete: true, output: "opencode 1.0.0" }), -); -const secondIdentity = await openCodeExecutableIdentity( - { PATH: "/replacement-runtime" }, - "linux", - async () => ({ complete: true, output: "opencode 1.1.0" }), -); -assert.notEqual(firstIdentity, secondIdentity, "a changed executable identity invalidates cached capability evidence before help is reused"); -const unresolvedIdentity = await openCodeExecutableIdentity( - { PATH: "/unreachable" }, - "linux", - async () => ({ complete: false, output: "" }), -); -assert.match(unresolvedIdentity, /^unresolved:/, "a failed runtime identity probe is never cached"); -assert.equal(openCodeExecutableIdentityLookupTimeoutMs(), 750, "runtime identity probes have a short bounded deadline"); +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"] }, @@ -162,6 +147,6 @@ 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"), true, "other platforms retain the bounded sixty-second capability cache"); +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 e5430fc80..8936d7fbb 100644 --- a/src/app/api/chat/send/chat-send-capabilities.ts +++ b/src/app/api/chat/send/chat-send-capabilities.ts @@ -1,5 +1,4 @@ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; -import { createHash } from "node:crypto"; import { covenLaunchCommand } from "@/lib/coven-bin"; import { covenRunSupportsAddDirFlag, @@ -15,12 +14,9 @@ let permissionFlagProbe: Promise | null = null; let addDirFlagProbe: Promise | null = null; let hermesModelFlagProbe: Promise | null = null; let openCodeModelFlagProbe: Promise | null = null; -let openCodeCapabilitiesProbe: { until: number; executableIdentity: string; scope: string; value: Promise } | null = null; -const OPENCODE_CAPABILITY_PROBE_TTL_MS = 60_000; const DEFAULT_CAPABILITY_PROBE_TIMEOUT_MS = 2_500; const WINDOWS_CAPABILITY_PROBE_TIMEOUT_MS = 6_000; const OPENCODE_PROBE_CLEANUP_GRACE_MS = 1_000; -const OPENCODE_IDENTITY_PROBE_TIMEOUT_MS = 750; /** PowerShell/npm shims can be delayed by cold start or Defender scanning. */ export function openCodeCapabilityProbeTimeoutMs(platform: NodeJS.Platform = process.platform): number { @@ -32,10 +28,12 @@ export function openCodeProbeCleanupGraceMs(): number { return OPENCODE_PROBE_CLEANUP_GRACE_MS; } -/** Windows launchers can be stable shims whose downstream target changes. - * Do not reuse their help-derived argv evidence between turns. */ +/** 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 { - return platform !== "win32"; + void platform; + return false; } /** Capability output can be configured per familiar through its scoped @@ -53,10 +51,6 @@ export function openCodeProbeSpawnOptions( return { detached: platform !== "win32" }; } -export function openCodeExecutableIdentityLookupTimeoutMs(): number { - return OPENCODE_IDENTITY_PROBE_TIMEOUT_MS; -} - /** `taskkill /T` is required because killing the PowerShell launcher alone * leaves its opencode(.cmd) child running on Windows. */ export function openCodeProbeTreeKillCommand( @@ -363,30 +357,8 @@ function advertisedStructuredSwitches(options: string[], noValueOptions: string[ }); } -/** Fingerprint a verified help/version contract for diagnostics and tests. */ -export function openCodeCapabilityIdentity(help: string, version: string): string { - return createHash("sha256") - .update(help) - .update("\0") - .update(version) - .digest("hex"); -} - type OpenCodeRunContractProbe = { helpProbe: ProbeOutput; versionProbe: ProbeOutput }; -type OpenCodeVersionProbe = (env: NodeJS.ProcessEnv) => Promise; - -function probeOpenCodeVersion(env: NodeJS.ProcessEnv): Promise { - const launch = openCodeLaunch(["--version"]); - return probeOutput( - launch.command, - launch.args, - env, - launch.input, - OPENCODE_IDENTITY_PROBE_TIMEOUT_MS, - ); -} - async function probeOpenCodeRunContract(env: NodeJS.ProcessEnv): Promise { const helpLaunch = openCodeLaunch(["run", "--help"]); const versionLaunch = openCodeLaunch(["--version"]); @@ -397,29 +369,6 @@ async function probeOpenCodeRunContract(env: NodeJS.ProcessEnv): Promise { - void _platform; - const result = await versionProbe(env); - if (result.complete && result.output.trim()) { - return openCodeCapabilityIdentity( - `${env.PATH ?? env.Path ?? ""}\0${env.PATHEXT ?? ""}`, - result.output.trim(), - ); - } - // An unresolved version is never retained across turns: installation or a - // PATH update becomes visible immediately without keeping failed evidence. - return `unresolved:${Date.now()}`; -} - function advertisedFormatProtocols( outputs: Array<{ option: string; values: string[] }>, switches: Array<{ option: string; protocols: string[] }>, @@ -525,42 +474,21 @@ export function openCodeRunSupportsModel(): Promise { * 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): Promise { - // The executable identity uses exactly the scoped environment that will - // execute the chat turn, but is intentionally much cheaper than `--help`. +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 cacheable = openCodeCapabilityProbeCacheable(); - const executableIdentity = cacheable ? await openCodeExecutableIdentity(env) : "uncached-windows-launcher"; - const scope = openCodeCapabilityProbeScope(familiarId); - if (cacheable && openCodeCapabilitiesProbe && Date.now() < openCodeCapabilitiesProbe.until && openCodeCapabilitiesProbe.executableIdentity === executableIdentity && openCodeCapabilitiesProbe.scope === scope) { - return openCodeCapabilitiesProbe.value; - } - const value = (async () => { - const { helpProbe, versionProbe } = await probeOpenCodeRunContract(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. Probe again after the short TTL instead of risking an argv - // that the installed client does not accept. - const capabilities = !helpProbe.complete - ? { version, json: false, model: false, session: false, protocols: [], options: [], valueOptions: [], noValueOptions: [], endOfOptions: false, structuredSwitches: [], structuredOutputs: [] } - : parseOpenCodeRunCapabilitiesHelp(helpProbe.output, version); - // The help surface is the actual argv/event contract. Include its - // fingerprint in any retained entry so an in-place shim replacement with - // the same version can never reuse stale capability evidence. - const contractIdentity = helpProbe.complete - ? openCodeCapabilityIdentity(helpProbe.output, versionProbe.output) - : `unresolved:${Date.now()}`; - if (cacheable && !contractIdentity.startsWith("unresolved:")) { - openCodeCapabilitiesProbe = { - until: Date.now() + OPENCODE_CAPABILITY_PROBE_TTL_MS, - executableIdentity, - scope, - value: Promise.resolve(capabilities), - }; - } - return capabilities; - })(); - return value; + 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-opencode.test.ts b/src/app/api/chat/send/harness-routing-opencode.test.ts index 9d9dddfe1..540472eb0 100644 --- a/src/app/api/chat/send/harness-routing-opencode.test.ts +++ b/src/app/api/chat/send/harness-routing-opencode.test.ts @@ -210,15 +210,10 @@ assert.match( /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.match( - capabilities, - /export async function openCodeRunCapabilities\(familiarId\?[^)]*\)[\s\S]*?const env = openCodeSpawnEnv\(familiarId\);[\s\S]*?await openCodeExecutableIdentity\(env\)[\s\S]*?probeOpenCodeRunContract\(env\)[\s\S]*?json:[\s\S]*?model:[\s\S]*?session:[\s\S]*?contractIdentity[\s\S]*?openCodeCapabilityIdentity\(helpProbe\.output, versionProbe\.output\)/, - "OpenCode fingerprints the completed scoped help contract before retaining capability evidence", -); -assert.match( +assert.doesNotMatch( capabilities, - /openCodeExecutableIdentity/, - "capability discovery fingerprints the launched OpenCode contract without walking machine-local PATH entries", + /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"); From 33f3276b3be63b3a275087fcc8965051dc23556e Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 20:16:23 -0400 Subject: [PATCH 107/112] fix(chat): persist plain OpenCode fallback verbatim --- .../api/chat/send/route-opencode.integration.test.ts | 11 +++++++++++ src/app/api/chat/send/route.ts | 9 ++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/app/api/chat/send/route-opencode.integration.test.ts b/src/app/api/chat/send/route-opencode.integration.test.ts index 44cd4c06c..27c008f75 100644 --- a/src/app/api/chat/send/route-opencode.integration.test.ts +++ b/src/app/api/chat/send/route-opencode.integration.test.ts @@ -117,6 +117,17 @@ try { /"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;\n", + "reload preserves plain-fallback indentation and trailing newline instead of trimming the persisted assistant turn", + ); } finally { if (previousHome === undefined) delete process.env.COVEN_HOME; else process.env.COVEN_HOME = previousHome; diff --git a/src/app/api/chat/send/route.ts b/src/app/api/chat/send/route.ts index fcffd32c7..791cc5e77 100644 --- a/src/app/api/chat/send/route.ts +++ b/src/app/api/chat/send/route.ts @@ -2678,8 +2678,15 @@ 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(), { + parseAgentAttachments(assistantTextForPersistence, { allowedRoots: sshRuntime ? [] : [familiarCwd ?? cwd, ...grantedProjectRoots], }); for (const attachment of agentAttachments) { From b3a53b30f51466b6b488b0a55245873d00da958d Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 20:56:31 -0400 Subject: [PATCH 108/112] fix(opencode): reject error-frame session tokens --- src/lib/opencode-stream.test.ts | 11 +++++++++++ src/lib/opencode-stream.ts | 9 ++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/lib/opencode-stream.test.ts b/src/lib/opencode-stream.test.ts index 3808c2313..7197f92f0 100644 --- a/src/lib/opencode-stream.test.ts +++ b/src/lib/opencode-stream.test.ts @@ -151,6 +151,17 @@ 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" } } } }, diff --git a/src/lib/opencode-stream.ts b/src/lib/opencode-stream.ts index 9100d99a0..d03858bbb 100644 --- a/src/lib/opencode-stream.ts +++ b/src/lib/opencode-stream.ts @@ -273,7 +273,14 @@ export function handleOpenCodeJsonLine( // 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. - const trustedSession = event.kind !== "other" && !(event.kind === "text" && event.diagnostic === "unknown-event"); + // 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; From e1d8cbf9936bfe4c824a647854059d979c90499f Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 20:59:15 -0400 Subject: [PATCH 109/112] fix(chat): revalidate OpenCode launch contracts --- .../send/harness-routing-opencode.test.ts | 9 +++-- src/app/api/chat/send/route.ts | 35 +++++++++++-------- 2 files changed, 28 insertions(+), 16 deletions(-) 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 540472eb0..bdd0762ca 100644 --- a/src/app/api/chat/send/harness-routing-opencode.test.ts +++ b/src/app/api/chat/send/harness-routing-opencode.test.ts @@ -112,8 +112,13 @@ assert.match( ); assert.doesNotMatch( route, - /openCodePlainFallback|openCodeStructuredIncompatibility|structured-stream-quarantined/, - "an incompatible structured request is never replayed as a plain retry", + /openCodeStructuredIncompatibility|structured-stream-quarantined/, + "an incompatible structured request is never replayed as an unbounded plain retry", +); +assert.match( + route, + /const openCodePlainFallback = openCodeDirect && openCodeCompatibility\?\.mode === "plain";[\s\S]*?if \(openCodePlainFallback && RESUME_ERR_RE\.test\(line\)\) \{[\s\S]*?resumeFailed = true;[\s\S]*?return;/, + "a documented missing native session on plain OpenCode stdout is discarded before the one fresh-session replay retry", ); assert.match( route, diff --git a/src/app/api/chat/send/route.ts b/src/app/api/chat/send/route.ts index 791cc5e77..9482229c5 100644 --- a/src/app/api/chat/send/route.ts +++ b/src/app/api/chat/send/route.ts @@ -937,6 +937,18 @@ 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; @@ -1004,19 +1016,6 @@ 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" } }, - ); - } if (sshRuntime && binding.harness === "openclaw") { return new Response( JSON.stringify({ @@ -2059,7 +2058,15 @@ export async function POST(req: Request) { : "OpenCode schema refresh was not trusted; using the last known compatible parser"; pushProgress("opencode-compatibility", diagnostic, "error", openCodeCompatibility.diagnostic); } - if (RESUME_ERR_RE.test(line)) resumeFailed = true; + const openCodePlainFallback = openCodeDirect && openCodeCompatibility?.mode === "plain"; + // Plain OpenCode has no structured error envelope. A documented + // missing-session line on stdout is a failed native resume, not + // assistant content: discard it and take the existing replay retry. + if (openCodePlainFallback && RESUME_ERR_RE.test(line)) { + resumeFailed = true; + return; + } + if (!openCodePlainFallback && RESUME_ERR_RE.test(line)) resumeFailed = true; const isJson = !hermesDirect && line.startsWith("{") && line.endsWith("}"); if (copilotStream) { handleCopilotLine(line, isJson); From 57d39d81c99a2ae3046f9bdabf9f7013e369d2c0 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 21:24:47 -0400 Subject: [PATCH 110/112] fix(release): reject credentialed schema registry URLs --- docs/opencode-compatibility-registry.md | 2 +- scripts/check-opencode-registry-release.mjs | 8 ++++++- .../check-opencode-registry-release.test.mjs | 22 ++++++++++++++++++- 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/docs/opencode-compatibility-registry.md b/docs/opencode-compatibility-registry.md index 672ee5c8e..cd96aa441 100644 --- a/docs/opencode-compatibility-registry.md +++ b/docs/opencode-compatibility-registry.md @@ -13,7 +13,7 @@ Every desktop release must provide these GitHub Actions secrets: 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, the key is not Ed25519, or the checkpoint is malformed. +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. diff --git a/scripts/check-opencode-registry-release.mjs b/scripts/check-opencode-registry-release.mjs index f2bbe23e5..863e45375 100644 --- a/scripts/check-opencode-registry-release.mjs +++ b/scripts/check-opencode-registry-release.mjs @@ -26,7 +26,13 @@ if (!url || !keyring || typeof keyring !== "object" || Array.isArray(keyring) || fail("OpenCode compatibility registry URL, Ed25519 public key/keyring, and immutable sequence checkpoint must be configured for every desktop release."); } else { try { - if (new URL(url).protocol !== "https:") throw new Error("registry URL must use HTTPS"); + 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) { diff --git a/scripts/check-opencode-registry-release.test.mjs b/scripts/check-opencode-registry-release.test.mjs index 67136d03d..4d8927313 100644 --- a/scripts/check-opencode-registry-release.test.mjs +++ b/scripts/check-opencode-registry-release.test.mjs @@ -1,4 +1,6 @@ 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"); @@ -8,7 +10,7 @@ const docs = await readFile(new URL("../docs/opencode-compatibility-registry.md" 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, /new URL\(url\)\.protocol !== "https:"/); +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/); @@ -16,4 +18,22 @@ 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"); From a9afef6bb92c29395beda51b0e0a5d010f2f25b8 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 23:36:53 -0400 Subject: [PATCH 111/112] fix(chat): preserve unframed OpenCode fallback text --- .../send/harness-routing-opencode.test.ts | 4 ++-- .../send/route-opencode.integration.test.ts | 19 ++++++++++++++++--- src/app/api/chat/send/route.ts | 19 +++++++++---------- 3 files changed, 27 insertions(+), 15 deletions(-) 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 bdd0762ca..a2c2005c1 100644 --- a/src/app/api/chat/send/harness-routing-opencode.test.ts +++ b/src/app/api/chat/send/harness-routing-opencode.test.ts @@ -115,10 +115,10 @@ assert.doesNotMatch( /openCodeStructuredIncompatibility|structured-stream-quarantined/, "an incompatible structured request is never replayed as an unbounded plain retry", ); -assert.match( +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;/, - "a documented missing native session on plain OpenCode stdout is discarded before the one fresh-session replay retry", + "unframed plain OpenCode output never discards assistant text merely because it resembles a resume failure", ); assert.match( route, diff --git a/src/app/api/chat/send/route-opencode.integration.test.ts b/src/app/api/chat/send/route-opencode.integration.test.ts index 27c008f75..13451c402 100644 --- a/src/app/api/chat/send/route-opencode.integration.test.ts +++ b/src/app/api/chat/send/route-opencode.integration.test.ts @@ -38,7 +38,7 @@ const launcher = process.platform === "win32" " 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;& exit /b 0)", + "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", @@ -54,7 +54,7 @@ const launcher = process.platform === "win32" " 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;\\n'; exit 0; 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 '", @@ -125,9 +125,22 @@ try { 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;\n", + "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; diff --git a/src/app/api/chat/send/route.ts b/src/app/api/chat/send/route.ts index 9482229c5..2942da9ab 100644 --- a/src/app/api/chat/send/route.ts +++ b/src/app/api/chat/send/route.ts @@ -2059,13 +2059,10 @@ export async function POST(req: Request) { pushProgress("opencode-compatibility", diagnostic, "error", openCodeCompatibility.diagnostic); } const openCodePlainFallback = openCodeDirect && openCodeCompatibility?.mode === "plain"; - // Plain OpenCode has no structured error envelope. A documented - // missing-session line on stdout is a failed native resume, not - // assistant content: discard it and take the existing replay retry. - if (openCodePlainFallback && RESUME_ERR_RE.test(line)) { - resumeFailed = true; - return; - } + // 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) { @@ -2693,9 +2690,11 @@ export async function POST(req: Request) { ? assistantText : assistantText.trim(); const { text: cleanedAssistantText, attachments: agentAttachments } = - parseAgentAttachments(assistantTextForPersistence, { - 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 }); } From ce58a48a5d7d54edf32b0e316109f198a7ad7f10 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sun, 26 Jul 2026 00:14:40 -0400 Subject: [PATCH 112/112] fix(chat): redact OpenCode launch diagnostics --- src/app/api/chat/send/route.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/app/api/chat/send/route.ts b/src/app/api/chat/send/route.ts index 2942da9ab..254195b03 100644 --- a/src/app/api/chat/send/route.ts +++ b/src/app/api/chat/send/route.ts @@ -2267,7 +2267,10 @@ export async function POST(req: Request) { "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 ? (() => { @@ -2405,11 +2408,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") { @@ -2430,7 +2439,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();