From 8c69c0fea1be55331c76e3444f1ec2e26f7fbb5c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:25:03 -0700 Subject: [PATCH] fix(codex): harden repeated WSL runtime drains --- ...sl-runtime-auth-drain-apply-script.test.ts | 3 +- ...runtime-auth-drain-rollback-script.test.ts | 45 +++++++++------ .../legacy-wsl-runtime-auth-drain-scripts.ts | 2 +- .../legacy-wsl-runtime-auth-drain.test.ts | 55 +++++++++++++++++-- .../legacy-wsl-runtime-auth-drain.ts | 25 ++++++++- .../codex-pane-account-registry-types.ts | 2 + src/main/codex/codex-pane-account-registry.ts | 49 +++++++++++++---- .../codex/codex-stale-pane-accounts.test.ts | 30 +++++++++- 8 files changed, 172 insertions(+), 39 deletions(-) diff --git a/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-apply-script.test.ts b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-apply-script.test.ts index 28c8742660c..fefc9f27877 100644 --- a/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-apply-script.test.ts +++ b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-apply-script.test.ts @@ -252,7 +252,8 @@ process.exit(result.status ?? 1) sha256(TARGET_AUTH), options.promoteAuth === false ? '0' : '1', options.deleteSource ? '1' : '0', - options.sourceCredentials === undefined ? 'missing' : sha256(options.sourceCredentials) + options.sourceCredentials === undefined ? 'missing' : sha256(options.sourceCredentials), + '1' ], { encoding: 'utf8', diff --git a/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-rollback-script.test.ts b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-rollback-script.test.ts index 0e62e2751a0..c87eeef799a 100644 --- a/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-rollback-script.test.ts +++ b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-rollback-script.test.ts @@ -63,24 +63,33 @@ describe.skipIf(process.platform === 'win32')('legacy WSL auth drain rollback re writeFileSync(targetAuthPath, TARGET_AUTH) expect(runInspect(root, legacyHome, markerPath)).toBe(0) - execFileSync( - '/bin/sh', - [ - '-c', - _internals.applyLegacyAuthScript, - 'sh', - legacyHome, - join(root, 'absent-active-home'), - markerPath, - targetHome, - sha256(SOURCE_AUTH), - sha256(TARGET_AUTH), - '1', - '1', - sha256(SOURCE_CREDENTIALS) - ], - { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 20_000 } - ) + const apply = (targetHash: string, promote: string, retire: string, bridge: string): void => { + execFileSync( + '/bin/sh', + [ + '-c', + _internals.applyLegacyAuthScript, + 'sh', + legacyHome, + join(root, 'absent-active-home'), + markerPath, + targetHome, + sha256(SOURCE_AUTH), + targetHash, + promote, + retire, + sha256(SOURCE_CREDENTIALS), + bridge + ], + { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 20_000 } + ) + } + + apply(sha256(TARGET_AUTH), '1', '0', '0') + expect(existsSync(sourceSessionPath)).toBe(true) + expect(existsSync(targetSessionPath)).toBe(false) + + apply(sha256(SOURCE_AUTH), '0', '1', '1') expect(existsSync(legacyAuthPath)).toBe(false) expect(readFileSync(targetAuthPath, 'utf8')).toBe(SOURCE_AUTH) diff --git a/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-scripts.ts b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-scripts.ts index 846294b2a95..c89125d4be7 100644 --- a/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-scripts.ts +++ b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-scripts.ts @@ -205,7 +205,7 @@ if [ "$7" = 1 ]; then fi rm -- "$temporary_previous_auth" fi -if [ "$8" != 1 ]; then +if [ "$8" != 1 ] && [ "\${10}" = 1 ]; then expected_target_hash="$6" [ "$7" != 1 ] || expected_target_hash="$5" # Keep both live auth inodes observable while links are staged, then prove diff --git a/src/main/codex-accounts/legacy-wsl-runtime-auth-drain.test.ts b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain.test.ts index 147ed4faecc..4669ee4a5fc 100644 --- a/src/main/codex-accounts/legacy-wsl-runtime-auth-drain.test.ts +++ b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain.test.ts @@ -1,13 +1,18 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { runWslProcessMock } = vi.hoisted(() => ({ - runWslProcessMock: vi.fn() +const { runWslProcessMock, startWslCodexSessionBridgeInBackgroundMock } = vi.hoisted(() => ({ + runWslProcessMock: vi.fn(), + startWslCodexSessionBridgeInBackgroundMock: vi.fn(() => Promise.resolve()) })) vi.mock('../wsl/wsl-runner', () => ({ runWslProcess: runWslProcessMock })) +vi.mock('../codex/wsl-codex-session-bridge', () => ({ + startWslCodexSessionBridgeInBackground: startWslCodexSessionBridgeInBackgroundMock +})) + import { _internals, drainLegacyWslRuntimeAuth, @@ -40,6 +45,7 @@ function result(code: number, stdout = '') { describe('legacy WSL runtime auth drain', () => { beforeEach(() => { runWslProcessMock.mockReset() + startWslCodexSessionBridgeInBackgroundMock.mockClear() _internals.resetDrainQueue() }) @@ -66,7 +72,8 @@ describe('legacy WSL runtime auth drain', () => { expect.any(String), '1', '0', - 'missing' + 'missing', + '1' ]) expect(runWslProcessMock.mock.calls[1]?.[0].script).toContain('readlink -f') expect(runWslProcessMock.mock.calls[1]?.[0].script).toContain('source_credentials=') @@ -90,7 +97,7 @@ describe('legacy WSL runtime auth drain', () => { }) expect(runWslProcessMock).toHaveBeenCalledTimes(2) - expect(runWslProcessMock.mock.calls[1]?.[0].args.slice(-3)).toEqual(['0', '0', 'missing']) + expect(runWslProcessMock.mock.calls[1]?.[0].args.slice(-4)).toEqual(['0', '0', 'missing', '1']) }) it('refuses a source with no unique destination', async () => { @@ -121,7 +128,7 @@ describe('legacy WSL runtime auth drain', () => { }) }) - expect(runWslProcessMock.mock.calls[1]?.[0].args.slice(-3)).toEqual(['0', '1', 'missing']) + expect(runWslProcessMock.mock.calls[1]?.[0].args.slice(-4)).toEqual(['0', '1', 'missing', '1']) expect(runWslProcessMock.mock.calls[1]?.[0].timeoutMs).toBe(30_000) }) @@ -236,6 +243,44 @@ describe('legacy WSL runtime auth drain', () => { expect(runWslProcessMock).toHaveBeenCalledTimes(1) }) + it('does not block sequential pending launches on repeated session walks', async () => { + runWslProcessMock.mockImplementation((options: { script: string }) => + Promise.resolve( + options.script === _internals.inspectLegacyAuthScript + ? result(0, inspection(SOURCE_AUTH)) + : result(0) + ) + ) + const options = { + distro: 'Ubuntu', + guestHomeLinuxPath: '/home/alice', + legacyPanePresent: true, + resolveDestination: () => ({ + authContents: STALE_AUTH, + linuxHomePath: '/home/alice/.local/share/orca/codex-accounts/account-1/home' + }) + } + + await startLegacyWslRuntimeAuthDrain(options, { throwOnFailure: true }) + await startLegacyWslRuntimeAuthDrain(options, { throwOnFailure: true }) + await startLegacyWslRuntimeAuthDrain( + { ...options, legacyPanePresent: false }, + { throwOnFailure: true } + ) + + const applyCalls = runWslProcessMock.mock.calls.filter( + ([call]) => call.script === _internals.applyLegacyAuthScript + ) + expect(applyCalls).toHaveLength(3) + expect(applyCalls.map(([call]) => call.args.at(-1))).toEqual(['1', '0', '1']) + expect(applyCalls.map(([call]) => call.timeoutMs)).toEqual([30_000, 5_000, 30_000]) + expect(startWslCodexSessionBridgeInBackgroundMock).toHaveBeenCalledExactlyOnceWith({ + distro: 'Ubuntu', + systemCodexHomePath: '/home/alice/.local/share/orca/codex-runtime-home/home', + managedCodexHomePath: '/home/alice/.local/share/orca/codex-accounts/account-1/home' + }) + }) + it('reads every candidate home in one bounded guest process', async () => { runWslProcessMock.mockResolvedValueOnce( result( diff --git a/src/main/codex-accounts/legacy-wsl-runtime-auth-drain.ts b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain.ts index a3bd29a931d..fea5345c512 100644 --- a/src/main/codex-accounts/legacy-wsl-runtime-auth-drain.ts +++ b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain.ts @@ -1,6 +1,7 @@ import { createHash } from 'node:crypto' import { posix as pathPosix } from 'node:path' import { wslCodexRuntimeHomeForGuestHome } from '../pty/codex-home-wsl-env' +import { startWslCodexSessionBridgeInBackground } from '../codex/wsl-codex-session-bridge' import { WSL_SESSION_BRIDGE_TIMEOUT_MS } from '../codex/wsl-codex-session-bridge-script' import { runWslProcess } from '../wsl/wsl-runner' import { compareCodexAuthFreshness, codexAuthIsFresher } from './codex-auth-identity' @@ -38,6 +39,7 @@ type LegacyWslRuntimeAuthDrainOptions = { const drainQueueByDistro = new Map>() const completedDistroKeys = new Set() +const pendingSessionBridgeRouteByDistro = new Map() export function startLegacyWslRuntimeAuthDrain( options: LegacyWslRuntimeAuthDrainOptions, @@ -78,6 +80,7 @@ function logDrainFailure(task: Promise): Promise { export async function drainLegacyWslRuntimeAuth( options: LegacyWslRuntimeAuthDrainOptions ): Promise<'complete' | 'pending'> { + const distroKey = options.distro.trim().toLowerCase() const paths = resolveLegacyRuntimePaths(options.guestHomeLinuxPath) const inspection = await runWslProcess({ distro: options.distro, @@ -113,6 +116,13 @@ export async function drainLegacyWslRuntimeAuth( const promoteAuth = freshness !== null && codexAuthIsFresher(inspected.authContents, destination.authContents) const deleteSource = !options.legacyPanePresent && freshness !== null + const sessionBridgeRoute = [ + paths.runtimeHome, + destination.linuxHomePath, + options.legacyPanePresent ? 'retained' : 'released' + ].join('\0') + const bridgeSessions = + deleteSource || pendingSessionBridgeRouteByDistro.get(distroKey) !== sessionBridgeRoute const result = await runWslProcess({ distro: options.distro, loginPath: 'none', @@ -126,9 +136,10 @@ export async function drainLegacyWslRuntimeAuth( sha256(destination.authContents), promoteAuth ? '1' : '0', deleteSource ? '1' : '0', - inspected.credentials.kind === 'present' ? sha256(inspected.credentials.contents) : 'missing' + inspected.credentials.kind === 'present' ? sha256(inspected.credentials.contents) : 'missing', + bridgeSessions ? '1' : '0' ], - timeoutMs: WSL_SESSION_BRIDGE_TIMEOUT_MS, + timeoutMs: bridgeSessions ? WSL_SESSION_BRIDGE_TIMEOUT_MS : 5_000, maxOutputBytes: 16 * 1024 }) try { @@ -136,6 +147,15 @@ export async function drainLegacyWslRuntimeAuth( } catch { return recoverAfterFailedApply(options.distro, paths) } + if (!deleteSource && bridgeSessions) { + pendingSessionBridgeRouteByDistro.set(distroKey, sessionBridgeRoute) + } else if (!deleteSource) { + void startWslCodexSessionBridgeInBackground({ + distro: options.distro, + systemCodexHomePath: paths.runtimeHome, + managedCodexHomePath: destination.linuxHomePath + }) + } return deleteSource ? 'complete' : 'pending' } @@ -251,5 +271,6 @@ export const _internals = { resetDrainQueue: (): void => { drainQueueByDistro.clear() completedDistroKeys.clear() + pendingSessionBridgeRouteByDistro.clear() } } diff --git a/src/main/codex/codex-pane-account-registry-types.ts b/src/main/codex/codex-pane-account-registry-types.ts index 1c93de135b2..446ca93e051 100644 --- a/src/main/codex/codex-pane-account-registry-types.ts +++ b/src/main/codex/codex-pane-account-registry-types.ts @@ -26,4 +26,6 @@ export type CodexPaneAccountRecord = { export type CodexPaneAccountRegistryFile = { version: 2 panes: Record + /** Set after record loss until daemon inventory proves no unattributed pane remains. */ + legacyWslAttributionUnknown?: true } diff --git a/src/main/codex/codex-pane-account-registry.ts b/src/main/codex/codex-pane-account-registry.ts index ef42b54461b..ebad8632676 100644 --- a/src/main/codex/codex-pane-account-registry.ts +++ b/src/main/codex/codex-pane-account-registry.ts @@ -62,6 +62,9 @@ function readRegistryOrNull(): CodexPaneAccountRegistryFile | null { const parsedRegistry = parseRegistryJson(rawRegistry) cachedRegistryIsAuthoritative = isAuthoritativeRegistry(parsedRegistry) cachedRegistry = parseRegistry(parsedRegistry) + if (!cachedRegistryIsAuthoritative) { + cachedRegistry.legacyWslAttributionUnknown = true + } return cachedRegistry } @@ -99,11 +102,16 @@ function isAuthoritativeRegistry(parsed: unknown): boolean { return false } const panes = (parsed as Partial).panes + const version = (parsed as Partial).version + const legacyWslAttributionUnknown = (parsed as Partial) + .legacyWslAttributionUnknown return ( + version === 2 && Boolean(panes) && typeof panes === 'object' && !Array.isArray(panes) && - Object.values(panes).every(isPaneAccountRecord) + Object.values(panes).every(isPaneAccountRecord) && + (legacyWslAttributionUnknown === undefined || legacyWslAttributionUnknown === true) ) } @@ -116,6 +124,9 @@ function parseRegistry(parsed: unknown): CodexPaneAccountRegistryFile { if (!panes || typeof panes !== 'object' || Array.isArray(panes)) { return empty } + if ((parsed as Partial).legacyWslAttributionUnknown === true) { + empty.legacyWslAttributionUnknown = true + } for (const [ptyId, record] of Object.entries(panes)) { if (isPaneAccountRecord(record)) { empty.panes[ptyId] = { @@ -163,7 +174,8 @@ function isPaneAccountRecord(value: unknown): value is CodexPaneAccountRecord { } const record = value as Partial return ( - typeof record.selectionKey === 'string' && + (record.selectionKey === 'host' || + (typeof record.selectionKey === 'string' && /^wsl:.+/.test(record.selectionKey))) && (record.accountId === null || typeof record.accountId === 'string') ) } @@ -285,11 +297,15 @@ export function hasRecordedLegacySharedCodexPane(): boolean { /** True when a retained WSL pane may still read the retired per-distro runtime home. */ export function hasRecordedLegacyWslCodexPane(selectionKey: string): boolean { - return Object.values(readRegistryOrThrow().panes).some( - (record) => - (wslSelectionKeysMatch(record.selectionKey, selectionKey) || - record.selectionKey === 'wsl:__default__') && - (record.homeRoute === undefined || record.homeRoute === 'wsl-home') + const registry = readRegistryOrThrow() + return ( + Boolean(registry.legacyWslAttributionUnknown) || + Object.values(registry.panes).some( + (record) => + (wslSelectionKeysMatch(record.selectionKey, selectionKey) || + record.selectionKey === 'wsl:__default__') && + (record.homeRoute === undefined || record.homeRoute === 'wsl-home') + ) ) } @@ -303,10 +319,14 @@ function wslSelectionKeysMatch(left: string, right: string): boolean { /** True when startup should reconcile a retained legacy WSL record with daemon inventory. */ export function hasAnyRecordedLegacyWslCodexPane(): boolean { - return Object.values(readRegistry().panes).some( - (record) => - record.selectionKey.startsWith('wsl:') && - (record.homeRoute === undefined || record.homeRoute === 'wsl-home') + const registry = readRegistry() + return ( + Boolean(registry.legacyWslAttributionUnknown) || + Object.values(registry.panes).some( + (record) => + record.selectionKey.startsWith('wsl:') && + (record.homeRoute === undefined || record.homeRoute === 'wsl-home') + ) ) } @@ -334,6 +354,13 @@ export function reconcileCodexPaneAccountsWithLivePtys(livePtyIds: readonly stri } const livePtyIdSet = new Set(livePtyIds) let changed = false + if ( + registry.legacyWslAttributionUnknown && + livePtyIds.every((ptyId) => ptyId in registry.panes) + ) { + delete registry.legacyWslAttributionUnknown + changed = true + } for (const ptyId of Object.keys(registry.panes)) { if (!livePtyIdSet.has(ptyId)) { delete registry.panes[ptyId] diff --git a/src/main/codex/codex-stale-pane-accounts.test.ts b/src/main/codex/codex-stale-pane-accounts.test.ts index 4d883f16468..027d059f342 100644 --- a/src/main/codex/codex-stale-pane-accounts.test.ts +++ b/src/main/codex/codex-stale-pane-accounts.test.ts @@ -291,7 +291,12 @@ describe('codex pane account registry', () => { it.each([ ['unparseable JSON', '{ not json'], - ['a malformed pane record', '{"version":2,"panes":{"pty-1":{"selectionKey":7}}}'] + ['a malformed pane record', '{"version":2,"panes":{"pty-1":{"selectionKey":7}}}'], + [ + 'an invalid lane key', + '{"version":2,"panes":{"pty-1":{"selectionKey":"Ubuntu","accountId":null}}}' + ], + ['an unknown registry version', '{"version":999,"panes":{}}'] ])('refuses to authorize a destructive WSL drain from %s', (_label, contents) => { writeFileSync(join(userDataPath, 'codex-pane-accounts.json'), contents) _internals.resetCache() @@ -299,6 +304,29 @@ describe('codex pane account registry', () => { expect(() => hasRecordedLegacyWslCodexPane('wsl:Ubuntu')).toThrow('registry could not be read') }) + it('does not authorize retirement after a new pane repairs a corrupt registry', () => { + writeFileSync(join(userDataPath, 'codex-pane-accounts.json'), '{ not json') + _internals.resetCache() + expect(() => hasRecordedLegacyWslCodexPane('wsl:Ubuntu')).toThrow() + + recordCodexPaneAccount('pty-direct', { + selectionKey: 'wsl:Ubuntu', + accountId: 'account-new', + homeRoute: 'account-home' + }) + _internals.resetCache() + + expect(hasRecordedLegacyWslCodexPane('wsl:Ubuntu')).toBe(true) + + reconcileCodexPaneAccountsWithLivePtys(['pty-direct', 'pty-legacy-unknown']) + _internals.resetCache() + expect(hasRecordedLegacyWslCodexPane('wsl:Ubuntu')).toBe(true) + + reconcileCodexPaneAccountsWithLivePtys(['pty-direct']) + _internals.resetCache() + expect(hasRecordedLegacyWslCodexPane('wsl:Ubuntu')).toBe(false) + }) + it('drops a malformed record without discarding its valid siblings', () => { writeFileSync( join(userDataPath, 'codex-pane-accounts.json'),