diff --git a/src/app/api/api-contracts.test.ts b/src/app/api/api-contracts.test.ts index cbac800f5..f1c971733 100644 --- a/src/app/api/api-contracts.test.ts +++ b/src/app/api/api-contracts.test.ts @@ -462,12 +462,12 @@ for (const contract of contracts) { ); assert.match( sendSource, - /const reportLaunchFailure = \(err: NodeJS\.ErrnoException\) => \{[\s\S]{0,800}?const launchCode =[\s\S]{0,300}?launchFailure \?\?= \{[\s\S]{0,180}?code: sshRuntime \? err\.code \?\? "runtime_launch_failed" : launchCode,[\s\S]{0,400}?pushProgress\([\s\S]{0,220}?launchError,[\s\S]{0,300}?code: launchCode/, - "/chat/send: launch state, progress, and the post-spawn ENOENT race event must reuse one normalized message", + /const reportLaunchFailure = \(err: NodeJS\.ErrnoException\) => \{[\s\S]*?const launchCode =[\s\S]*?launchFailure \?\?= \{[\s\S]*?code: sshRuntime \? err\.code \?\? "runtime_launch_failed" : launchCode,[\s\S]*?message: launchError,[\s\S]*?pushProgress\([\s\S]*?launchError,[\s\S]*?code: launchFailure\.code[\s\S]*?message: launchError/, + "/chat/send: launch state, progress, and the post-spawn race event must reuse one normalized message and structured code", ); assert.match( sendSource, - /const localLaunchError = localRuntimeLaunchError\([\s\S]{0,100}?err\.code,[\s\S]{0,80}?const launchError = sshRuntime[\s\S]{0,180}?: localLaunchError\.message/, + /const localLaunchError = localRuntimeLaunchError\([\s\S]{0,200}?err\.code,[\s\S]{0,800}?const launchError = sshRuntime[\s\S]{0,600}?: localLaunchError\.message/, "/chat/send: every local post-spawn failure uses the shared runner-specific normalizer while SSH retains transport diagnostics", ); assert.match( 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 40128aa96..bbfc63535 100644 --- a/src/app/api/chat/send/chat-send-capabilities.test.ts +++ b/src/app/api/chat/send/chat-send-capabilities.test.ts @@ -32,10 +32,16 @@ assert.notEqual( assert.equal(openCodeCapabilityProbeScope(), "default", "the unscoped probe cache has a stable explicit scope"); assert.deepEqual(openCodeProbeSpawnOptions("linux"), { detached: true }, "POSIX OpenCode probes create an isolated process group that timeout cleanup can terminate"); assert.deepEqual(openCodeProbeSpawnOptions("win32"), { detached: false }, "Windows probes rely on taskkill's explicit process-tree cleanup rather than a detached process group"); -const firstCapabilities = await openCodeRunCapabilities("probe-fixture", async () => ({ - helpProbe: { complete: true, output: " --format Output format: text, json\n" }, - versionProbe: { complete: true, output: "opencode 1.0.0" }, -}), () => "probe-fixture-launcher"); +const probeEnv = { PATH: "/scoped/opencode", XDG_RUNTIME_DIR: "/tmp" }; +let observedProbeEnv: NodeJS.ProcessEnv | undefined; +const firstCapabilities = await openCodeRunCapabilities("probe-fixture", async (env) => { + observedProbeEnv = env; + return { + helpProbe: { complete: true, output: " --format Output format: text, json\n" }, + versionProbe: { complete: true, output: "opencode 1.0.0" }, + }; +}, probeEnv); +assert.equal(observedProbeEnv, probeEnv, "callers can retain one scoped environment for preflight, probes, and launch"); const replacementCapabilities = await openCodeRunCapabilities("probe-fixture", async () => ({ helpProbe: { complete: true, output: " --format Output format: text\n" }, versionProbe: { complete: true, output: "opencode 1.0.0" }, diff --git a/src/app/api/chat/send/chat-send-capabilities.ts b/src/app/api/chat/send/chat-send-capabilities.ts index 2a22bfa6a..c7a174f97 100644 --- a/src/app/api/chat/send/chat-send-capabilities.ts +++ b/src/app/api/chat/send/chat-send-capabilities.ts @@ -386,8 +386,8 @@ function advertisedStructuredSwitches(options: string[], noValueOptions: string[ } async function probeOpenCodeRunContract(env: NodeJS.ProcessEnv): Promise { - const helpLaunch = openCodeLaunch(["run", "--help"]); - const versionLaunch = openCodeLaunch(["--version"]); + const helpLaunch = openCodeLaunch(["run", "--help"], process.platform, env); + const versionLaunch = openCodeLaunch(["--version"], process.platform, env); const [helpProbe, versionProbe] = await Promise.all([ probeOutput(helpLaunch.command, helpLaunch.args, env, helpLaunch.input), probeOutput(versionLaunch.command, versionLaunch.args, env, versionLaunch.input), @@ -601,12 +601,13 @@ export function hermesChatSupportsModel(): Promise { /** OpenCode is direct-spawned so its own documented capability is authoritative. */ export function openCodeRunSupportsModel(): Promise { - const launch = openCodeLaunch(["run", "--help"]); + const env = openCodeSpawnEnv(); + const launch = openCodeLaunch(["run", "--help"], process.platform, env); return (openCodeModelFlagProbe ??= probeHelp( launch.command, launch.args, (help) => /(^|\s)--model(?![\w-])/m.test(help), - openCodeSpawnEnv(), + env, launch.input, )); } @@ -619,13 +620,18 @@ export function openCodeRunSupportsModel(): Promise { export async function openCodeRunCapabilities( familiarId?: string, probeRunContract: (env: NodeJS.ProcessEnv) => Promise = probeOpenCodeRunContract, - capabilityIdentity: CapabilityIdentityProbe = openCodeCapabilityLaunchIdentity, - now: CapabilityClock = Date.now, + spawnEnvOrCapabilityIdentity?: NodeJS.ProcessEnv | CapabilityIdentityProbe, + capabilityIdentityNow: CapabilityClock = Date.now, ): Promise { // Probe the exact scoped environment used for this chat turn. A version // string alone is not a safe cache key: package managers and shims can // replace a CLI in place while preserving both PATH and `--version`. - const env = openCodeSpawnEnv(familiarId); + const env = typeof spawnEnvOrCapabilityIdentity === "function" + ? openCodeSpawnEnv(familiarId) + : spawnEnvOrCapabilityIdentity ?? openCodeSpawnEnv(familiarId); + const capabilityIdentity = typeof spawnEnvOrCapabilityIdentity === "function" + ? spawnEnvOrCapabilityIdentity + : openCodeCapabilityLaunchIdentity; // Hashing a large npm executable is streamed asynchronously and overlaps // the help/version children, so it never blocks the request event loop. const launcherIdentityBeforeProbe = Promise.resolve(capabilityIdentity(env)); @@ -642,7 +648,7 @@ export async function openCodeRunCapabilities( ? versionProbe.output.match(/\b\d+(?:\.\d+){1,3}(?:[-+][\w.-]+)?\b/)?.[0] ?? null : null; const scope = openCodeCapabilityProbeScope(familiarId); - const observedNow = now(); + const observedNow = capabilityIdentityNow(); pruneVerifiedOpenCodeCapabilities(observedNow); if (helpProbe.complete) { const capabilities = { 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 18e0ef2a6..94a0fc390 100644 --- a/src/app/api/chat/send/harness-routing-opencode.test.ts +++ b/src/app/api/chat/send/harness-routing-opencode.test.ts @@ -12,7 +12,7 @@ assert.match( ); assert.match( route, - /let localRuntimePlan: LocalRuntimePlan \| null = null;[\s\S]*?const openCodeCapabilities = openCodeDirect[\s\S]*?probeReadyLocalRuntimeCapability\(\{[\s\S]*?plan: localRuntimePlan,[\s\S]*?runner: "opencode",[\s\S]*?probe: \(\) => openCodeRunCapabilities\(body\.familiarId\),[\s\S]*?resolveOpenCodeCompatibility\(openCodeCapabilities\)/, + /let localRuntimePlan: LocalRuntimePlan \| null = null;[\s\S]*?const openCodeCapabilities = openCodeDirect[\s\S]*?probeReadyLocalRuntimeCapability\(\{[\s\S]*?plan: localRuntimePlan,[\s\S]*?runner: "opencode",[\s\S]*?probe: \(\) => openCodeRunCapabilities\(body\.familiarId, undefined, localRuntimePlan\?\.env\),[\s\S]*?resolveOpenCodeCompatibility\(openCodeCapabilities\)/, "OpenCode capability discovery starts only after its exact local launch plan is ready", ); assert.match( @@ -70,9 +70,24 @@ assert.match( /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", ); +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", +); +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", +); +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", +); assert.match( capabilities, - /const launch = openCodeLaunch\(\["run", "--help"\]\);[\s\S]*?launch\.command,[\s\S]*?launch\.args,[\s\S]*?openCodeSpawnEnv\(\),/, + /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", ); assert.match( @@ -89,7 +104,7 @@ assert.match( const earlyGate = route.indexOf("const openCodeCapabilities"); assert.ok(earlyGate >= 0, "capability routing begins only after local plans are established"); for (const capabilityCall of [ - "openCodeRunCapabilities(body.familiarId)", + "openCodeRunCapabilities(body.familiarId, undefined, localRuntimePlan?.env)", "probe: hermesChatSupportsModel", "probeCovenCapability(covenRunSupportsModel)", "probeCovenCapability(covenRunSupportsPermission)", 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 13451c402..7faa127fe 100644 --- a/src/app/api/chat/send/route-opencode.integration.test.ts +++ b/src/app/api/chat/send/route-opencode.integration.test.ts @@ -141,6 +141,38 @@ 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.ts b/src/app/api/chat/send/route.ts index 6762a6d19..bbed26ca3 100644 --- a/src/app/api/chat/send/route.ts +++ b/src/app/api/chat/send/route.ts @@ -70,7 +70,14 @@ import { redactedGrokEventFingerprint, resolveGrokCompatibility, } from "@/lib/grok-compatibility"; -import { openCodeCommand, openCodeLaunch, openCodeSpawnEnv, writeOpenCodeLaunchInput } from "@/lib/opencode-bin"; +import { + openCodeCommand, + openCodeLaunch, + openCodeSpawnEnv, + OPENCODE_COMMAND_NOT_FOUND_MARKER, + OPENCODE_LAUNCH_FAILED_MARKER, + writeOpenCodeLaunchInput, +} from "@/lib/opencode-bin"; import { codexAdapterFailureAvailability, probeCodexRuntimeAvailability, @@ -1377,7 +1384,7 @@ export async function POST(req: Request) { ? await probeReadyLocalRuntimeCapability({ plan: localRuntimePlan, runner: "opencode", - probe: () => openCodeRunCapabilities(body.familiarId), + probe: () => openCodeRunCapabilities(body.familiarId, undefined, localRuntimePlan?.env), }) : null; const openCodeCompatibility = openCodeCapabilities @@ -3485,15 +3492,27 @@ 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 - : localLaunchError.message; + : openCodeWindowsOuterLaunchFailure + ? "OpenCode failed to start. Check its installation and try again." + : openCodeCommandMissing + ? missingRunnerMessage("opencode") + : localLaunchError.message; const launchCode = !sshRuntime && err.code === "ENOENT" && binding.harness === "claude" ? RUNTIME_AVAILABILITY_ERROR_CODES.coven_missing - : localLaunchError.code; + : openCodeCommandMissing + ? "runtime_missing" + : openCodeWindowsOuterLaunchFailure + ? "runtime_launch_failed" + : localLaunchError.code; result.is_error = true; launchFailure ??= { code: sshRuntime ? err.code ?? "runtime_launch_failed" : launchCode, @@ -3508,7 +3527,7 @@ export async function POST(req: Request) { ); push({ kind: "error", - ...(sshRuntime ? (err.code ? { code: err.code } : {}) : { code: launchCode }), + ...(sshRuntime ? (err.code ? { code: err.code } : {}) : { code: launchFailure.code }), message: launchError, }); }; @@ -3616,25 +3635,38 @@ export async function POST(req: Request) { shell: false, }) as ChildProcessWithoutNullStreams; } 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, - (error as NodeJS.ErrnoException).code, + err.code, ); const code = - (error as NodeJS.ErrnoException).code === "ENOENT" - && binding.harness === "claude" + missingOpenCodeCommand + ? "runtime_missing" + : err.code === "ENOENT" && binding.harness === "claude" ? RUNTIME_AVAILABILITY_ERROR_CODES.coven_missing - : launchError.code; + : openCodeDirect + ? "runtime_launch_failed" + : launchError.code; + const message = missingOpenCodeCommand + ? missingRunnerMessage("opencode") + : openCodeDirect + ? "OpenCode failed to start. Check its installation and try again." + : launchError.message; result.is_error = true; - launchFailure = { code, message: launchError.message }; + launchFailure = { code, message }; pushProgress( "harness-start", `${binding.harness} failed to start`, "error", - launchError.message, + message, Date.now() - attemptStartedAt, ); - push({ kind: "error", code, message: launchError.message }); + push({ kind: "error", code, message }); return null; } if (openCodeLaunchCommand) { @@ -3728,8 +3760,33 @@ 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) { @@ -3737,6 +3794,10 @@ 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.test.ts b/src/app/api/harnesses/route.test.ts index 71b291375..373a2afb7 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 env =\s*id === "opencode" \? openCodeSpawnEnv\(null\) : harnessSpawnEnv\(null\);[\s\S]*?const launch = openCodeLaunch\(\[\], process\.platform, env\);/, + "OpenCode availability derives its Windows PowerShell host from the same scoped environment it probes", +); assert.match( source, /if \(id === "codex"\) \{[\s\S]*?await probeCodexRuntimeAvailability\(\{[\s\S]*?launch: covenLaunchCommand\(\),[\s\S]*?env,[\s\S]*?\}\)/, diff --git a/src/app/api/harnesses/route.ts b/src/app/api/harnesses/route.ts index 770ed7db9..6a1afd976 100644 --- a/src/app/api/harnesses/route.ts +++ b/src/app/api/harnesses/route.ts @@ -93,7 +93,7 @@ async function adapterAvailability(id: string): Promise { // No stream manifest → copilot chats fall back to `coven run` below. } if (id === "opencode") { - const launch = openCodeLaunch([]); + const launch = openCodeLaunch([], process.platform, env); return { availability: summarizeRuntimeAvailability(evaluateRuntimeAvailability({ runner: "opencode", diff --git a/src/components/chat-view.test.ts b/src/components/chat-view.test.ts index b1a48366d..557a35b8f 100644 --- a/src/components/chat-view.test.ts +++ b/src/components/chat-view.test.ts @@ -226,7 +226,7 @@ assert.match( ); assert.match( source, - /const covenMissing = useMemo\([\s\S]{0,500}?Coven CLI \(\?:not found on PATH\|was found as a Windows launcher shim\|is installed as a Windows command shim\)[\s\S]{0,500}?Windows PowerShell was not found at its system location, so Coven CLI cannot be launched[\s\S]{0,500}?code === "ENOENT"/, + /const runtimeMissing = useMemo\([\s\S]{0,500}?code === "runtime_missing"[\s\S]{0,500}?Coven CLI \(\?:not found on PATH\|was found as a Windows launcher shim\|is installed as a Windows command shim\)[\s\S]{0,500}?Windows PowerShell was not found at its system location, so Coven CLI cannot be launched[\s\S]{0,500}?code === "ENOENT"/, "the error strip offers Setup for missing and known-unlaunchable Coven launchers", ); assert.match( diff --git a/src/components/chat-view.tsx b/src/components/chat-view.tsx index 42c7db2b0..3700f979a 100644 --- a/src/components/chat-view.tsx +++ b/src/components/chat-view.tsx @@ -606,13 +606,13 @@ function ChatErrorStrip({ () => parseHarnessAuthFailure(detailText, harnessId), [detailText, harnessId], ); - // The Coven CLI couldn't be resolved from the app's spawn environment - // (the #2610 class of failure). Rather than a bare error + generic Retry, - // offer a soft "Open Setup" link (overlay, not a hard nav) — the message - // stays in the composer for retry (#2618). - const covenMissing = useMemo( + // A preflight-confirmed missing runtime (or the legacy Coven ENOENT path) + // gets a soft Setup recovery instead of a bare error + generic Retry. The + // message stays in the composer for retry (#2618, #3862). + const runtimeMissing = useMemo( () => - /Coven CLI (?:not found on PATH|was found as a Windows launcher shim|is installed as a Windows command shim)/i.test(message) + code === "runtime_missing" + || /Coven CLI (?:not found on PATH|was found as a Windows launcher shim|is installed as a Windows command shim)/i.test(message) || /Windows PowerShell was not found at its system location, so Coven CLI cannot be launched/i.test(message) || code === "ENOENT", [message, code], @@ -699,11 +699,11 @@ function ChatErrorStrip({ {!harnessFailure && authFailure ? ( ) : null} - {!harnessFailure && !authFailure && covenMissing ? ( + {!harnessFailure && !authFailure && runtimeMissing ? (
- The Coven CLI isn't resolvable from this app's environment. Open Setup to install - or repair it, then retry — your message is kept. + This runtime isn't resolvable from the app's environment. Open Setup to install or repair + it, then retry — your message is kept.
) : null} - {!harnessFailure && !authFailure && !covenMissing && onPickProject && pickProjectOptions ? ( + {!harnessFailure && !authFailure && !runtimeMissing && onPickProject && pickProjectOptions ? (
{pickProjectOptions.length ? ( <> diff --git a/src/components/familiar-studio-brain-tab.test.ts b/src/components/familiar-studio-brain-tab.test.ts index 15328e9ee..dda97686c 100644 --- a/src/components/familiar-studio-brain-tab.test.ts +++ b/src/components/familiar-studio-brain-tab.test.ts @@ -477,6 +477,16 @@ assert.match( /familiar-studio-brain__hint familiar-studio-brain__hint--warn" role="status"/, "not-ready states render as warning hints announced via role=status", ); +assert.match( + source, + /import type \{ RuntimeAvailabilitySummary \} from "@\/lib\/runtime-availability";[\s\S]*?availability\?: RuntimeAvailabilitySummary;/, + "the runtime picker receives the launchability summary returned by /api/harnesses", +); +assert.match( + source, + /const selectedHarnessAvailability = harnesses\.find\(\(item\) => item\.id === harnessId\)\?\.availability;[\s\S]*?selectedHarnessAvailability\.state !== "ready"[\s\S]*?selectedHarnessAvailability\.message/, + "the selected runtime shows truthful launch remediation rather than only an install bit", +); assert.match( source, /kind === "on-device"[\s\S]{0,200}runs fully on-device/, diff --git a/src/components/familiar-studio-brain-tab.tsx b/src/components/familiar-studio-brain-tab.tsx index 1bac1b201..e11b3bfe8 100644 --- a/src/components/familiar-studio-brain-tab.tsx +++ b/src/components/familiar-studio-brain-tab.tsx @@ -275,6 +275,7 @@ export function FamiliarStudioBrainTab({ familiar }: Props) { const defaultHarnessId = familiar.defaultHarness ?? familiar.harness ?? ""; const defaultHarnessLabel = runtimeLabel(defaultHarnessId, harnesses); const harnessId = draftHarness || defaultHarnessId; + const selectedHarnessAvailability = harnesses.find((item) => item.id === harnessId)?.availability; // Model parity: source the per-familiar model menu from the same runtime → // provider catalog the chat picker uses. allowCustom keeps the free-text @@ -861,6 +862,11 @@ export function FamiliarStudioBrainTab({ familiar }: Props) { } satisfies StandardSelectGroup, ]} /> + {selectedHarnessAvailability?.state !== undefined && selectedHarnessAvailability.state !== "ready" && selectedHarnessAvailability.message ? ( +

+ {selectedHarnessAvailability.message} +

+ ) : null}
diff --git a/src/lib/opencode-bin.test.ts b/src/lib/opencode-bin.test.ts index e4df8aff7..f566b6772 100644 --- a/src/lib/opencode-bin.test.ts +++ b/src/lib/opencode-bin.test.ts @@ -1,6 +1,13 @@ // @ts-nocheck import assert from "node:assert/strict"; -import { openCodeCommand, openCodeLaunch, openCodeNeedsTmpRuntimeDir, preferOpenCodeLaunchPath } from "./opencode-bin.ts"; +import { + openCodeCommand, + openCodeLaunch, + openCodeNeedsTmpRuntimeDir, + OPENCODE_COMMAND_NOT_FOUND_MARKER, + OPENCODE_LAUNCH_FAILED_MARKER, + preferOpenCodeLaunchPath, +} from "./opencode-bin.ts"; assert.equal(openCodeCommand(), "opencode", "OpenCode uses the same executable name on all desktop platforms"); @@ -14,6 +21,9 @@ assert.equal(windowsLaunch.command, "C:\\Windows\\System32\\WindowsPowerShell\\v assert.ok(windowsLaunch.args.includes("-NoProfile"), "Windows launch does not load profile aliases"); assert.equal(windowsLaunch.input, JSON.stringify(["run", "safe & literal", "percent%PATH%"]), "Windows launch preserves untrusted argv as JSON data"); assert.match(windowsLaunch.args.at(-1) ?? "", /\[Console\]::In\.ReadToEnd\(\)/, "Windows launch reads argv from stdin instead of a shell command"); +assert.match(windowsLaunch.args.at(-1) ?? "", new RegExp(OPENCODE_COMMAND_NOT_FOUND_MARKER), "Windows reports an inner OpenCode command race without exposing shell details"); +assert.match(windowsLaunch.args.at(-1) ?? "", new RegExp(OPENCODE_LAUNCH_FAILED_MARKER), "Windows reports other inner launch failures without exposing shell details"); +assert.ok(!windowsLaunch.args.join(" ").includes("safe & literal"), "Windows never interpolates untrusted argv into PowerShell code"); const longPrompt = "x".repeat(40_000); const longWindowsLaunch = openCodeLaunch(["run", longPrompt], "win32", { SystemRoot: "C:\\Windows" }); assert.equal(longWindowsLaunch.input, JSON.stringify(["run", longPrompt]), "Windows preserves a long prompt outside the command line"); diff --git a/src/lib/opencode-bin.ts b/src/lib/opencode-bin.ts index 8a4dd2c31..6063645ce 100644 --- a/src/lib/opencode-bin.ts +++ b/src/lib/opencode-bin.ts @@ -6,6 +6,13 @@ export function openCodeCommand(): string { return "opencode"; } +// Fixed, value-free sentinels emitted only when the Windows PowerShell host +// could not invoke the inner CLI after the route's preflight passed. They let +// the route preserve the no-synthetic-turn launch-failure contract for the +// small race between the stat check and PowerShell resolving `opencode`. +export const OPENCODE_COMMAND_NOT_FOUND_MARKER = "COVEN_OPENCODE_COMMAND_NOT_FOUND"; +export const OPENCODE_LAUNCH_FAILED_MARKER = "COVEN_OPENCODE_LAUNCH_FAILED"; + export type OpenCodeLaunch = { command: string; args: string[]; input?: string }; function windowsPowerShell(env: NodeJS.ProcessEnv): string { @@ -34,8 +41,7 @@ export function openCodeLaunch( "[Console]::OutputEncoding = $utf8", "$OutputEncoding = $utf8", "$openCodeArgs = [Console]::In.ReadToEnd() | ConvertFrom-Json", - "& opencode @openCodeArgs", - "exit $LASTEXITCODE", + "try { & opencode @openCodeArgs; exit $LASTEXITCODE } catch { if ($_.Exception -is [System.Management.Automation.CommandNotFoundException]) { [Console]::Error.WriteLine('COVEN_OPENCODE_COMMAND_NOT_FOUND'); exit 127 }; [Console]::Error.WriteLine('COVEN_OPENCODE_LAUNCH_FAILED'); exit 126 }", ].join("; "); return { command: windowsPowerShell(env), diff --git a/src/lib/runtime-availability.test.ts b/src/lib/runtime-availability.test.ts index a849fcb85..d0f2ad1b8 100644 --- a/src/lib/runtime-availability.test.ts +++ b/src/lib/runtime-availability.test.ts @@ -19,6 +19,7 @@ import { summarizeRuntimeAvailability, RUNTIME_AVAILABILITY_ERROR_CODES, } from "./runtime-availability.ts"; +import { openCodeCommand, openCodeLaunch } from "./opencode-bin.ts"; const scratch = mkdtempSync(path.join(tmpdir(), "runtime-availability-")); try { @@ -38,7 +39,6 @@ try { }, "every non-ENOENT local spawn error uses the normalized launch-failure contract", ); - const binDir = path.join(scratch, "bin"); const emptyDir = path.join(scratch, "empty"); mkdirSync(binDir); @@ -50,6 +50,8 @@ try { chmodSync(executable, 0o755); // Verification matrix: binary resolves in the spawn env → ready. + // Do not use this Windows host's temporary path while simulating Linux: + // a drive letter contains `:`, which is a POSIX PATH delimiter. const ready = evaluateRuntimeAvailability({ runner: "grok", command: nativeGrok, @@ -383,13 +385,23 @@ try { // OpenCode's Windows launch is PowerShell-hosted: the host must exist and // the inner `opencode` command must resolve with PATHEXT semantics. - const psHost = "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"; + const openCodeWindowsLaunch = openCodeLaunch( + ["run", "safe & literal"], + "win32", + { SystemRoot: "C:\\Windows", NODE_ENV: "test" }, + ); + const psHost = openCodeWindowsLaunch.command; + assert.equal( + openCodeWindowsLaunch.input, + JSON.stringify(["run", "safe & literal"]), + "the preflight receives the same JSON-stdin launch plan as chat", + ); const openCodeWinReady = evaluateRuntimeAvailability({ runner: "opencode", command: psHost, env: { Path: "C:\\bin", PATHEXT: ".COM;.EXE;.BAT;.CMD" }, platform: "win32", - powerShellHostedCommand: "opencode", + powerShellHostedCommand: openCodeWindowsLaunch.input === undefined ? undefined : openCodeCommand(), statFile: winStats([psHost, "C:\\bin\\opencode.CMD"]), }); assert.equal( @@ -430,10 +442,37 @@ try { /PowerShell/, "the host failure names the actual remediation target", ); + assert.doesNotMatch( + openCodeHostGone.state === "unlaunchable" ? openCodeHostGone.message : "", + /OpenCode CLI not found/i, + "a missing host never blames the inner OpenCode command", + ); + + const openCodeInnerProbeFailed = evaluateRuntimeAvailability({ + runner: "opencode", + command: psHost, + env: { Path: "C:\\bin" }, + platform: "win32", + powerShellHostedCommand: openCodeCommand(), + statFile: (candidate) => { + if (candidate === psHost) return true; + throw Object.assign(new Error(`EACCES: permission denied, stat '${candidate}'`), { + code: "EACCES", + }); + }, + }); + assert.equal( + openCodeInnerProbeFailed.state, + "probe_failed", + "an unreadable inner OpenCode command is not reported as missing", + ); + assert.ok( + openCodeInnerProbeFailed.state === "probe_failed" && !openCodeInnerProbeFailed.message.includes("C:\\bin"), + "hosted-command probe errors do not leak the launch PATH", + ); - // Availability never executes anything: the whole evaluation uses bounded - // filesystem inspection, so evaluating before every chat turn stays cheap - // and side-effect free. + // Availability never executes anything: the whole evaluation is stat-only, + // so evaluating before every chat turn stays cheap and side-effect free. // (Enforced structurally — the module must not import child_process.) const moduleSource = readFileSync( new URL("./runtime-availability.ts", import.meta.url),