fix: prevent 401 errors from CLI-refreshed tokens being clobbered (#1286)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-04-30 22:24:10 -07:00 committed by GitHub
parent b4f1f0ea6a
commit 03e7284e3c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 836 additions and 2 deletions

View File

@ -0,0 +1,478 @@
# Fix: Claude & Codex accounts intermittently return 401 Unauthorized
**Issue:** [#1284](https://github.com/stablyai/orca/issues/1284)
**Status:** Proposed fix
**Approach:** Add `readBackRefreshedTokens()` to both `CodexRuntimeHomeService` and `ClaudeRuntimeAuthService`
## Problem
Codex (and Claude) managed accounts intermittently return `401 Unauthorized` / `token_expired` errors after working normally for some time.
### Root Cause
Both `CodexRuntimeHomeService` and `ClaudeRuntimeAuthService` perform a **one-directional** auth sync: they always copy the managed account's credentials into the shared runtime path, but never read back refreshed tokens.
**The problematic flow (identical for both providers):**
1. User authenticates → credentials saved to managed storage
2. `syncForCurrentSelection()` copies managed credentials → runtime path
3. CLI runs using the runtime credentials
4. OAuth access token expires over time. **CLI refreshes it and writes updated tokens back to the runtime path**
5. Next PTY launch or rate-limit fetch triggers `syncForCurrentSelection()` again
6. **The sync overwrites the runtime credentials with the stale managed copy** — the refreshed token is lost
7. Next request uses the expired token → `401 Unauthorized`
**Codex specifics:**
- Runtime path: `~/.codex/auth.json`
- Managed path: `<userData>/codex-accounts/<id>/home/auth.json`
- Affected: `CodexRuntimeHomeService.syncForCurrentSelection()` line 84
**Claude specifics:**
- Runtime path: `~/.claude/.credentials.json` (+ Keychain on macOS)
- Managed path: `<userData>/claude-accounts/<id>/auth/.credentials.json` (+ Keychain)
- Affected: `ClaudeRuntimeAuthService.doSyncForCurrentSelection()` line 125
- Note: Claude already has `lastWrittenCredentialsJson` and `detectExternalLoginAndUpdateSnapshot()` for the managed→system-default transition, but it **does not** read back refreshed tokens during steady-state sync — the same one-directional overwrite happens
### Why it's intermittent
- Tokens have a multi-hour lifetime, so the bug only surfaces after enough time passes for the original token to expire
- The overwrite only happens on PTY launch or rate-limit fetch, not continuously
- If the user re-authenticates, the cycle resets with a fresh token
### Affected code
**Codex:**
- `src/main/codex-accounts/runtime-home-service.ts``syncForCurrentSelection()` (line 49-85)
- Called from `prepareForCodexLaunch()` (line 39) and `prepareForRateLimitFetch()` (line 44)
**Claude:**
- `src/main/claude-accounts/runtime-auth-service.ts``doSyncForCurrentSelection()` (line 96-131)
- Called from `prepareForClaudeLaunch()` (line 47) and `prepareForRateLimitFetch()` (line 52)
## System Context
```
┌──────────────────────────────────────────────────────────────┐
│ Orca (main process) │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ CodexRuntimeHomeService │ │
│ │ │ │
│ │ lastSyncedAccountId: string | null │ │
│ │ lastWrittenAuthJson: string | null ← NEW │ │
│ │ │ │
│ │ syncForCurrentSelection() │ │
│ │ ├─ captureSystemDefaultSnapshotIfNeeded() │ │
│ │ ├─ readBackRefreshedTokens() ← NEW │ │
│ │ └─ writeRuntimeAuth() │ │
│ │ │ │
│ │ restoreSystemDefaultSnapshot() │ │
│ │ └─ detectExternalLoginAndUpdateSnapshot() ← NEW│ │
│ └──────────┬──────────────────────────┬─────────────┘ │
│ │ │ │
│ ┌───────▼───────┐ ┌────────▼──────────┐ │
│ │ Managed Home │ │ Runtime Home │ │
│ │ <userData>/ │ │ ~/.codex/ │ │
│ │ codex-accounts│ │ auth.json │ │
│ │ /<id>/home/ │ │ │ │
│ │ auth.json │ │ │ │
│ └───────────────┘ └────────┬──────────┘ │
└──────────────────────────────────────────┼────────────────────┘
┌───────▼───────┐
│ Codex CLI │
│ │
│ reads auth.json
│ refreshes token
│ writes back │
└───────────────┘
```
## Data Flow: Token Refresh Preservation
```
PTY launch / rate-limit fetch
syncForCurrentSelection()
├─ captureSystemDefaultSnapshotIfNeeded()
├─ Is lastSyncedAccountId === activeAccount.id?
│ │
│ YES → readBackRefreshedTokens()
│ │ │
│ │ ├─ Read ~/.codex/auth.json (current runtime)
│ │ ├─ Compare to lastWrittenAuthJson
│ │ │ │
│ │ │ DIFFER → Codex CLI refreshed the token
│ │ │ │ Write runtime auth.json back
│ │ │ │ to managed home auth.json
│ │ │ │
│ │ │ MATCH → No external changes, continue
│ │ │
│ │ └─ (try/catch: failures log + continue)
│ │
│ NO → Skip read-back (account switch in progress)
├─ Read managed auth.json
├─ writeRuntimeAuth(contents)
│ ├─ Write to ~/.codex/auth.json
│ └─ Record lastWrittenAuthJson = contents
└─ lastSyncedAccountId = activeAccount.id
```
## Data Flow: External Login Detection (managed → system-default)
```
User deselects managed account (activeAccount = null)
syncForCurrentSelection()
├─ lastSyncedAccountId !== null? (was managed)
│ │
│ YES → restoreSystemDefaultSnapshot()
│ │ │
│ │ ├─ detectExternalLoginAndUpdateSnapshot()
│ │ │ │
│ │ │ ├─ lastWrittenAuthJson !== null?
│ │ │ ├─ Read current ~/.codex/auth.json
│ │ │ ├─ Compare to lastWrittenAuthJson
│ │ │ │ │
│ │ │ │ DIFFER → External login detected
│ │ │ │ │ (e.g. `codex auth login`)
│ │ │ │ │ Delete stale snapshot
│ │ │ │ │ Clear lastWrittenAuthJson
│ │ │ │ │ Return true (skip restore)
│ │ │ │ │
│ │ │ │ MATCH → No external login
│ │ │ │ Return false (do restore)
│ │ │ │
│ │ │ └─ (try/catch: failures → return false)
│ │ │
│ │ └─ If not external: restore snapshot as before
│ │
│ NO → Skip (was never managed)
└─ lastSyncedAccountId = null
```
## Fix: Add `readBackRefreshedTokens()` to Both Services
The fix adds the same read-back mechanism to both `CodexRuntimeHomeService` and `ClaudeRuntimeAuthService`. The core logic is identical: before overwriting runtime credentials, compare the file to what Orca last wrote. If they differ, the CLI refreshed the token — write it back to managed storage.
### What each service needs
| Change | Codex (`runtime-home-service.ts`) | Claude (`runtime-auth-service.ts`) |
|--------|-----------------------------------|-------------------------------------|
| Add `lastWrittenAuthJson` field | **NEW** | Already exists as `lastWrittenCredentialsJson` |
| Track writes in `writeRuntime*()` | **NEW** | Already done (line 271) |
| `readBackRefreshedTokens()` | **NEW** | **NEW** (missing despite having the tracking field) |
| `detectExternalLoginAndUpdateSnapshot()` | **NEW** | Already exists (line 224) |
| `clearLastWritten*()` for re-auth | **NEW** | **NEW** (same re-auth clobbering risk) |
### Key differences between services
| Aspect | Claude | Codex |
|--------|--------|-------|
| Auth storage | Keychain (macOS) + `.credentials.json` | `auth.json` only |
| Sync model | Async (serialized via `mutationQueue`) | Synchronous |
| Managed path | `managedAuthPath/.credentials.json` | `managedHomePath/auth.json` |
| Tracking field | `lastWrittenCredentialsJson` | `lastWrittenAuthJson` |
### Codex implementation
Add a `lastWrittenAuthJson` field and three new behaviors:
1. **`writeRuntimeAuth()`** — record what was written
2. **`readBackRefreshedTokens()`** — before overwriting, check if Codex CLI refreshed the token
3. **`detectExternalLoginAndUpdateSnapshot()`** — on managed→system-default transition, detect external logins
### Pseudocode
```typescript
export class CodexRuntimeHomeService {
private lastSyncedAccountId: string | null = null
// Why: tracks the auth.json content Orca last wrote to ~/.codex/auth.json.
// On managed→system-default transition, if the file differs from this value,
// an external login (e.g. `codex auth login`) overwrote it — so Orca adopts
// the file as the new system default instead of restoring a stale snapshot.
// Between syncs, if the file differs, Codex CLI refreshed the token — so
// Orca writes back the refreshed token to managed storage.
private lastWrittenAuthJson: string | null = null
// ... constructor, prepare methods unchanged ...
syncForCurrentSelection(): void {
this.captureSystemDefaultSnapshotIfNeeded()
const settings = this.store.getSettings()
const activeAccount = this.getActiveAccount(
settings.codexManagedAccounts,
settings.activeCodexManagedAccountId
)
if (!activeAccount) {
if (this.lastSyncedAccountId !== null) {
this.restoreSystemDefaultSnapshot()
this.lastSyncedAccountId = null
}
return
}
const activeAuthPath = join(activeAccount.managedHomePath, 'auth.json')
if (!existsSync(activeAuthPath)) {
console.warn(
'[codex-runtime-home] Active managed account is missing auth.json, restoring system default'
)
this.store.updateSettings({ activeCodexManagedAccountId: null })
if (this.lastSyncedAccountId !== null) {
this.restoreSystemDefaultSnapshot()
this.lastSyncedAccountId = null
}
return
}
// NEW: Before overwriting runtime auth, check if Codex CLI refreshed
// the token since our last write. If so, preserve those refreshed tokens
// back to managed storage so they aren't lost.
if (this.lastSyncedAccountId === activeAccount.id) {
this.readBackRefreshedTokens(activeAuthPath)
}
this.lastSyncedAccountId = activeAccount.id
this.writeRuntimeAuth(readFileSync(activeAuthPath, 'utf-8'))
}
// Why: Codex CLI refreshes expired OAuth tokens and writes them back to
// ~/.codex/auth.json. If we detect the runtime file differs from what Orca
// last wrote, the CLI must have refreshed — so we write the updated tokens
// back to managed storage before overwriting runtime with managed state.
// This is the Codex analog of the read-back logic implied by
// lastWrittenCredentialsJson in ClaudeRuntimeAuthService.
private readBackRefreshedTokens(managedAuthPath: string): void {
try {
const runtimeAuthPath = this.getRuntimeAuthPath()
if (!existsSync(runtimeAuthPath)) {
return
}
// Nothing to compare against — first sync or after restart.
// Skip read-back to avoid capturing stale/unknown state.
if (this.lastWrittenAuthJson === null) {
return
}
const runtimeContents = readFileSync(runtimeAuthPath, 'utf-8')
if (runtimeContents === this.lastWrittenAuthJson) {
return
}
// Codex CLI refreshed tokens at runtime — preserve them in managed storage.
writeFileAtomically(managedAuthPath, runtimeContents, { mode: 0o600 })
} catch (error) {
// Why: read-back is best-effort. A transient fs error (permissions,
// file locked by another process) must not block the forward sync
// path — the worst case is one more stale-token cycle, which is
// strictly better than failing the entire sync.
console.warn('[codex-runtime-home] Failed to read back refreshed tokens:', error)
}
}
private restoreSystemDefaultSnapshot(): void {
// Why: detect whether an external tool (e.g. `codex auth login`) overwrote
// auth.json while a managed account was active. If so, that external login
// becomes the new system default — skip the stale snapshot restore.
if (this.detectExternalLoginAndUpdateSnapshot()) {
return
}
const snapshotPath = this.getSystemDefaultSnapshotPath()
if (!existsSync(snapshotPath)) {
return
}
this.writeRuntimeAuth(readFileSync(snapshotPath, 'utf-8'))
}
// Why: mirrors ClaudeRuntimeAuthService.detectExternalLoginAndUpdateSnapshot().
// If the runtime auth.json differs from what Orca last wrote, something
// external changed it. That external state should become the new system
// default rather than being overwritten by a potentially stale snapshot.
private detectExternalLoginAndUpdateSnapshot(): boolean {
if (this.lastWrittenAuthJson === null) {
return false
}
const runtimeAuthPath = this.getRuntimeAuthPath()
if (!existsSync(runtimeAuthPath)) {
return false
}
try {
const currentAuth = readFileSync(runtimeAuthPath, 'utf-8')
if (currentAuth === this.lastWrittenAuthJson) {
return false
}
} catch {
return false
}
// External login detected — adopt current state as the new system default.
const snapshotPath = this.getSystemDefaultSnapshotPath()
rmSync(snapshotPath, { force: true })
this.lastWrittenAuthJson = null
return true
}
private writeRuntimeAuth(contents: string): void {
writeFileAtomically(this.getRuntimeAuthPath(), contents, { mode: 0o600 })
// Why: record what we wrote so readBackRefreshedTokens() and
// detectExternalLoginAndUpdateSnapshot() can detect external changes.
this.lastWrittenAuthJson = contents
}
}
```
### Re-auth safety: clearing `lastWrittenAuthJson`
When `CodexAccountService.doReauthenticateAccount()` runs, it writes fresh tokens to managed storage via `codex login`, then calls `syncForCurrentSelection()`. Without intervention, the read-back logic would see that runtime differs from `lastWrittenAuthJson` (because Codex CLI may have refreshed the runtime token between syncs) and write the stale runtime content back to managed — overwriting the fresh re-auth tokens.
**Fix:** `CodexRuntimeHomeService` must expose a method to clear `lastWrittenAuthJson` so that the re-auth caller can signal "managed storage was externally updated, skip read-back on next sync."
```typescript
// Called by CodexAccountService before syncForCurrentSelection() after re-auth or add-account.
clearLastWrittenAuthJson(): void {
this.lastWrittenAuthJson = null
}
```
The same applies to `doAddAccount()`, which also writes managed auth then syncs. Both callers must call `clearLastWrittenAuthJson()` before `syncForCurrentSelection()`.
### Changes to `CodexAccountService`
Both `doAddAccount()` and `doReauthenticateAccount()` write fresh tokens to managed storage (via `codex login`) then call `syncForCurrentSelection()`. They must clear the tracking field first to prevent read-back from overwriting the fresh tokens:
```typescript
// In doAddAccount(), after runCodexLogin succeeds:
this.runtimeHome.clearLastWrittenAuthJson()
this.runtimeHome.syncForCurrentSelection()
// In doReauthenticateAccount(), after runCodexLogin succeeds:
this.runtimeHome.clearLastWrittenAuthJson()
this.runtimeHome.syncForCurrentSelection()
```
### Safety considerations
- **Only reads back when Orca owns the runtime auth** (`lastSyncedAccountId === activeAccount.id`), so external `codex login` changes are not accidentally captured into the wrong managed account
- **Skips read-back when `lastWrittenAuthJson` is null** (first sync, after restart, or after re-auth/add) — avoids capturing stale or unknown state that Orca didn't write
- **try/catch around read-back** — a transient fs error (permissions, locked file) logs a warning but does not block the forward sync. Worst case: one more stale-token cycle, strictly better than a sync failure
- **try/catch around external login detection** — same rationale; detection failure falls through to normal snapshot restore, which is the safe default
- **Atomic write** to managed auth prevents partial-write corruption
- **No behavior change for account switches** — when switching accounts, `lastSyncedAccountId` differs from the new account ID, so no read-back occurs
- **No behavior change for system-default flow** — read-back only runs when a managed account is active and was previously synced
- **External login detection on managed→system-default transition** — if `codex auth login` ran while a managed account was active, the snapshot is stale. Deleting it and clearing `lastWrittenAuthJson` lets the external login persist as the new system default
- **In-memory tracking only**`lastWrittenAuthJson` is not persisted to disk. After an Orca restart, the field is null, and the first sync performs a clean write without read-back. This is intentionally conservative: we'd rather do one redundant overwrite than risk reading back unknown state
### Claude implementation
Claude already has `lastWrittenCredentialsJson` tracking and `detectExternalLoginAndUpdateSnapshot()`. It only needs two additions:
1. **`readBackRefreshedTokens()`** — same logic as Codex, adapted for Claude's async model and Keychain
2. **`clearLastWrittenCredentialsJson()`** — for re-auth safety (same pattern as Codex)
```typescript
// In ClaudeRuntimeAuthService:
// Add to doSyncForCurrentSelection(), before writeRuntimeCredentials():
if (this.lastSyncedAccountId === activeAccount.id) {
await this.readBackRefreshedTokens(activeAccount)
}
// Why: Claude CLI refreshes expired OAuth tokens and writes them back to
// .credentials.json (and Keychain on macOS). If we detect the runtime file
// differs from what Orca last wrote, the CLI must have refreshed.
private async readBackRefreshedTokens(account: ClaudeManagedAccount): Promise<void> {
try {
if (this.lastWrittenCredentialsJson === null) {
return
}
const paths = this.pathResolver.getRuntimePaths()
if (!existsSync(paths.credentialsPath)) {
return
}
const runtimeContents = readFileSync(paths.credentialsPath, 'utf-8')
if (runtimeContents === this.lastWrittenCredentialsJson) {
return
}
// CLI refreshed tokens — write back to managed storage.
if (process.platform === 'darwin') {
await writeManagedClaudeKeychainCredentials(account.id, runtimeContents)
} else {
const credentialsPath = join(account.managedAuthPath, '.credentials.json')
writeFileAtomically(credentialsPath, runtimeContents, { mode: 0o600 })
}
} catch (error) {
console.warn('[claude-runtime-auth] Failed to read back refreshed tokens:', error)
}
}
// Exposed for ClaudeAccountService to call before sync after re-auth/add.
clearLastWrittenCredentialsJson(): void {
this.lastWrittenCredentialsJson = null
}
```
### Changes to `ClaudeAccountService`
Same pattern as Codex — both `doAddAccount()` and `doReauthenticateAccount()` must clear the tracking field before syncing:
```typescript
// In doAddAccount(), after login succeeds:
this.runtimeAuth.clearLastWrittenCredentialsJson()
await this.runtimeAuth.syncForCurrentSelection()
// In doReauthenticateAccount(), after login succeeds:
this.runtimeAuth.clearLastWrittenCredentialsJson()
await this.runtimeAuth.syncForCurrentSelection()
```
### Structural alignment
The two services should stay structurally aligned. Both now have the same three mechanisms:
| Mechanism | Codex | Claude |
|-----------|-------|--------|
| Track what we wrote | `lastWrittenAuthJson` | `lastWrittenCredentialsJson` |
| Read back refreshed tokens | `readBackRefreshedTokens()` | `readBackRefreshedTokens()` |
| Detect external logins | `detectExternalLoginAndUpdateSnapshot()` | `detectExternalLoginAndUpdateSnapshot()` |
| Clear tracking on re-auth | `clearLastWrittenAuthJson()` | `clearLastWrittenCredentialsJson()` |
Future changes to either service should be cross-checked against the other.
### Files to modify
**Codex:**
- `src/main/codex-accounts/runtime-home-service.ts` — add `lastWrittenAuthJson` field, `readBackRefreshedTokens()`, `detectExternalLoginAndUpdateSnapshot()`, `clearLastWrittenAuthJson()`, update `writeRuntimeAuth()` and `restoreSystemDefaultSnapshot()`
- `src/main/codex-accounts/service.ts` — add `clearLastWrittenAuthJson()` calls in `doAddAccount()` and `doReauthenticateAccount()`
- `src/main/codex-accounts/runtime-home-service.test.ts` — add test cases
**Claude:**
- `src/main/claude-accounts/runtime-auth-service.ts` — add `readBackRefreshedTokens()`, `clearLastWrittenCredentialsJson()`, call read-back in `doSyncForCurrentSelection()`
- `src/main/claude-accounts/service.ts` — add `clearLastWrittenCredentialsJson()` calls in `doAddAccount()` and `doReauthenticateAccount()`
- `src/main/claude-accounts/runtime-auth-service.test.ts` — add test cases
**Test cases (both services):**
- Token read-back when CLI refreshes tokens between syncs
- No read-back on first sync (tracking field is null)
- No read-back on account switch (`lastSyncedAccountId` differs)
- External login detection on managed→system-default transition
- Graceful degradation when read-back throws (fs error)
- Re-auth does not lose fresh tokens: after clearing tracking field + sync, managed storage retains the re-auth tokens
- Add-account does not lose fresh tokens: same pattern as re-auth

View File

@ -13,7 +13,8 @@ import {
deleteActiveClaudeKeychainCredentials,
readActiveClaudeKeychainCredentials,
readManagedClaudeKeychainCredentials,
writeActiveClaudeKeychainCredentials
writeActiveClaudeKeychainCredentials,
writeManagedClaudeKeychainCredentials
} from './keychain'
export type ClaudeRuntimeAuthPreparation = {
@ -122,6 +123,14 @@ export class ClaudeRuntimeAuthService {
return
}
// Why: Claude CLI refreshes expired OAuth tokens and writes them back to
// .credentials.json. If we detect the runtime file differs from what Orca
// last wrote, the CLI must have refreshed — so we preserve those tokens
// back to managed storage before overwriting runtime with managed state.
if (this.lastSyncedAccountId === activeAccount.id) {
await this.readBackRefreshedTokens(activeAccount)
}
this.writeRuntimeCredentials(credentialsJson)
if (process.platform === 'darwin') {
await writeActiveClaudeKeychainCredentials(credentialsJson)
@ -130,6 +139,44 @@ export class ClaudeRuntimeAuthService {
this.lastSyncedAccountId = activeAccount.id
}
// Why: called by ClaudeAccountService before syncForCurrentSelection() after
// re-auth or add-account. Those flows write fresh tokens to managed storage,
// so the read-back must be skipped to avoid overwriting them with stale
// runtime tokens.
clearLastWrittenCredentialsJson(): void {
this.lastWrittenCredentialsJson = null
}
private async readBackRefreshedTokens(account: ClaudeManagedAccount): Promise<void> {
try {
if (this.lastWrittenCredentialsJson === null) {
return
}
const paths = this.pathResolver.getRuntimePaths()
if (!existsSync(paths.credentialsPath)) {
return
}
const runtimeContents = readFileSync(paths.credentialsPath, 'utf-8')
if (runtimeContents === this.lastWrittenCredentialsJson) {
return
}
if (process.platform === 'darwin') {
await writeManagedClaudeKeychainCredentials(account.id, runtimeContents)
} else {
const credentialsPath = join(account.managedAuthPath, '.credentials.json')
writeFileAtomically(credentialsPath, runtimeContents, { mode: 0o600 })
}
} catch (error) {
// Why: read-back is best-effort. A transient fs error must not block the
// forward sync path — the worst case is one more stale-token cycle, which
// is strictly better than failing the entire sync.
console.warn('[claude-runtime-auth] Failed to read back refreshed tokens:', error)
}
}
private getPreparation(): ClaudeRuntimeAuthPreparation {
const settings = this.store.getSettings()
const paths = this.pathResolver.getRuntimePaths()

View File

@ -115,6 +115,7 @@ export class ClaudeAccountService {
claudeManagedAccounts: [...previousSettings.claudeManagedAccounts, account],
activeClaudeManagedAccountId: account.id
})
this.runtimeAuth.clearLastWrittenCredentialsJson()
await this.syncRuntimeAuthWithLivePtyGate()
await this.rateLimits.refreshForClaudeAccountChange(outgoingAccountId)
return this.getSnapshot()
@ -153,6 +154,7 @@ export class ClaudeAccountService {
)
})
try {
this.runtimeAuth.clearLastWrittenCredentialsJson()
await this.syncRuntimeAuthWithLivePtyGate()
await this.rateLimits.refreshForClaudeAccountChange()
return this.getSnapshot()

View File

@ -498,6 +498,225 @@ describe('CodexRuntimeHomeService', () => {
expect(existsSync(snapshotPath)).toBe(false)
})
it('reads back CLI-refreshed tokens into managed storage on subsequent sync', async () => {
const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json')
writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8')
const managedHomePath = createManagedAuth(
testState.userDataDir,
'account-1',
'{"tokens":"original"}\n'
)
const managedAuthPath = join(managedHomePath, 'auth.json')
const settings = createSettings({
codexManagedAccounts: [
{
id: 'account-1',
email: 'user@example.com',
managedHomePath,
providerAccountId: null,
workspaceLabel: null,
workspaceAccountId: null,
createdAt: 1,
updatedAt: 1,
lastAuthenticatedAt: 1
}
],
activeCodexManagedAccountId: 'account-1'
})
const store = createStore(settings)
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
const service = new CodexRuntimeHomeService(store as never)
// Simulate CLI refreshing the token in ~/.codex/auth.json
writeFileSync(runtimeAuthPath, '{"tokens":"refreshed"}\n', 'utf-8')
// Next sync should read back the refreshed token to managed storage
service.syncForCurrentSelection()
expect(readFileSync(managedAuthPath, 'utf-8')).toBe('{"tokens":"refreshed"}\n')
expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"tokens":"refreshed"}\n')
})
it('skips read-back on first sync after restart (lastWrittenAuthJson is null)', async () => {
const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json')
// Pre-populate runtime with different content to simulate a CLI refresh while Orca was down
writeFileSync(runtimeAuthPath, '{"tokens":"refreshed-while-down"}\n', 'utf-8')
const managedHomePath = createManagedAuth(
testState.userDataDir,
'account-1',
'{"tokens":"original"}\n'
)
const managedAuthPath = join(managedHomePath, 'auth.json')
const store = createStore(
createSettings({
codexManagedAccounts: [
{
id: 'account-1',
email: 'user@example.com',
managedHomePath,
providerAccountId: null,
workspaceLabel: null,
workspaceAccountId: null,
createdAt: 1,
updatedAt: 1,
lastAuthenticatedAt: 1
}
],
activeCodexManagedAccountId: 'account-1'
})
)
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
new CodexRuntimeHomeService(store as never)
// After restart, managed storage should NOT be overwritten by unknown runtime state
expect(readFileSync(managedAuthPath, 'utf-8')).toBe('{"tokens":"original"}\n')
// Runtime should be overwritten with managed (conservative behavior)
expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"tokens":"original"}\n')
})
it('skips read-back during account switch', async () => {
const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json')
writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8')
const managedHomePath1 = createManagedAuth(
testState.userDataDir,
'account-1',
'{"tokens":"account1"}\n'
)
const managedHomePath2 = createManagedAuth(
testState.userDataDir,
'account-2',
'{"tokens":"account2"}\n'
)
const managedAuthPath2 = join(managedHomePath2, 'auth.json')
const settings = createSettings({
codexManagedAccounts: [
{
id: 'account-1',
email: 'user1@example.com',
managedHomePath: managedHomePath1,
providerAccountId: null,
workspaceLabel: null,
workspaceAccountId: null,
createdAt: 1,
updatedAt: 1,
lastAuthenticatedAt: 1
},
{
id: 'account-2',
email: 'user2@example.com',
managedHomePath: managedHomePath2,
providerAccountId: null,
workspaceLabel: null,
workspaceAccountId: null,
createdAt: 1,
updatedAt: 1,
lastAuthenticatedAt: 1
}
],
activeCodexManagedAccountId: 'account-1'
})
const store = createStore(settings)
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
const service = new CodexRuntimeHomeService(store as never)
// Switch to account-2 — runtime still has account-1 tokens
settings.activeCodexManagedAccountId = 'account-2'
service.syncForCurrentSelection()
// Account-2's managed auth should NOT be contaminated with account-1's runtime tokens
expect(readFileSync(managedAuthPath2, 'utf-8')).toBe('{"tokens":"account2"}\n')
expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"tokens":"account2"}\n')
})
it('detects external login on managed→system-default transition', async () => {
const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json')
writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8')
const managedHomePath = createManagedAuth(
testState.userDataDir,
'account-1',
'{"account":"managed"}\n'
)
const settings = createSettings({
codexManagedAccounts: [
{
id: 'account-1',
email: 'user@example.com',
managedHomePath,
providerAccountId: null,
workspaceLabel: null,
workspaceAccountId: null,
createdAt: 1,
updatedAt: 1,
lastAuthenticatedAt: 1
}
],
activeCodexManagedAccountId: 'account-1'
})
const store = createStore(settings)
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
const service = new CodexRuntimeHomeService(store as never)
// External `codex auth login` overwrites runtime auth
writeFileSync(runtimeAuthPath, '{"account":"external-login"}\n', 'utf-8')
// Deselect managed account
settings.activeCodexManagedAccountId = null
service.syncForCurrentSelection()
// Should keep the external login, not restore stale system snapshot
expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"external-login"}\n')
// Snapshot should be deleted so next capture picks up the external login
expect(
existsSync(join(testState.userDataDir, 'codex-runtime-home', 'system-default-auth.json'))
).toBe(false)
})
it('does not clobber fresh tokens after clearLastWrittenAuthJson', async () => {
const runtimeAuthPath = join(testState.fakeHomeDir, '.codex', 'auth.json')
writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8')
const managedHomePath = createManagedAuth(
testState.userDataDir,
'account-1',
'{"tokens":"original"}\n'
)
const managedAuthPath = join(managedHomePath, 'auth.json')
const settings = createSettings({
codexManagedAccounts: [
{
id: 'account-1',
email: 'user@example.com',
managedHomePath,
providerAccountId: null,
workspaceLabel: null,
workspaceAccountId: null,
createdAt: 1,
updatedAt: 1,
lastAuthenticatedAt: 1
}
],
activeCodexManagedAccountId: 'account-1'
})
const store = createStore(settings)
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
const service = new CodexRuntimeHomeService(store as never)
// Simulate re-auth: managed storage gets fresh tokens
writeFileSync(managedAuthPath, '{"tokens":"reauthed"}\n', 'utf-8')
// Clear tracking before sync (as CodexAccountService would)
service.clearLastWrittenAuthJson()
service.syncForCurrentSelection()
// Fresh re-auth tokens should survive — not be clobbered by stale runtime read-back
expect(readFileSync(managedAuthPath, 'utf-8')).toBe('{"tokens":"reauthed"}\n')
expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"tokens":"reauthed"}\n')
})
it('preserves conflicting legacy session files under deterministic names', async () => {
const runtimeSessionsDir = join(testState.fakeHomeDir, '.codex', 'sessions')
mkdirSync(runtimeSessionsDir, { recursive: true })

View File

@ -24,6 +24,13 @@ export class CodexRuntimeHomeService {
// (e.g. user running `codex login` or another auth tool). The snapshot
// restore only fires on the managed→system-default transition.
private lastSyncedAccountId: string | null = null
// Why: tracks the auth.json content Orca last wrote to ~/.codex/auth.json.
// Between syncs, if the file differs, Codex CLI refreshed the token — so
// Orca writes back the refreshed token to managed storage before overwriting.
// On managed→system-default transition, if the file differs, an external
// login (e.g. `codex auth login`) overwrote it — so Orca adopts the file as
// the new system default instead of restoring a stale snapshot.
private lastWrittenAuthJson: string | null = null
constructor(private readonly store: Store) {
this.safeMigrateLegacyManagedState()
@ -80,10 +87,51 @@ export class CodexRuntimeHomeService {
return
}
// Why: Codex CLI refreshes expired OAuth tokens and writes them back to
// ~/.codex/auth.json. If we detect the runtime file differs from what Orca
// last wrote, the CLI must have refreshed — so we preserve those tokens
// back to managed storage before overwriting runtime with managed state.
if (this.lastSyncedAccountId === activeAccount.id) {
this.readBackRefreshedTokens(activeAuthPath)
}
this.lastSyncedAccountId = activeAccount.id
this.writeRuntimeAuth(readFileSync(activeAuthPath, 'utf-8'))
}
// Why: called by CodexAccountService before syncForCurrentSelection() after
// re-auth or add-account. Those flows write fresh tokens to managed storage,
// so the read-back must be skipped to avoid overwriting them with stale
// runtime tokens.
clearLastWrittenAuthJson(): void {
this.lastWrittenAuthJson = null
}
private readBackRefreshedTokens(managedAuthPath: string): void {
try {
const runtimeAuthPath = this.getRuntimeAuthPath()
if (!existsSync(runtimeAuthPath)) {
return
}
if (this.lastWrittenAuthJson === null) {
return
}
const runtimeContents = readFileSync(runtimeAuthPath, 'utf-8')
if (runtimeContents === this.lastWrittenAuthJson) {
return
}
writeFileAtomically(managedAuthPath, runtimeContents, { mode: 0o600 })
} catch (error) {
// Why: read-back is best-effort. A transient fs error must not block the
// forward sync path — the worst case is one more stale-token cycle, which
// is strictly better than failing the entire sync.
console.warn('[codex-runtime-home] Failed to read back refreshed tokens:', error)
}
}
private safeSyncForCurrentSelection(): void {
try {
this.syncForCurrentSelection()
@ -297,6 +345,13 @@ export class CodexRuntimeHomeService {
}
private restoreSystemDefaultSnapshot(): void {
// Why: detect whether an external tool (e.g. `codex auth login`) overwrote
// auth.json while a managed account was active. If so, that external login
// becomes the new system default — skip the stale snapshot restore.
if (this.detectExternalLoginAndUpdateSnapshot()) {
return
}
const snapshotPath = this.getSystemDefaultSnapshotPath()
if (!existsSync(snapshotPath)) {
return
@ -305,10 +360,40 @@ export class CodexRuntimeHomeService {
this.writeRuntimeAuth(readFileSync(snapshotPath, 'utf-8'))
}
// Why: mirrors ClaudeRuntimeAuthService.detectExternalLoginAndUpdateSnapshot().
// If the runtime auth.json differs from what Orca last wrote, something
// external changed it. That external state should become the new system
// default rather than being overwritten by a potentially stale snapshot.
private detectExternalLoginAndUpdateSnapshot(): boolean {
if (this.lastWrittenAuthJson === null) {
return false
}
const runtimeAuthPath = this.getRuntimeAuthPath()
if (!existsSync(runtimeAuthPath)) {
return false
}
try {
const currentAuth = readFileSync(runtimeAuthPath, 'utf-8')
if (currentAuth === this.lastWrittenAuthJson) {
return false
}
} catch {
return false
}
const snapshotPath = this.getSystemDefaultSnapshotPath()
rmSync(snapshotPath, { force: true })
this.lastWrittenAuthJson = null
return true
}
private writeRuntimeAuth(contents: string): void {
// Why: auth.json contains sensitive credentials. Restrict to owner-only
// so other users on a shared Linux/macOS machine cannot read it.
writeFileAtomically(this.getRuntimeAuthPath(), contents, { mode: 0o600 })
this.lastWrittenAuthJson = contents
}
clearSystemDefaultSnapshot(): void {

View File

@ -118,7 +118,8 @@ function createRateLimits() {
function createRuntimeHome() {
return {
syncForCurrentSelection: vi.fn()
syncForCurrentSelection: vi.fn(),
clearLastWrittenAuthJson: vi.fn()
}
}

View File

@ -106,6 +106,7 @@ export class CodexAccountService {
activeCodexManagedAccountId: account.id
})
this.safeSyncCanonicalConfigToManagedHomes()
this.runtimeHome.clearLastWrittenAuthJson()
this.runtimeHome.syncForCurrentSelection()
// Why: the new account becomes active, so the previous active account is
@ -149,6 +150,7 @@ export class CodexAccountService {
codexManagedAccounts: updatedAccounts
})
this.safeSyncCanonicalConfigToManagedHomes()
this.runtimeHome.clearLastWrittenAuthJson()
this.runtimeHome.syncForCurrentSelection()
// Why: re-auth can change which actual Codex identity the managed home