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.
This commit is contained in:
Neil
2026-10-29 05:10:19 -07:00
committed by GitHub
parent 43ed9878b7
commit 434ffbac1b
21 changed files with 653 additions and 116 deletions
@@ -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')
})
})
@@ -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<typeof setTimeout> | undefined
try {
@@ -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,
@@ -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)
@@ -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)
@@ -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('/')
@@ -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 })
@@ -94,7 +94,10 @@ export abstract class CodexRuntimeHomeState {
abstract reconcileLegacySharedHomeForRetainedPanes(): void
abstract syncActiveWslSelectionsBeforeRestart(): Promise<void>
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
@@ -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(
@@ -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<typeof CodexPaneAccountRegistry>()),
@@ -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<typeof WslCodexAuthBatchReader>()),
@@ -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<typeof WslCodexAuthBatchReader>()),
@@ -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<typeof CodexHomePaths>()),
syncCodexGlobalInstructionsIntoManagedHome: mocks.syncInstructions
}))
vi.doMock('../codex/codex-config-mirror', async (importOriginal) => ({
...(await importOriginal<typeof CodexConfigMirror>()),
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()
})
})
@@ -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<typeof WslPaths>()
@@ -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',
+1
View File
@@ -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,
+107 -91
View File
@@ -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<LocalPtyProviderOptions['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
}
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 })) {
@@ -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<Record<string, unknown>>()),
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<Record<string, unknown>>()),
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)
})
})
+26 -1
View File
@@ -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<PtySpawnHostEnv> {
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<PtySpawnHostEnv> {
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')
@@ -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<string, string>,
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])
})
})
@@ -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<string, string> | Promise<Record<string, string>>
@@ -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
})
}
@@ -0,0 +1,49 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('../wsl', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
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<typeof runAsWslLaunchUserAct>[0]): Promise<boolean> {
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
)
})
})
+13
View File
@@ -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<T>(
launch: { isWsl: boolean; passiveRestore: boolean; distro: string | null | undefined },
act: () => Promise<T>
): Promise<T> {
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<T>(
distro: string | null | undefined,