mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 16:03:17 +00:00
merge(codex-ma): integrate PR-C readback
# Conflicts: # src/main/codex-accounts/runtime-home-service.ts
This commit is contained in:
@@ -1309,6 +1309,251 @@ describe('CodexRuntimeHomeService', () => {
|
||||
expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(account1Auth)
|
||||
})
|
||||
|
||||
it('persists an outgoing managed refresh at launch before the real-home lane takes over', async () => {
|
||||
const runtimeAuthPath = getRuntimeCodexAuthPath()
|
||||
const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system')
|
||||
const managedAuth = createCodexAuthJson('user@example.com', 'acct-user', 'managed', 1)
|
||||
const refreshedManagedAuth = createCodexAuthJson(
|
||||
'user@example.com',
|
||||
'acct-user',
|
||||
'refreshed',
|
||||
2
|
||||
)
|
||||
writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8')
|
||||
const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth)
|
||||
const settings = createSettings({
|
||||
codexSystemDefaultRealHomeEnabled: true,
|
||||
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: {} }
|
||||
})
|
||||
const store = createStore(settings)
|
||||
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
|
||||
const service = new CodexRuntimeHomeService(store as never)
|
||||
|
||||
// Managed account is active, so the launch stays on the managed lane and
|
||||
// mirrors account-1 into the shared runtime home.
|
||||
expect(service.isHostSystemDefaultRealHome()).toBe(false)
|
||||
expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath())
|
||||
expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(managedAuth)
|
||||
|
||||
// Codex CLI refreshes the token in the shared runtime home.
|
||||
writeFileSync(runtimeAuthPath, refreshedManagedAuth, 'utf-8')
|
||||
|
||||
// Selection drops to the system default WITHOUT an explicit select (no
|
||||
// syncForCurrentSelection), then Codex launches on the real home.
|
||||
settings.activeCodexManagedAccountId = null
|
||||
settings.activeCodexManagedAccountIdsByRuntime = { host: null, wsl: {} }
|
||||
expect(service.isHostSystemDefaultRealHome()).toBe(true)
|
||||
expect(service.prepareForCodexLaunch()).toBeNull()
|
||||
|
||||
// The refreshed token was read back to account-1's own home before the real
|
||||
// home took over.
|
||||
expect(readFileSync(join(managedHomePath, 'auth.json'), 'utf-8')).toBe(refreshedManagedAuth)
|
||||
})
|
||||
|
||||
it('reads back only once when polling first observes a managed-to-real-home transition', async () => {
|
||||
const runtimeAuthPath = getRuntimeCodexAuthPath()
|
||||
const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system')
|
||||
const managedAuth = createCodexAuthJson('user@example.com', 'acct-user', 'managed', 1)
|
||||
const refreshedManagedAuth = createCodexAuthJson(
|
||||
'user@example.com',
|
||||
'acct-user',
|
||||
'refreshed',
|
||||
2
|
||||
)
|
||||
writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8')
|
||||
const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth)
|
||||
const settings = createSettings({
|
||||
codexSystemDefaultRealHomeEnabled: true,
|
||||
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: {} }
|
||||
})
|
||||
const store = createStore(settings)
|
||||
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
|
||||
const service = new CodexRuntimeHomeService(store as never)
|
||||
writeFileSync(runtimeAuthPath, refreshedManagedAuth, 'utf-8')
|
||||
settings.activeCodexManagedAccountId = null
|
||||
settings.activeCodexManagedAccountIdsByRuntime = { host: null, wsl: {} }
|
||||
const syncSpy = vi.spyOn(service, 'syncForCurrentSelection')
|
||||
|
||||
expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath())
|
||||
expect(readFileSync(join(managedHomePath, 'auth.json'), 'utf-8')).toBe(refreshedManagedAuth)
|
||||
expect(readFileSync(getSystemCodexAuthPath(), 'utf-8')).toBe(systemAuth)
|
||||
expect(syncSpy).toHaveBeenCalledTimes(1)
|
||||
|
||||
expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath())
|
||||
expect(service.prepareForCodexLaunch()).toBeNull()
|
||||
expect(syncSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('persists the active managed refresh when missing auth auto-deselects to the real home', 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 settings = createSettings({
|
||||
codexSystemDefaultRealHomeEnabled: true,
|
||||
codexManagedAccounts: [
|
||||
{
|
||||
id: 'account-1',
|
||||
email: 'one@example.com',
|
||||
managedHomePath: managedHomePath1,
|
||||
providerAccountId: 'acct-1',
|
||||
workspaceLabel: null,
|
||||
workspaceAccountId: 'acct-1',
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
lastAuthenticatedAt: 1
|
||||
}
|
||||
],
|
||||
activeCodexManagedAccountId: 'account-1',
|
||||
activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} }
|
||||
})
|
||||
const store = createStore(settings)
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
|
||||
const service = new CodexRuntimeHomeService(store as never)
|
||||
|
||||
// account-1 is synced; Codex refreshes its token 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.
|
||||
rmSync(join(managedHomePath1, 'auth.json'), { force: true })
|
||||
expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath())
|
||||
|
||||
// account-1's runtime refresh was recovered before auto-deselect, and the
|
||||
// selection is now the system default without changing the real auth.
|
||||
expect(readFileSync(join(managedHomePath1, 'auth.json'), 'utf-8')).toBe(account1Refreshed)
|
||||
expect(readFileSync(getSystemCodexAuthPath(), 'utf-8')).toBe(systemAuth)
|
||||
expect(store.updateSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ activeCodexManagedAccountId: null })
|
||||
)
|
||||
expect(warnSpy).toHaveBeenCalled()
|
||||
|
||||
// The follow-up launch takes the real-home lane and stays a no-op read-back.
|
||||
expect(service.isHostSystemDefaultRealHome()).toBe(true)
|
||||
expect(service.prepareForCodexLaunch()).toBeNull()
|
||||
expect(readFileSync(join(managedHomePath1, 'auth.json'), 'utf-8')).toBe(account1Refreshed)
|
||||
})
|
||||
|
||||
it('refuses 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)
|
||||
const mismatchedAuth = createCodexAuthJson('other@example.com', 'acct-other', 'other', 2)
|
||||
writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8')
|
||||
const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth)
|
||||
const settings = createSettings({
|
||||
codexSystemDefaultRealHomeEnabled: true,
|
||||
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: {} }
|
||||
})
|
||||
const store = createStore(settings)
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
|
||||
const service = new CodexRuntimeHomeService(store as never)
|
||||
|
||||
writeFileSync(runtimeAuthPath, mismatchedAuth, 'utf-8')
|
||||
rmSync(join(managedHomePath, 'auth.json'), { force: true })
|
||||
expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath())
|
||||
|
||||
expect(existsSync(join(managedHomePath, 'auth.json'))).toBe(false)
|
||||
expect(readFileSync(getSystemCodexAuthPath(), 'utf-8')).toBe(systemAuth)
|
||||
expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(systemAuth)
|
||||
expect(warnSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not re-run the outgoing read-back on a real-home launch after a normal deselect', async () => {
|
||||
const runtimeAuthPath = getRuntimeCodexAuthPath()
|
||||
const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system')
|
||||
const managedAuth = createCodexAuthJson('user@example.com', 'acct-user', 'managed', 1)
|
||||
const refreshedManagedAuth = createCodexAuthJson(
|
||||
'user@example.com',
|
||||
'acct-user',
|
||||
'refreshed',
|
||||
2
|
||||
)
|
||||
writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8')
|
||||
const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth)
|
||||
const settings = createSettings({
|
||||
codexSystemDefaultRealHomeEnabled: true,
|
||||
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: {} }
|
||||
})
|
||||
const store = createStore(settings)
|
||||
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
|
||||
const service = new CodexRuntimeHomeService(store as never)
|
||||
|
||||
// Refresh, then deselect through the normal (explicit) select path.
|
||||
writeFileSync(runtimeAuthPath, refreshedManagedAuth, 'utf-8')
|
||||
settings.activeCodexManagedAccountId = null
|
||||
settings.activeCodexManagedAccountIdsByRuntime = { host: null, wsl: {} }
|
||||
service.syncForCurrentSelection()
|
||||
expect(readFileSync(join(managedHomePath, 'auth.json'), 'utf-8')).toBe(refreshedManagedAuth)
|
||||
|
||||
// A subsequent real-home launch must not re-run the transition — the
|
||||
// outgoing account is already reconciled to the system default.
|
||||
const syncSpy = vi.spyOn(service, 'syncForCurrentSelection')
|
||||
expect(service.prepareForCodexLaunch()).toBeNull()
|
||||
expect(syncSpy).not.toHaveBeenCalled()
|
||||
expect(readFileSync(join(managedHomePath, 'auth.json'), 'utf-8')).toBe(refreshedManagedAuth)
|
||||
})
|
||||
|
||||
it('uses the same host CODEX_HOME after switching managed Codex accounts', async () => {
|
||||
const runtimeAuthPath = getRuntimeCodexAuthPath()
|
||||
const account1Auth = createCodexAuthJson('one@example.com', 'acct-1', 'one')
|
||||
|
||||
@@ -184,6 +184,10 @@ export class CodexRuntimeHomeService {
|
||||
// Returning null tells the PTY/env layer to inject no managed CODEX_HOME;
|
||||
// sessions, auth, and config all live in the native home. No system->
|
||||
// managed session bridge runs, so the real home stays the single source.
|
||||
// First guarantee any still-synced managed account's refreshed token is
|
||||
// read back to its own home: this early return skips syncForCurrentSelection,
|
||||
// so a launch-time managed->default transition would otherwise lose it.
|
||||
this.readBackOutgoingManagedHostAccountBeforeSystemDefault()
|
||||
return null
|
||||
}
|
||||
this.invalidateBackfillAfterManagedSystemDefaultLaunch(launchEnv)
|
||||
@@ -298,6 +302,22 @@ export class CodexRuntimeHomeService {
|
||||
this.lastSyncedAccountId = null
|
||||
}
|
||||
|
||||
// Why: the real-home / system-default lane leaves the shared runtime home
|
||||
// untouched, so a managed account still recorded as synced (selection nulled
|
||||
// without a syncForCurrentSelection pass, or auto-deselect on missing auth)
|
||||
// can strand a Codex-refreshed token there. Run the managed->system-default
|
||||
// transition to read that token back to its canonical per-account home before
|
||||
// the real home takes over. Callers gate this on the system-default selection
|
||||
// (host === null), so syncForCurrentSelection restores only Orca's runtime
|
||||
// mirror from ~/.codex and never writes the real ~/.codex. No-op once the
|
||||
// selection has already been reconciled to the system default.
|
||||
private readBackOutgoingManagedHostAccountBeforeSystemDefault(): void {
|
||||
if (this.lastSyncedAccountId === null) {
|
||||
return
|
||||
}
|
||||
this.syncForCurrentSelection()
|
||||
}
|
||||
|
||||
private invalidateBackfillAfterManagedSystemDefaultLaunch(launchEnv?: NodeJS.ProcessEnv): void {
|
||||
const settings = this.store.getSettings()
|
||||
if (normalizeCodexRuntimeSelection(settings).host !== null) {
|
||||
@@ -482,6 +502,7 @@ export class CodexRuntimeHomeService {
|
||||
// CODEX_HOME before ~/.codex. Nested Orca launches can inherit the
|
||||
// managed home, restarting the background OAuth conflict (#5370), so
|
||||
// pin this non-interactive lane to the native home explicitly.
|
||||
this.readBackOutgoingManagedHostAccountBeforeSystemDefault()
|
||||
return getSystemCodexHomePath()
|
||||
}
|
||||
this.syncForCurrentSelection()
|
||||
@@ -595,6 +616,9 @@ export class CodexRuntimeHomeService {
|
||||
console.warn(
|
||||
'[codex-runtime-home] Active managed account is missing auth.json, restoring system default'
|
||||
)
|
||||
if (this.lastSyncedAccountId === activeAccount.id) {
|
||||
outgoingReadBackResult = this.recoverRefreshForMissingActiveAccount(activeAccount)
|
||||
}
|
||||
this.store.updateSettings({
|
||||
activeCodexManagedAccountId: null,
|
||||
activeCodexManagedAccountIdsByRuntime: {
|
||||
@@ -603,7 +627,9 @@ export class CodexRuntimeHomeService {
|
||||
}
|
||||
})
|
||||
if (this.lastSyncedAccountId !== null) {
|
||||
this.restoreSystemDefaultSnapshot({ detectExternalLogin: true })
|
||||
this.restoreSystemDefaultSnapshot({
|
||||
detectExternalLogin: outgoingReadBackResult !== 'rejected'
|
||||
})
|
||||
this.lastSyncedAccountId = null
|
||||
}
|
||||
return
|
||||
@@ -737,6 +763,32 @@ export class CodexRuntimeHomeService {
|
||||
})
|
||||
}
|
||||
|
||||
private recoverRefreshForMissingActiveAccount(account: CodexManagedAccount): CodexReadBackResult {
|
||||
try {
|
||||
const runtimeAuthPath = this.getRuntimeAuthPath()
|
||||
if (!existsSync(runtimeAuthPath) || this.lastWrittenAuthJson === null) {
|
||||
return 'rejected'
|
||||
}
|
||||
const runtimeContents = readFileSync(runtimeAuthPath, 'utf-8')
|
||||
if (runtimeContents === this.lastWrittenAuthJson) {
|
||||
return 'unchanged'
|
||||
}
|
||||
// Why: the canonical file is gone, so the exact in-memory bytes Orca
|
||||
// previously mirrored are the only safe identity baseline for recovery.
|
||||
if (!this.runtimeAuthMatchesAccount(runtimeContents, account, this.lastWrittenAuthJson)) {
|
||||
return 'rejected'
|
||||
}
|
||||
writeFileAtomically(join(account.managedHomePath, 'auth.json'), runtimeContents, {
|
||||
mode: 0o600
|
||||
})
|
||||
this.lastWrittenAuthJson = runtimeContents
|
||||
return 'persisted'
|
||||
} catch (error) {
|
||||
console.warn('[codex-runtime-home] Failed to recover missing managed auth:', error)
|
||||
return 'rejected'
|
||||
}
|
||||
}
|
||||
|
||||
private safeSyncForCurrentSelection(): void {
|
||||
try {
|
||||
this.syncForCurrentSelection()
|
||||
|
||||
Reference in New Issue
Block a user