fix(codex): harden repeated WSL runtime drains

This commit is contained in:
Brennan Benson
2026-08-27 15:25:03 -07:00
parent 6af1626154
commit 8c69c0fea1
8 changed files with 172 additions and 39 deletions
@@ -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',
@@ -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)
@@ -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
@@ -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(
@@ -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<string, Promise<void>>()
const completedDistroKeys = new Set<string>()
const pendingSessionBridgeRouteByDistro = new Map<string, string>()
export function startLegacyWslRuntimeAuthDrain(
options: LegacyWslRuntimeAuthDrainOptions,
@@ -78,6 +80,7 @@ function logDrainFailure(task: Promise<void>): Promise<void> {
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()
}
}
@@ -26,4 +26,6 @@ export type CodexPaneAccountRecord = {
export type CodexPaneAccountRegistryFile = {
version: 2
panes: Record<string, CodexPaneAccountRecord>
/** Set after record loss until daemon inventory proves no unattributed pane remains. */
legacyWslAttributionUnknown?: true
}
+38 -11
View File
@@ -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<CodexPaneAccountRegistryFile>).panes
const version = (parsed as Partial<CodexPaneAccountRegistryFile>).version
const legacyWslAttributionUnknown = (parsed as Partial<CodexPaneAccountRegistryFile>)
.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<CodexPaneAccountRegistryFile>).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<CodexPaneAccountRecord>
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]
@@ -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'),