fix: prevent 401 errors from CLI-refreshed tokens being clobbered (#1286)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
b4f1f0ea6a
commit
03e7284e3c
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -118,7 +118,8 @@ function createRateLimits() {
|
|||
|
||||
function createRuntimeHome() {
|
||||
return {
|
||||
syncForCurrentSelection: vi.fn()
|
||||
syncForCurrentSelection: vi.fn(),
|
||||
clearLastWrittenAuthJson: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue