mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 16:03:49 +00:00
fix(codex): harden repeated WSL runtime drains
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user