mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 08:03:39 +00:00
fix(codex): harden WSL auth projection
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user