diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index d0bbf29477f..4fd076bd2d0 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -199,6 +199,7 @@ "../src/main/codex/codex-trust-grant-ledger.ts", "../src/main/codex/codex-user-hook-trust-moves.ts", "../src/main/codex/codex-wsl-hook-install-plan.ts", + "../src/main/wsl-interop-spawn-directory.ts", "../src/main/codex/config-settings-baseline.ts", "../src/main/codex/config-settings-conflict-resolution.ts", "../src/main/codex/config-plugin-registration-promotion.ts", diff --git a/src/main/codex-accounts/service.ts b/src/main/codex-accounts/service.ts index 8e810f3fb7c..c5eb27d1a92 100644 --- a/src/main/codex-accounts/service.ts +++ b/src/main/codex-accounts/service.ts @@ -12,6 +12,7 @@ import type { CodexRuntimeHomeService } from './runtime-home-service' import type { Store } from '../persistence' import type { RateLimitService } from '../rate-limits/service' import { buildEncodedWslBashCommand } from '../wsl-bash-command' +import { resolveWslInteropSpawnCwd } from '../wsl-interop-spawn-directory' import { admitSelfInitiatedTreeKill } from '../own-chromium-tree-kill-guard' import type { CodexAccountSelectionTarget } from './runtime-selection' import { CodexAccountIdentity, type ResolvedCodexIdentity } from './codex-account-identity' @@ -102,7 +103,7 @@ export class CodexAccountService { execFileSync( 'wsl.exe', ['-d', distro, '--exec', 'bash', '-lc', buildEncodedWslBashCommand(script)], - { windowsHide: true, encoding: 'utf-8', timeout: 5000 } + { windowsHide: true, encoding: 'utf-8', timeout: 5000, cwd: resolveWslInteropSpawnCwd() } ) ) this.managedHomes = new CodexManagedHomeLifecycle(this.managedHomePaths) diff --git a/src/main/codex/codex-state-db-backfill-recovery-process.ts b/src/main/codex/codex-state-db-backfill-recovery-process.ts index e0d9651fe4e..bc08343aeff 100644 --- a/src/main/codex/codex-state-db-backfill-recovery-process.ts +++ b/src/main/codex/codex-state-db-backfill-recovery-process.ts @@ -10,6 +10,7 @@ import { } from '../provider-process/provider-process-supervisor' import type { CodexAppServerSpawn } from './codex-app-server-process-tree-kill' import { stopSupervisedChildProcess } from '../provider-process/supervised-child-process-stop' +import { resolveWslInteropSpawnCwd } from '../wsl-interop-spawn-directory' const BACKFILL_RECOVERY_KILL_SITE = 'codex-state-db-backfill-recovery' // Codex drains on its stdin end, as the Codex connection's root-only close asks; one value @@ -36,7 +37,8 @@ export function spawnCodexBackfillRecoveryProcess( { stdio: ['pipe', 'ignore', 'ignore'], windowsHide: true, - env: process.env + env: process.env, + cwd: resolveWslInteropSpawnCwd() } ) return { child, supervised: false } diff --git a/src/main/codex/codex-trust-grant-host.ts b/src/main/codex/codex-trust-grant-host.ts index 59ebe765837..c3b8409a59f 100644 --- a/src/main/codex/codex-trust-grant-host.ts +++ b/src/main/codex/codex-trust-grant-host.ts @@ -11,6 +11,7 @@ import { type CodexTrustGrantBinaryStamp, type CodexTrustGrantLedgerHome } from './codex-trust-grant-ledger' +import { resolveWslInteropSpawnCwd } from '../wsl-interop-spawn-directory' // Why: WSL pays cold-distro and login-shell startup, but stays hard-bounded on launch prep. const WSL_GRANT_TIMEOUT_MS = 30_000 @@ -65,6 +66,7 @@ async function buildWslCodexBinaryStamp( const result = await runProcess({ program: 'wsl.exe', args: probe.args, + cwd: resolveWslInteropSpawnCwd(), timeoutMs: WSL_CODEX_AVAILABILITY_TIMEOUT_MS }) if (result.code !== 0 || result.timedOut) { diff --git a/src/main/codex/codex-wsl-hook-install-plan.ts b/src/main/codex/codex-wsl-hook-install-plan.ts index 9f5a3088f73..e1fa9d899e6 100644 --- a/src/main/codex/codex-wsl-hook-install-plan.ts +++ b/src/main/codex/codex-wsl-hook-install-plan.ts @@ -1,6 +1,7 @@ import { execFile } from 'node:child_process' import { posix as pathPosix, win32 as pathWin32 } from 'node:path' import { parseWslUncPath } from '../../shared/wsl-paths' +import { resolveWslInteropSpawnCwd } from '../wsl-interop-spawn-directory' export type CodexWslRuntimeHookTarget = { runtime?: 'host' | 'wsl' @@ -118,7 +119,12 @@ function scheduleWslLinuxPathCanonicalization( execFile( 'wsl.exe', args, - { encoding: 'utf-8', timeout: WSL_CANONICALIZE_TIMEOUT_MS, windowsHide: true }, + { + encoding: 'utf-8', + timeout: WSL_CANONICALIZE_TIMEOUT_MS, + windowsHide: true, + cwd: resolveWslInteropSpawnCwd() + }, (error, stdout) => { const canonicalPath = stdout.trim() const resolvedPath = !error && canonicalPath.startsWith('/') ? canonicalPath : null diff --git a/src/main/git/wsl-git-read-environment.ts b/src/main/git/wsl-git-read-environment.ts index 21193404c30..f2307495b59 100644 --- a/src/main/git/wsl-git-read-environment.ts +++ b/src/main/git/wsl-git-read-environment.ts @@ -3,6 +3,7 @@ import { buildWslCapturedLoginShellCommand, buildWslExecArgs } from '../../shared/wsl-login-shell-command' +import { resolveWslInteropSpawnCwd } from '../wsl-interop-spawn-directory' export type WslGitReadEnvironment = { gitPath: string; home: string; path: string } @@ -92,7 +93,8 @@ function probeWslGitReadEnvironment(distro: string): Promise { encoding: 'utf8', maxBuffer: PROBE_MAX_BUFFER, timeout: PROBE_TIMEOUT_MS, - windowsHide: true + windowsHide: true, + cwd: resolveWslInteropSpawnCwd() }, (error, stdout) => { if (error) { diff --git a/src/main/orcad/orcad-daemon-teardown-fixture.ts b/src/main/orcad/orcad-daemon-teardown-fixture.ts index 83da6adf733..81391d365e5 100644 --- a/src/main/orcad/orcad-daemon-teardown-fixture.ts +++ b/src/main/orcad/orcad-daemon-teardown-fixture.ts @@ -249,28 +249,16 @@ export async function killProfileDaemons(userData: string): Promise { } } -/** - * A full orcad on Windows probes WSL with `wsl.exe` run in its profile, and the WSL - * service, outside the test's process tree, holds that directory busy for ~60s after. - */ -export const WINDOWS_WSL_PROFILE_HOLD_MS = 75_000 - -/** - * Removes a test root, retrying a late writer's ENOTEMPTY/EBUSY on every platform. - * `windowsBusyHoldMs` keeps retrying a Windows EBUSY that long once attempts run out. - */ -export async function removeTestRoot(root: string, windowsBusyHoldMs = 0): Promise { - const holdDeadline = Date.now() + (process.platform === 'win32' ? windowsBusyHoldMs : 0) +/** Removes a test root, retrying a late writer's ENOTEMPTY/EBUSY on every platform. */ +export async function removeTestRoot(root: string): Promise { for (let attempt = 1; ; attempt += 1) { try { await removeTree(root) return } catch (error) { - const code = errorCode(error) ?? '' - const held = code === 'EBUSY' && Date.now() < holdDeadline if ( - (attempt >= REMOVE_ATTEMPTS && !held) || - !['ENOTEMPTY', 'EBUSY', 'EPERM'].includes(code) + attempt >= REMOVE_ATTEMPTS || + !['ENOTEMPTY', 'EBUSY', 'EPERM'].includes(errorCode(error) ?? '') ) { throw error } diff --git a/src/main/orcad/orcad-daemon-teardown-removal.test.ts b/src/main/orcad/orcad-daemon-teardown-removal.test.ts deleted file mode 100644 index b85e442a686..00000000000 --- a/src/main/orcad/orcad-daemon-teardown-removal.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { removeTree } from '../../shared/windows-transient-lock-removal' -import { removeTestRoot } from './orcad-daemon-teardown-fixture' - -vi.mock('../../shared/windows-transient-lock-removal', () => ({ removeTree: vi.fn() })) - -const originalPlatform = process.platform - -function setPlatform(platform: NodeJS.Platform): void { - Object.defineProperty(process, 'platform', { value: platform, configurable: true }) -} - -function failTimes(times: number, code: string): void { - let calls = 0 - vi.mocked(removeTree).mockImplementation(async () => { - calls += 1 - if (calls <= times) { - throw Object.assign(new Error(code), { code }) - } - }) -} - -async function settle(removal: Promise): Promise { - const outcome = removal.then( - () => 'removed', - (error: unknown) => error - ) - await vi.runAllTimersAsync() - return outcome -} - -describe('removeTestRoot Windows busy hold', () => { - beforeEach(() => { - vi.useFakeTimers() - vi.mocked(removeTree).mockReset() - }) - - afterEach(() => { - vi.useRealTimers() - setPlatform(originalPlatform) - }) - - it('outlasts an EBUSY hold longer than its attempts within the hold window', async () => { - setPlatform('win32') - failTimes(40, 'EBUSY') - expect(await settle(removeTestRoot('root', 10_000))).toBe('removed') - expect(removeTree).toHaveBeenCalledTimes(41) - }) - - it('still fails an EBUSY that outlives the hold window', async () => { - setPlatform('win32') - failTimes(Number.POSITIVE_INFINITY, 'EBUSY') - expect(await settle(removeTestRoot('root', 1_000))).toMatchObject({ code: 'EBUSY' }) - }) - - it('keeps the attempt bound for ENOTEMPTY, a late writer rather than a held directory', async () => { - setPlatform('win32') - failTimes(Number.POSITIVE_INFINITY, 'ENOTEMPTY') - expect(await settle(removeTestRoot('root', 10_000))).toMatchObject({ code: 'ENOTEMPTY' }) - expect(removeTree).toHaveBeenCalledTimes(8) - }) - - it('ignores the hold off Windows', async () => { - setPlatform('linux') - failTimes(Number.POSITIVE_INFINITY, 'EBUSY') - expect(await settle(removeTestRoot('root', 10_000))).toMatchObject({ code: 'EBUSY' }) - expect(removeTree).toHaveBeenCalledTimes(8) - }) -}) diff --git a/src/main/orcad/orcad-serve-parity.integration.test.ts b/src/main/orcad/orcad-serve-parity.integration.test.ts index e64e88e3cbc..942c71f2c19 100644 --- a/src/main/orcad/orcad-serve-parity.integration.test.ts +++ b/src/main/orcad/orcad-serve-parity.integration.test.ts @@ -24,8 +24,7 @@ import { killProfileDaemons, recordProfileDaemons, removeTestRoot, - waitForProcessExit, - WINDOWS_WSL_PROFILE_HOLD_MS + waitForProcessExit } from './orcad-daemon-teardown-fixture' const slotDir = resolve('out/orcad') @@ -36,7 +35,6 @@ const skip = skipForMissingInputs('artifact', runtime ? [] : ['a Node orcad slot const roots: string[] = [] const cleanupDiagnostics = new Set() -// Past the 60s hook default: daemon cleanup plus the Windows WSL profile hold. afterEach(async () => { const failures: unknown[] = [] // Stop every profile before removing any project root it may still use. @@ -61,7 +59,7 @@ afterEach(async () => { const diagnostics = [...cleanupDiagnostics].find((entry) => entry.owns(root)) const startedAt = Date.now() try { - await removeTestRoot(root, WINDOWS_WSL_PROFILE_HOLD_MS) + await removeTestRoot(root) roots.splice(roots.indexOf(root), 1) if (diagnostics?.rootRemoved(root)) { cleanupDiagnostics.delete(diagnostics) @@ -76,7 +74,7 @@ afterEach(async () => { if (failures.length > 0) { throw new AggregateError(failures, 'Test root removal incomplete') } -}, 150_000) +}) /** Without Vitest's markers: daemon-entry.js does not start its server under VITEST. */ function serveEnv(userData: string): NodeJS.ProcessEnv { diff --git a/src/main/rate-limits/codex-fetcher.test.ts b/src/main/rate-limits/codex-fetcher.test.ts index feac7000585..3c7c863bb61 100644 --- a/src/main/rate-limits/codex-fetcher.test.ts +++ b/src/main/rate-limits/codex-fetcher.test.ts @@ -44,6 +44,9 @@ vi.mock('../codex-cli/command', () => ({ resolveCodexCommand: resolveCodexCommandMock })) +vi.mock('../wsl-interop-spawn-directory', () => ({ + resolveWslInteropSpawnCwd: () => 'C:\\Windows' +})) vi.mock('node-pty', () => ({ spawn: ptySpawnMock })) @@ -678,7 +681,7 @@ describe('fetchCodexRateLimits', () => { expect(shellCommand).not.toContain('wsl-codex-path') expect(spawnOptions).toEqual( expect.objectContaining({ - cwd: expect.stringContaining('rate-limit-pty-cwd'), + cwd: 'C:\\Windows', env: expect.not.objectContaining({ CODEX_HOME: expect.anything() }) }) ) diff --git a/src/main/rate-limits/codex-fetcher.ts b/src/main/rate-limits/codex-fetcher.ts index c3aa28c053b..8892a275cf6 100644 --- a/src/main/rate-limits/codex-fetcher.ts +++ b/src/main/rate-limits/codex-fetcher.ts @@ -34,6 +34,7 @@ import { resolveHiddenRateLimitPtyCwd } from './hidden-rate-limit-pty-cwd' import { quoteHiddenRateLimitShellValue } from './hidden-rate-limit-shell' +import { resolveWslInteropSpawnCwd } from '../wsl-interop-spawn-directory' const RPC_TIMEOUT_MS = 10_000 const WSL_RPC_TIMEOUT_MS = 25_000 @@ -96,7 +97,8 @@ async function fetchViaRpc(options?: CodexRateLimitFetchOptions): Promise() beforeEach(() => { @@ -50,40 +54,41 @@ afterEach(() => { }) 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 + it('names the system root, never the profile or the user folder', () => { + const systemRoot = makeExistingDirectory() + process.env.SystemRoot = systemRoot + process.env.ORCA_USER_DATA_PATH = makeExistingDirectory() process.env.USERPROFILE = makeExistingDirectory() - expect(resolveWslInteropSpawnCwd()).toBe(userData) + expect(resolveWslInteropSpawnCwd()).toBe(systemRoot) + }) + + it('never falls back to the profile or user folder when no system directory resolves', () => { + const profile = makeExistingDirectory() + const userFolder = makeExistingDirectory() + process.env.ORCA_USER_DATA_PATH = profile + process.env.USERPROFILE = userFolder + + expect([profile, userFolder]).not.toContain(resolveWslInteropSpawnCwd()) }) 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 + process.env.SystemRoot = join(tmpdir(), 'orca-wsl-spawn-cwd-never-created') + const windir = makeExistingDirectory() + process.env.windir = windir - 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)) + expect(resolveWslInteropSpawnCwd()).toBe(windir) }) 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. + // A memo that is never re-validated reproduces #16463 one layer up. const doomed = makeExistingDirectory() - process.env.ORCA_USER_DATA_PATH = doomed + process.env.SystemRoot = doomed const survivor = makeExistingDirectory() expect(resolveWslInteropSpawnCwd()).toBe(doomed) rmSync(doomed, { recursive: true, force: true }) - process.env.ORCA_USER_DATA_PATH = survivor + process.env.SystemRoot = survivor expect(resolveWslInteropSpawnCwd()).toBe(survivor) }) diff --git a/src/main/wsl-interop-spawn-directory.ts b/src/main/wsl-interop-spawn-directory.ts index daa8e08d830..0b7ceca44a3 100644 --- a/src/main/wsl-interop-spawn-directory.ts +++ b/src/main/wsl-interop-spawn-directory.ts @@ -1,5 +1,5 @@ import { statSync } from 'node:fs' -import { homedir } from 'node:os' +import { tmpdir } from 'node:os' /** * A Windows directory that is safe to hand `wsl.exe` as its working directory. @@ -19,6 +19,10 @@ import { homedir } from 'node:os' * 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. + * + * Why a system directory and never the profile or user folder (#29845): WSL + * holds this directory busy for about its ~60s idle timeout after `wsl.exe` + * exits, so a profile named here cannot be deleted for a minute after Orca exits. */ let cachedSpawnCwd: string | null = null @@ -40,22 +44,18 @@ export function resetWslInteropSpawnDirectoryCache(): void { } 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. + // Why re-validate: the answer is only useful while it still resolves. 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:\\' + env.SystemRoot, + env.windir, + env.SystemDrive ? `${env.SystemDrive}\\` : undefined, + 'C:\\', + // Why last: unreachable on a real Windows host; it keeps POSIX test hosts naming a directory. + tmpdir() ] for (const candidate of candidates) { if (isExistingDirectory(candidate)) { diff --git a/src/main/wsl-running-distros.test.ts b/src/main/wsl-running-distros.test.ts index 147bdc488e7..04b90aeddd4 100644 --- a/src/main/wsl-running-distros.test.ts +++ b/src/main/wsl-running-distros.test.ts @@ -1,3 +1,6 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import type { ProcessResult } from '@orca/process-host/process-spec' @@ -17,11 +20,20 @@ import { listRunningWslHomeDirsAsync } from './wsl' import { filterPathsToRunningWslDistrosAsync } from './wsl-running-path-filter' +import { resetWslInteropSpawnDirectoryCache } from './wsl-interop-spawn-directory' import { observeWslTranscriptRunningState, resetWslTranscriptRunningObserverForTests } from './native-chat/wsl-transcript-running-observer' +function restoreEnv(key: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } +} + async function withPlatform(value: NodeJS.Platform, fn: () => Promise): Promise { const original = process.platform Object.defineProperty(process, 'platform', { configurable: true, value }) @@ -56,6 +68,28 @@ describe('running WSL distro discovery', () => { }) }) + it('runs the startup probe from the system root, never the profile (#29845)', async () => { + runProcessMock.mockResolvedValue(exited('Ubuntu\n')) + const saved = { SystemRoot: process.env.SystemRoot, profile: process.env.ORCA_USER_DATA_PATH } + const systemRoot = mkdtempSync(join(tmpdir(), 'orca-wsl-probe-system-root-')) + const profile = mkdtempSync(join(tmpdir(), 'orca-wsl-probe-profile-')) + process.env.SystemRoot = systemRoot + process.env.ORCA_USER_DATA_PATH = profile + resetWslInteropSpawnDirectoryCache() + try { + await withPlatform('win32', () => listRunningWslDistrosAsync({ requireConfirmed: true })) + expect(runProcessMock).toHaveBeenCalledWith( + expect.objectContaining({ args: ['--list', '--running', '--quiet'], cwd: systemRoot }) + ) + } finally { + restoreEnv('SystemRoot', saved.SystemRoot) + restoreEnv('ORCA_USER_DATA_PATH', saved.profile) + resetWslInteropSpawnDirectoryCache() + rmSync(systemRoot, { recursive: true, force: true }) + rmSync(profile, { recursive: true, force: true }) + } + }) + it('confirms an empty list when no distro runs', async () => { // Captured on WSL 2.7: with nothing running, `--quiet` exits 0 with empty stdout in both // encodings; only the non-quiet form prints the localized message and exits -1. diff --git a/src/main/wsl-unc-delete.test.ts b/src/main/wsl-unc-delete.test.ts index 160ca24ab44..1f8d9b2a6c5 100644 --- a/src/main/wsl-unc-delete.test.ts +++ b/src/main/wsl-unc-delete.test.ts @@ -4,6 +4,9 @@ import type { ProcessResult } from '@orca/process-host/process-spec' const { runProcessMock } = vi.hoisted(() => ({ runProcessMock: vi.fn() })) vi.mock('@orca/process-host', () => ({ runProcess: runProcessMock })) +vi.mock('./wsl-interop-spawn-directory', () => ({ + resolveWslInteropSpawnCwd: () => 'C:\\Windows' +})) function exited(code: number, stderr = ''): ProcessResult { return { code, signal: null, stdout: '', stderr, timedOut: false } @@ -58,7 +61,7 @@ describe('tryDeleteWslUncPath', () => { expect(timeoutMs).toBe(30000) // Why a concrete directory (#16463): this deletes worktrees, so the cwd it // would otherwise inherit is the very directory about to disappear. - expect(cwd).toEqual(expect.any(String)) + expect(cwd).toEqual('C:\\Windows') expect(spawnArgs).toEqual([ '-d', 'Ubuntu', diff --git a/src/main/wsl.test.ts b/src/main/wsl.test.ts index 2123075c2a1..3eb057694cf 100644 --- a/src/main/wsl.test.ts +++ b/src/main/wsl.test.ts @@ -10,6 +10,9 @@ vi.mock('@orca/process-host', () => ({ runProcess: runProcessMock, runProcessSync: runProcessSyncMock })) +vi.mock('./wsl-interop-spawn-directory', () => ({ + resolveWslInteropSpawnCwd: () => 'C:\\Windows' +})) function exited(stdout: string, code: number | null = 0): ProcessResult { return { code, signal: null, stdout, stderr: '', timedOut: false } @@ -391,7 +394,7 @@ describe('WSL availability cache', () => { program: 'wsl.exe', args: ['--status'], timeoutMs: 5000, - cwd: expect.any(String) + cwd: 'C:\\Windows' }) ) }) diff --git a/src/main/wsl/wsl-guest-environment.ts b/src/main/wsl/wsl-guest-environment.ts index aad903b91a9..3a24a17d86a 100644 --- a/src/main/wsl/wsl-guest-environment.ts +++ b/src/main/wsl/wsl-guest-environment.ts @@ -4,6 +4,7 @@ import { buildWslExecArgs } from '../../shared/wsl-login-shell-command' import { resolveWslExecutablePath } from './wsl-executable-path' +import { resolveWslInteropSpawnCwd } from '../wsl-interop-spawn-directory' /** * A distro's login-shell environment, probed once and cached. @@ -91,6 +92,7 @@ async function probeGuestEnvironment( // spawns wsl.exe itself, so without it wsl.exe's own errors arrive UTF-16LE // and the NUL-separated payload below is read through NUL-riddled text. env: { ...process.env, WSL_UTF8: '1' }, + cwd: resolveWslInteropSpawnCwd(), timeoutMs: Math.min(PROBE_TIMEOUT_MS, budgetMs), maxOutputBytes: PROBE_MAX_OUTPUT_BYTES }) diff --git a/src/main/wsl/wsl-runner.test.ts b/src/main/wsl/wsl-runner.test.ts index 4402e2bd9ee..29bc774ba12 100644 --- a/src/main/wsl/wsl-runner.test.ts +++ b/src/main/wsl/wsl-runner.test.ts @@ -2,6 +2,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const runProcessMock = vi.hoisted(() => vi.fn()) vi.mock('@orca/process-host', () => ({ runProcess: runProcessMock })) +vi.mock('../wsl-interop-spawn-directory', () => ({ + resolveWslInteropSpawnCwd: () => 'C:\\Windows' +})) vi.mock('./wsl-executable-path', () => ({ resolveWslExecutablePath: () => 'C:\\Windows\\System32\\wsl.exe' })) @@ -244,7 +247,7 @@ describe('guest cwd', () => { // The contract is that the GUEST path never becomes wsl.exe's Windows cwd, // not that there is no cwd: inheriting one is its own bug (#16463). expect(runProcessMock.mock.calls.at(-1)?.[0].cwd).not.toBe('/home/u/repo') - expect(runProcessMock.mock.calls.at(-1)?.[0].cwd).toEqual(expect.any(String)) + expect(runProcessMock.mock.calls.at(-1)?.[0].cwd).toEqual('C:\\Windows') expect(lastArgv()).toContain('/home/u/repo') expect(lastArgv()).toContain('sh') })