mirror of
https://github.com/stablyai/orca.git
synced 2026-11-02 10:08:13 +00:00
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:
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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' }
|
||||
)
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user