diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 66b81b71ce..99e1c84f39 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -100,6 +100,46 @@ active account without logging the others out. Identity-less Kimi and Kiro crede active slot, while `chatgpt` is always single-slot because Codex pool accounts have a separate ledger. Tokens stay in `~/.opencodex/auth.json`; `/api/oauth/accounts` returns masked metadata only. +### OAuth reliability + +opencodex coordinates token refresh and Codex pool routing so concurrent requests do not race the +credential store. This is reliability and diagnostics work — it does **not** guarantee protection +from provider enforcement, rate limits, or account actions. + +**Refresh coordination.** Before a routed call, an expired access token is refreshed once per +`(provider, account)`: + +1. In-process single-flight — concurrent callers share one refresh promise. +2. Per-account file lock — cross-process writers serialize on the same account. +3. Generation CAS — persist only when the stored credential generation still matches; a newer writer + wins, and an older refresh result cannot overwrite it. + +Terminal refresh failures mark the account as needing reauthentication instead of retrying forever. + +**Cooldowns (Codex pool).** Upstream `429` / quota responses set a hard cooldown from +`Retry-After`, quota `reset` headers (capped), or a short default backoff. Accounts on an explicit +`Retry-After` cooldown are not probed early; reset-derived cooldowns may receive a paced probe lease +so recovery can be detected without flooding the provider. + +**Session affinity.** Codex thread→account affinity is process-local (in-memory only; not persisted +across proxy restarts). On credential failures (`401` / `403`) the account is quarantined for +reauth and affinities for that account are cleared. On `429`, the account enters cooldown, affinities +are cleared, and pool selection may rotate — threads are not pinned through a rate-limit response. + +**Codex client metadata.** The ChatGPT forward path passes through the curated `FORWARD_HEADERS` +allowlist (authorization, `chatgpt-account-id`, originator, session/thread ids, and related Codex +headers — see [Adapters](/reference/adapters/)). Pool mode overwrites only auth and +`chatgpt-account-id` to match the selected credential. opencodex does **not** fabricate official +client identity (for example `originator`, session, or thread headers) when the caller did not send +them. + +**Diagnostics and reauth.** Human `ocx status` prints an OAuth health block (redacted account ids, +no tokens). `ocx doctor` adds an OAuth reliability section with writable-store / single-flight checks +and WARN rows that include a recovery Action. When an OAuth provider account needs reauthentication, run +`ocx login ` (or use Reauthenticate in the dashboard). Codex pool accounts are not an +`ocx login` provider — reauthenticate via the dashboard Codex account pool. See +[`ocx status` / `ocx doctor`](/reference/cli/) in the CLI reference. + ### Kiro credential import `ocx login kiro` searches the platform Kiro CLI stores and opens SQLite databases read-only. Two diff --git a/docs-site/src/content/docs/reference/architecture.md b/docs-site/src/content/docs/reference/architecture.md index 96c0530fa3..c33c6047f3 100644 --- a/docs-site/src/content/docs/reference/architecture.md +++ b/docs-site/src/content/docs/reference/architecture.md @@ -125,9 +125,14 @@ loopback; configured `corsAllowOrigins` entries extend the local-origin allowlis OAuth implementations live in `oauth/`; access tokens are loaded or refreshed immediately before a routed call, while `oauth/token-guardian.ts` can proactively refresh only providers whose policy -allows it. Codex/ChatGPT pool credentials and thread affinity live under `codex/` and are kept out of -management responses. Request usage is normalized to `OcxUsage`, surfaced in Responses terminal -events, and aggregated by `usage/` for the dashboard and optional JSONL diagnostics. +allows it. Refresh is coordinated with in-process single-flight, a per-account file lock, and +generation CAS so concurrent writers cannot clobber a newer credential. A shared health projection +(`oauth/health.ts`) feeds `ocx status`, `ocx doctor`, the management API, and the dashboard. +Codex/ChatGPT pool credentials and process-local thread affinity live under `codex/` and are kept out +of management responses; affinity clears on `401` / `403` / `429` (not pinned through rate limits) +and is not persisted across restarts. Request usage is normalized to `OcxUsage`, surfaced in +Responses terminal events, and aggregated by `usage/` for the dashboard and optional JSONL +diagnostics. ## Transport and compaction diff --git a/docs-site/src/content/docs/reference/cli.md b/docs-site/src/content/docs/reference/cli.md index f09529b51f..110a5989d5 100644 --- a/docs-site/src/content/docs/reference/cli.md +++ b/docs-site/src/content/docs/reference/cli.md @@ -67,9 +67,16 @@ Idempotently ensure a background proxy is running, then sync its live model cata Print a read-only diagnostic summary: proxy PID, `/healthz` reachability, dashboard URL, config path, default provider, Codex autostart setting, service state, and shim state. +Human output also includes an **OAuth health** block after the OAuth logins summary: `OAuth health: +ok` when every known account is healthy, or `OAuth health: warning` with one redacted line per +non-healthy account (provider, masked account id, status such as reauthentication required / rate or +quota limited / refresh conflict) plus an optional `Action:` hint. Account ids are redacted; tokens +and emails are never printed. The `--json` contract does not currently include this health block. + Use `--json` for a machine-readable, read-only diagnostics contract: ```bash +ocx status ocx status --json ``` @@ -327,8 +334,14 @@ credentials under `~/.opencodex/`; API-key login providers open their key dashbo key, validate it when possible, and save the resulting provider config. The command prints the currently accepted OAuth and API-key provider ids when the name is missing or unknown. +Use the same command to **reauthenticate** after `ocx status` / `ocx doctor` reports +reauthentication required or a terminal refresh failure (or use Reauthenticate in the dashboard). +Codex pool accounts are not a public `ocx login` provider — reauthenticate via the dashboard Codex +account pool (Reauthenticate) instead. + ```bash ocx login xai +ocx login anthropic ``` ### `ocx logout ` @@ -404,6 +417,11 @@ Run read-only environment and connectivity diagnostics: state paths and filesyst installs, proxy environment/config, ChatGPT reachability, Codex plugin and project-config warnings, and pending history migration. It prints repair hints but does not apply them. +The **OAuth reliability** section reports whether credential storage is writable, whether refresh +single-flight / lock files can be created under `OPENCODEX_HOME`, non-healthy OAuth or Codex pool +accounts (redacted ids) with a recovery `Action:`, and a static OK that the Codex forward path does +not fabricate official-client metadata. Doctor never mutates credentials or applies repairs. + ### `ocx debug [provider|usage …]` Read or change runtime debug overrides through the running proxy's management API. 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..798219e8ff --- /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 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 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 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 diff --git a/gui/src/components/CodexAccountPool.tsx b/gui/src/components/CodexAccountPool.tsx index f4ca46a228..9e0027e36c 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 { accountNeedsReauth, copyTextToClipboard, type DoctorCopyFeedback } 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 @@ -50,7 +53,7 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban // but stays inert (no load, no polling) whenever a shared controller was injected. const ownController = useCodexAccountPool(apiBase, !injectedController); const controller = injectedController ?? ownController; - const { accounts, activeId, loadState, switchingId, activeNeedsReauth, load } = controller; + const { accounts, activeId, loadState, switchingId, load } = controller; const [confirm, setConfirm] = useState(null); const [showAdd, setShowAdd] = useState(false); const [reauthId, setReauthId] = useState(null); @@ -62,6 +65,20 @@ 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) => { + void copyTextToClipboard(DOCTOR_CMD).then((ok) => { + const feedback: DoctorCopyFeedback = { + accountId, + outcome: ok ? "copied" : "unavailable", + }; + setCopiedDoctorFor(feedback); + setTimeout(() => setCopiedDoctorFor(current => ( + current?.accountId === accountId && current.outcome === feedback.outcome ? null : current + )), 2500); + }); + }, []); // 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,10 +110,11 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban const activePoolAccount = activeId && activeId !== "__main__" ? accounts.find(a => a.id === activeId) : null; + const activePoolNeedsReauth = accountNeedsReauth(activePoolAccount); useEffect(() => { - onActiveNeedsReauthChange?.(activeNeedsReauth); - }, [activeNeedsReauth, onActiveNeedsReauthChange]); + onActiveNeedsReauthChange?.(activePoolNeedsReauth); + }, [activePoolNeedsReauth, onActiveNeedsReauthChange]); const openReauth = useCallback((id: string) => { setReauthId(id); @@ -237,6 +255,8 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban switchActionLabel={switchActionLabel} onSwitch={setConfirm} onOpenReset={openResetPopup} + onCopyDoctor={copyDoctor} + copiedDoctorFor={copiedDoctorFor} />
@@ -247,7 +267,7 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban
- {activePoolAccount?.needsReauth && ( + {activePoolNeedsReauth && activePoolAccount && ( openReauth(activePoolAccount.id)} /> )} @@ -264,6 +284,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?: DoctorCopyFeedback | 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); + const healthLabel = formatOAuthHealthLabel(t, a.health); + const healthSummary = formatOAuthHealthSummary(t, "codex", a.id, a.health); + return (
- + {a.alias ?? a.email} {a.plan && {a.plan}} onOpenReset(a)} /> - {a.needsReauth && {t("codexAuth.needsReauth")}} - {isNext(a.id) && !a.needsReauth && ( + {healthLabel && ( + {healthLabel} + )} + {showReauth && !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 +100,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)}
+ {healthSummary && ( +
{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..eb74f43f81 100644 --- a/gui/src/components/codex-account-pool-main-card.tsx +++ b/gui/src/components/codex-account-pool-main-card.tsx @@ -5,6 +5,16 @@ 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 { + doctorCopyButtonLabel, + formatOAuthHealthLabel, + formatOAuthHealthSummary, + oauthHealthBadgeClass, + oauthHealthIsCooldown, + oauthHealthShowsDoctor, + oauthHealthShowsReauth, + type DoctorCopyFeedback, +} from "../oauth-health-display"; export function CodexAccountPoolMainCard({ t, @@ -15,6 +25,8 @@ export function CodexAccountPoolMainCard({ switchActionLabel, onSwitch, onOpenReset, + onCopyDoctor, + copiedDoctorFor, }: { t: TFn; main: CodexAccountEntry | undefined; @@ -24,8 +36,11 @@ export function CodexAccountPoolMainCard({ switchActionLabel: string; onSwitch: (entry: CodexAccountEntry) => void; onOpenReset: (account: CodexAccountEntry) => void; + onCopyDoctor?: (accountId: string) => void; + copiedDoctorFor?: DoctorCopyFeedback | null; }) { const mainFallbackLabel = t("codexAuth.codexApp"); + const mainId = main?.id ?? "__main__"; const mainSwitchEntry: CodexAccountEntry = { id: "__main__", email: main?.email || mainFallbackLabel, @@ -34,32 +49,52 @@ export function CodexAccountPoolMainCard({ hasCredential: true, quota: main?.quota ?? null, }; + 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 (
- + {t("codexAuth.mainAccount")} {main && onOpenReset({ ...main, id: "__main__" } as CodexAccountEntry)} />} - {main?.needsReauth && {t("codexAuth.needsReauth")}} + {healthLabel && ( + {healthLabel} + )} + {showReauth && !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 + {healthSummary && ( +
{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..d7e35ec75b 100644 --- a/gui/src/components/provider-workspace/ProviderAuthPanel.tsx +++ b/gui/src/components/provider-workspace/ProviderAuthPanel.tsx @@ -8,10 +8,24 @@ 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 { 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"; 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 +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 surface = providerAuthSurface({ ...item, hasApiKey: item.hasApiKey || keys.length > 0 }); const isOauth = surface === "oauth-accounts"; @@ -156,23 +171,51 @@ 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 = displayAccountId(account.id); + const healthLabel = formatOAuthHealthLabel(t, account.health); + const healthSummary = formatOAuthHealthSummary(t, item.name, account.id, account.health); + const copyDoctor = () => { + 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 (
  • - {account.needsReauth && ( + {showReauth && ( + )}