mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 08:03:28 +00:00
fix(codex): never launch the previously synced account during the credential grace window
The credential-absence grace window exists so a mid-rotation read of a managed auth.json does not deselect the account over a race. In the shared runtime-home lane it did that by returning early from syncForCurrentSelection, which leaves the runtime home holding whatever it held before — the previously synced account's credentials. So when the selected account's auth.json was missing or unreadable (home on a disconnected network drive, never provisioned, removed, or held under an EPERM/EACCES lock by Windows AV), the next launch ran Codex as the *previous* account while Settings showed the selected one: wrong quota, wrong identity, work attributed to the wrong account, and no signal to the user. Not being able to read the selected account's credentials proves nothing about the identity already mirrored in the runtime home. The grace window now only short-circuits when Orca can prove the runtime home already holds that same account (shared-runtime-auth provenance, falling back to the in-memory sync state on pre-provenance installs). Otherwise the runtime auth is cleared and the provenance fenced: the selection survives in case the absence heals, but the launch is logged out rather than logged in as someone else. Tests cover ENOENT, EACCES/EPERM, and a genuinely still-settling rotation of the selected account, which keeps its grace and its credentials.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
@@ -57,6 +57,21 @@ describe('CodexCredentialAbsenceGrace', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a permission-denied read inside the grace window before it turns durable', () => {
|
||||
if (process.platform === 'win32' || process.getuid?.() === 0) {
|
||||
return
|
||||
}
|
||||
const grace = new CodexCredentialAbsenceGrace()
|
||||
writeFileSync(authPath, VALID_AUTH, 'utf-8')
|
||||
chmodSync(authPath, 0o000)
|
||||
|
||||
expect(grace.assess(authPath, 1_000)).toEqual({ state: 'unreadable', durable: false })
|
||||
expect(grace.assess(authPath, 1_000 + CODEX_CREDENTIAL_ABSENCE_GRACE_MS)).toEqual({
|
||||
state: 'unreadable',
|
||||
durable: true
|
||||
})
|
||||
})
|
||||
|
||||
it('reports settled credential-free JSON as durable immediately', () => {
|
||||
const grace = new CodexCredentialAbsenceGrace()
|
||||
writeFileSync(authPath, '{}', 'utf-8')
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { chmodSync, 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'
|
||||
@@ -208,6 +208,18 @@ describe('waitForManagedCodexAuthReady', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('separates an absent credential from one that cannot be read', () => {
|
||||
const fixture = createFixture()
|
||||
expect(readStoredCodexCredentialState(join(fixture.home, 'auth.json'))).toBe('missing')
|
||||
|
||||
if (process.platform === 'win32' || process.getuid?.() === 0) {
|
||||
return
|
||||
}
|
||||
writeAuth(fixture.home, testChatGptAuth)
|
||||
chmodSync(join(fixture.home, 'auth.json'), 0o000)
|
||||
expect(readStoredCodexCredentialState(join(fixture.home, 'auth.json'))).toBe('unreadable')
|
||||
})
|
||||
|
||||
it('does not gate system, WSL, or unmanaged custom homes', async () => {
|
||||
const fixture = createFixture()
|
||||
await waitForManagedCodexAuthReady({
|
||||
|
||||
@@ -467,6 +467,121 @@ describe('CodexRuntimeHomeService', () => {
|
||||
expect(store.getSettings().activeCodexManagedAccountIdsByRuntime?.host).toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['missing', (authPath: string) => rmSync(authPath, { force: true })],
|
||||
[
|
||||
'unreadable',
|
||||
(authPath: string) => {
|
||||
chmodSync(authPath, 0o000)
|
||||
}
|
||||
]
|
||||
])(
|
||||
'never launches the previously synced account when the selected account auth is %s',
|
||||
async (label, breakAuth) => {
|
||||
if (label === 'unreadable' && (process.platform === 'win32' || process.getuid?.() === 0)) {
|
||||
return
|
||||
}
|
||||
const runtimeAuthPath = getRuntimeCodexAuthPath()
|
||||
const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system')
|
||||
const authA = createCodexAuthJson('a@example.com', 'acct-a', 'a')
|
||||
const authB = createCodexAuthJson('b@example.com', 'acct-b', 'b')
|
||||
writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8')
|
||||
const homeA = createManagedAuth(testState.userDataDir, 'account-a', authA)
|
||||
const homeB = createManagedAuth(testState.userDataDir, 'account-b', authB)
|
||||
const store = createStore(
|
||||
createSettings({
|
||||
codexManagedAccounts: [
|
||||
{
|
||||
id: 'account-a',
|
||||
email: 'a@example.com',
|
||||
managedHomePath: homeA,
|
||||
providerAccountId: 'acct-a',
|
||||
workspaceLabel: null,
|
||||
workspaceAccountId: 'acct-a',
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
lastAuthenticatedAt: 1
|
||||
},
|
||||
{
|
||||
id: 'account-b',
|
||||
email: 'b@example.com',
|
||||
managedHomePath: homeB,
|
||||
providerAccountId: 'acct-b',
|
||||
workspaceLabel: null,
|
||||
workspaceAccountId: 'acct-b',
|
||||
createdAt: 2,
|
||||
updatedAt: 2,
|
||||
lastAuthenticatedAt: 2
|
||||
}
|
||||
],
|
||||
activeCodexManagedAccountId: 'account-a',
|
||||
activeCodexManagedAccountIdsByRuntime: { host: 'account-a', wsl: {} }
|
||||
})
|
||||
)
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
|
||||
const service = new CodexRuntimeHomeService(store as never)
|
||||
expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(authA)
|
||||
|
||||
store.updateSettings({
|
||||
activeCodexManagedAccountId: 'account-b',
|
||||
activeCodexManagedAccountIdsByRuntime: { host: 'account-b', wsl: {} }
|
||||
})
|
||||
breakAuth(join(homeB, 'auth.json'))
|
||||
|
||||
expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath())
|
||||
|
||||
// The selection survives the grace window, but the launch must never run
|
||||
// as account A while the UI says account B.
|
||||
expect(store.getSettings().activeCodexManagedAccountIdsByRuntime?.host).toBe('account-b')
|
||||
expect(existsSync(runtimeAuthPath) ? readFileSync(runtimeAuthPath, 'utf-8') : null).not.toBe(
|
||||
authA
|
||||
)
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'[codex-runtime-home] Active managed account auth.json unavailable while the runtime home holds another account, clearing runtime auth'
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps the runtime auth through the grace window while the selected account rotates', async () => {
|
||||
const runtimeAuthPath = getRuntimeCodexAuthPath()
|
||||
const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system')
|
||||
const managedAuth = createCodexAuthJson('user@example.com', 'acct-user', 'managed')
|
||||
writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8')
|
||||
const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth)
|
||||
const store = createStore(
|
||||
createSettings({
|
||||
codexManagedAccounts: [
|
||||
{
|
||||
id: 'account-1',
|
||||
email: 'user@example.com',
|
||||
managedHomePath,
|
||||
providerAccountId: 'acct-user',
|
||||
workspaceLabel: null,
|
||||
workspaceAccountId: 'acct-user',
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
lastAuthenticatedAt: 1
|
||||
}
|
||||
],
|
||||
activeCodexManagedAccountId: 'account-1',
|
||||
activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} }
|
||||
})
|
||||
)
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
|
||||
const service = new CodexRuntimeHomeService(store as never)
|
||||
expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(managedAuth)
|
||||
|
||||
// Codex rewrites the selected account's auth.json in place: the runtime home
|
||||
// already holds that same account, so its credentials must survive the race.
|
||||
writeFileSync(join(managedHomePath, 'auth.json'), '{"tokens":{"acc', 'utf-8')
|
||||
expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath())
|
||||
|
||||
expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(managedAuth)
|
||||
expect(store.getSettings().activeCodexManagedAccountIdsByRuntime?.host).toBe('account-1')
|
||||
})
|
||||
|
||||
it('repoints legacy active host CODEX_HOME to the shared runtime home on startup', async () => {
|
||||
const legacyLaunchHomePath = join(
|
||||
testState.userDataDir,
|
||||
|
||||
@@ -741,11 +741,21 @@ export class CodexRuntimeHomeService {
|
||||
const authAbsence = this.credentialAbsenceGrace.assess(activeAuthPath)
|
||||
if (authAbsence.state !== 'present' && authAbsence.state !== 'incomplete') {
|
||||
if (!authAbsence.durable) {
|
||||
// Why: mid-rotation reads look missing/unreadable for a moment; skip
|
||||
// this sync without deselecting and let a settled read decide later.
|
||||
if (this.sharedRuntimeAuthBelongsToAccount(activeAccount.id)) {
|
||||
// Why: mid-rotation reads look missing/unreadable for a moment; skip
|
||||
// this sync without deselecting and let a settled read decide later.
|
||||
console.warn(
|
||||
'[codex-runtime-home] Active managed account auth.json unavailable, keeping selection through grace window'
|
||||
)
|
||||
return
|
||||
}
|
||||
// Why: the runtime home still holds another account, so riding out the
|
||||
// grace would launch that account under this selection. Not being able
|
||||
// to read the selected account is no license to run a different one.
|
||||
console.warn(
|
||||
'[codex-runtime-home] Active managed account auth.json unavailable, keeping selection through grace window'
|
||||
'[codex-runtime-home] Active managed account auth.json unavailable while the runtime home holds another account, clearing runtime auth'
|
||||
)
|
||||
this.clearRuntimeAuthForUnprovenSelection()
|
||||
return
|
||||
}
|
||||
console.warn(
|
||||
@@ -920,6 +930,30 @@ export class CodexRuntimeHomeService {
|
||||
}
|
||||
}
|
||||
|
||||
// Why: an unreadable managed auth.json says nothing about who the shared
|
||||
// runtime home currently holds, so only Orca's own provenance can prove those
|
||||
// bytes belong to the selected account. Pre-provenance installs have no record
|
||||
// to consult and fall back to the in-memory sync state.
|
||||
private sharedRuntimeAuthBelongsToAccount(accountId: string): boolean {
|
||||
if (!existsSync(this.getRuntimeAuthPath())) {
|
||||
return true
|
||||
}
|
||||
const status = this.resolveSharedRuntimeAuthProvenanceStatus()
|
||||
if (status.kind === 'committed') {
|
||||
return status.provenance.owner === 'managed' && status.provenance.accountId === accountId
|
||||
}
|
||||
return status.kind === 'missing' && this.lastSyncedAccountId === accountId
|
||||
}
|
||||
|
||||
// Why: the absence may still heal, so keep the selection — but leave no other
|
||||
// identity's credentials behind for the launch. Logged out beats logged in as
|
||||
// someone else, and fencing stops later syncs adopting the removed bytes.
|
||||
private clearRuntimeAuthForUnprovenSelection(): void {
|
||||
rmSync(this.getRuntimeAuthPath(), { force: true })
|
||||
this.lastWrittenAuthJson = null
|
||||
this.persistSharedRuntimeAuthProvenance({ owner: 'fenced' })
|
||||
}
|
||||
|
||||
private safeSyncForCurrentSelection(): void {
|
||||
try {
|
||||
this.syncForCurrentSelection()
|
||||
|
||||
Reference in New Issue
Block a user