diff --git a/docs/fix-codex-token-refresh-loss.md b/docs/fix-codex-token-refresh-loss.md new file mode 100644 index 000000000..d5822cc3a --- /dev/null +++ b/docs/fix-codex-token-refresh-loss.md @@ -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: `/codex-accounts//home/auth.json` +- Affected: `CodexRuntimeHomeService.syncForCurrentSelection()` line 84 + +**Claude specifics:** +- Runtime path: `~/.claude/.credentials.json` (+ Keychain on macOS) +- Managed path: `/claude-accounts//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 │ │ +│ │ / │ │ ~/.codex/ │ │ +│ │ codex-accounts│ │ auth.json │ │ +│ │ //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 { + 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 diff --git a/src/main/claude-accounts/runtime-auth-service.ts b/src/main/claude-accounts/runtime-auth-service.ts index 64768f2ee..3c9f32c36 100644 --- a/src/main/claude-accounts/runtime-auth-service.ts +++ b/src/main/claude-accounts/runtime-auth-service.ts @@ -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 { + 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() diff --git a/src/main/claude-accounts/service.ts b/src/main/claude-accounts/service.ts index b1245c234..78324c2ca 100644 --- a/src/main/claude-accounts/service.ts +++ b/src/main/claude-accounts/service.ts @@ -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() diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 2227c05bc..2dfb2b32e 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -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 }) diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index f0cf6fd84..3ec95ecfa 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -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 { diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index fa8eb07d2..492bb0007 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -118,7 +118,8 @@ function createRateLimits() { function createRuntimeHome() { return { - syncForCurrentSelection: vi.fn() + syncForCurrentSelection: vi.fn(), + clearLastWrittenAuthJson: vi.fn() } } diff --git a/src/main/codex-accounts/service.ts b/src/main/codex-accounts/service.ts index f2344954b..2fcaa742c 100644 --- a/src/main/codex-accounts/service.ts +++ b/src/main/codex-accounts/service.ts @@ -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