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,