From 9b4f3f94aeb60f659df9eb4c77d02129e3a70259 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sun, 26 Jul 2026 04:59:13 +0200 Subject: [PATCH 01/18] docs: add OAuth reliability design and implementation plan --- .../2026-07-26-oauth-reliability-integrity.md | 762 ++++++++++++++++++ ...7-26-oauth-reliability-integrity-design.md | 113 +++ 2 files changed, 875 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-26-oauth-reliability-integrity.md create mode 100644 docs/superpowers/specs/2026-07-26-oauth-reliability-integrity-design.md diff --git a/docs/superpowers/plans/2026-07-26-oauth-reliability-integrity.md b/docs/superpowers/plans/2026-07-26-oauth-reliability-integrity.md new file mode 100644 index 0000000000..051b601c4d --- /dev/null +++ b/docs/superpowers/plans/2026-07-26-oauth-reliability-integrity.md @@ -0,0 +1,762 @@ +# OAuth Reliability and Client Integrity Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Generalize cross-process OAuth refresh locking and generation CAS, expose a shared OAuth health projection through status/doctor/dashboard, and harden Codex client-metadata integrity tests — without changing Codex affinity policy A or adding impersonation/limit-bypass behaviour. + +**Architecture:** Reuse `createOAuthRefreshIntentLock` + `mergeAccountCredential` (already proven for xAI/Anthropic) for remaining OAuth providers behind the existing in-process `tokenRefreshes` map. Project existing `needsReauth` / Codex cooldown / conflict signals into one `OAuthAccountHealth` type consumed by CLI, management API, and GUI. Keep Codex pool 401/403 quarantine and 429 affinity-clear/rotate behaviour unchanged. + +**Tech Stack:** Bun, TypeScript, existing `src/oauth/*`, `src/codex/*`, `src/cli/*`, React GUI, Bun test runner, docs-site (Astro/Starlight). + +**Spec:** `docs/superpowers/specs/2026-07-26-oauth-reliability-integrity-design.md` + +## Global Constraints + +- Target branch: `feat/oauth-reliability-integrity` (worktree); PRs target `dev` +- TDD: write failing test → confirm fail → minimal implementation → confirm pass → commit +- No new dependencies +- Never log access tokens, refresh tokens, authorization headers, OAuth codes, or full account identifiers +- Redact account IDs in CLI/UI (`maskAccountId`) +- Do not claim ban protection; describe reliability, integrity, diagnostics only +- Affinity policy A: keep current Codex clear-on-401/403/429 behaviour +- Do not persist `threadAccountMap` to disk +- Do not fabricate official Codex client metadata +- Avoid unrelated refactors + +## File map + +| Path | Role | +|------|------| +| `src/lib/privacy.ts` | Add `maskAccountId` | +| `src/oauth/log.ts` | Structured redacted OAuth transition logs | +| `src/oauth/health.ts` | Shared health projection + aggregators | +| `src/oauth/index.ts` | Generalized locked refresh for non-xAI/Anthropic providers | +| `src/oauth/store.ts` | Only if tiny helpers needed for incomplete-credential detection | +| `src/cli/status.ts` / `src/cli/index.ts` | Status OAuth health block | +| `src/cli/doctor.ts` | Doctor OAuth checks | +| `src/server/management/oauth-account-routes.ts` | Expose health on account DTOs | +| `src/codex/auth-context.ts` / `src/adapters/openai-responses.ts` | Metadata integrity (tests; code only if gap found) | +| `gui/src/lib/privacy.ts` or shared import path | GUI redaction helper if GUI cannot import runtime privacy directly | +| `gui/src/components/provider-workspace/*` | Health badge + explanation | +| `docs-site/src/content/docs/**` | User-facing docs | +| `tests/*.test.ts` | Behaviour tests per task | + +--- + +### Task 1: Account ID redaction helper + +**Files:** +- Modify: `src/lib/privacy.ts` +- Test: `tests/privacy-mask-account.test.ts` +- Modify (if CLI already prints raw IDs in oauth summary paths later): none in this task beyond helper + +**Interfaces:** +- Consumes: none +- Produces: `maskAccountId(value: string | null | undefined): string | null` + +- [ ] **Step 1: Write the failing test** + +```ts +import { describe, expect, test } from "bun:test"; +import { maskAccountId } from "../src/lib/privacy"; + +describe("maskAccountId", () => { + test("redacts long account ids to account-…suffix", () => { + expect(maskAccountId("acct_abcdefghijklmnopqrstuvwxyz")).toBe("account-…wxyz"); + }); + + test("returns null for empty", () => { + expect(maskAccountId(null)).toBeNull(); + expect(maskAccountId("")).toBeNull(); + }); + + test("short ids still redact without leaking full value when length > 4", () => { + expect(maskAccountId("abcdef")).toBe("account-…cdef"); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test tests/privacy-mask-account.test.ts` + +Expected: FAIL — `maskAccountId` is not exported + +- [ ] **Step 3: Write minimal implementation** + +In `src/lib/privacy.ts`: + +```ts +export function maskAccountId(value: string | null | undefined): string | null { + if (!value) return null; + const id = value.trim(); + if (!id) return null; + const suffix = id.length <= 4 ? id : id.slice(-4); + return `account-…${suffix}`; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bun test tests/privacy-mask-account.test.ts` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/privacy.ts tests/privacy-mask-account.test.ts +git commit -m "$(cat <<'EOF' +feat(privacy): add maskAccountId for OAuth diagnostics + +EOF +)" +``` + +--- + +### Task 2: Structured OAuth logger + +**Files:** +- Create: `src/oauth/log.ts` +- Test: `tests/oauth-log.test.ts` + +**Interfaces:** +- Consumes: `maskAccountId` from `src/lib/privacy.ts` +- Produces: + - `logOAuthEvent(event: string, fields: { provider: string; accountId?: string; [k: string]: unknown }): void` + - Events must never include keys: `access`, `refresh`, `authorization`, `code`, `token` + +- [ ] **Step 1: Write the failing test** + +```ts +import { describe, expect, test } from "bun:test"; +import { logOAuthEvent } from "../src/oauth/log"; + +describe("logOAuthEvent", () => { + test("emits redacted account and never prints a token-looking field value", () => { + const lines: string[] = []; + const original = console.info; + console.info = (msg?: unknown) => { lines.push(String(msg)); }; + try { + logOAuthEvent("OAuth refresh started", { + provider: "kiro", + accountId: "acct_abcdefghijklmnopqrstuvwxyz", + until: "2026-07-23T14:30:00.000Z", + }); + } finally { + console.info = original; + } + expect(lines.length).toBe(1); + expect(lines[0]).toContain("[opencodex]"); + expect(lines[0]).toContain("provider=kiro"); + expect(lines[0]).toContain("account=account-…wxyz"); + expect(lines[0]).not.toContain("acct_abcdefghijklmnopqrstuvwxyz"); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test tests/oauth-log.test.ts` + +Expected: FAIL — module missing + +- [ ] **Step 3: Write minimal implementation** + +```ts +// src/oauth/log.ts +import { maskAccountId } from "../lib/privacy"; + +const FORBIDDEN = /^(access|refresh|authorization|code|token|accessToken|refreshToken)$/i; + +export function logOAuthEvent( + event: string, + fields: { provider: string; accountId?: string; [key: string]: unknown }, +): void { + const parts = [`[opencodex] ${event}`, `provider=${fields.provider}`]; + if (fields.accountId) parts.push(`account=${maskAccountId(fields.accountId)}`); + for (const [key, value] of Object.entries(fields)) { + if (key === "provider" || key === "accountId") continue; + if (FORBIDDEN.test(key)) continue; + if (value === undefined) continue; + parts.push(`${key}=${String(value)}`); + } + console.info(parts.join(" ")); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bun test tests/oauth-log.test.ts` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/oauth/log.ts tests/oauth-log.test.ts +git commit -m "$(cat <<'EOF' +feat(oauth): add redacted structured OAuth event logger + +EOF +)" +``` + +--- + +### Task 3: Generalized locked refresh + CAS for generic OAuth providers + +**Files:** +- Modify: `src/oauth/index.ts` (`refreshAndPersistAccessToken` generic branch ~352–400) +- Test: `tests/oauth-refresh-generic-lock.test.ts` (new; mirror patterns from `tests/xai-refresh-lock.test.ts` / `tests/oauth-refresh.test.ts`) + +**Interfaces:** +- Consumes: `createOAuthRefreshIntentLock`, `mergeAccountCredential`, `credentialGeneration`, `markAccountNeedsReauthIfGeneration`, `getAccountCredential`, `logOAuthEvent` +- Produces: generic path behaviour equivalent to: + 1. lock → reload → skip if already fresh → refresh → CAS persist → unlock + 2. in-process `tokenRefreshes` still coalesces callers +- Keep xAI / Anthropic / Kiro special branches unchanged in behaviour + +- [ ] **Step 1: Write the failing tests** + +Create `tests/oauth-refresh-generic-lock.test.ts` covering at least: + +1. Ten concurrent `getValidAccessTokenForAccount("kimi", id)` (or another non-xAI/Anthropic provider with injectable `refresh`) trigger **one** IdP refresh; all get same access token +2. Failed refresh clears single-flight so a later call can retry +3. After lock acquire, a newer disk credential is adopted without a second IdP call +4. Older refresh result cannot overwrite newer stored token (`mergeAccountCredential` superseded path) +5. Rotated refresh token is persisted on disk + +Use the existing test helpers that point `OPENCODEX_HOME` at a temp dir and stub `OAUTH_PROVIDERS[provider].refresh` / fetch. Follow `tests/oauth-refresh.test.ts` setup patterns for auth store isolation. + +Sketch for concurrent refresh: + +```ts +test("ten concurrent generic refreshes share one IdP call and same credential", async () => { + let refreshCalls = 0; + // arrange expired kimi (or github-copilot) credential in temp auth store + // stub provider refresh to increment refreshCalls and return rotated tokens + const results = await Promise.all( + Array.from({ length: 10 }, () => getValidAccessTokenForAccount(provider, accountId)), + ); + expect(new Set(results).size).toBe(1); + expect(refreshCalls).toBe(1); + const stored = getAccountCredential(provider, accountId); + expect(stored?.refresh).toBe("rotated-refresh"); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test tests/oauth-refresh-generic-lock.test.ts` + +Expected: FAIL — generic path still uses unlocked `saveAccountCredential` / can double-refresh under injected dual locks or pre-persist races (assert the specific failure your test constructs) + +- [ ] **Step 3: Write minimal implementation** + +Replace the generic branch in `refreshAndPersistAccessToken` with a shared helper, e.g. `refreshGenericAccountWithLock`, modeled on xAI/Anthropic but without Grok/Claude local-cli logic: + +```ts +async function refreshGenericAccountWithLock( + provider: string, + accountId: string, + def: OAuthProviderDef, + callerCredential: OAuthCredentials, +): Promise { + logOAuthEvent("OAuth refresh started", { provider, accountId }); + const guard = await createOAuthRefreshIntentLock(provider, accountId).acquire(); + try { + const stored = getAccountCredential(provider, accountId); + if (!stored) throw new OAuthLoginRequiredError(provider); + if ( + credentialGeneration(stored) !== credentialGeneration(callerCredential) + && stored.expires > Date.now() + REFRESH_SKEW_MS + ) { + logOAuthEvent("OAuth refresh joined existing operation", { provider, accountId }); + return stored.access; + } + const generation = credentialGeneration(stored); + try { + const fresh = merged(await def.refresh(stored.refresh), stored); + const outcome = await mergeAccountCredential(provider, accountId, fresh, { + expectedGeneration: generation, + }); + if (outcome.superseded) { + if (outcome.stored.expires > Date.now() + REFRESH_SKEW_MS) return outcome.stored.access; + throw new OAuthLoginRequiredError(provider); + } + logOAuthEvent("OAuth credentials rotated and persisted", { provider, accountId }); + return fresh.access; + } catch (error) { + if (!isTerminalRefreshError(error)) throw error; + await markAccountNeedsReauthIfGeneration(provider, accountId, generation); + throw new OAuthLoginRequiredError(provider); + } + } finally { + guard.release(); + } +} +``` + +Wire it from the generic branch (still after Kiro active-import and xAI/Anthropic special cases). Ensure `tokenRefreshes` finally-clear behaviour remains so failed refreshes allow retry. + +Also log `"OAuth refresh joined existing operation"` when `tokenRefreshes.get(key)` hits an existing promise. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: + +```bash +bun test tests/oauth-refresh-generic-lock.test.ts tests/oauth-refresh.test.ts tests/xai-refresh-lock.test.ts +``` + +Expected: PASS (no regressions on xAI/Anthropic) + +- [ ] **Step 5: Commit** + +```bash +git add src/oauth/index.ts tests/oauth-refresh-generic-lock.test.ts +git commit -m "$(cat <<'EOF' +feat(oauth): lock and CAS generic provider token refresh + +EOF +)" +``` + +--- + +### Task 4: Shared OAuth health projection + +**Files:** +- Create: `src/oauth/health.ts` +- Test: `tests/oauth-health.test.ts` +- Modify: export from `src/oauth/index.ts` if that is the public surface used by CLI + +**Interfaces:** +- Consumes: + - OAuth store `needsReauth` / credential presence via existing getters + - Codex cooldown via exported read helpers — if none exist, add a **read-only** `getCodexAccountCooldown(accountId): { until: number; source: string } | null` in `src/codex/routing.ts` without changing write policy +- Produces: + +```ts +export type OAuthAccountHealth = + | { status: "healthy" } + | { status: "cooldown"; until: string; reason: "rate_limit" | "quota" } + | { status: "reauth_required"; reason: "unauthorized" | "forbidden" | "refresh_failed" } + | { status: "warning"; reason: "refresh_conflict" | "metadata_mismatch" | "stale_credentials" }; + +export type OAuthHealthEntry = { + provider: string; + accountId: string; + health: OAuthAccountHealth; + action?: string; +}; + +export function projectOAuthAccountHealth(input: { + needsReauth?: boolean; + reauthReason?: "unauthorized" | "forbidden" | "refresh_failed"; + cooldownUntilMs?: number; + cooldownReason?: "rate_limit" | "quota"; + warningReason?: "refresh_conflict" | "metadata_mismatch" | "stale_credentials"; + now?: number; +}): OAuthAccountHealth; + +export function collectOAuthHealthEntries(now?: number): OAuthHealthEntry[]; +``` + +Priority when multiple signals exist: `reauth_required` > `cooldown` > `warning` > `healthy`. + +- [ ] **Step 1: Write the failing tests** + +```ts +test("reauth beats cooldown", () => { + expect(projectOAuthAccountHealth({ + needsReauth: true, + reauthReason: "refresh_failed", + cooldownUntilMs: Date.now() + 60_000, + })).toEqual({ status: "reauth_required", reason: "refresh_failed" }); +}); + +test("active cooldown projects until ISO timestamp", () => { + const until = Date.parse("2026-07-23T14:30:00.000Z"); + expect(projectOAuthAccountHealth({ + cooldownUntilMs: until, + cooldownReason: "rate_limit", + now: until - 1000, + })).toEqual({ + status: "cooldown", + until: "2026-07-23T14:30:00.000Z", + reason: "rate_limit", + }); +}); +``` + +Also test `collectOAuthHealthEntries` with a temp auth store marking one account `needsReauth`. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test tests/oauth-health.test.ts` + +Expected: FAIL — module missing + +- [ ] **Step 3: Write minimal implementation** + +Implement `projectOAuthAccountHealth` and `collectOAuthHealthEntries`. For Codex pool accounts, read cooldown via a new thin getter in `src/codex/routing.ts`: + +```ts +export function getCodexAccountHealthSnapshot(accountId: string, now = Date.now()): { + cooldownUntil?: number; + cooldownSource?: "retry-after" | "reset-derived" | "default"; +} | null +``` + +Map `retry-after` → `rate_limit`, others → `quota` for health reason. Do **not** change `recordCodexUpstreamOutcome`. + +Set `action` strings: +- reauth: `run \`ocx auth login \`` +- cooldown: `wait until or start a new session with another eligible account` +- warning refresh_conflict: `re-run \`ocx doctor\` after ensuring only one proxy process writes the credential store` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bun test tests/oauth-health.test.ts tests/codex-routing.test.ts` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/oauth/health.ts src/oauth/index.ts src/codex/routing.ts tests/oauth-health.test.ts +git commit -m "$(cat <<'EOF' +feat(oauth): add shared account health projection + +EOF +)" +``` + +--- + +### Task 5: `ocx status` OAuth health output + +**Files:** +- Modify: `src/cli/index.ts` (status human printer that currently calls `oauthLoginSummary`) +- Modify: `src/cli/status.ts` only if JSON status should gain a redacted health summary (prefer human-first; add JSON only if existing tests/docs allow a non-secret block) +- Test: `tests/cli-status-oauth-health.test.ts` + +**Interfaces:** +- Consumes: `collectOAuthHealthEntries`, `maskAccountId` +- Produces: human-readable block matching the spec examples (warning / rate limited) + +- [ ] **Step 1: Write the failing test** + +Drive `collectOAuthHealthEntries` via store fixtures, then call a new pure formatter: + +```ts +import { formatOAuthHealthForStatus } from "../src/cli/status-oauth"; + +test("formats reauthentication required", () => { + const text = formatOAuthHealthForStatus([{ + provider: "openai", + accountId: "acct_abcdefghijklmnopqrstuvwxyz", + health: { status: "reauth_required", reason: "refresh_failed" }, + action: "run `ocx auth login openai`", + }]); + expect(text).toContain("OAuth health: warning"); + expect(text).toContain("account-…wxyz"); + expect(text).not.toContain("acct_abcdefghijklmnopqrstuvwxyz"); + expect(text).toContain("reauthentication required"); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test tests/cli-status-oauth-health.test.ts` + +Expected: FAIL + +- [ ] **Step 3: Write minimal implementation** + +Create `src/cli/status-oauth.ts` with `formatOAuthHealthForStatus`. Wire into `handleStatus` human output after the existing OAuth logins summary (or replace sparse summary with health-aware block when non-healthy entries exist). Keep emails masked; never print tokens. + +- [ ] **Step 4: Run tests** + +Run: `bun test tests/cli-status-oauth-health.test.ts tests/cli-status-json.test.ts` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/cli/status-oauth.ts src/cli/index.ts tests/cli-status-oauth-health.test.ts +git commit -m "$(cat <<'EOF' +feat(cli): show OAuth health in ocx status + +EOF +)" +``` + +--- + +### Task 6: `ocx doctor` OAuth checks + +**Files:** +- Modify: `src/cli/doctor.ts` +- Test: `tests/doctor-oauth.test.ts` (or extend `tests/doctor.test.ts`) + +**Interfaces:** +- Consumes: `collectOAuthHealthEntries`, auth store writability checks, refresh lock path helpers if exported +- Produces: doctor rows like: + - `[OK] OAuth credential storage is writable.` + - `[OK] Token refresh single-flight is active.` + - `[WARN] Account account-…42 requires reauthentication. Action: run \`ocx auth login \`` + - `[WARN] Account account-…17 is rate limited until … Action: …` + - `[OK] No fabricated official-client metadata detected.` (static OK for Codex forward path unless a runtime detector exists; do not invent a false positive scanner) + +- [ ] **Step 1: Write the failing test** + +Seed a temp account with `needsReauth`, run the new `collectOAuthDoctorChecks()` (pure), assert WARN + action present and account id redacted. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test tests/doctor-oauth.test.ts` + +Expected: FAIL + +- [ ] **Step 3: Write minimal implementation** + +Add `collectOAuthDoctorChecks(): Array<{ level: "OK" | "WARN"; message: string }>` and append in `runDoctor()` output. Observe-only: no mutations, no auto-repair. + +- [ ] **Step 4: Run tests** + +Run: `bun test tests/doctor-oauth.test.ts tests/doctor.test.ts` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/cli/doctor.ts tests/doctor-oauth.test.ts +git commit -m "$(cat <<'EOF' +feat(cli): add OAuth reliability checks to ocx doctor + +EOF +)" +``` + +--- + +### Task 7: Management API + dashboard health + +**Files:** +- Modify: `src/server/management/oauth-account-routes.ts` (and Codex auth DTO path in `src/codex/auth-api.ts` if Codex accounts are the primary UI) +- Modify: `gui/src/components/provider-workspace/ProviderAuthPanel.tsx` +- Modify: `gui/src/components/CodexAccountPool.tsx` (if showing Codex cooldown/reauth) +- Possibly: `gui/src/provider-workspace/catalog.ts` / types for account DTO +- Test: `tests/oauth-accounts-api.test.ts` (extend) +- Test: GUI unit/render test if the repo already has a pattern; otherwise a pure formatter test for badge labels in `gui/src/...` plus API contract test + +**Interfaces:** +- API account objects gain: + +```ts +health: OAuthAccountHealth +healthLabel: "Healthy" | "Rate limited" | "Reauthentication required" | "Refresh failed" | "Metadata mismatch" | "Credential conflict" +``` + +Map warning reasons to labels (`refresh_conflict` → Credential conflict, etc.). + +- [ ] **Step 1: Write the failing API test** + +Assert `/api/oauth/accounts?provider=...` includes `health` and redacted display helpers never return full raw id in `healthSummary` strings. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test tests/oauth-accounts-api.test.ts` + +Expected: FAIL on missing `health` + +- [ ] **Step 3: Minimal API + UI implementation** + +Attach projected health to account DTOs. In GUI, show badge + short explanation (what happened, provider/account redacted, blocked?, next action). Actions: Reauthenticate button (existing), copy `ocx doctor`, disable probe messaging during cooldown. No “anti-ban” copy. + +- [ ] **Step 4: Run tests** + +Run: + +```bash +bun test tests/oauth-accounts-api.test.ts +bun run lint:gui +``` + +Expected: PASS / lint clean for touched files + +- [ ] **Step 5: Commit** + +```bash +git add src/server/management/oauth-account-routes.ts src/codex/auth-api.ts gui/src/components/provider-workspace/ProviderAuthPanel.tsx gui/src/components/CodexAccountPool.tsx tests/oauth-accounts-api.test.ts +git commit -m "$(cat <<'EOF' +feat(gui): surface OAuth account health diagnostics + +EOF +)" +``` + +--- + +### Task 8: Codex metadata integrity regressions + 401 replay invariants + +**Files:** +- Test: `tests/codex-metadata-integrity.test.ts` (new) +- Modify only if a real gap is found: `src/codex/auth-context.ts`, `src/adapters/openai-responses.ts` +- Confirm existing: `tests/server-xai-oauth-401-replay.test.ts`, `tests/server-kiro-oauth-401-replay.test.ts`, `tests/codex-routing.test.ts` (policy A) + +**Interfaces:** +- Consumes: `headersForCodexAuthContext`, `FORWARD_HEADERS` +- Produces: tests proving: + 1. Genuine `originator` / `session_id` / `thread-id` preserved + 2. Missing `originator` is not filled with `codex_cli_rs` + 3. Outgoing `chatgpt-account-id` matches selected pool credential + 4. Policy A: 429 clears affinity (existing tests remain green) — do not invert + +- [ ] **Step 1: Write failing tests for any missing assertion** + +```ts +test("does not fabricate originator when absent", () => { + const incoming = new Headers({ + "x-codex-parent-thread-id": "thread-1", + }); + // resolve auth context with pool account A + const headers = headersForCodexAuthContext(incoming, authContext); + expect(headers.get("originator")).toBeNull(); + expect(headers.get("chatgpt-account-id")).toBe(accountA.chatgptAccountId); +}); + +test("preserves genuine originator", () => { + const incoming = new Headers({ + originator: "codex_cli_rs", + "x-codex-parent-thread-id": "thread-1", + }); + const headers = headersForCodexAuthContext(incoming, authContext); + expect(headers.get("originator")).toBe("codex_cli_rs"); +}); +``` + +- [ ] **Step 2: Run tests** + +Run: `bun test tests/codex-metadata-integrity.test.ts` + +Expected: FAIL only if implementation gap exists; if PASS immediately, keep tests as regressions and skip code changes. + +- [ ] **Step 3: Fix only real gaps** + +If fabrication or account-id mismatch is found, fix the minimal header path. Do not add fake official metadata. + +- [ ] **Step 4: Run related suite** + +```bash +bun test tests/codex-metadata-integrity.test.ts tests/codex-auth-context.test.ts tests/codex-routing.test.ts tests/session-affinity.test.ts tests/server-xai-oauth-401-replay.test.ts tests/server-kiro-oauth-401-replay.test.ts +``` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add tests/codex-metadata-integrity.test.ts src/codex/auth-context.ts src/adapters/openai-responses.ts +git commit -m "$(cat <<'EOF' +test(codex): lock metadata pass-through and non-fabrication + +EOF +)" +``` + +--- + +### Task 9: Documentation + +**Files:** +- Modify: `docs-site/src/content/docs/guides/providers.md` +- Modify: `docs-site/src/content/docs/reference/cli.md` +- Modify: `docs-site/src/content/docs/reference/architecture.md` (brief) +- Update translated locales only enough to avoid contradictions if they mirror the changed English sections; prefer English-first + short note if locale sync is heavy + +Content to add (factual, concise): + +- How OAuth refresh coordination works (in-process single-flight + per-account file lock + generation CAS) +- How cooldowns work (Retry-After / reset headers / backoff; no probe during Retry-After cooldowns) +- Session affinity is process-local; policy on errors (policy A) +- Which Codex client metadata is preserved; what is not fabricated +- How to use `ocx status` and `ocx doctor` for OAuth health +- How to reauthenticate +- Explicit: this does not guarantee protection from provider enforcement + +- [ ] **Step 1: Update English docs** + +- [ ] **Step 2: Skim locales for contradictory statements; fix only contradictions** + +- [ ] **Step 3: Commit** + +```bash +git add docs-site/src/content/docs +git commit -m "$(cat <<'EOF' +docs: document OAuth reliability and diagnostics + +EOF +)" +``` + +--- + +### Task 10: Full verification and handoff + +- [ ] **Step 1: Run verification commands** + +```bash +bun test tests/privacy-mask-account.test.ts tests/oauth-log.test.ts tests/oauth-refresh-generic-lock.test.ts tests/oauth-health.test.ts tests/cli-status-oauth-health.test.ts tests/doctor-oauth.test.ts tests/oauth-accounts-api.test.ts tests/codex-metadata-integrity.test.ts +bun test tests/oauth-refresh.test.ts tests/xai-refresh-lock.test.ts tests/codex-routing.test.ts tests/session-affinity.test.ts tests/codex-auth-context.test.ts +bun run test +bun run typecheck +bun run lint:gui +bun run privacy:scan +bun run build:gui +``` + +- [ ] **Step 2: Inspect final diff for** + +- duplicated OAuth state +- token leakage +- weak locking left on generic path +- accidental affinity policy changes +- fabricated official-client metadata +- unrelated changes + +- [ ] **Step 3: Write handoff summary** covering findings, files changed, behaviours, tests, command results, limitations, and confirmation that no impersonation/fingerprint spoofing/limit-bypass was added + +--- + +## Spec coverage checklist + +| Spec item | Task | +|-----------|------| +| Refresh single-flight + cross-process lock | 3 | +| Atomic CAS persistence / no stale overwrite | 3 | +| 401 replay where existing providers support it | 8 (regression) | +| 403/429 policy A unchanged | 8 + existing routing tests | +| Affinity process-local, policy A | 8 + design decision | +| Client metadata integrity | 8 | +| Health model | 4 | +| `ocx status` | 5 | +| `ocx doctor` | 6 | +| Dashboard | 7 | +| Structured logs | 2 (+ hooks in 3) | +| Account redaction | 1 (+ consumers 5–7) | +| Docs | 9 | +| Verification | 10 | + +## Placeholder / consistency self-review + +- No TBD/TODO left in tasks +- `OAuthAccountHealth` shape is identical in Tasks 4–7 +- `maskAccountId` / `logOAuthEvent` / `collectOAuthHealthEntries` names are stable across tasks +- Policy A is restated wherever affinity/429 tests are mentioned so implementers do not “fix” it to pin-through-429 diff --git a/docs/superpowers/specs/2026-07-26-oauth-reliability-integrity-design.md b/docs/superpowers/specs/2026-07-26-oauth-reliability-integrity-design.md new file mode 100644 index 0000000000..60cbd7333d --- /dev/null +++ b/docs/superpowers/specs/2026-07-26-oauth-reliability-integrity-design.md @@ -0,0 +1,113 @@ +# OAuth Reliability and Client Integrity — Design + +**Date:** 2026-07-26 +**Branch:** `feat/oauth-reliability-integrity` +**Status:** Approved (Approach 1 + affinity policy A) + +## Goal + +Improve OAuth refresh reliability, token persistence safety, actionable diagnostics, and legitimate client-metadata integrity — without client impersonation, fingerprint spoofing, or rate-limit circumvention. + +## Product decisions + +1. **Approach 1 — Strengthen + surface:** generalize existing xAI/Anthropic lock+CAS patterns; add a thin health projection; wire status/doctor/dashboard. +2. **Affinity policy A:** keep current Codex pool behaviour: + - 401/403 → reauth quarantine + clear affinities + - 429 → cooldown + clear affinities + may rotate `activeCodexAccountId` + - Do **not** pin threads through 429 in this work +3. Affinity remains process-local (`threadAccountMap`); no new disk persistence. +4. Do not remove existing non-Codex adapter client headers (xAI/MiMo) in this work; Codex forward path must not fabricate official Codex identity. + +## Non-goals + +- Ban protection / anti-detection marketing or behaviour +- Fabricating `originator: codex_cli_rs`, official Codex versions, or device fingerprints +- Account rotation to bypass provider limits +- Automatic destructive doctor repairs +- New npm dependencies + +## Current architecture (baseline) + +```text +request + → client metadata (FORWARD_HEADERS / adapter headers) + → routeModel / resolveCodexAuthContext + → credential load (auth.json / codex-accounts.json) + → refresh if needed (tokenRefreshes ± file lock/CAS) + → provider request + → classify outcome → reauth / cooldown / failover + → atomic persist + → status / doctor / dashboard (thin OAuth surface today) +``` + +Existing strengths: in-process single-flight; atomic writes; xAI/Anthropic/Codex cross-process refresh locks + generation CAS; Codex cooldown/Retry-After/probe leases; Codex header passthrough with pool account injection. + +Gaps: generic OAuth providers lack cross-process refresh lock + CAS; no shared health projection; weak status/doctor/dashboard OAuth detail; no `maskAccountId`; sparse structured OAuth logs. + +## Design units + +### 1. Privacy helper — `maskAccountId` + +Extend `src/lib/privacy.ts` with account-id redaction (`account-…42` style). Use in CLI, doctor, logs, and dashboard secondary labels where full IDs are currently shown. + +### 2. Structured OAuth logger + +Small helper (e.g. `src/oauth/log.ts`) that emits one-line transition events with redacted account ids. Never logs tokens, auth headers, codes, or full account identifiers. + +### 3. Generalized locked refresh + +Extract/generalize the xAI/Anthropic pattern into a shared path for remaining OAuth providers in `refreshAndPersistAccessToken`: + +1. Acquire `createOAuthRefreshIntentLock(provider, accountId)` +2. Reload credential from store +3. If another writer already refreshed (generation changed + still valid) → return stored access +4. Call `def.refresh` +5. Persist via `mergeAccountCredential` with `expectedGeneration` (CAS) +6. On terminal failure → `markAccountNeedsReauthIfGeneration` +7. Release lock in `finally` +8. Keep in-process `tokenRefreshes` map as first-layer single-flight + +Preserve provider-specific branches (xAI Grok CLI adoption, Anthropic durable intent, Kiro local-cli import). + +### 4. Health projection + +New module (e.g. `src/oauth/health.ts`) projecting existing state into: + +```ts +type OAuthAccountHealth = + | { status: "healthy" } + | { status: "cooldown"; until: string; reason: "rate_limit" | "quota" } + | { status: "reauth_required"; reason: "unauthorized" | "forbidden" | "refresh_failed" } + | { status: "warning"; reason: "refresh_conflict" | "metadata_mismatch" | "stale_credentials" }; +``` + +Sources: `needsReauth`, Codex `upstreamHealth` cooldowns, refresh-intent / CAS conflict markers, incomplete credentials. Single projection consumed by status, doctor, management API, dashboard — no parallel stores. + +### 5. Diagnostics surfaces + +- **`ocx status`:** concise OAuth health block (provider, redacted account, status, reason/action or retry-after). +- **`ocx doctor`:** checks for writable credential store, single-flight/lock readiness, reauth, cooldown, incomplete credentials, refresh conflicts; each WARN includes recovery action. +- **Dashboard:** health badge on provider/account views with explanation + actions (reauthenticate, copy `ocx doctor`, retry after cooldown). Copy must say reliability/diagnostics — never “anti-ban”. + +### 6. Client metadata integrity (Codex path) + +Keep `FORWARD_HEADERS` passthrough. Ensure pool mode overwrites only auth + `chatgpt-account-id` to match selected credential. Add regression tests that genuine metadata is preserved and official-client values are not fabricated when absent. Treat untrusted remote identity headers as untrusted unless already authenticated by architecture. + +### 7. Documentation + +Update docs-site guides/reference: refresh coordination, cooldowns, affinity (process-local + policy A), preserved vs non-fabricated metadata, status/doctor usage, reauth, explicit statement that this cannot guarantee protection from provider enforcement. + +## Testing strategy + +TDD: failing test → implement → pass → commit per task. + +Cover: concurrent refresh → one IdP call; shared result; failed refresh clears single-flight; retry after failure; rotated refresh persisted; older result cannot overwrite newer; reload after lock; 401 path where applicable (one refresh + one retry); repeated auth failure → reauth; 403/429 policy A assertions; metadata pass-through + non-fabrication; status/doctor/dashboard; redaction; no secrets in logs. + +## Success criteria + +- Generic OAuth refresh uses file lock + generation CAS +- Health projection shared across CLI/API/UI +- Diagnostics actionable and redacted +- Codex metadata integrity tests green +- `bun run typecheck`, targeted OAuth tests, and full `bun run test` pass +- No impersonation / fingerprint spoofing / limit-bypass behaviour added From 4be70435926067a442993b5aceca5eab779ea403 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sun, 26 Jul 2026 05:02:41 +0200 Subject: [PATCH 02/18] feat(privacy): add maskAccountId for OAuth diagnostics --- src/lib/privacy.ts | 8 ++++++++ tests/privacy-mask-account.test.ts | 17 +++++++++++++++++ 2 files changed, 25 insertions(+) create mode 100644 tests/privacy-mask-account.test.ts diff --git a/src/lib/privacy.ts b/src/lib/privacy.ts index e3bff121a1..7604a2c5d1 100644 --- a/src/lib/privacy.ts +++ b/src/lib/privacy.ts @@ -9,3 +9,11 @@ export function maskEmail(value: string | null | undefined): string | null { if (local.length === 2) return `${local[0]}*@${domain}`; return `${local[0]}***${local[local.length - 1]}@${domain}`; } + +export function maskAccountId(value: string | null | undefined): string | null { + if (!value) return null; + const id = value.trim(); + if (!id) return null; + const suffix = id.length <= 4 ? id : id.slice(-4); + return `account-…${suffix}`; +} diff --git a/tests/privacy-mask-account.test.ts b/tests/privacy-mask-account.test.ts new file mode 100644 index 0000000000..5167bc922e --- /dev/null +++ b/tests/privacy-mask-account.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, test } from "bun:test"; +import { maskAccountId } from "../src/lib/privacy"; + +describe("maskAccountId", () => { + test("redacts long account ids to account-…suffix", () => { + expect(maskAccountId("acct_abcdefghijklmnopqrstuvwxyz")).toBe("account-…wxyz"); + }); + + test("returns null for empty", () => { + expect(maskAccountId(null)).toBeNull(); + expect(maskAccountId("")).toBeNull(); + }); + + test("short ids still redact without leaking full value when length > 4", () => { + expect(maskAccountId("abcdef")).toBe("account-…cdef"); + }); +}); From c8f3f65510ac79e66b3eb76735f17ea5e1173d17 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sun, 26 Jul 2026 05:04:32 +0200 Subject: [PATCH 03/18] feat(oauth): add redacted structured OAuth event logger --- src/oauth/log.ts | 19 +++++++++++++++++++ tests/oauth-log.test.ts | 24 ++++++++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 src/oauth/log.ts create mode 100644 tests/oauth-log.test.ts diff --git a/src/oauth/log.ts b/src/oauth/log.ts new file mode 100644 index 0000000000..e9bca1b95c --- /dev/null +++ b/src/oauth/log.ts @@ -0,0 +1,19 @@ +// src/oauth/log.ts +import { maskAccountId } from "../lib/privacy"; + +const FORBIDDEN = /^(access|refresh|authorization|code|token|accessToken|refreshToken)$/i; + +export function logOAuthEvent( + event: string, + fields: { provider: string; accountId?: string; [key: string]: unknown }, +): void { + const parts = [`[opencodex] ${event}`, `provider=${fields.provider}`]; + if (fields.accountId) parts.push(`account=${maskAccountId(fields.accountId)}`); + for (const [key, value] of Object.entries(fields)) { + if (key === "provider" || key === "accountId") continue; + if (FORBIDDEN.test(key)) continue; + if (value === undefined) continue; + parts.push(`${key}=${String(value)}`); + } + console.info(parts.join(" ")); +} diff --git a/tests/oauth-log.test.ts b/tests/oauth-log.test.ts new file mode 100644 index 0000000000..a505b95ebd --- /dev/null +++ b/tests/oauth-log.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from "bun:test"; +import { logOAuthEvent } from "../src/oauth/log"; + +describe("logOAuthEvent", () => { + test("emits redacted account and never prints a token-looking field value", () => { + const lines: string[] = []; + const original = console.info; + console.info = (msg?: unknown) => { lines.push(String(msg)); }; + try { + logOAuthEvent("OAuth refresh started", { + provider: "kiro", + accountId: "acct_abcdefghijklmnopqrstuvwxyz", + until: "2026-07-23T14:30:00.000Z", + }); + } finally { + console.info = original; + } + expect(lines.length).toBe(1); + expect(lines[0]).toContain("[opencodex]"); + expect(lines[0]).toContain("provider=kiro"); + expect(lines[0]).toContain("account=account-…wxyz"); + expect(lines[0]).not.toContain("acct_abcdefghijklmnopqrstuvwxyz"); + }); +}); From 9e5651e16a15b3322450e499a76a5384f8ee80cc Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sun, 26 Jul 2026 05:06:03 +0200 Subject: [PATCH 04/18] test(oauth): cover forbidden keys in logOAuthEvent --- tests/oauth-log.test.ts | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/tests/oauth-log.test.ts b/tests/oauth-log.test.ts index a505b95ebd..8e03094b87 100644 --- a/tests/oauth-log.test.ts +++ b/tests/oauth-log.test.ts @@ -21,4 +21,41 @@ describe("logOAuthEvent", () => { expect(lines[0]).toContain("account=account-…wxyz"); expect(lines[0]).not.toContain("acct_abcdefghijklmnopqrstuvwxyz"); }); + + test("omits forbidden token-like field keys from output", () => { + const lines: string[] = []; + const original = console.info; + console.info = (msg?: unknown) => { lines.push(String(msg)); }; + try { + logOAuthEvent("OAuth refresh started", { + provider: "kiro", + access: "secret-access-value", + refresh: "secret-refresh-value", + authorization: "secret-authorization-value", + code: "secret-code-value", + token: "secret-token-value", + accessToken: "secret-accessToken-value", + refreshToken: "secret-refreshToken-value", + safe: "visible", + }); + } finally { + console.info = original; + } + expect(lines.length).toBe(1); + const line = lines[0]; + expect(line).toContain("provider=kiro"); + expect(line).toContain("safe=visible"); + for (const key of [ + "access", + "refresh", + "authorization", + "code", + "token", + "accessToken", + "refreshToken", + ]) { + expect(line).not.toContain(`${key}=`); + expect(line).not.toContain(`secret-${key}-value`); + } + }); }); From ee90aa9eba51794e818991b6b2ae56013fab0412 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sun, 26 Jul 2026 05:09:38 +0200 Subject: [PATCH 05/18] feat(oauth): lock and CAS generic provider token refresh --- src/oauth/index.ts | 71 +++++--- tests/oauth-refresh-generic-lock.test.ts | 202 +++++++++++++++++++++++ 2 files changed, 251 insertions(+), 22 deletions(-) create mode 100644 tests/oauth-refresh-generic-lock.test.ts diff --git a/src/oauth/index.ts b/src/oauth/index.ts index a8382accc3..65d89fbd81 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -16,6 +16,7 @@ import { deriveOAuthDefaultModel, deriveOAuthProviderConfig } from "../providers import { effectiveGoogleMode } from "../providers/registry"; import { resolveProviderTransport } from "../providers/xai-transport"; import { detectClaudeCodeToken, detectGrokCliToken, hasComparableGrokIdentity, isSameGrokIdentity, shouldAdoptGrokGeneration } from "./local-token-detect"; +import { logOAuthEvent } from "./log"; const REFRESH_SKEW_MS = 60_000; export interface OAuthAccessSnapshot { @@ -30,6 +31,7 @@ const XAI_PERMANENT_FAILURE_TTL_MS=30_000; const permanentRefreshFailures=new Map(); interface XaiRefreshDeps { intentLock?:ReturnType; now?:()=>number; afterPrePersistRead?:()=>void|Promise } interface AnthropicRefreshDeps { intentLock?:ReturnType; now?:()=>number; afterPrePersistRead?:()=>void|Promise } +interface GenericRefreshDeps { intentLock?:ReturnType; afterPrePersistRead?:()=>void|Promise } function verdictKey(p:string,a:string,c:OAuthCredentials){return `${p}\0${a}\0${credentialGeneration(c)}`;} function cached(p:string,a:string,c:OAuthCredentials,now:()=>number){const k=verdictKey(p,a,c),u=permanentRefreshFailures.get(k);if(u===undefined)return false;if(u<=now()){permanentRefreshFailures.delete(k);return false;}return true;} @@ -195,7 +197,10 @@ async function resolveAccessSnapshotForAccount( const key = `${provider}\u0000${accountId}`; const existing = tokenRefreshes.get(key); - if (existing) return existing; + if (existing) { + logOAuthEvent("OAuth refresh joined existing operation", { provider, accountId }); + return existing; + } const refresh = (async (): Promise => { const accessToken = await refreshAndPersistAccessToken(provider, accountId, def, cred); @@ -349,6 +354,48 @@ export async function refreshAnthropicAccountWithLock( } } +export async function refreshGenericAccountWithLock( + provider: string, + accountId: string, + def: OAuthProviderDef, + callerCredential: OAuthCredentials, + deps: GenericRefreshDeps = {}, +): Promise { + logOAuthEvent("OAuth refresh started", { provider, accountId }); + const guard = await (deps.intentLock ?? createOAuthRefreshIntentLock(provider, accountId)).acquire(); + try { + const stored = getAccountCredential(provider, accountId); + if (!stored) throw new OAuthLoginRequiredError(provider); + if ( + credentialGeneration(stored) !== credentialGeneration(callerCredential) + && stored.expires > Date.now() + REFRESH_SKEW_MS + ) { + logOAuthEvent("OAuth refresh joined existing operation", { provider, accountId }); + return stored.access; + } + const generation = credentialGeneration(stored); + try { + const fresh = merged(await def.refresh(stored.refresh), stored); + const outcome = await mergeAccountCredential(provider, accountId, fresh, { + expectedGeneration: generation, + afterPrePersistRead: deps.afterPrePersistRead, + }); + if (outcome.superseded) { + if (outcome.stored.expires > Date.now() + REFRESH_SKEW_MS) return outcome.stored.access; + throw new OAuthLoginRequiredError(provider); + } + logOAuthEvent("OAuth credentials rotated and persisted", { provider, accountId }); + return fresh.access; + } catch (error) { + if (!isTerminalRefreshError(error)) throw error; + await markAccountNeedsReauthIfGeneration(provider, accountId, generation); + throw new OAuthLoginRequiredError(provider); + } + } finally { + guard.release(); + } +} + async function refreshAndPersistAccessToken( provider: string, accountId: string, @@ -368,23 +415,7 @@ async function refreshAndPersistAccessToken( if (provider === "xai") return refreshXaiAccountWithLock(provider, accountId, def, cred); if (provider === "anthropic") return refreshAnthropicAccountWithLock(provider, accountId, def, cred); try { - const fresh = await def.refresh(cred.refresh); - const detachedLocalCli = provider === "xai" && cred.source === "local-cli"; - if (detachedLocalCli) console.warn(XAI_LOCAL_CLI_DETACH_WARNING); - // Persist to THIS account (rotation-safe: new refresh token hits disk before use) without - // touching activeAccountId. - await saveAccountCredential(provider, accountId, { - ...fresh, - source: detachedLocalCli ? "oauth" : fresh.source ?? cred.source ?? "oauth", - // Preserve a previously-discovered project id when a refresh-time re-discovery comes back empty - // (e.g. a transient network blip), so Antigravity does not lose its CCA project across refresh. - ...(fresh.projectId === undefined && cred.projectId ? { projectId: cred.projectId } : {}), - ...(fresh.apiBaseUrl === undefined && cred.apiBaseUrl ? { apiBaseUrl: cred.apiBaseUrl } : {}), - // Preserve identity fields the refresh response may omit, so identity matching stays stable. - ...(fresh.email === undefined && cred.email ? { email: cred.email } : {}), - ...(fresh.accountId === undefined && cred.accountId ? { accountId: cred.accountId } : {}), - }); - return fresh.access; + return await refreshGenericAccountWithLock(provider, accountId, def, cred); } catch (err) { if (provider === "kiro" && isActive) { const imported = readFreshKiroCliCredential(); @@ -393,10 +424,6 @@ async function refreshAndPersistAccessToken( return imported.access; } } - if (isTerminalRefreshError(err)) { - await markAccountNeedsReauth(provider, accountId, true); - throw new OAuthLoginRequiredError(provider); - } throw err; } } diff --git a/tests/oauth-refresh-generic-lock.test.ts b/tests/oauth-refresh-generic-lock.test.ts new file mode 100644 index 0000000000..57570429b1 --- /dev/null +++ b/tests/oauth-refresh-generic-lock.test.ts @@ -0,0 +1,202 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + getValidAccessTokenForAccount, + OAuthLoginRequiredError, + OAUTH_PROVIDERS, +} from "../src/oauth"; +import type { OAuthCredentials } from "../src/oauth/types"; +import { getAccountCredential, getAccountSet, saveCredential } from "../src/oauth/store"; + +const origHome = process.env.HOME; +const origOcxHome = process.env.OPENCODEX_HOME; +const origKimiRefresh = OAUTH_PROVIDERS.kimi!.refresh; +let tmp: string; + +beforeEach(() => { + tmp = join(tmpdir(), `oauth-generic-lock-${Date.now()}-${Math.random().toString(16).slice(2)}`); + mkdirSync(tmp, { recursive: true }); + process.env.HOME = tmp; + process.env.OPENCODEX_HOME = join(tmp, "ocx"); +}); + +afterEach(() => { + OAUTH_PROVIDERS.kimi!.refresh = origKimiRefresh; + if (origHome === undefined) delete process.env.HOME; + else process.env.HOME = origHome; + if (origOcxHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = origOcxHome; + rmSync(tmp, { recursive: true, force: true }); +}); + +async function seedExpiredKimi(): Promise { + await saveCredential("kimi", { + access: "kimi-old", + refresh: "rt-old", + expires: Date.now() - 1, + accountId: "kimi-acct", + }); + return getAccountSet("kimi")!.activeAccountId; +} + +function stubKimiRefresh( + handler: (refreshToken: string) => Promise, +): { calls: () => number } { + let refreshCalls = 0; + OAUTH_PROVIDERS.kimi!.refresh = async (refreshToken: string) => { + refreshCalls++; + return handler(refreshToken); + }; + return { calls: () => refreshCalls }; +} + +describe("generic OAuth refresh lock + CAS", () => { + test("ten concurrent generic refreshes share one IdP call and same credential", async () => { + const accountId = await seedExpiredKimi(); + const tracker = stubKimiRefresh(async () => ({ + access: "kimi-fresh", + refresh: "rotated-refresh", + expires: Date.now() + 3_600_000, + })); + + const results = await Promise.all( + Array.from({ length: 10 }, () => getValidAccessTokenForAccount("kimi", accountId)), + ); + + expect(new Set(results).size).toBe(1); + expect(results[0]).toBe("kimi-fresh"); + expect(tracker.calls()).toBe(1); + expect(getAccountCredential("kimi", accountId)?.refresh).toBe("rotated-refresh"); + }); + + test("failed refresh clears single-flight so a later call can retry", async () => { + const accountId = await seedExpiredKimi(); + let refreshCalls = 0; + OAUTH_PROVIDERS.kimi!.refresh = async () => { + refreshCalls++; + if (refreshCalls === 1) throw new Error("network down"); + return { + access: "kimi-recovered", + refresh: "rotated-refresh", + expires: Date.now() + 3_600_000, + }; + }; + + await expect(getValidAccessTokenForAccount("kimi", accountId)).rejects.toThrow("network down"); + await expect(getValidAccessTokenForAccount("kimi", accountId)).resolves.toBe("kimi-recovered"); + expect(refreshCalls).toBe(2); + }); + + test("after lock acquire, a newer disk credential is adopted without a second IdP call", async () => { + const accountId = await seedExpiredKimi(); + let release!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + const tracker = stubKimiRefresh(async () => { + await gate; + return { + access: "stale-refresh-result", + refresh: "rt-from-idp", + expires: Date.now() + 3_600_000, + }; + }); + + const pending = getValidAccessTokenForAccount("kimi", accountId); + while (tracker.calls() === 0) await Bun.sleep(1); + + await saveCredential("kimi", { + access: "writer-fresh", + refresh: "writer-refresh", + expires: Date.now() + 3_600_000, + accountId: "kimi-acct", + }); + release(); + + await expect(pending).resolves.toBe("writer-fresh"); + expect(tracker.calls()).toBe(1); + expect(getAccountCredential("kimi", accountId)?.refresh).toBe("writer-refresh"); + }); + + test("older refresh result cannot overwrite newer stored token", async () => { + const accountId = await seedExpiredKimi(); + let reject!: () => void; + let started!: () => void; + const began = new Promise(resolve => { started = resolve; }); + const tracker = stubKimiRefresh( + () => new Promise((_, rejectPromise) => { + started(); + reject = () => rejectPromise(new Error("late idp failure")); + }), + ); + + const pending = getValidAccessTokenForAccount("kimi", accountId); + await began; + await saveCredential("kimi", { + access: "newer-writer", + refresh: "newer-refresh", + expires: Date.now() + 3_600_000, + accountId: "kimi-acct", + }); + reject(); + await expect(pending).rejects.toThrow("late idp failure"); + expect(getAccountCredential("kimi", accountId)?.access).toBe("newer-writer"); + expect(getAccountCredential("kimi", accountId)?.refresh).toBe("newer-refresh"); + }); + + test("late refresh result adopts superseding fresh credential via CAS", async () => { + const accountId = await seedExpiredKimi(); + let release!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + const tracker = stubKimiRefresh(async () => { + await gate; + return { + access: "late-idp-access", + refresh: "late-idp-refresh", + expires: Date.now() + 3_600_000, + }; + }); + + const pending = getValidAccessTokenForAccount("kimi", accountId); + while (tracker.calls() === 0) await Bun.sleep(1); + + await saveCredential("kimi", { + access: "superseding-writer", + refresh: "superseding-refresh", + expires: Date.now() + 3_600_000, + accountId: "kimi-acct", + }); + release(); + + await expect(pending).resolves.toBe("superseding-writer"); + expect(getAccountCredential("kimi", accountId)?.refresh).toBe("superseding-refresh"); + expect(tracker.calls()).toBe(1); + }); + + test("terminal refresh failure marks needsReauth only for matching generation", async () => { + const accountId = await seedExpiredKimi(); + let reject!: () => void; + let refreshCalls = 0; + const gate = new Promise((_, rejectPromise) => { + reject = () => rejectPromise(new Error("invalid_grant")); + }); + OAUTH_PROVIDERS.kimi!.refresh = async () => { + refreshCalls++; + return gate; + }; + + const pending = getValidAccessTokenForAccount("kimi", accountId); + while (refreshCalls === 0) await Bun.sleep(1); + await saveCredential("kimi", { + access: "replacement", + refresh: "replacement-rt", + expires: Date.now() + 3_600_000, + accountId: "kimi-acct", + }); + reject(); + + await expect(pending).rejects.toBeInstanceOf(OAuthLoginRequiredError); + expect(getAccountCredential("kimi", accountId)?.access).toBe("replacement"); + expect(getAccountSet("kimi")!.accounts.find(a => a.id === accountId)!.needsReauth).toBeUndefined(); + }); +}); From 9a5248a8132e555628876218137eb0ac99c4c13a Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sun, 26 Jul 2026 05:16:48 +0200 Subject: [PATCH 06/18] feat(oauth): add shared account health projection --- src/codex/routing.ts | 14 ++++ src/oauth/health.ts | 131 +++++++++++++++++++++++++++++++++++++ src/oauth/index.ts | 6 ++ tests/oauth-health.test.ts | 122 ++++++++++++++++++++++++++++++++++ 4 files changed, 273 insertions(+) create mode 100644 src/oauth/health.ts create mode 100644 tests/oauth-health.test.ts diff --git a/src/codex/routing.ts b/src/codex/routing.ts index c2ab45a2ea..b0b581fa76 100644 --- a/src/codex/routing.ts +++ b/src/codex/routing.ts @@ -310,6 +310,20 @@ export function getCodexAccountCooldownUntil(accountId: string, now = Date.now() return typeof cooldownUntil === "number" && Number.isFinite(cooldownUntil) && cooldownUntil > now ? cooldownUntil : null; } +/** Read-only cooldown snapshot for shared OAuth health projection (no write side effects). */ +export function getCodexAccountHealthSnapshot(accountId: string, now = Date.now()): { + cooldownUntil?: number; + cooldownSource?: "retry-after" | "reset-derived" | "default"; +} | null { + const cooldownUntil = getCodexAccountCooldownUntil(accountId, now); + if (cooldownUntil === null) return null; + const source = upstreamHealth.get(accountId)?.cooldownSource; + return { + cooldownUntil, + ...(source ? { cooldownSource: source } : {}), + }; +} + export function isCodexAccountInCooldown(accountId: string, now = Date.now()): boolean { return getCodexAccountCooldownUntil(accountId, now) !== null; } diff --git a/src/oauth/health.ts b/src/oauth/health.ts new file mode 100644 index 0000000000..9460ab50f0 --- /dev/null +++ b/src/oauth/health.ts @@ -0,0 +1,131 @@ +import { getCodexAccountHealthSnapshot } from "../codex/routing"; +import { isAccountNeedsReauth } from "../codex/account-runtime-state"; +import { getCodexAccountCredential, listCodexAccountIds } from "../codex/account-store"; +import { MAIN_CODEX_ACCOUNT_ID } from "../codex/main-account"; +import { loadAuthStore, readOAuthRefreshIntent } from "./store"; +import type { ProviderAccount } from "./types"; + +export type OAuthAccountHealth = + | { status: "healthy" } + | { status: "cooldown"; until: string; reason: "rate_limit" | "quota" } + | { status: "reauth_required"; reason: "unauthorized" | "forbidden" | "refresh_failed" } + | { status: "warning"; reason: "refresh_conflict" | "metadata_mismatch" | "stale_credentials" }; + +export type OAuthHealthEntry = { + provider: string; + accountId: string; + health: OAuthAccountHealth; + action?: string; +}; + +type OAuthWarningReason = "refresh_conflict" | "metadata_mismatch" | "stale_credentials"; + +export function projectOAuthAccountHealth(input: { + needsReauth?: boolean; + reauthReason?: "unauthorized" | "forbidden" | "refresh_failed"; + cooldownUntilMs?: number; + cooldownReason?: "rate_limit" | "quota"; + warningReason?: OAuthWarningReason; + now?: number; +}): OAuthAccountHealth { + const now = input.now ?? Date.now(); + if (input.needsReauth) { + return { status: "reauth_required", reason: input.reauthReason ?? "refresh_failed" }; + } + if ( + typeof input.cooldownUntilMs === "number" + && Number.isFinite(input.cooldownUntilMs) + && input.cooldownUntilMs > now + ) { + return { + status: "cooldown", + until: new Date(input.cooldownUntilMs).toISOString(), + reason: input.cooldownReason ?? "quota", + }; + } + if (input.warningReason) { + return { status: "warning", reason: input.warningReason }; + } + return { status: "healthy" }; +} + +function actionFor(provider: string, health: OAuthAccountHealth): string | undefined { + if (health.status === "reauth_required") { + return `run \`ocx auth login ${provider}\``; + } + if (health.status === "cooldown") { + const local = new Date(health.until).toLocaleString(); + return `wait until ${local} or start a new session with another eligible account`; + } + if (health.status === "warning" && health.reason === "refresh_conflict") { + return "re-run `ocx doctor` after ensuring only one proxy process writes the credential store"; + } + return undefined; +} + +function detectOAuthWarning(provider: string, account: ProviderAccount): OAuthWarningReason | undefined { + const intent = readOAuthRefreshIntent(provider, account.id); + if (intent?.uncertain) return "refresh_conflict"; + const cred = account.credential; + if (!cred?.access || !cred?.refresh) return "stale_credentials"; + return undefined; +} + +function cooldownReasonFromSource( + source: "retry-after" | "reset-derived" | "default" | undefined, +): "rate_limit" | "quota" | undefined { + if (!source) return undefined; + return source === "retry-after" ? "rate_limit" : "quota"; +} + +function pushEntry( + entries: OAuthHealthEntry[], + provider: string, + accountId: string, + health: OAuthAccountHealth, +): void { + const action = actionFor(provider, health); + entries.push({ + provider, + accountId, + health, + ...(action ? { action } : {}), + }); +} + +export function collectOAuthHealthEntries(now = Date.now()): OAuthHealthEntry[] { + const entries: OAuthHealthEntry[] = []; + const store = loadAuthStore(); + + for (const [provider, set] of Object.entries(store)) { + for (const account of set.accounts) { + const health = projectOAuthAccountHealth({ + needsReauth: account.needsReauth === true, + reauthReason: account.needsReauth === true ? "refresh_failed" : undefined, + warningReason: detectOAuthWarning(provider, account), + now, + }); + pushEntry(entries, provider, account.id, health); + } + } + + const codexIds = new Set(listCodexAccountIds()); + codexIds.add(MAIN_CODEX_ACCOUNT_ID); + for (const accountId of codexIds) { + const snap = getCodexAccountHealthSnapshot(accountId, now); + const needsReauth = isAccountNeedsReauth(accountId); + const hasPoolCredential = accountId !== MAIN_CODEX_ACCOUNT_ID && getCodexAccountCredential(accountId) !== null; + if (!hasPoolCredential && !needsReauth && !snap) continue; + + const health = projectOAuthAccountHealth({ + needsReauth, + reauthReason: needsReauth ? "refresh_failed" : undefined, + cooldownUntilMs: snap?.cooldownUntil, + cooldownReason: cooldownReasonFromSource(snap?.cooldownSource), + now, + }); + pushEntry(entries, "codex", accountId, health); + } + + return entries; +} diff --git a/src/oauth/index.ts b/src/oauth/index.ts index 65d89fbd81..2f5a103d45 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -17,6 +17,12 @@ import { effectiveGoogleMode } from "../providers/registry"; import { resolveProviderTransport } from "../providers/xai-transport"; import { detectClaudeCodeToken, detectGrokCliToken, hasComparableGrokIdentity, isSameGrokIdentity, shouldAdoptGrokGeneration } from "./local-token-detect"; import { logOAuthEvent } from "./log"; +export { + collectOAuthHealthEntries, + projectOAuthAccountHealth, + type OAuthAccountHealth, + type OAuthHealthEntry, +} from "./health"; const REFRESH_SKEW_MS = 60_000; export interface OAuthAccessSnapshot { diff --git a/tests/oauth-health.test.ts b/tests/oauth-health.test.ts new file mode 100644 index 0000000000..78d75321bd --- /dev/null +++ b/tests/oauth-health.test.ts @@ -0,0 +1,122 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + collectOAuthHealthEntries, + projectOAuthAccountHealth, +} from "../src/oauth/health"; +import { getAccountSet, markAccountNeedsReauth, saveCredential } from "../src/oauth/store"; +import { + clearCodexUpstreamHealth, + getCodexAccountHealthSnapshot, + recordCodexUpstreamOutcome, +} from "../src/codex/routing"; +import type { OcxConfig } from "../src/types"; + +const origHome = process.env.HOME; +const origOcxHome = process.env.OPENCODEX_HOME; +let tmp: string; + +beforeEach(() => { + tmp = join(tmpdir(), `oauth-health-${Date.now()}-${Math.random().toString(16).slice(2)}`); + mkdirSync(tmp, { recursive: true }); + process.env.HOME = tmp; + process.env.OPENCODEX_HOME = join(tmp, "ocx"); + clearCodexUpstreamHealth(); +}); + +afterEach(() => { + if (origHome === undefined) delete process.env.HOME; + else process.env.HOME = origHome; + if (origOcxHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = origOcxHome; + clearCodexUpstreamHealth(); + rmSync(tmp, { recursive: true, force: true }); +}); + +describe("projectOAuthAccountHealth", () => { + test("reauth beats cooldown", () => { + expect(projectOAuthAccountHealth({ + needsReauth: true, + reauthReason: "refresh_failed", + cooldownUntilMs: Date.now() + 60_000, + })).toEqual({ status: "reauth_required", reason: "refresh_failed" }); + }); + + test("active cooldown projects until ISO timestamp", () => { + const until = Date.parse("2026-07-23T14:30:00.000Z"); + expect(projectOAuthAccountHealth({ + cooldownUntilMs: until, + cooldownReason: "rate_limit", + now: until - 1000, + })).toEqual({ + status: "cooldown", + until: "2026-07-23T14:30:00.000Z", + reason: "rate_limit", + }); + }); + + test("cooldown beats warning, warning beats healthy", () => { + const until = Date.now() + 60_000; + expect(projectOAuthAccountHealth({ + cooldownUntilMs: until, + cooldownReason: "quota", + warningReason: "refresh_conflict", + now: until - 1, + })).toEqual({ + status: "cooldown", + until: new Date(until).toISOString(), + reason: "quota", + }); + expect(projectOAuthAccountHealth({ + warningReason: "metadata_mismatch", + })).toEqual({ status: "warning", reason: "metadata_mismatch" }); + expect(projectOAuthAccountHealth({})).toEqual({ status: "healthy" }); + }); + + test("expired cooldown is healthy", () => { + const until = Date.parse("2026-07-23T14:30:00.000Z"); + expect(projectOAuthAccountHealth({ + cooldownUntilMs: until, + cooldownReason: "rate_limit", + now: until, + })).toEqual({ status: "healthy" }); + }); +}); + +describe("collectOAuthHealthEntries", () => { + test("projects needsReauth account with reauth action", async () => { + await saveCredential("kimi", { + access: "kimi-access", + refresh: "kimi-refresh", + expires: Date.now() + 3_600_000, + accountId: "kimi-acct-1", + }); + const accountId = getAccountSet("kimi")!.activeAccountId; + await markAccountNeedsReauth("kimi", accountId, true); + + const entries = collectOAuthHealthEntries(); + const entry = entries.find(e => e.provider === "kimi" && e.accountId === accountId); + expect(entry).toEqual({ + provider: "kimi", + accountId, + health: { status: "reauth_required", reason: "refresh_failed" }, + action: "run `ocx auth login kimi`", + }); + }); +}); + +describe("getCodexAccountHealthSnapshot", () => { + test("exposes active cooldown source without changing write policy", () => { + const config = { providers: {} } as OcxConfig; + const now = Date.parse("2026-07-23T14:00:00.000Z"); + recordCodexUpstreamOutcome(config, "pool-acct", 429, { retryAfter: "120", now }); + + expect(getCodexAccountHealthSnapshot("pool-acct", now)).toEqual({ + cooldownUntil: now + 120_000, + cooldownSource: "retry-after", + }); + expect(getCodexAccountHealthSnapshot("missing", now)).toBeNull(); + }); +}); From e4d918f12ffcdbb559d4528805d3eb61837d5336 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sun, 26 Jul 2026 05:22:13 +0200 Subject: [PATCH 07/18] feat(cli): show OAuth health in ocx status --- src/cli/index.ts | 9 ++- src/cli/status-oauth.ts | 42 ++++++++++++ tests/cli-status-oauth-health.test.ts | 94 +++++++++++++++++++++++++++ 3 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 src/cli/status-oauth.ts create mode 100644 tests/cli-status-oauth-health.test.ts diff --git a/src/cli/index.ts b/src/cli/index.ts index 724ccb57f6..bc35871034 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -539,11 +539,18 @@ async function handleStatus() { console.log(` Suggested: ${status.json.codexPlugins.suggestedRepair}`); } } - const { oauthLoginSummary } = await import("../oauth"); + const { collectOAuthHealthEntries, oauthLoginSummary } = await import("../oauth"); + const { formatOAuthHealthForStatus } = await import("./status-oauth"); console.log(` OAuth logins:`); for (const e of oauthLoginSummary()) { console.log(` ${e.provider.padEnd(10)} ${e.loggedIn ? `✓ logged in${e.email ? ` (${e.email})` : ""}` : "✗ not logged in"}`); } + const oauthHealthBlock = formatOAuthHealthForStatus(collectOAuthHealthEntries()); + if (oauthHealthBlock) { + for (const line of oauthHealthBlock.split("\n")) { + console.log(` ${line}`); + } + } } function handleRecoverHistory() { diff --git a/src/cli/status-oauth.ts b/src/cli/status-oauth.ts new file mode 100644 index 0000000000..02e6cf5ccd --- /dev/null +++ b/src/cli/status-oauth.ts @@ -0,0 +1,42 @@ +import { maskAccountId } from "../lib/privacy"; +import type { OAuthAccountHealth, OAuthHealthEntry } from "../oauth/health"; + +function describeHealth(health: OAuthAccountHealth): string { + switch (health.status) { + case "healthy": + return "healthy"; + case "reauth_required": + return "reauthentication required"; + case "cooldown": + return health.reason === "rate_limit" + ? `rate limited until ${health.until}` + : `quota limited until ${health.until}`; + case "warning": + switch (health.reason) { + case "refresh_conflict": + return "refresh conflict"; + case "metadata_mismatch": + return "metadata mismatch"; + case "stale_credentials": + return "stale credentials"; + } + } +} + +/** Human-readable OAuth health block for `ocx status` (redacted account ids, no tokens). */ +export function formatOAuthHealthForStatus(entries: OAuthHealthEntry[]): string { + if (entries.length === 0) return ""; + + const notable = entries.filter((entry) => entry.health.status !== "healthy"); + if (notable.length === 0) return "OAuth health: ok"; + + const lines = ["OAuth health: warning"]; + for (const entry of notable) { + const masked = maskAccountId(entry.accountId) ?? "account-…????"; + lines.push(` ${entry.provider} ${masked} ${describeHealth(entry.health)}`); + if (entry.action) { + lines.push(` Action: ${entry.action}`); + } + } + return lines.join("\n"); +} diff --git a/tests/cli-status-oauth-health.test.ts b/tests/cli-status-oauth-health.test.ts new file mode 100644 index 0000000000..8d62958900 --- /dev/null +++ b/tests/cli-status-oauth-health.test.ts @@ -0,0 +1,94 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { formatOAuthHealthForStatus } from "../src/cli/status-oauth"; +import { collectOAuthHealthEntries } from "../src/oauth/health"; +import { getAccountSet, markAccountNeedsReauth, saveCredential } from "../src/oauth/store"; + +const origHome = process.env.HOME; +const origOcxHome = process.env.OPENCODEX_HOME; +let tmp: string; + +beforeEach(() => { + tmp = join(tmpdir(), `cli-status-oauth-health-${Date.now()}-${Math.random().toString(16).slice(2)}`); + mkdirSync(tmp, { recursive: true }); + process.env.HOME = tmp; + process.env.OPENCODEX_HOME = join(tmp, "ocx"); +}); + +afterEach(() => { + if (origHome === undefined) delete process.env.HOME; + else process.env.HOME = origHome; + if (origOcxHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = origOcxHome; + rmSync(tmp, { recursive: true, force: true }); +}); + +describe("formatOAuthHealthForStatus", () => { + test("formats reauthentication required", () => { + const text = formatOAuthHealthForStatus([{ + provider: "openai", + accountId: "acct_abcdefghijklmnopqrstuvwxyz", + health: { status: "reauth_required", reason: "refresh_failed" }, + action: "run `ocx auth login openai`", + }]); + expect(text).toContain("OAuth health: warning"); + expect(text).toContain("account-…wxyz"); + expect(text).not.toContain("acct_abcdefghijklmnopqrstuvwxyz"); + expect(text).toContain("reauthentication required"); + }); + + test("formats rate limited cooldown", () => { + const text = formatOAuthHealthForStatus([{ + provider: "codex", + accountId: "acct_rate_limit_account_17", + health: { + status: "cooldown", + until: "2026-07-23T14:30:00.000Z", + reason: "rate_limit", + }, + action: "wait until local or start a new session with another eligible account", + }]); + expect(text).toContain("OAuth health: warning"); + expect(text).toContain("account-…t_17"); + expect(text).not.toContain("acct_rate_limit_account_17"); + expect(text).toContain("rate limited"); + expect(text).toContain("2026-07-23T14:30:00.000Z"); + }); + + test("all healthy reports ok without listing accounts", () => { + const text = formatOAuthHealthForStatus([{ + provider: "xai", + accountId: "acct_healthy_zzzz", + health: { status: "healthy" }, + }]); + expect(text).toBe("OAuth health: ok"); + expect(text).not.toContain("acct_healthy_zzzz"); + expect(text).not.toContain("account-…"); + }); +}); + +describe("collectOAuthHealthEntries via status formatter", () => { + test("redacts account id for needsReauth store fixture", async () => { + await saveCredential("xai", { + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + email: "person@example.test", + accountId: "acct_abcdefghijklmnopqrstuvwxyz", + source: "oauth", + }); + const set = getAccountSet("xai"); + expect(set).toBeTruthy(); + await markAccountNeedsReauth("xai", set!.activeAccountId, true); + + const text = formatOAuthHealthForStatus(collectOAuthHealthEntries()); + expect(text).toContain("OAuth health: warning"); + expect(text).toContain("reauthentication required"); + expect(text).toContain("account-…"); + expect(text).not.toContain(set!.activeAccountId); + expect(text).not.toContain("access-token"); + expect(text).not.toContain("refresh-token"); + }); +}); From 62dc52aff364a5cb377a1b05c2ce51d9cc89f557 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sun, 26 Jul 2026 05:27:14 +0200 Subject: [PATCH 08/18] feat(cli): add OAuth reliability checks to ocx doctor --- src/cli/doctor.ts | 131 ++++++++++++++++++++++++++++++++++++- tests/doctor-oauth.test.ts | 78 ++++++++++++++++++++++ 2 files changed, 207 insertions(+), 2 deletions(-) create mode 100644 tests/doctor-oauth.test.ts diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index 2676ebd372..2b6e9be593 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -7,11 +7,12 @@ * it never sets proxy env, relocates state dirs, mutates quota, or changes * networking. See devlog/_plan/260630_wsl-account-autoswitch/30_*. */ -import { existsSync, readFileSync } from "node:fs"; +import { accessSync, constants, existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { getConfigDir, getConfigPath, readConfigDiagnostics, readPid, readRuntimePort, resolveEnvValue } from "../config"; import { gracefulStopHost } from "../lib/process-control"; +import { maskAccountId } from "../lib/privacy"; import { loadServiceTokenFromFile } from "../lib/service-secrets"; import { readCodexTokens } from "../codex/auth-collision"; import { resolveCodexHomeDir as resolveCodexHomeDirImpl, isWslRuntime, listWslWindowsCodexHomes, wslAutomountRoot, type CodexHomeDeps } from "../codex/home"; @@ -26,8 +27,128 @@ import { resolveAndPersistCodexRuntime, resolveCodexRuntime, } from "../codex/runtime"; +import { collectOAuthHealthEntries, type OAuthHealthEntry } from "../oauth/health"; +import { getAuthRefreshIntentLockPath, getAuthStorePath } from "../oauth/store"; export { resolveCodexHomeDir } from "../codex/home"; +export type OAuthDoctorCheck = { level: "OK" | "WARN"; message: string }; + +function pathIsWritable(path: string): boolean { + try { + accessSync(path, constants.W_OK); + return true; + } catch { + return false; + } +} + +/** Observe-only: can we write auth.json (or create it in the config dir)? */ +function isOAuthCredentialStorageWritable(): boolean { + const storePath = getAuthStorePath(); + if (existsSync(storePath)) return pathIsWritable(storePath); + const dir = getConfigDir(); + if (existsSync(dir)) return pathIsWritable(dir); + // Config dir missing: check nearest existing ancestor (no mkdir — observe-only). + let parent = dirname(dir); + for (let i = 0; i < 8; i++) { + if (existsSync(parent)) return pathIsWritable(parent); + const next = dirname(parent); + if (next === parent) break; + parent = next; + } + return false; +} + +/** Observe-only: refresh lock paths resolve and their parent dir is writable. */ +function isOAuthRefreshSingleFlightReady(): boolean { + try { + const sample = getAuthRefreshIntentLockPath("doctor-probe", "probe-account"); + if (!sample.includes("auth.refresh.")) return false; + const dir = getConfigDir(); + if (existsSync(dir)) return pathIsWritable(dir); + return isOAuthCredentialStorageWritable(); + } catch { + return false; + } +} + +function actionForDoctorEntry(entry: OAuthHealthEntry): string { + if (entry.action) return entry.action; + if (entry.health.status === "warning" && entry.health.reason === "stale_credentials") { + return `run \`ocx auth login ${entry.provider}\``; + } + if (entry.health.status === "warning" && entry.health.reason === "metadata_mismatch") { + return `run \`ocx auth login ${entry.provider}\` to refresh credentials`; + } + return `run \`ocx doctor\` again after fixing OAuth state for ${entry.provider}`; +} + +function describeDoctorHealth(entry: OAuthHealthEntry): string { + const masked = maskAccountId(entry.accountId) ?? "account-…????"; + const health = entry.health; + switch (health.status) { + case "reauth_required": + return `Account ${masked} requires reauthentication`; + case "cooldown": + return health.reason === "rate_limit" + ? `Account ${masked} is rate limited until ${health.until}` + : `Account ${masked} is quota limited until ${health.until}`; + case "warning": + switch (health.reason) { + case "refresh_conflict": + return `Account ${masked} has a refresh conflict`; + case "metadata_mismatch": + return `Account ${masked} has a metadata mismatch`; + case "stale_credentials": + return `Account ${masked} has incomplete credentials`; + } + case "healthy": + return `Account ${masked} is healthy`; + } +} + +/** + * OAuth reliability checks for `ocx doctor`. Observe-only: never mutates + * credentials, locks, or networking. Every WARN includes a recovery Action. + */ +export function collectOAuthDoctorChecks(now = Date.now()): OAuthDoctorCheck[] { + const checks: OAuthDoctorCheck[] = []; + + if (isOAuthCredentialStorageWritable()) { + checks.push({ level: "OK", message: "OAuth credential storage is writable." }); + } else { + checks.push({ + level: "WARN", + message: + "OAuth credential storage is not writable. Action: fix permissions on OPENCODEX_HOME so ocx can write auth.json", + }); + } + + if (isOAuthRefreshSingleFlightReady()) { + checks.push({ level: "OK", message: "Token refresh single-flight is active." }); + } else { + checks.push({ + level: "WARN", + message: + "Token refresh single-flight is unavailable. Action: fix permissions on OPENCODEX_HOME so ocx can create refresh lock files", + }); + } + + for (const entry of collectOAuthHealthEntries(now)) { + if (entry.health.status === "healthy") continue; + const action = actionForDoctorEntry(entry); + checks.push({ + level: "WARN", + message: `${describeDoctorHealth(entry)}. Action: ${action}`, + }); + } + + // Static integrity note for the Codex forward path (no runtime scanner). + checks.push({ level: "OK", message: "No fabricated official-client metadata detected." }); + + return checks; +} + const WHAM_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage"; const PROBE_TIMEOUT_MS = 8000; @@ -600,6 +721,12 @@ export async function runDoctor(args: string[] = []): Promise { } } + // OAuth reliability: observe-only (no mutations / auto-repair). + console.log("\nOAuth reliability"); + for (const check of collectOAuthDoctorChecks()) { + console.log(` [${check.level}] ${check.message}`); + } + // Hints, not fixes. const hints: string[] = []; const anyDrvfs = paths.some(p => detectFsType(p.path, mounts).isDrvfs || detectFsType(p.path, mounts).isMntDrive); diff --git a/tests/doctor-oauth.test.ts b/tests/doctor-oauth.test.ts new file mode 100644 index 0000000000..8f1e1bbcd7 --- /dev/null +++ b/tests/doctor-oauth.test.ts @@ -0,0 +1,78 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { collectOAuthDoctorChecks } from "../src/cli/doctor"; +import { getAccountSet, markAccountNeedsReauth, saveCredential } from "../src/oauth/store"; + +const origHome = process.env.HOME; +const origOcxHome = process.env.OPENCODEX_HOME; +let tmp: string; + +beforeEach(() => { + tmp = join(tmpdir(), `doctor-oauth-${Date.now()}-${Math.random().toString(16).slice(2)}`); + mkdirSync(tmp, { recursive: true }); + process.env.HOME = tmp; + process.env.OPENCODEX_HOME = join(tmp, "ocx"); +}); + +afterEach(() => { + if (origHome === undefined) delete process.env.HOME; + else process.env.HOME = origHome; + if (origOcxHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = origOcxHome; + rmSync(tmp, { recursive: true, force: true }); +}); + +describe("collectOAuthDoctorChecks", () => { + test("needsReauth account yields WARN with action and redacted id", async () => { + await saveCredential("openai", { + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + accountId: "acct_abcdefghijklmnopqrstuvwxyz", + source: "oauth", + }); + const set = getAccountSet("openai"); + expect(set).toBeTruthy(); + const accountId = set!.activeAccountId; + await markAccountNeedsReauth("openai", accountId, true); + + const checks = collectOAuthDoctorChecks(); + const warn = checks.find( + (c) => c.level === "WARN" && c.message.includes("requires reauthentication"), + ); + expect(warn).toBeTruthy(); + expect(warn!.message).toContain("Action:"); + expect(warn!.message).toContain("ocx auth login openai"); + expect(warn!.message).toContain("account-…"); + expect(warn!.message).not.toContain(accountId); + expect(warn!.message).not.toContain("access-token"); + expect(warn!.message).not.toContain("refresh-token"); + }); + + test("emits static OK rows for storage, single-flight, and metadata", () => { + const checks = collectOAuthDoctorChecks(); + expect(checks.some((c) => c.level === "OK" && c.message.includes("OAuth credential storage is writable"))).toBe(true); + expect(checks.some((c) => c.level === "OK" && c.message.includes("Token refresh single-flight is active"))).toBe(true); + expect(checks.some((c) => c.level === "OK" && c.message.includes("No fabricated official-client metadata detected"))).toBe(true); + }); + + test("every WARN includes a recovery Action", async () => { + await saveCredential("xai", { + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + accountId: "acct_needs_reauth_suffix_42", + source: "oauth", + }); + const set = getAccountSet("xai")!; + await markAccountNeedsReauth("xai", set.activeAccountId, true); + + const warns = collectOAuthDoctorChecks().filter((c) => c.level === "WARN"); + expect(warns.length).toBeGreaterThan(0); + for (const warn of warns) { + expect(warn.message).toMatch(/Action:/); + } + }); +}); From 5cea409674d5103e35a43ebba7f481b6c371bc38 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sun, 26 Jul 2026 05:36:32 +0200 Subject: [PATCH 09/18] feat(gui): surface OAuth account health diagnostics --- gui/src/components/CodexAccountPool.tsx | 19 ++- .../components/codex-account-pool-cards.tsx | 50 ++++++-- .../codex-account-pool-main-card.tsx | 36 +++++- .../provider-workspace/ProviderAuthPanel.tsx | 47 +++++++- .../components/provider-workspace/types.ts | 6 + gui/src/hooks/useCodexAccountPool.ts | 4 + gui/src/hooks/useProviderAccountPools.ts | 13 ++- gui/src/i18n/de.ts | 3 + gui/src/i18n/en.ts | 3 + gui/src/i18n/ja.ts | 3 + gui/src/i18n/ko.ts | 3 + gui/src/i18n/ru.ts | 3 + gui/src/i18n/zh.ts | 3 + gui/src/lib/privacy.ts | 13 +++ gui/src/oauth-health-display.ts | 37 ++++++ gui/tests/oauth-health-display.test.ts | 39 +++++++ src/codex/auth-api.ts | 23 +++- src/oauth/health.ts | 109 ++++++++++++++++++ src/oauth/index.ts | 7 ++ src/server/management/oauth-account-routes.ts | 19 ++- tests/oauth-accounts-api.test.ts | 31 +++++ 21 files changed, 445 insertions(+), 26 deletions(-) create mode 100644 gui/src/lib/privacy.ts create mode 100644 gui/src/oauth-health-display.ts create mode 100644 gui/tests/oauth-health-display.test.ts diff --git a/gui/src/components/CodexAccountPool.tsx b/gui/src/components/CodexAccountPool.tsx index f4ca46a228..44e6693a17 100644 --- a/gui/src/components/CodexAccountPool.tsx +++ b/gui/src/components/CodexAccountPool.tsx @@ -15,10 +15,13 @@ import { CodexAccountResetModal } from "./codex-account-reset-modal"; import { CodexAccountPoolLoadStates, CodexAccountPoolMainCard, CodexAccountPoolPageHead } from "./codex-account-pool-main-card"; import { redeemResetCredit } from "./codex-account-pool-handlers"; import type { CodexAccountEntry } from "./codex-account-pool-types"; +import { oauthHealthShowsReauth } from "../oauth-health-display"; // Single definition lives with the controller that owns this data (WP3). export type { CodexAccountEntry } from "../hooks/useCodexAccountPool"; +const DOCTOR_CMD = "ocx doctor"; + /** * Global ChatGPT / Codex account pool (main + extras), extracted from the Codex * Auth page (WP060). `accountModeState` arrives as a prop (the parent owns the @@ -62,6 +65,14 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban const [redeeming, setRedeeming] = useState(false); const [creditDetails, setCreditDetails] = useState<{ granted_at: string; expires_at: string }[] | null>(null); const [creditDetailsLoading, setCreditDetailsLoading] = useState(false); + const [copiedDoctorFor, setCopiedDoctorFor] = useState(null); + + const copyDoctor = useCallback((accountId: string) => { + navigator.clipboard.writeText(DOCTOR_CMD).then(() => { + setCopiedDoctorFor(accountId); + setTimeout(() => setCopiedDoctorFor(current => current === accountId ? null : current), 2500); + }).catch(() => {}); + }, []); // The controller owns loading and polling. This surface only feeds the auto-switch // threshold observer and leases a pause while an OAuth modal is open. @@ -93,6 +104,8 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban const activePoolAccount = activeId && activeId !== "__main__" ? accounts.find(a => a.id === activeId) : null; + const activePoolNeedsReauth = Boolean(activePoolAccount?.needsReauth) + || oauthHealthShowsReauth(activePoolAccount?.health?.status); useEffect(() => { onActiveNeedsReauthChange?.(activeNeedsReauth); @@ -237,6 +250,8 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban switchActionLabel={switchActionLabel} onSwitch={setConfirm} onOpenReset={openResetPopup} + onCopyDoctor={copyDoctor} + doctorCopied={copiedDoctorFor === (main?.id ?? "__main__")} />
@@ -247,7 +262,7 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban
- {activePoolAccount?.needsReauth && ( + {activePoolNeedsReauth && activePoolAccount && ( openReauth(activePoolAccount.id)} /> )} @@ -264,6 +279,8 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban onReauth={openReauth} onEditAlias={editAlias} onRemove={remove} + onCopyDoctor={copyDoctor} + copiedDoctorFor={copiedDoctorFor} /> void; onEditAlias: (account: CodexAccountEntry) => void; onRemove: (id: string) => void; + onCopyDoctor?: (accountId: string) => void; + copiedDoctorFor?: string | null; }) { const t = useT(); const isNext = (id: string) => activeId === id; return ( <> - {pool.map(a => ( + {pool.map(a => { + const healthStatus = a.health?.status; + const showReauth = Boolean(a.needsReauth) || oauthHealthShowsReauth(healthStatus); + const inCooldown = oauthHealthIsCooldown(healthStatus); + return (
- + {a.alias ?? a.email} {a.plan && {a.plan}} onOpenReset(a)} /> - {a.needsReauth && {t("codexAuth.needsReauth")}} - {isNext(a.id) && !a.needsReauth && ( + {a.healthLabel && a.healthLabel !== "Healthy" && ( + {a.healthLabel} + )} + {showReauth && !a.healthLabel && {t("codexAuth.needsReauth")}} + {isNext(a.id) && !showReauth && !inCooldown && ( {t(accountModeState === "direct" ? "codexAuth.poolPrepared" : "codexAuth.nextSession")} )} - {!isNext(a.id) && !a.needsReauth && ( + {!isNext(a.id) && !showReauth && !inCooldown && ( )} - {a.needsReauth && ( + {showReauth && ( )} + {onCopyDoctor && oauthHealthShowsDoctor(healthStatus) && ( + + )} @@ -71,12 +94,19 @@ export function CodexAccountPoolCards({
-
{a.email}{a.plan ? ` · ${a.plan}` : ""} · {t("prov.accountId")}: {a.id}
- {a.needsReauth +
{a.email}{a.plan ? ` · ${a.plan}` : ""} · {t("prov.accountId")}: {displayAccountId(a.id)}
+ {a.healthSummary && a.healthLabel !== "Healthy" && ( +
{a.healthSummary}
+ )} + {inCooldown && ( +
{t("pws.healthCooldownHint")}
+ )} + {showReauth ?
{t("codexAuth.tokenExpired")}
- : } + : !inCooldown && }
- ))} + ); + })} ); } diff --git a/gui/src/components/codex-account-pool-main-card.tsx b/gui/src/components/codex-account-pool-main-card.tsx index ee787c908b..d9b47b2b5d 100644 --- a/gui/src/components/codex-account-pool-main-card.tsx +++ b/gui/src/components/codex-account-pool-main-card.tsx @@ -5,6 +5,12 @@ import { CodexTicketBadge } from "./codex-account-pool-helpers"; import type { CodexAccountEntry } from "./codex-account-pool-types"; import type { CodexAccountModeState } from "../codex-multi-state"; import type { TFn } from "../i18n/shared"; +import { + oauthHealthBadgeClass, + oauthHealthIsCooldown, + oauthHealthShowsDoctor, + oauthHealthShowsReauth, +} from "../oauth-health-display"; export function CodexAccountPoolMainCard({ t, @@ -15,6 +21,8 @@ export function CodexAccountPoolMainCard({ switchActionLabel, onSwitch, onOpenReset, + onCopyDoctor, + doctorCopied, }: { t: TFn; main: CodexAccountEntry | undefined; @@ -24,6 +32,8 @@ export function CodexAccountPoolMainCard({ switchActionLabel: string; onSwitch: (entry: CodexAccountEntry) => void; onOpenReset: (account: CodexAccountEntry) => void; + onCopyDoctor?: (accountId: string) => void; + doctorCopied?: boolean; }) { const mainFallbackLabel = t("codexAuth.codexApp"); const mainSwitchEntry: CodexAccountEntry = { @@ -34,32 +44,48 @@ export function CodexAccountPoolMainCard({ hasCredential: true, quota: main?.quota ?? null, }; + const showReauth = Boolean(main?.needsReauth) || oauthHealthShowsReauth(main?.health?.status); + const inCooldown = oauthHealthIsCooldown(main?.health?.status); return (
- + {t("codexAuth.mainAccount")} {main && onOpenReset({ ...main, id: "__main__" } as CodexAccountEntry)} />} - {main?.needsReauth && {t("codexAuth.needsReauth")}} + {main?.healthLabel && main.healthLabel !== "Healthy" && ( + {main.healthLabel} + )} + {showReauth && !main?.healthLabel && {t("codexAuth.needsReauth")}} {isMainActive ? t(accountModeState === "direct" ? "codexAuth.poolPrepared" : "codexAuth.nextSession") : t("codexAuth.current")} - {!isMainActive && ( + {!isMainActive && !showReauth && !inCooldown && ( )} + {onCopyDoctor && oauthHealthShowsDoctor(main?.health?.status) && ( + + )} {t("codexAuth.appLogin")}
{main?.email || t("codexAuth.appLogin")}{main?.plan ? ` · ${main.plan}` : ""}
- {main?.needsReauth + {main?.healthSummary && main.healthLabel !== "Healthy" && ( +
{main.healthSummary}
+ )} + {inCooldown && ( +
{t("pws.healthCooldownHint")}
+ )} + {showReauth ?
{t("codexAuth.mainTokenExpired")}
- : main?.quota && } + : !inCooldown && main?.quota && }
); } diff --git a/gui/src/components/provider-workspace/ProviderAuthPanel.tsx b/gui/src/components/provider-workspace/ProviderAuthPanel.tsx index 820618de0a..4c39654fb9 100644 --- a/gui/src/components/provider-workspace/ProviderAuthPanel.tsx +++ b/gui/src/components/provider-workspace/ProviderAuthPanel.tsx @@ -8,10 +8,19 @@ import { useT } from "../../i18n/shared"; import { IconLock, IconExternal, IconTrash } from "../../icons"; import type { WorkspaceItem } from "../../provider-workspace/catalog"; import { oauthAccountDisplayLabel, providerAuthSurface } from "../../provider-workspace/auth"; +import { maskAccountId } from "../../lib/privacy"; +import { + oauthHealthBadgeClass, + oauthHealthIsCooldown, + oauthHealthShowsDoctor, + oauthHealthShowsReauth, +} from "../../oauth-health-display"; import CodexAccountPool from "../CodexAccountPool"; import type { CodexAccountPoolController } from "../../hooks/useCodexAccountPool"; import type { AccountLoadState, OAuthAccountRow, ApiKeyRow, LoginHint, ProviderAuthHandlers } from "./types"; +const DOCTOR_CMD = "ocx doctor"; + export default function ProviderAuthPanel({ item, apiBase, oauth, accounts = [], keys = [], accountLoadState = "ready", switchingAccountId = null, busy = false, loginHint, authHandlers, onCodexActiveNeedsReauthChange, @@ -36,6 +45,7 @@ export default function ProviderAuthPanel({ const [newKey, setNewKey] = useState(""); const [keyBusy, setKeyBusy] = useState(false); const [deviceCodeCopied, setDeviceCodeCopied] = useState(false); + const [copiedDoctorFor, setCopiedDoctorFor] = useState(null); const surface = providerAuthSurface({ ...item, hasApiKey: item.hasApiKey || keys.length > 0 }); const isOauth = surface === "oauth-accounts"; @@ -156,23 +166,43 @@ export default function ProviderAuthPanel({ {accounts.map(account => { const label = oauthAccountDisplayLabel(accounts, account, t); const switching = switchingAccountId === account.id; + const healthStatus = account.health?.status; + const showReauth = Boolean(account.needsReauth) || oauthHealthShowsReauth(healthStatus); + const showDoctor = oauthHealthShowsDoctor(healthStatus); + const inCooldown = oauthHealthIsCooldown(healthStatus); + const maskedId = maskAccountId(account.id) ?? account.id; + const copyDoctor = () => { + navigator.clipboard.writeText(DOCTOR_CMD).then(() => { + setCopiedDoctorFor(account.id); + setTimeout(() => setCopiedDoctorFor(current => current === account.id ? null : current), 2500); + }).catch(() => {}); + }; return (
  • - {account.needsReauth && ( + {showReauth && ( + )} )}
    {a.email}{a.plan ? ` · ${a.plan}` : ""} · {t("prov.accountId")}: {displayAccountId(a.id)}
    - {a.healthSummary && a.healthLabel !== "Healthy" && ( -
    {a.healthSummary}
    + {healthSummary && ( +
    {healthSummary}
    )} {inCooldown && (
    {t("pws.healthCooldownHint")}
    diff --git a/gui/src/components/codex-account-pool-main-card.tsx b/gui/src/components/codex-account-pool-main-card.tsx index d9b47b2b5d..eb74f43f81 100644 --- a/gui/src/components/codex-account-pool-main-card.tsx +++ b/gui/src/components/codex-account-pool-main-card.tsx @@ -6,10 +6,14 @@ import type { CodexAccountEntry } from "./codex-account-pool-types"; import type { CodexAccountModeState } from "../codex-multi-state"; import type { TFn } from "../i18n/shared"; import { + doctorCopyButtonLabel, + formatOAuthHealthLabel, + formatOAuthHealthSummary, oauthHealthBadgeClass, oauthHealthIsCooldown, oauthHealthShowsDoctor, oauthHealthShowsReauth, + type DoctorCopyFeedback, } from "../oauth-health-display"; export function CodexAccountPoolMainCard({ @@ -22,7 +26,7 @@ export function CodexAccountPoolMainCard({ onSwitch, onOpenReset, onCopyDoctor, - doctorCopied, + copiedDoctorFor, }: { t: TFn; main: CodexAccountEntry | undefined; @@ -33,9 +37,10 @@ export function CodexAccountPoolMainCard({ onSwitch: (entry: CodexAccountEntry) => void; onOpenReset: (account: CodexAccountEntry) => void; onCopyDoctor?: (accountId: string) => void; - doctorCopied?: boolean; + copiedDoctorFor?: DoctorCopyFeedback | null; }) { const mainFallbackLabel = t("codexAuth.codexApp"); + const mainId = main?.id ?? "__main__"; const mainSwitchEntry: CodexAccountEntry = { id: "__main__", email: main?.email || mainFallbackLabel, @@ -46,6 +51,10 @@ export function CodexAccountPoolMainCard({ }; const showReauth = Boolean(main?.needsReauth) || oauthHealthShowsReauth(main?.health?.status); const inCooldown = oauthHealthIsCooldown(main?.health?.status); + const healthLabel = formatOAuthHealthLabel(t, main?.health); + const healthSummary = main + ? formatOAuthHealthSummary(t, "codex", mainId, main.health) + : null; return (
    @@ -54,10 +63,10 @@ export function CodexAccountPoolMainCard({ {t("codexAuth.mainAccount")} {main && onOpenReset({ ...main, id: "__main__" } as CodexAccountEntry)} />} - {main?.healthLabel && main.healthLabel !== "Healthy" && ( - {main.healthLabel} + {healthLabel && ( + {healthLabel} )} - {showReauth && !main?.healthLabel && {t("codexAuth.needsReauth")}} + {showReauth && !healthLabel && {t("codexAuth.needsReauth")}} {isMainActive ? t(accountModeState === "direct" ? "codexAuth.poolPrepared" : "codexAuth.nextSession") @@ -70,15 +79,15 @@ export function CodexAccountPoolMainCard({ )} {onCopyDoctor && oauthHealthShowsDoctor(main?.health?.status) && ( - )} {t("codexAuth.appLogin")}
    {main?.email || t("codexAuth.appLogin")}{main?.plan ? ` · ${main.plan}` : ""}
    - {main?.healthSummary && main.healthLabel !== "Healthy" && ( -
    {main.healthSummary}
    + {healthSummary && ( +
    {healthSummary}
    )} {inCooldown && (
    {t("pws.healthCooldownHint")}
    diff --git a/gui/src/components/provider-workspace/ProviderAuthPanel.tsx b/gui/src/components/provider-workspace/ProviderAuthPanel.tsx index f48ffd243a..d7e35ec75b 100644 --- a/gui/src/components/provider-workspace/ProviderAuthPanel.tsx +++ b/gui/src/components/provider-workspace/ProviderAuthPanel.tsx @@ -10,10 +10,15 @@ import type { WorkspaceItem } from "../../provider-workspace/catalog"; import { oauthAccountDisplayLabel, providerAuthSurface } from "../../provider-workspace/auth"; import { displayAccountId } from "../../lib/privacy"; import { + copyTextToClipboard, + doctorCopyButtonLabel, + formatOAuthHealthLabel, + formatOAuthHealthSummary, oauthHealthBadgeClass, oauthHealthIsCooldown, oauthHealthShowsDoctor, oauthHealthShowsReauth, + type DoctorCopyFeedback, } from "../../oauth-health-display"; import CodexAccountPool from "../CodexAccountPool"; import type { CodexAccountPoolController } from "../../hooks/useCodexAccountPool"; @@ -45,7 +50,7 @@ export default function ProviderAuthPanel({ const [newKey, setNewKey] = useState(""); const [keyBusy, setKeyBusy] = useState(false); const [deviceCodeCopied, setDeviceCodeCopied] = useState(false); - const [copiedDoctorFor, setCopiedDoctorFor] = useState(null); + const [copiedDoctorFor, setCopiedDoctorFor] = useState(null); const surface = providerAuthSurface({ ...item, hasApiKey: item.hasApiKey || keys.length > 0 }); const isOauth = surface === "oauth-accounts"; @@ -171,11 +176,19 @@ export default function ProviderAuthPanel({ const showDoctor = oauthHealthShowsDoctor(healthStatus); const inCooldown = oauthHealthIsCooldown(healthStatus); const maskedId = displayAccountId(account.id); + const healthLabel = formatOAuthHealthLabel(t, account.health); + const healthSummary = formatOAuthHealthSummary(t, item.name, account.id, account.health); const copyDoctor = () => { - navigator.clipboard.writeText(DOCTOR_CMD).then(() => { - setCopiedDoctorFor(account.id); - setTimeout(() => setCopiedDoctorFor(current => current === account.id ? null : current), 2500); - }).catch(() => {}); + void copyTextToClipboard(DOCTOR_CMD).then((ok) => { + const feedback: DoctorCopyFeedback = { + accountId: account.id, + outcome: ok ? "copied" : "unavailable", + }; + setCopiedDoctorFor(feedback); + setTimeout(() => setCopiedDoctorFor(current => ( + current?.accountId === account.id && current.outcome === feedback.outcome ? null : current + )), 2500); + }); }; return (
  • @@ -188,17 +201,17 @@ export default function ProviderAuthPanel({ {label} {[account.email, `${t("prov.accountId")}: ${maskedId}`].filter(Boolean).join(" · ")} - {account.healthSummary && account.healthLabel !== "Healthy" && ( - {account.healthSummary} + {healthSummary && ( + {healthSummary} )} {inCooldown && ( {t("pws.healthCooldownHint")} )} - {account.healthLabel && account.healthLabel !== "Healthy" && ( - {account.healthLabel} + {healthLabel && ( + {healthLabel} )} - {showReauth && !account.healthLabel && {t("pws.reauth")}} + {showReauth && !healthLabel && {t("pws.reauth")}} {account.active && {t("prov.accountActive")}} {switching && {t("pws.accountSwitching")}} @@ -214,7 +227,7 @@ export default function ProviderAuthPanel({ )} {showDoctor && ( )}