diff --git a/scripts/cross-environment.test.ts b/scripts/cross-environment.test.ts index 30bb71ad3..c840da19f 100644 --- a/scripts/cross-environment.test.ts +++ b/scripts/cross-environment.test.ts @@ -17,7 +17,7 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; -import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -184,10 +184,91 @@ function skip(reason: string): void { // --------------------------------------------------------------------------- { assert.equal(openCodeCommand(), "opencode", "OpenCode keeps one executable name across desktop platforms"); - const windowsLaunch = openCodeLaunch(["run", "safe & literal"], "win32", { SystemRoot: "C:\\Windows" }); - assert.match(windowsLaunch.command, /WindowsPowerShell\\v1\.0\\powershell\.exe$/i, "Windows runs npm's opencode.cmd shim through PowerShell"); - assert.equal(windowsLaunch.input, JSON.stringify(["run", "safe & literal"]), "Windows shell wrapper keeps chat input out of command syntax"); - assert.match(windowsLaunch.args.at(-1) ?? "", /\[Console\]::In\.ReadToEnd\(\)/, "Windows reads OpenCode argv from stdin so long prompts do not exceed its command-line limit"); + const hostileArgs = ["run", "--format", "json"]; + const hostileInput = `${"x".repeat(40_000)} +review 😀 +quotes: "double" 'single' +slashes: C:\\Program Files\\OpenCode\\bin +shell: ; rm -rf ~ | $(evil) && echo "%PATH%" > pwned <'quote\``; + const simulatedShim = "C:\\npm\\opencode.cmd"; + const simulatedTarget = "C:\\npm\\node_modules\\opencode-ai\\bin\\opencode.exe"; + const windowsLaunch = openCodeLaunch( + hostileArgs, + "win32", + { Path: "C:\\npm", PATHEXT: ".COM;.EXE;.BAT;.CMD" }, + { + statFile: (candidate) => candidate === simulatedShim, + resolveWindowsShim: (candidate) => { + assert.equal(candidate, simulatedShim); + return { command: simulatedTarget, fixedArgs: [] }; + }, + }, + ); + assert.equal( + windowsLaunch.command, + simulatedTarget, + "Windows resolves the npm command shim to its native package target", + ); + assert.deepEqual( + windowsLaunch.args, + hostileArgs, + "Windows keeps the complete option argv as direct child-process data", + ); + assert.doesNotMatch( + windowsLaunch.command, + /\.(?:cmd|bat|ps1)$/i, + "Windows never executes a shell shim between Cave and OpenCode stdin", + ); + + if (process.platform === "win32") { + // Execute the REAL npm .cmd resolution → direct native process chain. Cave + // reads the shim only to prove its package target, then bypasses every + // shell re-parse boundary while a >40K prompt travels over stdin. + const openCodeDir = mkdtempSync(path.join(os.tmpdir(), "opencode-conf-launch-")); + try { + writeFileSync( + path.join(openCodeDir, "opencode-shim.mjs"), + [ + "let input = \"\";", + "process.stdin.setEncoding(\"utf8\");", + "for await (const chunk of process.stdin) input += chunk;", + "console.log(JSON.stringify({ args: process.argv.slice(2), input }));", + ].join("\n"), + ); + writeFileSync( + path.join(openCodeDir, "opencode.cmd"), + '"%dp0%\\node.exe" "%dp0%\\opencode-shim.mjs" %*\r\n', + ); + const pathKey = Object.keys(process.env).find((key) => key.toLowerCase() === "path") ?? "Path"; + const inheritedPath = process.env[pathKey] ?? ""; + const env = { + ...process.env, + [pathKey]: `${openCodeDir};${inheritedPath}`, + }; + const realLaunch = openCodeLaunch(hostileArgs, process.platform, env); + const result = spawnSync(realLaunch.command, realLaunch.args, { + env, + encoding: "utf8", + input: hostileInput, + windowsHide: true, + }); + assert.equal( + result.status, + 0, + `Windows OpenCode launch failed:\n${result.stderr}\n${result.stdout}`, + ); + assert.deepEqual( + JSON.parse(result.stdout.trim()), + { args: hostileArgs, input: hostileInput }, + "the resolved direct process preserves option argv and >40K multiline Unicode stdin exactly", + ); + } finally { + rmSync(openCodeDir, { recursive: true, force: true }); + } + } else { + skip("OpenCode Windows shim resolution: requires a Windows host (matrix runs it on windows-latest)"); + } + assert.equal(openCodeNeedsTmpRuntimeDir("win32", {}), false, "Windows does not receive an XDG runtime directory"); assert.equal(openCodeNeedsTmpRuntimeDir("linux", {}), true, "headless Linux receives /tmp for OpenCode runtime files"); assert.equal(openCodeNeedsTmpRuntimeDir("linux", { XDG_RUNTIME_DIR: "/run/user/1000" }), false, "native Linux preserves its XDG runtime directory"); diff --git a/scripts/run-tests.mjs b/scripts/run-tests.mjs index af11b7cae..072e0c96b 100644 --- a/scripts/run-tests.mjs +++ b/scripts/run-tests.mjs @@ -1051,6 +1051,7 @@ export const SUITES = { "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-opencode-preflight.integration.test.ts", "src/app/api/chat/send/route-runtime-availability.integration.test.ts", "src/app/api/chat/send/route-codex-runtime-availability.integration.test.ts", "src/app/api/chat/send/offline-queue.test.ts", @@ -1325,6 +1326,7 @@ const ALIAS_LOADER = new Set([ "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-opencode-preflight.integration.test.ts", "src/app/api/chat/send/route-runtime-availability.integration.test.ts", "src/app/api/chat/send/route-codex-runtime-availability.integration.test.ts", "src/lib/familiar-workspace-sessions.test.ts", diff --git a/src/app/api/chat/send/chat-send-capabilities.test.ts b/src/app/api/chat/send/chat-send-capabilities.test.ts index bbfc63535..25d20a0c4 100644 --- a/src/app/api/chat/send/chat-send-capabilities.test.ts +++ b/src/app/api/chat/send/chat-send-capabilities.test.ts @@ -1,6 +1,6 @@ // @ts-nocheck import assert from "node:assert/strict"; -import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, utimesSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { @@ -17,8 +17,21 @@ import { parseOpenCodeRunCapabilitiesHelp, } from "./chat-send-capabilities.ts"; +const capabilitiesSource = readFileSync(new URL("./chat-send-capabilities.ts", import.meta.url), "utf8"); + +assert.match( + capabilitiesSource, + /probeOpenCodeRunContract[\s\S]*?evaluateRuntimeAvailability\(openCodeAvailabilityProbe\(helpLaunch, env\)\)\.state !== "ready"[\s\S]*?evaluateRuntimeAvailability\(openCodeAvailabilityProbe\(versionLaunch, env\)\)\.state !== "ready"[\s\S]*?probeOutput\(helpLaunch\.command/, + "run-contract probes passively verify both exact launch plans and required files before spawning", +); +assert.match( + capabilitiesSource, + /openCodeRunSupportsModel[\s\S]*?evaluateRuntimeAvailability\(openCodeAvailabilityProbe\(launch, env\)\)\.state !== "ready"[\s\S]*?probeHelp\(/, + "the model-flag probe passively verifies its exact launch plan and required files before spawning", +); + assert.equal(openCodeCapabilityProbeTimeoutMs("linux"), 2_500, "non-Windows capability probes retain the short bounded deadline"); -assert.equal(openCodeCapabilityProbeTimeoutMs("win32"), 6_000, "Windows PowerShell/npm launchers receive a bounded cold-start allowance"); +assert.equal(openCodeCapabilityProbeTimeoutMs("win32"), 6_000, "Windows native/npm launches receive a bounded cold-start allowance"); assert.equal(openCodeProbeCleanupGraceMs(), 1_000, "timed-out probe cleanup has a short final deadline so chat can fall back"); assert.equal(openCodeVerifiedCapabilityFallbackTtlMs(), 60_000, "only a short-lived verified OpenCode contract can survive a transient probe failure"); assert.equal(openCodeVerifiedCapabilityFallbackLimit(), 64, "verified capability evidence remains bounded across scoped launches"); @@ -112,13 +125,16 @@ if (process.platform === "win32") { const packageBin = path.join(identityRoot, "node_modules", "opencode-ai", "bin"); mkdirSync(localBin, { recursive: true }); mkdirSync(packageBin, { recursive: true }); - writeFileSync(path.join(localBin, "opencode.cmd"), "@echo off\r\n"); writeFileSync(path.join(localBin, "opencode.exe"), "direct-executable"); const packageExecutable = path.join(packageBin, "opencode.exe"); writeFileSync(packageExecutable, "package-target-one"); + writeFileSync( + path.join(localBin, "opencode.cmd"), + '"%dp0%\\..\\opencode-ai\\bin\\opencode.exe" %*\r\n', + ); const cmdIdentity = await openCodeCapabilityLaunchIdentity({ PATH: localBin, PATHEXT: ".CMD;.EXE" }, "win32"); const exeIdentity = await openCodeCapabilityLaunchIdentity({ PATH: localBin, PATHEXT: ".EXE;.CMD" }, "win32"); - assert.notEqual(cmdIdentity, exeIdentity, "PATHEXT order selects and fingerprints only the actual Windows launcher"); + assert.notEqual(cmdIdentity, exeIdentity, "PATHEXT order selects and fingerprints only the exact shell-free target"); const timestamp = new Date("2026-01-01T00:00:00.000Z"); utimesSync(packageExecutable, timestamp, timestamp); writeFileSync(packageExecutable, "package-target-two"); @@ -146,7 +162,7 @@ assert.equal(evictedFallback.probeStatus, "unavailable", "bounded evidence evict assert.deepEqual( openCodeProbeTreeKillCommand(4242, "win32"), { command: "taskkill.exe", args: ["/PID", "4242", "/T", "/F"] }, - "timed-out Windows probes terminate their launcher tree rather than only PowerShell", + "timed-out Windows probes terminate the complete native process tree", ); assert.equal(openCodeProbeTreeKillCommand(4242, "linux"), null, "non-Windows probes retain process-local termination"); diff --git a/src/app/api/chat/send/chat-send-capabilities.ts b/src/app/api/chat/send/chat-send-capabilities.ts index c7a174f97..3e9e69d21 100644 --- a/src/app/api/chat/send/chat-send-capabilities.ts +++ b/src/app/api/chat/send/chat-send-capabilities.ts @@ -1,8 +1,9 @@ -import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { spawn, type ChildProcessByStdio } from "node:child_process"; import { createHash } from "node:crypto"; import { createReadStream } from "node:fs"; import { realpath, stat } from "node:fs/promises"; import path from "node:path"; +import type { Readable } from "node:stream"; import { covenLaunchCommand } from "@/lib/coven-bin"; import { covenRunSupportsAddDirFlag, @@ -10,8 +11,14 @@ import { covenRunSupportsPermissionFlag, } from "@/lib/harness-adapters"; import { harnessSpawnEnv } from "@/lib/harness-spawn-env"; -import { openCodeCommand, openCodeLaunch, openCodeSpawnEnv, writeOpenCodeLaunchInput } from "@/lib/opencode-bin"; +import { + isOpenCodeLaunchSpawnable, + openCodeAvailabilityProbe, + openCodeLaunch, + openCodeSpawnEnv, +} from "@/lib/opencode-bin"; import type { OpenCodeRunCapabilities } from "@/lib/opencode-compatibility"; +import { evaluateRuntimeAvailability } from "@/lib/runtime-availability"; let modelFlagProbe: Promise | null = null; let permissionFlagProbe: Promise | null = null; @@ -41,8 +48,9 @@ type VerifiedCapability = { // false claim that an otherwise unchanged OpenCode lacks JSON support. const verifiedOpenCodeCapabilities = new Map(); const openCodeFileIdentities = new Map(); +type OpenCodeProbeChild = ChildProcessByStdio; -/** PowerShell/npm shims can be delayed by cold start or Defender scanning. */ +/** Native OpenCode startup can be delayed by cold start or Defender scanning. */ export function openCodeCapabilityProbeTimeoutMs(platform: NodeJS.Platform = process.platform): number { return platform === "win32" ? WINDOWS_CAPABILITY_PROBE_TIMEOUT_MS : DEFAULT_CAPABILITY_PROBE_TIMEOUT_MS; } @@ -86,8 +94,8 @@ export function openCodeProbeSpawnOptions( return { detached: platform !== "win32" }; } -/** `taskkill /T` is required because killing the PowerShell launcher alone - * leaves its opencode(.cmd) child running on Windows. */ +/** `taskkill /T` ensures a timed-out native OpenCode probe cannot leave helper + * children running on Windows. */ export function openCodeProbeTreeKillCommand( pid: number | undefined, platform: NodeJS.Platform = process.platform, @@ -97,7 +105,7 @@ export function openCodeProbeTreeKillCommand( return { command: "taskkill.exe", args: ["/PID", String(processId), "/T", "/F"] }; } -function terminateProbeProcessTree(child: ChildProcessWithoutNullStreams): Promise { +function terminateProbeProcessTree(child: OpenCodeProbeChild): Promise { const treeKill = openCodeProbeTreeKillCommand(child.pid); if (!treeKill) { return new Promise((resolve) => { @@ -158,7 +166,6 @@ function probeHelp( args: string[], matches: (help: string) => boolean, env = harnessSpawnEnv(), - input?: string, ): Promise { return new Promise((resolve) => { let output = ""; @@ -171,10 +178,9 @@ function probeHelp( try { const child = spawn(command, args, { env, - stdio: input === undefined ? ["ignore", "pipe", "pipe"] : ["pipe", "pipe", "pipe"], + stdio: ["ignore", "pipe", "pipe"], ...openCodeProbeSpawnOptions(), - }) as ChildProcessWithoutNullStreams; - if (input !== undefined) writeOpenCodeLaunchInput(child, { command, args, input }); + }); child.stdout.on("data", (chunk) => (output += chunk.toString())); child.stderr.on("data", (chunk) => (output += chunk.toString())); const timeout = setTimeout(() => { @@ -205,7 +211,6 @@ function probeOutput( command: string, args: string[], env = harnessSpawnEnv(), - input?: string, timeoutMs = openCodeCapabilityProbeTimeoutMs(), ): Promise { return new Promise((resolve) => { @@ -220,10 +225,9 @@ function probeOutput( try { const child = spawn(command, args, { env, - stdio: input === undefined ? ["ignore", "pipe", "pipe"] : ["pipe", "pipe", "pipe"], + stdio: ["ignore", "pipe", "pipe"], ...openCodeProbeSpawnOptions(), - }) as ChildProcessWithoutNullStreams; - if (input !== undefined) writeOpenCodeLaunchInput(child, { command, args, input }); + }); let overflowed = false; const append = (chunk: Buffer) => { if (output.length >= MAX_PROBE_OUTPUT) { overflowed = true; return; } @@ -388,18 +392,29 @@ function advertisedStructuredSwitches(options: string[], noValueOptions: string[ async function probeOpenCodeRunContract(env: NodeJS.ProcessEnv): Promise { const helpLaunch = openCodeLaunch(["run", "--help"], process.platform, env); const versionLaunch = openCodeLaunch(["--version"], process.platform, env); + if ( + !isOpenCodeLaunchSpawnable(helpLaunch) + || !isOpenCodeLaunchSpawnable(versionLaunch) + || evaluateRuntimeAvailability(openCodeAvailabilityProbe(helpLaunch, env)).state !== "ready" + || evaluateRuntimeAvailability(openCodeAvailabilityProbe(versionLaunch, env)).state !== "ready" + ) { + return { + helpProbe: { output: "", complete: false }, + versionProbe: { output: "", complete: false }, + }; + } const [helpProbe, versionProbe] = await Promise.all([ - probeOutput(helpLaunch.command, helpLaunch.args, env, helpLaunch.input), - probeOutput(versionLaunch.command, versionLaunch.args, env, versionLaunch.input), + probeOutput(helpLaunch.command, helpLaunch.args, env), + probeOutput(versionLaunch.command, versionLaunch.args, env), ]); return { helpProbe, versionProbe }; } /** - * Return a local-only fingerprint for the exact command files PowerShell or - * spawn can resolve. It is intentionally never rendered or persisted. The - * npm PowerShell shim and its package executable both participate: package - * upgrades can replace the latter while leaving the small `.cmd` shim intact. + * Return a local-only fingerprint for the exact shell-free launch files. It + * is intentionally never rendered or persisted. Windows npm shims are parsed + * first, so only the native package target (or Node plus its legacy script) + * participates; the command-shell wrappers are never trusted as launchers. */ function rememberOpenCodeFileIdentity(key: string, identity: string): void { openCodeFileIdentities.delete(key); @@ -451,44 +466,42 @@ async function fileIdentity(file: string, platform: NodeJS.Platform): Promise { + const pathApi = platform === "win32" ? path.win32 : path.posix; + const pathLike = pathApi.isAbsolute(command) + || command.includes("/") + || (platform === "win32" && command.includes("\\")); + if (pathLike) return fileIdentity(command, platform); + const rawPath = env.PATH ?? env.Path ?? env.path; if (!rawPath) return null; const separator = platform === "win32" ? ";" : ":"; - const pathApi = platform === "win32" ? path.win32 : path.posix; - const extensions = platform === "win32" - ? [...new Set([".PS1", ...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)].map((extension) => extension.toLowerCase()))] - : [""]; for (const directory of rawPath.split(separator).filter(Boolean).slice(0, 512)) { - for (const extension of extensions) { - const candidate = pathApi.join(directory, `${openCodeCommand()}${extension}`); - const launcher = await fileIdentity(candidate, platform); - if (!launcher) continue; - if (platform !== "win32" || extension === ".exe" || extension === ".com") return launcher; - - // A cmd/bat/PowerShell wrapper can dispatch to a mutable downstream - // executable. Permit fallback only for npm's known layout, and include - // the resolved package executable in the fingerprint. Unknown wrappers - // deliberately receive no fallback evidence. - const packageTarget = await fileIdentity(npmPackageExecutable(directory, pathApi), platform); - return packageTarget ? `${launcher}\0${packageTarget}` : null; - } + const identity = await fileIdentity(pathApi.join(directory, command), platform); + if (identity) return identity; } return null; } +export async function openCodeCapabilityLaunchIdentity( + env: NodeJS.ProcessEnv, + platform: NodeJS.Platform = process.platform, +): Promise { + const launch = openCodeLaunch([], platform, env); + if (!isOpenCodeLaunchSpawnable(launch)) return null; + const identities = await Promise.all([ + commandFileIdentity(launch.command, env, platform), + ...(launch.requiredFiles ?? []).map((file) => fileIdentity(file, platform)), + ]); + return identities.every((identity): identity is string => identity !== null) + ? identities.join("\0") + : null; +} + function capabilityKey(scope: string, launcherIdentity: string, version: string): string { return `${scope}\0${launcherIdentity}\0${version}`; } @@ -603,12 +616,17 @@ export function hermesChatSupportsModel(): Promise { export function openCodeRunSupportsModel(): Promise { const env = openCodeSpawnEnv(); const launch = openCodeLaunch(["run", "--help"], process.platform, env); + if ( + !isOpenCodeLaunchSpawnable(launch) + || evaluateRuntimeAvailability(openCodeAvailabilityProbe(launch, env)).state !== "ready" + ) { + return Promise.resolve(false); + } return (openCodeModelFlagProbe ??= probeHelp( launch.command, launch.args, (help) => /(^|\s)--model(?![\w-])/m.test(help), env, - launch.input, )); } 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 94a0fc390..e911315f3 100644 --- a/src/app/api/chat/send/harness-routing-opencode.test.ts +++ b/src/app/api/chat/send/harness-routing-opencode.test.ts @@ -37,8 +37,13 @@ assert.match( ); assert.match( route, - /const openCodeEndOfOptionsSupported = Boolean\([\s\S]*?capabilities\.endOfOptions[\s\S]*?mode === "plain"[\s\S]*?launch\.endOfOptions === true[\s\S]*?const openCodePromptNeedsDelimiter = openCodeDirect && harnessPrompt\.startsWith\("--"\);[\s\S]*?if \(openCodePromptNeedsDelimiter && !openCodeEndOfOptionsSupported\)[\s\S]*?status: 400[\s\S]*?if \(openCodeEndOfOptionsSupported\) a\.push\("--"\);/, - "OpenCode emits the end-of-options delimiter only when both the selected schema and help probe confirm it, refusing an unsafe flag-shaped prompt otherwise", + /if \(openCodeDirect\) \{[\s\S]*?const a = \["run"\];[\s\S]*?if \(forwardModel\) a\.push\("--model", forwardModel\);[\s\S]*?OpenCode reads non-TTY stdin verbatim[\s\S]*?return a;/, + "OpenCode builds an option-only run argv and leaves the full prompt for the stdin transport", +); +assert.doesNotMatch( + route, + /openCodePromptNeedsDelimiter|a\.push\(prompt\)/, + "OpenCode never interprets a flag-shaped or oversized prompt as argv", ); assert.match( route, @@ -62,33 +67,28 @@ assert.match( ); assert.match( route, - /const launch = openCodeLaunch\(\[\], process\.platform, env\);[\s\S]*?launch: \{ command: launch\.command, fixedArgs: launch\.args \}[\s\S]*?powerShellHostedCommand:[\s\S]*?launch\.input !== undefined \? openCodeCommand\(\) : undefined/, - "OpenCode's passive plan owns the exact outer host, PowerShell argv, and inner command", -); -assert.match( - route, - /const openCodeLaunchCommand = openCodeDirect[\s\S]*?command: localPlan\.command,[\s\S]*?args: \[\.\.\.localPlan\.fixedArgs\],[\s\S]*?input: JSON\.stringify\(spawnArgs\)[\s\S]*?const availability =[\s\S]*?command: localPlan\.command,[\s\S]*?env: localPlan\.env,[\s\S]*?let child:[\s\S]*?try \{[\s\S]*?child = spawn\(command\.command, command\.args, \{[\s\S]*?env: localPlan\.env,[\s\S]*?writeOpenCodeLaunchInput\(child, openCodeLaunchCommand\)/, - "OpenCode carries one Windows-safe outer host, inner command, and scoped environment from early preflight through the immediate spawn recheck", + /const launch = openCodeLaunch\(\[\], process\.platform, env\);[\s\S]*?const availabilityProbe = openCodeAvailabilityProbe\(launch, env\);[\s\S]*?command: launch\.command,[\s\S]*?fixedArgs: launch\.args,[\s\S]*?unresolvedWindowsShim: launch\.unresolvedWindowsShim,[\s\S]*?availability: evaluateRuntimeAvailability\(availabilityProbe\)/, + "OpenCode's canonical passive probe owns the exact direct command, fixed target args, shim-resolution state, and environment", ); assert.match( route, - /const env = openCodeSpawnEnv\(body\.familiarId\);[\s\S]*?const launch = openCodeLaunch\(\[\], process\.platform, env\);[\s\S]*?runner: "opencode",[\s\S]*?powerShellHostedCommand:[\s\S]*?launch\.input !== undefined \? openCodeCommand\(\) : undefined[\s\S]*?const availability =[\s\S]*?command: localPlan\.command,[\s\S]*?powerShellHostedCommand:[\s\S]*?localPlan\.powerShellHostedCommand,[\s\S]*?if \(availability\.state !== "ready"\) \{[\s\S]*?launchFailure = \{ code: availability\.code, message: availability\.message \};[\s\S]*?return null;/, - "OpenCode preflights the exact PowerShell/JSON-stdin plan and returns the shared structured error before spawn", + /const openCodeLaunchCommand = openCodeDirect[\s\S]*?openCodeLaunch\(spawnArgs, process\.platform, localPlan\.env\)[\s\S]*?const availability =[\s\S]*?openCodeAvailabilityProbe\([\s\S]*?openCodeLaunchCommand,[\s\S]*?localPlan\.env[\s\S]*?if \(openCodeDirect\) \{[\s\S]*?stdio: \["pipe", "pipe", "pipe"\],[\s\S]*?child\.stdin\.on\("error"[\s\S]*?child\.stdin\.end\(apiPrompt, "utf8"\);/, + "OpenCode regenerates its canonical shell-free option argv, rechecks it, and writes the exact attempt prompt through an observed UTF-8 stdin pipe", ); assert.match( route, - /child\.on\("error", \(err: NodeJS\.ErrnoException\) => \{[\s\S]*?const openCodeWindowsOuterLaunchFailure =[\s\S]*?process\.platform === "win32" && err\.code === "ENOENT";[\s\S]*?const openCodeCommandMissing =[\s\S]*?!openCodeWindowsOuterLaunchFailure[\s\S]*?launchFailure \?\?= \{[\s\S]*?"runtime_missing"[\s\S]*?"runtime_launch_failed"/, - "an ambiguous Windows outer-process race stays generic while a missing POSIX OpenCode command remains distinct", + /openCodeLaunchCommand[\s\S]*?\? openCodeAvailabilityProbe\([\s\S]*?openCodeLaunchCommand,[\s\S]*?localPlan\.env,[\s\S]*?\)/, + "the immediate pre-spawn gate probes OpenCode through its canonical mapper over the exact launch plan and environment the child will receive", ); assert.match( route, - /OPENCODE_COMMAND_NOT_FOUND_MARKER[\s\S]*?OPENCODE_LAUNCH_FAILED_MARKER[\s\S]*?launchFailure = \{[\s\S]*?code: commandMissing \? "runtime_missing" : "runtime_launch_failed"[\s\S]*?push\(\{ kind: "error", code: launchFailure\.code, message: launchError \}\)/, - "a PowerShell-hosted inner OpenCode race is streamed as a launch failure instead of becoming synthetic no-output text", + /const reportLaunchFailure = \(err: NodeJS\.ErrnoException\) => \{[\s\S]*?const localLaunchError = localRuntimeLaunchError\([\s\S]*?localRuntimePlan\?\.runner \?\? "coven",[\s\S]*?err\.code,[\s\S]*?\);[\s\S]*?const launchCode =[\s\S]*?binding\.harness === "claude"[\s\S]*?RUNTIME_AVAILABILITY_ERROR_CODES\.coven_missing[\s\S]*?: localLaunchError\.code;[\s\S]*?launchFailure \?\?= \{[\s\S]*?code: sshRuntime \?[\s\S]*?: launchCode,[\s\S]*?message: launchError/, + "a post-gate OpenCode spawn failure keeps the shared value-free runner classification instead of leaking host paths or blaming authentication", ); assert.match( capabilities, - /async function probeOpenCodeRunContract\(env: NodeJS\.ProcessEnv\)[\s\S]*?const helpLaunch = openCodeLaunch\(\["run", "--help"\], process\.platform, env\);[\s\S]*?probeOutput\(helpLaunch\.command, helpLaunch\.args, env, helpLaunch\.input\)/, - "OpenCode probes its CLI with the same Windows-safe command and WSL-compatible environment as a chat run", + /const env = openCodeSpawnEnv\(\);[\s\S]*?const launch = openCodeLaunch\(\["run", "--help"\], process\.platform, env\);[\s\S]*?launch\.command,[\s\S]*?launch\.args,[\s\S]*?env,/, + "OpenCode resolves and probes its CLI inside the same Windows-safe or WSL-compatible environment as a chat run", ); assert.match( route, diff --git a/src/app/api/chat/send/route-opencode-preflight.integration.test.ts b/src/app/api/chat/send/route-opencode-preflight.integration.test.ts new file mode 100644 index 000000000..4b05accf4 --- /dev/null +++ b/src/app/api/chat/send/route-opencode-preflight.integration.test.ts @@ -0,0 +1,183 @@ +// @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"; + +// OpenCode preflight (#3862), exercised through the real route: +// +// 1. A prompt larger than Windows' command-line limit, with multiline and +// shell-hostile data, survives preflight and launch over stdin. The gate +// never consults it, and argv remains option-only. +// 2. A CLI that starts and exits non-zero keeps its existing error +// classification: it is never reported as a missing install, and its +// diagnostic turn is persisted (unlike launch failures, which persist +// nothing). +// +// Two route scenarios are deliberately absent, both for the same reason: +// OpenCode launches by BARE NAME inside openCodeSpawnEnv()'s augmented PATH +// (Homebrew, ~/.local/bin, …), so on any machine with OpenCode installed a +// fixture cannot make it disappear (missing) and execvp's PATH search skips a +// planted non-executable candidate and finds the real install (EACCES race). +// The missing classification and remediation copy are unit-covered in +// src/lib/opencode-bin.test.ts; the route's no-spawn mechanics for +// runtime_missing and the post-gate spawn-failure race fallback are proven +// behaviorally in route-runtime-availability.integration.test.ts (grok pins +// its launch to an absolute path, so both failures are deterministic there), +// and OpenCode's value-free launch-failure copy on that shared fallback is +// pinned by harness-routing-opencode.test.ts. +const home = await mkdtemp(path.join(homedir(), "cave-opencode-preflight-")); +const bin = path.join(home, "bin"); +const familiarWorkspace = path.join(home, "familiars", "opal"); +await mkdir(bin, { recursive: true }); +await mkdir(familiarWorkspace, { recursive: true }); + +const previousHome = process.env.COVEN_HOME; +const previousCaveHome = process.env.COVEN_CAVE_HOME; +const previousPath = process.env.PATH; +process.env.COVEN_HOME = home; +process.env.COVEN_CAVE_HOME = path.join(home, "cave"); +process.env.PATH = `${bin}${path.delimiter}${previousPath ?? ""}`; + +// Exceed CreateProcess' 32,767-character command-line ceiling before Cave adds +// identity and runtime context. Newlines, Unicode, quotes, and backslashes must +// remain byte-for-byte data rather than being reinterpreted by a launcher. +const hostilePrompt = `${"x".repeat(40_000)} +review 😀 +quotes: "double" 'single' +slashes: C:\\Program Files\\OpenCode\\bin +shell: ; rm -rf ~ | $(evil) && echo "%PATH%" > pwned <'quote\``; +assert.ok( + Buffer.byteLength(hostilePrompt, "utf8") > 40_000, + "the fixture must stay above Windows' command-line limit", +); +const failPrompt = "FAIL_AUTH_EXIT"; + +// The shim is a Node script behind the same launcher shape npm installs use +// (shell wrapper on POSIX, parsed `.cmd` on Windows), so it can reflect the +// exact stdin and argv it received back through OpenCode's JSON event protocol +// without any shell re-parsing. +const shimScript = path.join(bin, "opencode-shim.mjs"); +await writeFile(shimScript, [ + "const args = process.argv.slice(2);", + "if (args[0] === \"--version\") { console.log(\"1.2.3\"); process.exit(0); }", + "if (args[0] === \"run\" && args[1] === \"--help\") {", + " console.log(\" --format Output format: text, json\");", + " console.log(\" --session Session to continue\");", + " process.exit(0);", + "}", + "if (args[0] !== \"run\" || args[1] !== \"--format\" || args[2] !== \"json\") process.exit(9);", + "let input = \"\";", + "process.stdin.setEncoding(\"utf8\");", + "for await (const chunk of process.stdin) input += chunk;", + "if (input.includes(\"FAIL_AUTH_EXIT\")) {", + " console.error(\"opencode: authentication required for provider\");", + " process.exit(3);", + "}", + "console.log(JSON.stringify({ type: \"text\", sessionID: \"native_preflight_session\", part: { type: \"text\", text: `STDIN:${input}@ARGC=${args.length}@ARGS=${JSON.stringify(args)}@` } }));", +].join("\n")); + +const executable = process.platform === "win32" ? "opencode.cmd" : "opencode"; +const shimPath = path.join(bin, executable); +const launcher = process.platform === "win32" + ? '"%dp0%\\node.exe" "%dp0%\\opencode-shim.mjs" %*\r\n' + : "#!/bin/sh\nexec node \"$(dirname \"$0\")/opencode-shim.mjs\" \"$@\""; +await writeFile(shimPath, launcher, { mode: 0o755 }); + +async function readSse(response) { + assert.equal(response.status, 200, await response.clone().text()); + const body = await response.text(); + const events = body + .split("\n") + .filter((line) => line.startsWith("data: ")) + .map((line) => JSON.parse(line.slice("data: ".length))); + return { body, events }; +} + +try { + // Other route modules can initialize Cave's augmented PATH before this + // fixture installs its shim. Reset that process-local cache so the gate and + // the spawned turn resolve the same temporary OpenCode executable. + const { refreshCovenBin } = await import("@/lib/coven-bin"); + refreshCovenBin(); + const { saveConfig } = await import("@/lib/cave-config"); + const { loadConversation } = await import("@/lib/cave-conversations"); + const { createProject } = await import("@/lib/cave-projects"); + const { grantProjectToFamiliar } = await import("@/lib/project-permissions"); + const { POST } = await import("./route.ts"); + await saveConfig({ familiars: { opal: { harness: "opencode" } } }); + const project = await createProject({ name: "Preflight fixture", root: familiarWorkspace }); + await grantProjectToFamiliar({ familiarId: "opal", projectId: project.id, source: "human", access: "write" }); + + const send = (prompt) => POST(new Request("http://localhost/api/chat/send", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ familiarId: "opal", prompt, projectRoot: familiarWorkspace }), + })); + + // Scenario 1 — a large hostile prompt is stdin data through preflight AND + // launch, while argv remains a small option-only command line. + { + const { events } = await readSse(await send(hostilePrompt)); + const chunks = events + .filter((event) => event.kind === "assistant_chunk") + .map((event) => event.text) + .join(""); + assert.equal( + chunks.includes(hostilePrompt), + true, + "the spawned CLI received the large multiline prompt verbatim over stdin", + ); + assert.equal( + chunks.includes("@ARGC=3@ARGS=[\"run\",\"--format\",\"json\"]@"), + true, + "the spawned CLI receives only OpenCode options in argv", + ); + assert.ok( + !events.some((event) => event.kind === "error"), + "a ready runner with a hostile prompt launches without availability errors", + ); + } + + // Scenario 2 — a CLI that STARTS and fails keeps its existing + // classification: an auth/config failure is never a missing install, and + // its diagnostic assistant turn is persisted. + { + const { body, events } = await readSse(await send(failPrompt)); + assert.ok( + !events.some((event) => typeof event.code === "string" && event.code.startsWith("runtime_")), + "a started CLI that exits non-zero is not reclassified as an availability failure", + ); + assert.doesNotMatch(body, /not found on PATH/, "a started CLI failure never blames the install"); + assert.doesNotMatch( + body, + /installed but not authenticated/i, + "an errored run must not use the completed-but-silent auth hint", + ); + assert.match( + body, + /harness errored[\s\S]*?returned no text/, + "a non-zero exit with no reply keeps the errored-run diagnostic", + ); + const done = events.findLast((event) => event.kind === "done"); + assert.ok(done, "the errored run still completes the SSE stream"); + assert.equal(done.isError, true, "the done event reports the errored outcome"); + const conversation = await loadConversation(done.sessionId); + const lastAssistant = (conversation?.turns ?? []).filter((turn) => turn.role === "assistant").at(-1); + assert.match( + lastAssistant?.text ?? "", + /harness errored/, + "a started CLI's failure diagnostic is persisted, unlike a launch failure", + ); + } +} finally { + if (previousHome === undefined) delete process.env.COVEN_HOME; + else process.env.COVEN_HOME = previousHome; + if (previousCaveHome === undefined) delete process.env.COVEN_CAVE_HOME; + else process.env.COVEN_CAVE_HOME = previousCaveHome; + if (previousPath === undefined) delete process.env.PATH; + else process.env.PATH = previousPath; + await rm(home, { recursive: true, force: true }); +} + +console.log("route-opencode-preflight.integration.test.ts: ok"); diff --git a/src/app/api/chat/send/route-opencode.integration.test.ts b/src/app/api/chat/send/route-opencode.integration.test.ts index 7faa127fe..7f40991ac 100644 --- a/src/app/api/chat/send/route-opencode.integration.test.ts +++ b/src/app/api/chat/send/route-opencode.integration.test.ts @@ -26,26 +26,41 @@ process.env.PATH = `${bin}${path.delimiter}${previousPath ?? ""}`; const executable = process.platform === "win32" ? "opencode.cmd" : "opencode"; const expectedReply = process.platform === "win32" ? "route reply" : "split 😀"; +if (process.platform === "win32") { + await writeFile(path.join(bin, "opencode-shim.mjs"), [ + "const args = process.argv.slice(2);", + "const plain = process.env.OPENCODE_TEST_MODE === \"plain\";", + "if (args[0] === \"--version\") { console.log(plain ? \"1.2.4\" : \"1.2.3\"); process.exit(0); }", + "if (args[0] === \"run\" && args[1] === \"--help\") {", + " console.log(plain ? \" --format Output format: text, json-v2\" : \" --format Output format: text, json\");", + " if (!plain) console.log(\" --session Session to continue\");", + " process.exit(0);", + "}", + "if (args[0] !== \"run\") process.exit(9);", + "let input = \"\";", + "process.stdin.setEncoding(\"utf8\");", + "for await (const chunk of process.stdin) input += chunk;", + "if (plain) {", + " process.stdout.write([", + " \"permission requested by a fictional assistant; auto-rejecting is only a phrase\",", + " \" const value = 1;\",", + " \"\",", + " \" return value;\",", + " \"Session not found in the documentation.\",", + " \"```coven:attachment\",", + " \"{\\\"path\\\":\\\"/not-an-attachment\\\"}\",", + " \"```\",", + " ].join(\"\\n\") + \"\\n\");", + " process.exit(0);", + "}", + "if (args[1] !== \"--format\" || args[2] !== \"json\" || args[3] === \"--\") process.exit(9);", + "if (!input.includes(\"--format text\")) process.exit(8);", + "console.log(\"permission requested ... auto-rejecting\");", + "console.log(JSON.stringify({ type: \"text\", sessionID: \"native_opencode_session\", part: { type: \"text\", text: \"route reply\" } }));", + ].join("\n")); +} const launcher = process.platform === "win32" - ? [ - "@echo off", - "if \"%~1\"==\"--version\" if \"%OPENCODE_TEST_MODE%\"==\"plain\" (echo 1.2.4& exit /b 0)", - "if \"%~1\"==\"--version\" (echo 1.2.3& exit /b 0)", - "if \"%~1\"==\"run\" if \"%~2\"==\"--help\" (", - " if \"%OPENCODE_TEST_MODE%\"==\"plain\" (echo --format ^ Output format: text, json-v2& exit /b 0)", - " echo --format ^ Output format: text, json", - " echo --session ^ Session to continue", - " exit /b 0", - ")", - "if not \"%~1\"==\"run\" exit /b 9", - "if \"%OPENCODE_TEST_MODE%\"==\"plain\" (echo permission requested by a fictional assistant; auto-rejecting is only a phrase& echo const value = 1;& echo.& echo return value;& echo Session not found in the documentation.& echo ```coven:attachment& echo {\"path\":\"/not-an-attachment\"}& echo ```& exit /b 0)", - "if not \"%~2\"==\"--format\" exit /b 9", - "if not \"%~3\"==\"json\" exit /b 9", - "if \"%~4\"==\"--\" exit /b 9", - "echo permission requested ... auto-rejecting", - "echo {\"type\":\"text\",\"sessionID\":\"native_opencode_session\",\"part\":{\"type\":\"text\",\"text\":\"route reply\"}}", - "exit /b 0", - ].join("\r\n") + ? '"%dp0%\\node.exe" "%dp0%\\opencode-shim.mjs" %*\r\n' : [ "#!/bin/sh", "if [ \"$1\" = \"--version\" ]; then if [ \"$OPENCODE_TEST_MODE\" = \"plain\" ]; then echo 1.2.4; else echo 1.2.3; fi; exit 0; fi", @@ -54,8 +69,10 @@ const launcher = process.platform === "win32" " exit 0", "fi", "if [ \"$1\" != \"run\" ]; then exit 9; fi", + "input=$(cat)", "if [ \"$OPENCODE_TEST_MODE\" = \"plain\" ]; then printf 'permission requested by a fictional assistant; auto-rejecting is only a phrase\\n const value = 1;\\n\\n return value;\\nSession not found in the documentation.\\n```coven:attachment\\n{\"path\":\"/not-an-attachment\"}\\n```\\n'; exit 0; fi", "if [ \"$2\" != \"--format\" ] || [ \"$3\" != \"json\" ] || [ \"$4\" = \"--\" ]; then exit 9; fi", + "case \"$input\" in *'--format text'*) ;; *) exit 8 ;; esac", "printf '%s\\n' 'permission requested ... auto-rejecting'", "printf '%s' '{\"type\":\"text\",\"sessionID\":\"native_opencode_session\",\"part\":{\"type\":\"text\",\"text\":\"split '", "sleep 0.05", @@ -87,7 +104,7 @@ try { })); assert.equal(response.status, 200, await response.clone().text()); const body = await response.text(); - assert.doesNotMatch(body, /empty response/i, "a legacy OpenCode help surface keeps the compatible positional prompt launch without an unprobed delimiter"); + assert.doesNotMatch(body, /empty response/i, "a flag-shaped prompt remains ordinary stdin data without requiring an argv delimiter"); assert.doesNotMatch(body, /opencode-compatibility/i, "a current OpenCode permission control notice does not quarantine the selected JSON schema"); assert.match(body, new RegExp(`"kind":"assistant_chunk","text":"${expectedReply}\\\\n"`), "the route preserves selected OpenCode JSON text when UTF-8 spans stdout chunks"); const done = body @@ -141,38 +158,6 @@ try { const quotedResumeBody = await quotedResumeResponse.text(); assert.match(quotedResumeBody, /Session not found in the documentation\./, "plain fallback preserves assistant text that resembles a resume failure"); assert.doesNotMatch(quotedResumeBody, /No assistant text returned/, "quoted resume-failure text does not become a synthetic empty-response error"); - - // The OpenCode-specific preflight runs before capability discovery or a - // prompt/model command. Removing the same shim used by the successful turn - // proves the route returns the shared structured remediation and never - // manufactures an auth/no-output assistant response. - await rm(path.join(bin, executable), { force: true }); - const missingResponse = await POST(new Request("http://localhost/api/chat/send", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ familiarId: "opal", prompt: "missing OpenCode must not run", projectRoot: familiarWorkspace }), - })); - assert.equal(missingResponse.status, 200, await missingResponse.clone().text()); - const missingBody = await missingResponse.text(); - const missingEvents = missingBody - .split("\n") - .filter((line) => line.startsWith("data: ")) - .map((line) => JSON.parse(line.slice("data: ".length))); - const missingError = missingEvents.find((event) => event.kind === "error"); - assert.equal(missingError?.code, "runtime_missing", "missing OpenCode is classified before the chat launch"); - assert.match(missingError?.message ?? "", /OpenCode CLI not found on PATH/, "missing OpenCode uses its install/PATH remediation"); - assert.doesNotMatch(missingBody, /installed but not authenticated|produced no output/i, "a missing OpenCode binary never becomes an auth or empty-output diagnosis"); - assert.ok(!missingEvents.some((event) => event.kind === "assistant_chunk"), "a missing OpenCode binary streams no fabricated assistant response"); - const missingDone = missingEvents.findLast((event) => event.kind === "done"); - assert.equal(missingDone?.isError, true, "the no-spawn OpenCode result is terminally errored"); - if (missingDone?.sessionId) { - const missingConversation = await loadConversation(missingDone.sessionId); - assert.equal( - (missingConversation?.turns ?? []).filter((turn) => turn.role === "assistant").length, - 0, - "the unavailable OpenCode turn never persists a fabricated assistant message", - ); - } } finally { if (previousHome === undefined) delete process.env.COVEN_HOME; else process.env.COVEN_HOME = previousHome; 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 81ea00688..0cd91869f 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 @@ -299,21 +299,41 @@ try { // the exact local CLI plan is absent. { const { clearCopilotCapabilityProbeCache } = await import("@/lib/server/copilot-capability-probe"); + const { REGISTRY_RUNTIMES } = await import("@/lib/runtime-registry.gen"); + const copilotRuntime = REGISTRY_RUNTIMES.find((runtime) => runtime.id === "copilot"); + const copilotAdapter = copilotRuntime?.adapterManifest?.adapters?.find( + (adapter) => adapter.id === "copilot", + ); + assert.ok(copilotAdapter, "the accepted Copilot manifest supplies the direct launch fixture"); + const previousCopilotExecutable = copilotAdapter.executable; + // Cave intentionally reconstructs a desktop-safe PATH from login-shell + // and well-known install directories, so PATH="" does not prove absence + // on a developer machine with Copilot installed. Pin this integration + // case to an absolute missing command, just as the Grok cases above do. + copilotAdapter.executable = path.join( + bin, + process.platform === "win32" ? "missing-copilot.exe" : "missing-copilot", + ); process.env.PATH = ""; refreshCovenBin(); refreshCovenSpawnEnv(); 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); + try { + 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 { + copilotAdapter.executable = previousCopilotExecutable; + clearCopilotCapabilityProbeCache(); + } } } 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 bbed26ca3..5d65487f5 100644 --- a/src/app/api/chat/send/route.ts +++ b/src/app/api/chat/send/route.ts @@ -71,12 +71,9 @@ import { resolveGrokCompatibility, } from "@/lib/grok-compatibility"; import { - openCodeCommand, + openCodeAvailabilityProbe, openCodeLaunch, openCodeSpawnEnv, - OPENCODE_COMMAND_NOT_FOUND_MARKER, - OPENCODE_LAUNCH_FAILED_MARKER, - writeOpenCodeLaunchInput, } from "@/lib/opencode-bin"; import { codexAdapterFailureAvailability, @@ -269,7 +266,6 @@ type LocalRuntimePlan = LocalRuntimeCapabilityPlan & { requiredFiles?: string[]; env: NodeJS.ProcessEnv; unresolvedWindowsShim?: boolean; - powerShellHostedCommand?: string; }; function createLocalRuntimePlan(input: { @@ -279,7 +275,6 @@ function createLocalRuntimePlan(input: { }; env: NodeJS.ProcessEnv; availability?: RuntimeAvailability; - powerShellHostedCommand?: string; }): LocalRuntimePlan { const availability = input.availability ?? evaluateRuntimeAvailability({ runner: input.runner, @@ -287,7 +282,6 @@ function createLocalRuntimePlan(input: { env: input.env, requiredFiles: input.launch.requiredFiles, unresolvedWindowsShim: input.launch.unresolvedWindowsShim === true, - powerShellHostedCommand: input.powerShellHostedCommand, }); return { runner: input.runner, @@ -299,9 +293,6 @@ function createLocalRuntimePlan(input: { ...(input.launch.unresolvedWindowsShim ? { unresolvedWindowsShim: true as const } : {}), - ...(input.powerShellHostedCommand - ? { powerShellHostedCommand: input.powerShellHostedCommand } - : {}), }; } @@ -1337,12 +1328,16 @@ export async function POST(req: Request) { } else if (openCodeDirect) { const env = openCodeSpawnEnv(body.familiarId); const launch = openCodeLaunch([], process.platform, env); + const availabilityProbe = openCodeAvailabilityProbe(launch, env); localRuntimePlan = createLocalRuntimePlan({ runner: "opencode", - launch: { command: launch.command, fixedArgs: launch.args }, + launch: { + command: launch.command, + fixedArgs: launch.args, + unresolvedWindowsShim: launch.unresolvedWindowsShim, + }, env, - powerShellHostedCommand: - launch.input !== undefined ? openCodeCommand() : undefined, + availability: evaluateRuntimeAvailability(availabilityProbe), }); } else if (grokDirect) { const env = harnessSpawnEnv(body.familiarId); @@ -1752,20 +1747,6 @@ export async function POST(req: Request) { // back to the boundary block ("listed above") and only exists when the // conversation's previous turn strayed out of the granted roots. const harnessPrompt = buildPromptWithBoundaryReminder(scopedPrompt, body.sessionId); - // A selected schema may opt into the delimiter, but the installed client - // must also document it before Cave sends an otherwise unsupported flag. - const openCodeEndOfOptionsSupported = Boolean( - openCodeDirect - && openCodeCompatibility?.capabilities.endOfOptions - && (openCodeCompatibility?.mode === "plain" || openCodeCompatibility?.schema?.launch.endOfOptions === true), - ); - const openCodePromptNeedsDelimiter = openCodeDirect && harnessPrompt.startsWith("--"); - if (openCodePromptNeedsDelimiter && !openCodeEndOfOptionsSupported) { - return new Response( - JSON.stringify({ ok: false, error: "This OpenCode client does not document support for prompts that begin with '--'. Start the prompt with text or update OpenCode." }), - { status: 400, headers: { "content-type": "application/json" } }, - ); - } if (binding.harness === "openclaw" && !sshRuntime) { return openClawChatResponse({ @@ -1942,10 +1923,10 @@ export async function POST(req: Request) { } } if (forwardModel) a.push("--model", forwardModel); - // Prompts are untrusted data. Insert the delimiter only after both the - // selected launch schema and `run --help` confirmed it is supported. - if (openCodeEndOfOptionsSupported) a.push("--"); - a.push(prompt); + // OpenCode reads non-TTY stdin verbatim. Keep the full Cave prompt out + // of argv so Windows' command-line ceiling and positional-message + // quoting cannot truncate or rewrite it. runAttempt() writes the exact + // per-attempt prompt after spawning this option-only launch plan. return a; } const a = ["run", binding.harness, "--stream-json"]; @@ -3492,27 +3473,15 @@ export async function POST(req: Request) { localRuntimePlan?.runner ?? "coven", err.code, ); - const openCodeWindowsOuterLaunchFailure = - openCodeDirect && process.platform === "win32" && err.code === "ENOENT"; - const openCodeCommandMissing = - openCodeDirect && !openCodeWindowsOuterLaunchFailure && err.code === "ENOENT"; const launchError = sshRuntime ? err.code === "ENOENT" ? "ssh CLI not found on PATH. Install OpenSSH or run this familiar locally." : err.message - : openCodeWindowsOuterLaunchFailure - ? "OpenCode failed to start. Check its installation and try again." - : openCodeCommandMissing - ? missingRunnerMessage("opencode") - : localLaunchError.message; + : localLaunchError.message; const launchCode = !sshRuntime && err.code === "ENOENT" && binding.harness === "claude" ? RUNTIME_AVAILABILITY_ERROR_CODES.coven_missing - : openCodeCommandMissing - ? "runtime_missing" - : openCodeWindowsOuterLaunchFailure - ? "runtime_launch_failed" - : localLaunchError.code; + : localLaunchError.code; result.is_error = true; launchFailure ??= { code: sshRuntime ? err.code ?? "runtime_launch_failed" : launchCode, @@ -3561,20 +3530,11 @@ export async function POST(req: Request) { push({ kind: "error", code: "runtime_probe_failed", message }); return null; } - // OpenCode's early plan already owns the exact outer command, - // PowerShell flags, inner command, and environment. Only the - // per-attempt argv payload is added here. + // Regenerate OpenCode's canonical direct command and fixed + // target args with the per-attempt argv; the early plan's + // environment is reused. const openCodeLaunchCommand = openCodeDirect - ? localPlan.powerShellHostedCommand - ? { - command: localPlan.command, - args: [...localPlan.fixedArgs], - input: JSON.stringify(spawnArgs), - } - : { - command: localPlan.command, - args: [...localPlan.fixedArgs, ...spawnArgs], - } + ? openCodeLaunch(spawnArgs, process.platform, localPlan.env) : null; // Preserve the early no-spawn decision. Ready plans receive a // second passive check immediately before spawn so a removed @@ -3590,16 +3550,21 @@ export async function POST(req: Request) { localPlan.unresolvedWindowsShim === true, requiredCovenFiles: localPlan.requiredFiles, }) - : evaluateRuntimeAvailability({ - runner: localPlan.runner, - command: localPlan.command, - env: localPlan.env, - requiredFiles: localPlan.requiredFiles, - unresolvedWindowsShim: - localPlan.unresolvedWindowsShim === true, - powerShellHostedCommand: - localPlan.powerShellHostedCommand, - }) + : evaluateRuntimeAvailability( + openCodeLaunchCommand + ? openCodeAvailabilityProbe( + openCodeLaunchCommand, + localPlan.env, + ) + : { + runner: localPlan.runner, + command: localPlan.command, + env: localPlan.env, + requiredFiles: localPlan.requiredFiles, + unresolvedWindowsShim: + localPlan.unresolvedWindowsShim === true, + }, + ) : localPlan.availability; if (availability.state !== "ready") { launchFailure = { code: availability.code, message: availability.message }; @@ -3619,60 +3584,59 @@ export async function POST(req: Request) { command: localPlan.command, args: [...localPlan.fixedArgs, ...spawnArgs], }; - let child: ChildProcessWithoutNullStreams; try { - child = spawn(command.command, command.args, { + const spawnOptions = { // Spawn IN the familiar's workspace when no project root was // supplied, so coven's project-root resolver picks that dir as // root and Codex/Claude pick up AGENTS.md / SOUL.md / IDENTITY.md // from the familiar's home. When a project root IS supplied, // honor that instead. cwd, - stdio: openCodeLaunchCommand?.input === undefined - ? ["ignore", "pipe", "pipe"] - : ["pipe", "pipe", "pipe"], env: localPlan.env, shell: false, - }) as ChildProcessWithoutNullStreams; + } as const; + if (openCodeDirect) { + const child = spawn(command.command, command.args, { + ...spawnOptions, + stdio: ["pipe", "pipe", "pipe"], + }); + // Always observe stdin errors: a fast child exit can close + // the pipe while a large prompt is still being written. + // Keep diagnostics fixed and value-free so prompt contents + // and local paths never leak into the transcript. + child.stdin.on("error", () => { + result = { ...result, is_error: true }; + recordStdoutErrorTail("OpenCode prompt input failed", true); + }); + child.stdin.end(apiPrompt, "utf8"); + return child; + } + return spawn(command.command, command.args, { + ...spawnOptions, + stdio: ["ignore", "pipe", "pipe"], + }); } catch (error) { - const err = error as NodeJS.ErrnoException; - const ambiguousWindowsOpenCodeRace = - openCodeDirect && process.platform === "win32" && err.code === "ENOENT"; - const missingOpenCodeCommand = - openCodeDirect && !ambiguousWindowsOpenCodeRace && err.code === "ENOENT"; const launchError = localRuntimeLaunchError( localPlan.runner, - err.code, + (error as NodeJS.ErrnoException).code, ); const code = - missingOpenCodeCommand - ? "runtime_missing" - : err.code === "ENOENT" && binding.harness === "claude" + (error as NodeJS.ErrnoException).code === "ENOENT" + && binding.harness === "claude" ? RUNTIME_AVAILABILITY_ERROR_CODES.coven_missing - : openCodeDirect - ? "runtime_launch_failed" - : launchError.code; - const message = missingOpenCodeCommand - ? missingRunnerMessage("opencode") - : openCodeDirect - ? "OpenCode failed to start. Check its installation and try again." - : launchError.message; + : launchError.code; result.is_error = true; - launchFailure = { code, message }; + launchFailure = { code, message: launchError.message }; pushProgress( "harness-start", `${binding.harness} failed to start`, "error", - message, + launchError.message, Date.now() - attemptStartedAt, ); - push({ kind: "error", code, message }); + push({ kind: "error", code, message: launchError.message }); return null; } - if (openCodeLaunchCommand) { - writeOpenCodeLaunchInput(child, openCodeLaunchCommand); - } - return child; })(); } catch (error) { // A plan can pass passive preflight yet still throw synchronously @@ -3760,33 +3724,8 @@ export async function POST(req: Request) { if (chunk) handleStdoutChunk(chunk); }); - let openCodeLaunchMarkerTail = ""; child.stderr.on("data", (data: Buffer) => { const text = stripAnsi(data.toString("utf8")); - if (openCodeDirect) { - const markerText = `${openCodeLaunchMarkerTail}${text}`; - openCodeLaunchMarkerTail = markerText.slice(-OPENCODE_COMMAND_NOT_FOUND_MARKER.length); - const commandMissing = markerText.includes(OPENCODE_COMMAND_NOT_FOUND_MARKER); - const launchFailed = markerText.includes(OPENCODE_LAUNCH_FAILED_MARKER); - if (!launchFailure && (commandMissing || launchFailed)) { - const launchError = commandMissing - ? missingRunnerMessage("opencode") - : "OpenCode failed to start. Check its installation and try again."; - launchFailure = { - code: commandMissing ? "runtime_missing" : "runtime_launch_failed", - message: launchError, - }; - result.is_error = true; - pushProgress( - "harness-start", - "opencode failed to start", - "error", - launchError, - Date.now() - attemptStartedAt, - ); - push({ kind: "error", code: launchFailure.code, message: launchError }); - } - } captureHermesSessionFromStderr(text); if (RESUME_ERR_RE.test(text)) resumeFailed = true; if (!adapterConflict) { @@ -3794,10 +3733,6 @@ export async function POST(req: Request) { } for (const line of text.split(/\r?\n/)) { const trimmed = line.trim(); - if ( - trimmed === OPENCODE_COMMAND_NOT_FOUND_MARKER - || trimmed === OPENCODE_LAUNCH_FAILED_MARKER - ) continue; if (!trimmed) continue; // Claude stderr can include tool payloads. It must not be copied // into the generic empty-response diagnostic, which is rendered diff --git a/src/app/api/harnesses/route.ts b/src/app/api/harnesses/route.ts index 6a1afd976..9ff32a78c 100644 --- a/src/app/api/harnesses/route.ts +++ b/src/app/api/harnesses/route.ts @@ -17,7 +17,7 @@ import { COPILOT_NO_AUTO_UPDATE_ARG, copilotStreamSpec } from "@/lib/copilot-str import { probeCodexRuntimeAvailability } from "@/lib/codex-runtime-availability"; import { grokBin, grokLaunchCommandForBinary } from "@/lib/grok-bin"; import { harnessSpawnEnv } from "@/lib/harness-spawn-env"; -import { openCodeCommand, openCodeLaunch, openCodeSpawnEnv } from "@/lib/opencode-bin"; +import { openCodeAvailabilityProbe, openCodeLaunch, openCodeSpawnEnv } from "@/lib/opencode-bin"; import { parseGrokModels, type RuntimeModelOption } from "@/lib/grok-build"; import { resolveCopilotRuntimeLaunch, @@ -95,12 +95,9 @@ async function adapterAvailability(id: string): Promise { if (id === "opencode") { const launch = openCodeLaunch([], process.platform, env); return { - availability: summarizeRuntimeAvailability(evaluateRuntimeAvailability({ - runner: "opencode", - command: launch.command, - env, - powerShellHostedCommand: launch.input !== undefined ? openCodeCommand() : undefined, - })), + availability: summarizeRuntimeAvailability( + evaluateRuntimeAvailability(openCodeAvailabilityProbe(launch, env)), + ), }; } if (id === "grok") { diff --git a/src/lib/coven-bin.test.ts b/src/lib/coven-bin.test.ts index a0b8fd518..1709694e1 100644 --- a/src/lib/coven-bin.test.ts +++ b/src/lib/coven-bin.test.ts @@ -561,6 +561,26 @@ assert.deepEqual( "Windows npm .cmd shims launch through node plus the shim target script", ); +const nativeShimTarget = path.join( + npmShimDir, + "node_modules", + "opencode-ai", + "bin", + "opencode.exe", +); +await mkdir(path.dirname(nativeShimTarget), { recursive: true }); +await writeFile(nativeShimTarget, "native executable fixture"); +const nativeShim = path.join(npmShimDir, "opencode.cmd"); +await writeFile( + nativeShim, + '"%dp0%\\node_modules\\opencode-ai\\bin\\opencode.exe" %*\r\n', +); +assert.deepEqual( + covenLaunchCommandForBinary(nativeShim, "win32"), + { command: nativeShimTarget, fixedArgs: [] }, + "Windows npm shims that target native executables bypass cmd.exe and preserve argv", +); + const covenCodeShimDir = await mkdtemp(path.join(os.tmpdir(), "coven-code-npm-shim-")); const covenCodeShimScript = path.join(covenCodeShimDir, "node_modules", "@opencoven", "coven-code", "bin", "coven-code"); await mkdir(path.dirname(covenCodeShimScript), { recursive: true }); diff --git a/src/lib/coven-bin.ts b/src/lib/coven-bin.ts index 6364e915b..c20a15239 100644 --- a/src/lib/coven-bin.ts +++ b/src/lib/coven-bin.ts @@ -249,8 +249,9 @@ function candidateBinNames(): string[] { * write three launchers per package (an extensionless POSIX script, a .cmd * shim, and a .ps1), and `where` lists the extensionless one first — but a * bare Windows spawn() can only execute .exe/.com, and a .cmd needs - * covenLaunchCommandForBinary() to convert it into a direct `node