From 1bdefc7a2a5afb6cceca7155e75e047a832ca311 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Thu, 4 Jun 2026 18:17:49 -0400 Subject: [PATCH] Improve WSL agent CLI detection (#4662) --- src/main/codex-accounts/service.test.ts | 13 ++- src/main/codex-accounts/service.ts | 9 +- src/main/ipc/preflight-wsl-agent-detection.ts | 105 ++++++++++++++++++ src/main/ipc/preflight.test.ts | 29 +++-- src/main/ipc/preflight.ts | 17 ++- 5 files changed, 149 insertions(+), 24 deletions(-) create mode 100644 src/main/ipc/preflight-wsl-agent-detection.ts diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index ef4c16824..04cb90844 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -713,9 +713,9 @@ describe('CodexAccountService config sync', () => { expect(args).toEqual([ '-d', 'Debian', - '--', + '--exec', 'bash', - '-lc', + '-ic', `export CODEX_HOME='${wslLinuxHomePath}'; exec codex login` ]) expect(readFileSync(join(wslManagedHomePath, 'config.toml'), 'utf-8')).toBe( @@ -812,6 +812,7 @@ describe('CodexAccountService config sync', () => { return `${wslLinuxHomePath}\n` } if (script.includes('command -v codex')) { + expect(args.slice(0, 5)).toEqual(['-d', 'Debian', '--exec', 'bash', '-ic']) throw new Error('codex missing') } mkdirSync(wslManagedHomePath, { recursive: true }) @@ -897,9 +898,9 @@ describe('CodexAccountService config sync', () => { expect(args).toEqual([ '-d', 'Ubuntu', - '--', + '--exec', 'bash', - '-lc', + '-ic', `export CODEX_HOME='${wslLinuxHomePath}'; exec codex login` ]) const child = new EventEmitter() as EventEmitter & { @@ -1012,9 +1013,9 @@ describe('CodexAccountService config sync', () => { expect(args).toEqual([ '-d', 'Ubuntu', - '--', + '--exec', 'bash', - '-lc', + '-ic', `export CODEX_HOME='${wslLinuxHomePath}'; exec codex login` ]) expect(readFileSync(join(wslManagedHomePath, '.orca-managed-home'), 'utf-8')).toBe( diff --git a/src/main/codex-accounts/service.ts b/src/main/codex-accounts/service.ts index 0abe08777..ef28b584a 100644 --- a/src/main/codex-accounts/service.ts +++ b/src/main/codex-accounts/service.ts @@ -789,12 +789,13 @@ export class CodexAccountService { const spawnConfig = wslInfo ? { command: 'wsl.exe', + // Why: nvm and similar WSL installs often initialize PATH from interactive shell config. args: [ '-d', wslInfo.distro, - '--', + '--exec', 'bash', - '-lc', + '-ic', `export CODEX_HOME=${shellQuote(wslInfo.linuxPath)}; exec codex login` ], env: process.env, @@ -911,9 +912,9 @@ export class CodexAccountService { [ '-d', wslInfo.distro, - '--', + '--exec', 'bash', - '-lc', + '-ic', buildEncodedWslBashCommand('command -v codex >/dev/null 2>&1') ], { encoding: 'utf-8', timeout: 5000 } diff --git a/src/main/ipc/preflight-wsl-agent-detection.ts b/src/main/ipc/preflight-wsl-agent-detection.ts new file mode 100644 index 000000000..098a930aa --- /dev/null +++ b/src/main/ipc/preflight-wsl-agent-detection.ts @@ -0,0 +1,105 @@ +import { execFile } from 'child_process' +import { promisify } from 'util' +import path from 'path' + +const execFileAsync = promisify(execFile) +const WSL_AGENT_DETECTION_TIMEOUT_MS = 10000 +const WSL_AGENT_DETECTION_PREFIX = '__ORCA_AGENT_PATH__' + +export type WslPreflightTarget = { + distro?: string +} + +export async function detectWslCommandsOnPath( + wslTarget: WslPreflightTarget, + commands: readonly string[] +): Promise> { + const uniqueCommands = [...new Set(commands.filter(Boolean))] + if (uniqueCommands.length === 0) { + return new Set() + } + + const commandList = uniqueCommands.map(shellQuote).join(' ') + const script = [ + `for cmd in ${commandList}; do`, + 'if resolved=$(command -v "$cmd" 2>/dev/null); then', + `printf '${WSL_AGENT_DETECTION_PREFIX}%s\\t%s\\n' "$cmd" "$resolved";`, + 'fi', + 'done' + ].join(' ') + + try { + // Why: WSL cold-start plus many parallel wsl.exe probes can timeout and + // cache an empty result. One interactive probe matches user terminals and + // gives the distro a single startup path. + const { stdout } = await execWslAgentDetectionCommand(wslTarget, script) + return parseWslDetectedCommands(stdout) + } catch { + return new Set() + } +} + +function shellQuote(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'` +} + +async function execWslAgentDetectionCommand( + target: WslPreflightTarget, + command: string +): Promise<{ stdout: string; stderr: string }> { + const distroArgs = target.distro ? ['-d', target.distro] : [] + const commandPromise = execFileAsync( + 'wsl.exe', + [...distroArgs, '--exec', 'bash', '-ic', command], + { + encoding: 'utf-8', + timeout: WSL_AGENT_DETECTION_TIMEOUT_MS + } + ) as Promise<{ stdout: string; stderr: string }> + return withWslAgentDetectionTimeout(commandPromise) +} + +async function withWslAgentDetectionTimeout(commandPromise: Promise): Promise { + let timeout: ReturnType | null = null + try { + return await Promise.race([ + commandPromise, + new Promise((_resolve, reject) => { + timeout = setTimeout(() => { + const error = Object.assign(new Error('Timed out running wsl.exe'), { + code: 'ETIMEDOUT' + }) + reject(error) + }, WSL_AGENT_DETECTION_TIMEOUT_MS) + if (typeof timeout.unref === 'function') { + timeout.unref() + } + }) + ]) + } finally { + if (timeout) { + clearTimeout(timeout) + } + } +} + +function parseWslDetectedCommands(stdout: string): Set { + const found = new Set() + for (const rawLine of stdout.split(/\r?\n/)) { + const line = rawLine.trim() + if (!line.startsWith(WSL_AGENT_DETECTION_PREFIX)) { + continue + } + const payload = line.slice(WSL_AGENT_DETECTION_PREFIX.length) + const separatorIndex = payload.indexOf('\t') + if (separatorIndex <= 0) { + continue + } + const command = payload.slice(0, separatorIndex) + const resolvedPath = payload.slice(separatorIndex + 1) + if (path.posix.isAbsolute(resolvedPath) || path.win32.isAbsolute(resolvedPath)) { + found.add(command) + } + } + return found +} diff --git a/src/main/ipc/preflight.test.ts b/src/main/ipc/preflight.test.ts index 63bebd720..bb28e1bc6 100644 --- a/src/main/ipc/preflight.test.ts +++ b/src/main/ipc/preflight.test.ts @@ -509,20 +509,30 @@ describe('preflight', () => { value: 'win32' }) execFileAsyncMock.mockImplementation(async (command, args) => { - if (command === 'where') { - throw new Error('not found') - } if (command !== 'wsl.exe') { throw new Error(`unexpected command ${String(command)}`) } const script = String(args[5]) - if (script === "command -v 'claude'") { - return { stdout: '/home/test/.local/bin/claude\n' } + if (script.includes("'claude'")) { + return { stdout: '__ORCA_AGENT_PATH__claude\t/home/test/.local/bin/claude\n' } } throw new Error('not found') }) await expect(detectInstalledAgents({ wslDistro: 'Ubuntu' })).resolves.toEqual(['claude']) + expect(execFileAsyncMock).toHaveBeenCalledTimes(1) + expect(execFileAsyncMock).toHaveBeenCalledWith( + 'wsl.exe', + expect.arrayContaining([ + '-d', + 'Ubuntu', + '--exec', + 'bash', + '-ic', + expect.stringContaining("'claude'") + ]), + { encoding: 'utf-8', timeout: 10000 } + ) }) it('detects agents from the default WSL distro when requested', async () => { @@ -535,17 +545,18 @@ describe('preflight', () => { throw new Error(`unexpected command ${String(command)}`) } const script = String(args[3]) - if (script === "command -v 'codex'") { - return { stdout: '/home/test/.local/bin/codex\n' } + if (script.includes("'codex'")) { + return { stdout: '__ORCA_AGENT_PATH__codex\t/home/test/.local/bin/codex\n' } } throw new Error('not found') }) await expect(detectInstalledAgents({ wslDefault: true })).resolves.toEqual(['codex']) + expect(execFileAsyncMock).toHaveBeenCalledTimes(1) expect(execFileAsyncMock).toHaveBeenCalledWith( 'wsl.exe', - ['--', 'bash', '-lc', "command -v 'codex'"], - { encoding: 'utf-8', timeout: 5000 } + expect.arrayContaining(['--exec', 'bash', '-ic', expect.stringContaining("'codex'")]), + { encoding: 'utf-8', timeout: 10000 } ) }) diff --git a/src/main/ipc/preflight.ts b/src/main/ipc/preflight.ts index ccf63a11d..15068a5f8 100644 --- a/src/main/ipc/preflight.ts +++ b/src/main/ipc/preflight.ts @@ -10,6 +10,7 @@ import { getBitbucketAuthStatus } from '../bitbucket/client' import { getGiteaAuthStatus } from '../gitea/client' import { _resetKnownHostsCache } from '../gitlab/gl-utils' import { getActiveMultiplexer } from './ssh' +import { detectWslCommandsOnPath, type WslPreflightTarget } from './preflight-wsl-agent-detection' const execFileAsync = promisify(execFile) const PREFLIGHT_COMMAND_TIMEOUT_MS = 5000 @@ -52,10 +53,6 @@ export function _resetPreflightCache(): void { cached = null } -type WslPreflightTarget = { - distro?: string -} - function shellQuote(value: string): string { return `'${value.replace(/'/g, "'\\''")}'` } @@ -184,10 +181,20 @@ async function detectCommandRuntime( export async function detectInstalledAgents(context?: PreflightRuntimeContext): Promise { const wslTarget = getPreflightWslTarget(context) + if (wslTarget) { + const foundCommands = await detectWslCommandsOnPath( + wslTarget, + KNOWN_AGENT_COMMANDS.map(({ cmd }) => cmd) + ) + return uniqueAgentIds( + KNOWN_AGENT_COMMANDS.filter(({ cmd }) => foundCommands.has(cmd)).map(({ id }) => id) + ) + } + const checks = await Promise.all( KNOWN_AGENT_COMMANDS.map(async ({ id, cmd }) => ({ id, - installed: await isCommandOnPath(cmd, wslTarget ?? undefined) + installed: await isCommandOnPath(cmd) })) ) return uniqueAgentIds(checks.filter((c) => c.installed).map((c) => c.id))