From ebcd637db981b0ce272c8e183794a8fc7caaa7be Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 26 Aug 2026 21:22:05 -0700 Subject: [PATCH] fix(codex): launch WSL accounts from direct homes (#16504) * fix(codex): launch WSL accounts from direct homes * fix(codex): coalesce WSL auth drains and validate distro homes * fix(codex): preserve legacy WSL account home metadata * fix(codex): retain marked WSL home compatibility * fix(codex): verify the bytes the WSL drain promotes, not an earlier read The apply script validated the source hash and then re-read it with cp, so a legacy pane rotating in that window put bytes freshness never judged over a valid account home. Codex rewrites auth.json in place, so that read can be torn. Covers it by running the real guest script under sh with a sha256sum shim that rotates the source between the two reads; without the guard it exits 0. * fix(codex): harden WSL auth drain races --- ...sl-runtime-auth-drain-apply-script.test.ts | 170 ++++++++++ .../legacy-wsl-runtime-auth-drain.test.ts | 212 ++++++++++++ .../legacy-wsl-runtime-auth-drain.ts | 315 ++++++++++++++++++ .../codex-accounts/runtime-home-service.ts | 197 +++++++++-- .../runtime-home-wsl-managed-accounts.test.ts | 71 ++-- .../runtime-home-wsl-session-bridge.test.ts | 62 ++-- .../runtime-home-wsl-system-default.test.ts | 37 +- .../wsl-codex-auth-batch-reader.ts | 70 ++++ src/main/codex/codex-pane-account-registry.ts | 9 + .../codex/codex-pane-launch-account.test.ts | 2 +- src/main/codex/codex-pane-launch-account.ts | 17 +- .../codex/codex-stale-pane-accounts.test.ts | 31 ++ 12 files changed, 1043 insertions(+), 150 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.test.ts create mode 100644 src/main/codex-accounts/legacy-wsl-runtime-auth-drain.ts create mode 100644 src/main/codex-accounts/wsl-codex-auth-batch-reader.ts 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..44ce6a6e00f --- /dev/null +++ b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain-apply-script.test.ts @@ -0,0 +1,170 @@ +import { execFileSync } from 'node:child_process' +import { createHash } from 'node:crypto' +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, 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 isWindows = process.platform === 'win32' + +const SOURCE_AUTH = '{"tokens":{"expires_at":2000}}\n' +const TARGET_AUTH = '{"tokens":{"expires_at":1000}}\n' +const NEWER_AUTH = '{"tokens":{"expires_at":3000}}\n' +// Codex truncates before it writes, so a read landing mid-rotation sees this. +const TORN_AUTH = '{"tokens":{"exp' +const SOURCE_CREDENTIALS = '{"server":{"access_token":"source"}}\n' +const TORN_CREDENTIALS = '{"server":' + +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. + */ +function runApplyScript( + options: { + rewriteSourceAfterHashCall?: number + rewriteBytes?: string + rewriteTarget?: 'source-auth' | 'source-credentials' | 'target-auth' + sourceCredentials?: string + } = {} +): { + legacyAuth: string + status: number + targetAuth: string + targetCredentials: string | null +} { + 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') + writeFileSync(legacyAuthPath, SOURCE_AUTH) + writeFileSync(targetAuthPath, TARGET_AUTH) + if (options.sourceCredentials !== undefined) { + writeFileSync(legacyCredentialsPath, options.sourceCredentials) + } + + const counterPath = join(root, 'hash-calls') + writeFileSync(counterPath, '0') + 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) +} +` + ) + chmodSync(shimPath, 0o755) + + let status = 0 + try { + execFileSync( + '/bin/sh', + [ + '-c', + _internals.applyLegacyAuthScript, + 'sh', + legacyHome, + join(root, 'absent-active-home'), + join(root, 'absent-marker.json'), + targetHome, + sha256(SOURCE_AUTH), + sha256(TARGET_AUTH), + '1', + '0', + options.sourceCredentials === undefined ? 'missing' : sha256(options.sourceCredentials) + ], + { + encoding: 'utf8', + env: { + ...process.env, + HASH_COUNTER: counterPath, + PATH: `${binDir}:${process.env.PATH ?? ''}`, + REWRITE_AFTER: options.rewriteSourceAfterHashCall + ? String(options.rewriteSourceAfterHashCall) + : '', + REWRITE_BYTES: options.rewriteBytes ?? TORN_AUTH, + 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 + } + return { + legacyAuth: readFileSync(legacyAuthPath, 'utf8'), + status, + targetAuth: readFileSync(targetAuthPath, 'utf8'), + targetCredentials: existsSync(targetCredentialsPath) + ? readFileSync(targetCredentialsPath, 'utf8') + : null + } +} + +describe.skipIf(isWindows)('legacy WSL auth drain apply script', () => { + 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({ rewriteSourceAfterHashCall: 1 }) + expect(outcome.status).toBe(42) + expect(outcome.targetAuth).toBe(TARGET_AUTH) + }) + + it('refuses MCP credentials that changed after host validation', () => { + const outcome = runApplyScript({ + rewriteSourceAfterHashCall: 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, + rewriteSourceAfterHashCall: 4, + rewriteTarget: 'target-auth' + }) + expect(outcome.status).toBe(39) + expect(outcome.targetAuth).toBe(NEWER_AUTH) + }) +}) 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..0c0caa1b669 --- /dev/null +++ b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain.test.ts @@ -0,0 +1,212 @@ +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' + ]) + 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') + }) + + it('does not write when freshness cannot be proven', async () => { + runWslProcessMock.mockResolvedValueOnce(result(0, inspection('{"tokens":{}}\n'))) + + await drainLegacyWslRuntimeAuth({ + distro: 'Ubuntu', + guestHomeLinuxPath: '/home/alice', + legacyPanePresent: false, + resolveDestination: () => ({ + authContents: '{"tokens":{}}\n', + linuxHomePath: '/home/alice/.codex' + }) + }) + + expect(runWslProcessMock).toHaveBeenCalledTimes(1) + }) + + 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(-3)).toEqual(['0', '1', 'missing']) + }) + + it('keeps an absent source retryable while a legacy pane remains', async () => { + runWslProcessMock.mockResolvedValueOnce(result(21)) + + await drainLegacyWslRuntimeAuth({ + distro: 'Ubuntu', + guestHomeLinuxPath: '/home/alice', + legacyPanePresent: true, + 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 source complete after every legacy pane exits', async () => { + runWslProcessMock.mockResolvedValueOnce(result(21)).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('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..cc50be185af --- /dev/null +++ b/src/main/codex-accounts/legacy-wsl-runtime-auth-drain.ts @@ -0,0 +1,315 @@ +import { createHash } from 'node:crypto' +import { posix as pathPosix } from 'node:path' +import { WSL_CODEX_RUNTIME_HOME_SEGMENTS } from '../pty/codex-home-wsl-env' +import { runWslProcess } from '../wsl/wsl-runner' +import { compareCodexAuthFreshness, codexAuthIsFresher } from './codex-auth-identity' +import { decodeWslBase64Payload } from './wsl-codex-auth-batch-reader' + +const DRAIN_MARKER_NAME = 'direct-home-auth-drain-v1.json' +const MARKER_PRESENT_EXIT = 20 +const SOURCE_AUTH_ABSENT_EXIT = 21 + +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() + +export function startLegacyWslRuntimeAuthDrain(options: LegacyWslRuntimeAuthDrainOptions): void { + const key = options.distro.trim().toLowerCase() + if (completedDistroKeys.has(key)) { + return + } + // 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. + if (drainQueueByDistro.has(key)) { + return + } + const next = drainLegacyWslRuntimeAuth(options) + .then((status) => { + if (status === 'complete') { + completedDistroKeys.add(key) + } + }) + .catch((error) => { + console.warn('[codex-wsl-auth-drain] Failed to drain legacy runtime auth:', error) + }) + drainQueueByDistro.set(key, next) + void next.finally(() => { + if (drainQueueByDistro.get(key) === next) { + drainQueueByDistro.delete(key) + } + }) +} + +export async function drainLegacyWslRuntimeAuth( + options: LegacyWslRuntimeAuthDrainOptions +): Promise<'complete' | 'pending'> { + 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 === SOURCE_AUTH_ABSENT_EXIT) { + if (!options.legacyPanePresent) { + await finalizeAbsentLegacyAuth(options.distro, paths) + return 'complete' + } + 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) + if (freshness === null) { + return 'pending' + } + const promoteAuth = codexAuthIsFresher(inspected.authContents, destination.authContents) + 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', + options.legacyPanePresent ? '0' : '1', + inspected.credentials.kind === 'present' ? sha256(inspected.credentials.contents) : 'missing' + ], + timeoutMs: 5_000, + maxOutputBytes: 16 * 1024 + }) + assertSuccessfulDrainStep('apply', result) + return options.legacyPanePresent ? 'pending' : 'complete' +} + +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 = pathPosix.join(guestHomeLinuxPath, ...WSL_CODEX_RUNTIME_HOME_SEGMENTS) + 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 { + 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 + }) + assertSuccessfulDrainStep('finalize', result) +} + +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') +} + +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" + fi +fi +` + +const INSPECT_LEGACY_AUTH_SCRIPT = ` +set -eu +[ ! -f "$3" ] || exit ${MARKER_PRESENT_EXIT} +${RESOLVE_LEGACY_HOME_SCRIPT} +source_auth="$legacy_home/auth.json" +[ -f "$source_auth" ] || exit ${SOURCE_AUTH_ABSENT_EXIT} +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" ]; 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 +` + +const APPLY_LEGACY_AUTH_SCRIPT = ` +set -eu +[ ! -f "$3" ] || exit 0 +${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" +[ -f "$source_auth" ] || exit 35 +[ -f "$target_auth" ] || exit 36 +hash_file() { sha256sum -- "$1" | cut -d ' ' -f 1; } +[ "$(hash_file "$source_auth")" = "$5" ] || exit 37 +[ "$(hash_file "$target_auth")" = "$6" ] || exit 38 +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_marker="$3.orca-drain-$$" +cleanup() { rm -f -- "$temporary_auth" "$temporary_credentials" "$temporary_previous_auth" "$temporary_marker"; } +trap cleanup EXIT HUP INT TERM +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 + [ "$(hash_file "$source_auth")" = "$5" ] || exit 40 + rm -- "$source_auth" + printf '%s\n' '{"completed":true}' > "$temporary_marker" + chmod 600 "$temporary_marker" + mv -f -- "$temporary_marker" "$3" +fi +` + +const FINALIZE_ABSENT_AUTH_SCRIPT = ` +set -eu +[ ! -f "$3" ] || exit 0 +${RESOLVE_LEGACY_HOME_SCRIPT} +[ ! -e "$legacy_home/auth.json" ] && [ ! -L "$legacy_home/auth.json" ] || exit 41 +umask 077 +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" +` + +export const _internals = { + applyLegacyAuthScript: APPLY_LEGACY_AUTH_SCRIPT, + resetDrainQueue: (): void => { + drainQueueByDistro.clear() + completedDistroKeys.clear() + } +} diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index cf3a0105d63..e9c0b9a2e0d 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -61,6 +61,7 @@ import { } from '../codex/codex-config-mirror' import { parseWslUncPath, toLinuxPath } from '../../shared/wsl-paths' import { + getCodexSelectionLaneKey, getWslSelectionKey, getSelectedCodexAccountIdForTarget, normalizeCodexRuntimeSelection, @@ -91,10 +92,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 @@ -244,11 +251,11 @@ 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.syncWslConfigAndGlobalInstructionsForLaunch(wslTarget, homePath) + this.startWslSessionBridgeForLaunch(wslTarget, homePath) + return homePath } const selfContainedAccount = this.getSelfContainedManagedHostAccount() if (selfContainedAccount) { @@ -790,10 +797,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 +839,7 @@ export class CodexRuntimeHomeService { launchEnv?: NodeJS.ProcessEnv ): void { if (target?.runtime === 'wsl') { - this.syncWslRuntimeForCurrentSelection(target) + this.startLegacyWslAuthDrain(this.resolveWslDefaultTarget(target)) return } @@ -1051,28 +1057,128 @@ export class CodexRuntimeHomeService { } 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) + this.startLegacyWslAuthDrain(target) + return this.getWslCodexHomePathForSelection(target) } - private syncWslRuntimeForCurrentSelection(target: CodexAccountSelectionTarget): string | null { + private getWslCodexHomePathForSelection(target: CodexAccountSelectionTarget): string | null { + const settings = this.store.getSettings() + const account = this.getActiveAccount( + settings.codexManagedAccounts, + getSelectedCodexAccountIdForTarget(settings, target) + ) + if (account) { + const managedHome = parseWslUncPath(account.managedHomePath) + const targetDistro = this.resolveWslDefaultTarget(target).wslDistro?.trim() + // Persisted selections can outlive an account's runtime metadata. Never + // hand a host home (or another distro's UNC home) to a WSL launch. + const accountDistro = account.wslDistro?.trim() + const distroMatches = + (!accountDistro || + !targetDistro || + accountDistro.toLowerCase() === targetDistro.toLowerCase()) && + (!managedHome || + !targetDistro || + managedHome.distro.toLowerCase() === targetDistro.toLowerCase()) + // Older persisted fixtures may not carry a UNC spelling, but the + // explicit WSL runtime marker still proves the lane; reject unmarked + // host paths while retaining those legacy WSL records. + if (distroMatches && (account.managedHomeRuntime === 'wsl' || managedHome)) { + return account.managedHomePath + } + } + return this.getWslSystemCodexHomePath(target) + } + + private startLegacyWslAuthDrain(target: CodexAccountSelectionTarget): void { + if (process.platform !== 'win32') { + return + } + const distro = target.wslDistro?.trim() || getDefaultWslDistro() + if (!distro) { + return + } + const guestHome = getWslHome(distro) + const parsedGuestHome = guestHome ? parseWslUncPath(guestHome) : null + if (!parsedGuestHome) { + return + } + let legacyPanePresent: boolean + try { + legacyPanePresent = hasRecordedLegacyWslCodexPane(getCodexSelectionLaneKey(target)) + } catch (error) { + console.warn('[codex-wsl-auth-drain] Pane registry unavailable; deferring drain:', error) + return + } + startLegacyWslRuntimeAuthDrain({ + distro, + guestHomeLinuxPath: parsedGuestHome.linuxPath, + legacyPanePresent, + resolveDestination: (runtimeAuthContents) => + this.resolveLegacyWslAuthDestination(distro, runtimeAuthContents) + }) + } + + private async resolveLegacyWslAuthDestination( + distro: string, + runtimeAuthContents: string + ): Promise { + const accounts = this.store.getSettings().codexManagedAccounts.filter((account) => { + const parsedHome = parseWslUncPath(account.managedHomePath) + return parsedHome?.distro.toLowerCase() === distro.toLowerCase() + }) + const accountHomes = accounts.flatMap((account) => { + const parsedHome = parseWslUncPath(account.managedHomePath) + return parsedHome ? [{ account, linuxPath: parsedHome.linuxPath }] : [] + }) + 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 parsedHome = parseWslUncPath(match.account.managedHomePath) + if (!parsedHome || parsedHome.distro.toLowerCase() !== distro.toLowerCase()) { + return null + } + return { + authContents: match.managedAuthContents, + linuxHomePath: parsedHome.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 + } + + syncWslRuntimeForCurrentSelection(target: CodexAccountSelectionTarget): string | null { if (process.platform !== 'win32') { return null } @@ -1319,7 +1425,11 @@ export class CodexRuntimeHomeService { private findManagedAccountForRuntimeAuth( runtimeAuthContents: string, - expectedAccountId?: string + expectedAccountId?: string, + options?: { + accounts: readonly CodexManagedAccount[] + authReads: ReadonlyMap + } ): CodexReadBackMatch { const matches: { account: CodexManagedAccount @@ -1327,18 +1437,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 +1460,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 }) } 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..95228744bab 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,5 +1,5 @@ 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 { createSettings } from './runtime-home-settings-test-fixtures' import { @@ -87,18 +87,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 +105,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 +159,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 +171,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 +210,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) @@ -255,7 +248,7 @@ describe('CodexRuntimeHomeService', () => { ).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 +310,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) @@ -413,10 +407,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) @@ -476,7 +470,8 @@ describe('CodexRuntimeHomeService', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) - expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath) + expect(service.prepareForCodexLaunch(target)).toBe(managedHomePath) + mkdirSync(wslRuntimeHomePath, { recursive: true }) writeFileSync(runtimeAuthPath, staleRuntimeAuth, 'utf-8') writeFileSync(managedAuthPath, reauthedAuth, 'utf-8') @@ -484,7 +479,7 @@ describe('CodexRuntimeHomeService', () => { service.syncForCurrentSelection(target) expect(readFileSync(managedAuthPath, 'utf-8')).toBe(reauthedAuth) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(reauthedAuth) + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(staleRuntimeAuth) } finally { if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) @@ -492,7 +487,7 @@ describe('CodexRuntimeHomeService', () => { } }) - it('reads active WSL token refreshes back before restart using the selected distro', async () => { + it('keeps direct-home WSL token refreshes canonical across restart preservation', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) const wslHome = join(testState.userDataDir, 'wsl-home') @@ -548,13 +543,13 @@ describe('CodexRuntimeHomeService', () => { ) const runtimeAuthPath = join(wslRuntimeHomePath, 'auth.json') - expect(service.prepareForCodexLaunch(target)).toBe(wslRuntimeHomePath) - writeFileSync(runtimeAuthPath, refreshedAuth, 'utf-8') + expect(service.prepareForCodexLaunch(target)).toBe(managedHomePath) + writeFileSync(managedAuthPath, refreshedAuth, 'utf-8') service.syncActiveWslSelectionsBeforeRestart() expect(readFileSync(managedAuthPath, 'utf-8')).toBe(refreshedAuth) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(refreshedAuth) + expect(existsSync(runtimeAuthPath)).toBe(false) } finally { 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..33eae33977f 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,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import type * as WslPaths from '../../shared/wsl-paths' import { createSettings } from './runtime-home-settings-test-fixtures' @@ -89,7 +89,7 @@ describe('CodexRuntimeHomeService', () => { } }) - 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 +124,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 +140,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 +173,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 +220,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') @@ -287,7 +262,8 @@ describe('CodexRuntimeHomeService', () => { return { ...actual, parseWslUncPath: (candidate: string) => - candidate === wslRuntimeHomePath + candidate === wslRuntimeHomePath || + candidate.includes('codex-accounts/debian-account/home') ? { distro: 'Debian', linuxPath: '/home/alice/.local/share/orca/codex-runtime-home/home' @@ -328,12 +304,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') 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..dbf0241a155 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 @@ -93,15 +93,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 +138,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 +219,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 +248,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 +260,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 +300,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) { 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-pane-account-registry.ts b/src/main/codex/codex-pane-account-registry.ts index 449c11ba711..4a4467bd219 100644 --- a/src/main/codex/codex-pane-account-registry.ts +++ b/src/main/codex/codex-pane-account-registry.ts @@ -266,6 +266,15 @@ export function hasRecordedLegacySharedCodexPane(): boolean { ) } +/** True when a retained WSL pane may still read the retired per-distro runtime home. */ +export function hasRecordedLegacyWslCodexPane(selectionKey: string): boolean { + return Object.values(readRegistryOrThrow().panes).some( + (record) => + (record.selectionKey === selectionKey || record.selectionKey === 'wsl:__default__') && + (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( diff --git a/src/main/codex/codex-pane-launch-account.test.ts b/src/main/codex/codex-pane-launch-account.test.ts index 456b9bc08be..c862099e3ac 100644 --- a/src/main/codex/codex-pane-launch-account.test.ts +++ b/src/main/codex/codex-pane-launch-account.test.ts @@ -202,7 +202,7 @@ describe('resolveCodexPaneLaunchAccount', () => { ).toEqual({ selectionKey: 'wsl:Ubuntu', accountId: 'wsl-account', - homeRoute: 'wsl-home' + 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..089c1975db3 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) === @@ -93,10 +91,19 @@ function resolveCodexPaneHomeRoute(args: { const launchHome = normalizeRuntimePathForComparison(args.launchCodexHomePath) const accountOwnsHome = args.settings.codexManagedAccounts?.some( (account) => - getCodexSelectionTargetForAccount(account).runtime === 'host' && + getCodexSelectionLaneKey(getCodexSelectionTargetForAccount(account)) === + getCodexSelectionLaneKey(args.target) && normalizeRuntimePathForComparison(account.managedHomePath) === launchHome ) - 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. */ diff --git a/src/main/codex/codex-stale-pane-accounts.test.ts b/src/main/codex/codex-stale-pane-accounts.test.ts index bf2f7e5b5ea..c5deb104543 100644 --- a/src/main/codex/codex-stale-pane-accounts.test.ts +++ b/src/main/codex/codex-stale-pane-accounts.test.ts @@ -8,6 +8,7 @@ import { forgetCodexPaneAccount, getCodexPaneAccount, hasRecordedLegacySharedCodexPane, + hasRecordedLegacyWslCodexPane, hasRecordedManagedHostCodexPane, isCodexPaneHomeRouteProvenAwayFromSharedHome, reconcileCodexPaneAccountsWithLivePtys, @@ -138,6 +139,36 @@ 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) + 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 startup inventory only for managed host panes', () => { recordCodexPaneAccount('pty-real', { selectionKey: 'host',