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
This commit is contained in:
Neil
2026-10-27 18:38:06 -07:00
committed by GitHub
parent f4fb9cac56
commit e0550733cd
20 changed files with 583 additions and 43 deletions
@@ -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'])
})
@@ -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 => {
@@ -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.'
)
}
)
@@ -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))
})
@@ -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<NodeJS.ErrnoException>((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()
})
})
+38 -1
View File
@@ -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<Parameters<typeof resolveCliCommand>[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)
}
@@ -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)
})
@@ -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'
}
@@ -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)
})
})
@@ -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<Parameters<typeof resolveCliCommand>[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
}
@@ -44,6 +44,7 @@ export type AgentSessionPreSpawnReason = Extract<
| 'claudeAccountFolderMissing'
| 'claudeAccountSetupFailed'
| 'agentCommandNotRunnable'
| 'cliMissing'
| 'runtimeNotInstalled'
| 'conversationOpenInTerminal'
>
@@ -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<typeof actual.resolveGuestNativeProgram>) =>
actual.resolveGuestNativeProgram(args[0], args[1], args[2], guest.mounts)
}
})
@@ -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<typeof GuestDrivePath>()),
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()
})
})
@@ -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<GlobalSettings, 'agentCmdOverrides' | 'agentDefaultEnv'>
@@ -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<T extends { comma
return resolved
}
} catch (error) {
if (!isAgentSessionPreSpawnError(error) || error.reason !== 'agentCommandNotRunnable') {
// cliMissing: only a Windows copy was found; a refreshed shell may now show a WSL install.
if (
!isAgentSessionPreSpawnError(error) ||
(error.reason !== 'agentCommandNotRunnable' && error.reason !== 'cliMissing')
) {
throw error
}
}
@@ -0,0 +1,28 @@
import { EventEmitter } from 'node:events'
import { expect, it, vi } from 'vitest'
import { WINDOWS_ONLY_AGENT_CLI_DIR } from '../codex-cli/windows-only-agent-cli'
import {
readCodexRateLimitsViaRpc,
type CodexRpcRateLimitChild
} from './codex-rpc-rate-limit-probe'
it('reports a Windows-only Codex as unavailable, with the WSL wording', async () => {
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.'
})
})
@@ -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 }
)
@@ -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' }
)
+17
View File
@@ -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<string, unknown>,
fence: number | null
) {
return {
sessionId,
expectedRuntimeFence: fence,
clientOperationId: `${Date.now()}-${randomUUID().replaceAll('-', '')}`,
payloadFingerprint: computeAgentSessionPayloadFingerprint({ sessionId, method, fields })
}
}
+49 -2
View File
@@ -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 <T>(step: () => Promise<T>): Promise<T | null> => {
@@ -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<unknown>) =>
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
}
@@ -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<void>): Promise<void>
}
function envelope(
sessionId: string,
method: string,
fields: Record<string, unknown>,
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 }
}