From 434ffbac1b618a4ae0cdb078446a9924b78f7b82 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 29 Oct 2026 05:10:19 -0700 Subject: [PATCH] fix(wsl): a passive restore's env prep never starts a stopped WSL distro (E9) (#35858) * fix(wsl): a passive restore's env prep never starts a stopped WSL distro (E9) resolvePtySpawnHostEnv runs before #35813's hold. Every spawn but a passive restore now runs its prep as a user act on its distro, so explicit launches are unchanged. prepareForCodexLaunchAsync checks blockedWslDistroForExec once for WSL targets and, when blocked, skips the guest-home probe, the legacy auth drain, the config and instructions sync and the session bridge, returning the home without probing. When not blocked, the home is warmed with getWslHomeAsync so the sync reads hit the cache instead of a 5 s wsl.exe. The state-DB recovery and the explicit plugin readiness wait are gated the same way. * fix(wsl): only a launch someone asked for acts on its distro; gate the relay start A passive restore or a non-WSL launch never runs as a user act, not even on the default distro (a null distro resolved to the default before). The daemon-off local provider applies the same rule, receiving wslPassiveRestore from the spawn options. Starting the hook relay (ensureForDistro from every WSL spawn's env build) now checks blockedForExec before the state check, so a passive restore on a stopped distro starts no relay. --- .../wsl-agent-plugin-readiness.test.ts | 51 +++++ .../agent-hooks/wsl-agent-plugin-readiness.ts | 5 + src/main/agent-hooks/wsl-hook-relay-deps.ts | 4 + .../wsl-hook-relay-manager.test.ts | 11 + .../agent-hooks/wsl-hook-relay-manager.ts | 5 + .../runtime-home-service-home-routing.ts | 9 +- .../runtime-home-service-launch.ts | 12 ++ .../runtime-home-service-state.ts | 5 +- .../runtime-home-service-wsl.ts | 8 +- .../runtime-home-wsl-managed-accounts.test.ts | 34 +-- ...time-home-wsl-passive-restore-prep.test.ts | 155 ++++++++++++++ .../runtime-home-wsl-session-bridge.test.ts | 20 +- src/main/ipc/pty/ipc/spawn-env-codex.ts | 1 + src/main/ipc/pty/provider/local-configure.ts | 198 ++++++++++-------- ...spawn-host-env-wsl-passive-restore.test.ts | 130 ++++++++++++ src/main/ipc/pty/spawn-host-env.ts | 27 ++- .../providers/local-pty-pi-owner-env.test.ts | 27 +++ .../providers/local-pty-provider-types.ts | 2 + .../providers/local-pty-spawn-environment.ts | 3 +- src/main/wsl/wsl-background-exec-gate.test.ts | 49 +++++ src/main/wsl/wsl-background-exec-gate.ts | 13 ++ 21 files changed, 653 insertions(+), 116 deletions(-) create mode 100644 src/main/agent-hooks/wsl-agent-plugin-readiness.test.ts create mode 100644 src/main/codex-accounts/runtime-home-wsl-passive-restore-prep.test.ts create mode 100644 src/main/ipc/pty/spawn-host-env-wsl-passive-restore.test.ts create mode 100644 src/main/wsl/wsl-background-exec-gate.test.ts diff --git a/src/main/agent-hooks/wsl-agent-plugin-readiness.test.ts b/src/main/agent-hooks/wsl-agent-plugin-readiness.test.ts new file mode 100644 index 00000000000..ac2280cc069 --- /dev/null +++ b/src/main/agent-hooks/wsl-agent-plugin-readiness.test.ts @@ -0,0 +1,51 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + blocked: vi.fn(async (): Promise<{ distro: string; state: string } | null> => null) +})) +vi.mock('../wsl/wsl-background-exec-gate', () => ({ blockedWslDistroForExec: mocks.blocked })) + +import { awaitExplicitAgentPluginGuestReadiness } from './wsl-agent-plugin-readiness' + +function manager() { + return { + ensureForDistro: vi.fn(async () => {}), + getGuestEndpointFilePath: vi.fn(() => '/tmp/endpoint'), + getGuestAgentPath: vi.fn(() => '/tmp/pi') + } +} + +beforeEach(() => { + mocks.blocked.mockReset().mockResolvedValue(null) +}) + +// Ensuring the hook relay launches wsl.exe, before a passive restore's hold (#35813, E9). +describe('explicit agent plugin readiness on WSL', () => { + it('does not ensure the relay of a stopped distro', async () => { + mocks.blocked.mockResolvedValue({ distro: 'Ubuntu', state: 'stopped' }) + const relay = manager() + await expect( + awaitExplicitAgentPluginGuestReadiness({ + isWsl: true, + distro: 'Ubuntu', + launchAgent: 'pi', + manager: relay + }) + ).resolves.toBe(false) + expect(mocks.blocked).toHaveBeenCalledWith('Ubuntu') + expect(relay.ensureForDistro).not.toHaveBeenCalled() + }) + + it('ensures it for a running distro, or inside a user act', async () => { + const relay = manager() + await expect( + awaitExplicitAgentPluginGuestReadiness({ + isWsl: true, + distro: 'Ubuntu', + launchAgent: 'pi', + manager: relay + }) + ).resolves.toBe(true) + expect(relay.ensureForDistro).toHaveBeenCalledWith('Ubuntu', undefined, 'pi') + }) +}) diff --git a/src/main/agent-hooks/wsl-agent-plugin-readiness.ts b/src/main/agent-hooks/wsl-agent-plugin-readiness.ts index 57b3039f302..6051e9d391b 100644 --- a/src/main/agent-hooks/wsl-agent-plugin-readiness.ts +++ b/src/main/agent-hooks/wsl-agent-plugin-readiness.ts @@ -11,6 +11,7 @@ import { } from '../../shared/pi-agent-kind' import type { TuiAgent } from '../../shared/tui-agent' import { wslHookRelayManager, type WslHookRelayManager } from './wsl-hook-relay-manager' +import { blockedWslDistroForExec } from '../wsl/wsl-background-exec-gate' export async function awaitExplicitAgentPluginGuestReadiness(args: { isWsl: boolean @@ -41,6 +42,10 @@ export async function awaitExplicitAgentPluginGuestReadiness(args: { return true } const distro = args.distro ?? null + // Ensuring the relay launches wsl.exe; a passive restore must not start a stopped distro (E9). + if (await blockedWslDistroForExec(distro)) { + return false + } const manager = args.manager ?? wslHookRelayManager let timer: ReturnType | undefined try { diff --git a/src/main/agent-hooks/wsl-hook-relay-deps.ts b/src/main/agent-hooks/wsl-hook-relay-deps.ts index bb970ee2c55..330cd45414a 100644 --- a/src/main/agent-hooks/wsl-hook-relay-deps.ts +++ b/src/main/agent-hooks/wsl-hook-relay-deps.ts @@ -27,6 +27,7 @@ import { readDaemonBridgeSessions, type DaemonBridgeSessionVerdict } from './wsl-bridge-daemon-sessions' +import { blockedWslDistroForExec } from '../wsl/wsl-background-exec-gate' // Concurrent wsl.exe spawns can trigger transient E_UNEXPECTED failures. export const WSL_RELAY_TRANSIENT_RETRY_DELAY_MS = 2_000 @@ -55,6 +56,8 @@ export type WslHookRelayManagerDeps = { /** Recovery must not boot a distro the user shut down. */ isDistroRunning: typeof isWslDistroRunning deferUntilRunning: typeof deferUntilWslDistroRunning + /** Starting a relay runs wsl.exe; only a running distro, or a user act's, may get one (E9). */ + blockedForExec: typeof blockedWslDistroForExec spawnRelay: typeof spawnWslRelayProcess runInstall: typeof runWslInstallProcess waitForSentinel: typeof waitForWslRelaySentinel @@ -106,6 +109,7 @@ export const defaultWslHookRelayDeps: WslHookRelayManagerDeps = { listDistros: () => listWslDistrosAsync(), isDistroRunning: isWslDistroRunning, deferUntilRunning: deferUntilWslDistroRunning, + blockedForExec: blockedWslDistroForExec, spawnRelay: spawnWslRelayProcess, runInstall: runWslInstallProcess, waitForSentinel: waitForWslRelaySentinel, diff --git a/src/main/agent-hooks/wsl-hook-relay-manager.test.ts b/src/main/agent-hooks/wsl-hook-relay-manager.test.ts index f068f8bed11..7058cf46062 100644 --- a/src/main/agent-hooks/wsl-hook-relay-manager.test.ts +++ b/src/main/agent-hooks/wsl-hook-relay-manager.test.ts @@ -237,6 +237,7 @@ describe('WslHookRelayManager', () => { listDistros: async () => ['Ubuntu'], isDistroRunning: vi.fn(async () => true), deferUntilRunning: vi.fn(), + blockedForExec: vi.fn(async () => null), spawnRelay: vi.fn(() => fakeChild()), runInstall: vi.fn(async () => ({ code: 0, stderr: '' })), waitForSentinel: vi.fn(async () => guestTransport()), @@ -261,6 +262,16 @@ describe('WslHookRelayManager', () => { return { manager: new WslHookRelayManager(deps), deps } } + it('starts no relay for a stopped distro outside a user act (a passive restore, E9)', async () => { + const { manager, deps } = createManager({ + blockedForExec: vi.fn(async () => ({ distro: 'Ubuntu', state: 'stopped' as const })) + }) + await manager.ensureForDistro('Ubuntu', codexHome) + expect(deps.blockedForExec).toHaveBeenCalledWith('Ubuntu') + expect(deps.spawnRelay).not.toHaveBeenCalled() + expect(manager.getGuestEndpointFilePath('Ubuntu')).toBeNull() + }) + it('starts one relay per distro, installs hooks, exposes the guest endpoint path, and forwards envelopes', async () => { const { manager, deps } = createManager({}) manager.ensureForDistro('Ubuntu', codexHome) diff --git a/src/main/agent-hooks/wsl-hook-relay-manager.ts b/src/main/agent-hooks/wsl-hook-relay-manager.ts index 51a2ae3d2dc..332726b1b58 100644 --- a/src/main/agent-hooks/wsl-hook-relay-manager.ts +++ b/src/main/agent-hooks/wsl-hook-relay-manager.ts @@ -126,6 +126,11 @@ export class WslHookRelayManager { if (!distro || this.disposed) { return } + // Before the state check, so concurrent ensures still join one start. A passive restore's env + // prep ensures the relay before its hold (#35813): its wsl.exe must not start the distro (E9). + if ((await this.deps.blockedForExec(distro)) || this.disposed) { + return + } const key = wslHookRelayStateKey(distro) this.idle.restoreParked(distro) const existing = this.states.get(key) diff --git a/src/main/codex-accounts/runtime-home-service-home-routing.ts b/src/main/codex-accounts/runtime-home-service-home-routing.ts index 0921e83bd52..83e9d7f4f37 100644 --- a/src/main/codex-accounts/runtime-home-service-home-routing.ts +++ b/src/main/codex-accounts/runtime-home-service-home-routing.ts @@ -2,6 +2,7 @@ import { posix as pathPosix } from 'node:path' import { parseWslUncPath, toLinuxPath, toWindowsWslUncPath } from '../../shared/wsl-paths' import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' import { getDefaultWslDistro, getWslHome } from '../wsl' +import { getCachedWslHome } from '../wsl-home-cache' import { isWslDistroRunning } from '../wsl-background-distro-gate' import { getSystemCodexHomePath, @@ -186,7 +187,10 @@ export abstract class CodexRuntimeHomeRouting extends CodexRuntimeHomeManagedHom await Promise.all(drains) } - protected getWslSystemCodexHomePath(target: CodexAccountSelectionTarget): string | null { + protected getWslSystemCodexHomePath( + target: CodexAccountSelectionTarget, + { probeHome = true }: { probeHome?: boolean } = {} + ): string | null { if (process.platform !== 'win32') { return null } @@ -194,7 +198,8 @@ export abstract class CodexRuntimeHomeRouting extends CodexRuntimeHomeManagedHom if (!distro) { return null } - const home = getWslHome(distro) + // Null (unknown home) means the guest's own ~/.codex, which is the system default anyway. + const home = probeHome ? getWslHome(distro) : (getCachedWslHome(distro) ?? null) if (home && /^[A-Za-z]:[\\/]/.test(home)) { const linuxHome = toLinuxPath(home).trim() return linuxHome.startsWith('/') diff --git a/src/main/codex-accounts/runtime-home-service-launch.ts b/src/main/codex-accounts/runtime-home-service-launch.ts index 254fc2b5daf..b2988bef073 100644 --- a/src/main/codex-accounts/runtime-home-service-launch.ts +++ b/src/main/codex-accounts/runtime-home-service-launch.ts @@ -20,6 +20,8 @@ import { resolveCodexSessionBackfillPaths } from '../codex/codex-session-backfil import type { CodexSessionBackfillDate } from '../codex/codex-session-backfill-types' import { ManagedCodexHomeTemporarilyUnavailableError } from './host-codex-managed-home-ownership' import { CodexRuntimeHomeRouting } from './runtime-home-service-home-routing' +import { blockedWslDistroForExec } from '../wsl/wsl-background-exec-gate' +import { getWslHomeAsync } from '../wsl' export abstract class CodexRuntimeHomeLaunch extends CodexRuntimeHomeRouting { protected initializeLastSyncedState(): void { @@ -134,6 +136,16 @@ export abstract class CodexRuntimeHomeLaunch extends CodexRuntimeHomeRouting { return this.prepareForCodexLaunch(target, launchEnv, options) } const wslTarget = this.resolveWslDefaultTarget(target) + // A passive restore reaches this before its hold (#35813); the probe, drain, sync and bridge + // below would each start a stopped distro (E9). A user act passes and does them all. + if (await blockedWslDistroForExec(wslTarget.wslDistro)) { + return this.getWslCodexHomePathForSelection(wslTarget, { probeHome: false }) + } + const distro = wslTarget.wslDistro?.trim() + if (distro && process.platform === 'win32') { + // Warm the guest-home cache off the main thread, so the sync reads below skip a 5 s wsl.exe. + await getWslHomeAsync(distro) + } const homePath = this.getWslCodexHomePathForSelection(wslTarget) // Drain potentially fresher retired-home credentials before the first direct-home spawn. await this.startLegacyWslAuthDrain(wslTarget, { throwOnFailure: true }) diff --git a/src/main/codex-accounts/runtime-home-service-state.ts b/src/main/codex-accounts/runtime-home-service-state.ts index 6a2bd2455b1..b30ea37c2a3 100644 --- a/src/main/codex-accounts/runtime-home-service-state.ts +++ b/src/main/codex-accounts/runtime-home-service-state.ts @@ -94,7 +94,10 @@ export abstract class CodexRuntimeHomeState { abstract reconcileLegacySharedHomeForRetainedPanes(): void abstract syncActiveWslSelectionsBeforeRestart(): Promise - protected abstract getWslSystemCodexHomePath(target: CodexAccountSelectionTarget): string | null + protected abstract getWslSystemCodexHomePath( + target: CodexAccountSelectionTarget, + options?: { probeHome?: boolean } + ): string | null protected abstract finishWslLaunchPreparation( target: CodexAccountSelectionTarget, homePath: string | null diff --git a/src/main/codex-accounts/runtime-home-service-wsl.ts b/src/main/codex-accounts/runtime-home-service-wsl.ts index 37ce93d661f..f8e0b1135ea 100644 --- a/src/main/codex-accounts/runtime-home-service-wsl.ts +++ b/src/main/codex-accounts/runtime-home-service-wsl.ts @@ -20,7 +20,11 @@ export abstract class CodexRuntimeHomeWsl extends CodexRuntimeHomeWslCore { return this.getWslCodexHomePathForSelection(target) } - protected getWslCodexHomePathForSelection(target: CodexAccountSelectionTarget): string | null { + /** `probeHome: false` never runs wsl.exe for the guest home: an uncached one reads as unknown. */ + protected getWslCodexHomePathForSelection( + target: CodexAccountSelectionTarget, + options: { probeHome?: boolean } = {} + ): string | null { const settings = this.store.getSettings() const account = this.getActiveAccount( settings.codexManagedAccounts, @@ -33,7 +37,7 @@ export abstract class CodexRuntimeHomeWsl extends CodexRuntimeHomeWslCore { return accountHome } } - return this.getWslSystemCodexHomePath(target) + return this.getWslSystemCodexHomePath(target, options) } protected getWslLaunchCodexHomePath( diff --git a/src/main/codex-accounts/runtime-home-wsl-managed-accounts.test.ts b/src/main/codex-accounts/runtime-home-wsl-managed-accounts.test.ts index 1d7baf1ea2b..995070b61ae 100644 --- a/src/main/codex-accounts/runtime-home-wsl-managed-accounts.test.ts +++ b/src/main/codex-accounts/runtime-home-wsl-managed-accounts.test.ts @@ -17,6 +17,8 @@ import { testState } from './runtime-home-service-test-harness' +// These launches are the user's own, so the E9 exec gate lets their prep run. +vi.mock('../wsl/wsl-background-exec-gate', () => ({ blockedWslDistroForExec: async () => null })) vi.mock('electron', () => ({ app: { getPath: () => testState.userDataDir @@ -46,7 +48,8 @@ describe('CodexRuntimeHomeService', () => { const wslHome = join(testState.userDataDir, 'wsl-home') vi.doMock('../wsl', () => ({ getDefaultWslDistro: () => 'Ubuntu', - getWslHome: () => wslHome + getWslHome: () => wslHome, + getWslHomeAsync: async () => wslHome })) const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json') writeFileSync(runtimeAuthPath, '{"account":"host-system"}\n', 'utf-8') @@ -116,7 +119,8 @@ describe('CodexRuntimeHomeService', () => { const wslHome = join(testState.userDataDir, 'wsl-home') vi.doMock('../wsl', () => ({ getDefaultWslDistro: () => 'Ubuntu', - getWslHome: () => wslHome + getWslHome: () => wslHome, + getWslHomeAsync: async () => wslHome })) const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system-token') const managedHomePath = createManagedAuth( @@ -182,7 +186,8 @@ describe('CodexRuntimeHomeService', () => { const wslHome = join(testState.userDataDir, 'wsl-home') vi.doMock('../wsl', () => ({ getDefaultWslDistro: () => 'Ubuntu', - getWslHome: () => wslHome + getWslHome: () => wslHome, + getWslHomeAsync: async () => wslHome })) const systemCodexHomePath = join(wslHome, '.codex') mkdirSync(systemCodexHomePath, { recursive: true }) @@ -240,7 +245,8 @@ describe('CodexRuntimeHomeService', () => { const wslHome = join(testState.userDataDir, 'wsl-home') vi.doMock('../wsl', () => ({ getDefaultWslDistro: () => 'Ubuntu', - getWslHome: () => wslHome + getWslHome: () => wslHome, + getWslHomeAsync: async () => wslHome })) const firstAuth = createCodexAuthJson('first@example.com', 'acct-first', 'first-token') const secondAuth = createCodexAuthJson('second@example.com', 'acct-second', 'second-token') @@ -320,7 +326,8 @@ describe('CodexRuntimeHomeService', () => { const wslHome = join(testState.userDataDir, 'wsl-home') vi.doMock('../wsl', () => ({ getDefaultWslDistro: () => 'Ubuntu', - getWslHome: () => wslHome + getWslHome: () => wslHome, + getWslHomeAsync: async () => wslHome })) let finishDrain: (() => void) | undefined const startLegacyWslRuntimeAuthDrain = vi.fn( @@ -373,9 +380,8 @@ describe('CodexRuntimeHomeService', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) const launch = service.prepareForCodexLaunchAsync({ runtime: 'wsl', wslDistro: 'Ubuntu' }) - await Promise.resolve() - - expect(startLegacyWslRuntimeAuthDrain).toHaveBeenCalledTimes(1) + // The E9 gate and the async home warm-up come first. + await vi.waitFor(() => expect(startLegacyWslRuntimeAuthDrain).toHaveBeenCalledTimes(1)) expect(startLegacyWslRuntimeAuthDrain.mock.calls[0]?.[1]).toEqual({ throwOnFailure: true }) expect(startWslCodexSessionBridgeInBackground).not.toHaveBeenCalled() @@ -400,7 +406,8 @@ describe('CodexRuntimeHomeService', () => { const wslHome = join(testState.userDataDir, 'wsl-home') vi.doMock('../wsl', () => ({ getDefaultWslDistro: () => 'Ubuntu', - getWslHome: () => wslHome + getWslHome: () => wslHome, + getWslHomeAsync: async () => wslHome })) vi.doMock('../codex/codex-pane-account-registry', async (importOriginal) => ({ ...(await importOriginal()), @@ -438,7 +445,8 @@ describe('CodexRuntimeHomeService', () => { const wslHome = join(testState.userDataDir, 'wsl-home') vi.doMock('../wsl', () => ({ getDefaultWslDistro: () => 'Ubuntu', - getWslHome: () => wslHome + getWslHome: () => wslHome, + getWslHomeAsync: async () => wslHome })) const wslManagedAuth = createCodexAuthJson( 'wsl@example.com', @@ -520,7 +528,8 @@ describe('CodexRuntimeHomeService', () => { const linuxHomePath = '/mnt/c/Users/alice/orca/codex-accounts/drive-account/home' vi.doMock('../wsl', () => ({ getDefaultWslDistro: () => 'Ubuntu', - getWslHome: () => 'C:\\Users\\alice' + getWslHome: () => 'C:\\Users\\alice', + getWslHomeAsync: async () => 'C:\\Users\\alice' })) vi.doMock('./wsl-codex-auth-batch-reader', async (importOriginal) => ({ ...(await importOriginal()), @@ -629,7 +638,8 @@ describe('CodexRuntimeHomeService', () => { ) vi.doMock('../wsl', () => ({ getDefaultWslDistro: () => 'Ubuntu', - getWslHome: () => 'C:\\Users\\alice' + getWslHome: () => 'C:\\Users\\alice', + getWslHomeAsync: async () => 'C:\\Users\\alice' })) vi.doMock('./wsl-codex-auth-batch-reader', async (importOriginal) => ({ ...(await importOriginal()), diff --git a/src/main/codex-accounts/runtime-home-wsl-passive-restore-prep.test.ts b/src/main/codex-accounts/runtime-home-wsl-passive-restore-prep.test.ts new file mode 100644 index 00000000000..d92bbab3045 --- /dev/null +++ b/src/main/codex-accounts/runtime-home-wsl-passive-restore-prep.test.ts @@ -0,0 +1,155 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { join } from 'node:path' +import type * as CodexConfigMirror from '../codex/codex-config-mirror' +import type * as CodexHomePaths from '../codex/codex-home-paths' +import { createSettings } from './runtime-home-settings-test-fixtures' +import { + createCodexAuthJson, + createManagedAuth, + createStore, + setupRuntimeHomeTest, + teardownRuntimeHomeTest, + testState +} from './runtime-home-service-test-harness' + +// The real rule: a stopped distro blocks, except for a user act on it. +vi.mock('../wsl/wsl-background-exec-gate', async () => { + const { isWslUserActFor, runAsWslUserAct } = await import('../wsl/wsl-user-act-scope') + return { + blockedWslDistroForExec: async (distro: string | null | undefined) => + distro && !isWslUserActFor(distro) ? { distro, state: 'stopped' } : null, + // The same scope instance the gate reads, whatever the module registry did between tests. + runAsWslExecUserAct: runAsWslUserAct + } +}) +vi.mock('electron', () => ({ app: { getPath: () => testState.userDataDir } })) +vi.mock(import('node:os'), async (importOriginal) => ({ + ...(await importOriginal()), + homedir: () => testState.fakeHomeDir +})) + +const LINUX_ACCOUNT_HOME = '/home/alice/.local/share/orca/codex-accounts/account-1/home' + +function wslMocks() { + const mocks = { + getWslHome: vi.fn(() => join(testState.userDataDir, 'wsl-home')), + getWslHomeAsync: vi.fn(async () => join(testState.userDataDir, 'wsl-home')), + drain: vi.fn(async () => {}), + bridge: vi.fn(async () => {}), + syncInstructions: vi.fn(), + syncConfig: vi.fn() + } + vi.doMock('../wsl', () => ({ + getDefaultWslDistro: () => 'Ubuntu', + getWslHome: mocks.getWslHome, + getWslHomeAsync: mocks.getWslHomeAsync + })) + vi.doMock('./legacy-wsl-runtime-auth-drain', () => ({ + startLegacyWslRuntimeAuthDrain: mocks.drain + })) + vi.doMock('../codex/wsl-codex-session-bridge', () => ({ + startWslCodexSessionBridgeInBackground: mocks.bridge + })) + vi.doMock('../codex/codex-home-paths', async (importOriginal) => ({ + ...(await importOriginal()), + syncCodexGlobalInstructionsIntoManagedHome: mocks.syncInstructions + })) + vi.doMock('../codex/codex-config-mirror', async (importOriginal) => ({ + ...(await importOriginal()), + syncSystemConfigIntoManagedCodexHome: mocks.syncConfig + })) + return mocks +} + +function accountStore(selected: boolean) { + const managedHomePath = createManagedAuth( + testState.userDataDir, + 'account-1', + createCodexAuthJson('wsl@example.com', 'acct-wsl', 'managed-token') + ) + return createStore( + createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'wsl@example.com', + managedHomePath, + managedHomeRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxHomePath: LINUX_ACCOUNT_HOME, + providerAccountId: 'acct-wsl', + workspaceLabel: null, + workspaceAccountId: 'acct-wsl', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeCodexManagedAccountIdsByRuntime: { + host: null, + wsl: { Ubuntu: selected ? 'account-1' : null } + } + }) + ) +} + +const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + +beforeEach(() => { + setupRuntimeHomeTest() + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) +}) + +afterEach(() => { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + teardownRuntimeHomeTest() +}) + +// A passive restore reaches launch prep before its hold (#35813); prep must not start the distro (E9). +describe('Codex launch prep for a stopped WSL distro', () => { + it('a passive restore gets the account home without a probe, drain, sync or bridge', async () => { + const mocks = wslMocks() + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the harness store implements the Store methods the runtime home service reads. + const service = new CodexRuntimeHomeService(accountStore(true) as never) + + const home = await service.prepareForCodexLaunchAsync({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + + expect(home).toBeTruthy() + expect(mocks.getWslHome).not.toHaveBeenCalled() + expect(mocks.getWslHomeAsync).not.toHaveBeenCalled() + expect(mocks.drain).not.toHaveBeenCalled() + expect(mocks.syncInstructions).not.toHaveBeenCalled() + expect(mocks.syncConfig).not.toHaveBeenCalled() + expect(mocks.bridge).not.toHaveBeenCalled() + }) + + it('a passive restore on system default reads an unknown home as null, never probing', async () => { + const mocks = wslMocks() + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the harness store implements the Store methods the runtime home service reads. + const service = new CodexRuntimeHomeService(accountStore(false) as never) + + await expect( + service.prepareForCodexLaunchAsync({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + ).resolves.toBeNull() + expect(mocks.getWslHome).not.toHaveBeenCalled() + }) + + it('the same launch as a user act warms the home off the main thread and drains', async () => { + const mocks = wslMocks() + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const { runAsWslExecUserAct } = await import('../wsl/wsl-background-exec-gate') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the harness store implements the Store methods the runtime home service reads. + const service = new CodexRuntimeHomeService(accountStore(true) as never) + + await runAsWslExecUserAct('Ubuntu', () => + service.prepareForCodexLaunchAsync({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + ) + + expect(mocks.getWslHomeAsync).toHaveBeenCalledWith('Ubuntu') + expect(mocks.drain).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/codex-accounts/runtime-home-wsl-session-bridge.test.ts b/src/main/codex-accounts/runtime-home-wsl-session-bridge.test.ts index ceb1990607a..3d857a9a94f 100644 --- a/src/main/codex-accounts/runtime-home-wsl-session-bridge.test.ts +++ b/src/main/codex-accounts/runtime-home-wsl-session-bridge.test.ts @@ -18,6 +18,8 @@ import { testState } from './runtime-home-service-test-harness' +// These launches are the user's own, so the E9 exec gate lets their prep run. +vi.mock('../wsl/wsl-background-exec-gate', () => ({ blockedWslDistroForExec: async () => null })) vi.mock('electron', () => ({ app: { getPath: () => testState.userDataDir @@ -51,7 +53,8 @@ describe('CodexRuntimeHomeService', () => { const wslHome = join(testState.userDataDir, 'wsl-home') vi.doMock('../wsl', () => ({ getDefaultWslDistro: () => 'Ubuntu', - getWslHome: () => wslHome + getWslHome: () => wslHome, + getWslHomeAsync: async () => wslHome })) const wslSystemHomePath = join(wslHome, '.codex') mkdirSync(wslSystemHomePath, { recursive: true }) @@ -101,7 +104,8 @@ describe('CodexRuntimeHomeService', () => { const wslHome = join(testState.userDataDir, 'wsl-home') vi.doMock('../wsl', () => ({ getDefaultWslDistro: () => 'Ubuntu', - getWslHome: () => wslHome + getWslHome: () => wslHome, + getWslHomeAsync: async () => wslHome })) const store = createStore( createSettings({ @@ -158,7 +162,8 @@ describe('CodexRuntimeHomeService', () => { const wslHome = join(testState.userDataDir, 'wsl-home') vi.doMock('../wsl', () => ({ getDefaultWslDistro: () => 'Ubuntu', - getWslHome: () => wslHome + getWslHome: () => wslHome, + getWslHomeAsync: async () => wslHome })) const store = createStore( createSettings({ @@ -198,7 +203,8 @@ describe('CodexRuntimeHomeService', () => { const wslHome = join(testState.userDataDir, 'debian-wsl-home') vi.doMock('../wsl', () => ({ getDefaultWslDistro: () => null, - getWslHome: (distro: string) => (distro === 'Debian' ? wslHome : null) + getWslHome: (distro: string) => (distro === 'Debian' ? wslHome : null), + getWslHomeAsync: async (distro: string) => (distro === 'Debian' ? wslHome : null) })) vi.doMock('../../shared/wsl-paths', async (importOriginal) => { const actual = await importOriginal() @@ -273,7 +279,8 @@ describe('CodexRuntimeHomeService', () => { const wslHome = join(testState.userDataDir, 'ubuntu-home') vi.doMock('../wsl', () => ({ getDefaultWslDistro: () => 'Ubuntu', - getWslHome: () => wslHome + getWslHome: () => wslHome, + getWslHomeAsync: async () => wslHome })) const managedHomePath = join(wslHome, '.local', 'share', 'orca', 'codex-accounts', 'a', 'home') const retiredBridgeRuns = vi.fn() @@ -345,7 +352,8 @@ describe('CodexRuntimeHomeService', () => { const wslHome = `\\\\wsl.localhost\\Ubuntu${guestHome.replaceAll('/', '\\')}` vi.doMock('../wsl', () => ({ getDefaultWslDistro: () => 'Ubuntu', - getWslHome: () => wslHome + getWslHome: () => wslHome, + getWslHomeAsync: async () => wslHome })) const ownerAuth = createCodexAuthJson( 'owner@example.com', diff --git a/src/main/ipc/pty/ipc/spawn-env-codex.ts b/src/main/ipc/pty/ipc/spawn-env-codex.ts index 6cb4b1fa2fd..a0a26866990 100644 --- a/src/main/ipc/pty/ipc/spawn-env-codex.ts +++ b/src/main/ipc/pty/ipc/spawn-env-codex.ts @@ -26,6 +26,7 @@ export async function assemblePtyIpcSpawnCodexEnv(ctx: PtyIpcSpawnState): Promis mintedSessionId: ctx.ownsNewIncarnation, resumeOnSelectedAccount: Boolean(args.replacesPtyId), emptySessionIdSkipsHostEnv: false, + wslPassiveRestore: args.wslPassiveRestore === true, requestedAgentTeamsPath: ctx.requestedAgentTeamsPath, provider: ctx.provider, deps: ctx.deps, diff --git a/src/main/ipc/pty/provider/local-configure.ts b/src/main/ipc/pty/provider/local-configure.ts index bf26259d3aa..1987d0c57ff 100644 --- a/src/main/ipc/pty/provider/local-configure.ts +++ b/src/main/ipc/pty/provider/local-configure.ts @@ -6,6 +6,8 @@ import type { GlobalSettings } from '../../../../shared/global-settings-types' import { isAgentStatusHooksEnabled } from '../../../agent-hooks/managed-agent-hook-controls' import { isPwshAvailableAsync } from '../../../pwsh' import { LocalPtyProvider } from '../../../providers/local-pty-provider' +import type { LocalPtyProviderOptions } from '../../../providers/local-pty-provider-types' +import { runAsWslLaunchUserAct } from '../../../wsl/wsl-background-exec-gate' import { addOrcaWslInteropEnv, stampWslOrchestrationCompatibilityHost @@ -38,6 +40,101 @@ export function configureLocalPtyProvider(args: { return } const { runtime, getSettings, getSelectedCodexHomePath, trustedTerminalHandleEnv } = args + const buildLocalSpawnEnv: NonNullable = async ( + id, + baseEnv, + ctx + ) => { + await prepareAntigravityAccountForLaunch({ + launchAgent: ctx?.launchAgent, + command: ctx?.command, + isWsl: ctx?.isWsl, + env: baseEnv, + envIsComplete: true + }) + const codexSelectionTarget: CodexAccountSelectionTarget = + ctx?.isWsl === true + ? { runtime: 'wsl', wslDistro: ctx.wslDistro ?? null } + : { runtime: 'host' } + const selectedCodexHomePath = getCompatibleSelectedCodexHomePath( + codexSelectionTarget, + ctx?.codexHomePathOverride + ? ctx.codexHomePathOverride.value + : ((await getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv)) ?? null) + ) + const skipCodexHomeEnv = ctx?.isWsl === true && !selectedCodexHomePath + const ptySettings = getSettings?.() + await inheritOmpLaunchEnvironment(baseEnv, { + shellPath: ctx?.shellPath, + explicitEnv: ctx?.explicitEnv, + isWsl: ctx?.isWsl, + launchAgent: ctx?.launchAgent, + launchCommand: ctx?.command + }) + await awaitExplicitAgentPluginGuestReadiness({ + settings: ptySettings, + isWsl: ctx?.isWsl === true, + distro: ctx?.wslDistro, + codexHomePath: selectedCodexHomePath, + launchAgent: ctx?.launchAgent, + launchCommand: ctx?.command + }) + await getClaudeAgentViewService().readyForLaunch({ + settings: ptySettings, + isWsl: ctx?.isWsl, + launchAgent: ctx?.launchAgent, + launchCommand: ctx?.command + }) + const env = buildPtyHostEnv(id, baseEnv, { + isPackaged: getAppEnvironment().isPackaged(), + resourcesPath: process.resourcesPath, + userDataPath: getAppEnvironment().getPath('userData'), + selectedCodexHomePath, + skipCodexHomeEnv, + stripInheritedOrcaCodexHome: shouldStripInheritedOrcaCodexHome({ + target: codexSelectionTarget, + selectedCodexHomePath, + skipCodexHomeEnv, + settings: ptySettings + }), + launchCommand: ctx?.command, + launchAgent: ctx?.launchAgent, + shellPath: ctx?.shellPath, + isWsl: ctx?.isWsl, + wslDistro: ctx?.wslDistro ?? null, + agentStatusHooksEnabled: isAgentStatusHooksEnabled(ptySettings), + claudeSubagentNavigationEnabled: ptySettings?.claudeSubagentNavigationEnabled, + terminalInlineImages: ptySettings?.terminalInlineImages, + disabledTuiAgents: ptySettings?.disabledTuiAgents, + networkProxySettings: ptySettings, + routeBrowserOpensToClient: runtime?.shouldRelayTerminalBrowserOpens?.() + }) + // Supply terminal identity at process start so agents can address orchestration without another RPC. + const requestedHandle = baseEnv.ORCA_TERMINAL_HANDLE + const preAllocatedHandle = + requestedHandle && trustedTerminalHandleEnv.has(requestedHandle) + ? requestedHandle + : runtime?.preAllocateHandleForPty(id) + if (requestedHandle && requestedHandle !== preAllocatedHandle) { + delete env.ORCA_TERMINAL_HANDLE + } + if (preAllocatedHandle) { + env.ORCA_TERMINAL_HANDLE = preAllocatedHandle + } + stampWslOrchestrationCompatibilityHost( + env, + runtime?.getOrchestrationCompatibilityHostId?.(), + ctx?.isWsl === true ? ctx.wslDistro : null + ) + stampRuntimeSourceEnv( + env, + getRuntimeSourceStamp(runtime, getAppEnvironment().getPath('userData')) + ) + if (ctx?.isWsl === true) { + addOrcaWslInteropEnv(env) + } + return env + } localProvider.configure({ isHistoryEnabled: () => getSettings?.()?.terminalScopeHistoryByWorktree ?? true, getWindowsShell: () => getSettings?.()?.terminalWindowsShell, @@ -45,97 +142,16 @@ export function configureLocalPtyProvider(args: { getWindowsPowerShellImplementation: () => getSettings ? (getSettings()?.terminalWindowsPowerShellImplementation ?? 'auto') : undefined, pwshAvailable: () => isPwshAvailableAsync(), - buildSpawnEnv: async (id, baseEnv, ctx) => { - await prepareAntigravityAccountForLaunch({ - launchAgent: ctx?.launchAgent, - command: ctx?.command, - isWsl: ctx?.isWsl, - env: baseEnv, - envIsComplete: true - }) - const codexSelectionTarget: CodexAccountSelectionTarget = - ctx?.isWsl === true - ? { runtime: 'wsl', wslDistro: ctx.wslDistro ?? null } - : { runtime: 'host' } - const selectedCodexHomePath = getCompatibleSelectedCodexHomePath( - codexSelectionTarget, - ctx?.codexHomePathOverride - ? ctx.codexHomePathOverride.value - : ((await getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv)) ?? null) - ) - const skipCodexHomeEnv = ctx?.isWsl === true && !selectedCodexHomePath - const ptySettings = getSettings?.() - await inheritOmpLaunchEnvironment(baseEnv, { - shellPath: ctx?.shellPath, - explicitEnv: ctx?.explicitEnv, - isWsl: ctx?.isWsl, - launchAgent: ctx?.launchAgent, - launchCommand: ctx?.command - }) - await awaitExplicitAgentPluginGuestReadiness({ - settings: ptySettings, - isWsl: ctx?.isWsl === true, - distro: ctx?.wslDistro, - codexHomePath: selectedCodexHomePath, - launchAgent: ctx?.launchAgent, - launchCommand: ctx?.command - }) - await getClaudeAgentViewService().readyForLaunch({ - settings: ptySettings, - isWsl: ctx?.isWsl, - launchAgent: ctx?.launchAgent, - launchCommand: ctx?.command - }) - const env = buildPtyHostEnv(id, baseEnv, { - isPackaged: getAppEnvironment().isPackaged(), - resourcesPath: process.resourcesPath, - userDataPath: getAppEnvironment().getPath('userData'), - selectedCodexHomePath, - skipCodexHomeEnv, - stripInheritedOrcaCodexHome: shouldStripInheritedOrcaCodexHome({ - target: codexSelectionTarget, - selectedCodexHomePath, - skipCodexHomeEnv, - settings: ptySettings - }), - launchCommand: ctx?.command, - launchAgent: ctx?.launchAgent, - shellPath: ctx?.shellPath, - isWsl: ctx?.isWsl, - wslDistro: ctx?.wslDistro ?? null, - agentStatusHooksEnabled: isAgentStatusHooksEnabled(ptySettings), - claudeSubagentNavigationEnabled: ptySettings?.claudeSubagentNavigationEnabled, - terminalInlineImages: ptySettings?.terminalInlineImages, - disabledTuiAgents: ptySettings?.disabledTuiAgents, - networkProxySettings: ptySettings, - routeBrowserOpensToClient: runtime?.shouldRelayTerminalBrowserOpens?.() - }) - // Supply terminal identity at process start so agents can address orchestration without another RPC. - const requestedHandle = baseEnv.ORCA_TERMINAL_HANDLE - const preAllocatedHandle = - requestedHandle && trustedTerminalHandleEnv.has(requestedHandle) - ? requestedHandle - : runtime?.preAllocateHandleForPty(id) - if (requestedHandle && requestedHandle !== preAllocatedHandle) { - delete env.ORCA_TERMINAL_HANDLE - } - if (preAllocatedHandle) { - env.ORCA_TERMINAL_HANDLE = preAllocatedHandle - } - stampWslOrchestrationCompatibilityHost( - env, - runtime?.getOrchestrationCompatibilityHostId?.(), - ctx?.isWsl === true ? ctx.wslDistro : null - ) - stampRuntimeSourceEnv( - env, - getRuntimeSourceStamp(runtime, getAppEnvironment().getPath('userData')) - ) - if (ctx?.isWsl === true) { - addOrcaWslInteropEnv(env) - } - return env - }, + // Daemon-off launches build their env here: the same user-act rule as the daemon path. + buildSpawnEnv: (id, baseEnv, ctx) => + runAsWslLaunchUserAct( + { + isWsl: ctx?.isWsl === true, + passiveRestore: ctx?.wslPassiveRestore === true, + distro: ctx?.wslDistro + }, + async () => buildLocalSpawnEnv(id, baseEnv, ctx) + ), onSpawned: (id, incarnationId) => runtime?.onPtySpawned(id, incarnationId), onExit: (id, code, incarnationId, cause) => { if (!isCurrentPtyExit({ id, incarnationId })) { diff --git a/src/main/ipc/pty/spawn-host-env-wsl-passive-restore.test.ts b/src/main/ipc/pty/spawn-host-env-wsl-passive-restore.test.ts new file mode 100644 index 00000000000..bad03125350 --- /dev/null +++ b/src/main/ipc/pty/spawn-host-env-wsl-passive-restore.test.ts @@ -0,0 +1,130 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + ensureBackfill: vi.fn(async () => {}), + blockedPath: vi.fn(async (): Promise<{ distro: string; state: string } | null> => null) +})) + +// The common single-distro setup: a null distro resolves to this default. +vi.mock('../../wsl', async (importOriginal) => ({ + ...(await importOriginal>()), + listWslDistrosAsync: async () => ['Ubuntu'] +})) +vi.mock('../../codex/codex-state-db-backfill-recovery', () => ({ + ensureCodexStateDbBackfillRecoveryStarted: mocks.ensureBackfill +})) +vi.mock('../../wsl/wsl-unc-access-gate', async (importOriginal) => ({ + ...(await importOriginal>()), + blockedWslDistroForPath: mocks.blockedPath +})) + +import { resolvePtySpawnHostEnv } from './spawn-host-env' +import { noCodexResumeLaunch, stripSequencedStartupResumeArgv } from './host-env/codex-resume' +import { isWslUserActFor } from '../../wsl/wsl-user-act-scope' +import type { IPtyProvider } from '../../providers/pty-provider-contract' +import type { CodexAccountSelectionTarget } from '../../codex-accounts/runtime-selection' + +const HOME = '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex' +const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + +function resolve( + wslPassiveRestore: boolean, + seen: boolean[], + launch: { target?: CodexAccountSelectionTarget; expectedWslDistro?: string | null } = {} +) { + return resolvePtySpawnHostEnv({ + launchAgent: 'codex', + command: 'codex', + resumeProviderSession: undefined, + env: {}, + cwd: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\repo', + shellOverride: undefined, + expectedWslDistro: launch.expectedWslDistro === undefined ? 'Ubuntu' : launch.expectedWslDistro, + codexSelectionTarget: launch.target ?? { runtime: 'wsl', wslDistro: 'Ubuntu' }, + preAdopted: false, + isDaemonHostSpawn: false, + sessionId: 'session-1', + callerNamedSession: true, + mintedSessionId: false, + resumeOnSelectedAccount: false, + emptySessionIdSkipsHostEnv: false, + wslPassiveRestore, + requestedAgentTeamsPath: undefined, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a non-daemon spawn returns before any provider call. + provider: {} as IPtyProvider, + deps: { + prepareCodexResumeHome: () => null, + resolveCodexResumeLaunch: async () => noCodexResumeLaunch('codex'), + noCodexResumeLaunch, + reconcileSharedRuntimeResumeHome: async (home) => home.codexHomePath, + stripSequencedStartupResumeArgv, + getSelectedCodexHomePath: async () => { + seen.push(isWslUserActFor('Ubuntu')) + return HOME + } + } + }) +} + +beforeEach(() => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + mocks.ensureBackfill.mockClear() + mocks.blockedPath.mockReset().mockResolvedValue(null) +}) + +afterEach(() => { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } +}) + +// Spawn env prep runs before #35813's hold: only a launch someone asked for may start the distro (E9). +describe('WSL spawn host env', () => { + it('runs a launch someone asked for as a user act on its distro', async () => { + const seen: boolean[] = [] + await resolve(false, seen) + expect(seen).toEqual([true]) + }) + + it('runs a passive restore outside any user act, so its prep is gated', async () => { + const seen: boolean[] = [] + await resolve(true, seen) + expect(seen).toEqual([false]) + }) + + it('never turns a passive restore with no named distro into a user act on the default one', async () => { + const seen: boolean[] = [] + await resolve(true, seen, { + target: { runtime: 'wsl', wslDistro: null }, + expectedWslDistro: null + }) + expect(seen).toEqual([false]) + }) + + it('never turns a host-target spawn into a user act on the default distro', async () => { + const seen: boolean[] = [] + await resolve(false, seen, { target: { runtime: 'host' }, expectedWslDistro: null }) + expect(seen).toEqual([false]) + }) + + it('a WSL launch someone asked for, with no named distro, acts on the default one', async () => { + const seen: boolean[] = [] + await resolve(false, seen, { + target: { runtime: 'wsl', wslDistro: null }, + expectedWslDistro: null + }) + expect(seen).toEqual([true]) + }) + + it('skips the state-DB recovery (a UNC read) when the distro is stopped', async () => { + mocks.blockedPath.mockResolvedValue({ distro: 'Ubuntu', state: 'stopped' }) + await resolve(true, []) + expect(mocks.blockedPath).toHaveBeenCalledWith(HOME) + expect(mocks.ensureBackfill).not.toHaveBeenCalled() + }) + + it('still starts the state-DB recovery when the distro runs', async () => { + await resolve(true, []) + expect(mocks.ensureBackfill).toHaveBeenCalledWith(HOME) + }) +}) diff --git a/src/main/ipc/pty/spawn-host-env.ts b/src/main/ipc/pty/spawn-host-env.ts index 2d231d9a329..1094d69545d 100644 --- a/src/main/ipc/pty/spawn-host-env.ts +++ b/src/main/ipc/pty/spawn-host-env.ts @@ -18,6 +18,8 @@ import { stampWslOrchestrationCompatibilityHost } from '../../pty/wsl-orca-env' import { getRuntimeSourceStamp } from '../../runtime/host-descriptor' import type { OrcaRuntimeService } from '../../runtime/orca-runtime' import { buildPtyHostEnv } from './host-env/assembly' +import { blockedWslDistroForPath } from '../../wsl/wsl-unc-access-gate' +import { runAsWslLaunchUserAct } from '../../wsl/wsl-background-exec-gate' import { codexHomePathsEqual, getCompatibleSelectedCodexHomePath, @@ -86,6 +88,8 @@ type PtySpawnHostEnvArgs = { resumeOnSelectedAccount: boolean /** Host accepts empty IDs without host env; window rejects them. */ emptySessionIdSkipsHostEnv: boolean + /** A restore no user act asked for (#35813): its prep must not start a stopped WSL distro. */ + wslPassiveRestore?: boolean requestedAgentTeamsPath: string | undefined provider: IPtyProvider deps: PtySpawnCodexResumeDeps & { @@ -96,7 +100,23 @@ type PtySpawnHostEnvArgs = { mark?: (phase: string) => void } +/** + * Every spawn but a passive restore is a launch someone asked for, so its prep may start the WSL + * distro it runs in. A passive restore's prep goes through the E9 gates and skips on a stopped one. + */ export async function resolvePtySpawnHostEnv(args: PtySpawnHostEnvArgs): Promise { + const target = args.codexSelectionTarget + return runAsWslLaunchUserAct( + { + isWsl: target.runtime === 'wsl', + passiveRestore: args.wslPassiveRestore === true, + distro: args.expectedWslDistro || target.wslDistro?.trim() || null + }, + () => resolvePtySpawnHostEnvForAct(args) + ) +} + +async function resolvePtySpawnHostEnvForAct(args: PtySpawnHostEnvArgs): Promise { const { deps, codexSelectionTarget: target } = args const codexResumePreparation = args.preAdopted ? null @@ -151,7 +171,12 @@ export async function resolvePtySpawnHostEnv(args: PtySpawnHostEnvArgs): Promise }) selectedCodexHomePath = resolution instanceof Promise ? await resolution : resolution } - if (launchesCodex && selectedCodexHomePath) { + // Its pending check reads the home's state DB, which starts a stopped distro's UNC home (E9). + if ( + launchesCodex && + selectedCodexHomePath && + !(await blockedWslDistroForPath(selectedCodexHomePath)) + ) { await ensureCodexStateDbBackfillRecoveryStarted(selectedCodexHomePath) } args.mark?.('codex_home') diff --git a/src/main/providers/local-pty-pi-owner-env.test.ts b/src/main/providers/local-pty-pi-owner-env.test.ts index 4b8643b7bc5..5743c530587 100644 --- a/src/main/providers/local-pty-pi-owner-env.test.ts +++ b/src/main/providers/local-pty-pi-owner-env.test.ts @@ -75,4 +75,31 @@ describe('independent local terminal Pi ownership', () => { } expect(env).toMatchObject({ ...terminalEnv, APP_ENV: 'kept' }) }) + + it('tells the daemon-off env builder whether this spawn is a passive restore (#35813)', async () => { + const seen: (boolean | undefined)[] = [] + const getOptions = () => ({ + buildSpawnEnv: ( + _id: string, + baseEnv: Record, + ctx?: { wslPassiveRestore?: boolean } + ) => { + seen.push(ctx?.wslPassiveRestore) + return baseEnv + } + }) + await buildLocalPtySpawnEnvironment({ + id: 'restored', + spawn: { cols: 80, rows: 24, wslPassiveRestore: true }, + getOptions, + plan + }) + await buildLocalPtySpawnEnvironment({ + id: 'new', + spawn: { cols: 80, rows: 24 }, + getOptions, + plan + }) + expect(seen).toEqual([true, false]) + }) }) diff --git a/src/main/providers/local-pty-provider-types.ts b/src/main/providers/local-pty-provider-types.ts index 7f92fe14496..167d751e0c0 100644 --- a/src/main/providers/local-pty-provider-types.ts +++ b/src/main/providers/local-pty-provider-types.ts @@ -15,6 +15,8 @@ export type LocalPtyProviderOptions = { shellPath?: string isWsl?: boolean wslDistro?: string | null + /** A restore no user act asked for (#35813): its prep must not start a stopped distro. */ + wslPassiveRestore?: boolean } // Async Codex hook/trust preparation must keep Electron main responsive (#16441). ) => Record | Promise> diff --git a/src/main/providers/local-pty-spawn-environment.ts b/src/main/providers/local-pty-spawn-environment.ts index 4b52a169e77..efcf7d661bc 100644 --- a/src/main/providers/local-pty-spawn-environment.ts +++ b/src/main/providers/local-pty-spawn-environment.ts @@ -46,7 +46,8 @@ export function buildLocalPtySpawnEnvironment(args: { cwd: plan.cwd, shellPath: plan.shellPath, isWsl: plan.isWslShell, - wslDistro: plan.launchWslDistro + wslDistro: plan.launchWslDistro, + wslPassiveRestore: spawn.wslPassiveRestore === true }) } diff --git a/src/main/wsl/wsl-background-exec-gate.test.ts b/src/main/wsl/wsl-background-exec-gate.test.ts new file mode 100644 index 00000000000..36114f502b5 --- /dev/null +++ b/src/main/wsl/wsl-background-exec-gate.test.ts @@ -0,0 +1,49 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('../wsl', async (importOriginal) => ({ + ...(await importOriginal>()), + listWslDistrosAsync: async () => ['Ubuntu'] +})) + +import { runAsWslLaunchUserAct } from './wsl-background-exec-gate' +import { isWslUserActFor } from './wsl-user-act-scope' + +const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + +beforeEach(() => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) +}) + +afterEach(() => { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } +}) + +async function actsOnUbuntu(launch: Parameters[0]): Promise { + return runAsWslLaunchUserAct(launch, async () => isWslUserActFor('Ubuntu')) +} + +// The default distro stands in for a null one only for a WSL launch someone asked for (E9). +describe('runAsWslLaunchUserAct', () => { + it('acts on the named or default distro for a WSL launch someone asked for', async () => { + await expect( + actsOnUbuntu({ isWsl: true, passiveRestore: false, distro: 'Ubuntu' }) + ).resolves.toBe(true) + await expect(actsOnUbuntu({ isWsl: true, passiveRestore: false, distro: null })).resolves.toBe( + true + ) + }) + + it('never acts for a passive restore or a non-WSL launch, even with a default distro', async () => { + await expect(actsOnUbuntu({ isWsl: true, passiveRestore: true, distro: null })).resolves.toBe( + false + ) + await expect( + actsOnUbuntu({ isWsl: true, passiveRestore: true, distro: 'Ubuntu' }) + ).resolves.toBe(false) + await expect(actsOnUbuntu({ isWsl: false, passiveRestore: false, distro: null })).resolves.toBe( + false + ) + }) +}) diff --git a/src/main/wsl/wsl-background-exec-gate.ts b/src/main/wsl/wsl-background-exec-gate.ts index 7e80157662d..1f528626ee3 100644 --- a/src/main/wsl/wsl-background-exec-gate.ts +++ b/src/main/wsl/wsl-background-exec-gate.ts @@ -36,6 +36,19 @@ export async function assertWslDistroRunningForExec( } } +/** + * A terminal launch someone asked for runs as a user act on its distro (null: the default); a + * passive restore (#35813) or a non-WSL launch runs as-is, so the E9 gates apply to it. + */ +export function runAsWslLaunchUserAct( + launch: { isWsl: boolean; passiveRestore: boolean; distro: string | null | undefined }, + act: () => Promise +): Promise { + return launch.isWsl && !launch.passiveRestore + ? runAsWslExecUserAct(launch.distro ?? null, act) + : act() +} + /** Runs `act` as a user act on `distro` (null: the default), so its `wsl.exe` runs may start it. */ export async function runAsWslExecUserAct( distro: string | null | undefined,