fix(codex): prevent transient managed-auth onboarding (#11731)
* fix(codex): gate terminal spawn on managed auth readiness * fix(codex): recover unavailable managed auth safely --------- Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
This commit is contained in:
parent
7d24dad48a
commit
9f30a780f5
|
|
@ -0,0 +1,241 @@
|
|||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { CodexManagedAccount, GlobalSettings } from '../../shared/types'
|
||||
import { waitForManagedCodexAuthReady } from './managed-codex-auth-readiness'
|
||||
|
||||
const roots: string[] = []
|
||||
const testIdToken = 'e30.eyJlbWFpbCI6InVzZXJAZXhhbXBsZS5jb20ifQ.sig'
|
||||
const testChatGptAuth = {
|
||||
auth_mode: 'chatgpt',
|
||||
tokens: {
|
||||
access_token: 'access',
|
||||
id_token: testIdToken,
|
||||
refresh_token: 'refresh',
|
||||
account_id: 'account'
|
||||
},
|
||||
last_refresh: '2026-07-31T00:00:00Z'
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
for (const root of roots.splice(0)) {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe('waitForManagedCodexAuthReady', () => {
|
||||
it.each([
|
||||
['ChatGPT', testChatGptAuth],
|
||||
['ChatGPT auth tokens', { ...testChatGptAuth, auth_mode: 'chatgptAuthTokens' }],
|
||||
['API key', { auth_mode: 'apikey', OPENAI_API_KEY: 'sk-test' }],
|
||||
[
|
||||
'agent identity',
|
||||
{
|
||||
auth_mode: 'agentIdentity',
|
||||
agent_identity: { agent_runtime_id: 'runtime', agent_private_key: 'private-key' }
|
||||
}
|
||||
],
|
||||
[
|
||||
'future agent identity storage',
|
||||
{ auth_mode: 'agentIdentity', agent_identity: { future_material: 'opaque' } }
|
||||
],
|
||||
[
|
||||
'personal access token',
|
||||
{ auth_mode: 'personalAccessToken', personal_access_token: 'pat-test' }
|
||||
],
|
||||
[
|
||||
'Bedrock API key',
|
||||
{
|
||||
auth_mode: 'bedrockApiKey',
|
||||
bedrock_api_key: { api_key: 'bedrock-key', region: 'us-east-1' }
|
||||
}
|
||||
],
|
||||
['future auth mode', { auth_mode: 'futureAuthMode', future_credential: { value: 'opaque' } }],
|
||||
['future auth shape', { future_credential: { value: 'opaque' } }],
|
||||
[
|
||||
'ChatGPT with agent identity metadata',
|
||||
{
|
||||
...testChatGptAuth,
|
||||
agent_identity: { agent_runtime_id: 'runtime', agent_private_key: 'private-key' }
|
||||
}
|
||||
]
|
||||
])('accepts a readable managed %s credential', (_label, auth) => {
|
||||
const fixture = createFixture()
|
||||
writeAuth(fixture.home, auth)
|
||||
|
||||
expect(waitForManagedCodexAuthReady(fixture.args)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('waits for a missing managed credential to be restored', async () => {
|
||||
vi.useFakeTimers()
|
||||
const fixture = createFixture()
|
||||
const readiness = waitForManagedCodexAuthReady(fixture.args)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
writeAuth(fixture.home, { OPENAI_API_KEY: 'sk-test' })
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
await expect(readiness).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('waits for a partial managed ChatGPT credential to become complete', async () => {
|
||||
vi.useFakeTimers()
|
||||
const fixture = createFixture()
|
||||
writeAuth(fixture.home, {
|
||||
tokens: { access_token: 'access', id_token: testIdToken }
|
||||
})
|
||||
let resolved = false
|
||||
const readiness = waitForManagedCodexAuthReady(fixture.args)
|
||||
void readiness?.then(() => {
|
||||
resolved = true
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
expect(resolved).toBe(false)
|
||||
writeAuth(fixture.home, testChatGptAuth)
|
||||
await vi.advanceTimersByTimeAsync(25)
|
||||
|
||||
await expect(readiness).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('waits for an empty managed ChatGPT refresh token to be restored', async () => {
|
||||
vi.useFakeTimers()
|
||||
const fixture = createFixture()
|
||||
writeAuth(fixture.home, {
|
||||
...testChatGptAuth,
|
||||
tokens: { ...testChatGptAuth.tokens, refresh_token: '' }
|
||||
})
|
||||
let resolved = false
|
||||
const readiness = waitForManagedCodexAuthReady(fixture.args)
|
||||
void readiness?.then(() => {
|
||||
resolved = true
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
expect(resolved).toBe(false)
|
||||
writeAuth(fixture.home, testChatGptAuth)
|
||||
await vi.advanceTimersByTimeAsync(25)
|
||||
|
||||
await expect(readiness).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[
|
||||
'empty agent identity record',
|
||||
{ auth_mode: 'agentIdentity', agent_identity: {} },
|
||||
{
|
||||
auth_mode: 'agentIdentity',
|
||||
agent_identity: { agent_runtime_id: 'runtime', agent_private_key: 'private-key' }
|
||||
}
|
||||
],
|
||||
[
|
||||
'agent identity missing its private key',
|
||||
{
|
||||
auth_mode: 'agentIdentity',
|
||||
agent_identity: { agent_runtime_id: 'runtime', plan_type: 'team' }
|
||||
},
|
||||
{
|
||||
auth_mode: 'agentIdentity',
|
||||
agent_identity: { agent_runtime_id: 'runtime', agent_private_key: 'private-key' }
|
||||
}
|
||||
],
|
||||
[
|
||||
'agent identity with an empty runtime id',
|
||||
{
|
||||
auth_mode: 'agentIdentity',
|
||||
agent_identity: { agent_runtime_id: '', agent_private_key: 'private-key' }
|
||||
},
|
||||
{
|
||||
auth_mode: 'agentIdentity',
|
||||
agent_identity: { agent_runtime_id: 'runtime', agent_private_key: 'private-key' }
|
||||
}
|
||||
],
|
||||
[
|
||||
'object-valued personal access token',
|
||||
{ auth_mode: 'personalAccessToken', personal_access_token: { token_id: 'pat' } },
|
||||
{ auth_mode: 'personalAccessToken', personal_access_token: 'pat-test' }
|
||||
],
|
||||
[
|
||||
'Bedrock metadata without an API key',
|
||||
{ auth_mode: 'bedrockApiKey', bedrock_api_key: { region: 'us-east-1' } },
|
||||
{
|
||||
auth_mode: 'bedrockApiKey',
|
||||
bedrock_api_key: { api_key: 'bedrock-key', region: 'us-east-1' }
|
||||
}
|
||||
]
|
||||
])('waits for %s to become complete', async (_label, partial, complete) => {
|
||||
vi.useFakeTimers()
|
||||
const fixture = createFixture()
|
||||
writeAuth(fixture.home, partial)
|
||||
|
||||
const readiness = waitForManagedCodexAuthReady(fixture.args)
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
writeAuth(fixture.home, complete)
|
||||
await vi.advanceTimersByTimeAsync(25)
|
||||
|
||||
await expect(readiness).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('reports an unreadable managed credential after the retry window', async () => {
|
||||
vi.useFakeTimers()
|
||||
const fixture = createFixture()
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
writeFileSync(join(fixture.home, 'auth.json'), '{', 'utf8')
|
||||
|
||||
const readiness = waitForManagedCodexAuthReady(fixture.args)
|
||||
await vi.advanceTimersByTimeAsync(2_000)
|
||||
|
||||
await expect(readiness).resolves.toBe(false)
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'[codex-auth-readiness] Managed credential remained unavailable after 1500ms'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not gate system, WSL, or unmanaged custom homes', async () => {
|
||||
const fixture = createFixture()
|
||||
await waitForManagedCodexAuthReady({
|
||||
...fixture.args,
|
||||
codexHomePath: join(fixture.root, 'custom-home')
|
||||
})
|
||||
await waitForManagedCodexAuthReady({
|
||||
...fixture.args,
|
||||
target: { runtime: 'wsl', wslDistro: 'Ubuntu' }
|
||||
})
|
||||
await waitForManagedCodexAuthReady({
|
||||
...fixture.args,
|
||||
codexHomePath: null
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
function createFixture(): {
|
||||
root: string
|
||||
home: string
|
||||
args: Parameters<typeof waitForManagedCodexAuthReady>[0]
|
||||
} {
|
||||
const root = mkdtempSync(join(tmpdir(), 'orca-managed-codex-auth-'))
|
||||
roots.push(root)
|
||||
const home = join(root, 'account', 'home')
|
||||
mkdirSync(home, { recursive: true })
|
||||
const account = {
|
||||
id: 'account-1',
|
||||
managedHomePath: home,
|
||||
managedHomeRuntime: 'host'
|
||||
} as CodexManagedAccount
|
||||
return {
|
||||
root,
|
||||
home,
|
||||
args: {
|
||||
codexHomePath: home,
|
||||
settings: { codexManagedAccounts: [account] } as GlobalSettings,
|
||||
target: { runtime: 'host' }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function writeAuth(home: string, auth: object): void {
|
||||
writeFileSync(join(home, 'auth.json'), JSON.stringify(auth), { mode: 0o600 })
|
||||
}
|
||||
|
|
@ -0,0 +1,175 @@
|
|||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path'
|
||||
import type { GlobalSettings } from '../../shared/types'
|
||||
import type { CodexAccountSelectionTarget } from './runtime-selection'
|
||||
|
||||
const AUTH_READY_TIMEOUT_MS = 1_500
|
||||
const AUTH_READY_RETRY_MS = 25
|
||||
|
||||
type StoredCodexAuth = {
|
||||
auth_mode?: unknown
|
||||
OPENAI_API_KEY?: unknown
|
||||
agent_identity?: unknown
|
||||
personal_access_token?: unknown
|
||||
bedrock_api_key?: unknown
|
||||
last_refresh?: unknown
|
||||
tokens?: unknown
|
||||
}
|
||||
|
||||
const KNOWN_CREDENTIAL_KEYS = [
|
||||
'OPENAI_API_KEY',
|
||||
'tokens',
|
||||
'agent_identity',
|
||||
'personal_access_token',
|
||||
'bedrock_api_key'
|
||||
] as const
|
||||
const KNOWN_AGENT_IDENTITY_RECORD_KEYS = [
|
||||
'agent_runtime_id',
|
||||
'agent_private_key',
|
||||
'plan_type',
|
||||
'task_id'
|
||||
] as const
|
||||
|
||||
export function waitForManagedCodexAuthReady(args: {
|
||||
codexHomePath: string | null
|
||||
settings: GlobalSettings | undefined
|
||||
target: CodexAccountSelectionTarget
|
||||
}): Promise<boolean> | undefined {
|
||||
if (isCodexHomeAuthReadyForLaunch(args)) {
|
||||
return
|
||||
}
|
||||
return waitForStoredCodexCredential(join(args.codexHomePath!, 'auth.json'))
|
||||
}
|
||||
|
||||
export function isCodexHomeAuthReadyForLaunch(args: {
|
||||
codexHomePath: string | null
|
||||
settings: GlobalSettings | undefined
|
||||
target: CodexAccountSelectionTarget
|
||||
}): boolean {
|
||||
return (
|
||||
args.target.runtime !== 'host' ||
|
||||
!args.codexHomePath ||
|
||||
!isManagedHostCodexHome(args.codexHomePath, args.settings) ||
|
||||
hasStoredCodexCredential(join(args.codexHomePath, 'auth.json'))
|
||||
)
|
||||
}
|
||||
|
||||
async function waitForStoredCodexCredential(authPath: string): Promise<boolean> {
|
||||
const deadline = Date.now() + AUTH_READY_TIMEOUT_MS
|
||||
do {
|
||||
await delay(AUTH_READY_RETRY_MS)
|
||||
if (hasStoredCodexCredential(authPath)) {
|
||||
return true
|
||||
}
|
||||
} while (Date.now() < deadline)
|
||||
|
||||
console.warn(
|
||||
`[codex-auth-readiness] Managed credential remained unavailable after ${AUTH_READY_TIMEOUT_MS}ms`
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
function isManagedHostCodexHome(
|
||||
codexHomePath: string,
|
||||
settings: GlobalSettings | undefined
|
||||
): boolean {
|
||||
const expected = normalizeRuntimePathForComparison(codexHomePath)
|
||||
return (
|
||||
settings?.codexManagedAccounts?.some(
|
||||
(account) =>
|
||||
account.managedHomeRuntime !== 'wsl' &&
|
||||
normalizeRuntimePathForComparison(account.managedHomePath) === expected
|
||||
) === true
|
||||
)
|
||||
}
|
||||
|
||||
export function hasStoredCodexCredential(authPath: string): boolean {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(readFileSync(authPath, 'utf8'))
|
||||
if (!isRecord(parsed) || Object.keys(parsed).length === 0) {
|
||||
return false
|
||||
}
|
||||
const auth = parsed as StoredCodexAuth
|
||||
return auth.auth_mode == null
|
||||
? hasCredentialWithoutDeclaredMode(auth)
|
||||
: hasCredentialForDeclaredMode(auth)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function hasCredentialForDeclaredMode(auth: StoredCodexAuth): boolean {
|
||||
if (!isNonEmptyString(auth.auth_mode)) {
|
||||
return false
|
||||
}
|
||||
switch (auth.auth_mode) {
|
||||
case 'apikey':
|
||||
return isNonEmptyString(auth.OPENAI_API_KEY)
|
||||
case 'chatgpt':
|
||||
case 'chatgptAuthTokens':
|
||||
return hasChatGptCredential(auth.tokens)
|
||||
case 'agentIdentity':
|
||||
return hasAgentIdentityCredential(auth.agent_identity)
|
||||
case 'personalAccessToken':
|
||||
return isNonEmptyString(auth.personal_access_token)
|
||||
case 'bedrockApiKey':
|
||||
return hasBedrockApiKey(auth.bedrock_api_key)
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
function hasCredentialWithoutDeclaredMode(auth: StoredCodexAuth): boolean {
|
||||
const knownCredentialPresent = KNOWN_CREDENTIAL_KEYS.some((key) => key in auth)
|
||||
if (knownCredentialPresent) {
|
||||
return (
|
||||
isNonEmptyString(auth.OPENAI_API_KEY) ||
|
||||
hasChatGptCredential(auth.tokens) ||
|
||||
hasAgentIdentityCredential(auth.agent_identity) ||
|
||||
isNonEmptyString(auth.personal_access_token) ||
|
||||
hasBedrockApiKey(auth.bedrock_api_key)
|
||||
)
|
||||
}
|
||||
return Object.keys(auth).some((key) => key !== 'auth_mode' && key !== 'last_refresh')
|
||||
}
|
||||
|
||||
function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === 'string' && value.trim().length > 0
|
||||
}
|
||||
|
||||
function hasChatGptCredential(tokens: unknown): boolean {
|
||||
if (!isRecord(tokens)) {
|
||||
return false
|
||||
}
|
||||
return (
|
||||
isNonEmptyString(tokens.access_token) &&
|
||||
isNonEmptyString(tokens.id_token) &&
|
||||
isNonEmptyString(tokens.refresh_token)
|
||||
)
|
||||
}
|
||||
|
||||
function hasAgentIdentityCredential(value: unknown): boolean {
|
||||
if (isNonEmptyString(value)) {
|
||||
return true
|
||||
}
|
||||
if (!isRecord(value) || Object.keys(value).length === 0) {
|
||||
return false
|
||||
}
|
||||
return KNOWN_AGENT_IDENTITY_RECORD_KEYS.some((key) => key in value)
|
||||
? isNonEmptyString(value.agent_runtime_id) && isNonEmptyString(value.agent_private_key)
|
||||
: true
|
||||
}
|
||||
|
||||
function hasBedrockApiKey(value: unknown): boolean {
|
||||
return isRecord(value) && isNonEmptyString(value.api_key)
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
|
@ -179,9 +179,10 @@ describe('CodexRuntimeHomeService per-account takeover composition', () => {
|
|||
rmSync(accountAuthPath)
|
||||
writeFileSync(sharedAuthPath(), laterShared, 'utf-8')
|
||||
|
||||
expect(service.prepareForCodexLaunch()).toBeNull()
|
||||
expect(service.prepareForCodexLaunch()).toBe(account.managedHomePath)
|
||||
expect(service.prepareForRateLimitFetch()).toBe(account.managedHomePath)
|
||||
expect(existsSync(accountAuthPath)).toBe(false)
|
||||
expect(settings.activeCodexManagedAccountId).toBeNull()
|
||||
expect(settings.activeCodexManagedAccountId).toBe(account.id)
|
||||
expect(readFileSync(sharedAuthPath(), 'utf-8')).toBe(laterShared)
|
||||
expect(readFileSync(systemAuthPath(), 'utf-8')).toBe('system auth sentinel\n')
|
||||
})
|
||||
|
|
|
|||
|
|
@ -268,7 +268,9 @@ function createCodexAuthJson(
|
|||
].join('.')
|
||||
|
||||
return `${JSON.stringify({
|
||||
auth_mode: 'chatgpt',
|
||||
tokens: {
|
||||
access_token: `access-${accountId}`,
|
||||
id_token: idToken,
|
||||
account_id: accountId,
|
||||
...(expiresAt === undefined ? {} : { expires_at: expiresAt }),
|
||||
|
|
@ -1194,6 +1196,12 @@ describe('CodexRuntimeHomeService', () => {
|
|||
settings.activeCodexManagedAccountId = 'account-2'
|
||||
settings.activeCodexManagedAccountIdsByRuntime = { host: 'account-2', wsl: {} }
|
||||
expect(service.prepareForCodexLaunch()).toBe(home2)
|
||||
expect(
|
||||
service.prepareForCodexLaunch(undefined, undefined, {
|
||||
unavailableManagedHomePath: home1
|
||||
})
|
||||
).toBe(home2)
|
||||
expect(store.updateSettings).not.toHaveBeenCalled()
|
||||
|
||||
// Nothing is hot-swapped, so the still-running account-1 pane keeps seeing
|
||||
// account-1's credentials — the single-auth.json race (GAP-5) is gone.
|
||||
|
|
@ -1280,7 +1288,7 @@ describe('CodexRuntimeHomeService', () => {
|
|||
expect(service.prepareForRateLimitFetch()).toBe(home1)
|
||||
})
|
||||
|
||||
it('drops a managed selection whose auth.json vanished and resolves the real home (flag ON)', async () => {
|
||||
it('preserves a managed selection whose auth.json is temporarily missing (flag ON)', async () => {
|
||||
writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8')
|
||||
// A managed home that has lost its auth.json (only the marker remains).
|
||||
const brokenHome = join(testState.userDataDir, 'codex-accounts', 'account-1', 'home')
|
||||
|
|
@ -1308,9 +1316,9 @@ describe('CodexRuntimeHomeService', () => {
|
|||
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
|
||||
const service = new CodexRuntimeHomeService(store as never)
|
||||
|
||||
// The broken account is dropped and the launch resolves to the real home.
|
||||
expect(service.prepareForCodexLaunch()).toBeNull()
|
||||
expect(store.getSettings().activeCodexManagedAccountId).toBeNull()
|
||||
expect(service.prepareForCodexLaunch()).toBe(brokenHome)
|
||||
expect(service.prepareForRateLimitFetch()).toBe(brokenHome)
|
||||
expect(store.getSettings().activeCodexManagedAccountId).toBe('account-1')
|
||||
})
|
||||
|
||||
it('keeps the shared runtime home + auth hot-swap for managed accounts when the flag is OFF', async () => {
|
||||
|
|
@ -1641,13 +1649,18 @@ describe('CodexRuntimeHomeService', () => {
|
|||
expect(syncSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails closed when per-account auth disappears before a real-home fallback', async () => {
|
||||
it('preserves selected identity when per-account auth disappears before launch', async () => {
|
||||
const runtimeAuthPath = getRuntimeCodexAuthPath()
|
||||
const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system')
|
||||
const account1Auth = createCodexAuthJson('one@example.com', 'acct-1', 'one', 1)
|
||||
const account1Refreshed = createCodexAuthJson('one@example.com', 'acct-1', 'one-refreshed', 2)
|
||||
writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8')
|
||||
const managedHomePath1 = createManagedAuth(testState.userDataDir, 'account-1', account1Auth)
|
||||
const wslManagedHomePath = createManagedAuth(
|
||||
testState.userDataDir,
|
||||
'wsl-account',
|
||||
createCodexAuthJson('wsl@example.com', 'acct-wsl', 'wsl')
|
||||
)
|
||||
const settings = createSettings({
|
||||
codexSystemDefaultRealHomeEnabled: true,
|
||||
codexManagedAccounts: [
|
||||
|
|
@ -1661,10 +1674,24 @@ describe('CodexRuntimeHomeService', () => {
|
|||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
lastAuthenticatedAt: 1
|
||||
},
|
||||
{
|
||||
id: 'wsl-account',
|
||||
email: 'wsl@example.com',
|
||||
managedHomePath: wslManagedHomePath,
|
||||
managedHomeRuntime: 'wsl',
|
||||
wslDistro: 'Ubuntu',
|
||||
wslLinuxHomePath: '/home/alice/.local/share/orca/codex-accounts/wsl-account/home',
|
||||
createdAt: 2,
|
||||
updatedAt: 2,
|
||||
lastAuthenticatedAt: 2
|
||||
}
|
||||
],
|
||||
activeCodexManagedAccountId: 'account-1',
|
||||
activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} }
|
||||
activeCodexManagedAccountIdsByRuntime: {
|
||||
host: 'account-1',
|
||||
wsl: { Ubuntu: 'wsl-account' }
|
||||
}
|
||||
})
|
||||
const store = createStore(settings)
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
|
@ -1674,27 +1701,52 @@ describe('CodexRuntimeHomeService', () => {
|
|||
// A stale pre-E process leaves a matching refresh in the shared runtime home.
|
||||
writeFileSync(runtimeAuthPath, account1Refreshed, 'utf-8')
|
||||
|
||||
// The active account's canonical auth disappears before launch. This is the
|
||||
// same-account auto-deselect path, where no ordinary outgoing switch runs.
|
||||
// The active account's canonical auth disappears before launch.
|
||||
rmSync(join(managedHomePath1, 'auth.json'), { force: true })
|
||||
expect(service.prepareForCodexLaunch()).toBeNull()
|
||||
expect(service.prepareForCodexLaunch()).toBe(managedHomePath1)
|
||||
|
||||
// Missing canonical auth clears selection without reviving shared bytes.
|
||||
// Missing canonical auth preserves selection without reviving shared bytes.
|
||||
expect(existsSync(join(managedHomePath1, 'auth.json'))).toBe(false)
|
||||
expect(readFileSync(getSystemCodexAuthPath(), 'utf-8')).toBe(systemAuth)
|
||||
expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(account1Refreshed)
|
||||
expect(store.getSettings().activeCodexManagedAccountId).toBe('account-1')
|
||||
expect(store.updateSettings).not.toHaveBeenCalled()
|
||||
expect(warnSpy).not.toHaveBeenCalled()
|
||||
|
||||
expect(service.isHostSystemDefaultRealHome()).toBe(false)
|
||||
expect(service.prepareForRateLimitFetch()).toBe(managedHomePath1)
|
||||
expect(service.prepareForCodexLaunch()).toBe(managedHomePath1)
|
||||
expect(existsSync(join(managedHomePath1, 'auth.json'))).toBe(false)
|
||||
|
||||
writeFileSync(join(managedHomePath1, 'auth.json'), account1Auth, 'utf-8')
|
||||
expect(
|
||||
service.prepareForCodexLaunch(undefined, undefined, {
|
||||
unavailableManagedHomePath: managedHomePath1
|
||||
})
|
||||
).toBe(managedHomePath1)
|
||||
expect(store.updateSettings).not.toHaveBeenCalled()
|
||||
|
||||
rmSync(join(managedHomePath1, 'auth.json'), { force: true })
|
||||
expect(
|
||||
service.prepareForCodexLaunch(undefined, undefined, {
|
||||
unavailableManagedHomePath: managedHomePath1
|
||||
})
|
||||
).toBeNull()
|
||||
expect(store.getSettings().activeCodexManagedAccountId).toBeNull()
|
||||
expect(store.getSettings().activeCodexManagedAccountIdsByRuntime).toEqual({
|
||||
host: null,
|
||||
wsl: { Ubuntu: 'wsl-account' }
|
||||
})
|
||||
expect(store.getSettings().codexManagedAccounts).toHaveLength(2)
|
||||
expect(store.updateSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ activeCodexManagedAccountId: null })
|
||||
)
|
||||
expect(warnSpy).toHaveBeenCalled()
|
||||
|
||||
// The follow-up launch takes the real-home lane and remains fail-closed.
|
||||
expect(service.isHostSystemDefaultRealHome()).toBe(true)
|
||||
expect(service.prepareForCodexLaunch()).toBeNull()
|
||||
expect(existsSync(join(managedHomePath1, 'auth.json'))).toBe(false)
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'[codex-runtime-home] Active managed account credential remained unavailable, clearing selection'
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses mismatched runtime auth when the active managed auth is missing', async () => {
|
||||
it('ignores mismatched runtime auth when the active managed auth is missing', async () => {
|
||||
const runtimeAuthPath = getRuntimeCodexAuthPath()
|
||||
const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system')
|
||||
const managedAuth = createCodexAuthJson('user@example.com', 'acct-user', 'managed', 1)
|
||||
|
|
@ -1726,12 +1778,13 @@ describe('CodexRuntimeHomeService', () => {
|
|||
|
||||
writeFileSync(runtimeAuthPath, mismatchedAuth, 'utf-8')
|
||||
rmSync(join(managedHomePath, 'auth.json'), { force: true })
|
||||
expect(service.prepareForCodexLaunch()).toBeNull()
|
||||
expect(service.prepareForCodexLaunch()).toBe(managedHomePath)
|
||||
|
||||
expect(existsSync(join(managedHomePath, 'auth.json'))).toBe(false)
|
||||
expect(readFileSync(getSystemCodexAuthPath(), 'utf-8')).toBe(systemAuth)
|
||||
expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(mismatchedAuth)
|
||||
expect(warnSpy).toHaveBeenCalled()
|
||||
expect(store.getSettings().activeCodexManagedAccountId).toBe('account-1')
|
||||
expect(warnSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores shared auth on an explicit managed-to-real-home deselect', async () => {
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import {
|
|||
} from 'node:path'
|
||||
import { app } from 'electron'
|
||||
import type { CodexManagedAccount } from '../../shared/types'
|
||||
import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path'
|
||||
import type { Store } from '../persistence'
|
||||
import { WSL_CODEX_RUNTIME_HOME_SEGMENTS } from '../pty/codex-home-wsl-env'
|
||||
import { writeFileAtomically } from './fs-utils'
|
||||
|
|
@ -72,6 +73,7 @@ import {
|
|||
codexAuthMatchesSystemDefaultIdentity
|
||||
} from './codex-auth-identity'
|
||||
import { migrateLegacySharedAuthToPerAccountHome } from './legacy-shared-auth-migration'
|
||||
import { hasStoredCodexCredential } from './managed-codex-auth-readiness'
|
||||
|
||||
type CodexSystemDefaultSnapshot = {
|
||||
authJson: string | null
|
||||
|
|
@ -153,7 +155,8 @@ export class CodexRuntimeHomeService {
|
|||
*/
|
||||
prepareForCodexLaunch(
|
||||
target?: CodexAccountSelectionTarget,
|
||||
launchEnv?: NodeJS.ProcessEnv
|
||||
launchEnv?: NodeJS.ProcessEnv,
|
||||
options?: { unavailableManagedHomePath?: string }
|
||||
): string | null {
|
||||
if (target?.runtime === 'wsl') {
|
||||
const wslTarget = this.resolveWslDefaultTarget(target)
|
||||
|
|
@ -165,12 +168,15 @@ export class CodexRuntimeHomeService {
|
|||
}
|
||||
const selfContainedAccount = this.getSelfContainedManagedHostAccount()
|
||||
if (selfContainedAccount) {
|
||||
const perAccountHome = this.prepareSelfContainedManagedHomeForLaunch(selfContainedAccount)
|
||||
const perAccountHome = this.prepareSelfContainedManagedHomeForLaunch(
|
||||
selfContainedAccount,
|
||||
options?.unavailableManagedHomePath
|
||||
)
|
||||
if (perAccountHome) {
|
||||
return perAccountHome
|
||||
}
|
||||
// Why: the account's home lost its auth.json, so the selection was just
|
||||
// dropped. Fall through and resolve this launch as the system default.
|
||||
// Why: only an untrusted home clears the selection; fall through to the
|
||||
// system default without injecting a path Orca cannot prove it owns.
|
||||
}
|
||||
if (this.isHostSystemDefaultRealHome(launchEnv)) {
|
||||
// Why (flag ON, system default): run Codex on the user's own ~/.codex.
|
||||
|
|
@ -235,14 +241,24 @@ export class CodexRuntimeHomeService {
|
|||
return homes
|
||||
}
|
||||
|
||||
private prepareSelfContainedManagedHomeForLaunch(account: CodexManagedAccount): string | null {
|
||||
private prepareSelfContainedManagedHomeForLaunch(
|
||||
account: CodexManagedAccount,
|
||||
unavailableManagedHomePath?: string
|
||||
): string | null {
|
||||
const perAccountHome = this.getTrustedSelfContainedManagedHomePath(account)
|
||||
if (!perAccountHome || !existsSync(join(perAccountHome, 'auth.json'))) {
|
||||
// Why: drop the selection so this and future launches resolve to the
|
||||
// system default rather than a home codex cannot authenticate against.
|
||||
if (!perAccountHome) {
|
||||
this.clearSelfContainedManagedSelection(account)
|
||||
return null
|
||||
}
|
||||
if (
|
||||
unavailableManagedHomePath &&
|
||||
normalizeRuntimePathForComparison(unavailableManagedHomePath) ===
|
||||
normalizeRuntimePathForComparison(perAccountHome) &&
|
||||
!hasStoredCodexCredential(join(perAccountHome, 'auth.json'))
|
||||
) {
|
||||
this.clearSelfContainedManagedSelection(account, 'credential remained unavailable')
|
||||
return null
|
||||
}
|
||||
// Why: link the user's real ~/.codex resources and mirror config into THIS
|
||||
// home (never symlinking into or mutating ~/.codex), so the per-account home
|
||||
// is a complete CODEX_HOME. Hooks/trust are installed by the launch caller.
|
||||
|
|
@ -282,11 +298,11 @@ export class CodexRuntimeHomeService {
|
|||
|
||||
// Why: the per-account home is both the launch CODEX_HOME and the credential
|
||||
// store, so codex reads/refreshes auth.json in place — there is no shared-home
|
||||
// hot-swap or token read-back to reconcile. Only validate the credential
|
||||
// survives; a vanished auth.json drops the selection to the system default.
|
||||
// hot-swap or token read-back to reconcile. A trusted home remains selected
|
||||
// while Codex atomically replaces auth.json.
|
||||
private syncSelfContainedManagedSelection(account: CodexManagedAccount): void {
|
||||
const perAccountHome = this.getTrustedSelfContainedManagedHomePath(account)
|
||||
if (perAccountHome && existsSync(join(perAccountHome, 'auth.json'))) {
|
||||
if (perAccountHome) {
|
||||
this.lastSyncedAccountId = account.id
|
||||
this.lastHostAccountUsedSelfContainedHome = true
|
||||
// Why: selection runs well before the user restarts a pane, so history is
|
||||
|
|
@ -314,10 +330,11 @@ export class CodexRuntimeHomeService {
|
|||
}
|
||||
}
|
||||
|
||||
private clearSelfContainedManagedSelection(account: CodexManagedAccount): void {
|
||||
console.warn(
|
||||
'[codex-runtime-home] Active managed account home is invalid or missing auth.json, clearing selection'
|
||||
)
|
||||
private clearSelfContainedManagedSelection(
|
||||
account: CodexManagedAccount,
|
||||
reason = 'home is invalid'
|
||||
): void {
|
||||
console.warn(`[codex-runtime-home] Active managed account ${reason}, clearing selection`)
|
||||
const settings = this.store.getSettings()
|
||||
if (normalizeCodexRuntimeSelection(settings).host !== account.id) {
|
||||
return
|
||||
|
|
@ -516,14 +533,9 @@ export class CodexRuntimeHomeService {
|
|||
const selfContainedHome = selfContainedAccount
|
||||
? this.getTrustedSelfContainedManagedHomePath(selfContainedAccount)
|
||||
: null
|
||||
if (
|
||||
selfContainedAccount &&
|
||||
selfContainedHome &&
|
||||
existsSync(join(selfContainedHome, 'auth.json'))
|
||||
) {
|
||||
if (selfContainedAccount && selfContainedHome) {
|
||||
// Why: the quota fetch reads the account's own auth.json in place; no
|
||||
// shared-home hot-swap, and no per-poll resource relink (that is launch
|
||||
// prep). Config was mirrored on add/select and refreshed at launch.
|
||||
// shared-home hot-swap or per-poll resource relink (that is launch prep).
|
||||
return selfContainedHome
|
||||
}
|
||||
if (selfContainedAccount) {
|
||||
|
|
|
|||
|
|
@ -28,7 +28,8 @@ import {
|
|||
registerPaneKeyTeardownListener,
|
||||
getLocalPtyProvider,
|
||||
getSshPtyProvider,
|
||||
registerHeadlessPtyRuntime
|
||||
registerHeadlessPtyRuntime,
|
||||
type CodexHomeLaunchContext
|
||||
} from './ipc/pty'
|
||||
import {
|
||||
initDaemonPtyProvider,
|
||||
|
|
@ -98,7 +99,7 @@ import {
|
|||
resolveUpdateInstallMode
|
||||
} from './updater'
|
||||
import { configureRemoteServerUpdater } from './runtime/remote-server-updater'
|
||||
import type { TuiAgent, UpdateCheckOptions } from '../shared/types'
|
||||
import type { UpdateCheckOptions } from '../shared/types'
|
||||
import { recordUpdaterLifecycle } from './updater-lifecycle-diagnostics'
|
||||
import {
|
||||
installServeSupervisorDisconnectQuit,
|
||||
|
|
@ -890,7 +891,7 @@ function startTerminalRuntimeStartupServices(): Promise<void> {
|
|||
function prepareCodexRuntimeHomeForLaunch(
|
||||
target?: CodexAccountSelectionTarget,
|
||||
launchEnv?: NodeJS.ProcessEnv,
|
||||
launchContext?: { workspacePath?: string; launchAgent?: TuiAgent }
|
||||
launchContext?: CodexHomeLaunchContext
|
||||
): string | null {
|
||||
if (
|
||||
target?.runtime !== 'wsl' &&
|
||||
|
|
@ -922,14 +923,18 @@ function prepareCodexRuntimeHomeForLaunch(
|
|||
return true
|
||||
}
|
||||
let realHomeHooksPrepared = ensureRealHomeHooksIfSelected()
|
||||
let runtimeHomePath = codexRuntimeHome!.prepareForCodexLaunch(target, launchEnv)
|
||||
let runtimeHomePath = codexRuntimeHome!.prepareForCodexLaunch(target, launchEnv, {
|
||||
unavailableManagedHomePath: launchContext?.unavailableManagedHomePath
|
||||
})
|
||||
if (runtimeHomePath === null && !realHomeHooksPrepared) {
|
||||
// Why: a managed home can lose auth during launch prep, which clears its
|
||||
// selection and falls through to real home. Establish hook capability for
|
||||
// that newly selected lane, then re-resolve if the capability gate rejects it.
|
||||
// Why: launch prep can reject an untrusted managed home and clear its
|
||||
// selection. Establish hook capability for that newly selected lane, then
|
||||
// re-resolve if the capability gate rejects it.
|
||||
realHomeHooksPrepared = ensureRealHomeHooksIfSelected()
|
||||
if (realHomeHooksPrepared) {
|
||||
runtimeHomePath = codexRuntimeHome!.prepareForCodexLaunch(target, launchEnv)
|
||||
runtimeHomePath = codexRuntimeHome!.prepareForCodexLaunch(target, launchEnv, {
|
||||
unavailableManagedHomePath: launchContext?.unavailableManagedHomePath
|
||||
})
|
||||
}
|
||||
}
|
||||
if (runtimeHomePath === null && target?.runtime !== 'wsl') {
|
||||
|
|
|
|||
|
|
@ -226,6 +226,7 @@ import {
|
|||
setPtyOwnership,
|
||||
setLocalPtyProvider,
|
||||
rebindLocalProviderListeners,
|
||||
resolveCodexHomeAfterManagedAuthReadiness,
|
||||
unregisterSshPtyProvider,
|
||||
getLocalPtyProvider,
|
||||
isCurrentPtyExit,
|
||||
|
|
@ -269,6 +270,15 @@ const TEST_CODEX_HOME =
|
|||
process.platform === 'win32'
|
||||
? 'C:\\Users\\test\\AppData\\Roaming\\orca\\codex-runtime-home\\home'
|
||||
: '/tmp/orca-codex-home'
|
||||
const TEST_CODEX_AUTH_JSON = JSON.stringify({
|
||||
tokens: {
|
||||
access_token: 'access',
|
||||
id_token: 'e30.eyJlbWFpbCI6InVzZXJAZXhhbXBsZS5jb20ifQ.sig',
|
||||
refresh_token: 'refresh',
|
||||
account_id: 'account'
|
||||
},
|
||||
last_refresh: '2026-07-31T00:00:00Z'
|
||||
})
|
||||
|
||||
function makeDisposable() {
|
||||
return { dispose: vi.fn() }
|
||||
|
|
@ -2768,6 +2778,70 @@ describe('registerPtyHandlers', () => {
|
|||
expect(env.ORCA_CODEX_HOME).toBe(TEST_CODEX_HOME)
|
||||
})
|
||||
|
||||
it('waits for managed Codex auth before spawning a local PTY', async () => {
|
||||
vi.useFakeTimers()
|
||||
let authReady = false
|
||||
readFileSyncMock.mockImplementation((filePath: string) => {
|
||||
if (!filePath.endsWith('auth.json')) {
|
||||
return ''
|
||||
}
|
||||
if (!authReady) {
|
||||
throw Object.assign(new Error('missing auth'), { code: 'ENOENT' })
|
||||
}
|
||||
return TEST_CODEX_AUTH_JSON
|
||||
})
|
||||
handlers.clear()
|
||||
registerPtyHandlers(mainWindow as never, undefined, () => TEST_CODEX_HOME, (() => ({
|
||||
codexManagedAccounts: [
|
||||
{
|
||||
id: 'account-1',
|
||||
managedHomePath: TEST_CODEX_HOME,
|
||||
managedHomeRuntime: 'host'
|
||||
}
|
||||
]
|
||||
})) as never)
|
||||
|
||||
const spawnPromise = handlers.get('pty:spawn')!(null, {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
launchAgent: 'codex'
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(spawnMock).not.toHaveBeenCalled()
|
||||
|
||||
authReady = true
|
||||
await vi.advanceTimersByTimeAsync(25)
|
||||
await spawnPromise
|
||||
|
||||
expect(spawnMock.mock.calls.at(-1)?.[2].env).toMatchObject({
|
||||
CODEX_HOME: TEST_CODEX_HOME,
|
||||
ORCA_CODEX_HOME: TEST_CODEX_HOME
|
||||
})
|
||||
})
|
||||
|
||||
it('does not gate a bare local shell on managed Codex auth', async () => {
|
||||
readFileSyncMock.mockImplementation((filePath: string) => {
|
||||
if (filePath.endsWith('auth.json')) {
|
||||
throw Object.assign(new Error('missing auth'), { code: 'ENOENT' })
|
||||
}
|
||||
return ''
|
||||
})
|
||||
handlers.clear()
|
||||
registerPtyHandlers(mainWindow as never, undefined, () => TEST_CODEX_HOME, (() => ({
|
||||
codexManagedAccounts: [
|
||||
{
|
||||
id: 'account-1',
|
||||
managedHomePath: TEST_CODEX_HOME,
|
||||
managedHomeRuntime: 'host'
|
||||
}
|
||||
]
|
||||
})) as never)
|
||||
|
||||
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('leaves an inherited CODEX_HOME untouched for system default when the flag is OFF', async () => {
|
||||
// Why: flag OFF must stay byte-identical to today. With no managed home
|
||||
// selected (resolver null) and the real-home flag off, no CODEX_HOME
|
||||
|
|
@ -3016,6 +3090,371 @@ describe('registerPtyHandlers', () => {
|
|||
).env
|
||||
}
|
||||
|
||||
it('waits for managed Codex auth before spawning a daemon PTY', async () => {
|
||||
vi.useFakeTimers()
|
||||
let authReady = false
|
||||
readFileSyncMock.mockImplementation((filePath: string) => {
|
||||
if (!filePath.endsWith('auth.json')) {
|
||||
return ''
|
||||
}
|
||||
if (!authReady) {
|
||||
throw Object.assign(new Error('missing auth'), { code: 'ENOENT' })
|
||||
}
|
||||
return TEST_CODEX_AUTH_JSON
|
||||
})
|
||||
const daemonSpawn = setupDaemonAdapter()
|
||||
handlers.clear()
|
||||
registerPtyHandlers(mainWindow as never, undefined, () => TEST_CODEX_HOME, (() => ({
|
||||
codexManagedAccounts: [
|
||||
{
|
||||
id: 'account-1',
|
||||
managedHomePath: TEST_CODEX_HOME,
|
||||
managedHomeRuntime: 'host'
|
||||
}
|
||||
]
|
||||
})) as never)
|
||||
|
||||
const spawnPromise = handlers.get('pty:spawn')!(null, {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
launchAgent: 'codex'
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(daemonSpawn).not.toHaveBeenCalled()
|
||||
|
||||
authReady = true
|
||||
await vi.advanceTimersByTimeAsync(25)
|
||||
await spawnPromise
|
||||
|
||||
expect(daemonSpawn.mock.calls.at(-1)?.[0].env).toMatchObject({
|
||||
CODEX_HOME: TEST_CODEX_HOME,
|
||||
ORCA_CODEX_HOME: TEST_CODEX_HOME
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves valid managed Codex auth synchronously', () => {
|
||||
readFileSyncMock.mockReturnValue(TEST_CODEX_AUTH_JSON)
|
||||
const resolveCurrent = vi.fn(() => TEST_CODEX_HOME)
|
||||
const resolveAfterUnavailable = vi.fn(() => null)
|
||||
const settings = {
|
||||
codexManagedAccounts: [
|
||||
{
|
||||
id: 'account-1',
|
||||
managedHomePath: TEST_CODEX_HOME,
|
||||
managedHomeRuntime: 'host'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const resolution = resolveCodexHomeAfterManagedAuthReadiness({
|
||||
selectedCodexHomePath: TEST_CODEX_HOME,
|
||||
getSettings: () => settings as never,
|
||||
target: { runtime: 'host' },
|
||||
resolveCurrent,
|
||||
resolveAfterUnavailable
|
||||
})
|
||||
|
||||
expect(resolution).toBe(TEST_CODEX_HOME)
|
||||
expect(resolveCurrent).not.toHaveBeenCalled()
|
||||
expect(resolveAfterUnavailable).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses the current account when the original auth recovers after a switch', async () => {
|
||||
vi.useFakeTimers()
|
||||
const nextHome = '/managed/next/home'
|
||||
let originalAuthReady = false
|
||||
let selectedHome = TEST_CODEX_HOME
|
||||
readFileSyncMock.mockImplementation((filePath: string) => {
|
||||
if (filePath === join(TEST_CODEX_HOME, 'auth.json') && !originalAuthReady) {
|
||||
throw Object.assign(new Error('missing auth'), { code: 'ENOENT' })
|
||||
}
|
||||
if (filePath.endsWith('auth.json')) {
|
||||
return TEST_CODEX_AUTH_JSON
|
||||
}
|
||||
return ''
|
||||
})
|
||||
const daemonSpawn = setupDaemonAdapter()
|
||||
const resolveHome = vi.fn(() => selectedHome)
|
||||
handlers.clear()
|
||||
registerPtyHandlers(mainWindow as never, undefined, resolveHome, (() => ({
|
||||
codexManagedAccounts: [TEST_CODEX_HOME, nextHome].map((managedHomePath, index) => ({
|
||||
id: `account-${index + 1}`,
|
||||
managedHomePath,
|
||||
managedHomeRuntime: 'host'
|
||||
}))
|
||||
})) as never)
|
||||
|
||||
const spawnPromise = handlers.get('pty:spawn')!(null, {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
launchAgent: 'codex'
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
selectedHome = nextHome
|
||||
originalAuthReady = true
|
||||
await vi.advanceTimersByTimeAsync(25)
|
||||
await spawnPromise
|
||||
|
||||
expect(resolveHome).toHaveBeenCalledTimes(2)
|
||||
expect(daemonSpawn.mock.calls[0]?.[0].env).toMatchObject({
|
||||
CODEX_HOME: nextHome,
|
||||
ORCA_CODEX_HOME: nextHome
|
||||
})
|
||||
})
|
||||
|
||||
it('does not gate a non-Codex daemon PTY on managed Codex auth', async () => {
|
||||
readFileSyncMock.mockImplementation((filePath: string) => {
|
||||
if (filePath.endsWith('auth.json')) {
|
||||
throw Object.assign(new Error('missing auth'), { code: 'ENOENT' })
|
||||
}
|
||||
return ''
|
||||
})
|
||||
const daemonSpawn = setupDaemonAdapter()
|
||||
handlers.clear()
|
||||
registerPtyHandlers(mainWindow as never, undefined, () => TEST_CODEX_HOME, (() => ({
|
||||
codexManagedAccounts: [
|
||||
{
|
||||
id: 'account-1',
|
||||
managedHomePath: TEST_CODEX_HOME,
|
||||
managedHomeRuntime: 'host'
|
||||
}
|
||||
]
|
||||
})) as never)
|
||||
|
||||
await handlers.get('pty:spawn')!(null, {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
launchAgent: 'claude'
|
||||
})
|
||||
|
||||
expect(daemonSpawn).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not gate a Codex daemon reattach on current managed auth', async () => {
|
||||
readFileSyncMock.mockImplementation((filePath: string) => {
|
||||
if (filePath.endsWith('auth.json')) {
|
||||
throw Object.assign(new Error('missing auth'), { code: 'ENOENT' })
|
||||
}
|
||||
return ''
|
||||
})
|
||||
const daemonSpawn = setupDaemonAdapter()
|
||||
handlers.clear()
|
||||
registerPtyHandlers(mainWindow as never, undefined, () => TEST_CODEX_HOME, (() => ({
|
||||
codexManagedAccounts: [
|
||||
{
|
||||
id: 'account-1',
|
||||
managedHomePath: TEST_CODEX_HOME,
|
||||
managedHomeRuntime: 'host'
|
||||
}
|
||||
]
|
||||
})) as never)
|
||||
|
||||
await handlers.get('pty:spawn')!(null, {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
launchAgent: 'codex',
|
||||
sessionId: 'retained-codex'
|
||||
})
|
||||
|
||||
expect(daemonSpawn).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not gate a runtime-created Codex reattach on current managed auth', async () => {
|
||||
type RuntimeSpawnController = {
|
||||
spawn(args: {
|
||||
cols: number
|
||||
rows: number
|
||||
launchAgent: 'codex'
|
||||
sessionId: string
|
||||
}): Promise<{ id: string }>
|
||||
}
|
||||
readFileSyncMock.mockImplementation((filePath: string) => {
|
||||
if (filePath.endsWith('auth.json')) {
|
||||
throw Object.assign(new Error('missing auth'), { code: 'ENOENT' })
|
||||
}
|
||||
return ''
|
||||
})
|
||||
const daemonSpawn = setupDaemonAdapter()
|
||||
const runtime = {
|
||||
setPtyController: vi.fn(),
|
||||
registerPty: vi.fn(),
|
||||
noteTerminalSpawnCommand: vi.fn(),
|
||||
onPtySpawned: vi.fn(),
|
||||
onPtyExit: vi.fn(),
|
||||
onPtyData: vi.fn()
|
||||
}
|
||||
handlers.clear()
|
||||
registerPtyHandlers(mainWindow as never, runtime as never, () => TEST_CODEX_HOME, (() => ({
|
||||
codexManagedAccounts: [
|
||||
{
|
||||
id: 'account-1',
|
||||
managedHomePath: TEST_CODEX_HOME,
|
||||
managedHomeRuntime: 'host'
|
||||
}
|
||||
]
|
||||
})) as never)
|
||||
const controller = runtime.setPtyController.mock.calls[0]?.[0] as RuntimeSpawnController
|
||||
|
||||
await controller.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
launchAgent: 'codex',
|
||||
sessionId: 'retained-runtime-codex'
|
||||
})
|
||||
|
||||
expect(daemonSpawn).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('falls back when managed Codex auth stays unavailable', async () => {
|
||||
vi.useFakeTimers()
|
||||
readFileSyncMock.mockImplementation((filePath: string) => {
|
||||
if (filePath.endsWith('auth.json')) {
|
||||
throw Object.assign(new Error('missing auth'), { code: 'ENOENT' })
|
||||
}
|
||||
return ''
|
||||
})
|
||||
const daemonSpawn = setupDaemonAdapter()
|
||||
const resolveHome = vi.fn(
|
||||
(
|
||||
_target?: unknown,
|
||||
_env?: NodeJS.ProcessEnv,
|
||||
context?: { unavailableManagedHomePath?: string }
|
||||
) => (context?.unavailableManagedHomePath ? null : TEST_CODEX_HOME)
|
||||
)
|
||||
handlers.clear()
|
||||
registerPtyHandlers(mainWindow as never, undefined, resolveHome, (() => ({
|
||||
codexManagedAccounts: [
|
||||
{
|
||||
id: 'account-1',
|
||||
managedHomePath: TEST_CODEX_HOME,
|
||||
managedHomeRuntime: 'host'
|
||||
}
|
||||
]
|
||||
})) as never)
|
||||
|
||||
const spawnPromise = handlers.get('pty:spawn')!(null, {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
launchAgent: 'codex'
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(2_000)
|
||||
await spawnPromise
|
||||
|
||||
expect(resolveHome).toHaveBeenCalledTimes(2)
|
||||
expect(resolveHome.mock.calls[1]?.[2]).toMatchObject({
|
||||
unavailableManagedHomePath: TEST_CODEX_HOME
|
||||
})
|
||||
expect(daemonSpawn).toHaveBeenCalledOnce()
|
||||
expect(daemonSpawn.mock.calls[0]?.[0].env).not.toHaveProperty('CODEX_HOME')
|
||||
})
|
||||
|
||||
it('rejects when account changes keep resolving unavailable managed homes', async () => {
|
||||
vi.useFakeTimers()
|
||||
const secondHome = '/managed/second/home'
|
||||
const thirdHome = '/managed/third/home'
|
||||
readFileSyncMock.mockImplementation((filePath: string) => {
|
||||
if (filePath.endsWith('auth.json')) {
|
||||
throw Object.assign(new Error('missing auth'), { code: 'ENOENT' })
|
||||
}
|
||||
return ''
|
||||
})
|
||||
const daemonSpawn = setupDaemonAdapter()
|
||||
const resolveHome = vi.fn(
|
||||
(
|
||||
_target?: unknown,
|
||||
_env?: NodeJS.ProcessEnv,
|
||||
context?: { unavailableManagedHomePath?: string }
|
||||
) =>
|
||||
!context?.unavailableManagedHomePath
|
||||
? TEST_CODEX_HOME
|
||||
: context.unavailableManagedHomePath === TEST_CODEX_HOME
|
||||
? secondHome
|
||||
: thirdHome
|
||||
)
|
||||
handlers.clear()
|
||||
registerPtyHandlers(mainWindow as never, undefined, resolveHome, (() => ({
|
||||
codexManagedAccounts: [TEST_CODEX_HOME, secondHome, thirdHome].map(
|
||||
(managedHomePath, index) => ({
|
||||
id: `account-${index + 1}`,
|
||||
managedHomePath,
|
||||
managedHomeRuntime: 'host'
|
||||
})
|
||||
)
|
||||
})) as never)
|
||||
|
||||
const spawnPromise = handlers.get('pty:spawn')!(null, {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
launchAgent: 'codex'
|
||||
})
|
||||
const rejection = expect(spawnPromise).rejects.toThrow(
|
||||
'The selected Codex account credentials are temporarily unavailable. Try opening the terminal again.'
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(4_000)
|
||||
await rejection
|
||||
|
||||
expect(resolveHome.mock.calls.map((call) => call[2]?.unavailableManagedHomePath)).toEqual([
|
||||
undefined,
|
||||
TEST_CODEX_HOME,
|
||||
secondHome
|
||||
])
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
expect(daemonSpawn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back for a runtime-created Codex launch when auth stays unavailable', async () => {
|
||||
type RuntimeSpawnController = {
|
||||
spawn(args: { cols: number; rows: number; launchAgent: 'codex' }): Promise<{ id: string }>
|
||||
}
|
||||
vi.useFakeTimers()
|
||||
readFileSyncMock.mockImplementation((filePath: string) => {
|
||||
if (filePath.endsWith('auth.json')) {
|
||||
throw Object.assign(new Error('missing auth'), { code: 'ENOENT' })
|
||||
}
|
||||
return ''
|
||||
})
|
||||
const daemonSpawn = setupDaemonAdapter()
|
||||
const resolveHome = vi.fn(
|
||||
(
|
||||
_target?: unknown,
|
||||
_env?: NodeJS.ProcessEnv,
|
||||
context?: { unavailableManagedHomePath?: string }
|
||||
) => (context?.unavailableManagedHomePath ? null : TEST_CODEX_HOME)
|
||||
)
|
||||
const runtime = {
|
||||
setPtyController: vi.fn(),
|
||||
registerPty: vi.fn(),
|
||||
noteTerminalSpawnCommand: vi.fn(),
|
||||
onPtySpawned: vi.fn(),
|
||||
onPtyExit: vi.fn(),
|
||||
onPtyData: vi.fn()
|
||||
}
|
||||
handlers.clear()
|
||||
registerPtyHandlers(mainWindow as never, runtime as never, resolveHome, (() => ({
|
||||
codexManagedAccounts: [
|
||||
{
|
||||
id: 'account-1',
|
||||
managedHomePath: TEST_CODEX_HOME,
|
||||
managedHomeRuntime: 'host'
|
||||
}
|
||||
]
|
||||
})) as never)
|
||||
const controller = runtime.setPtyController.mock.calls[0]?.[0] as RuntimeSpawnController
|
||||
|
||||
const spawnPromise = controller.spawn({ cols: 80, rows: 24, launchAgent: 'codex' })
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(daemonSpawn).not.toHaveBeenCalled()
|
||||
await vi.advanceTimersByTimeAsync(2_000)
|
||||
await spawnPromise
|
||||
|
||||
expect(resolveHome).toHaveBeenCalledTimes(2)
|
||||
expect(resolveHome.mock.calls[1]?.[2]).toMatchObject({
|
||||
unavailableManagedHomePath: TEST_CODEX_HOME
|
||||
})
|
||||
expect(daemonSpawn).toHaveBeenCalledOnce()
|
||||
expect(daemonSpawn.mock.calls[0]?.[0].env).not.toHaveProperty('CODEX_HOME')
|
||||
})
|
||||
|
||||
it('injects OpenCode plugin env (OPENCODE_CONFIG_DIR) on the daemon path', async () => {
|
||||
const env = await daemonSpawnAndGetEnv({}, undefined, undefined, {
|
||||
OPENCODE_CONFIG_DIR: undefined
|
||||
|
|
@ -14574,6 +15013,70 @@ describe('registerPtyHandlers', () => {
|
|||
])
|
||||
})
|
||||
|
||||
it('does not resume under another account when the origin auth stays unavailable', async () => {
|
||||
vi.useFakeTimers()
|
||||
const spawn = vi.fn(async () => ({ id: 'pty-must-not-spawn' }))
|
||||
setLocalPtyProvider({
|
||||
spawn,
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
kill: vi.fn(),
|
||||
shutdown: vi.fn(),
|
||||
onData: vi.fn(() => vi.fn()),
|
||||
onExit: vi.fn(() => vi.fn()),
|
||||
listProcesses: vi.fn(async () => []),
|
||||
getForegroundProcess: vi.fn(async () => null)
|
||||
} as never)
|
||||
readFileSyncMock.mockImplementation((filePath: string) => {
|
||||
if (filePath.endsWith('auth.json')) {
|
||||
throw Object.assign(new Error('missing auth'), { code: 'ENOENT' })
|
||||
}
|
||||
return ''
|
||||
})
|
||||
const resolveHome = vi.fn(() => '/managed/current/home')
|
||||
registerPtyHandlers(
|
||||
mainWindow as never,
|
||||
undefined,
|
||||
resolveHome,
|
||||
(() => ({
|
||||
codexManagedAccounts: [
|
||||
{ id: 'account-a', managedHomePath: '/managed/origin/home' },
|
||||
{ id: 'account-b', managedHomePath: '/managed/current/home' }
|
||||
]
|
||||
})) as never,
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
prepareCodexSessionResume: async () => ({
|
||||
outcome: 'resume' as const,
|
||||
codexHomePath: '/managed/origin/home'
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
const launch = handlers.get('pty:spawn')!(null, {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
command: 'codex resume session-a',
|
||||
envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME'],
|
||||
launchAgent: 'codex',
|
||||
resumeProviderSession: {
|
||||
key: 'session_id',
|
||||
id: 'session-a',
|
||||
transcriptPath: '/managed/origin/home/sessions/2026/07/20/rollout-a.jsonl'
|
||||
}
|
||||
})
|
||||
const rejection = expect(launch).rejects.toThrow(
|
||||
'The Codex account credentials for this session are temporarily unavailable. Try opening the terminal again.'
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(2_000)
|
||||
await rejection
|
||||
|
||||
expect(resolveHome).not.toHaveBeenCalled()
|
||||
expect(spawn).not.toHaveBeenCalled()
|
||||
expect(recordCodexPaneAccountMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('records the origin account a resumed Codex pane is pinned to', async () => {
|
||||
setLocalPtyProvider({
|
||||
spawn: vi.fn(async () => ({ id: 'pty-resumed' })),
|
||||
|
|
@ -14607,6 +15110,7 @@ describe('registerPtyHandlers', () => {
|
|||
})
|
||||
}
|
||||
)
|
||||
readFileSyncMock.mockReturnValue(TEST_CODEX_AUTH_JSON)
|
||||
|
||||
await handlers.get('pty:spawn')!(null, {
|
||||
cols: 80,
|
||||
|
|
@ -14625,6 +15129,7 @@ describe('registerPtyHandlers', () => {
|
|||
expect(recordCodexPaneAccountMock.mock.calls).toEqual([
|
||||
['pty-resumed', { selectionKey: 'host', accountId: 'account-a' }]
|
||||
])
|
||||
expect(readFileSyncMock).toHaveBeenCalledWith('/managed/origin/home/auth.json', 'utf8')
|
||||
expect(forgetCodexPaneAccountMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
|
@ -14726,6 +15231,7 @@ describe('registerPtyHandlers', () => {
|
|||
}
|
||||
)
|
||||
const controller = runtime.setPtyController.mock.calls[0]?.[0] as RuntimeSpawnController
|
||||
readFileSyncMock.mockReturnValue(TEST_CODEX_AUTH_JSON)
|
||||
|
||||
await controller.spawn({
|
||||
cols: 80,
|
||||
|
|
@ -14743,6 +15249,7 @@ describe('registerPtyHandlers', () => {
|
|||
expect(recordCodexPaneAccountMock.mock.calls).toEqual([
|
||||
['pty-runtime-resumed', { selectionKey: 'host', accountId: 'account-a' }]
|
||||
])
|
||||
expect(readFileSyncMock).toHaveBeenCalledWith('/managed/origin/home/auth.json', 'utf8')
|
||||
expect(forgetCodexPaneAccountMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -187,6 +187,10 @@ import { setTerminalViewAttributes } from '../runtime/terminal-view-attribute-st
|
|||
import { validateTerminalViewAttributes } from '../../shared/terminal-view-attributes'
|
||||
import type { PtyModelRestoreReason } from '../../shared/pty-model-restore-marker'
|
||||
import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection'
|
||||
import {
|
||||
isCodexHomeAuthReadyForLaunch,
|
||||
waitForManagedCodexAuthReady
|
||||
} from '../codex-accounts/managed-codex-auth-readiness'
|
||||
import {
|
||||
forgetCodexPaneAccount,
|
||||
recordCodexPaneAccount
|
||||
|
|
@ -823,10 +827,16 @@ function getLocalOrcaCodexHomeEnvKeysToDelete(env: Record<string, string>): stri
|
|||
return keysToDelete
|
||||
}
|
||||
|
||||
export type CodexHomeLaunchContext = {
|
||||
workspacePath?: string
|
||||
launchAgent?: TuiAgent
|
||||
unavailableManagedHomePath?: string
|
||||
}
|
||||
|
||||
export type GetSelectedCodexHomePath = (
|
||||
target?: CodexAccountSelectionTarget,
|
||||
launchEnv?: NodeJS.ProcessEnv,
|
||||
launchContext?: { workspacePath?: string; launchAgent?: TuiAgent }
|
||||
launchContext?: CodexHomeLaunchContext
|
||||
) => string | null
|
||||
export type PrepareCodexSessionResume = (args: {
|
||||
providerSession: AgentProviderSessionMetadata
|
||||
|
|
@ -866,6 +876,113 @@ function getCompatibleSelectedCodexHomePath(
|
|||
: selectedCodexHomePath
|
||||
}
|
||||
|
||||
const MANAGED_CODEX_AUTH_UNAVAILABLE_MESSAGE =
|
||||
'The selected Codex account credentials are temporarily unavailable. Try opening the terminal again.'
|
||||
const CODEX_RESUME_AUTH_UNAVAILABLE_MESSAGE =
|
||||
'The Codex account credentials for this session are temporarily unavailable. Try opening the terminal again.'
|
||||
|
||||
type ManagedCodexAuthResolutionArgs = {
|
||||
selectedCodexHomePath: string | null
|
||||
getSettings: () => GlobalSettings | undefined
|
||||
requiredCodexHomePath?: string
|
||||
target: CodexAccountSelectionTarget
|
||||
resolveCurrent: () => string | null
|
||||
resolveAfterUnavailable: (unavailableManagedHomePath: string) => string | null
|
||||
}
|
||||
|
||||
export function resolveCodexHomeAfterManagedAuthReadiness(
|
||||
args: ManagedCodexAuthResolutionArgs
|
||||
): string | null | Promise<string | null> {
|
||||
const selectedCodexHomePath = args.selectedCodexHomePath
|
||||
if (
|
||||
args.requiredCodexHomePath &&
|
||||
!codexHomePathsEqual(selectedCodexHomePath, args.requiredCodexHomePath)
|
||||
) {
|
||||
throw new Error(CODEX_RESUME_AUTH_UNAVAILABLE_MESSAGE)
|
||||
}
|
||||
const readiness = waitForManagedCodexAuthReady({
|
||||
codexHomePath: selectedCodexHomePath,
|
||||
settings: args.getSettings(),
|
||||
target: args.target
|
||||
})
|
||||
return readiness
|
||||
? continueCodexHomeAfterManagedAuthWait(args, selectedCodexHomePath, readiness)
|
||||
: selectedCodexHomePath
|
||||
}
|
||||
|
||||
async function continueCodexHomeAfterManagedAuthWait(
|
||||
args: ManagedCodexAuthResolutionArgs,
|
||||
initialCodexHomePath: string | null,
|
||||
initialReadiness: Promise<boolean>
|
||||
): Promise<string | null> {
|
||||
let selectedCodexHomePath = initialCodexHomePath
|
||||
let readiness = initialReadiness
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
if (await readiness) {
|
||||
if (args.requiredCodexHomePath) {
|
||||
return selectedCodexHomePath
|
||||
}
|
||||
const currentCodexHomePath = args.resolveCurrent()
|
||||
if (codexHomeSelectionsEqual(selectedCodexHomePath, currentCodexHomePath)) {
|
||||
return selectedCodexHomePath
|
||||
}
|
||||
selectedCodexHomePath = currentCodexHomePath
|
||||
if (attempt === 1) {
|
||||
break
|
||||
}
|
||||
const nextReadiness = waitForManagedCodexAuthReady({
|
||||
codexHomePath: selectedCodexHomePath,
|
||||
settings: args.getSettings(),
|
||||
target: args.target
|
||||
})
|
||||
if (!nextReadiness) {
|
||||
return selectedCodexHomePath
|
||||
}
|
||||
readiness = nextReadiness
|
||||
continue
|
||||
}
|
||||
if (args.requiredCodexHomePath) {
|
||||
throw new Error(CODEX_RESUME_AUTH_UNAVAILABLE_MESSAGE)
|
||||
}
|
||||
selectedCodexHomePath = args.resolveAfterUnavailable(selectedCodexHomePath!)
|
||||
if (attempt === 1) {
|
||||
break
|
||||
}
|
||||
const nextReadiness = waitForManagedCodexAuthReady({
|
||||
codexHomePath: selectedCodexHomePath,
|
||||
settings: args.getSettings(),
|
||||
target: args.target
|
||||
})
|
||||
if (!nextReadiness) {
|
||||
return selectedCodexHomePath
|
||||
}
|
||||
readiness = nextReadiness
|
||||
}
|
||||
if (
|
||||
isCodexHomeAuthReadyForLaunch({
|
||||
codexHomePath: selectedCodexHomePath,
|
||||
settings: args.getSettings(),
|
||||
target: args.target
|
||||
})
|
||||
) {
|
||||
return selectedCodexHomePath
|
||||
}
|
||||
throw new Error(MANAGED_CODEX_AUTH_UNAVAILABLE_MESSAGE)
|
||||
}
|
||||
|
||||
function codexHomeSelectionsEqual(left: string | null, right: string | null): boolean {
|
||||
return (
|
||||
left === right ||
|
||||
(left !== null &&
|
||||
right !== null &&
|
||||
normalizeRuntimePathForComparison(left) === normalizeRuntimePathForComparison(right))
|
||||
)
|
||||
}
|
||||
|
||||
function codexHomePathsEqual(left: string | null, right: string): boolean {
|
||||
return codexHomeSelectionsEqual(left, right)
|
||||
}
|
||||
|
||||
// Why: CODEX_HOME is fixed in a shell's environment at spawn and the daemon
|
||||
// keeps that shell alive across app restarts, so the launch account is the only
|
||||
// way to tell later that a pane still runs Codex as the previously selected
|
||||
|
|
@ -3775,7 +3892,7 @@ export function registerPtyHandlers(
|
|||
if (args.preAllocatedHandle) {
|
||||
env = { ...env, ORCA_TERMINAL_HANDLE: args.preAllocatedHandle }
|
||||
}
|
||||
const selectedCodexHomePath = isDaemonHostSpawn
|
||||
let selectedCodexHomePath = !args.connectionId
|
||||
? getCompatibleSelectedCodexHomePath(
|
||||
codexSelectionTarget,
|
||||
codexResumeHome
|
||||
|
|
@ -3786,6 +3903,35 @@ export function registerPtyHandlers(
|
|||
}) ?? null)
|
||||
)
|
||||
: null
|
||||
if (args.launchAgent === 'codex' && callerRequestedSessionId === undefined) {
|
||||
const resolution = resolveCodexHomeAfterManagedAuthReadiness({
|
||||
selectedCodexHomePath,
|
||||
getSettings: () => getSettings?.(),
|
||||
requiredCodexHomePath: codexResumeHome?.codexHomePath,
|
||||
target: codexSelectionTarget,
|
||||
resolveCurrent: () =>
|
||||
getCompatibleSelectedCodexHomePath(
|
||||
codexSelectionTarget,
|
||||
getSelectedCodexHomePath?.(codexSelectionTarget, env, {
|
||||
workspacePath: cwd,
|
||||
launchAgent: 'codex'
|
||||
}) ?? null
|
||||
),
|
||||
resolveAfterUnavailable: (unavailableManagedHomePath) =>
|
||||
getCompatibleSelectedCodexHomePath(
|
||||
codexSelectionTarget,
|
||||
getSelectedCodexHomePath?.(codexSelectionTarget, env, {
|
||||
workspacePath: cwd,
|
||||
launchAgent: 'codex',
|
||||
unavailableManagedHomePath
|
||||
}) ?? null
|
||||
)
|
||||
})
|
||||
selectedCodexHomePath = resolution instanceof Promise ? await resolution : resolution
|
||||
}
|
||||
const codexResumeHomeSelected = Boolean(
|
||||
codexResumeHome && codexHomePathsEqual(selectedCodexHomePath, codexResumeHome.codexHomePath)
|
||||
)
|
||||
const skipCodexHomeEnv =
|
||||
isDaemonHostSpawn &&
|
||||
shouldSkipCodexHomeEnvForWindowsShell(daemonShellOverride, cwd) &&
|
||||
|
|
@ -3836,8 +3982,8 @@ export function registerPtyHandlers(
|
|||
env,
|
||||
...(isMintedSessionId ? { isNewSession: true } : {})
|
||||
}
|
||||
if (!isDaemonHostSpawn && codexResumeHome) {
|
||||
spawnOptions.codexHomePathOverride = { value: codexResumeHome.codexHomePath }
|
||||
if (!args.connectionId && !isDaemonHostSpawn) {
|
||||
spawnOptions.codexHomePathOverride = { value: selectedCodexHomePath }
|
||||
}
|
||||
const startupTerminalColorQueryReplyColors = getStartupTerminalColorQueryReplyColors(args)
|
||||
if (startupTerminalColorQueryReplyColors) {
|
||||
|
|
@ -3873,7 +4019,7 @@ export function registerPtyHandlers(
|
|||
'ORCA_CODEX_HOME'
|
||||
])
|
||||
}
|
||||
if (codexResumeHome?.codexHomePath) {
|
||||
if (codexResumeHomeSelected) {
|
||||
spawnOptions.envToDelete = removeCodexHomeDeletionRequests(spawnOptions.envToDelete)
|
||||
}
|
||||
deleteRequestedEnvKeys(env, spawnOptions.envToDelete)
|
||||
|
|
@ -4213,7 +4359,7 @@ export function registerPtyHandlers(
|
|||
ptyId: result.id,
|
||||
isDaemonHostSpawn,
|
||||
isReattach: result.isReattach === true,
|
||||
pinnedByResume: Boolean(codexResumeHome),
|
||||
pinnedByResume: codexResumeHomeSelected,
|
||||
launchCodexHomePath: selectedCodexHomePath,
|
||||
target: codexSelectionTarget,
|
||||
settings: getSettings?.()
|
||||
|
|
@ -4953,7 +5099,7 @@ export function registerPtyHandlers(
|
|||
// Why: declared after the strip so a local-provider spawn cannot capture the
|
||||
// pre-strip env — only the daemon branch below re-derives this from baseEnv.
|
||||
let env: Record<string, string> | undefined = baseEnv
|
||||
const selectedCodexHomePath = isDaemonHostSpawn
|
||||
let selectedCodexHomePath = !args.connectionId
|
||||
? getCompatibleSelectedCodexHomePath(
|
||||
codexSelectionTarget,
|
||||
codexResumeHome
|
||||
|
|
@ -4964,6 +5110,35 @@ export function registerPtyHandlers(
|
|||
}) ?? null)
|
||||
)
|
||||
: null
|
||||
if (args.launchAgent === 'codex' && args.sessionId === undefined) {
|
||||
const resolution = resolveCodexHomeAfterManagedAuthReadiness({
|
||||
selectedCodexHomePath,
|
||||
getSettings: () => getSettings?.(),
|
||||
requiredCodexHomePath: codexResumeHome?.codexHomePath,
|
||||
target: codexSelectionTarget,
|
||||
resolveCurrent: () =>
|
||||
getCompatibleSelectedCodexHomePath(
|
||||
codexSelectionTarget,
|
||||
getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv, {
|
||||
workspacePath: cwd,
|
||||
launchAgent: 'codex'
|
||||
}) ?? null
|
||||
),
|
||||
resolveAfterUnavailable: (unavailableManagedHomePath) =>
|
||||
getCompatibleSelectedCodexHomePath(
|
||||
codexSelectionTarget,
|
||||
getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv, {
|
||||
workspacePath: cwd,
|
||||
launchAgent: 'codex',
|
||||
unavailableManagedHomePath
|
||||
}) ?? null
|
||||
)
|
||||
})
|
||||
selectedCodexHomePath = resolution instanceof Promise ? await resolution : resolution
|
||||
}
|
||||
const codexResumeHomeSelected = Boolean(
|
||||
codexResumeHome && codexHomePathsEqual(selectedCodexHomePath, codexResumeHome.codexHomePath)
|
||||
)
|
||||
const skipCodexHomeEnv =
|
||||
isDaemonHostSpawn &&
|
||||
shouldSkipCodexHomeEnvForWindowsShell(effectiveShellOverride, cwd) &&
|
||||
|
|
@ -5038,7 +5213,7 @@ export function registerPtyHandlers(
|
|||
// main cannot safely decide ownership for a process it may not parent.
|
||||
stripInheritedOrcaCodexHome ? ['ORCA_CODEX_HOME'] : []
|
||||
)
|
||||
if (codexResumeHome?.codexHomePath) {
|
||||
if (codexResumeHomeSelected) {
|
||||
combinedEnvToDelete = removeCodexHomeDeletionRequests(combinedEnvToDelete)
|
||||
}
|
||||
deleteRequestedEnvKeys(spawnEnv, combinedEnvToDelete)
|
||||
|
|
@ -5050,8 +5225,8 @@ export function registerPtyHandlers(
|
|||
env: spawnEnv,
|
||||
...(isMintedSessionId ? { isNewSession: true } : {})
|
||||
}
|
||||
if (!isDaemonHostSpawn && codexResumeHome) {
|
||||
spawnOptions.codexHomePathOverride = { value: codexResumeHome.codexHomePath }
|
||||
if (!args.connectionId && !isDaemonHostSpawn) {
|
||||
spawnOptions.codexHomePathOverride = { value: selectedCodexHomePath }
|
||||
}
|
||||
if (combinedEnvToDelete) {
|
||||
spawnOptions.envToDelete = combinedEnvToDelete
|
||||
|
|
@ -5268,7 +5443,7 @@ export function registerPtyHandlers(
|
|||
ptyId: result.id,
|
||||
isDaemonHostSpawn,
|
||||
isReattach: result.isReattach === true,
|
||||
pinnedByResume: Boolean(codexResumeHome),
|
||||
pinnedByResume: codexResumeHomeSelected,
|
||||
launchCodexHomePath: selectedCodexHomePath,
|
||||
target: codexSelectionTarget,
|
||||
settings: getSettings?.()
|
||||
|
|
|
|||
|
|
@ -177,4 +177,37 @@ describe('fetchCodexRateLimits auth errors', () => {
|
|||
error: authError
|
||||
})
|
||||
})
|
||||
|
||||
it('stops a PTY probe when Codex renders its sign-in screen', async () => {
|
||||
const ptyHandlers: { onData?: (data: string) => void } = {}
|
||||
const ptyWrite = vi.fn()
|
||||
const ptyKill = vi.fn()
|
||||
|
||||
childSpawnMock.mockImplementation(() => {
|
||||
throw new Error('rpc unavailable')
|
||||
})
|
||||
ptySpawnMock.mockReturnValue({
|
||||
onData: vi.fn((callback) => {
|
||||
ptyHandlers.onData = callback
|
||||
return makeDisposable()
|
||||
}),
|
||||
onExit: vi.fn(() => makeDisposable()),
|
||||
write: ptyWrite,
|
||||
kill: ptyKill
|
||||
})
|
||||
|
||||
const resultPromise = fetchCodexRateLimits()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
ptyHandlers.onData?.('\u001b[2JSign in with ChatGPT\r\n')
|
||||
|
||||
await expect(resultPromise).resolves.toMatchObject({
|
||||
provider: 'codex',
|
||||
session: null,
|
||||
weekly: null,
|
||||
status: 'error',
|
||||
error: 'Sign in with ChatGPT'
|
||||
})
|
||||
expect(ptyWrite).not.toHaveBeenCalled()
|
||||
expect(ptyKill).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1035,6 +1035,29 @@ async function fetchViaPty(options?: FetchCodexRateLimitsOptions): Promise<Provi
|
|||
output = output.slice(-MAX_DIAGNOSTIC_OUTPUT_LENGTH)
|
||||
}
|
||||
|
||||
const authError = extractCodexAuthError(output)
|
||||
if (authError) {
|
||||
resolved = true
|
||||
if (timeout) {
|
||||
clearTimeout(timeout)
|
||||
timeout = null
|
||||
}
|
||||
if (settleTimer) {
|
||||
clearTimeout(settleTimer)
|
||||
settleTimer = null
|
||||
}
|
||||
cleanupHiddenRateLimitPty(term, termDisposables, { kill: true })
|
||||
resolve({
|
||||
provider: 'codex',
|
||||
session: null,
|
||||
weekly: null,
|
||||
updatedAt: Date.now(),
|
||||
error: authError,
|
||||
status: 'error'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
armStatusNudge()
|
||||
|
||||
// Wait for prompt, then send /status
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ afterEach(() => {
|
|||
describe('isCodexAuthError', () => {
|
||||
it('matches Codex authentication refresh failures', () => {
|
||||
expect(isCodexAuthError('Access token could not be refreshed')).toBe(true)
|
||||
expect(isCodexAuthError('Sign in with ChatGPT')).toBe(true)
|
||||
expect(isCodexAuthError('plain provider error')).toBe(false)
|
||||
expect(isCodexAuthError(null)).toBe(false)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ const CODEX_AUTH_ERROR_PATTERNS = [
|
|||
/please (?:log out and )?sign in again/i,
|
||||
/please reauthenticate/i,
|
||||
/not logged in/i,
|
||||
/sign in with chatgpt/i,
|
||||
/token data is not available/i,
|
||||
/auth (?:is missing|tokens are missing|does not expose)/i,
|
||||
// Why: app-server rejects account/rateLimits/read with this when auth.json
|
||||
|
|
|
|||
Loading…
Reference in New Issue