fix(startup): read a nushell login PATH from $env.PATH

Use Nushell login command syntax and preserve the actual login PATH value.

Related: https://github.com/stablyai/orca/pull/22677

Co-authored-by: Kh05ifr4nD <meandSSH0219@gmail.com>
Co-authored-by: Neil <neil@stably.ai>
This commit is contained in:
Kh05ifr4nD
2026-10-03 00:51:46 -07:00
committed by GitHub
co-authored by Neil
parent f113d353cf
commit 98a6d20e4a
4 changed files with 86 additions and 19 deletions
+1
View File
@@ -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.
@@ -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
+2 -19
View File
@@ -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<T>(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<HydrationResult> {
return new Promise((resolve) => {
// Why: delimiters isolate PATH from profile banners and MOTDs.
+37
View File
@@ -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: ';' }
}