fix(codex): harden WSL auth projection

This commit is contained in:
Brennan Benson
2026-08-23 14:01:39 -07:00
parent 15f23bea0c
commit 8fe9f19c87
7 changed files with 534 additions and 132 deletions
@@ -6,6 +6,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import type { GlobalSettings } from '../../shared/global-settings-types'
import type { CodexManagedAccount } from '../../shared/managed-account-types'
import {
classifyStoredCodexAuthContents,
readStoredCodexCredentialState,
waitForManagedCodexAuthReady
} from './managed-codex-auth-readiness'
@@ -30,6 +31,59 @@ afterEach(() => {
}
})
describe('classifyStoredCodexAuthContents', () => {
it('keeps external ChatGPT tokens distinct and permits an empty refresh token', () => {
const classified = classifyStoredCodexAuthContents(
JSON.stringify({
...testChatGptAuth,
auth_mode: 'chatgptAuthTokens',
tokens: { ...testChatGptAuth.tokens, refresh_token: '' }
})
)
expect(classified).toEqual({ state: 'present', mode: 'chatgptAuthTokens' })
})
it.each([
[{ auth_mode: 'agentIdentity', agent_identity: 'identity' }, 'agentIdentity'],
[
{ auth_mode: 'personalAccessToken', personal_access_token: 'pat-test' },
'personalAccessToken'
],
[{ auth_mode: 'bedrockApiKey', bedrock_api_key: { api_key: 'bedrock' } }, 'bedrockApiKey']
])('preserves the declared mode for %j', (auth, mode) => {
expect(classifyStoredCodexAuthContents(JSON.stringify(auth))).toEqual({
state: 'present',
mode
})
})
it('keeps different future declared modes unequal', () => {
const first = classifyStoredCodexAuthContents(
JSON.stringify({ auth_mode: 'futureOne', credential: 'one' })
)
const second = classifyStoredCodexAuthContents(
JSON.stringify({ auth_mode: 'futureTwo', credential: 'two' })
)
expect(first.mode).toBe('unknown:futureOne')
expect(second.mode).toBe('unknown:futureTwo')
expect(first.mode).not.toBe(second.mode)
})
it.each([
[{ personal_access_token: 'pat-test' }, 'personalAccessToken'],
[{ bedrock_api_key: { api_key: 'bedrock' } }, 'bedrockApiKey'],
[{ OPENAI_API_KEY: 'sk-test' }, 'apikey'],
[{ tokens: testChatGptAuth.tokens }, 'chatgpt']
])('infers the legacy mode for %j', (auth, mode) => {
expect(classifyStoredCodexAuthContents(JSON.stringify(auth))).toEqual({
state: 'present',
mode
})
})
})
describe('waitForManagedCodexAuthReady', () => {
it.each([
['ChatGPT', testChatGptAuth],
@@ -92,23 +92,44 @@ export type StoredCodexCredentialState =
| 'unreadable'
| 'no-credential'
export type StoredCodexAuthMode =
| 'apikey'
| 'chatgpt'
| 'chatgptAuthTokens'
| 'agentIdentity'
| 'personalAccessToken'
| 'bedrockApiKey'
| `unknown:${string}`
export type StoredCodexAuthObservation = {
state: StoredCodexCredentialState
mode: StoredCodexAuthMode | null
contents: string | null
}
// Why: callers deciding whether to deselect an account must tell a settled
// logout ('no-credential') apart from a rotation in progress ('unreadable') or
// an absent file ('missing') — collapsing them to false logs users out on races.
export function readStoredCodexCredentialState(authPath: string): StoredCodexCredentialState {
return readStoredCodexAuthObservation(authPath).state
}
export function readStoredCodexAuthObservation(authPath: string): StoredCodexAuthObservation {
let raw: string
try {
raw = readFileSync(authPath, 'utf8')
} catch (error) {
const code = (error as NodeJS.ErrnoException).code
// Why: Windows auth.json rotation can surface transient EPERM/EBUSY reads.
return code === 'ENOENT' || code === 'ENOTDIR' ? 'missing' : 'unreadable'
return {
state: code === 'ENOENT' || code === 'ENOTDIR' ? 'missing' : 'unreadable',
mode: null,
contents: null
}
}
return classifyStoredCodexAuthContents(raw).state
return { ...classifyStoredCodexAuthContents(raw), contents: raw }
}
export type StoredCodexAuthMode = 'apikey' | 'chatgpt' | 'other'
export function classifyStoredCodexAuthContents(raw: string): {
state: Exclude<StoredCodexCredentialState, 'missing'>
mode: StoredCodexAuthMode | null
@@ -145,18 +166,26 @@ function storedAuthMode(auth: StoredCodexAuth): StoredCodexAuthMode {
if (isNonEmptyString(auth.auth_mode)) {
switch (auth.auth_mode) {
case 'apikey':
return 'apikey'
case 'chatgpt':
case 'chatgptAuthTokens':
return 'chatgpt'
case 'agentIdentity':
case 'personalAccessToken':
case 'bedrockApiKey':
return auth.auth_mode
default:
return 'other'
return `unknown:${auth.auth_mode}`
}
}
if (isNonEmptyString(auth.personal_access_token)) {
return 'personalAccessToken'
}
if (hasBedrockApiKey(auth.bedrock_api_key)) {
return 'bedrockApiKey'
}
if (isNonEmptyString(auth.OPENAI_API_KEY)) {
return 'apikey'
}
return hasChatGptCredential(auth.tokens) ? 'chatgpt' : 'other'
return 'chatgpt'
}
function hasCredentialForDeclaredMode(auth: StoredCodexAuth): boolean {
@@ -167,8 +196,9 @@ function hasCredentialForDeclaredMode(auth: StoredCodexAuth): boolean {
case 'apikey':
return isNonEmptyString(auth.OPENAI_API_KEY)
case 'chatgpt':
case 'chatgptAuthTokens':
return hasChatGptCredential(auth.tokens)
case 'chatgptAuthTokens':
return hasExternalChatGptCredential(auth.tokens)
case 'agentIdentity':
return hasAgentIdentityCredential(auth.agent_identity)
case 'personalAccessToken':
@@ -213,6 +243,12 @@ function hasChatGptCredential(tokens: unknown): boolean {
)
}
function hasExternalChatGptCredential(tokens: unknown): boolean {
return (
isRecord(tokens) && isNonEmptyString(tokens.access_token) && isNonEmptyString(tokens.id_token)
)
}
function hasAgentIdentityCredential(value: unknown): boolean {
if (isNonEmptyString(value)) {
return true
+43 -45
View File
@@ -86,6 +86,10 @@ import {
import { migrateLegacySharedAuthToPerAccountHome } from './legacy-shared-auth-migration'
import { CodexCredentialAbsenceGrace } from './codex-credential-absence-grace'
import { decideWslRuntimeAuthProjection } from './wsl-runtime-auth-projection'
import {
readStoredCodexAuthObservation,
type StoredCodexAuthObservation
} from './managed-codex-auth-readiness'
import { syncLegacySharedCodexConfigForRetainedPanes } from './legacy-shared-config-compatibility'
import {
getCodexPaneAccount,
@@ -1101,65 +1105,59 @@ export class CodexRuntimeHomeService {
}
}
const runtimeAuth = readStoredCodexAuthObservation(runtimeAuthPath)
const activeAuthPath = activeAccount ? join(activeAccount.managedHomePath, 'auth.json') : null
if (activeAccount && activeAuthPath && existsSync(activeAuthPath)) {
const activeAuth = readFileSync(activeAuthPath, 'utf-8')
if (
const activeAuth = activeAuthPath ? readStoredCodexAuthObservation(activeAuthPath) : null
if (activeAccount && activeAuth) {
if (activeAuth.state === 'missing' || activeAuth.state === 'no-credential') {
console.warn(
'[codex-runtime-home] Active WSL managed account has no credential, restoring system default'
)
this.lastSyncedWslAccountIdByDistro.set(distro, null)
this.deselectWslManagedAccount(wslTarget)
} else {
this.applyWslRuntimeAuthProjection({
runtimeAuthPath,
runtimeAuth,
distro,
sourceAuthContents: activeAuth,
sourceAuth: activeAuth,
explicitAccountSwitch,
selectedAccountId: activeAccount.id,
wslTarget
})
) {
return runtimeHomePath
}
}
if (activeAccount && activeAuthPath) {
console.warn(
'[codex-runtime-home] Active WSL managed account is missing auth.json, restoring system default'
)
this.deselectWslManagedAccount(wslTarget)
}
const systemAuthPath = this.getWslSystemCodexAuthPath({ runtime: 'wsl', wslDistro: distro })
if (systemAuthPath && existsSync(systemAuthPath)) {
const systemAuth = readFileSync(systemAuthPath, 'utf-8')
const systemAuth: StoredCodexAuthObservation = systemAuthPath
? readStoredCodexAuthObservation(systemAuthPath)
: { state: 'missing', mode: null, contents: null }
if (systemAuthPath && systemAuth.state === 'present' && systemAuth.contents) {
const mirroredSystemDefaultAuth = this.lastWrittenWslAuthJsonByDistro.get(distro) ?? null
const runtimeAuth = existsSync(runtimeAuthPath)
? readFileSync(runtimeAuthPath, 'utf-8')
: null
if (
runtimeAuth !== null &&
runtimeAuth !== systemAuth &&
this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuth, systemAuth) &&
((mirroredSystemDefaultAuth !== null && systemAuth === mirroredSystemDefaultAuth) ||
runtimeAuth.state === 'present' &&
runtimeAuth.contents &&
runtimeAuth.contents !== systemAuth.contents &&
this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuth.contents, systemAuth.contents) &&
((mirroredSystemDefaultAuth !== null &&
systemAuth.contents === mirroredSystemDefaultAuth) ||
(mirroredSystemDefaultAuth === null &&
this.runtimeAuthIsFresher(runtimeAuth, systemAuth)))
this.runtimeAuthIsFresher(runtimeAuth.contents, systemAuth.contents)))
) {
// Why: WSL baselines are lost on restart, so a same-identity fresher runtime auth is a token refresh; copy it back before mirroring ~/.codex.
this.writeRuntimeAuthAtPath(systemAuthPath, runtimeAuth)
this.lastWrittenWslAuthJsonByDistro.set(distro, runtimeAuth)
this.writeRuntimeAuthAtPath(systemAuthPath, runtimeAuth.contents)
this.lastWrittenWslAuthJsonByDistro.set(distro, runtimeAuth.contents)
this.lastSyncedWslAccountIdByDistro.set(distro, null)
return runtimeHomePath
}
this.applyWslRuntimeAuthProjection({
runtimeAuthPath,
distro,
sourceAuthContents: systemAuth,
explicitAccountSwitch: false,
selectedAccountId: null,
wslTarget
})
return runtimeHomePath
}
this.applyWslRuntimeAuthProjection({
runtimeAuthPath,
runtimeAuth,
distro,
sourceAuthContents: null,
sourceAuth: systemAuth,
explicitAccountSwitch: false,
selectedAccountId: null,
wslTarget
@@ -1172,18 +1170,19 @@ export class CodexRuntimeHomeService {
return home ? this.joinWslPath(home, ...WSL_CODEX_RUNTIME_HOME_SEGMENTS) : null
}
// Returns true when the caller must stop: keep, replace, or a completed wipe.
// Applies the pure projection verdict while keeping writes and selection updates local.
private applyWslRuntimeAuthProjection(args: {
runtimeAuthPath: string
runtimeAuth: StoredCodexAuthObservation
distro: string
sourceAuthContents: string | null
sourceAuth: StoredCodexAuthObservation
explicitAccountSwitch: boolean
selectedAccountId: string | null
wslTarget: CodexAccountSelectionTarget
}): boolean {
}): void {
const decision = decideWslRuntimeAuthProjection({
runtimeAuthPath: args.runtimeAuthPath,
sourceAuthContents: args.sourceAuthContents,
runtimeAuth: args.runtimeAuth,
sourceAuth: args.sourceAuth,
explicitAccountSwitch: args.explicitAccountSwitch
})
if (decision.action === 'keep') {
@@ -1191,21 +1190,20 @@ export class CodexRuntimeHomeService {
this.deselectWslManagedAccount(args.wslTarget)
this.lastSyncedWslAccountIdByDistro.set(args.distro, null)
}
return true
return
}
if (decision.action === 'wipe') {
rmSync(args.runtimeAuthPath, { force: true })
this.lastWrittenWslAuthJsonByDistro.set(args.distro, null)
this.lastSyncedWslAccountIdByDistro.set(args.distro, null)
return true
return
}
if (!args.sourceAuthContents) {
return false
if (!args.sourceAuth.contents) {
return
}
this.writeRuntimeAuthAtPath(args.runtimeAuthPath, args.sourceAuthContents)
this.lastWrittenWslAuthJsonByDistro.set(args.distro, args.sourceAuthContents)
this.writeRuntimeAuthAtPath(args.runtimeAuthPath, args.sourceAuth.contents)
this.lastWrittenWslAuthJsonByDistro.set(args.distro, args.sourceAuth.contents)
this.lastSyncedWslAccountIdByDistro.set(args.distro, args.selectedAccountId)
return true
}
private deselectWslManagedAccount(wslTarget: CodexAccountSelectionTarget): void {
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import type { CodexManagedAccount } from '../../shared/managed-account-types'
import { createSettings } from './runtime-home-settings-test-fixtures'
@@ -213,44 +213,4 @@ describe('STA-4991 WSL API-key runtime projection', () => {
expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe(runtimeHome)
expect(readFileSync(join(runtimeHome, 'auth.json'), 'utf-8')).toBe(chatgptAuth)
})
it('does not overwrite or deselect when runtime auth.json is unreadable', async () => {
const wslHome = join(testState.userDataDir, 'wsl-home')
vi.doMock('../wsl', () => ({
getDefaultWslDistro: () => 'Ubuntu',
getWslHome: () => wslHome
}))
const chatgptAuth = createCodexAuthJson('wsl@example.com', 'acct-wsl', 'managed-token')
const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', chatgptAuth)
const runtimeHome = wslRuntimeHomePath(wslHome)
mkdirSync(runtimeHome, { recursive: true })
const runtimeAuthPath = join(runtimeHome, 'auth.json')
writeFileSync(runtimeAuthPath, API_KEY_AUTH, 'utf-8')
chmodSync(runtimeAuthPath, 0o000)
const store = createStore(
createSettings({
codexManagedAccounts: [
wslAccount({
id: 'account-1',
email: 'wsl@example.com',
providerAccountId: 'acct-wsl',
managedHomePath
})
],
activeCodexManagedAccountId: null,
activeCodexManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'account-1' } }
})
)
try {
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
const service = new CodexRuntimeHomeService(store as never)
expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe(
runtimeHome
)
expect(store.updateSettings).not.toHaveBeenCalled()
} finally {
chmodSync(runtimeAuthPath, 0o600)
}
expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(API_KEY_AUTH)
})
})
@@ -0,0 +1,233 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as NodeFs from 'node:fs'
import { join } from 'node:path'
import type { CodexManagedAccount } from '../../shared/managed-account-types'
import { createSettings } from './runtime-home-settings-test-fixtures'
import {
createCodexAuthJson,
createManagedAuth,
createStore,
setupRuntimeHomeTest,
teardownRuntimeHomeTest,
testState
} from './runtime-home-service-test-harness'
const deniedReads = vi.hoisted(() => {
const paths = new Set<string>()
const counts = new Map<string, number>()
return {
deny(path: string): void {
paths.add(path)
},
count(path: string): number {
return counts.get(path) ?? 0
},
reset(): void {
paths.clear()
counts.clear()
},
check(target: unknown): void {
if (typeof target !== 'string' || !paths.has(target)) {
return
}
counts.set(target, (counts.get(target) ?? 0) + 1)
const error: NodeJS.ErrnoException = new Error(`EACCES: permission denied, read '${target}'`)
error.code = 'EACCES'
error.syscall = 'read'
error.path = target
throw error
}
}
})
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof NodeFs>()
const readFileSync = ((...args: Parameters<typeof actual.readFileSync>) => {
deniedReads.check(args[0])
return actual.readFileSync(...args)
}) as typeof actual.readFileSync
const patched = { ...actual, readFileSync: Object.assign(readFileSync, actual.readFileSync) }
return { ...patched, default: patched }
})
vi.mock('electron', () => ({ app: { getPath: () => testState.userDataDir } }))
vi.mock('node:os', async () => {
const actual = await vi.importActual<typeof import('node:os')>('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import()
return { ...actual, homedir: () => testState.fakeHomeDir }
})
const realFs = await vi.importActual<typeof NodeFs>('node:fs')
const API_KEY_AUTH = `${JSON.stringify({
auth_mode: 'apikey',
OPENAI_API_KEY: 'sk-live-runtime'
})}\n`
const target = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' }
describe('STA-4991 WSL auth projection failures', () => {
let originalPlatform: PropertyDescriptor | undefined
beforeEach(() => {
deniedReads.reset()
setupRuntimeHomeTest()
originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
})
afterEach(() => {
deniedReads.reset()
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform)
}
teardownRuntimeHomeTest()
})
it.each(['runtime', 'managed', 'system'] as const)(
'keeps live runtime auth when the %s auth read returns EACCES',
async (deniedLane) => {
const fixture = createFixture({
selected: deniedLane !== 'system',
systemAuth: deniedLane === 'system'
})
realFs.mkdirSync(fixture.runtimeHome, { recursive: true })
realFs.writeFileSync(fixture.runtimeAuthPath, API_KEY_AUTH, 'utf-8')
const deniedPath =
deniedLane === 'runtime'
? fixture.runtimeAuthPath
: deniedLane === 'managed'
? fixture.managedAuthPath
: fixture.systemAuthPath
deniedReads.deny(deniedPath)
const service = await createService(fixture.store)
expect(service.prepareForCodexLaunch(target)).toBe(fixture.runtimeHome)
expect(deniedReads.count(deniedPath)).toBeGreaterThan(0)
expect(realFs.readFileSync(fixture.runtimeAuthPath, 'utf-8')).toBe(API_KEY_AUTH)
expect(fixture.store.updateSettings).not.toHaveBeenCalled()
}
)
it.each([
['incomplete', JSON.stringify({ auth_mode: 'chatgpt', tokens: { access_token: 'partial' } })],
['malformed', '{']
])('keeps selection when managed auth is readable but %s', async (_label, contents) => {
const fixture = createFixture({ selected: true })
realFs.mkdirSync(fixture.runtimeHome, { recursive: true })
realFs.writeFileSync(fixture.runtimeAuthPath, API_KEY_AUTH, 'utf-8')
realFs.writeFileSync(fixture.managedAuthPath, contents, 'utf-8')
const service = await createService(fixture.store)
expect(service.prepareForCodexLaunch(target)).toBe(fixture.runtimeHome)
expect(realFs.readFileSync(fixture.runtimeAuthPath, 'utf-8')).toBe(API_KEY_AUTH)
expect(fixture.store.updateSettings).not.toHaveBeenCalled()
})
it.each(['missing', 'no-credential'] as const)(
'deselects a managed source that is definitively %s',
async (sourceState) => {
const fixture = createFixture({ selected: true })
realFs.mkdirSync(fixture.runtimeHome, { recursive: true })
realFs.writeFileSync(fixture.runtimeAuthPath, API_KEY_AUTH, 'utf-8')
if (sourceState === 'missing') {
realFs.rmSync(fixture.managedAuthPath)
} else {
realFs.writeFileSync(fixture.managedAuthPath, '{}', 'utf-8')
}
const service = await createService(fixture.store)
expect(service.prepareForCodexLaunch(target)).toBe(fixture.runtimeHome)
expect(realFs.readFileSync(fixture.runtimeAuthPath, 'utf-8')).toBe(API_KEY_AUTH)
expect(fixture.store.updateSettings).toHaveBeenCalledWith({
activeCodexManagedAccountId: null,
activeCodexManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: null } }
})
}
)
it('projects restored bytes when the same missing managed account is reselected', async () => {
const fixture = createFixture({ selected: true })
const service = await createService(fixture.store)
expect(service.prepareForCodexLaunch(target)).toBe(fixture.runtimeHome)
realFs.writeFileSync(fixture.runtimeAuthPath, API_KEY_AUTH, 'utf-8')
realFs.rmSync(fixture.managedAuthPath)
expect(service.prepareForCodexLaunch(target)).toBe(fixture.runtimeHome)
expect(realFs.readFileSync(fixture.runtimeAuthPath, 'utf-8')).toBe(API_KEY_AUTH)
const restoredAuth = createCodexAuthJson(
'managed@example.com',
'acct-managed',
'restored-refresh'
)
realFs.writeFileSync(fixture.managedAuthPath, restoredAuth, 'utf-8')
fixture.store.updateSettings({
activeCodexManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'account-1' } }
})
expect(service.prepareForCodexLaunch(target)).toBe(fixture.runtimeHome)
expect(realFs.readFileSync(fixture.runtimeAuthPath, 'utf-8')).toBe(restoredAuth)
})
})
function createFixture(args: { selected: boolean; systemAuth?: boolean }) {
const wslHome = join(testState.userDataDir, 'wsl-home')
vi.doMock('../wsl', () => ({
getDefaultWslDistro: () => 'Ubuntu',
getWslHome: () => wslHome
}))
const managedAuth = createCodexAuthJson('managed@example.com', 'acct-managed', 'managed-refresh')
const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth)
const systemAuthPath = join(wslHome, '.codex', 'auth.json')
if (args.systemAuth) {
realFs.mkdirSync(join(systemAuthPath, '..'), { recursive: true })
realFs.writeFileSync(
systemAuthPath,
createCodexAuthJson('system@example.com', 'acct-system', 'system-refresh'),
'utf-8'
)
}
const store = createStore(
createSettings({
codexManagedAccounts: [wslAccount(managedHomePath)],
activeCodexManagedAccountId: null,
activeCodexManagedAccountIdsByRuntime: {
host: null,
wsl: { Ubuntu: args.selected ? 'account-1' : null }
}
})
)
const runtimeHome = join(wslHome, '.local', 'share', 'orca', 'codex-runtime-home', 'home')
return {
store,
runtimeHome,
runtimeAuthPath: join(runtimeHome, 'auth.json'),
managedAuthPath: join(managedHomePath, 'auth.json'),
systemAuthPath
}
}
async function createService(store: ReturnType<typeof createStore>) {
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
return new CodexRuntimeHomeService(store as never)
}
function wslAccount(managedHomePath: string): CodexManagedAccount {
return {
id: 'account-1',
email: 'managed@example.com',
managedHomePath,
managedHomeRuntime: 'wsl',
wslDistro: 'Ubuntu',
wslLinuxHomePath: '/home/alice/.local/share/orca/codex-accounts/account-1/home',
providerAccountId: 'acct-managed',
workspaceLabel: null,
workspaceAccountId: 'acct-managed',
createdAt: 1,
updatedAt: 1,
lastAuthenticatedAt: 1
}
}
@@ -0,0 +1,127 @@
import { describe, expect, it } from 'vitest'
import {
classifyStoredCodexAuthContents,
type StoredCodexAuthObservation
} from './managed-codex-auth-readiness'
import {
decideWslRuntimeAuthProjection,
wslRuntimeAuthMayReplaceSource
} from './wsl-runtime-auth-projection'
const API_KEY = JSON.stringify({ auth_mode: 'apikey', OPENAI_API_KEY: 'sk-test' })
describe('decideWslRuntimeAuthProjection', () => {
it.each(['unreadable', 'incomplete'] as const)(
'keeps an indeterminate %s source without deselecting',
(state) => {
expect(
decideWslRuntimeAuthProjection({
runtimeAuth: observe(API_KEY),
sourceAuth: { state, mode: null, contents: state === 'incomplete' ? '{}' : null },
explicitAccountSwitch: false
})
).toEqual({ action: 'keep', deselect: false })
}
)
it('replaces only from a present source on an explicit switch', () => {
expect(
decideWslRuntimeAuthProjection({
runtimeAuth: observe(API_KEY),
sourceAuth: observe(chatGptAuth('account-1', 'refresh-1')),
explicitAccountSwitch: true
})
).toEqual({ action: 'replace' })
})
it('seeds an absent runtime and wipes only when both sides are absent', () => {
const missing = missingObservation()
expect(
decideWslRuntimeAuthProjection({
runtimeAuth: missing,
sourceAuth: observe(API_KEY),
explicitAccountSwitch: false
})
).toEqual({ action: 'replace' })
expect(
decideWslRuntimeAuthProjection({
runtimeAuth: missing,
sourceAuth: missing,
explicitAccountSwitch: false
})
).toEqual({ action: 'wipe' })
})
})
describe('wslRuntimeAuthMayReplaceSource', () => {
it.each(['chatgpt', 'chatgptAuthTokens'] as const)(
'requires matching identity for %s credentials',
(mode) => {
const first = observe(chatGptAuth('account-1', 'refresh-1', mode))
const same = observe(chatGptAuth('account-1', 'refresh-2', mode))
const other = observe(chatGptAuth('account-2', 'refresh-2', mode))
expect(wslRuntimeAuthMayReplaceSource(first, same)).toBe(true)
expect(wslRuntimeAuthMayReplaceSource(first, other)).toBe(false)
}
)
it('accepts byte-identical PAT credentials but preserves differing PAT bytes', () => {
const first = observe(
JSON.stringify({ auth_mode: 'personalAccessToken', personal_access_token: 'pat-1' })
)
const other = observe(
JSON.stringify({ auth_mode: 'personalAccessToken', personal_access_token: 'pat-2' })
)
expect(wslRuntimeAuthMayReplaceSource(first, first)).toBe(true)
expect(wslRuntimeAuthMayReplaceSource(first, other)).toBe(false)
})
it('rejects different known and future modes', () => {
const bedrock = observe(
JSON.stringify({ auth_mode: 'bedrockApiKey', bedrock_api_key: { api_key: 'bedrock' } })
)
const agent = observe(
JSON.stringify({ auth_mode: 'agentIdentity', agent_identity: 'identity' })
)
const futureOne = observe(JSON.stringify({ auth_mode: 'futureOne', credential: 'one' }))
const futureTwo = observe(JSON.stringify({ auth_mode: 'futureTwo', credential: 'two' }))
expect(wslRuntimeAuthMayReplaceSource(bedrock, agent)).toBe(false)
expect(wslRuntimeAuthMayReplaceSource(futureOne, futureTwo)).toBe(false)
})
})
function observe(contents: string): StoredCodexAuthObservation {
return { ...classifyStoredCodexAuthContents(contents), contents }
}
function missingObservation(): StoredCodexAuthObservation {
return { state: 'missing', mode: null, contents: null }
}
function chatGptAuth(
accountId: string,
refreshToken: string,
authMode: 'chatgpt' | 'chatgptAuthTokens' = 'chatgpt'
): string {
const payload = Buffer.from(
JSON.stringify({
email: `${accountId}@example.com`,
'https://api.openai.com/auth': {
chatgpt_account_id: accountId,
workspace_account_id: accountId
}
})
).toString('base64url')
return JSON.stringify({
auth_mode: authMode,
tokens: {
access_token: `access-${refreshToken}`,
id_token: `header.${payload}.signature`,
refresh_token: authMode === 'chatgptAuthTokens' ? '' : refreshToken,
account_id: accountId
}
})
}
@@ -1,9 +1,5 @@
import { readFileSync } from 'node:fs'
import { codexAuthMatchesSystemDefaultIdentity } from './codex-auth-identity'
import {
classifyStoredCodexAuthContents,
readStoredCodexCredentialState
} from './managed-codex-auth-readiness'
import type { StoredCodexAuthObservation } from './managed-codex-auth-readiness'
export type WslRuntimeAuthProjection =
| { action: 'replace' }
@@ -11,55 +7,53 @@ export type WslRuntimeAuthProjection =
| { action: 'wipe' }
export function decideWslRuntimeAuthProjection(args: {
runtimeAuthPath: string
sourceAuthContents: string | null
runtimeAuth: StoredCodexAuthObservation
sourceAuth: StoredCodexAuthObservation
explicitAccountSwitch: boolean
}): WslRuntimeAuthProjection {
const runtimeState = readStoredCodexCredentialState(args.runtimeAuthPath)
if (runtimeState === 'unreadable' || runtimeState === 'incomplete') {
if (isIndeterminate(args.runtimeAuth) || isIndeterminate(args.sourceAuth)) {
return { action: 'keep', deselect: false }
}
if (args.explicitAccountSwitch) {
return args.sourceAuthContents ? { action: 'replace' } : { action: 'wipe' }
if (args.explicitAccountSwitch && args.sourceAuth.state === 'present') {
return { action: 'replace' }
}
if (runtimeState === 'missing' || runtimeState === 'no-credential') {
return args.sourceAuthContents ? { action: 'replace' } : { action: 'wipe' }
if (args.runtimeAuth.state === 'missing' || args.runtimeAuth.state === 'no-credential') {
return args.sourceAuth.state === 'present' ? { action: 'replace' } : { action: 'wipe' }
}
const runtimeContents = readRuntimeAuthContents(args.runtimeAuthPath)
if (runtimeContents === null) {
if (args.sourceAuth.state !== 'present') {
return { action: 'keep', deselect: false }
}
if (!args.sourceAuthContents) {
return { action: 'keep', deselect: true }
}
if (wslRuntimeAuthMayReplaceSource(runtimeContents, args.sourceAuthContents)) {
if (wslRuntimeAuthMayReplaceSource(args.runtimeAuth, args.sourceAuth)) {
return { action: 'replace' }
}
return { action: 'keep', deselect: true }
}
// Why: a live API-key (or other mismatched) runtime login is not dirt to restore.
// Why: a live credential with a conflicting or unprovable owner is not a stale mirror.
export function wslRuntimeAuthMayReplaceSource(
runtimeContents: string,
sourceContents: string
runtimeAuth: StoredCodexAuthObservation,
sourceAuth: StoredCodexAuthObservation
): boolean {
const runtime = classifyStoredCodexAuthContents(runtimeContents)
const source = classifyStoredCodexAuthContents(sourceContents)
if (runtime.state !== 'present' || source.state !== 'present' || runtime.mode !== source.mode) {
if (
runtimeAuth.state !== 'present' ||
sourceAuth.state !== 'present' ||
!runtimeAuth.contents ||
!sourceAuth.contents
) {
return false
}
return (
runtime.mode !== 'chatgpt' ||
codexAuthMatchesSystemDefaultIdentity(runtimeContents, sourceContents)
)
if (runtimeAuth.contents === sourceAuth.contents) {
return true
}
if (runtimeAuth.mode !== sourceAuth.mode) {
return false
}
if (runtimeAuth.mode === 'chatgpt' || runtimeAuth.mode === 'chatgptAuthTokens') {
return codexAuthMatchesSystemDefaultIdentity(runtimeAuth.contents, sourceAuth.contents)
}
return runtimeAuth.mode !== 'personalAccessToken'
}
function readRuntimeAuthContents(runtimeAuthPath: string): string | null {
try {
return readFileSync(runtimeAuthPath, 'utf-8')
} catch {
// Why: a race off the earlier present-state read is still not absence; skip.
return null
}
function isIndeterminate(observation: StoredCodexAuthObservation): boolean {
return observation.state === 'unreadable' || observation.state === 'incomplete'
}