From e0550733cd1febdca3c17512f9dbf3d76255980a Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 27 Oct 2026 18:38:06 -0700 Subject: [PATCH] fix(wsl-host): chat and text generation never run a Windows-side agent CLI on a WSL host (#34590) * fix(wsl-host): chat and text generation never run a Windows-side agent CLI on a WSL host * fix(wsl-host): a Windows-only claude or codex reads as not installed to every CLI caller * fix(wsl-host): say a Windows-only claude or codex is installed only on Windows at sign-in * test(wsl): move the lane's chat envelope out of the contract to break an import cycle * test(native-chat): inject guest mounts into the stock lookup the WSL guest-path tests exercise --- ...laude-command-process-windows-only.test.ts | 64 ++++++++++++++ .../claude-accounts/claude-command-process.ts | 22 ++++- .../codex-login-session-windows-only.test.ts | 49 +++++++++++ .../codex-accounts/codex-login-session.ts | 6 +- src/main/codex-cli/command-guest-path.test.ts | 77 ++++++++++++++++ src/main/codex-cli/command.ts | 39 ++++++++- .../codex-cli/windows-only-agent-cli.test.ts | 12 +++ src/main/codex-cli/windows-only-agent-cli.ts | 32 +++++++ src/main/ipc/wsl-guest-native-program.test.ts | 87 +++++++++++++++++++ src/main/ipc/wsl-guest-windows-drive-path.ts | 37 ++++++++ ...structured-agent-session-adapter-errors.ts | 1 + ...tructured-agent-command-guest-path.test.ts | 5 +- ...-agent-command-resolution-wsl-host.test.ts | 38 ++++++++ .../structured-agent-command-resolution.ts | 22 +++-- ...-rpc-rate-limit-probe-windows-only.test.ts | 28 ++++++ .../rate-limits/codex-rpc-rate-limit-probe.ts | 12 +-- .../source-control-agent-launch.ts | 4 +- src/main/wsl/wsl-orcad-chat-envelope.ts | 17 ++++ src/main/wsl/wsl-orcad-chat-windows-decoy.ts | 51 ++++++++++- .../wsl/wsl-orcad-structured-chat-contract.ts | 23 ++--- 20 files changed, 583 insertions(+), 43 deletions(-) create mode 100644 src/main/claude-accounts/claude-command-process-windows-only.test.ts create mode 100644 src/main/codex-accounts/codex-login-session-windows-only.test.ts create mode 100644 src/main/codex-cli/command-guest-path.test.ts create mode 100644 src/main/codex-cli/windows-only-agent-cli.test.ts create mode 100644 src/main/codex-cli/windows-only-agent-cli.ts create mode 100644 src/main/ipc/wsl-guest-native-program.test.ts create mode 100644 src/main/native-chat/structured-agent-command-resolution-wsl-host.test.ts create mode 100644 src/main/rate-limits/codex-rpc-rate-limit-probe-windows-only.test.ts create mode 100644 src/main/wsl/wsl-orcad-chat-envelope.ts diff --git a/src/main/claude-accounts/claude-command-process-windows-only.test.ts b/src/main/claude-accounts/claude-command-process-windows-only.test.ts new file mode 100644 index 00000000000..778e49d99f1 --- /dev/null +++ b/src/main/claude-accounts/claude-command-process-windows-only.test.ts @@ -0,0 +1,64 @@ +import { EventEmitter } from 'node:events' +import { PassThrough } from 'node:stream' +import { afterEach, expect, it, vi } from 'vitest' + +const state = vi.hoisted(() => { + const resolved: string[] = [] + const programs: string[] = [] + return { resolved, programs } +}) + +vi.mock('@orca/process-host', () => ({ + spawnProcess: (spec: { program: string }) => { + state.programs.push(spec.program) + const child = Object.assign(new EventEmitter(), { + stdin: new PassThrough(), + stdout: new PassThrough(), + stderr: new PassThrough(), + pid: undefined, + kill: vi.fn() + }) + queueMicrotask(() => + spec.program.startsWith('/proc/') + ? child.emit( + 'error', + Object.assign(new Error(`spawn ${spec.program} ENOENT`), { code: 'ENOENT' }) + ) + : child.emit('close', 0) + ) + return child + } +})) +vi.mock('../codex-cli/command', () => ({ + resolveClaudeCommand: () => state.resolved.shift() ?? '/home/me/.local/bin/claude' +})) + +import { runClaudeCommandProcess } from './claude-command-process' + +const WINDOWS_ONLY = '/proc/orca-agent-cli-installed-only-on-windows/claude' +const config = { windowsPath: '/home/me/.claude', linuxPath: null, wslDistro: null } +const originalPlatform = process.platform +afterEach(() => { + state.resolved.length = 0 + state.programs.length = 0 + Object.defineProperty(process, 'platform', { value: originalPlatform }) +}) + +it('says Claude is installed only on Windows instead of a raw spawn ENOENT', async () => { + Object.defineProperty(process, 'platform', { value: 'linux' }) + state.resolved.push(WINDOWS_ONLY, WINDOWS_ONLY, WINDOWS_ONLY) + await expect(runClaudeCommandProcess(['auth', 'status'], config, 1000)).rejects.toThrow( + 'Claude is installed only on Windows, not in this WSL distro. Install it inside the distro.' + ) +}) + +it('never keeps the Windows-only answer, so a Claude installed in WSL since is used', async () => { + Object.defineProperty(process, 'platform', { value: 'linux' }) + // The first lookup sees only the Windows copy; every later one finds the new WSL install. + state.resolved.push(WINDOWS_ONLY) + await expect(runClaudeCommandProcess(['auth', 'status'], config, 1000)).rejects.toThrow( + 'installed only on Windows' + ) + await runClaudeCommandProcess(['auth', 'status'], config, 1000) + expect(state.programs).toEqual([WINDOWS_ONLY, '/home/me/.local/bin/claude']) +}) diff --git a/src/main/claude-accounts/claude-command-process.ts b/src/main/claude-accounts/claude-command-process.ts index fe14b816afd..8bbf97e4218 100644 --- a/src/main/claude-accounts/claude-command-process.ts +++ b/src/main/claude-accounts/claude-command-process.ts @@ -7,6 +7,11 @@ import { import { buildWslExecArgs, buildWslLoginShellCommand } from '../../shared/wsl-login-shell-command' import { quotePosixShell } from '../../shared/shell-argument-quoting' import { resolveClaudeCommand } from '../codex-cli/command' +import { + isSpawnEnoent, + isWindowsOnlyAgentCommand, + windowsOnlyCliMessage +} from '../codex-cli/windows-only-agent-cli' import { resolveWslExecutablePath } from '../wsl/wsl-executable-path' import { buildWindowsCommandInvocation } from './windows-command-invocation' import { terminateClaudeProcess } from './claude-login-process-termination' @@ -46,7 +51,17 @@ export function runClaudeCommandProcess( args[1] === 'login' // WSL launches never need the host binary, so resolve it lazily. let cachedHostClaudeCommand: string | null = null - const hostClaudeCommand = (): string => (cachedHostClaudeCommand ??= resolveClaudeCommand()) + const hostClaudeCommand = (): string => { + if (cachedHostClaudeCommand) { + return cachedHostClaudeCommand + } + const resolved = resolveClaudeCommand() + // A Windows-only result is never kept, so a later WSL install is found by the next resolve. + if (!isWindowsOnlyAgentCommand(resolved)) { + cachedHostClaudeCommand = resolved + } + return resolved + } // The visible-console wrapper relays the login PID so termination reaches it. const interactiveLogin = isWindowsHostInteractiveLogin ? buildWindowsHostInteractiveLoginSpawn(hostClaudeCommand(), args) @@ -143,7 +158,10 @@ export function runClaudeCommandProcess( } const onError = (error: Error): void => { if (!terminationPending) { - settle(() => rejectPromise(error)) + const windowsOnly = isSpawnEnoent(error) + ? windowsOnlyCliMessage(spawnConfig.command, 'Claude') + : null + settle(() => rejectPromise(windowsOnly ? new Error(windowsOnly) : error)) } } const onDone = (code: number | null): void => { diff --git a/src/main/codex-accounts/codex-login-session-windows-only.test.ts b/src/main/codex-accounts/codex-login-session-windows-only.test.ts new file mode 100644 index 00000000000..55eb04fa4fc --- /dev/null +++ b/src/main/codex-accounts/codex-login-session-windows-only.test.ts @@ -0,0 +1,49 @@ +import { EventEmitter } from 'node:events' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { PassThrough } from 'node:stream' +import { afterEach, expect, it, vi } from 'vitest' +import { WINDOWS_ONLY_AGENT_CLI_DIR } from '../codex-cli/windows-only-agent-cli' + +vi.mock('../codex-cli/command', () => ({ + resolveCodexCommand: () => '/proc/orca-agent-cli-installed-only-on-windows/codex' +})) + +import { runCodexLoginSession } from './codex-login-session' + +const roots: string[] = [] +afterEach(() => roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true }))) + +it.skipIf(process.platform === 'win32')( + 'tells a WSL host user Codex is installed only on Windows, not that Node is missing', + async () => { + const home = mkdtempSync(join(tmpdir(), 'orca-codex-login-')) + roots.push(home) + const spawn = vi.fn((request: { command: string }) => { + expect(request.command).toBe(`${WINDOWS_ONLY_AGENT_CLI_DIR}/codex`) + const child = Object.assign(new EventEmitter(), { + stdout: new PassThrough(), + stderr: new PassThrough(), + exitCode: null, + signalCode: null, + kill: () => true + }) + queueMicrotask(() => + child.emit('error', Object.assign(new Error('spawn ENOENT'), { code: 'ENOENT' })) + ) + return child + }) + await expect( + runCodexLoginSession(home, { + wslCommand: 'wsl.exe', + spawn, + killProcessTree: vi.fn(), + setCancel: vi.fn(), + onAuthUrl: vi.fn() + }) + ).rejects.toThrow( + 'Codex is installed only on Windows, not in this WSL distro. Install it inside the distro.' + ) + } +) diff --git a/src/main/codex-accounts/codex-login-session.ts b/src/main/codex-accounts/codex-login-session.ts index 42a82c7b956..14e9e9e9119 100644 --- a/src/main/codex-accounts/codex-login-session.ts +++ b/src/main/codex-accounts/codex-login-session.ts @@ -5,6 +5,7 @@ import { withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution' import { CODEX_LOGIN_CANCELLED_MESSAGE } from '../../shared/codex-auth-errors' import { parseWslUncPath } from '../../shared/wsl-paths' import { resolveCodexCommand } from '../codex-cli/command' +import { codexSpawnEnoent } from '../codex-cli/windows-only-agent-cli' import { getSpawnArgsForWindows } from '../win32-utils' import { runWslProcess } from '../wsl/wsl-runner' import { parseCodexLoginAuthUrl } from './codex-login-auth-url' @@ -248,11 +249,8 @@ async function runCodexLoginProcess( settle(() => { const isEnoent = (error as NodeJS.ErrnoException).code === 'ENOENT' // ENOENT can mean the CLI or its Node interpreter is missing. - const isBareCommand = spawnConfig.codexCommand === 'codex' const message = isEnoent - ? isBareCommand - ? 'Codex CLI not found.' - : 'Codex CLI found but could not run — Node.js may not be in your PATH.' + ? codexSpawnEnoent(spawnConfig.codexCommand).message : error.message rejectPromise(new Error(message)) }) diff --git a/src/main/codex-cli/command-guest-path.test.ts b/src/main/codex-cli/command-guest-path.test.ts new file mode 100644 index 00000000000..129298bb692 --- /dev/null +++ b/src/main/codex-cli/command-guest-path.test.ts @@ -0,0 +1,77 @@ +import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { spawnProcess } from '@orca/process-host' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { probeRecentClaudeCliVersion } from '../claude/claude-hook-event-versions' +import { resolveGuestNativeAgentCommand, WINDOWS_ONLY_AGENT_CLI_DIR } from './command' + +let root: string +let native: string +let drive: string + +function install(dir: string, name: string): string { + mkdirSync(dir, { recursive: true }) + const program = join(dir, name) + writeFileSync(program, '#!/bin/sh\necho "9.9.9 (Claude Code)"\n') + chmodSync(program, 0o755) + return program +} + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orca-agent-cli-guest-')) + native = join(root, 'home', '.local', 'bin') + // Stands in for a Windows drive mount such as /mnt/c. + drive = join(root, 'c') +}) + +afterEach(() => { + rmSync(root, { recursive: true, force: true }) +}) + +describe.skipIf(process.platform === 'win32')('agent CLI resolution on a WSL host', () => { + const options = () => ({ pathEnv: `${native}:${drive}/npm`, homePath: join(root, 'home') }) + + it.each(['claude', 'codex'])('takes the WSL install of %s over a Windows copy', (name) => { + const own = install(native, name) + install(join(drive, 'npm'), name) + expect(resolveGuestNativeAgentCommand(name, options(), [drive])).toBe(own) + }) + + it.each(['claude', 'codex'])( + 'reads a Windows-only %s as not installed: never a bare name the spawn would find', + (name) => { + install(join(drive, 'npm'), name) + expect(resolveGuestNativeAgentCommand(name, options(), [drive])).toBe( + `${WINDOWS_ONLY_AGENT_CLI_DIR}/${name}` + ) + } + ) + + it('leaves a CLI missing everywhere as its bare name, as before', () => { + expect(resolveGuestNativeAgentCommand('claude', options(), [drive])).toBe('claude') + }) + + it('resolves exactly as before off a WSL host', () => { + const windowsCopy = install(join(drive, 'npm'), 'claude') + expect(resolveGuestNativeAgentCommand('claude', options(), [])).toBe(windowsCopy) + }) +}) + +describe.skipIf(process.platform === 'win32')('callers given a Windows-only CLI', () => { + const missing = `${WINDOWS_ONLY_AGENT_CLI_DIR}/claude` + + it('see nothing on disk (hook fingerprints, presence checks)', () => { + expect(existsSync(missing)).toBe(false) + }) + + it('get ENOENT from a spawn (login, rate limits, app-server probes)', async () => { + const child = spawnProcess({ program: missing, args: ['--version'] }) + const error = await new Promise((resolve) => child.on('error', resolve)) + expect(error.code).toBe('ENOENT') + }) + + it('read no version (hook-event and diff-panel version probes)', async () => { + await expect(probeRecentClaudeCliVersion(missing)).resolves.toBeNull() + }) +}) diff --git a/src/main/codex-cli/command.ts b/src/main/codex-cli/command.ts index 61e08eb457e..240e4ef405f 100644 --- a/src/main/codex-cli/command.ts +++ b/src/main/codex-cli/command.ts @@ -1 +1,38 @@ -export * from '../../shared/node-cli-command-resolution' +import { resolveCliCommand } from '../../shared/node-cli-command-resolution' +import { resolveGuestNativeProgram } from '../ipc/wsl-guest-windows-drive-path' +import { WINDOWS_ONLY_AGENT_CLI_DIR } from './windows-only-agent-cli' + +export { + getVersionManagerBinPaths, + resolveCliCommand, + resolveCliCommands, + resolveExecutableCommand, + withCliRuntimeOnPath +} from '../../shared/node-cli-command-resolution' +export { WINDOWS_ONLY_AGENT_CLI_DIR } from './windows-only-agent-cli' + +type ResolveCommandOptions = NonNullable[1]> + +/** + * On a WSL-host orcad, a Windows-only copy reads as not installed: a path every spawn (ENOENT) and + * stat reports missing, never a bare name the spawn's own PATH search would take to the Windows + * shim. Unchanged wherever there are no Windows drive mounts. + */ +export function resolveGuestNativeAgentCommand( + name: string, + options: ResolveCommandOptions = {}, + mounts?: readonly string[] +): string { + return ( + resolveGuestNativeProgram(name, options, resolveCliCommand, mounts) ?? + `${WINDOWS_ONLY_AGENT_CLI_DIR}/${name}` + ) +} + +export function resolveCodexCommand(options: ResolveCommandOptions = {}): string { + return resolveGuestNativeAgentCommand('codex', options) +} + +export function resolveClaudeCommand(options: ResolveCommandOptions = {}): string { + return resolveGuestNativeAgentCommand('claude', options) +} diff --git a/src/main/codex-cli/windows-only-agent-cli.test.ts b/src/main/codex-cli/windows-only-agent-cli.test.ts new file mode 100644 index 00000000000..57689bab795 --- /dev/null +++ b/src/main/codex-cli/windows-only-agent-cli.test.ts @@ -0,0 +1,12 @@ +import { expect, it } from 'vitest' +import { codexSpawnEnoent, WINDOWS_ONLY_AGENT_CLI_DIR } from './windows-only-agent-cli' + +it('words a Codex spawn ENOENT by what was resolved', () => { + expect(codexSpawnEnoent(`${WINDOWS_ONLY_AGENT_CLI_DIR}/codex`)).toEqual({ + message: + 'Codex is installed only on Windows, not in this WSL distro. Install it inside the distro.', + missing: true + }) + expect(codexSpawnEnoent('codex')).toEqual({ message: 'Codex CLI not found.', missing: true }) + expect(codexSpawnEnoent('/usr/lib/node_modules/codex/bin/codex.js').missing).toBe(false) +}) diff --git a/src/main/codex-cli/windows-only-agent-cli.ts b/src/main/codex-cli/windows-only-agent-cli.ts new file mode 100644 index 00000000000..cbbba2e89c4 --- /dev/null +++ b/src/main/codex-cli/windows-only-agent-cli.ts @@ -0,0 +1,32 @@ +// Kept apart from ./command, which many tests mock whole, so error paths always see these. +/** procfs takes no new entries, so nothing ever exists here. */ +export const WINDOWS_ONLY_AGENT_CLI_DIR = '/proc/orca-agent-cli-installed-only-on-windows' + +export function isWindowsOnlyAgentCommand(command: string): boolean { + return command.startsWith(`${WINDOWS_ONLY_AGENT_CLI_DIR}/`) +} + +/** What sign-in and status surfaces say when the only copy is the Windows one; null otherwise. */ +export function windowsOnlyCliMessage(command: string, agent: string): string | null { + return isWindowsOnlyAgentCommand(command) + ? `${agent} is installed only on Windows, not in this WSL distro. Install it inside the distro.` + : null +} + +/** Codex's spawn ENOENT: missing (bare name, or only a Windows copy), or a found CLI without its Node. */ +export function codexSpawnEnoent(command: string): { message: string; missing: boolean } { + const windowsOnly = windowsOnlyCliMessage(command, 'Codex') + if (windowsOnly) { + return { message: windowsOnly, missing: true } + } + return command === 'codex' + ? { message: 'Codex CLI not found.', missing: true } + : { + message: 'Codex CLI found but could not run — Node.js may not be in your PATH.', + missing: false + } +} + +export function isSpawnEnoent(error: Error): boolean { + return 'code' in error && error.code === 'ENOENT' +} diff --git a/src/main/ipc/wsl-guest-native-program.test.ts b/src/main/ipc/wsl-guest-native-program.test.ts new file mode 100644 index 00000000000..ed3346a4488 --- /dev/null +++ b/src/main/ipc/wsl-guest-native-program.test.ts @@ -0,0 +1,87 @@ +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + guestNativeSpawnEnv, + isWslHostOrcadProcess, + ORCAD_WSL_HOST_ENV, + resolveGuestNativeProgram +} from './wsl-guest-windows-drive-path' + +let root: string +let native: string +let drive: string + +function install(dir: string): string { + mkdirSync(dir, { recursive: true }) + const program = join(dir, 'claude') + writeFileSync(program, '#!/bin/sh\n') + chmodSync(program, 0o755) + return program +} + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orca-guest-native-')) + native = join(root, 'home', '.local', 'bin') + // Stands in for a Windows drive mount such as /mnt/c. + drive = join(root, 'c') +}) + +afterEach(() => { + rmSync(root, { recursive: true, force: true }) +}) + +describe.skipIf(process.platform === 'win32')('resolveGuestNativeProgram', () => { + const options = () => ({ pathEnv: `${native}:${drive}/npm`, homePath: join(root, 'home') }) + + it('prefers the WSL install over a Windows copy later on PATH', () => { + const own = install(native) + install(join(drive, 'npm')) + expect(resolveGuestNativeProgram('claude', options(), undefined, [drive])).toBe(own) + }) + + it('answers null when only a Windows copy exists, which a bare-name spawn would still reach', () => { + install(join(drive, 'npm')) + expect(resolveGuestNativeProgram('claude', options(), undefined, [drive])).toBeNull() + }) + + it('leaves a program missing everywhere as its bare name, so the spawn fails as not installed', () => { + expect(resolveGuestNativeProgram('claude', options(), undefined, [drive])).toBe('claude') + }) + + it('resolves exactly as before off a WSL host (native Linux, macOS, SSH hosts, the desktop)', () => { + const windowsCopy = install(join(drive, 'npm')) + expect(resolveGuestNativeProgram('claude', options(), undefined, [])).toBe(windowsCopy) + }) +}) + +describe('guestNativeSpawnEnv', () => { + it('drops Windows drive entries from a WSL-host agent child PATH', () => { + expect(guestNativeSpawnEnv({ PATH: '/usr/bin:/mnt/c/npm', HOME: '/h' }, ['/mnt/c'])).toEqual({ + PATH: '/usr/bin', + HOME: '/h' + }) + }) + + it('returns the env untouched where there are no drive mounts', () => { + const env = { PATH: '/usr/bin:/mnt/c/npm' } + expect(guestNativeSpawnEnv(env, [])).toBe(env) + }) +}) + +describe('the WSL-host gate', () => { + const marked = { [ORCAD_WSL_HOST_ENV]: '1', WSL_DISTRO_NAME: 'Ubuntu' } + + it('stays off on macOS, the Windows desktop, native Linux and SSH-host orcad', () => { + expect(isWslHostOrcadProcess(marked, 'darwin', () => true)).toBe(false) + expect(isWslHostOrcadProcess(marked, 'win32', () => true)).toBe(false) + expect(isWslHostOrcadProcess({}, 'linux', () => false)).toBe(false) + // An SSH host's orcad is launched with nohup and never gets the WSL-host marker. + expect(isWslHostOrcadProcess({ ORCA_USER_DATA: '/srv/orca' }, 'linux', () => false)).toBe(false) + }) + + it('turns on only for orcad launched as a WSL host inside the guest', () => { + expect(isWslHostOrcadProcess(marked, 'linux', () => false)).toBe(true) + }) +}) diff --git a/src/main/ipc/wsl-guest-windows-drive-path.ts b/src/main/ipc/wsl-guest-windows-drive-path.ts index 9a18d5a40ec..b3712f77f4f 100644 --- a/src/main/ipc/wsl-guest-windows-drive-path.ts +++ b/src/main/ipc/wsl-guest-windows-drive-path.ts @@ -1,5 +1,7 @@ // WSL-host orcad must ignore Windows CLI shims; identify mounts by type because the automount root is configurable. import { existsSync, readFileSync } from 'node:fs' +import { isAbsolute } from 'node:path' +import { resolveCliCommand } from '../../shared/node-cli-command-resolution' const WINDOWS_DRIVE_MOUNT_TYPES = new Set(['drvfs', '9p', 'virtiofs']) @@ -88,3 +90,38 @@ export function guestNativePathEnv( const value = pathEnv ?? process.env.PATH ?? null return value === null ? null : withoutWindowsDriveMountDirs(value, mounts) } + +/** A one-shot agent child's env on a WSL-host orcad: its PATH lookup can't reach a Windows shim. */ +export function guestNativeSpawnEnv( + env: NodeJS.ProcessEnv, + mounts: readonly string[] = readGuestWindowsDriveMounts() +): NodeJS.ProcessEnv { + if (mounts.length === 0) { + return env + } + const pathEnv = guestNativePathEnv(env.PATH ?? null, mounts) + return pathEnv === null || pathEnv === undefined ? env : { ...env, PATH: pathEnv } +} + +/** + * Resolve an agent program as a WSL-host orcad must: never a Windows-drive copy. `null` means only a + * Windows copy exists; a bare name would reach it through the spawn's own PATH lookup. + */ +export function resolveGuestNativeProgram( + command: string, + options: NonNullable[1]>, + resolve: typeof resolveCliCommand = resolveCliCommand, + mounts: readonly string[] = readGuestWindowsDriveMounts() +): string | null { + if (mounts.length === 0) { + return resolve(command, options) + } + const native = resolve(command, { + ...options, + pathEnv: guestNativePathEnv(options.pathEnv, mounts) + }) + if (isAbsolute(native)) { + return native + } + return isAbsolute(resolve(command, options)) ? null : native +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-errors.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-errors.ts index 5c4447b76a2..9f73902168c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-errors.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-errors.ts @@ -44,6 +44,7 @@ export type AgentSessionPreSpawnReason = Extract< | 'claudeAccountFolderMissing' | 'claudeAccountSetupFailed' | 'agentCommandNotRunnable' + | 'cliMissing' | 'runtimeNotInstalled' | 'conversationOpenInTerminal' > diff --git a/src/main/native-chat/structured-agent-command-guest-path.test.ts b/src/main/native-chat/structured-agent-command-guest-path.test.ts index 8a050e49dee..f0669d32640 100644 --- a/src/main/native-chat/structured-agent-command-guest-path.test.ts +++ b/src/main/native-chat/structured-agent-command-guest-path.test.ts @@ -10,7 +10,10 @@ vi.mock('../ipc/wsl-guest-windows-drive-path', async (original) => { return { ...actual, guestNativePathEnv: (pathEnv: string | null | undefined) => - actual.guestNativePathEnv(pathEnv, guest.mounts) + actual.guestNativePathEnv(pathEnv, guest.mounts), + // The stock lookup reads mounts inside this helper, not through the export above. + resolveGuestNativeProgram: (...args: Parameters) => + actual.resolveGuestNativeProgram(args[0], args[1], args[2], guest.mounts) } }) diff --git a/src/main/native-chat/structured-agent-command-resolution-wsl-host.test.ts b/src/main/native-chat/structured-agent-command-resolution-wsl-host.test.ts new file mode 100644 index 00000000000..2f510e90f2f --- /dev/null +++ b/src/main/native-chat/structured-agent-command-resolution-wsl-host.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it, vi } from 'vitest' +import type * as GuestDrivePath from '../ipc/wsl-guest-windows-drive-path' + +const resolveGuestNativeProgram = vi.hoisted(() => vi.fn<() => string | null>()) +vi.mock('../ipc/wsl-guest-windows-drive-path', async (importOriginal) => ({ + ...(await importOriginal()), + resolveGuestNativeProgram +})) + +import { + resolveAgainAfterRefreshOnMissingProgram, + resolveStructuredAgentCommand +} from './structured-agent-command-resolution' + +describe('a chat program on a WSL host with only a Windows copy', () => { + it.each(['claude', 'codex', 'pi'] as const)( + 'refuses %s as not installed instead of letting the spawn find the Windows shim', + (agent) => { + resolveGuestNativeProgram.mockReturnValueOnce(null) + expect(() => resolveStructuredAgentCommand(agent, {})).toThrow( + expect.objectContaining({ name: 'AgentSessionPreSpawnError', reason: 'cliMissing' }) + ) + } + ) + + it('rereads the shell once, so a WSL install made since the last read is found', async () => { + resolveGuestNativeProgram.mockReturnValueOnce(null).mockReturnValueOnce('/home/me/bin/claude') + const refresh = vi.fn(async () => {}) + await expect( + resolveAgainAfterRefreshOnMissingProgram( + async () => ({ command: resolveStructuredAgentCommand('claude', {}) }), + refresh, + { env: () => ({}) } + ) + ).resolves.toEqual({ command: '/home/me/bin/claude' }) + expect(refresh).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/native-chat/structured-agent-command-resolution.ts b/src/main/native-chat/structured-agent-command-resolution.ts index 43dd1dc4208..36820a02089 100644 --- a/src/main/native-chat/structured-agent-command-resolution.ts +++ b/src/main/native-chat/structured-agent-command-resolution.ts @@ -14,7 +14,7 @@ import { AgentSessionPreSpawnError, isAgentSessionPreSpawnError } from './agent-session-wire/structured-agent-session-adapter' -import { guestNativePathEnv } from '../ipc/wsl-guest-windows-drive-path' +import { guestNativePathEnv, resolveGuestNativeProgram } from '../ipc/wsl-guest-windows-drive-path' export type StructuredAgentCommandSettings = Partial< Pick @@ -51,11 +51,15 @@ export function resolveStructuredAgentCommand( ): string { if (!hasExplicitTuiLaunchCommand(settings, agent)) { // WSL PATH may contain drive-mounted Windows shims after native entries. - const pathEnv = guestNativePathEnv(options.pathEnv) - return (stock.resolve ?? resolveCliCommand)( - stock.command ?? agent, - pathEnv === options.pathEnv ? options : { ...options, pathEnv } - ) + const command = stock.command ?? agent + const resolved = resolveGuestNativeProgram(command, options, stock.resolve) + if (resolved === null) { + throw new AgentSessionPreSpawnError( + `${command} is installed only on Windows, not in WSL ${process.env.WSL_DISTRO_NAME ?? 'distro'}`, + { reason: 'cliMissing' } + ) + } + return resolved } const command = resolveOverride(agent, settings, options) if (!command || !spawnableOn(options.platform ?? process.platform, command)) { @@ -114,7 +118,11 @@ export async function resolveAgainAfterRefreshOnMissingProgram { + const child: CodexRpcRateLimitChild & EventEmitter = Object.assign(new EventEmitter(), { + stdout: new EventEmitter(), + stderr: new EventEmitter(), + stdin: Object.assign(new EventEmitter(), { write: vi.fn() }) + }) + const probe = readCodexRateLimitsViaRpc({ + child, + codexCommand: `${WINDOWS_ONLY_AGENT_CLI_DIR}/codex`, + initTimeoutMs: 30_000, + rpcTimeoutMs: 10_000, + terminate: async () => {} + }) + child.emit('error', Object.assign(new Error('spawn ENOENT'), { code: 'ENOENT' })) + await expect(probe).resolves.toMatchObject({ + status: 'unavailable', + error: + 'Codex is installed only on Windows, not in this WSL distro. Install it inside the distro.' + }) +}) diff --git a/src/main/rate-limits/codex-rpc-rate-limit-probe.ts b/src/main/rate-limits/codex-rpc-rate-limit-probe.ts index aab4ea855a3..e6c643c7cd9 100644 --- a/src/main/rate-limits/codex-rpc-rate-limit-probe.ts +++ b/src/main/rate-limits/codex-rpc-rate-limit-probe.ts @@ -1,5 +1,6 @@ import type { ProviderRateLimits } from '../../shared/rate-limit-types' import { extractCodexAuthError } from '../../shared/codex-auth-errors' +import { codexSpawnEnoent, isSpawnEnoent } from '../codex-cli/windows-only-agent-cli' import { excerptAgentFailureOutput, sanitizeAgentFailureDetail @@ -161,20 +162,15 @@ export function readCodexRateLimitsViaRpc( } function onError(error: Error): void { - const isEnoent = (error as NodeJS.ErrnoException).code === 'ENOENT' - const isBareCommand = codexCommand === 'codex' + const enoent = isSpawnEnoent(error) ? codexSpawnEnoent(codexCommand) : null settle( { provider: 'codex', session: null, weekly: null, updatedAt: Date.now(), - error: isEnoent - ? isBareCommand - ? 'Codex CLI not found' - : 'Codex CLI found but could not run — Node.js may not be in your PATH' - : withMacTailscaleDnsHint(error.message, stderr), - status: isEnoent && isBareCommand ? 'unavailable' : 'error' + error: enoent?.message ?? withMacTailscaleDnsHint(error.message, stderr), + status: enoent?.missing ? 'unavailable' : 'error' }, { kill: true } ) diff --git a/src/main/text-generation/source-control-agent-launch.ts b/src/main/text-generation/source-control-agent-launch.ts index b9c69d4a070..5d98fa6753b 100644 --- a/src/main/text-generation/source-control-agent-launch.ts +++ b/src/main/text-generation/source-control-agent-launch.ts @@ -5,6 +5,7 @@ import { createProviderSpawnSpec } from '../provider-process/provider-process-su import { guardSupervisedProviderGroup } from '../provider-process/provider-group-orphan-guard' import { SOURCE_CONTROL_KILL_SITE } from './source-control-local-process' import { wslAwareSpawn } from '../git/runner' +import { guestNativeSpawnEnv } from '../ipc/wsl-guest-windows-drive-path' import type { SourceControlAgentSpawnInput, SpawnedSourceControlAgentProcess, @@ -98,7 +99,8 @@ function spawnSupervisedAgent( args: input.args, ...(input.useCwdForNative && input.cwd !== undefined ? { cwd: input.cwd } : {}) }, - withCliRuntimeOnPath(input.binary, spawnEnv), + // On a WSL-host orcad, a missing CLI must fail rather than run a Windows shim found via PATH. + withCliRuntimeOnPath(input.binary, guestNativeSpawnEnv(spawnEnv)), process.platform, { lifetime: 'one-shot' } ) diff --git a/src/main/wsl/wsl-orcad-chat-envelope.ts b/src/main/wsl/wsl-orcad-chat-envelope.ts new file mode 100644 index 00000000000..e95114af429 --- /dev/null +++ b/src/main/wsl/wsl-orcad-chat-envelope.ts @@ -0,0 +1,17 @@ +import { randomUUID } from 'node:crypto' +import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' + +/** The mutation envelope the lane's agentSession calls carry, as a paired client sends it. */ +export function envelope( + sessionId: string, + method: string, + fields: Record, + fence: number | null +) { + return { + sessionId, + expectedRuntimeFence: fence, + clientOperationId: `${Date.now()}-${randomUUID().replaceAll('-', '')}`, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ sessionId, method, fields }) + } +} diff --git a/src/main/wsl/wsl-orcad-chat-windows-decoy.ts b/src/main/wsl/wsl-orcad-chat-windows-decoy.ts index 023f0fc805b..2480c50e2c8 100644 --- a/src/main/wsl/wsl-orcad-chat-windows-decoy.ts +++ b/src/main/wsl/wsl-orcad-chat-windows-decoy.ts @@ -1,5 +1,7 @@ -import { expect } from 'vitest' +import { expect, vi } from 'vitest' import { quotePosixShell } from '../../shared/shell-argument-quoting' +import { randomUUID } from 'node:crypto' +import { envelope } from './wsl-orcad-chat-envelope' import type { ChatInstall, ChatLane } from './wsl-orcad-structured-chat-contract' /** Guest file the Windows-side `claude` decoy appends to each time anything runs it. */ @@ -55,7 +57,7 @@ function errorText(error: unknown): string { return error instanceof Error ? error.message : String(error) } -/** With no guest install left, whether orcad runs the Windows-side `claude`; records, never fails. */ +/** With no guest install left, whether orcad runs the Windows-side `claude`; records, the caller asserts. */ export async function probeWindowsDecoy(lane: ChatLane, install: ChatInstall, worktree: string) { const errors: string[] = [] const attempt = async (step: () => Promise): Promise => { @@ -83,3 +85,48 @@ export async function probeWindowsDecoy(lane: ChatLane, install: ChatInstall, wo } return { support, decoyRuns: runs, errors } } + +/** + * A chat with only a Windows `claude` must refuse, never start that copy: `cliMissing` when the + * chat's login PATH shows the Windows copy, else a plain ENOENT (Debian's /etc/profile resets PATH). + */ +const WINDOWS_ONLY_REFUSAL = /cliMissing|spawn claude ENOENT/u + +export async function expectWindowsOnlyChatRefused(lane: ChatLane, worktree: string) { + const sessionId = randomUUID() + const fields = { worktree, agent: 'claude' } + const outcome = (step: Promise) => + step.then( + (value) => value, + (error: unknown) => ({ thrown: errorText(error) }) + ) + const created = await outcome( + lane.rpc('agentSession.create', { + ...fields, + envelope: envelope(sessionId, 'agentSession.create', fields, null) + }) + ) + const body = { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] } + const sent = await outcome( + lane.rpc('agentSession.send', { + body, + envelope: envelope(sessionId, 'agentSession.send', { body }, null) + }) + ) + let history: unknown = null + await vi + .waitFor( + async () => { + history = await outcome( + lane.rpc('agentSession.history', { sessionId, direction: 'tail', limit: 50 }) + ) + expect(JSON.stringify([created, sent, history])).toMatch(WINDOWS_ONLY_REFUSAL) + }, + { timeout: 60_000, interval: 2_000 } + ) + .catch(() => {}) + await outcome(lane.rpc('agentSession.close', { sessionId })) + const seen = { created, sent, history } + expect(JSON.stringify(seen), JSON.stringify(seen)).toMatch(WINDOWS_ONLY_REFUSAL) + return seen +} diff --git a/src/main/wsl/wsl-orcad-structured-chat-contract.ts b/src/main/wsl/wsl-orcad-structured-chat-contract.ts index db90d3eaed7..323f0dceb1c 100644 --- a/src/main/wsl/wsl-orcad-structured-chat-contract.ts +++ b/src/main/wsl/wsl-orcad-structured-chat-contract.ts @@ -1,7 +1,6 @@ import { randomUUID } from 'node:crypto' import { expect, vi } from 'vitest' import { z } from 'zod' -import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' import type { AgentSessionHistoryResult, @@ -17,9 +16,11 @@ import { claudeSessionIdForOrcaSession } from '../claude/structured/claude-struc import { packagedClaudeChatScenario } from '../orcad/orcad-structured-chat-scenario' import { quotePosixShell } from '../../shared/shell-argument-quoting' import { wslClaudeChatScenario } from './wsl-orcad-chat-scenario' +import { envelope } from './wsl-orcad-chat-envelope' import { expectWindowsDecoyReachable, expectWindowsDecoyUnrun, + expectWindowsOnlyChatRefused, probeWindowsDecoy } from './wsl-orcad-chat-windows-decoy' import { @@ -34,20 +35,6 @@ export type ChatLane = WslChatHost & { reconnect(whileDisconnected: () => Promise): Promise } -function envelope( - sessionId: string, - method: string, - fields: Record, - fence: number | null -) { - return { - sessionId, - expectedRuntimeFence: fence, - clientOperationId: `${Date.now()}-${randomUUID().replaceAll('-', '')}`, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ sessionId, method, fields }) - } -} - function assistantText(row: AgentJournalRenderItem): string { return row.body?.kind === 'message' && row.body.role === 'assistant' ? row.body.blocks.map((block) => (block.type === 'text' ? block.text : '')).join('') @@ -301,7 +288,9 @@ export async function proveWslStructuredChat(lane: ChatLane) { const folder = await exerciseChat(lane, install, folderWorktree, folderPath) const decoy = await expectWindowsDecoyReachable(lane) await expectWindowsDecoyUnrun(lane, home) - // Recorded, not asserted: whether a Windows-only install is picked up is the open question. const windowsOnly = await probeWindowsDecoy(lane, install, gitWorktree) - return { git, folder, decoy, windowsOnly } + expect(windowsOnly.errors, JSON.stringify(windowsOnly)).toEqual([]) + const windowsOnlyChat = await expectWindowsOnlyChatRefused(lane, gitWorktree) + await expectWindowsDecoyUnrun(lane, home) + return { git, folder, decoy, windowsOnly, windowsOnlyChat } }