From 3cb9b5d87f442a05fa848f4cb40cc984ac7f1b55 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:11:33 -0400 Subject: [PATCH 1/4] fix(serve): keep Chromium switches out of CLI redirect (#17633) --- .../startup/appimage-cli-redirect.test.ts | 70 ++++++++++++++++++- src/main/startup/appimage-cli-redirect.ts | 36 ++++++++-- ...serve-mode-argv-cli-redirect-order.test.ts | 14 ++-- src/main/startup/serve-mode-argv.test.ts | 18 +++++ src/main/startup/serve-mode-argv.ts | 3 +- 5 files changed, 126 insertions(+), 15 deletions(-) diff --git a/src/main/startup/appimage-cli-redirect.test.ts b/src/main/startup/appimage-cli-redirect.test.ts index f3a18aa57eb..99d5024a517 100644 --- a/src/main/startup/appimage-cli-redirect.test.ts +++ b/src/main/startup/appimage-cli-redirect.test.ts @@ -53,7 +53,28 @@ describe('AppImage CLI redirect', () => { ).toBeNull() }) - it('routes no-sandbox serve launches through the CLI', () => { + it('keeps direct serve launches in Electron when Chromium switches are present', () => { + expect( + getAppImageCliArgs( + [ + 'AppRun', + '--no-sandbox', + '--disable-features=FedCm,DirectSockets', + 'serve', + '--port', + '6768' + ], + { APPIMAGE: '/opt/orca' }, + { + platform: 'linux', + isPackaged: true, + commandNames + } + ) + ).toBeNull() + }) + + it('keeps clean serve launches on the CLI path for validation', () => { expect( getAppImageCliArgs( ['AppRun', '--no-sandbox', 'serve', '--port', '6768'], @@ -67,6 +88,34 @@ describe('AppImage CLI redirect', () => { ).toEqual(['serve', '--port', '6768']) }) + it('handles a space-separated Chromium switch value', () => { + expect( + getAppImageCliArgs( + ['AppRun', '--disable-features', 'FedCm', 'serve'], + { APPIMAGE: '/opt/orca' }, + { + platform: 'linux', + isPackaged: true, + commandNames + } + ) + ).toBeNull() + }) + + it('does not broaden the Electron-owned exception to switches after serve', () => { + expect( + getAppImageCliArgs( + ['AppRun', 'serve', '--disable-features=FedCm'], + { APPIMAGE: '/opt/orca' }, + { + platform: 'linux', + isPackaged: true, + commandNames + } + ) + ).toEqual(['serve', '--disable-features=FedCm']) + }) + it('removes no-sandbox before forwarding CLI help', () => { expect( getAppImageCliArgs( @@ -81,6 +130,20 @@ describe('AppImage CLI redirect', () => { ).toEqual(['serve', '--help']) }) + it('still redirects serve help even when Chromium switches are present', () => { + expect( + getAppImageCliArgs( + ['AppRun', '--disable-features=FedCm', 'serve', '--help'], + { APPIMAGE: '/opt/orca' }, + { + platform: 'linux', + isPackaged: true, + commandNames + } + ) + ).toEqual(['--disable-features=FedCm', 'serve', '--help']) + }) + it('spawns the unpacked CLI entrypoint with Electron node mode', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-appimage-cli-redirect-')) const cliEntryPath = join(root, 'app.asar.unpacked', 'out', 'cli', 'index.js') @@ -118,14 +181,14 @@ describe('AppImage CLI redirect', () => { expect(spawnOptions?.env).not.toHaveProperty('NODE_REPL_EXTERNAL_MODULE') }) - it('forwards an explicit no-sandbox choice to the serve child', async () => { + it('keeps a clean no-sandbox serve launch on the CLI path', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-appimage-cli-redirect-')) const cliEntryPath = join(root, 'app.asar.unpacked', 'out', 'cli', 'index.js') await mkdir(join(root, 'app.asar.unpacked', 'out', 'cli'), { recursive: true }) await writeFile(cliEntryPath, '', 'utf8') const spawn = vi.fn((..._args: unknown[]) => ({ status: 0 })) - maybeRedirectAppImageCliLaunch({ + const result = maybeRedirectAppImageCliLaunch({ argv: ['orca-linux.AppImage', '--no-sandbox', 'serve'], env: { APPIMAGE: '/opt/orca/orca-linux.AppImage' }, platform: 'linux', @@ -136,6 +199,7 @@ describe('AppImage CLI redirect', () => { spawn: spawn as never }) + expect(result).toEqual({ redirected: true, status: 0 }) expect(spawn).toHaveBeenCalledWith( '/opt/orca/orca-ide', [cliEntryPath, 'serve'], diff --git a/src/main/startup/appimage-cli-redirect.ts b/src/main/startup/appimage-cli-redirect.ts index c2f90768049..2ca5f54e155 100644 --- a/src/main/startup/appimage-cli-redirect.ts +++ b/src/main/startup/appimage-cli-redirect.ts @@ -24,7 +24,8 @@ type RedirectOptions = { const HELP_FLAGS = new Set(['--help', '-h', 'help']) const APPIMAGE_DESKTOP_FLAGS = new Set(['--no-sandbox']) -const CLI_FLAGS_WITH_VALUES = new Set(['--environment', '--pairing-code']) +const ELECTRON_LAUNCH_SWITCHES = new Set(['--disable-features']) +const CLI_FLAGS_WITH_VALUES = new Set(['--environment', '--pairing-code', '--disable-features']) // Why: the main tsconfig cannot import the CLI project, but AppImage direct // launches need a conservative allow-list before bypassing the GUI startup. const APPIMAGE_CLI_COMMAND_NAMES = [ @@ -157,21 +158,44 @@ export function getAppImageCliArgs( const commandNames = new Set(options.commandNames) const firstPositional = findFirstCommandCandidate(cliArgs) - return firstPositional && commandNames.has(firstPositional) ? cliArgs : null + if (!firstPositional || !commandNames.has(firstPositional)) { + return null + } + // Keep serve in Electron only when an Electron launch switch is present. + // Forwarding it to the strict Node-mode CLI parser makes an otherwise valid + // serve launch fail, while clean serve invocations retain CLI validation. + if ( + firstPositional === 'serve' && + cliArgs + .slice(0, findFirstCommandCandidateIndex(cliArgs)) + .some((arg) => ELECTRON_LAUNCH_SWITCHES.has(flagName(arg))) + ) { + return null + } + return cliArgs } function findFirstCommandCandidate(args: string[]): string | null { + const index = findFirstCommandCandidateIndex(args) + return index === -1 ? null : args[index]! +} + +function findFirstCommandCandidateIndex(args: string[]): number { for (let index = 0; index < args.length; index += 1) { const arg = args[index] if (!arg.startsWith('-')) { - return arg + return index } - const flagName = arg.includes('=') ? arg.slice(0, arg.indexOf('=')) : arg - if (CLI_FLAGS_WITH_VALUES.has(flagName) && !arg.includes('=')) { + if (CLI_FLAGS_WITH_VALUES.has(flagName(arg)) && !arg.includes('=')) { index += 1 } } - return null + return -1 +} + +function flagName(arg: string): string { + const equalsIndex = arg.indexOf('=') + return equalsIndex === -1 ? arg : arg.slice(0, equalsIndex) } function buildElectronRunAsNodeEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { diff --git a/src/main/startup/serve-mode-argv-cli-redirect-order.test.ts b/src/main/startup/serve-mode-argv-cli-redirect-order.test.ts index 3a389bb80d4..be6bf76e625 100644 --- a/src/main/startup/serve-mode-argv-cli-redirect-order.test.ts +++ b/src/main/startup/serve-mode-argv-cli-redirect-order.test.ts @@ -4,10 +4,9 @@ import { describe, expect, it } from 'vitest' import { getAppImageCliArgs } from './appimage-cli-redirect' import { argvRequestsServeMode, normalizeServeModeArgv } from './serve-mode-argv' -// Why: index.ts must run both CLI redirects before rewriting argv. Rewriting -// first replaces the `serve` positional with `--serve`, so the redirect's -// command-name lookup finds a port number instead of a command and bails — -// silently dropping AppImage serve launches out of the CLI path (#12677). +// Why: index.ts runs CLI redirects before rewriting argv. Direct AppImage serve +// stays in Electron so launch switches do not cross into the strict Node-mode +// CLI parser; other CLI commands still depend on redirect ordering (#12677). const REDIRECT_OPTIONS = { platform: 'linux' as const, @@ -24,7 +23,7 @@ function rewriteAsIndexDoes(argv: string[]): string[] { describe('serve argv rewrite vs AppImage CLI redirect ordering', () => { const launchArgv = ['/opt/orca/orca-ide', '--no-sandbox', 'serve', '--port', '7777', '--json'] - it('hands the launch argv to the CLI when the redirect runs first', () => { + it('keeps clean serve validation on the CLI path', () => { expect(getAppImageCliArgs(launchArgv, MOUNTED_APPIMAGE_ENV, REDIRECT_OPTIONS)).toEqual([ 'serve', '--port', @@ -33,6 +32,11 @@ describe('serve argv rewrite vs AppImage CLI redirect ordering', () => { ]) }) + it('keeps an injected Chromium switch in Electron before argv rewriting', () => { + const injected = [...launchArgv.slice(0, 2), '--disable-features=FedCm', ...launchArgv.slice(2)] + expect(getAppImageCliArgs(injected, MOUNTED_APPIMAGE_ENV, REDIRECT_OPTIONS)).toBeNull() + }) + it('loses the redirect if the rewrite runs first', () => { const rewritten = rewriteAsIndexDoes(launchArgv) expect(rewritten).toContain('--serve') diff --git a/src/main/startup/serve-mode-argv.test.ts b/src/main/startup/serve-mode-argv.test.ts index e82c02189d8..13c340c8e10 100644 --- a/src/main/startup/serve-mode-argv.test.ts +++ b/src/main/startup/serve-mode-argv.test.ts @@ -107,6 +107,24 @@ describe('serve-mode-argv', () => { ]) }) + it('keeps Electron-injected Chromium switches while normalizing direct serve', () => { + expect( + normalizeServeModeArgv([ + '/opt/orca/orca-ide', + '--disable-features=FedCm,DirectSockets', + 'serve', + '--port', + '6768' + ]) + ).toEqual([ + '/opt/orca/orca-ide', + '--disable-features=FedCm,DirectSockets', + '--serve', + '--serve-port', + '6768' + ]) + }) + it('leaves already-normalized argv unchanged', () => { // Why every value flag: the CLI's own `orca serve` spawns the app with exactly this shape // (serveOrcaApp), and the rewrite now runs over it too — a bad mapping would drop the port here. diff --git a/src/main/startup/serve-mode-argv.ts b/src/main/startup/serve-mode-argv.ts index 3ba25fd8d85..6b406faf80e 100644 --- a/src/main/startup/serve-mode-argv.ts +++ b/src/main/startup/serve-mode-argv.ts @@ -24,13 +24,14 @@ const CLI_TO_SERVE_VALUE_FLAG = new Map([ /** * Flags that consume the next argv token as a value (CLI-form + Electron passthrough). * Residual class: a flag outside this list whose space-separated value is literally `serve` would - * read as the subcommand. Chromium switches are `--flag=value` only, so no real launch does that. + * read as the subcommand. Include switches that may arrive in either argv shape. */ const VALUE_TAKING_FLAGS = new Set([ ...CLI_TO_SERVE_VALUE_FLAG.keys(), '--serve-port', '--serve-pairing-address', '--serve-project-root', + '--disable-features', '--user-data-dir', '--environment', '--pairing-code' From 5ff1aa540e70c48e2db87470be4d645d99576f9c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 31 Aug 2026 11:20:22 -0700 Subject: [PATCH 2/4] fix(codex): re-land WSL direct-home cutover with counsel findings fixed (#16854) * fix(codex): safely re-land WSL direct homes * fix(codex): finish WSL direct-home cutover * fix(codex): coalesce WSL launch hook installs * perf(codex): avoid duplicate retired WSL session scan * fix(codex): retain canonical WSL retired-home path * fix(codex): fail closed before retiring WSL auth * fix(codex): reopen WSL drain after rollback * fix(codex): preserve WSL source on unknown panes * fix(codex): harden repeated WSL runtime drains * perf(codex): bound pending WSL session scans * fix(codex): recover invalid WSL session watermarks * fix(codex): validate retained WSL scan state * fix(codex): accept durable WSL scan state * test(codex): cover the drain's inode-identity guard against destination replacement Removing the four `target_auth -ef temporary_destination_auth` assertions left all 33 apply-script tests passing, so a regression deleting them would have shipped silently. Reproduced before writing this. A hash check cannot catch the case. The pinned hard link keeps the original inode, so it still hashes correctly after another writer atomically renames a different file over the destination path; only inode identity sees it. Without the guard the script exits 0 and retires the source, leaving the user holding bytes nothing validated. The new case asserts the source survives. The harness is split by responsibility so no file exceeds its max-lines budget: fixtures, the coreutils interference shims, the run types, the apply runner, and the recovery/absent runners. The atomic-rename hook is deliberately separate from the in-place rewrite shim because different guards catch them. * fix(codex): keep the split drain harness inside the child-process boundaries Extracting the harness into non-test modules moved it out of the exemptions the single test file had: three new files import child_process, and two spawned without windowsHide. Adds the three to the import allowlist, and sets windowsHide on the spawns rather than exempting them - the flag is correct for these calls regardless of the ratchet, and they are skipped on win32 anyway. --------- Co-authored-by: Merge Sim --- .../agent-auth-restart-preservation.test.ts | 143 +++- src/main/agent-auth-restart-preservation.ts | 47 +- ...sl-runtime-auth-drain-apply-script.test.ts | 463 +++++++++++++ ...egacy-wsl-runtime-auth-drain-exit-codes.ts | 4 + ...time-auth-drain-recovery-script-harness.ts | 110 ++++ ...runtime-auth-drain-rollback-script.test.ts | 171 +++++ ...-wsl-runtime-auth-drain-script-fixtures.ts | 27 + ...y-wsl-runtime-auth-drain-script-harness.ts | 275 ++++++++ ...me-auth-drain-script-interference-shims.ts | 62 ++ ...wsl-runtime-auth-drain-script-run-types.ts | 40 ++ .../legacy-wsl-runtime-auth-drain-scripts.ts | 300 +++++++++ ...y-wsl-runtime-auth-drain-shell-commands.ts | 90 +++ .../legacy-wsl-runtime-auth-drain.test.ts | 307 +++++++++ .../legacy-wsl-runtime-auth-drain.ts | 269 ++++++++ ...legacy-wsl-runtime-auth-finalize-script.ts | 71 ++ .../codex-accounts/runtime-home-service.ts | 623 +++++++----------- .../runtime-home-wsl-managed-accounts.test.ts | 485 +++++++++----- .../runtime-home-wsl-session-bridge.test.ts | 381 ++++++++--- .../runtime-home-wsl-system-default.test.ts | 49 +- .../wsl-codex-auth-batch-reader.ts | 70 ++ .../codex-hook-service-implementation.ts | 19 + .../codex-pane-account-registry-types.ts | 2 + src/main/codex/codex-pane-account-registry.ts | 78 ++- .../codex/codex-pane-launch-account.test.ts | 30 +- src/main/codex/codex-pane-launch-account.ts | 69 +- .../codex/codex-stale-pane-accounts.test.ts | 87 +++ .../codex/hook-service-wsl-runtime.test.ts | 33 + .../codex/wsl-codex-session-bridge-script.ts | 122 ++++ .../codex/wsl-codex-session-bridge.test.ts | 111 +++- src/main/codex/wsl-codex-session-bridge.ts | 42 +- src/main/index.ts | 40 +- src/main/pty/codex-home-wsl-env.ts | 4 +- .../__fixtures__/wsl-invocation-allowlist.txt | 1 - .../child-process-import-allowlist.txt | 4 +- src/shared/wsl-paths.test.ts | 9 +- src/shared/wsl-paths.ts | 9 +- 36 files changed, 3802 insertions(+), 845 deletions(-) create mode 100644 src/main/codex-accounts/legacy-wsl-runtime-auth-drain-apply-script.test.ts create mode 100644 src/main/codex-accounts/legacy-wsl-runtime-auth-drain-exit-codes.ts create mode 100644 src/main/codex-accounts/legacy-wsl-runtime-auth-drain-recovery-script-harness.ts create mode 100644 src/main/codex-accounts/legacy-wsl-runtime-auth-drain-rollback-script.test.ts create mode 100644 src/main/codex-accounts/legacy-wsl-runtime-auth-drain-script-fixtures.ts create mode 100644 src/main/codex-accounts/legacy-wsl-runtime-auth-drain-script-harness.ts create mode 100644 src/main/codex-accounts/legacy-wsl-runtime-auth-drain-script-interference-shims.ts create mode 100644 src/main/codex-accounts/legacy-wsl-runtime-auth-drain-script-run-types.ts create mode 100644 src/main/codex-accounts/legacy-wsl-runtime-auth-drain-scripts.ts create mode 100644 src/main/codex-accounts/legacy-wsl-runtime-auth-drain-shell-commands.ts create mode 100644 src/main/codex-accounts/legacy-wsl-runtime-auth-drain.test.ts create mode 100644 src/main/codex-accounts/legacy-wsl-runtime-auth-drain.ts create mode 100644 src/main/codex-accounts/legacy-wsl-runtime-auth-finalize-script.ts create mode 100644 src/main/codex-accounts/wsl-codex-auth-batch-reader.ts create mode 100644 src/main/codex/wsl-codex-session-bridge-script.ts diff --git a/src/main/agent-auth-restart-preservation.test.ts b/src/main/agent-auth-restart-preservation.test.ts index 441ec609624..70063128068 100644 --- a/src/main/agent-auth-restart-preservation.test.ts +++ b/src/main/agent-auth-restart-preservation.test.ts @@ -15,8 +15,7 @@ describe('preserveAgentAuthBeforeRestart', () => { codexRuntimeHome: { syncForCurrentSelection: vi.fn(() => { calls.push('codex') - }), - syncActiveWslSelectionsBeforeRestart: vi.fn() + }) }, claudeRuntimeAuth: { syncForCurrentSelection: vi.fn(async () => { @@ -33,35 +32,13 @@ describe('preserveAgentAuthBeforeRestart', () => { expect(calls).toEqual(['codex', 'claude', 'flush']) }) - it('runs WSL Codex preservation through the runtime service', async () => { - const syncForCurrentSelection = vi.fn() - const syncActiveWslSelectionsBeforeRestart = vi.fn() - - await preserveAgentAuthBeforeRestart({ - codexRuntimeHome: { - syncForCurrentSelection, - syncActiveWslSelectionsBeforeRestart - }, - store: { - flushPendingOrThrowAsync: vi.fn() - } - }) - - expect(syncForCurrentSelection).toHaveBeenCalledTimes(1) - expect(syncForCurrentSelection).toHaveBeenNthCalledWith(1) - expect(syncActiveWslSelectionsBeforeRestart).toHaveBeenCalledTimes(1) - }) - - it('runs Claude preservation before WSL Codex preservation', async () => { + it('runs Claude preservation after Codex and before the store flush', async () => { const calls: string[] = [] await preserveAgentAuthBeforeRestart({ codexRuntimeHome: { syncForCurrentSelection: vi.fn(() => { calls.push('codex-host') - }), - syncActiveWslSelectionsBeforeRestart: vi.fn(() => { - calls.push('codex-wsl') }) }, claudeRuntimeAuth: { @@ -76,29 +53,104 @@ describe('preserveAgentAuthBeforeRestart', () => { } }) - expect(calls).toEqual(['codex-host', 'claude', 'codex-wsl', 'flush']) + expect(calls).toEqual(['codex-host', 'claude', 'flush']) }) - it('continues after WSL Codex preservation fails', async () => { + it('drains retained WSL Codex auth before flushing the store', async () => { + const calls: string[] = [] + + await preserveAgentAuthBeforeRestart({ + codexRuntimeHome: { + syncForCurrentSelection: vi.fn(() => { + calls.push('codex-host') + }), + syncActiveWslSelectionsBeforeRestart: vi.fn(async () => { + calls.push('codex-wsl') + }) + }, + store: { + flushPendingOrThrowAsync: vi.fn(async () => { + calls.push('flush') + }) + } + }) + + expect(calls).toEqual(['codex-host', 'codex-wsl', 'flush']) + }) + + it('does not release restart while the bounded WSL drain is still running', async () => { + vi.useFakeTimers() + let finishWslDrain!: () => void + let settled = false + const flushPendingOrThrowAsync = vi.fn() + + const preservation = preserveAgentAuthBeforeRestart({ + codexRuntimeHome: { + syncForCurrentSelection: vi.fn(), + syncActiveWslSelectionsBeforeRestart: vi.fn( + () => + new Promise((resolve) => { + finishWslDrain = resolve + }) + ) + }, + store: { flushPendingOrThrowAsync } + }).then(() => { + settled = true + }) + + await vi.advanceTimersByTimeAsync(2_000) + await vi.advanceTimersByTimeAsync(1) + expect(flushPendingOrThrowAsync).toHaveBeenCalledTimes(1) + expect(settled).toBe(false) + + finishWslDrain() + await preservation + expect(settled).toBe(true) + }) + + it('continues after the bounded WSL drain fails without logging secrets', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) const flushPendingOrThrowAsync = vi.fn() await preserveAgentAuthBeforeRestart({ codexRuntimeHome: { syncForCurrentSelection: vi.fn(), - syncActiveWslSelectionsBeforeRestart: vi.fn(() => { + syncActiveWslSelectionsBeforeRestart: vi.fn(async () => { throw new Error('wsl-token-secret') }) }, - store: { - flushPendingOrThrowAsync - } + store: { flushPendingOrThrowAsync } }) expect(flushPendingOrThrowAsync).toHaveBeenCalledTimes(1) + expect(warn).toHaveBeenCalledWith( + '[agent-auth-restart] Codex auth preservation failed (Error); continuing restart/update' + ) expect(JSON.stringify(warn.mock.calls)).not.toContain('token-secret') }) + it('does not start Claude after host Codex exhausts the lifecycle budget', async () => { + vi.useFakeTimers() + const startedAt = Date.now() + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const syncClaude = vi.fn(async () => {}) + + await preserveAgentAuthBeforeRestart({ + codexRuntimeHome: { + syncForCurrentSelection: vi.fn(() => { + vi.setSystemTime(startedAt + 2_000) + }) + }, + claudeRuntimeAuth: { syncForCurrentSelection: syncClaude } + }) + + expect(syncClaude).not.toHaveBeenCalled() + expect(warn).toHaveBeenCalledWith( + '[agent-auth-restart] Claude auth preservation exceeded 0ms; continuing restart/update' + ) + }) + it('flushes the store when auth services are missing', async () => { const flushPendingOrThrowAsync = vi.fn() @@ -116,8 +168,7 @@ describe('preserveAgentAuthBeforeRestart', () => { codexRuntimeHome: { syncForCurrentSelection: vi.fn(() => { throw new Error('codex-token-secret') - }), - syncActiveWslSelectionsBeforeRestart: vi.fn() + }) }, claudeRuntimeAuth: { syncForCurrentSelection: vi.fn(async () => { @@ -170,6 +221,32 @@ describe('preserveAgentAuthBeforeRestart', () => { expect(calls).toEqual(['claude-start', 'flush', 'claude-finish']) }) + it('shares the original lifecycle timeout between Claude and store preservation', async () => { + vi.useFakeTimers() + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + let settled = false + + const preservation = preserveAgentAuthBeforeRestart({ + claudeRuntimeAuth: { + syncForCurrentSelection: vi.fn(() => new Promise(() => {})) + }, + store: { + flushPendingOrThrowAsync: vi.fn(() => new Promise(() => {})) + } + }).then(() => { + settled = true + }) + + await vi.advanceTimersByTimeAsync(2_000) + await vi.runOnlyPendingTimersAsync() + await preservation + + expect(settled).toBe(true) + expect(warn).toHaveBeenCalledWith( + '[agent-auth-restart] Store persistence exceeded 0ms; continuing restart/update' + ) + }) + it('bounds a store flush that never settles', async () => { vi.useFakeTimers() const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) diff --git a/src/main/agent-auth-restart-preservation.ts b/src/main/agent-auth-restart-preservation.ts index eaae45f331b..a3e24ef8abe 100644 --- a/src/main/agent-auth-restart-preservation.ts +++ b/src/main/agent-auth-restart-preservation.ts @@ -4,10 +4,8 @@ import type { Store } from './persistence' const AUTH_PRESERVATION_TIMEOUT_MS = 2_000 -type CodexRuntimeAuthSync = Pick< - CodexRuntimeHomeService, - 'syncForCurrentSelection' | 'syncActiveWslSelectionsBeforeRestart' -> +type CodexRuntimeAuthSync = Pick & + Partial> type ClaudeRuntimeAuthSync = Pick type ShutdownStore = Pick @@ -28,34 +26,33 @@ export async function preserveAgentAuthBeforeRestart({ store }: AgentAuthRestartPreservationOptions): Promise { const startedAt = Date.now() - runCodexPreservationStep(codexRuntimeHome) + // Why: the drain owns guest-process timeouts; a shared 2s cutoff can relaunch before promotion. + const wslCodexPreservation = runWslCodexPreservationStep(codexRuntimeHome) - const remainingMs = Math.max(0, AUTH_PRESERVATION_TIMEOUT_MS - (Date.now() - startedAt)) - if (claudeRuntimeAuth && remainingMs > 0) { + const claudeRemainingMs = remainingLifecycleTime(startedAt) + if (claudeRuntimeAuth && claudeRemainingMs > 0) { await runWithinLifecycleTimeout( 'Claude auth preservation', () => claudeRuntimeAuth.syncForCurrentSelection(), - remainingMs + claudeRemainingMs ) } else if (claudeRuntimeAuth) { logStepTimeout('Claude auth preservation', 0) } - if (codexRuntimeHome && Date.now() - startedAt < AUTH_PRESERVATION_TIMEOUT_MS) { - runWslCodexPreservationStep(codexRuntimeHome) - } else if (codexRuntimeHome) { - logStepTimeout('Codex auth preservation', 0) - } + const storePreservation = store + ? runWithinLifecycleTimeout( + 'Store persistence', + () => store.flushPendingOrThrowAsync(), + remainingLifecycleTime(startedAt) + ) + : Promise.resolve() + await Promise.all([wslCodexPreservation, storePreservation]) +} - if (store) { - const storeRemainingMs = Math.max(0, AUTH_PRESERVATION_TIMEOUT_MS - (Date.now() - startedAt)) - await runWithinLifecycleTimeout( - 'Store persistence', - () => store.flushPendingOrThrowAsync(), - storeRemainingMs - ) - } +function remainingLifecycleTime(startedAt: number): number { + return Math.max(0, AUTH_PRESERVATION_TIMEOUT_MS - (Date.now() - startedAt)) } function runCodexPreservationStep(codexRuntimeHome: CodexRuntimeAuthSync | null | undefined): void { @@ -66,11 +63,11 @@ function runCodexPreservationStep(codexRuntimeHome: CodexRuntimeAuthSync | null } } -function runWslCodexPreservationStep( +async function runWslCodexPreservationStep( codexRuntimeHome: CodexRuntimeAuthSync | null | undefined -): void { +): Promise { try { - codexRuntimeHome?.syncActiveWslSelectionsBeforeRestart() + await codexRuntimeHome?.syncActiveWslSelectionsBeforeRestart?.() } catch (error) { logStepFailure('Codex auth preservation', error) } @@ -89,7 +86,7 @@ async function runWithinLifecycleTimeout( }) // Why: this timeout only releases the restart/update path. It does not - // cancel a sync that already started, and Codex sync is synchronous today. + // cancel a sync that already started. const timeoutResult = new Promise<'timeout'>((resolve) => { timeout = setTimeout(() => resolve('timeout'), timeoutMs) }) 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 new file mode 100644 index 00000000000..96d8daf0e8a --- /dev/null +++ b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-apply-script.test.ts @@ -0,0 +1,463 @@ +import { describe, expect, it } from 'vitest' +import { _internals } from './legacy-wsl-runtime-auth-drain' +import { + NEWER_AUTH, + RETIRED_SESSION, + SOURCE_AUTH, + SOURCE_CREDENTIALS, + TARGET_AUTH, + TORN_CREDENTIALS, + isWindows +} from './legacy-wsl-runtime-auth-drain-script-fixtures' +import { runApplyScript } from './legacy-wsl-runtime-auth-drain-script-harness' +import { + runAbsentLegacyHomeScript, + runRecoveryScript +} from './legacy-wsl-runtime-auth-drain-recovery-script-harness' + +describe.skipIf(isWindows)('legacy WSL auth drain apply script', () => { + it('distinguishes an absent legacy home from an authless retained home', () => { + const absent = runAbsentLegacyHomeScript({ + createLegacyHome: false, + script: _internals.inspectLegacyAuthScript + }) + const retained = runAbsentLegacyHomeScript({ + createLegacyHome: true, + script: _internals.inspectLegacyAuthScript + }) + + expect(absent.status).toBe(22) + expect(retained.status).toBe(21) + }) + + it('resolves an active-home-only legacy layout as retained', () => { + const outcome = runAbsentLegacyHomeScript({ + activeHomeOnly: true, + createLegacyHome: true, + script: _internals.inspectLegacyAuthScript + }) + + expect(outcome.status).toBe(21) + expect(outcome.markerExists).toBe(false) + }) + + it('does not finalize while an authless legacy home still holds sessions', () => { + const outcome = runAbsentLegacyHomeScript({ + createLegacyHome: true, + script: _internals.finalizeAbsentAuthScript + }) + + expect(outcome.status).toBe(47) + expect(outcome.markerExists).toBe(false) + }) + + it('creates the completion-marker parent when the retired tree never existed', () => { + const outcome = runAbsentLegacyHomeScript({ + createLegacyHome: false, + markerParentMissing: true, + script: _internals.finalizeAbsentAuthScript + }) + + expect(outcome.status).toBe(0) + expect(outcome.markerExists).toBe(true) + }) + + it('promotes the validated source into the account home', () => { + const outcome = runApplyScript() + expect(outcome.targetAuth).toBe(SOURCE_AUTH) + }) + + it('leaves the legacy home untouched while promoting', () => { + // One-directional: the promote step must never write back to the old home. + expect(runApplyScript().legacyAuth).toBe(SOURCE_AUTH) + }) + + it('refuses torn bytes and leaves the account home intact', () => { + // Hash call 1 is the source pre-check; rotating right after it means `cp` + // reads bytes freshness never judged. Pre-guard, those reached the target. + const outcome = runApplyScript({ rewriteAfterHashCall: 1 }) + expect(outcome.status).toBe(42) + expect(outcome.targetAuth).toBe(TARGET_AUTH) + }) + + it('refuses a symlinked live source before the destructive path can retire it', () => { + const result = runApplyScript({ deleteSource: true, sourceAuthSymlink: true }) + + expect(result.status).toBe(46) + expect(result.legacyAuth).toBe(SOURCE_AUTH) + expect(result.markerExists).toBe(false) + expect(result.targetAuth).toBe(TARGET_AUTH) + }) + + it('refuses MCP credentials that changed after host validation', () => { + const outcome = runApplyScript({ + rewriteAfterHashCall: 2, + rewriteBytes: TORN_CREDENTIALS, + rewriteTarget: 'source-credentials', + sourceCredentials: SOURCE_CREDENTIALS + }) + expect(outcome.status).toBe(43) + expect(outcome.targetCredentials).toBeNull() + }) + + it('does not overwrite auth changed after the destination hash check', () => { + const outcome = runApplyScript({ + rewriteBytes: NEWER_AUTH, + rewriteAfterHashCall: 4, + rewriteTarget: 'target-auth' + }) + expect(outcome.status).toBe(39) + expect(outcome.targetAuth).toBe(NEWER_AUTH) + }) + + it('keeps the source pending when an unpromoted destination changes before deletion', () => { + // Hash call 3 is the pinned destination check; the quarantine recheck must + // observe this in-place rewrite before removing the source. + const outcome = runApplyScript({ + deleteSource: true, + promoteAuth: false, + rewriteAfterHashCall: 3, + rewriteBytes: NEWER_AUTH, + rewriteTarget: 'target-auth' + }) + + expect(outcome.status).toBe(45) + expect(outcome.legacyAuth).toBe(SOURCE_AUTH) + expect(outcome.targetAuth).toBe(NEWER_AUTH) + expect(outcome.markerExists).toBe(false) + }) + + it('keeps the source pending when the destination changes after final precommit validation', () => { + // Hash call 9 validates the independent snapshot. A rewrite immediately + // afterward races the atomic cutover and must be detected on the old inode. + const outcome = runApplyScript({ + deleteSource: true, + promoteAuth: false, + rewriteAfterHashCall: 9, + rewriteBytes: NEWER_AUTH, + rewriteTarget: 'target-auth' + }) + + expect(outcome.status).toBe(45) + expect(outcome.legacyAuth).toBe(SOURCE_AUTH) + expect(outcome.targetAuth).toBe(NEWER_AUTH) + expect(outcome.markerExists).toBe(false) + }) + + it('blocks destination rewrites after the final locked validation', () => { + // Hash call 12 is the installed read-only destination check. The shim's + // attempted in-place rewrite must fail before the source is retired. + const outcome = runApplyScript({ + deleteSource: true, + promoteAuth: false, + rewriteAfterHashCall: 12, + rewriteTarget: 'target-auth' + }) + + expect(outcome.status).toBe(0) + expect(outcome.legacyAuth).toBeNull() + expect(outcome.targetAuth).toBe(TARGET_AUTH) + expect(outcome.markerExists).toBe(true) + }) + + it('recovers the source and destination mode after abrupt interruption', () => { + const outcome = runApplyScript({ deleteSource: true, killAfterSourceRemoval: true }) + + expect(outcome.status).not.toBe(0) + expect(outcome.legacyAuth).toBe(SOURCE_AUTH) + expect(outcome.targetAuth).toBe(SOURCE_AUTH) + expect(outcome.targetMode).toBe(0o600) + expect(outcome.markerExists).toBe(false) + }) + + it('can unlock the destination after interruption during atomic installation', () => { + const outcome = runApplyScript({ + deleteSource: true, + killAfterDestinationInstall: true + }) + + expect(outcome.status).not.toBe(0) + expect(outcome.legacyAuth).toBe(SOURCE_AUTH) + expect(outcome.targetAuth).toBe(SOURCE_AUTH) + expect(outcome.targetMode).toBe(0o600) + expect(outcome.markerExists).toBe(false) + }) + + it('cleans path metadata when interrupted before destination recovery is linked', () => { + const outcome = runApplyScript({ + deleteSource: true, + killBeforeDestinationRecoveryLink: true + }) + + expect(outcome.status).not.toBe(0) + expect(outcome.legacyAuth).toBe(SOURCE_AUTH) + expect(outcome.destinationRecoveryAuth).toBeNull() + expect(outcome.destinationRecoveryPathExists).toBe(false) + expect(outcome.markerExists).toBe(false) + }) + + it('restores verified source recovery instead of mutable quarantine after a crash', () => { + const outcome = runApplyScript({ + deleteSource: true, + killAfterSourceRemoval: true, + rewriteQuarantineBeforeRecovery: true + }) + + expect(outcome.status).not.toBe(0) + expect(outcome.legacyAuth).toBe(SOURCE_AUTH) + expect(outcome.sourceRecoveryAuth).toBeNull() + expect(outcome.sourceQuarantineAuth).toBe(NEWER_AUTH) + expect(outcome.markerExists).toBe(false) + }) + + it('retains verified destination recovery when the target inode changed after a crash', () => { + const outcome = runApplyScript({ + deleteSource: true, + killAfterSourceRemoval: true, + replaceTargetBeforeRecovery: true + }) + + expect(outcome.status).not.toBe(0) + expect(outcome.legacyAuth).toBe(SOURCE_AUTH) + expect(outcome.targetAuth).toBe(NEWER_AUTH) + expect(outcome.destinationRecoveryAuth).toBe(SOURCE_AUTH) + expect(outcome.destinationRecoveryPathExists).toBe(true) + expect(outcome.markerExists).toBe(false) + }) + + it('preserves a source rewrite that lands before quarantine', () => { + const outcome = runApplyScript({ + deleteSource: true, + promoteAuth: false, + rewriteAfterHashCall: 6, + rewriteBytes: NEWER_AUTH, + rewriteTarget: 'source-auth', + sourceSession: RETIRED_SESSION + }) + + expect(outcome.status).toBe(40) + expect(outcome.legacyAuth).toBe(NEWER_AUTH) + expect(outcome.targetSession).toBeNull() + expect(outcome.markerExists).toBe(false) + }) + + it('bridges retired sessions while a legacy pane still owns the source', () => { + const outcome = runApplyScript({ + deleteSource: false, + promoteAuth: false, + sourceSession: RETIRED_SESSION + }) + + expect(outcome.status).toBe(0) + expect(outcome.legacyAuth).toBe(SOURCE_AUTH) + expect(outcome.targetSession).toBe(RETIRED_SESSION) + expect(outcome.markerExists).toBe(false) + }) + + it('rolls back retained-pane links when source ownership changes during the bridge', () => { + const outcome = runApplyScript({ + deleteSource: false, + promoteAuth: false, + rewriteBytes: NEWER_AUTH, + rewriteSourceAfterSessionLink: true, + sourceSession: RETIRED_SESSION + }) + + expect(outcome.status).toBe(40) + expect(outcome.legacyAuth).toBe(NEWER_AUTH) + expect(outcome.targetSession).toBeNull() + expect(outcome.markerExists).toBe(false) + }) + + it('rolls back retained-pane links when destination ownership changes during the bridge', () => { + const outcome = runApplyScript({ + deleteSource: false, + promoteAuth: false, + rewriteBytes: NEWER_AUTH, + rewriteTargetAfterSessionLink: true, + sourceSession: RETIRED_SESSION + }) + + expect(outcome.status).toBe(45) + expect(outcome.legacyAuth).toBe(SOURCE_AUTH) + expect(outcome.targetAuth).toBe(NEWER_AUTH) + expect(outcome.targetSession).toBeNull() + expect(outcome.markerExists).toBe(false) + }) + + it('rolls back retired links when ownership changes during the bridge', () => { + const outcome = runApplyScript({ + deleteSource: true, + promoteAuth: false, + rewriteBytes: NEWER_AUTH, + rewriteSourceAfterSessionLink: true, + sourceSession: RETIRED_SESSION + }) + + expect(outcome.status).toBe(40) + expect(outcome.legacyAuth).toBe(NEWER_AUTH) + expect(outcome.sourceRecoveryAuth).toBe(SOURCE_AUTH) + expect(outcome.sourceQuarantineAuth).toBe(SOURCE_AUTH) + expect(outcome.targetSession).toBeNull() + expect(outcome.markerExists).toBe(false) + }) + + it('rolls back when an atomic rename replaces the destination during the bridge', () => { + const outcome = runApplyScript({ + deleteSource: true, + promoteAuth: false, + replaceTargetAfterSessionLink: true, + rewriteBytes: NEWER_AUTH, + sourceSession: RETIRED_SESSION + }) + + expect(outcome.status).toBe(45) + expect(outcome.legacyAuth).toBe(SOURCE_AUTH) + expect(outcome.targetAuth).toBe(NEWER_AUTH) + expect(outcome.targetSession).toBeNull() + expect(outcome.markerExists).toBe(false) + }) + + it('retires auth after bridging sessions across guest filesystems', () => { + const outcome = runApplyScript({ + crossFilesystemBridge: true, + deleteSource: true, + promoteAuth: false, + sourceSession: RETIRED_SESSION + }) + + expect(outcome.status).toBe(0) + expect(outcome.legacyAuth).toBeNull() + expect(outcome.targetSession).toBe(RETIRED_SESSION) + expect(outcome.markerExists).toBe(true) + }) + + it('restores the verified recovery when an open writer mutates the quarantined inode', () => { + const outcome = runApplyScript({ + deleteSource: true, + promoteAuth: false, + rewriteBytes: NEWER_AUTH, + rewriteQuarantineAfterSessionLink: true, + sourceSession: RETIRED_SESSION + }) + + expect(outcome.status).toBe(40) + expect(outcome.legacyAuth).toBe(SOURCE_AUTH) + expect(outcome.sourceRecoveryAuth).toBeNull() + expect(outcome.sourceQuarantineAuth).toBe(NEWER_AUTH) + expect(outcome.targetSession).toBeNull() + expect(outcome.markerExists).toBe(false) + }) + + it('rolls back a published session link after abrupt interruption', () => { + const outcome = runApplyScript({ + deleteSource: true, + killAfterSessionLink: true, + sourceSession: RETIRED_SESSION + }) + + expect(outcome.status).not.toBe(0) + expect(outcome.legacyAuth).toBe(SOURCE_AUTH) + expect(outcome.targetSession).toBeNull() + expect(outcome.markerExists).toBe(false) + }) + + it('finishes a durable session-link commit after abrupt interruption', () => { + const outcome = runApplyScript({ + deleteSource: true, + killDuringSessionCommit: true, + sourceSession: RETIRED_SESSION + }) + + expect(outcome.status).not.toBe(0) + expect(outcome.legacyAuth).toBe(SOURCE_AUTH) + expect(outcome.targetSession).toBe(RETIRED_SESSION) + expect(outcome.sessionCommitMarkerExists).toBe(false) + expect(outcome.markerExists).toBe(false) + }) + + it('finishes durable cleanup when inspection finds a committed marker', () => { + const outcome = runRecoveryScript({ + markerPresent: true, + script: _internals.inspectLegacyAuthScript + }) + + expect(outcome.status).toBe(20) + expect(outcome.destinationMode).toBe(0o600) + expect(outcome.destinationRecoveryExists).toBe(false) + expect(outcome.destinationRecoveryPathExists).toBe(false) + expect(outcome.sourceRecoveryExists).toBe(false) + }) + + it('finishes durable cleanup when apply finds a committed marker', () => { + const outcome = runRecoveryScript({ + markerPresent: true, + script: _internals.applyLegacyAuthScript + }) + + expect(outcome.status).toBe(0) + expect(outcome.destinationMode).toBe(0o600) + expect(outcome.destinationRecoveryExists).toBe(false) + expect(outcome.destinationRecoveryPathExists).toBe(false) + expect(outcome.sourceRecoveryExists).toBe(false) + }) + + it('refuses absent-source finalization while a durable recovery copy exists', () => { + const outcome = runRecoveryScript({ + markerPresent: false, + script: _internals.finalizeAbsentAuthScript + }) + + expect(outcome.status).toBe(46) + expect(outcome.sourceAuth).toBe(SOURCE_AUTH) + expect(outcome.destinationMode).toBe(0o600) + expect(outcome.destinationRecoveryExists).toBe(false) + expect(outcome.destinationRecoveryPathExists).toBe(false) + expect(outcome.sourceRecoveryExists).toBe(false) + expect(outcome.markerExists).toBe(false) + }) + + it('fails closed when destination recovery has no target-path metadata', () => { + const outcome = runRecoveryScript({ + markerPresent: false, + pathMetadata: false, + script: _internals.inspectLegacyAuthScript + }) + + expect(outcome.status).toBe(46) + expect(outcome.destinationRecoveryExists).toBe(true) + expect(outcome.destinationRecoveryPathExists).toBe(false) + }) + + it('deletes a promoted source only while the destination remains intact', () => { + const changedDestination = runApplyScript({ + deleteSource: true, + rewriteAfterHashCall: 7, + rewriteBytes: NEWER_AUTH, + rewriteTarget: 'target-auth' + }) + expect(changedDestination.status).toBe(45) + expect(changedDestination.legacyAuth).toBe(SOURCE_AUTH) + expect(changedDestination.markerExists).toBe(false) + + const outcome = runApplyScript({ deleteSource: true }) + + expect(outcome.status).toBe(0) + expect(outcome.legacyAuth).toBeNull() + expect(outcome.targetAuth).toBe(SOURCE_AUTH) + expect(outcome.markerExists).toBe(true) + }) + + it('refuses to retire the source when the destination is atomically replaced after the inode pin', () => { + // Why this needs its own case: the pinned hard link keeps the ORIGINAL inode, so its hash + // still matches after another writer renames a different file over the destination path. + // Only the `-ef` inode-identity assertions can see that; hash checks cannot. Without them + // the script proceeds and retires the source, leaving the user with bytes nobody validated. + const outcome = runApplyScript({ replaceTargetOnHashOf: '.orca-drain-destination-' }) + + expect(outcome.status).not.toBe(0) + // The source is the only thing that must survive an unproven destination. + expect(outcome.legacyAuth).toBe(SOURCE_AUTH) + expect(outcome.markerExists).toBe(false) + }) +}) diff --git a/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-exit-codes.ts b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-exit-codes.ts new file mode 100644 index 00000000000..73ef3593aa4 --- /dev/null +++ b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-exit-codes.ts @@ -0,0 +1,4 @@ +export const MARKER_PRESENT_EXIT = 20 +export const SOURCE_AUTH_ABSENT_EXIT = 21 +export const LEGACY_HOME_ABSENT_EXIT = 22 +export const LEGACY_HOME_STILL_PRESENT_EXIT = 47 diff --git a/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-recovery-script-harness.ts b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-recovery-script-harness.ts new file mode 100644 index 00000000000..36ddb2f5c89 --- /dev/null +++ b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-recovery-script-harness.ts @@ -0,0 +1,110 @@ +// Why: runs the drain's recovery and absent-legacy-home guest scripts under `sh`. +import { execFileSync } from 'node:child_process' +import { + linkSync, + mkdirSync, + mkdtempSync, + readFileSync, + writeFileSync, + existsSync, + statSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + RETIRED_SESSION, + RETIRED_SESSION_SEGMENTS, + SOURCE_AUTH, + TARGET_AUTH +} from './legacy-wsl-runtime-auth-drain-script-fixtures' + +export function runRecoveryScript(options: { + destinationRecovery?: boolean + markerPresent: boolean + pathMetadata?: boolean + script: string +}): { + destinationMode: number + destinationRecoveryExists: boolean + destinationRecoveryPathExists: boolean + markerExists: boolean + sourceAuth: string | null + sourceRecoveryExists: boolean + status: number +} { + const root = mkdtempSync(join(tmpdir(), 'orca-drain-recovery-')) + const legacyHome = join(root, 'legacy') + const targetHome = join(root, 'account') + mkdirSync(legacyHome) + mkdirSync(targetHome) + const markerPath = join(root, 'drain-marker.json') + const sourceRecoveryPath = `${markerPath}.orca-drain-source` + const destinationRecoveryPath = `${markerPath}.orca-drain-destination` + const destinationRecoveryTargetPath = `${markerPath}.orca-drain-destination-path` + const sourceAuthPath = join(legacyHome, 'auth.json') + const destinationAuthPath = join(targetHome, 'auth.json') + writeFileSync(sourceRecoveryPath, SOURCE_AUTH, { mode: 0o400 }) + writeFileSync(destinationAuthPath, TARGET_AUTH, { mode: 0o400 }) + if (options.destinationRecovery !== false) { + linkSync(destinationAuthPath, destinationRecoveryPath) + } + if (options.pathMetadata !== false) { + writeFileSync(destinationRecoveryTargetPath, `${destinationAuthPath}\0`, { mode: 0o600 }) + } + if (options.markerPresent) { + writeFileSync(markerPath, '{"completed":true}\n') + } + let status = 0 + try { + execFileSync( + '/bin/sh', + ['-c', options.script, 'sh', legacyHome, join(root, 'absent-active-home'), markerPath], + { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 20_000, windowsHide: true } + ) + } catch (error) { + status = (error as { status?: number }).status ?? -1 + } + return { + destinationMode: statSync(destinationAuthPath).mode & 0o777, + destinationRecoveryExists: existsSync(destinationRecoveryPath), + destinationRecoveryPathExists: existsSync(destinationRecoveryTargetPath), + markerExists: existsSync(markerPath), + sourceAuth: existsSync(sourceAuthPath) ? readFileSync(sourceAuthPath, 'utf8') : null, + sourceRecoveryExists: existsSync(sourceRecoveryPath), + status + } +} + +export function runAbsentLegacyHomeScript(options: { + activeHomeOnly?: boolean + createLegacyHome: boolean + markerParentMissing?: boolean + script: string +}): { + markerExists: boolean + status: number +} { + const root = mkdtempSync(join(tmpdir(), 'orca-drain-absent-home-')) + const legacyHome = join(root, 'legacy') + const activeHome = join(root, 'absent-active-home') + const markerPath = options.markerParentMissing + ? join(root, 'marker-parent', 'drain-marker.json') + : join(root, 'drain-marker.json') + if (options.createLegacyHome) { + const sessionHome = options.activeHomeOnly ? activeHome : legacyHome + mkdirSync(join(sessionHome, ...RETIRED_SESSION_SEGMENTS.slice(0, -1)), { recursive: true }) + writeFileSync(join(sessionHome, ...RETIRED_SESSION_SEGMENTS), RETIRED_SESSION) + } + let status = 0 + try { + execFileSync('/bin/sh', ['-c', options.script, 'sh', legacyHome, activeHome, markerPath], { + encoding: 'utf8', + windowsHide: true, + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 20_000 + }) + } catch (error) { + status = (error as { status?: number }).status ?? -1 + } + return { markerExists: existsSync(markerPath), status } +} 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 new file mode 100644 index 00000000000..615c95cd599 --- /dev/null +++ b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-rollback-script.test.ts @@ -0,0 +1,171 @@ +import { execFileSync } from 'node:child_process' +import { createHash } from 'node:crypto' +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { _internals } from './legacy-wsl-runtime-auth-drain' + +const SOURCE_AUTH = '{"tokens":{"expires_at":2000}}\n' +const TARGET_AUTH = '{"tokens":{"expires_at":1000}}\n' +const SOURCE_CREDENTIALS = '{"server":{"access_token":"source"}}\n' +const RETIRED_SESSION = '{"session":"retired"}\n' +const LATER_SESSION = '{"session":"later"}\n' +const now = new Date() +const SESSION_SEGMENTS = [ + 'sessions', + String(now.getFullYear()), + String(now.getMonth() + 1).padStart(2, '0'), + String(now.getDate()).padStart(2, '0'), + 'retired.jsonl' +] + +function sha256(contents: string): string { + return createHash('sha256').update(contents).digest('hex') +} + +function runInspect(root: string, legacyHome: string, markerPath: string): number { + try { + execFileSync( + '/bin/sh', + [ + '-c', + _internals.inspectLegacyAuthScript, + 'sh', + legacyHome, + join(root, 'absent-active-home'), + markerPath + ], + { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 20_000 } + ) + return 0 + } catch (error) { + return (error as { status?: number }).status ?? -1 + } +} + +describe.skipIf(process.platform === 'win32')('legacy WSL auth drain rollback recovery', () => { + it('reopens a completed drain when rollback recreated the retired home', () => { + const root = mkdtempSync(join(tmpdir(), 'orca-drain-reland-')) + const legacyHome = join(root, 'legacy') + const targetHome = join(root, 'account') + const markerPath = join(root, 'drain-marker.json') + const legacyAuthPath = join(legacyHome, 'auth.json') + const targetAuthPath = join(targetHome, 'auth.json') + const targetCredentialsPath = join(targetHome, '.credentials.json') + const sourceSessionPath = join(legacyHome, ...SESSION_SEGMENTS) + const targetSessionPath = join(targetHome, ...SESSION_SEGMENTS) + const laterSourceSessionPath = join(sourceSessionPath, '..', 'later.jsonl') + const laterTargetSessionPath = join(targetSessionPath, '..', 'later.jsonl') + const oldSourceSessionPath = join(legacyHome, 'sessions', '1999', '01', '01', 'old.jsonl') + const oldTargetSessionPath = join(targetHome, 'sessions', '1999', '01', '01', 'old.jsonl') + const rollbackSourceSessionPath = join(sourceSessionPath, '..', 'clock-rollback.jsonl') + const rollbackTargetSessionPath = join(targetSessionPath, '..', 'clock-rollback.jsonl') + const restartSourceSessionPath = join(sourceSessionPath, '..', 'restart-full.jsonl') + const restartTargetSessionPath = join(targetSessionPath, '..', 'restart-full.jsonl') + const blockedSourceSessionPath = join(sourceSessionPath, '..', 'blocked-watermark.jsonl') + const blockedTargetSessionPath = join(targetSessionPath, '..', 'blocked-watermark.jsonl') + mkdirSync(join(sourceSessionPath, '..'), { recursive: true }) + mkdirSync(targetHome) + writeFileSync(markerPath, '{"completed":true}\n') + writeFileSync(legacyAuthPath, SOURCE_AUTH) + writeFileSync(join(legacyHome, '.credentials.json'), SOURCE_CREDENTIALS) + writeFileSync(sourceSessionPath, RETIRED_SESSION) + writeFileSync(targetAuthPath, TARGET_AUTH) + + expect(runInspect(root, legacyHome, markerPath)).toBe(0) + 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', 'recent') + expect(existsSync(sourceSessionPath)).toBe(true) + expect(readFileSync(targetSessionPath, 'utf8')).toBe(RETIRED_SESSION) + + mkdirSync(join(oldSourceSessionPath, '..'), { recursive: true }) + writeFileSync(laterSourceSessionPath, LATER_SESSION) + writeFileSync(oldSourceSessionPath, RETIRED_SESSION) + apply(sha256(SOURCE_AUTH), '0', '0', 'recent') + expect(readFileSync(laterTargetSessionPath, 'utf8')).toBe(LATER_SESSION) + expect(existsSync(oldTargetSessionPath)).toBe(false) + + const watermarkPath = `${markerPath}.orca-drain-session-watermark` + const linkedWatermarkPath = join(root, 'linked-watermark') + writeFileSync(linkedWatermarkPath, '1999/01/01\n') + rmSync(watermarkPath) + symlinkSync(linkedWatermarkPath, watermarkPath) + apply(sha256(SOURCE_AUTH), '0', '0', 'recent') + expect(readFileSync(oldTargetSessionPath, 'utf8')).toBe(RETIRED_SESSION) + + rmSync(watermarkPath) + writeFileSync(watermarkPath, '2999/12/31\n') + writeFileSync(rollbackSourceSessionPath, LATER_SESSION) + apply(sha256(SOURCE_AUTH), '0', '0', 'recent') + expect(readFileSync(rollbackTargetSessionPath, 'utf8')).toBe(LATER_SESSION) + + writeFileSync(restartSourceSessionPath, LATER_SESSION) + apply(sha256(SOURCE_AUTH), '0', '0', 'full') + expect(readFileSync(restartTargetSessionPath, 'utf8')).toBe(LATER_SESSION) + + rmSync(watermarkPath) + mkdirSync(watermarkPath) + writeFileSync(blockedSourceSessionPath, LATER_SESSION) + expect(() => apply(sha256(SOURCE_AUTH), '0', '0', 'full')).toThrow() + expect(existsSync(blockedTargetSessionPath)).toBe(false) + expect(() => apply(sha256(SOURCE_AUTH), '0', '0', 'recent')).toThrow() + expect(existsSync(blockedTargetSessionPath)).toBe(false) + rmSync(watermarkPath, { recursive: true }) + apply(sha256(SOURCE_AUTH), '0', '0', 'recent') + expect(readFileSync(blockedTargetSessionPath, 'utf8')).toBe(LATER_SESSION) + + apply(sha256(SOURCE_AUTH), '0', '1', 'full') + + expect(existsSync(legacyAuthPath)).toBe(false) + expect(readFileSync(targetAuthPath, 'utf8')).toBe(SOURCE_AUTH) + expect(readFileSync(targetCredentialsPath, 'utf8')).toBe(SOURCE_CREDENTIALS) + expect(readFileSync(targetSessionPath, 'utf8')).toBe(RETIRED_SESSION) + expect(readFileSync(oldTargetSessionPath, 'utf8')).toBe(RETIRED_SESSION) + expect(existsSync(markerPath)).toBe(true) + }) + + it('keeps completion authoritative when rollback recreated auth as a symlink', () => { + const root = mkdtempSync(join(tmpdir(), 'orca-drain-reland-symlink-')) + const legacyHome = join(root, 'legacy') + const markerPath = join(root, 'drain-marker.json') + const linkedAuthPath = join(root, 'linked-auth.json') + mkdirSync(legacyHome) + writeFileSync(markerPath, '{"completed":true}\n') + writeFileSync(linkedAuthPath, SOURCE_AUTH) + symlinkSync(linkedAuthPath, join(legacyHome, 'auth.json')) + + expect(runInspect(root, legacyHome, markerPath)).toBe(46) + expect(existsSync(markerPath)).toBe(true) + expect(readFileSync(linkedAuthPath, 'utf8')).toBe(SOURCE_AUTH) + }) +}) diff --git a/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-script-fixtures.ts b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-script-fixtures.ts new file mode 100644 index 00000000000..64f5b464e66 --- /dev/null +++ b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-script-fixtures.ts @@ -0,0 +1,27 @@ +// Why: the auth/credential/session byte fixtures the real-script drain suites assert against. +import { createHash } from 'node:crypto' + +export const isWindows = process.platform === 'win32' + +export const SOURCE_AUTH = '{"tokens":{"expires_at":2000}}\n' +export const TARGET_AUTH = '{"tokens":{"expires_at":1000}}\n' +export const NEWER_AUTH = '{"tokens":{"expires_at":3000}}\n' +// A different, well-formed auth that another writer atomically renames into place. +export const INTRUDER_AUTH = '{"tokens":{"expires_at":9000}}\n' + +// Codex truncates before it writes, so a read landing mid-rotation sees this. +export const TORN_AUTH = '{"tokens":{"exp' +export const SOURCE_CREDENTIALS = '{"server":{"access_token":"source"}}\n' +export const TORN_CREDENTIALS = '{"server":' +export const RETIRED_SESSION = '{"session":"retired"}\n' +export const RETIRED_SESSION_SEGMENTS = ['sessions', '2026', '08', '26', 'retired.jsonl'] + +export function sha256(contents: string): string { + return createHash('sha256').update(contents).digest('hex') +} + +/** + * Runs the real guest script under `sh`, with `sha256sum` shimmed so a chosen + * hash call can rewrite the source underneath the script. That is the only way + * to land Codex's in-place rotation inside the window the script itself opens. + */ diff --git a/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-script-harness.ts b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-script-harness.ts new file mode 100644 index 00000000000..1f35d4fcda0 --- /dev/null +++ b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-script-harness.ts @@ -0,0 +1,275 @@ +// Why: runs the real guest apply script under `sh` with shimmed coreutils so tests can inject +// precise interference at chosen points. +import { execFileSync } from 'node:child_process' +import { + chmodSync, + mkdirSync, + mkdtempSync, + readFileSync, + renameSync, + symlinkSync, + writeFileSync, + existsSync, + statSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { installDrainInterferenceShims } from './legacy-wsl-runtime-auth-drain-script-interference-shims' +import { + INTRUDER_AUTH, + NEWER_AUTH, + RETIRED_SESSION_SEGMENTS, + SOURCE_AUTH, + TARGET_AUTH, + TORN_AUTH, + sha256 +} from './legacy-wsl-runtime-auth-drain-script-fixtures' +import type { + DrainApplyInterference, + DrainApplyOutcome +} from './legacy-wsl-runtime-auth-drain-script-run-types' +import { _internals } from './legacy-wsl-runtime-auth-drain' + +export function runApplyScript(options: DrainApplyInterference = {}): DrainApplyOutcome { + const root = mkdtempSync(join(tmpdir(), 'orca-drain-apply-')) + const legacyHome = join(root, 'legacy') + const targetHome = join(root, 'account') + const binDir = join(root, 'bin') + for (const dir of [legacyHome, targetHome, binDir]) { + mkdirSync(dir, { recursive: true }) + } + const legacyAuthPath = join(legacyHome, 'auth.json') + const targetAuthPath = join(targetHome, 'auth.json') + const legacyCredentialsPath = join(legacyHome, '.credentials.json') + const targetCredentialsPath = join(targetHome, '.credentials.json') + const markerPath = join(root, 'drain-marker.json') + writeFileSync(legacyAuthPath, SOURCE_AUTH) + writeFileSync(targetAuthPath, TARGET_AUTH) + if (options.sourceAuthSymlink) { + const sourceAuthTarget = join(root, 'linked-source-auth.json') + renameSync(legacyAuthPath, sourceAuthTarget) + symlinkSync(sourceAuthTarget, legacyAuthPath) + } + if (options.sourceCredentials !== undefined) { + writeFileSync(legacyCredentialsPath, options.sourceCredentials) + } + if (options.sourceSession !== undefined) { + const sessionPath = join(legacyHome, ...RETIRED_SESSION_SEGMENTS) + mkdirSync(join(sessionPath, '..'), { recursive: true }) + writeFileSync(sessionPath, options.sourceSession) + } + + const counterPath = join(root, 'hash-calls') + writeFileSync(counterPath, '0') + installDrainInterferenceShims(binDir, options) + if (options.killDuringSessionCommit) { + const rmShimPath = join(binDir, 'rm') + writeFileSync( + rmShimPath, + `#!/usr/bin/env node +const { spawnSync } = require('node:child_process') +const fs = require('node:fs') +const args = process.argv.slice(2) +const result = spawnSync('/bin/rm', args, { stdio: 'inherit' }) +const target = args.at(-1) ?? '' +if ( + result.status === 0 && + fs.existsSync(process.env.SESSION_COMMIT_MARKER) && + target.includes('/account/sessions/') && + target.includes('.orca-bridge-') +) { + const parent = spawnSync('/bin/ps', ['-o', 'ppid=', '-p', String(process.ppid)], { + encoding: 'utf8' + }) + process.kill(Number(parent.stdout.trim()), 'SIGKILL') +} +process.exit(result.status ?? 1) +` + ) + chmodSync(rmShimPath, 0o755) + } + if ( + options.crossFilesystemBridge || + options.killBeforeDestinationRecoveryLink || + options.killAfterSessionLink || + options.replaceTargetAfterSessionLink || + options.rewriteQuarantineAfterSessionLink || + options.rewriteSourceAfterSessionLink || + options.rewriteTargetAfterSessionLink + ) { + const lnShimPath = join(binDir, 'ln') + writeFileSync( + lnShimPath, + `#!/usr/bin/env node +const { spawnSync } = require('node:child_process') +const fs = require('node:fs') +const args = process.argv.slice(2) +if ( + process.env.KILL_DESTINATION_RECOVERY === '1' && + args.at(-1)?.endsWith('.orca-drain-destination') +) { + process.kill(process.ppid, 'SIGKILL') + process.exit(1) +} +if ( + process.env.CROSS_FILESYSTEM_BRIDGE === '1' && + args.at(-2)?.includes('.orca-drain-session-stage') && + args.at(-1)?.includes('.orca-bridge-') +) { + process.exit(1) +} +const result = spawnSync('/bin/ln', args, { stdio: 'inherit' }) +if ( + result.status === 0 && + args.at(-1)?.includes('/account/sessions/') && + args.at(-1)?.endsWith('/retired.jsonl') +) { + if (process.env.KILL_SESSION_LINK === '1') { + const parent = spawnSync('/bin/ps', ['-o', 'ppid=', '-p', String(process.ppid)], { + encoding: 'utf8' + }) + process.kill(Number(parent.stdout.trim()), 'SIGKILL') + } else if (process.env.REWRITE_AFTER_SESSION_LINK === '1') { + if (process.env.REPLACE_TARGET === '1') { + const replacement = process.env.REWRITE_SESSION_AUTH + '.replacement' + fs.writeFileSync(replacement, process.env.REWRITE_BYTES) + fs.renameSync(replacement, process.env.REWRITE_SESSION_AUTH) + } else { + if (process.env.REWRITE_QUARANTINE === '1') { + fs.chmodSync(process.env.REWRITE_SESSION_AUTH, 0o600) + } + fs.writeFileSync(process.env.REWRITE_SESSION_AUTH, process.env.REWRITE_BYTES) + } + } +} +process.exit(result.status ?? 1) +` + ) + chmodSync(lnShimPath, 0o755) + } + + let status = 0 + try { + execFileSync( + '/bin/sh', + [ + '-c', + _internals.applyLegacyAuthScript, + 'sh', + legacyHome, + join(root, 'absent-active-home'), + markerPath, + targetHome, + sha256(SOURCE_AUTH), + sha256(TARGET_AUTH), + options.promoteAuth === false ? '0' : '1', + options.deleteSource ? '1' : '0', + options.sourceCredentials === undefined ? 'missing' : sha256(options.sourceCredentials), + 'full' + ], + { + encoding: 'utf8', + windowsHide: true, + env: { + ...process.env, + HASH_COUNTER: counterPath, + REPLACE_ON_HASH_OF: options.replaceTargetOnHashOf ?? '', + REPLACE_PATH: targetAuthPath, + REPLACE_BYTES: INTRUDER_AUTH, + CROSS_FILESYSTEM_BRIDGE: options.crossFilesystemBridge ? '1' : '0', + KILL_DESTINATION: options.killAfterDestinationInstall ? '1' : '0', + KILL_DESTINATION_RECOVERY: options.killBeforeDestinationRecoveryLink ? '1' : '0', + KILL_SESSION_LINK: options.killAfterSessionLink ? '1' : '0', + KILL_SOURCE: options.killAfterSourceRemoval ? '1' : '0', + PATH: `${binDir}:${process.env.PATH ?? ''}`, + SESSION_COMMIT_MARKER: `${markerPath}.orca-drain-session-commit`, + REWRITE_AFTER: options.rewriteAfterHashCall ? String(options.rewriteAfterHashCall) : '', + REWRITE_AFTER_SESSION_LINK: + options.replaceTargetAfterSessionLink || + options.rewriteQuarantineAfterSessionLink || + options.rewriteSourceAfterSessionLink || + options.rewriteTargetAfterSessionLink + ? '1' + : '0', + REWRITE_BYTES: options.rewriteBytes ?? TORN_AUTH, + REWRITE_QUARANTINE: options.rewriteQuarantineAfterSessionLink ? '1' : '0', + REPLACE_TARGET: options.replaceTargetAfterSessionLink ? '1' : '0', + REWRITE_SESSION_AUTH: + options.rewriteTargetAfterSessionLink || options.replaceTargetAfterSessionLink + ? targetAuthPath + : options.rewriteQuarantineAfterSessionLink + ? `${markerPath}.orca-drain-live-source` + : legacyAuthPath, + REWRITE_TARGET: + options.rewriteTarget === 'source-credentials' + ? legacyCredentialsPath + : options.rewriteTarget === 'target-auth' + ? targetAuthPath + : legacyAuthPath + }, + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 20_000 + } + ) + } catch (error) { + status = (error as { status?: number }).status ?? -1 + } + if ( + options.killAfterDestinationInstall || + options.killBeforeDestinationRecoveryLink || + options.killAfterSessionLink || + options.killAfterSourceRemoval || + options.killDuringSessionCommit + ) { + if (options.rewriteQuarantineBeforeRecovery) { + const quarantinePath = `${markerPath}.orca-drain-live-source` + chmodSync(quarantinePath, 0o600) + writeFileSync(quarantinePath, NEWER_AUTH) + } + if (options.replaceTargetBeforeRecovery) { + const replacementPath = `${targetAuthPath}.replacement` + writeFileSync(replacementPath, NEWER_AUTH) + renameSync(replacementPath, targetAuthPath) + } + try { + execFileSync( + '/bin/sh', + [ + '-c', + _internals.inspectLegacyAuthScript, + 'sh', + legacyHome, + join(root, 'absent-active-home'), + markerPath + ], + { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 20_000, windowsHide: true } + ) + } catch { + // Recovery succeeds before inspect continues and emits the pending auth payload. + } + } + return { + legacyAuth: existsSync(legacyAuthPath) ? readFileSync(legacyAuthPath, 'utf8') : null, + markerExists: existsSync(markerPath), + status, + targetAuth: readFileSync(targetAuthPath, 'utf8'), + targetCredentials: existsSync(targetCredentialsPath) + ? readFileSync(targetCredentialsPath, 'utf8') + : null, + targetMode: statSync(targetAuthPath).mode & 0o777, + targetSession: existsSync(join(targetHome, ...RETIRED_SESSION_SEGMENTS)) + ? readFileSync(join(targetHome, ...RETIRED_SESSION_SEGMENTS), 'utf8') + : null, + sourceQuarantineAuth: existsSync(`${markerPath}.orca-drain-live-source`) + ? readFileSync(`${markerPath}.orca-drain-live-source`, 'utf8') + : null, + sourceRecoveryAuth: existsSync(`${markerPath}.orca-drain-source`) + ? readFileSync(`${markerPath}.orca-drain-source`, 'utf8') + : null, + destinationRecoveryAuth: existsSync(`${markerPath}.orca-drain-destination`) + ? readFileSync(`${markerPath}.orca-drain-destination`, 'utf8') + : null, + destinationRecoveryPathExists: existsSync(`${markerPath}.orca-drain-destination-path`), + sessionCommitMarkerExists: existsSync(`${markerPath}.orca-drain-session-commit`) + } +} diff --git a/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-script-interference-shims.ts b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-script-interference-shims.ts new file mode 100644 index 00000000000..735e95bec86 --- /dev/null +++ b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-script-interference-shims.ts @@ -0,0 +1,62 @@ +// Why: installs node-backed sha256sum/mv/rm shims on PATH so the real guest script can be +// interfered with at exact points - a hash read, an install rename, a source removal. +import { chmodSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' + +export function installDrainInterferenceShims( + binDir: string, + options: { killAfterDestinationInstall?: boolean; killAfterSourceRemoval?: boolean } +): void { + const shimPath = join(binDir, 'sha256sum') + writeFileSync( + shimPath, + `#!/usr/bin/env node + const { createHash } = require('node:crypto') + const fs = require('node:fs') + const file = process.argv[process.argv.length - 1] + process.stdout.write( + createHash('sha256').update(fs.readFileSync(file)).digest('hex') + ' ' + file + '\\n' + ) + const calls = Number(fs.readFileSync(process.env.HASH_COUNTER, 'utf8')) + 1 + fs.writeFileSync(process.env.HASH_COUNTER, String(calls)) + if (process.env.REWRITE_AFTER && calls === Number(process.env.REWRITE_AFTER)) { + fs.writeFileSync(process.env.REWRITE_TARGET, process.env.REWRITE_BYTES) + } + // Why: an atomic rename leaves the pinned hard link on the OLD inode, so its hash still + // matches while the path now resolves elsewhere. Only an inode identity check sees that. + if (process.env.REPLACE_ON_HASH_OF && file.includes(process.env.REPLACE_ON_HASH_OF)) { + const staged = process.env.REPLACE_PATH + '.intruder' + fs.writeFileSync(staged, process.env.REPLACE_BYTES) + fs.renameSync(staged, process.env.REPLACE_PATH) + } + ` + ) + chmodSync(shimPath, 0o755) + if (options.killAfterDestinationInstall || options.killAfterSourceRemoval) { + const mvShimPath = join(binDir, 'mv') + writeFileSync( + mvShimPath, + `#!/usr/bin/env node + const { spawnSync } = require('node:child_process') + const fs = require('node:fs') + const args = process.argv.slice(2) + const result = spawnSync('/bin/mv', args, { stdio: 'inherit' }) + const from = args.at(-2) ?? '' + const to = args.at(-1) ?? '' + const sourceInstalled = + process.env.KILL_SOURCE === '1' && + from.endsWith('/legacy/auth.json') && + to.endsWith('.orca-drain-live-source') + const destinationInstalled = + process.env.KILL_DESTINATION === '1' && + from.includes('/account/auth.json.orca-drain-snapshot-') && + to.endsWith('/account/auth.json') + if (result.status === 0 && (sourceInstalled || destinationInstalled)) { + process.kill(process.ppid, 'SIGKILL') + } + process.exit(result.status ?? 1) + ` + ) + chmodSync(mvShimPath, 0o755) + } +} diff --git a/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-script-run-types.ts b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-script-run-types.ts new file mode 100644 index 00000000000..f433b9c83cc --- /dev/null +++ b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-script-run-types.ts @@ -0,0 +1,40 @@ +// Why: the interference knobs and the observable result surface of a real apply-script run, +// kept beside the harness so neither module carries both the contract and the machinery. +export type DrainApplyInterference = { + crossFilesystemBridge?: boolean + deleteSource?: boolean + killAfterDestinationInstall?: boolean + killBeforeDestinationRecoveryLink?: boolean + killAfterSessionLink?: boolean + killAfterSourceRemoval?: boolean + killDuringSessionCommit?: boolean + promoteAuth?: boolean + replaceTargetAfterSessionLink?: boolean + replaceTargetOnHashOf?: string + replaceTargetBeforeRecovery?: boolean + rewriteAfterHashCall?: number + rewriteBytes?: string + rewriteQuarantineAfterSessionLink?: boolean + rewriteQuarantineBeforeRecovery?: boolean + rewriteSourceAfterSessionLink?: boolean + rewriteTargetAfterSessionLink?: boolean + rewriteTarget?: 'source-auth' | 'source-credentials' | 'target-auth' + sourceAuthSymlink?: boolean + sourceSession?: string + sourceCredentials?: string +} + +export type DrainApplyOutcome = { + legacyAuth: string | null + markerExists: boolean + status: number + targetAuth: string + targetCredentials: string | null + targetMode: number + targetSession: string | null + sourceQuarantineAuth: string | null + sourceRecoveryAuth: string | null + destinationRecoveryAuth: string | null + destinationRecoveryPathExists: boolean + sessionCommitMarkerExists: boolean +} 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 new file mode 100644 index 00000000000..6611489941b --- /dev/null +++ b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-scripts.ts @@ -0,0 +1,300 @@ +import { + LEGACY_HOME_ABSENT_EXIT, + MARKER_PRESENT_EXIT, + SOURCE_AUTH_ABSENT_EXIT +} from './legacy-wsl-runtime-auth-drain-exit-codes' +import { + DISCARD_DESTINATION_RECOVERY_COMMAND, + RECOVER_DESTINATION_AUTH_COMMAND, + RESOLVE_LEGACY_HOME_SCRIPT, + RETIRED_SESSION_BRIDGE_COMMAND, + RETIRED_RECENT_SESSION_BRIDGE_COMMAND, + ROLLBACK_SESSION_LINKS_FUNCTION +} from './legacy-wsl-runtime-auth-drain-shell-commands' +export * from './legacy-wsl-runtime-auth-drain-exit-codes' +export { FINALIZE_ABSENT_AUTH_SCRIPT } from './legacy-wsl-runtime-auth-finalize-script' +export const INSPECT_LEGACY_AUTH_SCRIPT = ` +set -eu +source_recovery_auth="$3.orca-drain-source" +source_quarantine_auth="$3.orca-drain-live-source" +destination_recovery_auth="$3.orca-drain-destination" +destination_recovery_path="$3.orca-drain-destination-path" +session_link_manifest="$3.orca-drain-session-links" +session_commit_marker="$3.orca-drain-session-commit" +session_stage_root="$3.orca-drain-session-stage" +${ROLLBACK_SESSION_LINKS_FUNCTION} +${RESOLVE_LEGACY_HOME_SCRIPT} +source_auth="$legacy_home/auth.json" +if [ -e "$3" ] || [ -L "$3" ]; then + [ -f "$3" ] && [ ! -L "$3" ] || exit 46 + commit_session_links || exit 46 + if [ -f "$destination_recovery_auth" ] && [ ! -L "$destination_recovery_auth" ]; then + chmod 600 "$destination_recovery_auth" + fi + rm -f -- "$source_recovery_auth" "$source_quarantine_auth" "$destination_recovery_auth" "$destination_recovery_path" + if [ ! -e "$source_auth" ] && [ ! -L "$source_auth" ]; then + exit ${MARKER_PRESENT_EXIT} + fi + [ -f "$source_auth" ] && [ ! -L "$source_auth" ] || exit 46 + rm -- "$3" +fi +rollback_session_links +if [ "$legacy_home_resolved" = 0 ]; then + exit ${LEGACY_HOME_ABSENT_EXIT} +fi +if [ ! -e "$source_auth" ] && [ ! -L "$source_auth" ]; then + if [ -f "$source_recovery_auth" ] && [ ! -L "$source_recovery_auth" ]; then + mv -- "$source_recovery_auth" "$source_auth" + chmod 600 "$source_auth" + elif [ -f "$source_quarantine_auth" ] && [ ! -L "$source_quarantine_auth" ]; then + mv -- "$source_quarantine_auth" "$source_auth" + chmod 600 "$source_auth" + elif [ -e "$source_recovery_auth" ] || [ -L "$source_recovery_auth" ]; then + exit 46 + fi +fi +if [ -e "$destination_recovery_auth" ] || [ -L "$destination_recovery_auth" ]; then + [ -f "$destination_recovery_auth" ] && [ ! -L "$destination_recovery_auth" ] || exit 46 + [ -f "$destination_recovery_path" ] && [ ! -L "$destination_recovery_path" ] || exit 46 + ${RECOVER_DESTINATION_AUTH_COMMAND} || exit 46 +elif [ -e "$destination_recovery_path" ] || [ -L "$destination_recovery_path" ]; then + [ -f "$destination_recovery_path" ] && [ ! -L "$destination_recovery_path" ] || exit 46 + rm -- "$destination_recovery_path" +fi +if [ ! -e "$source_auth" ] && [ ! -L "$source_auth" ]; then + exit ${SOURCE_AUTH_ABSENT_EXIT} +fi +[ -f "$source_auth" ] && [ ! -L "$source_auth" ] || exit 46 +encode_file() { + encoded=$(base64 < "$1") || return 1 + printf '%s' "$encoded" | tr -d '\n' +} +encode_file "$source_auth" +printf '\n' +source_credentials="$legacy_home/.credentials.json" +if [ -f "$source_credentials" ] && [ ! -L "$source_credentials" ]; then + printf 'present\n' + encode_file "$source_credentials" + printf '\n' +elif [ ! -e "$source_credentials" ] && [ ! -L "$source_credentials" ]; then + printf 'missing\n\n' +else + exit 44 +fi +` + +export const APPLY_LEGACY_AUTH_SCRIPT = ` +set -eu +source_recovery_auth="$3.orca-drain-source" +source_quarantine_auth="$3.orca-drain-live-source" +destination_recovery_auth="$3.orca-drain-destination" +destination_recovery_path="$3.orca-drain-destination-path" +session_link_manifest="$3.orca-drain-session-links" +session_commit_marker="$3.orca-drain-session-commit" +session_stage_root="$3.orca-drain-session-stage" +session_scan_watermark="$3.orca-drain-session-watermark" +${ROLLBACK_SESSION_LINKS_FUNCTION} +if [ -e "$3" ] || [ -L "$3" ]; then + [ -f "$3" ] && [ ! -L "$3" ] || exit 46 + commit_session_links || exit 46 + if [ -f "$destination_recovery_auth" ] && [ ! -L "$destination_recovery_auth" ]; then + chmod 600 "$destination_recovery_auth" + fi + rm -f -- "$source_recovery_auth" "$source_quarantine_auth" "$destination_recovery_auth" "$destination_recovery_path" + exit 0 +fi +${RESOLVE_LEGACY_HOME_SCRIPT} +target_home=$(readlink -f -- "$4") || exit 33 +[ "$legacy_home" != "$target_home" ] || exit 34 +source_auth="$legacy_home/auth.json" +target_auth="$target_home/auth.json" +if [ ! -e "$source_auth" ] && [ ! -L "$source_auth" ]; then exit 35; fi +[ -f "$source_auth" ] && [ ! -L "$source_auth" ] || exit 46 +if [ ! -e "$target_auth" ] && [ ! -L "$target_auth" ]; then exit 36; fi +[ -f "$target_auth" ] && [ ! -L "$target_auth" ] || exit 46 +hash_file() { sha256sum -- "$1" | cut -d ' ' -f 1; } +[ "$(hash_file "$source_auth")" = "$5" ] || exit 37 +[ "$(hash_file "$target_auth")" = "$6" ] || exit 38 +if [ -e "$source_quarantine_auth" ] || [ -L "$source_quarantine_auth" ]; then + [ -f "$source_quarantine_auth" ] && [ ! -L "$source_quarantine_auth" ] || exit 46 + rm -- "$source_quarantine_auth" +fi +if [ -e "$destination_recovery_auth" ] || [ -L "$destination_recovery_auth" ] || [ -e "$destination_recovery_path" ] || [ -L "$destination_recovery_path" ]; then + [ -f "$destination_recovery_auth" ] && [ ! -L "$destination_recovery_auth" ] || exit 46 + [ -f "$destination_recovery_path" ] && [ ! -L "$destination_recovery_path" ] || exit 46 + ${DISCARD_DESTINATION_RECOVERY_COMMAND} || exit 46 +fi +umask 077 +temporary_auth="$target_auth.orca-drain-$$" +temporary_credentials="$target_home/.credentials.json.orca-drain-$$" +temporary_previous_auth="$target_auth.orca-drain-previous-$$" +temporary_destination_auth="$target_auth.orca-drain-destination-$$" +temporary_source_auth="$source_auth.orca-drain-source-$$" +temporary_destination_snapshot="$target_auth.orca-drain-snapshot-$$" +temporary_destination_path="$3.orca-drain-destination-path-$$" +temporary_source_snapshot="$3.orca-drain-source-$$" +temporary_marker="$3.orca-drain-$$" +temporary_session_scan_watermark="$session_scan_watermark.$$" +drain_marker="$3" +expected_source_hash="$5" +cleanup() { + if [ ! -f "$drain_marker" ]; then + rollback_session_links + else + commit_session_links || : + fi + if [ ! -f "$drain_marker" ] && [ ! -e "$source_auth" ] && [ ! -L "$source_auth" ]; then + if [ -f "$source_recovery_auth" ] && [ ! -L "$source_recovery_auth" ] && [ "$(hash_file "$source_recovery_auth")" = "$expected_source_hash" ]; then + mv -- "$source_recovery_auth" "$source_auth" || : + chmod 600 "$source_auth" || : + elif [ -f "$source_quarantine_auth" ] && [ ! -L "$source_quarantine_auth" ] && [ "$(hash_file "$source_quarantine_auth")" = "$expected_source_hash" ]; then + mv -- "$source_quarantine_auth" "$source_auth" || : + chmod 600 "$source_auth" || : + fi + elif [ ! -f "$drain_marker" ]; then + # A late writer owns the recreated path; retain verified recovery artifacts for retry. + : + elif [ -f "$drain_marker" ]; then + rm -f -- "$source_recovery_auth" "$source_quarantine_auth" "$destination_recovery_path" + fi + if [ -f "$drain_marker" ]; then + if [ -f "$destination_recovery_auth" ] && [ ! -L "$destination_recovery_auth" ]; then + chmod 600 "$destination_recovery_auth" || : + fi + rm -f -- "$destination_recovery_auth" "$destination_recovery_path" + elif [ -e "$destination_recovery_auth" ] || [ -L "$destination_recovery_auth" ]; then + if [ -f "$destination_recovery_auth" ] && [ ! -L "$destination_recovery_auth" ] && [ -f "$destination_recovery_path" ] && [ ! -L "$destination_recovery_path" ]; then + ${RECOVER_DESTINATION_AUTH_COMMAND} || : + fi + elif [ -f "$destination_recovery_path" ] && [ ! -L "$destination_recovery_path" ]; then + rm -f -- "$destination_recovery_path" + fi + if [ -f "$target_auth" ]; then + chmod 600 "$target_auth" || : + fi + rm -f -- "$temporary_auth" "$temporary_credentials" "$temporary_previous_auth" "$temporary_destination_auth" "$temporary_source_auth" "$temporary_destination_snapshot" "$temporary_destination_path" "$temporary_source_snapshot" "$temporary_marker" "$temporary_session_scan_watermark" +} +trap cleanup EXIT HUP INT TERM +if [ "$8" != 1 ]; then + session_scan_start=''; session_scan_day=$(date +%Y/%m/%d) || exit 46 + if [ -f "$session_scan_watermark" ] && [ ! -L "$session_scan_watermark" ]; then + [ "\${10}" != recent ] || IFS= read -r session_scan_start < "$session_scan_watermark" || session_scan_start='' + elif [ -e "$session_scan_watermark" ] && [ ! -L "$session_scan_watermark" ]; then exit 46 + fi +fi +source_credentials="$legacy_home/.credentials.json" +target_credentials="$target_home/.credentials.json" +if [ -f "$source_credentials" ] && [ ! -e "$target_credentials" ] && [ ! -L "$target_credentials" ]; then + [ "$9" != missing ] || exit 43 + [ "$(hash_file "$source_credentials")" = "$9" ] || exit 43 + cp -- "$source_credentials" "$temporary_credentials" + chmod 600 "$temporary_credentials" + [ "$(hash_file "$temporary_credentials")" = "$9" ] || exit 43 + [ "$(hash_file "$source_credentials")" = "$9" ] || exit 43 + mv -n -- "$temporary_credentials" "$target_credentials" +elif [ "$9" = missing ] && [ ! -e "$target_credentials" ] && [ ! -L "$target_credentials" ]; then + [ ! -e "$source_credentials" ] && [ ! -L "$source_credentials" ] || exit 43 +fi +if [ "$7" = 1 ]; then + cp -- "$source_auth" "$temporary_auth" + chmod 600 "$temporary_auth" + # Codex rewrites auth.json in place, so this copy is a second read: verify the + # bytes being promoted, not the ones freshness was judged on. + [ "$(hash_file "$temporary_auth")" = "$5" ] || exit 42 + [ "$(hash_file "$target_auth")" = "$6" ] || exit 39 + # The hard link keeps the destination inode observable without creating a + # missing-path crash window. In-place writers update both names. + ln -- "$target_auth" "$temporary_previous_auth" + [ "$(hash_file "$temporary_previous_auth")" = "$6" ] || exit 39 + mv -f -- "$temporary_auth" "$target_auth" + if [ "$(hash_file "$temporary_previous_auth")" != "$6" ]; then + mv -f -- "$temporary_previous_auth" "$target_auth" + exit 39 + fi + rm -- "$temporary_previous_auth" +fi +if [ "$8" != 1 ]; then + expected_target_hash="$6" + [ "$7" != 1 ] || expected_target_hash="$5" + # Keep both live auth inodes observable while links are staged, then prove + # the paths still name those identities before publishing the bridge. + ln -- "$source_auth" "$temporary_source_auth" + ln -- "$target_auth" "$temporary_destination_auth" + [ "$(hash_file "$temporary_source_auth")" = "$5" ] || exit 40 + [ "$(hash_file "$temporary_destination_auth")" = "$expected_target_hash" ] || exit 45 + [ "$source_auth" -ef "$temporary_source_auth" ] || exit 40 + [ "$target_auth" -ef "$temporary_destination_auth" ] || exit 45 + if [ "\${10}" = full ]; then + ${RETIRED_SESSION_BRIDGE_COMMAND} + elif [ "\${10}" = recent ]; then + case "$session_scan_start" in + ????/??/??) ${RETIRED_RECENT_SESSION_BRIDGE_COMMAND} ;; + *) ${RETIRED_SESSION_BRIDGE_COMMAND} ;; + esac + else + exit 46 + fi + [ "$(hash_file "$temporary_source_auth")" = "$5" ] || exit 40 + [ "$(hash_file "$temporary_destination_auth")" = "$expected_target_hash" ] || exit 45 + [ "$source_auth" -ef "$temporary_source_auth" ] || exit 40 + [ "$target_auth" -ef "$temporary_destination_auth" ] || exit 45 + rm -- "$temporary_source_auth" "$temporary_destination_auth" + commit_session_links + printf '%s\n' "$session_scan_day" > "$temporary_session_scan_watermark" + chmod 600 "$temporary_session_scan_watermark" + mv -f -- "$temporary_session_scan_watermark" "$session_scan_watermark" +fi +if [ "$8" = 1 ]; then + expected_target_hash="$6" + [ "$7" != 1 ] || expected_target_hash="$5" + # Pin the live inode and stage independent snapshots before retiring the source. + ln -- "$target_auth" "$temporary_destination_auth" + [ "$(hash_file "$temporary_destination_auth")" = "$expected_target_hash" ] || exit 45 + [ "$target_auth" -ef "$temporary_destination_auth" ] || exit 45 + cp -- "$target_auth" "$temporary_destination_snapshot" + chmod 400 "$temporary_destination_snapshot" + [ "$(hash_file "$temporary_destination_snapshot")" = "$expected_target_hash" ] || exit 45 + cp -- "$source_auth" "$temporary_source_snapshot" + chmod 400 "$temporary_source_snapshot" + [ "$(hash_file "$temporary_source_snapshot")" = "$5" ] || exit 40 + [ "$(hash_file "$source_auth")" = "$5" ] || exit 40 + mv -f -- "$temporary_source_snapshot" "$source_recovery_auth" + [ "$(hash_file "$source_recovery_auth")" = "$5" ] || exit 40 + printf '%s\\0' "$target_auth" > "$temporary_destination_path" + chmod 600 "$temporary_destination_path" + mv -f -- "$temporary_destination_path" "$destination_recovery_path" + ln -- "$temporary_destination_snapshot" "$destination_recovery_auth" + [ "$temporary_destination_snapshot" -ef "$destination_recovery_auth" ] || exit 45 + [ "$(hash_file "$destination_recovery_auth")" = "$expected_target_hash" ] || exit 45 + [ "$(hash_file "$temporary_destination_auth")" = "$expected_target_hash" ] || exit 45 + [ "$target_auth" -ef "$temporary_destination_auth" ] || exit 45 + [ "$(hash_file "$temporary_destination_snapshot")" = "$expected_target_hash" ] || exit 45 + # The atomic replacement detaches the destination path from any writer that + # already opened the old inode; read-only mode blocks new writers until commit. + mv -f -- "$temporary_destination_snapshot" "$target_auth" + if [ "$(hash_file "$temporary_destination_auth")" != "$expected_target_hash" ]; then + mv -f -- "$temporary_destination_auth" "$target_auth" + exit 45 + fi + [ "$(hash_file "$target_auth")" = "$expected_target_hash" ] || exit 45 + [ "$target_auth" -ef "$destination_recovery_auth" ] || exit 45 + [ "$(hash_file "$destination_recovery_auth")" = "$expected_target_hash" ] || exit 45 + [ "$(hash_file "$source_auth")" = "$5" ] || exit 40 + mv -- "$source_auth" "$source_quarantine_auth" + chmod 400 "$source_quarantine_auth" + [ "$(hash_file "$source_quarantine_auth")" = "$5" ] || exit 40 + ${RETIRED_SESSION_BRIDGE_COMMAND} + [ ! -e "$source_auth" ] && [ ! -L "$source_auth" ] || exit 40 + [ "$(hash_file "$source_quarantine_auth")" = "$5" ] || exit 40 + [ "$(hash_file "$target_auth")" = "$expected_target_hash" ] || exit 45 + [ "$target_auth" -ef "$destination_recovery_auth" ] || exit 45 + [ "$(hash_file "$destination_recovery_auth")" = "$expected_target_hash" ] || exit 45 + commit_session_links + rm -- "$temporary_destination_auth" + printf '%s\n' '{"completed":true}' > "$temporary_marker" + chmod 600 "$temporary_marker" + mv -f -- "$temporary_marker" "$3" + chmod 600 "$destination_recovery_auth" + rm -- "$source_recovery_auth" "$source_quarantine_auth" "$destination_recovery_auth" "$destination_recovery_path" +fi +` diff --git a/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-shell-commands.ts b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-shell-commands.ts new file mode 100644 index 00000000000..3ed3100ee24 --- /dev/null +++ b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-shell-commands.ts @@ -0,0 +1,90 @@ +import { quotePosixShell } from '../../shared/wsl-login-shell-command' +import { WSL_CODEX_SESSION_BRIDGE_SCRIPT } from '../codex/wsl-codex-session-bridge-script' + +export const RETIRED_SESSION_BRIDGE_COMMAND = `bash -c ${quotePosixShell(WSL_CODEX_SESSION_BRIDGE_SCRIPT)} bash "$legacy_home/sessions" "$target_home/sessions" "$session_link_manifest" "$session_stage_root"` +export const RETIRED_RECENT_SESSION_BRIDGE_COMMAND = `${RETIRED_SESSION_BRIDGE_COMMAND} recent "$session_scan_start" "$session_scan_day"` +const ROLLBACK_SESSION_LINKS_COMMAND = `bash -c ${quotePosixShell(`while IFS= read -r -d '' staged_file && IFS= read -r -d '' target_file; do + if [ -e "$target_file" ] || [ -L "$target_file" ]; then + [ -f "$staged_file" ] && [ ! -L "$staged_file" ] && [ "$target_file" -ef "$staged_file" ] || exit 1 + rm -- "$target_file" || exit 1 + fi + if [ -e "$staged_file" ] || [ -L "$staged_file" ]; then + [ -f "$staged_file" ] && [ ! -L "$staged_file" ] || exit 1 + rm -- "$staged_file" || exit 1 + fi +done < "$1"`)} bash` + +const COMMIT_SESSION_LINKS_COMMAND = `bash -c ${quotePosixShell(`while IFS= read -r -d '' staged_file && IFS= read -r -d '' target_file; do + if [ -e "$staged_file" ] || [ -L "$staged_file" ]; then + [ -f "$staged_file" ] && [ ! -L "$staged_file" ] || exit 1 + rm -- "$staged_file" || exit 1 + fi +done < "$1"`)} bash` + +export const RECOVER_DESTINATION_AUTH_COMMAND = `bash -c ${quotePosixShell(`IFS= read -r -d '' target_auth < "$1" || exit 1 +case "$target_auth" in /*) ;; *) exit 1 ;; esac +if [ ! -e "$target_auth" ] && [ ! -L "$target_auth" ]; then + mv -- "$2" "$target_auth" || exit 1 + chmod 600 "$target_auth" || exit 1 + rm -- "$1" || exit 1 +elif [ -f "$target_auth" ] && [ ! -L "$target_auth" ] && [ "$target_auth" -ef "$2" ]; then + chmod 600 "$target_auth" || exit 1 + rm -- "$2" "$1" || exit 1 +else + chmod 600 "$2" || exit 1 +fi`)} bash "$destination_recovery_path" "$destination_recovery_auth"` + +export const DISCARD_DESTINATION_RECOVERY_COMMAND = `bash -c ${quotePosixShell(`IFS= read -r -d '' recorded_target < "$1" || exit 1 +[ "$recorded_target" = "$3" ] || exit 1 +chmod 600 "$2" "$3" || exit 1 +rm -- "$2" "$1" || exit 1`)} bash "$destination_recovery_path" "$destination_recovery_auth" "$target_auth"` + +export const ROLLBACK_SESSION_LINKS_FUNCTION = ` +rollback_session_links() { + if [ -e "$session_commit_marker" ] || [ -L "$session_commit_marker" ]; then + [ -f "$session_commit_marker" ] && [ ! -L "$session_commit_marker" ] || return 1 + commit_session_links || return 1 + return 0 + fi + if [ -f "$session_link_manifest" ] && [ ! -L "$session_link_manifest" ]; then + ${ROLLBACK_SESSION_LINKS_COMMAND} "$session_link_manifest" || return 1 + rm -- "$session_link_manifest" || return 1 + elif [ -e "$session_link_manifest" ] || [ -L "$session_link_manifest" ]; then + return 1 + fi + rm -rf -- "$session_stage_root" || return 1 +} + +commit_session_links() { + if [ ! -e "$session_commit_marker" ] && [ ! -L "$session_commit_marker" ]; then + : > "$session_commit_marker" || return 1 + fi + [ -f "$session_commit_marker" ] && [ ! -L "$session_commit_marker" ] || return 1 + if [ -f "$session_link_manifest" ] && [ ! -L "$session_link_manifest" ]; then + ${COMMIT_SESSION_LINKS_COMMAND} "$session_link_manifest" || return 1 + rm -- "$session_link_manifest" || return 1 + elif [ -e "$session_link_manifest" ] || [ -L "$session_link_manifest" ]; then + return 1 + fi + rm -rf -- "$session_stage_root" || return 1 + rm -- "$session_commit_marker" || return 1 +} +` + +export const RESOLVE_LEGACY_HOME_SCRIPT = ` +legacy_home="$1" +legacy_home_resolved=0 +if [ -e "$1" ] || [ -L "$1" ]; then + legacy_home=$(readlink -f -- "$1") || exit 30 + legacy_home_resolved=1 +fi +if [ -e "$2" ] || [ -L "$2" ]; then + active_home=$(readlink -f -- "$2") || exit 31 + if [ "$legacy_home_resolved" = 1 ]; then + [ "$active_home" = "$legacy_home" ] || exit 32 + else + legacy_home="$active_home" + legacy_home_resolved=1 + fi +fi +` 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 new file mode 100644 index 00000000000..79564148287 --- /dev/null +++ b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain.test.ts @@ -0,0 +1,307 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { runWslProcessMock } = vi.hoisted(() => ({ runWslProcessMock: vi.fn() })) + +vi.mock('../wsl/wsl-runner', () => ({ + runWslProcess: runWslProcessMock +})) + +import { + _internals, + drainLegacyWslRuntimeAuth, + startLegacyWslRuntimeAuthDrain +} from './legacy-wsl-runtime-auth-drain' +import { readWslCodexAuths } from './wsl-codex-auth-batch-reader' + +const SOURCE_AUTH = '{"tokens":{"expires_at":2000}}\n' +const STALE_AUTH = '{"tokens":{"expires_at":1000}}\n' +const NEWER_AUTH = '{"tokens":{"expires_at":3000}}\n' + +function inspection(auth: string, credentials?: string): string { + return [ + Buffer.from(auth).toString('base64'), + credentials === undefined ? 'missing' : 'present', + credentials === undefined ? '' : Buffer.from(credentials).toString('base64') + ].join('\n') +} + +function result(code: number, stdout = '') { + return { + code, + stdout, + stderr: '', + timedOut: false, + environmentResolved: true + } +} + +describe('legacy WSL runtime auth drain', () => { + beforeEach(() => { + runWslProcessMock.mockReset() + _internals.resetDrainQueue() + }) + + it('promotes fresher auth guest-side while a legacy pane remains', async () => { + runWslProcessMock + .mockResolvedValueOnce(result(0, inspection(SOURCE_AUTH))) + .mockResolvedValueOnce(result(0)) + const resolveDestination = vi.fn(() => ({ + authContents: STALE_AUTH, + linuxHomePath: '/home/alice/.local/share/orca/codex-accounts/account-1/home' + })) + await drainLegacyWslRuntimeAuth({ + distro: 'Ubuntu', + guestHomeLinuxPath: '/home/alice', + legacyPanePresent: true, + resolveDestination + }) + + expect(resolveDestination).toHaveBeenCalledWith(SOURCE_AUTH) + expect(runWslProcessMock).toHaveBeenCalledTimes(2) + expect(runWslProcessMock.mock.calls[1]?.[0].args.slice(3)).toEqual([ + '/home/alice/.local/share/orca/codex-accounts/account-1/home', + expect.any(String), + expect.any(String), + '1', + '0', + 'missing', + 'full' + ]) + expect(runWslProcessMock.mock.calls[1]?.[0].script).toContain('readlink -f') + expect(runWslProcessMock.mock.calls[1]?.[0].script).toContain('source_credentials=') + expect(runWslProcessMock.mock.calls[1]?.[0].script).toContain('chmod 600') + expect(runWslProcessMock.mock.calls[1]?.[0].timeoutMs).toBe(30_000) + }) + + it('bridges sessions without changing auth when freshness cannot be proven', async () => { + runWslProcessMock + .mockResolvedValueOnce(result(0, inspection('{"tokens":{}}\n'))) + .mockResolvedValueOnce(result(0)) + + await drainLegacyWslRuntimeAuth({ + distro: 'Ubuntu', + guestHomeLinuxPath: '/home/alice', + legacyPanePresent: false, + resolveDestination: () => ({ + authContents: '{"tokens":{}}\n', + linuxHomePath: '/home/alice/.codex' + }) + }) + + expect(runWslProcessMock).toHaveBeenCalledTimes(2) + expect(runWslProcessMock.mock.calls[1]?.[0].args.slice(-4)).toEqual([ + '0', + '0', + 'missing', + 'full' + ]) + }) + + it('refuses a source with no unique destination', async () => { + runWslProcessMock.mockResolvedValueOnce(result(0, inspection(SOURCE_AUTH))) + + await drainLegacyWslRuntimeAuth({ + distro: 'Ubuntu', + guestHomeLinuxPath: '/home/alice', + legacyPanePresent: false, + resolveDestination: () => null + }) + + expect(runWslProcessMock).toHaveBeenCalledTimes(1) + }) + + it('retires stale legacy auth only after the last recorded pane exits', async () => { + runWslProcessMock + .mockResolvedValueOnce(result(0, inspection(SOURCE_AUTH))) + .mockResolvedValueOnce(result(0)) + + await drainLegacyWslRuntimeAuth({ + distro: 'Ubuntu', + guestHomeLinuxPath: '/home/alice', + legacyPanePresent: false, + resolveDestination: () => ({ + authContents: NEWER_AUTH, + linuxHomePath: '/home/alice/.codex' + }) + }) + + expect(runWslProcessMock.mock.calls[1]?.[0].args.slice(-4)).toEqual([ + '0', + '1', + 'missing', + 'full' + ]) + expect(runWslProcessMock.mock.calls[1]?.[0].timeoutMs).toBe(30_000) + }) + + it('recovers a failed guest apply before resolving the drain as pending', async () => { + runWslProcessMock + .mockResolvedValueOnce(result(0, inspection(SOURCE_AUTH))) + .mockResolvedValueOnce(result(45)) + .mockResolvedValueOnce(result(0, inspection(SOURCE_AUTH))) + + await expect( + drainLegacyWslRuntimeAuth({ + distro: 'Ubuntu', + guestHomeLinuxPath: '/home/alice', + legacyPanePresent: false, + resolveDestination: () => ({ + authContents: NEWER_AUTH, + linuxHomePath: '/home/alice/.codex' + }) + }) + ).resolves.toBe('pending') + + expect(runWslProcessMock).toHaveBeenCalledTimes(3) + expect(runWslProcessMock.mock.calls[2]?.[0]).toEqual( + expect.objectContaining({ + script: _internals.inspectLegacyAuthScript, + timeoutMs: 5_000 + }) + ) + }) + + it('rejects an awaited launch drain when guest recovery cannot finish', async () => { + runWslProcessMock + .mockResolvedValueOnce(result(0, inspection(SOURCE_AUTH))) + .mockResolvedValueOnce(result(45)) + .mockResolvedValueOnce(result(46)) + + await expect( + startLegacyWslRuntimeAuthDrain( + { + distro: 'Ubuntu', + guestHomeLinuxPath: '/home/alice', + legacyPanePresent: false, + resolveDestination: () => ({ + authContents: NEWER_AUTH, + linuxHomePath: '/home/alice/.codex' + }) + }, + { throwOnFailure: true } + ) + ).rejects.toThrow('Legacy WSL auth drain recover failed') + }) + + it('keeps an absent source retryable while the legacy home remains', async () => { + runWslProcessMock.mockResolvedValueOnce(result(21)) + + await drainLegacyWslRuntimeAuth({ + distro: 'Ubuntu', + guestHomeLinuxPath: '/home/alice', + legacyPanePresent: false, + resolveDestination: vi.fn() + }) + + expect(runWslProcessMock).toHaveBeenCalledTimes(1) + }) + + it('does nothing after the guest-side completion marker is present', async () => { + runWslProcessMock.mockResolvedValueOnce(result(20)) + const resolveDestination = vi.fn() + + await drainLegacyWslRuntimeAuth({ + distro: 'Ubuntu', + guestHomeLinuxPath: '/home/alice', + legacyPanePresent: false, + resolveDestination + }) + + expect(resolveDestination).not.toHaveBeenCalled() + expect(runWslProcessMock).toHaveBeenCalledTimes(1) + }) + + it('marks an absent legacy home complete after every legacy pane exits', async () => { + runWslProcessMock.mockResolvedValueOnce(result(22)).mockResolvedValueOnce(result(0)) + + await drainLegacyWslRuntimeAuth({ + distro: 'Ubuntu', + guestHomeLinuxPath: '/home/alice', + legacyPanePresent: false, + resolveDestination: vi.fn() + }) + + expect(runWslProcessMock).toHaveBeenCalledTimes(2) + expect(runWslProcessMock.mock.calls[1]?.[0].args).toEqual([ + '/home/alice/.local/share/orca/codex-runtime-home/home', + '/home/alice/.local/share/orca/codex-runtime-home/active/wsl/home', + '/home/alice/.local/share/orca/codex-runtime-home/direct-home-auth-drain-v1.json' + ]) + }) + + it('coalesces concurrent drain triggers instead of queueing every poll', async () => { + runWslProcessMock.mockImplementation(() => new Promise(() => {})) + const options = { + distro: 'Ubuntu', + guestHomeLinuxPath: '/home/alice', + legacyPanePresent: true, + resolveDestination: () => null + } + + startLegacyWslRuntimeAuthDrain(options) + startLegacyWslRuntimeAuthDrain(options) + await Promise.resolve() + + expect(runWslProcessMock).toHaveBeenCalledTimes(1) + }) + + it('bounds sequential pending launches to recent session dates', 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(['full', 'recent', 'full']) + expect(applyCalls.map(([call]) => call.timeoutMs)).toEqual([30_000, 5_000, 30_000]) + }) + + it('reads every candidate home in one bounded guest process', async () => { + runWslProcessMock.mockResolvedValueOnce( + result( + 0, + [`present:${Buffer.from(SOURCE_AUTH).toString('base64')}`, 'missing', 'unreadable'].join( + '\n' + ) + ) + ) + + await expect( + readWslCodexAuths('Ubuntu', ['/home/alice/.codex-a', '/home/alice/.codex-b', '/bad']) + ).resolves.toEqual([ + { kind: 'present', contents: SOURCE_AUTH }, + { kind: 'missing' }, + { kind: 'unreadable' } + ]) + expect(runWslProcessMock).toHaveBeenCalledTimes(1) + expect(runWslProcessMock).toHaveBeenCalledWith( + expect.objectContaining({ + args: ['/home/alice/.codex-a', '/home/alice/.codex-b', '/bad'], + maxOutputBytes: 2 * 1024 * 1024, + timeoutMs: 5_000 + }) + ) + }) +}) diff --git a/src/main/codex-accounts/legacy-wsl-runtime-auth-drain.ts b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain.ts new file mode 100644 index 00000000000..32beb908a53 --- /dev/null +++ b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain.ts @@ -0,0 +1,269 @@ +import { createHash } from 'node:crypto' +import { posix as pathPosix } from 'node:path' +import { wslCodexRuntimeHomeForGuestHome } from '../pty/codex-home-wsl-env' +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' +import { + APPLY_LEGACY_AUTH_SCRIPT, + FINALIZE_ABSENT_AUTH_SCRIPT, + INSPECT_LEGACY_AUTH_SCRIPT, + LEGACY_HOME_ABSENT_EXIT, + LEGACY_HOME_STILL_PRESENT_EXIT, + MARKER_PRESENT_EXIT, + SOURCE_AUTH_ABSENT_EXIT +} from './legacy-wsl-runtime-auth-drain-scripts' +import { decodeWslBase64Payload } from './wsl-codex-auth-batch-reader' + +const DRAIN_MARKER_NAME = 'direct-home-auth-drain-v1.json' + +export type LegacyWslRuntimeAuthDestination = { + authContents: string + linuxHomePath: string +} + +type LegacyWslRuntimeInspection = { + authContents: string + credentials: { kind: 'missing' } | { kind: 'present'; contents: string } +} + +type LegacyWslRuntimeAuthDrainOptions = { + distro: string + guestHomeLinuxPath: string + legacyPanePresent: boolean + resolveDestination: ( + runtimeAuthContents: string + ) => LegacyWslRuntimeAuthDestination | null | Promise +} + +const drainQueueByDistro = new Map>() +const completedDistroKeys = new Set() +const pendingSessionBridgeRouteByDistro = new Map() + +export function startLegacyWslRuntimeAuthDrain( + options: LegacyWslRuntimeAuthDrainOptions, + startOptions: { throwOnFailure?: boolean } = {} +): Promise { + const key = options.distro.trim().toLowerCase() + if (completedDistroKeys.has(key)) { + return Promise.resolve() + } + // Coalesce launch/rate-limit callers while a drain is in flight. Queuing a + // new pass for every poll can otherwise build an unbounded promise chain + // while a legacy pane keeps the migration pending. + const inFlight = drainQueueByDistro.get(key) + if (inFlight) { + return startOptions.throwOnFailure ? inFlight : logDrainFailure(inFlight) + } + const next = drainLegacyWslRuntimeAuth(options).then((status) => { + if (status === 'complete') { + completedDistroKeys.add(key) + } + }) + drainQueueByDistro.set(key, next) + const clearQueue = (): void => { + if (drainQueueByDistro.get(key) === next) { + drainQueueByDistro.delete(key) + } + } + void next.then(clearQueue, clearQueue) + return startOptions.throwOnFailure ? next : logDrainFailure(next) +} + +function logDrainFailure(task: Promise): Promise { + return task.catch((error) => { + console.warn('[codex-wsl-auth-drain] Failed to drain legacy runtime auth:', error) + }) +} + +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, + loginPath: 'none', + script: INSPECT_LEGACY_AUTH_SCRIPT, + args: [paths.runtimeHome, paths.activeHome, paths.marker], + timeoutMs: 5_000, + maxOutputBytes: 2 * 1024 * 1024 + }) + if (inspection.code === MARKER_PRESENT_EXIT) { + return 'complete' + } + if (inspection.code === LEGACY_HOME_ABSENT_EXIT) { + if (!options.legacyPanePresent) { + return finalizeAbsentLegacyAuth(options.distro, paths) + } + return 'pending' + } + if (inspection.code === SOURCE_AUTH_ABSENT_EXIT) { + return 'pending' + } + assertSuccessfulDrainStep('inspect', inspection) + + const inspected = parseLegacyRuntimeInspection(inspection.stdout) + if (!inspected) { + return 'pending' + } + const destination = await options.resolveDestination(inspected.authContents) + if (!destination) { + return 'pending' + } + const freshness = compareCodexAuthFreshness(inspected.authContents, destination.authContents) + 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 bridgeAllSessions = + deleteSource || pendingSessionBridgeRouteByDistro.get(distroKey) !== sessionBridgeRoute + const result = await runWslProcess({ + distro: options.distro, + loginPath: 'none', + script: APPLY_LEGACY_AUTH_SCRIPT, + args: [ + paths.runtimeHome, + paths.activeHome, + paths.marker, + destination.linuxHomePath, + sha256(inspected.authContents), + sha256(destination.authContents), + promoteAuth ? '1' : '0', + deleteSource ? '1' : '0', + inspected.credentials.kind === 'present' ? sha256(inspected.credentials.contents) : 'missing', + bridgeAllSessions ? 'full' : 'recent' + ], + timeoutMs: bridgeAllSessions ? WSL_SESSION_BRIDGE_TIMEOUT_MS : 5_000, + maxOutputBytes: 16 * 1024 + }) + try { + assertSuccessfulDrainStep('apply', result) + } catch { + return recoverAfterFailedApply(options.distro, paths) + } + if (!deleteSource) { + pendingSessionBridgeRouteByDistro.set(distroKey, sessionBridgeRoute) + } + return deleteSource ? 'complete' : 'pending' +} + +async function recoverAfterFailedApply( + distro: string, + paths: ReturnType +): Promise<'complete' | 'pending'> { + const recovery = await runWslProcess({ + distro, + loginPath: 'none', + script: INSPECT_LEGACY_AUTH_SCRIPT, + args: [paths.runtimeHome, paths.activeHome, paths.marker], + timeoutMs: 5_000, + maxOutputBytes: 2 * 1024 * 1024 + }) + if (recovery.code === MARKER_PRESENT_EXIT) { + return 'complete' + } + if ( + !recovery.timedOut && + (recovery.code === 0 || + recovery.code === SOURCE_AUTH_ABSENT_EXIT || + recovery.code === LEGACY_HOME_ABSENT_EXIT) + ) { + return 'pending' + } + assertSuccessfulDrainStep('recover', recovery) + return 'pending' +} + +function parseLegacyRuntimeInspection(stdout: string): LegacyWslRuntimeInspection | null { + const [authBase64, credentialsKind, credentialsBase64] = stdout.split('\n') + const authContents = decodeWslBase64Payload(authBase64 ?? '') + if (authContents === null) { + return null + } + if (credentialsKind === 'missing') { + return { authContents, credentials: { kind: 'missing' } } + } + if (credentialsKind !== 'present') { + return null + } + const credentialsContents = decodeWslBase64Payload(credentialsBase64 ?? '') + if (!credentialsContents || !isJsonObject(credentialsContents)) { + return null + } + return { authContents, credentials: { kind: 'present', contents: credentialsContents } } +} + +function isJsonObject(contents: string): boolean { + try { + const value = JSON.parse(contents) as unknown + return Boolean(value) && typeof value === 'object' && !Array.isArray(value) + } catch { + return false + } +} + +function resolveLegacyRuntimePaths(guestHomeLinuxPath: string): { + activeHome: string + marker: string + runtimeHome: string +} { + const runtimeHome = wslCodexRuntimeHomeForGuestHome(guestHomeLinuxPath) + const runtimeRoot = pathPosix.dirname(runtimeHome) + return { + activeHome: pathPosix.join(runtimeRoot, 'active', 'wsl', 'home'), + marker: pathPosix.join(runtimeRoot, DRAIN_MARKER_NAME), + runtimeHome + } +} + +async function finalizeAbsentLegacyAuth( + distro: string, + paths: ReturnType +): Promise<'complete' | 'pending'> { + const result = await runWslProcess({ + distro, + loginPath: 'none', + script: FINALIZE_ABSENT_AUTH_SCRIPT, + args: [paths.runtimeHome, paths.activeHome, paths.marker], + timeoutMs: 5_000, + maxOutputBytes: 16 * 1024 + }) + if (result.code === LEGACY_HOME_STILL_PRESENT_EXIT) { + return 'pending' + } + assertSuccessfulDrainStep('finalize', result) + return 'complete' +} + +function assertSuccessfulDrainStep( + step: string, + result: { code: number | null; stderr: string; timedOut: boolean } +): void { + if (result.code === 0 && !result.timedOut) { + return + } + const detail = result.stderr.trim() + throw new Error( + `Legacy WSL auth drain ${step} failed (${result.timedOut ? 'timeout' : `exit ${result.code}`})${detail ? `: ${detail}` : ''}` + ) +} + +function sha256(contents: string): string { + return createHash('sha256').update(contents).digest('hex') +} + +export const _internals = { + applyLegacyAuthScript: APPLY_LEGACY_AUTH_SCRIPT, + finalizeAbsentAuthScript: FINALIZE_ABSENT_AUTH_SCRIPT, + inspectLegacyAuthScript: INSPECT_LEGACY_AUTH_SCRIPT, + resetDrainQueue: (): void => { + drainQueueByDistro.clear() + completedDistroKeys.clear() + pendingSessionBridgeRouteByDistro.clear() + } +} diff --git a/src/main/codex-accounts/legacy-wsl-runtime-auth-finalize-script.ts b/src/main/codex-accounts/legacy-wsl-runtime-auth-finalize-script.ts new file mode 100644 index 00000000000..7fee0a20719 --- /dev/null +++ b/src/main/codex-accounts/legacy-wsl-runtime-auth-finalize-script.ts @@ -0,0 +1,71 @@ +import { LEGACY_HOME_STILL_PRESENT_EXIT } from './legacy-wsl-runtime-auth-drain-exit-codes' +import { + RECOVER_DESTINATION_AUTH_COMMAND, + RESOLVE_LEGACY_HOME_SCRIPT, + ROLLBACK_SESSION_LINKS_FUNCTION +} from './legacy-wsl-runtime-auth-drain-shell-commands' + +export const FINALIZE_ABSENT_AUTH_SCRIPT = ` +set -eu +source_recovery_auth="$3.orca-drain-source" +source_quarantine_auth="$3.orca-drain-live-source" +destination_recovery_auth="$3.orca-drain-destination" +destination_recovery_path="$3.orca-drain-destination-path" +session_link_manifest="$3.orca-drain-session-links" +session_commit_marker="$3.orca-drain-session-commit" +session_stage_root="$3.orca-drain-session-stage" +${ROLLBACK_SESSION_LINKS_FUNCTION} +if [ -e "$3" ] || [ -L "$3" ]; then + [ -f "$3" ] && [ ! -L "$3" ] || exit 46 + commit_session_links || exit 46 + if [ -f "$destination_recovery_auth" ] && [ ! -L "$destination_recovery_auth" ]; then + chmod 600 "$destination_recovery_auth" + fi + rm -f -- "$source_recovery_auth" "$source_quarantine_auth" "$destination_recovery_auth" "$destination_recovery_path" + exit 0 +fi +rollback_session_links +${RESOLVE_LEGACY_HOME_SCRIPT} +if [ -f "$source_recovery_auth" ] && [ ! -L "$source_recovery_auth" ]; then + [ ! -e "$legacy_home/auth.json" ] && [ ! -L "$legacy_home/auth.json" ] || exit 41 + mv -- "$source_recovery_auth" "$legacy_home/auth.json" + chmod 600 "$legacy_home/auth.json" + if [ -e "$destination_recovery_auth" ] || [ -L "$destination_recovery_auth" ]; then + [ -f "$destination_recovery_auth" ] && [ ! -L "$destination_recovery_auth" ] || exit 46 + [ -f "$destination_recovery_path" ] && [ ! -L "$destination_recovery_path" ] || exit 46 + ${RECOVER_DESTINATION_AUTH_COMMAND} || exit 46 + elif [ -e "$destination_recovery_path" ] || [ -L "$destination_recovery_path" ]; then + [ -f "$destination_recovery_path" ] && [ ! -L "$destination_recovery_path" ] || exit 46 + rm -- "$destination_recovery_path" + fi + exit 46 +fi +if [ -f "$source_quarantine_auth" ] && [ ! -L "$source_quarantine_auth" ]; then + [ ! -e "$legacy_home/auth.json" ] && [ ! -L "$legacy_home/auth.json" ] || exit 41 + mv -- "$source_quarantine_auth" "$legacy_home/auth.json" + chmod 600 "$legacy_home/auth.json" + if [ -e "$destination_recovery_auth" ] || [ -L "$destination_recovery_auth" ]; then + [ -f "$destination_recovery_auth" ] && [ ! -L "$destination_recovery_auth" ] || exit 46 + [ -f "$destination_recovery_path" ] && [ ! -L "$destination_recovery_path" ] || exit 46 + ${RECOVER_DESTINATION_AUTH_COMMAND} || exit 46 + elif [ -e "$destination_recovery_path" ] || [ -L "$destination_recovery_path" ]; then + [ -f "$destination_recovery_path" ] && [ ! -L "$destination_recovery_path" ] || exit 46 + rm -- "$destination_recovery_path" + fi + exit 46 +fi +[ ! -e "$source_recovery_auth" ] && [ ! -L "$source_recovery_auth" ] || exit 46 +[ ! -e "$source_quarantine_auth" ] && [ ! -L "$source_quarantine_auth" ] || exit 46 +[ ! -e "$destination_recovery_auth" ] && [ ! -L "$destination_recovery_auth" ] || exit 46 +[ ! -e "$destination_recovery_path" ] && [ ! -L "$destination_recovery_path" ] || exit 46 +[ ! -e "$legacy_home/auth.json" ] && [ ! -L "$legacy_home/auth.json" ] || exit 41 +[ "$legacy_home_resolved" = 0 ] || exit ${LEGACY_HOME_STILL_PRESENT_EXIT} +umask 077 +marker_parent=\${3%/*} +mkdir -p -- "$marker_parent" +temporary_marker="$3.orca-drain-$$" +trap 'rm -f -- "$temporary_marker"' EXIT HUP INT TERM +printf '%s\n' '{"completed":true}' > "$temporary_marker" +chmod 600 "$temporary_marker" +mv -f -- "$temporary_marker" "$3" +` diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index cf3a0105d63..2d246bf480d 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -1,5 +1,4 @@ /* eslint-disable max-lines -- Why: keeps Codex's whole runtime-home contract in one place so account-switch semantics don't drift across launch/login/quota paths. */ -import { quotePosixShell } from '../../shared/wsl-login-shell-command' import { appendFileSync, copyFileSync, @@ -18,13 +17,13 @@ import { unlinkSync } from 'node:fs' import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence' -import { execFileSync } from 'node:child_process' import { dirname, extname, isAbsolute, join, parse, + posix as pathPosix, relative, resolve, win32 as pathWin32 @@ -33,7 +32,6 @@ import { app } from 'electron' import type { CodexManagedAccount } from '../../shared/managed-account-types' import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' import type { Store } from '../persistence' -import { WSL_CODEX_RUNTIME_HOME_SEGMENTS } from '../pty/codex-home-wsl-env' import { recoverInterruptedGuardedFileOperation, removeFileAtomicallyIfUnchanged, @@ -55,16 +53,13 @@ import { resolveWslCodexSessionSourceHome } from '../codex/codex-session-source-home' import { startWslCodexSessionBridgeInBackground } from '../codex/wsl-codex-session-bridge' +import { syncSystemConfigIntoManagedCodexHome } from '../codex/codex-config-mirror' +import { parseWslUncPath, toLinuxPath, toWindowsWslUncPath } from '../../shared/wsl-paths' import { - prepareSystemConfigForFreshRuntimeMirror, - syncSystemConfigIntoManagedCodexHome -} from '../codex/codex-config-mirror' -import { parseWslUncPath, toLinuxPath } from '../../shared/wsl-paths' -import { + getCodexSelectionLaneKey, getWslSelectionKey, getSelectedCodexAccountIdForTarget, normalizeCodexRuntimeSelection, - setSelectedCodexAccountIdForTarget, type CodexAccountSelectionTarget } from './runtime-selection' import { getDefaultWslDistro, getWslHome } from '../wsl' @@ -91,10 +86,16 @@ import { CodexCredentialAbsenceGrace } from './codex-credential-absence-grace' import { syncLegacySharedCodexConfigForRetainedPanes } from './legacy-shared-config-compatibility' import { getCodexPaneAccount, + hasRecordedLegacyWslCodexPane, hasRecordedLegacySharedCodexPane, type CodexPaneHomeRoute } from '../codex/codex-pane-account-registry' import { isShellStartupEnvProbeSupported } from '../pty/shell-startup-env' +import { + startLegacyWslRuntimeAuthDrain, + type LegacyWslRuntimeAuthDestination +} from './legacy-wsl-runtime-auth-drain' +import { readWslCodexAuths, type WslCodexAuthRead } from './wsl-codex-auth-batch-reader' type CodexSystemDefaultSnapshot = { authJson: string | null @@ -130,7 +131,6 @@ type CodexRuntimeLogoutMarkerStatus = | { kind: 'applies' } | { kind: 'system-default-changed'; systemDefaultAuthJson: string | null } -type CodexReadBackResult = 'unchanged' | 'persisted' | 'rejected' type CodexReadBackMatch = | { kind: 'matched' @@ -195,11 +195,6 @@ export class CodexRuntimeHomeService { private lastSyncedAccountId: string | null = null // Last auth.json Orca wrote to the runtime home; a later diff signals an out-of-band change (Codex token refresh, or external login to adopt). private lastWrittenAuthJson: string | null = null - // Why: WSL terminals have per-distro runtime homes; sharing the host baseline can make stale WSL auth look newer than managed storage. - private readonly lastWrittenWslAuthJsonByDistro = new Map() - private readonly lastSyncedWslAccountIdByDistro = new Map() - private readonly wslRuntimeHomePathByDistro = new Map() - private skipNextReadBackForAccountId: string | null = null // Why: a managed host account refreshes auth in its own home. Remember that // provenance so a later deselect never adopts stale shared bytes. private lastHostAccountUsedSelfContainedHome = false @@ -209,7 +204,6 @@ export class CodexRuntimeHomeService { private hostSystemDefaultSessionMigrationPending = false private pendingHostSystemDefaultSessionMigrationNeedsFullScan = false private pendingHostSystemDefaultSessionMigrationTarget: string | null = null - constructor(private readonly store: Store) { this.safeRecoverInterruptedRuntimeAuthOperation() this.safeMigrateLegacySharedAuth() @@ -244,11 +238,10 @@ export class CodexRuntimeHomeService { ): string | null { if (target?.runtime === 'wsl') { const wslTarget = this.resolveWslDefaultTarget(target) - const syncedRuntimeHomePath = this.syncWslRuntimeForCurrentSelection(wslTarget) - this.syncWslConfigAndGlobalInstructionsForLaunch(wslTarget, syncedRuntimeHomePath) - const runtimeHomePath = syncedRuntimeHomePath ?? this.getWslSystemCodexHomePath(wslTarget) - this.startWslSessionBridgeForLaunch(wslTarget, runtimeHomePath) - return runtimeHomePath + const homePath = this.getWslCodexHomePathForSelection(wslTarget) + this.startLegacyWslAuthDrain(wslTarget) + this.finishWslLaunchPreparation(wslTarget, homePath) + return homePath } const selfContainedAccount = this.getSelfContainedManagedHostAccount() if (selfContainedAccount) { @@ -281,6 +274,23 @@ export class CodexRuntimeHomeService { return this.getRuntimeHomePath() } + async prepareForCodexLaunchAsync( + target?: CodexAccountSelectionTarget, + launchEnv?: NodeJS.ProcessEnv, + options?: { unavailableManagedHomePath?: string } + ): Promise { + if (target?.runtime !== 'wsl') { + return this.prepareForCodexLaunch(target, launchEnv, options) + } + const wslTarget = this.resolveWslDefaultTarget(target) + const homePath = this.getWslCodexHomePathForSelection(wslTarget) + // Why: the retired home may hold the freshest credential, so the first + // direct-home Codex spawn must wait for its bounded guest transaction. + await this.startLegacyWslAuthDrain(wslTarget, { throwOnFailure: true }) + this.finishWslLaunchPreparation(wslTarget, homePath) + return homePath + } + beginHostSystemDefaultSessionMigrationLaunch( codexHomePath: string | null, options: { reattached?: boolean; launchEnv?: NodeJS.ProcessEnv } = {} @@ -375,9 +385,18 @@ export class CodexRuntimeHomeService { } private getManagedHostAccountHomesForSessionDiscovery(): string[] { - return this.getManagedAccountHomesForSessionDiscovery().filter( - (home) => parseWslUncPath(home) === null - ) + const settings = this.store.getSettings() + const homes: string[] = [] + for (const account of settings.codexManagedAccounts) { + if (this.getWslManagedHomePath(account)) { + continue + } + const trustedHome = this.getTrustedSelfContainedManagedHomePath(account) + if (trustedHome) { + homes.push(trustedHome) + } + } + return homes } private prepareSelfContainedManagedHomeForLaunch( @@ -570,15 +589,14 @@ export class CodexRuntimeHomeService { const systemCodexHomePath = resolveWslCodexSessionSourceHome(this.store.getSettings(), distro) ?? this.getWslSystemCodexHomePath({ runtime: 'wsl', wslDistro: distro }) - if (!systemCodexHomePath || systemCodexHomePath === runtimeHomePath) { - return + if (systemCodexHomePath && systemCodexHomePath !== runtimeHomePath) { + // Why: WSL history must be hardlinked inside the distro; host-side links can't bridge Windows and WSL filesystems in a resume-visible way. + void startWslCodexSessionBridgeInBackground({ + distro, + systemCodexHomePath, + managedCodexHomePath: runtimeHomePath + }) } - // Why: WSL history must be hardlinked inside the distro; host-side links can't bridge Windows and WSL filesystems in a resume-visible way. - void startWslCodexSessionBridgeInBackground({ - distro, - systemCodexHomePath, - managedCodexHomePath: runtimeHomePath - }) } getHostCodexHomePathsForSessionDiscovery(): string[] { @@ -724,26 +742,6 @@ export class CodexRuntimeHomeService { syncLegacySharedCodexConfigForRetainedPanes() } - syncActiveWslSelectionsBeforeRestart(): void { - if (process.platform !== 'win32') { - return - } - - const settings = this.store.getSettings() - for (const [selectedDistroKey, accountId] of Object.entries( - normalizeCodexRuntimeSelection(settings).wsl - )) { - if (!accountId) { - continue - } - const account = this.getActiveAccount(settings.codexManagedAccounts, accountId) - if (!account || account.managedHomeRuntime !== 'wsl') { - continue - } - this.safeReadBackActiveWslAccountBeforeRestart(account, selectedDistroKey) - } - } - private getWslSystemCodexHomePath(target: CodexAccountSelectionTarget): string | null { if (process.platform !== 'win32') { return null @@ -753,9 +751,23 @@ export class CodexRuntimeHomeService { return null } const home = getWslHome(distro) + if (home && /^[A-Za-z]:[\\/]/.test(home)) { + const linuxHome = toLinuxPath(home).trim() + return linuxHome.startsWith('/') + ? toWindowsWslUncPath(pathPosix.join(linuxHome, '.codex'), distro) + : null + } return home ? this.joinWslPath(home, '.codex') : null } + private finishWslLaunchPreparation( + target: CodexAccountSelectionTarget, + homePath: string | null + ): void { + this.syncWslConfigAndGlobalInstructionsForLaunch(target, homePath) + this.startWslSessionBridgeForLaunch(target, homePath) + } + private syncWslConfigAndGlobalInstructionsForLaunch( target: CodexAccountSelectionTarget, runtimeHomePath: string | null @@ -790,10 +802,9 @@ export class CodexRuntimeHomeService { prepareForRateLimitFetch(target?: CodexAccountSelectionTarget): CodexRateLimitHomeResolution { if (target?.runtime === 'wsl') { const wslTarget = this.resolveWslDefaultTarget(target) - const syncedRuntimeHomePath = this.getPreparedWslRateLimitHomePath(wslTarget) return { kind: 'ready', - codexHomePath: syncedRuntimeHomePath ?? this.getWslSystemCodexHomePath(wslTarget) + codexHomePath: this.getPreparedWslRateLimitHomePath(wslTarget) } } const selfContainedAccount = this.getSelfContainedManagedHostAccount() @@ -833,7 +844,7 @@ export class CodexRuntimeHomeService { launchEnv?: NodeJS.ProcessEnv ): void { if (target?.runtime === 'wsl') { - this.syncWslRuntimeForCurrentSelection(target) + this.startLegacyWslAuthDrain(this.resolveWslDefaultTarget(target)) return } @@ -880,7 +891,6 @@ export class CodexRuntimeHomeService { // distro-local runtime home, so the host mirror only drops its baseline. this.lastSyncedAccountId = null this.lastWrittenAuthJson = null - this.skipNextReadBackForAccountId = null return } if (normalizeCodexRuntimeSelection(settings).host) { @@ -922,71 +932,13 @@ export class CodexRuntimeHomeService { } } - // Why: re-auth/add-account write fresh managed tokens, so skip the next read-back to avoid clobbering them with stale runtime tokens. + // Why: re-auth/add-account writes fresh host tokens, invalidating the shared mirror baseline. clearLastWrittenAuthJson( accountId = normalizeCodexRuntimeSelection(this.store.getSettings()).host ): void { if (accountId === normalizeCodexRuntimeSelection(this.store.getSettings()).host) { this.lastWrittenAuthJson = null } - this.skipNextReadBackForAccountId = accountId - } - - private readBackRefreshedTokensFromPath( - runtimeAuthPath: string, - options: { - updateLastWrittenAuthJson: boolean - lastWrittenAuthJson?: string | null - setLastWrittenAuthJson?: (contents: string) => void - expectedAccountId?: string - } - ): CodexReadBackResult { - try { - if (!existsSync(runtimeAuthPath)) { - return 'unchanged' - } - - const lastWrittenAuthJson = - options.lastWrittenAuthJson === undefined - ? this.lastWrittenAuthJson - : options.lastWrittenAuthJson - const runtimeContents = readFileSync(runtimeAuthPath, 'utf-8') - if (lastWrittenAuthJson !== null && runtimeContents === lastWrittenAuthJson) { - return 'unchanged' - } - - const match = this.findManagedAccountForRuntimeAuth( - runtimeContents, - options.expectedAccountId - ) - if (match.kind !== 'matched') { - if (match.kind === 'ambiguous') { - console.warn('[codex-runtime-home] Refusing ambiguous Codex auth read-back') - } - return 'rejected' - } - // Why: after restart there's no last-written baseline, so identity alone can't prove runtime auth is newer than managed storage. - if ( - lastWrittenAuthJson === null && - !this.runtimeAuthIsFresher(runtimeContents, match.managedAuthContents) - ) { - return 'rejected' - } - - writeFileAtomically(match.managedAuthPath, runtimeContents, { mode: 0o600 }) - if (options.updateLastWrittenAuthJson) { - if (options.setLastWrittenAuthJson) { - options.setLastWrittenAuthJson(runtimeContents) - } else { - this.lastWrittenAuthJson = runtimeContents - } - } - return 'persisted' - } catch (error) { - // Why: read-back is best-effort; a transient fs error must not block the forward sync — worst case is one more stale-token cycle. - console.warn('[codex-runtime-home] Failed to read back refreshed tokens:', error) - return 'rejected' - } } // Why: which ~/.codex bytes the mirror was seeded from, and whether the system @@ -1041,233 +993,182 @@ export class CodexRuntimeHomeService { } private getWslManagedHomePath(account: CodexManagedAccount | null): string | null { - if (!account) { - return null - } - if (account.managedHomeRuntime === 'wsl' && parseWslUncPath(account.managedHomePath)) { - return account.managedHomePath - } - return parseWslUncPath(account.managedHomePath) ? account.managedHomePath : null + return this.getWslManagedHomeIdentity(account) ? (account?.managedHomePath ?? null) : null } private getPreparedWslRateLimitHomePath(target: CodexAccountSelectionTarget): string | null { - const distro = target.wslDistro?.trim() - if (distro) { - const settings = this.store.getSettings() - const selectedAccountId = getSelectedCodexAccountIdForTarget(settings, target) - if (selectedAccountId === null) { - // Why: the system-default account changes outside Orca, so read its real home directly to avoid a stale cached runtime copy. - return this.getWslSystemCodexHomePath(target) - } - const cachedRuntimeHomePath = this.wslRuntimeHomePathByDistro.get(distro) - if ( - cachedRuntimeHomePath && - this.lastSyncedWslAccountIdByDistro.has(distro) && - this.lastSyncedWslAccountIdByDistro.get(distro) === selectedAccountId - ) { - // Why: RateLimitService resolves provenance twice per poll; stay path-only so it doesn't block main on UNC reads and a wsl.exe probe. - return cachedRuntimeHomePath - } - } - return this.syncWslRuntimeForCurrentSelection(target) + return this.getWslCodexHomePathForSelection(target) } - private syncWslRuntimeForCurrentSelection(target: CodexAccountSelectionTarget): string | null { - if (process.platform !== 'win32') { - return null - } - - const wslTarget = this.resolveWslDefaultTarget(target) + private getWslCodexHomePathForSelection(target: CodexAccountSelectionTarget): string | null { const settings = this.store.getSettings() - const activeAccount = this.getActiveAccount( + const account = this.getActiveAccount( settings.codexManagedAccounts, - getSelectedCodexAccountIdForTarget(settings, wslTarget) + getSelectedCodexAccountIdForTarget(settings, target) ) - const distro = wslTarget.wslDistro?.trim() || activeAccount?.wslDistro || getDefaultWslDistro() - if (!distro) { - return null - } - - const runtimeHomePath = this.getWslRuntimeHomePath(distro) - if (!runtimeHomePath) { - return null - } - this.wslRuntimeHomePathByDistro.set(distro, runtimeHomePath) - - mkdirSync(runtimeHomePath, { recursive: true }) - this.safeMigrateLegacyWslActiveHomePointer(distro, runtimeHomePath) - this.seedWslRuntimeHome(runtimeHomePath, activeAccount, distro) - - const runtimeAuthPath = join(runtimeHomePath, 'auth.json') - const previousWslAccountId = this.lastSyncedWslAccountIdByDistro.get(distro) ?? null - if (previousWslAccountId) { - if (this.skipNextReadBackForAccountId === previousWslAccountId) { - this.skipNextReadBackForAccountId = null - } else { - const previousWslAccount = this.getActiveAccount( - settings.codexManagedAccounts, - previousWslAccountId - ) - if (previousWslAccount) { - this.readBackRefreshedTokensFromPath(runtimeAuthPath, { - updateLastWrittenAuthJson: true, - lastWrittenAuthJson: this.lastWrittenWslAuthJsonByDistro.get(distro) ?? null, - setLastWrittenAuthJson: (contents) => { - this.lastWrittenWslAuthJsonByDistro.set(distro, contents) - }, - expectedAccountId: previousWslAccount.id - }) - } + if (account) { + const targetDistro = this.resolveWslDefaultTarget(target).wslDistro?.trim() + const accountHome = this.getWslLaunchCodexHomePath(account, targetDistro) + if (accountHome) { + return accountHome } } - - const activeAuthPath = activeAccount ? join(activeAccount.managedHomePath, 'auth.json') : null - if (activeAccount && activeAuthPath && existsSync(activeAuthPath)) { - const activeAuth = readFileSync(activeAuthPath, 'utf-8') - this.writeRuntimeAuthAtPath(runtimeAuthPath, activeAuth) - this.lastWrittenWslAuthJsonByDistro.set(distro, activeAuth) - this.lastSyncedWslAccountIdByDistro.set(distro, activeAccount.id) - return runtimeHomePath - } - if (activeAccount && activeAuthPath) { - console.warn( - '[codex-runtime-home] Active WSL managed account is missing auth.json, restoring system default' - ) - this.store.updateSettings({ - activeCodexManagedAccountId: settings.activeCodexManagedAccountId, - activeCodexManagedAccountIdsByRuntime: setSelectedCodexAccountIdForTarget( - normalizeCodexRuntimeSelection(settings), - null, - wslTarget - ) - }) - } - - const systemAuthPath = this.getWslSystemCodexAuthPath({ runtime: 'wsl', wslDistro: distro }) - if (systemAuthPath && existsSync(systemAuthPath)) { - const systemAuth = readFileSync(systemAuthPath, 'utf-8') - const mirroredSystemDefaultAuth = this.lastWrittenWslAuthJsonByDistro.get(distro) ?? null - const runtimeAuth = existsSync(runtimeAuthPath) - ? readFileSync(runtimeAuthPath, 'utf-8') - : null - if ( - runtimeAuth !== null && - runtimeAuth !== systemAuth && - this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuth, systemAuth) && - ((mirroredSystemDefaultAuth !== null && systemAuth === mirroredSystemDefaultAuth) || - (mirroredSystemDefaultAuth === null && - this.runtimeAuthIsFresher(runtimeAuth, systemAuth))) - ) { - // Why: WSL baselines are lost on restart, so a same-identity fresher runtime auth is a token refresh; copy it back before mirroring ~/.codex. - this.writeRuntimeAuthAtPath(systemAuthPath, runtimeAuth) - this.lastWrittenWslAuthJsonByDistro.set(distro, runtimeAuth) - this.lastSyncedWslAccountIdByDistro.set(distro, null) - return runtimeHomePath - } - this.writeRuntimeAuthAtPath(runtimeAuthPath, systemAuth) - this.lastWrittenWslAuthJsonByDistro.set(distro, systemAuth) - this.lastSyncedWslAccountIdByDistro.set(distro, null) - return runtimeHomePath - } - - rmSync(runtimeAuthPath, { force: true }) - this.lastWrittenWslAuthJsonByDistro.set(distro, null) - this.lastSyncedWslAccountIdByDistro.set(distro, null) - return runtimeHomePath + return this.getWslSystemCodexHomePath(target) } - private getWslRuntimeHomePath(distro: string): string | null { - const home = getWslHome(distro) - return home ? this.joinWslPath(home, ...WSL_CODEX_RUNTIME_HOME_SEGMENTS) : null - } - - private safeReadBackActiveWslAccountBeforeRestart( + private getWslLaunchCodexHomePath( account: CodexManagedAccount, - selectedDistroKey: string - ): void { - try { - this.readBackActiveWslAccountBeforeRestart(account, selectedDistroKey) - } catch (error) { - console.warn('[codex-runtime-home] Failed to preserve WSL Codex auth before restart:', error) + targetDistro: string | undefined + ): string | null { + const wslHome = this.getWslManagedHomeIdentity(account) + if (!wslHome) { + return null } + const accountDistro = wslHome.distro + if (targetDistro && accountDistro.toLowerCase() !== targetDistro.toLowerCase()) { + return null + } + if (/^[A-Za-z]:[\\/]/.test(account.managedHomePath)) { + return toWindowsWslUncPath(wslHome.linuxHomePath, accountDistro) + } + return account.managedHomePath || toWindowsWslUncPath(wslHome.linuxHomePath, accountDistro) } - private readBackActiveWslAccountBeforeRestart( - account: CodexManagedAccount, - selectedDistroKey: string - ): void { - const distro = - selectedDistroKey === getWslSelectionKey(null) - ? account.wslDistro?.trim() - : selectedDistroKey.trim() || account.wslDistro?.trim() + private getWslManagedHomeIdentity( + account: CodexManagedAccount | null + ): { distro: string; linuxHomePath: string } | null { + if (!account) { + return null + } + const distro = account.wslDistro?.trim() + const linuxHomePath = account.wslLinuxHomePath?.trim() + if (account.managedHomeRuntime === 'wsl' && distro && linuxHomePath?.startsWith('/')) { + return { distro, linuxHomePath } + } + const legacyHome = parseWslUncPath(account.managedHomePath) + return legacyHome ? { distro: legacyHome.distro, linuxHomePath: legacyHome.linuxPath } : null + } + + private startLegacyWslAuthDrain( + target: CodexAccountSelectionTarget, + options: { throwOnFailure?: boolean } = {} + ): Promise { + if (process.platform !== 'win32') { + return Promise.resolve() + } + const distro = target.wslDistro?.trim() || getDefaultWslDistro() if (!distro) { - return + return Promise.resolve() } - - const runtimeHomePath = this.wslRuntimeHomePathByDistro.get(distro) - if (!runtimeHomePath) { - return + const guestHome = getWslHome(distro) + const guestHomeLinuxPath = guestHome ? toLinuxPath(guestHome).trim() : '' + if (!guestHomeLinuxPath.startsWith('/')) { + return Promise.resolve() } - - this.readBackRefreshedTokensFromPath(join(runtimeHomePath, 'auth.json'), { - updateLastWrittenAuthJson: true, - lastWrittenAuthJson: this.lastWrittenWslAuthJsonByDistro.get(distro) ?? null, - setLastWrittenAuthJson: (contents) => { - this.lastWrittenWslAuthJsonByDistro.set(distro, contents) - }, - expectedAccountId: account.id - }) - } - - private safeMigrateLegacyWslActiveHomePointer(distro: string, runtimeHomePath: string): void { + let legacyPanePresent = true try { - this.migrateLegacyWslActiveHomePointer(distro, runtimeHomePath) + legacyPanePresent = hasRecordedLegacyWslCodexPane(getCodexSelectionLaneKey(target)) } catch (error) { - console.warn('[codex-runtime-home] Failed to migrate legacy WSL active Codex home:', error) + // Why: unknown pane liveness must preserve the source, but promotion can + // still keep the direct home from launching stale auth. + console.warn('[codex-wsl-auth-drain] Pane registry unavailable; preserving source:', error) } - } - - private migrateLegacyWslActiveHomePointer(distro: string, runtimeHomePath: string): void { - const runtimeWsl = parseWslUncPath(runtimeHomePath) - if (!runtimeWsl?.linuxPath.endsWith('/codex-runtime-home/home')) { - return - } - const activeLinuxPath = runtimeWsl.linuxPath.replace( - /\/codex-runtime-home\/home$/, - '/codex-runtime-home/active/wsl/home' - ) - const nextLinuxPath = `${activeLinuxPath}.next-${process.pid}-${Date.now()}` - const activeLinuxParentPath = this.dirnameLinuxPath(activeLinuxPath) - // Why: login-shell cleanup turns `exit 0` into status 1, so fall through. - execFileSync( - 'wsl.exe', - [ - '-d', + return startLegacyWslRuntimeAuthDrain( + { distro, - '--exec', - 'bash', - '-lc', - [ - 'set -e', - `if [ ! -e ${quotePosixShell(activeLinuxPath)} ] && [ ! -L ${quotePosixShell(activeLinuxPath)} ]; then :`, - `elif [ -e ${quotePosixShell(activeLinuxPath)} ] && [ ! -L ${quotePosixShell(activeLinuxPath)} ]; then :`, - 'else', - `mkdir -p ${quotePosixShell(activeLinuxParentPath)}`, - `rm -rf -- ${quotePosixShell(nextLinuxPath)}`, - `ln -s -- ${quotePosixShell(runtimeWsl.linuxPath)} ${quotePosixShell(nextLinuxPath)}`, - `mv -Tf -- ${quotePosixShell(nextLinuxPath)} ${quotePosixShell(activeLinuxPath)}`, - 'fi' - ].join('\n') - ], - // wsl.exe is console-subsystem: without this a GUI-launched Orca flashes - // a conhost and steals foreground for up to the timeout (#10488). - { stdio: ['ignore', 'pipe', 'pipe'], timeout: 5000, windowsHide: true } + guestHomeLinuxPath, + legacyPanePresent, + resolveDestination: (runtimeAuthContents) => + this.resolveLegacyWslAuthDestination(distro, runtimeAuthContents) + }, + options ) } - private dirnameLinuxPath(value: string): string { - const index = value.lastIndexOf('/') - return index > 0 ? value.slice(0, index) : '/' + /** Preserve refreshed auth from retained legacy WSL panes before restart. */ + async syncActiveWslSelectionsBeforeRestart(): Promise { + if (process.platform !== 'win32') { + return + } + const settings = this.store.getSettings() + const drains: Promise[] = [] + for (const [selectedDistroKey, accountId] of Object.entries( + normalizeCodexRuntimeSelection(settings).wsl + )) { + if (!accountId) { + continue + } + const account = this.getActiveAccount(settings.codexManagedAccounts, accountId) + if (!account || account.managedHomeRuntime !== 'wsl') { + continue + } + const distro = + selectedDistroKey === getWslSelectionKey(null) + ? account.wslDistro?.trim() || null + : selectedDistroKey.trim() || null + if (distro) { + drains.push(this.startLegacyWslAuthDrain({ runtime: 'wsl', wslDistro: distro })) + } + } + await Promise.all(drains) + } + + private async resolveLegacyWslAuthDestination( + distro: string, + runtimeAuthContents: string + ): Promise { + const accountHomes = this.store.getSettings().codexManagedAccounts.flatMap((account) => { + const wslHome = this.getWslManagedHomeIdentity(account) + return wslHome?.distro.toLowerCase() === distro.toLowerCase() + ? [{ account, linuxPath: wslHome.linuxHomePath }] + : [] + }) + const accounts = accountHomes.map(({ account }) => account) + const systemHome = this.getWslSystemCodexHomePath({ runtime: 'wsl', wslDistro: distro }) + const parsedSystemHome = systemHome ? parseWslUncPath(systemHome) : null + let reads: WslCodexAuthRead[] + try { + reads = await readWslCodexAuths(distro, [ + ...accountHomes.map(({ linuxPath }) => linuxPath), + ...(parsedSystemHome ? [parsedSystemHome.linuxPath] : []) + ]) + } catch { + reads = accountHomes.map(() => ({ kind: 'unreadable' })) + if (parsedSystemHome) { + reads.push({ kind: 'unreadable' }) + } + } + const authReads = new Map( + accountHomes.map(({ account }, index) => [account.id, reads[index] ?? { kind: 'unreadable' }]) + ) + const match = this.findManagedAccountForRuntimeAuth(runtimeAuthContents, undefined, { + accounts, + authReads + }) + if (match.kind === 'ambiguous') { + return null + } + if (match.kind === 'matched') { + const accountHome = accountHomes.find(({ account }) => account.id === match.account.id) + if (!accountHome) { + return null + } + return { + authContents: match.managedAuthContents, + linuxHomePath: accountHome.linuxPath + } + } + + if (!systemHome || !parsedSystemHome) { + return null + } + const systemAuth = reads[accountHomes.length] ?? { kind: 'unreadable' } + if (systemAuth.kind !== 'present') { + return null + } + return this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuthContents, systemAuth.contents) + ? { authContents: systemAuth.contents, linuxHomePath: parsedSystemHome.linuxPath } + : null } private joinWslPath(basePath: string, ...segments: string[]): string { @@ -1286,40 +1187,13 @@ export class CodexRuntimeHomeService { return defaultDistro ? { runtime: 'wsl', wslDistro: defaultDistro } : target } - private getWslSystemCodexAuthPath(target: CodexAccountSelectionTarget): string | null { - const home = this.getWslSystemCodexHomePath(target) - return home ? this.joinWslPath(home, 'auth.json') : null - } - - private seedWslRuntimeHome( - runtimeHomePath: string, - activeAccount: CodexManagedAccount | null, - distro: string - ): void { - const runtimeConfigPath = join(runtimeHomePath, 'config.toml') - if (existsSync(runtimeConfigPath)) { - return - } - - const candidateHomes = [ - activeAccount?.managedHomePath, - this.getWslSystemCodexHomePath({ runtime: 'wsl', wslDistro: distro }) - ].filter((value): value is string => Boolean(value)) - for (const homePath of candidateHomes) { - const configPath = join(homePath, 'config.toml') - if (existsSync(configPath)) { - writeFileAtomically( - runtimeConfigPath, - prepareWslRuntimeSeedConfig(readFileSync(configPath, 'utf-8'), homePath) - ) - return - } - } - } - private findManagedAccountForRuntimeAuth( runtimeAuthContents: string, - expectedAccountId?: string + expectedAccountId?: string, + options?: { + accounts: readonly CodexManagedAccount[] + authReads: ReadonlyMap + } ): CodexReadBackMatch { const matches: { account: CodexManagedAccount @@ -1327,18 +1201,17 @@ export class CodexRuntimeHomeService { managedAuthContents: string }[] = [] let unreadableHomeCouldOwnRuntimeAuth = false - for (const account of this.store.getSettings().codexManagedAccounts) { + for (const account of options?.accounts ?? this.store.getSettings().codexManagedAccounts) { if (expectedAccountId && account.id !== expectedAccountId) { continue } const managedAuthPath = join(account.managedHomePath, 'auth.json') - if (!existsSync(managedAuthPath)) { + let managedAuthContents: string + const suppliedRead = options?.authReads.get(account.id) + if (suppliedRead?.kind === 'missing') { continue } - let managedAuthContents: string - try { - managedAuthContents = readFileSync(managedAuthPath, 'utf-8') - } catch { + if (suppliedRead?.kind === 'unreadable') { // Why: an unreadable home can never be compared, but letting the read // throw abandons the scan for every other account — dropping a refresh // the runtime home holds for one of them. Only its record can rule it @@ -1351,6 +1224,24 @@ export class CodexRuntimeHomeService { } continue } + if (suppliedRead?.kind === 'present') { + managedAuthContents = suppliedRead.contents + } else { + if (!existsSync(managedAuthPath)) { + continue + } + try { + managedAuthContents = readFileSync(managedAuthPath, 'utf-8') + } catch { + if ( + !expectedAccountId && + codexAuthCouldBelongToManagedAccount(runtimeAuthContents, account) + ) { + unreadableHomeCouldOwnRuntimeAuth = true + } + continue + } + } if (codexAuthMatchesManagedAccount(runtimeAuthContents, account, managedAuthContents)) { matches.push({ account, managedAuthPath, managedAuthContents }) } @@ -1372,10 +1263,6 @@ export class CodexRuntimeHomeService { return codexAuthMatchesSystemDefaultIdentity(runtimeAuthContents, systemDefaultAuthContents) } - private runtimeAuthIsFresher(runtimeAuthContents: string, managedAuthContents: string): boolean { - return codexAuthIsFresher(runtimeAuthContents, managedAuthContents) - } - private safeMigrateLegacySharedAuth(): void { const settings = this.store.getSettings() try { @@ -2082,15 +1969,6 @@ export class CodexRuntimeHomeService { return true } - private writeRuntimeAuthAtPath(authPath: string, contents: string): void { - if (this.fileContentsEqual(authPath, contents)) { - this.ensureOwnerOnlyMode(authPath) - return - } - mkdirSync(dirname(authPath), { recursive: true }) - writeFileAtomically(authPath, contents, { mode: 0o600 }) - } - /** * `true`/`false` only when the bytes were actually read; `null` when the file * could not be read at all. The old `catch { return false }` reported "these @@ -2397,14 +2275,3 @@ export class CodexRuntimeHomeService { rmSync(this.getSystemDefaultSnapshotPath(), { force: true }) } } - -// Why: Codex reads this config inside WSL, so relative path settings must anchor to the Linux-side home (verbatim copy breaks load, os error 2). -export function prepareWslRuntimeSeedConfig( - configContents: string, - sourceHomePath: string -): string { - return prepareSystemConfigForFreshRuntimeMirror( - configContents, - parseWslUncPath(sourceHomePath)?.linuxPath ?? sourceHomePath - ) -} 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 efc7ea3f2b9..0868e0afcf5 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 @@ -1,6 +1,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { join } from 'node:path' +import type * as CodexConfigMirror from '../codex/codex-config-mirror' +import type * as CodexHomePaths from '../codex/codex-home-paths' +import type * as CodexPaneAccountRegistry from '../codex/codex-pane-account-registry' +import type * as LegacyWslRuntimeAuthDrain from './legacy-wsl-runtime-auth-drain' +import type * as WslCodexAuthBatchReader from './wsl-codex-auth-batch-reader' import { createSettings } from './runtime-home-settings-test-fixtures' import { createCodexAuthJson, @@ -87,18 +92,16 @@ describe('CodexRuntimeHomeService', () => { expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"host-system"}\n') expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath()) expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( - wslRuntimeHomePath - ) - expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe( - '{"account":"wsl"}\n' + wslManagedHomePath ) + expect(existsSync(join(wslRuntimeHomePath, 'auth.json'))).toBe(false) expect(service.prepareForRateLimitFetch()).toEqual({ kind: 'ready', codexHomePath: getRuntimeCodexHomePath() }) expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toEqual({ kind: 'ready', - codexHomePath: wslRuntimeHomePath + codexHomePath: wslManagedHomePath }) } finally { if (originalPlatform) { @@ -107,7 +110,7 @@ describe('CodexRuntimeHomeService', () => { } }) - it('clears a selected WSL managed account when auth.json is missing', async () => { + it('keeps a selected WSL managed home when auth.json is temporarily missing', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) const wslHome = join(testState.userDataDir, 'wsl-home') @@ -161,13 +164,11 @@ describe('CodexRuntimeHomeService', () => { ) expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( - wslRuntimeHomePath + managedHomePath ) - expect(store.updateSettings).toHaveBeenCalledWith({ - activeCodexManagedAccountId: null, - activeCodexManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: null } } - }) - expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(systemAuth) + expect(store.updateSettings).not.toHaveBeenCalled() + expect(existsSync(join(wslRuntimeHomePath, 'auth.json'))).toBe(false) + expect(readFileSync(join(systemCodexHomePath, 'auth.json'), 'utf-8')).toBe(systemAuth) } finally { if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) @@ -175,7 +176,7 @@ describe('CodexRuntimeHomeService', () => { } }) - it('seeds the WSL runtime config with rewritten paths and no system hook trust', async () => { + it('launches WSL system default against its existing config without a runtime seed', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) const wslHome = join(testState.userDataDir, 'wsl-home') @@ -214,21 +215,18 @@ describe('CodexRuntimeHomeService', () => { ) expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( - wslRuntimeHomePath + systemCodexHomePath ) const runtimeConfigPath = join(wslRuntimeHomePath, 'config.toml') - const runtimeConfig = readFileSync(runtimeConfigPath, 'utf-8') - expect(runtimeConfig).toContain( - `model_instructions_file = '${join(systemCodexHomePath, 'instructions.md')}'` - ) - expect(runtimeConfig).toContain('[projects."/home/alice/repo"]') - expect(runtimeConfig).not.toContain('[hooks.state.') + expect(existsSync(runtimeConfigPath)).toBe(false) + const systemConfigPath = join(systemCodexHomePath, 'config.toml') + const systemConfig = readFileSync(systemConfigPath, 'utf-8') + expect(systemConfig).toContain('model_instructions_file = "instructions.md"') + expect(systemConfig).toContain('[hooks.state.') - // Why: WSL runtime configs are seeded once; Codex writes trust into them - // afterwards, so a relaunch must not clobber the seeded file. - writeFileSync(runtimeConfigPath, `${runtimeConfig}\n[projects."/tmp/x"]\n`, 'utf-8') + writeFileSync(systemConfigPath, `${systemConfig}\n[projects."/tmp/x"]\n`, 'utf-8') service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' }) - expect(readFileSync(runtimeConfigPath, 'utf-8')).toContain('[projects."/tmp/x"]') + expect(readFileSync(systemConfigPath, 'utf-8')).toContain('[projects."/tmp/x"]') } finally { if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) @@ -236,26 +234,7 @@ describe('CodexRuntimeHomeService', () => { } }) - it('anchors WSL seed rewrites to the Linux-side home parsed from the UNC source', async () => { - const { prepareWslRuntimeSeedConfig } = await import('./runtime-home-service') - - // Why: real UNC sources cannot back live fs operations in tests, so pin - // the UNC -> Linux-side anchor translation on the extracted seed function. - expect( - prepareWslRuntimeSeedConfig( - 'model_instructions_file = "instructions.md"\n', - '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex' - ) - ).toContain("model_instructions_file = '/home/alice/.codex/instructions.md'") - expect( - prepareWslRuntimeSeedConfig( - 'model_instructions_file = "instructions.md"\n', - '\\\\wsl$\\Ubuntu\\home\\alice\\.codex' - ) - ).toContain("model_instructions_file = '/home/alice/.codex/instructions.md'") - }) - - it('switches WSL accounts by rewriting one stable WSL runtime home', async () => { + it('switches WSL accounts by selecting each account home directly', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) const wslHome = join(testState.userDataDir, 'wsl-home') @@ -317,16 +296,17 @@ describe('CodexRuntimeHomeService', () => { 'home' ) - expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath) - expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(firstAuth) + expect(service.prepareForCodexLaunch(target)).toBe(firstManagedHomePath) + expect(existsSync(join(wslRuntimeHomePath, 'auth.json'))).toBe(false) store.updateSettings({ activeCodexManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'account-2' } } }) service.syncForCurrentSelection(target) - expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath) - expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(secondAuth) + expect(service.prepareForCodexLaunch(target)).toBe(secondManagedHomePath) + expect(readFileSync(join(firstManagedHomePath, 'auth.json'), 'utf-8')).toBe(firstAuth) + expect(readFileSync(join(secondManagedHomePath, 'auth.json'), 'utf-8')).toBe(secondAuth) } finally { if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) @@ -334,7 +314,125 @@ describe('CodexRuntimeHomeService', () => { } }) - it('does not use host auth baseline to accept stale WSL runtime auth', async () => { + it('waits for the legacy drain before completing direct-home launch preparation', async () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const wslHome = join(testState.userDataDir, 'wsl-home') + vi.doMock('../wsl', () => ({ + getDefaultWslDistro: () => 'Ubuntu', + getWslHome: () => wslHome + })) + let finishDrain: (() => void) | undefined + const startLegacyWslRuntimeAuthDrain = vi.fn( + (_options: unknown, _startOptions?: { throwOnFailure?: boolean }) => + new Promise((resolve) => { + finishDrain = resolve + }) + ) + vi.doMock('./legacy-wsl-runtime-auth-drain', () => ({ startLegacyWslRuntimeAuthDrain })) + const startWslCodexSessionBridgeInBackground = vi.fn(() => Promise.resolve()) + vi.doMock('../codex/wsl-codex-session-bridge', () => ({ + startWslCodexSessionBridgeInBackground + })) + vi.doMock('../codex/codex-home-paths', async (importOriginal) => ({ + ...(await importOriginal()), + syncCodexGlobalInstructionsIntoManagedHome: vi.fn() + })) + vi.doMock('../codex/codex-config-mirror', async (importOriginal) => ({ + ...(await importOriginal()), + syncSystemConfigIntoManagedCodexHome: vi.fn() + })) + const managedHomePath = createManagedAuth( + testState.userDataDir, + 'account-1', + createCodexAuthJson('wsl@example.com', 'acct-wsl', 'managed-token') + ) + const store = createStore( + createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'wsl@example.com', + managedHomePath, + managedHomeRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxHomePath: '/home/alice/.local/share/orca/codex-accounts/account-1/home', + providerAccountId: 'acct-wsl', + workspaceLabel: null, + workspaceAccountId: 'acct-wsl', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeCodexManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'account-1' } } + }) + ) + + try { + 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) + expect(startLegacyWslRuntimeAuthDrain.mock.calls[0]?.[1]).toEqual({ throwOnFailure: true }) + expect(startWslCodexSessionBridgeInBackground).not.toHaveBeenCalled() + + finishDrain?.() + await expect(launch).resolves.toBe(managedHomePath) + expect(startWslCodexSessionBridgeInBackground).toHaveBeenCalledTimes(1) + } finally { + vi.doUnmock('../codex/codex-config-mirror') + vi.doUnmock('../codex/codex-home-paths') + vi.doUnmock('../codex/wsl-codex-session-bridge') + vi.doUnmock('./legacy-wsl-runtime-auth-drain') + vi.doUnmock('../wsl') + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + } + }) + + it('preserves legacy auth while draining when pane attribution is unavailable', async () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const wslHome = join(testState.userDataDir, 'wsl-home') + vi.doMock('../wsl', () => ({ + getDefaultWslDistro: () => 'Ubuntu', + getWslHome: () => wslHome + })) + vi.doMock('../codex/codex-pane-account-registry', async (importOriginal) => ({ + ...(await importOriginal()), + hasRecordedLegacyWslCodexPane: () => { + throw new Error('registry unavailable') + } + })) + const startLegacyWslRuntimeAuthDrain = vi.fn(() => Promise.resolve()) + vi.doMock('./legacy-wsl-runtime-auth-drain', () => ({ startLegacyWslRuntimeAuthDrain })) + const store = createStore(createSettings()) + + try { + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + await service.prepareForCodexLaunchAsync({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + + expect(startLegacyWslRuntimeAuthDrain).toHaveBeenCalledWith( + expect.objectContaining({ legacyPanePresent: true }), + { throwOnFailure: true } + ) + } finally { + vi.doUnmock('./legacy-wsl-runtime-auth-drain') + vi.doUnmock('../codex/codex-pane-account-registry') + vi.doUnmock('../wsl') + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + } + }) + + it('ignores stale retired runtime auth when launching a managed WSL account', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) const wslHome = join(testState.userDataDir, 'wsl-home') @@ -342,7 +440,6 @@ describe('CodexRuntimeHomeService', () => { getDefaultWslDistro: () => 'Ubuntu', getWslHome: () => wslHome })) - const hostAuth = createCodexAuthJson('host@example.com', 'acct-host', 'host-token') const wslManagedAuth = createCodexAuthJson( 'wsl@example.com', 'acct-wsl', @@ -355,7 +452,6 @@ describe('CodexRuntimeHomeService', () => { 'runtime-stale', 1_000 ) - const hostManagedHomePath = createManagedAuth(testState.userDataDir, 'host-account', hostAuth) const wslManagedHomePath = createManagedAuth( testState.userDataDir, 'wsl-account', @@ -374,17 +470,6 @@ describe('CodexRuntimeHomeService', () => { const store = createStore( createSettings({ codexManagedAccounts: [ - { - id: 'host-account', - email: 'host@example.com', - managedHomePath: hostManagedHomePath, - providerAccountId: 'acct-host', - workspaceLabel: null, - workspaceAccountId: 'acct-host', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - }, { id: 'wsl-account', email: 'wsl@example.com', @@ -395,14 +480,13 @@ describe('CodexRuntimeHomeService', () => { providerAccountId: 'acct-wsl', workspaceLabel: null, workspaceAccountId: 'acct-wsl', - createdAt: 2, - updatedAt: 2, - lastAuthenticatedAt: 2 + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 } ], - activeCodexManagedAccountId: 'host-account', activeCodexManagedAccountIdsByRuntime: { - host: 'host-account', + host: null, wsl: { Ubuntu: 'wsl-account' } } }) @@ -413,10 +497,10 @@ describe('CodexRuntimeHomeService', () => { const service = new CodexRuntimeHomeService(store as never) expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( - wslRuntimeHomePath + wslManagedHomePath ) expect(readFileSync(join(wslManagedHomePath, 'auth.json'), 'utf-8')).toBe(wslManagedAuth) - expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(wslManagedAuth) + expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(staleWslRuntimeAuth) } finally { if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) @@ -424,104 +508,74 @@ describe('CodexRuntimeHomeService', () => { } }) - it('does not clobber fresh WSL tokens after clearLastWrittenAuthJson', async () => { + it('launches and drains a mounted-drive WSL account through its distro path', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - const wslHome = join(testState.userDataDir, 'wsl-home') - vi.doMock('../wsl', () => ({ - getDefaultWslDistro: () => 'Ubuntu', - getWslHome: () => wslHome - })) - const target = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' } - const originalAuth = createCodexAuthJson('wsl@example.com', 'acct-wsl', 'original', 1_000) - const staleRuntimeAuth = createCodexAuthJson('wsl@example.com', 'acct-wsl', 'stale', 1_500) - const reauthedAuth = createCodexAuthJson('wsl@example.com', 'acct-wsl', 'reauthed', 2_000) - const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', originalAuth) - const managedAuthPath = join(managedHomePath, 'auth.json') - const wslRuntimeHomePath = join( - wslHome, - '.local', - 'share', - 'orca', - 'codex-runtime-home', - 'home' - ) - const runtimeAuthPath = join(wslRuntimeHomePath, 'auth.json') - const store = createStore( - createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'wsl@example.com', - managedHomePath, - managedHomeRuntime: 'wsl', - wslDistro: 'Ubuntu', - wslLinuxHomePath: '/home/alice/.local/share/orca/codex-accounts/account-1/home', - providerAccountId: 'acct-wsl', - workspaceLabel: null, - workspaceAccountId: 'acct-wsl', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountIdsByRuntime: { - host: null, - wsl: { Ubuntu: 'account-1' } - } - }) - ) - - try { - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath) - writeFileSync(runtimeAuthPath, staleRuntimeAuth, 'utf-8') - writeFileSync(managedAuthPath, reauthedAuth, 'utf-8') - - service.clearLastWrittenAuthJson('account-1') - service.syncForCurrentSelection(target) - - expect(readFileSync(managedAuthPath, 'utf-8')).toBe(reauthedAuth) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(reauthedAuth) - } finally { - if (originalPlatform) { - Object.defineProperty(process, 'platform', originalPlatform) - } - } - }) - - it('reads active WSL token refreshes back before restart using the selected distro', async () => { - const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - const wslHome = join(testState.userDataDir, 'wsl-home') - vi.doMock('../wsl', () => ({ - getDefaultWslDistro: () => 'Ubuntu', - getWslHome: () => wslHome - })) - const managedAuth = createCodexAuthJson('wsl@example.com', 'acct-wsl', 'managed', 1_000) - const refreshedAuth = createCodexAuthJson( - 'wsl@example.com', - 'acct-wsl', - 'runtime-refreshed', + const managedAuth = createCodexAuthJson( + 'drive@example.com', + 'acct-drive', + 'drive-refresh', 2_000 ) - const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth) - const managedAuthPath = join(managedHomePath, 'auth.json') + const linuxHomePath = '/mnt/c/Users/alice/orca/codex-accounts/drive-account/home' + vi.doMock('../wsl', () => ({ + getDefaultWslDistro: () => 'Ubuntu', + getWslHome: () => 'C:\\Users\\alice' + })) + vi.doMock('./wsl-codex-auth-batch-reader', async (importOriginal) => ({ + ...(await importOriginal()), + readWslCodexAuths: vi.fn(async (_distro: string, homes: string[]) => + homes.map((home) => + home === linuxHomePath + ? { kind: 'present' as const, contents: managedAuth } + : { kind: 'missing' as const } + ) + ) + })) + let drainGuestHome: string | null = null + let drainDestination: { authContents: string; linuxHomePath: string } | null = null + const drainTasks: Promise[] = [] + vi.doMock('./legacy-wsl-runtime-auth-drain', async (importOriginal) => ({ + ...(await importOriginal()), + startLegacyWslRuntimeAuthDrain: ( + options: Parameters[0] + ) => { + drainGuestHome = options.guestHomeLinuxPath + const task = Promise.resolve(options.resolveDestination(managedAuth)).then( + (destination) => { + drainDestination = destination + } + ) + drainTasks.push(task) + return task + } + })) + const startWslCodexSessionBridgeInBackground = vi.fn(() => Promise.resolve()) + vi.doMock('../codex/wsl-codex-session-bridge', () => ({ + startWslCodexSessionBridgeInBackground, + syncWslCodexSessionsIntoManagedHome: vi.fn(() => Promise.resolve()) + })) + vi.doMock('../codex/codex-home-paths', async (importOriginal) => ({ + ...(await importOriginal()), + syncCodexGlobalInstructionsIntoManagedHome: vi.fn() + })) + vi.doMock('../codex/codex-config-mirror', async (importOriginal) => ({ + ...(await importOriginal()), + syncSystemConfigIntoManagedCodexHome: vi.fn() + })) const store = createStore( createSettings({ codexManagedAccounts: [ { - id: 'account-1', - email: 'wsl@example.com', - managedHomePath, + id: 'drive-account', + email: 'drive@example.com', + managedHomePath: 'C:\\Users\\alice\\orca\\codex-accounts\\drive-account\\home', managedHomeRuntime: 'wsl', - wslDistro: null, - wslLinuxHomePath: '/home/alice/.local/share/orca/codex-accounts/account-1/home', - providerAccountId: 'acct-wsl', + wslDistro: 'Ubuntu', + wslLinuxHomePath: linuxHomePath, + providerAccountId: 'acct-drive', workspaceLabel: null, - workspaceAccountId: 'acct-wsl', + workspaceAccountId: 'acct-drive', createdAt: 1, updatedAt: 1, lastAuthenticatedAt: 1 @@ -529,7 +583,7 @@ describe('CodexRuntimeHomeService', () => { ], activeCodexManagedAccountIdsByRuntime: { host: null, - wsl: { Ubuntu: 'account-1' } + wsl: { Ubuntu: 'drive-account' } } }) ) @@ -537,25 +591,106 @@ describe('CodexRuntimeHomeService', () => { try { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) - const target = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' } - const wslRuntimeHomePath = join( - wslHome, - '.local', - 'share', - 'orca', - 'codex-runtime-home', - 'home' + + expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( + '\\\\wsl.localhost\\Ubuntu\\mnt\\c\\Users\\alice\\orca\\codex-accounts\\drive-account\\home' ) - const runtimeAuthPath = join(wslRuntimeHomePath, 'auth.json') + await Promise.all(drainTasks) - expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath) - writeFileSync(runtimeAuthPath, refreshedAuth, 'utf-8') - - service.syncActiveWslSelectionsBeforeRestart() - - expect(readFileSync(managedAuthPath, 'utf-8')).toBe(refreshedAuth) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(refreshedAuth) + expect(drainGuestHome).toBe('/mnt/c/Users/alice') + expect(drainDestination).toEqual({ authContents: managedAuth, linuxHomePath }) + expect(startWslCodexSessionBridgeInBackground).toHaveBeenCalledWith({ + distro: 'Ubuntu', + systemCodexHomePath: '\\\\wsl.localhost\\Ubuntu\\mnt\\c\\Users\\alice\\.codex', + managedCodexHomePath: + '\\\\wsl.localhost\\Ubuntu\\mnt\\c\\Users\\alice\\orca\\codex-accounts\\drive-account\\home' + }) } finally { + vi.doUnmock('../codex/codex-config-mirror') + vi.doUnmock('../codex/codex-home-paths') + vi.doUnmock('../codex/wsl-codex-session-bridge') + vi.doUnmock('./legacy-wsl-runtime-auth-drain') + vi.doUnmock('./wsl-codex-auth-batch-reader') + vi.doUnmock('../wsl') + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + } + }) + + it('resolves a mounted-drive WSL system home as the legacy drain destination', async () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const systemAuth = createCodexAuthJson( + 'system@example.com', + 'acct-system', + 'system-refresh', + 2_000 + ) + vi.doMock('../wsl', () => ({ + getDefaultWslDistro: () => 'Ubuntu', + getWslHome: () => 'C:\\Users\\alice' + })) + vi.doMock('./wsl-codex-auth-batch-reader', async (importOriginal) => ({ + ...(await importOriginal()), + readWslCodexAuths: vi.fn(async (_distro: string, homes: string[]) => + homes.map((home) => + home === '/mnt/c/Users/alice/.codex' + ? { kind: 'present' as const, contents: systemAuth } + : { kind: 'missing' as const } + ) + ) + })) + let drainDestination: { authContents: string; linuxHomePath: string } | null = null + const drainTasks: Promise[] = [] + vi.doMock('./legacy-wsl-runtime-auth-drain', async (importOriginal) => ({ + ...(await importOriginal()), + startLegacyWslRuntimeAuthDrain: ( + options: Parameters[0] + ) => { + const task = Promise.resolve(options.resolveDestination(systemAuth)).then((destination) => { + drainDestination = destination + }) + drainTasks.push(task) + return task + } + })) + vi.doMock('../codex/wsl-codex-session-bridge', () => ({ + startWslCodexSessionBridgeInBackground: vi.fn(() => Promise.resolve()) + })) + vi.doMock('../codex/codex-home-paths', async (importOriginal) => ({ + ...(await importOriginal()), + syncCodexGlobalInstructionsIntoManagedHome: vi.fn() + })) + vi.doMock('../codex/codex-config-mirror', async (importOriginal) => ({ + ...(await importOriginal()), + syncSystemConfigIntoManagedCodexHome: vi.fn() + })) + const store = createStore( + createSettings({ + activeCodexManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: null } } + }) + ) + + try { + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( + '\\\\wsl.localhost\\Ubuntu\\mnt\\c\\Users\\alice\\.codex' + ) + await Promise.all(drainTasks) + expect(drainDestination).toEqual({ + authContents: systemAuth, + linuxHomePath: '/mnt/c/Users/alice/.codex' + }) + } finally { + vi.doUnmock('../codex/codex-config-mirror') + vi.doUnmock('../codex/codex-home-paths') + vi.doUnmock('../codex/wsl-codex-session-bridge') + vi.doUnmock('./legacy-wsl-runtime-auth-drain') + vi.doUnmock('./wsl-codex-auth-batch-reader') + vi.doUnmock('../wsl') if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) } 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 05cfa12e240..7226ebfd8cb 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 @@ -1,9 +1,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { execFileSync } from 'node:child_process' +import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import type * as WslPaths from '../../shared/wsl-paths' +import type * as CodexConfigMirror from '../codex/codex-config-mirror' +import type * as CodexHomePaths from '../codex/codex-home-paths' +import type * as LegacyWslRuntimeAuthDrain from './legacy-wsl-runtime-auth-drain' +import type * as WslCodexAuthBatchReader from './wsl-codex-auth-batch-reader' import { createSettings } from './runtime-home-settings-test-fixtures' import { + createCodexAuthJson, createManagedAuth, createStore, setupRuntimeHomeTest, @@ -34,62 +40,7 @@ describe('CodexRuntimeHomeService', () => { teardownRuntimeHomeTest() }) - it('builds a valid WSL legacy active-home migration shell command', async () => { - const execFileSyncMock = vi.fn() - vi.doMock('node:child_process', () => ({ execFileSync: execFileSyncMock })) - - try { - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService( - createStore(createSettings()) as never - ) as unknown as { - migrateLegacyWslActiveHomePointer(distro: string, runtimeHomePath: string): void - } - - service.migrateLegacyWslActiveHomePointer( - 'Ubuntu', - '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.local\\share\\orca\\codex-runtime-home\\home' - ) - - expect(execFileSyncMock).toHaveBeenCalledTimes(1) - const firstCall = execFileSyncMock.mock.calls[0] - expect(firstCall).toBeDefined() - const [command, args] = firstCall as [string, string[]] - expect(command).toBe('wsl.exe') - expect(args.slice(0, 5)).toEqual(['-d', 'Ubuntu', '--exec', 'bash', '-lc']) - expect(args).toHaveLength(6) - - const shellCommand = args[5] - expect(shellCommand).toContain( - "if [ ! -e '/home/alice/.local/share/orca/codex-runtime-home/active/wsl/home' ] && [ ! -L '/home/alice/.local/share/orca/codex-runtime-home/active/wsl/home' ]; then :" - ) - expect(shellCommand).toContain( - "elif [ -e '/home/alice/.local/share/orca/codex-runtime-home/active/wsl/home' ] && [ ! -L '/home/alice/.local/share/orca/codex-runtime-home/active/wsl/home' ]; then :" - ) - expect(shellCommand).toContain( - "mkdir -p '/home/alice/.local/share/orca/codex-runtime-home/active/wsl'" - ) - expect(shellCommand).toContain( - "ln -s -- '/home/alice/.local/share/orca/codex-runtime-home/home' '/home/alice/.local/share/orca/codex-runtime-home/active/wsl/home.next-" - ) - expect(shellCommand).toContain( - "mv -Tf -- '/home/alice/.local/share/orca/codex-runtime-home/active/wsl/home.next-" - ) - expect(shellCommand).toContain( - "' '/home/alice/.local/share/orca/codex-runtime-home/active/wsl/home'" - ) - expect(shellCommand).not.toContain('[! -L') - expect(shellCommand).not.toContain('mv -Tf--') - expect(shellCommand).not.toContain('$1') - expect(shellCommand).not.toContain('$2') - expect(shellCommand).not.toContain('$3') - expect(shellCommand).not.toContain('exit 0') - } finally { - vi.doUnmock('node:child_process') - } - }) - - it('starts WSL session bridging after materializing the WSL launch home', async () => { + it('skips WSL session bridging when system default already uses its direct home', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) const startWslCodexSessionBridgeInBackground = vi.fn(() => Promise.resolve()) @@ -124,17 +75,13 @@ describe('CodexRuntimeHomeService', () => { ) expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( - wslRuntimeHomePath + wslSystemHomePath ) - expect(startWslCodexSessionBridgeInBackground).toHaveBeenCalledTimes(1) - expect(startWslCodexSessionBridgeInBackground).toHaveBeenCalledWith({ - distro: 'Ubuntu', - systemCodexHomePath: wslSystemHomePath, - managedCodexHomePath: wslRuntimeHomePath - }) - const runtimeAgentsPath = join(wslRuntimeHomePath, 'AGENTS.md') - expect(readFileSync(runtimeAgentsPath, 'utf-8')).toBe('# WSL instructions\n') - expect(lstatSync(runtimeAgentsPath).isSymbolicLink()).toBe(false) + expect(startWslCodexSessionBridgeInBackground).not.toHaveBeenCalled() + expect(readFileSync(join(wslSystemHomePath, 'AGENTS.md'), 'utf-8')).toBe( + '# WSL instructions\n' + ) + expect(existsSync(join(wslRuntimeHomePath, 'AGENTS.md'))).toBe(false) } finally { vi.doUnmock('../codex/wsl-codex-session-bridge') vi.doUnmock('../wsl') @@ -144,7 +91,7 @@ describe('CodexRuntimeHomeService', () => { } }) - it('promotes WSL in-Codex setting changes on the next Codex launch', async () => { + it('keeps WSL in-Codex setting changes in the direct system home', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) vi.doMock('../codex/wsl-codex-session-bridge', () => ({ @@ -177,32 +124,20 @@ describe('CodexRuntimeHomeService', () => { 'home' ) - // First launch seeds the runtime config and records the per-distro baseline. expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( - wslRuntimeHomePath + join(wslHome, '.codex') ) const baselinePath = join(wslRuntimeHomePath, '.orca-config-settings-baseline.json') - expect(existsSync(baselinePath)).toBe(true) + expect(existsSync(baselinePath)).toBe(false) - // A direct WSL Codex edit wins and is mirrored into Orca's runtime before - // the baseline advances, so later in-Orca changes remain promotable. - const runtimeConfigPath = join(wslRuntimeHomePath, 'config.toml') writeFileSync(wslSystemConfigPath, 'model = "outside-edit"\n', 'utf-8') service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' }) - expect(readFileSync(runtimeConfigPath, 'utf-8')).toBe('model = "outside-edit"\n') - expect(readFileSync(baselinePath, 'utf-8')).toContain('"model": "\\"outside-edit\\""') - - // Codex now persists a /model change inside Orca's reconciled runtime. - writeFileSync( - runtimeConfigPath, - readFileSync(runtimeConfigPath, 'utf-8').replace('model = "outside-edit"', 'model = "o4"'), - 'utf-8' - ) + expect(readFileSync(wslSystemConfigPath, 'utf-8')).toBe('model = "outside-edit"\n') + writeFileSync(wslSystemConfigPath, 'model = "o4"\n', 'utf-8') service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' }) expect(readFileSync(wslSystemConfigPath, 'utf-8')).toBe('model = "o4"\n') - // Baseline advances so the promoted value is not re-promoted forever. - expect(readFileSync(baselinePath, 'utf-8')).toContain('"model": "\\"o4\\""') + expect(existsSync(baselinePath)).toBe(false) } finally { vi.doUnmock('../codex/wsl-codex-session-bridge') vi.doUnmock('../wsl') @@ -236,22 +171,13 @@ describe('CodexRuntimeHomeService', () => { try { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) - const wslRuntimeHomePath = join( - wslHome, - '.local', - 'share', - 'orca', - 'codex-runtime-home', - 'home' - ) - service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' }) expect(startWslCodexSessionBridgeInBackground).toHaveBeenCalledTimes(1) expect(startWslCodexSessionBridgeInBackground).toHaveBeenCalledWith({ distro: 'Ubuntu', systemCodexHomePath: '/home/me/.config/codex', - managedCodexHomePath: wslRuntimeHomePath + managedCodexHomePath: join(wslHome, '.codex') }) } finally { vi.doUnmock('../codex/wsl-codex-session-bridge') @@ -262,7 +188,7 @@ describe('CodexRuntimeHomeService', () => { } }) - it('starts WSL session bridging for the distro used by the materialized runtime home', async () => { + it('starts WSL session bridging for the selected direct account home', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) const startWslCodexSessionBridgeInBackground = vi.fn(() => Promise.resolve()) @@ -270,14 +196,6 @@ describe('CodexRuntimeHomeService', () => { startWslCodexSessionBridgeInBackground })) const wslHome = join(testState.userDataDir, 'debian-wsl-home') - const wslRuntimeHomePath = join( - wslHome, - '.local', - 'share', - 'orca', - 'codex-runtime-home', - 'home' - ) vi.doMock('../wsl', () => ({ getDefaultWslDistro: () => null, getWslHome: (distro: string) => (distro === 'Debian' ? wslHome : null) @@ -287,10 +205,10 @@ describe('CodexRuntimeHomeService', () => { return { ...actual, parseWslUncPath: (candidate: string) => - candidate === wslRuntimeHomePath + candidate.includes('codex-accounts/debian-account/home') ? { distro: 'Debian', - linuxPath: '/home/alice/.local/share/orca/codex-runtime-home/home' + linuxPath: '/home/alice/.local/share/orca/codex-accounts/debian-account/home' } : null } @@ -328,12 +246,12 @@ describe('CodexRuntimeHomeService', () => { const service = new CodexRuntimeHomeService(store as never) expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: null })).toBe( - wslRuntimeHomePath + managedHomePath ) expect(startWslCodexSessionBridgeInBackground).toHaveBeenCalledWith({ distro: 'Debian', systemCodexHomePath: join(wslHome, '.codex'), - managedCodexHomePath: wslRuntimeHomePath + managedCodexHomePath: managedHomePath }) } finally { vi.doUnmock('../codex/wsl-codex-session-bridge') @@ -344,4 +262,251 @@ describe('CodexRuntimeHomeService', () => { } } }) + + it('does not rescan retired sessions after the launch drain bridges them', async () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const startWslCodexSessionBridgeInBackground = vi.fn(() => Promise.resolve()) + vi.doMock('../codex/wsl-codex-session-bridge', () => ({ + startWslCodexSessionBridgeInBackground + })) + const wslHome = join(testState.userDataDir, 'ubuntu-home') + vi.doMock('../wsl', () => ({ + getDefaultWslDistro: () => 'Ubuntu', + getWslHome: () => wslHome + })) + const managedHomePath = join(wslHome, '.local', 'share', 'orca', 'codex-accounts', 'a', 'home') + const retiredBridgeRuns = vi.fn() + vi.doMock('./legacy-wsl-runtime-auth-drain', async (importOriginal) => ({ + ...(await importOriginal()), + startLegacyWslRuntimeAuthDrain: async (options: { + onDestinationAuthorized?: (destination: { + authContents: string + linuxHomePath: string + }) => void + }) => { + retiredBridgeRuns() + options.onDestinationAuthorized?.({ + authContents: '{"account":"a"}\n', + linuxHomePath: managedHomePath + }) + } + })) + const store = createStore( + createSettings({ + codexManagedAccounts: [ + { + id: 'a', + email: 'a@example.com', + managedHomePath, + managedHomeRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxHomePath: managedHomePath, + providerAccountId: 'acct-a', + workspaceLabel: null, + workspaceAccountId: 'acct-a', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeCodexManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'a' } } + }) + ) + + try { + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + await service.prepareForCodexLaunchAsync({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + + expect(retiredBridgeRuns).toHaveBeenCalledTimes(1) + expect(startWslCodexSessionBridgeInBackground).toHaveBeenCalledExactlyOnceWith({ + distro: 'Ubuntu', + systemCodexHomePath: join(wslHome, '.codex'), + managedCodexHomePath: managedHomePath + }) + } finally { + vi.doUnmock('./legacy-wsl-runtime-auth-drain') + vi.doUnmock('../codex/wsl-codex-session-bridge') + vi.doUnmock('../wsl') + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + } + }) + + it.skipIf(process.platform === 'win32')( + 'links retired WSL sessions only into the auth-matched direct home', + async () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const guestHome = join(testState.userDataDir, 'ubuntu-home') + const wslHome = `\\\\wsl.localhost\\Ubuntu${guestHome.replaceAll('/', '\\')}` + vi.doMock('../wsl', () => ({ + getDefaultWslDistro: () => 'Ubuntu', + getWslHome: () => wslHome + })) + const ownerAuth = createCodexAuthJson( + 'owner@example.com', + 'acct-owner', + 'owner-refresh', + 2_000 + ) + const selectedAuth = createCodexAuthJson( + 'selected@example.com', + 'acct-selected', + 'selected-refresh', + 2_000 + ) + const ownerHome = createManagedAuth(testState.userDataDir, 'owner', ownerAuth) + const selectedHome = createManagedAuth(testState.userDataDir, 'selected', selectedAuth) + vi.doMock('./wsl-codex-auth-batch-reader', async (importOriginal) => ({ + ...(await importOriginal()), + readWslCodexAuths: vi.fn(async (_distro: string, homes: string[]) => + homes.map((home) => { + if (home === ownerHome) { + return { kind: 'present' as const, contents: ownerAuth } + } + if (home === selectedHome) { + return { kind: 'present' as const, contents: selectedAuth } + } + return { kind: 'missing' as const } + }) + ) + })) + const drainTasks: Promise[] = [] + vi.doMock('../wsl/wsl-runner', () => ({ + runWslProcess: vi.fn( + async (options: { args?: string[]; script: string; shell?: 'bash' }) => { + try { + const stdout = execFileSync(options.shell === 'bash' ? '/bin/bash' : '/bin/sh', [ + '-c', + options.script, + options.shell ?? 'sh', + ...(options.args ?? []) + ]).toString() + return { + code: 0, + stdout, + stderr: '', + timedOut: false, + environmentResolved: true + } + } catch (error) { + return { + code: (error as { status?: number }).status ?? 1, + stdout: (error as { stdout?: Buffer }).stdout?.toString() ?? '', + stderr: (error as { stderr?: Buffer }).stderr?.toString() ?? '', + timedOut: false, + environmentResolved: true + } + } + } + ) + })) + vi.doMock('./legacy-wsl-runtime-auth-drain', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + startLegacyWslRuntimeAuthDrain: ( + options: Parameters[0] + ) => { + const task = actual.startLegacyWslRuntimeAuthDrain(options) + drainTasks.push(task) + return task + } + } + }) + vi.doMock('../codex/codex-home-paths', async (importOriginal) => ({ + ...(await importOriginal()), + syncCodexGlobalInstructionsIntoManagedHome: vi.fn() + })) + vi.doMock('../codex/codex-config-mirror', async (importOriginal) => ({ + ...(await importOriginal()), + syncSystemConfigIntoManagedCodexHome: vi.fn() + })) + const retiredHome = join(guestHome, '.local', 'share', 'orca', 'codex-runtime-home', 'home') + const relativeSessionPath = join('sessions', '2026', '08', '26', 'retired.jsonl') + const retiredSessionPath = join(retiredHome, relativeSessionPath) + mkdirSync(join(retiredSessionPath, '..'), { recursive: true }) + writeFileSync(join(retiredHome, 'auth.json'), ownerAuth, 'utf-8') + writeFileSync(retiredSessionPath, '{"session":"retired"}\n', 'utf-8') + const blockedTargetDirectory = join(ownerHome, 'sessions', '2026') + mkdirSync(join(blockedTargetDirectory, '..'), { recursive: true }) + writeFileSync(blockedTargetDirectory, 'not-a-directory\n', 'utf-8') + const store = createStore( + createSettings({ + codexManagedAccounts: [ + { + id: 'owner', + email: 'owner@example.com', + managedHomePath: ownerHome, + managedHomeRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxHomePath: ownerHome, + providerAccountId: 'acct-owner', + workspaceLabel: null, + workspaceAccountId: 'acct-owner', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + }, + { + id: 'selected', + email: 'selected@example.com', + managedHomePath: selectedHome, + managedHomeRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxHomePath: selectedHome, + providerAccountId: 'acct-selected', + workspaceLabel: null, + workspaceAccountId: 'acct-selected', + createdAt: 2, + updatedAt: 2, + lastAuthenticatedAt: 2 + } + ], + activeCodexManagedAccountIdsByRuntime: { + host: null, + wsl: { Ubuntu: 'selected' } + } + }) + ) + + try { + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + await Promise.all(drainTasks) + + const linkedSessionPath = join(ownerHome, relativeSessionPath) + expect(existsSync(linkedSessionPath)).toBe(false) + rmSync(blockedTargetDirectory) + + service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + await Promise.all(drainTasks) + expect(readFileSync(linkedSessionPath, 'utf-8')).toBe('{"session":"retired"}\n') + expect(statSync(linkedSessionPath).ino).toBe(statSync(retiredSessionPath).ino) + expect(existsSync(join(selectedHome, relativeSessionPath))).toBe(false) + + rmSync(linkedSessionPath) + rmSync(retiredHome, { recursive: true }) + service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + await Promise.all(drainTasks) + expect(existsSync(linkedSessionPath)).toBe(false) + } finally { + vi.doUnmock('../codex/wsl-codex-session-bridge') + vi.doUnmock('../codex/codex-config-mirror') + vi.doUnmock('../codex/codex-home-paths') + vi.doUnmock('./legacy-wsl-runtime-auth-drain') + vi.doUnmock('./wsl-codex-auth-batch-reader') + vi.doUnmock('../wsl/wsl-runner') + vi.doUnmock('../wsl') + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + } + } + ) }) diff --git a/src/main/codex-accounts/runtime-home-wsl-system-default.test.ts b/src/main/codex-accounts/runtime-home-wsl-system-default.test.ts index a1ad86a0a5f..25bd2d5d75e 100644 --- a/src/main/codex-accounts/runtime-home-wsl-system-default.test.ts +++ b/src/main/codex-accounts/runtime-home-wsl-system-default.test.ts @@ -53,15 +53,6 @@ describe('CodexRuntimeHomeService', () => { try { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) - const syncWslRuntime = vi.spyOn( - service as unknown as { - syncWslRuntimeForCurrentSelection: (target: { - runtime: 'wsl' - wslDistro?: string | null - }) => string | null - }, - 'syncWslRuntimeForCurrentSelection' - ) const target = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' } const expectedHome = join(wslHome, '.codex') @@ -73,7 +64,6 @@ describe('CodexRuntimeHomeService', () => { kind: 'ready', codexHomePath: expectedHome }) - expect(syncWslRuntime).not.toHaveBeenCalled() } finally { if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) @@ -93,15 +83,6 @@ describe('CodexRuntimeHomeService', () => { const debianAuth = createCodexAuthJson('debian@example.com', 'acct-debian', 'debian-token') const ubuntuHomePath = createManagedAuth(testState.userDataDir, 'ubuntu-account', ubuntuAuth) const debianHomePath = createManagedAuth(testState.userDataDir, 'debian-account', debianAuth) - const runtimeAuthPath = join( - wslHome, - '.local', - 'share', - 'orca', - 'codex-runtime-home', - 'home', - 'auth.json' - ) const store = createStore( createSettings({ codexManagedAccounts: [ @@ -147,9 +128,9 @@ describe('CodexRuntimeHomeService', () => { expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: null })).toEqual({ kind: 'ready', - codexHomePath: join(wslHome, '.local', 'share', 'orca', 'codex-runtime-home', 'home') + codexHomePath: ubuntuHomePath }) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(ubuntuAuth) + expect(readFileSync(join(ubuntuHomePath, 'auth.json'), 'utf-8')).toBe(ubuntuAuth) } finally { if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) @@ -228,7 +209,7 @@ describe('CodexRuntimeHomeService', () => { } }) - it('reads WSL system-default token refreshes back to WSL system auth', async () => { + it('keeps WSL system-default token refreshes in its direct home', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) const wslHome = join(testState.userDataDir, 'wsl-home') @@ -257,21 +238,11 @@ describe('CodexRuntimeHomeService', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) const target = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' } - const wslRuntimeHomePath = join( - wslHome, - '.local', - 'share', - 'orca', - 'codex-runtime-home', - 'home' - ) + expect(service.prepareForCodexLaunch(target)).toBe(systemCodexHomePath) + writeFileSync(join(systemCodexHomePath, 'auth.json'), refreshedAuth, 'utf-8') - expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath) - writeFileSync(join(wslRuntimeHomePath, 'auth.json'), refreshedAuth, 'utf-8') - - expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath) + expect(service.prepareForCodexLaunch(target)).toBe(systemCodexHomePath) expect(readFileSync(join(systemCodexHomePath, 'auth.json'), 'utf-8')).toBe(refreshedAuth) - expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(refreshedAuth) } finally { if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) @@ -279,7 +250,7 @@ describe('CodexRuntimeHomeService', () => { } }) - it('preserves WSL system-default token refreshes after app restart', async () => { + it('does not overwrite direct WSL system auth from the retired runtime on restart', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) const wslHome = join(testState.userDataDir, 'wsl-home') @@ -319,8 +290,8 @@ describe('CodexRuntimeHomeService', () => { const service = new CodexRuntimeHomeService(store as never) const target = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' } - expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath) - expect(readFileSync(join(systemCodexHomePath, 'auth.json'), 'utf-8')).toBe(refreshedAuth) + expect(service.prepareForCodexLaunch(target)).toBe(systemCodexHomePath) + expect(readFileSync(join(systemCodexHomePath, 'auth.json'), 'utf-8')).toBe(systemAuth) expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(refreshedAuth) } finally { if (originalPlatform) { @@ -362,7 +333,7 @@ describe('CodexRuntimeHomeService', () => { expect(syncConfig).toHaveBeenCalledWith({ runtimeHomePath: join(testState.userDataDir, 'runtime-home'), - systemHomePath: 'C:\\Users\\alice/.codex', + systemHomePath: '\\\\wsl.localhost\\Ubuntu\\mnt\\c\\Users\\alice\\.codex', systemConfigDir: '/mnt/c/Users/alice/.codex' }) } finally { diff --git a/src/main/codex-accounts/wsl-codex-auth-batch-reader.ts b/src/main/codex-accounts/wsl-codex-auth-batch-reader.ts new file mode 100644 index 00000000000..40870fcd5ef --- /dev/null +++ b/src/main/codex-accounts/wsl-codex-auth-batch-reader.ts @@ -0,0 +1,70 @@ +import { runWslProcess } from '../wsl/wsl-runner' + +export type WslCodexAuthRead = + | { kind: 'missing' | 'unreadable' } + | { kind: 'present'; contents: string } + +export async function readWslCodexAuths( + distro: string, + linuxHomePaths: readonly string[] +): Promise { + if (linuxHomePaths.length === 0) { + return [] + } + const result = await runWslProcess({ + distro, + loginPath: 'none', + script: READ_AUTHS_SCRIPT, + args: linuxHomePaths, + timeoutMs: 5_000, + maxOutputBytes: 2 * 1024 * 1024 + }) + if (result.code !== 0 || result.timedOut) { + return linuxHomePaths.map(() => ({ kind: 'unreadable' })) + } + const rows = result.stdout.split('\n') + return linuxHomePaths.map((_, index) => parseAuthReadRow(rows[index] ?? '')) +} + +export function decodeWslBase64Payload(encoded: string): string | null { + try { + const decoded = Buffer.from(encoded, 'base64') + const canonical = decoded.toString('base64').replace(/=+$/, '') + return canonical === encoded.replace(/=+$/, '') ? decoded.toString('utf8') : null + } catch { + return null + } +} + +function parseAuthReadRow(row: string): WslCodexAuthRead { + if (row === 'missing' || row === 'unreadable') { + return { kind: row } + } + if (!row.startsWith('present:')) { + return { kind: 'unreadable' } + } + const contents = decodeWslBase64Payload(row.slice('present:'.length)) + return contents === null ? { kind: 'unreadable' } : { kind: 'present', contents } +} + +const READ_AUTHS_SCRIPT = ` +set -eu +for home_path in "$@"; do + auth_path="$home_path/auth.json" + if [ ! -f "$auth_path" ]; then + if [ ! -e "$auth_path" ] && [ ! -L "$auth_path" ]; then + printf 'missing\n' + else + printf 'unreadable\n' + fi + continue + fi + if encoded=$(base64 < "$auth_path"); then + printf 'present:' + printf '%s' "$encoded" | tr -d '\n' + printf '\n' + else + printf 'unreadable\n' + fi +done +` diff --git a/src/main/codex/codex-hook-service-implementation.ts b/src/main/codex/codex-hook-service-implementation.ts index a5c40495bd6..af470144958 100644 --- a/src/main/codex/codex-hook-service-implementation.ts +++ b/src/main/codex/codex-hook-service-implementation.ts @@ -154,6 +154,25 @@ export class CodexHookService { return install } + async prepareRuntimeHomeForLaunch( + runtimeHomePath: string | null | undefined, + target: CodexWslRuntimeHookTarget | undefined, + hooksEnabled: boolean + ): Promise { + if (hooksEnabled) { + // Why: a managed account's launch home is its self-contained CODEX_HOME, + // so hooks/trust must install there rather than the shared mirror. + return ( + (await this.installForRuntimeHomeSerialized(runtimeHomePath, target)) ?? + (await this.install(runtimeHomePath ?? undefined)) + ) + } + return ( + this.refreshRuntimeUserHooksForRuntimeHome(runtimeHomePath, target) ?? + (await this.refreshRuntimeUserHooks(runtimeHomePath ?? undefined)) + ) + } + refreshRuntimeUserHooksForRuntimeHome( runtimeHomePath: string | null | undefined, target?: CodexWslRuntimeHookTarget 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 449c11ba711..ebad8632676 100644 --- a/src/main/codex/codex-pane-account-registry.ts +++ b/src/main/codex/codex-pane-account-registry.ts @@ -29,6 +29,7 @@ export type { */ let cachedRegistry: CodexPaneAccountRegistryFile | null = null +let cachedRegistryIsAuthoritative = true function getRegistryPath(): string { return join(getOrcaUserDataPath(), 'codex-pane-accounts.json') @@ -58,7 +59,12 @@ function readRegistryOrNull(): CodexPaneAccountRegistryFile | null { // Why: a corrupt registry still degrades to empty and IS cached — rebuilding // unparseable state is the intent, and re-reading it every call would only // repeat the parse failure. - cachedRegistry = parseRegistry(parseRegistryJson(rawRegistry)) + const parsedRegistry = parseRegistryJson(rawRegistry) + cachedRegistryIsAuthoritative = isAuthoritativeRegistry(parsedRegistry) + cachedRegistry = parseRegistry(parsedRegistry) + if (!cachedRegistryIsAuthoritative) { + cachedRegistry.legacyWslAttributionUnknown = true + } return cachedRegistry } @@ -85,12 +91,30 @@ function readRegistry(): CodexPaneAccountRegistryFile { function readRegistryOrThrow(): CodexPaneAccountRegistryFile { mutations.flush() const registry = readRegistryOrNull() - if (!registry) { + if (!registry || !cachedRegistryIsAuthoritative) { throw new Error('Codex pane account registry could not be read') } return registry } +function isAuthoritativeRegistry(parsed: unknown): boolean { + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + 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) && + (legacyWslAttributionUnknown === undefined || legacyWslAttributionUnknown === true) + ) +} + function parseRegistry(parsed: unknown): CodexPaneAccountRegistryFile { const empty: CodexPaneAccountRegistryFile = { version: 2, panes: {} } if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { @@ -100,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] = { @@ -147,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') ) } @@ -172,6 +200,7 @@ function writeRegistry(registry: CodexPaneAccountRegistryFile): boolean { mode: 0o600 }) renameSync(temporaryPath, registryPath) + cachedRegistryIsAuthoritative = true return true } catch (error) { // Why: this record only powers a restart hint; losing it must never break a @@ -266,6 +295,41 @@ 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 { + 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') + ) + ) +} + +function wslSelectionKeysMatch(left: string, right: string): boolean { + return ( + left.startsWith('wsl:') && + right.startsWith('wsl:') && + left.slice('wsl:'.length).toLowerCase() === right.slice('wsl:'.length).toLowerCase() + ) +} + +/** True when startup should reconcile a retained legacy WSL record with daemon inventory. */ +export function hasAnyRecordedLegacyWslCodexPane(): boolean { + const registry = readRegistry() + return ( + Boolean(registry.legacyWslAttributionUnknown) || + Object.values(registry.panes).some( + (record) => + record.selectionKey.startsWith('wsl:') && + (record.homeRoute === undefined || record.homeRoute === 'wsl-home') + ) + ) +} + /** True when startup may need to repair hooks for a retained managed host pane. */ export function hasRecordedManagedHostCodexPane(): boolean { return Object.values(readRegistry().panes).some( @@ -290,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] @@ -302,6 +373,7 @@ export function reconcileCodexPaneAccountsWithLivePtys(livePtyIds: readonly stri export const _internals = { resetCache: (): void => { cachedRegistry = null + cachedRegistryIsAuthoritative = true mutations.reset() } } diff --git a/src/main/codex/codex-pane-launch-account.test.ts b/src/main/codex/codex-pane-launch-account.test.ts index 456b9bc08be..9d28d372ece 100644 --- a/src/main/codex/codex-pane-launch-account.test.ts +++ b/src/main/codex/codex-pane-launch-account.test.ts @@ -202,7 +202,35 @@ describe('resolveCodexPaneLaunchAccount', () => { ).toEqual({ selectionKey: 'wsl:Ubuntu', accountId: 'wsl-account', - homeRoute: 'wsl-home' + homeRoute: 'account-home' + }) + }) + + it('attributes a mounted-drive WSL launch through its distro UNC spelling', () => { + const account = managedAccount({ + id: 'drive-account', + managedHomePath: 'C:\\Users\\u\\orca\\codex-accounts\\drive-account\\home', + managedHomeRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxHomePath: '/mnt/c/Users/u/orca/codex-accounts/drive-account/home' + }) + const args = { + launchCodexHomePath: + '\\\\wsl.localhost\\Ubuntu\\mnt\\c\\Users\\u\\orca\\codex-accounts\\drive-account\\home', + systemCodexHomePath: SYSTEM_HOME, + settings: settings({ wsl: { Ubuntu: 'drive-account' }, accounts: [account] }), + target: { runtime: 'wsl' as const, wslDistro: 'Ubuntu' } + } + + expect(resolveCodexPaneLaunchAccount({ ...args, pinnedByResume: false })).toEqual({ + selectionKey: 'wsl:Ubuntu', + accountId: 'drive-account', + homeRoute: 'account-home' + }) + expect(resolveCodexPaneLaunchAccount({ ...args, pinnedByResume: true })).toEqual({ + selectionKey: 'wsl:Ubuntu', + accountId: 'drive-account', + homeRoute: 'account-home' }) }) diff --git a/src/main/codex/codex-pane-launch-account.ts b/src/main/codex/codex-pane-launch-account.ts index b99700da081..2ceac661e4e 100644 --- a/src/main/codex/codex-pane-launch-account.ts +++ b/src/main/codex/codex-pane-launch-account.ts @@ -1,5 +1,6 @@ import type { GlobalSettings } from '../../shared/global-settings-types' import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' +import { parseWslUncPath } from '../../shared/wsl-paths' import { getCodexSelectionLaneKey, getCodexSelectionTargetForAccount, @@ -80,9 +81,6 @@ function resolveCodexPaneHomeRoute(args: { settings: CodexPaneLaunchAccountSettings target: CodexAccountSelectionTarget }): CodexPaneHomeRoute { - if (args.target.runtime === 'wsl') { - return 'wsl-home' - } if ( !args.launchCodexHomePath || normalizeRuntimePathForComparison(args.launchCodexHomePath) === @@ -90,13 +88,19 @@ function resolveCodexPaneHomeRoute(args: { ) { return 'real-home' } - const launchHome = normalizeRuntimePathForComparison(args.launchCodexHomePath) - const accountOwnsHome = args.settings.codexManagedAccounts?.some( - (account) => - getCodexSelectionTargetForAccount(account).runtime === 'host' && - normalizeRuntimePathForComparison(account.managedHomePath) === launchHome + const launchHomePath = args.launchCodexHomePath + const accountOwnsHome = args.settings.codexManagedAccounts?.some((account) => + accountOwnsCodexHome(account, args.target, launchHomePath) ) - return accountOwnsHome ? 'account-home' : 'shared-home' + if (accountOwnsHome) { + return 'account-home' + } + if (args.target.runtime === 'wsl') { + return parseWslUncPath(args.launchCodexHomePath)?.linuxPath.endsWith('/.codex') + ? 'real-home' + : 'wsl-home' + } + return 'shared-home' } /** undefined when no account owns the home; null means the system-default account. */ @@ -110,17 +114,15 @@ function resolveCodexHomeOwnerAccountId(args: { if (!args.launchCodexHomePath) { return null } - const launchHome = normalizeRuntimePathForComparison(args.launchCodexHomePath) - if (launchHome === normalizeRuntimePathForComparison(args.systemCodexHomePath)) { + if ( + normalizeRuntimePathForComparison(args.launchCodexHomePath) === + normalizeRuntimePathForComparison(args.systemCodexHomePath) + ) { return null } - const laneKey = getCodexSelectionLaneKey(args.target) - const owner = args.settings.codexManagedAccounts?.find( - (account) => - // Why: a WSL pane resolves its account from its own per-distro lane, so a - // host account's home must never answer for it (and vice versa). - getCodexSelectionLaneKey(getCodexSelectionTargetForAccount(account)) === laneKey && - normalizeRuntimePathForComparison(account.managedHomePath) === launchHome + const launchHomePath = args.launchCodexHomePath + const owner = args.settings.codexManagedAccounts?.find((account) => + accountOwnsCodexHome(account, args.target, launchHomePath) ) // Why: an unowned home cannot be named, and naming the account a pane is stuck // on is the prompt's whole job — so decline rather than guess. A wrong notice @@ -130,3 +132,34 @@ function resolveCodexHomeOwnerAccountId(args: { // still unnameable, so that cohort stays unreported. return owner ? owner.id : undefined } + +function accountOwnsCodexHome( + account: NonNullable[number], + target: CodexAccountSelectionTarget, + launchHomePath: string +): boolean { + // Why: a WSL pane resolves its account from its own per-distro lane, so a + // host account's home must never answer for it (and vice versa). + if ( + getCodexSelectionLaneKey(getCodexSelectionTargetForAccount(account)) !== + getCodexSelectionLaneKey(target) + ) { + return false + } + if ( + normalizeRuntimePathForComparison(account.managedHomePath) === + normalizeRuntimePathForComparison(launchHomePath) + ) { + return true + } + const launchWslHome = target.runtime === 'wsl' ? parseWslUncPath(launchHomePath) : null + const accountDistro = account.wslDistro?.trim() + const accountLinuxHome = account.wslLinuxHomePath?.trim() + return Boolean( + launchWslHome && + accountDistro && + accountLinuxHome && + launchWslHome.distro.toLowerCase() === accountDistro.toLowerCase() && + launchWslHome.linuxPath === accountLinuxHome + ) +} diff --git a/src/main/codex/codex-stale-pane-accounts.test.ts b/src/main/codex/codex-stale-pane-accounts.test.ts index bf2f7e5b5ea..027d059f342 100644 --- a/src/main/codex/codex-stale-pane-accounts.test.ts +++ b/src/main/codex/codex-stale-pane-accounts.test.ts @@ -7,7 +7,9 @@ import { _internals, forgetCodexPaneAccount, getCodexPaneAccount, + hasAnyRecordedLegacyWslCodexPane, hasRecordedLegacySharedCodexPane, + hasRecordedLegacyWslCodexPane, hasRecordedManagedHostCodexPane, isCodexPaneHomeRouteProvenAwayFromSharedHome, reconcileCodexPaneAccountsWithLivePtys, @@ -138,6 +140,53 @@ describe('codex pane account registry', () => { expect(hasRecordedLegacySharedCodexPane()).toBe(true) }) + it('identifies only legacy runtime-home panes on the requested WSL lane', () => { + recordCodexPaneAccount('pty-legacy', { + selectionKey: 'wsl:Ubuntu', + accountId: 'account-old', + homeRoute: 'wsl-home' + }) + recordCodexPaneAccount('pty-direct', { + selectionKey: 'wsl:Ubuntu', + accountId: 'account-new', + homeRoute: 'account-home' + }) + recordCodexPaneAccount('pty-other-distro', { + selectionKey: 'wsl:Debian', + accountId: 'account-debian', + homeRoute: 'wsl-home' + }) + recordCodexPaneAccount('pty-default', { + selectionKey: 'wsl:__default__', + accountId: null, + homeRoute: 'wsl-home' + }) + + expect(hasRecordedLegacyWslCodexPane('wsl:Ubuntu')).toBe(true) + expect(hasRecordedLegacyWslCodexPane('wsl:ubuntu')).toBe(true) + forgetCodexPaneAccount('pty-legacy') + expect(hasRecordedLegacyWslCodexPane('wsl:Ubuntu')).toBe(true) + forgetCodexPaneAccount('pty-default') + expect(hasRecordedLegacyWslCodexPane('wsl:Ubuntu')).toBe(false) + expect(hasRecordedLegacyWslCodexPane('wsl:Debian')).toBe(true) + }) + + it('requests daemon reconciliation for WSL-only legacy records', () => { + recordCodexPaneAccount('pty-direct', { + selectionKey: 'wsl:Ubuntu', + accountId: 'account-new', + homeRoute: 'account-home' + }) + expect(hasAnyRecordedLegacyWslCodexPane()).toBe(false) + + recordCodexPaneAccount('pty-legacy', { + selectionKey: 'wsl:Ubuntu', + accountId: 'account-old', + homeRoute: 'wsl-home' + }) + expect(hasAnyRecordedLegacyWslCodexPane()).toBe(true) + }) + it('requests startup inventory only for managed host panes', () => { recordCodexPaneAccount('pty-real', { selectionKey: 'host', @@ -240,6 +289,44 @@ describe('codex pane account registry', () => { expect(getCodexPaneAccount('pty-1')).toEqual({ selectionKey: 'host', accountId: 'account-a' }) }) + it.each([ + ['unparseable JSON', '{ not json'], + ['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() + + 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'), diff --git a/src/main/codex/hook-service-wsl-runtime.test.ts b/src/main/codex/hook-service-wsl-runtime.test.ts index 05baab50912..ba03d835231 100644 --- a/src/main/codex/hook-service-wsl-runtime.test.ts +++ b/src/main/codex/hook-service-wsl-runtime.test.ts @@ -83,6 +83,39 @@ function expectedManagedCommand(scriptPath: string): string { } describe('Codex WSL runtime hook install', () => { + it('coalesces launch installs for one home without blocking independent homes', async () => { + const service = new CodexHookService() + const releases: (() => void)[] = [] + const started: string[] = [] + vi.spyOn(service, 'installForRuntimeHome').mockImplementation(async (runtimeHomePath) => { + if (!runtimeHomePath) { + throw new Error('expected a runtime home') + } + started.push(runtimeHomePath) + await new Promise((resolve) => releases.push(resolve)) + return { + agent: 'codex', + state: 'installed', + configPath: `${runtimeHomePath}\\hooks.json`, + managedHooksPresent: true, + detail: null + } + }) + const firstHome = '\\\\wsl$\\Ubuntu\\home\\Alice\\.codex' + const alias = firstHome.replace('\\\\wsl$', '\\\\wsl.localhost') + const independent = firstHome.replace('\\Alice\\', '\\Bob\\') + const target = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' } + + const first = service.prepareRuntimeHomeForLaunch(firstHome, target, true) + const second = service.prepareRuntimeHomeForLaunch(alias, target, true) + const third = service.prepareRuntimeHomeForLaunch(independent, target, true) + await vi.waitFor(() => expect(started).toEqual([firstHome, independent])) + + releases.splice(0).forEach((release) => release()) + await Promise.all([first, second, third]) + expect(started).toEqual([firstHome, independent]) + }) + it('coalesces aliases of one runtime home without blocking independent homes', async () => { const service = new CodexHookService() const releases: (() => void)[] = [] diff --git a/src/main/codex/wsl-codex-session-bridge-script.ts b/src/main/codex/wsl-codex-session-bridge-script.ts new file mode 100644 index 00000000000..77f52e3dd4e --- /dev/null +++ b/src/main/codex/wsl-codex-session-bridge-script.ts @@ -0,0 +1,122 @@ +import { quotePosixShell } from '../../shared/wsl-login-shell-command' + +export const WSL_SESSION_BRIDGE_TIMEOUT_MS = 30_000 + +const WSL_CODEX_SESSION_BRIDGE_BODY = [ + 'set -u', + 'rollback_manifest=${3-}', + 'rollback_stage_root=${4-}', + 'scan_scope=${5-full}', + 'scan_start=${6-}', + 'scan_end=${7-}', + 'if [ -n "$rollback_manifest" ]; then', + ' [ -n "$rollback_stage_root" ] || exit 1', + ' mkdir -p -- "$rollback_stage_root" || exit 1', + ' : > "$rollback_manifest" || exit 1', + 'fi', + 'scanned_files=0', + 'linked_files=0', + 'bridge_failed=0', + 'if [ ! -d "$source_sessions_root" ]; then', + ` printf '{"scannedFiles":0,"linkedFiles":0}\\n'`, + ' exit 0', + 'fi', + 'file_list=$(mktemp) || exit 1', + 'day_list="$file_list.days"', + 'trap \'rm -f -- "$file_list" "$day_list"\' EXIT HUP INT TERM', + 'case "$scan_scope" in', + ` full) find "$source_sessions_root" -type f -name '*.jsonl' -print0 > "$file_list" || exit 1 ;;`, + ' recent)', + ' case "$scan_start" in ????/??/??) ;; *) exit 1 ;; esac', + ' case "$scan_end" in ????/??/??) ;; *) exit 1 ;; esac', + ' if [[ "$scan_start" > "$scan_end" ]]; then', + ` find "$source_sessions_root" -type f -name '*.jsonl' -print0 > "$file_list" || exit 1`, + ' else', + ' : > "$file_list" || exit 1', + ' find "$source_sessions_root" -mindepth 3 -maxdepth 3 -type d -print0 > "$day_list" || exit 1', + " while IFS= read -r -d '' session_day_root; do", + ' session_day=${session_day_root#"$source_sessions_root"/}', + ' case "$session_day" in ????/??/??) ;; *) continue ;; esac', + ' [[ "$session_day" < "$scan_start" ]] && continue', + ' find "$session_day_root" -maxdepth 1 -type f -name \'*.jsonl\' -print0 >> "$file_list" || exit 1', + ' done < "$day_list"', + ' fi', + ' ;;', + ' *) exit 1 ;;', + 'esac', + "while IFS= read -r -d '' source_file; do", + ' scanned_files=$((scanned_files + 1))', + ' relative_path=${source_file#"$source_sessions_root"/}', + ' target_file="$managed_sessions_root/$relative_path"', + ' if [ -e "$target_file" ] || [ -L "$target_file" ]; then', + ' continue', + ' fi', + ' target_dir=${target_file%/*}', + ' if ! mkdir -p -- "$target_dir"; then', + ' bridge_failed=1', + ' continue', + ' fi', + ' link_source="$source_file"', + ' if [ -n "$rollback_manifest" ]; then', + ' staged_file="$rollback_stage_root/$relative_path"', + ' staged_dir=${staged_file%/*}', + ' if ! mkdir -p -- "$staged_dir" || ! ln -- "$source_file" "$staged_file"; then', + ' bridge_failed=1', + ' continue', + ' fi', + ' target_stage="$target_file.orca-bridge-$$"', + ' if [ -e "$target_stage" ] || [ -L "$target_stage" ]; then', + ' bridge_failed=1', + ' continue', + ' fi', + ' if ! ln -- "$staged_file" "$target_stage"; then', + ' if ! cp -- "$staged_file" "$target_stage" || ! cmp -s -- "$staged_file" "$target_stage"; then', + ' rm -f -- "$target_stage"', + ' bridge_failed=1', + ' continue', + ' fi', + ' fi', + ' if ! printf \'%s\\0%s\\0\' "$target_stage" "$target_file" >> "$rollback_manifest"; then', + ' rm -f -- "$target_stage"', + ' bridge_failed=1', + ' continue', + ' fi', + ' link_source="$target_stage"', + ' fi', + // Codex resume ignores symlinked JSONL, so create links inside the distro. + ' if ln -- "$link_source" "$target_file"; then', + ' linked_files=$((linked_files + 1))', + ' elif [ ! -e "$target_file" ] && [ ! -L "$target_file" ]; then', + ' if [ -n "$rollback_manifest" ]; then', + ' bridge_failed=1', + ' else', + ' target_stage="$target_file.orca-bridge-$$"', + ' if [ ! -e "$target_stage" ] && [ ! -L "$target_stage" ] && cp -- "$source_file" "$target_stage" && cmp -s -- "$source_file" "$target_stage" && ln -- "$target_stage" "$target_file"; then', + ' linked_files=$((linked_files + 1))', + ' else', + ' bridge_failed=1', + ' fi', + ' rm -f -- "$target_stage"', + ' fi', + ' fi', + 'done < "$file_list"', + '[ "$bridge_failed" = 0 ] || exit 1', + `printf '{"scannedFiles":%s,"linkedFiles":%s}\\n' "$scanned_files" "$linked_files"` +].join('\n') + +export const WSL_CODEX_SESSION_BRIDGE_SCRIPT = [ + 'source_sessions_root="$1"', + 'managed_sessions_root="$2"', + WSL_CODEX_SESSION_BRIDGE_BODY +].join('\n') + +export function buildWslCodexSessionBridgeShellCommand(paths: { + managedSessionsRoot: string + systemSessionsRoot: string +}): string { + return [ + `source_sessions_root=${quotePosixShell(paths.systemSessionsRoot)}`, + `managed_sessions_root=${quotePosixShell(paths.managedSessionsRoot)}`, + WSL_CODEX_SESSION_BRIDGE_BODY + ].join('\n') +} diff --git a/src/main/codex/wsl-codex-session-bridge.test.ts b/src/main/codex/wsl-codex-session-bridge.test.ts index ef880a378aa..8e5cd4e67d3 100644 --- a/src/main/codex/wsl-codex-session-bridge.test.ts +++ b/src/main/codex/wsl-codex-session-bridge.test.ts @@ -1,4 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { execFileSync } from 'node:child_process' +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' const { runWslProcessMock } = vi.hoisted(() => ({ runWslProcessMock: vi.fn() @@ -60,10 +64,10 @@ describe('syncWslCodexSessionsIntoManagedHome', () => { "managed_sessions_root='/home/alice/.local/share/orca/codex-runtime-home/home/sessions'" ) expect(shellCommand).toContain(`find "$source_sessions_root" -type f -name '*.jsonl' -print0`) - expect(shellCommand).toContain('ln -- "$source_file" "$target_file"') + expect(shellCommand).toContain('ln -- "$link_source" "$target_file"') expect(shellCommand).toContain('if [ -e "$target_file" ] || [ -L "$target_file" ]; then') expect(shellCommand).not.toContain('ln -s') - expect(shellCommand).not.toContain('cp ') + expect(shellCommand).toContain('cp --') expect(shellCommand).not.toContain('sqlite') }) @@ -180,4 +184,107 @@ describe('buildWslCodexSessionBridgeShellCommand', () => { expect(shellCommand).toContain('$source_file') expect(shellCommand).toContain('$((scanned_files + 1))') }) + + it.skipIf(process.platform === 'win32')( + 'publishes a verified guest-side copy across filesystems', + () => { + const root = mkdtempSync(join(tmpdir(), 'orca-wsl-session-bridge-cross-fs-')) + const sourceSessionsRoot = join(root, 'legacy', 'sessions') + const managedSessionsRoot = join(root, 'managed', 'sessions') + const binDir = join(root, 'bin') + const relativePath = join('2026', '08', '26', 'retired.jsonl') + const sourcePath = join(sourceSessionsRoot, relativePath) + const targetPath = join(managedSessionsRoot, relativePath) + mkdirSync(join(sourcePath, '..'), { recursive: true }) + mkdirSync(binDir) + writeFileSync(sourcePath, '{"session":"retired"}\n', 'utf-8') + const lnShimPath = join(binDir, 'ln') + writeFileSync( + lnShimPath, + `#!/bin/sh +if [ "$2" = "$BRIDGE_SOURCE" ] && [ "$3" = "$BRIDGE_TARGET" ]; then + exit 1 +fi +exec /bin/ln "$@" +` + ) + chmodSync(lnShimPath, 0o755) + const shellCommand = buildWslCodexSessionBridgeShellCommand({ + systemSessionsRoot: sourceSessionsRoot, + managedSessionsRoot + }) + + try { + execFileSync('/bin/bash', ['-c', shellCommand], { + env: { + ...process.env, + BRIDGE_SOURCE: sourcePath, + BRIDGE_TARGET: targetPath, + PATH: `${binDir}:${process.env.PATH ?? ''}` + } + }) + expect(readFileSync(targetPath, 'utf-8')).toBe('{"session":"retired"}\n') + } finally { + rmSync(root, { recursive: true, force: true }) + } + } + ) + + it.skipIf(process.platform === 'win32')( + 'leaves an existing copied session to its current writer', + () => { + const root = mkdtempSync(join(tmpdir(), 'orca-wsl-session-bridge-existing-')) + const sourceSessionsRoot = join(root, 'legacy', 'sessions') + const managedSessionsRoot = join(root, 'managed', 'sessions') + const relativePath = join('2026', '08', '26', 'retired.jsonl') + const sourcePath = join(sourceSessionsRoot, relativePath) + const targetPath = join(managedSessionsRoot, relativePath) + mkdirSync(join(sourcePath, '..'), { recursive: true }) + mkdirSync(join(targetPath, '..'), { recursive: true }) + writeFileSync(sourcePath, '{"session":"retired"}\n{"event":"legacy"}\n', 'utf-8') + writeFileSync(targetPath, '{"session":"retired"}\n', 'utf-8') + const shellCommand = buildWslCodexSessionBridgeShellCommand({ + systemSessionsRoot: sourceSessionsRoot, + managedSessionsRoot + }) + + try { + execFileSync('/bin/bash', ['-c', shellCommand]) + expect(readFileSync(targetPath, 'utf-8')).toBe('{"session":"retired"}\n') + } finally { + rmSync(root, { recursive: true, force: true }) + } + } + ) + + it.skipIf(process.platform === 'win32')( + 'fails in the guest shell when a missing session cannot be linked, then retries cleanly', + () => { + const root = mkdtempSync(join(tmpdir(), 'orca-wsl-session-bridge-')) + const sourceSessionsRoot = join(root, 'legacy', 'sessions') + const managedSessionsRoot = join(root, 'managed', 'sessions') + const relativePath = join('2026', '08', '26', 'retired.jsonl') + const sourcePath = join(sourceSessionsRoot, relativePath) + const blockingPath = join(managedSessionsRoot, '2026') + mkdirSync(join(sourcePath, '..'), { recursive: true }) + mkdirSync(managedSessionsRoot, { recursive: true }) + writeFileSync(sourcePath, '{"session":"retired"}\n', 'utf-8') + writeFileSync(blockingPath, 'not-a-directory\n', 'utf-8') + const shellCommand = buildWslCodexSessionBridgeShellCommand({ + systemSessionsRoot: sourceSessionsRoot, + managedSessionsRoot + }) + + try { + expect(() => execFileSync('/bin/bash', ['-c', shellCommand])).toThrow() + rmSync(blockingPath) + execFileSync('/bin/bash', ['-c', shellCommand]) + expect(readFileSync(join(managedSessionsRoot, relativePath), 'utf-8')).toBe( + '{"session":"retired"}\n' + ) + } finally { + rmSync(root, { recursive: true, force: true }) + } + } + ) }) diff --git a/src/main/codex/wsl-codex-session-bridge.ts b/src/main/codex/wsl-codex-session-bridge.ts index f65dfdbaa03..a84b10fa615 100644 --- a/src/main/codex/wsl-codex-session-bridge.ts +++ b/src/main/codex/wsl-codex-session-bridge.ts @@ -1,7 +1,12 @@ import { posix as pathPosix } from 'node:path' -import { quotePosixShell as quoteBashString } from '../../shared/wsl-login-shell-command' import { parseWslUncPath } from '../../shared/wsl-paths' import { runWslProcess } from '../wsl/wsl-runner' +import { + buildWslCodexSessionBridgeShellCommand, + WSL_SESSION_BRIDGE_TIMEOUT_MS +} from './wsl-codex-session-bridge-script' + +export { buildWslCodexSessionBridgeShellCommand } from './wsl-codex-session-bridge-script' export type WslCodexSessionBridgeTarget = { distro: string @@ -21,8 +26,6 @@ export type WslCodexSessionBridgeSummary = { const emptySummary: WslCodexSessionBridgeSummary = { scannedFiles: 0, linkedFiles: 0 } const backgroundWslSessionBridgeTasks = new Map>() -const WSL_SESSION_BRIDGE_TIMEOUT_MS = 30_000 - export function startWslCodexSessionBridgeInBackground( target: WslCodexSessionBridgeTarget ): Promise { @@ -86,39 +89,6 @@ export function resolveWslCodexSessionBridgeLinuxPaths( } } -export function buildWslCodexSessionBridgeShellCommand( - paths: WslCodexSessionBridgeLinuxPaths -): string { - const shellCommand = [ - 'set -u', - `source_sessions_root=${quoteBashString(paths.systemSessionsRoot)}`, - `managed_sessions_root=${quoteBashString(paths.managedSessionsRoot)}`, - 'scanned_files=0', - 'linked_files=0', - 'if [ ! -d "$source_sessions_root" ]; then', - ` printf '{"scannedFiles":0,"linkedFiles":0}\\n'`, - ' exit 0', - 'fi', - "while IFS= read -r -d '' source_file; do", - ' scanned_files=$((scanned_files + 1))', - ' relative_path=${source_file#"$source_sessions_root"/}', - ' target_file="$managed_sessions_root/$relative_path"', - ' if [ -e "$target_file" ] || [ -L "$target_file" ]; then', - ' continue', - ' fi', - ' target_dir=${target_file%/*}', - ' mkdir -p -- "$target_dir" || continue', - // Why: Codex resume ignores symlinked JSONL, so WSL links must be - // Linux hardlinks created inside the distro filesystem. - ' if ln -- "$source_file" "$target_file"; then', - ' linked_files=$((linked_files + 1))', - ' fi', - `done < <(find "$source_sessions_root" -type f -name '*.jsonl' -print0 2>/dev/null)`, - `printf '{"scannedFiles":%s,"linkedFiles":%s}\\n' "$scanned_files" "$linked_files"` - ].join('\n') - return shellCommand -} - function getWslSessionBridgeTaskKey(target: WslCodexSessionBridgeTarget): string { return [target.distro, target.systemCodexHomePath, target.managedCodexHomePath].join('\0') } diff --git a/src/main/index.ts b/src/main/index.ts index 0780aefd9b9..acbe2a6bd0f 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -69,6 +69,7 @@ import { import { type CodexPaneHomeRoute, getCodexPaneAccount, + hasAnyRecordedLegacyWslCodexPane, hasRecordedManagedHostCodexPane, isCodexPaneHomeRouteProvenAwayFromSharedHome, reconcileCodexPaneAccountsWithLivePtys @@ -1116,7 +1117,8 @@ function startTerminalRuntimeStartupServices(): WindowsDesktopStartupServices { macosLoginSessionWatch: process.platform === 'darwin' && !isServeMode }) // Why: a retained shell keeps its launch-time Codex home even when the current routing lane changes. - if (codexRuntimeHome && hasRecordedManagedHostCodexPane()) { + const hasRetainedManagedHostPane = hasRecordedManagedHostCodexPane() + if (codexRuntimeHome && (hasRetainedManagedHostPane || hasAnyRecordedLegacyWslCodexPane())) { const livePtyIds = await listLiveDaemonPtyIds() if (livePtyIds) { reconcileCodexPaneAccountsWithLivePtys(livePtyIds) @@ -1124,15 +1126,17 @@ function startTerminalRuntimeStartupServices(): WindowsDesktopStartupServices { // Why (#16441): each retained home can run a codex app-server grant // session. Awaiting them here delayed the first window by N sessions; // a retained shell cannot invoke Codex before this provider serves. - void reconcileRetainedCodexHookHomes({ - hookService: codexHookService, - hooksEnabled: - isAgentStatusHooksEnabled(settings) && - settings?.disabledTuiAgents.includes('codex') !== true, - runtimeHomePaths: codexRuntimeHome.getRetainedHostCodexHookHomePaths(livePtyIds) - }).catch((error: unknown) => { - console.warn('[codex-hook-service] retained Codex home reconcile failed:', error) - }) + if (hasRetainedManagedHostPane) { + void reconcileRetainedCodexHookHomes({ + hookService: codexHookService, + hooksEnabled: + isAgentStatusHooksEnabled(settings) && + settings?.disabledTuiAgents.includes('codex') !== true, + runtimeHomePaths: codexRuntimeHome.getRetainedHostCodexHookHomePaths(livePtyIds) + }).catch((error: unknown) => { + console.warn('[codex-hook-service] retained Codex home reconcile failed:', error) + }) + } } } // Why: retained shells can invoke Codex immediately after the startup gate. @@ -1230,7 +1234,7 @@ async function prepareCodexRuntimeHomeForLaunch( // Why: a ManagedCodexHomeTemporarilyUnavailableError must escape uncaught — // the fallbacks below all key off `null`, which means "system default", so // swallowing the refusal would launch the wrong account (#STA-4422). - let runtimeHomePath = codexRuntimeHome!.prepareForCodexLaunch(target, launchEnv, { + let runtimeHomePath = await codexRuntimeHome!.prepareForCodexLaunchAsync(target, launchEnv, { unavailableManagedHomePath: launchContext?.unavailableManagedHomePath }) if (runtimeHomePath === null && !realHomeHooksPrepared) { @@ -1239,7 +1243,7 @@ async function prepareCodexRuntimeHomeForLaunch( // re-resolve if the capability gate rejects it. realHomeHooksPrepared = await ensureRealHomeHooksIfSelected() if (realHomeHooksPrepared) { - runtimeHomePath = codexRuntimeHome!.prepareForCodexLaunch(target, launchEnv, { + runtimeHomePath = await codexRuntimeHome!.prepareForCodexLaunchAsync(target, launchEnv, { unavailableManagedHomePath: launchContext?.unavailableManagedHomePath }) } @@ -1259,13 +1263,11 @@ async function prepareCodexRuntimeHomeForLaunch( const hooksEnabled = isAgentStatusHooksEnabled(store?.getSettings()) try { // Why: honor the persisted off switch so post-startup launches can't reinstall removed hooks. - const status = hooksEnabled - ? ((await codexHookService.installForRuntimeHome(runtimeHomePath, hookTarget)) ?? - // Why: a managed account's launch home is its own self-contained - // CODEX_HOME, so hooks/trust must install there, not the shared mirror. - (await codexHookService.install(runtimeHomePath ?? undefined))) - : (codexHookService.refreshRuntimeUserHooksForRuntimeHome(runtimeHomePath, hookTarget) ?? - (await codexHookService.refreshRuntimeUserHooks(runtimeHomePath ?? undefined))) + const status = await codexHookService.prepareRuntimeHomeForLaunch( + runtimeHomePath, + hookTarget, + hooksEnabled + ) if (status.state === 'error') { console.warn( `[codex-hook-service] failed to ${ diff --git a/src/main/pty/codex-home-wsl-env.ts b/src/main/pty/codex-home-wsl-env.ts index f61272de8c3..6667e3fd608 100644 --- a/src/main/pty/codex-home-wsl-env.ts +++ b/src/main/pty/codex-home-wsl-env.ts @@ -1,6 +1,4 @@ -/** Guest-relative layout of Orca's managed WSL CODEX_HOME. Must stay in sync - * with getWslRuntimeHomePath (codex-accounts/runtime-home-service.ts), which - * builds the UNC twin of this path. */ +/** Guest-relative layout of Orca's retired WSL CODEX_HOME, retained for migration reads. */ export const WSL_CODEX_RUNTIME_HOME_SEGMENTS = [ '.local', 'share', diff --git a/src/main/wsl/__fixtures__/wsl-invocation-allowlist.txt b/src/main/wsl/__fixtures__/wsl-invocation-allowlist.txt index fc1478330d9..45f99ffa246 100644 --- a/src/main/wsl/__fixtures__/wsl-invocation-allowlist.txt +++ b/src/main/wsl/__fixtures__/wsl-invocation-allowlist.txt @@ -17,7 +17,6 @@ main/agent-hooks/wsl-hook-relay-launch.ts main/browser/wsl-browser-network-relay-launch.ts main/claude-accounts/claude-command-process.ts -main/codex-accounts/runtime-home-service.ts main/codex-accounts/service.ts main/codex/codex-state-db-backfill-recovery.ts main/codex/codex-trust-grant-host.ts diff --git a/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt b/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt index 42cfe12add2..988adc08755 100644 --- a/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt +++ b/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt @@ -46,7 +46,9 @@ src/main/browser/browser-cookie-import.ts src/main/browser/browser-route-egress-electron-launch.ts src/main/browser/browser-route-persisted-worker-electron-process.ts src/main/claude-accounts/keychain.ts -src/main/codex-accounts/runtime-home-service.ts +src/main/codex-accounts/legacy-wsl-runtime-auth-drain-recovery-script-harness.ts +src/main/codex-accounts/legacy-wsl-runtime-auth-drain-script-harness.ts +src/main/codex-accounts/legacy-wsl-runtime-auth-drain-script-interference-shims.ts src/main/codex-accounts/service.ts src/main/codex/codex-app-server-client.ts src/main/codex/codex-app-server-posix-supervisor.ts diff --git a/src/shared/wsl-paths.test.ts b/src/shared/wsl-paths.test.ts index 78c858e0265..c6cc52c182f 100644 --- a/src/shared/wsl-paths.test.ts +++ b/src/shared/wsl-paths.test.ts @@ -6,7 +6,8 @@ import { isWslUncPath, parseWslUncPath, resolveWslRepoWorktreeBasePath, - toWindowsWslPath + toWindowsWslPath, + toWindowsWslUncPath } from './wsl-paths' describe('wsl path helpers', () => { @@ -35,6 +36,12 @@ describe('wsl path helpers', () => { ])('converts %s without folding case-sensitive Linux paths', (linuxPath, expected) => { expect(toWindowsWslPath(linuxPath, 'Ubuntu')).toBe(expected) }) + + it('keeps mounted-drive paths on the distro UNC view when requested', () => { + expect(toWindowsWslUncPath('/mnt/c/Users/jin', 'Ubuntu')).toBe( + '\\\\wsl.localhost\\Ubuntu\\mnt\\c\\Users\\jin' + ) + }) }) describe('resolveWslRepoWorktreeBasePath', () => { diff --git a/src/shared/wsl-paths.ts b/src/shared/wsl-paths.ts index c7ae675778b..9dc465c75b1 100644 --- a/src/shared/wsl-paths.ts +++ b/src/shared/wsl-paths.ts @@ -55,7 +55,12 @@ export function toWindowsWslPath(linuxPath: string, distro: string): string { return `${mntMatch[1].toUpperCase()}:${rest || '\\'}` } - return `\\\\wsl.localhost\\${distro}${linuxPath.replace(/\//g, '\\')}` + return toWindowsWslUncPath(linuxPath, distro) +} + +/** Keep a Linux path addressable through its distro, including drvfs mounts. */ +export function toWindowsWslUncPath(linuxPath: string, distro: string): string { + return `\\\\wsl.localhost\\${distro}${linuxPath === '/' ? '\\' : linuxPath.replace(/\//g, '\\')}` } /** @@ -86,7 +91,7 @@ export function resolveWslRepoWorktreeBasePath(repoPath: string, basePath: strin return basePath } const collapsed = collapsePosixDotSegments(basePath) - return `\\\\wsl.localhost\\${repoWsl.distro}${collapsed === '/' ? '\\' : collapsed.replace(/\//g, '\\')}` + return toWindowsWslUncPath(collapsed, repoWsl.distro) } function collapsePosixDotSegments(absolutePosixPath: string): string { From faaf38ac45e8da7c60d5770b3950f2cdd8c63cc1 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:25:34 -0400 Subject: [PATCH 3/4] fix(orchestration): submit staged mail pointer while working (#17470) --- config/reliability-gates.jsonc | 2 +- ...n-mailbox-notification-consistency.test.ts | 63 ++++++++++++++----- .../orchestration/mailbox-pointer-submit.ts | 5 +- 3 files changed, 50 insertions(+), 20 deletions(-) diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 02dfd4fe4bc..ce075110603 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -12651,7 +12651,7 @@ "large persisted mismatches route in 50-row pages with coalesced per-mailbox wakes", "Run pointers pin the existing mixed-version-compatible --run identity", "direct and Dispatch mail remain durable without unpinned synthetic terminal turns", - "a delayed pointer submit cannot press Enter after the agent becomes working", + "a staged pointer submits once when Codex or Claude becomes working and still withholds Enter for permission", "an explicit check releases its staged pointer reservation without redrive", "accepted pointer text is durably deduplicated before delayed Enter and provider Enter refusal", "a known PTY exit releases staged rows for the replacement process", diff --git a/src/main/runtime/orchestration-mailbox-notification-consistency.test.ts b/src/main/runtime/orchestration-mailbox-notification-consistency.test.ts index bbcbc42e564..ac1167ebe9d 100644 --- a/src/main/runtime/orchestration-mailbox-notification-consistency.test.ts +++ b/src/main/runtime/orchestration-mailbox-notification-consistency.test.ts @@ -471,25 +471,54 @@ describe('orchestration notification mailbox consistency', () => { db.close() }) - it('does not submit or duplicate a pointer after the agent becomes working', async () => { - vi.useFakeTimers() - const db = createDatabase('orca-mailbox-working-before-enter-') - const harness = createRuntime(db) - const run = createBoundRun(db, 'Working-before-Enter Run') - const message = insertDirectRunMessage(db, run.id, 'Actionable status') + it.each([ + ['Codex', '\x1b]0;Codex working\x07', '\x1b]0;Codex done\x07'], + ['Claude', '\x1b]0;\u280b Claude working\x07', '\x1b]0;\u2733 Claude Code\x07'] + ])( + 'submits a staged pointer after %s becomes working without duplicating it', + async (_provider, workingTitle, idleTitle) => { + vi.useFakeTimers() + const db = createDatabase('orca-mailbox-working-before-enter-') + const harness = createRuntime(db) + const run = createBoundRun(db, 'Working-before-Enter Run') + const message = insertDirectRunMessage(db, run.id, 'Actionable status') - await driveToLiveIdle(harness.runtime) - expect(pointerCount(harness.write)).toBe(1) - harness.runtime.onPtyData(PTY_ID, '\x1b]0;Codex working\x07', 3) - await vi.advanceTimersByTimeAsync(500) + await driveToLiveIdle(harness.runtime) + expect(pointerCount(harness.write)).toBe(1) + harness.runtime.onPtyData(PTY_ID, workingTitle, 3) + await vi.advanceTimersByTimeAsync(500) - expect(harness.write.mock.calls.filter(([, payload]) => payload === '\r')).toHaveLength(0) - expect(db.getMessageById(message.id)?.delivered_at).toEqual(expect.any(String)) - harness.runtime.onPtyData(PTY_ID, '\x1b]0;Codex done\x07', 4) - await Promise.resolve() - expect(pointerCount(harness.write)).toBe(1) - db.close() - }) + expect(harness.write.mock.calls.filter(([, payload]) => payload === '\r')).toHaveLength(1) + expect(db.getMessageById(message.id)?.delivered_at).toEqual(expect.any(String)) + harness.runtime.onPtyData(PTY_ID, idleTitle, 4) + await Promise.resolve() + expect(pointerCount(harness.write)).toBe(1) + db.close() + } + ) + + it.each([ + ['Codex', '\x1b]0;Codex permission\x07'], + ['Claude', '\x1b]0;Claude waiting for permission\x07'] + ])( + 'does not submit a staged pointer after %s enters a permission state', + async (_provider, permissionTitle) => { + vi.useFakeTimers() + const db = createDatabase('orca-mailbox-permission-before-enter-') + const harness = createRuntime(db) + const run = createBoundRun(db, 'Permission-before-Enter Run') + const message = insertDirectRunMessage(db, run.id, 'Actionable status') + + await driveToLiveIdle(harness.runtime) + expect(pointerCount(harness.write)).toBe(1) + harness.runtime.onPtyData(PTY_ID, permissionTitle, 3) + await vi.advanceTimersByTimeAsync(500) + + expect(harness.write.mock.calls.filter(([, payload]) => payload === '\r')).toHaveLength(0) + expect(db.getMessageById(message.id)?.delivered_at).toEqual(expect.any(String)) + db.close() + } + ) it('releases staged pointer state when an explicit check owns the batch', async () => { vi.useFakeTimers() diff --git a/src/main/runtime/orchestration/mailbox-pointer-submit.ts b/src/main/runtime/orchestration/mailbox-pointer-submit.ts index 5d16ea6ba05..9692e52dd50 100644 --- a/src/main/runtime/orchestration/mailbox-pointer-submit.ts +++ b/src/main/runtime/orchestration/mailbox-pointer-submit.ts @@ -54,8 +54,9 @@ export function submitOrchestrationMailboxPointer Date: Mon, 31 Aug 2026 14:34:55 -0400 Subject: [PATCH 4/4] fix(orchestration): explain invalid send message types (#17487) --- src/cli/specs/orchestration.test.ts | 17 +++++++++ src/cli/specs/orchestration.ts | 2 + .../orchestration-send-invalid-type.test.ts | 37 +++++++++++++++++++ src/main/runtime/rpc/methods/orchestration.ts | 19 ++++------ 4 files changed, 64 insertions(+), 11 deletions(-) create mode 100644 src/cli/specs/orchestration.test.ts create mode 100644 src/main/runtime/rpc/methods/orchestration-send-invalid-type.test.ts diff --git a/src/cli/specs/orchestration.test.ts b/src/cli/specs/orchestration.test.ts new file mode 100644 index 00000000000..e1d800dff33 --- /dev/null +++ b/src/cli/specs/orchestration.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' +import { ORCHESTRATION_COMMAND_SPECS } from './orchestration' + +describe('orchestration send command spec', () => { + it('documents valid message types and the question reply path', () => { + const sendSpec = ORCHESTRATION_COMMAND_SPECS.find( + (spec) => spec.path.join(' ') === 'orchestration send' + ) + + expect(sendSpec?.notes).toEqual( + expect.arrayContaining([ + 'Valid --type values: status, dispatch, worker_done, merge_ready, escalation, handoff, decision_gate, question, heartbeat.', + 'To answer a worker question, use orchestration reply --id --body with the same Orca CLI executable.' + ]) + ) + }) +}) diff --git a/src/cli/specs/orchestration.ts b/src/cli/specs/orchestration.ts index 1bbe8b52db3..26d60935771 100644 --- a/src/cli/specs/orchestration.ts +++ b/src/cli/specs/orchestration.ts @@ -68,6 +68,8 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [ 'phase' ], notes: [ + 'Valid --type values: status, dispatch, worker_done, merge_ready, escalation, handoff, decision_gate, question, heartbeat.', + 'To answer a worker question, use orchestration reply --id --body with the same Orca CLI executable.', 'On Windows PowerShell, quote group addresses such as --to "@all" or --to "@worktree:".', "worker_done and heartbeat are exact-Dispatch signals and cannot target groups; omit --to to use the Dispatch's Run mailbox.", 'worker_done requires --outcome succeeded or --outcome failed.', diff --git a/src/main/runtime/rpc/methods/orchestration-send-invalid-type.test.ts b/src/main/runtime/rpc/methods/orchestration-send-invalid-type.test.ts new file mode 100644 index 00000000000..72750d611a3 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-send-invalid-type.test.ts @@ -0,0 +1,37 @@ +import { afterEach, describe, expect, it } from 'vitest' +import type { RpcRequest } from '../core' +import { ORCHESTRATION_METHODS } from './orchestration' +import { RpcDispatcher } from '../dispatcher' +import { createOrchestrationRpcHarness } from './orchestration-rpc-test-harness' +import { ORCHESTRATION_CONTRACT_VERSION } from '../../../../shared/protocol-version' + +describe('orchestration.send invalid message type', () => { + const h = createOrchestrationRpcHarness() + + afterEach(() => { + h.cleanup() + }) + + it('explains valid message types and the question reply path', async () => { + const { runtime, ctx } = h.setup() + const dispatcher = new RpcDispatcher({ runtime, methods: ORCHESTRATION_METHODS }) + const request: RpcRequest = { + id: 'req_1', + authToken: 'token', + method: 'orchestration.send', + params: { to: 'b', subject: 'hi', type: 'answer' }, + orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION + } + + const response = await dispatcher.dispatch(request, ctx) + + expect(response).toMatchObject({ + ok: false, + error: { + code: 'invalid_argument', + message: + 'Invalid --type. Expected one of: status, dispatch, worker_done, merge_ready, escalation, handoff, decision_gate, question, heartbeat. To answer a worker question, use the same Orca CLI executable with orchestration reply --id --body .' + } + }) + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration.ts b/src/main/runtime/rpc/methods/orchestration.ts index 876021e79e1..2d5e7b6a796 100644 --- a/src/main/runtime/rpc/methods/orchestration.ts +++ b/src/main/runtime/rpc/methods/orchestration.ts @@ -76,6 +76,11 @@ async function routeAllMailboxPages( type DispatchMutationMessageType = 'worker_done' | 'heartbeat' | 'escalation' | 'decision_gate' +const SEND_MESSAGE_TYPE_ERROR = [ + `Invalid --type. Expected one of: ${MESSAGE_TYPES.join(', ')}.`, + 'To answer a worker question, use the same Orca CLI executable with orchestration reply --id --body .' +].join(' ') + function isDispatchMutationMessageType( type: string | undefined ): type is DispatchMutationMessageType { @@ -132,17 +137,9 @@ const SendParams = z from: OptionalString, body: OptionalString, type: z - .enum([ - 'status', - 'dispatch', - 'worker_done', - 'merge_ready', - 'escalation', - 'handoff', - 'decision_gate', - 'question', - 'heartbeat' - ]) + .enum(MESSAGE_TYPES, { + error: SEND_MESSAGE_TYPE_ERROR + }) .optional(), priority: z.enum(['normal', 'high', 'urgent']).optional(), threadId: OptionalString,