From 52ea6f6a9fe36ebf73917e3586b74a19f3d315f3 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sun, 26 Jul 2026 11:28:35 -0400 Subject: [PATCH 01/61] feat(grok): add fail-closed stream compatibility --- docs/grok-compatibility-registry.md | 11 + scripts/run-tests.mjs | 1 + src/app/api/chat/send/route.ts | 163 ++++++++++-- src/lib/grok-build.test.ts | 26 ++ src/lib/grok-build.ts | 48 ++-- src/lib/grok-compatibility.test.ts | 29 +++ src/lib/grok-compatibility.ts | 369 ++++++++++++++++++++++++++++ 7 files changed, 601 insertions(+), 46 deletions(-) create mode 100644 docs/grok-compatibility-registry.md create mode 100644 src/lib/grok-compatibility.test.ts create mode 100644 src/lib/grok-compatibility.ts diff --git a/docs/grok-compatibility-registry.md b/docs/grok-compatibility-registry.md new file mode 100644 index 000000000..b4f1a376e --- /dev/null +++ b/docs/grok-compatibility-registry.md @@ -0,0 +1,11 @@ +# Grok Build compatibility registry + +Grok Build's built-in profile is limited to the xAI-documented `text`, `thought`, `end`, and `error` `streaming-json` frames. It contains no tool-event aliases. A tool schema is enabled only when the installed launcher advertises `--output-format streaming-json` and a selected Ed25519-signed bundle explicitly names every envelope field and lifecycle event. + +Release configuration is public verification material, never a signing key: + +- `GROK_SCHEMA_REGISTRY_URL` — canonical credential-free HTTPS bundle URL. +- `GROK_SCHEMA_REGISTRY_PUBLIC_KEY`, or `GROK_SCHEMA_REGISTRY_PUBLIC_KEYS` — PEM Ed25519 trust anchor(s), with one to four key IDs for rotation. +- `GROK_SCHEMA_REGISTRY_CHECKPOINT` — JSON `{ "sequence": number, "payloadHash": "" }` that anchors first use and rollback resistance. + +The release maps these to `NEXT_PUBLIC_COVEN_GROK_SCHEMA_REGISTRY_*`. Development may use `COVEN_GROK_SCHEMA_REGISTRY_*`. The cache is per-user, bounded, atomically replaced, and always reverified; an unknown or malformed selected event quarantines that schema in-process and future turns fall back to plain text. Do not publish a Grok tool schema until its precise stdout envelope is source-verified and captured from an approved non-production fixture. Never store a private key in this repository, app configuration, or release secrets. diff --git a/scripts/run-tests.mjs b/scripts/run-tests.mjs index cac2f44e7..f14ba5ccc 100644 --- a/scripts/run-tests.mjs +++ b/scripts/run-tests.mjs @@ -1013,6 +1013,7 @@ export const SUITES = { "src/lib/grok-bin.test.ts", "src/lib/grok-build.test.ts", "src/lib/runtime-availability.test.ts", + "src/lib/grok-compatibility.test.ts", "src/app/api/harnesses/route.test.ts", "src/lib/copilot-stream.test.ts", "src/lib/copilot-bin.test.ts", diff --git a/src/app/api/chat/send/route.ts b/src/app/api/chat/send/route.ts index a358dcbc8..40d96f3b1 100644 --- a/src/app/api/chat/send/route.ts +++ b/src/app/api/chat/send/route.ts @@ -62,6 +62,13 @@ import { parseGrokStreamEvent, } from "@/lib/grok-build"; import { grokLaunchCommand } from "@/lib/grok-bin"; +import { + parseGrokCompatibilityEvent, + probeGrokRunCapabilities, + quarantineGrokSchema, + redactedGrokEventFingerprint, + resolveGrokCompatibility, +} from "@/lib/grok-compatibility"; import { openCodeCommand, openCodeLaunch, openCodeSpawnEnv, writeOpenCodeLaunchInput } from "@/lib/opencode-bin"; import { evaluateRuntimeAvailability, @@ -1160,6 +1167,22 @@ export async function POST(req: Request) { const openCodeCompatibility = openCodeCapabilities ? await resolveOpenCodeCompatibility(openCodeCapabilities) : null; + // Probe the same ready local launcher/environment that the direct run will + // use. A missing or changed CLI remains a truthful preflight failure; it + // never turns undocumented output into tool activity. + const grokCapabilities = grokDirect + ? await probeReadyLocalRuntimeCapability({ + plan: localRuntimePlan, + runner: "grok", + probe: () => probeGrokRunCapabilities( + { command: localRuntimePlan!.command, fixedArgs: localRuntimePlan!.fixedArgs }, + localRuntimePlan!.env, + ), + }) + : null; + const grokCompatibility = grokCapabilities + ? await resolveGrokCompatibility(grokCapabilities) + : null; const hermesModelCapability = hermesDirect && hermesApi === null ? await probeReadyLocalRuntimeCapability({ @@ -1649,6 +1672,7 @@ export async function POST(req: Request) { binding.display_name, binding.role, ), + outputFormat: grokCompatibility?.mode === "structured" ? "streaming-json" : null, }); } if (openCodeDirect) { @@ -1810,7 +1834,7 @@ export async function POST(req: Request) { // 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 persistedCompatibilityDiagnostics: NonNullable = []; const pushProgress = ( id: string, label: string, @@ -1818,8 +1842,8 @@ export async function POST(req: Request) { detail?: string, durationMs?: number, ) => { - if (id === "opencode-compatibility") { - persistedOpenCodeDiagnostics.push({ + if (id === "opencode-compatibility" || id === "grok-compatibility") { + persistedCompatibilityDiagnostics.push({ id, label, status, @@ -1988,6 +2012,24 @@ export async function POST(req: Request) { let openCodeProtocolQuarantineNoticeSent = false; let openCodeStructuredProtocolQuarantined = false; let openCodeModelRejected = false; + let grokCompatibilityHealthNoticeSent = false; + let grokStructuredProtocolQuarantined = false; + let grokProtocolQuarantineNoticeSent = false; + const quarantineGrokProtocol = ( + detail: "malformed-jsonl-event" | "unframed-jsonl-event" | `unknown-event:${string}`, + ) => { + grokStructuredProtocolQuarantined = true; + quarantineGrokSchema(grokCompatibility?.schema); + recordStdoutErrorTail("Grok Build emitted a malformed structured event", true); + if (grokProtocolQuarantineNoticeSent) return; + grokProtocolQuarantineNoticeSent = true; + pushProgress( + "grok-compatibility", + "Grok Build sent an unrecognized event; future turns will use safe plain chat until a compatible schema is available", + "error", + detail, + ); + }; const quarantineOpenCodeProtocol = ( label: string, detail: "malformed-json-event" | "oversized-jsonl-event", @@ -2297,12 +2339,56 @@ export async function POST(req: Request) { }; const handleGrokLine = (line: string, isJson: boolean) => { + if (!grokCompatibilityHealthNoticeSent && grokCompatibility?.diagnostic) { + grokCompatibilityHealthNoticeSent = true; + const label = grokCompatibility.diagnostic === "streaming-json-unavailable" + ? "This Grok Build client does not advertise streaming JSON; continuing without tool activity" + : grokCompatibility.diagnostic === "built-in-schema-expired" + ? "Grok Build's built-in compatibility schema has expired; continuing without tool activity" + : grokCompatibility.diagnostic === "no-compatible-schema" + ? "This Grok Build client has no verified tool-event schema; continuing without tool activity" + : "Grok Build's structured event protocol is unavailable; continuing without tool activity"; + pushProgress("grok-compatibility", label, "error", grokCompatibility.diagnostic); + } + if (grokCompatibility?.mode === "plain") { + let unverifiedStructuredOutput = false; + if (isJson) { + try { + const candidate = JSON.parse(line); + unverifiedStructuredOutput = !!candidate && typeof candidate === "object"; + } catch { + // Plain prose can begin with a brace or bracket. + } + } + if (unverifiedStructuredOutput) { + recordStdoutErrorTail("Grok Build emitted an unverified structured event", true); + if (!grokProtocolQuarantineNoticeSent) { + grokProtocolQuarantineNoticeSent = true; + pushProgress( + "grok-compatibility", + "Grok Build emitted unverified structured output; continuing without tool activity", + "error", + "unverified-structured-output", + ); + } + return; + } + const text = `${resolveBackspaces(stripAnsi(line))}\n`; + if (!grokSessionId && grokSessionHint) grokSessionId = grokSessionHint; + if (!sessionId && grokSessionHint) announceSession(grokSessionHint); + assistantText += text; + push({ kind: "assistant_chunk", text }); + return; + } if (!isJson) { - recordStdoutErrorTail(resolveBackspaces(stripAnsi(line))); + quarantineGrokProtocol("unframed-jsonl-event"); return; } try { - const event = parseGrokStreamEvent(JSON.parse(line)); + const raw = JSON.parse(line); + const event = grokStructuredProtocolQuarantined + ? parseGrokStreamEvent(raw, grokCompatibility?.schema) + : parseGrokCompatibilityEvent(raw, grokCompatibility?.schema); // A fresh native session's id is assigned by Cave because Grok only // returns it in its final frame. Once its first response frame // confirms the process is live, retain that id for a cancelled @@ -2324,7 +2410,7 @@ export async function POST(req: Request) { // launch means its --model contract accepted the selected id. if (!confirmedModel && grokForwardModel) confirmedModel = desiredModel; result = { - is_error: event.isError, + is_error: false, usage: parseStreamJsonUsage(event.usage), costUsd: parseCostUsd(event.totalCostUsd), }; @@ -2335,7 +2421,49 @@ export async function POST(req: Request) { usage: parseStreamJsonUsage(event.usage), costUsd: parseCostUsd(event.totalCostUsd), }; - recordStdoutErrorTail(event.message); + recordStdoutErrorTail("Grok Build returned a structured error", true); + return; + case "tool_start": { + boundarySentinel?.observe(event.name, event.input); + const started = toolTracker.envelopeToolUse(event.id, event.name, formatToolInputValue(event.input), assistantText.length); + if (started) push({ kind: "tool_use", ...started }); + const progress = toolTracker.consumePendingEnvelopeProgress(event.id); + if (progress) push({ kind: "tool_use", ...progress }); + const ended = toolTracker.consumePendingEnvelopeResult(event.id); + if (ended) push({ kind: "tool_use", ...ended }); + return; + } + case "tool_progress": { + const progress = toolTracker.envelopeToolProgress(event.id, typeof event.output === "string" ? event.output : formatToolInputValue(event.output)); + if (progress) push({ kind: "tool_use", ...progress }); + return; + } + case "tool_end": { + const ended = toolTracker.envelopeToolResult(event.id, typeof event.output === "string" ? event.output : formatToolInputValue(event.output), event.isError); + if (ended) push({ kind: "tool_use", ...ended }); + return; + } + case "tool_complete": { + boundarySentinel?.observe(event.name, event.input); + const started = toolTracker.envelopeToolUse(event.id, event.name, formatToolInputValue(event.input), assistantText.length); + if (started) push({ kind: "tool_use", ...started }); + const ended = toolTracker.envelopeToolResult(event.id, typeof event.output === "string" ? event.output : formatToolInputValue(event.output), event.isError); + if (ended) push({ kind: "tool_use", ...ended }); + return; + } + case "unknown": + grokStructuredProtocolQuarantined = true; + quarantineGrokSchema(grokCompatibility?.schema); + recordStdoutErrorTail("Grok Build emitted a malformed structured event", true); + if (!grokProtocolQuarantineNoticeSent) { + grokProtocolQuarantineNoticeSent = true; + pushProgress( + "grok-compatibility", + "Grok Build sent an unrecognized event; future turns will use safe plain chat until a compatible schema is available", + "error", + `unknown-event:${redactedGrokEventFingerprint(raw)}`, + ); + } return; case "ignore": return; @@ -2343,7 +2471,7 @@ export async function POST(req: Request) { } catch { /* not valid JSON after all — fall through to the error tail */ } - recordStdoutErrorTail(resolveBackspaces(stripAnsi(line))); + quarantineGrokProtocol("malformed-jsonl-event"); }; const handleOpenCodeLine = (line: string) => { @@ -2517,7 +2645,7 @@ export async function POST(req: Request) { return; } if (grokDirect) { - handleGrokLine(line, isJson); + handleGrokLine(line, isJson || /^[{\[]/.test(line.trimStart())); return; } if (openCodeDirect) { @@ -3199,7 +3327,7 @@ export async function POST(req: Request) { // OpenCode normally emits a JSON error envelope, but older CLI // builds can exit non-zero with only stderr. Do not mistake that // failed invocation for a successful model application below. - if ((openCodeDirect || copilotStream) && code !== 0) { + if ((openCodeDirect || copilotStream || grokDirect) && code !== 0) { result = { ...result, is_error: true }; } // Copilot JSONL stderr can contain raw prompt or tool payloads on @@ -3212,6 +3340,11 @@ export async function POST(req: Request) { stdoutErrTail.push("Copilot exited before completing its response."); } } + if (grokDirect) { + stderrTail.length = 0; + stdoutErrTail.length = 0; + if (code !== 0) stdoutErrTail.push("Grok Build exited before completing its response."); + } pushProgress( "harness-start", `${binding.harness} exited`, @@ -3406,7 +3539,7 @@ export async function POST(req: Request) { // 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 + const tailBlock = !openCodeDirect && !grokDirect && tailSource.length ? `\n\n\`\`\`\n${tailSource.slice(-5).join("\n")}\n\`\`\`` : ""; const diagnostic = result.is_error @@ -3472,11 +3605,11 @@ export async function POST(req: Request) { // 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" + const assistantTextForPersistence = (openCodeDirect && openCodeCompatibility?.mode === "plain") || (grokDirect && grokCompatibility?.mode === "plain") ? assistantText : assistantText.trim(); const { text: cleanedAssistantText, attachments: agentAttachments } = - openCodeDirect && openCodeCompatibility?.mode === "plain" + (openCodeDirect && openCodeCompatibility?.mode === "plain") || (grokDirect && grokCompatibility?.mode === "plain") ? { text: assistantTextForPersistence, attachments: [] } : parseAgentAttachments(assistantTextForPersistence, { allowedRoots: sshRuntime ? [] : [cwd, ...grantedProjectRoots], @@ -3610,8 +3743,8 @@ export async function POST(req: Request) { ...(result.usage ? { usage: result.usage } : {}), ...(result.costUsd !== undefined ? { costUsd: result.costUsd } : {}), ...(persistedTools ? { tools: persistedTools } : {}), - ...(persistedOpenCodeDiagnostics.length - ? { progress: persistedOpenCodeDiagnostics } + ...(persistedCompatibilityDiagnostics.length + ? { progress: persistedCompatibilityDiagnostics } : {}), parentId: userTurnId, responseMetadata, diff --git a/src/lib/grok-build.test.ts b/src/lib/grok-build.test.ts index ecdcb120f..f7ad9fbdc 100644 --- a/src/lib/grok-build.test.ts +++ b/src/lib/grok-build.test.ts @@ -8,6 +8,11 @@ import { parseGrokModels, parseGrokStreamEvent, } from "./grok-build.ts"; +import { + BUILTIN_GROK_SCHEMA_BUNDLE, + parseGrokCompatibilityEvent, + type GrokEventSchema, +} from "./grok-compatibility.ts"; const catalog = parseGrokModels(`You are logged in with grok.com.\n\nDefault model: grok-4.5\n\nAvailable models:\n * grok-4.5 (default)\n * grok-code-fast-1`); assert.equal(catalog.defaultModel, "grok-4.5"); @@ -29,6 +34,7 @@ assert.deepEqual( permissionMode: "read", grantDirs: ["/work/project", ""], identityRules: "You are Nova.", + outputFormat: "streaming-json", }), [ "--no-auto-update", "--output-format", "streaming-json", @@ -50,6 +56,7 @@ assert.ok( permissionMode: "full", grantDirs: [], identityRules: "", + outputFormat: "streaming-json", }).includes("--sandbox"), "resumed chats preserve Grok's session-bound sandbox instead of failing on a changed composer mode", ); @@ -63,6 +70,7 @@ assert.deepEqual( permissionMode: "full", grantDirs: [], identityRules: "", + outputFormat: "streaming-json", }), [ "--no-auto-update", "--output-format", "streaming-json", @@ -93,6 +101,24 @@ assert.deepEqual( { kind: "error", message: "not authenticated", usage: undefined, totalCostUsd: 0 }, ); assert.deepEqual(parseGrokStreamEvent({ type: "thought", data: "hidden" }), { kind: "ignore" }); +assert.deepEqual( + parseGrokCompatibilityEvent({ type: "tool_started", id: "call-1", name: "read_file", input: { path: "secret" } }, BUILTIN_GROK_SCHEMA_BUNDLE.schemas[0]), + { kind: "unknown" }, + "undocumented tool names never become activity in the built-in schema", +); +const verifiedToolSchema: GrokEventSchema = { + ...BUILTIN_GROK_SCHEMA_BUNDLE.schemas[0], + id: "fixture-verified-tool-schema", + eventTypes: { + ...BUILTIN_GROK_SCHEMA_BUNDLE.schemas[0].eventTypes, + toolStart: ["tool_started"], toolProgress: ["tool_progress"], toolEnd: ["tool_finished"], toolComplete: ["tool_complete"], + }, +}; +assert.deepEqual( + parseGrokCompatibilityEvent({ type: "tool_started", id: "call-1", name: "read_file", input: { path: "README.md" } }, verifiedToolSchema), + { kind: "tool_start", id: "call-1", name: "read_file", input: { path: "README.md" } }, + "a selected verified schema can declare a complete tool lifecycle without hardcoded protocol guesses", +); assert.equal(grokSandboxProfileForPermission("read"), "read"); assert.equal(grokSandboxProfileForPermission(undefined), "full"); diff --git a/src/lib/grok-build.ts b/src/lib/grok-build.ts index da31a4f77..040ec5c4d 100644 --- a/src/lib/grok-build.ts +++ b/src/lib/grok-build.ts @@ -4,6 +4,12 @@ // proposed registry adapter is not merged, while Cave needs the CLI's real // streaming/session contract to drive a local chat safely. +import { + BUILTIN_GROK_SCHEMA_BUNDLE, + parseGrokCompatibilityEvent, + type GrokEventSchema, +} from "./grok-compatibility"; + export type RuntimeModelOption = { id: string; label: string }; export type GrokSandboxProfile = "full" | "read"; @@ -106,8 +112,11 @@ export function buildGrokBuildArgs(input: { permissionMode: "full" | "read"; grantDirs: string[]; identityRules: string; + /** Omit structured output entirely when no local capability/schema proves it safe. */ + outputFormat?: "streaming-json" | null; }): string[] { - const args = ["--no-auto-update", "--output-format", "streaming-json"]; + const args = ["--no-auto-update"]; + if (input.outputFormat === "streaming-json") args.push("--output-format", input.outputFormat); if (input.resumeSessionId) args.push("--resume", input.resumeSessionId); else if (input.newSessionId) args.push("--session-id", input.newSessionId); const model = bareModel(input.model); @@ -134,35 +143,12 @@ export function buildGrokBuildArgs(input: { } /** Map Grok Build's documented streaming-json JSONL frames to Cave events. */ -export function parseGrokStreamEvent(raw: unknown): GrokStreamEvent { - if (!raw || typeof raw !== "object") return { kind: "ignore" }; - const event = raw as { - type?: unknown; - data?: unknown; - sessionId?: unknown; - message?: unknown; - usage?: unknown; - total_cost_usd?: unknown; - }; - if (event.type === "text" && typeof event.data === "string") { - return { kind: "text", text: event.data }; - } - if (event.type === "end") { - return { - kind: "end", - sessionId: typeof event.sessionId === "string" ? event.sessionId : undefined, - isError: false, - usage: event.usage, - totalCostUsd: event.total_cost_usd, - }; - } - if (event.type === "error") { - return { - kind: "error", - message: typeof event.message === "string" ? event.message : "Grok Build returned an error.", - usage: event.usage, - totalCostUsd: event.total_cost_usd, - }; +export function parseGrokStreamEvent(raw: unknown, schema: GrokEventSchema = BUILTIN_GROK_SCHEMA_BUNDLE.schemas[0]): GrokStreamEvent { + const event = parseGrokCompatibilityEvent(raw, schema); + switch (event.kind) { + case "text": return event; + case "end": return { ...event, isError: false }; + case "error": return event; + default: return { kind: "ignore" }; } - return { kind: "ignore" }; } diff --git a/src/lib/grok-compatibility.test.ts b/src/lib/grok-compatibility.test.ts new file mode 100644 index 000000000..60d7dc616 --- /dev/null +++ b/src/lib/grok-compatibility.test.ts @@ -0,0 +1,29 @@ +import assert from "node:assert/strict"; +import { generateKeyPairSync, sign } from "node:crypto"; +import { + BUILTIN_GROK_SCHEMA_BUNDLE, + grokSchemaBundleSigningPayload, + resolveGrokCompatibility, + verifyGrokSchemaBundle, +} from "./grok-compatibility.ts"; + +const { privateKey, publicKey } = generateKeyPairSync("ed25519"); +const signed = structuredClone(BUILTIN_GROK_SCHEMA_BUNDLE); +signed.sequence = 2; +signed.keyId = "test-key"; +signed.signature = { + algorithm: "ed25519", + value: sign(null, Buffer.from(grokSchemaBundleSigningPayload(signed)), privateKey).toString("base64"), +}; +const keyring = { "test-key": publicKey.export({ type: "spki", format: "pem" }).toString() }; +assert.equal(verifyGrokSchemaBundle(signed, keyring), true, "Ed25519 verification accepts a canonical signed bundle"); +assert.equal(verifyGrokSchemaBundle({ ...signed, sequence: 3 }, keyring), false, "a signature cannot be replayed onto changed schema data"); + +const supported = { version: "fixture", streamingJson: true, options: ["--output-format"], valueOptions: ["--output-format"] }; +const selected = await resolveGrokCompatibility(supported, { publicKeys: keyring, fetch: async () => new Response(JSON.stringify(signed)) }); +assert.equal(selected.mode, "structured"); +assert.equal(selected.schema?.id, "grok-build-streaming-json-v1"); +const unsupported = await resolveGrokCompatibility({ ...supported, streamingJson: false }); +assert.deepEqual(unsupported.diagnostic, "streaming-json-unavailable"); + +console.log("grok compatibility tests passed"); diff --git a/src/lib/grok-compatibility.ts b/src/lib/grok-compatibility.ts new file mode 100644 index 000000000..f09c3f247 --- /dev/null +++ b/src/lib/grok-compatibility.ts @@ -0,0 +1,369 @@ +/** + * Grok Build's streaming protocol is deliberately treated as a compatibility + * contract, not an invitation to guess at future event names. The compiled + * baseline below is restricted to the frames documented by xAI; a signed + * registry can add a tool envelope only after a local capability probe has + * established that the installed launcher advertises that protocol. + */ +import { createHash, createPublicKey, verify } from "node:crypto"; +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import * as fs from "node:fs/promises"; +import { homedir } from "node:os"; +import path from "node:path"; + +export type GrokRunCapabilities = { + version: string | null; + streamingJson: boolean; + options: string[]; + valueOptions: string[]; +}; + +export function grokRunCapabilitiesFromHelp(help: string, version: string | null = null): GrokRunCapabilities { + const options = [...help.matchAll(/^\s*(--[a-z][a-z0-9-]*)\b/gim)].map((match) => match[1]); + const unique = [...new Set(options)]; + const valueOptions = unique.filter((option) => new RegExp(`${option.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?:\\s|=)+(?:<[^>]+>|\\[[^\\]]+\\]|[A-Z][A-Z_-]*)`, "i").test(help)); + return { + version, + streamingJson: /--output-format(?:\s|=)+(?:<[^>]+>|\[[^\]]+\]|[A-Z][A-Z_-]*)?[\s\S]{0,240}\bstreaming-json\b/i.test(help), + options: unique, + valueOptions, + }; +} + +/** Bounded, credential-free local contract probe. It never invokes a run. */ +export async function probeGrokRunCapabilities( + launch: { command: string; fixedArgs: string[] }, + env: NodeJS.ProcessEnv, + timeoutMs = process.platform === "win32" ? 6_000 : 2_500, +): Promise { + const probe = (args: string[]): Promise => new Promise((resolve) => { + let output = ""; let settled = false; + const finish = (value: string | null) => { if (!settled) { settled = true; resolve(value); } }; + try { + const child = spawn(launch.command, [...launch.fixedArgs, ...args], { env, stdio: ["ignore", "pipe", "pipe"], windowsHide: true }) as unknown as ChildProcessWithoutNullStreams; + const append = (chunk: Buffer) => { if (output.length < 64 * 1024) output += chunk.toString().slice(0, 64 * 1024 - output.length); }; + child.stdout.on("data", append); child.stderr.on("data", append); + const timer = setTimeout(() => { try { child.kill("SIGTERM"); } catch { /* bounded best effort */ } finish(null); }, timeoutMs); + child.once("error", () => { clearTimeout(timer); finish(null); }); + child.once("close", (code) => { clearTimeout(timer); finish(code === 0 ? output : null); }); + } catch { finish(null); } + }); + const [help, versionOutput] = await Promise.all([probe(["--help"]), probe(["--version"])]); + const version = versionOutput?.match(/\d+(?:\.\d+){1,3}(?:[-+][\w.-]+)?/)?.[0] ?? null; + return help ? grokRunCapabilitiesFromHelp(help, version) : { version, streamingJson: false, options: [], valueOptions: [] }; +} + +export type GrokRegistryKeyring = Record; +export type GrokRegistryCheckpoint = { sequence: number; payloadHash: string }; + +/** Data only: registries cannot supply selectors, paths, code, or argv. */ +export type GrokEventSchema = { + id: string; + priority?: number; + requires: { streamingJson: true; options?: string[] }; + eventTypes: { + ignored: string[]; + text: string[]; + end: string[]; + error: string[]; + toolStart: string[]; + toolProgress?: string[]; + toolEnd: string[]; + toolComplete: string[]; + }; + fields: { + type: string[]; + text: string[]; + sessionId: string[]; + message: string[]; + usage: string[]; + totalCostUsd: string[]; + id: string[]; + name: string[]; + input: string[]; + output: string[]; + state: string[]; + error: string[]; + terminalStates: string[]; + errorStates: string[]; + }; + /** Only the documented output option/value may be selected by a schema. */ + launch: { outputOption: "--output-format"; outputValue: "streaming-json" }; +}; + +export type GrokSchemaBundle = { + format: 1; + runtime: "grok-build"; + sequence: number; + issuedAt: string; + expiresAt: string; + keyId?: string; + retiredSchemaIds?: string[]; + schemas: GrokEventSchema[]; + signature?: { algorithm: "ed25519"; value: string }; +}; + +export type GrokCompatibilityDiagnostic = + | "streaming-json-unavailable" + | "no-compatible-schema" + | "schema-quarantined" + | "schema-registry-refresh-rejected" + | "cached-schema-unavailable"; + +export type GrokCompatibility = { + mode: "structured" | "plain"; + capabilities: GrokRunCapabilities; + schema?: GrokEventSchema; + bundleSource: "built-in" | "cache" | "remote"; + diagnostic?: GrokCompatibilityDiagnostic; +}; + +export type GrokParsedEvent = + | { kind: "text"; text: string } + | { kind: "end"; sessionId?: string; usage?: unknown; totalCostUsd?: unknown } + | { kind: "error"; message: string; usage?: unknown; totalCostUsd?: unknown } + | { kind: "tool_start"; id: string; name: string; input: unknown } + | { kind: "tool_progress"; id: string; output: unknown } + | { kind: "tool_end"; id: string; output: unknown; isError: boolean } + | { kind: "tool_complete"; id: string; name: string; input: unknown; output: unknown; isError: boolean } + | { kind: "ignore" } + | { kind: "unknown" }; + +const MAX_BUNDLE_BYTES = 256 * 1024; +const MAX_CACHE_BYTES = 512 * 1024; +const MAX_SCHEMAS = 32; +const schemaQuarantine = new Map(); + +/** The only source-verified built-in Grok frame contract as of 2026-07-26. */ +export const BUILTIN_GROK_SCHEMA_BUNDLE: GrokSchemaBundle = { + format: 1, + runtime: "grok-build", + sequence: 1, + issuedAt: "2026-07-26T00:00:00.000Z", + expiresAt: "2030-01-01T00:00:00.000Z", + schemas: [{ + id: "grok-build-streaming-json-v1", + requires: { streamingJson: true, options: ["--output-format"] }, + eventTypes: { + ignored: ["thought", "max_turns_reached", "auto_compact_start", "auto_compact_end"], + text: ["text"], end: ["end"], error: ["error"], + toolStart: [], toolProgress: [], toolEnd: [], toolComplete: [], + }, + fields: { + type: ["type"], text: ["data"], sessionId: ["sessionId", "session_id"], + message: ["message"], usage: ["usage"], totalCostUsd: ["total_cost_usd"], + id: ["id", "tool_call_id"], name: ["name", "tool_name"], input: ["input"], + output: ["output", "result"], state: ["state", "status"], error: ["error"], + terminalStates: ["completed", "complete", "failed", "error"], errorStates: ["failed", "error"], + }, + launch: { outputOption: "--output-format", outputValue: "streaming-json" }, + }], +}; + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function stringField(value: Record, aliases: string[]): string | undefined { + for (const alias of aliases) if (typeof value[alias] === "string") return value[alias]; + return undefined; +} + +function valueField(value: Record, aliases: string[]): unknown { + for (const alias of aliases) if (alias in value) return value[alias]; + return undefined; +} + +export function redactedGrokEventFingerprint(value: unknown): string { + const safe = ["type", "data", "sessionId", "session_id", "id", "tool_call_id", "name", "tool_name", "state", "status"]; + const shape = isRecord(value) + ? Object.fromEntries(safe.filter((key) => key in value).map((key) => [key, typeof value[key]])) + : typeof value; + return createHash("sha256").update(JSON.stringify(shape)).digest("hex").slice(0, 16); +} + +/** A frame only becomes tool activity after the selected schema explicitly names it. */ +export function parseGrokCompatibilityEvent(raw: unknown, schema?: GrokEventSchema): GrokParsedEvent { + if (!schema || !isRecord(raw)) return { kind: "unknown" }; + const type = stringField(raw, schema.fields.type); + if (!type) return { kind: "unknown" }; + const fields = schema.fields; + if (schema.eventTypes.ignored.includes(type)) return { kind: "ignore" }; + if (schema.eventTypes.text.includes(type)) { + const text = stringField(raw, fields.text); + return text === undefined ? { kind: "unknown" } : { kind: "text", text }; + } + if (schema.eventTypes.end.includes(type)) return { kind: "end", sessionId: stringField(raw, fields.sessionId), usage: valueField(raw, fields.usage), totalCostUsd: valueField(raw, fields.totalCostUsd) }; + if (schema.eventTypes.error.includes(type)) return { kind: "error", message: stringField(raw, fields.message) ?? "Grok Build returned an error.", usage: valueField(raw, fields.usage), totalCostUsd: valueField(raw, fields.totalCostUsd) }; + const id = stringField(raw, fields.id); + if (!id) return { kind: "unknown" }; + if (schema.eventTypes.toolStart.includes(type)) { + const name = stringField(raw, fields.name); + return name === undefined ? { kind: "unknown" } : { kind: "tool_start", id, name, input: valueField(raw, fields.input) }; + } + if ((schema.eventTypes.toolProgress ?? []).includes(type)) return { kind: "tool_progress", id, output: valueField(raw, fields.output) }; + const isError = fields.errorStates.includes(stringField(raw, fields.state) ?? "") || valueField(raw, fields.error) === true; + if (schema.eventTypes.toolEnd.includes(type)) return { kind: "tool_end", id, output: valueField(raw, fields.output), isError }; + if (schema.eventTypes.toolComplete.includes(type)) { + const name = stringField(raw, fields.name); + return name === undefined ? { kind: "unknown" } : { kind: "tool_complete", id, name, input: valueField(raw, fields.input), output: valueField(raw, fields.output), isError }; + } + return { kind: "unknown" }; +} + +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(",")}}`; +} + +export function grokSchemaBundleSigningPayload(bundle: GrokSchemaBundle): string { + const { signature: _signature, ...unsigned } = bundle; + return stableJson(unsigned); +} + +export function grokSchemaBundlePayloadHash(bundle: GrokSchemaBundle): string { + return createHash("sha256").update(grokSchemaBundleSigningPayload(bundle)).digest("hex"); +} + +function validName(value: unknown): value is string { + return typeof value === "string" && /^[a-z][a-z0-9_-]{0,95}$/i.test(value); +} + +function validAliases(value: unknown, allowEmpty = false): value is string[] { + return Array.isArray(value) && (allowEmpty || value.length > 0) && value.length <= 8 && value.every(validName) && new Set(value).size === value.length; +} + +function validOptions(value: unknown): value is string[] { + return Array.isArray(value) && value.length <= 16 && value.every((option) => typeof option === "string" && /^--[a-z][a-z0-9-]{0,63}$/.test(option)) && new Set(value).size === value.length; +} + +function validSchema(value: unknown): value is GrokEventSchema { + if (!isRecord(value) || !validName(value.id) || !isRecord(value.requires) || value.requires.streamingJson !== true || !isRecord(value.eventTypes) || !isRecord(value.fields) || !isRecord(value.launch)) return false; + if (value.priority !== undefined && (typeof value.priority !== "number" || !Number.isSafeInteger(value.priority) || Math.abs(value.priority) > 1_000)) return false; + if (value.requires.options !== undefined && !validOptions(value.requires.options)) return false; + if (value.launch.outputOption !== "--output-format" || value.launch.outputValue !== "streaming-json") return false; + const events = value.eventTypes; + if (![events.ignored, events.text, events.end, events.error, events.toolStart, events.toolEnd, events.toolComplete].every((items) => validAliases(items, true)) || (events.toolProgress !== undefined && !validAliases(events.toolProgress, true))) return false; + const f = value.fields; + return [f.type, f.text, f.sessionId, f.message, f.usage, f.totalCostUsd, f.id, f.name, f.input, f.output, f.state, f.error, f.terminalStates, f.errorStates].every((items) => validAliases(items, true)); +} + +function canonicalTime(value: unknown): number | null { + if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(value)) return null; + const time = Date.parse(value); + return Number.isFinite(time) && new Date(time).toISOString() === value ? time : null; +} + +export function isGrokSchemaBundle(value: unknown, now = Date.now(), allowExpired = false): value is GrokSchemaBundle { + if (!isRecord(value) || value.format !== 1 || value.runtime !== "grok-build" || typeof value.sequence !== "number" || !Number.isSafeInteger(value.sequence) || value.sequence < 1 || !Array.isArray(value.schemas) || value.schemas.length < 1 || value.schemas.length > MAX_SCHEMAS || !value.schemas.every(validSchema)) return false; + if (!Object.keys(value).every((key) => ["format", "runtime", "sequence", "issuedAt", "expiresAt", "keyId", "retiredSchemaIds", "schemas", "signature"].includes(key))) return false; + const issued = canonicalTime(value.issuedAt); const expires = canonicalTime(value.expiresAt); + if (issued === null || expires === null || issued > now || expires <= issued || (!allowExpired && expires <= now)) return false; + if (value.keyId !== undefined && !validName(value.keyId)) return false; + if (value.retiredSchemaIds !== undefined && (!validAliases(value.retiredSchemaIds, true) || !value.retiredSchemaIds.every((id) => BUILTIN_GROK_SCHEMA_BUNDLE.schemas.some((schema) => schema.id === id)))) return false; + if (value.signature !== undefined && (!isRecord(value.signature) || value.signature.algorithm !== "ed25519" || typeof value.signature.value !== "string")) return false; + const ids = value.schemas.map((schema) => schema.id); + return new Set(ids).size === ids.length; +} + +export function verifyGrokSchemaBundle(value: unknown, publicKeys: string | GrokRegistryKeyring, now = Date.now()): value is GrokSchemaBundle { + if (!isGrokSchemaBundle(value, now) || !value.signature) return false; + const keys = typeof publicKeys === "string" ? { legacy: publicKeys } : publicKeys; + const candidates = Object.entries(keys).filter(([id, pem]) => validName(id) && typeof pem === "string" && (value.keyId === undefined || value.keyId === id)); + if (candidates.length === 0 || candidates.length > 4 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value.signature.value)) return false; + try { + const signature = Buffer.from(value.signature.value, "base64"); + return signature.toString("base64") === value.signature.value && candidates.some(([, pem]) => { + const key = createPublicKey(pem); + return key.asymmetricKeyType === "ed25519" && verify(null, Buffer.from(grokSchemaBundleSigningPayload(value)), key, signature); + }); + } catch { return false; } +} + +export function selectGrokSchema(schemas: GrokEventSchema[], capabilities: GrokRunCapabilities): GrokEventSchema | null { + if (!capabilities.streamingJson) return null; + const options = new Set(capabilities.options); + const matches = schemas.filter((schema) => schema.requires.options?.every((option) => options.has(option)) ?? true); + if (!matches.length) return null; + const specificity = Math.max(...matches.map((schema) => schema.requires.options?.length ?? 0)); + const preferred = matches.filter((schema) => (schema.requires.options?.length ?? 0) === specificity); + const priority = Math.max(...preferred.map((schema) => schema.priority ?? 0)); + const winners = preferred.filter((schema) => (schema.priority ?? 0) === priority); + return winners.length === 1 ? winners[0] : null; +} + +export function quarantineGrokSchema(schema: GrokEventSchema | undefined): void { + if (!schema) return; + if (schemaQuarantine.size >= 64 && !schemaQuarantine.has(schema.id)) schemaQuarantine.delete(schemaQuarantine.keys().next().value!); + schemaQuarantine.set(schema.id, createHash("sha256").update(stableJson(schema)).digest("hex")); +} + +type RegistrySource = { url?: string; publicKeys?: GrokRegistryKeyring; checkpoint?: GrokRegistryCheckpoint; now?: () => number; fetch?: typeof globalThis.fetch; cachePath?: string }; +type Cached = { bundle: GrokSchemaBundle; checkedAt: number }; + +function configuredGrokRegistrySource(): RegistrySource { + const url = process.env.NEXT_PUBLIC_COVEN_GROK_SCHEMA_REGISTRY_URL || process.env.COVEN_GROK_SCHEMA_REGISTRY_URL; + const single = process.env.NEXT_PUBLIC_COVEN_GROK_SCHEMA_REGISTRY_PUBLIC_KEY || process.env.COVEN_GROK_SCHEMA_REGISTRY_PUBLIC_KEY; + const rawKeyring = process.env.NEXT_PUBLIC_COVEN_GROK_SCHEMA_REGISTRY_PUBLIC_KEYS || process.env.COVEN_GROK_SCHEMA_REGISTRY_PUBLIC_KEYS; + const rawCheckpoint = process.env.NEXT_PUBLIC_COVEN_GROK_SCHEMA_REGISTRY_CHECKPOINT || process.env.COVEN_GROK_SCHEMA_REGISTRY_CHECKPOINT; + let publicKeys: GrokRegistryKeyring | undefined; + let checkpoint: GrokRegistryCheckpoint | undefined; + try { publicKeys = rawKeyring ? JSON.parse(rawKeyring) : single ? { legacy: single } : undefined; } catch { /* invalid config fails closed */ } + try { checkpoint = rawCheckpoint ? JSON.parse(rawCheckpoint) : undefined; } catch { /* invalid config fails closed */ } + return { url, publicKeys, checkpoint }; +} + +function defaultCachePath(): string { + return path.join(process.env.COVEN_CAVE_HOME || path.join(process.env.COVEN_HOME || homedir(), ".coven", "cave"), "grok-schema-bundle-v1.json"); +} + +async function readCache(file: string, keys: GrokRegistryKeyring, now: number): Promise { + try { + const raw = await fs.readFile(file, "utf8"); + if (Buffer.byteLength(raw) > MAX_CACHE_BYTES) return null; + const cached = JSON.parse(raw) as Cached; + return verifyGrokSchemaBundle(cached?.bundle, keys, now) ? cached.bundle : null; + } catch { return null; } +} + +async function writeCache(file: string, bundle: GrokSchemaBundle): Promise { + try { + await fs.mkdir(path.dirname(file), { recursive: true }); + const temporary = `${file}.${process.pid}.${Date.now()}.tmp`; + await fs.writeFile(temporary, JSON.stringify({ checkedAt: Date.now(), bundle }), { mode: 0o600 }); + await fs.rename(temporary, file); + } catch { /* Cache is an optimization; a failed write never broadens parsing. */ } +} + +function trustedBundle(bundle: GrokSchemaBundle, checkpoint?: GrokRegistryCheckpoint): boolean { + if (bundle.sequence < BUILTIN_GROK_SCHEMA_BUNDLE.sequence) return false; + if (bundle.sequence === BUILTIN_GROK_SCHEMA_BUNDLE.sequence && grokSchemaBundleSigningPayload(bundle) !== grokSchemaBundleSigningPayload(BUILTIN_GROK_SCHEMA_BUNDLE)) return false; + return !checkpoint || bundle.sequence > checkpoint.sequence || (bundle.sequence === checkpoint.sequence && grokSchemaBundlePayloadHash(bundle) === checkpoint.payloadHash); +} + +export async function resolveGrokCompatibility(capabilities: GrokRunCapabilities, source: RegistrySource = configuredGrokRegistrySource()): Promise { + if (!capabilities.streamingJson) return { mode: "plain", capabilities, bundleSource: "built-in", diagnostic: "streaming-json-unavailable" }; + const now = source.now?.() ?? Date.now(); + const keys = source.publicKeys ?? {}; + const cacheFile = source.cachePath ?? defaultCachePath(); + let bundle = BUILTIN_GROK_SCHEMA_BUNDLE; let bundleSource: GrokCompatibility["bundleSource"] = "built-in"; let diagnostic: GrokCompatibilityDiagnostic | undefined; + const cached = Object.keys(keys).length ? await readCache(cacheFile, keys, now) : null; + if (cached && trustedBundle(cached, source.checkpoint)) { bundle = cached; bundleSource = "cache"; } + if (source.url && Object.keys(keys).length) { + try { + const response = await (source.fetch ?? fetch)(source.url, { signal: AbortSignal.timeout(5_000) }); + const body = await response.text(); + if (!response.ok || Buffer.byteLength(body) > MAX_BUNDLE_BYTES) throw new Error("untrusted registry response"); + const remote = JSON.parse(body); + if (!verifyGrokSchemaBundle(remote, keys, now) || !trustedBundle(remote, source.checkpoint)) throw new Error("untrusted registry bundle"); + bundle = remote; bundleSource = "remote"; await writeCache(cacheFile, remote); + } catch { diagnostic = "schema-registry-refresh-rejected"; } + } + const remoteById = new Map(bundle.schemas.map((schema) => [schema.id, schema])); + const candidates = bundleSource === "built-in" ? bundle.schemas : [...bundle.schemas, ...BUILTIN_GROK_SCHEMA_BUNDLE.schemas.filter((schema) => !bundle.retiredSchemaIds?.includes(schema.id) && !remoteById.has(schema.id))]; + const schema = selectGrokSchema(candidates, capabilities); + if (!schema) return { mode: "plain", capabilities, bundleSource, diagnostic: diagnostic ?? "no-compatible-schema" }; + if (schemaQuarantine.get(schema.id) === createHash("sha256").update(stableJson(schema)).digest("hex")) return { mode: "plain", capabilities, bundleSource, diagnostic: "schema-quarantined" }; + return { mode: "structured", capabilities, schema, bundleSource, diagnostic }; +} From 7fb62a34ac22e6003b5f288588ea7d5d5a5af1a5 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sun, 26 Jul 2026 11:33:04 -0400 Subject: [PATCH 02/61] ci(grok): require signed registry trust material --- .github/workflows/release.yml | 20 ++++++++++++++ scripts/check-grok-registry-release.mjs | 29 ++++++++++++++++++++ scripts/check-grok-registry-release.test.mjs | 14 ++++++++++ scripts/run-tests.mjs | 1 + src/lib/grok-compatibility.ts | 5 ++++ 5 files changed, 69 insertions(+) create mode 100644 scripts/check-grok-registry-release.mjs create mode 100644 scripts/check-grok-registry-release.test.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a330ff866..c8b7c850a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -252,6 +252,26 @@ jobs: echo "NEXT_PUBLIC_COVEN_OPENCODE_SCHEMA_REGISTRY_CHECKPOINT=$NEXT_PUBLIC_COVEN_OPENCODE_SCHEMA_REGISTRY_CHECKPOINT" } >> "$GITHUB_ENV" + - name: Require signed Grok compatibility registry + shell: bash + env: + NEXT_PUBLIC_COVEN_GROK_SCHEMA_REGISTRY_URL: ${{ secrets.GROK_SCHEMA_REGISTRY_URL }} + NEXT_PUBLIC_COVEN_GROK_SCHEMA_REGISTRY_PUBLIC_KEY: ${{ secrets.GROK_SCHEMA_REGISTRY_PUBLIC_KEY }} + NEXT_PUBLIC_COVEN_GROK_SCHEMA_REGISTRY_PUBLIC_KEYS: ${{ secrets.GROK_SCHEMA_REGISTRY_PUBLIC_KEYS }} + NEXT_PUBLIC_COVEN_GROK_SCHEMA_REGISTRY_CHECKPOINT: ${{ secrets.GROK_SCHEMA_REGISTRY_CHECKPOINT }} + run: | + node scripts/check-grok-registry-release.mjs + { + echo "NEXT_PUBLIC_COVEN_GROK_SCHEMA_REGISTRY_URL=$NEXT_PUBLIC_COVEN_GROK_SCHEMA_REGISTRY_URL" + echo "NEXT_PUBLIC_COVEN_GROK_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/scripts/check-grok-registry-release.mjs b/scripts/check-grok-registry-release.mjs new file mode 100644 index 000000000..f8bee3574 --- /dev/null +++ b/scripts/check-grok-registry-release.mjs @@ -0,0 +1,29 @@ +// Public verification material is embedded in a desktop release; this guard +// makes a missing trust anchor a release failure rather than an unsafe default. +import { createPublicKey } from "node:crypto"; + +const url = process.env.NEXT_PUBLIC_COVEN_GROK_SCHEMA_REGISTRY_URL; +const publicKey = process.env.NEXT_PUBLIC_COVEN_GROK_SCHEMA_REGISTRY_PUBLIC_KEY; +const publicKeys = process.env.NEXT_PUBLIC_COVEN_GROK_SCHEMA_REGISTRY_PUBLIC_KEYS; +const checkpoint = process.env.NEXT_PUBLIC_COVEN_GROK_SCHEMA_REGISTRY_CHECKPOINT; +const fail = (message) => { console.error(`::error::${message}`); process.exitCode = 1; }; +let keyring; +try { keyring = publicKeys ? JSON.parse(publicKeys) : publicKey ? { legacy: publicKey } : null; } catch { keyring = null; } +if (!url || !keyring || typeof keyring !== "object" || Array.isArray(keyring) || !checkpoint) { + fail("Grok compatibility registry URL, Ed25519 public key/keyring, and immutable sequence checkpoint must be configured for every desktop release."); +} else { + try { + const parsedUrl = new URL(url); + if (parsedUrl.protocol !== "https:" || parsedUrl.username || parsedUrl.password) throw new Error("registry URL must use HTTPS without credentials"); + const entries = Object.entries(keyring); + if (!entries.length || entries.length > 4) throw new Error("registry keyring must contain one to four keys"); + for (const [id, pem] of entries) { + if (!/^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(id) || typeof pem !== "string") throw new Error("registry keyring has an invalid key id"); + if (createPublicKey(pem).asymmetricKeyType !== "ed25519") throw new Error("registry key must be Ed25519"); + } + const parsedCheckpoint = JSON.parse(checkpoint); + if (!parsedCheckpoint || typeof parsedCheckpoint !== "object" || Array.isArray(parsedCheckpoint) + || Object.keys(parsedCheckpoint).length !== 2 || !Number.isSafeInteger(parsedCheckpoint.sequence) || parsedCheckpoint.sequence < 1 + || typeof parsedCheckpoint.payloadHash !== "string" || !/^[a-f0-9]{64}$/.test(parsedCheckpoint.payloadHash)) throw new Error("registry checkpoint must contain a sequence and SHA-256 payload hash"); + } catch (error) { fail(`Invalid Grok compatibility registry configuration: ${error instanceof Error ? error.message : "unknown error"}`); } +} diff --git a/scripts/check-grok-registry-release.test.mjs b/scripts/check-grok-registry-release.test.mjs new file mode 100644 index 000000000..5465ec19b --- /dev/null +++ b/scripts/check-grok-registry-release.test.mjs @@ -0,0 +1,14 @@ +import assert from "node:assert/strict"; +import { generateKeyPairSync } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { readFile } from "node:fs/promises"; + +const workflow = await readFile(new URL("../.github/workflows/release.yml", import.meta.url), "utf8"); +const guard = await readFile(new URL("./check-grok-registry-release.mjs", import.meta.url), "utf8"); +assert.match(workflow, /Require signed Grok compatibility registry[\s\S]*?NEXT_PUBLIC_COVEN_GROK_SCHEMA_REGISTRY_URL[\s\S]*?check-grok-registry-release\.mjs/); +assert.match(guard, /registry URL must use HTTPS without credentials/); +const { publicKey } = generateKeyPairSync("ed25519"); +const result = spawnSync(process.execPath, ["scripts/check-grok-registry-release.mjs"], { cwd: new URL("..", import.meta.url), encoding: "utf8", env: { ...process.env, NEXT_PUBLIC_COVEN_GROK_SCHEMA_REGISTRY_URL: "https://publisher:secret@registry.example/grok.json", NEXT_PUBLIC_COVEN_GROK_SCHEMA_REGISTRY_PUBLIC_KEY: publicKey.export({ type: "spki", format: "pem" }).toString(), NEXT_PUBLIC_COVEN_GROK_SCHEMA_REGISTRY_CHECKPOINT: JSON.stringify({ sequence: 1, payloadHash: "a".repeat(64) }) } }); +assert.notEqual(result.status, 0); +assert.doesNotMatch(result.stderr, /secret/); +console.log("check-grok-registry-release.test.mjs: ok"); diff --git a/scripts/run-tests.mjs b/scripts/run-tests.mjs index f14ba5ccc..3a0f13cf4 100644 --- a/scripts/run-tests.mjs +++ b/scripts/run-tests.mjs @@ -103,6 +103,7 @@ export const SUITES = { "scripts/eslint/design-system-plugin.test.mjs", "scripts/bundle-budget.test.mjs", "scripts/check-opencode-registry-release.test.mjs", + "scripts/check-grok-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/grok-compatibility.ts b/src/lib/grok-compatibility.ts index f09c3f247..e796c1cc1 100644 --- a/src/lib/grok-compatibility.ts +++ b/src/lib/grok-compatibility.ts @@ -311,6 +311,11 @@ function configuredGrokRegistrySource(): RegistrySource { let checkpoint: GrokRegistryCheckpoint | undefined; try { publicKeys = rawKeyring ? JSON.parse(rawKeyring) : single ? { legacy: single } : undefined; } catch { /* invalid config fails closed */ } try { checkpoint = rawCheckpoint ? JSON.parse(rawCheckpoint) : undefined; } catch { /* invalid config fails closed */ } + try { + const parsed = url ? new URL(url) : null; + if (parsed && (parsed.protocol !== "https:" || parsed.username || parsed.password)) return {}; + } catch { return {}; } + if (!checkpoint || !Number.isSafeInteger(checkpoint.sequence) || checkpoint.sequence < 1 || !/^[a-f0-9]{64}$/.test(checkpoint.payloadHash)) return {}; return { url, publicKeys, checkpoint }; } From 9b63024eea3d72e636b74fd077d7ac331fcece77 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sun, 26 Jul 2026 12:01:16 -0400 Subject: [PATCH 03/61] fix(grok): resolve compatibility module in Node tests --- src/lib/grok-build.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/grok-build.ts b/src/lib/grok-build.ts index 040ec5c4d..dcf2a1141 100644 --- a/src/lib/grok-build.ts +++ b/src/lib/grok-build.ts @@ -8,7 +8,7 @@ import { BUILTIN_GROK_SCHEMA_BUNDLE, parseGrokCompatibilityEvent, type GrokEventSchema, -} from "./grok-compatibility"; +} from "./grok-compatibility.ts"; export type RuntimeModelOption = { id: string; label: string }; export type GrokSandboxProfile = "full" | "read"; From 14d081e4187d5e970aa8ea6d92684fffcb441ecf Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sun, 26 Jul 2026 12:04:33 -0400 Subject: [PATCH 04/61] fix(grok): anchor cached schema revisions --- src/lib/grok-compatibility.test.ts | 21 ++++++++++++++ src/lib/grok-compatibility.ts | 44 ++++++++++++++++++++++++------ 2 files changed, 57 insertions(+), 8 deletions(-) diff --git a/src/lib/grok-compatibility.test.ts b/src/lib/grok-compatibility.test.ts index 60d7dc616..48a1cfb82 100644 --- a/src/lib/grok-compatibility.test.ts +++ b/src/lib/grok-compatibility.test.ts @@ -1,5 +1,8 @@ import assert from "node:assert/strict"; import { generateKeyPairSync, sign } from "node:crypto"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; import { BUILTIN_GROK_SCHEMA_BUNDLE, grokSchemaBundleSigningPayload, @@ -26,4 +29,22 @@ assert.equal(selected.schema?.id, "grok-build-streaming-json-v1"); const unsupported = await resolveGrokCompatibility({ ...supported, streamingJson: false }); assert.deepEqual(unsupported.diagnostic, "streaming-json-unavailable"); +const highWater = structuredClone(signed); +highWater.sequence = 3; +highWater.signature = { + algorithm: "ed25519", + value: sign(null, Buffer.from(grokSchemaBundleSigningPayload(highWater)), privateKey).toString("base64"), +}; +const cacheDirectory = await mkdtemp(path.join(tmpdir(), "coven-grok-registry-")); +const cachePath = path.join(cacheDirectory, "schema.json"); +try { + const refreshed = await resolveGrokCompatibility(supported, { publicKeys: keyring, cachePath, url: "https://registry.example/grok.json", fetch: async () => new Response(JSON.stringify(highWater)) }); + assert.equal(refreshed.bundleSource, "remote"); + const rollback = await resolveGrokCompatibility(supported, { publicKeys: keyring, cachePath, url: "https://registry.example/grok.json", fetch: async () => new Response(JSON.stringify(signed)) }); + assert.equal(rollback.bundleSource, "cache", "a lower signed sequence cannot replace the local high-water contract"); + assert.equal(rollback.diagnostic, "schema-registry-refresh-rejected"); +} finally { + await rm(cacheDirectory, { recursive: true, force: true }); +} + console.log("grok compatibility tests passed"); diff --git a/src/lib/grok-compatibility.ts b/src/lib/grok-compatibility.ts index e796c1cc1..03947cf42 100644 --- a/src/lib/grok-compatibility.ts +++ b/src/lib/grok-compatibility.ts @@ -5,7 +5,7 @@ * registry can add a tool envelope only after a local capability probe has * established that the installed launcher advertises that protocol. */ -import { createHash, createPublicKey, verify } from "node:crypto"; +import { createHash, createPublicKey, randomBytes, verify } from "node:crypto"; import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; import * as fs from "node:fs/promises"; import { homedir } from "node:os"; @@ -301,6 +301,7 @@ export function quarantineGrokSchema(schema: GrokEventSchema | undefined): void type RegistrySource = { url?: string; publicKeys?: GrokRegistryKeyring; checkpoint?: GrokRegistryCheckpoint; now?: () => number; fetch?: typeof globalThis.fetch; cachePath?: string }; type Cached = { bundle: GrokSchemaBundle; checkedAt: number }; +type TrustAnchor = { sequence: number; payloadHash: string }; function configuredGrokRegistrySource(): RegistrySource { const url = process.env.NEXT_PUBLIC_COVEN_GROK_SCHEMA_REGISTRY_URL || process.env.COVEN_GROK_SCHEMA_REGISTRY_URL; @@ -323,24 +324,50 @@ function defaultCachePath(): string { return path.join(process.env.COVEN_CAVE_HOME || path.join(process.env.COVEN_HOME || homedir(), ".coven", "cave"), "grok-schema-bundle-v1.json"); } -async function readCache(file: string, keys: GrokRegistryKeyring, now: number): Promise { +function trustAnchorPath(file: string): string { return `${file}.anchor`; } + +function validTrustAnchor(value: unknown): value is TrustAnchor { + return isRecord(value) && Object.keys(value).length === 2 && typeof value.sequence === "number" && Number.isSafeInteger(value.sequence) && value.sequence >= 1 && typeof value.payloadHash === "string" && /^[a-f0-9]{64}$/.test(value.payloadHash); +} + +async function readTrustAnchor(file: string): Promise { + try { + const raw = await fs.readFile(trustAnchorPath(file), "utf8"); + if (Buffer.byteLength(raw) > 1024) return null; + const anchor = JSON.parse(raw); + return validTrustAnchor(anchor) ? anchor : null; + } catch { return null; } +} + +function meetsTrustAnchor(bundle: GrokSchemaBundle, anchor: TrustAnchor | null): boolean { + return !anchor || bundle.sequence > anchor.sequence || (bundle.sequence === anchor.sequence && grokSchemaBundlePayloadHash(bundle) === anchor.payloadHash); +} + +async function readCache(file: string, keys: GrokRegistryKeyring, now: number, anchor: TrustAnchor | null): Promise { try { const raw = await fs.readFile(file, "utf8"); if (Buffer.byteLength(raw) > MAX_CACHE_BYTES) return null; const cached = JSON.parse(raw) as Cached; - return verifyGrokSchemaBundle(cached?.bundle, keys, now) ? cached.bundle : null; + return verifyGrokSchemaBundle(cached?.bundle, keys, now) && meetsTrustAnchor(cached.bundle, anchor) ? cached.bundle : null; } catch { return null; } } -async function writeCache(file: string, bundle: GrokSchemaBundle): Promise { +async function writeJsonAtomic(file: string, value: unknown): Promise { + const temporary = `${file}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`; try { await fs.mkdir(path.dirname(file), { recursive: true }); - const temporary = `${file}.${process.pid}.${Date.now()}.tmp`; - await fs.writeFile(temporary, JSON.stringify({ checkedAt: Date.now(), bundle }), { mode: 0o600 }); + await fs.writeFile(temporary, JSON.stringify(value), { mode: 0o600 }); await fs.rename(temporary, file); } catch { /* Cache is an optimization; a failed write never broadens parsing. */ } } +async function writeCache(file: string, bundle: GrokSchemaBundle): Promise { + // Anchor first: an interruption can make the cache unavailable, never make + // an older cache appear acceptable after a newer bundle was trusted. + await writeJsonAtomic(trustAnchorPath(file), { sequence: bundle.sequence, payloadHash: grokSchemaBundlePayloadHash(bundle) }); + await writeJsonAtomic(file, { checkedAt: Date.now(), bundle }); +} + function trustedBundle(bundle: GrokSchemaBundle, checkpoint?: GrokRegistryCheckpoint): boolean { if (bundle.sequence < BUILTIN_GROK_SCHEMA_BUNDLE.sequence) return false; if (bundle.sequence === BUILTIN_GROK_SCHEMA_BUNDLE.sequence && grokSchemaBundleSigningPayload(bundle) !== grokSchemaBundleSigningPayload(BUILTIN_GROK_SCHEMA_BUNDLE)) return false; @@ -353,7 +380,8 @@ export async function resolveGrokCompatibility(capabilities: GrokRunCapabilities const keys = source.publicKeys ?? {}; const cacheFile = source.cachePath ?? defaultCachePath(); let bundle = BUILTIN_GROK_SCHEMA_BUNDLE; let bundleSource: GrokCompatibility["bundleSource"] = "built-in"; let diagnostic: GrokCompatibilityDiagnostic | undefined; - const cached = Object.keys(keys).length ? await readCache(cacheFile, keys, now) : null; + const anchor = Object.keys(keys).length ? await readTrustAnchor(cacheFile) : null; + const cached = Object.keys(keys).length ? await readCache(cacheFile, keys, now, anchor) : null; if (cached && trustedBundle(cached, source.checkpoint)) { bundle = cached; bundleSource = "cache"; } if (source.url && Object.keys(keys).length) { try { @@ -361,7 +389,7 @@ export async function resolveGrokCompatibility(capabilities: GrokRunCapabilities const body = await response.text(); if (!response.ok || Buffer.byteLength(body) > MAX_BUNDLE_BYTES) throw new Error("untrusted registry response"); const remote = JSON.parse(body); - if (!verifyGrokSchemaBundle(remote, keys, now) || !trustedBundle(remote, source.checkpoint)) throw new Error("untrusted registry bundle"); + if (!verifyGrokSchemaBundle(remote, keys, now) || !trustedBundle(remote, source.checkpoint) || !meetsTrustAnchor(remote, anchor)) throw new Error("untrusted registry bundle"); bundle = remote; bundleSource = "remote"; await writeCache(cacheFile, remote); } catch { diagnostic = "schema-registry-refresh-rejected"; } } From 62df71fada47ea6ad6aedb7cbd9047be28e8e9e2 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sun, 26 Jul 2026 12:09:08 -0400 Subject: [PATCH 05/61] test(grok): cover compatibility route wiring --- .../harness-routing-copilot-jsonl.test.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) 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 f3826b915..ab6586755 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 @@ -255,6 +255,26 @@ assert.match( /Grok Build chats currently run on this Cave host/, "SSH Grok must fail explicitly instead of falling back to coven run", ); +assert.match( + chatRoute, + /resolveGrokCompatibility\(await probeGrokRunCapabilities\(grokLaunchCommand\(\), harnessSpawnEnv\(body\.familiarId\)\)\)/, + "Grok must probe the exact launcher before selecting a structured schema", +); +assert.match( + chatRoute, + /outputFormat: grokCompatibility\?\.mode === "structured" \? "streaming-json" : null/, + "an unverified Grok client must use plain output rather than an assumed JSON protocol", +); +assert.match( + chatRoute, + /case "tool_start":[\s\S]*?envelopeToolUse[\s\S]*?consumePendingEnvelopeProgress[\s\S]*?consumePendingEnvelopeResult/, + "selected Grok schemas must reconcile reordered progress and terminal tool results through the shared tracker", +); +assert.match( + chatRoute, + /case "unknown":[\s\S]*?quarantineGrokSchema[\s\S]*?redactedGrokEventFingerprint/, + "unknown selected-schema events must quarantine future structured launches with a redacted diagnostic", +); assert.match( chatRoute, From 57b36ccecefc580f335865e980c9eb062ffefd76 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sun, 26 Jul 2026 12:35:07 -0400 Subject: [PATCH 06/61] fix(grok): harden compatibility routing --- docs/grok-compatibility-registry.md | 6 +- scripts/run-tests.mjs | 2 + ...ute-grok-compatibility.integration.test.ts | 103 ++++++++++ src/app/api/chat/send/route.ts | 2 + src/lib/grok-build.test.ts | 18 +- src/lib/grok-compatibility.test.ts | 20 ++ src/lib/grok-compatibility.ts | 189 ++++++++++++++++-- 7 files changed, 302 insertions(+), 38 deletions(-) create mode 100644 src/app/api/chat/send/route-grok-compatibility.integration.test.ts diff --git a/docs/grok-compatibility-registry.md b/docs/grok-compatibility-registry.md index b4f1a376e..6779ee25b 100644 --- a/docs/grok-compatibility-registry.md +++ b/docs/grok-compatibility-registry.md @@ -1,11 +1,11 @@ # Grok Build compatibility registry -Grok Build's built-in profile is limited to the xAI-documented `text`, `thought`, `end`, and `error` `streaming-json` frames. It contains no tool-event aliases. A tool schema is enabled only when the installed launcher advertises `--output-format streaming-json` and a selected Ed25519-signed bundle explicitly names every envelope field and lifecycle event. +Grok Build's built-in profile is limited to the xAI-documented `text`, `thought`, `end`, and `error` `streaming-json` frames. It contains no tool-event aliases. A tool schema is enabled only when the exact locally resolved launcher advertises the value-bearing `--output-format streaming-json` option and a selected Ed25519-signed bundle explicitly names every envelope field and lifecycle event. Help/version probes never receive credential-bearing environment variables and run no model request. Release configuration is public verification material, never a signing key: - `GROK_SCHEMA_REGISTRY_URL` — canonical credential-free HTTPS bundle URL. -- `GROK_SCHEMA_REGISTRY_PUBLIC_KEY`, or `GROK_SCHEMA_REGISTRY_PUBLIC_KEYS` — PEM Ed25519 trust anchor(s), with one to four key IDs for rotation. +- `GROK_SCHEMA_REGISTRY_PUBLIC_KEY`, or `GROK_SCHEMA_REGISTRY_PUBLIC_KEYS` — PEM Ed25519 trust anchor(s), with one to four key IDs for rotation. A bundle signed against a multi-key keyring must carry its exact `keyId`. - `GROK_SCHEMA_REGISTRY_CHECKPOINT` — JSON `{ "sequence": number, "payloadHash": "" }` that anchors first use and rollback resistance. -The release maps these to `NEXT_PUBLIC_COVEN_GROK_SCHEMA_REGISTRY_*`. Development may use `COVEN_GROK_SCHEMA_REGISTRY_*`. The cache is per-user, bounded, atomically replaced, and always reverified; an unknown or malformed selected event quarantines that schema in-process and future turns fall back to plain text. Do not publish a Grok tool schema until its precise stdout envelope is source-verified and captured from an approved non-production fixture. Never store a private key in this repository, app configuration, or release secrets. +The release maps these to `NEXT_PUBLIC_COVEN_GROK_SCHEMA_REGISTRY_*`. Development may use `COVEN_GROK_SCHEMA_REGISTRY_*`. Registry downloads reject redirects, credentials, oversized bodies, malformed bundles, invalid signatures, checkpoint regressions, and cache-anchor rollbacks. The per-user cache is bounded, atomically replaced under a short writer lock, and always reverified; an unknown or malformed selected event quarantines that schema in-process and future turns fall back to plain text. Do not publish a Grok tool schema until its precise stdout envelope is source-verified and captured from an approved non-production fixture. Never store a private key in this repository, app configuration, or release secrets. diff --git a/scripts/run-tests.mjs b/scripts/run-tests.mjs index 3a0f13cf4..a51097de5 100644 --- a/scripts/run-tests.mjs +++ b/scripts/run-tests.mjs @@ -1036,6 +1036,7 @@ export const SUITES = { "src/app/api/chat/send/chat-send-models.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/route-grok-compatibility.integration.test.ts", "src/app/api/chat/send/route-runtime-availability.integration.test.ts", "src/app/api/chat/send/offline-queue.test.ts", "src/app/api/chat/send/first-turn-stub.test.ts", @@ -1308,6 +1309,7 @@ const ALIAS_LOADER = new Set([ "src/app/api/chat/send/chat-send-models.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/route-grok-compatibility.integration.test.ts", "src/app/api/chat/send/route-runtime-availability.integration.test.ts", "src/lib/familiar-workspace-sessions.test.ts", "src/lib/use-projects-scope-transition.test.ts", diff --git a/src/app/api/chat/send/route-grok-compatibility.integration.test.ts b/src/app/api/chat/send/route-grok-compatibility.integration.test.ts new file mode 100644 index 000000000..77583f529 --- /dev/null +++ b/src/app/api/chat/send/route-grok-compatibility.integration.test.ts @@ -0,0 +1,103 @@ +// @ts-nocheck +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import path from "node:path"; + +// Exercise the real Grok route with a deterministic local launcher. This covers +// the boundary pure parser tests cannot: secret-free capability probing, +// verified JSON launch, plain fallback, and persisted native session handling. +const home = await mkdtemp(path.join(homedir(), "cave-grok-route-")); +const bin = path.join(home, "bin"); +const familiarWorkspace = path.join(home, "familiars", "opal"); +await mkdir(bin, { recursive: true }); +await mkdir(familiarWorkspace, { recursive: true }); + +const previousHome = process.env.COVEN_HOME; +const previousCaveHome = process.env.COVEN_CAVE_HOME; +const previousPath = process.env.PATH; +const previousGrokBin = process.env.GROK_BIN; +const previousGrokTestMode = process.env.GROK_TEST_MODE; +const previousXaiApiKey = process.env.XAI_API_KEY; +process.env.COVEN_HOME = home; +process.env.COVEN_CAVE_HOME = path.join(home, "cave"); +process.env.PATH = `${bin}${path.delimiter}${previousPath ?? ""}`; +process.env.XAI_API_KEY = "probe-must-not-receive-this"; + +const executable = process.platform === "win32" ? "grok.cmd" : "grok"; +const windowsShimTarget = path.join(bin, "grok-launcher.js"); +const windowsShimProgram = [ + "const [command] = process.argv.slice(2);", + "if (command === '--help') { if (process.env.XAI_API_KEY) process.exit(12); console.log(process.env.GROK_TEST_MODE === 'plain' ? ' --output-format Output format: text' : ' --output-format Output format: text, streaming-json'); process.exit(0); }", + "if (command === '--version') { if (process.env.XAI_API_KEY) process.exit(12); console.log('1.0.0'); process.exit(0); }", + "if (process.env.GROK_TEST_MODE === 'plain') console.log('plain fallback reply');", + "else console.log('{\\\"type\\\":\\\"text\\\",\\\"data\\\":\\\"verified route reply\\\"}\\n{\\\"type\\\":\\\"end\\\",\\\"sessionId\\\":\\\"native_grok_session\\\"}');", +].join("\n"); +const launcher = process.platform === "win32" + ? [ + "@echo off", + "\"%~dp0\\grok-launcher.js\" %*", + ].join("\r\n") + : [ + "#!/bin/sh", + "if [ \"$1\" = \"--help\" ]; then", + " [ -z \"$XAI_API_KEY\" ] || exit 12", + " if [ \"$GROK_TEST_MODE\" = \"plain\" ]; then printf '%s\\n' ' --output-format Output format: text'; else printf '%s\\n' ' --output-format Output format: text, streaming-json'; fi", + " exit 0", + "fi", + "if [ \"$1\" = \"--version\" ]; then [ -z \"$XAI_API_KEY\" ] || exit 12; printf '%s\\n' '1.0.0'; exit 0; fi", + "if [ \"$GROK_TEST_MODE\" = \"plain\" ]; then printf '%s\\n' 'plain fallback reply'; exit 0; fi", + "printf '%s\\n' '{\"type\":\"text\",\"data\":\"verified route reply\"}' '{\"type\":\"end\",\"sessionId\":\"native_grok_session\"}'", + ].join("\n"); +const launcherPath = path.join(bin, executable); +await writeFile(launcherPath, launcher, { mode: 0o755 }); +if (process.platform === "win32") await writeFile(windowsShimTarget, windowsShimProgram); +process.env.GROK_BIN = launcherPath; + +async function readSse(response: Response) { + assert.equal(response.status, 200, await response.clone().text()); + const body = await response.text(); + return { + body, + events: body.split("\n").filter((line) => line.startsWith("data: ")).map((line) => JSON.parse(line.slice(6))), + }; +} + +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: "grok" } } }); + const project = await createProject({ name: "Grok route fixture", root: familiarWorkspace }); + await grantProjectToFamiliar({ familiarId: "opal", projectId: project.id, source: "human", access: "write" }); + + const structured = await readSse(await POST(new Request("http://localhost/api/chat/send", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ familiarId: "opal", prompt: "fixture", projectRoot: familiarWorkspace }), + }))); + assert.match(structured.body, /"kind":"assistant_chunk","text":"verified route reply"/, "a source-verified selected JSON schema renders assistant text"); + const structuredDone = structured.events.findLast((event) => event.kind === "done"); + assert.equal(typeof structuredDone?.sessionId, "string"); + const conversation = await loadConversation(structuredDone.sessionId); + assert.equal(conversation?.harnessSessionId, "native_grok_session", "the route persists Grok's native resume id separately from Cave's id"); + + process.env.GROK_TEST_MODE = "plain"; + const plain = await readSse(await POST(new Request("http://localhost/api/chat/send", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ familiarId: "opal", prompt: "fallback", projectRoot: familiarWorkspace }), + }))); + assert.match(plain.body, /"kind":"assistant_chunk","text":"plain fallback reply\\n"/, "an unverified output format remains safe plain assistant text"); + assert.match(plain.body, /without tool activity/, "plain fallback provides an accessible compatibility diagnostic"); +} finally { + if (previousHome === undefined) delete process.env.COVEN_HOME; else process.env.COVEN_HOME = previousHome; + if (previousCaveHome === undefined) delete process.env.COVEN_CAVE_HOME; else process.env.COVEN_CAVE_HOME = previousCaveHome; + if (previousPath === undefined) delete process.env.PATH; else process.env.PATH = previousPath; + if (previousGrokBin === undefined) delete process.env.GROK_BIN; else process.env.GROK_BIN = previousGrokBin; + if (previousGrokTestMode === undefined) delete process.env.GROK_TEST_MODE; else process.env.GROK_TEST_MODE = previousGrokTestMode; + if (previousXaiApiKey === undefined) delete process.env.XAI_API_KEY; else process.env.XAI_API_KEY = previousXaiApiKey; + await rm(home, { recursive: true, force: true }); +} + +console.log("route-grok-compatibility.integration.test.ts: ok"); diff --git a/src/app/api/chat/send/route.ts b/src/app/api/chat/send/route.ts index 40d96f3b1..ffa1e04d0 100644 --- a/src/app/api/chat/send/route.ts +++ b/src/app/api/chat/send/route.ts @@ -2421,6 +2421,8 @@ export async function POST(req: Request) { usage: parseStreamJsonUsage(event.usage), costUsd: parseCostUsd(event.totalCostUsd), }; + // Provider error frames are untrusted structured payloads; keep + // their values out of transcript diagnostics. recordStdoutErrorTail("Grok Build returned a structured error", true); return; case "tool_start": { diff --git a/src/lib/grok-build.test.ts b/src/lib/grok-build.test.ts index f7ad9fbdc..1c91e17b5 100644 --- a/src/lib/grok-build.test.ts +++ b/src/lib/grok-build.test.ts @@ -11,7 +11,6 @@ import { import { BUILTIN_GROK_SCHEMA_BUNDLE, parseGrokCompatibilityEvent, - type GrokEventSchema, } from "./grok-compatibility.ts"; const catalog = parseGrokModels(`You are logged in with grok.com.\n\nDefault model: grok-4.5\n\nAvailable models:\n * grok-4.5 (default)\n * grok-code-fast-1`); @@ -102,22 +101,9 @@ assert.deepEqual( ); assert.deepEqual(parseGrokStreamEvent({ type: "thought", data: "hidden" }), { kind: "ignore" }); assert.deepEqual( - parseGrokCompatibilityEvent({ type: "tool_started", id: "call-1", name: "read_file", input: { path: "secret" } }, BUILTIN_GROK_SCHEMA_BUNDLE.schemas[0]), + parseGrokCompatibilityEvent({ type: "future_event" }, BUILTIN_GROK_SCHEMA_BUNDLE.schemas[0]), { kind: "unknown" }, - "undocumented tool names never become activity in the built-in schema", -); -const verifiedToolSchema: GrokEventSchema = { - ...BUILTIN_GROK_SCHEMA_BUNDLE.schemas[0], - id: "fixture-verified-tool-schema", - eventTypes: { - ...BUILTIN_GROK_SCHEMA_BUNDLE.schemas[0].eventTypes, - toolStart: ["tool_started"], toolProgress: ["tool_progress"], toolEnd: ["tool_finished"], toolComplete: ["tool_complete"], - }, -}; -assert.deepEqual( - parseGrokCompatibilityEvent({ type: "tool_started", id: "call-1", name: "read_file", input: { path: "README.md" } }, verifiedToolSchema), - { kind: "tool_start", id: "call-1", name: "read_file", input: { path: "README.md" } }, - "a selected verified schema can declare a complete tool lifecycle without hardcoded protocol guesses", + "unknown future frames never become activity in the built-in schema", ); assert.equal(grokSandboxProfileForPermission("read"), "read"); diff --git a/src/lib/grok-compatibility.test.ts b/src/lib/grok-compatibility.test.ts index 48a1cfb82..d5b24c623 100644 --- a/src/lib/grok-compatibility.test.ts +++ b/src/lib/grok-compatibility.test.ts @@ -5,6 +5,8 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { BUILTIN_GROK_SCHEMA_BUNDLE, + grokProbeEnvironment, + grokRunCapabilitiesFromHelp, grokSchemaBundleSigningPayload, resolveGrokCompatibility, verifyGrokSchemaBundle, @@ -21,6 +23,20 @@ signed.signature = { const keyring = { "test-key": publicKey.export({ type: "spki", format: "pem" }).toString() }; assert.equal(verifyGrokSchemaBundle(signed, keyring), true, "Ed25519 verification accepts a canonical signed bundle"); assert.equal(verifyGrokSchemaBundle({ ...signed, sequence: 3 }, keyring), false, "a signature cannot be replayed onto changed schema data"); +const { keyId: _keyId, signature: _signature, ...unsignedWithoutKeyId } = signed; +const signedWithoutKeyId = { + ...unsignedWithoutKeyId, + signature: { algorithm: "ed25519" as const, value: sign(null, Buffer.from(grokSchemaBundleSigningPayload(unsignedWithoutKeyId)), privateKey).toString("base64") }, +}; +assert.equal(verifyGrokSchemaBundle(signedWithoutKeyId, { "test-key": keyring["test-key"], next: keyring["test-key"] }), false, "rotating keyrings require an explicit signed key id"); + +assert.deepEqual( + grokRunCapabilitiesFromHelp(" --output-format FORMAT Choose text or streaming-json\n --model MODEL\n"), + { version: null, streamingJson: true, options: ["--output-format", "--model"], valueOptions: ["--output-format", "--model"] }, + "the output value must appear in --output-format's own help stanza", +); +assert.equal(grokRunCapabilitiesFromHelp(" --output-format\n --other streaming-json\n").streamingJson, false, "unrelated help prose cannot authorize structured argv"); +assert.deepEqual(grokProbeEnvironment({ NODE_ENV: "test", PATH: "/bin", XAI_API_KEY: "secret", GROK_AUTH_TOKEN: "secret", SERVICE_PASSWORD: "secret" }), { NODE_ENV: "test", PATH: "/bin" }, "capability probes inherit no credential-bearing environment variables"); const supported = { version: "fixture", streamingJson: true, options: ["--output-format"], valueOptions: ["--output-format"] }; const selected = await resolveGrokCompatibility(supported, { publicKeys: keyring, fetch: async () => new Response(JSON.stringify(signed)) }); @@ -28,6 +44,10 @@ assert.equal(selected.mode, "structured"); assert.equal(selected.schema?.id, "grok-build-streaming-json-v1"); const unsupported = await resolveGrokCompatibility({ ...supported, streamingJson: false }); assert.deepEqual(unsupported.diagnostic, "streaming-json-unavailable"); +let unsafeFetchCalled = false; +const unsafeRegistry = await resolveGrokCompatibility(supported, { publicKeys: keyring, url: "http://registry.example/grok.json", fetch: async () => { unsafeFetchCalled = true; return new Response(); } }); +assert.equal(unsafeFetchCalled, false, "an unsafe registry URL is rejected before any request"); +assert.equal(unsafeRegistry.bundleSource, "built-in"); const highWater = structuredClone(signed); highWater.sequence = 3; diff --git a/src/lib/grok-compatibility.ts b/src/lib/grok-compatibility.ts index 03947cf42..fca7387c5 100644 --- a/src/lib/grok-compatibility.ts +++ b/src/lib/grok-compatibility.ts @@ -18,18 +18,71 @@ export type GrokRunCapabilities = { valueOptions: 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 optionTakesValue(help: string, option: string): boolean { + const stanza = optionStanza(help, option); + if (!stanza) return false; + const declaration = stanza.split(/\r?\n/, 1)[0].slice(stanza.indexOf(option) + option.length); + return /(?:^|\s)(?:<[^>]+>|\[[^\]]+\]|[A-Z][A-Z_-]*)(?:\s|$)/.test(declaration) + || /\[(?:string|number|boolean|array|count)\]/i.test(stanza); +} + export function grokRunCapabilitiesFromHelp(help: string, version: string | null = null): GrokRunCapabilities { const options = [...help.matchAll(/^\s*(--[a-z][a-z0-9-]*)\b/gim)].map((match) => match[1]); const unique = [...new Set(options)]; - const valueOptions = unique.filter((option) => new RegExp(`${option.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?:\\s|=)+(?:<[^>]+>|\\[[^\\]]+\\]|[A-Z][A-Z_-]*)`, "i").test(help)); + const valueOptions = unique.filter((option) => optionTakesValue(help, option)); return { version, - streamingJson: /--output-format(?:\s|=)+(?:<[^>]+>|\[[^\]]+\]|[A-Z][A-Z_-]*)?[\s\S]{0,240}\bstreaming-json\b/i.test(help), + // The protocol must be documented in the output option's own stanza; + // a banner or another option mentioning JSON is not launch evidence. + streamingJson: unique.includes("--output-format") + && valueOptions.includes("--output-format") + && /\bstreaming-json\b/i.test(optionStanza(help, "--output-format")), options: unique, valueOptions, }; } +/** Help/version probes never receive API keys or other secret-bearing vars. */ +export function grokProbeEnvironment(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const probeEnv = { ...env }; + for (const key of Object.keys(probeEnv)) { + if (/(?:key|token|secret|password|credential|authorization|^xai_|^grok_.*(?:auth|api))/i.test(key)) delete probeEnv[key]; + } + return probeEnv; +} + +function terminateGrokProbeTree(child: ChildProcessWithoutNullStreams): Promise { + return new Promise((resolve) => { + let settled = false; + let grace: ReturnType | undefined; + const finish = () => { + if (settled) return; + settled = true; + if (grace) clearTimeout(grace); + resolve(); + }; + grace = setTimeout(finish, 1_000); + child.once("close", () => { clearTimeout(grace); finish(); }); + try { + if (process.platform === "win32" && child.pid) { + const killer = spawn("taskkill.exe", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore", windowsHide: true }); + killer.once("close", finish); + killer.once("error", () => { try { child.kill("SIGTERM"); } catch { /* best effort */ } }); + } else if (child.pid) { + // Probes use their own process group on POSIX so a shim cannot leave + // a helper running after a capability timeout. + process.kill(-child.pid, "SIGTERM"); + } else { + child.kill("SIGTERM"); + } + } catch { try { child.kill("SIGTERM"); } catch { /* already exited */ } } + }); +} + /** Bounded, credential-free local contract probe. It never invokes a run. */ export async function probeGrokRunCapabilities( launch: { command: string; fixedArgs: string[] }, @@ -40,12 +93,22 @@ export async function probeGrokRunCapabilities( let output = ""; let settled = false; const finish = (value: string | null) => { if (!settled) { settled = true; resolve(value); } }; try { - const child = spawn(launch.command, [...launch.fixedArgs, ...args], { env, stdio: ["ignore", "pipe", "pipe"], windowsHide: true }) as unknown as ChildProcessWithoutNullStreams; - const append = (chunk: Buffer) => { if (output.length < 64 * 1024) output += chunk.toString().slice(0, 64 * 1024 - output.length); }; + const child = spawn(launch.command, [...launch.fixedArgs, ...args], { + env: grokProbeEnvironment(env), + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + detached: process.platform !== "win32", + }) as unknown as ChildProcessWithoutNullStreams; + let overflowed = false; + const append = (chunk: Buffer) => { + if (output.length >= 64 * 1024) { overflowed = true; return; } + output += chunk.toString().slice(0, 64 * 1024 - output.length); + if (output.length >= 64 * 1024) overflowed = true; + }; child.stdout.on("data", append); child.stderr.on("data", append); - const timer = setTimeout(() => { try { child.kill("SIGTERM"); } catch { /* bounded best effort */ } finish(null); }, timeoutMs); + const timer = setTimeout(() => { void terminateGrokProbeTree(child).finally(() => finish(null)); }, timeoutMs); child.once("error", () => { clearTimeout(timer); finish(null); }); - child.once("close", (code) => { clearTimeout(timer); finish(code === 0 ? output : null); }); + child.once("close", (code) => { clearTimeout(timer); finish(code === 0 && !overflowed ? output : null); }); } catch { finish(null); } }); const [help, versionOutput] = await Promise.all([probe(["--help"]), probe(["--version"])]); @@ -132,6 +195,9 @@ export type GrokParsedEvent = const MAX_BUNDLE_BYTES = 256 * 1024; const MAX_CACHE_BYTES = 512 * 1024; const MAX_SCHEMAS = 32; +const CACHE_LOCK_STALE_MS = 30_000; +const CACHE_LOCK_WAIT_MS = 500; +const CACHE_LOCK_POLL_MS = 20; const schemaQuarantine = new Map(); /** The only source-verified built-in Grok frame contract as of 2026-07-26. */ @@ -202,7 +268,11 @@ export function parseGrokCompatibilityEvent(raw: unknown, schema?: GrokEventSche return name === undefined ? { kind: "unknown" } : { kind: "tool_start", id, name, input: valueField(raw, fields.input) }; } if ((schema.eventTypes.toolProgress ?? []).includes(type)) return { kind: "tool_progress", id, output: valueField(raw, fields.output) }; - const isError = fields.errorStates.includes(stringField(raw, fields.state) ?? "") || valueField(raw, fields.error) === true; + const errorValue = valueField(raw, fields.error); + const isError = fields.errorStates.includes(stringField(raw, fields.state) ?? "") + || errorValue === true + || (typeof errorValue === "string" && errorValue.length > 0) + || (isRecord(errorValue) && Object.keys(errorValue).length > 0); if (schema.eventTypes.toolEnd.includes(type)) return { kind: "tool_end", id, output: valueField(raw, fields.output), isError }; if (schema.eventTypes.toolComplete.includes(type)) { const name = stringField(raw, fields.name); @@ -240,6 +310,11 @@ function validOptions(value: unknown): value is string[] { function validSchema(value: unknown): value is GrokEventSchema { if (!isRecord(value) || !validName(value.id) || !isRecord(value.requires) || value.requires.streamingJson !== true || !isRecord(value.eventTypes) || !isRecord(value.fields) || !isRecord(value.launch)) return false; + if (!Object.keys(value).every((key) => ["id", "priority", "requires", "eventTypes", "fields", "launch"].includes(key))) return false; + if (!Object.keys(value.requires).every((key) => key === "streamingJson" || key === "options")) return false; + if (!Object.keys(value.eventTypes).every((key) => ["ignored", "text", "end", "error", "toolStart", "toolProgress", "toolEnd", "toolComplete"].includes(key))) return false; + if (!Object.keys(value.fields).every((key) => ["type", "text", "sessionId", "message", "usage", "totalCostUsd", "id", "name", "input", "output", "state", "error", "terminalStates", "errorStates"].includes(key))) return false; + if (!Object.keys(value.launch).every((key) => key === "outputOption" || key === "outputValue")) return false; if (value.priority !== undefined && (typeof value.priority !== "number" || !Number.isSafeInteger(value.priority) || Math.abs(value.priority) > 1_000)) return false; if (value.requires.options !== undefined && !validOptions(value.requires.options)) return false; if (value.launch.outputOption !== "--output-format" || value.launch.outputValue !== "streaming-json") return false; @@ -262,7 +337,7 @@ export function isGrokSchemaBundle(value: unknown, now = Date.now(), allowExpire if (issued === null || expires === null || issued > now || expires <= issued || (!allowExpired && expires <= now)) return false; if (value.keyId !== undefined && !validName(value.keyId)) return false; if (value.retiredSchemaIds !== undefined && (!validAliases(value.retiredSchemaIds, true) || !value.retiredSchemaIds.every((id) => BUILTIN_GROK_SCHEMA_BUNDLE.schemas.some((schema) => schema.id === id)))) return false; - if (value.signature !== undefined && (!isRecord(value.signature) || value.signature.algorithm !== "ed25519" || typeof value.signature.value !== "string")) 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 ids = value.schemas.map((schema) => schema.id); return new Set(ids).size === ids.length; } @@ -270,6 +345,7 @@ export function isGrokSchemaBundle(value: unknown, now = Date.now(), allowExpire export function verifyGrokSchemaBundle(value: unknown, publicKeys: string | GrokRegistryKeyring, now = Date.now()): value is GrokSchemaBundle { if (!isGrokSchemaBundle(value, now) || !value.signature) return false; const keys = typeof publicKeys === "string" ? { legacy: publicKeys } : publicKeys; + if (Object.keys(keys).length > 1 && value.keyId === undefined) return false; const candidates = Object.entries(keys).filter(([id, pem]) => validName(id) && typeof pem === "string" && (value.keyId === undefined || value.keyId === id)); if (candidates.length === 0 || candidates.length > 4 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value.signature.value)) return false; try { @@ -284,7 +360,12 @@ export function verifyGrokSchemaBundle(value: unknown, publicKeys: string | Grok export function selectGrokSchema(schemas: GrokEventSchema[], capabilities: GrokRunCapabilities): GrokEventSchema | null { if (!capabilities.streamingJson) return null; const options = new Set(capabilities.options); - const matches = schemas.filter((schema) => schema.requires.options?.every((option) => options.has(option)) ?? true); + const valueOptions = new Set(capabilities.valueOptions); + const matches = schemas.filter((schema) => + (schema.requires.options?.every((option) => options.has(option)) ?? true) + && options.has(schema.launch.outputOption) + && valueOptions.has(schema.launch.outputOption), + ); if (!matches.length) return null; const specificity = Math.max(...matches.map((schema) => schema.requires.options?.length ?? 0)); const preferred = matches.filter((schema) => (schema.requires.options?.length ?? 0) === specificity); @@ -325,11 +406,41 @@ function defaultCachePath(): string { } function trustAnchorPath(file: string): string { return `${file}.anchor`; } +function cacheLockPath(file: string): string { return `${file}.lock`; } function validTrustAnchor(value: unknown): value is TrustAnchor { return isRecord(value) && Object.keys(value).length === 2 && typeof value.sequence === "number" && Number.isSafeInteger(value.sequence) && value.sequence >= 1 && typeof value.payloadHash === "string" && /^[a-f0-9]{64}$/.test(value.payloadHash); } +function isSafeRegistryUrl(value: string | undefined): value is string { + try { + const parsed = value ? new URL(value) : null; + return !!parsed && parsed.protocol === "https:" && !parsed.username && !parsed.password; + } catch { return false; } +} + +async function readBoundedRegistryBody(response: Response): Promise { + const declaredLength = response.headers.get("content-length"); + if (declaredLength && (!/^\d+$/.test(declaredLength) || Number(declaredLength) > MAX_BUNDLE_BYTES)) throw new Error("oversized registry response"); + if (!response.body) return ""; + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + while (true) { + const part = await reader.read(); + if (part.done) break; + total += part.value.byteLength; + if (total > MAX_BUNDLE_BYTES) throw new Error("oversized registry response"); + chunks.push(part.value); + } + } finally { reader.releaseLock(); } + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; } + return new TextDecoder("utf-8", { fatal: true }).decode(bytes); +} + async function readTrustAnchor(file: string): Promise { try { const raw = await fs.readFile(trustAnchorPath(file), "utf8"); @@ -348,24 +459,56 @@ async function readCache(file: string, keys: GrokRegistryKeyring, now: number, a const raw = await fs.readFile(file, "utf8"); if (Buffer.byteLength(raw) > MAX_CACHE_BYTES) return null; const cached = JSON.parse(raw) as Cached; - return verifyGrokSchemaBundle(cached?.bundle, keys, now) && meetsTrustAnchor(cached.bundle, anchor) ? cached.bundle : null; + return isRecord(cached) && Object.keys(cached).length === 2 && Number.isSafeInteger(cached.checkedAt) + && verifyGrokSchemaBundle(cached.bundle, keys, now) && meetsTrustAnchor(cached.bundle, anchor) + ? cached.bundle + : null; } catch { return null; } } -async function writeJsonAtomic(file: string, value: unknown): Promise { +async function writeJsonAtomic(file: string, value: unknown): Promise { const temporary = `${file}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`; try { await fs.mkdir(path.dirname(file), { recursive: true }); await fs.writeFile(temporary, JSON.stringify(value), { mode: 0o600 }); await fs.rename(temporary, file); - } catch { /* Cache is an optimization; a failed write never broadens parsing. */ } + return true; + } catch { return false; /* Cache is an optimization; a failed write never broadens parsing. */ } } -async function writeCache(file: string, bundle: GrokSchemaBundle): Promise { +async function acquireCacheLock(file: string): Promise<(() => Promise) | null> { + const lock = cacheLockPath(file); + const deadline = Date.now() + CACHE_LOCK_WAIT_MS; + while (Date.now() < deadline) { + try { + await fs.mkdir(path.dirname(file), { recursive: true }); + const handle = await fs.open(lock, "wx", 0o600); + await handle.writeFile(`${process.pid}:${randomBytes(8).toString("hex")}`); + await handle.close(); + return async () => { await fs.rm(lock, { force: true }); }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") return null; + try { + const stat = await fs.stat(lock); + if (Date.now() - stat.mtimeMs > CACHE_LOCK_STALE_MS) await fs.rm(lock, { force: true }); + } catch { /* another writer released or replaced the lock */ } + await new Promise((resolve) => setTimeout(resolve, CACHE_LOCK_POLL_MS)); + } + } + return null; +} + +/** Returns false when another process has already committed a newer anchor. */ +async function writeCache(file: string, bundle: GrokSchemaBundle): Promise { + const release = await acquireCacheLock(file); + if (!release) return false; + try { + if (!meetsTrustAnchor(bundle, await readTrustAnchor(file))) return false; // Anchor first: an interruption can make the cache unavailable, never make // an older cache appear acceptable after a newer bundle was trusted. - await writeJsonAtomic(trustAnchorPath(file), { sequence: bundle.sequence, payloadHash: grokSchemaBundlePayloadHash(bundle) }); - await writeJsonAtomic(file, { checkedAt: Date.now(), bundle }); + if (!await writeJsonAtomic(trustAnchorPath(file), { sequence: bundle.sequence, payloadHash: grokSchemaBundlePayloadHash(bundle) })) return false; + return writeJsonAtomic(file, { checkedAt: Date.now(), bundle }); + } finally { await release(); } } function trustedBundle(bundle: GrokSchemaBundle, checkpoint?: GrokRegistryCheckpoint): boolean { @@ -385,12 +528,20 @@ export async function resolveGrokCompatibility(capabilities: GrokRunCapabilities if (cached && trustedBundle(cached, source.checkpoint)) { bundle = cached; bundleSource = "cache"; } if (source.url && Object.keys(keys).length) { try { - const response = await (source.fetch ?? fetch)(source.url, { signal: AbortSignal.timeout(5_000) }); - const body = await response.text(); - if (!response.ok || Buffer.byteLength(body) > MAX_BUNDLE_BYTES) throw new Error("untrusted registry response"); + if (!isSafeRegistryUrl(source.url)) throw new Error("unsafe registry URL"); + const response = await (source.fetch ?? fetch)(source.url, { signal: AbortSignal.timeout(5_000), credentials: "omit", redirect: "error" }); + const body = await readBoundedRegistryBody(response); + if (!response.ok) throw new Error("untrusted registry response"); const remote = JSON.parse(body); if (!verifyGrokSchemaBundle(remote, keys, now) || !trustedBundle(remote, source.checkpoint) || !meetsTrustAnchor(remote, anchor)) throw new Error("untrusted registry bundle"); - bundle = remote; bundleSource = "remote"; await writeCache(cacheFile, remote); + if (await writeCache(cacheFile, remote)) { + bundle = remote; bundleSource = "remote"; + } else { + const currentAnchor = await readTrustAnchor(cacheFile); + const currentCache = await readCache(cacheFile, keys, now, currentAnchor); + if (!currentCache || !trustedBundle(currentCache, source.checkpoint)) throw new Error("registry cache write rejected"); + bundle = currentCache; bundleSource = "cache"; diagnostic = "schema-registry-refresh-rejected"; + } } catch { diagnostic = "schema-registry-refresh-rejected"; } } const remoteById = new Map(bundle.schemas.map((schema) => [schema.id, schema])); From d18c0d99aa23d1a83750144bd1c425a45282b18d Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sun, 26 Jul 2026 12:51:36 -0400 Subject: [PATCH 07/61] fix(grok): quarantine malformed compatibility frames --- docs/grok-compatibility-registry.md | 4 ++++ .../route-grok-compatibility.integration.test.ts | 10 ++++++++++ src/lib/grok-compatibility.test.ts | 8 ++++++++ src/lib/grok-compatibility.ts | 15 +++++++++++++-- 4 files changed, 35 insertions(+), 2 deletions(-) diff --git a/docs/grok-compatibility-registry.md b/docs/grok-compatibility-registry.md index 6779ee25b..e4a24f9d7 100644 --- a/docs/grok-compatibility-registry.md +++ b/docs/grok-compatibility-registry.md @@ -9,3 +9,7 @@ Release configuration is public verification material, never a signing key: - `GROK_SCHEMA_REGISTRY_CHECKPOINT` — JSON `{ "sequence": number, "payloadHash": "" }` that anchors first use and rollback resistance. The release maps these to `NEXT_PUBLIC_COVEN_GROK_SCHEMA_REGISTRY_*`. Development may use `COVEN_GROK_SCHEMA_REGISTRY_*`. Registry downloads reject redirects, credentials, oversized bodies, malformed bundles, invalid signatures, checkpoint regressions, and cache-anchor rollbacks. The per-user cache is bounded, atomically replaced under a short writer lock, and always reverified; an unknown or malformed selected event quarantines that schema in-process and future turns fall back to plain text. Do not publish a Grok tool schema until its precise stdout envelope is source-verified and captured from an approved non-production fixture. Never store a private key in this repository, app configuration, or release secrets. + +## Evidence + +Verified on 2026-07-26 from xAI's [Grok Build overview](https://docs.x.ai/build/overview), which documents headless `grok -p ... --output-format streaming-json`, and the upstream [headless-mode source documentation](https://github.com/xai-org/grok-build/blob/47348d13ec4508dcfe440e34c6d511bb02998fb2/crates/codegen/xai-grok-pager/docs/user-guide/14-headless-mode.md) at Grok Build revision [`47348d13ec4508dcfe440e34c6d511bb02998fb2`](https://github.com/xai-org/grok-build/tree/47348d13ec4508dcfe440e34c6d511bb02998fb2). Those sources establish the baseline text/thought/end/error transport only; they do **not** document tool lifecycle envelope names. No live Grok capture is stored or required. Future signed schemas need separately recorded source evidence for every added event and field before release owners publish them. diff --git a/src/app/api/chat/send/route-grok-compatibility.integration.test.ts b/src/app/api/chat/send/route-grok-compatibility.integration.test.ts index 77583f529..8307e0450 100644 --- a/src/app/api/chat/send/route-grok-compatibility.integration.test.ts +++ b/src/app/api/chat/send/route-grok-compatibility.integration.test.ts @@ -31,6 +31,7 @@ const windowsShimProgram = [ "if (command === '--help') { if (process.env.XAI_API_KEY) process.exit(12); console.log(process.env.GROK_TEST_MODE === 'plain' ? ' --output-format Output format: text' : ' --output-format Output format: text, streaming-json'); process.exit(0); }", "if (command === '--version') { if (process.env.XAI_API_KEY) process.exit(12); console.log('1.0.0'); process.exit(0); }", "if (process.env.GROK_TEST_MODE === 'plain') console.log('plain fallback reply');", + "else if (process.env.GROK_TEST_MODE === 'malformed') console.log('unframed private tool payload');", "else console.log('{\\\"type\\\":\\\"text\\\",\\\"data\\\":\\\"verified route reply\\\"}\\n{\\\"type\\\":\\\"end\\\",\\\"sessionId\\\":\\\"native_grok_session\\\"}');", ].join("\n"); const launcher = process.platform === "win32" @@ -47,6 +48,7 @@ const launcher = process.platform === "win32" "fi", "if [ \"$1\" = \"--version\" ]; then [ -z \"$XAI_API_KEY\" ] || exit 12; printf '%s\\n' '1.0.0'; exit 0; fi", "if [ \"$GROK_TEST_MODE\" = \"plain\" ]; then printf '%s\\n' 'plain fallback reply'; exit 0; fi", + "if [ \"$GROK_TEST_MODE\" = \"malformed\" ]; then printf '%s\\n' 'unframed private tool payload'; exit 0; fi", "printf '%s\\n' '{\"type\":\"text\",\"data\":\"verified route reply\"}' '{\"type\":\"end\",\"sessionId\":\"native_grok_session\"}'", ].join("\n"); const launcherPath = path.join(bin, executable); @@ -90,6 +92,14 @@ try { }))); assert.match(plain.body, /"kind":"assistant_chunk","text":"plain fallback reply\\n"/, "an unverified output format remains safe plain assistant text"); assert.match(plain.body, /without tool activity/, "plain fallback provides an accessible compatibility diagnostic"); + + process.env.GROK_TEST_MODE = "malformed"; + const malformed = await readSse(await POST(new Request("http://localhost/api/chat/send", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ familiarId: "opal", prompt: "malformed", projectRoot: familiarWorkspace }), + }))); + assert.match(malformed.body, /unframed-jsonl-event/, "unframed selected-schema output emits an accessible compatibility diagnostic"); + assert.doesNotMatch(malformed.body, /private tool payload/, "unframed structured payloads never enter assistant text or diagnostics"); } 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; diff --git a/src/lib/grok-compatibility.test.ts b/src/lib/grok-compatibility.test.ts index d5b24c623..b05b4bd53 100644 --- a/src/lib/grok-compatibility.test.ts +++ b/src/lib/grok-compatibility.test.ts @@ -8,6 +8,7 @@ import { grokProbeEnvironment, grokRunCapabilitiesFromHelp, grokSchemaBundleSigningPayload, + isGrokSchemaBundle, resolveGrokCompatibility, verifyGrokSchemaBundle, } from "./grok-compatibility.ts"; @@ -23,6 +24,13 @@ signed.signature = { const keyring = { "test-key": publicKey.export({ type: "spki", format: "pem" }).toString() }; assert.equal(verifyGrokSchemaBundle(signed, keyring), true, "Ed25519 verification accepts a canonical signed bundle"); assert.equal(verifyGrokSchemaBundle({ ...signed, sequence: 3 }, keyring), false, "a signature cannot be replayed onto changed schema data"); +const ambiguous = structuredClone(signed); +ambiguous.schemas[0].eventTypes.toolStart = ["text"]; +assert.equal(isGrokSchemaBundle(ambiguous), false, "ambiguous event aliases are rejected before a signed schema can be selected"); +const incompleteTool = structuredClone(signed); +incompleteTool.schemas[0].eventTypes.toolStart = ["tool_started"]; +incompleteTool.schemas[0].fields.id = []; +assert.equal(isGrokSchemaBundle(incompleteTool), false, "tool schemas must declare their stable id field before selection"); const { keyId: _keyId, signature: _signature, ...unsignedWithoutKeyId } = signed; const signedWithoutKeyId = { ...unsignedWithoutKeyId, diff --git a/src/lib/grok-compatibility.ts b/src/lib/grok-compatibility.ts index fca7387c5..9e93d8d9b 100644 --- a/src/lib/grok-compatibility.ts +++ b/src/lib/grok-compatibility.ts @@ -319,9 +319,20 @@ function validSchema(value: unknown): value is GrokEventSchema { if (value.requires.options !== undefined && !validOptions(value.requires.options)) return false; if (value.launch.outputOption !== "--output-format" || value.launch.outputValue !== "streaming-json") return false; const events = value.eventTypes; - if (![events.ignored, events.text, events.end, events.error, events.toolStart, events.toolEnd, events.toolComplete].every((items) => validAliases(items, true)) || (events.toolProgress !== undefined && !validAliases(events.toolProgress, true))) return false; + const eventGroups = [events.ignored, events.text, events.end, events.error, events.toolStart, events.toolProgress ?? [], events.toolEnd, events.toolComplete]; + if (!eventGroups.every((items) => validAliases(items, true))) return false; + // A signed schema is still untrusted input: reject ambiguous event names and + // incomplete tool envelopes before selection rather than quarantining only + // after a real user's stream reaches the parser. + if (new Set(eventGroups.flat()).size !== eventGroups.flat().length) return false; const f = value.fields; - return [f.type, f.text, f.sessionId, f.message, f.usage, f.totalCostUsd, f.id, f.name, f.input, f.output, f.state, f.error, f.terminalStates, f.errorStates].every((items) => validAliases(items, true)); + if (![f.type, f.text, f.sessionId, f.message, f.usage, f.totalCostUsd, f.id, f.name, f.input, f.output, f.state, f.error, f.terminalStates, f.errorStates].every((items) => validAliases(items, true))) return false; + const typedEvents = events as GrokEventSchema["eventTypes"]; + const typedFields = f as GrokEventSchema["fields"]; + if (!typedFields.type.length) return false; + if (typedEvents.text.length && !typedFields.text.length) return false; + if ((typedEvents.toolStart.length || typedEvents.toolEnd.length || typedEvents.toolComplete.length || (typedEvents.toolProgress?.length ?? 0)) && !typedFields.id.length) return false; + return !(typedEvents.toolStart.length || typedEvents.toolComplete.length) || typedFields.name.length > 0; } function canonicalTime(value: unknown): number | null { From 1e1136b35cec188a1f996f51fffb930261a05476 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sun, 26 Jul 2026 13:08:43 -0400 Subject: [PATCH 08/61] fix(grok): preserve fail-closed fallback integrity --- ...ute-grok-compatibility.integration.test.ts | 14 +++++++++-- src/app/api/chat/send/route.ts | 4 ++++ src/lib/grok-compatibility.test.ts | 24 ++++++++++++++++++- src/lib/grok-compatibility.ts | 13 +++++++++- 4 files changed, 51 insertions(+), 4 deletions(-) diff --git a/src/app/api/chat/send/route-grok-compatibility.integration.test.ts b/src/app/api/chat/send/route-grok-compatibility.integration.test.ts index 8307e0450..9de3a0e99 100644 --- a/src/app/api/chat/send/route-grok-compatibility.integration.test.ts +++ b/src/app/api/chat/send/route-grok-compatibility.integration.test.ts @@ -28,9 +28,10 @@ const executable = process.platform === "win32" ? "grok.cmd" : "grok"; const windowsShimTarget = path.join(bin, "grok-launcher.js"); const windowsShimProgram = [ "const [command] = process.argv.slice(2);", - "if (command === '--help') { if (process.env.XAI_API_KEY) process.exit(12); console.log(process.env.GROK_TEST_MODE === 'plain' ? ' --output-format Output format: text' : ' --output-format Output format: text, streaming-json'); process.exit(0); }", + "if (command === '--help') { if (process.env.XAI_API_KEY) process.exit(12); console.log(['plain', 'plain-structured'].includes(process.env.GROK_TEST_MODE ?? '') ? ' --output-format Output format: text' : ' --output-format Output format: text, streaming-json'); process.exit(0); }", "if (command === '--version') { if (process.env.XAI_API_KEY) process.exit(12); console.log('1.0.0'); process.exit(0); }", "if (process.env.GROK_TEST_MODE === 'plain') console.log('plain fallback reply');", + "else if (process.env.GROK_TEST_MODE === 'plain-structured') console.log('{\\\"type\\\":\\\"tool_started\\\",\\\"input\\\":\\\"private tool payload\\\"}');", "else if (process.env.GROK_TEST_MODE === 'malformed') console.log('unframed private tool payload');", "else console.log('{\\\"type\\\":\\\"text\\\",\\\"data\\\":\\\"verified route reply\\\"}\\n{\\\"type\\\":\\\"end\\\",\\\"sessionId\\\":\\\"native_grok_session\\\"}');", ].join("\n"); @@ -43,11 +44,12 @@ const launcher = process.platform === "win32" "#!/bin/sh", "if [ \"$1\" = \"--help\" ]; then", " [ -z \"$XAI_API_KEY\" ] || exit 12", - " if [ \"$GROK_TEST_MODE\" = \"plain\" ]; then printf '%s\\n' ' --output-format Output format: text'; else printf '%s\\n' ' --output-format Output format: text, streaming-json'; fi", + " if [ \"$GROK_TEST_MODE\" = \"plain\" ] || [ \"$GROK_TEST_MODE\" = \"plain-structured\" ]; then printf '%s\\n' ' --output-format Output format: text'; else printf '%s\\n' ' --output-format Output format: text, streaming-json'; fi", " exit 0", "fi", "if [ \"$1\" = \"--version\" ]; then [ -z \"$XAI_API_KEY\" ] || exit 12; printf '%s\\n' '1.0.0'; exit 0; fi", "if [ \"$GROK_TEST_MODE\" = \"plain\" ]; then printf '%s\\n' 'plain fallback reply'; exit 0; fi", + "if [ \"$GROK_TEST_MODE\" = \"plain-structured\" ]; then printf '%s\\n' '{\"type\":\"tool_started\",\"input\":\"private tool payload\"}'; exit 0; fi", "if [ \"$GROK_TEST_MODE\" = \"malformed\" ]; then printf '%s\\n' 'unframed private tool payload'; exit 0; fi", "printf '%s\\n' '{\"type\":\"text\",\"data\":\"verified route reply\"}' '{\"type\":\"end\",\"sessionId\":\"native_grok_session\"}'", ].join("\n"); @@ -93,6 +95,14 @@ try { assert.match(plain.body, /"kind":"assistant_chunk","text":"plain fallback reply\\n"/, "an unverified output format remains safe plain assistant text"); assert.match(plain.body, /without tool activity/, "plain fallback provides an accessible compatibility diagnostic"); + process.env.GROK_TEST_MODE = "plain-structured"; + const unverifiedStructured = await readSse(await POST(new Request("http://localhost/api/chat/send", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ familiarId: "opal", prompt: "unsafe fallback", projectRoot: familiarWorkspace }), + }))); + assert.match(unverifiedStructured.body, /unverified-structured-output/, "plain fallback reports an accessible fixed diagnostic for raw structured output"); + assert.doesNotMatch(unverifiedStructured.body, /private tool payload/, "plain fallback never persists unverified structured payload values as assistant text or diagnostics"); + process.env.GROK_TEST_MODE = "malformed"; const malformed = await readSse(await POST(new Request("http://localhost/api/chat/send", { method: "POST", headers: { "content-type": "application/json" }, diff --git a/src/app/api/chat/send/route.ts b/src/app/api/chat/send/route.ts index ffa1e04d0..7d53e0722 100644 --- a/src/app/api/chat/send/route.ts +++ b/src/app/api/chat/send/route.ts @@ -2351,6 +2351,10 @@ export async function POST(req: Request) { pushProgress("grok-compatibility", label, "error", grokCompatibility.diagnostic); } if (grokCompatibility?.mode === "plain") { + // Plain output is safe only while it is prose. A changed client can + // still emit an unverified JSON envelope even after capability + // probing failed; never turn that possible tool payload into a + // persisted assistant message or diagnostic. let unverifiedStructuredOutput = false; if (isJson) { try { diff --git a/src/lib/grok-compatibility.test.ts b/src/lib/grok-compatibility.test.ts index b05b4bd53..0492e4285 100644 --- a/src/lib/grok-compatibility.test.ts +++ b/src/lib/grok-compatibility.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { generateKeyPairSync, sign } from "node:crypto"; -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdir, mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { @@ -71,6 +71,28 @@ try { const rollback = await resolveGrokCompatibility(supported, { publicKeys: keyring, cachePath, url: "https://registry.example/grok.json", fetch: async () => new Response(JSON.stringify(signed)) }); assert.equal(rollback.bundleSource, "cache", "a lower signed sequence cannot replace the local high-water contract"); assert.equal(rollback.diagnostic, "schema-registry-refresh-rejected"); + + // Simulate an interruption after the high-water anchor is committed but + // before the replacement cache file can be installed. The prior cache was + // selected before fetch, so this verifies the current turn drops it too. + const interruptedBundle = structuredClone(highWater); + interruptedBundle.sequence = 4; + interruptedBundle.signature = { + algorithm: "ed25519", + value: sign(null, Buffer.from(grokSchemaBundleSigningPayload(interruptedBundle)), privateKey).toString("base64"), + }; + const interruptedRefresh = await resolveGrokCompatibility(supported, { + publicKeys: keyring, + cachePath, + url: "https://registry.example/grok.json", + fetch: async () => { + await rm(cachePath, { force: true }); + await mkdir(cachePath); + return new Response(JSON.stringify(interruptedBundle)); + }, + }); + assert.equal(interruptedRefresh.bundleSource, "built-in", "a cache below a newly committed high-water anchor is not selected in the interrupted refresh turn"); + assert.equal(interruptedRefresh.diagnostic, "schema-registry-refresh-rejected"); } finally { await rm(cacheDirectory, { recursive: true, force: true }); } diff --git a/src/lib/grok-compatibility.ts b/src/lib/grok-compatibility.ts index 9e93d8d9b..cc1dc6947 100644 --- a/src/lib/grok-compatibility.ts +++ b/src/lib/grok-compatibility.ts @@ -553,7 +553,18 @@ export async function resolveGrokCompatibility(capabilities: GrokRunCapabilities if (!currentCache || !trustedBundle(currentCache, source.checkpoint)) throw new Error("registry cache write rejected"); bundle = currentCache; bundleSource = "cache"; diagnostic = "schema-registry-refresh-rejected"; } - } catch { diagnostic = "schema-registry-refresh-rejected"; } + } catch { + // `writeCache` commits the high-water anchor before its cache record. + // If the process loses the cache write after that point, the bundle that + // was read before the refresh is now below the durable anchor and cannot + // remain selected for this turn. + const currentAnchor = await readTrustAnchor(cacheFile); + if (bundleSource !== "built-in" && !meetsTrustAnchor(bundle, currentAnchor)) { + bundle = BUILTIN_GROK_SCHEMA_BUNDLE; + bundleSource = "built-in"; + } + diagnostic = "schema-registry-refresh-rejected"; + } } const remoteById = new Map(bundle.schemas.map((schema) => [schema.id, schema])); const candidates = bundleSource === "built-in" ? bundle.schemas : [...bundle.schemas, ...BUILTIN_GROK_SCHEMA_BUNDLE.schemas.filter((schema) => !bundle.retiredSchemaIds?.includes(schema.id) && !remoteById.has(schema.id))]; From 36acd2998b2c093a573eb6302d480966e3d1f98b Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sun, 26 Jul 2026 13:11:52 -0400 Subject: [PATCH 09/61] fix(grok): scrub proxy credentials from probes --- src/lib/grok-compatibility.test.ts | 2 +- src/lib/grok-compatibility.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/grok-compatibility.test.ts b/src/lib/grok-compatibility.test.ts index 0492e4285..878477173 100644 --- a/src/lib/grok-compatibility.test.ts +++ b/src/lib/grok-compatibility.test.ts @@ -44,7 +44,7 @@ assert.deepEqual( "the output value must appear in --output-format's own help stanza", ); assert.equal(grokRunCapabilitiesFromHelp(" --output-format\n --other streaming-json\n").streamingJson, false, "unrelated help prose cannot authorize structured argv"); -assert.deepEqual(grokProbeEnvironment({ NODE_ENV: "test", PATH: "/bin", XAI_API_KEY: "secret", GROK_AUTH_TOKEN: "secret", SERVICE_PASSWORD: "secret" }), { NODE_ENV: "test", PATH: "/bin" }, "capability probes inherit no credential-bearing environment variables"); +assert.deepEqual(grokProbeEnvironment({ NODE_ENV: "test", PATH: "/bin", XAI_API_KEY: "secret", GROK_AUTH_TOKEN: "secret", SERVICE_PASSWORD: "secret", SESSION_AUTH: "secret", HTTPS_PROXY: "https://user:secret@proxy.example" }), { NODE_ENV: "test", PATH: "/bin" }, "capability probes inherit no credential-bearing environment variables"); const supported = { version: "fixture", streamingJson: true, options: ["--output-format"], valueOptions: ["--output-format"] }; const selected = await resolveGrokCompatibility(supported, { publicKeys: keyring, fetch: async () => new Response(JSON.stringify(signed)) }); diff --git a/src/lib/grok-compatibility.ts b/src/lib/grok-compatibility.ts index cc1dc6947..f8fa5afcd 100644 --- a/src/lib/grok-compatibility.ts +++ b/src/lib/grok-compatibility.ts @@ -50,7 +50,7 @@ export function grokRunCapabilitiesFromHelp(help: string, version: string | null export function grokProbeEnvironment(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { const probeEnv = { ...env }; for (const key of Object.keys(probeEnv)) { - if (/(?:key|token|secret|password|credential|authorization|^xai_|^grok_.*(?:auth|api))/i.test(key)) delete probeEnv[key]; + if (/(?:key|token|secret|password|credential|auth(?:orization)?|cookie|proxy|^xai_|^grok_.*(?:auth|api))/i.test(key)) delete probeEnv[key]; } return probeEnv; } From 6bdd2feaf5cb7aa26cd8b4c44d3d0e1a2ceaf6fc Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sun, 26 Jul 2026 13:23:54 -0400 Subject: [PATCH 10/61] fix(grok): preserve trusted registry cache invariants --- src/lib/grok-compatibility.test.ts | 29 ++++++++++++- src/lib/grok-compatibility.ts | 66 ++++++++++++++++++++++-------- 2 files changed, 77 insertions(+), 18 deletions(-) diff --git a/src/lib/grok-compatibility.test.ts b/src/lib/grok-compatibility.test.ts index 878477173..3abbaa5bb 100644 --- a/src/lib/grok-compatibility.test.ts +++ b/src/lib/grok-compatibility.test.ts @@ -1,12 +1,13 @@ import assert from "node:assert/strict"; import { generateKeyPairSync, sign } from "node:crypto"; -import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { BUILTIN_GROK_SCHEMA_BUNDLE, grokProbeEnvironment, grokRunCapabilitiesFromHelp, + grokSchemaBundlePayloadHash, grokSchemaBundleSigningPayload, isGrokSchemaBundle, resolveGrokCompatibility, @@ -68,10 +69,31 @@ const cachePath = path.join(cacheDirectory, "schema.json"); try { const refreshed = await resolveGrokCompatibility(supported, { publicKeys: keyring, cachePath, url: "https://registry.example/grok.json", fetch: async () => new Response(JSON.stringify(highWater)) }); assert.equal(refreshed.bundleSource, "remote"); - const rollback = await resolveGrokCompatibility(supported, { publicKeys: keyring, cachePath, url: "https://registry.example/grok.json", fetch: async () => new Response(JSON.stringify(signed)) }); + let freshCacheFetched = false; + const freshCache = await resolveGrokCompatibility(supported, { + publicKeys: keyring, + cachePath, + url: "https://registry.example/grok.json", + fetch: async () => { freshCacheFetched = true; return new Response(JSON.stringify(signed)); }, + }); + assert.equal(freshCache.bundleSource, "cache", "a fresh verified cache remains available without a network round trip"); + assert.equal(freshCacheFetched, false, "a fresh cache does not delay every chat on registry I/O"); + const rollback = await resolveGrokCompatibility(supported, { publicKeys: keyring, cachePath, now: () => Date.now() + 7 * 60 * 60 * 1000, url: "https://registry.example/grok.json", fetch: async () => new Response(JSON.stringify(signed)) }); assert.equal(rollback.bundleSource, "cache", "a lower signed sequence cannot replace the local high-water contract"); assert.equal(rollback.diagnostic, "schema-registry-refresh-rejected"); + // Losing the durable anchor must not turn the still-present cache into a + // first-use state where an older signed response can replace it. + await rm(`${cachePath}.anchor`); + const missingAnchor = await resolveGrokCompatibility(supported, { + publicKeys: keyring, + cachePath, + url: "https://registry.example/grok.json", + fetch: async () => new Response(JSON.stringify(signed)), + }); + assert.equal(missingAnchor.bundleSource, "built-in", "a cache without its durable high-water anchor is never selected or refreshed"); + assert.equal(missingAnchor.diagnostic, "cached-schema-unavailable"); + // Simulate an interruption after the high-water anchor is committed but // before the replacement cache file can be installed. The prior cache was // selected before fetch, so this verifies the current turn drops it too. @@ -81,9 +103,12 @@ try { algorithm: "ed25519", value: sign(null, Buffer.from(grokSchemaBundleSigningPayload(interruptedBundle)), privateKey).toString("base64"), }; + // Restore the expected anchor before exercising an interrupted update. + await writeFile(`${cachePath}.anchor`, JSON.stringify({ sequence: highWater.sequence, payloadHash: grokSchemaBundlePayloadHash(highWater) })); const interruptedRefresh = await resolveGrokCompatibility(supported, { publicKeys: keyring, cachePath, + now: () => Date.now() + 14 * 60 * 60 * 1000, url: "https://registry.example/grok.json", fetch: async () => { await rm(cachePath, { force: true }); diff --git a/src/lib/grok-compatibility.ts b/src/lib/grok-compatibility.ts index f8fa5afcd..b7ddc75ae 100644 --- a/src/lib/grok-compatibility.ts +++ b/src/lib/grok-compatibility.ts @@ -195,6 +195,7 @@ export type GrokParsedEvent = const MAX_BUNDLE_BYTES = 256 * 1024; const MAX_CACHE_BYTES = 512 * 1024; const MAX_SCHEMAS = 32; +const CACHE_TTL_MS = 6 * 60 * 60 * 1000; const CACHE_LOCK_STALE_MS = 30_000; const CACHE_LOCK_WAIT_MS = 500; const CACHE_LOCK_POLL_MS = 20; @@ -461,18 +462,26 @@ async function readTrustAnchor(file: string): Promise { } catch { return null; } } +async function cacheRecordExists(file: string): Promise { + try { + return (await fs.stat(file)).isFile(); + } catch { + return false; + } +} + function meetsTrustAnchor(bundle: GrokSchemaBundle, anchor: TrustAnchor | null): boolean { return !anchor || bundle.sequence > anchor.sequence || (bundle.sequence === anchor.sequence && grokSchemaBundlePayloadHash(bundle) === anchor.payloadHash); } -async function readCache(file: string, keys: GrokRegistryKeyring, now: number, anchor: TrustAnchor | null): Promise { +async function readCache(file: string, keys: GrokRegistryKeyring, now: number, anchor: TrustAnchor | null): Promise { try { const raw = await fs.readFile(file, "utf8"); if (Buffer.byteLength(raw) > MAX_CACHE_BYTES) return null; const cached = JSON.parse(raw) as Cached; return isRecord(cached) && Object.keys(cached).length === 2 && Number.isSafeInteger(cached.checkedAt) && verifyGrokSchemaBundle(cached.bundle, keys, now) && meetsTrustAnchor(cached.bundle, anchor) - ? cached.bundle + ? cached : null; } catch { return null; } } @@ -487,16 +496,30 @@ async function writeJsonAtomic(file: string, value: unknown): Promise { } catch { return false; /* Cache is an optimization; a failed write never broadens parsing. */ } } -async function acquireCacheLock(file: string): Promise<(() => Promise) | null> { +type CacheLock = { + owns: () => Promise; + release: () => Promise; +}; + +async function acquireCacheLock(file: string): Promise { const lock = cacheLockPath(file); const deadline = Date.now() + CACHE_LOCK_WAIT_MS; while (Date.now() < deadline) { try { await fs.mkdir(path.dirname(file), { recursive: true }); const handle = await fs.open(lock, "wx", 0o600); - await handle.writeFile(`${process.pid}:${randomBytes(8).toString("hex")}`); + const token = `${process.pid}:${randomBytes(8).toString("hex")}`; + await handle.writeFile(token); await handle.close(); - return async () => { await fs.rm(lock, { force: true }); }; + const owns = async () => { + try { return await fs.readFile(lock, "utf8") === token; } catch { return false; } + }; + return { + owns, + // A stale-lock taker may install its own lock before this writer + // resumes. Never let the former owner remove that newer lock. + release: async () => { if (await owns()) await fs.rm(lock, { force: true }); }, + }; } catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") return null; try { @@ -510,16 +533,18 @@ async function acquireCacheLock(file: string): Promise<(() => Promise) | n } /** Returns false when another process has already committed a newer anchor. */ -async function writeCache(file: string, bundle: GrokSchemaBundle): Promise { - const release = await acquireCacheLock(file); - if (!release) return false; +async function writeCache(file: string, bundle: GrokSchemaBundle, checkedAt = Date.now()): Promise { + const lock = await acquireCacheLock(file); + if (!lock) return false; try { + if (!await lock.owns()) return false; if (!meetsTrustAnchor(bundle, await readTrustAnchor(file))) return false; // Anchor first: an interruption can make the cache unavailable, never make // an older cache appear acceptable after a newer bundle was trusted. if (!await writeJsonAtomic(trustAnchorPath(file), { sequence: bundle.sequence, payloadHash: grokSchemaBundlePayloadHash(bundle) })) return false; - return writeJsonAtomic(file, { checkedAt: Date.now(), bundle }); - } finally { await release(); } + if (!await lock.owns()) return false; + return writeJsonAtomic(file, { checkedAt, bundle }); + } finally { await lock.release(); } } function trustedBundle(bundle: GrokSchemaBundle, checkpoint?: GrokRegistryCheckpoint): boolean { @@ -535,9 +560,18 @@ export async function resolveGrokCompatibility(capabilities: GrokRunCapabilities const cacheFile = source.cachePath ?? defaultCachePath(); let bundle = BUILTIN_GROK_SCHEMA_BUNDLE; let bundleSource: GrokCompatibility["bundleSource"] = "built-in"; let diagnostic: GrokCompatibilityDiagnostic | undefined; const anchor = Object.keys(keys).length ? await readTrustAnchor(cacheFile) : null; - const cached = Object.keys(keys).length ? await readCache(cacheFile, keys, now, anchor) : null; - if (cached && trustedBundle(cached, source.checkpoint)) { bundle = cached; bundleSource = "cache"; } - if (source.url && Object.keys(keys).length) { + // A verified remote cache without its matching high-water anchor is not a + // valid offline contract. Treat it as a trust failure rather than allowing + // a lower signed response to establish a new history after the anchor was + // deleted or corrupted. + const cacheAnchorMissing = Object.keys(keys).length > 0 && !anchor && await cacheRecordExists(cacheFile); + const cached = Object.keys(keys).length && !cacheAnchorMissing ? await readCache(cacheFile, keys, now, anchor) : null; + if (cached && trustedBundle(cached.bundle, source.checkpoint)) { bundle = cached.bundle; bundleSource = "cache"; } + const cacheFresh = !!cached && trustedBundle(cached.bundle, source.checkpoint) + && now - cached.checkedAt >= 0 && now - cached.checkedAt < CACHE_TTL_MS; + if (cacheAnchorMissing) { + diagnostic = "cached-schema-unavailable"; + } else if (source.url && Object.keys(keys).length && !cacheFresh) { try { if (!isSafeRegistryUrl(source.url)) throw new Error("unsafe registry URL"); const response = await (source.fetch ?? fetch)(source.url, { signal: AbortSignal.timeout(5_000), credentials: "omit", redirect: "error" }); @@ -545,13 +579,13 @@ export async function resolveGrokCompatibility(capabilities: GrokRunCapabilities if (!response.ok) throw new Error("untrusted registry response"); const remote = JSON.parse(body); if (!verifyGrokSchemaBundle(remote, keys, now) || !trustedBundle(remote, source.checkpoint) || !meetsTrustAnchor(remote, anchor)) throw new Error("untrusted registry bundle"); - if (await writeCache(cacheFile, remote)) { + if (await writeCache(cacheFile, remote, now)) { bundle = remote; bundleSource = "remote"; } else { const currentAnchor = await readTrustAnchor(cacheFile); const currentCache = await readCache(cacheFile, keys, now, currentAnchor); - if (!currentCache || !trustedBundle(currentCache, source.checkpoint)) throw new Error("registry cache write rejected"); - bundle = currentCache; bundleSource = "cache"; diagnostic = "schema-registry-refresh-rejected"; + if (!currentCache || !trustedBundle(currentCache.bundle, source.checkpoint)) throw new Error("registry cache write rejected"); + bundle = currentCache.bundle; bundleSource = "cache"; diagnostic = "schema-registry-refresh-rejected"; } } catch { // `writeCache` commits the high-water anchor before its cache record. From 48afb1d56b1353b1d0104244755e94a66e60c414 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sun, 26 Jul 2026 13:43:16 -0400 Subject: [PATCH 11/61] fix(grok): redact fallback protocol failures --- ...oute-grok-compatibility.integration.test.ts | 14 ++++++++++++-- src/app/api/chat/send/route.ts | 5 +++++ src/lib/grok-compatibility.test.ts | 7 +++++++ src/lib/grok-compatibility.ts | 18 +++++++++++++++++- 4 files changed, 41 insertions(+), 3 deletions(-) diff --git a/src/app/api/chat/send/route-grok-compatibility.integration.test.ts b/src/app/api/chat/send/route-grok-compatibility.integration.test.ts index 9de3a0e99..b27372ea6 100644 --- a/src/app/api/chat/send/route-grok-compatibility.integration.test.ts +++ b/src/app/api/chat/send/route-grok-compatibility.integration.test.ts @@ -31,8 +31,9 @@ const windowsShimProgram = [ "if (command === '--help') { if (process.env.XAI_API_KEY) process.exit(12); console.log(['plain', 'plain-structured'].includes(process.env.GROK_TEST_MODE ?? '') ? ' --output-format Output format: text' : ' --output-format Output format: text, streaming-json'); process.exit(0); }", "if (command === '--version') { if (process.env.XAI_API_KEY) process.exit(12); console.log('1.0.0'); process.exit(0); }", "if (process.env.GROK_TEST_MODE === 'plain') console.log('plain fallback reply');", - "else if (process.env.GROK_TEST_MODE === 'plain-structured') console.log('{\\\"type\\\":\\\"tool_started\\\",\\\"input\\\":\\\"private tool payload\\\"}');", + "else if (process.env.GROK_TEST_MODE === 'plain-structured') console.log(' {\\\"type\\\":\\\"tool_started\\\",\\\"input\\\":\\\"private tool payload\\\"}');", "else if (process.env.GROK_TEST_MODE === 'malformed') console.log('unframed private tool payload');", + "else if (process.env.GROK_TEST_MODE === 'exit-error') { console.error('private Grok stderr payload'); process.exit(3); }", "else console.log('{\\\"type\\\":\\\"text\\\",\\\"data\\\":\\\"verified route reply\\\"}\\n{\\\"type\\\":\\\"end\\\",\\\"sessionId\\\":\\\"native_grok_session\\\"}');", ].join("\n"); const launcher = process.platform === "win32" @@ -49,8 +50,9 @@ const launcher = process.platform === "win32" "fi", "if [ \"$1\" = \"--version\" ]; then [ -z \"$XAI_API_KEY\" ] || exit 12; printf '%s\\n' '1.0.0'; exit 0; fi", "if [ \"$GROK_TEST_MODE\" = \"plain\" ]; then printf '%s\\n' 'plain fallback reply'; exit 0; fi", - "if [ \"$GROK_TEST_MODE\" = \"plain-structured\" ]; then printf '%s\\n' '{\"type\":\"tool_started\",\"input\":\"private tool payload\"}'; exit 0; fi", + "if [ \"$GROK_TEST_MODE\" = \"plain-structured\" ]; then printf '%s\\n' ' {\"type\":\"tool_started\",\"input\":\"private tool payload\"}'; exit 0; fi", "if [ \"$GROK_TEST_MODE\" = \"malformed\" ]; then printf '%s\\n' 'unframed private tool payload'; exit 0; fi", + "if [ \"$GROK_TEST_MODE\" = \"exit-error\" ]; then printf '%s\\n' 'private Grok stderr payload' >&2; exit 3; fi", "printf '%s\\n' '{\"type\":\"text\",\"data\":\"verified route reply\"}' '{\"type\":\"end\",\"sessionId\":\"native_grok_session\"}'", ].join("\n"); const launcherPath = path.join(bin, executable); @@ -110,6 +112,14 @@ try { }))); assert.match(malformed.body, /unframed-jsonl-event/, "unframed selected-schema output emits an accessible compatibility diagnostic"); assert.doesNotMatch(malformed.body, /private tool payload/, "unframed structured payloads never enter assistant text or diagnostics"); + + process.env.GROK_TEST_MODE = "exit-error"; + const exited = await readSse(await POST(new Request("http://localhost/api/chat/send", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ familiarId: "opal", prompt: "exit error", projectRoot: familiarWorkspace }), + }))); + assert.equal(exited.events.findLast((event) => event.kind === "done")?.isError, true, "a non-zero Grok process cannot be persisted as a successful turn"); + assert.doesNotMatch(exited.body, /private Grok stderr payload/, "Grok stderr values never enter assistant-visible or persisted diagnostics"); } 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; diff --git a/src/app/api/chat/send/route.ts b/src/app/api/chat/send/route.ts index 7d53e0722..36cbe92c4 100644 --- a/src/app/api/chat/send/route.ts +++ b/src/app/api/chat/send/route.ts @@ -2651,6 +2651,8 @@ export async function POST(req: Request) { return; } if (grokDirect) { + // Plain Grok fallback must not make a structured envelope with + // leading whitespace look like harmless assistant prose. handleGrokLine(line, isJson || /^[{\[]/.test(line.trimStart())); return; } @@ -3346,6 +3348,9 @@ export async function POST(req: Request) { stdoutErrTail.push("Copilot exited before completing its response."); } } + // Grok's stderr can include provider request details or local + // paths. Its structured errors already yield a fixed diagnostic; + // an unframed non-zero exit must receive the same redaction. if (grokDirect) { stderrTail.length = 0; stdoutErrTail.length = 0; diff --git a/src/lib/grok-compatibility.test.ts b/src/lib/grok-compatibility.test.ts index 3abbaa5bb..a5d61abff 100644 --- a/src/lib/grok-compatibility.test.ts +++ b/src/lib/grok-compatibility.test.ts @@ -38,6 +38,13 @@ const signedWithoutKeyId = { signature: { algorithm: "ed25519" as const, value: sign(null, Buffer.from(grokSchemaBundleSigningPayload(unsignedWithoutKeyId)), privateKey).toString("base64") }, }; assert.equal(verifyGrokSchemaBundle(signedWithoutKeyId, { "test-key": keyring["test-key"], next: keyring["test-key"] }), false, "rotating keyrings require an explicit signed key id"); +assert.equal( + verifyGrokSchemaBundle(signed, { + "test-key": keyring["test-key"], one: keyring["test-key"], two: keyring["test-key"], three: keyring["test-key"], four: keyring["test-key"], + }), + false, + "key rotation fails closed when more than four configured public keys are supplied", +); assert.deepEqual( grokRunCapabilitiesFromHelp(" --output-format FORMAT Choose text or streaming-json\n --model MODEL\n"), diff --git a/src/lib/grok-compatibility.ts b/src/lib/grok-compatibility.ts index b7ddc75ae..6d102cfb4 100644 --- a/src/lib/grok-compatibility.ts +++ b/src/lib/grok-compatibility.ts @@ -309,6 +309,21 @@ function validOptions(value: unknown): value is string[] { return Array.isArray(value) && value.length <= 16 && value.every((option) => typeof option === "string" && /^--[a-z][a-z0-9-]{0,63}$/.test(option)) && new Set(value).size === value.length; } +/** Keep rotation configuration as strict as the signed bundle's key-id rules. */ +function validKeyring(value: unknown): value is GrokRegistryKeyring { + if (!isRecord(value)) return false; + const entries = Object.entries(value); + if (entries.length < 1 || entries.length > 4) return false; + try { + for (const [id, pem] of entries) { + if (!validName(id) || typeof pem !== "string" || createPublicKey(pem).asymmetricKeyType !== "ed25519") return false; + } + return true; + } catch { + return false; + } +} + function validSchema(value: unknown): value is GrokEventSchema { if (!isRecord(value) || !validName(value.id) || !isRecord(value.requires) || value.requires.streamingJson !== true || !isRecord(value.eventTypes) || !isRecord(value.fields) || !isRecord(value.launch)) return false; if (!Object.keys(value).every((key) => ["id", "priority", "requires", "eventTypes", "fields", "launch"].includes(key))) return false; @@ -357,6 +372,7 @@ export function isGrokSchemaBundle(value: unknown, now = Date.now(), allowExpire export function verifyGrokSchemaBundle(value: unknown, publicKeys: string | GrokRegistryKeyring, now = Date.now()): value is GrokSchemaBundle { if (!isGrokSchemaBundle(value, now) || !value.signature) return false; const keys = typeof publicKeys === "string" ? { legacy: publicKeys } : publicKeys; + if (!validKeyring(keys)) return false; if (Object.keys(keys).length > 1 && value.keyId === undefined) return false; const candidates = Object.entries(keys).filter(([id, pem]) => validName(id) && typeof pem === "string" && (value.keyId === undefined || value.keyId === id)); if (candidates.length === 0 || candidates.length > 4 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value.signature.value)) return false; @@ -409,7 +425,7 @@ function configuredGrokRegistrySource(): RegistrySource { const parsed = url ? new URL(url) : null; if (parsed && (parsed.protocol !== "https:" || parsed.username || parsed.password)) return {}; } catch { return {}; } - if (!checkpoint || !Number.isSafeInteger(checkpoint.sequence) || checkpoint.sequence < 1 || !/^[a-f0-9]{64}$/.test(checkpoint.payloadHash)) return {}; + if (!validKeyring(publicKeys) || !checkpoint || !Number.isSafeInteger(checkpoint.sequence) || checkpoint.sequence < 1 || !/^[a-f0-9]{64}$/.test(checkpoint.payloadHash)) return {}; return { url, publicKeys, checkpoint }; } From 1d5b88b7a58ec4c0cf997742956f3e344546ad74 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sun, 26 Jul 2026 13:54:29 -0400 Subject: [PATCH 12/61] fix(grok): bind tool schemas to verified revisions --- docs/grok-compatibility-registry.md | 4 +- ...ute-grok-compatibility.integration.test.ts | 16 +++- src/lib/grok-compatibility.test.ts | 21 +++- src/lib/grok-compatibility.ts | 95 +++++++++++++++++-- 4 files changed, 120 insertions(+), 16 deletions(-) diff --git a/docs/grok-compatibility-registry.md b/docs/grok-compatibility-registry.md index e4a24f9d7..e11ad92ad 100644 --- a/docs/grok-compatibility-registry.md +++ b/docs/grok-compatibility-registry.md @@ -1,6 +1,6 @@ # Grok Build compatibility registry -Grok Build's built-in profile is limited to the xAI-documented `text`, `thought`, `end`, and `error` `streaming-json` frames. It contains no tool-event aliases. A tool schema is enabled only when the exact locally resolved launcher advertises the value-bearing `--output-format streaming-json` option and a selected Ed25519-signed bundle explicitly names every envelope field and lifecycle event. Help/version probes never receive credential-bearing environment variables and run no model request. +Grok Build's built-in profile is limited to the xAI-documented `text`, `thought`, `end`, and `error` `streaming-json` frames. It contains no tool-event aliases. A tool schema is enabled only when the exact locally resolved launcher advertises the value-bearing `--output-format streaming-json` option and a selected Ed25519-signed bundle explicitly names every envelope field and lifecycle event **and pins those aliases to exact locally probed Grok Build versions**. Help/version probes never receive credential-bearing environment variables and run no model request. Release configuration is public verification material, never a signing key: @@ -8,7 +8,7 @@ Release configuration is public verification material, never a signing key: - `GROK_SCHEMA_REGISTRY_PUBLIC_KEY`, or `GROK_SCHEMA_REGISTRY_PUBLIC_KEYS` — PEM Ed25519 trust anchor(s), with one to four key IDs for rotation. A bundle signed against a multi-key keyring must carry its exact `keyId`. - `GROK_SCHEMA_REGISTRY_CHECKPOINT` — JSON `{ "sequence": number, "payloadHash": "" }` that anchors first use and rollback resistance. -The release maps these to `NEXT_PUBLIC_COVEN_GROK_SCHEMA_REGISTRY_*`. Development may use `COVEN_GROK_SCHEMA_REGISTRY_*`. Registry downloads reject redirects, credentials, oversized bodies, malformed bundles, invalid signatures, checkpoint regressions, and cache-anchor rollbacks. The per-user cache is bounded, atomically replaced under a short writer lock, and always reverified; an unknown or malformed selected event quarantines that schema in-process and future turns fall back to plain text. Do not publish a Grok tool schema until its precise stdout envelope is source-verified and captured from an approved non-production fixture. Never store a private key in this repository, app configuration, or release secrets. +The release maps these to `NEXT_PUBLIC_COVEN_GROK_SCHEMA_REGISTRY_*`. Development may use `COVEN_GROK_SCHEMA_REGISTRY_*`. Registry downloads reject redirects, credentials, oversized bodies, malformed bundles, invalid signatures, checkpoint regressions, and cache-anchor rollbacks. The per-user cache is bounded, atomically replaced under a short writer lock, and keeps a bounded immutable high-water journal so a resumed stale writer cannot lower the accepted sequence. It is always reverified; an unknown or malformed selected event quarantines that schema in-process and future turns fall back to plain text. Do not publish a Grok tool schema until its precise stdout envelope is source-verified and captured from an approved non-production fixture. Never store a private key in this repository, app configuration, or release secrets. ## Evidence diff --git a/src/app/api/chat/send/route-grok-compatibility.integration.test.ts b/src/app/api/chat/send/route-grok-compatibility.integration.test.ts index b27372ea6..d409aa8e8 100644 --- a/src/app/api/chat/send/route-grok-compatibility.integration.test.ts +++ b/src/app/api/chat/send/route-grok-compatibility.integration.test.ts @@ -28,10 +28,11 @@ const executable = process.platform === "win32" ? "grok.cmd" : "grok"; const windowsShimTarget = path.join(bin, "grok-launcher.js"); const windowsShimProgram = [ "const [command] = process.argv.slice(2);", - "if (command === '--help') { if (process.env.XAI_API_KEY) process.exit(12); console.log(['plain', 'plain-structured'].includes(process.env.GROK_TEST_MODE ?? '') ? ' --output-format Output format: text' : ' --output-format Output format: text, streaming-json'); process.exit(0); }", + "if (command === '--help') { if (process.env.XAI_API_KEY) process.exit(12); console.log(['plain', 'plain-structured', 'plain-structured-array'].includes(process.env.GROK_TEST_MODE ?? '') ? ' --output-format Output format: text' : ' --output-format Output format: text, streaming-json'); process.exit(0); }", "if (command === '--version') { if (process.env.XAI_API_KEY) process.exit(12); console.log('1.0.0'); process.exit(0); }", "if (process.env.GROK_TEST_MODE === 'plain') console.log('plain fallback reply');", "else if (process.env.GROK_TEST_MODE === 'plain-structured') console.log(' {\\\"type\\\":\\\"tool_started\\\",\\\"input\\\":\\\"private tool payload\\\"}');", + "else if (process.env.GROK_TEST_MODE === 'plain-structured-array') console.log('[{\\\"type\\\":\\\"tool_started\\\",\\\"input\\\":\\\"private array tool payload\\\"}]');", "else if (process.env.GROK_TEST_MODE === 'malformed') console.log('unframed private tool payload');", "else if (process.env.GROK_TEST_MODE === 'exit-error') { console.error('private Grok stderr payload'); process.exit(3); }", "else console.log('{\\\"type\\\":\\\"text\\\",\\\"data\\\":\\\"verified route reply\\\"}\\n{\\\"type\\\":\\\"end\\\",\\\"sessionId\\\":\\\"native_grok_session\\\"}');", @@ -45,12 +46,13 @@ const launcher = process.platform === "win32" "#!/bin/sh", "if [ \"$1\" = \"--help\" ]; then", " [ -z \"$XAI_API_KEY\" ] || exit 12", - " if [ \"$GROK_TEST_MODE\" = \"plain\" ] || [ \"$GROK_TEST_MODE\" = \"plain-structured\" ]; then printf '%s\\n' ' --output-format Output format: text'; else printf '%s\\n' ' --output-format Output format: text, streaming-json'; fi", + " if [ \"$GROK_TEST_MODE\" = \"plain\" ] || [ \"$GROK_TEST_MODE\" = \"plain-structured\" ] || [ \"$GROK_TEST_MODE\" = \"plain-structured-array\" ]; then printf '%s\\n' ' --output-format Output format: text'; else printf '%s\\n' ' --output-format Output format: text, streaming-json'; fi", " exit 0", "fi", "if [ \"$1\" = \"--version\" ]; then [ -z \"$XAI_API_KEY\" ] || exit 12; printf '%s\\n' '1.0.0'; exit 0; fi", "if [ \"$GROK_TEST_MODE\" = \"plain\" ]; then printf '%s\\n' 'plain fallback reply'; exit 0; fi", - "if [ \"$GROK_TEST_MODE\" = \"plain-structured\" ]; then printf '%s\\n' ' {\"type\":\"tool_started\",\"input\":\"private tool payload\"}'; exit 0; fi", + "if [ \"$GROK_TEST_MODE\" = \"plain-structured\" ]; then printf '%s\\n' ' {\"type\":\"tool_started\",\"input\":\"private tool payload\"}'; exit 0; fi", + "if [ \"$GROK_TEST_MODE\" = \"plain-structured-array\" ]; then printf '%s\\n' '[{\"type\":\"tool_started\",\"input\":\"private array tool payload\"}]'; exit 0; fi", "if [ \"$GROK_TEST_MODE\" = \"malformed\" ]; then printf '%s\\n' 'unframed private tool payload'; exit 0; fi", "if [ \"$GROK_TEST_MODE\" = \"exit-error\" ]; then printf '%s\\n' 'private Grok stderr payload' >&2; exit 3; fi", "printf '%s\\n' '{\"type\":\"text\",\"data\":\"verified route reply\"}' '{\"type\":\"end\",\"sessionId\":\"native_grok_session\"}'", @@ -105,6 +107,14 @@ try { assert.match(unverifiedStructured.body, /unverified-structured-output/, "plain fallback reports an accessible fixed diagnostic for raw structured output"); assert.doesNotMatch(unverifiedStructured.body, /private tool payload/, "plain fallback never persists unverified structured payload values as assistant text or diagnostics"); + process.env.GROK_TEST_MODE = "plain-structured-array"; + const unverifiedArray = await readSse(await POST(new Request("http://localhost/api/chat/send", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ familiarId: "opal", prompt: "unsafe array fallback", projectRoot: familiarWorkspace }), + }))); + assert.match(unverifiedArray.body, /unverified-structured-output/, "plain fallback also rejects array-shaped structured output"); + assert.doesNotMatch(unverifiedArray.body, /private array tool payload/, "plain fallback never persists unverified array payload values"); + process.env.GROK_TEST_MODE = "malformed"; const malformed = await readSse(await POST(new Request("http://localhost/api/chat/send", { method: "POST", headers: { "content-type": "application/json" }, diff --git a/src/lib/grok-compatibility.test.ts b/src/lib/grok-compatibility.test.ts index a5d61abff..f5a16a038 100644 --- a/src/lib/grok-compatibility.test.ts +++ b/src/lib/grok-compatibility.test.ts @@ -11,6 +11,7 @@ import { grokSchemaBundleSigningPayload, isGrokSchemaBundle, resolveGrokCompatibility, + selectGrokSchema, verifyGrokSchemaBundle, } from "./grok-compatibility.ts"; @@ -32,6 +33,18 @@ const incompleteTool = structuredClone(signed); incompleteTool.schemas[0].eventTypes.toolStart = ["tool_started"]; incompleteTool.schemas[0].fields.id = []; assert.equal(isGrokSchemaBundle(incompleteTool), false, "tool schemas must declare their stable id field before selection"); +const unboundTool = structuredClone(signed); +unboundTool.schemas[0].eventTypes.toolStart = ["tool_started"]; +assert.equal(isGrokSchemaBundle(unboundTool), false, "tool schemas require exact locally-probed launcher versions"); +const versionBoundTool = structuredClone(signed); +versionBoundTool.schemas[0].eventTypes.toolStart = ["tool_started"]; +versionBoundTool.schemas[0].requires.versions = ["1.0.0"]; +assert.equal(isGrokSchemaBundle(versionBoundTool), true, "a tool schema may bind aliases to verified launcher versions"); +assert.equal( + selectGrokSchema(versionBoundTool.schemas, { version: "1.0.1", streamingJson: true, options: ["--output-format"], valueOptions: ["--output-format"] }), + null, + "a signed tool schema never applies to an unlisted Grok Build version", +); const { keyId: _keyId, signature: _signature, ...unsignedWithoutKeyId } = signed; const signedWithoutKeyId = { ...unsignedWithoutKeyId, @@ -89,8 +102,8 @@ try { assert.equal(rollback.bundleSource, "cache", "a lower signed sequence cannot replace the local high-water contract"); assert.equal(rollback.diagnostic, "schema-registry-refresh-rejected"); - // Losing the durable anchor must not turn the still-present cache into a - // first-use state where an older signed response can replace it. + // The immutable journal survives loss of the replaceable recovery sidecar, + // so a stale writer cannot erase high-water state by replacing that sidecar. await rm(`${cachePath}.anchor`); const missingAnchor = await resolveGrokCompatibility(supported, { publicKeys: keyring, @@ -98,8 +111,8 @@ try { url: "https://registry.example/grok.json", fetch: async () => new Response(JSON.stringify(signed)), }); - assert.equal(missingAnchor.bundleSource, "built-in", "a cache without its durable high-water anchor is never selected or refreshed"); - assert.equal(missingAnchor.diagnostic, "cached-schema-unavailable"); + assert.equal(missingAnchor.bundleSource, "cache", "the immutable high-water journal keeps the verified cache available after sidecar loss"); + assert.equal(missingAnchor.diagnostic, undefined); // Simulate an interruption after the high-water anchor is committed but // before the replacement cache file can be installed. The prior cache was diff --git a/src/lib/grok-compatibility.ts b/src/lib/grok-compatibility.ts index 6d102cfb4..536d5a798 100644 --- a/src/lib/grok-compatibility.ts +++ b/src/lib/grok-compatibility.ts @@ -123,7 +123,7 @@ export type GrokRegistryCheckpoint = { sequence: number; payloadHash: string }; export type GrokEventSchema = { id: string; priority?: number; - requires: { streamingJson: true; options?: string[] }; + requires: { streamingJson: true; options?: string[]; versions?: string[] }; eventTypes: { ignored: string[]; text: string[]; @@ -199,6 +199,8 @@ const CACHE_TTL_MS = 6 * 60 * 60 * 1000; const CACHE_LOCK_STALE_MS = 30_000; const CACHE_LOCK_WAIT_MS = 500; const CACHE_LOCK_POLL_MS = 20; +const MAX_ANCHOR_JOURNAL_ENTRIES = 16; +const MAX_ANCHOR_JOURNAL_SCAN = 64; const schemaQuarantine = new Map(); /** The only source-verified built-in Grok frame contract as of 2026-07-26. */ @@ -212,7 +214,7 @@ export const BUILTIN_GROK_SCHEMA_BUNDLE: GrokSchemaBundle = { id: "grok-build-streaming-json-v1", requires: { streamingJson: true, options: ["--output-format"] }, eventTypes: { - ignored: ["thought", "max_turns_reached", "auto_compact_start", "auto_compact_end"], + ignored: ["thought"], text: ["text"], end: ["end"], error: ["error"], toolStart: [], toolProgress: [], toolEnd: [], toolComplete: [], }, @@ -327,12 +329,13 @@ function validKeyring(value: unknown): value is GrokRegistryKeyring { function validSchema(value: unknown): value is GrokEventSchema { if (!isRecord(value) || !validName(value.id) || !isRecord(value.requires) || value.requires.streamingJson !== true || !isRecord(value.eventTypes) || !isRecord(value.fields) || !isRecord(value.launch)) return false; if (!Object.keys(value).every((key) => ["id", "priority", "requires", "eventTypes", "fields", "launch"].includes(key))) return false; - if (!Object.keys(value.requires).every((key) => key === "streamingJson" || key === "options")) return false; + if (!Object.keys(value.requires).every((key) => key === "streamingJson" || key === "options" || key === "versions")) return false; if (!Object.keys(value.eventTypes).every((key) => ["ignored", "text", "end", "error", "toolStart", "toolProgress", "toolEnd", "toolComplete"].includes(key))) return false; if (!Object.keys(value.fields).every((key) => ["type", "text", "sessionId", "message", "usage", "totalCostUsd", "id", "name", "input", "output", "state", "error", "terminalStates", "errorStates"].includes(key))) return false; if (!Object.keys(value.launch).every((key) => key === "outputOption" || key === "outputValue")) return false; if (value.priority !== undefined && (typeof value.priority !== "number" || !Number.isSafeInteger(value.priority) || Math.abs(value.priority) > 1_000)) return false; if (value.requires.options !== undefined && !validOptions(value.requires.options)) return false; + if (value.requires.versions !== undefined && (!Array.isArray(value.requires.versions) || value.requires.versions.length < 1 || value.requires.versions.length > 8 || new Set(value.requires.versions).size !== value.requires.versions.length || !value.requires.versions.every((version) => typeof version === "string" && /^\d+(?:\.\d+){1,3}(?:[-+][A-Za-z0-9.-]+)?$/.test(version)))) return false; if (value.launch.outputOption !== "--output-format" || value.launch.outputValue !== "streaming-json") return false; const events = value.eventTypes; const eventGroups = [events.ignored, events.text, events.end, events.error, events.toolStart, events.toolProgress ?? [], events.toolEnd, events.toolComplete]; @@ -348,6 +351,10 @@ function validSchema(value: unknown): value is GrokEventSchema { if (!typedFields.type.length) return false; if (typedEvents.text.length && !typedFields.text.length) return false; if ((typedEvents.toolStart.length || typedEvents.toolEnd.length || typedEvents.toolComplete.length || (typedEvents.toolProgress?.length ?? 0)) && !typedFields.id.length) return false; + // Tool envelopes are version-sensitive protocol claims. A publisher must + // bind them to the exact locally probed launcher revisions rather than + // allowing a signed alias to match every future Grok Build version. + if ((typedEvents.toolStart.length || typedEvents.toolEnd.length || typedEvents.toolComplete.length || (typedEvents.toolProgress?.length ?? 0)) && !value.requires.versions?.length) return false; return !(typedEvents.toolStart.length || typedEvents.toolComplete.length) || typedFields.name.length > 0; } @@ -391,6 +398,7 @@ export function selectGrokSchema(schemas: GrokEventSchema[], capabilities: GrokR const valueOptions = new Set(capabilities.valueOptions); const matches = schemas.filter((schema) => (schema.requires.options?.every((option) => options.has(option)) ?? true) + && (schema.requires.versions === undefined || (capabilities.version !== null && schema.requires.versions.includes(capabilities.version))) && options.has(schema.launch.outputOption) && valueOptions.has(schema.launch.outputOption), ); @@ -434,6 +442,10 @@ function defaultCachePath(): string { } function trustAnchorPath(file: string): string { return `${file}.anchor`; } +function trustAnchorJournalPath(file: string): string { return `${trustAnchorPath(file)}.journal`; } +function trustAnchorJournalEntryPath(file: string, anchor: TrustAnchor): string { + return path.join(trustAnchorJournalPath(file), `${anchor.sequence}-${anchor.payloadHash}.json`); +} function cacheLockPath(file: string): string { return `${file}.lock`; } function validTrustAnchor(value: unknown): value is TrustAnchor { @@ -469,7 +481,7 @@ async function readBoundedRegistryBody(response: Response): Promise { return new TextDecoder("utf-8", { fatal: true }).decode(bytes); } -async function readTrustAnchor(file: string): Promise { +async function readLegacyTrustAnchor(file: string): Promise { try { const raw = await fs.readFile(trustAnchorPath(file), "utf8"); if (Buffer.byteLength(raw) > 1024) return null; @@ -478,6 +490,46 @@ async function readTrustAnchor(file: string): Promise { } catch { return null; } } +/** + * High-water records use immutable sequence/hash filenames. A reclaimed + * writer lease can therefore add a stale record but can never replace the + * newer record that readers use for rollback protection. + */ +async function readJournalTrustAnchors(file: string): Promise { + let directory: Awaited>; + try { + directory = await fs.opendir(trustAnchorJournalPath(file)); + } catch (error) { + return (error as NodeJS.ErrnoException).code === "ENOENT" ? [] : null; + } + const anchors: TrustAnchor[] = []; + try { + let count = 0; + for await (const entry of directory) { + count += 1; + if (count > MAX_ANCHOR_JOURNAL_SCAN || !entry.isFile()) return null; + const match = /^(\d{1,16})-([a-f0-9]{64})\.json$/.exec(entry.name); + if (!match) return null; + const sequence = Number(match[1]); + if (!Number.isSafeInteger(sequence) || sequence < 1) return null; + anchors.push({ sequence, payloadHash: match[2] }); + } + } finally { + await directory.close().catch(() => undefined); + } + return anchors; +} + +async function readTrustAnchor(file: string): Promise { + const [legacy, journal] = await Promise.all([readLegacyTrustAnchor(file), readJournalTrustAnchors(file)]); + if (journal === null) return null; + const anchors = legacy ? [...journal, legacy] : journal; + if (!anchors.length) return null; + const highWater = Math.max(...anchors.map((anchor) => anchor.sequence)); + const highest = anchors.filter((anchor) => anchor.sequence === highWater); + return new Set(highest.map((anchor) => anchor.payloadHash)).size === 1 ? highest[0] : null; +} + async function cacheRecordExists(file: string): Promise { try { return (await fs.stat(file)).isFile(); @@ -512,6 +564,33 @@ async function writeJsonAtomic(file: string, value: unknown): Promise { } catch { return false; /* Cache is an optimization; a failed write never broadens parsing. */ } } +async function writeTrustAnchor(file: string, anchor: TrustAnchor): Promise { + const entry = trustAnchorJournalEntryPath(file, anchor); + try { + await fs.mkdir(path.dirname(entry), { recursive: true }); + await fs.writeFile(entry, JSON.stringify(anchor), { flag: "wx", mode: 0o600 }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") return false; + try { + const existing = JSON.parse(await fs.readFile(entry, "utf8")); + if (!validTrustAnchor(existing) || existing.sequence !== anchor.sequence || existing.payloadHash !== anchor.payloadHash) return false; + } catch { return false; } + } + const entries = await readJournalTrustAnchors(file); + if (entries === null) return false; + const retained = new Set(entries + .sort((left, right) => right.sequence - left.sequence || right.payloadHash.localeCompare(left.payloadHash)) + .slice(0, MAX_ANCHOR_JOURNAL_ENTRIES) + .map((entryValue) => `${entryValue.sequence}-${entryValue.payloadHash}.json`)); + await Promise.all(entries + .filter((entryValue) => !retained.has(`${entryValue.sequence}-${entryValue.payloadHash}.json`)) + .map((entryValue) => fs.rm(trustAnchorJournalEntryPath(file, entryValue), { force: true }).catch(() => undefined))); + // Keep the original single-record sidecar as a fast recovery hint. Readers + // always consult the immutable journal as well, so this replace cannot lower + // the accepted high-water mark after a stale lock lease resumes. + return writeJsonAtomic(trustAnchorPath(file), anchor); +} + type CacheLock = { owns: () => Promise; release: () => Promise; @@ -555,10 +634,12 @@ async function writeCache(file: string, bundle: GrokSchemaBundle, checkedAt = Da try { if (!await lock.owns()) return false; if (!meetsTrustAnchor(bundle, await readTrustAnchor(file))) return false; - // Anchor first: an interruption can make the cache unavailable, never make - // an older cache appear acceptable after a newer bundle was trusted. - if (!await writeJsonAtomic(trustAnchorPath(file), { sequence: bundle.sequence, payloadHash: grokSchemaBundlePayloadHash(bundle) })) return false; + // Anchor first: an interruption can make the cache unavailable, never make + // an older cache appear acceptable after a newer bundle was trusted. + const anchor = { sequence: bundle.sequence, payloadHash: grokSchemaBundlePayloadHash(bundle) }; + if (!await writeTrustAnchor(file, anchor)) return false; if (!await lock.owns()) return false; + if (!meetsTrustAnchor(bundle, await readTrustAnchor(file))) return false; return writeJsonAtomic(file, { checkedAt, bundle }); } finally { await lock.release(); } } From f3f28e255f4bebb766a10758814d27ee1a7aa2fe Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sun, 26 Jul 2026 14:11:36 -0400 Subject: [PATCH 13/61] test(grok): cover structured fallback route wiring --- .../api/chat/send/harness-routing-copilot-jsonl.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 ab6586755..646e66135 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 @@ -212,8 +212,8 @@ assert.match( ); assert.match( chatRoute, - /if \(grokDirect\) \{\s*handleGrokLine\(line, isJson\);\s*return;/, - "Grok stdout routes through the native JSONL parser, never generic stream-json parsing", + /if \(grokDirect\) \{[\s\S]*?handleGrokLine\(line, isJson \|\| [\s\S]*?line\.trimStart\(\)\)\);\s*return;/, + "Grok stdout routes through the native parser, rejecting whitespace-prefixed object and array envelopes before plain fallback persistence", ); assert.match( chatRoute, @@ -344,8 +344,8 @@ assert.match( ); assert.match( chatRoute, - /\(openCodeDirect \|\| copilotStream\) && code !== 0[\s\S]*?is_error: true/, - "a nonzero direct Copilot process exit persists the turn as an error even without a final result frame", + /\(openCodeDirect \|\| copilotStream \|\| grokDirect\) && code !== 0[\s\S]*?is_error: true/, + "a nonzero direct Copilot or Grok process exit persists the turn as an error even without a final result frame", ); assert.match( chatRoute, From 64bf2d84d533c65a6d0b02e00146249766d4eaa8 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sun, 26 Jul 2026 14:23:31 -0400 Subject: [PATCH 14/61] fix(grok): preserve safe plain fallback turns --- ...ute-grok-compatibility.integration.test.ts | 27 +++++++++++++------ src/app/api/chat/send/route.ts | 6 ++++- src/lib/grok-compatibility.test.ts | 20 +++----------- 3 files changed, 28 insertions(+), 25 deletions(-) diff --git a/src/app/api/chat/send/route-grok-compatibility.integration.test.ts b/src/app/api/chat/send/route-grok-compatibility.integration.test.ts index d409aa8e8..34e2d05ec 100644 --- a/src/app/api/chat/send/route-grok-compatibility.integration.test.ts +++ b/src/app/api/chat/send/route-grok-compatibility.integration.test.ts @@ -28,11 +28,12 @@ const executable = process.platform === "win32" ? "grok.cmd" : "grok"; const windowsShimTarget = path.join(bin, "grok-launcher.js"); const windowsShimProgram = [ "const [command] = process.argv.slice(2);", - "if (command === '--help') { if (process.env.XAI_API_KEY) process.exit(12); console.log(['plain', 'plain-structured', 'plain-structured-array'].includes(process.env.GROK_TEST_MODE ?? '') ? ' --output-format Output format: text' : ' --output-format Output format: text, streaming-json'); process.exit(0); }", + "if (command === '--help') { if (process.env.XAI_API_KEY) process.exit(12); console.log(['plain', 'plain-structured', 'plain-structured-array', 'plain-bracketed-text'].includes(process.env.GROK_TEST_MODE ?? '') ? ' --output-format Output format: text' : ' --output-format Output format: text, streaming-json'); process.exit(0); }", "if (command === '--version') { if (process.env.XAI_API_KEY) process.exit(12); console.log('1.0.0'); process.exit(0); }", "if (process.env.GROK_TEST_MODE === 'plain') console.log('plain fallback reply');", - "else if (process.env.GROK_TEST_MODE === 'plain-structured') console.log(' {\\\"type\\\":\\\"tool_started\\\",\\\"input\\\":\\\"private tool payload\\\"}');", - "else if (process.env.GROK_TEST_MODE === 'plain-structured-array') console.log('[{\\\"type\\\":\\\"tool_started\\\",\\\"input\\\":\\\"private array tool payload\\\"}]');", + "else if (process.env.GROK_TEST_MODE === 'plain-structured') console.log(' {\\\"opaque\\\":\\\"private structured payload\\\"}');", + "else if (process.env.GROK_TEST_MODE === 'plain-structured-array') console.log('[{\\\"opaque\\\":\\\"private array payload\\\"}]');", + "else if (process.env.GROK_TEST_MODE === 'plain-bracketed-text') console.log('[a safe plain-text reply]');", "else if (process.env.GROK_TEST_MODE === 'malformed') console.log('unframed private tool payload');", "else if (process.env.GROK_TEST_MODE === 'exit-error') { console.error('private Grok stderr payload'); process.exit(3); }", "else console.log('{\\\"type\\\":\\\"text\\\",\\\"data\\\":\\\"verified route reply\\\"}\\n{\\\"type\\\":\\\"end\\\",\\\"sessionId\\\":\\\"native_grok_session\\\"}');", @@ -46,13 +47,14 @@ const launcher = process.platform === "win32" "#!/bin/sh", "if [ \"$1\" = \"--help\" ]; then", " [ -z \"$XAI_API_KEY\" ] || exit 12", - " if [ \"$GROK_TEST_MODE\" = \"plain\" ] || [ \"$GROK_TEST_MODE\" = \"plain-structured\" ] || [ \"$GROK_TEST_MODE\" = \"plain-structured-array\" ]; then printf '%s\\n' ' --output-format Output format: text'; else printf '%s\\n' ' --output-format Output format: text, streaming-json'; fi", + " if [ \"$GROK_TEST_MODE\" = \"plain\" ] || [ \"$GROK_TEST_MODE\" = \"plain-structured\" ] || [ \"$GROK_TEST_MODE\" = \"plain-structured-array\" ] || [ \"$GROK_TEST_MODE\" = \"plain-bracketed-text\" ]; then printf '%s\\n' ' --output-format Output format: text'; else printf '%s\\n' ' --output-format Output format: text, streaming-json'; fi", " exit 0", "fi", "if [ \"$1\" = \"--version\" ]; then [ -z \"$XAI_API_KEY\" ] || exit 12; printf '%s\\n' '1.0.0'; exit 0; fi", "if [ \"$GROK_TEST_MODE\" = \"plain\" ]; then printf '%s\\n' 'plain fallback reply'; exit 0; fi", - "if [ \"$GROK_TEST_MODE\" = \"plain-structured\" ]; then printf '%s\\n' ' {\"type\":\"tool_started\",\"input\":\"private tool payload\"}'; exit 0; fi", - "if [ \"$GROK_TEST_MODE\" = \"plain-structured-array\" ]; then printf '%s\\n' '[{\"type\":\"tool_started\",\"input\":\"private array tool payload\"}]'; exit 0; fi", + "if [ \"$GROK_TEST_MODE\" = \"plain-structured\" ]; then printf '%s\\n' ' {\"opaque\":\"private structured payload\"}'; exit 0; fi", + "if [ \"$GROK_TEST_MODE\" = \"plain-structured-array\" ]; then printf '%s\\n' '[{\"opaque\":\"private array payload\"}]'; exit 0; fi", + "if [ \"$GROK_TEST_MODE\" = \"plain-bracketed-text\" ]; then printf '%s\\n' '[a safe plain-text reply]'; exit 0; fi", "if [ \"$GROK_TEST_MODE\" = \"malformed\" ]; then printf '%s\\n' 'unframed private tool payload'; exit 0; fi", "if [ \"$GROK_TEST_MODE\" = \"exit-error\" ]; then printf '%s\\n' 'private Grok stderr payload' >&2; exit 3; fi", "printf '%s\\n' '{\"type\":\"text\",\"data\":\"verified route reply\"}' '{\"type\":\"end\",\"sessionId\":\"native_grok_session\"}'", @@ -98,6 +100,15 @@ try { }))); assert.match(plain.body, /"kind":"assistant_chunk","text":"plain fallback reply\\n"/, "an unverified output format remains safe plain assistant text"); assert.match(plain.body, /without tool activity/, "plain fallback provides an accessible compatibility diagnostic"); + const plainConversation = await loadConversation(plain.events.findLast((event) => event.kind === "done")?.sessionId); + assert.ok(plainConversation?.turns.at(-1)?.progress?.some((event) => event.id === "grok-compatibility"), "plain fallback persists its value-free compatibility diagnostic for reload"); + + process.env.GROK_TEST_MODE = "plain-bracketed-text"; + const bracketedText = await readSse(await POST(new Request("http://localhost/api/chat/send", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ familiarId: "opal", prompt: "bracketed fallback", projectRoot: familiarWorkspace }), + }))); + assert.match(bracketedText.body, /\[a safe plain-text reply\]/, "plain fallback preserves prose that merely starts with a bracket"); process.env.GROK_TEST_MODE = "plain-structured"; const unverifiedStructured = await readSse(await POST(new Request("http://localhost/api/chat/send", { @@ -105,7 +116,7 @@ try { body: JSON.stringify({ familiarId: "opal", prompt: "unsafe fallback", projectRoot: familiarWorkspace }), }))); assert.match(unverifiedStructured.body, /unverified-structured-output/, "plain fallback reports an accessible fixed diagnostic for raw structured output"); - assert.doesNotMatch(unverifiedStructured.body, /private tool payload/, "plain fallback never persists unverified structured payload values as assistant text or diagnostics"); + assert.doesNotMatch(unverifiedStructured.body, /private structured payload/, "plain fallback never persists unverified structured payload values as assistant text or diagnostics"); process.env.GROK_TEST_MODE = "plain-structured-array"; const unverifiedArray = await readSse(await POST(new Request("http://localhost/api/chat/send", { @@ -113,7 +124,7 @@ try { body: JSON.stringify({ familiarId: "opal", prompt: "unsafe array fallback", projectRoot: familiarWorkspace }), }))); assert.match(unverifiedArray.body, /unverified-structured-output/, "plain fallback also rejects array-shaped structured output"); - assert.doesNotMatch(unverifiedArray.body, /private array tool payload/, "plain fallback never persists unverified array payload values"); + assert.doesNotMatch(unverifiedArray.body, /private array payload/, "plain fallback never persists unverified array payload values"); process.env.GROK_TEST_MODE = "malformed"; const malformed = await readSse(await POST(new Request("http://localhost/api/chat/send", { diff --git a/src/app/api/chat/send/route.ts b/src/app/api/chat/send/route.ts index 36cbe92c4..36312aba4 100644 --- a/src/app/api/chat/send/route.ts +++ b/src/app/api/chat/send/route.ts @@ -2361,7 +2361,8 @@ export async function POST(req: Request) { const candidate = JSON.parse(line); unverifiedStructuredOutput = !!candidate && typeof candidate === "object"; } catch { - // Plain prose can begin with a brace or bracket. + // Plain prose can begin with a brace or bracket. It has no + // parseable structured boundary, so preserve it as text. } } if (unverifiedStructuredOutput) { @@ -2378,6 +2379,9 @@ export async function POST(req: Request) { return; } const text = `${resolveBackspaces(stripAnsi(line))}\n`; + // Plain mode has no native session event. Once actual assistant + // prose arrives, the launched UUID is safe to retain for persistence + // and the next resume; do not manufacture it for raw envelopes. if (!grokSessionId && grokSessionHint) grokSessionId = grokSessionHint; if (!sessionId && grokSessionHint) announceSession(grokSessionHint); assistantText += text; diff --git a/src/lib/grok-compatibility.test.ts b/src/lib/grok-compatibility.test.ts index f5a16a038..52583c8bb 100644 --- a/src/lib/grok-compatibility.test.ts +++ b/src/lib/grok-compatibility.test.ts @@ -11,7 +11,6 @@ import { grokSchemaBundleSigningPayload, isGrokSchemaBundle, resolveGrokCompatibility, - selectGrokSchema, verifyGrokSchemaBundle, } from "./grok-compatibility.ts"; @@ -29,21 +28,10 @@ assert.equal(verifyGrokSchemaBundle({ ...signed, sequence: 3 }, keyring), false, const ambiguous = structuredClone(signed); ambiguous.schemas[0].eventTypes.toolStart = ["text"]; assert.equal(isGrokSchemaBundle(ambiguous), false, "ambiguous event aliases are rejected before a signed schema can be selected"); -const incompleteTool = structuredClone(signed); -incompleteTool.schemas[0].eventTypes.toolStart = ["tool_started"]; -incompleteTool.schemas[0].fields.id = []; -assert.equal(isGrokSchemaBundle(incompleteTool), false, "tool schemas must declare their stable id field before selection"); -const unboundTool = structuredClone(signed); -unboundTool.schemas[0].eventTypes.toolStart = ["tool_started"]; -assert.equal(isGrokSchemaBundle(unboundTool), false, "tool schemas require exact locally-probed launcher versions"); -const versionBoundTool = structuredClone(signed); -versionBoundTool.schemas[0].eventTypes.toolStart = ["tool_started"]; -versionBoundTool.schemas[0].requires.versions = ["1.0.0"]; -assert.equal(isGrokSchemaBundle(versionBoundTool), true, "a tool schema may bind aliases to verified launcher versions"); -assert.equal( - selectGrokSchema(versionBoundTool.schemas, { version: "1.0.1", streamingJson: true, options: ["--output-format"], valueOptions: ["--output-format"] }), - null, - "a signed tool schema never applies to an unlisted Grok Build version", +assert.deepEqual( + signed.schemas[0].eventTypes, + { ignored: ["thought"], text: ["text"], end: ["end"], error: ["error"], toolStart: [], toolProgress: [], toolEnd: [], toolComplete: [] }, + "the built-in schema makes no undocumented tool-event claim", ); const { keyId: _keyId, signature: _signature, ...unsignedWithoutKeyId } = signed; const signedWithoutKeyId = { From e45d311ba4602ca320bfe753725893961bb4b25f Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sun, 26 Jul 2026 14:31:46 -0400 Subject: [PATCH 15/61] test(grok): update shared routing contracts --- src/app/api/chat/send/harness-routing-opencode.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 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 ca56e3e54..bd6b33fe1 100644 --- a/src/app/api/chat/send/harness-routing-opencode.test.ts +++ b/src/app/api/chat/send/harness-routing-opencode.test.ts @@ -169,8 +169,8 @@ assert.match( ); assert.match( route, - /child\.on\("close", \(code\) => \{[\s\S]*?if \(\(openCodeDirect \|\| copilotStream\) && code !== 0\)[\s\S]*?is_error: true/, - "a non-zero direct OpenCode or Copilot exit cannot be treated as a successful run when no JSON error arrives", + /child\.on\("close", \(code\) => \{[\s\S]*?if \(\(openCodeDirect \|\| copilotStream \|\| grokDirect\) && code !== 0\)[\s\S]*?is_error: true/, + "a non-zero direct OpenCode, Copilot, or Grok exit cannot be treated as a successful run when no JSON error arrives", ); assert.match( route, @@ -179,8 +179,8 @@ assert.match( ); assert.match( route, - /const tailBlock = !openCodeDirect && tailSource\.length/, - "OpenCode stderr never becomes assistant-visible or persisted empty-response diagnostics", + /const tailBlock = !openCodeDirect && !grokDirect && tailSource\.length/, + "OpenCode and Grok stderr never become assistant-visible or persisted empty-response diagnostics", ); assert.match( route, From 2b962aa6b4febc317467f5f2336cdc1216dde7f5 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sun, 26 Jul 2026 14:32:13 -0400 Subject: [PATCH 16/61] test(grok): persist shared compatibility diagnostics --- 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 bd6b33fe1..5ee173b0c 100644 --- a/src/app/api/chat/send/harness-routing-opencode.test.ts +++ b/src/app/api/chat/send/harness-routing-opencode.test.ts @@ -219,8 +219,8 @@ assert.match( ); assert.match( route, - /persistedOpenCodeDiagnostics[\s\S]*?id === "opencode-compatibility"[\s\S]*?progress: persistedOpenCodeDiagnostics/, - "safe OpenCode compatibility diagnostics persist with the completed assistant turn", + /persistedCompatibilityDiagnostics[\s\S]*?id === "opencode-compatibility" \|\| id === "grok-compatibility"[\s\S]*?progress: persistedCompatibilityDiagnostics/, + "safe OpenCode and Grok compatibility diagnostics persist with the completed assistant turn", ); assert.match( route, From 7c33223cc300d9600d96e38f0d1ffb05424d9de3 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sun, 26 Jul 2026 14:55:29 -0400 Subject: [PATCH 17/61] fix(grok): fail closed after schema expiry --- docs/grok-compatibility-registry.md | 2 +- src/lib/grok-compatibility.test.ts | 26 ++++++++++++++++++++++++++ src/lib/grok-compatibility.ts | 23 +++++++++++++++++++++++ 3 files changed, 50 insertions(+), 1 deletion(-) diff --git a/docs/grok-compatibility-registry.md b/docs/grok-compatibility-registry.md index e11ad92ad..208976763 100644 --- a/docs/grok-compatibility-registry.md +++ b/docs/grok-compatibility-registry.md @@ -8,7 +8,7 @@ Release configuration is public verification material, never a signing key: - `GROK_SCHEMA_REGISTRY_PUBLIC_KEY`, or `GROK_SCHEMA_REGISTRY_PUBLIC_KEYS` — PEM Ed25519 trust anchor(s), with one to four key IDs for rotation. A bundle signed against a multi-key keyring must carry its exact `keyId`. - `GROK_SCHEMA_REGISTRY_CHECKPOINT` — JSON `{ "sequence": number, "payloadHash": "" }` that anchors first use and rollback resistance. -The release maps these to `NEXT_PUBLIC_COVEN_GROK_SCHEMA_REGISTRY_*`. Development may use `COVEN_GROK_SCHEMA_REGISTRY_*`. Registry downloads reject redirects, credentials, oversized bodies, malformed bundles, invalid signatures, checkpoint regressions, and cache-anchor rollbacks. The per-user cache is bounded, atomically replaced under a short writer lock, and keeps a bounded immutable high-water journal so a resumed stale writer cannot lower the accepted sequence. It is always reverified; an unknown or malformed selected event quarantines that schema in-process and future turns fall back to plain text. Do not publish a Grok tool schema until its precise stdout envelope is source-verified and captured from an approved non-production fixture. Never store a private key in this repository, app configuration, or release secrets. +The release maps these to `NEXT_PUBLIC_COVEN_GROK_SCHEMA_REGISTRY_*`. Development may use `COVEN_GROK_SCHEMA_REGISTRY_*`. Registry downloads reject redirects, credentials, oversized bodies, malformed bundles, invalid signatures, checkpoint regressions, and cache-anchor rollbacks. The per-user cache is bounded, atomically replaced under a short writer lock, and keeps a bounded immutable high-water journal so a resumed stale writer cannot lower the accepted sequence. It is always reverified; an unknown or malformed selected event quarantines that schema in-process and future turns fall back to plain text. If a newer remote contract was previously accepted, its cache expires, or its anchor is missing/corrupt, Cave does not revive the older compiled parser; it waits in plain chat for a verified refresh. The compiled baseline also expires rather than parsing future output indefinitely. Do not publish a Grok tool schema until its precise stdout envelope is source-verified and captured from an approved non-production fixture. Never store a private key in this repository, app configuration, or release secrets. ## Evidence diff --git a/src/lib/grok-compatibility.test.ts b/src/lib/grok-compatibility.test.ts index 52583c8bb..262ba0f40 100644 --- a/src/lib/grok-compatibility.test.ts +++ b/src/lib/grok-compatibility.test.ts @@ -90,6 +90,16 @@ try { assert.equal(rollback.bundleSource, "cache", "a lower signed sequence cannot replace the local high-water contract"); assert.equal(rollback.diagnostic, "schema-registry-refresh-rejected"); + const expiredRemote = await resolveGrokCompatibility(supported, { + publicKeys: keyring, + cachePath, + now: () => Date.parse("2031-01-01T00:00:00.000Z"), + url: "https://registry.example/grok.json", + fetch: async () => new Response(JSON.stringify(signed)), + }); + assert.equal(expiredRemote.mode, "plain", "an expired previously trusted remote contract cannot fall back to the older built-in parser"); + assert.equal(expiredRemote.diagnostic, "schema-registry-refresh-rejected"); + // The immutable journal survives loss of the replaceable recovery sidecar, // so a stale writer cannot erase high-water state by replacing that sidecar. await rm(`${cachePath}.anchor`); @@ -102,6 +112,16 @@ try { assert.equal(missingAnchor.bundleSource, "cache", "the immutable high-water journal keeps the verified cache available after sidecar loss"); assert.equal(missingAnchor.diagnostic, undefined); + await rm(`${cachePath}.anchor.journal`, { recursive: true, force: true }); + const lostAnchor = await resolveGrokCompatibility(supported, { + publicKeys: keyring, + cachePath, + url: "https://registry.example/grok.json", + fetch: async () => new Response(JSON.stringify(signed)), + }); + assert.equal(lostAnchor.mode, "plain", "a remote cache without any high-water anchor cannot revive the older built-in parser"); + assert.equal(lostAnchor.diagnostic, "cached-schema-unavailable"); + // Simulate an interruption after the high-water anchor is committed but // before the replacement cache file can be installed. The prior cache was // selected before fetch, so this verifies the current turn drops it too. @@ -130,4 +150,10 @@ try { await rm(cacheDirectory, { recursive: true, force: true }); } +const expiredBuiltIn = await resolveGrokCompatibility(supported, { + now: () => Date.parse("2031-01-01T00:00:00.000Z"), +}); +assert.equal(expiredBuiltIn.mode, "plain", "the shipped parser expires instead of continuing to parse future Grok output"); +assert.equal(expiredBuiltIn.diagnostic, "built-in-schema-expired"); + console.log("grok compatibility tests passed"); diff --git a/src/lib/grok-compatibility.ts b/src/lib/grok-compatibility.ts index 536d5a798..0b88903fe 100644 --- a/src/lib/grok-compatibility.ts +++ b/src/lib/grok-compatibility.ts @@ -168,6 +168,7 @@ export type GrokSchemaBundle = { export type GrokCompatibilityDiagnostic = | "streaming-json-unavailable" + | "built-in-schema-expired" | "no-compatible-schema" | "schema-quarantined" | "schema-registry-refresh-rejected" @@ -662,6 +663,17 @@ export async function resolveGrokCompatibility(capabilities: GrokRunCapabilities // a lower signed response to establish a new history after the anchor was // deleted or corrupted. const cacheAnchorMissing = Object.keys(keys).length > 0 && !anchor && await cacheRecordExists(cacheFile); + // Once a client has accepted a newer remote contract, the compiled parser + // is no longer an offline fallback: its aliases may describe an older wire + // format. A missing/corrupt cache anchor has the same fail-closed outcome. + const builtInAnchor = { + sequence: BUILTIN_GROK_SCHEMA_BUNDLE.sequence, + payloadHash: grokSchemaBundlePayloadHash(BUILTIN_GROK_SCHEMA_BUNDLE), + }; + const remoteSchemaRequired = cacheAnchorMissing || ( + !!anchor + && (anchor.sequence !== builtInAnchor.sequence || anchor.payloadHash !== builtInAnchor.payloadHash) + ); const cached = Object.keys(keys).length && !cacheAnchorMissing ? await readCache(cacheFile, keys, now, anchor) : null; if (cached && trustedBundle(cached.bundle, source.checkpoint)) { bundle = cached.bundle; bundleSource = "cache"; } const cacheFresh = !!cached && trustedBundle(cached.bundle, source.checkpoint) @@ -697,6 +709,17 @@ export async function resolveGrokCompatibility(capabilities: GrokRunCapabilities diagnostic = "schema-registry-refresh-rejected"; } } + if (remoteSchemaRequired && bundleSource === "built-in") { + return { + mode: "plain", + capabilities, + bundleSource, + diagnostic: diagnostic ?? "cached-schema-unavailable", + }; + } + if (bundleSource === "built-in" && Date.parse(BUILTIN_GROK_SCHEMA_BUNDLE.expiresAt) <= now) { + return { mode: "plain", capabilities, bundleSource, diagnostic: "built-in-schema-expired" }; + } const remoteById = new Map(bundle.schemas.map((schema) => [schema.id, schema])); const candidates = bundleSource === "built-in" ? bundle.schemas : [...bundle.schemas, ...BUILTIN_GROK_SCHEMA_BUNDLE.schemas.filter((schema) => !bundle.retiredSchemaIds?.includes(schema.id) && !remoteById.has(schema.id))]; const schema = selectGrokSchema(candidates, capabilities); From 244d290fd5bdb2f465e91c77ffcd8a3649e72690 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sun, 26 Jul 2026 15:05:27 -0400 Subject: [PATCH 18/61] fix(grok): bound registry refresh trust --- docs/grok-compatibility-registry.md | 2 +- src/lib/grok-compatibility.test.ts | 14 +++++ src/lib/grok-compatibility.ts | 81 +++++++++++++++++++++++++---- 3 files changed, 87 insertions(+), 10 deletions(-) diff --git a/docs/grok-compatibility-registry.md b/docs/grok-compatibility-registry.md index 208976763..0965ec738 100644 --- a/docs/grok-compatibility-registry.md +++ b/docs/grok-compatibility-registry.md @@ -8,7 +8,7 @@ Release configuration is public verification material, never a signing key: - `GROK_SCHEMA_REGISTRY_PUBLIC_KEY`, or `GROK_SCHEMA_REGISTRY_PUBLIC_KEYS` — PEM Ed25519 trust anchor(s), with one to four key IDs for rotation. A bundle signed against a multi-key keyring must carry its exact `keyId`. - `GROK_SCHEMA_REGISTRY_CHECKPOINT` — JSON `{ "sequence": number, "payloadHash": "" }` that anchors first use and rollback resistance. -The release maps these to `NEXT_PUBLIC_COVEN_GROK_SCHEMA_REGISTRY_*`. Development may use `COVEN_GROK_SCHEMA_REGISTRY_*`. Registry downloads reject redirects, credentials, oversized bodies, malformed bundles, invalid signatures, checkpoint regressions, and cache-anchor rollbacks. The per-user cache is bounded, atomically replaced under a short writer lock, and keeps a bounded immutable high-water journal so a resumed stale writer cannot lower the accepted sequence. It is always reverified; an unknown or malformed selected event quarantines that schema in-process and future turns fall back to plain text. If a newer remote contract was previously accepted, its cache expires, or its anchor is missing/corrupt, Cave does not revive the older compiled parser; it waits in plain chat for a verified refresh. The compiled baseline also expires rather than parsing future output indefinitely. Do not publish a Grok tool schema until its precise stdout envelope is source-verified and captured from an approved non-production fixture. Never store a private key in this repository, app configuration, or release secrets. +The release maps these to `NEXT_PUBLIC_COVEN_GROK_SCHEMA_REGISTRY_*`; production reads only those packaged anchors. Development may use `COVEN_GROK_SCHEMA_REGISTRY_*`, which production deliberately ignores. Registry downloads reject redirects, credentials, oversized or stalled bodies, malformed bundles, invalid signatures, checkpoint regressions, and cache-anchor rollbacks. The per-user cache is bounded, atomically replaced under a short writer lock, and keeps a bounded immutable high-water journal so a resumed stale writer cannot lower the accepted sequence. It is always reverified; an unknown or malformed selected event quarantines that schema in-process and future turns fall back to plain text. If a newer remote contract was previously accepted, its cache expires, or its anchor is missing/corrupt, Cave does not revive the older compiled parser; it waits in plain chat for a verified refresh. The compiled baseline also expires rather than parsing future output indefinitely. Do not publish a Grok tool schema until its precise stdout envelope is source-verified and captured from an approved non-production fixture. Never store a private key in this repository, app configuration, or release secrets. ## Evidence diff --git a/src/lib/grok-compatibility.test.ts b/src/lib/grok-compatibility.test.ts index 262ba0f40..6f24fa90d 100644 --- a/src/lib/grok-compatibility.test.ts +++ b/src/lib/grok-compatibility.test.ts @@ -90,6 +90,20 @@ try { assert.equal(rollback.bundleSource, "cache", "a lower signed sequence cannot replace the local high-water contract"); assert.equal(rollback.diagnostic, "schema-registry-refresh-rejected"); + const stalledRefresh = await Promise.race([ + resolveGrokCompatibility(supported, { + publicKeys: keyring, + cachePath, + now: () => Date.now() + 14 * 60 * 60 * 1000, + url: "https://registry.example/grok.json", + refreshTimeoutMs: 10, + fetch: async () => new Response(new ReadableStream()), + }), + new Promise((_, reject) => setTimeout(() => reject(new Error("Grok registry body read was not bounded")), 500)), + ]); + assert.equal(stalledRefresh.bundleSource, "cache", "a registry response that never finishes falls back to the verified cache"); + assert.equal(stalledRefresh.diagnostic, "schema-registry-refresh-rejected", "the bounded body timeout is an accessible refresh failure"); + const expiredRemote = await resolveGrokCompatibility(supported, { publicKeys: keyring, cachePath, diff --git a/src/lib/grok-compatibility.ts b/src/lib/grok-compatibility.ts index 0b88903fe..a61b08c2e 100644 --- a/src/lib/grok-compatibility.ts +++ b/src/lib/grok-compatibility.ts @@ -417,15 +417,38 @@ export function quarantineGrokSchema(schema: GrokEventSchema | undefined): void schemaQuarantine.set(schema.id, createHash("sha256").update(stableJson(schema)).digest("hex")); } -type RegistrySource = { url?: string; publicKeys?: GrokRegistryKeyring; checkpoint?: GrokRegistryCheckpoint; now?: () => number; fetch?: typeof globalThis.fetch; cachePath?: string }; +type RegistrySource = { + url?: string; + publicKeys?: GrokRegistryKeyring; + checkpoint?: GrokRegistryCheckpoint; + now?: () => number; + fetch?: typeof globalThis.fetch; + cachePath?: string; + /** Test-only override; production refreshes are bounded to five seconds. */ + refreshTimeoutMs?: number; +}; type Cached = { bundle: GrokSchemaBundle; checkedAt: number }; type TrustAnchor = { sequence: number; payloadHash: string }; +// Release-time public verification material becomes the production trust +// anchor. Mutable COVEN_* values remain a development/test escape hatch only; +// a production process environment must not replace its packaged keyring or +// checkpoint after the app has been built. +const PACKAGED_GROK_REGISTRY_URL = process.env.NEXT_PUBLIC_COVEN_GROK_SCHEMA_REGISTRY_URL; +const PACKAGED_GROK_REGISTRY_PUBLIC_KEY = process.env.NEXT_PUBLIC_COVEN_GROK_SCHEMA_REGISTRY_PUBLIC_KEY; +const PACKAGED_GROK_REGISTRY_PUBLIC_KEYS = process.env.NEXT_PUBLIC_COVEN_GROK_SCHEMA_REGISTRY_PUBLIC_KEYS; +const PACKAGED_GROK_REGISTRY_CHECKPOINT = process.env.NEXT_PUBLIC_COVEN_GROK_SCHEMA_REGISTRY_CHECKPOINT; + function configuredGrokRegistrySource(): RegistrySource { - const url = process.env.NEXT_PUBLIC_COVEN_GROK_SCHEMA_REGISTRY_URL || process.env.COVEN_GROK_SCHEMA_REGISTRY_URL; - const single = process.env.NEXT_PUBLIC_COVEN_GROK_SCHEMA_REGISTRY_PUBLIC_KEY || process.env.COVEN_GROK_SCHEMA_REGISTRY_PUBLIC_KEY; - const rawKeyring = process.env.NEXT_PUBLIC_COVEN_GROK_SCHEMA_REGISTRY_PUBLIC_KEYS || process.env.COVEN_GROK_SCHEMA_REGISTRY_PUBLIC_KEYS; - const rawCheckpoint = process.env.NEXT_PUBLIC_COVEN_GROK_SCHEMA_REGISTRY_CHECKPOINT || process.env.COVEN_GROK_SCHEMA_REGISTRY_CHECKPOINT; + const production = process.env.NODE_ENV === "production"; + const url = PACKAGED_GROK_REGISTRY_URL + ?? (production ? undefined : process.env.COVEN_GROK_SCHEMA_REGISTRY_URL); + const single = PACKAGED_GROK_REGISTRY_PUBLIC_KEY + ?? (production ? undefined : process.env.COVEN_GROK_SCHEMA_REGISTRY_PUBLIC_KEY); + const rawKeyring = PACKAGED_GROK_REGISTRY_PUBLIC_KEYS + ?? (production ? undefined : process.env.COVEN_GROK_SCHEMA_REGISTRY_PUBLIC_KEYS); + const rawCheckpoint = PACKAGED_GROK_REGISTRY_CHECKPOINT + ?? (production ? undefined : process.env.COVEN_GROK_SCHEMA_REGISTRY_CHECKPOINT); let publicKeys: GrokRegistryKeyring | undefined; let checkpoint: GrokRegistryCheckpoint | undefined; try { publicKeys = rawKeyring ? JSON.parse(rawKeyring) : single ? { legacy: single } : undefined; } catch { /* invalid config fails closed */ } @@ -475,7 +498,12 @@ async function readBoundedRegistryBody(response: Response): Promise { if (total > MAX_BUNDLE_BYTES) throw new Error("oversized registry response"); chunks.push(part.value); } - } finally { reader.releaseLock(); } + } finally { + // A registry server can leave a chunked response open forever. The outer + // refresh deadline races this read, and cancellation makes that deadline + // release its socket/body reader rather than leaving it pinned in Node. + await reader.cancel().catch(() => undefined); + } const bytes = new Uint8Array(total); let offset = 0; for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; } @@ -651,6 +679,43 @@ function trustedBundle(bundle: GrokSchemaBundle, checkpoint?: GrokRegistryCheckp return !checkpoint || bundle.sequence > checkpoint.sequence || (bundle.sequence === checkpoint.sequence && grokSchemaBundlePayloadHash(bundle) === checkpoint.payloadHash); } +/** The refresh deadline covers headers and an indefinitely open response body. */ +async function fetchGrokSchemaBundle( + url: string, + fetcher: typeof globalThis.fetch, + timeoutMs: number, +): Promise { + const controller = new AbortController(); + let response: Response | undefined; + let timer: ReturnType | undefined; + let timedOut = false; + const deadline = new Promise((_, reject) => { + timer = setTimeout(() => { + timedOut = true; + controller.abort(); + void response?.body?.cancel().catch(() => undefined); + reject(new Error("registry refresh timed out")); + }, timeoutMs); + }); + try { + return await Promise.race([ + (async () => { + response = await fetcher(url, { + signal: controller.signal, + credentials: "omit", + redirect: "error", + headers: { accept: "application/json" }, + }); + if (timedOut || !response.ok) throw new Error("untrusted registry response"); + return readBoundedRegistryBody(response); + })(), + deadline, + ]); + } finally { + if (timer) clearTimeout(timer); + } +} + export async function resolveGrokCompatibility(capabilities: GrokRunCapabilities, source: RegistrySource = configuredGrokRegistrySource()): Promise { if (!capabilities.streamingJson) return { mode: "plain", capabilities, bundleSource: "built-in", diagnostic: "streaming-json-unavailable" }; const now = source.now?.() ?? Date.now(); @@ -683,9 +748,7 @@ export async function resolveGrokCompatibility(capabilities: GrokRunCapabilities } else if (source.url && Object.keys(keys).length && !cacheFresh) { try { if (!isSafeRegistryUrl(source.url)) throw new Error("unsafe registry URL"); - const response = await (source.fetch ?? fetch)(source.url, { signal: AbortSignal.timeout(5_000), credentials: "omit", redirect: "error" }); - const body = await readBoundedRegistryBody(response); - if (!response.ok) throw new Error("untrusted registry response"); + const body = await fetchGrokSchemaBundle(source.url, source.fetch ?? fetch, source.refreshTimeoutMs ?? 5_000); const remote = JSON.parse(body); if (!verifyGrokSchemaBundle(remote, keys, now) || !trustedBundle(remote, source.checkpoint) || !meetsTrustAnchor(remote, anchor)) throw new Error("untrusted registry bundle"); if (await writeCache(cacheFile, remote, now)) { From 463f8942e2647808adcf0d392287649e2033bb87 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sun, 26 Jul 2026 15:25:52 -0400 Subject: [PATCH 19/61] fix(grok): preserve reordered combined tool completion --- ...ute-grok-compatibility.integration.test.ts | 71 +++++++++++++++++++ src/app/api/chat/send/route.ts | 10 +++ 2 files changed, 81 insertions(+) diff --git a/src/app/api/chat/send/route-grok-compatibility.integration.test.ts b/src/app/api/chat/send/route-grok-compatibility.integration.test.ts index 34e2d05ec..7f948fdee 100644 --- a/src/app/api/chat/send/route-grok-compatibility.integration.test.ts +++ b/src/app/api/chat/send/route-grok-compatibility.integration.test.ts @@ -1,5 +1,6 @@ // @ts-nocheck import assert from "node:assert/strict"; +import { generateKeyPairSync, sign } from "node:crypto"; import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import path from "node:path"; @@ -19,11 +20,53 @@ const previousPath = process.env.PATH; const previousGrokBin = process.env.GROK_BIN; const previousGrokTestMode = process.env.GROK_TEST_MODE; const previousXaiApiKey = process.env.XAI_API_KEY; +const previousGrokRegistryKeys = process.env.COVEN_GROK_SCHEMA_REGISTRY_PUBLIC_KEYS; +const previousGrokRegistryCheckpoint = process.env.COVEN_GROK_SCHEMA_REGISTRY_CHECKPOINT; process.env.COVEN_HOME = home; process.env.COVEN_CAVE_HOME = path.join(home, "cave"); process.env.PATH = `${bin}${path.delimiter}${previousPath ?? ""}`; process.env.XAI_API_KEY = "probe-must-not-receive-this"; +// These event names are a deterministic signed-registry fixture only; they +// make no claim about Grok Build's undocumented tool protocol. +const { privateKey, publicKey } = generateKeyPairSync("ed25519"); +process.env.COVEN_GROK_SCHEMA_REGISTRY_PUBLIC_KEYS = JSON.stringify({ fixture: publicKey.export({ format: "pem", type: "spki" }).toString() }); +const { grokSchemaBundlePayloadHash, grokSchemaBundleSigningPayload } = await import("@/lib/grok-compatibility"); +const fixtureBundle = { + format: 1 as const, + runtime: "grok-build" as const, + sequence: 2, + issuedAt: "2026-07-26T00:00:00.000Z", + expiresAt: "2030-01-01T00:00:00.000Z", + keyId: "fixture", + schemas: [{ + id: "grok-build-fixture-tool-events", + priority: 1, + requires: { streamingJson: true as const, options: ["--output-format"], versions: ["1.0.0"] }, + eventTypes: { + ignored: ["thought"], text: ["text"], end: ["end"], error: ["error"], + toolStart: ["fixture_tool_start"], toolProgress: ["fixture_tool_progress"], + toolEnd: ["fixture_tool_end"], toolComplete: ["fixture_tool_complete"], + }, + fields: { + type: ["type"], text: ["data"], sessionId: ["sessionId"], message: ["message"], + usage: [], totalCostUsd: [], id: ["id"], name: ["name"], input: ["input"], + output: ["output"], state: ["state"], error: ["error"], terminalStates: [], errorStates: ["error"], + }, + launch: { outputOption: "--output-format" as const, outputValue: "streaming-json" as const }, + }], +}; +fixtureBundle.signature = { + algorithm: "ed25519" as const, + value: sign(null, Buffer.from(grokSchemaBundleSigningPayload(fixtureBundle)), privateKey).toString("base64"), +}; +const fixtureHash = grokSchemaBundlePayloadHash(fixtureBundle); +process.env.COVEN_GROK_SCHEMA_REGISTRY_CHECKPOINT = JSON.stringify({ sequence: fixtureBundle.sequence, payloadHash: fixtureHash }); +const fixtureCachePath = path.join(process.env.COVEN_CAVE_HOME, "grok-schema-bundle-v1.json"); +await mkdir(`${fixtureCachePath}.anchor.journal`, { recursive: true }); +await writeFile(fixtureCachePath, JSON.stringify({ checkedAt: Date.now(), bundle: fixtureBundle })); +await writeFile(`${fixtureCachePath}.anchor.journal/${fixtureBundle.sequence}-${fixtureHash}.json`, JSON.stringify({ sequence: fixtureBundle.sequence, payloadHash: fixtureHash })); + const executable = process.platform === "win32" ? "grok.cmd" : "grok"; const windowsShimTarget = path.join(bin, "grok-launcher.js"); const windowsShimProgram = [ @@ -34,6 +77,7 @@ const windowsShimProgram = [ "else if (process.env.GROK_TEST_MODE === 'plain-structured') console.log(' {\\\"opaque\\\":\\\"private structured payload\\\"}');", "else if (process.env.GROK_TEST_MODE === 'plain-structured-array') console.log('[{\\\"opaque\\\":\\\"private array payload\\\"}]');", "else if (process.env.GROK_TEST_MODE === 'plain-bracketed-text') console.log('[a safe plain-text reply]');", + "else if (process.env.GROK_TEST_MODE === 'tool-activity') console.log(['{\"type\":\"fixture_tool_end\",\"id\":\"reordered\",\"output\":\"first terminal result\"}', '{\"type\":\"fixture_tool_progress\",\"id\":\"reordered\",\"output\":\"early progress\"}', '{\"type\":\"fixture_tool_complete\",\"id\":\"reordered\",\"name\":\"fixture_call\",\"input\":{\"safe\":true},\"output\":\"duplicate terminal result\"}', '{\"type\":\"fixture_tool_complete\",\"id\":\"terminal-error\",\"name\":\"fixture_error\",\"input\":{},\"output\":\"terminal error\",\"error\":true}', '{\"type\":\"end\",\"sessionId\":\"native_grok_session\"}'].join('\\n'));", "else if (process.env.GROK_TEST_MODE === 'malformed') console.log('unframed private tool payload');", "else if (process.env.GROK_TEST_MODE === 'exit-error') { console.error('private Grok stderr payload'); process.exit(3); }", "else console.log('{\\\"type\\\":\\\"text\\\",\\\"data\\\":\\\"verified route reply\\\"}\\n{\\\"type\\\":\\\"end\\\",\\\"sessionId\\\":\\\"native_grok_session\\\"}');", @@ -55,6 +99,7 @@ const launcher = process.platform === "win32" "if [ \"$GROK_TEST_MODE\" = \"plain-structured\" ]; then printf '%s\\n' ' {\"opaque\":\"private structured payload\"}'; exit 0; fi", "if [ \"$GROK_TEST_MODE\" = \"plain-structured-array\" ]; then printf '%s\\n' '[{\"opaque\":\"private array payload\"}]'; exit 0; fi", "if [ \"$GROK_TEST_MODE\" = \"plain-bracketed-text\" ]; then printf '%s\\n' '[a safe plain-text reply]'; exit 0; fi", + "if [ \"$GROK_TEST_MODE\" = \"tool-activity\" ]; then printf '%s\\n' '{\"type\":\"fixture_tool_end\",\"id\":\"reordered\",\"output\":\"first terminal result\"}' '{\"type\":\"fixture_tool_progress\",\"id\":\"reordered\",\"output\":\"early progress\"}' '{\"type\":\"fixture_tool_complete\",\"id\":\"reordered\",\"name\":\"fixture_call\",\"input\":{\"safe\":true},\"output\":\"duplicate terminal result\"}' '{\"type\":\"fixture_tool_complete\",\"id\":\"terminal-error\",\"name\":\"fixture_error\",\"input\":{},\"output\":\"terminal error\",\"error\":true}' '{\"type\":\"end\",\"sessionId\":\"native_grok_session\"}'; exit 0; fi", "if [ \"$GROK_TEST_MODE\" = \"malformed\" ]; then printf '%s\\n' 'unframed private tool payload'; exit 0; fi", "if [ \"$GROK_TEST_MODE\" = \"exit-error\" ]; then printf '%s\\n' 'private Grok stderr payload' >&2; exit 3; fi", "printf '%s\\n' '{\"type\":\"text\",\"data\":\"verified route reply\"}' '{\"type\":\"end\",\"sessionId\":\"native_grok_session\"}'", @@ -93,6 +138,30 @@ try { const conversation = await loadConversation(structuredDone.sessionId); assert.equal(conversation?.harnessSessionId, "native_grok_session", "the route persists Grok's native resume id separately from Cave's id"); + process.env.GROK_TEST_MODE = "tool-activity"; + const toolActivity = await readSse(await POST(new Request("http://localhost/api/chat/send", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ familiarId: "opal", prompt: "tool fixture", projectRoot: familiarWorkspace }), + }))); + const toolEvents = toolActivity.events.filter((event) => event.kind === "tool_use"); + assert.deepEqual( + toolEvents.map((event) => [event.name, event.status, event.output]), + [ + ["fixture_call", "running", undefined], + ["fixture_call", "running", "early progress"], + ["fixture_call", "ok", "first terminal result"], + ["fixture_error", "running", undefined], + ["fixture_error", "error", "terminal error"], + ], + "a selected signed fixture preserves stable tool activity across reordered, duplicate, and error terminal frames", + ); + assert.doesNotMatch(toolActivity.body, /duplicate terminal result/, "the first terminal result wins when a combined completion is retransmitted"); + const toolConversation = await loadConversation(toolActivity.events.findLast((event) => event.kind === "done")?.sessionId); + assert.deepEqual(toolConversation?.turns.at(-1)?.tools?.map((tool) => [tool.name, tool.status, tool.output]), [ + ["fixture_call", "ok", "first terminal result"], + ["fixture_error", "error", "terminal error"], + ], "selected-schema tool activity persists through the real chat route"); + process.env.GROK_TEST_MODE = "plain"; const plain = await readSse(await POST(new Request("http://localhost/api/chat/send", { method: "POST", headers: { "content-type": "application/json" }, @@ -148,6 +217,8 @@ try { if (previousGrokBin === undefined) delete process.env.GROK_BIN; else process.env.GROK_BIN = previousGrokBin; if (previousGrokTestMode === undefined) delete process.env.GROK_TEST_MODE; else process.env.GROK_TEST_MODE = previousGrokTestMode; if (previousXaiApiKey === undefined) delete process.env.XAI_API_KEY; else process.env.XAI_API_KEY = previousXaiApiKey; + if (previousGrokRegistryKeys === undefined) delete process.env.COVEN_GROK_SCHEMA_REGISTRY_PUBLIC_KEYS; else process.env.COVEN_GROK_SCHEMA_REGISTRY_PUBLIC_KEYS = previousGrokRegistryKeys; + if (previousGrokRegistryCheckpoint === undefined) delete process.env.COVEN_GROK_SCHEMA_REGISTRY_CHECKPOINT; else process.env.COVEN_GROK_SCHEMA_REGISTRY_CHECKPOINT = previousGrokRegistryCheckpoint; 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 36312aba4..68864e9ed 100644 --- a/src/app/api/chat/send/route.ts +++ b/src/app/api/chat/send/route.ts @@ -2457,6 +2457,16 @@ export async function POST(req: Request) { boundarySentinel?.observe(event.name, event.input); const started = toolTracker.envelopeToolUse(event.id, event.name, formatToolInputValue(event.input), assistantText.length); if (started) push({ kind: "tool_use", ...started }); + const progress = toolTracker.consumePendingEnvelopeProgress(event.id); + if (progress) push({ kind: "tool_use", ...progress }); + // A terminal frame can be flushed before the combined snapshot. + // Keep ToolCallTracker's first terminal outcome instead of + // overwriting it with a later duplicate completion. + const reorderedEnd = toolTracker.consumePendingEnvelopeResult(event.id); + if (reorderedEnd) { + push({ kind: "tool_use", ...reorderedEnd }); + return; + } const ended = toolTracker.envelopeToolResult(event.id, typeof event.output === "string" ? event.output : formatToolInputValue(event.output), event.isError); if (ended) push({ kind: "tool_use", ...ended }); return; From 605596c8cf4e063366cd9a76fce88d0d7b70419a Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sun, 26 Jul 2026 15:50:38 -0400 Subject: [PATCH 20/61] test(grok): cover preflight-backed capability probing --- src/app/api/chat/send/harness-routing-copilot-jsonl.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 646e66135..d9b23fbb8 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 @@ -257,8 +257,8 @@ assert.match( ); assert.match( chatRoute, - /resolveGrokCompatibility\(await probeGrokRunCapabilities\(grokLaunchCommand\(\), harnessSpawnEnv\(body\.familiarId\)\)\)/, - "Grok must probe the exact launcher before selecting a structured schema", + /const grokCapabilities = grokDirect[\s\S]*?probeReadyLocalRuntimeCapability\([\s\S]*?runner: "grok",[\s\S]*?probe: \(\) => probeGrokRunCapabilities\([\s\S]*?command: localRuntimePlan!\.command,[\s\S]*?fixedArgs: localRuntimePlan!\.fixedArgs[\s\S]*?localRuntimePlan!\.env[\s\S]*?const grokCompatibility = grokCapabilities[\s\S]*?resolveGrokCompatibility\(grokCapabilities\)/, + "Grok must probe the exact preflight-approved launcher and environment before selecting a structured schema", ); assert.match( chatRoute, From d14f3ccb0ca23c77e499bb2dbecf1101e7485d60 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 21:35:31 -0400 Subject: [PATCH 21/61] docs: plan direct OpenClaw gateway dispatch --- ...26-07-25-openclaw-gateway-dispatch-plan.md | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 docs/specs/2026-07-25-openclaw-gateway-dispatch-plan.md diff --git a/docs/specs/2026-07-25-openclaw-gateway-dispatch-plan.md b/docs/specs/2026-07-25-openclaw-gateway-dispatch-plan.md new file mode 100644 index 000000000..c0d6ef11f --- /dev/null +++ b/docs/specs/2026-07-25-openclaw-gateway-dispatch-plan.md @@ -0,0 +1,89 @@ +# OpenClaw Gateway-dispatched tool activity + +**GitHub:** #3865 (implementation), #3847 (parent compatibility work), supersedes the reverted observer-only approach in #3852. + +## Decision + +Cave must not observe a CLI-created OpenClaw run. The CLI does not expose the +Gateway's accepted run ID before `session.tool` events may arrive, so such an +observer cannot attribute tool cards safely when sessions overlap. + +When a Gateway meets the supported compatibility contract, Cave dispatches the +turn through the authenticated Gateway itself. The same Gateway connection owns +the accepted `runId`, subscribes to session events, and accepts only events +belonging to that exact run. The current CLI bridge stays the authoritative +fallback for every other runtime. + +## Supported contract + +The first supported profile is OpenClaw Gateway protocol v4 using the published +`@openclaw/gateway-client` and `@openclaw/gateway-protocol` packages pinned in +Cave's lockfile. Cave requests `operator.read` and `operator.write`, advertises +only `tool-events` and `session-scoped-events`, validates the negotiated +role/scopes, and requires the documented `chat.send`, `sessions.subscribe`, +`sessions.messages.subscribe`, `chat`, and `session.tool` surfaces. + +The direct dispatcher supplies an idempotency key, receives the accepted run +identifier, then binds all live state to `(sessionKey, agentId, runId)`. A tool +call key is `(runId, toolCallId)`, not a session-wide call ID. + +No runtime is upgraded heuristically. Older protocol versions, unavailable +packages, unpaired devices, missing `operator.write`, an absent capability, or +an unknown schema use the existing CLI/plain-chat path with a visible +diagnostic. A protocol-version mismatch is a compatibility boundary, not a +reason to guess a field shape. + +## Runtime sequence + +1. Resolve the local OpenClaw runtime and Gateway endpoint without passing + Gateway credentials to a fallback child process. +2. Create or load a paired device identity; authenticate with the reference + Gateway client and validate `hello-ok` plus negotiated policy limits. +3. Establish `sessions.subscribe` and the selected canonical-session + subscription before dispatching the turn. +4. Send `chat.send` with the Cave message, canonical session key, agent ID, + and an idempotency key derived from the Cave request ID. Record the + Gateway-accepted `runId`. +5. Project only matching `chat` and `session.tool` events to Cave SSE. Maintain + a per-run high-water sequence, reject replay, reload history on a forward + gap, and never treat an unknown event as liveness. +6. On terminal chat state, persist the response and reconciled tool cards. On + cancellation, abort the exact `runId`, close the stream, and settle only its + unfinished cards. +7. On authentication, dispatch, validation, sequence, disconnect, or protocol + failure, close the Gateway attempt. Fall back to CLI only if no Gateway run + was accepted; never execute both transports for one user turn. + +## Compatibility and upgrade policy + +- Depend on the official protocol/client packages rather than local copies of + WebSocket framing, signing, or schemas. +- Keep an explicit profile table keyed by protocol version and package release + range. A profile declares exact methods, events, scopes, payload validators, + limits, and migration behavior. +- Generate/capture protocol conformance fixtures from each supported package + release. Include supported, old/unsupported, future/unknown, missing-scope, + pairing-required, replay, sequence-gap, disconnect, cancellation, and + concurrent-run cases. +- Upgrade only after the schema diff and fixtures pass. Unknown wire versions + fail closed to CLI; a new Cave release adds a tested profile. + +## Verification + +Add a route-level Gateway fixture that performs the real authenticated +handshake, subscriptions, `chat.send` acknowledgement, and emitted chat/tool +lifecycle. It must prove that start/update/result cards reach SSE and +persistence for the accepted run, and that otherwise-valid concurrent-session +frames are rejected. Exercise cancellation, reconnect/history reconciliation, +and every fallback boundary above. + +## Delivery slices + +1. Add official protocol/client dependencies, capability/profile discovery, + paired-device credential storage, and protocol fixtures. +2. Implement one owned Gateway turn with chat SSE projection and a CLI fallback + selected before dispatch. +3. Implement correlated tool lifecycle, persistence, cancellation, and + reconciliation. +4. Add cross-version conformance and route-level integration tests; document + operator setup and upgrade support boundaries. From 1cec85d5d568b35095a3b73aa08b3b0d1f27a780 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 23:16:41 -0400 Subject: [PATCH 22/61] docs: clarify OpenClaw gateway dispatch plan --- ...26-07-25-openclaw-gateway-dispatch-plan.md | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/docs/specs/2026-07-25-openclaw-gateway-dispatch-plan.md b/docs/specs/2026-07-25-openclaw-gateway-dispatch-plan.md index c0d6ef11f..283e76125 100644 --- a/docs/specs/2026-07-25-openclaw-gateway-dispatch-plan.md +++ b/docs/specs/2026-07-25-openclaw-gateway-dispatch-plan.md @@ -1,6 +1,8 @@ -# OpenClaw Gateway-dispatched tool activity +# OpenClaw Gateway-dispatch implementation plan -**GitHub:** #3865 (implementation), #3847 (parent compatibility work), supersedes the reverted observer-only approach in #3852. +**GitHub:** #3865 (implementation issue), #3847 (parent compatibility work), +and #3852 (the retained safe CLI/plain-chat stop point). This document is a +planning contract only; it does not enable Gateway dispatch. ## Decision @@ -37,8 +39,10 @@ reason to guess a field shape. 1. Resolve the local OpenClaw runtime and Gateway endpoint without passing Gateway credentials to a fallback child process. -2. Create or load a paired device identity; authenticate with the reference - Gateway client and validate `hello-ok` plus negotiated policy limits. +2. Create or load a paired device identity from OS-backed secret storage; + authenticate with the reference Gateway client and validate `hello-ok` plus + negotiated policy limits. Never persist credentials in plaintext or include + them in logs, caches, SSE, or diagnostics. 3. Establish `sessions.subscribe` and the selected canonical-session subscription before dispatching the turn. 4. Send `chat.send` with the Cave message, canonical session key, agent ID, @@ -50,9 +54,15 @@ reason to guess a field shape. 6. On terminal chat state, persist the response and reconciled tool cards. On cancellation, abort the exact `runId`, close the stream, and settle only its unfinished cards. -7. On authentication, dispatch, validation, sequence, disconnect, or protocol - failure, close the Gateway attempt. Fall back to CLI only if no Gateway run - was accepted; never execute both transports for one user turn. +7. Before a `chat.send` acknowledgement, resolve an ambiguous dispatch using + its idempotency key and authoritative Gateway status/history. Start the CLI + fallback only after acceptance is disproven; a lost acknowledgement is not + permission to duplicate the turn. +8. After acceptance, use the official keepalive/liveness policy. On reconnect, + restore both subscriptions, reconcile authoritative history and the active + run, then resume only validated frames for the accepted run. If recovery + fails, terminate and settle the Gateway-owned turn; never replace it with a + CLI invocation. ## Compatibility and upgrade policy From f206b5a1d54466b29f2cc7487a54488f913ccce4 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 23:22:45 -0400 Subject: [PATCH 23/61] docs: fence cancelled OpenClaw gateway runs --- docs/specs/2026-07-25-openclaw-gateway-dispatch-plan.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/specs/2026-07-25-openclaw-gateway-dispatch-plan.md b/docs/specs/2026-07-25-openclaw-gateway-dispatch-plan.md index 283e76125..94d6da5e0 100644 --- a/docs/specs/2026-07-25-openclaw-gateway-dispatch-plan.md +++ b/docs/specs/2026-07-25-openclaw-gateway-dispatch-plan.md @@ -52,8 +52,11 @@ reason to guess a field shape. a per-run high-water sequence, reject replay, reload history on a forward gap, and never treat an unknown event as liveness. 6. On terminal chat state, persist the response and reconciled tool cards. On - cancellation, abort the exact `runId`, close the stream, and settle only its - unfinished cards. + cancellation, first persist a per-run `cancelled` terminal fence, then abort + the exact `runId`, close the stream, and settle only its unfinished cards. + Every event, reconciliation, and persistence path checks that fence: a + queued or late result for that run may not replace cancelled card or turn + state with success. 7. Before a `chat.send` acknowledgement, resolve an ambiguous dispatch using its idempotency key and authoritative Gateway status/history. Start the CLI fallback only after acceptance is disproven; a lost acknowledgement is not From db065fb1dba0efb0a5261b7818614f792fc1d3e8 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 23:54:13 -0400 Subject: [PATCH 24/61] feat(chat): dispatch OpenClaw turns through Gateway --- package.json | 3 + pnpm-lock.yaml | 55 +++ scripts/run-tests.mjs | 1 + .../send/harness-routing-tool-events.test.ts | 16 + src/app/api/chat/send/route.ts | 224 +++++++++-- src/lib/openclaw-gateway.test.ts | 134 +++++++ src/lib/openclaw-gateway.ts | 348 ++++++++++++++++++ 7 files changed, 757 insertions(+), 24 deletions(-) create mode 100644 src/lib/openclaw-gateway.test.ts create mode 100644 src/lib/openclaw-gateway.ts diff --git a/package.json b/package.json index 740ea9528..37659e6ed 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,8 @@ "@iconify/react": "6.0.2", "@lezer/highlight": "1.2.3", "@milkdown/crepe": "7.21.2", + "@openclaw/gateway-client": "2026.7.2-beta.4", + "@openclaw/gateway-protocol": "2026.7.2-beta.4", "@tailwindcss/browser": "4.3.1", "@tauri-apps/api": "2.11.1", "@tauri-apps/plugin-notification": "2.3.3", @@ -101,6 +103,7 @@ "sharp": "0.34.5", "shiki": "4.3.0", "sucrase": "3.35.1", + "typebox": "1.3.6", "ws": "8.21.0", "yaml": "2.9.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 154c9651e..53ed4a542 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,12 @@ importers: '@milkdown/crepe': specifier: 7.21.2 version: 7.21.2(prosemirror-model@1.25.9)(prosemirror-state@1.4.4)(prosemirror-view@1.42.0)(typescript@6.0.3) + '@openclaw/gateway-client': + specifier: 2026.7.2-beta.4 + version: 2026.7.2-beta.4 + '@openclaw/gateway-protocol': + specifier: 2026.7.2-beta.4 + version: 2026.7.2-beta.4 '@tailwindcss/browser': specifier: 4.3.1 version: 4.3.1 @@ -131,6 +137,9 @@ importers: sucrase: specifier: 3.35.1 version: 3.35.1 + typebox: + specifier: 1.3.6 + version: 1.3.6 ws: specifier: 8.21.0 version: 8.21.0 @@ -978,6 +987,14 @@ packages: '@ocavue/utils@1.7.0': resolution: {integrity: sha512-yEk9ATNBjTZTtuVFMB/MAIF6zJBvJ2+lVNQvK2+O+ggEBGTgx2tp27d4FPgmD5bRsNHHP3D0SleQia/bvIeV8w==} + '@openclaw/gateway-client@2026.7.2-beta.4': + resolution: {integrity: sha512-iWkaaUQ+sBuLYYw6NkFlmboFJ4ZCIv/5YZiLcCvl8+EsYOyumVim1C+ar9OfIRj/eusMeMbvEpzpkuVfu6WDmg==} + engines: {node: '>=22.19.0'} + + '@openclaw/gateway-protocol@2026.7.2-beta.4': + resolution: {integrity: sha512-oE2uQYu6/GtIp4eGgkZyDFE2dZ06PMWCPsPKQvKI2DljhVvQ4F9Jn6IPWTHlO8oQqU8zE1Hyu/3Lnqjkc7Ng8w==} + engines: {node: '>=22.19.0'} + '@oxc-project/types@0.133.0': resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} @@ -2244,6 +2261,10 @@ packages: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} engines: {node: '>=12'} + ipaddr.js@2.4.0: + resolution: {integrity: sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==} + engines: {node: '>= 10'} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -2996,6 +3017,9 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + typebox@1.3.6: + resolution: {integrity: sha512-Sc8RA0NCMEFmApHNU9ZMzqcpQj46She44J8ffpLM/bdhLNUZKq7DJumcLcsFx1gRmDfQPgCgOmFFJ7rcnfWNyA==} + typescript@6.0.3: resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} engines: {node: '>=14.17'} @@ -3183,6 +3207,18 @@ packages: utf-8-validate: optional: true + ws@8.21.1: + resolution: {integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + y18n@4.0.3: resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} @@ -4244,6 +4280,19 @@ snapshots: '@ocavue/utils@1.7.0': {} + '@openclaw/gateway-client@2026.7.2-beta.4': + dependencies: + '@openclaw/gateway-protocol': 2026.7.2-beta.4 + ipaddr.js: 2.4.0 + ws: 8.21.1 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@openclaw/gateway-protocol@2026.7.2-beta.4': + dependencies: + typebox: 1.3.6 + '@oxc-project/types@0.133.0': {} '@playwright/test@1.61.1': @@ -5568,6 +5617,8 @@ snapshots: internmap@2.0.3: {} + ipaddr.js@2.4.0: {} + is-extglob@2.1.1: {} is-fullwidth-code-point@3.0.0: {} @@ -6563,6 +6614,8 @@ snapshots: dependencies: prelude-ls: 1.2.1 + typebox@1.3.6: {} + typescript@6.0.3: {} undici-types@7.18.2: {} @@ -6716,6 +6769,8 @@ snapshots: ws@8.21.0: {} + ws@8.21.1: {} + y18n@4.0.3: {} yaml@2.9.0: {} diff --git a/scripts/run-tests.mjs b/scripts/run-tests.mjs index 5f2e7894b..1ff41a5ee 100644 --- a/scripts/run-tests.mjs +++ b/scripts/run-tests.mjs @@ -1007,6 +1007,7 @@ export const SUITES = { "src/lib/hermes-responses-stream.test.ts", "src/lib/openclaw-bin.test.ts", "src/lib/openclaw-bridge.test.ts", + "src/lib/openclaw-gateway.test.ts", "src/lib/coven-identity-canon.test.ts", "src/lib/familiar-runtime.test.ts", "src/lib/harness-adapters.test.ts", diff --git a/src/app/api/chat/send/harness-routing-tool-events.test.ts b/src/app/api/chat/send/harness-routing-tool-events.test.ts index 34545c4dc..00105e6b0 100644 --- a/src/app/api/chat/send/harness-routing-tool-events.test.ts +++ b/src/app/api/chat/send/harness-routing-tool-events.test.ts @@ -36,6 +36,22 @@ const chatView = await readFile( new URL("../../../../components/chat-view.tsx", import.meta.url), "utf8", ); + +assert.match( + chatRoute, + /dispatchOpenClawGatewayTurn\([\s\S]*?sessionKey: openClawSessionKey\(conversationId\),[\s\S]*?agentId,[\s\S]*?message: args\.harnessPrompt/, + "OpenClaw uses the Gateway-owned dispatcher with the canonical session key and agent id before selecting the CLI fallback", +); +assert.match( + chatRoute, + /gatewayDispatch\.kind === "accepted"[\s\S]*?return;[\s\S]*?pushProgress\("openclaw-start", "Starting OpenClaw bridge"/, + "an accepted Gateway turn exits before the CLI branch, preventing duplicate transport ownership", +); +assert.match( + chatRoute, + /gatewayDispatch\.kind === "indeterminate"[\s\S]*?openclaw_gateway_indeterminate[\s\S]*?return;/, + "an ambiguous Gateway acknowledgement produces a terminal error instead of a duplicate CLI turn", +); // ── Tool-event fidelity (CHAT-D4-03 + CHAT-D4-04) ────────────────────────── // Source pins: the route must route BOTH tool-event sources through the // shared ToolCallTracker — hook lines and stream-json envelope blocks — and diff --git a/src/app/api/chat/send/route.ts b/src/app/api/chat/send/route.ts index 2836f27af..e423ae50f 100644 --- a/src/app/api/chat/send/route.ts +++ b/src/app/api/chat/send/route.ts @@ -129,6 +129,7 @@ import { } from "@/lib/server/chat-project-launch"; import { validateCaveProjectRoot } from "@/lib/server/project-paths"; import { openClawLaunchCommand, openClawSpawnEnv } from "@/lib/openclaw-bin"; +import { dispatchOpenClawGatewayTurn } from "@/lib/openclaw-gateway"; import { OpenClawAgentResolutionError, extractOpenClawSessionId, @@ -607,30 +608,6 @@ function openClawChatResponse(args: { } const agentId = agentBinding.openclawAgentId; pushProgress("openclaw-resolve", "OpenClaw agent resolved", "done", `${agentId} (${agentBinding.source})`); - const argv = openClawAgentArgs(args.harnessPrompt, agentId, conversationId); - const openclawLaunch = openClawLaunchCommand(); - if (openclawLaunch.unresolvedWindowsShim) { - pushProgress( - "openclaw-start", - "OpenClaw bridge cannot start safely", - "error", - "The resolved OpenClaw Windows npm shim could not be mapped to its JavaScript entry point. Reinstall OpenClaw or configure a native executable with OPENCLAW_BIN.", - ); - push({ - kind: "error", - code: "openclaw_unsafe_shell", - message: - "OpenClaw chat is unavailable because its Windows npm shim could not be launched without shell parsing. Reinstall OpenClaw or configure a native executable with OPENCLAW_BIN.", - }); - push({ - kind: "done", - durationMs: Date.now() - startedAt, - isError: true, - }); - close(); - return; - } - const spawnArgv = [...openclawLaunch.fixedArgs, ...argv]; let cwd: string; try { cwd = await resolveLocalRuntimeCwd( @@ -669,6 +646,205 @@ function openClawChatResponse(args: { gatewaySessionId: undefined, sessionKey: openClawSessionKey(conversationId), }; + + // Gateway dispatch owns a turn only after it returns the authoritative + // run id. Until then this branch leaves the existing CLI bridge as the + // safe compatibility fallback; after a request might have reached the + // Gateway it deliberately never starts a second CLI turn. + let gatewayAssistantText = ""; + let gatewayAssistantTextEmitted = false; + const gatewayDispatch = await dispatchOpenClawGatewayTurn({ + sessionKey: openClawSessionKey(conversationId), + agentId, + message: args.harnessPrompt, + onEvent: (event) => { + if (event.kind === "status") { + pushProgress("openclaw-gateway", "OpenClaw Gateway", "running", event.phase); + return; + } + if (event.kind === "delta") { + // The published v4 delta schema is append-oriented. A future + // replace-capable UI must consume `replace` explicitly; we keep + // the complete terminal message authoritative for persistence. + gatewayAssistantText += event.text; + gatewayAssistantTextEmitted = true; + push({ kind: "assistant_chunk", text: event.text }); + return; + } + if (event.kind === "final" && event.text) { + gatewayAssistantText = event.text; + } + if (event.kind === "error") { + pushProgress("openclaw-gateway", "OpenClaw Gateway", "error", event.message); + } + }, + }); + if (gatewayDispatch.kind === "indeterminate") { + pushProgress("openclaw-gateway", "OpenClaw Gateway dispatch is indeterminate", "error", gatewayDispatch.reason); + push({ kind: "error", code: "openclaw_gateway_indeterminate", message: gatewayDispatch.reason }); + push({ kind: "done", durationMs: Date.now() - startedAt, isError: true, responseMetadata }); + close(); + return; + } + if (gatewayDispatch.kind === "accepted") { + responseMetadata.gatewaySessionId = gatewayDispatch.runId; + pushProgress("openclaw-gateway", "OpenClaw Gateway dispatch accepted", "done", `run ${gatewayDispatch.runId}`); + const pendingUserTurnId = crypto.randomUUID(); + const stubTitle = chatTitleFromPrompt(args.promptText) ?? defaultChatTitleForSession(conversationId); + void setDefaultSessionTitleIfMissing(conversationId, stubTitle).catch(() => undefined); + const stubWrite = createConversationStub({ + sessionId: conversationId, + familiarId: args.body.familiarId, + harness: "openclaw", + ...(responseMetadata.model ? { model: responseMetadata.model } : {}), + ...(responseMetadata.runtime ? { runtime: responseMetadata.runtime } : {}), + title: stubTitle, + ...(args.body.origin ? { origin: args.body.origin } : {}), + userTurn: { + id: pendingUserTurnId, + text: args.promptText, + ...(args.attachments.length ? { attachments: args.attachments } : {}), + }, + }).catch(() => undefined); + const stopGateway = () => { + void gatewayDispatch.abort(); + }; + const runHandle = registerChatRun([args.body.runId, conversationId], stopGateway); + let detachKillTimer: ReturnType | null = null; + const armDetachKill = () => { + if (runHandle.stopRequested || detachKillTimer != null) return; + detachKillTimer = setTimeout(stopGateway, CHAT_DETACH_MAX_MS); + }; + runBuffer = openRunBuffer([args.body.runId, conversationId], { + attach: () => { + if (detachKillTimer != null) { + clearTimeout(detachKillTimer); + detachKillTimer = null; + } + }, + detach: () => { + if (args.req.signal.aborted) armDetachKill(); + }, + }); + const onAbort = () => armDetachKill(); + args.req.signal.addEventListener("abort", onAbort, { once: true }); + pushProgress("openclaw-response", "Waiting for OpenClaw Gateway response", "running"); + const gatewayResult = await gatewayDispatch.done; + args.req.signal.removeEventListener("abort", onAbort); + if (detachKillTimer != null) clearTimeout(detachKillTimer); + unregisterChatRun(runHandle); + const durationMs = Date.now() - startedAt; + const cancelledByUser = runHandle.stopRequested || gatewayResult.state === "aborted"; + const isError = gatewayResult.state === "error"; + if (!gatewayAssistantText.trim()) { + gatewayAssistantText = cancelledByUser + ? "(cancelled)" + : gatewayResult.message ?? "_The OpenClaw Gateway returned no text._"; + } + pushProgress( + "openclaw-response", + isError ? "OpenClaw Gateway response failed" : "OpenClaw Gateway response received", + isError ? "error" : "done", + gatewayResult.message, + durationMs, + ); + push({ kind: "session", sessionId: conversationId }); + if (!gatewayAssistantTextEmitted) push({ kind: "assistant_chunk", text: gatewayAssistantText }); + pushProgress("save-transcript", "Saving transcript", "running"); + await recordSessionFamiliar(conversationId, args.body.familiarId); + await stubWrite; + const existing = await loadConversation(conversationId); + const hadFirstTurnStub = existing ? stripConversationStubTurn(existing, pendingUserTurnId) : false; + const isFirstExchange = !existing || hadFirstTurnStub; + const now = new Date().toISOString(); + const chatTitle = existing?.title ?? defaultChatTitleForSession(conversationId); + if (!existing) await setDefaultSessionTitleIfMissing(conversationId, chatTitle); + const branchParentId = + args.body.parentTurnId !== undefined ? args.body.parentTurnId : existing?.activeLeafId ?? null; + const conv = existing ?? { + sessionId: conversationId, + familiarId: args.body.familiarId, + harness: "openclaw", + model: responseMetadata.model, + runtime: responseMetadata.runtime, + title: chatTitle, + ...(args.body.origin ? { origin: args.body.origin } : {}), + createdAt: now, + updatedAt: now, + turns: [], + }; + conv.model = responseMetadata.model; + conv.runtime = responseMetadata.runtime; + persistSendModelIntent(conv, args.body, args.modelState); + const workBranch = await captureWorkBranch(cwdFromConversationRuntime(conv.runtime)); + if (workBranch) conv.branch = workBranch; + const reportedPrUrl = latestPrUrlFromText(gatewayAssistantText); + if (reportedPrUrl) conv.prUrl = reportedPrUrl; + const assistantTurnId = crypto.randomUUID(); + conv.turns.push( + { + id: pendingUserTurnId, + role: "user", + text: args.promptText, + ...(args.attachments.length ? { attachments: args.attachments } : {}), + createdAt: now, + ...(branchParentId != null ? { parentId: branchParentId } : {}), + }, + { + id: assistantTurnId, + role: "assistant", + text: gatewayAssistantText.trim(), + createdAt: new Date().toISOString(), + durationMs, + isError, + parentId: pendingUserTurnId, + responseMetadata, + ...(cancelledByUser ? { cancelled: true } : {}), + }, + ); + conv.activeLeafId = assistantTurnId; + await saveConversation(conv); + if (isFirstExchange && !isError) await autoNameSessionFromFirstExchange(conversationId, args.promptText); + if (!isError) await maybeAutoRenameFromContext(conversationId, args.promptText); + pushProgress("save-transcript", "Transcript saved", "done"); + push({ + kind: "done", + durationMs, + isError, + sessionId: conversationId, + ...(cancelledByUser ? { cancelled: true } : {}), + responseMetadata, + }); + gatewayDispatch.close(); + runBuffer?.finish(); + await sleep(20); + close(); + return; + } + const argv = openClawAgentArgs(args.harnessPrompt, agentId, conversationId); + const openclawLaunch = openClawLaunchCommand(); + if (openclawLaunch.unresolvedWindowsShim) { + pushProgress( + "openclaw-start", + "OpenClaw bridge cannot start safely", + "error", + "The resolved OpenClaw Windows npm shim could not be mapped to its JavaScript entry point. Reinstall OpenClaw or configure a native executable with OPENCLAW_BIN.", + ); + push({ + kind: "error", + code: "openclaw_unsafe_shell", + message: + "OpenClaw chat is unavailable because its Windows npm shim could not be launched without shell parsing. Reinstall OpenClaw or configure a native executable with OPENCLAW_BIN.", + }); + push({ + kind: "done", + durationMs: Date.now() - startedAt, + isError: true, + }); + close(); + return; + } + const spawnArgv = [...openclawLaunch.fixedArgs, ...argv]; pushProgress("openclaw-start", "Starting OpenClaw bridge", "running", cwd); const child = spawn(openclawLaunch.command, spawnArgv, { cwd, diff --git a/src/lib/openclaw-gateway.test.ts b/src/lib/openclaw-gateway.test.ts new file mode 100644 index 000000000..798ac13e9 --- /dev/null +++ b/src/lib/openclaw-gateway.test.ts @@ -0,0 +1,134 @@ +// @ts-nocheck +import assert from "node:assert/strict"; +import { + dispatchOpenClawGatewayTurn, + normalizeOpenClawGatewayChatEvent, + textFromOpenClawGatewayMessage, +} from "./openclaw-gateway.ts"; + +const expected = { + runId: "run-accepted", + sessionKey: "agent:nova:explicit:cave-123", + agentId: "nova", +}; + +const delta = { + runId: expected.runId, + sessionKey: expected.sessionKey, + agentId: expected.agentId, + seq: 4, + state: "delta", + deltaText: "Hello", +}; + +assert.deepEqual( + normalizeOpenClawGatewayChatEvent(delta, expected), + delta, + "published v4 chat deltas remain available to the run that Gateway accepted", +); + +assert.equal( + normalizeOpenClawGatewayChatEvent({ ...delta, runId: "another-run" }, expected), + null, + "a valid frame from another run in the exact same session must never reach this turn", +); +assert.equal( + normalizeOpenClawGatewayChatEvent({ ...delta, agentId: "other-agent" }, expected), + null, + "agent-scoped routing is required in addition to canonical session keys", +); +assert.equal( + normalizeOpenClawGatewayChatEvent({ ...delta, seq: "4" }, expected), + null, + "the official schema rejects malformed sequence values before lifecycle reconciliation", +); +assert.equal( + normalizeOpenClawGatewayChatEvent({ ...delta, state: "tool" }, expected), + null, + "unpublished tool payload shapes fail closed instead of becoming cards", +); + +assert.equal(textFromOpenClawGatewayMessage("plain"), "plain"); +assert.equal(textFromOpenClawGatewayMessage({ text: "envelope" }), "envelope"); +assert.equal( + textFromOpenClawGatewayMessage({ content: [{ type: "text", text: "a" }, { type: "text", text: "b" }] }), + "ab", +); +assert.equal(textFromOpenClawGatewayMessage({ raw: "not published" }), undefined); + +// The direct dispatcher is tested through the same official-client callback +// contract that production uses: it waits for hello and subscription, binds +// the acknowledged run id, and ignores a foreign run with the same session. +let clientOptions; +const emitted = []; +const dispatch = await dispatchOpenClawGatewayTurn({ + sessionKey: expected.sessionKey, + agentId: expected.agentId, + message: "hello", + env: { + OPENCLAW_GATEWAY_DISPATCH: "1", + OPENCLAW_GATEWAY_URL: "ws://127.0.0.1:18789", + }, + onEvent: (event) => emitted.push(event), + clientFactory: (options) => { + clientOptions = options; + return { + start() { + queueMicrotask(() => + options.onHelloOk?.({ + protocol: 4, + features: { methods: ["chat.send", "sessions.messages.subscribe"], events: ["chat"] }, + auth: { role: "operator", scopes: ["operator.read", "operator.write"] }, + }), + ); + }, + stop() {}, + async request(method, _params, requestOptions) { + if (method === "chat.send") { + requestOptions?.onSent?.(); + queueMicrotask(() => { + options.onEvent?.({ + type: "event", + event: "chat", + payload: { ...delta, runId: "foreign-run", seq: 0 }, + }); + options.onEvent?.({ + type: "event", + event: "chat", + payload: { ...delta, seq: 0 }, + }); + options.onEvent?.({ + type: "event", + event: "chat", + payload: { + runId: expected.runId, + sessionKey: expected.sessionKey, + agentId: expected.agentId, + seq: 1, + state: "final", + message: { text: "Hello" }, + }, + }); + }); + return { runId: expected.runId }; + } + return { subscribed: true, key: expected.sessionKey }; + }, + }; + }, +}); + +assert.equal(dispatch.kind, "accepted", "a documented accepted run id enables Gateway ownership"); +assert.equal(clientOptions.caps?.[0], "tool-events", "the official client requests tool events without parsing unpublished tools"); +assert.equal(clientOptions.minProtocol, 4); +assert.equal(clientOptions.maxProtocol, 4); +if (dispatch.kind === "accepted") { + assert.deepEqual(await dispatch.done, { state: "final", message: "Hello" }); +} +assert.deepEqual( + emitted.filter((event) => event.kind === "delta"), + [{ kind: "delta", text: "Hello", replace: false }], + "only the accepted run reaches Cave output; a foreign same-session run is rejected", +); + +console.log("openclaw Gateway run correlation tests passed"); diff --git a/src/lib/openclaw-gateway.ts b/src/lib/openclaw-gateway.ts new file mode 100644 index 000000000..e8d0789fd --- /dev/null +++ b/src/lib/openclaw-gateway.ts @@ -0,0 +1,348 @@ +import { GatewayClient } from "@openclaw/gateway-client"; +import { ChatEventSchema } from "@openclaw/gateway-protocol"; +import { PROTOCOL_VERSION } from "@openclaw/gateway-protocol/version"; +import { Value } from "typebox/value"; + +/** + * The direct Gateway transport is intentionally opt-in. It owns a turn only + * after `chat.send` returns the Gateway-generated run id; before that point + * callers may retain the existing CLI fallback without risking a duplicate + * agent turn. + */ +const GATEWAY_DISPATCH_ENV = "OPENCLAW_GATEWAY_DISPATCH"; +const GATEWAY_URL_ENV = "OPENCLAW_GATEWAY_URL"; +const GATEWAY_TOKEN_ENV = "OPENCLAW_GATEWAY_TOKEN"; +const GATEWAY_DEVICE_TOKEN_ENV = "OPENCLAW_GATEWAY_DEVICE_TOKEN"; +const STARTUP_TIMEOUT_MS = 3_000; + +export type OpenClawGatewayChatEvent = + | { kind: "status"; phase: string } + | { kind: "delta"; text: string; replace: boolean } + | { kind: "final"; text?: string } + | { kind: "aborted"; message?: string } + | { kind: "error"; message: string }; + +export type OpenClawGatewayDispatch = + | { kind: "unavailable"; reason: string } + | { kind: "indeterminate"; reason: string } + | { + kind: "accepted"; + runId: string; + done: Promise<{ state: "final" | "aborted" | "error"; message?: string }>; + abort: () => Promise; + close: () => void; + }; + +type GatewayClientPort = Pick; +type GatewayClientFactory = (options: ConstructorParameters[0]) => GatewayClientPort; + +type GatewayChatPayload = { + runId: string; + sessionKey: string; + agentId?: string; + seq: number; + state: "status" | "delta" | "final" | "aborted" | "error"; + phase?: string; + deltaText?: string; + replace?: boolean; + message?: unknown; + errorMessage?: string; +}; + +type GatewayHello = { + protocol: number; + features: { methods: string[]; events: string[]; capabilities?: string[] }; + auth: { role: string; scopes: string[] }; +}; + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function nonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +function gatewayDispatchEnabled(env: NodeJS.ProcessEnv): boolean { + return env[GATEWAY_DISPATCH_ENV] === "1" || env[GATEWAY_DISPATCH_ENV] === "true"; +} + +function supportedHello(hello: GatewayHello): string | null { + if (hello.protocol !== PROTOCOL_VERSION) return `Gateway protocol ${hello.protocol} is not supported`; + if (hello.auth.role !== "operator") return "Gateway did not grant the operator role"; + const scopes = new Set(hello.auth.scopes); + if (!scopes.has("operator.read") || !scopes.has("operator.write")) { + return "Gateway did not grant operator.read and operator.write"; + } + const methods = new Set(hello.features.methods); + if (!methods.has("chat.send") || !methods.has("sessions.messages.subscribe")) { + return "Gateway does not advertise the required chat.send and sessions.messages.subscribe methods"; + } + if (!hello.features.events.includes("chat")) return "Gateway does not advertise the versioned chat event"; + return null; +} + +/** Extract text only from the documented chat envelope shapes. */ +export function textFromOpenClawGatewayMessage(message: unknown): string | undefined { + if (typeof message === "string") return message; + if (!isRecord(message)) return undefined; + if (typeof message.text === "string") return message.text; + if (!Array.isArray(message.content)) return undefined; + const text = message.content + .map((part) => (isRecord(part) && typeof part.text === "string" ? part.text : "")) + .filter(Boolean) + .join(""); + return text || undefined; +} + +/** + * Validate a published v4 `chat` payload and reject any event not belonging + * to the single Gateway run accepted for this Cave turn. + */ +export function normalizeOpenClawGatewayChatEvent( + payload: unknown, + expected: { runId: string; sessionKey: string; agentId: string }, +): GatewayChatPayload | null { + if (!Value.Check(ChatEventSchema, payload)) return null; + const event = payload as GatewayChatPayload; + if (event.runId !== expected.runId || event.sessionKey !== expected.sessionKey) return null; + if (event.agentId !== undefined && event.agentId !== expected.agentId) return null; + return event; +} + +function withTimeout(promise: Promise, timeoutMs: number, message: string): Promise { + let timer: ReturnType | undefined; + return Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(message)), timeoutMs); + timer.unref?.(); + }), + ]).finally(() => { + if (timer) clearTimeout(timer); + }); +} + +/** + * Dispatch a Cave turn through the supported Gateway v4 client. The helper + * never guesses a run id and never permits a caller to fall back to the CLI + * once a request might have reached the Gateway. + */ +export async function dispatchOpenClawGatewayTurn(args: { + sessionKey: string; + agentId: string; + message: string; + onEvent: (event: OpenClawGatewayChatEvent) => void; + env?: NodeJS.ProcessEnv; + /** Injectable only so the official-client lifecycle can be tested without a live Gateway. */ + clientFactory?: GatewayClientFactory; +}): Promise { + const env = args.env ?? process.env; + if (!gatewayDispatchEnabled(env)) return { kind: "unavailable", reason: "Gateway dispatch is disabled" }; + const url = env[GATEWAY_URL_ENV]; + if (!nonEmptyString(url)) return { kind: "unavailable", reason: "Gateway URL is not configured" }; + + let client: GatewayClientPort; + let helloResolve: (() => void) | undefined; + let helloReject: ((error: Error) => void) | undefined; + let connected = false; + let expectedRunId: string | undefined; + let highestSequence = -1; + let dispatchSent = false; + let settled = false; + const queuedChatEvents: unknown[] = []; + let doneResolve!: (value: { state: "final" | "aborted" | "error"; message?: string }) => void; + const done = new Promise<{ state: "final" | "aborted" | "error"; message?: string }>((resolve) => { + doneResolve = resolve; + }); + + const settle = (state: "final" | "aborted" | "error", message?: string) => { + if (settled) return; + settled = true; + doneResolve({ state, ...(message ? { message } : {}) }); + }; + + const processChatEvent = (payload: unknown) => { + if (!expectedRunId || settled) { + if (queuedChatEvents.length < 128) queuedChatEvents.push(payload); + return; + } + const event = normalizeOpenClawGatewayChatEvent(payload, { + runId: expectedRunId, + sessionKey: args.sessionKey, + agentId: args.agentId, + }); + if (!event) return; + // Per-run ordering is authoritative. A duplicate or replay may not + // mutate a completed card/turn; a gap is unsafe without a published + // history-reconciliation schema, so finish this Gateway-owned turn as an + // error instead of pretending its stream is complete. + if (event.seq <= highestSequence) return; + if (highestSequence >= 0 && event.seq !== highestSequence + 1) { + args.onEvent({ kind: "error", message: "Gateway event sequence gap" }); + settle("error", "Gateway event sequence gap"); + client.stop(); + return; + } + highestSequence = event.seq; + if (event.state === "status" && nonEmptyString(event.phase)) { + args.onEvent({ kind: "status", phase: event.phase }); + return; + } + if (event.state === "delta" && typeof event.deltaText === "string") { + args.onEvent({ kind: "delta", text: event.deltaText, replace: event.replace === true }); + return; + } + const text = textFromOpenClawGatewayMessage(event.message); + if (event.state === "final") { + args.onEvent({ kind: "final", ...(text ? { text } : {}) }); + settle("final", text); + return; + } + if (event.state === "aborted") { + const message = event.errorMessage ?? text; + args.onEvent({ kind: "aborted", ...(message ? { message } : {}) }); + settle("aborted", message); + return; + } + const message = event.errorMessage ?? text ?? "Gateway chat run failed"; + args.onEvent({ kind: "error", message }); + settle("error", message); + }; + + const hello = new Promise((resolve, reject) => { + helloResolve = resolve; + helloReject = reject; + }); + + const clientOptions: ConstructorParameters[0] = { + url, + token: env[GATEWAY_TOKEN_ENV], + deviceToken: env[GATEWAY_DEVICE_TOKEN_ENV], + clientName: "gateway-client", + clientDisplayName: "Coven Cave", + clientVersion: "2026.7.2-beta.4", + platform: process.platform, + mode: "backend", + role: "operator", + scopes: ["operator.read", "operator.write"], + caps: ["tool-events"], + minProtocol: PROTOCOL_VERSION, + maxProtocol: PROTOCOL_VERSION, + connectChallengeTimeoutMs: STARTUP_TIMEOUT_MS, + requestTimeoutMs: STARTUP_TIMEOUT_MS, + onHelloOk: (rawHello) => { + const failure = supportedHello(rawHello as GatewayHello); + if (failure) { + const error = new Error(failure); + helloReject?.(error); + if (connected) { + args.onEvent({ kind: "error", message: failure }); + settle("error", failure); + } + client.stop(); + return; + } + if (!connected) { + connected = true; + helloResolve?.(); + return; + } + // The official client reconnects after a transport loss. Restore the + // documented session stream before accepting resumed chat frames. + void client + .request("sessions.messages.subscribe", { key: args.sessionKey, agentId: args.agentId }) + .catch(() => { + const message = "Gateway reconnect could not restore the session stream"; + args.onEvent({ kind: "error", message }); + settle("error", message); + client.stop(); + }); + }, + onConnectError: (error) => { + if (!connected) helloReject?.(error); + else if (!settled) { + args.onEvent({ kind: "error", message: "Gateway connection failed after dispatch" }); + settle("error", "Gateway connection failed after dispatch"); + } + }, + onEvent: (frame) => { + if (frame.event === "chat") processChatEvent(frame.payload); + }, + onGap: () => { + if (!expectedRunId || settled) return; + const message = "Gateway transport sequence gap"; + args.onEvent({ kind: "error", message }); + settle("error", message); + client.stop(); + }, + }; + client = args.clientFactory?.(clientOptions) ?? new GatewayClient(clientOptions); + + client.start(); + try { + await withTimeout(hello, STARTUP_TIMEOUT_MS, "Gateway did not complete its authenticated hello"); + await client.request("sessions.messages.subscribe", { key: args.sessionKey, agentId: args.agentId }); + } catch (error) { + client.stop(); + return { kind: "unavailable", reason: error instanceof Error ? error.message : "Gateway is unavailable" }; + } + + const idempotencyKey = crypto.randomUUID(); + let response: unknown; + try { + response = await client.request("chat.send", { + sessionKey: args.sessionKey, + agentId: args.agentId, + message: args.message, + idempotencyKey, + }, { + onSent: () => { + dispatchSent = true; + }, + }); + } catch (error) { + client.stop(); + if (dispatchSent) { + return { + kind: "indeterminate", + reason: "Gateway dispatch acknowledgement was lost; Cave will not start a duplicate CLI turn", + }; + } + return { kind: "unavailable", reason: error instanceof Error ? error.message : "Gateway dispatch failed" }; + } + + const runId = isRecord(response) && nonEmptyString(response.runId) ? response.runId : undefined; + if (!runId) { + client.stop(); + return { + kind: "indeterminate", + reason: "Gateway accepted a dispatch without a usable run id; Cave will not start a duplicate CLI turn", + }; + } + expectedRunId = runId; + for (const queued of queuedChatEvents.splice(0)) processChatEvent(queued); + + return { + kind: "accepted", + runId, + done, + abort: async () => { + if (settled) return; + // This fence is deliberately settled before the socket is stopped so a + // queued or late final frame cannot turn a user-cancelled turn into ok. + settle("aborted", "Cancelled by user"); + args.onEvent({ kind: "aborted", message: "Cancelled by user" }); + try { + await client.request("chat.abort", { + sessionKey: args.sessionKey, + agentId: args.agentId, + runId, + }); + } finally { + client.stop(); + } + }, + close: () => client.stop(), + }; +} From 1f5122c4217270a295f31d378155ef37de491d47 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 23:56:03 -0400 Subject: [PATCH 25/61] fix(chat): validate OpenClaw Gateway hello --- src/lib/openclaw-gateway.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/lib/openclaw-gateway.ts b/src/lib/openclaw-gateway.ts index e8d0789fd..97238f07b 100644 --- a/src/lib/openclaw-gateway.ts +++ b/src/lib/openclaw-gateway.ts @@ -1,5 +1,5 @@ import { GatewayClient } from "@openclaw/gateway-client"; -import { ChatEventSchema } from "@openclaw/gateway-protocol"; +import { ChatEventSchema, HelloOkSchema } from "@openclaw/gateway-protocol"; import { PROTOCOL_VERSION } from "@openclaw/gateway-protocol/version"; import { Value } from "typebox/value"; @@ -232,7 +232,9 @@ export async function dispatchOpenClawGatewayTurn(args: { connectChallengeTimeoutMs: STARTUP_TIMEOUT_MS, requestTimeoutMs: STARTUP_TIMEOUT_MS, onHelloOk: (rawHello) => { - const failure = supportedHello(rawHello as GatewayHello); + const failure = Value.Check(HelloOkSchema, rawHello) + ? supportedHello(rawHello as GatewayHello) + : "Gateway returned an invalid hello response for the pinned v4 schema"; if (failure) { const error = new Error(failure); helloReject?.(error); From ce307199f628698a64b835c0bd5fb63e2df6bb70 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sat, 25 Jul 2026 23:57:32 -0400 Subject: [PATCH 26/61] test(chat): use the published Gateway hello shape --- src/lib/openclaw-gateway.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/lib/openclaw-gateway.test.ts b/src/lib/openclaw-gateway.test.ts index 798ac13e9..5bcdb62fe 100644 --- a/src/lib/openclaw-gateway.test.ts +++ b/src/lib/openclaw-gateway.test.ts @@ -76,9 +76,18 @@ const dispatch = await dispatchOpenClawGatewayTurn({ start() { queueMicrotask(() => options.onHelloOk?.({ + type: "hello-ok", protocol: 4, + server: { version: "2026.7.2-beta.4", connId: "test-connection" }, features: { methods: ["chat.send", "sessions.messages.subscribe"], events: ["chat"] }, + snapshot: { + presence: [], + health: {}, + stateVersion: { presence: 0, health: 0 }, + uptimeMs: 0, + }, auth: { role: "operator", scopes: ["operator.read", "operator.write"] }, + policy: { maxPayload: 1024, maxBufferedBytes: 1024, tickIntervalMs: 1000 }, }), ); }, From 5d70ebc7ca767a799057f363c5e320df902e2ca7 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Sun, 26 Jul 2026 02:41:48 -0500 Subject: [PATCH 27/61] test(chat): count the Gateway dispatch path in send-route buffer pins --- src/app/api/chat/stream/route.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/api/chat/stream/route.test.ts b/src/app/api/chat/stream/route.test.ts index 4b50843a8..b1f2ad432 100644 --- a/src/app/api/chat/stream/route.test.ts +++ b/src/app/api/chat/stream/route.test.ts @@ -81,7 +81,7 @@ test("send route tees both harness stream paths through the run buffer", () => { const seqEmits = send.match(/controller\.enqueue\(chatSse\(e(?:vent)?, seq\)\)/g); assert.equal(seqEmits?.length, 2, "both paths emit the seq as the SSE id — live clients always hold a resume cursor"); const opens = send.match(/openRunBuffer\(\[/g); - assert.equal(opens?.length, 2, "both paths open a buffer under runId + conversation keys"); + assert.equal(opens?.length, 3, "all three dispatch paths (harness, OpenClaw CLI, OpenClaw Gateway) open a buffer under runId + conversation keys"); const finishes = send.match(/runBuffer\?\.finish\(\)/g); assert.ok((finishes?.length ?? 0) >= 3, "every stream exit (error + close paths) finishes the buffer"); }); @@ -93,5 +93,5 @@ test("re-attach disarms the detach-cap kill; the last tail re-arms only after th "attach hook cancels the pending kill", ); const rearms = send.match(/detach: \(\) => \{\s*if \((?:args\.)?req\.signal\.aborted\) armDetachKill\(\);/g); - assert.equal(rearms?.length, 2, "detach hooks re-arm only when the original request is gone — a resume tail closing can't kill a still-attached turn"); + assert.equal(rearms?.length, 3, "detach hooks re-arm only when the original request is gone — a resume tail closing can't kill a still-attached turn"); }); From 6558908d498c6d1696e7f4e6292697ea9fd90fba Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sun, 26 Jul 2026 09:33:38 -0400 Subject: [PATCH 28/61] test(chat): update stop-registry contract for Gateway dispatch --- src/app/api/api-contracts.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/app/api/api-contracts.test.ts b/src/app/api/api-contracts.test.ts index c4e10e577..4ff222407 100644 --- a/src/app/api/api-contracts.test.ts +++ b/src/app/api/api-contracts.test.ts @@ -433,8 +433,8 @@ for (const contract of contracts) { const runRegistrations = [...sendSource.matchAll(/= registerChatRun\(/g)]; assert.equal( runRegistrations.length, - 2, - "/chat/send: both adapter paths must register with the stop registry", + 3, + "/chat/send: all three dispatch paths must register with the stop registry", ); assert.match( sendSource, @@ -485,8 +485,8 @@ for (const contract of contracts) { ]; assert.equal( cancelledFlags.length, - 2, - "/chat/send: both adapter paths must persist cancelled: true on the assistant turn", + 4, + "/chat/send: every adapter path must mark both its assistant turn and terminal event as cancelled", ); assert.match( sendSource, From a1c541b30f9baab7b33239b0d358a01452841eb2 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sun, 26 Jul 2026 10:58:07 -0400 Subject: [PATCH 29/61] test(chat): cover Gateway first-turn stub persistence --- src/app/api/chat/send/first-turn-stub.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/api/chat/send/first-turn-stub.test.ts b/src/app/api/chat/send/first-turn-stub.test.ts index 318e83b46..99a0243ac 100644 --- a/src/app/api/chat/send/first-turn-stub.test.ts +++ b/src/app/api/chat/send/first-turn-stub.test.ts @@ -64,8 +64,8 @@ assert.match( assert.equal( (chatRoute.match(/const hadFirstTurnStub = existing\s*\? stripConversationStubTurn\(existing, pendingUserTurnId\)\s*: false;/g) ?? []).length, - 2, - "both save paths must strip the stub turn so the authoritative user turn re-lands cleanly", + 3, + "all save paths must strip the stub turn so the authoritative user turn re-lands cleanly", ); assert.equal( From f37aa1226baa79d5dcc7f9a598704ff482f2f31d Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sun, 26 Jul 2026 11:38:35 -0400 Subject: [PATCH 30/61] test(chat): cover Gateway work-branch persistence --- src/lib/server/chat-work-branch.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/server/chat-work-branch.test.ts b/src/lib/server/chat-work-branch.test.ts index dc1481970..fca765175 100644 --- a/src/lib/server/chat-work-branch.test.ts +++ b/src/lib/server/chat-work-branch.test.ts @@ -68,8 +68,8 @@ const captures = sendRoute.match( ); assert.equal( captures?.length, - 2, - "both saveConversation paths (OpenClaw bridge + coven-run) must record the work branch", + 3, + "all saveConversation paths (OpenClaw Gateway, bridge, and coven-run) must record the work branch", ); console.log("chat-work-branch.test.ts: all assertions passed"); From 85abfc6992355de9e1ca5f3a9d7c82c1dacb668d Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sun, 26 Jul 2026 12:02:28 -0400 Subject: [PATCH 31/61] fix(openclaw): require stable Gateway idempotency --- src/app/api/chat/send/route.ts | 4 ++++ src/lib/openclaw-gateway.test.ts | 22 +++++++++++++++++++++- src/lib/openclaw-gateway.ts | 12 ++++++++++-- 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/src/app/api/chat/send/route.ts b/src/app/api/chat/send/route.ts index e423ae50f..0b460ec65 100644 --- a/src/app/api/chat/send/route.ts +++ b/src/app/api/chat/send/route.ts @@ -657,6 +657,10 @@ function openClawChatResponse(args: { sessionKey: openClawSessionKey(conversationId), agentId, message: args.harnessPrompt, + // A direct Gateway turn is only safe when Cave has the caller's + // stable request id. Reusing it as Gateway's idempotency key makes a + // retry observable to the Gateway instead of creating another run. + idempotencyKey: args.body.runId ?? "", onEvent: (event) => { if (event.kind === "status") { pushProgress("openclaw-gateway", "OpenClaw Gateway", "running", event.phase); diff --git a/src/lib/openclaw-gateway.test.ts b/src/lib/openclaw-gateway.test.ts index 5bcdb62fe..23157c0da 100644 --- a/src/lib/openclaw-gateway.test.ts +++ b/src/lib/openclaw-gateway.test.ts @@ -56,6 +56,24 @@ assert.equal( ); assert.equal(textFromOpenClawGatewayMessage({ raw: "not published" }), undefined); +const missingRequestId = await dispatchOpenClawGatewayTurn({ + sessionKey: expected.sessionKey, + agentId: expected.agentId, + message: "hello", + idempotencyKey: "", + env: { + OPENCLAW_GATEWAY_DISPATCH: "1", + OPENCLAW_GATEWAY_URL: "ws://127.0.0.1:18789", + }, + onEvent: () => assert.fail("a Gateway without a Cave request id must not emit events"), + clientFactory: () => assert.fail("a Gateway without a Cave request id must not connect"), +}); +assert.deepEqual( + missingRequestId, + { kind: "unavailable", reason: "Gateway dispatch requires a Cave request id" }, + "without a stable Cave request id the route retains the CLI fallback", +); + // The direct dispatcher is tested through the same official-client callback // contract that production uses: it waits for hello and subscription, binds // the acknowledged run id, and ignores a foreign run with the same session. @@ -65,6 +83,7 @@ const dispatch = await dispatchOpenClawGatewayTurn({ sessionKey: expected.sessionKey, agentId: expected.agentId, message: "hello", + idempotencyKey: "cave-request-123", env: { OPENCLAW_GATEWAY_DISPATCH: "1", OPENCLAW_GATEWAY_URL: "ws://127.0.0.1:18789", @@ -92,8 +111,9 @@ const dispatch = await dispatchOpenClawGatewayTurn({ ); }, stop() {}, - async request(method, _params, requestOptions) { + async request(method, params, requestOptions) { if (method === "chat.send") { + assert.equal(params.idempotencyKey, "cave-request-123", "Gateway receives Cave's stable request id"); requestOptions?.onSent?.(); queueMicrotask(() => { options.onEvent?.({ diff --git a/src/lib/openclaw-gateway.ts b/src/lib/openclaw-gateway.ts index 97238f07b..3f7689fa9 100644 --- a/src/lib/openclaw-gateway.ts +++ b/src/lib/openclaw-gateway.ts @@ -132,6 +132,12 @@ export async function dispatchOpenClawGatewayTurn(args: { sessionKey: string; agentId: string; message: string; + /** + * Cave's stable per-request id. It is sent verbatim to Gateway so a client + * retry can be reconciled by the remote idempotency contract; generating a + * fresh value here would make an acknowledgement loss duplicate work. + */ + idempotencyKey: string; onEvent: (event: OpenClawGatewayChatEvent) => void; env?: NodeJS.ProcessEnv; /** Injectable only so the official-client lifecycle can be tested without a live Gateway. */ @@ -139,6 +145,9 @@ export async function dispatchOpenClawGatewayTurn(args: { }): Promise { const env = args.env ?? process.env; if (!gatewayDispatchEnabled(env)) return { kind: "unavailable", reason: "Gateway dispatch is disabled" }; + if (!nonEmptyString(args.idempotencyKey)) { + return { kind: "unavailable", reason: "Gateway dispatch requires a Cave request id" }; + } const url = env[GATEWAY_URL_ENV]; if (!nonEmptyString(url)) return { kind: "unavailable", reason: "Gateway URL is not configured" }; @@ -290,14 +299,13 @@ export async function dispatchOpenClawGatewayTurn(args: { return { kind: "unavailable", reason: error instanceof Error ? error.message : "Gateway is unavailable" }; } - const idempotencyKey = crypto.randomUUID(); let response: unknown; try { response = await client.request("chat.send", { sessionKey: args.sessionKey, agentId: args.agentId, message: args.message, - idempotencyKey, + idempotencyKey: args.idempotencyKey, }, { onSent: () => { dispatchSent = true; From 49936a704f381e6537163976fc4eada4f1fcd524 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sun, 26 Jul 2026 12:03:36 -0400 Subject: [PATCH 32/61] fix(openclaw): isolate Gateway credentials from CLI fallback --- src/lib/openclaw-bin.test.ts | 10 ++++++++++ src/lib/openclaw-bin.ts | 19 ++++++++++++++++--- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/lib/openclaw-bin.test.ts b/src/lib/openclaw-bin.test.ts index bbd0473f3..05b47cd00 100644 --- a/src/lib/openclaw-bin.test.ts +++ b/src/lib/openclaw-bin.test.ts @@ -81,5 +81,15 @@ assert.match( /const grantedVaultTokenKeys = new Set(?:)?\([\s\S]*GITHUB_HARNESS_TOKEN_ENV_KEYS\.filter\([\s\S]*isVaultKeyGrantedTo\(map\[key\]\)[\s\S]*!grantedVaultTokenKeys\.has\(key\)/, "OpenClaw must retain a shared Vault-managed GitHub credential through its final secret scrub", ); +assert.match( + src, + /const GATEWAY_AUTH_ENV_KEYS = new Set\(\[[\s\S]*OPENCLAW_GATEWAY_TOKEN[\s\S]*OPENCLAW_GATEWAY_DEVICE_TOKEN[\s\S]*\]\);/, + "Gateway authentication values have an explicit fallback-child denylist", +); +assert.match( + src, + /const mustNotReachFallback = GATEWAY_AUTH_ENV_KEYS\.has\(key\);[\s\S]*if \([\s\S]*mustNotReachFallback \|\|[\s\S]*genericSecret && !allowed\.has\(key\)/, + "Gateway auth cannot be reintroduced by the generic OpenClaw credential allow-list", +); console.log("openclaw-bin.test.ts: ok"); diff --git a/src/lib/openclaw-bin.ts b/src/lib/openclaw-bin.ts index b4c556f09..aba937951 100644 --- a/src/lib/openclaw-bin.ts +++ b/src/lib/openclaw-bin.ts @@ -22,6 +22,17 @@ import { isVaultKeyGrantedTo, loadVaultMap } from "./vault"; let cachedBin: string | null = null; const FORBIDDEN_SPAWN_ENV_KEYS = new Set(["GITHUB_PAT"]); +// The Gateway dispatcher reads these only in Cave's server process. They must +// never cross the fallback boundary, even if a broad harness allow-list was +// configured for a different OpenClaw integration. +const GATEWAY_AUTH_ENV_KEYS = new Set([ + "OPENCLAW_GATEWAY_TOKEN", + "OPENCLAW_GATEWAY_DEVICE_TOKEN", + "OPENCLAW_GATEWAY_BOOTSTRAP_TOKEN", + "OPENCLAW_GATEWAY_PASSWORD", + "OPENCLAW_GATEWAY_APPROVAL_RUNTIME_TOKEN", + "OPENCLAW_GATEWAY_AGENT_RUNTIME_IDENTITY_TOKEN", +]); const FORBIDDEN_SPAWN_ENV_RE = /(?:^|_)(?:TOKEN|KEY|SECRET|PASSWORD|PASS|PAT|CREDENTIALS?|COOKIE|SESSION)(?:_|$)/i; @@ -177,10 +188,12 @@ export function openClawSpawnEnv(): NodeJS.ProcessEnv { restoreAllowedGitHubTokenEnv(env, allowed, new Set(Object.keys(map))); for (const key of Object.keys(env)) { + const mustNotReachFallback = GATEWAY_AUTH_ENV_KEYS.has(key); + const genericSecret = + FORBIDDEN_SPAWN_ENV_KEYS.has(key) || FORBIDDEN_SPAWN_ENV_RE.test(key); if ( - (FORBIDDEN_SPAWN_ENV_KEYS.has(key) || FORBIDDEN_SPAWN_ENV_RE.test(key)) && - !allowed.has(key) && - !grantedVaultTokenKeys.has(key) + mustNotReachFallback || + (genericSecret && !allowed.has(key) && !grantedVaultTokenKeys.has(key)) ) { delete env[key]; } From 01ef03fd52af1aeb47036394308db7ac2fff5edc Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sun, 26 Jul 2026 12:04:57 -0400 Subject: [PATCH 33/61] fix(openclaw): fence Gateway events during reconnect --- src/lib/openclaw-gateway.test.ts | 88 ++++++++++++++++++++++++++++++++ src/lib/openclaw-gateway.ts | 22 ++++++-- 2 files changed, 107 insertions(+), 3 deletions(-) diff --git a/src/lib/openclaw-gateway.test.ts b/src/lib/openclaw-gateway.test.ts index 23157c0da..ca495878c 100644 --- a/src/lib/openclaw-gateway.test.ts +++ b/src/lib/openclaw-gateway.test.ts @@ -160,4 +160,92 @@ assert.deepEqual( "only the accepted run reaches Cave output; a foreign same-session run is rejected", ); +// A reconnect is not an excuse to trust an event before the documented +// session subscription returns. The queued frame is accepted only after the +// re-subscription succeeds. +let reconnectOptions; +let releaseResubscribe; +let subscriptionCalls = 0; +const reconnectEvents = []; +const reconnectDispatch = await dispatchOpenClawGatewayTurn({ + sessionKey: expected.sessionKey, + agentId: expected.agentId, + message: "hello again", + idempotencyKey: "cave-request-reconnect", + env: { + OPENCLAW_GATEWAY_DISPATCH: "1", + OPENCLAW_GATEWAY_URL: "ws://127.0.0.1:18789", + }, + onEvent: (event) => reconnectEvents.push(event), + clientFactory: (options) => { + reconnectOptions = options; + return { + start() { + queueMicrotask(() => options.onHelloOk?.(helloOk())); + }, + stop() {}, + async request(method, _params, requestOptions) { + if (method === "sessions.messages.subscribe") { + subscriptionCalls += 1; + if (subscriptionCalls === 1) return { subscribed: true }; + return new Promise((resolve) => { + releaseResubscribe = () => resolve({ subscribed: true }); + }); + } + if (method === "chat.send") { + requestOptions?.onSent?.(); + return { runId: expected.runId }; + } + return {}; + }, + }; + }, +}); + +function helloOk() { + return { + type: "hello-ok", + protocol: 4, + server: { version: "2026.7.2-beta.4", connId: "reconnect-connection" }, + features: { methods: ["chat.send", "sessions.messages.subscribe"], events: ["chat"] }, + snapshot: { + presence: [], + health: {}, + stateVersion: { presence: 0, health: 0 }, + uptimeMs: 0, + }, + auth: { role: "operator", scopes: ["operator.read", "operator.write"] }, + policy: { maxPayload: 1024, maxBufferedBytes: 1024, tickIntervalMs: 1000 }, + }; +} + +assert.equal(reconnectDispatch.kind, "accepted"); +reconnectOptions.onHelloOk?.(helloOk()); +reconnectOptions.onEvent?.({ type: "event", event: "chat", payload: { ...delta, seq: 0 } }); +await Promise.resolve(); +assert.deepEqual(reconnectEvents, [], "a pre-subscription reconnect frame is not projected"); +releaseResubscribe(); +await Promise.resolve(); +await Promise.resolve(); +assert.deepEqual( + reconnectEvents, + [{ kind: "delta", text: "Hello", replace: false }], + "the queued frame is projected after re-subscription succeeds", +); +reconnectOptions.onEvent?.({ + type: "event", + event: "chat", + payload: { + runId: expected.runId, + sessionKey: expected.sessionKey, + agentId: expected.agentId, + seq: 1, + state: "final", + message: { text: "done" }, + }, +}); +if (reconnectDispatch.kind === "accepted") { + assert.deepEqual(await reconnectDispatch.done, { state: "final", message: "done" }); +} + console.log("openclaw Gateway run correlation tests passed"); diff --git a/src/lib/openclaw-gateway.ts b/src/lib/openclaw-gateway.ts index 3f7689fa9..cb5bb4c1d 100644 --- a/src/lib/openclaw-gateway.ts +++ b/src/lib/openclaw-gateway.ts @@ -156,6 +156,7 @@ export async function dispatchOpenClawGatewayTurn(args: { let helloReject: ((error: Error) => void) | undefined; let connected = false; let expectedRunId: string | undefined; + let streamReady = false; let highestSequence = -1; let dispatchSent = false; let settled = false; @@ -171,8 +172,13 @@ export async function dispatchOpenClawGatewayTurn(args: { doneResolve({ state, ...(message ? { message } : {}) }); }; + const drainQueuedChatEvents = () => { + if (!expectedRunId || !streamReady || settled) return; + for (const queued of queuedChatEvents.splice(0)) processChatEvent(queued); + }; + const processChatEvent = (payload: unknown) => { - if (!expectedRunId || settled) { + if (!expectedRunId || !streamReady || settled) { if (queuedChatEvents.length < 128) queuedChatEvents.push(payload); return; } @@ -260,9 +266,17 @@ export async function dispatchOpenClawGatewayTurn(args: { return; } // The official client reconnects after a transport loss. Restore the - // documented session stream before accepting resumed chat frames. + // documented session stream before accepting resumed chat frames. Frames + // that arrive during this window remain queued until that subscription + // has succeeded, so a reconnect never turns an unverified stream into + // accepted lifecycle state. + streamReady = false; void client .request("sessions.messages.subscribe", { key: args.sessionKey, agentId: args.agentId }) + .then(() => { + streamReady = true; + drainQueuedChatEvents(); + }) .catch(() => { const message = "Gateway reconnect could not restore the session stream"; args.onEvent({ kind: "error", message }); @@ -275,6 +289,7 @@ export async function dispatchOpenClawGatewayTurn(args: { else if (!settled) { args.onEvent({ kind: "error", message: "Gateway connection failed after dispatch" }); settle("error", "Gateway connection failed after dispatch"); + client.stop(); } }, onEvent: (frame) => { @@ -294,6 +309,7 @@ export async function dispatchOpenClawGatewayTurn(args: { try { await withTimeout(hello, STARTUP_TIMEOUT_MS, "Gateway did not complete its authenticated hello"); await client.request("sessions.messages.subscribe", { key: args.sessionKey, agentId: args.agentId }); + streamReady = true; } catch (error) { client.stop(); return { kind: "unavailable", reason: error instanceof Error ? error.message : "Gateway is unavailable" }; @@ -331,7 +347,7 @@ export async function dispatchOpenClawGatewayTurn(args: { }; } expectedRunId = runId; - for (const queued of queuedChatEvents.splice(0)) processChatEvent(queued); + drainQueuedChatEvents(); return { kind: "accepted", From 997f66453c589b67b9d2de510d48b6d728f03692 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sun, 26 Jul 2026 12:05:26 -0400 Subject: [PATCH 34/61] docs(openclaw): record published tool schema boundary --- ...26-07-25-openclaw-gateway-dispatch-plan.md | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/docs/specs/2026-07-25-openclaw-gateway-dispatch-plan.md b/docs/specs/2026-07-25-openclaw-gateway-dispatch-plan.md index 94d6da5e0..13ebb05a7 100644 --- a/docs/specs/2026-07-25-openclaw-gateway-dispatch-plan.md +++ b/docs/specs/2026-07-25-openclaw-gateway-dispatch-plan.md @@ -81,6 +81,31 @@ reason to guess a field shape. - Upgrade only after the schema diff and fixtures pass. Unknown wire versions fail closed to CLI; a new Cave release adds a tested profile. +## Current release boundary (2026-07-26) + +The only published protocol/client release is the `2026.7.2-beta.4` beta +package pair, negotiating wire protocol v4. It publishes `HelloOkSchema`, +`ChatEventSchema`, `chat.send`, `chat.abort`, and +`sessions.messages.subscribe`, so Cave can support a strictly correlated +chat-only Gateway turn for that exact profile. + +It does **not** publish a `session.tool` event name, payload schema, or +validator. Cave must therefore ignore those frames and emit no Gateway tool +card for this release. A method/event capability string is not a substitute +for a versioned payload contract. The CLI/plain-chat bridge remains the +fallback when dispatch cannot prove its accepted run, while the direct path +may render only validated `chat` lifecycle frames. + +| Package profile | Wire protocol | Validated projection | Tool cards | Upgrade rule | +| --- | --- | --- | --- | --- | +| `2026.7.2-beta.4` | v4 only | `chat` frames correlated by session, agent, and accepted run | Disabled: no published schema | Add a fixture and explicit profile only when OpenClaw publishes a stable tool payload validator. | +| Any other version/profile | Not assumed | None | Disabled | Keep CLI/plain chat with a visible compatibility diagnostic. | + +Before enabling tool cards, record the package release, exported validator, +schema diff, and fixtures for lifecycle, foreign-run rejection, malformed +payload, replay, gap, disconnect, and cancellation. Do not infer a tool shape +from an observed Gateway frame. + ## Verification Add a route-level Gateway fixture that performs the real authenticated From 1a11aa790c3377250bd5c99080282ce58163d6d4 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sun, 26 Jul 2026 12:28:50 -0400 Subject: [PATCH 35/61] fix(openclaw): preserve Gateway stream generations --- .../send/harness-routing-tool-events.test.ts | 10 +++++ src/app/api/chat/send/route.ts | 12 ++++-- src/lib/openclaw-gateway.test.ts | 13 +++++-- src/lib/openclaw-gateway.ts | 38 ++++++++++++++----- 4 files changed, 58 insertions(+), 15 deletions(-) diff --git a/src/app/api/chat/send/harness-routing-tool-events.test.ts b/src/app/api/chat/send/harness-routing-tool-events.test.ts index 00105e6b0..75a472b9f 100644 --- a/src/app/api/chat/send/harness-routing-tool-events.test.ts +++ b/src/app/api/chat/send/harness-routing-tool-events.test.ts @@ -52,6 +52,16 @@ assert.match( /gatewayDispatch\.kind === "indeterminate"[\s\S]*?openclaw_gateway_indeterminate[\s\S]*?return;/, "an ambiguous Gateway acknowledgement produces a terminal error instead of a duplicate CLI turn", ); +assert.match( + chatRoute, + /if \(event\.replace\) \{[\s\S]*?gatewayAssistantText = event\.text;[\s\S]*?kind: "assistant_replace"/, + "a published Gateway replacement delta corrects both the live stream and persisted transcript", +); +assert.match( + chatRoute, + /event\.kind === "final" && event\.text[\s\S]*?gatewayAssistantText !== event\.text[\s\S]*?kind: "assistant_replace"/, + "the terminal Gateway message reconciles divergent streamed text for connected clients", +); // ── Tool-event fidelity (CHAT-D4-03 + CHAT-D4-04) ────────────────────────── // Source pins: the route must route BOTH tool-event sources through the // shared ToolCallTracker — hook lines and stream-json envelope blocks — and diff --git a/src/app/api/chat/send/route.ts b/src/app/api/chat/send/route.ts index 0b460ec65..a56845dd8 100644 --- a/src/app/api/chat/send/route.ts +++ b/src/app/api/chat/send/route.ts @@ -667,15 +667,21 @@ function openClawChatResponse(args: { return; } if (event.kind === "delta") { - // The published v4 delta schema is append-oriented. A future - // replace-capable UI must consume `replace` explicitly; we keep - // the complete terminal message authoritative for persistence. + if (event.replace) { + gatewayAssistantText = event.text; + gatewayAssistantTextEmitted = true; + push({ kind: "assistant_replace", text: event.text }); + return; + } gatewayAssistantText += event.text; gatewayAssistantTextEmitted = true; push({ kind: "assistant_chunk", text: event.text }); return; } if (event.kind === "final" && event.text) { + if (gatewayAssistantTextEmitted && gatewayAssistantText !== event.text) { + push({ kind: "assistant_replace", text: event.text }); + } gatewayAssistantText = event.text; } if (event.kind === "error") { diff --git a/src/lib/openclaw-gateway.test.ts b/src/lib/openclaw-gateway.test.ts index ca495878c..992d7af08 100644 --- a/src/lib/openclaw-gateway.test.ts +++ b/src/lib/openclaw-gateway.test.ts @@ -164,7 +164,8 @@ assert.deepEqual( // session subscription returns. The queued frame is accepted only after the // re-subscription succeeds. let reconnectOptions; -let releaseResubscribe; +let releaseFirstResubscribe; +let releaseSecondResubscribe; let subscriptionCalls = 0; const reconnectEvents = []; const reconnectDispatch = await dispatchOpenClawGatewayTurn({ @@ -189,7 +190,8 @@ const reconnectDispatch = await dispatchOpenClawGatewayTurn({ subscriptionCalls += 1; if (subscriptionCalls === 1) return { subscribed: true }; return new Promise((resolve) => { - releaseResubscribe = () => resolve({ subscribed: true }); + if (subscriptionCalls === 2) releaseFirstResubscribe = () => resolve({ subscribed: true }); + else releaseSecondResubscribe = () => resolve({ subscribed: true }); }); } if (method === "chat.send") { @@ -221,10 +223,15 @@ function helloOk() { assert.equal(reconnectDispatch.kind, "accepted"); reconnectOptions.onHelloOk?.(helloOk()); +reconnectOptions.onHelloOk?.(helloOk()); reconnectOptions.onEvent?.({ type: "event", event: "chat", payload: { ...delta, seq: 0 } }); await Promise.resolve(); assert.deepEqual(reconnectEvents, [], "a pre-subscription reconnect frame is not projected"); -releaseResubscribe(); +releaseFirstResubscribe(); +await Promise.resolve(); +await Promise.resolve(); +assert.deepEqual(reconnectEvents, [], "a stale reconnect subscription cannot reopen a newer connection's stream"); +releaseSecondResubscribe(); await Promise.resolve(); await Promise.resolve(); assert.deepEqual( diff --git a/src/lib/openclaw-gateway.ts b/src/lib/openclaw-gateway.ts index cb5bb4c1d..e3b1633da 100644 --- a/src/lib/openclaw-gateway.ts +++ b/src/lib/openclaw-gateway.ts @@ -155,6 +155,10 @@ export async function dispatchOpenClawGatewayTurn(args: { let helloResolve: (() => void) | undefined; let helloReject: ((error: Error) => void) | undefined; let connected = false; + // Each authenticated hello represents a new transport generation. A + // subscription completion from an older connection must never make the + // newest connection's stream trusted again. + let connectionGeneration = 0; let expectedRunId: string | undefined; let streamReady = false; let highestSequence = -1; @@ -225,6 +229,15 @@ export async function dispatchOpenClawGatewayTurn(args: { settle("error", message); }; + const subscribeToSession = async (generation: number): Promise => { + if (generation === connectionGeneration) streamReady = false; + await client.request("sessions.messages.subscribe", { key: args.sessionKey, agentId: args.agentId }); + if (generation !== connectionGeneration || settled) return false; + streamReady = true; + drainQueuedChatEvents(); + return true; + }; + const hello = new Promise((resolve, reject) => { helloResolve = resolve; helloReject = reject; @@ -262,6 +275,7 @@ export async function dispatchOpenClawGatewayTurn(args: { } if (!connected) { connected = true; + connectionGeneration += 1; helloResolve?.(); return; } @@ -270,14 +284,10 @@ export async function dispatchOpenClawGatewayTurn(args: { // that arrive during this window remain queued until that subscription // has succeeded, so a reconnect never turns an unverified stream into // accepted lifecycle state. - streamReady = false; - void client - .request("sessions.messages.subscribe", { key: args.sessionKey, agentId: args.agentId }) - .then(() => { - streamReady = true; - drainQueuedChatEvents(); - }) + const generation = ++connectionGeneration; + void subscribeToSession(generation) .catch(() => { + if (generation !== connectionGeneration || settled) return; const message = "Gateway reconnect could not restore the session stream"; args.onEvent({ kind: "error", message }); settle("error", message); @@ -308,8 +318,18 @@ export async function dispatchOpenClawGatewayTurn(args: { client.start(); try { await withTimeout(hello, STARTUP_TIMEOUT_MS, "Gateway did not complete its authenticated hello"); - await client.request("sessions.messages.subscribe", { key: args.sessionKey, agentId: args.agentId }); - streamReady = true; + // A reconnect can arrive while the initial subscription is in flight. + // Retry until the completion belongs to the latest authenticated hello; + // an old socket's rejection is similarly irrelevant once a newer hello + // has already arrived. + for (;;) { + const generation = connectionGeneration; + try { + if (await subscribeToSession(generation)) break; + } catch (error) { + if (generation === connectionGeneration) throw error; + } + } } catch (error) { client.stop(); return { kind: "unavailable", reason: error instanceof Error ? error.message : "Gateway is unavailable" }; From df0a59797d43159a1d646236c9e2cc1d81003532 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sun, 26 Jul 2026 12:37:17 -0400 Subject: [PATCH 36/61] fix(openclaw): preserve transient Gateway reconnects --- src/lib/openclaw-gateway.test.ts | 4 ++++ src/lib/openclaw-gateway.ts | 17 ++++++++++++----- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/lib/openclaw-gateway.test.ts b/src/lib/openclaw-gateway.test.ts index 992d7af08..362295698 100644 --- a/src/lib/openclaw-gateway.test.ts +++ b/src/lib/openclaw-gateway.test.ts @@ -222,6 +222,10 @@ function helloOk() { } assert.equal(reconnectDispatch.kind, "accepted"); +// The published client reports a failed *reconnect attempt* through +// onConnectError before it retries. That must not terminate a Gateway-owned +// run; a subsequent authenticated hello restores the subscription. +reconnectOptions.onConnectError?.(new Error("transient reconnect failure")); reconnectOptions.onHelloOk?.(helloOk()); reconnectOptions.onHelloOk?.(helloOk()); reconnectOptions.onEvent?.({ type: "event", event: "chat", payload: { ...delta, seq: 0 } }); diff --git a/src/lib/openclaw-gateway.ts b/src/lib/openclaw-gateway.ts index e3b1633da..2e258b67f 100644 --- a/src/lib/openclaw-gateway.ts +++ b/src/lib/openclaw-gateway.ts @@ -296,11 +296,18 @@ export async function dispatchOpenClawGatewayTurn(args: { }, onConnectError: (error) => { if (!connected) helloReject?.(error); - else if (!settled) { - args.onEvent({ kind: "error", message: "Gateway connection failed after dispatch" }); - settle("error", "Gateway connection failed after dispatch"); - client.stop(); - } + // GatewayClient reports failures while attempting its *next* socket as + // onConnectError too. Once the first hello has completed, stopping here + // would defeat the client's documented reconnect policy before a later + // hello can restore the subscription. A terminal reconnect pause is + // handled below instead. + }, + onReconnectPaused: () => { + if (settled) return; + const message = "Gateway reconnect was paused after dispatch"; + args.onEvent({ kind: "error", message }); + settle("error", message); + client.stop(); }, onEvent: (frame) => { if (frame.event === "chat") processChatEvent(frame.payload); From 5b0733f338620970e74d74b0000620562bf6f9ad Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sun, 26 Jul 2026 12:37:25 -0400 Subject: [PATCH 37/61] docs(openclaw): distinguish chat and tool support --- ...26-07-25-openclaw-gateway-dispatch-plan.md | 50 ++++++++++--------- 1 file changed, 26 insertions(+), 24 deletions(-) diff --git a/docs/specs/2026-07-25-openclaw-gateway-dispatch-plan.md b/docs/specs/2026-07-25-openclaw-gateway-dispatch-plan.md index 13ebb05a7..5556c8f53 100644 --- a/docs/specs/2026-07-25-openclaw-gateway-dispatch-plan.md +++ b/docs/specs/2026-07-25-openclaw-gateway-dispatch-plan.md @@ -1,8 +1,9 @@ # OpenClaw Gateway-dispatch implementation plan **GitHub:** #3865 (implementation issue), #3847 (parent compatibility work), -and #3852 (the retained safe CLI/plain-chat stop point). This document is a -planning contract only; it does not enable Gateway dispatch. +and #3852 (the retained safe CLI/plain-chat stop point). This document records +both the shipped chat-only v4 boundary and the remaining plan for full tool +lifecycle support. ## Decision @@ -16,14 +17,13 @@ the accepted `runId`, subscribes to session events, and accepts only events belonging to that exact run. The current CLI bridge stays the authoritative fallback for every other runtime. -## Supported contract +## Target contract after a tool schema is published -The first supported profile is OpenClaw Gateway protocol v4 using the published -`@openclaw/gateway-client` and `@openclaw/gateway-protocol` packages pinned in -Cave's lockfile. Cave requests `operator.read` and `operator.write`, advertises -only `tool-events` and `session-scoped-events`, validates the negotiated -role/scopes, and requires the documented `chat.send`, `sessions.subscribe`, -`sessions.messages.subscribe`, `chat`, and `session.tool` surfaces. +Full tool activity requires a published, versioned `session.tool` event name, +payload validator, and lifecycle fixtures. Once those exist, Cave must request +only the documented capabilities, validate the negotiated role/scopes and +methods, and bind tool events to the Gateway-accepted run ID. Until then, no +capability string or observed frame is a substitute for a payload contract. The direct dispatcher supplies an idempotency key, receives the accepted run identifier, then binds all live state to `(sessionKey, agentId, runId)`. A tool @@ -43,15 +43,17 @@ reason to guess a field shape. authenticate with the reference Gateway client and validate `hello-ok` plus negotiated policy limits. Never persist credentials in plaintext or include them in logs, caches, SSE, or diagnostics. -3. Establish `sessions.subscribe` and the selected canonical-session - subscription before dispatching the turn. +3. Establish the selected canonical-session subscription before dispatching + the turn. Add any additional subscription only when its published schema + and contract fixture are available. 4. Send `chat.send` with the Cave message, canonical session key, agent ID, and an idempotency key derived from the Cave request ID. Record the Gateway-accepted `runId`. -5. Project only matching `chat` and `session.tool` events to Cave SSE. Maintain - a per-run high-water sequence, reject replay, reload history on a forward - gap, and never treat an unknown event as liveness. -6. On terminal chat state, persist the response and reconciled tool cards. On +5. Project only matching, schema-validated events to Cave SSE. Maintain a + per-run high-water sequence, reject replay, and fail the owned turn on a + forward gap until a published history-reconciliation contract is available. +6. On terminal chat state, persist the response. After a published tool schema + is supported, also persist reconciled tool cards. On cancellation, first persist a per-run `cancelled` terminal fence, then abort the exact `runId`, close the stream, and settle only its unfinished cards. Every event, reconciliation, and persistence path checks that fence: a @@ -62,10 +64,10 @@ reason to guess a field shape. fallback only after acceptance is disproven; a lost acknowledgement is not permission to duplicate the turn. 8. After acceptance, use the official keepalive/liveness policy. On reconnect, - restore both subscriptions, reconcile authoritative history and the active - run, then resume only validated frames for the accepted run. If recovery - fails, terminate and settle the Gateway-owned turn; never replace it with a - CLI invocation. + restore the validated session subscription and resume only validated frames + for the accepted run. Add history reconciliation only alongside its + published schema; if recovery fails, terminate and settle the Gateway-owned + turn, never replacing it with a CLI invocation. ## Compatibility and upgrade policy @@ -109,11 +111,11 @@ from an observed Gateway frame. ## Verification Add a route-level Gateway fixture that performs the real authenticated -handshake, subscriptions, `chat.send` acknowledgement, and emitted chat/tool -lifecycle. It must prove that start/update/result cards reach SSE and -persistence for the accepted run, and that otherwise-valid concurrent-session -frames are rejected. Exercise cancellation, reconnect/history reconciliation, -and every fallback boundary above. +handshake, subscription, `chat.send` acknowledgement, and emitted chat +lifecycle. It must prove that matching chat frames reach SSE and persistence +and that otherwise-valid concurrent-session frames are rejected. Once a +published tool validator exists, extend it with start/update/result cards, +history reconciliation, and every fallback boundary above. ## Delivery slices From 51e2e7a195c12f1c380350f5b61029531ac77958 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sun, 26 Jul 2026 13:25:44 -0400 Subject: [PATCH 38/61] fix(openclaw): require exact chat capability contract --- src/lib/openclaw-gateway.test.ts | 13 ++++++++++--- src/lib/openclaw-gateway.ts | 8 ++++---- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/lib/openclaw-gateway.test.ts b/src/lib/openclaw-gateway.test.ts index 362295698..b434a0291 100644 --- a/src/lib/openclaw-gateway.test.ts +++ b/src/lib/openclaw-gateway.test.ts @@ -37,6 +37,12 @@ assert.equal( null, "agent-scoped routing is required in addition to canonical session keys", ); +const { agentId: _omittedAgentId, ...chatEventWithoutAgentId } = delta; +assert.equal( + normalizeOpenClawGatewayChatEvent(chatEventWithoutAgentId, expected), + null, + "frames without the dispatched agent id cannot be attributed to this turn", +); assert.equal( normalizeOpenClawGatewayChatEvent({ ...delta, seq: "4" }, expected), null, @@ -91,6 +97,7 @@ const dispatch = await dispatchOpenClawGatewayTurn({ onEvent: (event) => emitted.push(event), clientFactory: (options) => { clientOptions = options; + assert.equal(options.caps, undefined, "chat-only dispatch must not request unpublished tool-event capabilities"); return { start() { queueMicrotask(() => @@ -98,7 +105,7 @@ const dispatch = await dispatchOpenClawGatewayTurn({ type: "hello-ok", protocol: 4, server: { version: "2026.7.2-beta.4", connId: "test-connection" }, - features: { methods: ["chat.send", "sessions.messages.subscribe"], events: ["chat"] }, + features: { methods: ["chat.send", "chat.abort", "sessions.messages.subscribe"], events: ["chat"] }, snapshot: { presence: [], health: {}, @@ -148,7 +155,7 @@ const dispatch = await dispatchOpenClawGatewayTurn({ }); assert.equal(dispatch.kind, "accepted", "a documented accepted run id enables Gateway ownership"); -assert.equal(clientOptions.caps?.[0], "tool-events", "the official client requests tool events without parsing unpublished tools"); +assert.equal(clientOptions.caps, undefined, "chat-only dispatch must not request unpublished tool-event capabilities"); assert.equal(clientOptions.minProtocol, 4); assert.equal(clientOptions.maxProtocol, 4); if (dispatch.kind === "accepted") { @@ -209,7 +216,7 @@ function helloOk() { type: "hello-ok", protocol: 4, server: { version: "2026.7.2-beta.4", connId: "reconnect-connection" }, - features: { methods: ["chat.send", "sessions.messages.subscribe"], events: ["chat"] }, + features: { methods: ["chat.send", "chat.abort", "sessions.messages.subscribe"], events: ["chat"] }, snapshot: { presence: [], health: {}, diff --git a/src/lib/openclaw-gateway.ts b/src/lib/openclaw-gateway.ts index 2e258b67f..288e6f12f 100644 --- a/src/lib/openclaw-gateway.ts +++ b/src/lib/openclaw-gateway.ts @@ -14,6 +14,7 @@ const GATEWAY_URL_ENV = "OPENCLAW_GATEWAY_URL"; const GATEWAY_TOKEN_ENV = "OPENCLAW_GATEWAY_TOKEN"; const GATEWAY_DEVICE_TOKEN_ENV = "OPENCLAW_GATEWAY_DEVICE_TOKEN"; const STARTUP_TIMEOUT_MS = 3_000; +const REQUIRED_GATEWAY_METHODS = ["chat.send", "chat.abort", "sessions.messages.subscribe"]; export type OpenClawGatewayChatEvent = | { kind: "status"; phase: string } @@ -75,8 +76,8 @@ function supportedHello(hello: GatewayHello): string | null { return "Gateway did not grant operator.read and operator.write"; } const methods = new Set(hello.features.methods); - if (!methods.has("chat.send") || !methods.has("sessions.messages.subscribe")) { - return "Gateway does not advertise the required chat.send and sessions.messages.subscribe methods"; + if (REQUIRED_GATEWAY_METHODS.some((method) => !methods.has(method))) { + return "Gateway does not advertise the required chat.send, chat.abort, and sessions.messages.subscribe methods"; } if (!hello.features.events.includes("chat")) return "Gateway does not advertise the versioned chat event"; return null; @@ -106,7 +107,7 @@ export function normalizeOpenClawGatewayChatEvent( if (!Value.Check(ChatEventSchema, payload)) return null; const event = payload as GatewayChatPayload; if (event.runId !== expected.runId || event.sessionKey !== expected.sessionKey) return null; - if (event.agentId !== undefined && event.agentId !== expected.agentId) return null; + if (event.agentId !== expected.agentId) return null; return event; } @@ -254,7 +255,6 @@ export async function dispatchOpenClawGatewayTurn(args: { mode: "backend", role: "operator", scopes: ["operator.read", "operator.write"], - caps: ["tool-events"], minProtocol: PROTOCOL_VERSION, maxProtocol: PROTOCOL_VERSION, connectChallengeTimeoutMs: STARTUP_TIMEOUT_MS, From 7ca04b99789b24bf1e47f40749b46bf03f11118e Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sun, 26 Jul 2026 13:28:00 -0400 Subject: [PATCH 39/61] fix(openclaw): gate Gateway dispatch on paired credentials --- .../chat/send/harness-routing-tool-events.test.ts | 5 +++++ src/app/api/chat/send/route.ts | 12 +++++++++--- src/lib/openclaw-gateway.test.ts | 9 +++++++++ src/lib/openclaw-gateway.ts | 13 +++++++++++++ 4 files changed, 36 insertions(+), 3 deletions(-) diff --git a/src/app/api/chat/send/harness-routing-tool-events.test.ts b/src/app/api/chat/send/harness-routing-tool-events.test.ts index 75a472b9f..c2e96592a 100644 --- a/src/app/api/chat/send/harness-routing-tool-events.test.ts +++ b/src/app/api/chat/send/harness-routing-tool-events.test.ts @@ -42,6 +42,11 @@ assert.match( /dispatchOpenClawGatewayTurn\([\s\S]*?sessionKey: openClawSessionKey\(conversationId\),[\s\S]*?agentId,[\s\S]*?message: args\.harnessPrompt/, "OpenClaw uses the Gateway-owned dispatcher with the canonical session key and agent id before selecting the CLI fallback", ); +assert.match( + chatRoute, + /openClawGatewayPairedDeviceAuthStatus\(\)[\s\S]*?gatewayAuth\.available[\s\S]*?dispatchOpenClawGatewayTurn/, + "the route must retain the CLI fallback until an OS-backed paired-device credential store can activate Gateway dispatch", +); assert.match( chatRoute, /gatewayDispatch\.kind === "accepted"[\s\S]*?return;[\s\S]*?pushProgress\("openclaw-start", "Starting OpenClaw bridge"/, diff --git a/src/app/api/chat/send/route.ts b/src/app/api/chat/send/route.ts index a56845dd8..b1749fc34 100644 --- a/src/app/api/chat/send/route.ts +++ b/src/app/api/chat/send/route.ts @@ -129,7 +129,10 @@ import { } from "@/lib/server/chat-project-launch"; import { validateCaveProjectRoot } from "@/lib/server/project-paths"; import { openClawLaunchCommand, openClawSpawnEnv } from "@/lib/openclaw-bin"; -import { dispatchOpenClawGatewayTurn } from "@/lib/openclaw-gateway"; +import { + dispatchOpenClawGatewayTurn, + openClawGatewayPairedDeviceAuthStatus, +} from "@/lib/openclaw-gateway"; import { OpenClawAgentResolutionError, extractOpenClawSessionId, @@ -653,7 +656,9 @@ function openClawChatResponse(args: { // Gateway it deliberately never starts a second CLI turn. let gatewayAssistantText = ""; let gatewayAssistantTextEmitted = false; - const gatewayDispatch = await dispatchOpenClawGatewayTurn({ + const gatewayAuth = openClawGatewayPairedDeviceAuthStatus(); + const gatewayDispatch = gatewayAuth.available + ? await dispatchOpenClawGatewayTurn({ sessionKey: openClawSessionKey(conversationId), agentId, message: args.harnessPrompt, @@ -688,7 +693,8 @@ function openClawChatResponse(args: { pushProgress("openclaw-gateway", "OpenClaw Gateway", "error", event.message); } }, - }); + }) + : { kind: "unavailable" as const, reason: gatewayAuth.reason ?? "Gateway paired-device authentication is unavailable" }; if (gatewayDispatch.kind === "indeterminate") { pushProgress("openclaw-gateway", "OpenClaw Gateway dispatch is indeterminate", "error", gatewayDispatch.reason); push({ kind: "error", code: "openclaw_gateway_indeterminate", message: gatewayDispatch.reason }); diff --git a/src/lib/openclaw-gateway.test.ts b/src/lib/openclaw-gateway.test.ts index b434a0291..73acbab07 100644 --- a/src/lib/openclaw-gateway.test.ts +++ b/src/lib/openclaw-gateway.test.ts @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import { dispatchOpenClawGatewayTurn, normalizeOpenClawGatewayChatEvent, + openClawGatewayPairedDeviceAuthStatus, textFromOpenClawGatewayMessage, } from "./openclaw-gateway.ts"; @@ -61,6 +62,14 @@ assert.equal( "ab", ); assert.equal(textFromOpenClawGatewayMessage({ raw: "not published" }), undefined); +assert.deepEqual( + openClawGatewayPairedDeviceAuthStatus(), + { + available: false, + reason: "Cave has no cross-platform OS-backed paired-device credential store for OpenClaw Gateway dispatch", + }, + "Cave must not activate the Gateway route before its host-owned paired-device credentials are secure", +); const missingRequestId = await dispatchOpenClawGatewayTurn({ sessionKey: expected.sessionKey, diff --git a/src/lib/openclaw-gateway.ts b/src/lib/openclaw-gateway.ts index 288e6f12f..f667e46d6 100644 --- a/src/lib/openclaw-gateway.ts +++ b/src/lib/openclaw-gateway.ts @@ -68,6 +68,19 @@ function gatewayDispatchEnabled(env: NodeJS.ProcessEnv): boolean { return env[GATEWAY_DISPATCH_ENV] === "1" || env[GATEWAY_DISPATCH_ENV] === "true"; } +/** + * The published client delegates device-identity creation, challenge signing, + * and device-token lifecycle to hostDeps. Cave has not yet implemented the + * required cross-platform OS credential-store boundary, so the route must not + * activate a write-capable Gateway transport with process-environment tokens. + */ +export function openClawGatewayPairedDeviceAuthStatus(): { available: boolean; reason?: string } { + return { + available: false, + reason: "Cave has no cross-platform OS-backed paired-device credential store for OpenClaw Gateway dispatch", + }; +} + function supportedHello(hello: GatewayHello): string | null { if (hello.protocol !== PROTOCOL_VERSION) return `Gateway protocol ${hello.protocol} is not supported`; if (hello.auth.role !== "operator") return "Gateway did not grant the operator role"; From 41750aea5830d18f521df0378edf990149c3e5ca Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sun, 26 Jul 2026 13:28:25 -0400 Subject: [PATCH 40/61] docs(openclaw): record paired-device activation blocker --- ...26-07-25-openclaw-gateway-dispatch-plan.md | 30 +++++++++++-------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/docs/specs/2026-07-25-openclaw-gateway-dispatch-plan.md b/docs/specs/2026-07-25-openclaw-gateway-dispatch-plan.md index 5556c8f53..aa3ea5c32 100644 --- a/docs/specs/2026-07-25-openclaw-gateway-dispatch-plan.md +++ b/docs/specs/2026-07-25-openclaw-gateway-dispatch-plan.md @@ -88,19 +88,25 @@ reason to guess a field shape. The only published protocol/client release is the `2026.7.2-beta.4` beta package pair, negotiating wire protocol v4. It publishes `HelloOkSchema`, `ChatEventSchema`, `chat.send`, `chat.abort`, and -`sessions.messages.subscribe`, so Cave can support a strictly correlated -chat-only Gateway turn for that exact profile. - -It does **not** publish a `session.tool` event name, payload schema, or -validator. Cave must therefore ignore those frames and emit no Gateway tool -card for this release. A method/event capability string is not a substitute -for a versioned payload contract. The CLI/plain-chat bridge remains the -fallback when dispatch cannot prove its accepted run, while the direct path -may render only validated `chat` lifecycle frames. - -| Package profile | Wire protocol | Validated projection | Tool cards | Upgrade rule | +`sessions.messages.subscribe`. Cave validates that exact chat-only contract in +its dispatcher, including the accepted `(sessionKey, agentId, runId)` tuple. + +Cave currently keeps the live route fail-closed **before client construction**: +the reference client delegates device identity, challenge signing, and token +lifecycle to host-owned `GatewayClientHostDeps`, and Cave does not yet have the +required cross-platform OS-backed credential-store boundary. In particular, +`OPENCLAW_GATEWAY_TOKEN` and `OPENCLAW_GATEWAY_DEVICE_TOKEN` cannot activate a +write-capable direct turn; the existing CLI/plain-chat bridge remains the +fallback. This is intentional until real paired-device storage is shipped. + +The release also does **not** publish a `session.tool` event name, payload +schema, or validator. Cave emits no Gateway tool card for this release and does +not request an unpublished tool-event capability. A method/event capability +string is not a substitute for a versioned payload contract. + +| Package profile | Wire protocol | Runtime projection | Tool cards | Upgrade rule | | --- | --- | --- | --- | --- | -| `2026.7.2-beta.4` | v4 only | `chat` frames correlated by session, agent, and accepted run | Disabled: no published schema | Add a fixture and explicit profile only when OpenClaw publishes a stable tool payload validator. | +| `2026.7.2-beta.4` | v4 only | None until Cave has OS-backed paired-device credentials; dispatcher tests validate only correlated `chat` frames | Disabled: no published schema | Add credential-store integration, then a fixture and explicit profile only when OpenClaw publishes a stable tool payload validator. | | Any other version/profile | Not assumed | None | Disabled | Keep CLI/plain chat with a visible compatibility diagnostic. | Before enabling tool cards, record the package release, exported validator, From 2796e9c4a48355a690edcc8c43ab454472ec09e3 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sun, 26 Jul 2026 13:57:31 -0400 Subject: [PATCH 41/61] fix(openclaw): isolate all Gateway settings from CLI fallback --- src/lib/openclaw-bin.test.ts | 8 ++++---- src/lib/openclaw-bin.ts | 15 ++++++--------- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/src/lib/openclaw-bin.test.ts b/src/lib/openclaw-bin.test.ts index 05b47cd00..69d391043 100644 --- a/src/lib/openclaw-bin.test.ts +++ b/src/lib/openclaw-bin.test.ts @@ -83,13 +83,13 @@ assert.match( ); assert.match( src, - /const GATEWAY_AUTH_ENV_KEYS = new Set\(\[[\s\S]*OPENCLAW_GATEWAY_TOKEN[\s\S]*OPENCLAW_GATEWAY_DEVICE_TOKEN[\s\S]*\]\);/, - "Gateway authentication values have an explicit fallback-child denylist", + /const GATEWAY_ENV_PREFIX = "OPENCLAW_GATEWAY_"/, + "the whole Gateway namespace has an explicit fallback-child denylist", ); assert.match( src, - /const mustNotReachFallback = GATEWAY_AUTH_ENV_KEYS\.has\(key\);[\s\S]*if \([\s\S]*mustNotReachFallback \|\|[\s\S]*genericSecret && !allowed\.has\(key\)/, - "Gateway auth cannot be reintroduced by the generic OpenClaw credential allow-list", + /const mustNotReachFallback = key\.startsWith\(GATEWAY_ENV_PREFIX\);[\s\S]*if \([\s\S]*mustNotReachFallback \|\|[\s\S]*genericSecret && !allowed\.has\(key\)/, + "Gateway settings cannot be reintroduced by the generic OpenClaw credential allow-list", ); console.log("openclaw-bin.test.ts: ok"); diff --git a/src/lib/openclaw-bin.ts b/src/lib/openclaw-bin.ts index aba937951..5aea3afec 100644 --- a/src/lib/openclaw-bin.ts +++ b/src/lib/openclaw-bin.ts @@ -25,14 +25,11 @@ const FORBIDDEN_SPAWN_ENV_KEYS = new Set(["GITHUB_PAT"]); // The Gateway dispatcher reads these only in Cave's server process. They must // never cross the fallback boundary, even if a broad harness allow-list was // configured for a different OpenClaw integration. -const GATEWAY_AUTH_ENV_KEYS = new Set([ - "OPENCLAW_GATEWAY_TOKEN", - "OPENCLAW_GATEWAY_DEVICE_TOKEN", - "OPENCLAW_GATEWAY_BOOTSTRAP_TOKEN", - "OPENCLAW_GATEWAY_PASSWORD", - "OPENCLAW_GATEWAY_APPROVAL_RUNTIME_TOKEN", - "OPENCLAW_GATEWAY_AGENT_RUNTIME_IDENTITY_TOKEN", -]); +// Keep the entire direct-Gateway namespace in Cave's server process. An +// explicit generic harness allow-list must not turn a future Gateway token, +// device identity, private key, or signing value into a CLI child credential. +// The CLI compatibility path has no supported need for these settings. +const GATEWAY_ENV_PREFIX = "OPENCLAW_GATEWAY_"; const FORBIDDEN_SPAWN_ENV_RE = /(?:^|_)(?:TOKEN|KEY|SECRET|PASSWORD|PASS|PAT|CREDENTIALS?|COOKIE|SESSION)(?:_|$)/i; @@ -188,7 +185,7 @@ export function openClawSpawnEnv(): NodeJS.ProcessEnv { restoreAllowedGitHubTokenEnv(env, allowed, new Set(Object.keys(map))); for (const key of Object.keys(env)) { - const mustNotReachFallback = GATEWAY_AUTH_ENV_KEYS.has(key); + const mustNotReachFallback = key.startsWith(GATEWAY_ENV_PREFIX); const genericSecret = FORBIDDEN_SPAWN_ENV_KEYS.has(key) || FORBIDDEN_SPAWN_ENV_RE.test(key); if ( From 08690899e0fdb29af5df753bd41c69b95eeffba0 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sun, 26 Jul 2026 13:57:31 -0400 Subject: [PATCH 42/61] fix(openclaw): fail closed on opaque Gateway chat frames --- src/lib/openclaw-gateway.test.ts | 49 ++++++++++++++++++++++++++++---- src/lib/openclaw-gateway.ts | 21 +++++++------- 2 files changed, 54 insertions(+), 16 deletions(-) diff --git a/src/lib/openclaw-gateway.test.ts b/src/lib/openclaw-gateway.test.ts index 73acbab07..3e2016248 100644 --- a/src/lib/openclaw-gateway.test.ts +++ b/src/lib/openclaw-gateway.test.ts @@ -55,11 +55,12 @@ assert.equal( "unpublished tool payload shapes fail closed instead of becoming cards", ); -assert.equal(textFromOpenClawGatewayMessage("plain"), "plain"); -assert.equal(textFromOpenClawGatewayMessage({ text: "envelope" }), "envelope"); +assert.equal(textFromOpenClawGatewayMessage("plain"), undefined); +assert.equal(textFromOpenClawGatewayMessage({ text: "envelope" }), undefined); assert.equal( textFromOpenClawGatewayMessage({ content: [{ type: "text", text: "a" }, { type: "text", text: "b" }] }), - "ab", + undefined, + "the published ChatEvent schema leaves message opaque, so its observed contents must not reach Cave output", ); assert.equal(textFromOpenClawGatewayMessage({ raw: "not published" }), undefined); assert.deepEqual( @@ -168,13 +169,51 @@ assert.equal(clientOptions.caps, undefined, "chat-only dispatch must not request assert.equal(clientOptions.minProtocol, 4); assert.equal(clientOptions.maxProtocol, 4); if (dispatch.kind === "accepted") { - assert.deepEqual(await dispatch.done, { state: "final", message: "Hello" }); + assert.deepEqual(await dispatch.done, { state: "final" }); } assert.deepEqual( emitted.filter((event) => event.kind === "delta"), [{ kind: "delta", text: "Hello", replace: false }], "only the accepted run reaches Cave output; a foreign same-session run is rejected", ); +assert.ok( + emitted.some((event) => event.kind === "final" && !("text" in event)), + "an opaque final message never becomes assistant-visible text", +); + +const gappedEvents = []; +const gappedDispatch = await dispatchOpenClawGatewayTurn({ + sessionKey: expected.sessionKey, + agentId: expected.agentId, + message: "gap", + idempotencyKey: "cave-request-gap", + env: { OPENCLAW_GATEWAY_DISPATCH: "1", OPENCLAW_GATEWAY_URL: "ws://127.0.0.1:18789" }, + onEvent: (event) => gappedEvents.push(event), + clientFactory: (options) => ({ + start() { queueMicrotask(() => options.onHelloOk?.(helloOk())); }, + stop() {}, + async request(method, _params, requestOptions) { + if (method === "chat.send") { + requestOptions?.onSent?.(); + queueMicrotask(() => options.onEvent?.({ + type: "event", + event: "chat", + payload: { ...delta, seq: 1 }, + })); + return { runId: expected.runId }; + } + return { subscribed: true }; + }, + }), +}); +if (gappedDispatch.kind === "accepted") { + assert.deepEqual( + await gappedDispatch.done, + { state: "error", message: "Gateway event sequence gap" }, + "the first accepted event must be sequence zero; otherwise the Gateway turn is incomplete", + ); +} +assert.deepEqual(gappedEvents, [{ kind: "error", message: "Gateway event sequence gap" }]); // A reconnect is not an excuse to trust an event before the documented // session subscription returns. The queued frame is accepted only after the @@ -272,7 +311,7 @@ reconnectOptions.onEvent?.({ }, }); if (reconnectDispatch.kind === "accepted") { - assert.deepEqual(await reconnectDispatch.done, { state: "final", message: "done" }); + assert.deepEqual(await reconnectDispatch.done, { state: "final" }); } console.log("openclaw Gateway run correlation tests passed"); diff --git a/src/lib/openclaw-gateway.ts b/src/lib/openclaw-gateway.ts index f667e46d6..04945789f 100644 --- a/src/lib/openclaw-gateway.ts +++ b/src/lib/openclaw-gateway.ts @@ -96,17 +96,16 @@ function supportedHello(hello: GatewayHello): string | null { return null; } -/** Extract text only from the documented chat envelope shapes. */ +/** + * The published v4 ChatEvent schema deliberately leaves `message` opaque. + * Do not infer a content envelope from an opaque value: doing so would make + * unversioned Gateway payloads assistant-visible. Text reaches Cave only via + * the schema's `deltaText` field until a versioned final-message validator is + * published. + */ export function textFromOpenClawGatewayMessage(message: unknown): string | undefined { - if (typeof message === "string") return message; - if (!isRecord(message)) return undefined; - if (typeof message.text === "string") return message.text; - if (!Array.isArray(message.content)) return undefined; - const text = message.content - .map((part) => (isRecord(part) && typeof part.text === "string" ? part.text : "")) - .filter(Boolean) - .join(""); - return text || undefined; + void message; + return undefined; } /** @@ -211,7 +210,7 @@ export async function dispatchOpenClawGatewayTurn(args: { // history-reconciliation schema, so finish this Gateway-owned turn as an // error instead of pretending its stream is complete. if (event.seq <= highestSequence) return; - if (highestSequence >= 0 && event.seq !== highestSequence + 1) { + if (event.seq !== highestSequence + 1) { args.onEvent({ kind: "error", message: "Gateway event sequence gap" }); settle("error", "Gateway event sequence gap"); client.stop(); From a7df1c49012d86963aa40d4267919caaa886ac68 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sun, 26 Jul 2026 14:20:39 -0400 Subject: [PATCH 43/61] fix(openclaw): guard dispatcher without paired credentials --- src/lib/openclaw-gateway.test.ts | 30 ++++++++++++++++++++++++++++++ src/lib/openclaw-gateway.ts | 17 +++++++++++++---- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/src/lib/openclaw-gateway.test.ts b/src/lib/openclaw-gateway.test.ts index 3e2016248..1c0f8e43d 100644 --- a/src/lib/openclaw-gateway.test.ts +++ b/src/lib/openclaw-gateway.test.ts @@ -90,6 +90,27 @@ assert.deepEqual( "without a stable Cave request id the route retains the CLI fallback", ); +const unpairedDispatch = await dispatchOpenClawGatewayTurn({ + sessionKey: expected.sessionKey, + agentId: expected.agentId, + message: "hello", + idempotencyKey: "cave-request-unpaired", + env: { + OPENCLAW_GATEWAY_DISPATCH: "1", + OPENCLAW_GATEWAY_URL: "ws://127.0.0.1:18789", + OPENCLAW_GATEWAY_TOKEN: "must-not-activate-direct-dispatch", + }, + onEvent: () => assert.fail("an unpaired Gateway must not emit events"), +}); +assert.deepEqual( + unpairedDispatch, + { + kind: "unavailable", + reason: "Cave has no cross-platform OS-backed paired-device credential store for OpenClaw Gateway dispatch", + }, + "an environment token alone must never activate a write-capable Gateway dispatch", +); + // The direct dispatcher is tested through the same official-client callback // contract that production uses: it waits for hello and subscription, binds // the acknowledged run id, and ignores a foreign run with the same session. @@ -103,10 +124,19 @@ const dispatch = await dispatchOpenClawGatewayTurn({ env: { OPENCLAW_GATEWAY_DISPATCH: "1", OPENCLAW_GATEWAY_URL: "ws://127.0.0.1:18789", + OPENCLAW_GATEWAY_TOKEN: "must-not-reach-the-client", + OPENCLAW_GATEWAY_DEVICE_TOKEN: "must-not-reach-the-client", }, onEvent: (event) => emitted.push(event), clientFactory: (options) => { clientOptions = options; + assert.equal(options.token, undefined, "Gateway process tokens must not reach the client"); + assert.equal(options.deviceToken, undefined, "Gateway process device tokens must not reach the client"); + assert.deepEqual( + options.env, + { NODE_ENV: process.env.NODE_ENV ?? "production" }, + "Gateway auth must not inherit Cave's process environment", + ); assert.equal(options.caps, undefined, "chat-only dispatch must not request unpublished tool-event capabilities"); return { start() { diff --git a/src/lib/openclaw-gateway.ts b/src/lib/openclaw-gateway.ts index 04945789f..be2b12680 100644 --- a/src/lib/openclaw-gateway.ts +++ b/src/lib/openclaw-gateway.ts @@ -11,8 +11,6 @@ import { Value } from "typebox/value"; */ const GATEWAY_DISPATCH_ENV = "OPENCLAW_GATEWAY_DISPATCH"; const GATEWAY_URL_ENV = "OPENCLAW_GATEWAY_URL"; -const GATEWAY_TOKEN_ENV = "OPENCLAW_GATEWAY_TOKEN"; -const GATEWAY_DEVICE_TOKEN_ENV = "OPENCLAW_GATEWAY_DEVICE_TOKEN"; const STARTUP_TIMEOUT_MS = 3_000; const REQUIRED_GATEWAY_METHODS = ["chat.send", "chat.abort", "sessions.messages.subscribe"]; @@ -158,6 +156,14 @@ export async function dispatchOpenClawGatewayTurn(args: { }): Promise { const env = args.env ?? process.env; if (!gatewayDispatchEnabled(env)) return { kind: "unavailable", reason: "Gateway dispatch is disabled" }; + const pairedDeviceAuth = openClawGatewayPairedDeviceAuthStatus(); + // Keep this guard in the dispatcher as well as the route. A future caller + // must not be able to turn an environment token into a write-capable + // Gateway session simply by bypassing the route-level fallback choice. The + // injectable port is test-only and cannot create a real Gateway connection. + if (!pairedDeviceAuth.available && !args.clientFactory) { + return { kind: "unavailable", reason: pairedDeviceAuth.reason ?? "Gateway paired-device authentication is unavailable" }; + } if (!nonEmptyString(args.idempotencyKey)) { return { kind: "unavailable", reason: "Gateway dispatch requires a Cave request id" }; } @@ -258,8 +264,11 @@ export async function dispatchOpenClawGatewayTurn(args: { const clientOptions: ConstructorParameters[0] = { url, - token: env[GATEWAY_TOKEN_ENV], - deviceToken: env[GATEWAY_DEVICE_TOKEN_ENV], + // Never let the Gateway client read Cave's process environment. The + // future paired-device boundary must provide identity and token lifecycle + // through hostDeps, rather than reviving token/device-token env auth. + // `GatewayClientOptions.env` requires NODE_ENV, which is non-secret. + env: { NODE_ENV: process.env.NODE_ENV ?? "production" }, clientName: "gateway-client", clientDisplayName: "Coven Cave", clientVersion: "2026.7.2-beta.4", From e3bc290b16241726a39bbf3f64084d720cbce22f Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sun, 26 Jul 2026 15:04:05 -0400 Subject: [PATCH 44/61] fix(openclaw): require chat routing capability --- src/lib/openclaw-gateway.test.ts | 44 ++++++++++++++++++++++++++++++-- src/lib/openclaw-gateway.ts | 11 +++++++- 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/src/lib/openclaw-gateway.test.ts b/src/lib/openclaw-gateway.test.ts index 1c0f8e43d..7c323c30f 100644 --- a/src/lib/openclaw-gateway.test.ts +++ b/src/lib/openclaw-gateway.test.ts @@ -145,7 +145,11 @@ const dispatch = await dispatchOpenClawGatewayTurn({ type: "hello-ok", protocol: 4, server: { version: "2026.7.2-beta.4", connId: "test-connection" }, - features: { methods: ["chat.send", "chat.abort", "sessions.messages.subscribe"], events: ["chat"] }, + features: { + methods: ["chat.send", "chat.abort", "sessions.messages.subscribe"], + events: ["chat"], + capabilities: ["chat-send-routing-contract"], + }, snapshot: { presence: [], health: {}, @@ -211,6 +215,38 @@ assert.ok( "an opaque final message never becomes assistant-visible text", ); +const missingRoutingCapability = await dispatchOpenClawGatewayTurn({ + sessionKey: expected.sessionKey, + agentId: expected.agentId, + message: "capability check", + idempotencyKey: "cave-request-missing-routing-capability", + env: { OPENCLAW_GATEWAY_DISPATCH: "1", OPENCLAW_GATEWAY_URL: "ws://127.0.0.1:18789" }, + onEvent: () => assert.fail("an incompatible Gateway must not emit events"), + clientFactory: (options) => ({ + start() { + queueMicrotask(() => options.onHelloOk?.({ + ...helloOk(), + features: { + ...helloOk().features, + capabilities: [], + }, + })); + }, + stop() {}, + async request() { + assert.fail("a Gateway without the routing capability must not dispatch"); + }, + }), +}); +assert.deepEqual( + missingRoutingCapability, + { + kind: "unavailable", + reason: "Gateway does not advertise the required chat-send-routing-contract capability", + }, + "agent and session routing must not be trusted without the published routing capability", +); + const gappedEvents = []; const gappedDispatch = await dispatchOpenClawGatewayTurn({ sessionKey: expected.sessionKey, @@ -294,7 +330,11 @@ function helloOk() { type: "hello-ok", protocol: 4, server: { version: "2026.7.2-beta.4", connId: "reconnect-connection" }, - features: { methods: ["chat.send", "chat.abort", "sessions.messages.subscribe"], events: ["chat"] }, + features: { + methods: ["chat.send", "chat.abort", "sessions.messages.subscribe"], + events: ["chat"], + capabilities: ["chat-send-routing-contract"], + }, snapshot: { presence: [], health: {}, diff --git a/src/lib/openclaw-gateway.ts b/src/lib/openclaw-gateway.ts index be2b12680..a49558325 100644 --- a/src/lib/openclaw-gateway.ts +++ b/src/lib/openclaw-gateway.ts @@ -1,5 +1,9 @@ import { GatewayClient } from "@openclaw/gateway-client"; -import { ChatEventSchema, HelloOkSchema } from "@openclaw/gateway-protocol"; +import { + ChatEventSchema, + GATEWAY_SERVER_CAPS, + HelloOkSchema, +} from "@openclaw/gateway-protocol"; import { PROTOCOL_VERSION } from "@openclaw/gateway-protocol/version"; import { Value } from "typebox/value"; @@ -13,6 +17,7 @@ const GATEWAY_DISPATCH_ENV = "OPENCLAW_GATEWAY_DISPATCH"; const GATEWAY_URL_ENV = "OPENCLAW_GATEWAY_URL"; const STARTUP_TIMEOUT_MS = 3_000; const REQUIRED_GATEWAY_METHODS = ["chat.send", "chat.abort", "sessions.messages.subscribe"]; +const REQUIRED_GATEWAY_CAPABILITIES = [GATEWAY_SERVER_CAPS.CHAT_SEND_ROUTING_CONTRACT]; export type OpenClawGatewayChatEvent = | { kind: "status"; phase: string } @@ -90,6 +95,10 @@ function supportedHello(hello: GatewayHello): string | null { if (REQUIRED_GATEWAY_METHODS.some((method) => !methods.has(method))) { return "Gateway does not advertise the required chat.send, chat.abort, and sessions.messages.subscribe methods"; } + const capabilities = new Set(hello.features.capabilities ?? []); + if (REQUIRED_GATEWAY_CAPABILITIES.some((capability) => !capabilities.has(capability))) { + return "Gateway does not advertise the required chat-send-routing-contract capability"; + } if (!hello.features.events.includes("chat")) return "Gateway does not advertise the versioned chat event"; return null; } From 1ba9d770a155b567b6a797f890e983c898f8bcd6 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sun, 26 Jul 2026 15:20:35 -0400 Subject: [PATCH 45/61] fix(openclaw): fence frames after transport close --- src/lib/openclaw-gateway.test.ts | 7 +++++++ src/lib/openclaw-gateway.ts | 9 +++++++++ 2 files changed, 16 insertions(+) diff --git a/src/lib/openclaw-gateway.test.ts b/src/lib/openclaw-gateway.test.ts index 7c323c30f..058021b75 100644 --- a/src/lib/openclaw-gateway.test.ts +++ b/src/lib/openclaw-gateway.test.ts @@ -351,6 +351,13 @@ assert.equal(reconnectDispatch.kind, "accepted"); // onConnectError before it retries. That must not terminate a Gateway-owned // run; a subsequent authenticated hello restores the subscription. reconnectOptions.onConnectError?.(new Error("transient reconnect failure")); +reconnectOptions.onClose?.(1006, "network reset"); +reconnectOptions.onEvent?.({ type: "event", event: "chat", payload: { ...delta, seq: 0 } }); +assert.deepEqual( + reconnectEvents, + [], + "a closed transport cannot project a buffered frame before its reconnect hello re-subscribes", +); reconnectOptions.onHelloOk?.(helloOk()); reconnectOptions.onHelloOk?.(helloOk()); reconnectOptions.onEvent?.({ type: "event", event: "chat", payload: { ...delta, seq: 0 } }); diff --git a/src/lib/openclaw-gateway.ts b/src/lib/openclaw-gateway.ts index a49558325..900f70bc0 100644 --- a/src/lib/openclaw-gateway.ts +++ b/src/lib/openclaw-gateway.ts @@ -339,6 +339,15 @@ export async function dispatchOpenClawGatewayTurn(args: { settle("error", message); client.stop(); }, + onClose: () => { + // A socket close invalidates the subscription immediately, not only + // when the next hello arrives. The Gateway client retries in the + // background, and a buffered callback from the closed transport must + // not be accepted in the interval before that new hello restores the + // session subscription. + streamReady = false; + connectionGeneration += 1; + }, onEvent: (frame) => { if (frame.event === "chat") processChatEvent(frame.payload); }, From a0e6fa875729baa0bf0b73f3f672344d04b9b159 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sun, 26 Jul 2026 15:29:40 -0400 Subject: [PATCH 46/61] fix(openclaw): drop frames from closed transports --- src/lib/openclaw-gateway.test.ts | 13 ++++++++++--- src/lib/openclaw-gateway.ts | 12 +++++++++++- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/lib/openclaw-gateway.test.ts b/src/lib/openclaw-gateway.test.ts index 058021b75..f4460d143 100644 --- a/src/lib/openclaw-gateway.test.ts +++ b/src/lib/openclaw-gateway.test.ts @@ -282,8 +282,9 @@ if (gappedDispatch.kind === "accepted") { assert.deepEqual(gappedEvents, [{ kind: "error", message: "Gateway event sequence gap" }]); // A reconnect is not an excuse to trust an event before the documented -// session subscription returns. The queued frame is accepted only after the -// re-subscription succeeds. +// session subscription returns. A callback delivered while the old socket is +// closed has no transport-generation provenance, so it is dropped rather +// than being replayed after the next subscription succeeds. let reconnectOptions; let releaseFirstResubscribe; let releaseSecondResubscribe; @@ -370,10 +371,16 @@ assert.deepEqual(reconnectEvents, [], "a stale reconnect subscription cannot reo releaseSecondResubscribe(); await Promise.resolve(); await Promise.resolve(); +assert.deepEqual( + reconnectEvents, + [], + "a frame delivered after transport close is never replayed on the reconnected stream", +); +reconnectOptions.onEvent?.({ type: "event", event: "chat", payload: { ...delta, seq: 0 } }); assert.deepEqual( reconnectEvents, [{ kind: "delta", text: "Hello", replace: false }], - "the queued frame is projected after re-subscription succeeds", + "a new frame is projected only after re-subscription succeeds", ); reconnectOptions.onEvent?.({ type: "event", diff --git a/src/lib/openclaw-gateway.ts b/src/lib/openclaw-gateway.ts index 900f70bc0..0fd832fc0 100644 --- a/src/lib/openclaw-gateway.ts +++ b/src/lib/openclaw-gateway.ts @@ -210,10 +210,20 @@ export async function dispatchOpenClawGatewayTurn(args: { }; const processChatEvent = (payload: unknown) => { - if (!expectedRunId || !streamReady || settled) { + if (settled) return; + // Until `chat.send` returns, events cannot yet be correlated to a Cave + // turn. Hold a bounded set so an accepted response can bind them to its + // run id. Once a run has been accepted, however, a disconnect or a + // reconnect subscription gap is a hard fence: an event callback has no + // transport-generation provenance, so accepting it after the next hello + // could expose a late frame buffered by the old socket. + if (!expectedRunId) { if (queuedChatEvents.length < 128) queuedChatEvents.push(payload); return; } + if (!streamReady) { + return; + } const event = normalizeOpenClawGatewayChatEvent(payload, { runId: expectedRunId, sessionKey: args.sessionKey, From f55039c938a28aaa2b223a9a9a34d8f61e76221b Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sun, 26 Jul 2026 16:06:35 -0400 Subject: [PATCH 47/61] fix(openclaw): retain fallback on Gateway startup failures --- src/lib/openclaw-gateway.test.ts | 27 +++++++++++++++++++++++++++ src/lib/openclaw-gateway.ts | 13 ++++++++----- 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/src/lib/openclaw-gateway.test.ts b/src/lib/openclaw-gateway.test.ts index f4460d143..ddacc4811 100644 --- a/src/lib/openclaw-gateway.test.ts +++ b/src/lib/openclaw-gateway.test.ts @@ -111,6 +111,33 @@ assert.deepEqual( "an environment token alone must never activate a write-capable Gateway dispatch", ); +let stoppedAfterStartupFailure = false; +const startupFailure = await dispatchOpenClawGatewayTurn({ + sessionKey: expected.sessionKey, + agentId: expected.agentId, + message: "hello", + idempotencyKey: "cave-request-startup-failure", + env: { OPENCLAW_GATEWAY_DISPATCH: "1", OPENCLAW_GATEWAY_URL: "ws://127.0.0.1:18789" }, + onEvent: () => assert.fail("a Gateway that fails to start must not emit events"), + clientFactory: () => ({ + start() { + throw new Error("Gateway client startup failed"); + }, + stop() { + stoppedAfterStartupFailure = true; + }, + async request() { + assert.fail("a Gateway that failed to start must not dispatch"); + }, + }), +}); +assert.deepEqual( + startupFailure, + { kind: "unavailable", reason: "Gateway client startup failed" }, + "a pre-dispatch client startup failure retains the CLI fallback", +); +assert.equal(stoppedAfterStartupFailure, true, "a partially started Gateway client is stopped before fallback"); + // The direct dispatcher is tested through the same official-client callback // contract that production uses: it waits for hello and subscription, binds // the acknowledged run id, and ignores a foreign run with the same session. diff --git a/src/lib/openclaw-gateway.ts b/src/lib/openclaw-gateway.ts index 0fd832fc0..1bee3b985 100644 --- a/src/lib/openclaw-gateway.ts +++ b/src/lib/openclaw-gateway.ts @@ -179,7 +179,7 @@ export async function dispatchOpenClawGatewayTurn(args: { const url = env[GATEWAY_URL_ENV]; if (!nonEmptyString(url)) return { kind: "unavailable", reason: "Gateway URL is not configured" }; - let client: GatewayClientPort; + let client!: GatewayClientPort; let helloResolve: (() => void) | undefined; let helloReject: ((error: Error) => void) | undefined; let connected = false; @@ -369,10 +369,13 @@ export async function dispatchOpenClawGatewayTurn(args: { client.stop(); }, }; - client = args.clientFactory?.(clientOptions) ?? new GatewayClient(clientOptions); - - client.start(); try { + // Construction and `start()` are both pre-dispatch compatibility steps. + // Treat a malformed endpoint or a host-client startup failure exactly like + // a failed hello so the caller can retain the CLI fallback before any + // `chat.send` request might have left Cave. + client = args.clientFactory?.(clientOptions) ?? new GatewayClient(clientOptions); + client.start(); await withTimeout(hello, STARTUP_TIMEOUT_MS, "Gateway did not complete its authenticated hello"); // A reconnect can arrive while the initial subscription is in flight. // Retry until the completion belongs to the latest authenticated hello; @@ -387,7 +390,7 @@ export async function dispatchOpenClawGatewayTurn(args: { } } } catch (error) { - client.stop(); + client?.stop(); return { kind: "unavailable", reason: error instanceof Error ? error.message : "Gateway is unavailable" }; } From 38e9bf967e50479c517489fef13f1d78a32a7b5d Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sun, 26 Jul 2026 12:14:25 -0400 Subject: [PATCH 48/61] fix(copilot): preflight resolved launch artifacts --- src/app/api/chat/send/route.ts | 8 +++++- src/lib/copilot-bin.test.ts | 5 ++++ src/lib/copilot-bin.ts | 16 +++++++++-- src/lib/copilot-stream.test.ts | 2 ++ src/lib/copilot-stream.ts | 14 ++++++++-- src/lib/runtime-availability.test.ts | 27 ++++++++++++++++++ src/lib/runtime-availability.ts | 35 ++++++++++++++++++++++++ src/lib/server/copilot-runtime-launch.ts | 8 ++++++ 8 files changed, 110 insertions(+), 5 deletions(-) diff --git a/src/app/api/chat/send/route.ts b/src/app/api/chat/send/route.ts index 2836f27af..10e42cad3 100644 --- a/src/app/api/chat/send/route.ts +++ b/src/app/api/chat/send/route.ts @@ -240,6 +240,7 @@ const CHAT_DETACH_MAX_MS = Math.max( type LocalRuntimePlan = LocalRuntimeCapabilityPlan & { command: string; fixedArgs: string[]; + requiredFiles?: string[]; env: NodeJS.ProcessEnv; unresolvedWindowsShim?: boolean; powerShellHostedCommand?: string; @@ -247,7 +248,9 @@ type LocalRuntimePlan = LocalRuntimeCapabilityPlan & { function createLocalRuntimePlan(input: { runner: DirectRunnerId; - launch: Pick; + launch: Pick & { + requiredFiles?: string[]; + }; env: NodeJS.ProcessEnv; availability?: RuntimeAvailability; powerShellHostedCommand?: string; @@ -256,6 +259,7 @@ function createLocalRuntimePlan(input: { runner: input.runner, command: input.launch.command, env: input.env, + requiredFiles: input.launch.requiredFiles, unresolvedWindowsShim: input.launch.unresolvedWindowsShim === true, powerShellHostedCommand: input.powerShellHostedCommand, }); @@ -263,6 +267,7 @@ function createLocalRuntimePlan(input: { runner: input.runner, command: input.launch.command, fixedArgs: input.launch.fixedArgs, + ...(input.launch.requiredFiles ? { requiredFiles: input.launch.requiredFiles } : {}), env: input.env, availability, ...(input.launch.unresolvedWindowsShim @@ -3160,6 +3165,7 @@ export async function POST(req: Request) { runner: localPlan.runner, command: localPlan.command, env: localPlan.env, + requiredFiles: localPlan.requiredFiles, unresolvedWindowsShim: localPlan.unresolvedWindowsShim === true, powerShellHostedCommand: diff --git a/src/lib/copilot-bin.test.ts b/src/lib/copilot-bin.test.ts index f45ca6300..9431c771b 100644 --- a/src/lib/copilot-bin.test.ts +++ b/src/lib/copilot-bin.test.ts @@ -14,5 +14,10 @@ writeFileSync(shim, `@echo off\n"%~dp0\\node_modules\\@github\\copilot\\index.js const windowsShim = await resolveCopilotLaunchCommand(shim, { platform: "win32" }); assert.equal(windowsShim.command, process.execPath, "a Windows npm shim runs through Node, never cmd.exe"); assert.deepEqual(windowsShim.fixedArgs, [entry], "the npm shim entry point is kept separate from untrusted argv"); +assert.deepEqual( + windowsShim.requiredFiles, + [entry], + "the converted argv-list launch records its required entry artifact for preflight", +); console.log("copilot-bin: ok"); diff --git a/src/lib/copilot-bin.ts b/src/lib/copilot-bin.ts index 4c372f114..d0ecdc9a7 100644 --- a/src/lib/copilot-bin.ts +++ b/src/lib/copilot-bin.ts @@ -12,6 +12,13 @@ import { covenLaunchCommandForBinary, pickWindowsLauncher, type CovenLaunchComma const MAX_WINDOWS_WHERE_OUTPUT = 64 * 1024; +/** The exact argv-list Copilot launch. `requiredFiles` are passive preflight + * artifacts, never shell input: npm's Windows shim conversion yields + * `node ` and both parts must remain available. */ +export type CopilotLaunchCommand = CovenLaunchCommand & { + requiredFiles: string[]; +}; + async function withinTimeout(work: Promise, timeoutMs: number, fallback: T): Promise<{ value: T; timedOut: boolean }> { if (timeoutMs <= 0) return { value: fallback, timedOut: true }; let timer: ReturnType | null = null; @@ -91,7 +98,7 @@ async function windowsCopilotLauncher(binary: string, timeoutMs: number, env?: N export async function resolveCopilotLaunchCommand( binary: string, options: { platform?: NodeJS.Platform; timeoutMs?: number; env?: NodeJS.ProcessEnv } = {}, -): Promise { +): Promise { const platform = options.platform ?? process.platform; const timeoutMs = options.timeoutMs ?? 1_500; const resolveLauncher = platform === "win32" @@ -102,8 +109,13 @@ export async function resolveCopilotLaunchCommand( timeoutMs, binary, ); + const launch = covenLaunchCommandForBinary(resolved.value, platform); return { - ...covenLaunchCommandForBinary(resolved.value, platform), + ...launch, + // A converted shim's first fixed argv item is the absolute script whose + // existence made the conversion safe. Native launches have no required + // artifact beyond their command. + requiredFiles: launch.fixedArgs.length === 1 ? [launch.fixedArgs[0]!] : [], ...(resolved.timedOut ? { resolutionTimedOut: true as const } : {}), }; } diff --git a/src/lib/copilot-stream.test.ts b/src/lib/copilot-stream.test.ts index 34dff36ba..5db6ee1d8 100644 --- a/src/lib/copilot-stream.test.ts +++ b/src/lib/copilot-stream.test.ts @@ -10,6 +10,7 @@ import { readFile } from "node:fs/promises"; import { buildCopilotStreamArgs, compareRuntimeClientVersions, + copilotDirectStreamConfigured, copilotIdentityPreamble, copilotProtocolDiagnostic, copilotStreamSpec, @@ -32,6 +33,7 @@ import { const spec = copilotStreamSpec(); assert.ok(spec, "registry manifest declares copilot stream mode"); +assert.equal(copilotDirectStreamConfigured(), true, "the synced registry explicitly opts Copilot into the direct JSONL transport"); assert.equal(spec.protocol.id, "copilot-jsonl-v1", "legacy callers use the verified registry baseline"); assert.equal(spec.executable, "copilot"); assert.deepEqual( diff --git a/src/lib/copilot-stream.ts b/src/lib/copilot-stream.ts index 3018dc1c7..87426a145 100644 --- a/src/lib/copilot-stream.ts +++ b/src/lib/copilot-stream.ts @@ -28,6 +28,7 @@ // ignores. The final `result` frame is top-level (no `data` envelope). import { REGISTRY_RUNTIMES } from "./runtime-registry.gen.ts"; +import type { CopilotLaunchCommand } from "./copilot-bin.ts"; import type { RuntimeToolAdapter, ToolAction } from "./runtime-tool-adapter.ts"; /** @@ -239,7 +240,7 @@ export type RuntimeProtocolDiagnostic = { export type CopilotStreamSpec = { executable: string; /** Resolved during the capability probe; direct spawns must reuse it. */ - launchCommand?: { command: string; fixedArgs: string[] }; + launchCommand?: CopilotLaunchCommand; /** Selected, versioned JSONL event contract for this local CLI. */ protocol: RuntimeEventProtocolSchema; /** JSONL stream launch args; ends with the prompt flag (`-p`). */ @@ -283,6 +284,15 @@ function stringArray(value: unknown): string[] | null { return value as string[]; } +/** Whether the synced registry explicitly opts local Copilot into Cave's + * direct JSONL transport. When it does not, callers retain the established + * generic `coven run` route; when it does, a malformed/incompatible direct + * plan must fail closed rather than silently changing transports. */ +export function copilotDirectStreamConfigured(): boolean { + const runtime = REGISTRY_RUNTIMES.find((entry) => entry.id === "copilot"); + return runtime?.capabilities.stream === true; +} + function record(value: unknown): Record | null { return value && typeof value === "object" && !Array.isArray(value) ? value as Record @@ -350,7 +360,7 @@ export function runtimeEventProtocolSchemas(value: unknown): RuntimeEventProtoco export function copilotStreamSpec( clientVersion?: string | null, compatibleEventProtocols?: unknown, - launchCommand?: { command: string; fixedArgs: string[] }, + launchCommand?: CopilotLaunchCommand, ): CopilotStreamSpec | null { const runtime = REGISTRY_RUNTIMES.find((entry) => entry.id === "copilot"); if (!runtime || !runtime.capabilities.stream) return null; diff --git a/src/lib/runtime-availability.test.ts b/src/lib/runtime-availability.test.ts index cf6e88727..c2ff4a71f 100644 --- a/src/lib/runtime-availability.test.ts +++ b/src/lib/runtime-availability.test.ts @@ -135,6 +135,33 @@ try { ); } + // A direct Windows npm-shim plan is `node `: validating only the + // host must not mark a removed or unreadable fixed script as ready. + const requiredArtifactMissing = evaluateRuntimeAvailability({ + runner: "copilot", + command: "node.exe", + requiredFiles: ["C:\\bin\\copilot-entry.js"], + env: { Path: "C:\\bin" }, + platform: "win32", + statFile: (candidate) => candidate === "C:\\bin\\node.exe", + }); + assert.equal(requiredArtifactMissing.state, "unlaunchable"); + assert.doesNotMatch( + requiredArtifactMissing.state === "unlaunchable" ? requiredArtifactMissing.message : "", + /copilot-entry|C:\\bin/i, + "required-artifact diagnostics do not expose local path details", + ); + + const requiredArtifactReady = evaluateRuntimeAvailability({ + runner: "copilot", + command: "node.exe", + requiredFiles: ["C:\\bin\\copilot-entry.js"], + env: { Path: "C:\\bin" }, + platform: "win32", + statFile: (candidate) => candidate === "C:\\bin\\node.exe" || candidate === "C:\\bin\\copilot-entry.js", + }); + assert.equal(requiredArtifactReady.state, "ready", "a complete direct launch plan is ready"); + // Verification matrix: binary absent from every discovery location → // missing, with per-runner install/PATH remediation. for (const runner of ["coven", "copilot", "grok", "hermes", "opencode"] as const) { diff --git a/src/lib/runtime-availability.ts b/src/lib/runtime-availability.ts index e09c789d4..3de3f16f6 100644 --- a/src/lib/runtime-availability.ts +++ b/src/lib/runtime-availability.ts @@ -77,12 +77,21 @@ export function summarizeRuntimeAvailability( * uses richer candidate inspection so POSIX execute permissions are checked. */ export type StatFileFn = (candidate: string) => boolean; +/** True when a fixed, non-executable argv artifact is readable by the child. + * A Windows npm shim becomes `node `, so the entry must remain + * readable in addition to the Node host being launchable. */ +export type ReadableFileFn = (candidate: string) => boolean; + export type RuntimeAvailabilityProbe = { runner: DirectRunnerId; /** The exact executable that will be passed to `spawn()`. */ command: string; /** The exact environment object the spawn will receive. */ env: Record; + /** Files the exact argv-list launch requires in addition to `command`. + * For example, a safe Windows npm shim launch is `node ` and the + * entry script must still exist when the child is spawned. */ + requiredFiles?: string[]; /** Coven/Grok launch resolution found a Windows shim it could not safely * convert into a runnable command (`CovenLaunchCommand.unresolvedWindowsShim`). */ unresolvedWindowsShim?: boolean; @@ -91,6 +100,7 @@ export type RuntimeAvailabilityProbe = { powerShellHostedCommand?: string; platform?: NodeJS.Platform; statFile?: StatFileFn; + readableFile?: ReadableFileFn; }; // The missing-runner remediation copy is shared with the post-spawn ENOENT @@ -187,6 +197,20 @@ function inspectWithStatFile(candidate: string, statFile: StatFileFn): Candidate return statFile(candidate) ? "launchable" : "missing"; } +function defaultReadableFile(candidate: string): boolean { + try { + if (!statSync(candidate).isFile()) return false; + accessSync(candidate, constants.R_OK); + return true; + } catch (error) { + const code = (error as NodeJS.ErrnoException)?.code; + if (code === "ENOENT" || code === "ENOTDIR" || code === "EACCES" || code === "EPERM") { + return false; + } + throw error; + } +} + function pathEntries(env: Record, platform: NodeJS.Platform): string[] { const raw = env.PATH ?? env.Path ?? env.path ?? ""; const delimiter = platform === "win32" ? ";" : ":"; @@ -307,6 +331,8 @@ export function evaluateRuntimeAvailability( const inspectCandidate: InspectCandidateFn = probe.statFile ? (candidate) => inspectWithStatFile(candidate, probe.statFile!) : (candidate) => defaultInspectCandidate(candidate, platform); + const readableFile = probe.readableFile + ?? (probe.statFile ? probe.statFile : defaultReadableFile); const label = RUNNER_LABELS[runner]; try { if (probe.unresolvedWindowsShim) { @@ -347,6 +373,15 @@ export function evaluateRuntimeAvailability( } return notReady(runner, "missing", missingRunnerMessage(runner)); } + for (const requiredFile of probe.requiredFiles ?? []) { + if (!readableFile(requiredFile)) { + return notReady( + runner, + "unlaunchable", + `${label} has a resolved launch command, but a required launch artifact is unavailable. Reinstall it, then try again.`, + ); + } + } if (probe.powerShellHostedCommand !== undefined) { const inner = resolveCommand( probe.powerShellHostedCommand, diff --git a/src/lib/server/copilot-runtime-launch.ts b/src/lib/server/copilot-runtime-launch.ts index 850d431bd..abe68df30 100644 --- a/src/lib/server/copilot-runtime-launch.ts +++ b/src/lib/server/copilot-runtime-launch.ts @@ -19,6 +19,8 @@ export type CopilotRuntimeLaunch = { /** Exact command and fixed argv prefix selected by launcher resolution. */ command: string; fixedArgs: string[]; + /** Fixed argv artifacts required by the selected launch plan. */ + requiredFiles: string[]; /** Absolute deadline shared by env, launcher, and identity discovery. */ deadline: number; availability: RuntimeAvailability; @@ -60,11 +62,13 @@ function failedPlan(input: { reason: "timeout" | "failed"; command?: string; fixedArgs?: string[]; + requiredFiles?: string[]; }): CopilotRuntimeLaunch { return { env: input.env, command: input.command ?? input.executable, fixedArgs: input.fixedArgs ?? [], + requiredFiles: input.requiredFiles ?? [], deadline: input.deadline, availability: copilotLaunchProbeFailureAvailability(input.reason), ...(input.reason === "timeout" ? { resolutionTimedOut: true as const } : {}), @@ -144,6 +148,7 @@ export async function resolveCopilotRuntimeLaunch( reason: "timeout", command: launch.command, fixedArgs: launch.fixedArgs, + requiredFiles: launch.requiredFiles, }); } @@ -153,6 +158,7 @@ export async function resolveCopilotRuntimeLaunch( runner: "copilot", command: launch.command, env, + requiredFiles: launch.requiredFiles, unresolvedWindowsShim: launch.unresolvedWindowsShim === true, platform: options.platform, }); @@ -164,12 +170,14 @@ export async function resolveCopilotRuntimeLaunch( reason: "timeout", command: launch.command, fixedArgs: launch.fixedArgs, + requiredFiles: launch.requiredFiles, }); } return { env, command: launch.command, fixedArgs: launch.fixedArgs, + requiredFiles: launch.requiredFiles, deadline: discoveryDeadline, availability, ...(launch.unresolvedWindowsShim From 4956bd9c2e39c048e25c07286745bcc7c22e4cd1 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sun, 26 Jul 2026 12:14:31 -0400 Subject: [PATCH 49/61] fix(chat): fail closed on invalid Copilot JSONL plans --- src/app/api/chat/send/copilot-routing.ts | 54 ++++++++++++++++--- .../harness-routing-copilot-jsonl.test.ts | 22 +++++--- ...e-runtime-availability.integration.test.ts | 22 ++++++++ src/app/api/chat/send/route.ts | 23 ++++---- src/lib/server/copilot-runtime-launch.test.ts | 5 ++ src/lib/server/copilot-runtime-launch.ts | 8 +-- 6 files changed, 103 insertions(+), 31 deletions(-) diff --git a/src/app/api/chat/send/copilot-routing.ts b/src/app/api/chat/send/copilot-routing.ts index 09e3dcfbf..5add55a48 100644 --- a/src/app/api/chat/send/copilot-routing.ts +++ b/src/app/api/chat/send/copilot-routing.ts @@ -1,8 +1,13 @@ import { + copilotDirectStreamConfigured, copilotStreamSpec, type CopilotStreamSpec, } from "../../../../lib/copilot-stream.ts"; -import type { RuntimeAvailability } from "../../../../lib/runtime-availability.ts"; +import { + RUNTIME_AVAILABILITY_ERROR_CODES, + type RuntimeAvailability, + type RuntimeAvailabilityErrorCode, +} from "../../../../lib/runtime-availability.ts"; import { copilotCapabilityFailureMessage, type CopilotCapabilityDiagnostic, @@ -10,7 +15,25 @@ import { export type CopilotChatRouting = | { mode: "direct-jsonl"; spec: CopilotStreamSpec; compatibilityDiagnostic: null } - | { mode: "plain"; spec: null; compatibilityDiagnostic: string | null }; + | { mode: "plain"; spec: null; compatibilityDiagnostic: null } + | { + mode: "blocked"; + spec: null; + compatibilityDiagnostic: string; + failure: Extract }>; + }; + +function blockedFailure( + state: "probe_failed" | "unsupported_runtime", + message: string, +): Extract }> { + return { + state, + runner: "copilot", + code: RUNTIME_AVAILABILITY_ERROR_CODES[state] as RuntimeAvailabilityErrorCode, + message, + }; +} /** * Keep the direct JSONL launch behind an explicit, testable capability gate. @@ -33,17 +56,36 @@ export function resolveCopilotChatRouting(input: { const spec = copilotStreamSpec(input.capabilityVersion, input.eventProtocols, input.launchCommand); if (spec) return { mode: "direct-jsonl", spec, compatibilityDiagnostic: null }; + // A registry that has not explicitly selected direct JSONL keeps the + // established Coven transport. Once it does, changing transports after a + // failed capability/configuration check could run a different launcher than + // the one just preflighted, so fail closed with runner-specific state. + if (!copilotDirectStreamConfigured()) { + return { mode: "plain", spec: null, compatibilityDiagnostic: null }; + } + + if (input.availability && input.availability.state !== "ready") { + return { + mode: "blocked", + spec: null, + compatibilityDiagnostic: input.availability.message, + failure: input.availability, + }; + } + const capabilityFailure = copilotCapabilityFailureMessage({ version: input.capabilityVersion, diagnostic: input.capabilityDiagnostic, availability: input.availability, }); + const probeTimedOut = input.capabilityDiagnostic === "probe-timeout"; + const message = capabilityFailure ?? + "This Copilot CLI or JSONL stream configuration is not compatible with Cave chat. Update the Copilot runtime schema or CLI, then try again."; return { - mode: "plain", + mode: "blocked", spec: null, - compatibilityDiagnostic: - capabilityFailure ?? - "This Copilot CLI version is not yet compatible with Cave tool activity. Chat continues without live tool details; update the Copilot runtime schema or CLI.", + compatibilityDiagnostic: message, + failure: blockedFailure(probeTimedOut ? "probe_failed" : "unsupported_runtime", message), }; } 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 f3826b915..9219d6e70 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 @@ -71,9 +71,10 @@ for (const capabilityVersion of ["1.0.70.1", "0.9.9", "2.0.0", "2.0.0-rc.1"]) { isSshRuntime: false, capabilityVersion, }); - assert.equal(routing.mode, "plain", `${capabilityVersion ?? "unavailable"} must use generic chat`); - assert.equal(routing.spec, null, "the fallback must never direct-spawn the JSONL parser"); - assert.match(routing.compatibilityDiagnostic ?? "", /not yet compatible/); + assert.equal(routing.mode, "blocked", `${capabilityVersion ?? "unavailable"} must not change transports`); + assert.equal(routing.spec, null, "an incompatible client must never direct-spawn the JSONL parser"); + assert.equal(routing.failure.code, "runtime_unsupported"); + assert.match(routing.compatibilityDiagnostic, /compatible/i); } const capabilityCauseMessages = { @@ -96,7 +97,7 @@ for (const [diagnostic, expected] of Object.entries(capabilityCauseMessages)) { resolvedPath: "/must-not-reach-wire", }, }); - assert.equal(routing.mode, "plain"); + assert.equal(routing.mode, "blocked"); assert.equal( routing.compatibilityDiagnostic, expected, @@ -123,6 +124,12 @@ assert.equal( missingAvailabilityMessage, "runtime availability cause wins before generic version diagnostics", ); +assert.equal( + missingRouting.mode, + "blocked", + "a missing Copilot launch plan never falls back to generic Coven", +); +assert.equal(missingRouting.failure.code, "runtime_missing"); assert.doesNotMatch( missingRouting.compatibilityDiagnostic ?? "", /must-not-reach-wire|resolvedPath|PATH=/, @@ -180,8 +187,9 @@ const preparedFallback = await prepareCopilotChatRouting({ }), resolveCompatibility: async () => ({ eventProtocols: [] }), }); -assert.equal(preparedFallback.mode, "plain", "an unsupported mocked runtime retains generic plain chat"); -assert.match(preparedFallback.compatibilityDiagnostic ?? "", /not yet compatible/); +assert.equal(preparedFallback.mode, "blocked", "an unsupported local runtime fails before generic Coven routing"); +assert.equal(preparedFallback.failure.code, "runtime_unsupported"); +assert.match(preparedFallback.compatibilityDiagnostic, /compatible/i); assert.notEqual( preparedFallback.compatibilityDiagnostic, capabilityCauseMessages["version-unavailable"], @@ -279,7 +287,7 @@ assert.match( assert.match( chatRoute, - /let localRuntimePlan: LocalRuntimePlan \| null = null;[\s\S]*?runner: "copilot"[\s\S]*?else if \(openCodeDirect\)[\s\S]*?runner: "opencode"[\s\S]*?else if \(grokDirect\)[\s\S]*?runner: "grok"[\s\S]*?else if \(hermesDirect\)[\s\S]*?runner: "hermes"[\s\S]*?runner: "coven"[\s\S]*?const command = openCodeLaunchCommand[\s\S]*?command: localPlan\.command,[\s\S]*?args: \[\.\.\.localPlan\.fixedArgs, \.\.\.spawnArgs\]/, + /let localRuntimePlan: LocalRuntimePlan \| null = null;[\s\S]*?runner: "copilot"[\s\S]*?copilotRouting\.mode === "blocked"[\s\S]*?else if \(openCodeDirect\)[\s\S]*?runner: "opencode"[\s\S]*?else if \(grokDirect\)[\s\S]*?runner: "grok"[\s\S]*?runner: "coven"[\s\S]*?requiredFiles: localPlan\.requiredFiles[\s\S]*?shell: false/, "Copilot, Grok Build, Hermes, and OpenCode direct turns spawn their own CLI; other local harnesses spawn coven", ); assert.match( diff --git a/src/app/api/chat/send/route-runtime-availability.integration.test.ts b/src/app/api/chat/send/route-runtime-availability.integration.test.ts index fb95ad993..198b5b5f1 100644 --- a/src/app/api/chat/send/route-runtime-availability.integration.test.ts +++ b/src/app/api/chat/send/route-runtime-availability.integration.test.ts @@ -36,6 +36,7 @@ const previousHome = process.env.COVEN_HOME; const previousCaveHome = process.env.COVEN_CAVE_HOME; const previousCovenBin = process.env.COVEN_BIN; const previousGrokBin = process.env.GROK_BIN; +const previousPath = process.env.PATH; process.env.COVEN_HOME = home; process.env.COVEN_CAVE_HOME = path.join(home, "cave"); process.env.GROK_BIN = pinnedGrok; @@ -259,6 +260,25 @@ try { "post-spawn diagnostics must never expose the runner path", ); } + // A direct Copilot configuration never falls through to generic Coven when + // the exact local CLI plan is absent. + { + const { clearCopilotCapabilityProbeCache } = await import("@/lib/server/copilot-capability-probe"); + process.env.PATH = ""; + refreshCovenBin(); + clearCopilotCapabilityProbeCache(); + await saveConfig({ familiars: { opal: { harness: "copilot" } } }); + const response = await POST(new Request("http://localhost/api/chat/send", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ familiarId: "opal", prompt: "copilot preflight", projectRoot: familiarWorkspace }), + })); + const { body, events } = await readSse(response); + const error = events.find((event) => event.kind === "error"); + assert.equal(error?.code, "runtime_missing"); + assert.match(String(error?.message), /copilot CLI not found on PATH/i); + assertNoFabricatedAssistantResponse(body, events); + } } finally { process.env.COVEN_HOME = previousHome; process.env.COVEN_CAVE_HOME = previousCaveHome; @@ -266,6 +286,8 @@ try { else process.env.COVEN_BIN = previousCovenBin; if (previousGrokBin === undefined) delete process.env.GROK_BIN; else process.env.GROK_BIN = previousGrokBin; + if (previousPath === undefined) delete process.env.PATH; + else process.env.PATH = previousPath; 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 10e42cad3..8d24d1d01 100644 --- a/src/app/api/chat/send/route.ts +++ b/src/app/api/chat/send/route.ts @@ -1114,13 +1114,16 @@ export async function POST(req: Request) { resolveCompatibility: () => resolveRuntimeCompatibility("copilot"), }); const copilotStream = copilotRouting.spec; - const copilotCompatibilityDiagnostic = copilotRouting.compatibilityDiagnostic; let localRuntimePlan: LocalRuntimePlan | null = null; if (!sshRuntime && binding.harness !== "openclaw" && !(hermesDirect && hermesApi)) { if ( copilotRuntimeLaunch && - (copilotRuntimeLaunch.availability.state !== "ready" || copilotStream) + ( + copilotRuntimeLaunch.availability.state !== "ready" + || copilotStream + || copilotRouting.mode === "blocked" + ) ) { localRuntimePlan = createLocalRuntimePlan({ runner: "copilot", @@ -1129,7 +1132,9 @@ export async function POST(req: Request) { body.familiarId, copilotRuntimeLaunch.env, ), - availability: copilotRuntimeLaunch.availability, + availability: copilotRouting.mode === "blocked" + ? copilotRouting.failure + : copilotRuntimeLaunch.availability, }); } else if (openCodeDirect) { const env = openCodeSpawnEnv(body.familiarId); @@ -1874,17 +1879,6 @@ export async function POST(req: Request) { }; push({ kind: "user", text: promptText }); - if ( - copilotCompatibilityDiagnostic && - copilotRuntimeLaunch?.availability.state === "ready" - ) { - pushProgress( - "copilot-client-compatibility", - "Copilot tool activity needs an update", - "error", - copilotCompatibilityDiagnostic, - ); - } if (hermesDirect && !hermesApi) { // Do not fabricate tool bubbles from the CLI's presentation layer. // This gives the operator an actionable, privacy-safe degradation @@ -3201,6 +3195,7 @@ export async function POST(req: Request) { ? ["ignore", "pipe", "pipe"] : ["pipe", "pipe", "pipe"], env: localPlan.env, + shell: false, }) as ChildProcessWithoutNullStreams; if (openCodeLaunchCommand) { writeOpenCodeLaunchInput(child, openCodeLaunchCommand); diff --git a/src/lib/server/copilot-runtime-launch.test.ts b/src/lib/server/copilot-runtime-launch.test.ts index 404ae2387..6d68347b2 100644 --- a/src/lib/server/copilot-runtime-launch.test.ts +++ b/src/lib/server/copilot-runtime-launch.test.ts @@ -169,6 +169,11 @@ try { [shimEntry], "a Windows npm shim keeps the exact transformed fixed args", ); + assert.deepEqual( + windowsPlan.requiredFiles, + [shimEntry], + "a converted Windows npm shim preflights its fixed JavaScript entry", + ); assert.equal( evaluatedWindowsCommand, windowsPlan.command, diff --git a/src/lib/server/copilot-runtime-launch.ts b/src/lib/server/copilot-runtime-launch.ts index abe68df30..76aa39720 100644 --- a/src/lib/server/copilot-runtime-launch.ts +++ b/src/lib/server/copilot-runtime-launch.ts @@ -148,7 +148,7 @@ export async function resolveCopilotRuntimeLaunch( reason: "timeout", command: launch.command, fixedArgs: launch.fixedArgs, - requiredFiles: launch.requiredFiles, + requiredFiles: launch.requiredFiles ?? [], }); } @@ -158,7 +158,7 @@ export async function resolveCopilotRuntimeLaunch( runner: "copilot", command: launch.command, env, - requiredFiles: launch.requiredFiles, + requiredFiles: launch.requiredFiles ?? [], unresolvedWindowsShim: launch.unresolvedWindowsShim === true, platform: options.platform, }); @@ -170,14 +170,14 @@ export async function resolveCopilotRuntimeLaunch( reason: "timeout", command: launch.command, fixedArgs: launch.fixedArgs, - requiredFiles: launch.requiredFiles, + requiredFiles: launch.requiredFiles ?? [], }); } return { env, command: launch.command, fixedArgs: launch.fixedArgs, - requiredFiles: launch.requiredFiles, + requiredFiles: launch.requiredFiles ?? [], deadline: discoveryDeadline, availability, ...(launch.unresolvedWindowsShim From 5bdb32b311b62e6b354bc79c2d5eb9cd9f018b21 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sun, 26 Jul 2026 12:14:36 -0400 Subject: [PATCH 50/61] fix(ui): show runtime preflight diagnostics --- src/components/chat-familiar-capabilities.tsx | 7 ++++++- src/components/familiar-studio-brain-tab.test.ts | 5 +++++ src/components/familiar-studio-brain-tab.tsx | 8 +++++++- src/components/familiar-summoning-circle.test.ts | 5 +++++ src/components/familiar-summoning-circle.tsx | 9 +++++++-- src/components/familiar-summoning-model.ts | 4 ++++ 6 files changed, 34 insertions(+), 4 deletions(-) diff --git a/src/components/chat-familiar-capabilities.tsx b/src/components/chat-familiar-capabilities.tsx index 02325757e..c9529a342 100644 --- a/src/components/chat-familiar-capabilities.tsx +++ b/src/components/chat-familiar-capabilities.tsx @@ -106,7 +106,12 @@ function FamiliarIdentityHero({ .map((h) => ({ value: h.id, label: h.label, - detail: [h.version, h.installed ? null : "not installed"].filter(Boolean).join(" · ") || undefined, + detail: [ + h.version, + h.availability && h.availability.state !== "ready" + ? h.availability.message + : h.installed ? null : "not installed", + ].filter(Boolean).join(" · ") || undefined, })), ]; diff --git a/src/components/familiar-studio-brain-tab.test.ts b/src/components/familiar-studio-brain-tab.test.ts index ca4f7ed96..429b5271b 100644 --- a/src/components/familiar-studio-brain-tab.test.ts +++ b/src/components/familiar-studio-brain-tab.test.ts @@ -18,6 +18,11 @@ assert.match( /catalogForRuntime/, "Brain tab model menu should source options from the runtime → provider catalog", ); +assert.match( + source, + /h\.availability && h\.availability\.state !== "ready"[\s\S]*?h\.availability\.message/, + "Runtime picker should reuse server-provided launchability remediation instead of treating an installed but unlaunchable CLI as ready", +); assert.match( source, /modelOptions\.map/, diff --git a/src/components/familiar-studio-brain-tab.tsx b/src/components/familiar-studio-brain-tab.tsx index 7a0292a71..743350220 100644 --- a/src/components/familiar-studio-brain-tab.tsx +++ b/src/components/familiar-studio-brain-tab.tsx @@ -71,6 +71,10 @@ type HarnessReport = { id: string; label: string; installed: boolean; + availability?: { + state: "ready" | "missing" | "unlaunchable" | "probe_failed" | "unsupported_runtime"; + message?: string; + }; models?: RuntimeModelOption[]; defaultModel?: string | null; }; @@ -840,7 +844,9 @@ export function FamiliarStudioBrainTab({ familiar }: Props) { .filter((h) => isBindableRuntimeChoice(h.id)) .map((h) => ({ value: h.id, - label: `${h.label}${h.installed ? "" : " (not installed)"}`, + label: h.availability && h.availability.state !== "ready" + ? `${h.label} (${h.availability.message ?? "not ready"})` + : `${h.label}${h.installed ? "" : " (not installed)"}`, })), } satisfies StandardSelectGroup, ]} diff --git a/src/components/familiar-summoning-circle.test.ts b/src/components/familiar-summoning-circle.test.ts index 7b88cff93..cb718831a 100644 --- a/src/components/familiar-summoning-circle.test.ts +++ b/src/components/familiar-summoning-circle.test.ts @@ -28,6 +28,11 @@ assert.match( /fetch\("\/api\/familiars",\s*\{[\s\S]*?method:\s*"POST"/, "the circle should POST to /api/familiars", ); +assert.match( + source, + /vessel !== "local" \|\| h\.availability\?\.state === undefined \|\| h\.availability\.state === "ready"/, + "Local runtime choices must exclude installed runners the shared preflight says are not launchable", +); assert.doesNotMatch( source, /onboarding\/setup/, diff --git a/src/components/familiar-summoning-circle.tsx b/src/components/familiar-summoning-circle.tsx index 9f75b021c..92a983d4b 100644 --- a/src/components/familiar-summoning-circle.tsx +++ b/src/components/familiar-summoning-circle.tsx @@ -772,8 +772,13 @@ function StageVessel({ // launcher. Hide it for SSH rather than letting a selection fall back to an // incompatible `coven run --stream-json` path. const installedHarnesses = (harnesses ?? []).filter( - (h) => h.installed && (vessel !== "ssh" || h.id !== "grok"), + (h) => h.installed + && (vessel !== "local" || h.availability?.state === undefined || h.availability.state === "ready") + && (vessel !== "ssh" || h.id !== "grok"), ); + const unavailableLocalHarness = vessel === "local" + ? (harnesses ?? []).find((h) => h.installed && h.availability?.state !== undefined && h.availability.state !== "ready") + : null; return (
@@ -809,7 +814,7 @@ function StageVessel({ // users between the circle and Settings (cave-tpji).

- No chat-capable runtime found. Run setup to install one (Codex, Claude Code, Copilot…), then return to the circle. + {unavailableLocalHarness?.availability?.message ?? "No chat-capable runtime found. Run setup to install one (Codex, Claude Code, Copilot…), then return to the circle."}

+ ) : availabilityProblem ? ( + + unavailable + ) : adapter.installed ? ( installed @@ -2040,10 +2053,15 @@ function StepRuntimes({ ) : null}
- {adapter.installed + {launchable ? (adapter.path ?? adapter.binary) : adapter.binary}
+ {availabilityMessage ? ( +

+ {availabilityMessage} +

+ ) : null} {openClaw ? (
Agents are discovered from{" "} From 7bed3f5d457ec60f9da1304ae8a9574b32eb45d0 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sun, 26 Jul 2026 15:58:54 -0400 Subject: [PATCH 54/61] fix: preserve Grok launch diagnostics after preflight --- src/app/api/api-contracts.test.ts | 2 +- ...e-runtime-availability.integration.test.ts | 39 +++++++- src/app/api/chat/send/route.ts | 95 ++++++++++--------- src/app/api/harnesses/route.test.ts | 7 +- src/app/api/harnesses/route.ts | 29 ++++-- src/lib/runtime-availability.test.ts | 15 +-- src/lib/runtime-availability.ts | 4 +- 7 files changed, 124 insertions(+), 67 deletions(-) diff --git a/src/app/api/api-contracts.test.ts b/src/app/api/api-contracts.test.ts index c4e10e577..4fb3c0aba 100644 --- a/src/app/api/api-contracts.test.ts +++ b/src/app/api/api-contracts.test.ts @@ -462,7 +462,7 @@ for (const contract of contracts) { ); assert.match( sendSource, - /launchFailure \?\?= \{[\s\S]{0,180}?message: launchError,[\s\S]{0,400}?pushProgress\([\s\S]{0,220}?launchError,[\s\S]{0,300}?code: "ENOENT",\s*message: launchError/, + /const reportLaunchFailure = \(err: NodeJS\.ErrnoException\) => \{[\s\S]{0,500}?launchFailure \?\?= \{[\s\S]{0,180}?message: launchError,[\s\S]{0,400}?pushProgress\([\s\S]{0,220}?launchError,[\s\S]{0,300}?code: localLaunchError\.code/, "/chat/send: launch state, progress, and the post-spawn ENOENT race event must reuse one normalized message", ); assert.match( diff --git a/src/app/api/chat/send/route-runtime-availability.integration.test.ts b/src/app/api/chat/send/route-runtime-availability.integration.test.ts index fb95ad993..36950332e 100644 --- a/src/app/api/chat/send/route-runtime-availability.integration.test.ts +++ b/src/app/api/chat/send/route-runtime-availability.integration.test.ts @@ -1,7 +1,9 @@ // @ts-nocheck import assert from "node:assert/strict"; import { createHook } from "node:async_hooks"; +import childProcess from "node:child_process"; import { chmod, mkdtemp, mkdir, rm, unlink, writeFile } from "node:fs/promises"; +import { syncBuiltinESMExports } from "node:module"; import { homedir } from "node:os"; import path from "node:path"; @@ -233,8 +235,8 @@ try { } else { assert.equal( error.code, - "ENOENT", - "the missing shebang interpreter reaches the post-spawn ENOENT handler", + "runtime_missing", + "the missing shebang interpreter stays in the structured missing-runtime contract", ); assert.equal( error.message, @@ -258,6 +260,39 @@ try { new RegExp(home.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")), "post-spawn diagnostics must never expose the runner path", ); + + const done = events.findLast((event) => event.kind === "done"); + assert.equal(done?.isError, true, "the post-spawn ENOENT completes the stream as an error"); + } + + // Scenario 3 — Windows can throw synchronously for an invalid executable + // instead of returning a child that emits error. It must use the same + // structured, no-fabricated-response completion as an async launch race. + { + await writeFile(pinnedGrok, "present for synchronous spawn\n", { mode: 0o755 }); + if (process.platform !== "win32") await chmod(pinnedGrok, 0o755); + const originalSpawn = childProcess.spawn; + childProcess.spawn = (() => { + throw Object.assign(new Error("synthetic synchronous spawn failure"), { code: "UNKNOWN" }); + }) as typeof childProcess.spawn; + syncBuiltinESMExports(); + 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 synchronous failure", projectRoot: familiarWorkspace }), + })); + const { body, events } = await readSse(response); + const error = events.find((event) => event.kind === "error"); + assert.equal(error?.code, "runtime_launch_failed"); + assert.equal(error?.message, runtimeLaunchFailedMessage("grok")); + assertNoFabricatedAssistantResponse(body, events); + const done = events.findLast((event) => event.kind === "done"); + assert.equal(done?.isError, true, "a synchronous spawn failure completes the stream as an error"); + } finally { + childProcess.spawn = originalSpawn; + syncBuiltinESMExports(); + } } } finally { process.env.COVEN_HOME = previousHome; diff --git a/src/app/api/chat/send/route.ts b/src/app/api/chat/send/route.ts index 56876a479..460b6bf68 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 { spawn, type ChildProcessByStdio, type ChildProcessWithoutNullStreams } from "node:child_process"; import { homedir } from "node:os"; +import type { Readable } from "node:stream"; import { StringDecoder } from "node:string_decoder"; import { resolveBackspaces, stripAnsi } from "@/lib/ansi"; import { @@ -3122,7 +3123,40 @@ export async function POST(req: Request) { // location detail. : openCodeDirect ? undefined : cwd, ); - const child = sshRuntime + const reportLaunchFailure = (err: NodeJS.ErrnoException) => { + const localLaunchError = localRuntimeLaunchError( + localRuntimePlan?.runner ?? "coven", + err.code, + ); + const launchError = sshRuntime + ? err.code === "ENOENT" + ? "ssh CLI not found on PATH. Install OpenSSH or run this familiar locally." + : err.message + : localLaunchError.message; + result.is_error = true; + launchFailure ??= { + code: sshRuntime ? err.code ?? "runtime_launch_failed" : localLaunchError.code, + message: launchError, + }; + pushProgress( + "harness-start", + `${binding.harness} failed to start`, + "error", + launchError, + Date.now() - attemptStartedAt, + ); + push({ + kind: "error", + ...(sshRuntime ? (err.code ? { code: err.code } : {}) : { code: localLaunchError.code }), + message: launchError, + }); + }; + let child: + | ChildProcessWithoutNullStreams + | ChildProcessByStdio + | null = null; + try { + child = sshRuntime ? (() => { const sshArgs = spawnArgs; return spawn("ssh", sshArgs, { @@ -3212,6 +3246,13 @@ export async function POST(req: Request) { } return child; })(); + } catch (error) { + // A plan can pass passive preflight yet still throw synchronously + // during spawn (notably invalid Windows executables). + reportLaunchFailure(error as NodeJS.ErrnoException); + resolve(); + return; + } if (!child) { resolve(); @@ -3219,6 +3260,7 @@ export async function POST(req: Request) { } currentChild = child; + let childLaunchFailed = false; const onAbort = () => { // Transport drop, not Stop — arm the detach cap and let the turn // finish. Deliberate stops kill through the registry instead. @@ -3311,53 +3353,18 @@ export async function POST(req: Request) { }); child.on("error", (err: NodeJS.ErrnoException) => { - // Local OS launch errors can include an absolute command, - // interpreter, or workspace path. Normalize them once and reuse - // the exact value-free message in state, progress, and SSE. - const localLaunchError = localRuntimeLaunchError( - localRuntimePlan?.runner ?? "coven", - err.code, - ); - const launchError = sshRuntime - ? err.code === "ENOENT" - ? "ssh CLI not found on PATH. Install OpenSSH or run this familiar locally." - : err.message - : localLaunchError.message; - // Race-safe fallback (#3856): the pre-spawn gate can pass and the - // binary still vanish before spawn. Mark the run errored BEFORE - // the empty-output diagnostic can run, so a launch failure is - // never misreported as "installed but not authenticated". - result.is_error = true; - launchFailure ??= { - code: localLaunchError.code, - message: launchError, - }; - pushProgress( - "harness-start", - `${binding.harness} failed to start`, - "error", - launchError, - Date.now() - attemptStartedAt, - ); - if (err.code === "ENOENT") { - push({ - kind: "error", - code: "ENOENT", - message: launchError, - }); - } else { - push({ - kind: "error", - ...(sshRuntime ? {} : { code: localLaunchError.code }), - message: launchError, - }); - } + childLaunchFailed = true; + reportLaunchFailure(err); req.signal.removeEventListener("abort", onAbort); resolve(); - close(); }); child.on("close", (code) => { + if (childLaunchFailed) { + req.signal.removeEventListener("abort", onAbort); + resolve(); + return; + } const trailingOpenCodeText = openCodeStdoutDecoder?.end(); if (trailingOpenCodeText) handleStdoutChunk(trailingOpenCodeText); const trailingStdoutText = openCodeStdoutDecoder ? "" : stdoutDecoder.end(); diff --git a/src/app/api/harnesses/route.test.ts b/src/app/api/harnesses/route.test.ts index 5ae99552e..71272225f 100644 --- a/src/app/api/harnesses/route.test.ts +++ b/src/app/api/harnesses/route.test.ts @@ -20,6 +20,11 @@ assert.match( /grokLaunchCommandForBinary\(path\)/, "Grok probes must run npm .cmd shims through their spawn-safe launch command", ); +assert.match( + source, + /const grokProbeEnv = h\.id === "grok" \? runtime\.spawnEnv : undefined;[\s\S]*?const grokReady = h\.id === "grok" && availability\.state === "ready";[\s\S]*?const readyGrokLaunch = grokReady \? grokLaunch : null;[\s\S]*?const version = h\.id === "grok" && !grokReady[\s\S]*?copilotLaunch\?\.env \?\? grokProbeEnv,[\s\S]*?const grokCatalog = readyGrokLaunch \? await probeGrokModels\(readyGrokLaunch, grokProbeEnv\) : null;/, + "Grok's version and authenticated catalog probes must only run after the shared launchability contract reports ready in the same scoped environment", +); assert.match( source, /const resolvedBinary = h\.id === "grok" \? grokBin\(\) : h\.binary;[\s\S]*?h\.id === "grok" && resolvedBinary !== h\.binary[\s\S]*?: await which\(h\.binary\)/, @@ -37,7 +42,7 @@ assert.match( ); assert.match( source, - /const version = await probeVersion\([\s\S]*?copilotLaunch\?\.command[\s\S]*?copilotLaunch\?\.fixedArgs[\s\S]*?copilotLaunch\?\.env/, + /const version = h\.id === "grok" && !grokReady[\s\S]*?copilotLaunch\?\.command[\s\S]*?copilotLaunch\?\.fixedArgs[\s\S]*?copilotLaunch\?\.env/, "Copilot version discovery uses the exact resolved command, fixed arguments, and credential-free environment", ); assert.match( diff --git a/src/app/api/harnesses/route.ts b/src/app/api/harnesses/route.ts index 364b70ac4..7edd26092 100644 --- a/src/app/api/harnesses/route.ts +++ b/src/app/api/harnesses/route.ts @@ -59,6 +59,8 @@ type AdapterAvailability = { availability: RuntimeAvailabilitySummary; /** Internal-only exact Copilot plan; never serialized inside availability. */ copilotLaunch?: CopilotRuntimeLaunch; + /** Internal-only environment used for a direct runner's availability check. */ + spawnEnv?: NodeJS.ProcessEnv; }; // Mirrors the send route's launch dispatch: copilot/grok/hermes/opencode use @@ -99,6 +101,7 @@ async function adapterAvailability(id: string): Promise { env, unresolvedWindowsShim: launch.unresolvedWindowsShim === true, })), + spawnEnv: env, }; } if (id === "hermes") { @@ -184,11 +187,12 @@ function probeVersion( function probeGrokModels( launch: CovenLaunchCommand, + env: NodeJS.ProcessEnv = covenSpawnEnv(), ): Promise<{ models: RuntimeModelOption[]; defaultModel: string | null }> { return new Promise((resolve) => { let child; try { - child = spawn(launch.command, [...launch.fixedArgs, "--no-auto-update", "models"], { env: covenSpawnEnv(), stdio: ["ignore", "pipe", "pipe"] }); + child = spawn(launch.command, [...launch.fixedArgs, "--no-auto-update", "models"], { env, stdio: ["ignore", "pipe", "pipe"] }); } catch { resolve({ models: [], defaultModel: null }); return; @@ -300,15 +304,20 @@ export async function GET() { return { ...h, installed: false, path: null, version: null, availability }; } const grokLaunch = h.id === "grok" ? grokLaunchCommandForBinary(path) : null; - const version = await probeVersion( - copilotLaunch?.command ?? grokLaunch?.command ?? h.binary, - copilotLaunch - ? [COPILOT_NO_AUTO_UPDATE_ARG, ...(h.versionArgs ?? ["--version"])] - : h.versionArgs ?? ["--version"], - copilotLaunch?.fixedArgs ?? grokLaunch?.fixedArgs, - copilotLaunch?.env, - ); - const grokCatalog = grokLaunch ? await probeGrokModels(grokLaunch) : null; + const grokProbeEnv = h.id === "grok" ? runtime.spawnEnv : undefined; + const grokReady = h.id === "grok" && availability.state === "ready"; + const readyGrokLaunch = grokReady ? grokLaunch : null; + const version = h.id === "grok" && !grokReady + ? null + : await probeVersion( + copilotLaunch?.command ?? readyGrokLaunch?.command ?? h.binary, + copilotLaunch + ? [COPILOT_NO_AUTO_UPDATE_ARG, ...(h.versionArgs ?? ["--version"])] + : h.versionArgs ?? ["--version"], + copilotLaunch?.fixedArgs ?? readyGrokLaunch?.fixedArgs, + copilotLaunch?.env ?? grokProbeEnv, + ); + const grokCatalog = readyGrokLaunch ? await probeGrokModels(readyGrokLaunch, grokProbeEnv) : null; return { ...h, installed: true, diff --git a/src/lib/runtime-availability.test.ts b/src/lib/runtime-availability.test.ts index cf6e88727..e2bec8d87 100644 --- a/src/lib/runtime-availability.test.ts +++ b/src/lib/runtime-availability.test.ts @@ -24,7 +24,7 @@ try { assert.deepEqual( localRuntimeLaunchError("grok", "ENOENT"), { - code: "ENOENT", + code: "runtime_missing", message: missingRunnerMessage("grok"), }, "a post-spawn missing-interpreter race retains the missing-runner contract", @@ -42,7 +42,8 @@ try { const emptyDir = path.join(scratch, "empty"); mkdirSync(binDir); mkdirSync(emptyDir); - const executable = path.join(binDir, "grok"); + const grokFilename = process.platform === "win32" ? "grok.exe" : "grok"; + const executable = path.join(binDir, grokFilename); writeFileSync(executable, "#!/bin/sh\n", { mode: 0o755 }); chmodSync(executable, 0o755); @@ -50,13 +51,13 @@ try { const ready = evaluateRuntimeAvailability({ runner: "grok", command: "grok", - env: { PATH: `${emptyDir}:${binDir}` }, - platform: "linux", + env: { PATH: `${emptyDir}${path.delimiter}${binDir}` }, + platform: process.platform, }); assert.equal(ready.state, "ready", "a bare command on the spawn PATH is ready"); assert.equal( - ready.state === "ready" && ready.resolvedPath, - executable, + ready.state === "ready" && path.win32.resolve(ready.resolvedPath), + path.win32.resolve(executable), "ready reports where the exact spawn command resolved", ); @@ -64,7 +65,7 @@ try { runner: "coven", command: executable, env: { PATH: "" }, - platform: "linux", + platform: process.platform, }); assert.equal( absoluteReady.state, diff --git a/src/lib/runtime-availability.ts b/src/lib/runtime-availability.ts index e09c789d4..ffaa34139 100644 --- a/src/lib/runtime-availability.ts +++ b/src/lib/runtime-availability.ts @@ -136,11 +136,11 @@ export function localRuntimeLaunchError( runner: DirectRunnerId, errorCode: string | undefined, ): { - code: "ENOENT" | "runtime_launch_failed"; + code: "runtime_missing" | "runtime_launch_failed"; message: string; } { return errorCode === "ENOENT" - ? { code: "ENOENT", message: missingRunnerMessage(runner) } + ? { code: "runtime_missing", message: missingRunnerMessage(runner) } : { code: "runtime_launch_failed", message: runtimeLaunchFailedMessage(runner), From 272637be920f61012bc9eae4e07486c8af33c9ee Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sun, 26 Jul 2026 15:59:44 -0400 Subject: [PATCH 55/61] test: wire familiar runtime capability coverage --- scripts/run-tests.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/run-tests.mjs b/scripts/run-tests.mjs index 5f2e7894b..e18d3dfee 100644 --- a/scripts/run-tests.mjs +++ b/scripts/run-tests.mjs @@ -663,6 +663,7 @@ export const SUITES = { "src/components/familiar-glyph-picker-panel.test.ts", "src/components/familiar-glyph-loading.test.ts", "src/components/familiar-studio-brain-tab.test.ts", + "src/components/chat-familiar-capabilities.test.ts", "src/components/journal-redirect.test.ts", "src/components/familiar-studio-identity-tab.test.ts", "src/components/familiar-lifecycle-section.test.ts", From a29bc56128930e9b8f8a248cf0354a9a0cdd9ea7 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sun, 26 Jul 2026 16:02:05 -0400 Subject: [PATCH 56/61] test: skip POSIX launcher fixtures on Windows --- scripts/beads-pr-bridge.test.mjs | 5 +++++ scripts/beads-pr-patrol.test.mjs | 5 +++++ scripts/dev-app-teardown.test.mjs | 9 ++++++++- 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/scripts/beads-pr-bridge.test.mjs b/scripts/beads-pr-bridge.test.mjs index 2463d70bf..16ca60368 100644 --- a/scripts/beads-pr-bridge.test.mjs +++ b/scripts/beads-pr-bridge.test.mjs @@ -4,6 +4,11 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { execFileSync } from "node:child_process"; +if (process.platform === "win32") { + console.log("beads-pr-bridge: skipped on native Windows (the fixture stubs POSIX command executables)"); + process.exit(0); +} + const root = path.resolve(new URL("..", import.meta.url).pathname); const temp = mkdtempSync(path.join(tmpdir(), "beads-pr-bridge-")); const bin = path.join(temp, "bin"); diff --git a/scripts/beads-pr-patrol.test.mjs b/scripts/beads-pr-patrol.test.mjs index 152934ec0..4b1f8e186 100644 --- a/scripts/beads-pr-patrol.test.mjs +++ b/scripts/beads-pr-patrol.test.mjs @@ -8,6 +8,11 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { execFileSync } from "node:child_process"; +if (process.platform === "win32") { + console.log("beads-pr-patrol: skipped on native Windows (the fixture stubs POSIX command executables)"); + process.exit(0); +} + const root = path.resolve(new URL("..", import.meta.url).pathname); const temp = mkdtempSync(path.join(tmpdir(), "beads-pr-patrol-")); const bin = path.join(temp, "bin"); diff --git a/scripts/dev-app-teardown.test.mjs b/scripts/dev-app-teardown.test.mjs index 7b0909256..a3e7232d1 100644 --- a/scripts/dev-app-teardown.test.mjs +++ b/scripts/dev-app-teardown.test.mjs @@ -9,8 +9,15 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; -const scriptsDir = path.dirname(fileURLToPath(import.meta.url)); +// Node maps SIGTERM/SIGINT to forced termination on native Windows, bypassing +// the Bash traps this test is intended to exercise. Keep signal-tree coverage +// on POSIX, where its delivery semantics are real rather than simulated. +if (process.platform === "win32") { + console.log("dev-app-teardown: skipped on native Windows (Bash signal traps are not observable through Node signals)"); + process.exit(0); +} +const scriptsDir = path.dirname(fileURLToPath(import.meta.url)); const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); function probePort(port) { From 590c7d67dde547bd0ba60e9f1de665da34060937 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sun, 26 Jul 2026 16:03:36 -0400 Subject: [PATCH 57/61] test: make Coven path expectations platform-aware --- src/lib/coven-paths.test.ts | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/lib/coven-paths.test.ts b/src/lib/coven-paths.test.ts index 7ddb05535..1cf68882b 100644 --- a/src/lib/coven-paths.test.ts +++ b/src/lib/coven-paths.test.ts @@ -1,6 +1,7 @@ // @ts-nocheck import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; +import { homedir } from "node:os"; import path from "node:path"; import { caveHome, @@ -44,8 +45,8 @@ workspace = '/tmp/coven-builder' # trailing comment id = "observer" `); -assert.equal(workspaces.get("researcher"), path.join(process.env.HOME ?? "", "coven", "researcher")); -assert.equal(workspaces.get("builder"), "/tmp/coven-builder"); +assert.equal(workspaces.get("researcher"), path.join(homedir(), "coven", "researcher")); +assert.equal(workspaces.get("builder"), path.resolve("/tmp/coven-builder")); assert.equal(workspaces.has("observer"), false); try { @@ -56,21 +57,21 @@ try { delete process.env.WORKSPACE_ROOT; delete process.env.NEXT_PUBLIC_WORKSPACE_ROOT; - assert.equal(caveHome(), "/tmp/coven-home/cave", "caveHome defaults to /cave"); + assert.equal(caveHome(), path.join("/tmp/coven-home", "cave"), "caveHome defaults to /cave"); process.env.COVEN_CAVE_HOME = "/tmp/custom-cave"; assert.equal(caveHome(), "/tmp/custom-cave", "COVEN_CAVE_HOME overrides caveHome"); delete process.env.COVEN_CAVE_HOME; - assert.equal(covenWorkspacesRoot(), "/tmp/coven-home/workspaces"); - assert.equal(covenWorkspaceRoot(), "/tmp/coven-home/workspaces"); - assert.equal(familiarWorkspacesRoot(), "/tmp/coven-home/workspaces/familiars"); - assert.equal(await familiarWorkspace("orchestrator"), "/tmp/coven-home/workspaces/familiars/orchestrator"); + assert.equal(covenWorkspacesRoot(), path.join("/tmp/coven-home", "workspaces")); + assert.equal(covenWorkspaceRoot(), path.join("/tmp/coven-home", "workspaces")); + assert.equal(familiarWorkspacesRoot(), path.join("/tmp/coven-home", "workspaces", "familiars")); + assert.equal(await familiarWorkspace("orchestrator"), path.join("/tmp/coven-home", "workspaces", "familiars", "orchestrator")); assert.deepEqual(await familiarIds(), [], "familiarIds only returns declared familiars"); process.env.COVEN_WORKSPACES_ROOT = "/tmp/coven-workspaces"; assert.equal(covenWorkspacesRoot(), "/tmp/coven-workspaces"); assert.equal(covenWorkspaceRoot(), "/tmp/coven-workspaces"); - assert.equal(await familiarWorkspace("helper"), "/tmp/coven-workspaces/familiars/helper"); + assert.equal(await familiarWorkspace("helper"), path.join("/tmp/coven-workspaces", "familiars", "helper")); process.env.COVEN_WORKSPACE_ROOT = "/tmp/explicit-workspace-root"; assert.equal(covenWorkspaceRoot(), "/tmp/explicit-workspace-root"); From 3db3df6d70b5d1b0bd8d87be06187dbe18a870c7 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sun, 26 Jul 2026 16:05:36 -0400 Subject: [PATCH 58/61] test: guard Windows migration symlink recovery --- ...ave-home-migration-backup-recovery.test.ts | 27 +++++++++++++++++-- ...ave-home-migration-status-recovery.test.ts | 27 +++++++++++++++++-- 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/src/lib/server/cave-home-migration-backup-recovery.test.ts b/src/lib/server/cave-home-migration-backup-recovery.test.ts index 341a98f63..4f272ea54 100644 --- a/src/lib/server/cave-home-migration-backup-recovery.test.ts +++ b/src/lib/server/cave-home-migration-backup-recovery.test.ts @@ -41,6 +41,27 @@ async function denySymlink() { throw error; } +// Native Windows requires Developer Mode or elevated privilege for file +// symlinks. Keep the tests that use a real link when the host permits one; +// the explicit `denySymlink` cases below still cover the normal fallback. +const nativeFileSymlinkAvailable = await (async () => { + const root = await mkdtemp(path.join(tmpdir(), "cave-home-symlink-probe-")); + const target = path.join(root, "target"); + const link = path.join(root, "link"); + try { + await writeFile(target, "probe", "utf8"); + await symlink("target", link, "file"); + return true; + } catch (error) { + if (process.platform === "win32" && (error as NodeJS.ErrnoException).code === "EPERM") { + return false; + } + throw error; + } finally { + await rm(root, { recursive: true, force: true }); + } +})(); + const baseState = () => ({ sessionFamiliar: {}, sessionTitles: {}, sessionArchived: {}, sessionSacrificed: {}, sessionKeep: {}, sessionArchiveExtendedUntil: {}, sessionOwned: {}, mergedPrAutoArchived: {}, @@ -69,7 +90,7 @@ try { // Do not treat an arbitrary or broken legacy symlink as a completed // compatibility bridge. Its target may contain the only remaining data. - { + if (nativeFileSymlinkAvailable) { const { coven, cave } = await home("foreign-legacy-symlink"); const foreign = path.join(coven, "foreign-config.json"); await mkdir(cave, { recursive: true }); @@ -82,6 +103,8 @@ try { const result = await migrateCaveHome(); assert.equal(result.errors.some((entry) => entry.legacy === "cave-config.json"), true); assert.deepEqual(await json(foreign), { source: "foreign" }); + } else { + console.log("cave-home-migration: skipped native symlink cases (Windows symlink privilege unavailable)"); } // Legacy-only data moves to canonical storage. On normal Windows, a verified @@ -177,7 +200,7 @@ try { // A symlink can be installed before canonical validation notices a late // invalid write. Remove that attempted link and restore the original legacy // pathname instead of hiding the recoverable bytes in a retired file. - { + if (nativeFileSymlinkAvailable) { const { coven, cave } = await home("canonical-post-link-write"); const legacyPath = path.join(coven, "cave-config.json"); const canonicalPath = path.join(cave, "config.json"); diff --git a/src/lib/server/cave-home-migration-status-recovery.test.ts b/src/lib/server/cave-home-migration-status-recovery.test.ts index 981145b1c..097dacd82 100644 --- a/src/lib/server/cave-home-migration-status-recovery.test.ts +++ b/src/lib/server/cave-home-migration-status-recovery.test.ts @@ -41,6 +41,27 @@ async function denySymlink() { throw error; } +// Native Windows requires Developer Mode or elevated privilege for file +// symlinks. Keep the real-link recovery cases when the host permits them; +// the explicit `denySymlink` cases below still cover the normal fallback. +const nativeFileSymlinkAvailable = await (async () => { + const root = await mkdtemp(path.join(tmpdir(), "cave-home-symlink-probe-")); + const target = path.join(root, "target"); + const link = path.join(root, "link"); + try { + await writeFile(target, "probe", "utf8"); + await symlink("target", link, "file"); + return true; + } catch (error) { + if (process.platform === "win32" && (error as NodeJS.ErrnoException).code === "EPERM") { + return false; + } + throw error; + } finally { + await rm(root, { recursive: true, force: true }); + } +})(); + const baseState = () => ({ sessionFamiliar: {}, sessionTitles: {}, sessionArchived: {}, sessionSacrificed: {}, sessionKeep: {}, sessionArchiveExtendedUntil: {}, sessionOwned: {}, mergedPrAutoArchived: {}, @@ -229,7 +250,7 @@ try { // The status surface offers Recover legacy when canonical storage is an // invalid symlink. Retire that exact link safely instead of advertising an // action that can never replace it; the link target itself remains intact. - { + if (nativeFileSymlinkAvailable) { const { coven, cave } = await home("canonical-symlink-recovery"); const legacyPath = path.join(coven, "cave-config.json"); const canonicalPath = path.join(cave, "config.json"); @@ -252,6 +273,8 @@ try { assert.deepEqual(await json(canonicalPath), { source: "legacy" }); assert.deepEqual(await json(foreignPath), { source: "foreign" }); assert.equal((await caveHomeMigrationStatus()).migrated, true); + } else { + console.log("cave-home-migration-status: skipped native symlink cases (Windows symlink privilege unavailable)"); } { const { coven, cave } = await home("malformed-identical"); @@ -345,7 +368,7 @@ try { // An unrelated legacy-path problem must not take every gated Cave store // offline or force a full reconciliation pass on every state read. - { + if (nativeFileSymlinkAvailable) { const { coven, cave } = await home("reader-gate-scoped"); globalThis.__caveHomeMigration = undefined; await mkdir(cave, { recursive: true }); From 6c0d47f4dfc3c7f63e57d18ca101c84d87965f54 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Sun, 26 Jul 2026 15:43:30 -0500 Subject: [PATCH 59/61] feat(settings): refresh daemon controls and iOS chat launch (#3929) Refresh the Settings daemon control sheet from the approved ZIP handoff while preserving live multi-host, travel, executor, and Omnigent behavior. Update iOS connection copy and open Chats at the newest non-archived conversation without overriding explicit navigation intents. Normalize saved hub URLs and use design-system spacing tokens in response to Copilot review. Signed-off-by: Val Alexander --- .../CovenCave/CovenCave/State/AppModel.swift | 7 + .../CovenCave/Views/ChatsHomeView.swift | 15 + .../CovenCave/CovenCave/Views/RootView.swift | 2 +- .../LaunchThreadIntentTests.swift | 27 + scripts/ios-claude-design-fidelity.test.mjs | 21 +- src/components/daemon-start-button.test.ts | 4 +- .../settings-action-buttons.test.ts | 6 +- .../settings-daemon-multihost.test.ts | 97 +- src/components/settings-daemon.tsx | 956 ++++++++++++++++++ src/components/settings-overview.test.ts | 4 +- src/components/settings-shell-polish.test.ts | 7 +- src/components/settings-shell.tsx | 592 +---------- src/styles/settings-daemon.css | 936 +++++++++++++++++ 13 files changed, 2073 insertions(+), 601 deletions(-) create mode 100644 src/components/settings-daemon.tsx create mode 100644 src/styles/settings-daemon.css diff --git a/apps/ios/CovenCave/CovenCave/State/AppModel.swift b/apps/ios/CovenCave/CovenCave/State/AppModel.swift index 4fb230c7f..641e0098f 100644 --- a/apps/ios/CovenCave/CovenCave/State/AppModel.swift +++ b/apps/ios/CovenCave/CovenCave/State/AppModel.swift @@ -73,6 +73,13 @@ final class AppModel { var familiarOrder: [String] = [] var threads: [ChatThread] = [] + /// Default Chats destination: the newest active conversation. Pinning only + /// affects list order and never makes an older thread the launch default. + var mostRecentThread: ChatThread? { + threads + .filter { !$0.archived } + .max { $0.updatedAt < $1.updatedAt } + } /// Process-lifetime launch intent. It survives destination remounts until a /// matching hydrated thread can be opened, then is consumed exactly once. var launchThreadId: String? diff --git a/apps/ios/CovenCave/CovenCave/Views/ChatsHomeView.swift b/apps/ios/CovenCave/CovenCave/Views/ChatsHomeView.swift index 0b1b4d3f7..af4b8daab 100644 --- a/apps/ios/CovenCave/CovenCave/Views/ChatsHomeView.swift +++ b/apps/ios/CovenCave/CovenCave/Views/ChatsHomeView.swift @@ -101,9 +101,11 @@ struct ChatsHomeView: View { .onAppear { consumeLaunchThreadIntent() consumeGlobalRequests() + selectMostRecentThreadIfNeeded() } .onChange(of: app.threads.map(\.id)) { _, _ in consumeLaunchThreadIntent() + selectMostRecentThreadIfNeeded() } // A slash command (`/new`, `/familiar `) or a task link asked to // open a specific thread — surface it in the detail column. @@ -390,6 +392,19 @@ struct ChatsHomeView: View { open(.thread(thread)) } + /// Open Chats at the latest active conversation without stealing focus from + /// a deep link, cross-view handoff, New Chat, or an existing selection. + private func selectMostRecentThreadIfNeeded() { + guard selection == nil, + !showNewChat, + app.threadToOpen == nil, + app.launchThreadId == nil, + !app.newChatRequested, + let thread = app.mostRecentThread + else { return } + open(.thread(thread)) + } + /// Consume a cross-destination thread handoff on first appearance and on /// later updates. Clearing the one-shot intent prevents re-appearance from /// reopening the same conversation. diff --git a/apps/ios/CovenCave/CovenCave/Views/RootView.swift b/apps/ios/CovenCave/CovenCave/Views/RootView.swift index dfca35b41..92d3aa33c 100644 --- a/apps/ios/CovenCave/CovenCave/Views/RootView.swift +++ b/apps/ios/CovenCave/CovenCave/Views/RootView.swift @@ -280,7 +280,7 @@ struct ConnectingView: View { .accessibilityHidden(true) .padding(.bottom, 34) - Text("Opening the Cave") + Text("Entering the Cave") .font(.title.weight(.medium)) .fontDesign(.serif) .italic() diff --git a/apps/ios/CovenCave/CovenCaveTests/LaunchThreadIntentTests.swift b/apps/ios/CovenCave/CovenCaveTests/LaunchThreadIntentTests.swift index b9592776f..d91944362 100644 --- a/apps/ios/CovenCave/CovenCaveTests/LaunchThreadIntentTests.swift +++ b/apps/ios/CovenCave/CovenCaveTests/LaunchThreadIntentTests.swift @@ -34,4 +34,31 @@ final class LaunchThreadIntentTests: XCTestCase { app.threads = [expected] XCTAssertTrue(app.consumeLaunchThreadIntent() === expected) } + + func testMostRecentThreadUsesUpdateTimeAndSkipsArchivedThreads() { + let app = AppModel() + let olderPinned = ChatThread(id: "older-pinned", title: "Older pinned", familiarIds: []) + olderPinned.updatedAt = Date(timeIntervalSince1970: 100) + olderPinned.pinned = true + + let newest = ChatThread(id: "newest", title: "Newest", familiarIds: []) + newest.updatedAt = Date(timeIntervalSince1970: 200) + + let archived = ChatThread(id: "archived", title: "Archived", familiarIds: []) + archived.updatedAt = Date(timeIntervalSince1970: 300) + archived.archived = true + + app.threads = [olderPinned, archived, newest] + + XCTAssertTrue(app.mostRecentThread === newest) + } + + func testMostRecentThreadIsNilWithoutAnActiveConversation() { + let app = AppModel() + let archived = ChatThread(id: "archived", title: "Archived", familiarIds: []) + archived.archived = true + app.threads = [archived] + + XCTAssertNil(app.mostRecentThread) + } } diff --git a/scripts/ios-claude-design-fidelity.test.mjs b/scripts/ios-claude-design-fidelity.test.mjs index f68e3c581..7cb069261 100644 --- a/scripts/ios-claude-design-fidelity.test.mjs +++ b/scripts/ios-claude-design-fidelity.test.mjs @@ -92,6 +92,11 @@ assert.match( /func consumeLaunchThreadIntent\(\) -> ChatThread\?/, "the launch thread intent waits for a matching thread before consuming", ); +assert.match( + appModel, + /var mostRecentThread: ChatThread\? \{[\s\S]{0,180}filter \{ !\$0\.archived \}[\s\S]{0,180}max \{ \$0\.updatedAt < \$1\.updatedAt \}/, + "the default chat is the newest active thread, independent of pin order", +); assert.match( appModel, /if let threadId = ChatNotifications\.threadId\(fromDeepLink: url\) \{[\s\S]{0,140}launchThreadId = threadId[\s\S]{0,140}selectedTab = \.chats/, @@ -109,8 +114,18 @@ assert.doesNotMatch( ); assert.match( home, - /onChange\(of: app\.threads\.map\(\\\.id\)\)[\s\S]{0,120}consumeLaunchThreadIntent\(\)/, - "Chats retries a pending launch intent when hydration adds threads", + /\.onAppear \{\s*consumeLaunchThreadIntent\(\)\s*consumeGlobalRequests\(\)\s*selectMostRecentThreadIfNeeded\(\)\s*\}/, + "Chats selects the most recent thread after honoring explicit launch requests", +); +assert.match( + home, + /onChange\(of: app\.threads\.map\(\\\.id\)\)[\s\S]{0,160}consumeLaunchThreadIntent\(\)[\s\S]{0,160}selectMostRecentThreadIfNeeded\(\)/, + "Chats retries explicit and default selection when hydration adds threads", +); +assert.match( + home, + /private func selectMostRecentThreadIfNeeded\(\) \{[\s\S]{0,500}guard selection == nil,[\s\S]{0,500}!showNewChat,[\s\S]{0,500}app\.threadToOpen == nil,[\s\S]{0,500}app\.launchThreadId == nil,[\s\S]{0,500}!app\.newChatRequested,[\s\S]{0,500}let thread = app\.mostRecentThread[\s\S]{0,180}open\(\.thread\(thread\)\)/, + "the default never overrides an explicit destination or New Chat intent", ); // Authored navigation and discovery surfaces. @@ -144,7 +159,7 @@ assert.match(glass, /UINavigationBarAppearance\.glass/, // Quiet Portal keeps the cold connection state honest, themed, deterministic, // and accessible without inventing stages the runtime cannot prove. -assert.match(root, /Text\("Opening the Cave"\)/, "connecting state uses the approved headline"); +assert.match(root, /Text\("Entering the Cave"\)/, "connecting state uses the approved headline"); assert.match(root, /Text\("Connecting to your desktop"\)/, "connecting state names the live operation"); assert.match(root, /if let host = app\.connection\?\.host/, "connecting state renders the real saved host"); assert.match( diff --git a/src/components/daemon-start-button.test.ts b/src/components/daemon-start-button.test.ts index 788f3447d..1e41c5779 100644 --- a/src/components/daemon-start-button.test.ts +++ b/src/components/daemon-start-button.test.ts @@ -2,7 +2,9 @@ import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; -const settings = await readFile(new URL("./settings-shell.tsx", import.meta.url), "utf8"); +const settingsShell = await readFile(new URL("./settings-shell.tsx", import.meta.url), "utf8"); +const settingsDaemon = await readFile(new URL("./settings-daemon.tsx", import.meta.url), "utf8"); +const settings = `${settingsShell}\n${settingsDaemon}`; const workspace = await readFile(new URL("./workspace.tsx", import.meta.url), "utf8"); assert.match(settings, /fetch\("\/api\/daemon\/start", \{ method: "POST" \}\)/); diff --git a/src/components/settings-action-buttons.test.ts b/src/components/settings-action-buttons.test.ts index 4adf4597b..1ff65e375 100644 --- a/src/components/settings-action-buttons.test.ts +++ b/src/components/settings-action-buttons.test.ts @@ -4,6 +4,7 @@ import { readFileSync } from "node:fs"; import ts from "typescript"; const shell = readFileSync(new URL("./settings-shell.tsx", import.meta.url), "utf8"); +const daemon = readFileSync(new URL("./settings-daemon.tsx", import.meta.url), "utf8"); const picker = readFileSync(new URL("./settings-familiar-picker.tsx", import.meta.url), "utf8"); const controls = readFileSync(new URL("./ui/settings-controls.tsx", import.meta.url), "utf8"); const css = readFileSync(new URL("../app/globals.css", import.meta.url), "utf8"); @@ -45,12 +46,13 @@ function jsxElementBlocks(fileName: string, source: string, tagName: string): st } const reviewedSemanticControl = (button: string): boolean => - /goToSetting\(e\)|selectDevice\(device\)|settings-nav__item|role="switch"|aria-pressed=|aria-label=\{`Pick \$\{label\} color`\}|aria-haspopup="dialog"|role="option"|role="radio"|role="checkbox"|setEnlarged\(true\)|Drag to reorder|familiar-studio-lifecycle__row-main/.test( + /goToSetting\(e\)|selectDevice\(device\)|settings-nav__item|role="switch"|aria-pressed=|aria-expanded=|aria-label=\{`Pick \$\{label\} color`\}|aria-haspopup="dialog"|role="option"|role="radio"|role="checkbox"|setEnlarged\(true\)|Drag to reorder|familiar-studio-lifecycle__row-main/.test( button, ); for (const [name, source] of [ ["settings shell", shell], + ["settings daemon", daemon], ["settings familiar picker", picker], ["settings segmented control", controls], ...studioSources, @@ -72,7 +74,7 @@ for (const [name, source] of [ } } -const saveConnectionButtons = jsxElementBlocks("settings-shell.tsx", shell, "Button").filter((block) => +const saveConnectionButtons = jsxElementBlocks("settings-daemon.tsx", daemon, "Button").filter((block) => block.includes("Save connection"), ); assert.equal(saveConnectionButtons.length, 1, "Save connection renders through exactly one shared Button"); diff --git a/src/components/settings-daemon-multihost.test.ts b/src/components/settings-daemon-multihost.test.ts index 38d0269b4..e034ee91d 100644 --- a/src/components/settings-daemon-multihost.test.ts +++ b/src/components/settings-daemon-multihost.test.ts @@ -1,26 +1,107 @@ // @ts-nocheck import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; -const shell = readFileSync(new URL("./settings-shell.tsx", import.meta.url), "utf8"); +const shellEntry = readFileSync(new URL("./settings-shell.tsx", import.meta.url), "utf8"); +const daemonUrl = new URL("./settings-daemon.tsx", import.meta.url); +const daemon = existsSync(daemonUrl) ? readFileSync(daemonUrl, "utf8") : ""; +const shell = `${shellEntry}\n${daemon}`; const sections = readFileSync(new URL("./settings-sections.ts", import.meta.url), "utf8"); +const daemonCssUrl = new URL("../styles/settings-daemon.css", import.meta.url); +const daemonCss = existsSync(daemonCssUrl) ? readFileSync(daemonCssUrl, "utf8") : ""; +// ── Claude Design daemon control sheet ─────────────────────────────────────── assert.match( + shellEntry, + /import \{ DaemonSection \} from "\.\/settings-daemon"/, + "SettingsShell should delegate the daemon control sheet to a focused component", +); +assert.match( + daemon, + /export function DaemonSection/, + "the focused daemon module should export the Settings section", +); +assert.match(daemon, /className="settings-daemon"/, "the daemon page should own a responsive control-sheet container"); +assert.match(daemon, /className="settings-daemon-hero"/, "the daemon page should open with the approved compact hero"); +assert.match(daemon, /Settings · Daemon/, "the hero should carry the approved settings kicker"); +assert.match(daemon, /className="settings-daemon-chip-list"/, "the hero should summarize target, API, queue, and uptime"); +assert.match(daemon, />\s*Refresh\s*HOMEAWAY\s*Revert\s*\s*Save connection\s*\}/, "the Vault-gated Omnigent settings must remain reachable from Daemon"); + +assert.match( + daemonCss, + /@container settings-daemon \(max-width:/, + "the control sheet should adapt to its pane with a container query", +); +assert.match( + daemonCss, + /@media \(prefers-reduced-motion: reduce\)/, + "daemon-specific motion should have an explicit reduced-motion treatment", +); +assert.doesNotMatch( + daemonCss, + /(?:gap|padding|width|height):\s*(?:2|3|6|8|12)px|box-shadow:\s*inset\s+2px/, + "daemon micro-spacing should use the design-system spacing tokens", +); +assert.doesNotMatch( shell, - /type MultiHostMode = "local" \| "hub"/, - "SettingsShell should model local vs server hub mode explicitly", + /bg-red-400/, + "daemon error states should use the semantic danger token across every theme", ); assert.match( shell, - /fetch\("\/api\/config", \{ cache: "no-store", signal: ctl\.signal \}\)/, - "Daemon settings should load Cave config before rendering connection controls", + /type MultiHostMode = "local" \| "hub"/, + "SettingsShell should model local vs server hub mode explicitly", ); assert.match( shell, - /body: JSON\.stringify\(\{ multiHost: \{ mode: nextMode, hubUrl, executorUrls: parseExecutorUrls\(executorText\) \} \}\)/, - "Daemon settings should persist the selected connection mode through cave-config", + /fetch\("\/api\/config", \{ cache: "no-store", signal: ctl\.signal \}\)/, + "Daemon settings should load Cave config before rendering connection controls", ); assert.match( diff --git a/src/components/settings-daemon.tsx b/src/components/settings-daemon.tsx new file mode 100644 index 000000000..c9357a26e --- /dev/null +++ b/src/components/settings-daemon.tsx @@ -0,0 +1,956 @@ +"use client"; + +import "@/styles/settings-daemon.css"; + +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; +import { Button } from "@/components/ui/button"; +import { ErrorState } from "@/components/ui/error-state"; +import { IconButton } from "@/components/ui/icon-button"; +import { useAnnouncer } from "@/components/ui/live-region"; +import { RelativeTime } from "@/components/ui/relative-time"; +import { TextArea } from "@/components/ui/text-area"; +import { TextInput } from "@/components/ui/text-input"; +import { copyText } from "@/lib/clipboard"; +import { Icon, type IconName } from "@/lib/icon"; +import { parseExecutorUrls } from "./settings-multihost"; + +type DaemonStatus = { + running: boolean; + reason?: string; + checkedAt?: string; + covenVersion?: string; + apiVersion?: string; + workspacePath?: string; + daemon?: { pid: number; startedAt: string; socket: string }; + executors?: Array<{ + url: string; + healthUrl: string; + ok: boolean; + state: "available" | "unreachable"; + detail: string; + }>; + target?: { + mode: "local" | "hub" | "unconfigured-hub"; + label: string; + socket?: string; + url?: string; + error?: string; + }; + travel?: { + mode: "home" | "hub" | "watching-hub" | "travel" | "handoff-pending"; + authority: "local" | "hub" | "travel-local"; + reason: string; + manualOffline: boolean; + staleCache: boolean; + wakeLocalSubdaemon: boolean; + localBindHost: "127.0.0.1"; + hubUnreachableSince: string | null; + hubUnreachableForMs: number; + pendingQueueCount: number; + handoffPending: boolean; + }; +}; + +type MultiHostMode = "local" | "hub"; + +type TailscaleDevice = { + name: string; + dnsName: string | null; + hostName: string | null; + tailnetIp: string | null; + os: string | null; + online: boolean; + lastSeen: string | null; + isSelf: boolean; +}; + +type DaemonProbe = { + reachable: boolean; + status: number; + latencyMs: number; + reason?: string; + url: string; +}; + +type SavedConnection = { + mode: MultiHostMode; + hubUrl: string; + executorUrls: string[]; +}; + +type StatusTone = "danger" | "neutral" | "success" | "warning"; + +const RUNTIME_TARGETS: Array<{ + id: MultiHostMode; + label: string; + blurb: string; + icon: IconName; +}> = [ + { + id: "local", + label: "Local", + blurb: "Runs everything on this machine. The default, and the fastest path.", + icon: "ph:terminal-window", + }, + { + id: "hub", + label: "Server hub", + blurb: "Connects to a shared daemon on another machine over your tailnet.", + icon: "ph:cloud-bold", + }, +]; + +function SectionRule({ id, label, meta }: { id: string; label: string; meta?: ReactNode }) { + return ( +
+

{label}

+
+ ); +} + +function isValidHubUrl(value: string): boolean { + try { + const url = new URL(value.trim()); + return (url.protocol === "http:" || url.protocol === "https:") && Boolean(url.hostname); + } catch { + return false; + } +} + +function classifyTailscaleFailure(raw: string): { headline: string; hint: string } { + const text = raw.toLowerCase(); + if (text.includes("tailscale") && (text.includes("not installed") || text.includes("cli not found"))) { + return { + headline: "Tailscale isn’t installed", + hint: "Install Tailscale and sign in, then refresh devices.", + }; + } + if ( + text.includes("tailscale") && + (text.includes("signed out") || + text.includes("logged out") || + text.includes("not connected") || + text.includes("not running") || + text.includes("stopped") || + text.includes("unreachable")) + ) { + return { + headline: "Tailscale isn’t running", + hint: "Open Tailscale and sign in, then refresh devices.", + }; + } + return { + headline: "Tailnet devices unavailable", + hint: "Retry device discovery, or enter the server hub URL directly.", + }; +} + +export function DaemonSection({ + suggestedHubUrl, + onSuggestionConsumed, + omnigentSettings, +}: { + suggestedHubUrl: string | null; + onSuggestionConsumed: () => void; + omnigentSettings?: ReactNode; +}) { + const { announce } = useAnnouncer(); + const [status, setStatus] = useState(null); + const [loading, setLoading] = useState(true); + const [statusLoadError, setStatusLoadError] = useState(null); + const [starting, setStarting] = useState(false); + const [restarting, setRestarting] = useState(false); + const [startError, setStartError] = useState(null); + + const [mode, setMode] = useState("local"); + const [hubUrl, setHubUrl] = useState(""); + const [executorText, setExecutorText] = useState(""); + const [savedConnection, setSavedConnection] = useState(null); + const [savingConnection, setSavingConnection] = useState(false); + const [connectionError, setConnectionError] = useState(null); + const [executorsOpen, setExecutorsOpen] = useState(false); + + const [devices, setDevices] = useState([]); + const [devicesLoading, setDevicesLoading] = useState(false); + const [devicesError, setDevicesError] = useState(null); + const [probe, setProbe] = useState(null); + const [probing, setProbing] = useState(false); + + const [savingTravel, setSavingTravel] = useState(false); + const [travelError, setTravelError] = useState(null); + const [copiedInfo, setCopiedInfo] = useState<"socket" | "workspace" | null>(null); + + const refreshCtlRef = useRef(null); + const devicesCtlRef = useRef(null); + const suggestionAppliedRef = useRef(false); + const copyTimerRef = useRef | null>(null); + + const refresh = useCallback(async (announceResult = false) => { + refreshCtlRef.current?.abort(); + const ctl = new AbortController(); + refreshCtlRef.current = ctl; + setLoading(true); + setStatusLoadError(null); + try { + const response = await fetch("/api/daemon/status", { cache: "no-store", signal: ctl.signal }); + const result = await response.json().catch(() => ({})) as DaemonStatus & { error?: string }; + if (!response.ok) throw new Error(result.error || `status failed (${response.status})`); + if (ctl.signal.aborted) return; + setStatus(result); + if (announceResult) announce("Daemon status refreshed."); + } catch (error) { + if (ctl.signal.aborted) return; + const message = error instanceof Error ? error.message : "daemon status unavailable"; + setStatusLoadError(message); + if (announceResult) announce(`Couldn't refresh daemon status: ${message}`, "assertive"); + } finally { + if (!ctl.signal.aborted) setLoading(false); + } + }, [announce]); + + useEffect(() => { + void refresh(); + return () => refreshCtlRef.current?.abort(); + }, [refresh]); + + useEffect(() => { + const ctl = new AbortController(); + fetch("/api/config", { cache: "no-store", signal: ctl.signal }) + .then((response) => response.json()) + .then((result: { + ok?: boolean; + config?: { + multiHost?: { + mode?: MultiHostMode; + hubUrl?: string; + executorUrls?: string[]; + }; + }; + }) => { + if (ctl.signal.aborted || !result.ok) return; + const multiHost = result.config?.multiHost; + const next: SavedConnection = { + mode: multiHost?.mode === "hub" ? "hub" : "local", + hubUrl: multiHost?.hubUrl ?? "", + executorUrls: multiHost?.executorUrls ?? [], + }; + setSavedConnection(next); + setExecutorText(next.executorUrls.join("\n")); + setExecutorsOpen(next.executorUrls.length > 0); + if (suggestionAppliedRef.current) return; + setMode(next.mode); + setHubUrl(next.hubUrl); + }) + .catch(() => { + if (!ctl.signal.aborted) setConnectionError("Couldn’t load the saved daemon connection."); + }); + return () => ctl.abort(); + }, []); + + useEffect(() => { + if (!suggestedHubUrl) return; + suggestionAppliedRef.current = true; + setMode("hub"); + setHubUrl(suggestedHubUrl); + setProbe(null); + onSuggestionConsumed(); + }, [onSuggestionConsumed, suggestedHubUrl]); + + useEffect(() => () => { + if (copyTimerRef.current) clearTimeout(copyTimerRef.current); + }, []); + + const loadDevices = useCallback(() => { + devicesCtlRef.current?.abort(); + const ctl = new AbortController(); + devicesCtlRef.current = ctl; + setDevicesLoading(true); + setDevicesError(null); + fetch("/api/tailscale/devices", { cache: "no-store", signal: ctl.signal }) + .then((response) => response.json()) + .then((result: { ok?: boolean; devices?: TailscaleDevice[]; reason?: string }) => { + if (ctl.signal.aborted) return; + if (!result.ok) { + setDevices([]); + setDevicesError(result.reason || "Tailscale status unavailable"); + } else { + setDevices(result.devices ?? []); + } + setDevicesLoading(false); + }) + .catch((error) => { + if (ctl.signal.aborted) return; + setDevicesError(error instanceof Error ? error.message : "Tailscale status unavailable"); + setDevicesLoading(false); + }); + }, []); + + useEffect(() => { + if (mode !== "hub") { + devicesCtlRef.current?.abort(); + return; + } + loadDevices(); + return () => devicesCtlRef.current?.abort(); + }, [loadDevices, mode]); + + const connectionDirty = savedConnection !== null && ( + mode !== savedConnection.mode || + hubUrl.trim() !== savedConnection.hubUrl.trim() || + JSON.stringify(parseExecutorUrls(executorText)) !== JSON.stringify(savedConnection.executorUrls) + ); + + const persistConnection = async (nextMode = mode) => { + const normalizedHubUrl = hubUrl.trim(); + const normalizedExecutorUrls = parseExecutorUrls(executorText); + setSavingConnection(true); + setConnectionError(null); + try { + const res = await fetch("/api/config", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ multiHost: { mode: nextMode, hubUrl: normalizedHubUrl, executorUrls: normalizedExecutorUrls } }), + }); + const json = await res.json().catch(() => ({})); + if (!res.ok || json?.ok === false) { + throw new Error(json?.error || `save failed (${res.status})`); + } + setMode(nextMode); + setHubUrl(normalizedHubUrl); + setSavedConnection({ + mode: nextMode, + hubUrl: normalizedHubUrl, + executorUrls: normalizedExecutorUrls, + }); + announce("Daemon connection saved."); + await refresh(); + } catch (err) { + const msg = err instanceof Error ? err.message : "could not save daemon connection"; + setConnectionError(msg); + announce(`Couldn't save daemon connection: ${msg}`, "assertive"); + } finally { + setSavingConnection(false); + } + }; + + const probeHub = async (candidate: string, saveWhenReachable: boolean) => { + const url = candidate.trim(); + if (!url) { + setConnectionError("Enter a Server hub URL."); + return; + } + if (!isValidHubUrl(url)) { + setConnectionError("Enter a full HTTP URL, such as http://server.tailnet:8787."); + return; + } + setProbing(true); + setConnectionError(null); + try { + const response = await fetch("/api/daemon/probe", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ url }), + }); + const result = await response.json().catch(() => ({})) as Partial & { + ok?: boolean; + error?: string; + }; + if (!response.ok || result.ok === false || typeof result.reachable !== "boolean") { + throw new Error(result.error || `probe failed (${response.status})`); + } + const nextProbe: DaemonProbe = { + reachable: result.reachable, + status: result.status ?? 0, + latencyMs: result.latencyMs ?? 0, + reason: result.reason, + url, + }; + setProbe(nextProbe); + if (nextProbe.reachable && saveWhenReachable) await persistConnection("hub"); + } catch (error) { + const message = error instanceof Error ? error.message : "could not probe Server hub"; + setConnectionError(message); + announce(`Couldn't check the Server hub: ${message}`, "assertive"); + } finally { + setProbing(false); + } + }; + + const saveConnection = async (nextMode = mode) => { + if (nextMode === "hub") { + await probeHub(hubUrl, true); + return; + } + await persistConnection(nextMode); + }; + + const chooseMode = (nextMode: MultiHostMode) => { + setMode(nextMode); + setConnectionError(null); + setProbe(null); + }; + + const revertConnection = () => { + if (!savedConnection) return; + setMode(savedConnection.mode); + setHubUrl(savedConnection.hubUrl); + setExecutorText(savedConnection.executorUrls.join("\n")); + setExecutorsOpen(savedConnection.executorUrls.length > 0); + setConnectionError(null); + setProbe(null); + announce("Daemon connection changes reverted."); + }; + + const selectDevice = (device: TailscaleDevice) => { + const host = device.tailnetIp || device.dnsName; + if (!host) return; + const url = `http://${host}:8787`; + setMode("hub"); + setHubUrl(url); + setProbe(null); + void probeHub(url, false); + }; + + const startDaemon = async () => { + setStarting(true); + setStartError(null); + try { + const res = await fetch("/api/daemon/start", { method: "POST" }); + const json = await res.json().catch(() => ({})); + if (!res.ok || json?.ok === false) { + throw new Error(json?.error || json?.stderr || "daemon did not start"); + } + announce("Daemon started."); + await refresh(); + } catch (err) { + const message = err instanceof Error ? err.message : "daemon did not start"; + setStartError(message); + announce(`Couldn't start the daemon: ${message}`, "assertive"); + } finally { + setStarting(false); + } + }; + + const restartDaemon = async () => { + setRestarting(true); + setStartError(null); + try { + const res = await fetch("/api/daemon/start", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ restart: true }), + }); + const json = await res.json().catch(() => ({})); + if (!res.ok || json?.ok === false) { + throw new Error(json?.error || json?.stderr || "daemon did not restart"); + } + announce("Daemon restarted."); + await refresh(); + } catch (err) { + const message = err instanceof Error ? err.message : "daemon did not restart"; + setStartError(message); + announce(`Couldn't restart the daemon: ${message}`, "assertive"); + } finally { + setRestarting(false); + } + }; + + const setManualOffline = async (manualOffline: boolean) => { + setSavingTravel(true); + setTravelError(null); + try { + const res = await fetch("/api/travel/client", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ manualOffline }), + }); + const json = await res.json().catch(() => ({})); + if (!res.ok || json?.ok === false) { + throw new Error(json?.error || `travel mode save failed (${res.status})`); + } + announce(manualOffline ? "Daemon set to manual offline." : "Daemon back online."); + await refresh(); + } catch (err) { + const message = err instanceof Error ? err.message : "could not save travel mode"; + setTravelError(message); + announce(`Couldn't update travel mode: ${message}`, "assertive"); + } finally { + setSavingTravel(false); + } + }; + + const copyInfoValue = async (key: "socket" | "workspace", label: string, value?: string) => { + if (!value) return; + const ok = await copyText(value); + announce(ok ? `${label} copied.` : `Couldn't copy ${label.toLowerCase()}.`, ok ? "polite" : "assertive"); + if (!ok) return; + setCopiedInfo(key); + if (copyTimerRef.current) clearTimeout(copyTimerRef.current); + copyTimerRef.current = setTimeout(() => setCopiedInfo(null), 1200); + }; + + const manualOffline = status?.travel?.manualOffline === true; + const daemonOnline = status?.running === true && !manualOffline; + const statusTone: StatusTone = loading + ? "neutral" + : restarting || starting || manualOffline + ? "warning" + : status?.running + ? "success" + : "danger"; + const stateLabel = loading + ? "Checking…" + : restarting + ? "Restarting…" + : starting + ? "Starting…" + : manualOffline + ? "Offline (manual)" + : status?.running + ? "Running" + : "Offline"; + const targetLabel = status?.target?.label || (mode === "hub" ? "server hub" : "local runtime"); + const queueCount = status?.travel?.pendingQueueCount ?? 0; + const isAway = Boolean(status?.travel && status.travel.mode !== "home"); + const normalizedExecutorCount = parseExecutorUrls(executorText).length; + const hubUrlValid = isValidHubUrl(hubUrl); + const hubBadge = hubUrl.trim() ? (hubUrlValid ? "valid" : "check url") : "required"; + const hubBadgeTone = hubUrl.trim() ? (hubUrlValid ? "success" : "danger") : "neutral"; + + const statusMetrics = useMemo(() => [ + { + label: "AUTHORITY", + value: status?.travel?.authority || (mode === "hub" ? "server hub" : "local daemon"), + alert: false, + }, + { + label: "PENDING QUEUE", + value: String(queueCount), + alert: queueCount > 0, + }, + { + label: "LOCAL BIND", + value: status?.travel?.localBindHost || "127.0.0.1", + alert: false, + }, + { + label: "STALE CACHE", + value: status?.travel?.staleCache ? "yes" : "no", + alert: status?.travel?.staleCache === true, + }, + { + label: "WAKE LOCAL", + value: status?.travel?.wakeLocalSubdaemon ? "requested" : "standby", + alert: false, + }, + { + label: "HANDOFF", + value: status?.travel?.handoffPending ? "pending sync" : "clear", + alert: status?.travel?.handoffPending === true, + }, + ], [mode, queueCount, status?.travel]); + + const infoRows: Array<{ + key: string; + label: string; + value: ReactNode; + rawValue?: string; + copyKey?: "socket" | "workspace"; + }> = [ + { key: "coven", label: "Coven version", value: status?.covenVersion ?? "—" }, + { key: "api", label: "API version", value: status?.apiVersion ?? "—" }, + { + key: "socket", + label: "Socket", + value: status?.daemon?.socket ?? "—", + rawValue: status?.daemon?.socket, + copyKey: "socket", + }, + { + key: "workspace", + label: "Workspace", + value: status?.workspacePath ?? "—", + rawValue: status?.workspacePath, + copyKey: "workspace", + }, + { + key: "started", + label: "Started", + value: , + }, + ]; + + const tailscaleFailure = devicesError ? classifyTailscaleFailure(devicesError) : null; + + return ( +
+
+ +
+

Settings · Daemon

+

Daemon

+
    +
  • {targetLabel}
  • +
  • {status?.apiVersion || "coven.daemon.v1"}
  • +
  • 0 ? "warning" : "neutral"} />{queueCount} queued
  • +
  • + + up +
  • +
+
+
+ + {!loading && !status?.running && mode === "local" && ( + + )} + {status?.running && ( + + )} +
+
+ +
+ checked : "not checked"} + /> +
+
+
+ {statusLoadError ? ( + void refresh(true)}>Retry} + /> + ) : null} +
+ +
+ + {savedConnection === null ? "loading…" : connectionDirty ? "unsaved changes" : "saved"} + + } + /> +
+
+ RUNTIME TARGET +
+
+ {RUNTIME_TARGETS.map((target) => ( + + ))} +
+ + {mode === "hub" ? ( + <> +
+
+ + HTTP endpoint on your private network +
+
+ { + setHubUrl(event.target.value); + setProbe(null); + setConnectionError(null); + }} + aria-label="Server hub URL" + aria-describedby="settings-daemon-hub-hint" + placeholder="http://server.tailnet:8787" + spellCheck={false} + /> + {hubBadge} +
+ {probing || probe?.url === hubUrl.trim() ? ( +

+ {probing + ? "Checking reachability…" + : probe?.reachable + ? `Reachable · ${probe.latencyMs} ms` + : `Unreachable${probe?.reason ? ` · ${probe.reason}` : ""}`} +

+ ) : null} +
+ +
+
+
+ Tailnet devices + Choose a private-network machine or enter its address above. +
+ +
+ {tailscaleFailure ? ( +
+ {tailscaleFailure.headline} + {tailscaleFailure.hint} +
+ ) : null} + {!tailscaleFailure && !devicesLoading && devices.length === 0 ? ( +

No tailnet devices found.

+ ) : null} + {devices.length > 0 ? ( +
+ {devices.map((device) => { + const selectable = Boolean(device.tailnetIp || device.dnsName); + return ( + + ); + })} +
+ ) : null} +
+ + ) : null} + +
+ + {executorsOpen ? ( +
+