diff --git a/config/tsconfig.tc.web.json b/config/tsconfig.tc.web.json index ea5b5f31a5c..b687d0134a0 100644 --- a/config/tsconfig.tc.web.json +++ b/config/tsconfig.tc.web.json @@ -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", diff --git a/src/main/cli/wsl-cli-installer.test.ts b/src/main/cli/wsl-cli-installer.test.ts index 717232509ba..a728e0acafe 100644 --- a/src/main/cli/wsl-cli-installer.test.ts +++ b/src/main/cli/wsl-cli-installer.test.ts @@ -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') diff --git a/src/main/cli/wsl-cli-scripts.ts b/src/main/cli/wsl-cli-scripts.ts index 8eda355cc93..8875a81d18e 100644 --- a/src/main/cli/wsl-cli-scripts.ts +++ b/src/main/cli/wsl-cli-scripts.ts @@ -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.' diff --git a/src/main/git/command-runner/wsl-command-resolution.ts b/src/main/git/command-runner/wsl-command-resolution.ts index a70c0ca2bc9..559e1821bd1 100644 --- a/src/main/git/command-runner/wsl-command-resolution.ts +++ b/src/main/git/command-runner/wsl-command-resolution.ts @@ -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' }, diff --git a/src/main/git/runner-command-exec.test.ts b/src/main/git/runner-command-exec.test.ts index 1974c4e2384..27d7a72c747 100644 --- a/src/main/git/runner-command-exec.test.ts +++ b/src/main/git/runner-command-exec.test.ts @@ -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 diff --git a/src/main/git/runner-wsl-gh-fallback.test.ts b/src/main/git/runner-wsl-gh-fallback.test.ts index 57dd86ba26c..5f933c60620 100644 --- a/src/main/git/runner-wsl-gh-fallback.test.ts +++ b/src/main/git/runner-wsl-gh-fallback.test.ts @@ -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) ) }) diff --git a/src/main/gitlab/gitlab-known-host-probe-wsl-fallback.test.ts b/src/main/gitlab/gitlab-known-host-probe-wsl-fallback.test.ts index a3fe62c9063..40da88f0c91 100644 --- a/src/main/gitlab/gitlab-known-host-probe-wsl-fallback.test.ts +++ b/src/main/gitlab/gitlab-known-host-probe-wsl-fallback.test.ts @@ -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) ) }) diff --git a/src/main/text-generation/commit-message-text-generation-local-subprocess.test.ts b/src/main/text-generation/commit-message-text-generation-local-subprocess.test.ts index fcdbe4719cc..c124cb85779 100644 --- a/src/main/text-generation/commit-message-text-generation-local-subprocess.test.ts +++ b/src/main/text-generation/commit-message-text-generation-local-subprocess.test.ts @@ -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' }) }) diff --git a/src/main/text-generation/commit-message-text-generation-model-discovery.test.ts b/src/main/text-generation/commit-message-text-generation-model-discovery.test.ts index d54f1d995f6..7ef438c06ae 100644 --- a/src/main/text-generation/commit-message-text-generation-model-discovery.test.ts +++ b/src/main/text-generation/commit-message-text-generation-model-discovery.test.ts @@ -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 }) ) diff --git a/src/main/wsl-interop-spawn-directory.test.ts b/src/main/wsl-interop-spawn-directory.test.ts new file mode 100644 index 00000000000..310e5d3a5ca --- /dev/null +++ b/src/main/wsl-interop-spawn-directory.test.ts @@ -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() + +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) + }) +}) diff --git a/src/main/wsl-interop-spawn-directory.ts b/src/main/wsl-interop-spawn-directory.ts new file mode 100644 index 00000000000..daa8e08d830 --- /dev/null +++ b/src/main/wsl-interop-spawn-directory.ts @@ -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\\...\` + * 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 +} diff --git a/src/main/wsl.ts b/src/main/wsl.ts index 59e74a7f4df..579031de934 100644 --- a/src/main/wsl.ts +++ b/src/main/wsl.ts @@ -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 { - 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) diff --git a/src/main/wsl/__fixtures__/wsl-probe-failure-swallow-allowlist.txt b/src/main/wsl/__fixtures__/wsl-probe-failure-swallow-allowlist.txt index db6392e21f3..f0a4c4f1082 100644 --- a/src/main/wsl/__fixtures__/wsl-probe-failure-swallow-allowlist.txt +++ b/src/main/wsl/__fixtures__/wsl-probe-failure-swallow-allowlist.txt @@ -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