fix(wsl): name an explicit Windows cwd for wsl.exe spawns

Removing the worktree Orca was launched from broke every wsl.exe spawn for
the rest of the session. The WSL command builders passed `cwd: undefined`
meaning "the directory is inside the command" -- but CreateProcessW reads
NULL as "inherit the parent's", and the parent's was a \\wsl.localhost path
Linux had just deleted.

Fixes #16463
This commit is contained in:
Neil
2026-08-31 23:40:05 -07:00
parent a9e6fb7eff
commit 2bcce4d643
13 changed files with 247 additions and 20 deletions
+1
View File
@@ -20,6 +20,7 @@
"../src/main/wsl-distro-retry.ts",
"../src/main/wsl-running-distro-cache.ts",
"../src/main/wsl.ts",
"../src/main/wsl-interop-spawn-directory.ts",
"../src/main/persistence/applying-settings/ui-state-read.ts",
"../src/main/persistence/applying-settings/ui-state-update.ts",
"../src/main/persistence/applying-settings/ui-selection-normalization.ts",
+7 -1
View File
@@ -340,7 +340,13 @@ describe('WslCliInstaller', () => {
expect(bridge).toContain('$ForwardArgs = @($args[$ForwardArgStart..($args.Count - 1)])')
expect(bridge).toContain('if ([string]::IsNullOrEmpty($WslCwd))')
expect(bridge).toContain('$env:ORCA_CLI_CWD = $WslCwd')
expect(bridge).toContain('Push-Location -LiteralPath (Split-Path -Parent $OrcaLauncher)')
expect(bridge).toContain('$LauncherDirectory = Split-Path -Parent $OrcaLauncher')
expect(bridge).toContain('Push-Location -LiteralPath $LauncherDirectory')
// Why (#16463): Push-Location moves only the PowerShell provider location.
// Without an explicit WorkingDirectory the started app inherits the caller's
// Win32 cwd — the user's worktree on \\wsl.localhost — and every wsl.exe
// spawn it makes dies with ENOENT once that worktree is removed.
expect(bridge).toContain('$StartInfo.WorkingDirectory = $LauncherDirectory')
expect(bridge).toContain('function ConvertTo-NativeCommandLineArgument')
expect(bridge).toContain("[void]$Quoted.Append([char]'\\', $BackslashCount * 2 + 1)")
expect(bridge).toContain('$StartInfo.UseShellExecute = $false')
+9 -1
View File
@@ -88,7 +88,8 @@ try {
} else {
$env:ORCA_CLI_CWD = $WslCwd
}
Push-Location -LiteralPath (Split-Path -Parent $OrcaLauncher)
$LauncherDirectory = Split-Path -Parent $OrcaLauncher
Push-Location -LiteralPath $LauncherDirectory
# Why: Windows PowerShell 5.1 cannot losslessly splat strings to native argv.
$StartInfo = [System.Diagnostics.ProcessStartInfo]::new()
$StartInfo.FileName = $OrcaLauncher
@@ -96,6 +97,13 @@ try {
ConvertTo-NativeCommandLineArgument $_
}) -join ' ')
$StartInfo.UseShellExecute = $false
# Why (#16463): Push-Location moves the PowerShell provider location, not the
# Win32 current directory, and an empty WorkingDirectory with UseShellExecute
# disabled means "inherit the caller's". Launched from a WSL shell that is the
# user's worktree on the 9P share, so without this the app stands in a
# directory Linux can delete -- after which every CreateProcessW it makes
# fails ERROR_PATH_NOT_FOUND, reported as: spawn wsl.exe ENOENT.
$StartInfo.WorkingDirectory = $LauncherDirectory
$Process = [System.Diagnostics.Process]::Start($StartInfo)
if ($null -eq $Process) {
throw 'Unable to start the Orca Windows CLI launcher.'
@@ -13,6 +13,7 @@ import {
type WslProcessGroupTermination
} from '../wsl-process-group-termination'
import { translateArgForWsl, translateArgsForWsl } from './wsl-path-translation'
import { resolveWslInteropSpawnCwd } from '../../wsl-interop-spawn-directory'
// Env-assignment prefix for WSL-routed git, where spawn env can't cross the wsl.exe boundary; values are shell-safe unquoted.
const GIT_OUTPUT_LOCALE_SHELL_PREFIX = Object.entries(UNTRANSLATED_GIT_OUTPUT_ENV)
@@ -111,7 +112,7 @@ export function resolveCommand(
...(linuxCwd ? ['-C', linuxCwd] : []),
...translatedArgs
],
cwd: undefined,
cwd: resolveWslInteropSpawnCwd(),
wsl,
wslMode: 'direct-git'
},
@@ -130,7 +131,7 @@ export function resolveCommand(
{
binary: 'wsl.exe',
args: buildWslExecArgs(wsl.distro, ['sh', '-lc', captured.command]),
cwd: undefined,
cwd: resolveWslInteropSpawnCwd(),
wsl,
wslMode: 'login-shell',
captured
@@ -142,7 +143,7 @@ export function resolveCommand(
{
binary: 'wsl.exe',
args: buildWslExecArgs(wsl.distro, ['sh', '-lc', buildWslLoginShellCommand(shellCmd)]),
cwd: undefined,
cwd: resolveWslInteropSpawnCwd(),
wsl,
wslMode: 'login-shell'
},
@@ -154,8 +155,11 @@ export function resolveCommand(
{
binary: 'wsl.exe',
args: buildWslExecArgs(wsl.distro, ['bash', '-c', shellCmd]),
// Why: the `cd` inside bash -c handles the directory; a UNC cwd on the Node process is redundant and can break Node internals.
cwd: undefined,
// Why: the `cd` inside bash -c handles the Linux directory. This names an
// explicit Windows directory anyway, because `undefined` makes
// CreateProcessW inherit the parent's — which is a deletable WSL UNC path
// when Orca was launched from a worktree (#16463).
cwd: resolveWslInteropSpawnCwd(),
wsl,
wslMode: 'non-login-shell'
},
+10 -2
View File
@@ -626,7 +626,11 @@ describe('runner execFile timeout handling', () => {
expect(execFileMock).toHaveBeenCalledWith(
'wsl.exe',
['-d', 'Ubuntu', '--exec', 'sh', '-lc', expect.any(String)],
expect.objectContaining({ cwd: undefined }),
// Why a concrete directory (#16463): `undefined` makes CreateProcessW inherit
// Orca's own cwd, a deletable WSL UNC path when it was launched from a
// worktree. The Linux directory still rides inside the command (/mnt/c/repo,
// asserted below).
expect.objectContaining({ cwd: expect.any(String) }),
expect.any(Function)
)
// A read also warms the direct-git environment probe in the background, so
@@ -659,7 +663,11 @@ describe('runner execFile timeout handling', () => {
expect(execFileMock).toHaveBeenCalledWith(
'wsl.exe',
['-d', 'Ubuntu', '--exec', 'bash', '-c', expect.any(String)],
expect.objectContaining({ cwd: undefined }),
// Why a concrete directory (#16463): `undefined` makes CreateProcessW inherit
// Orca's own cwd, a deletable WSL UNC path when it was launched from a
// worktree. The Linux directory still rides inside the command (/mnt/c/repo,
// asserted below).
expect.objectContaining({ cwd: expect.any(String) }),
expect.any(Function)
)
const shellCommand = execFileMock.mock.calls[0]?.[1]?.[5] as string
+14 -3
View File
@@ -99,7 +99,10 @@ describe('ghExecFileAsync WSL fallback', () => {
'-c',
"cd '/home/jinwoo/stably/noqa' && 'gh' 'issue' 'list' '--repo' 'stablyhq/noqa' '--json' 'number,title'"
],
expect.objectContaining({ cwd: undefined }),
// Why a concrete directory (#16463): `undefined` makes CreateProcessW inherit
// Orca's own cwd, a deletable WSL UNC path when it was launched from a
// worktree. The Linux directory still rides inside the command.
expect.objectContaining({ cwd: expect.any(String) }),
expect.any(Function)
)
expect(execFileMock).toHaveBeenNthCalledWith(
@@ -382,7 +385,11 @@ describe('ghExecFileAsync WSL fallback', () => {
2,
'wsl.exe',
['-d', 'Ubuntu', '--exec', 'bash', '-c', "'gh' 'api' 'rate_limit'"],
expect.objectContaining({ cwd: undefined }),
// Why a concrete directory (#16463): `undefined` makes CreateProcessW inherit
// Orca's own cwd, a deletable WSL UNC path when it was launched from a
// worktree. This global call has no repo directory at all, so nothing about
// where it runs changes.
expect.objectContaining({ cwd: expect.any(String) }),
expect.any(Function)
)
})
@@ -501,7 +508,11 @@ describe('ghExecFileAsync WSL fallback', () => {
2,
'wsl.exe',
['-d', 'Ubuntu', '--exec', 'bash', '-c', "'glab' 'api' 'projects'"],
expect.objectContaining({ cwd: undefined }),
// Why a concrete directory (#16463): `undefined` makes CreateProcessW inherit
// Orca's own cwd, a deletable WSL UNC path when it was launched from a
// worktree. This global call has no repo directory at all, so nothing about
// where it runs changes.
expect.objectContaining({ cwd: expect.any(String) }),
expect.any(Function)
)
})
@@ -76,7 +76,11 @@ describe('glab known-hosts probe on Windows', () => {
expect(execFileMock).toHaveBeenCalledWith(
'wsl.exe',
['-d', 'Ubuntu', '--exec', 'bash', '-c', "'glab' 'auth' 'status'"],
expect.objectContaining({ cwd: undefined }),
// Why a concrete directory (#16463): `undefined` makes CreateProcessW inherit
// Orca's own cwd, a deletable WSL UNC path when it was launched from a
// worktree. This probe has no repo directory at all, so nothing about where
// it runs changes. The native `glab` assertion above keeps `undefined`.
expect.objectContaining({ cwd: expect.any(String) }),
expect.any(Function)
)
})
@@ -164,7 +164,11 @@ describe('generateCommitMessageFromContext', () => {
'wsl.exe',
['-d', 'Ubuntu 24.04', '--exec', 'sh', '-lc', expect.any(String)],
expect.objectContaining({
cwd: undefined,
// Why a concrete directory (#16463): `undefined` makes CreateProcessW inherit
// Orca's own cwd, a deletable WSL UNC path when it was launched from a
// worktree. The Linux directory still rides inside the command (/mnt/c/repo,
// asserted below), so the Windows-side cwd never decides where the agent runs.
cwd: expect.any(String),
windowsHide: true,
env: expect.objectContaining({ CODEX_HOME: '/home/tester/.codex' })
})
@@ -235,7 +235,11 @@ describe('discoverCommitMessageModelsLocal', () => {
'wsl.exe',
['-d', 'Ubuntu', '--exec', 'sh', '-lc', expect.any(String)],
expect.objectContaining({
cwd: undefined,
// Why a concrete directory (#16463): `undefined` makes CreateProcessW inherit
// Orca's own cwd, a deletable WSL UNC path when it was launched from a
// worktree. The Linux directory still rides inside the command (/mnt/c/repo,
// asserted below), so the Windows-side cwd never decides where discovery runs.
cwd: expect.any(String),
windowsHide: true
})
)
@@ -0,0 +1,90 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
resetWslInteropSpawnDirectoryCache,
resolveWslInteropSpawnCwd
} from './wsl-interop-spawn-directory'
// Regression coverage for #16463 ("Removing the worktree Orca was launched from
// breaks every wsl.exe spawn for the rest of the session"). The WSL command
// builders passed `cwd: undefined` meaning "the directory is inside the
// command", but CreateProcessW reads NULL as "inherit the parent's" — and the
// parent's was a `\\wsl.localhost\...` worktree Linux had just deleted. 1805 of
// 1806 git calls then failed `spawn wsl.exe ENOENT` until the app restarted.
const createdRoots: string[] = []
function makeExistingDirectory(): string {
const dir = mkdtempSync(join(tmpdir(), 'orca-wsl-spawn-cwd-'))
createdRoots.push(dir)
return dir
}
const ENV_KEYS = ['ORCA_USER_DATA_PATH', 'USERPROFILE', 'HOMEDRIVE', 'HOMEPATH'] as const
const savedEnv = new Map<string, string | undefined>()
beforeEach(() => {
for (const key of ENV_KEYS) {
savedEnv.set(key, process.env[key])
delete process.env[key]
}
resetWslInteropSpawnDirectoryCache()
})
afterEach(() => {
for (const key of ENV_KEYS) {
const saved = savedEnv.get(key)
if (saved === undefined) {
delete process.env[key]
} else {
process.env[key] = saved
}
}
resetWslInteropSpawnDirectoryCache()
while (createdRoots.length > 0) {
rmSync(createdRoots.pop()!, { recursive: true, force: true })
}
})
describe('resolveWslInteropSpawnCwd', () => {
it('names the app-owned directory first, so no worktree can be the answer', () => {
const userData = makeExistingDirectory()
process.env.ORCA_USER_DATA_PATH = userData
process.env.USERPROFILE = makeExistingDirectory()
expect(resolveWslInteropSpawnCwd()).toBe(userData)
})
it('skips a candidate that does not resolve instead of naming it', () => {
process.env.ORCA_USER_DATA_PATH = join(tmpdir(), 'orca-wsl-spawn-cwd-never-created')
const profile = makeExistingDirectory()
process.env.USERPROFILE = profile
expect(resolveWslInteropSpawnCwd()).toBe(profile)
})
it('always names some directory rather than letting the spawn inherit one', () => {
// Why: inheriting is the failure mode. With no configured candidate at all
// the home directory and system root still stand between a spawn and the
// parent's cwd.
expect(resolveWslInteropSpawnCwd()).toEqual(expect.any(String))
})
it('re-answers after the directory it memoized goes away mid-session', () => {
// This is the incident: the chosen directory was valid when the process
// started and was deleted underneath it hours later. A memo that is never
// re-validated reproduces the original bug one layer up.
const doomed = makeExistingDirectory()
process.env.ORCA_USER_DATA_PATH = doomed
const survivor = makeExistingDirectory()
expect(resolveWslInteropSpawnCwd()).toBe(doomed)
rmSync(doomed, { recursive: true, force: true })
process.env.ORCA_USER_DATA_PATH = survivor
expect(resolveWslInteropSpawnCwd()).toBe(survivor)
})
})
+70
View File
@@ -0,0 +1,70 @@
import { statSync } from 'node:fs'
import { homedir } from 'node:os'
/**
* A Windows directory that is safe to hand `wsl.exe` as its working directory.
*
* Why this exists (#16463): the WSL command builders set `cwd: undefined`,
* meaning "the directory is already expressed inside the command" — but that is
* not what `undefined` means to `CreateProcessW`. libuv passes NULL for
* `lpCurrentDirectory`, and NULL means *inherit the parent's*. Orca launched by
* `orca-ide` from a WSL shell inherits `\\wsl.localhost\<distro>\...\<worktree>`
* as its Win32 cwd; Linux can delete that directory out from under a Windows
* process across the 9P share, and from then on `CreateProcessW` fails
* `ERROR_PATH_NOT_FOUND` — surfaced by libuv as `spawn wsl.exe ENOENT`, for the
* rest of the process's life, for every repository.
*
* Naming an explicit directory removes the dependency on process-global state
* entirely, so a repaired or unrepaired `process.cwd()` cannot decide whether
* git works. It is never the cwd the command runs in: WSL invocations carry
* their Linux directory in `git -C`, a `cd` inside `bash -c`, or the `sh -c`
* wrapper `withGuestCwd` builds.
*/
let cachedSpawnCwd: string | null = null
function isExistingDirectory(path: string | undefined | null): path is string {
if (!path) {
return false
}
try {
return statSync(path).isDirectory()
} catch {
return false
}
}
/** Test seam: forget the memoized directory so a later probe re-validates. */
export function resetWslInteropSpawnDirectoryCache(): void {
cachedSpawnCwd = null
}
export function resolveWslInteropSpawnCwd(): string | undefined {
// Why re-validate: the answer is only useful while it still resolves, and the
// user's profile directory can go away on a roaming/mapped-drive host.
if (isExistingDirectory(cachedSpawnCwd)) {
return cachedSpawnCwd
}
const env = process.env
// Why this order: an app-owned directory first (it outlives every worktree),
// then the user's profile, then the system root as a floor that always exists.
// A root is fine here — nothing scans this directory, it is only the value
// `CreateProcessW` receives for `lpCurrentDirectory`.
const candidates: (string | undefined)[] = [
env.ORCA_USER_DATA_PATH,
env.USERPROFILE,
env.HOMEDRIVE && env.HOMEPATH ? `${env.HOMEDRIVE}${env.HOMEPATH}` : undefined,
homedir(),
env.SystemDrive ? `${env.SystemDrive}\\` : 'C:\\'
]
for (const candidate of candidates) {
if (isExistingDirectory(candidate)) {
cachedSpawnCwd = candidate
return candidate
}
}
cachedSpawnCwd = null
// Why undefined rather than a guess: inheriting is still better than naming a
// directory we just proved does not exist.
return undefined
}
+16 -5
View File
@@ -7,6 +7,7 @@ import {
_setWslAvailabilityCacheForTests,
dropStaleWslAvailabilityFailure
} from './wsl-availability'
import { resolveWslInteropSpawnCwd } from './wsl-interop-spawn-directory'
import {
_resetRunningWslDistroCacheForTests,
resolveRunningWslDistros
@@ -76,7 +77,8 @@ export function wslUncDirectoryExists(uncPath: string): boolean | null {
const stdout = execFileSync('wsl.exe', getWslDirectoryProbeArgs(info), {
stdio: ['pipe', 'pipe', 'pipe'],
timeout: 5000,
encoding: 'utf8'
encoding: 'utf8',
cwd: resolveWslInteropSpawnCwd()
})
return parseWslDirectoryProbeOutput(stdout)
} catch {
@@ -93,7 +95,8 @@ export function wslUncDirectoryExistsAsync(uncPath: string): Promise<boolean | n
return Promise.resolve(null)
}
return new Promise((resolve) => {
execFile('wsl.exe', getWslDirectoryProbeArgs(info), { timeout: 5000 }, (_error, stdout) => {
const probeOpts = { timeout: 5000, cwd: resolveWslInteropSpawnCwd() }
execFile('wsl.exe', getWslDirectoryProbeArgs(info), probeOpts, (_error, stdout) => {
// Why: wsl.exe uses numeric exits for both guest results and host failures; only the guest marker is authoritative.
resolve(parseWslDirectoryProbeOutput(stdout))
})
@@ -181,7 +184,8 @@ export function listWslDistros(): string[] {
const output = execFileSync('wsl.exe', ['--list', '--quiet'], {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
timeout: 5000
timeout: 5000,
cwd: resolveWslInteropSpawnCwd()
})
return cacheWslDistroList(parseWslDistros(output), probeSequence)
} catch {
@@ -283,7 +287,8 @@ export function getWslHome(distro: string): string | null {
const home = execFileSync('wsl.exe', ['-d', distro, '--exec', 'bash', '-c', 'echo $HOME'], {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
timeout: 5000
timeout: 5000,
cwd: resolveWslInteropSpawnCwd()
}).trim()
if (!home || !home.startsWith('/')) {
@@ -382,7 +387,13 @@ function execFileUtf8(command: string, args: string[], env?: NodeJS.ProcessEnv):
execFile(
command,
args,
{ encoding: 'utf-8', env, timeout: 5000, windowsHide: true },
{
encoding: 'utf-8',
env,
timeout: 5000,
windowsHide: true,
cwd: resolveWslInteropSpawnCwd()
},
(error, stdout) => {
if (error) {
reject(error)
@@ -23,3 +23,9 @@ main/ipc/preflight-command-exec.ts
main/ipc/preflight-test-harness.ts
main/ipc/preflight-wsl-agent-detection.ts
main/wsl.ts
# Scanned only because the filename starts with `wsl`; it answers nothing about a
# distro. The swallow is a `statSync` on a LOCAL WINDOWS directory, and only the
# positive answer is memoized -- and re-validated on every call, which is the
# point of the module (#16463). A failed stat drops to the next candidate for
# that one call and is re-asked on the next, so there is no value to pin.
main/wsl-interop-spawn-directory.ts