diff --git a/src/main/codex-accounts/managed-codex-auth-readiness.test.ts b/src/main/codex-accounts/managed-codex-auth-readiness.test.ts index d65a9344acc..9037b8106e9 100644 --- a/src/main/codex-accounts/managed-codex-auth-readiness.test.ts +++ b/src/main/codex-accounts/managed-codex-auth-readiness.test.ts @@ -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], diff --git a/src/main/codex-accounts/managed-codex-auth-readiness.ts b/src/main/codex-accounts/managed-codex-auth-readiness.ts index 60d5b23e69a..c4312c31b19 100644 --- a/src/main/codex-accounts/managed-codex-auth-readiness.ts +++ b/src/main/codex-accounts/managed-codex-auth-readiness.ts @@ -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 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 diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index f7dc4255e43..73896ebc5fa 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -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 { diff --git a/src/main/codex-accounts/sta-4991-wsl-apikey-runtime-projection.test.ts b/src/main/codex-accounts/sta-4991-wsl-apikey-runtime-projection.test.ts index 21807ac289b..e0b6c7afeea 100644 --- a/src/main/codex-accounts/sta-4991-wsl-apikey-runtime-projection.test.ts +++ b/src/main/codex-accounts/sta-4991-wsl-apikey-runtime-projection.test.ts @@ -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) - }) }) diff --git a/src/main/codex-accounts/sta-4991-wsl-auth-projection-fail-closed.test.ts b/src/main/codex-accounts/sta-4991-wsl-auth-projection-fail-closed.test.ts new file mode 100644 index 00000000000..8cfede88b13 --- /dev/null +++ b/src/main/codex-accounts/sta-4991-wsl-auth-projection-fail-closed.test.ts @@ -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() + const counts = new Map() + 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() + const readFileSync = ((...args: Parameters) => { + 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('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('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) { + 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 + } +} diff --git a/src/main/codex-accounts/wsl-runtime-auth-projection.test.ts b/src/main/codex-accounts/wsl-runtime-auth-projection.test.ts new file mode 100644 index 00000000000..07b5f5a288b --- /dev/null +++ b/src/main/codex-accounts/wsl-runtime-auth-projection.test.ts @@ -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 + } + }) +} diff --git a/src/main/codex-accounts/wsl-runtime-auth-projection.ts b/src/main/codex-accounts/wsl-runtime-auth-projection.ts index 8a96acea581..0e868e5bb0a 100644 --- a/src/main/codex-accounts/wsl-runtime-auth-projection.ts +++ b/src/main/codex-accounts/wsl-runtime-auth-projection.ts @@ -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' }