diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index 69fed98ea..abcafe1b4 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -15,6 +15,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { PassThrough } from 'node:stream' import type { GlobalSettings } from '../../shared/types' +import { buildWslCodexAvailabilityArgs, buildWslCodexLoginArgs } from './wsl-codex-command' const testState = { userDataDir: '', @@ -766,20 +767,20 @@ describe('CodexAccountService config sync', () => { if (script.includes('readlink -f')) { return `${wslLinuxHomePath}\n` } + if (script.includes('command -v codex')) { + throw new Error('bash -ic does not inherit the distro login-shell PATH') + } + if (args.slice(2, 5).join(' ') === '-- sh -c') { + expect(args).toEqual(buildWslCodexAvailabilityArgs('Debian')) + return '' + } mkdirSync(wslManagedHomePath, { recursive: true }) writeFileSync(join(wslManagedHomePath, '.orca-managed-home'), 'account-id-for-test\n') return '' }) const spawnMock = vi.fn((command: string, args: string[]) => { expect(command).toBe('wsl.exe') - expect(args).toEqual([ - '-d', - 'Debian', - '--exec', - 'bash', - '-ic', - `export CODEX_HOME='${wslLinuxHomePath}'; exec codex login` - ]) + expect(args).toEqual(buildWslCodexLoginArgs('Debian', wslLinuxHomePath)) // Why: codex login runs inside WSL, so the rewritten path must be the // Linux-side ~/.codex, not a Windows UNC path. expect(readFileSync(join(wslManagedHomePath, 'config.toml'), 'utf-8')).toBe( @@ -876,8 +877,8 @@ describe('CodexAccountService config sync', () => { if (script.includes('readlink -f')) { return `${wslLinuxHomePath}\n` } - if (script.includes('command -v codex')) { - expect(args.slice(0, 5)).toEqual(['-d', 'Debian', '--exec', 'bash', '-ic']) + if (args.slice(2, 5).join(' ') === '-- sh -c') { + expect(args).toEqual(buildWslCodexAvailabilityArgs('Debian')) throw new Error('codex missing') } mkdirSync(wslManagedHomePath, { recursive: true }) @@ -960,14 +961,7 @@ describe('CodexAccountService config sync', () => { }) const spawnMock = vi.fn((command: string, args: string[]) => { expect(command).toBe('wsl.exe') - expect(args).toEqual([ - '-d', - 'Ubuntu', - '--exec', - 'bash', - '-ic', - `export CODEX_HOME='${wslLinuxHomePath}'; exec codex login` - ]) + expect(args).toEqual(buildWslCodexLoginArgs('Ubuntu', wslLinuxHomePath)) const child = new EventEmitter() as EventEmitter & { stdout: PassThrough stderr: PassThrough @@ -1075,14 +1069,7 @@ describe('CodexAccountService config sync', () => { }) const spawnMock = vi.fn((command: string, args: string[]) => { expect(command).toBe('wsl.exe') - expect(args).toEqual([ - '-d', - 'Ubuntu', - '--exec', - 'bash', - '-ic', - `export CODEX_HOME='${wslLinuxHomePath}'; exec codex login` - ]) + expect(args).toEqual(buildWslCodexLoginArgs('Ubuntu', wslLinuxHomePath)) expect(readFileSync(join(wslManagedHomePath, '.orca-managed-home'), 'utf-8')).toBe( 'account-1\n' ) diff --git a/src/main/codex-accounts/service.ts b/src/main/codex-accounts/service.ts index c97972563..2a0fb54bd 100644 --- a/src/main/codex-accounts/service.ts +++ b/src/main/codex-accounts/service.ts @@ -22,6 +22,11 @@ import type { RateLimitService } from '../rate-limits/service' import { parseWslUncPath } from '../../shared/wsl-paths' import { toWindowsWslPath } from '../wsl' import { buildEncodedWslBashCommand } from '../wsl-bash-command' +import { + buildWslCodexAvailabilityArgs, + buildWslCodexLoginArgs, + WSL_CODEX_AVAILABILITY_TIMEOUT_MS +} from './wsl-codex-command' import { getCodexSelectionTargetForAccount, getSelectedCodexAccountIdForTarget, @@ -805,15 +810,7 @@ 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', - '-ic', - `export CODEX_HOME=${shellQuote(wslInfo.linuxPath)}; exec codex login` - ], + args: buildWslCodexLoginArgs(wslInfo.distro, wslInfo.linuxPath), env: process.env, codexCommand: 'codex' } @@ -923,18 +920,10 @@ export class CodexAccountService { private assertWslCodexCliAvailable(wslInfo: { distro: string; linuxPath: string }): void { try { - execFileSync( - 'wsl.exe', - [ - '-d', - wslInfo.distro, - '--exec', - 'bash', - '-ic', - buildEncodedWslBashCommand('command -v codex >/dev/null 2>&1') - ], - { encoding: 'utf-8', timeout: 5000 } - ) + execFileSync('wsl.exe', buildWslCodexAvailabilityArgs(wslInfo.distro), { + encoding: 'utf-8', + timeout: WSL_CODEX_AVAILABILITY_TIMEOUT_MS + }) } catch (error) { throw new Error( `Codex CLI is not available in WSL ${wslInfo.distro}. Install Codex in that distro or switch Account location to Windows.`, diff --git a/src/main/codex-accounts/wsl-codex-command.test.ts b/src/main/codex-accounts/wsl-codex-command.test.ts new file mode 100644 index 000000000..f696ddcde --- /dev/null +++ b/src/main/codex-accounts/wsl-codex-command.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' +import { buildWslCodexAvailabilityArgs, buildWslCodexLoginArgs } from './wsl-codex-command' + +describe('WSL Codex commands', () => { + it('checks the alias-neutral PATH from the distro login shell', () => { + const args = buildWslCodexAvailabilityArgs('Ubuntu24-Dev') + + expect(args.slice(0, 5)).toEqual(['-d', 'Ubuntu24-Dev', '--', 'sh', '-c']) + expect(args.at(-1)).toContain('getent passwd') + expect(args.at(-1)).toContain('_orca_lookup_command=') + expect(args.at(-1)).toContain('codex') + expect(args.at(-1)).not.toContain('bash -ic') + }) + + it('launches the resolved Codex executable with its quoted managed home', () => { + const args = buildWslCodexLoginArgs('Ubuntu', '/home/alice/managed-home') + const command = args.at(-1) + + expect(command).toContain('export CODEX_HOME=') + expect(command).toContain('/home/alice/managed-home') + expect(command).toContain('exec "\\$resolved" login') + }) +}) diff --git a/src/main/codex-accounts/wsl-codex-command.ts b/src/main/codex-accounts/wsl-codex-command.ts new file mode 100644 index 000000000..02527c8ed --- /dev/null +++ b/src/main/codex-accounts/wsl-codex-command.ts @@ -0,0 +1,43 @@ +import { buildPosixCommandPathLookupScript } from '../../shared/posix-command-path-lookup' +import { + buildWslLoginShellCommand, + escapeWslShCommandForWindows, + quotePosixShell +} from '../../shared/wsl-login-shell-command' + +export const WSL_CODEX_AVAILABILITY_TIMEOUT_MS = 5_000 + +export function buildWslCodexAvailabilityArgs(distro: string): string[] { + const command = [buildCodexPathLookup(), '[ -n "$resolved" ]'].join('\n') + return buildWslCodexShellArgs(distro, command) +} + +export function buildWslCodexLoginArgs(distro: string, linuxHomePath: string): string[] { + const command = [ + buildCodexPathLookup(), + 'if [ -z "$resolved" ]; then', + " printf '%s\\n' 'Codex CLI not found in the WSL login-shell PATH.' >&2", + ' exit 127', + 'fi', + `export CODEX_HOME=${quotePosixShell(linuxHomePath)}`, + 'exec "$resolved" login' + ].join('\n') + return buildWslCodexShellArgs(distro, command) +} + +function buildCodexPathLookup(): string { + return buildPosixCommandPathLookupScript({ kind: 'literal', value: 'codex' }) +} + +function buildWslCodexShellArgs(distro: string, command: string): string[] { + // Why: Codex must use the distro user's configured login shell, whose PATH + // can differ from a hard-coded non-login bash invocation. + return [ + '-d', + distro, + '--', + 'sh', + '-c', + escapeWslShCommandForWindows(buildWslLoginShellCommand(command)) + ] +}