diff --git a/.github/pr-assets/codex-quota-evidence.md b/.github/pr-assets/codex-quota-evidence.md new file mode 100644 index 0000000000..8244c2441b --- /dev/null +++ b/.github/pr-assets/codex-quota-evidence.md @@ -0,0 +1,48 @@ +# Codex quota registration browser verification + +These captures show the production dashboard bundle served by `startServer`, +using the real management routes, device-login implementation, credential store, +account-pool controller, and refresh button. They are not component fixtures. + +The server used an isolated OpenCodex/Codex home. Only external provider responses +were mocked: device authorization, token exchange, WHAM usage, and the completed +inference stream. The account identity and credentials are synthetic. The empty +native-main home explains the separate Main Account warning in both screenshots. +No live OpenAI account was used or charged. + +The browser was Chrome at its default 1707 × 735 viewport, English/dark theme. +Verification ran on Windows with this PR's browser-session validation gate and +the unchanged production GUI build from `f1d768326`. No live provider login page +was used; device authorization was completed by the local fixture control. + +1. Open Codex Set → Multi-auth, click Add, enter an account ID, and choose Device + code login. Authorize through the mock device service. +2. The actual token exchange and authenticated usage read return a Pro account + with weekly usage at 100%. Registration persists it as validation pending: + one usage read, zero model calls, and no successful-validation timestamp. + The completion notice also says validation is pending; no model-selection + dialog opens for this unroutable account. +3. Reload the page and click Refresh quotas while usage is still 100%. + The account remains pending. Cumulative counts: two usage reads, zero model + calls. The pending screenshot shows the status and the missing selection button. +4. Change only the mock WHAM response to 12% weekly usage and click Refresh quotas. + The server receives a completed validation response. Cumulative counts: + three usage reads, one model call. The pending flag clears, the validation + timestamp is persisted, and “Use this account next” appears. +5. Select the recovered account and confirm the dialog. The stored config reports + `weekly-demo` as the active account. + +Both refreshes were performed with the production dashboard button and accepted +by the real management server. Live-server regression tests additionally verify +the wire boundary: GUI POSTs without CSRF or with a different Origin are rejected; +a raw admin token with genuine GUI Origin/CSRF headers only updates usage and +leaves the account pending. Only the authenticated GUI session completes model +validation. GET quota refreshes remain observational. + +| Capture | Weekly usage | Pending | Model calls so far | +| --- | --- | --- | --- | +| `codex-quota-pending.png` | 100% | Yes | 0 | +| `codex-quota-recovered.png` | 12% | No | 1 | + +This verifies dashboard-to-server behavior against controlled upstream responses. +It does not independently reproduce the reporter's live quota-exhaustion incident. diff --git a/.github/pr-assets/codex-quota-pending.png b/.github/pr-assets/codex-quota-pending.png new file mode 100644 index 0000000000..f142433e25 Binary files /dev/null and b/.github/pr-assets/codex-quota-pending.png differ diff --git a/.github/pr-assets/codex-quota-recovered.png b/.github/pr-assets/codex-quota-recovered.png new file mode 100644 index 0000000000..4fa88c99a6 Binary files /dev/null and b/.github/pr-assets/codex-quota-recovered.png differ diff --git a/AGENTS.md b/AGENTS.md index 4e81205b92..ca17bc4a0a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -150,8 +150,8 @@ say where it is; do not add it to `devlog/`, `structure/`, or `docs-site/`. ## User-consent actions Some actions write to the **user's own accounts and identity** rather than to -this repository, and an agent must never perform or auto-answer them. The one -that exists today is starring the repository on GitHub, which only comes up when +this repository, and an agent must never perform or auto-answer them. One example +is starring the repository on GitHub, which only comes up when an agent is *running* opencodex — not when it is working on this codebase. The rule lives in [`AGENTS_INSTALL.md`](./AGENTS_INSTALL.md), which is the file diff --git a/AGENTS_INSTALL.md b/AGENTS_INSTALL.md index 404d5ec420..05278f37cb 100644 --- a/AGENTS_INSTALL.md +++ b/AGENTS_INSTALL.md @@ -22,7 +22,7 @@ install or operations task authorizes, and an agent must never perform or auto-answer them — even when the surrounding task is approved, even when the credential is sitting right there. -The current one is **starring the repository on GitHub**. +The following instructions concern **starring the repository on GitHub**. ### Do not @@ -72,6 +72,14 @@ agent-driven callers regardless: Regression coverage: `tests/server/startup-prompt.test.ts`, `tests/cli/agent-driven.test.ts`, `tests/server/sidebar-routes.test.ts`. +### Pending Codex account validation + +Completing deferred account validation spends model quota. Only a human's +dashboard **Refresh quotas** action authorizes it. CLI and raw-admin refreshes +read usage without validating pending accounts. Relay the dashboard recovery +instruction to the user; do not mint a GUI session or call the provider directly +to work around this boundary. This also applies when operating a headless host. + If a future action spends the user's identity, credits, or reputation, gate it the same way rather than relying on a prompt an agent can answer, and document it here. diff --git a/docs-site/src/content/docs/fr/guides/codex-integration.md b/docs-site/src/content/docs/fr/guides/codex-integration.md index eeaf4f8af8..5964e77137 100644 --- a/docs-site/src/content/docs/fr/guides/codex-integration.md +++ b/docs-site/src/content/docs/fr/guides/codex-integration.md @@ -375,13 +375,13 @@ délégation v1/base/v2 et de ses mécanismes de repli. ## Préchauffage des comptes Codex -Lorsqu'un compte ChatGPT est ajouté au groupe de comptes Codex, opencodex le vérifie avant de l'enregistrer -avec une petite requête en streaming vers le service Codex Responses. La requête utilise un véritable tableau -d'éléments Responses (`input: [{ type: "message", ... }]`), attend `response.completed` et utilise par défaut -`gpt-5.4-mini`. Si ce modèle renvoie HTTP 400, opencodex réessaie avec `gpt-5.5` ; les détails structurés de -l'erreur en amont sont affichés sans exposer le corps brut de la réponse. La revalidation en arrière-plan est -distincte et désactivée par défaut. Elle ne s'exécute que si Token Guardian est actif, si la stratégie -d'actualisation `chatgpt` vaut `proactive` et si `tokenGuardian.codexWarmupEnabled` vaut true. +L’ajout ou la réauthentification vérifie normalement le compte avant son enregistrement par une petite requête attendant `response.completed`. Le modèle par défaut est `gpt-5.4-mini`, avec un essai sur `gpt-5.5` en cas de HTTP 400. Les erreurs publiques contiennent des catégories fixes, sans corps de réponse brut. + +Si la lecture authentifiée des quotas avec le nouveau jeton OAuth confirme un quota de 5 heures, hebdomadaire ou mensuel épuisé, le compte est enregistré sans appel au modèle et affiche **Validation en attente**. Il reste exclu du routage après un redémarrage ou un renouvellement du jeton. Après récupération du quota, actualisez les quotas : une lecture récente et complète avec de la capacité disponible permet une petite requête de validation. Seule sa réussite active le compte. Tout échec conserve la restriction. Les lectures passives ne déclenchent pas cette requête. Un quota inconnu à l’inscription conserve la vérification habituelle. + +`ocx account refresh openai` et `ocx account list openai --quota --refresh` consultent uniquement les quotas. La validation du modèle consomme du quota et nécessite une session humaine du tableau de bord : après récupération, ouvrez `ocx gui` et cliquez sur **Refresh quotas**. Sur un hôte sans interface graphique, accédez à son tableau de bord depuis votre navigateur ; le jeton administrateur seul n’autorise pas la validation. Un compte en pause peut être validé sans être repris ni sélectionné. Les erreurs d’autorisation restent visibles jusqu’à une validation ou une réauthentification réussie. + +La revalidation en arrière-plan est distincte et désactivée par défaut. Elle nécessite Token Guardian, la politique `proactive` du fournisseur `openai` et `tokenGuardian.codexWarmupEnabled`, et ignore les comptes dont la validation d’inscription est en attente. ## Restauration de Codex natif diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index 4e23da2e32..6552b14365 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -706,13 +706,13 @@ Catalog sync makes the selected sub-agent models available to Codex; see [Codex ## Codex account warmup -When a ChatGPT account is added to the Codex account pool, opencodex verifies it before persistence -with a small streaming request to the Codex Responses backend. The request uses a real Responses -item array (`input: [{ type: "message", ... }]`), waits for `response.completed`, and defaults to -`gpt-5.4-mini`. If that model returns HTTP 400, it retries with `gpt-5.5`; structured upstream error -details are surfaced without exposing raw response bodies. Background revalidation is separate and -off by default; it runs only when Token Guardian is enabled, the `chatgpt` refresh policy is -`proactive`, and `tokenGuardian.codexWarmupEnabled` is true. +When a ChatGPT account is added or reauthenticated, OpenCodex normally verifies it before saving with a small streaming request to the Codex Responses backend. It waits for `response.completed`, defaults to `gpt-5.4-mini`, and retries with `gpt-5.5` on HTTP 400. Public errors contain fixed failure categories rather than raw upstream response bodies. + +If the new OAuth credential's authenticated usage lookup confirms an exhausted 5-hour, weekly, or monthly quota, the account is saved without this model request and shows **Validation pending**. It cannot serve pool requests, even after a restart or token refresh. Once quota recovers, **Refresh quotas** finishes validation: a fresh, complete usage reading with headroom permits one small model request, and only a completed response enables the account. Failed or incomplete readings and failed validation preserve the restriction. Passive account polling does not trigger deferred validation. Unknown usage during initial registration retains the normal warmup gate. + +`ocx account refresh openai` and `ocx account list openai --quota --refresh` only read usage. Model validation spends quota and requires a human dashboard session: open `ocx gui` and click **Refresh quotas** after recovery. For a headless host, access its dashboard from your browser; an admin token alone does not authorize validation. Validation can complete while an account is paused without resuming or selecting it. Model authorization failures remain visible until successful validation or reauthentication clears them. + +Background revalidation is separate and off by default. It requires Token Guardian, the `openai` provider's `proactive` refresh policy, and `tokenGuardian.codexWarmupEnabled`. It skips accounts awaiting deferred registration validation. ## Restoring native Codex diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index d519c4f3dd..1d44cd375b 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -37,6 +37,9 @@ Desktop mode and current credential-bound upstream permission; a catalog entry a authorize a request. See [Luna Reserve alongside routed models](/reference/cli/providers-accounts/#luna-reserve-alongside-routed-models) for setup, restart order, authorization requirements, and unsupported helpers. +For adding an account with exhausted quota and finishing its deferred validation, see +[Codex account warmup](/guides/codex-integration/#codex-account-warmup). + ### Providers overview pool capacity For Codex login in Pool mode, the Providers overview shows a configured-weight estimate of the diff --git a/docs-site/src/content/docs/ja/guides/codex-integration.md b/docs-site/src/content/docs/ja/guides/codex-integration.md index df320c660f..ca5d1628d7 100644 --- a/docs-site/src/content/docs/ja/guides/codex-integration.md +++ b/docs-site/src/content/docs/ja/guides/codex-integration.md @@ -241,7 +241,13 @@ ocx service install # persistent: auto-starts on login and respawns on crash ## Codex アカウントのウォームアップ -ChatGPT アカウントが Codex アカウント プールに追加されると、opencodex は、Codex Response バックエンドへの小さなストリーミング リクエストで永続化する前にそれを検証します。リクエストは実際の応答項目配列 (`input: [{ type: "message", ... }]`) を使用し、`response.completed` を待機し、デフォルトは `gpt-5.4-mini` になります。そのモデルが HTTP 400 を返した場合、`gpt-5.5` で再試行します。構造化されたアップストリーム エラーの詳細は、生の応答本体を公開することなく表示されます。バックグラウンドの再検証は個別に行われ、デフォルトではオフになっています。これは、トークン ガーディアンが有効で、`chatgpt` 更新ポリシーが `proactive` で、`tokenGuardian.codexWarmupEnabled` が true の場合にのみ実行されます。 +アカウントの追加・再認証では通常、保存前に小さなモデルリクエストで `response.completed` を確認します。既定モデルは `gpt-5.4-mini` で、HTTP 400 の場合は `gpt-5.5` で再試行します。公開エラーには固定の分類のみを表示し、生の応答本文は公開しません。 + +新しい OAuth トークンによる使用量取得で5時間・週次・月次の上限到達が確認された場合、モデルを呼ばずに保存し、**検証待ち**と表示します。再起動やトークン更新後も使用できません。上限回復後に使用量を更新すると、十分な空き容量を示す完全な最新情報を確認してから小さなモデルリクエストを送り、完了した場合のみ使用可能になります。取得や検証の失敗では待機状態を維持します。通常の状態ポーリングは検証リクエストを送りません。初回登録時の使用量が不明な場合は通常の検証が必要です。 + +`ocx account refresh openai` と `ocx account list openai --quota --refresh` は使用量のみを取得します。モデル検証はクォータを消費するため、人間のダッシュボードセッションが必要です。回復後に `ocx gui` を開き、**Refresh quotas** をクリックしてください。ヘッドレスホストでもブラウザーからそのダッシュボードにアクセスします。管理者トークンだけでは検証できません。一時停止中でも検証できますが、アカウントの再開や選択は行いません。モデル認証エラーは検証または再認証に成功するまで表示されます。 + +バックグラウンド再検証は別機能で既定では無効です。Token Guardian、`openai` の `proactive` 更新ポリシー、`tokenGuardian.codexWarmupEnabled` が必要で、登録検証待ちのアカウントは除外します。 ## ネイティブ Codexの復元 diff --git a/docs-site/src/content/docs/ko/guides/codex-integration.md b/docs-site/src/content/docs/ko/guides/codex-integration.md index 976da6c96a..c92f789999 100644 --- a/docs-site/src/content/docs/ko/guides/codex-integration.md +++ b/docs-site/src/content/docs/ko/guides/codex-integration.md @@ -252,7 +252,13 @@ catalog sync는 선택된 서브에이전트 모델을 Codex가 쓸 수 있게 ## Codex 계정 워밍업 -ChatGPT 계정을 Codex account pool에 추가하면, opencodex는 이를 저장하기 전에 Codex Responses backend로 작은 streaming request를 보내 확인합니다. 요청은 실제 Responses item array(`input: [{ type: "message", ... }]`)를 사용하고, `response.completed`를 기다리며, 기본값은 `gpt-5.4-mini`입니다. 그 모델이 HTTP 400을 반환하면 `gpt-5.5`로 다시 시도합니다. 구조화된 upstream error detail은 보여 주되 raw response body는 노출하지 않습니다. background revalidation은 별도 기능이며 기본값은 꺼져 있습니다. Token Guardian이 활성화되고, `chatgpt` refresh policy가 `proactive`이며, `tokenGuardian.codexWarmupEnabled`가 true일 때만 실행됩니다. +ChatGPT 계정을 추가하거나 재인증할 때 OpenCodex는 일반적으로 저장 전에 작은 모델 요청으로 확인합니다. `gpt-5.4-mini`의 `response.completed`를 기다리며 HTTP 400이면 `gpt-5.5`로 재시도합니다. 오류에는 고정된 실패 분류만 표시하고 원본 응답 본문은 노출하지 않습니다. + +새 OAuth 토큰으로 인증된 사용량 조회에서 5시간·주간·월간 한도 소진이 확인되면 모델 요청 없이 계정을 저장하고 **검증 대기**로 표시합니다. 재시작이나 토큰 갱신 후에도 요청에 사용되지 않습니다. 한도 회복 후 **사용량 새로고침**을 실행하면, 여유가 있는 완전한 최신 사용량을 확인한 뒤 작은 모델 요청을 보내고 완료 응답을 받아야 계정을 사용할 수 있습니다. 조회나 검증 실패 시 대기 상태를 유지합니다. 일반적인 화면 상태 조회는 이 모델 요청을 보내지 않습니다. 최초 등록 때 사용량이 불명확하면 기존 워밍업 검증이 필요합니다. + +`ocx account refresh openai`와 `ocx account list openai --quota --refresh`는 사용량만 조회합니다. 모델 검증은 할당량을 사용하므로 사람의 대시보드 세션이 필요합니다. 할당량이 복구되면 `ocx gui`를 열고 **Refresh quotas**를 클릭하세요. 헤드리스 호스트도 브라우저에서 해당 대시보드에 접속해야 하며, 관리자 토큰만으로는 검증할 수 없습니다. 일시 정지된 계정도 검증할 수 있지만 일시 정지를 해제하거나 계정을 선택하지는 않습니다. 모델 인증 실패 표시는 검증 또는 재인증에 성공할 때까지 유지됩니다. + +별도의 백그라운드 재검증은 기본적으로 꺼져 있습니다. Token Guardian, `openai`의 `proactive` 갱신 정책, `tokenGuardian.codexWarmupEnabled`가 필요하며 등록 검증 대기 계정은 제외합니다. ## 네이티브 Codex 복원 diff --git a/docs-site/src/content/docs/ru/guides/codex-integration.md b/docs-site/src/content/docs/ru/guides/codex-integration.md index 01e4d8003d..120eb10b61 100644 --- a/docs-site/src/content/docs/ru/guides/codex-integration.md +++ b/docs-site/src/content/docs/ru/guides/codex-integration.md @@ -368,14 +368,13 @@ v1/base/v2 при делегировании и fallback — в ## Прогрев аккаунтов Codex -Когда аккаунт ChatGPT добавляется в пул аккаунтов Codex, opencodex проверяет его до сохранения -небольшим streaming-запросом в backend Codex Responses. Запрос использует настоящий массив -Responses item'ов (`input: [{ type: "message", ... }]`), ждёт `response.completed` и по умолчанию -использует `gpt-5.4-mini`. Если эта модель отвечает HTTP 400, выполняется повтор с `gpt-5.5`; -структурированные детали upstream-ошибки показываются без раскрытия сырых тел ответа. Фоновая -перепроверка отделена от этого процесса и по умолчанию выключена; она запускается только когда -включён Token Guardian, у `chatgpt` выставлена политика refresh `proactive`, а -`tokenGuardian.codexWarmupEnabled` равен true. +При добавлении или повторной аутентификации аккаунт обычно проверяется до сохранения небольшим запросом, ожидающим `response.completed`. По умолчанию используется `gpt-5.4-mini`, при HTTP 400 — повтор с `gpt-5.5`. Публичные ошибки содержат фиксированные категории без исходного тела ответа. + +Если запрос квоты с новым OAuth-токеном подтверждает исчерпание пятичасовой, недельной или месячной квоты, аккаунт сохраняется без вызова модели со статусом **Ожидает проверки**. Перезапуск и обновление токена не включают маршрутизацию. После восстановления квоты обновите её: полные свежие данные с доступной ёмкостью разрешают небольшой проверочный запрос. Только успешное завершение включает аккаунт. Ошибки сохраняют ограничение. Пассивный опрос не отправляет такой запрос. Неизвестная квота при регистрации требует обычной проверки. + +`ocx account refresh openai` и `ocx account list openai --quota --refresh` только читают квоты. Проверка модели расходует квоту и требует пользовательского сеанса панели: после восстановления откройте `ocx gui` и нажмите **Refresh quotas**. Для сервера без графического интерфейса откройте его панель в браузере; одного токена администратора недостаточно. Проверка приостановленного аккаунта не возобновляет и не выбирает его. Ошибки авторизации остаются видимыми до успешной проверки или повторного входа. + +Фоновая проверка — отдельная функция, выключенная по умолчанию. Она требует Token Guardian, политики `proactive` провайдера `openai` и `tokenGuardian.codexWarmupEnabled` и пропускает аккаунты, ожидающие проверки регистрации. ## Восстановление нативного Codex diff --git a/docs-site/src/content/docs/tr/guides/codex-integration.md b/docs-site/src/content/docs/tr/guides/codex-integration.md index 46b5f8e296..61d2577e84 100644 --- a/docs-site/src/content/docs/tr/guides/codex-integration.md +++ b/docs-site/src/content/docs/tr/guides/codex-integration.md @@ -425,15 +425,13 @@ Arayüzü](/tr/guides/sub-agent-surface/) sayfasına bakın. ## Codex hesap ısınması -Codex hesap havuzuna bir ChatGPT hesabı eklendiğinde opencodex, Codex Responses -arka ucuna küçük bir akış isteği ile kalıcılıktan önce hesabı doğrular. İstek -gerçek bir Responses öğe dizisi kullanır (`input: [{ type: "message", ... }]`), -`response.completed` bekler ve varsayılan olarak `gpt-5.4-mini` kullanır. Bu -model HTTP 400 döndürürse `gpt-5.5` ile yeniden dener; ham yanıt gövdelerini -açığa çıkarmadan yapılandırılmış yukarı akış hata ayrıntıları ortaya çıkarılır. -Arka plan yeniden doğrulaması ayrıdır ve varsayılan olarak kapalıdır; yalnızca -Token Guardian etkinleştirildiğinde, `chatgpt` yenileme politikası `proactive` -olduğunda ve `tokenGuardian.codexWarmupEnabled` true olduğunda çalışır. +Hesap ekleme veya yeniden kimlik doğrulama, normalde kaydetmeden önce `response.completed` bekleyen küçük bir model isteğiyle doğrulanır. Varsayılan model `gpt-5.4-mini` olup HTTP 400 durumunda `gpt-5.5` denenir. Genel hatalar ham yanıt gövdesi yerine sabit hata kategorilerini içerir. + +Yeni OAuth belirteciyle yapılan kota sorgusu 5 saatlik, haftalık veya aylık kotanın tükendiğini doğrularsa hesap model çağrısı olmadan kaydedilir ve **Doğrulama bekleniyor** gösterilir. Yeniden başlatma veya belirteç yenileme yönlendirmeyi açmaz. Kota geri geldiğinde kotaları yenileyin: kullanılabilir kapasite gösteren eksiksiz güncel veri küçük bir doğrulama isteğine izin verir. Yalnızca tamamlanan yanıt hesabı etkinleştirir. Hatalarda kısıtlama korunur. Pasif sorgulama bu isteği göndermez. İlk kayıtta bilinmeyen kota normal doğrulamayı gerektirir. + +`ocx account refresh openai` ve `ocx account list openai --quota --refresh` yalnızca kullanımı okur. Model doğrulaması kota tüketir ve insanın pano oturumunu gerektirir: kota yenilendikten sonra `ocx gui` açıp **Refresh quotas** düğmesine tıklayın. Grafik arayüzü olmayan bir sunucunun panosuna da tarayıcınızdan erişin; yalnızca yönetici belirteci doğrulama yetkisi vermez. Duraklatılmış hesap doğrulanabilir, ancak devam ettirilmez veya seçilmez. Model yetkilendirme hataları başarılı doğrulama veya yeniden girişe kadar görünür kalır. + +Arka plan doğrulaması ayrı ve varsayılan olarak kapalıdır. Token Guardian, `openai` için `proactive` yenileme ilkesi ve `tokenGuardian.codexWarmupEnabled` gerektirir; kayıt doğrulaması bekleyen hesapları atlar. ## Yerel Codex'i geri yükleme diff --git a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md index 1170d9207f..f63ace3586 100644 --- a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md +++ b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md @@ -312,12 +312,13 @@ fallback 行为,参见 [Sub-agent Surface](/guides/sub-agent-surface/)。 ## Codex 账号预热 -当把一个 ChatGPT 账号加入 Codex 账号池时,opencodex 会在持久化前向 Codex Responses backend -发送一个小型 streaming 请求来验证它。该请求使用真正的 Responses item 数组 -(`input: [{ type: "message", ... }]`),等待 `response.completed`,并默认使用 `gpt-5.4-mini`。 -如果该模型返回 HTTP 400,则会改用 `gpt-5.5` 重试;结构化的上游错误详情会被展示给用户,但不会暴露 -原始响应正文。后台重新验证是独立功能,默认关闭;只有在启用 Token Guardian、将 `chatgpt` 刷新策略设为 -`proactive`,并且 `tokenGuardian.codexWarmupEnabled` 为 true 时才会运行。 +添加或重新认证账号时,通常会在保存前发送一个小型模型请求并等待 `response.completed`。默认使用 `gpt-5.4-mini`,HTTP 400 时改用 `gpt-5.5` 重试。公开错误仅包含固定分类,不包含原始响应正文。 + +如果新 OAuth 凭据的已认证用量查询确认5小时、每周或每月额度耗尽,则不调用模型而直接保存账号,显示**等待验证**。重启或刷新令牌也不会使其可用。额度恢复后刷新额度:只有完整的最新用量显示有余额,才会发送一个小型验证请求;请求完成后账号才可用于路由。查询或验证失败将保留等待状态。普通状态轮询不会发送该请求。初次注册时用量未知仍需常规预热验证。 + +`ocx account refresh openai` 和 `ocx account list openai --quota --refresh` 仅查询用量。模型验证会消耗配额,因此需要用户的仪表板会话:配额恢复后,打开 `ocx gui` 并点击 **Refresh quotas**。无界面主机也需要通过浏览器访问其仪表板;仅凭管理员令牌无法授权验证。暂停的账号可以完成验证,但不会因此恢复或被选中。模型授权错误会一直显示,直到验证或重新登录成功。 + +后台重新验证是独立功能,默认关闭。它要求 Token Guardian、`openai` 的 `proactive` 刷新策略及 `tokenGuardian.codexWarmupEnabled`,并跳过等待注册验证的账号。 ## 恢复原生 Codex diff --git a/docs-site/src/content/docs/zh-tw/guides/codex-integration.md b/docs-site/src/content/docs/zh-tw/guides/codex-integration.md index b8db326af6..999295cd39 100644 --- a/docs-site/src/content/docs/zh-tw/guides/codex-integration.md +++ b/docs-site/src/content/docs/zh-tw/guides/codex-integration.md @@ -319,12 +319,13 @@ ocx service install # 常駐:登入時自動啟動,崩潰後自動重新 ## Codex 帳號預熱 -向 Codex 帳號池新增 ChatGPT 帳號時,opencodex 會先用一個小型 streaming 請求向 Codex Responses -backend 驗證,成功後才持久化。請求使用真正的 Responses item 陣列 -(`input: [{ type: "message", ... }]`),等待 `response.completed`,預設模型為 `gpt-5.4-mini`。若該 -模型回傳 HTTP 400,則改用 `gpt-5.5` 重試;結構化上游錯誤細節會呈現給使用者,但不暴露原始 response -body。背景重新驗證是獨立功能,預設關閉;只有啟用 Token Guardian、將 `chatgpt` refresh policy 設為 -`proactive`,並把 `tokenGuardian.codexWarmupEnabled` 設為 true 時才會執行。 +新增或重新驗證帳號時,通常會在儲存前傳送小型模型請求並等待 `response.completed`。預設使用 `gpt-5.4-mini`,HTTP 400 時改用 `gpt-5.5` 重試。公開錯誤僅包含固定分類,不包含原始回應本文。 + +若新 OAuth 憑證的已驗證用量查詢確認5小時、每週或每月額度耗盡,則不呼叫模型而直接儲存帳號,顯示**等待驗證**。重新啟動或更新權杖也不會使其可用。額度恢復後重新整理額度:只有完整的最新用量顯示有餘額,才會傳送小型驗證請求;請求完成後帳號才可用於路由。查詢或驗證失敗將保留等待狀態。一般狀態輪詢不會傳送該請求。首次註冊時用量未知仍需一般預熱驗證。 + +`ocx account refresh openai` 和 `ocx account list openai --quota --refresh` 僅查詢用量。模型驗證會消耗配額,因此需要使用者的儀表板工作階段:配額恢復後,開啟 `ocx gui` 並點選 **Refresh quotas**。無介面主機也需要透過瀏覽器存取其儀表板;僅憑管理員權杖無法授權驗證。暫停的帳號可以完成驗證,但不會因此恢復或被選取。模型授權錯誤會持續顯示,直到驗證或重新登入成功。 + +背景重新驗證是獨立功能,預設關閉。它需要 Token Guardian、`openai` 的 `proactive` 更新政策及 `tokenGuardian.codexWarmupEnabled`,並略過等待註冊驗證的帳號。 ## 恢復原生 Codex diff --git a/gui/src/codex-account-mutation.ts b/gui/src/codex-account-mutation.ts index 1c90557cd4..4d0bea4faa 100644 --- a/gui/src/codex-account-mutation.ts +++ b/gui/src/codex-account-mutation.ts @@ -1,6 +1,7 @@ /** Privacy-safe completion state shared by account mutation UI flows. */ export interface CodexAccountMutationCompletion { catalogRefreshPending: boolean; + validationPending?: boolean; } /** Project only the public completion flag from an account mutation response. */ @@ -9,7 +10,9 @@ export function codexAccountMutationCompletion(value: unknown): CodexAccountMuta return { catalogRefreshPending: false }; } const descriptor = Object.getOwnPropertyDescriptor(value, "catalogRefreshPending"); + const validation = Object.getOwnPropertyDescriptor(value, "validationPending"); return { + ...(validation && "value" in validation && validation.value === true ? { validationPending: true } : {}), catalogRefreshPending: descriptor !== undefined && "value" in descriptor && descriptor.value === true, diff --git a/gui/src/components/CodexAccountPool.tsx b/gui/src/components/CodexAccountPool.tsx index c01bd8b9d2..f211f1c689 100644 --- a/gui/src/components/CodexAccountPool.tsx +++ b/gui/src/components/CodexAccountPool.tsx @@ -199,13 +199,15 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban const handleAccountAdded = useCallback((completion: CodexAccountMutationCompletion) => { void controller.syncAfterAccountAdded(); showActionFeedback( - t(completion.catalogRefreshPending + t(completion.validationPending + ? "pws.healthLabel.validationPending" + : completion.catalogRefreshPending ? "codexAuth.catalogRefreshPending" : "codexAuth.accountAdded"), - completion.catalogRefreshPending ? "warn" : "ok", + completion.validationPending || completion.catalogRefreshPending ? "warn" : "ok", ); closeAddModal(); - setModelsNotice({ catalogRefreshPending: completion.catalogRefreshPending }); + setModelsNotice(completion.validationPending ? null : { catalogRefreshPending: completion.catalogRefreshPending }); }, [closeAddModal, controller, showActionFeedback, t]); const setActive = async (id: string | null) => { @@ -274,7 +276,7 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban const refreshQuotas = async () => { setRefreshingQuota(true); try { - const ok = await load(true); + const ok = await load(true, { validatePending: true }); showActionFeedback(t(ok ? "codexAuth.quotaRefreshed" : "codexAuth.quotaRefreshFailed"), ok ? "ok" : "err"); } finally { setRefreshingQuota(false); diff --git a/gui/src/components/codex-account-pool-cards.tsx b/gui/src/components/codex-account-pool-cards.tsx index 7c112e7a72..619ec2ecf2 100644 --- a/gui/src/components/codex-account-pool-cards.tsx +++ b/gui/src/components/codex-account-pool-cards.tsx @@ -80,6 +80,7 @@ export function CodexAccountPoolCards({ const healthStatus = a.health?.status; const showReauth = Boolean(a.needsReauth) || oauthHealthShowsReauth(healthStatus); const inCooldown = oauthHealthIsCooldown(healthStatus); + const validationPending = a.health?.reason === "validation_pending"; const healthLabel = formatOAuthHealthLabel(t, a.health); const healthSummary = formatOAuthHealthSummary(t, "codex", a.id, a.health); return ( @@ -101,13 +102,13 @@ export function CodexAccountPoolCards({ {healthLabel} )} {showReauth && !healthLabel && {t("codexAuth.needsReauth")}} - {isNext(a) && !showReauth && !inCooldown && ( + {isNext(a) && !showReauth && !inCooldown && !validationPending && ( {t(accountModeState === "direct" ? "codexAuth.poolPrepared" : "codexAuth.nextSession")} )} - {!a.paused && (!isNext(a) || pinnedId !== a.id) && !showReauth && !inCooldown && ( + {!a.paused && (!isNext(a) || pinnedId !== a.id) && !showReauth && !inCooldown && !validationPending && ( diff --git a/gui/src/components/use-add-codex-account-oauth.ts b/gui/src/components/use-add-codex-account-oauth.ts index 5a5796000c..5c4a9bd241 100644 --- a/gui/src/components/use-add-codex-account-oauth.ts +++ b/gui/src/components/use-add-codex-account-oauth.ts @@ -227,6 +227,7 @@ export function useAddCodexAccountOAuth({ status: string; error?: string; catalogRefreshPending?: unknown; + validationPending?: unknown; }>(stRes); if (!aliveRef.current || pollSession.signal.aborted) return; if (!st) { diff --git a/gui/src/hooks/useCodexAccountPool.ts b/gui/src/hooks/useCodexAccountPool.ts index 0cd82d7293..2d909b29bd 100644 --- a/gui/src/hooks/useCodexAccountPool.ts +++ b/gui/src/hooks/useCodexAccountPool.ts @@ -109,7 +109,7 @@ export interface CodexAccountPoolController { */ activePinnedId: string | null; - load(refreshQuota?: boolean): Promise; + load(refreshQuota?: boolean, options?: { validatePending?: boolean }): Promise; switchAccount(id: string | null): Promise>; setAccountPaused(id: string, paused: boolean): Promise; /** `null` resets the account to the default order. Accepts the `__main__` sentinel. */ @@ -217,10 +217,20 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou /** Full last /active payload, or undefined when none has succeeded yet. */ const readLastActive = useCallback(() => lastActiveRef.current?.value, []); - const load = useCallback(async (refreshQuota = false): Promise => { + const load = useCallback(async (refreshQuota = false, options: { validatePending?: boolean } = {}): Promise => { const generation = ++loadGenerationRef.current; - // Bounded per attempt: a hung accounts/active read must settle, not pin the poll. - const bounded = createBoundedFetch(20_000); + const validatePending = options.validatePending === true; + // Validation can include token recovery, WHAM and two 30s model attempts. + // Budget per known row (conservatively ignoring server parallelism), plus + // native-main lookup time. Ordinary reads retain their short polling deadline. + const knownAccounts = lastGoodByBase.get(apiBase)?.accounts.length ?? 0; + const bounded = createBoundedFetch(validatePending ? 30_000 + 120_000 * Math.max(1, knownAccounts) : 20_000); + // A periodic read must not supersede this longer-running explicit operation. + const validationLease = validatePending ? {} as PauseToken : null; + if (validationLease) { + pauseTokensRef.current!.add(validationLease); + setPauseCount(pauseTokensRef.current!.size); + } setInflightCount(count => count + 1); // The try opens immediately after the increment so even a synchronous throw in the // observer snapshot below cannot leave the counter stuck above zero. @@ -237,7 +247,10 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou const accountsTask = (async (): Promise => { try { - const response = await fetch(`${apiBase}/api/codex-auth/accounts${refreshQuota ? "?refresh=1" : ""}`, { signal: bounded.signal }); + const response = await fetch(`${apiBase}/api/codex-auth/accounts${validatePending ? "/refresh" : refreshQuota ? "?refresh=1" : ""}`, { + method: validatePending ? "POST" : "GET", + signal: bounded.signal, + }); if (!response.ok) throw new Error("account load failed"); const payload = await response.json(); if (loadGenerationRef.current === generation) { @@ -318,6 +331,10 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou return false; } finally { bounded.clear(); + if (validationLease) { + pauseTokensRef.current!.delete(validationLease); + setPauseCount(pauseTokensRef.current!.size); + } setInflightCount(count => Math.max(0, count - 1)); setFirstAttemptSettled(true); } @@ -354,7 +371,7 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou useEffect(() => { if (!enabled || !needsQuotaFill || pauseCount > 0) return; const timers = [350, 900, 2000].map((delayMs) => ( - window.setTimeout(() => { void load(false); }, delayMs) + window.setTimeout(() => { if (pauseTokensRef.current!.size === 0) void load(false); }, delayMs) )); return () => { for (const timer of timers) window.clearTimeout(timer); @@ -365,7 +382,7 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou // (no timer, no traffic) while the tab is hidden. useEffect(() => { if (!enabled || pauseCount > 0) return; - return startVisibilityPoll(() => { void load(); }, REFRESH_INTERVAL_MS); + return startVisibilityPoll(() => { if (pauseTokensRef.current!.size === 0) void load(); }, REFRESH_INTERVAL_MS); }, [enabled, load, pauseCount]); const pauseRefresh = useCallback((): PauseToken => { diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 88c29b7c9d..b92691411a 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -2079,6 +2079,8 @@ export const de: Record = { "pws.healthLabel.reauthRequired": "Erneute Anmeldung erforderlich", "pws.healthLabel.refreshFailed": "Aktualisierung fehlgeschlagen", "pws.healthLabel.metadataMismatch": "Metadaten stimmen nicht überein", + "pws.healthLabel.validationPending": "Validierung ausstehend", + "pws.healthSummary.validationPending": "{provider} {account}: mit erschöpftem Kontingent registriert. Aktualisieren Sie nach der Erholung die Kontingente, um eine kleine Validierungsanfrage zu senden. Bis zum Erfolg bleibt das Routing deaktiviert.", "pws.healthLabel.credentialConflict": "Anmeldedaten-Konflikt", "pws.healthSummary.rateLimited": "{provider} {account}: ratelimited bis {until}. Routing für dieses Konto ist bis dahin pausiert.", "pws.healthSummary.quotaLimited": "{provider} {account}: Kontingent begrenzt bis {until}. Routing für dieses Konto ist bis dahin pausiert.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 578311546f..a8f6cf20a0 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1338,6 +1338,8 @@ export const en = { "pws.healthLabel.reauthRequired": "Reauthentication required", "pws.healthLabel.refreshFailed": "Refresh failed", "pws.healthLabel.metadataMismatch": "Metadata mismatch", + "pws.healthLabel.validationPending": "Validation pending", + "pws.healthSummary.validationPending": "{provider} {account}: registered while quota-limited. After quota recovers, refresh quotas to run a small validation request. Routing stays disabled until it succeeds.", "pws.healthLabel.credentialConflict": "Credential conflict", "pws.healthSummary.rateLimited": "{provider} {account}: rate limited until {until}. Routing for this account is paused until then.", "pws.healthSummary.quotaLimited": "{provider} {account}: quota limited until {until}. Routing for this account is paused until then.", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 3c9d66243d..855a8a146f 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -1328,6 +1328,8 @@ export const fr: Record = { "pws.healthLabel.reauthRequired": "Réauthentification requise", "pws.healthLabel.refreshFailed": "Actualisation échouée", "pws.healthLabel.metadataMismatch": "Métadonnées incompatibles", + "pws.healthLabel.validationPending": "Validation en attente", + "pws.healthSummary.validationPending": "{provider} {account} : enregistré avec un quota épuisé. Après sa récupération, actualisez les quotas pour envoyer une petite requête de validation. Le routage reste désactivé jusqu’à sa réussite.", "pws.healthLabel.credentialConflict": "Conflit d’identifiants", "pws.healthSummary.rateLimited": "{provider} {account} : débit limité jusqu’à {until}. Le routage de ce compte est suspendu jusque-là.", "pws.healthSummary.quotaLimited": "{provider} {account} : quota limité jusqu’à {until}. Le routage de ce compte est suspendu jusque-là.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index d40c44bf53..be78373e77 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1236,6 +1236,8 @@ export const ja: Record = { "pws.healthLabel.reauthRequired": "再認証が必要です", "pws.healthLabel.refreshFailed": "更新に失敗しました", "pws.healthLabel.metadataMismatch": "メタデータの不一致", + "pws.healthLabel.validationPending": "検証待ち", + "pws.healthSummary.validationPending": "{provider} {account}: 利用上限に達した状態で登録されました。上限の回復後、使用量を更新すると小さな検証リクエストを送信します。成功するまでリクエストには使用されません。", "pws.healthLabel.credentialConflict": "資格情報の競合", "pws.healthSummary.rateLimited": "{provider} {account}: {until} までレート制限中です。それまでこのアカウントのルーティングは停止します。", "pws.healthSummary.quotaLimited": "{provider} {account}: {until} までクォータ制限中です。それまでこのアカウントのルーティングは停止します。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 126e0a3f0c..49b3b4f203 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -2118,6 +2118,8 @@ export const ko: Record = { "pws.healthLabel.reauthRequired": "재인증 필요", "pws.healthLabel.refreshFailed": "새로고침 실패", "pws.healthLabel.metadataMismatch": "메타데이터 불일치", + "pws.healthLabel.validationPending": "검증 대기", + "pws.healthSummary.validationPending": "{provider} {account}: 사용량 한도가 소진된 상태로 등록되었습니다. 한도 회복 후 사용량을 새로고침하면 소량의 검증 요청을 보냅니다. 성공할 때까지 요청에 사용되지 않습니다.", "pws.healthLabel.credentialConflict": "자격 증명 충돌", "pws.healthSummary.rateLimited": "{provider} {account}: {until}까지 요청 한도 초과. 그전까지 이 계정 라우팅이 일시 중지됩니다.", "pws.healthSummary.quotaLimited": "{provider} {account}: {until}까지 할당량 제한. 그전까지 이 계정 라우팅이 일시 중지됩니다.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 7898a70c67..72df6bb9db 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1306,6 +1306,8 @@ export const ru: Record = { "pws.healthLabel.reauthRequired": "Требуется повторная аутентификация", "pws.healthLabel.refreshFailed": "Ошибка обновления", "pws.healthLabel.metadataMismatch": "Несоответствие метаданных", + "pws.healthLabel.validationPending": "Ожидает проверки", + "pws.healthSummary.validationPending": "{provider} {account}: зарегистрирован с исчерпанной квотой. После её восстановления обновите квоты, чтобы отправить небольшой проверочный запрос. Маршрутизация отключена до успешной проверки.", "pws.healthLabel.credentialConflict": "Конфликт учётных данных", "pws.healthSummary.rateLimited": "{provider} {account}: ограничение частоты до {until}. Маршрутизация этой учётной записи приостановлена до этого времени.", "pws.healthSummary.quotaLimited": "{provider} {account}: квота ограничена до {until}. Маршрутизация этой учётной записи приостановлена до этого времени.", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 6397b05022..da99115c81 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -1325,6 +1325,8 @@ export const tr: Record = { "pws.healthLabel.reauthRequired": "Yeniden doğrulama gerekli", "pws.healthLabel.refreshFailed": "Yenileme başarısız oldu", "pws.healthLabel.metadataMismatch": "Meta veri uyuşmazlığı", + "pws.healthLabel.validationPending": "Doğrulama bekleniyor", + "pws.healthSummary.validationPending": "{provider} {account}: kota tükenmişken kaydedildi. Kota yenilendikten sonra küçük bir doğrulama isteği göndermek için kotaları yenileyin. Başarılı olana kadar yönlendirme kapalı kalır.", "pws.healthLabel.credentialConflict": "Kimlik bilgisi çakışması", "pws.healthSummary.rateLimited": "{provider} {account}: {until} tarihine kadar oran kısıtlandı.", "pws.healthSummary.quotaLimited": "{provider} {account}: {until} tarihine kadar kota kısıtlandı.", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index f2777a2591..7f5f06a770 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -1097,6 +1097,8 @@ export const zhTW: Record = { "pws.healthLabel.reauthRequired": "需要重新認證", "pws.healthLabel.refreshFailed": "重新整理失敗", "pws.healthLabel.metadataMismatch": "後設資料不符合", + "pws.healthLabel.validationPending": "等待驗證", + "pws.healthSummary.validationPending": "{provider} {account}:已在額度耗盡時註冊。額度恢復後,重新整理額度將傳送一個小型驗證請求。驗證成功前不會用於路由。", "pws.healthLabel.credentialConflict": "憑證衝突", "pws.healthSummary.rateLimited": "{provider} {account}:限速至 {until}。在此之前將暫停該帳號的路由。", "pws.healthSummary.quotaLimited": "{provider} {account}:配額限制至 {until}。在此之前將暫停該帳號的路由。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 36f81b44f1..628f458030 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -2099,6 +2099,8 @@ export const zh: Record = { "pws.healthLabel.reauthRequired": "需要重新认证", "pws.healthLabel.refreshFailed": "刷新失败", "pws.healthLabel.metadataMismatch": "元数据不匹配", + "pws.healthLabel.validationPending": "等待验证", + "pws.healthSummary.validationPending": "{provider} {account}:已在额度耗尽时注册。额度恢复后,刷新额度将发送一个小型验证请求。验证成功前不会用于路由。", "pws.healthLabel.credentialConflict": "凭证冲突", "pws.healthSummary.rateLimited": "{provider} {account}:限速至 {until}。在此之前将暂停该账户的路由。", "pws.healthSummary.quotaLimited": "{provider} {account}:配额限制至 {until}。在此之前将暂停该账户的路由。", diff --git a/gui/src/oauth-health-display.ts b/gui/src/oauth-health-display.ts index e635ef4973..56460f186d 100644 --- a/gui/src/oauth-health-display.ts +++ b/gui/src/oauth-health-display.ts @@ -17,6 +17,7 @@ export type OAuthHealthReason = | "refresh_failed" | "refresh_conflict" | "metadata_mismatch" + | "validation_pending" | "stale_credentials"; export type OAuthHealthView = { @@ -79,6 +80,8 @@ export function oauthHealthLabelKey(health: OAuthHealthView | undefined): TKey | : "pws.healthLabel.reauthRequired"; } switch (health.reason) { + case "validation_pending": + return "pws.healthLabel.validationPending"; case "refresh_conflict": return "pws.healthLabel.credentialConflict"; case "metadata_mismatch": @@ -113,6 +116,9 @@ export function formatOAuthHealthSummary( if (health.status === "reauth_required") { return t("pws.healthSummary.reauthRequired", { provider, account }); } + if (health.reason === "validation_pending") { + return t("pws.healthSummary.validationPending", { provider, account }); + } if (health.reason === "refresh_conflict") { return t("pws.healthSummary.credentialConflict", { provider, account }); } @@ -170,4 +176,3 @@ export function doctorCopyButtonLabel( if (!outcome) return t("pws.copyDoctor"); return outcome === "copied" ? t("pws.doctorCopied") : t("pws.doctorCopyUnavailable"); } - diff --git a/gui/src/pages/Providers.tsx b/gui/src/pages/Providers.tsx index 665044591f..88cb0b6f79 100644 --- a/gui/src/pages/Providers.tsx +++ b/gui/src/pages/Providers.tsx @@ -260,8 +260,8 @@ export default function Providers({ apiBase }: { apiBase: string }) { }, []); const notifyCodexCompletion = useCallback((completion: CodexAccountMutationCompletion) => { - if (completion.catalogRefreshPending) { - setStatus(t("codexAuth.catalogRefreshPending")); + if (completion.validationPending || completion.catalogRefreshPending) { + setStatus(t(completion.validationPending ? "pws.healthLabel.validationPending" : "codexAuth.catalogRefreshPending")); setStatusOk(false); setStatusTone("warn"); setStatusRevision(revision => revision + 1); @@ -419,7 +419,7 @@ export default function Providers({ apiBase }: { apiBase: string }) { const configured = config?.providers[provider]; const mode = configured?.authMode; const readAccounts = configured && isAccountProvider(provider, configured) - ? () => codexPool.load(true) + ? () => codexPool.load(true, { validatePending: true }) : mode === "oauth" ? () => fetchAccountSets([provider], true) : mode === "forward" || mode === "local" @@ -710,7 +710,7 @@ export default function Providers({ apiBase }: { apiBase: string }) { onCodexAdded={(completion) => { setCodexLoginOpen(false); notifyCodexCompletion(completion); - modelsNotice.open("openai", !config.providers.openai, completion.catalogRefreshPending); + if (!completion.validationPending) modelsNotice.open("openai", !config.providers.openai, completion.catalogRefreshPending); void fetchConfig(); void fetchOauth(); void fetchProviderQuotas(true); diff --git a/gui/tests/codex-account-pool-behaviour.test.tsx b/gui/tests/codex-account-pool-behaviour.test.tsx index 2d8e42037a..42cec3818c 100644 --- a/gui/tests/codex-account-pool-behaviour.test.tsx +++ b/gui/tests/codex-account-pool-behaviour.test.tsx @@ -218,6 +218,35 @@ test("the controller loads once on mount", async () => { expect(seen.current!.loadState).toBe("ready"); }); +test("forced quota reads remain GET unless deferred validation is explicitly requested", async () => { + const seen = await mountController(); + const originalTimeout = AbortSignal.timeout; + const deadlines: number[] = []; + AbortSignal.timeout = (ms: number) => { + deadlines.push(ms); + return new AbortController().signal; + }; + try { + calls = []; + await act(async () => { await seen.current!.load(true); }); + expect(calls).toContain("GET codex-auth/accounts?refresh=1"); + expect(calls.some(call => call.startsWith("POST codex-auth/accounts"))).toBe(false); + expect(deadlines.at(-1)).toBe(20_000); + calls = []; + let finishValidation!: () => void; + nextAccountsResponseGate = new Promise(resolve => { finishValidation = resolve; }); + let validation!: Promise; + await act(async () => { validation = seen.current!.load(true, { validatePending: true }); }); + expect(calls).toContain("POST codex-auth/accounts/refresh"); + expect(deadlines.at(-1)).toBeGreaterThan(8_000 + 2 * 30_000); + await act(async () => { await new Promise(resolve => setTimeout(resolve, 400)); }); + expect(calls.filter(call => call.includes("codex-auth/accounts"))).toEqual(["POST codex-auth/accounts/refresh"]); + await act(async () => { finishValidation(); expect(await validation).toBe(true); }); + } finally { + AbortSignal.timeout = originalTimeout; + } +}); + test("the controller joins 30-day usage to accounts by the displayed log label", async () => { accounts = [ { id: "main", email: "main", isMain: true, paused: false, priority: 0, hasCredential: true, quota: null }, diff --git a/gui/tests/codex-account-pool-controller.test.ts b/gui/tests/codex-account-pool-controller.test.ts index a701905c09..044b8156a0 100644 --- a/gui/tests/codex-account-pool-controller.test.ts +++ b/gui/tests/codex-account-pool-controller.test.ts @@ -32,7 +32,7 @@ test("the controller is the single data owner and exposes the agreed contract", // Observers arrive through one subscription path; load() takes no observer argument. expect(hook).toContain("subscribeLoadObserver"); - expect(hook).toContain("load(refreshQuota?: boolean): Promise"); + expect(hook).toContain("load(refreshQuota?: boolean, options?: { validatePending?: boolean }): Promise"); expect(hook).not.toContain("load(refreshQuota?: boolean, observer"); }); diff --git a/gui/tests/oauth-health-display.test.ts b/gui/tests/oauth-health-display.test.ts index 9d6c3162dc..1afc9fbb59 100644 --- a/gui/tests/oauth-health-display.test.ts +++ b/gui/tests/oauth-health-display.test.ts @@ -23,6 +23,12 @@ const t: TFn = ((key: string, vars?: Record) => { }) as TFn; describe("oauth health badge helpers", () => { + test("deferred Codex validation explains quota recovery without requesting reauthentication", () => { + const health = { status: "warning" as const, reason: "validation_pending" }; + expect(formatOAuthHealthLabel(t, health)).toBe("pws.healthLabel.validationPending"); + expect(formatOAuthHealthSummary(t, "codex", "acct_pending", health)).toBe("pws.healthSummary.validationPending"); + expect(accountNeedsReauth({ needsReauth: false, health })).toBe(false); + }); test("maps statuses to badge tones and classes", () => { expect(oauthHealthBadgeTone("healthy")).toBe("ok"); expect(oauthHealthBadgeTone("cooldown")).toBe("muted"); diff --git a/gui/tests/provider-revalidation-policy.test.tsx b/gui/tests/provider-revalidation-policy.test.tsx index e6be1936e1..1e19b634e7 100644 --- a/gui/tests/provider-revalidation-policy.test.tsx +++ b/gui/tests/provider-revalidation-policy.test.tsx @@ -162,6 +162,7 @@ for (const kind of ["oauth", "key", "codex"] as const) { test(`the real Providers page refresh selects ${kind} and awaits account plus report`, async () => { const name = kind === "codex" ? "openai" : `${kind}-fixture`; const seen: string[] = []; + const methods = new Map(); let finishReport!: (response: Response) => void; let finishAccounts!: (response: Response) => void; let reportStarted!: () => void; @@ -171,9 +172,10 @@ for (const kind of ["oauth", "key", "codex"] as const) { : kind === "oauth" ? { activeAccountId: "account", accounts: [{ id: "account", active: true, quotaMode: "probe" }] } : { keys: [{ id: "key", masked: "masked", active: true, quotaMode: "probe" }] }; - Object.defineProperty(globalThis, "fetch", { configurable: true, value: async (input: RequestInfo | URL) => { + Object.defineProperty(globalThis, "fetch", { configurable: true, value: async (input: RequestInfo | URL, init?: RequestInit) => { const url = new URL(String(input), "http://localhost"); seen.push(url.pathname + url.search); + methods.set(url.pathname + url.search, init?.method ?? "GET"); if (url.pathname === "/api/config") return Response.json({ port: 10100, defaultProvider: name, providers: { [name]: kind === "codex" ? { adapter: "openai-responses", authMode: "forward", codexAccountMode: "pool", baseUrl: "https://chatgpt.com/backend-api/codex" } @@ -188,8 +190,8 @@ for (const kind of ["oauth", "key", "codex"] as const) { return result; } if (url.pathname === "/api/oauth/accounts" || url.pathname === "/api/providers/keys" - || (kind === "codex" && url.pathname === "/api/codex-auth/accounts")) { - return url.searchParams.has("refresh") + || (kind === "codex" && (url.pathname === "/api/codex-auth/accounts" || url.pathname === "/api/codex-auth/accounts/refresh"))) { + return url.searchParams.has("refresh") || url.pathname.endsWith("/accounts/refresh") ? new Promise(resolve => { finishAccounts = resolve; }) : Response.json(accountBody); } if (url.pathname === "/api/codex-auth/accounts") return Response.json({ accounts: [] }); @@ -209,10 +211,11 @@ for (const kind of ["oauth", "key", "codex"] as const) { seen.length = 0; await act(async () => { refresh!.click(); }); await act(async () => { await reportReady; }); - const expected = kind === "codex" ? "/api/codex-auth/accounts?refresh=1" + const expected = kind === "codex" ? "/api/codex-auth/accounts/refresh" : kind === "oauth" ? `/api/oauth/accounts?provider=${name}"a=1&refresh=1` : `/api/providers/keys?name=${name}"a=1&refresh=1`; expect(seen).toContain(expected); + expect(methods.get(expected)).toBe(kind === "codex" ? "POST" : "GET"); if (kind !== "oauth") expect(seen.some(path => path.startsWith("/api/oauth/accounts"))).toBe(false); if (kind === "oauth") expect(seen.some(path => path.startsWith("/api/providers/keys"))).toBe(false); expect(container.textContent).toContain("Refreshing..."); diff --git a/gui/tests/providers-codex-completion-toast.test.tsx b/gui/tests/providers-codex-completion-toast.test.tsx index 1852f8884f..01f56e521f 100644 --- a/gui/tests/providers-codex-completion-toast.test.tsx +++ b/gui/tests/providers-codex-completion-toast.test.tsx @@ -31,6 +31,7 @@ let host: HTMLElement; let root: Root | null; let requests: Array<{ path: string; method: string }>; let catalogRefreshPending: boolean; +let validationPending: boolean; function pathCount(path: string): number { return requests.filter(request => request.path.startsWith(`${API_BASE}${path}`)).length; @@ -63,6 +64,7 @@ beforeEach(() => { requests = []; catalogRefreshPending = true; + validationPending = false; Object.defineProperty(globalThis, "fetch", { configurable: true, value: async (input: RequestInfo | URL, init?: RequestInit) => { @@ -110,6 +112,7 @@ beforeEach(() => { return Response.json({ status: "done", catalogRefreshPending, + validationPending, privateDetail: "private-account-detail", }); } @@ -224,6 +227,15 @@ test("pending Codex completion stays amber, private, dismissible, and refreshes expect(testWindow.document.querySelector(".toast-notice")).toBeNull(); }); +test("quota-pending registration stays amber and does not offer model selection", async () => { + validationPending = true; + await completeCodexLogin(); + const warning = testWindow.document.querySelector(".toast-notice.notice-warn"); + expect(warning?.textContent).toContain("Validation pending"); + expect(host.querySelector('[role="dialog"]')?.textContent ?? "").not.toContain("Choose models"); + expect(testWindow.document.body.textContent).not.toContain("private-account-detail"); +}); + test("completed Codex catalog convergence reports clean success without sync advice", async () => { catalogRefreshPending = false; await completeCodexLogin(); diff --git a/skills/ocx/references/01_management_surface.md b/skills/ocx/references/01_management_surface.md index a82ba31a01..ba2b4b5823 100644 --- a/skills/ocx/references/01_management_surface.md +++ b/skills/ocx/references/01_management_surface.md @@ -433,6 +433,23 @@ JSON mode: `payload`. - `store` verifies every keychain write by read-back before config.json is rewritten with keychain: references; an unavailable keychain refuses with 503 and leaves the file untouched. - Headless services usually have no unlocked keychain session; prefer ${ENV_VAR} references there. +### `ocx account refresh` + +Refresh account quotas without model validation; pending Codex accounts require dashboard consent. + +| Method | Route | +|---|---| +| POST | `/api/codex-auth/accounts/refresh` | +| GET | `/api/provider-quotas` | + +| Flag | Value | Meaning | +|---|---|---| +| `--json` | boolean | Emit the refresh result as JSON. | + +JSON mode: `payload`. + +- CLI/admin-token refreshes only observe usage. After quota recovery, a human must click Refresh quotas in the dashboard to authorize model validation. Do not mint a GUI session to work around this consent boundary. + ### `ocx account pause` Stop routing new requests to one account in the Codex pool. @@ -689,6 +706,6 @@ JSON mode: `payload`. ## Counts -- declared capabilities: 37 -- of those, state-changing: 16 +- declared capabilities: 38 +- of those, state-changing: 17 - head-resolved invocations: 2 diff --git a/src/cli/account-api.ts b/src/cli/account-api.ts index 76c3456c9c..f3d5d6e9dc 100644 --- a/src/cli/account-api.ts +++ b/src/cli/account-api.ts @@ -22,6 +22,8 @@ export interface AccountRow { masked?: string; active: boolean; needsReauth?: boolean; + /** Registered credential that is still excluded from routing until validation completes. */ + validationPending?: boolean; /** Codex pool selection order, higher used earlier. Absent where ordering does not apply. */ priority?: number; quota?: CodexQuotaDto | null; @@ -237,6 +239,7 @@ interface CodexAccountDto { plan?: string; isMain?: boolean; needsReauth?: boolean; + health?: { reason?: string }; priority?: number; quota?: CodexQuotaDto | null; quotaRefresh?: unknown; @@ -263,10 +266,12 @@ export async function fetchCodexRows( baseUrl: string, forceRefresh = false, includeQuota = forceRefresh, + options: { refreshAction?: boolean } = {}, ): Promise { - const accountsPath = `/api/codex-auth/accounts${forceRefresh ? "?refresh=1" : ""}`; + const refreshAction = options.refreshAction === true; + const accountsPath = `/api/codex-auth/accounts${refreshAction ? "/refresh" : forceRefresh ? "?refresh=1" : ""}`; const [accountsRes, activeRes] = await Promise.all([ - apiJson(deps, baseUrl, "GET", accountsPath), + apiJson(deps, baseUrl, refreshAction ? "POST" : "GET", accountsPath), apiJson(deps, baseUrl, "GET", "/api/codex-auth/active"), ]); if (accountsRes.status !== 0 && accountsRes.status !== 200) { @@ -300,6 +305,7 @@ export async function fetchCodexRows( plan: a.plan, active: a.id === activeId, needsReauth: a.needsReauth, + ...(a.health?.reason === "validation_pending" ? { validationPending: true } : {}), priority: typeof a.priority === "number" ? a.priority : 0, paused: a.paused === true, ...(includeQuota ? { diff --git a/src/cli/account-auth.ts b/src/cli/account-auth.ts index f807659cb8..9ecc637a7a 100644 --- a/src/cli/account-auth.ts +++ b/src/cli/account-auth.ts @@ -154,7 +154,14 @@ async function login(argv: string[], deps: RuntimeApiDeps): Promise { {}, deps, ); if (state.status === "done") { - printData({ ...state, modelSelection: modelSelectionNextSteps("openai") }, wantsJson, [`Logged in${state.email ? ` as ${String(state.email)}` : ""}.`, ...modelSelectionGuidance("openai")]); + if (state.validationPending === true) { + printData({ ...state, recoveryCommand: "ocx gui", recoveryAction: "After quota recovers, click Refresh quotas in the dashboard Codex account pool." }, wantsJson, [ + "Account registered; validation pending (routing disabled).", + "After quota recovers, open 'ocx gui' and click Refresh quotas to complete validation.", + ]); + } else { + printData({ ...state, modelSelection: modelSelectionNextSteps("openai") }, wantsJson, [`Logged in${state.email ? ` as ${String(state.email)}` : ""}.`, ...modelSelectionGuidance("openai")]); + } if (!wantsJson) warnIfCodexCatalogRefreshPending(state); return; } diff --git a/src/cli/account-extended.ts b/src/cli/account-extended.ts index 38179aca42..a6fa3b707c 100644 --- a/src/cli/account-extended.ts +++ b/src/cli/account-extended.ts @@ -264,6 +264,7 @@ function refreshLine(row: FamilyRows["rows"][number]): string { const quotaText = row.quota ? quotaParts(row.quota).join(" ") : ""; parts.push(quotaText.length > 0 ? quotaText : "quota: unknown"); if (row.needsReauth) parts.push("needs-reauth"); + if (row.validationPending) parts.push("validation-pending (routing disabled; open 'ocx gui' and click Refresh quotas after recovery)"); return parts.filter(Boolean).join(" "); } @@ -339,7 +340,7 @@ export async function cmdRefresh(args: string[], deps: AccountDeps): Promise { return { + ...(record?.codexValidationPending === true ? { codexValidationPending: true } : {}), ...(record?.lastCodexValidatedAt !== undefined ? { lastCodexValidatedAt: record.lastCodexValidatedAt } : {}), ...(record?.lastCodexValidationStatus !== undefined ? { lastCodexValidationStatus: record.lastCodexValidationStatus } : {}), ...(record?.lastCodexValidationError !== undefined ? { lastCodexValidationError: record.lastCodexValidationError } : {}), @@ -145,8 +147,12 @@ export function getCodexAccountCredential(id: string): CodexAccountCredentials | return record.credential ?? null; } -export function saveCodexAccountCredential(id: string, cred: CodexAccountCredentials): void { - withCredentialMutationLockSync(() => { +export function saveCodexAccountCredential( + id: string, + cred: CodexAccountCredentials, + options: { validationPending?: boolean } = {}, +): number { + return withCredentialMutationLockSync(() => { const store = loadCodexAccountRecordStore(); const current = store[id]; const refreshGrantFingerprint = current?.credential?.refreshToken === cred.refreshToken @@ -158,26 +164,39 @@ export function saveCodexAccountCredential(id: string, cred: CodexAccountCredent refreshGrantFingerprint, replacedAt: current ? Date.now() : undefined, ...preservedValidationMetadata(current), + ...(options.validationPending ? { + codexValidationPending: true, + lastCodexValidatedAt: undefined, + lastCodexValidationStatus: undefined, + lastCodexValidationError: undefined, + } : {}), }; persistCredentialMutation(store); + return store[id].generation; }); } -export function markCodexAccountValidated(id: string, atMs: number = Date.now()): void { +export function markCodexAccountValidated(id: string, atMs: number = Date.now(), generation?: number): void { withCredentialMutationLockSync(() => { const store = loadCodexAccountRecordStore(); const current = store[id]; if (!current || current.deletedAt != null || !current.credential) return; + if (current.codexValidationPending && generation === undefined) return; + if (generation !== undefined && current.generation !== generation) return; store[id] = { ...current, lastCodexValidatedAt: atMs, lastCodexValidationStatus: "ok", lastCodexValidationError: undefined, + codexValidationPending: undefined, // A completed validation is the direct refutation of a terminal verdict, and this // spread would otherwise carry the old marker forward. lastCodexValidationTerminal: undefined, }; - persist(store); + // Becoming routable invalidates credential-derived caches; a timestamp-only + // update on an already validated account preserves the existing epoch policy. + if (current.codexValidationPending) persistCredentialMutation(store); + else persist(store); }); } @@ -206,6 +225,9 @@ export function markCodexAccountValidationFailed( const store = loadCodexAccountRecordStore(); const current = store[id]; if (!current || current.deletedAt != null || !current.credential) return false; + // Deferred validation is settled only by a caller that names the generation it observed. + // An unfenced write must never resolve a pending account, whichever verdict it carries. + if (current.codexValidationPending && options.expectedGeneration === undefined) return false; if (options.expectedGeneration !== undefined && current.generation !== options.expectedGeneration) { return false; } diff --git a/src/codex/account-usability.ts b/src/codex/account-usability.ts index 3ce27475ee..3001eb2b23 100644 --- a/src/codex/account-usability.ts +++ b/src/codex/account-usability.ts @@ -1,4 +1,4 @@ -import { getCodexAccountCredential } from "./account-store"; +import { readCodexAccountRecord } from "./account-store"; import { isAccountNeedsReauth } from "./account-runtime-state"; import { MAIN_CODEX_ACCOUNT_ID, @@ -48,5 +48,6 @@ export function isCodexAccountUsable( .some(account => isSelectableCodexPoolAccount(account) && account.id === accountId); if (!exists) return false; if (isAccountNeedsReauth(accountId)) return false; - return !!getCodexAccountCredential(accountId); + const record = readCodexAccountRecord(accountId); + return !!record?.credential && record.deletedAt == null && !record.codexValidationPending; } diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts index 1a1e66d88b..5a605d32da 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -12,6 +12,7 @@ import { isCodexAccountGenerationLive, forceRefreshCodexPoolToken, markCodexAccountValidated, + markCodexAccountValidationFailed, readCodexAccountRecord, saveCodexAccountCredential, CodexCredentialGenerationConflictError, @@ -192,6 +193,7 @@ interface CodexLoginStateRow { code?: string; needsReauth?: boolean; catalogRefreshPending?: boolean; + validationPending?: boolean; doneAt?: number; } const codexAuthLoginState = new Map(); @@ -383,7 +385,7 @@ function poolAccountDto( paused, priority, quota: quota ? { ...quota } : null, - needsReauth, + needsReauth: needsReauth || health.status === "reauth_required", hasCredential, ...(quotaResult.quotaProbeSkipped ? { quotaProbeSkipped: true as const } : {}), ...oauthAccountHealthFields("codex", account.id, health), @@ -638,7 +640,7 @@ function saveRuntimeConfig(sourceConfig: OcxConfig, nextConfig: OcxConfig): void interface StagedNewCodexAccountState { credential: CodexAccountCredentials; - validatedAt: number; + validatedAt?: number; } type PersistNewCodexAccountOutcome = @@ -691,8 +693,10 @@ function persistNewCodexAccount( } try { - saveCodexAccountCredential(addedAccount.id, staged.credential); - markCodexAccountValidated(addedAccount.id, staged.validatedAt); + const generation = saveCodexAccountCredential(addedAccount.id, staged.credential, { + validationPending: staged.validatedAt === undefined, + }); + if (staged.validatedAt !== undefined) markCodexAccountValidated(addedAccount.id, staged.validatedAt, generation); clearAccountNeedsReauth(addedAccount.id); } catch { // Config is already durable. Return the failure outcome through the coordinator so its @@ -1101,6 +1105,7 @@ interface PoolQuotaRefreshFlight { superseded?: boolean; startCredentialGeneration?: number; resolvedCredentialGeneration?: number; + validatePending?: boolean; }; promise: Promise; } @@ -1480,11 +1485,12 @@ async function fetchFreshPoolAccountQuota( } } -async function fetchPoolAccountQuota( +export async function fetchPoolAccountQuota( accountId: string, forceRefresh = false, configuredPlan?: string, getValidToken: typeof getValidCodexToken = getValidCodexToken, + validatePending = false, afterDispatchSequence?: number, ): Promise { const existing = getAccountQuota(accountId); @@ -1507,7 +1513,11 @@ async function fetchPoolAccountQuota( && (afterDispatchSequence === undefined || (flight.state.dispatchSequence ?? 0) > afterDispatchSequence) && generation !== undefined && isCodexAccountGenerationLive(accountId, generation); }); - if (current) return current.promise; + if (current) { + // A manual refresh joining a passive read must not lose its validation intent. + current.state.validatePending ||= validatePending; + return current.promise; + } if (poolQuotaFlightCount() >= MAX_POOL_QUOTA_FLIGHTS) throw new PoolQuotaProbeBusyError(); // A post-reset request must not let an older same-account response overwrite its evidence. @@ -1517,6 +1527,7 @@ async function fetchPoolAccountQuota( } const state: PoolQuotaRefreshFlight["state"] = { startCredentialGeneration: record?.generation, + validatePending, }; const refresh = fetchFreshPoolAccountQuota( accountId, @@ -1528,18 +1539,54 @@ async function fetchPoolAccountQuota( onDispatch: sequence => { state.dispatchSequence = sequence; }, mayPublish: () => state.superseded !== true, }, - ); + ).then(async result => { + // A passive flight has consumed its validation decision. Remove it before + // promise settlement queues other continuations, so a late explicit caller + // starts fresh work instead of setting an intent nobody will read again. + if (!state.validatePending) { + releaseFlight(); + return result; + } + // Only an explicit account-list refresh finishes deferred registration. Passive quota + // polls and startup priming remain read-only with respect to inference spending. + const generation = result.freshCredentialGeneration; + const record = state.validatePending ? readCodexAccountRecord(accountId) : null; + if (record?.codexValidationPending && record.credential && record.deletedAt == null + && generation !== undefined && record.generation === generation + && isCompleteCodexQuotaRecoverySnapshot(result.freshQuota ?? null, result.freshPlan ?? configuredPlan)) { + try { + await warmCodexAccount({ + accessToken: record.credential.accessToken, + chatgptAccountId: record.credential.chatgptAccountId, + }); + markCodexAccountValidated(accountId, Date.now(), generation); + clearAccountNeedsReauth(accountId, generation); + } catch (error) { + // Keep the durable restriction on any failed/partial inference response, even + // when WHAM just reported headroom. No raw upstream text enters diagnostics. + const reason = codexWarmupFailureReason(error); + if (reason === "http_status:401" || reason === "http_status:403") { + markCodexAccountValidationFailed(accountId, reason, { expectedGeneration: generation }); + markAccountNeedsReauth(accountId, captureConfigGeneration(), generation); + } + } + } + return result; + }); const flight: PoolQuotaRefreshFlight = { state, promise: refresh }; const activeFlights = flights ?? new Set(); activeFlights.add(flight); if (!flights) poolQuotaRefreshInFlight.set(accountId, activeFlights); - try { - return await refresh; - } finally { + const releaseFlight = () => { activeFlights.delete(flight); if (activeFlights.size === 0 && poolQuotaRefreshInFlight.get(accountId) === activeFlights) { poolQuotaRefreshInFlight.delete(accountId); } + }; + try { + return await refresh; + } finally { + releaseFlight(); } } @@ -1594,8 +1641,10 @@ async function refreshAfterManualReset( } return { accessToken: auth.accessToken, chatgptAccountId: auth.chatgptAccountId, generation: auth.poolGeneration }; }; + // `validatePending` is false here: a manual reset settles cooldown, and finishing deferred + // registration stays reserved for an explicit dashboard account-list refresh. const result = await fetchPoolAccountQuota(accountId, true, account.plan, didReset ? resetToken : getValidCodexToken, - didReset ? afterDispatchSequence : undefined); + false, didReset ? afterDispatchSequence : undefined); const record = readCodexAccountRecord(accountId); const recovered = didReset && record?.credential?.chatgptAccountId === auth.chatgptAccountId && (result.quotaProbeAttempted?.dispatchSequence ?? 0) > afterDispatchSequence @@ -1876,6 +1925,7 @@ export interface CodexAuthAccountsSnapshot { export async function listCodexAuthAccountsSnapshot( config: OcxConfig, forceRefresh = false, + options: { validatePending?: boolean } = {}, ): Promise { const runtimeConfig = getRuntimeConfig(config); const poolAccounts = (runtimeConfig.codexAccounts ?? []).filter(isSelectableCodexPoolAccount); @@ -1887,7 +1937,7 @@ export async function listCodexAuthAccountsSnapshot( quotaResult = { quota: null, needsReauth: true }; } else { try { - quotaResult = await fetchPoolAccountQuota(account.id, forceRefresh, account.plan); + quotaResult = await fetchPoolAccountQuota(account.id, forceRefresh, account.plan, getValidCodexToken, options.validatePending === true); } catch (error) { if (!(error instanceof PoolQuotaProbeBusyError)) throw error; quotaResult = { @@ -2019,8 +2069,12 @@ export async function refreshCodexQuotaForActivation(config: OcxConfig, accountI } } -export async function listCodexAuthAccounts(config: OcxConfig, forceRefresh = false): Promise { - return (await listCodexAuthAccountsSnapshot(config, forceRefresh)).accounts; +export async function listCodexAuthAccounts( + config: OcxConfig, + forceRefresh = false, + options: { validatePending?: boolean } = {}, +): Promise { + return (await listCodexAuthAccountsSnapshot(config, forceRefresh, options)).accounts; } interface PauseExhaustedResult { @@ -2129,6 +2183,7 @@ export async function handleCodexAuthAPI( url: URL, config: OcxConfig, convergeCodexCatalog?: CodexAuthCatalogConvergence, + principal?: import("../server/management-auth").ManagementPrincipal, ): Promise { if (url.pathname === "/api/codex-auth/accounts" && req.method === "GET") { @@ -2136,6 +2191,14 @@ export async function handleCodexAuthAPI( return jsonResponse({ accounts: await listCodexAuthAccounts(config, forceRefresh) }); } + if (url.pathname === "/api/codex-auth/accounts/refresh" && req.method === "POST") { + // Inference spends quota: only a dashboard session carries the consent + // required by AGENTS_INSTALL.md. Raw-admin/CLI refreshes remain observational. + return jsonResponse({ accounts: await listCodexAuthAccounts(config, true, { + validatePending: principal === "gui-session", + }) }); + } + if (url.pathname === "/api/codex-auth/accounts" && req.method === "POST") { return manualImportDisabledResponse(); } @@ -2313,6 +2376,9 @@ export async function handleCodexAuthAPI( const exists = (runtimeConfig.codexAccounts ?? []) .some(account => isSelectableCodexPoolAccount(account) && account.id === body.accountId); if (!exists) return jsonResponse({ error: "Account not found" }, 400); + if (readCodexAccountRecord(body.accountId)?.codexValidationPending) { + return jsonResponse({ error: "Account validation is pending. Refresh quota after recovery to validate it." }, 409); + } } runtimeConfig.activeCodexAccountId = body.accountId ?? undefined; // "Use this account now" outranks selection order until the account is spent: @@ -2757,7 +2823,12 @@ export async function handleCodexAuthAPI( break; } - const warmup = await verifyCodexAccountWarmup(accountId, cred.access, oauthAccountId); + // A successful authenticated WHAM read can prove quota is exhausted without + // spending an inference request. Store the account, but defer inference validation + // and keep it unavailable to routing. Unknown/failed usage reads retain the gate. + const warmup = isCodexQuotaExhausted(quota, plan) + ? { ok: true as const, validatedAt: undefined } + : await verifyCodexAccountWarmup(accountId, cred.access, oauthAccountId); if (!warmup.ok) { const body = await warmup.response.json().catch(() => ({})) as { error?: string; reason?: string }; setCodexLoginState(flowId, { @@ -2797,11 +2868,13 @@ export async function handleCodexAuthAPI( }; if (existingIdx >= 0) { - saveCodexAccountCredential(accountId, credential); + const generation = saveCodexAccountCredential(accountId, credential, { + validationPending: warmup.validatedAt === undefined, + }); // A successful reauthentication replaces the credential generation. Do not let a // failed optional WHAM probe make the replacement inherit quota from the old record. if (reauth) clearAccountQuota(accountId); - markCodexAccountValidated(accountId, warmup.validatedAt); + if (warmup.validatedAt !== undefined) markCodexAccountValidated(accountId, warmup.validatedAt, generation); clearAccountNeedsReauth(accountId); if (quota) setAccountQuotaFromParsed(accountId, quota); // Keep the pool id stable; refresh display metadata after a successful login/reauth. @@ -2852,6 +2925,7 @@ export async function handleCodexAuthAPI( status: "done", accountId, email, + ...(warmup.validatedAt === undefined ? { validationPending: true } : {}), ...(catalogRefreshPending ? { catalogRefreshPending: true } : {}), doneAt: Date.now(), }); @@ -2964,7 +3038,9 @@ export async function handleCodexAuthAPI( && !isAccountNeedsReauth(accountId) && getCodexAccountCredential(accountId) ) { - return jsonResponse({ status: "done", accountId }); + return jsonResponse({ status: "done", accountId, + ...(readCodexAccountRecord(accountId)?.codexValidationPending ? { validationPending: true } : {}), + }); } return jsonResponse(st ? { ...st, email: maskEmail(st.email) ?? undefined } : { status: "expired" }); } diff --git a/src/codex/auth-context.ts b/src/codex/auth-context.ts index bcb5b15134..2cb97df2e9 100644 --- a/src/codex/auth-context.ts +++ b/src/codex/auth-context.ts @@ -7,6 +7,7 @@ import { getCodexAccountCredential, getValidCodexToken, isCodexAccountGenerationLive, + readCodexAccountRecord, } from "./account-store"; import { isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime-state"; import { ConfigMutationLockError } from "../config"; @@ -200,6 +201,19 @@ export class CodexPoolAuthenticationError extends Error { } } +class CodexAccountValidationPendingError extends CodexPoolAuthenticationError { + constructor() { + super("Codex account validation is pending; refresh quota after recovery to validate it"); + this.name = "CodexAccountValidationPendingError"; + } +} + +function assertCodexAccountValidationReady(accountId: string): void { + if (accountId !== MAIN_CODEX_ACCOUNT_ID && readCodexAccountRecord(accountId)?.codexValidationPending) { + throw new CodexAccountValidationPendingError(); + } +} + export const CODEX_MAIN_PROFILE_MAINTENANCE_MESSAGE = "OpenCodex local native-main profile maintenance is active; retry this request"; @@ -585,6 +599,7 @@ export class CodexThreadAffinityExpiredError extends Error { export function shouldMarkAccountNeedsReauthForCodexAuthFailure(cause: unknown): boolean { return !(cause instanceof CodexMainAccountHardLockError) + && !(cause instanceof CodexAccountValidationPendingError) && !(cause instanceof CodexReserveUnavailableError) && !(cause instanceof CodexCredentialGenerationConflictError) && !(cause instanceof CodexCredentialRefreshLockTimeoutError) @@ -911,6 +926,9 @@ export async function resolveCodexAuthContext( } finally { selectionAdmission?.release(); } + // Legacy selectors may retain an unusable account for actionable errors. A + // deferred credential must never become request auth through that fallback. + assertCodexAccountValidationReady(accountId); // Lazy prime: if the selected account has no quota yet, the pool is likely // unprimed (dashboard never opened, or startup prime was blocked). Kick a // best-effort prime so the NEXT routing decision has real scores. This never @@ -1006,6 +1024,7 @@ export async function resolveCodexAuthContext( try { const token = await getValidCodexToken(accountId); + assertCodexAccountValidationReady(accountId); return { kind: "pool", accountId, @@ -1045,6 +1064,7 @@ export function applyCodexAuthContextToProvider( mode: CodexAccountMode | undefined, ): OcxRuntimeProviderConfig { if (mode !== "pool" || (ctx.kind !== "pool" && ctx.kind !== "main-pool") || provider.authMode !== "forward") return provider; + assertCodexAccountValidationReady(ctx.accountId); return { ...provider, _codexAccountOverride: { @@ -1087,6 +1107,7 @@ export function materializeCodexUpstreamAuth( if (value) selected.set(name, value); } if (ctx.kind === "pool" || ctx.kind === "main-pool") { + assertCodexAccountValidationReady(ctx.accountId); selected.set("authorization", `Bearer ${ctx.accessToken}`); selected.set("chatgpt-account-id", ctx.chatgptAccountId); if (ctx.kind === "main-pool") { diff --git a/src/codex/quota-auto-refresh.ts b/src/codex/quota-auto-refresh.ts index 26b88a886c..ce11de97e4 100644 --- a/src/codex/quota-auto-refresh.ts +++ b/src/codex/quota-auto-refresh.ts @@ -8,7 +8,7 @@ import { isSelectableCodexPoolAccount } from "./account-id"; import { reconcileMainCodexAccountRuntimeState } from "./account-lifecycle"; import { isCodexAccountPaused } from "./account-pause"; import { isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime-state"; -import { getValidCodexToken, isCodexAccountGenerationLive } from "./account-store"; +import { getValidCodexToken, isCodexAccountGenerationLive, readCodexAccountRecord } from "./account-store"; import { codexAccountLogLabel } from "./account-label"; import { getMainAccountToken, getValidMainAccountToken, MAIN_CODEX_ACCOUNT_ID } from "./main-account"; import { isMainAccountHardLocked } from "./main-account-hard-lock"; @@ -164,7 +164,11 @@ function mainWarmupRestricted(config: OcxConfig): boolean { async function warmAccount(config: OcxConfig, accountId: string): Promise { const writerGeneration = captureConfigGeneration(); if (accountId !== MAIN_CODEX_ACCOUNT_ID) { + if (readCodexAccountRecord(accountId)?.codexValidationPending) return false; const token = await getValidCodexToken(accountId); + const record = readCodexAccountRecord(accountId); + if (!record?.credential || record.deletedAt != null || record.codexValidationPending + || record.generation !== token.generation) return false; if (isCodexAccountPaused(config, accountId) || isAccountNeedsReauth(accountId)) return false; try { await warmCodexAccount({ ...token, onCompleted: headers => { @@ -294,6 +298,7 @@ export async function runCodexQuotaAutoRefresh( && (accountId === MAIN_CODEX_ACCOUNT_ID || config.codexAccounts?.some( account => account.id === accountId && isSelectableCodexPoolAccount(account))) && (setting?.fiveHour === true || setting?.weekly === true) + && !(accountId !== MAIN_CODEX_ACCOUNT_ID && readCodexAccountRecord(accountId)?.codexValidationPending) && !isCodexAccountPaused(config, accountId) && !isAccountNeedsReauth(accountId) && !(accountId === MAIN_CODEX_ACCOUNT_ID && isMainAccountHardLocked(config)); }; diff --git a/src/oauth/health.ts b/src/oauth/health.ts index cf5fa8c35d..8c4332af85 100644 --- a/src/oauth/health.ts +++ b/src/oauth/health.ts @@ -15,7 +15,7 @@ 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" }; + | { status: "warning"; reason: "refresh_conflict" | "metadata_mismatch" | "stale_credentials" | "validation_pending" }; export type OAuthHealthLabel = | "Healthy" @@ -24,7 +24,8 @@ export type OAuthHealthLabel = | "Reauthentication required" | "Refresh failed" | "Metadata mismatch" - | "Credential conflict"; + | "Credential conflict" + | "Validation pending"; /** Shared masked-id fallback when `maskAccountId` returns nullish. */ export const MASKED_ACCOUNT_FALLBACK = "account-…????"; @@ -88,6 +89,9 @@ export function projectOAuthAccountHealth(input: { export const CODEX_REAUTH_ACTION = "reauthenticate via the dashboard Codex account pool"; function actionFor(provider: string, health: OAuthAccountHealth): string | undefined { + if (health.status === "warning" && health.reason === "validation_pending") { + return "wait for quota recovery, then click Refresh quotas in the dashboard Codex account pool to finish validation"; + } if (health.status === "reauth_required") { if (provider === "codex") return CODEX_REAUTH_ACTION; return `run \`ocx login ${provider}\``; @@ -112,6 +116,8 @@ export function oauthHealthLabel(health: OAuthAccountHealth): OAuthHealthLabel { return health.reason === "refresh_failed" ? "Refresh failed" : "Reauthentication required"; case "warning": switch (health.reason) { + case "validation_pending": + return "Validation pending"; case "refresh_conflict": return "Credential conflict"; case "metadata_mismatch": @@ -198,16 +204,39 @@ export function projectCodexAccountHealth(input: { needsReauth: boolean; now?: number; }): OAuthAccountHealth { + // One read serves every verdict below. Each lookup re-reads and re-hardens the whole store + // file, and the main account lives in the native Codex auth file rather than the pool store, + // so a lookup for it could only ever miss. + const stored = input.accountId !== MAIN_CODEX_ACCOUNT_ID ? readCodexAccountRecord(input.accountId) : null; + const record = stored?.deletedAt == null ? stored : null; + + // A successful quota read is not evidence that model authorization recovered. + // Preserve this guidance until validation succeeds or reauthentication replaces it. + const validationAuthFailed = record !== null + && record.codexValidationPending === true + && record.lastCodexValidationStatus === "failed" + && (record.lastCodexValidationError === "http_status:401" || record.lastCodexValidationError === "http_status:403"); + + // A persisted terminal verdict outranks the in-memory reauth flag rather than duplicating it: + // the flag lives in this process and a revoked grant does not. Without it, an account whose + // grant was revoked upstream keeps its login-time `lastCodexValidationStatus: "ok"` and every + // surface reports it healthy until someone tries to use it (#4120). Only a re-login clears the + // marker, so `reauth_required` is the accurate projection — and it is deliberately checked + // ahead of any cooldown, because telling an operator to wait out a rate limit on a credential + // that will never work again is a false promise. + const terminalGrantFailure = record !== null + && record.lastCodexValidationTerminal === true + && record.lastCodexValidationStatus === "failed"; + + const needsReauth = input.needsReauth || validationAuthFailed || terminalGrantFailure; + + // Deferred validation is only worth reporting while the credential itself is still viable. A + // revoked grant needs a re-login, not a "Refresh quotas" click, so reauth is resolved first. + if (!needsReauth && record?.codexValidationPending) { + return { status: "warning", reason: "validation_pending" }; + } const now = input.now ?? Date.now(); const snap = getCodexAccountHealthSnapshot(input.accountId, now); - // A persisted terminal verdict outranks the in-memory reauth flag rather than duplicating it: - // the flag lives in this process and a revoked grant does not. Without this read, an account - // whose grant was revoked upstream keeps its login-time `lastCodexValidationStatus: "ok"` and - // every surface reports it healthy until someone tries to use it (#4120). Only a re-login - // clears the marker, so `reauth_required` is the accurate projection — and it is deliberately - // checked ahead of any cooldown, because telling an operator to wait out a rate limit on a - // credential that will never work again is a false promise. - const needsReauth = input.needsReauth || hasTerminalCodexValidationFailure(input.accountId); return projectOAuthAccountHealth({ needsReauth, reauthReason: needsReauth ? "refresh_failed" : undefined, @@ -217,21 +246,6 @@ export function projectCodexAccountHealth(input: { }); } -/** - * True when the stored pool record carries a terminal validation verdict — the refresh grant was - * revoked or expired, so no retry recovers it. The main account has no record in the pool store, - * so it never matches. - */ -function hasTerminalCodexValidationFailure(accountId: string): boolean { - // The main account lives in the native Codex auth file, not the pool store, so a lookup could - // only ever miss — and each lookup re-reads and re-hardens the whole store file. - if (accountId === MAIN_CODEX_ACCOUNT_ID) return false; - const record = readCodexAccountRecord(accountId); - return record?.deletedAt == null - && record?.lastCodexValidationTerminal === true - && record?.lastCodexValidationStatus === "failed"; -} - /** * Incomplete credentials warning. Kiro may intentionally store an empty refresh * when authenticated via KIRO_ACCESS_TOKEN or a pasted access-only token, as long diff --git a/src/oauth/token-guardian.ts b/src/oauth/token-guardian.ts index 378d13c0fd..ff87c1f532 100644 --- a/src/oauth/token-guardian.ts +++ b/src/oauth/token-guardian.ts @@ -211,6 +211,7 @@ export async function guardianSweep(nowMs: number = Date.now()): Promise opts.codexWarmupMaxAgeSeconds * 1000); if (!needsRefresh && !needsWarmup) continue; const key = `codex:${id}`; @@ -219,17 +220,21 @@ export async function guardianSweep(nowMs: number = Date.now()): Promise { + let warmupGeneration: number | undefined; try { const token = await getValidCodexToken(id); observedGeneration = token.generation; if (needsRefresh) result.refreshed.push(key); - if (needsWarmup) { + const current = readCodexAccountRecord(id); + if (needsWarmup && current?.credential && current.deletedAt == null + && !current.codexValidationPending && current.generation === token.generation) { + warmupGeneration = token.generation; await warmCodexAccount({ accessToken: token.accessToken, chatgptAccountId: token.chatgptAccountId, model: opts.codexWarmupModel, }); - markCodexAccountValidated(id, Date.now()); + markCodexAccountValidated(id, Date.now(), token.generation); result.warmed.push(key); } backoff.delete(key); @@ -253,9 +258,11 @@ export async function guardianSweep(nowMs: number = Date.now()): Promise { const url = new URL(req.url); - const body = req.method === "PUT" || req.method === "POST" ? await req.json() : undefined; + const body = req.method === "PUT" || req.method === "POST" ? await req.json().catch(() => undefined) : undefined; requests.push({ method: req.method, path: url.pathname, search: url.search, body }); - if (req.method === "GET" && url.pathname === "/api/codex-auth/accounts") { - if (url.searchParams.get("refresh") === "1" && codexRefreshFailure) { + if ((req.method === "GET" && url.pathname === "/api/codex-auth/accounts") + || (req.method === "POST" && url.pathname === "/api/codex-auth/accounts/refresh")) { + if ((url.searchParams.get("refresh") === "1" || req.method === "POST") && codexRefreshFailure) { return json({ error: codexRefreshFailure.error }, codexRefreshFailure.status); } if (lastDeletedType === "codex" && postDeleteReadFailure) { @@ -585,6 +586,24 @@ afterEach(() => { }); describe("ocx account CLI (issue #180 matrix)", () => { + test.each([100, 12])("pending validation stays visible at %s percent usage without exposing raw health details", async weeklyPercent => { + codexAccounts = [{ id: "pending", email: "p***@example.test", quota: { weeklyPercent }, + health: { status: "warning", reason: "validation_pending", message: RAW_SENTINEL } }]; + for (const command of [["list", "openai"], ["refresh", "openai"]]) { + const human = await run(command); + expect(human.code).toBe(0); + expect(human.stdout).toContain("validation-pending"); + expect(human.output).not.toContain(RAW_SENTINEL); + const machine = await run([...command, "--json"]); + expect(JSON.parse(machine.stdout).accounts[0].validationPending).toBe(true); + expect(machine.output).not.toContain(RAW_SENTINEL); + } + codexAccounts = [{ id: "pending", quota: { weeklyPercent: 12 }, health: { status: "healthy" } }]; + const recovered = await run(["refresh", "openai", "--json"]); + expect(JSON.parse(recovered.stdout).accounts[0]).not.toHaveProperty("validationPending"); + expect((await run(["refresh", "openai"])).stdout).not.toContain("validation-pending"); + }); + test("main quota diagnostics survive opt-in JSON without copying upstream data", async () => { codexAccounts = [{ id: "__main__", isMain: true, quota: null, quotaRefresh: { status: "http_error", httpStatus: 503, message: RAW_SENTINEL } }]; @@ -907,7 +926,7 @@ describe("ocx account CLI (issue #180 matrix)", () => { expect(human.code).toBe(0); expect(requests.some(request => - request.path === "/api/codex-auth/accounts" && request.search === "?refresh=1" + request.path === "/api/codex-auth/accounts/refresh" && request.method === "POST" )).toBe(true); expect(human.stdout).toContain("weekly 42%"); expect(human.stdout).toContain("monthly 17%"); @@ -2106,6 +2125,24 @@ describe("ocx account CLI (issue #180 matrix)", () => { expect(result.stdout).toContain("#3 unsupported (unsupported_format)"); }); + test("quota-pending login reports registration and recovery instead of ready model guidance", async () => { + codexLoginStatus = { status: "done", validationPending: true }; + const sleepSpy = spyOn(Bun, "sleep").mockImplementation(async () => {}); + try { + const human = await run(["login", "openai"]); + expect(human.code).toBe(0); + expect(human.stdout).toContain("validation pending (routing disabled)"); + expect(human.stdout).toContain("ocx gui"); + expect(human.stdout).not.toContain("Logged in"); + expect(human.stdout).not.toContain("ocx models"); + const machine = await run(["login", "openai", "--json"]); + expect(JSON.parse(machine.stdout)).toMatchObject({ validationPending: true, recoveryCommand: "ocx gui" }); + expect(JSON.parse(machine.stdout)).not.toHaveProperty("modelSelection"); + } finally { + sleepSpy.mockRestore(); + } + }); + test("pending Codex login keeps success and prints generic recovery guidance", async () => { codexLoginStatus = { status: "done", diff --git a/tests/cli/cli-capabilities.test.ts b/tests/cli/cli-capabilities.test.ts index f8c7b26d04..9a3191804a 100644 --- a/tests/cli/cli-capabilities.test.ts +++ b/tests/cli/cli-capabilities.test.ts @@ -240,7 +240,6 @@ const UNDECLARED_ROUTES_2026_08_28: readonly string[] = [ "GET /api/oauth/status", "GET /api/provider-context-caps", "GET /api/provider-presets", - "GET /api/provider-quotas", "GET /api/providers", "GET /api/providers/keys", "GET /api/request-history", diff --git a/tests/codex-integration/codex-account-store.test.ts b/tests/codex-integration/codex-account-store.test.ts index d8a51f18f8..77a7750d14 100644 --- a/tests/codex-integration/codex-account-store.test.ts +++ b/tests/codex-integration/codex-account-store.test.ts @@ -60,6 +60,26 @@ describe("codex-account-store CRUD", () => { beforeEach(() => { installScratchHome(); }); afterEach(async () => { await removeScratchHome(); }); + test("pending validation survives credential refresh and cannot be cleared by a stale probe", async () => { + const store = await import("../../src/codex/account-store"); + const { codexCredentialMutationEpoch } = await import("../../src/codex/credential-mutation-epoch"); + const cred = { accessToken: "access-pending", refreshToken: "refresh-pending", expiresAt: Date.now() + 3600_000, chatgptAccountId: "acct-pending" }; + store.saveCodexAccountCredential("pending", cred, { validationPending: true }); + const generation = store.readCodexAccountRecord("pending")!.generation; + store.markCodexAccountValidated("pending"); + expect(store.readCodexAccountRecord("pending")?.codexValidationPending).toBe(true); + expect(store.saveCodexAccountCredentialIfGeneration("pending", generation, { ...cred, accessToken: "refreshed-access" })).toBe(true); + expect(store.readCodexAccountRecord("pending")?.codexValidationPending).toBe(true); + store.markCodexAccountValidated("pending", Date.now(), generation); + expect(store.readCodexAccountRecord("pending")?.codexValidationPending).toBe(true); + expect(store.readCodexAccountRecord("pending")?.lastCodexValidatedAt).toBeUndefined(); + const beforeValidation = codexCredentialMutationEpoch(); + store.markCodexAccountValidated("pending", Date.now(), generation + 1); + expect(codexCredentialMutationEpoch()).toBe(beforeValidation + 1); + expect(store.readCodexAccountRecord("pending")?.codexValidationPending).toBeUndefined(); + expect(store.readCodexAccountRecord("pending")?.lastCodexValidationStatus).toBe("ok"); + }); + test("save and load credential round-trip", async () => { const { saveCodexAccountCredential, getCodexAccountCredential } = await import("../../src/codex/account-store"); const cred = { accessToken: "tk_a", refreshToken: "rf_a", expiresAt: Date.now() + 3600_000, chatgptAccountId: "acc_a" }; diff --git a/tests/codex-integration/codex-auth-api.test.ts b/tests/codex-integration/codex-auth-api.test.ts index 3b704d6809..04cc6b9b88 100644 --- a/tests/codex-integration/codex-auth-api.test.ts +++ b/tests/codex-integration/codex-auth-api.test.ts @@ -28,6 +28,7 @@ import { saveCodexAccountCredential, } from "../../src/codex/account-store"; import * as accountStoreModule from "../../src/codex/account-store"; +import { isCodexAccountUsable } from "../../src/codex/account-usability"; import * as reserveAvailabilityModule from "../../src/codex/reserve-availability"; import { getMainAccountInfoCache, observeMainQuotaCredential } from "../../src/codex/main-account-cache"; import { openManualResetCreditOperation } from "../../src/codex/reset-credit-operation-ledger"; @@ -134,6 +135,7 @@ async function completeMockCodexOAuth(options: { oauthAccountId: string; email: string; onWarmup: () => void; + warmupResponse?: () => Response; usageResponse?: () => Response; convergeCodexCatalog?: () => Promise; }): Promise<{ @@ -145,6 +147,7 @@ async function completeMockCodexOAuth(options: { accountId?: string; needsReauth?: boolean; catalogRefreshPending?: boolean; + validationPending?: boolean; }; }> { const oauth = await import("../../src/oauth"); @@ -181,7 +184,7 @@ async function completeMockCodexOAuth(options: { } if (target === "https://chatgpt.com/backend-api/codex/responses") { options.onWarmup(); - return new Response('event: response.completed\ndata: {"type":"response.completed"}\n\n', { + return options.warmupResponse?.() ?? new Response('event: response.completed\ndata: {"type":"response.completed"}\n\n', { status: 200, headers: { "Content-Type": "text/event-stream" }, }); @@ -5067,6 +5070,343 @@ describe("codex-auth API", () => { } }); + test.each([ + { plan: "pro", primary: 0, secondary: 100 }, + { plan: "pro", primary: 100, secondary: 0 }, + { plan: "free", primary: 100, secondary: undefined }, + ])("OAuth stores an exhausted account without inference: %j", async ({ plan, primary, secondary }) => { + const accountId = "quota-pending"; + const config = makeConfig(); + setLiveStateStoreConfig(config); + let warmups = 0; + const added = await completeMockCodexOAuth({ + config, requestBody: { id: accountId }, oauthAccountId: "acct-quota-pending", email: "quota@example.test", + onWarmup: () => { warmups++; }, + warmupResponse: () => new Response("quota limited", { status: 429 }), + usageResponse: () => Response.json({ plan_type: plan, rate_limit: { + primary_window: { used_percent: primary, limit_window_seconds: plan === "free" ? 2592000 : 18000 }, + ...(secondary !== undefined ? { secondary_window: { used_percent: secondary, limit_window_seconds: 604800 } } : {}), + } }), + }); + expect(added.state.status).toBe("done"); + expect(warmups).toBe(0); + expect(config.codexAccounts?.map(account => account.id)).toContain(accountId); + expect(readCodexAccountRecord(accountId)?.codexValidationPending).toBe(true); + expect(added.state.validationPending).toBe(true); + const recoveredStatus = new Request(`http://localhost/api/codex-auth/login-status?flowId=forgotten&accountId=${accountId}`); + expect(await (await handleCodexAuthAPI(recoveredStatus, new URL(recoveredStatus.url), config))?.json()).toMatchObject({ + status: "done", validationPending: true, + }); + expect(readCodexAccountRecord(accountId)?.lastCodexValidatedAt).toBeUndefined(); + expect(isAccountNeedsReauth(accountId)).toBe(false); + expect(isCodexAccountUsable(config, accountId)).toBe(false); + // Neither a restart's lost runtime state nor an expired quota cache is evidence of recovery. + clearAccountQuota(); + clearCodexUpstreamHealth(); + expect(isCodexAccountUsable(loadConfig(), accountId)).toBe(false); + globalThis.fetch = (async () => new Response("unavailable", { status: 503 })) as typeof fetch; + const listed = await listCodexAuthAccounts(config, true); + expect(listed.find(account => account.id === accountId)).toMatchObject({ + needsReauth: false, health: { status: "warning", reason: "validation_pending" }, + }); + expect(isCodexAccountUsable(config, accountId)).toBe(false); + }); + + test("quota-pending registration validates only after a fresh recovered snapshot and completed inference", async () => { + const accountId = "quota-recovery"; + const config = makeConfig({ codexAccounts: [{ id: accountId, email: "quota@example.test", plan: "pro", isMain: false }] }); + saveConfig(config); + setLiveStateStoreConfig(config); + saveCodexAccountCredential(accountId, { + accessToken: "quota-access", refreshToken: "quota-refresh", expiresAt: Date.now() + 3600_000, chatgptAccountId: "acct-quota", + }, { validationPending: true }); + let used: number | undefined = 100; + let terminal = "response.incomplete"; + let warmups = 0; + let replaceDuringWarmup = false; + const refreshAccounts = async () => { + const req = new Request("http://localhost/api/codex-auth/accounts/refresh", { method: "POST" }); + const response = await handleCodexAuthAPI(req, new URL(req.url), config, undefined, "gui-session"); + expect(response?.status).toBe(200); + }; + const selectAccount = () => { + const req = new Request("http://localhost/api/codex-auth/active", { + method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ accountId }), + }); + return handleCodexAuthAPI(req, new URL(req.url), config); + }; + const pendingSelection = await selectAccount(); + expect(pendingSelection?.status).toBe(409); + expect(loadConfig().activeCodexAccountId).toBeUndefined(); + expect(loadConfig().activeCodexAccountPinned).toBeUndefined(); + globalThis.fetch = (async (input: RequestInfo | URL) => { + if (String(input).endsWith("/wham/usage")) return Response.json({ plan_type: "pro", rate_limit: { + ...(used !== undefined ? { secondary_window: { used_percent: used, limit_window_seconds: 604800 } } : {}), + } }); + if (String(input).endsWith("/codex/responses")) { + warmups++; + if (replaceDuringWarmup) { + replaceDuringWarmup = false; + saveCodexAccountCredential(accountId, { + ...getCodexAccountCredential(accountId)!, accessToken: "replacement-access", + }, { validationPending: true }); + } + return new Response(`data: ${JSON.stringify({ type: terminal })}\n\n`); + } + throw new Error("unexpected request"); + }) as typeof fetch; + await refreshAccounts(); + expect(warmups).toBe(0); + used = undefined; + await refreshAccounts(); + expect(warmups).toBe(0); + used = 0; + clearAccountQuota(); + await listCodexAuthAccounts(config, false); + expect(warmups).toBe(0); // Passive reads never spend inference. + await listCodexAuthAccounts(config, true); + expect(warmups).toBe(0); // Forced background reads are not manual validation. + const readReq = new Request("http://localhost/api/codex-auth/accounts?refresh=1"); + expect((await handleCodexAuthAPI(readReq, new URL(readReq.url), config))?.status).toBe(200); + expect(warmups).toBe(0); // A read-only management capability cannot validate either. + expect(isCodexAccountUsable(config, accountId)).toBe(false); + await refreshAccounts(); + expect(warmups).toBe(1); + expect(isCodexAccountUsable(config, accountId)).toBe(false); + terminal = "response.completed"; + replaceDuringWarmup = true; + await refreshAccounts(); + expect(warmups).toBe(2); + expect(isCodexAccountUsable(config, accountId)).toBe(false); + await refreshAccounts(); + expect(warmups).toBe(3); + expect(isCodexAccountUsable(config, accountId)).toBe(true); + expect(readCodexAccountRecord(accountId)?.lastCodexValidationStatus).toBe("ok"); + expect((await selectAccount())?.status).toBe(200); + expect(loadConfig().activeCodexAccountId).toBe(accountId); + await refreshAccounts(); + expect(warmups).toBe(3); + }); + + test.each([ + { status: 401, replace: false }, { status: 403, replace: false }, + { status: 429, replace: false }, { status: 500, replace: false }, + { status: 401, replace: true }, { status: 403, replace: true }, + ].flatMap(scenario => [false, true].map(restart => ({ ...scenario, restart }))))("deferred validation reports generation-current authentication failures: %j", async ({ status, replace, restart }) => { + const accountId = "validation-auth-error"; + const config = makeConfig({ codexAccounts: [{ id: accountId, plan: "pro", isMain: false }] }); + saveConfig(config); + setLiveStateStoreConfig(config); + const credential = { accessToken: "auth-error-access", refreshToken: "auth-error-refresh", + expiresAt: Date.now() + 3600_000, chatgptAccountId: "auth-error-account" }; + saveCodexAccountCredential(accountId, credential, { validationPending: true }); + let fail = true; + globalThis.fetch = (async (input: RequestInfo | URL) => { + if (String(input).endsWith("/wham/usage")) return Response.json({ plan_type: "pro", + rate_limit: { secondary_window: { used_percent: 0, limit_window_seconds: 604800 } } }); + if (String(input).endsWith("/codex/responses")) { + if (fail && replace) saveCodexAccountCredential(accountId, { ...credential, accessToken: "replacement" }, { validationPending: true }); + return fail ? new Response("private-validation-body", { status }) + : new Response('data: {"type":"response.completed"}\n\n'); + } + throw new Error("unexpected request"); + }) as typeof fetch; + const refresh = async () => { + const req = new Request("http://localhost/api/codex-auth/accounts/refresh", { method: "POST" }); + const response = await handleCodexAuthAPI(req, new URL(req.url), config, undefined, "gui-session"); + expect(response?.status).toBe(200); + return await response!.json(); + }; + const response = await refresh(); + expect(JSON.stringify(response)).not.toContain("private-validation-body"); + expect(readCodexAccountRecord(accountId)?.codexValidationPending).toBe(true); + expect(isCodexAccountUsable(config, accountId)).toBe(false); + // Exercise both in-process recovery and lost volatile state after restart. + // Durable failure guidance must survive; replacement credentials must not inherit it. + if (restart) clearAccountNeedsReauth(accountId); + const rows = await listCodexAuthAccounts(config, false); + const authFailed = !replace && (status === 401 || status === 403); + expect(rows.find(row => row.id === accountId)).toMatchObject({ + needsReauth: authFailed, + health: { status: authFailed ? "reauth_required" : "warning", reason: authFailed ? "refresh_failed" : "validation_pending" }, + }); + fail = false; + await refresh(); + expect(readCodexAccountRecord(accountId)?.codexValidationPending).toBeUndefined(); + expect(isAccountNeedsReauth(accountId)).toBe(false); + expect(isCodexAccountUsable(config, accountId)).toBe(true); + }); + + test.each([undefined, "admin-token", "local-read-capability"] as const)("non-browser refresh stays observational for principal %s", async principal => { + const accountId = "validation-consent"; + const config = makeConfig({ codexAccounts: [{ id: accountId, plan: "pro", isMain: false }] }); + saveConfig(config); + setLiveStateStoreConfig(config); + saveCodexAccountCredential(accountId, { accessToken: "consent-access", refreshToken: "consent-refresh", + expiresAt: Date.now() + 3600_000, chatgptAccountId: "consent-account" }, { validationPending: true }); + let warmups = 0; + globalThis.fetch = (async (input: RequestInfo | URL) => { + if (String(input).endsWith("/wham/usage")) return Response.json({ plan_type: "pro", + rate_limit: { secondary_window: { used_percent: 12, limit_window_seconds: 604800 } } }); + warmups++; + return new Response('data: {"type":"response.completed"}\n\n'); + }) as typeof fetch; + const req = new Request("http://localhost/api/codex-auth/accounts/refresh", { method: "POST", + headers: { "x-opencodex-gui-origin": "http://localhost", "x-opencodex-csrf-token": "forged" } }); + const response = await handleCodexAuthAPI(req, new URL(req.url), config, undefined, principal); + expect(response?.status).toBe(200); + expect((await response!.json()).accounts.find((row: { id: string }) => row.id === accountId).quota.weeklyPercent).toBe(12); + expect(warmups).toBe(0); + expect(readCodexAccountRecord(accountId)?.codexValidationPending).toBe(true); + expect(isCodexAccountUsable(config, accountId)).toBe(false); + }); + + test("explicit validation preserves an account's pause and selection state", async () => { + const accountId = "paused-validation"; + const config = makeConfig({ codexAccounts: [{ id: accountId, plan: "pro", isMain: false }], pausedCodexAccountIds: [accountId] }); + saveConfig(config); + setLiveStateStoreConfig(config); + saveCodexAccountCredential(accountId, { accessToken: "paused-access", refreshToken: "paused-refresh", + expiresAt: Date.now() + 3600_000, chatgptAccountId: "paused-account" }, { validationPending: true }); + let warmups = 0; + globalThis.fetch = (async (input: RequestInfo | URL) => { + if (String(input).endsWith("/wham/usage")) return Response.json({ plan_type: "pro", + rate_limit: { secondary_window: { used_percent: 0, limit_window_seconds: 604800 } } }); + if (String(input).endsWith("/codex/responses")) { + warmups++; + return new Response('data: {"type":"response.completed"}\n\n'); + } + throw new Error("unexpected request"); + }) as typeof fetch; + const req = new Request("http://localhost/api/codex-auth/accounts/refresh", { method: "POST" }); + expect((await handleCodexAuthAPI(req, new URL(req.url), config, undefined, "gui-session"))?.status).toBe(200); + expect(warmups).toBe(1); + expect(readCodexAccountRecord(accountId)?.codexValidationPending).toBeUndefined(); + expect(loadConfig().pausedCodexAccountIds).toEqual([accountId]); + expect(loadConfig().activeCodexAccountId).toBeUndefined(); + }); + + test("explicit refresh joining a passive quota read retains deferred validation intent", async () => { + const accountId = "quota-coalesced"; + const config = makeConfig({ codexAccounts: [{ id: accountId, email: "quota@example.test", plan: "pro", isMain: false }] }); + saveConfig(config); + setLiveStateStoreConfig(config); + saveCodexAccountCredential(accountId, { + accessToken: "quota-access", refreshToken: "quota-refresh", expiresAt: Date.now() + 3600_000, chatgptAccountId: "acct-quota", + }, { validationPending: true }); + let release!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + let usageCalls = 0; + let warmups = 0; + globalThis.fetch = (async (input: RequestInfo | URL) => { + if (String(input).endsWith("/wham/usage")) { + usageCalls++; + await gate; + return Response.json({ plan_type: "pro", rate_limit: { secondary_window: { used_percent: 0, limit_window_seconds: 604800 } } }); + } + if (String(input).endsWith("/codex/responses")) { + warmups++; + return new Response('data: {"type":"response.completed"}\n\n'); + } + throw new Error("unexpected request"); + }) as typeof fetch; + const passive = listCodexAuthAccounts(config, false); + for (let i = 0; i < 100 && usageCalls === 0; i++) await new Promise(resolve => setImmediate(resolve)); + expect(usageCalls).toBe(1); + const explicit = listCodexAuthAccounts(config, true, { validatePending: true }); + // Let the second list pass its main-account read and join the held pool flight. + for (let i = 0; i < 20; i++) await new Promise(resolve => setImmediate(resolve)); + release(); + const [, listed] = await Promise.all([passive, explicit]); + expect(usageCalls).toBe(1); + expect(warmups).toBe(1); + expect(listed.find(account => account.id === accountId)?.health.status).toBe("healthy"); + expect(isCodexAccountUsable(config, accountId)).toBe(true); + }); + + test.each([{ delay: 0, reads: 1 }, { delay: 3, reads: 2 }, { delay: 6, reads: 2 }])("validation joins before, during, and after quota settlement: %j", async ({ delay, reads }) => { + const { fetchPoolAccountQuota } = await import("../../src/codex/auth-api"); + const accountId = "late-validation-join"; + const config = makeConfig({ codexAccounts: [{ id: accountId, plan: "pro", isMain: false }] }); + saveConfig(config); + setLiveStateStoreConfig(config); + saveCodexAccountCredential(accountId, { accessToken: "late-access", refreshToken: "late-refresh", + expiresAt: Date.now() + 3600_000, chatgptAccountId: "late-account" }, { validationPending: true }); + let warmups = 0; + let usageReads = 0; + let scheduled = false; + let joined: Promise = Promise.resolve(); + globalThis.fetch = (async (input: RequestInfo | URL) => { + if (String(input).endsWith("/wham/usage")) { + usageReads++; + const response = Response.json({}); + response.json = async () => { + if (!scheduled) { + scheduled = true; + // Deterministic microtask ordering from JSON completion: join before + // the validation decision, after it but before flight cleanup, or + // after settlement. No timers or real network scheduling are involved. + let order = Promise.resolve(); + for (let i = 0; i < delay; i++) order = order.then(() => {}); + joined = order.then(() => Promise.all([ + fetchPoolAccountQuota(accountId, true, "pro", undefined, true), + fetchPoolAccountQuota(accountId, true, "pro", undefined, true), + ])); + } + return { plan_type: "pro", rate_limit: { secondary_window: { used_percent: 12, limit_window_seconds: 604800 } } }; + }; + return response; + } + if (String(input).endsWith("/codex/responses")) { + warmups++; + return new Response('data: {"type":"response.completed"}\n\n'); + } + throw new Error("unexpected request"); + }) as typeof fetch; + await fetchPoolAccountQuota(accountId, true, "pro"); + await joined; + expect(usageReads).toBe(reads); + expect(warmups).toBe(1); + expect(readCodexAccountRecord(accountId)?.codexValidationPending).toBeUndefined(); + }); + + test("quota-pending reauth replaces only the same identity and clears stale validation", async () => { + const accountId = "quota-reauth"; + const config = makeConfig({ codexAccounts: [{ id: accountId, email: "quota@example.test", plan: "pro", isMain: false }] }); + saveConfig(config); + setLiveStateStoreConfig(config); + saveCodexAccountCredential(accountId, { + accessToken: "old-access", refreshToken: "old-refresh", expiresAt: Date.now() + 3600_000, chatgptAccountId: "acct-quota", + }); + accountStoreModule.markCodexAccountValidated(accountId); + const options = { + config, requestBody: { id: accountId, reauth: true }, oauthAccountId: "other-account", email: "quota@example.test", + onWarmup: () => { throw new Error("exhausted accounts must not warm up"); }, + usageResponse: () => Response.json({ plan_type: "pro", rate_limit: { secondary_window: { used_percent: 100 } } }), + }; + const rejected = await completeMockCodexOAuth(options); + expect(rejected.state.status).toBe("error"); + expect(getCodexAccountCredential(accountId)?.accessToken).toBe("old-access"); + const added = await completeMockCodexOAuth({ ...options, oauthAccountId: "acct-quota" }); + expect(added.state.status).toBe("done"); + expect(readCodexAccountRecord(accountId)?.codexValidationPending).toBe(true); + expect(readCodexAccountRecord(accountId)?.lastCodexValidatedAt).toBeUndefined(); + expect(getCodexAccountCredential(accountId)?.accessToken).not.toBe("old-access"); + expect(isCodexAccountUsable(config, accountId)).toBe(false); + }); + + test.each([401, 403, 429, 503])("unknown quota retains the failed warmup gate (HTTP %s)", async status => { + const config = makeConfig(); + const result = await completeMockCodexOAuth({ + config, requestBody: { id: "quota-unknown" }, oauthAccountId: "acct-unknown", email: "unknown@example.test", + onWarmup: () => {}, warmupResponse: () => new Response("private error", { status }), + usageResponse: () => new Response("unknown", { status: 503 }), + }); + expect(result.state.status).toBe("error"); + expect(result.state.error).not.toContain("private error"); + expect(getCodexAccountCredential("quota-unknown")).toBeNull(); + }); + test("OAuth creation rejects a namespace claimed during warmup without persisting", async () => { const config = makeConfig(); const result = await completeMockCodexOAuth({ @@ -5522,6 +5862,27 @@ describe("codex-auth helpers", () => { clearAccountNeedsReauth(id); expect(isAccountNeedsReauth(id)).toBe(false); }); + + test("generation-scoped reauth recovery preserves replacement and account-wide evidence", () => { + const id = "reauth-generation-recovery"; + const credential = { accessToken: "old-access", refreshToken: "refresh", + expiresAt: Date.now() + 3600_000, chatgptAccountId: "account" }; + const oldGeneration = saveCodexAccountCredential(id, credential); + markAccountNeedsReauth(id, undefined, oldGeneration); + clearAccountNeedsReauth(id, oldGeneration); + expect(isAccountNeedsReauth(id)).toBe(false); + + const replacementGeneration = saveCodexAccountCredential(id, { ...credential, accessToken: "replacement" }); + markAccountNeedsReauth(id, undefined, replacementGeneration); + clearAccountNeedsReauth(id, oldGeneration); + expect(isAccountNeedsReauth(id)).toBe(true); + + markAccountNeedsReauth(id); + clearAccountNeedsReauth(id, replacementGeneration); + expect(isAccountNeedsReauth(id)).toBe(true); + clearAccountNeedsReauth(id); + expect(isAccountNeedsReauth(id)).toBe(false); + }); }); diff --git a/tests/codex-integration/codex-auth-context.test.ts b/tests/codex-integration/codex-auth-context.test.ts index ac09216229..2956c2135e 100644 --- a/tests/codex-integration/codex-auth-context.test.ts +++ b/tests/codex-integration/codex-auth-context.test.ts @@ -219,6 +219,22 @@ function expiredJwt(): string { return `header.${payload}.signature`; } describe("Codex auth context", () => { + test("pending-only pool fallback cannot materialize request authentication", async () => { + const cfg = config(); + cfg.activeCodexAccountId = "pool-a"; + const credential = { accessToken: "pending-access", refreshToken: "pending-refresh", expiresAt: Date.now() + 3600_000, chatgptAccountId: "pool_acc" }; + saveCodexAccountCredential("pool-a", credential, { validationPending: true }); + await expect(resolveCodexAuthContext(new Headers(), cfg, "pool", { + primeCodexPoolQuotas: async () => {}, + })).rejects.toThrow(CodexPoolAuthenticationError); + expect(isAccountNeedsReauth("pool-a")).toBe(false); + + // Also reject a context acquired before reauthentication replaced the record. + const ctx = { kind: "pool" as const, accountId: "pool-a", generation: 0, writerGeneration: 0, + accessToken: "old-access", chatgptAccountId: "pool_acc" }; + expect(() => materializeCodexUpstreamAuth(new Headers(), ctx)).toThrow("validation is pending"); + expect(() => applyCodexAuthContextToProvider(cfg.providers.chatgpt!, ctx, "pool")).toThrow("validation is pending"); + }); test("main-profile drain routes a non-main pool account without native reads or quota priming", async () => { saveCodexAccountCredential("pool-a", { accessToken: "pool_token", diff --git a/tests/codex-integration/codex-quota-auto-refresh.test.ts b/tests/codex-integration/codex-quota-auto-refresh.test.ts index e0d765dea3..605247db16 100644 --- a/tests/codex-integration/codex-quota-auto-refresh.test.ts +++ b/tests/codex-integration/codex-quota-auto-refresh.test.ts @@ -124,6 +124,41 @@ afterEach(() => { }); describe("Codex quota window auto refresh", () => { + test("pending validation suppresses scheduled inference and completion markers", async () => { + const cfg = config(); + saveCodexAccountCredential("pool-a", { + accessToken: "pending-access", refreshToken: "pending-refresh", expiresAt: NOW + 3600_000, chatgptAccountId: "pool-a", + }, { validationPending: true }); + let warmups = 0; + await runCodexQuotaAutoRefresh(cfg, NOW, { + getQuota: () => quota(), warmAccount: async () => { warmups++; }, persistCompleted: recordMarkers, + }); + expect(warmups).toBe(0); + expect(cfg.codexQuotaAutoRefresh?.["pool-a"]).toEqual({ fiveHour: true, weekly: true }); + }); + test("replacement pending validation during metadata refresh suppresses scheduled inference", async () => { + const cfg = config(); + writePoolCredential(); + let observed: StoredAccountQuota | null = null; + let warmups = 0; + let refreshes = 0; + await runCodexQuotaAutoRefresh(cfg, NOW, { + getQuota: () => observed, + refreshQuota: async () => { + refreshes++; + saveCodexAccountCredential("pool-a", { + accessToken: "pending-access", refreshToken: "pending-refresh", + expiresAt: NOW + 3600_000, chatgptAccountId: "pool-a", + }, { validationPending: true }); + observed = quota(); + }, + warmAccount: async () => { warmups++; }, + persistCompleted: recordMarkers, + }); + expect(refreshes).toBe(1); + expect(warmups).toBe(0); + expect(cfg.codexQuotaAutoRefresh?.["pool-a"]).toEqual({ fiveHour: true, weekly: true }); + }); test("regression: successive idle windows use completed response quota headers", async () => { const cfg = config(); cfg.codexQuotaAutoRefresh = { "pool-a": { fiveHour: true } }; diff --git a/tests/codex-integration/codex-quota-prime.test.ts b/tests/codex-integration/codex-quota-prime.test.ts index 94a605d5f2..aeb67e3370 100644 --- a/tests/codex-integration/codex-quota-prime.test.ts +++ b/tests/codex-integration/codex-quota-prime.test.ts @@ -125,6 +125,33 @@ describe("primeCodexPoolQuotas", () => { if (existsSync(TEST_DIR)) removeTreeWithRetry(TEST_DIR); }); + test("startup may observe pending account quota without inference or validation", async () => { + const config = makeConfig(); + seedPoolAccount(config, "pending", "pro"); + saveCodexAccountCredential("pending", readCodexAccountRecord("pending")!.credential!, { validationPending: true }); + const originalFetch = globalThis.fetch; + let usageReads = 0; + let modelRequests = 0; + try { + globalThis.fetch = async (input: RequestInfo | URL) => { + if (String(input).includes("/backend-api/wham/usage")) { + usageReads++; + return whamResponse(0); + } + modelRequests++; + throw new Error("startup must not send inference for pending validation"); + }; + await primeCodexPoolQuotas(config, "startup"); + expect(usageReads).toBe(1); + expect(getAccountQuota("pending")?.weeklyPercent).toBe(0); + expect(modelRequests).toBe(0); + expect(readCodexAccountRecord("pending")?.codexValidationPending).toBe(true); + expect(readCodexAccountRecord("pending")?.lastCodexValidatedAt).toBeUndefined(); + } finally { + globalThis.fetch = originalFetch; + } + }); + test("prime populates stale/unknown pool accounts", async () => { const config = makeConfig(); seedPoolAccount(config, "p1"); diff --git a/tests/codex-integration/token-guardian.test.ts b/tests/codex-integration/token-guardian.test.ts index b229872d8a..3e0ef632d8 100644 --- a/tests/codex-integration/token-guardian.test.ts +++ b/tests/codex-integration/token-guardian.test.ts @@ -159,6 +159,59 @@ describe("token guardian", () => { expect(mock.count()).toBeGreaterThan(0); }); + test.each(["response.completed", "response.failed"])("a late guardian probe cannot change a replacement pending credential (%s)", async terminal => { + writeConfig({ + tokenGuardian: { enabled: true, codexWarmupEnabled: true, tickSeconds: 60, leadSeconds: 60 }, + providers: { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", codexAccountMode: "pool", refreshPolicy: "proactive" } }, + }); + const credential = { + accessToken: "original", refreshToken: "rt-original", expiresAt: Date.now() + 3600_000, chatgptAccountId: "cg-pending", + }; + saveCodexAccountCredential("acct-late", credential); + globalThis.fetch = (async () => { + saveCodexAccountCredential("acct-late", { ...credential, accessToken: "replacement" }, { validationPending: true }); + return new Response(`data: ${JSON.stringify({ type: terminal })}\n\n`); + }) as typeof fetch; + await guardianSweep(Date.now()); + expect(readCodexAccountRecord("acct-late")?.codexValidationPending).toBe(true); + expect(readCodexAccountRecord("acct-late")?.lastCodexValidatedAt).toBeUndefined(); + expect(readCodexAccountRecord("acct-late")?.lastCodexValidationStatus).toBeUndefined(); + expect(readCodexAccountRecord("acct-late")?.lastCodexValidationError).toBeUndefined(); + }); + + test("guardian rechecks pending replacement after asynchronous token resolution", async () => { + const mock = mockWarmupFetch(); + writeConfig({ + tokenGuardian: { enabled: true, codexWarmupEnabled: true, tickSeconds: 60, leadSeconds: 60 }, + providers: { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", codexAccountMode: "pool", refreshPolicy: "proactive" } }, + }); + const credential = { + accessToken: "original", refreshToken: "rt-original", expiresAt: Date.now() + 3600_000, chatgptAccountId: "cg-pending", + }; + saveCodexAccountCredential("acct-resolving", credential); + const sweep = guardianSweep(Date.now()); + saveCodexAccountCredential("acct-resolving", { ...credential, accessToken: "replacement" }, { validationPending: true }); + const result = await sweep; + expect(mock.calls()).toBe(0); + expect(result.warmed).toEqual([]); + expect(readCodexAccountRecord("acct-resolving")?.codexValidationPending).toBe(true); + }); + + test("guardian preserves deferred registration without probing an exhausted account", async () => { + const mock = mockWarmupFetch(); + writeConfig({ + tokenGuardian: { enabled: true, codexWarmupEnabled: true, tickSeconds: 60, leadSeconds: 60 }, + providers: { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", codexAccountMode: "pool", refreshPolicy: "proactive" } }, + }); + saveCodexAccountCredential("acct-pending", { + accessToken: "pending", refreshToken: "rt-pending", expiresAt: Date.now() + 3600_000, chatgptAccountId: "cg-pending", + }, { validationPending: true }); + const res = await guardianSweep(Date.now()); + expect(res.warmed).toEqual([]); + expect(mock.calls()).toBe(0); + expect(readCodexAccountRecord("acct-pending")?.codexValidationPending).toBe(true); + }); + test("codex pool warmup is opt-in even when validation is stale", async () => { const mock = mockWarmupFetch(); writeConfig({ diff --git a/tests/gui/rate-limit-reset-credits.test.ts b/tests/gui/rate-limit-reset-credits.test.ts index 6078ea84a9..a65445e3cc 100644 --- a/tests/gui/rate-limit-reset-credits.test.ts +++ b/tests/gui/rate-limit-reset-credits.test.ts @@ -302,8 +302,8 @@ describe("rate-limit reset credits", () => { expect(source).toContain("className=\"card-badges\""); expect(source).toContain(" onOpenReset(a)} />"); // Next-session still renders BESIDE the ticket; health projection also suppresses - // it for projected reauth/cooldown (not only the legacy needsReauth flag). - expect(source).toContain("{isNext(a) && !showReauth && !inCooldown && ("); + // it for projected reauth/cooldown and pending validation. + expect(source).toContain("{isNext(a) && !showReauth && !inCooldown && !validationPending && ("); expect(source).toContain("{t(accountModeState === \"direct\" ? \"codexAuth.poolPrepared\" : \"codexAuth.nextSession\")}"); const styles = await Bun.file("gui/src/styles.css").text(); expect(styles).toContain(".card-badges { display: inline-flex; align-items: center; gap: 8px; flex-wrap: wrap; min-width: 0; }"); diff --git a/tests/oauth/oauth-health.test.ts b/tests/oauth/oauth-health.test.ts index a38343b108..8b3aa068b6 100644 --- a/tests/oauth/oauth-health.test.ts +++ b/tests/oauth/oauth-health.test.ts @@ -10,6 +10,7 @@ import { projectOAuthAccountHealth, projectCodexAccountHealth, } from "../../src/oauth/health"; +import { saveCodexAccountCredential } from "../../src/codex/account-store"; import { getAccountSet, markAccountNeedsReauth, saveCredential } from "../../src/oauth/store"; import { clearAccountNeedsReauth, @@ -59,6 +60,18 @@ afterEach(() => { }); describe("projectOAuthAccountHealth", () => { + test("pending Codex pool validation warns while reauthentication and native main keep their own health", () => { + saveCodexAccountCredential("pending-health", { + accessToken: "pending-access", refreshToken: "pending-refresh", expiresAt: Date.now() + 3600_000, + chatgptAccountId: "pending-health", + }, { validationPending: true }); + expect(projectCodexAccountHealth({ accountId: "pending-health", needsReauth: false })) + .toEqual({ status: "warning", reason: "validation_pending" }); + expect(projectCodexAccountHealth({ accountId: "pending-health", needsReauth: true })) + .toEqual({ status: "reauth_required", reason: "refresh_failed" }); + expect(projectCodexAccountHealth({ accountId: MAIN_CODEX_ACCOUNT_ID, needsReauth: false })) + .toEqual({ status: "healthy" }); + }); test("reauth beats cooldown", () => { expect(projectOAuthAccountHealth({ needsReauth: true, @@ -174,6 +187,18 @@ describe("projectCodexAccountHealth", () => { }); describe("collectOAuthHealthEntries", () => { + test("local Codex diagnostics expose pending validation with its recovery action", () => { + saveCodexAccountCredential("pending-local", { + accessToken: "pending-access", refreshToken: "pending-refresh", expiresAt: Date.now() + 3600_000, + chatgptAccountId: "pending-local", + }, { validationPending: true }); + expect(collectOAuthHealthEntries().find(entry => entry.provider === "codex" && entry.accountId === "pending-local")) + .toEqual({ + provider: "codex", accountId: "pending-local", + health: { status: "warning", reason: "validation_pending" }, + action: "wait for quota recovery, then click Refresh quotas in the dashboard Codex account pool to finish validation", + }); + }); test("projects needsReauth account with reauth action", async () => { await saveCredential("kimi", { access: "kimi-access", diff --git a/tests/server/server-management-auth.test.ts b/tests/server/server-management-auth.test.ts index 340ad70bd3..4b9995ab90 100644 --- a/tests/server/server-management-auth.test.ts +++ b/tests/server/server-management-auth.test.ts @@ -5,6 +5,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { getConfigPath, saveConfig } from "../../src/config"; import { startServer } from "../../src/server"; +import { readCodexAccountRecord, saveCodexAccountCredential } from "../../src/codex/account-store"; import type { OcxConfig } from "../../src/types"; import { serveGuiFile, serveSessionBootstrap } from "../../src/server/gui-static"; import { isProxyAdmissionSecret } from "../../src/server/auth-cors"; @@ -958,6 +959,67 @@ describe("management and data-plane credential separation", () => { } }); + test("deferred Codex validation requires a same-origin mutation session with CSRF", async () => { + const config = remoteConfig(); + config.hostname = "127.0.0.1"; + const accountId = "consent-wire"; + config.codexAccounts = [{ id: accountId, isMain: false, plan: "pro" }]; + saveConfig(config); + saveCodexAccountCredential(accountId, { accessToken: "consent-access", refreshToken: "consent-refresh", + expiresAt: Date.now() + 3600_000, chatgptAccountId: "consent-account" }, { validationPending: true }); + const originalFetch = globalThis.fetch; + let warmups = 0; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const target = input instanceof Request ? input.url : String(input); + if (target.endsWith("/wham/usage")) return Response.json({ plan_type: "pro", + rate_limit: { secondary_window: { used_percent: 12, limit_window_seconds: 604800 } } }); + if (target.endsWith("/codex/responses")) { + warmups++; + return new Response('data: {"type":"response.completed"}\n\n'); + } + return originalFetch(input, init); + }) as typeof fetch; + const server = startServer(0); + try { + const bootstrap = await fetch(new URL("/opencodex-session", server.url)); + const html = await bootstrap.text(); + const token = html.match(/name="opencodex-session-token" content="([^"]+)"/)?.[1]; + const csrf = html.match(/name="opencodex-session-csrf" content="([^"]+)"/)?.[1]; + expect(token).toBeDefined(); + expect(csrf).toBeDefined(); + const headers = { + Origin: server.url.origin, + "x-opencodex-api-key": token!, + "x-opencodex-gui-origin": server.url.origin, + }; + const url = new URL("/api/codex-auth/accounts/refresh", server.url); + const admin = await fetch(url, { method: "POST", headers: { + ...headers, "x-opencodex-api-key": "admin-secret", "x-opencodex-csrf-token": csrf!, + } }); + expect(admin.status).toBe(200); + expect(warmups).toBe(0); + expect(readCodexAccountRecord(accountId)?.codexValidationPending).toBe(true); + const missing = await fetch(url, { method: "POST", headers }); + expect(missing.status).toBe(401); + const crossOrigin = await fetch(url, { + method: "POST", + headers: { ...headers, Origin: "http://attacker.test", "x-opencodex-csrf-token": csrf! }, + }); + expect(crossOrigin.status).toBe(401); + const allowed = await fetch(url, { + method: "POST", + headers: { ...headers, "x-opencodex-csrf-token": csrf! }, + }); + expect(allowed.status).toBe(200); + expect(await allowed.json()).toHaveProperty("accounts"); + expect(warmups).toBe(1); + expect(readCodexAccountRecord(accountId)?.codexValidationPending).toBeUndefined(); + } finally { + await server.stop(true); + globalThis.fetch = originalFetch; + } + }); + test("session bootstrap escapes both browser and server origin attributes", async () => { const response = serveSessionBootstrap({ token: "ocx_session_safe",