diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 681a8c1ea14..471cebc5b02 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -238,6 +238,7 @@ "../src/main/openclaude/hook-service.ts", "../src/main/rolling-file-backup.ts", "../src/main/startup/hydrate-shell-path.ts", + "../src/main/startup/shell-path-probe.ts", "../src/main/startup/windows-shell-path-ownership.ts", // Why: serve-electron-flag-parity.test.ts checks the Electron-side serve argv rewrite against this // project's serve spec; the module has no imports, so listing it pulls in nothing else. diff --git a/src/main/startup/hydrate-shell-path.test.ts b/src/main/startup/hydrate-shell-path.test.ts index b2e40ab8709..f09b965486f 100644 --- a/src/main/startup/hydrate-shell-path.test.ts +++ b/src/main/startup/hydrate-shell-path.test.ts @@ -210,6 +210,52 @@ describe('hydrateShellPath', () => { expect((await captureProbeEnv()).ORCA_SHELL_PATH_PROBE).toBe('1') }) + // Why: on Windows the probe is PowerShell or Git Bash, so these argv are never produced. + it.skipIf(process.platform === 'win32').each(['/bin/bash', '/bin/zsh'])( + 'keeps the POSIX login probe for %s', + async (shell) => { + await captureProbeEnv(shell) + + expect(spawnMock.mock.calls[0][1]).toEqual([ + '-ilc', + `printf '%s' '__ORCA_SHELL_PATH__'; printf '%s' "$PATH"; printf '%s' '__ORCA_SHELL_PATH__'` + ]) + } + ) + + it.skipIf(process.platform === 'win32')( + 'reads Nushell PATH while preserving interactive login configuration', + async () => { + const proc = createMockShellProcess() + spawnMock.mockReturnValue(proc) + const resultPromise = hydrateShellPath({ + shellOverride: '/run/current-system/sw/bin/nu', + force: true + }) + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled()) + + expect(spawnMock.mock.calls[0][1]).toEqual([ + '-ilc', + [ + "print -n '__ORCA_SHELL_PATH__';", + 'print -n ($env.PATH | str join (char esep));', + "print -n '__ORCA_SHELL_PATH__'" + ].join(' ') + ]) + proc.stdout.emit( + 'data', + Buffer.from('__ORCA_SHELL_PATH__/etc/profiles/per-user/me/bin:/usr/bin__ORCA_SHELL_PATH__') + ) + proc.emit('close', 0) + + await expect(resultPromise).resolves.toEqual({ + segments: ['/etc/profiles/per-user/me/bin', '/usr/bin'], + ok: true, + failureReason: 'none' + }) + } + ) + it('overwrites the captured key in place so Windows never carries both Path and PATH', async () => { vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') const originalWindowsPath = process.env.Path diff --git a/src/main/startup/hydrate-shell-path.ts b/src/main/startup/hydrate-shell-path.ts index a109b6e61ec..73f48b512b9 100644 --- a/src/main/startup/hydrate-shell-path.ts +++ b/src/main/startup/hydrate-shell-path.ts @@ -2,6 +2,7 @@ import { spawn } from 'node:child_process' import { delimiter, win32 as pathWin32 } from 'node:path' import type { ShellHydrationFailureReason } from '../../shared/shell-path-hydration-types' import { resolveWindowsShellStartupFamily } from '../../shared/windows-terminal-shell' +import { SHELL_PATH_DELIMITER as DELIMITER, shellPathProbe } from './shell-path-probe' import { WindowsShellPathOwnership, createWindowsPathKey } from './windows-shell-path-ownership' // Why: GUI-launched Electron can miss PATH entries added by shell profiles. @@ -11,7 +12,6 @@ import { WindowsShellPathOwnership, createWindowsPathKey } from './windows-shell // // Probe the profile-loading shell once instead of hard-coding every tool's install path. -const DELIMITER = '__ORCA_SHELL_PATH__' // Why 10s: 5s was chosen without measurement and a real profile overruns it — // a bash -ilc loading nvm, rvm, conda and gcloud measures ~1s idle but 6-7s on a // loaded machine, so a cold start under load silently fell back to the seeded @@ -179,24 +179,7 @@ export function runWithLaunchPath(action: () => T): T { } } -function shellPathProbe(shell: string): { args: string[]; pathDelimiter: string } { - if (process.platform !== 'win32') { - const command = `printf '%s' '${DELIMITER}'; printf '%s' "$PATH"; printf '%s' '${DELIMITER}'` - return { args: ['-ilc', command], pathDelimiter: delimiter } - } - if (resolveWindowsShellStartupFamily(shell) === 'posix') { - // Why: native child processes cannot resolve Git Bash's /c/... PATH entries. - const command = `printf '%s' '${DELIMITER}'; cygpath -wp "$PATH"; printf '%s' '${DELIMITER}'` - return { args: ['-ilc', command], pathDelimiter: ';' } - } - const command = - `[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); ` + - `[Console]::Write('${DELIMITER}'); [Console]::Write($env:Path); ` + - `[Console]::Write('${DELIMITER}')` - // Why: omitting -NoProfile is the behavior this probe exists to capture. - return { args: ['-NoLogo', '-Command', command], pathDelimiter: ';' } -} - +/** Capture the login PATH without profile banners. */ function spawnShellAndReadPath(shell: string): Promise { return new Promise((resolve) => { // Why: delimiters isolate PATH from profile banners and MOTDs. diff --git a/src/main/startup/shell-path-probe.ts b/src/main/startup/shell-path-probe.ts new file mode 100644 index 00000000000..20461dbe617 --- /dev/null +++ b/src/main/startup/shell-path-probe.ts @@ -0,0 +1,37 @@ +import { delimiter, win32 as pathWin32 } from 'node:path' +import { resolveWindowsShellStartupFamily } from '../../shared/windows-terminal-shell' + +export const SHELL_PATH_DELIMITER = '__ORCA_SHELL_PATH__' + +/** Build the login PATH probe in the selected shell's native syntax. */ +export function shellPathProbe(shell: string): { args: string[]; pathDelimiter: string } { + if (process.platform !== 'win32') { + if (pathWin32.basename(shell).toLowerCase() === 'nu') { + // Why: Nushell's PATH is a list; POSIX "$PATH" prints a literal instead. + const command = [ + `print -n '${SHELL_PATH_DELIMITER}';`, + 'print -n ($env.PATH | str join (char esep));', + `print -n '${SHELL_PATH_DELIMITER}'` + ].join(' ') + // Why: existing interactive login flags preserve $nu.is-interactive configuration. + return { args: ['-ilc', command], pathDelimiter: delimiter } + } + const command = + `printf '%s' '${SHELL_PATH_DELIMITER}'; printf '%s' "$PATH"; ` + + `printf '%s' '${SHELL_PATH_DELIMITER}'` + return { args: ['-ilc', command], pathDelimiter: delimiter } + } + if (resolveWindowsShellStartupFamily(shell) === 'posix') { + // Why: native child processes cannot resolve Git Bash's /c/... PATH entries. + const command = + `printf '%s' '${SHELL_PATH_DELIMITER}'; cygpath -wp "$PATH"; ` + + `printf '%s' '${SHELL_PATH_DELIMITER}'` + return { args: ['-ilc', command], pathDelimiter: ';' } + } + const command = + `[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); ` + + `[Console]::Write('${SHELL_PATH_DELIMITER}'); [Console]::Write($env:Path); ` + + `[Console]::Write('${SHELL_PATH_DELIMITER}')` + // Why: omitting -NoProfile is the behavior this probe exists to capture. + return { args: ['-NoLogo', '-Command', command], pathDelimiter: ';' } +}