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
This commit is contained in:
Brennan Benson
2026-08-26 21:22:05 -07:00
committed by GitHub
parent d60a3c900b
commit ebcd637db9
12 changed files with 1043 additions and 150 deletions
@@ -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)
})
})
@@ -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
})
)
})
})
@@ -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<LegacyWslRuntimeAuthDestination | null>
}
const drainQueueByDistro = new Map<string, Promise<void>>()
const completedDistroKeys = new Set<string>()
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<typeof resolveLegacyRuntimePaths>
): Promise<void> {
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()
}
}
+162 -35
View File
@@ -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<LegacyWslRuntimeAuthDestination | null> {
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<string, WslCodexAuthRead>(
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<string, WslCodexAuthRead>
}
): 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 })
}
@@ -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)
@@ -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')
@@ -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) {
@@ -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<WslCodexAuthRead[]> {
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
`
@@ -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(
@@ -202,7 +202,7 @@ describe('resolveCodexPaneLaunchAccount', () => {
).toEqual({
selectionKey: 'wsl:Ubuntu',
accountId: 'wsl-account',
homeRoute: 'wsl-home'
homeRoute: 'account-home'
})
})
+12 -5
View File
@@ -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. */
@@ -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',