Improve WSL agent CLI detection (#4662)

This commit is contained in:
Jinwoo Hong 2026-06-04 18:17:49 -04:00 committed by GitHub
parent a672b803fe
commit 1bdefc7a2a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 149 additions and 24 deletions

View File

@ -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(

View File

@ -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 }

View File

@ -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<Set<string>> {
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<T>(commandPromise: Promise<T>): Promise<T> {
let timeout: ReturnType<typeof setTimeout> | null = null
try {
return await Promise.race([
commandPromise,
new Promise<never>((_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<string> {
const found = new Set<string>()
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
}

View File

@ -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 }
)
})

View File

@ -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<string[]> {
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))