Improve WSL agent CLI detection (#4662)
This commit is contained in:
parent
a672b803fe
commit
1bdefc7a2a
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 }
|
||||
)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
Loading…
Reference in New Issue