From 87e7a4652d5be4d8cb0721b5073a1018b7a430d1 Mon Sep 17 00:00:00 2001 From: chrisae9 Date: Sun, 26 Jul 2026 00:04:02 -0400 Subject: [PATCH 1/2] feat(codex): add account namespace foundation --- .../docs/ja/reference/configuration.md | 10 + .../docs/ko/reference/configuration.md | 10 + .../content/docs/reference/configuration.md | 10 + .../docs/ru/reference/configuration.md | 11 + .../docs/zh-cn/reference/configuration.md | 9 + gui/src/components/use-add-provider-oauth.ts | 6 +- .../add-provider-oauth-url-leak.test.tsx | 54 ++- src/cli/account-auth.ts | 2 +- src/cli/provider.ts | 7 + src/codex/account-id.ts | 34 ++ src/codex/account-lifecycle.ts | 3 +- src/codex/account-namespace-match.ts | 63 +++ src/codex/account-namespaces.ts | 149 ++++++ src/codex/account-usability.ts | 7 +- src/codex/auth-api.ts | 107 ++++- src/codex/auth-collision.ts | 3 +- src/codex/main-account.ts | 10 +- src/codex/routing.ts | 14 +- src/config.ts | 258 ++++++++++- src/oauth/index.ts | 92 +++- src/oauth/login-cli.ts | 12 + src/providers/alibaba-region-migration.ts | 19 +- src/server/management/combo-routes.ts | 7 + src/server/management/oauth-account-routes.ts | 15 +- src/server/management/provider-routes.ts | 5 + src/types.ts | 6 + tests/alibaba-region-migration.test.ts | 52 +++ tests/alibaba-region-startup.test.ts | 28 ++ tests/cli-account.test.ts | 27 +- tests/cli-provider.test.ts | 32 ++ tests/codex-account-namespaces.test.ts | 349 ++++++++++++++ tests/codex-auth-api.test.ts | 405 +++++++++++++++- tests/combo-management-api.test.ts | 21 + tests/config-user-edits.test.ts | 37 ++ tests/config.test.ts | 183 ++++++++ tests/kiro-review-regressions.test.ts | 8 +- tests/management-provider-validation.test.ts | 38 ++ tests/oauth-public-surface.test.ts | 436 +++++++++++++++++- tests/server-auth.test.ts | 2 + 39 files changed, 2466 insertions(+), 75 deletions(-) create mode 100644 src/codex/account-id.ts create mode 100644 src/codex/account-namespace-match.ts create mode 100644 src/codex/account-namespaces.ts create mode 100644 tests/codex-account-namespaces.test.ts diff --git a/docs-site/src/content/docs/ja/reference/configuration.md b/docs-site/src/content/docs/ja/reference/configuration.md index 2cf4d114a6..10e6c10c9f 100644 --- a/docs-site/src/content/docs/ja/reference/configuration.md +++ b/docs-site/src/content/docs/ja/reference/configuration.md @@ -51,6 +51,7 @@ namespaced selected id を bare id に変えます。 | `codexShimAutoRestore?` | `boolean` | `true` | 完了した外部 Codex 更新で以前にインストールした shim が置換された場合に復元します。無効にするには `false`、またはプロセスで `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0` を設定します。 | | `syncResumeHistory?` | `boolean` | `true` | 戻せる Codex App 履歴互換モード。opencodex は元の Codex thread metadata をバックアップし、旧 OpenAI interactive row を `opencodex` に再マッピングし、opencodex が作成した `exec` row を App に見えるソースとして一時的に昇格します。`ocx stop` / `ocx restore` はバックアップした OpenAI row を復元し、残った opencodex user thread を OpenAI に戻し、ネイティブ Codex が `config.toml` からプロキシを削除した後でも開き続けられるようにします。オフにするには `false` に設定します。 | | `codexAccounts?` | `CodexAccount[]` | `[]` | Codex Auth ダッシュボードが管理する ChatGPT/Codex pool アカウント metadata。secret は `codex-accounts.json` に別途置きます。 | +| `codexAccountNamespaces?` | `Record` | — | 公開 model selector namespace から保存済み Codex アカウント target への任意 map。この foundation layer は map を検証・保存しますが、picker row の追加や routing の変更は行いません。 | | `activeCodexAccountId?` | `string` | — | 手動選択した pool アカウント。既存 thread affinity を消去して次のリクエストから適用し、処理中のリクエストは現在のアカウントを維持します。 | | `autoSwitchThreshold?` | `number` | `80` | 新しいセッション自動切替用の使用量百分率 threshold。既知の 5 時間、週次、30 日 quota window のうち最も高いスコアを使います。`0` なら quota 自動切替をオフにします。`quota` 戦略と `fill-first` の drain threshold にも使います。 | | `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Codex pool の新しいセッション rotation 戦略。**新しいセッションのみ**に適用され、既存 thread id は affinity を維持します。`quota`(既定)— アクティブアカウントが `autoSwitchThreshold` を超えたら既知 usage 最小を選択。`round-robin` — 適格アカウント間を smooth weighted で均等分散。`fill-first` — cooldown、使用不可、または(設定時)`autoSwitchThreshold` までアクティブアカウントを使い切り(未知 usage は強制切替しない)、安定ソート順で次へ。 | @@ -63,6 +64,15 @@ namespaced selected id を bare id に変えます。 | `tokenGuardian?` | `OcxTokenGuardianConfig` | off | 選択型の proactive OAuth 更新と Codex アカウント warmup ポリシー。フィールドは下で説明します。 | | `corsAllowOrigins?` | `string[]` | `[]` | CORS で追加で許可する正確な origin。loopback origin は常に許可します。 | +`codexAccountNamespaces` のキーは公開 selector です。長さは 1〜64 文字、先頭と末尾は ASCII +英数字、内部には英数字、`.`、`_`、`-` を使用でき、予約済み JavaScript object 名は拒否されます。 +値は有効な pool account id(内部 `__main__` は不可)、または Codex Desktop アカウントを示す +`"@main"` です。provider と予約済み `openai` / `combo` との衝突は大文字小文字を区別せず検査され、 +namespace 付き combo alias はその namespace prefix に selector を再利用できません。設定済み pool id +や他の selector target も selector と再利用できません。raw account id と email は +非公開のままにし、selector を公開名として使ってください。この foundation layer では map は inert で、 +model picker entry の作成、session の固定、Pool / Direct routing の変更は行いません。 + `maxConcurrentThreadsPerSession` は `config.json` キーではなく `PUT /api/v2` で使う camel-case フィールドです。`ocx v2 threads ` は対応する `max_concurrent_threads_per_session` 値を Codex の `$CODEX_HOME/config.toml` 内 `[features.multi_agent_v2]` に保存します。その table ができるように v2 を先にオンにしてください。 diff --git a/docs-site/src/content/docs/ko/reference/configuration.md b/docs-site/src/content/docs/ko/reference/configuration.md index f91e3d116d..dc55a2de94 100644 --- a/docs-site/src/content/docs/ko/reference/configuration.md +++ b/docs-site/src/content/docs/ko/reference/configuration.md @@ -52,6 +52,7 @@ namespaced selected id를 bare id로 바꿉니다. | `codexShimAutoRestore?` | `boolean` | `true` | 완료된 외부 Codex 업데이트가 이전에 설치한 shim을 교체하면 자동으로 복구합니다. 끄려면 `false`로 설정하거나 프로세스에 `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0`을 설정합니다. | | `syncResumeHistory?` | `boolean` | `true` | 되돌릴 수 있는 Codex App 기록 호환 모드. opencodex가 원래 Codex thread metadata를 백업하고, 예전 OpenAI interactive row를 `opencodex`로 재매핑하며, opencodex가 만든 `exec` row를 App에 보이는 source로 잠시 승격합니다. `ocx stop` / `ocx restore`는 백업한 OpenAI row를 복원하고 남은 opencodex user thread를 OpenAI로 돌려 네이티브 Codex가 `config.toml`에서 프록시를 제거한 뒤에도 이어서 열 수 있게 합니다. 끄려면 `false`로 설정합니다. | | `codexAccounts?` | `CodexAccount[]` | `[]` | Codex Auth 대시보드에서 관리하는 ChatGPT/Codex pool 계정 metadata. secret은 `codex-accounts.json`에 따로 둡니다. | +| `codexAccountNamespaces?` | `Record` | — | 공개 model selector namespace에서 저장된 Codex 계정 target으로 연결하는 선택적 map입니다. 이 foundation layer는 map을 검증하고 저장하지만 picker row를 추가하거나 routing을 변경하지 않습니다. | | `activeCodexAccountId?` | `string` | — | 수동으로 선택한 pool 계정. 선택 시 기존 thread affinity를 지우고 다음 요청부터 적용하며, 진행 중인 요청은 기존 계정을 유지합니다. | | `autoSwitchThreshold?` | `number` | `80` | 새 세션 자동 전환용 사용량 백분율 threshold. 알려진 5시간, 주간, 30일 quota window 중 가장 높은 점수를 씁니다. `0`이면 quota 자동 전환을 끕니다. `quota` 전략과 `fill-first` drain threshold에도 사용됩니다. | | `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Codex pool의 새 세션 rotation 전략. **새 세션에만** 적용되며 기존 thread id는 affinity를 유지합니다. `quota`(기본) — 활성 계정이 `autoSwitchThreshold`를 넘으면 알려진 usage가 가장 낮은 계정 선택. `round-robin` — 적격 계정 간 smooth weighted 균등 분배. `fill-first` — cooldown, 사용 불가 또는(설정 시) `autoSwitchThreshold`까지 활성 계정을 소진(알 수 없는 usage는 강제 전환하지 않음)한 뒤 안정 정렬 순으로 다음 계정. | @@ -64,6 +65,15 @@ namespaced selected id를 bare id로 바꿉니다. | `tokenGuardian?` | `OcxTokenGuardianConfig` | off | 선택형 proactive OAuth 갱신 및 Codex 계정 warmup 정책. 필드는 아래에 설명합니다. | | `corsAllowOrigins?` | `string[]` | `[]` | CORS에서 추가로 허용할 정확한 origin. loopback origin은 항상 허용합니다. | +`codexAccountNamespaces` 키는 공개 selector입니다. 길이는 1~64자이고 시작과 끝은 ASCII 영숫자여야 +하며, 내부에는 영숫자, `.`, `_`, `-`를 사용할 수 있습니다. 예약된 JavaScript object 이름은 거부됩니다. +값은 유효한 pool account id(내부 `__main__` 제외)이거나 Codex Desktop 계정을 나타내는 `"@main"`입니다. +provider 및 예약된 `openai` / `combo` 충돌은 대소문자를 구분하지 않고 검사하며, namespace가 있는 +combo alias는 selector를 namespace prefix로 재사용할 수 없습니다. 설정된 pool id와 다른 selector +target도 selector로 재사용할 수 없습니다. raw account id와 email은 비공개로 +유지하고 selector를 공개 이름으로 사용하세요. 이 foundation layer에서 map은 inert하며 model picker +entry 생성, session 고정, Pool / Direct routing 변경을 수행하지 않습니다. + `maxConcurrentThreadsPerSession`은 `config.json` 키가 아니라 `PUT /api/v2`에서 쓰는 camel-case 필드입니다. `ocx v2 threads `은 대응하는 `max_concurrent_threads_per_session` 값을 Codex의 `$CODEX_HOME/config.toml` 안 `[features.multi_agent_v2]`에 저장합니다. 해당 table이 생기도록 v2를 diff --git a/docs-site/src/content/docs/reference/configuration.md b/docs-site/src/content/docs/reference/configuration.md index 01e2aa6f36..4a4fb90b23 100644 --- a/docs-site/src/content/docs/reference/configuration.md +++ b/docs-site/src/content/docs/reference/configuration.md @@ -57,6 +57,7 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids. | `codexShimAutoRestore?` | `boolean` | `true` | Restore a previously installed Codex shim when a completed external Codex update replaces it. Set `false`, or set `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0` for a process-level opt-out. | | `syncResumeHistory?` | `boolean` | `true` | Reversible Codex App history compatibility mode. opencodex backs up original Codex thread metadata, remaps old OpenAI interactive rows to `opencodex`, and temporarily promotes opencodex-created `exec` rows to an app-visible source. `ocx stop` / `ocx restore` restore backed-up OpenAI rows and eject remaining opencodex user threads to OpenAI so native Codex can resume them after the proxy is removed from `config.toml`. Set `false` to opt out. | | `codexAccounts?` | `CodexAccount[]` | `[]` | ChatGPT/Codex pool account metadata managed by the Codex Auth dashboard. Secrets live separately in `codex-accounts.json`. | +| `codexAccountNamespaces?` | `Record` | — | Optional public model-selector namespace → stored Codex account target map. This foundation layer validates and persists the map but does not add picker rows or change routing. | | `activeCodexAccountId?` | `string` | — | Manually selected Pool account. Selection clears existing thread affinity and applies to the next request; in-flight requests keep their captured account. | | `autoSwitchThreshold?` | `number` | `80` | Usage percent threshold for new-session auto-switching. The score uses the hottest known 5h, weekly, or 30d quota window. Set `0` to disable quota auto-switching. Used by the `quota` strategy and as the drain threshold for `fill-first`. | | `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | New-session rotation strategy for the Codex pool. Applies to **new sessions only**; existing thread ids keep affinity. `quota` — today's default: pick the lowest known usage when the active account crosses `autoSwitchThreshold`. `round-robin` — even spread across eligible accounts via smooth weighted selection. `fill-first` — keep the active account until it cools down, becomes unusable, or crosses `autoSwitchThreshold` when set (unknown usage does not force a switch), then advance to the next eligible account in stable sorted order. | @@ -70,6 +71,15 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids. | `tokenGuardian?` | `OcxTokenGuardianConfig` | off | Optional proactive OAuth refresh and Codex-account warmup policy; fields are listed below. | | `corsAllowOrigins?` | `string[]` | `[]` | Additional exact origins allowed by CORS. Loopback origins are always allowed. | +`codexAccountNamespaces` keys are public selectors: 1–64 characters, starting and ending with an +ASCII letter or number, with letters, numbers, `.`, `_`, or `-` inside; reserved JavaScript object +names are rejected. Each value is either a valid pool-account id (never the internal `__main__`) or +`"@main"` for the Codex Desktop account. Provider and reserved `openai` / `combo` collisions are +checked case-insensitively; a namespaced combo alias cannot reuse a selector as its namespace prefix, +and configured pool ids or selector targets also cannot reuse a selector. Keep raw +account ids and emails private—the selector is the public name. In this foundation layer the map is +inert: it does not create model-picker entries, pin sessions, or alter Pool or Direct routing. + `maxConcurrentThreadsPerSession` is the camel-case field used by `PUT /api/v2`, not a `config.json` key. `ocx v2 threads ` persists the corresponding `max_concurrent_threads_per_session` value under `[features.multi_agent_v2]` in Codex's diff --git a/docs-site/src/content/docs/ru/reference/configuration.md b/docs-site/src/content/docs/ru/reference/configuration.md index c6b674e49a..97db567f8f 100644 --- a/docs-site/src/content/docs/ru/reference/configuration.md +++ b/docs-site/src/content/docs/ru/reference/configuration.md @@ -56,6 +56,7 @@ opencodex настраивается файлом `~/.opencodex/config.json`. Е | `codexShimAutoRestore?` | `boolean` | `true` | Восстанавливает ранее установленный shim после того, как завершённое внешнее обновление Codex заменило его. Для отключения задайте `false` или установите процессу `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0`. | | `syncResumeHistory?` | `boolean` | `true` | Обратимый режим совместимости истории Codex App. opencodex резервирует исходные метаданные потоков Codex, переназначает старые интерактивные строки OpenAI на `opencodex` и временно повышает созданные opencodex строки `exec` до видимого в приложении источника. `ocx stop` / `ocx restore` восстанавливают зарезервированные строки OpenAI и возвращают оставшиеся пользовательские потоки opencodex обратно к OpenAI, чтобы нативный Codex мог возобновлять их после удаления прокси из `config.toml`. Установите `false`, чтобы отказаться. | | `codexAccounts?` | `CodexAccount[]` | `[]` | Метаданные аккаунтов пула ChatGPT/Codex, управляемые дашбордом Codex Auth. Секреты хранятся отдельно в `codex-accounts.json`. | +| `codexAccountNamespaces?` | `Record` | — | Необязательная map публичного namespace селектора модели на сохранённую цель аккаунта Codex. Этот foundation layer проверяет и сохраняет map, но не добавляет строки picker и не меняет routing. | | `activeCodexAccountId?` | `string` | — | Вручную выбранный аккаунт пула. Выбор очищает существующие привязки потоков и действует со следующего запроса; выполняющиеся запросы сохраняют захваченный аккаунт. | | `autoSwitchThreshold?` | `number` | `80` | Порог процента использования для автопереключения новых сессий. Оценка использует самое «горячее» из известных окон квоты — 5-часовое, недельное или 30-дневное. Установите `0`, чтобы отключить автопереключение по квоте. Используется стратегией `quota` и как порог исчерпания для `fill-first`. | | `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Стратегия ротации новых сессий для пула Codex. Применяется **только к новым сессиям**; существующие id потоков сохраняют affinity. `quota` (по умолчанию) — выбор наименьшего известного usage, когда активный аккаунт превышает `autoSwitchThreshold`. `round-robin` — равномерное распределение между подходящими аккаунтами через smooth weighted selection. `fill-first` — использовать активный аккаунт до cooldown, недоступности или (если задано) `autoSwitchThreshold` (неизвестный usage не принуждает к переключению), затем переход к следующему подходящему аккаунту в стабильном отсортированном порядке. | @@ -68,6 +69,16 @@ opencodex настраивается файлом `~/.opencodex/config.json`. Е | `tokenGuardian?` | `OcxTokenGuardianConfig` | выкл. | Необязательная политика проактивного обновления OAuth и прогрева аккаунтов Codex; поля перечислены ниже. | | `corsAllowOrigins?` | `string[]` | `[]` | Дополнительные точные origin, разрешённые CORS. Loopback-origin разрешены всегда. | +Ключи `codexAccountNamespaces` — публичные селекторы длиной 1–64 символа. Они должны начинаться и +заканчиваться ASCII-буквой или цифрой; внутри разрешены буквы, цифры, `.`, `_` и `-`. Зарезервированные +имена объектов JavaScript запрещены. Значение — допустимый id аккаунта пула (кроме внутреннего `__main__`) +либо `"@main"` для аккаунта Codex Desktop. Коллизии с provider и зарезервированными `openai` / `combo` +проверяются без учёта регистра; namespace-префикс namespaced combo alias не может повторять селектор. +Настроенные id пула и цели других селекторов также нельзя повторно использовать как селектор. Сохраняйте +raw id аккаунтов и email приватными, а селектор используйте как публичное имя. +В этом foundation layer map инертна: она не создаёт записи model picker, не закрепляет сессии и не меняет +маршрутизацию Pool / Direct. + `maxConcurrentThreadsPerSession` — это camelCase-поле, используемое `PUT /api/v2`, а не ключ `config.json`. `ocx v2 threads ` сохраняет соответствующее значение `max_concurrent_threads_per_session` в `[features.multi_agent_v2]` в diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration.md b/docs-site/src/content/docs/zh-cn/reference/configuration.md index da12eab6de..a14bfe139c 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration.md @@ -50,6 +50,7 @@ no-replace 方式创建 `config.json.pre-openai-tiers-v2.bak`,并把已知旧 | `codexShimAutoRestore?` | `boolean` | `true` | 已完成的外部 Codex 更新替换此前安装的 shim 时自动恢复。若要关闭,请设为 `false`,或为进程设置 `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0`。 | | `syncResumeHistory?` | `boolean` | `true` | 可逆的 Codex App 历史兼容模式。opencodex 会备份原始 Codex thread metadata,把旧 OpenAI interactive row 重映射到 `opencodex`,并暂时把 opencodex 创建的 `exec` row 提升成 App 可见 source。`ocx stop` / `ocx restore` 会恢复已备份的 OpenAI row,并把剩余 opencodex user thread 转回 OpenAI,使原生 Codex 在从 `config.toml` 移除代理后仍能继续这些 thread。设为 `false` 可退出该模式。 | | `codexAccounts?` | `CodexAccount[]` | `[]` | Codex Auth 仪表盘管理的 ChatGPT/Codex pool account metadata。secret 单独存放在 `codex-accounts.json`。 | +| `codexAccountNamespaces?` | `Record` | — | 可选的公开 model selector namespace 到已保存 Codex account target 的映射。此 foundation layer 只验证并持久化该映射,不会添加 picker row 或改变 routing。 | | `activeCodexAccountId?` | `string` | — | 手动选择的 pool account。选择时清除已有 thread affinity,并从下一次请求开始生效;进行中的请求保留原账号。 | | `autoSwitchThreshold?` | `number` | `80` | 新 session 自动切换的 usage 百分比 threshold。分数取已知 5 小时、周或 30 天 quota window 中最高的一项。设为 `0` 可禁用 quota 自动切换。`quota` 策略和 `fill-first` 的耗尽 threshold 都会用到。 | | `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Codex pool 的新 session 轮换策略。仅适用于**新 session**;已有 thread id 保留 affinity。`quota`(默认):活跃账号超过 `autoSwitchThreshold` 时选已知 usage 最低者。`round-robin`:在合格账号间平滑加权均分。`fill-first`:持续使用活跃账号,直到 cooldown、不可用或(如已设置)超过 `autoSwitchThreshold`(未知 usage 不会强制切换),再按稳定排序进入下一个合格账号。 | @@ -62,6 +63,14 @@ no-replace 方式创建 `config.json.pre-openai-tiers-v2.bak`,并把已知旧 | `tokenGuardian?` | `OcxTokenGuardianConfig` | 关闭 | 可选的 proactive OAuth 刷新和 Codex account warmup 策略;字段见下文。 | | `corsAllowOrigins?` | `string[]` | `[]` | CORS 额外允许的精确 origin。loopback origin 始终允许。 | +`codexAccountNamespaces` 的 key 是公开 selector:长度为 1–64 个字符,首尾必须是 ASCII 字母或数字, +中间可使用字母、数字、`.`、`_` 或 `-`;保留的 JavaScript object 名称会被拒绝。value 必须是有效的 +pool account id(不能是内部 `__main__`),或用 `"@main"` 表示 Codex Desktop 账号。与 provider 及 +保留的 `openai` / `combo` 冲突时不区分大小写;带 namespace 的 combo alias 不能把 selector 复用为 +其 namespace prefix,已配置的 pool id 和其他 selector target 也不能复用为 selector。raw account id +与 email 应保持私密,selector 才是公开名称。在此 foundation layer 中, +该映射是 inert 的:不会创建 model picker entry、固定 session,也不会改变 Pool / Direct routing。 + `maxConcurrentThreadsPerSession` 是 `PUT /api/v2` 使用的 camel-case 字段,不是 `config.json` key。 `ocx v2 threads ` 会把对应的 `max_concurrent_threads_per_session` 值写入 Codex `$CODEX_HOME/config.toml` 的 `[features.multi_agent_v2]` 下;请先启用 v2,确保该 table 存在。 diff --git a/gui/src/components/use-add-provider-oauth.ts b/gui/src/components/use-add-provider-oauth.ts index 95e6b394a7..dfcceb4b32 100644 --- a/gui/src/components/use-add-provider-oauth.ts +++ b/gui/src/components/use-add-provider-oauth.ts @@ -2,6 +2,8 @@ import { useCallback } from "react"; import type { TFn } from "../i18n/shared"; import { readJsonIfOk } from "../fetch-json"; +export const OAUTH_LOGIN_POLL_INTERVAL_MS = 2_000; + export function useAddProviderOAuth({ apiBase, t, @@ -52,17 +54,17 @@ export function useAddProviderOAuth({ if (data.url) { setOauthUrl(data.url, providerId); setOauthMsg(t("modal.waitingLogin")); } else { setOauthMsg(data.instructions || t("modal.loggingIn")); } for (let i = 0; i < 100; i++) { - await new Promise(r => setTimeout(r, 2000)); + await new Promise(r => setTimeout(r, OAUTH_LOGIN_POLL_INTERVAL_MS)); if (!aliveRef.current) return; const sRes = await fetch(`${apiBase}/api/oauth/status?provider=${providerId}`).catch(() => null); const s = sRes ? await readJsonIfOk<{ loggedIn?: boolean; error?: string }>(sRes) : null; if (!aliveRef.current) return; - if (s?.loggedIn) { onAdded(providerId); return; } if (s?.error) { setOauthMsgTone("warn"); setOauthMsg(t("modal.loginError", { error: s.error })); return; } + if (s?.loggedIn) { onAdded(providerId); return; } } setOauthMsgTone("warn"); setOauthMsg(t("modal.loginTimeout")); diff --git a/gui/tests/add-provider-oauth-url-leak.test.tsx b/gui/tests/add-provider-oauth-url-leak.test.tsx index 9e0a04ecc5..8265f64071 100644 --- a/gui/tests/add-provider-oauth-url-leak.test.tsx +++ b/gui/tests/add-provider-oauth-url-leak.test.tsx @@ -1,9 +1,10 @@ -import { afterEach, beforeEach, expect, test } from "bun:test"; +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; import { Window } from "happy-dom"; import { act } from "react"; import type { Root } from "react-dom/client"; import { LanguageProvider } from "../src/i18n/provider"; import AddProviderModal from "../src/components/AddProviderModal"; +import { OAUTH_LOGIN_POLL_INTERVAL_MS } from "../src/components/use-add-provider-oauth"; /** * The add-provider OAuth pane renders the authorization URL so a user whose @@ -23,6 +24,7 @@ let host: HTMLElement; let root: Root | null = null; let originalFetch: typeof globalThis.fetch; let pendingLogins: Array<(url: string) => void> = []; +let oauthStatus: { loggedIn: boolean; error?: string } = { loggedIn: false }; const PRESETS = [ { id: "claude", label: "Claude", adapter: "anthropic", baseUrl: "https://api.anthropic.com", auth: "oauth", oauthProvider: "claude" }, @@ -43,6 +45,7 @@ beforeEach(() => { (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; pendingLogins = []; + oauthStatus = { loggedIn: false }; Object.defineProperty(globalThis, "fetch", { configurable: true, value: async (input: RequestInfo | URL, init?: RequestInit) => { @@ -56,7 +59,7 @@ beforeEach(() => { pendingLogins.push((authUrl: string) => resolve(Response.json({ url: authUrl }))); }); } - if (url.pathname === "/api/oauth/status") return Response.json({ loggedIn: false }); + if (url.pathname === "/api/oauth/status") return Response.json(oauthStatus); return Response.json({}); }, }); @@ -78,13 +81,13 @@ afterEach(async () => { await win.happyDOM?.close?.(); }); -async function mountModal() { +async function mountModal(onAdded: (name: string) => void = () => {}) { const { createRoot } = await import("react-dom/client"); await act(async () => { root = createRoot(host); root.render( - {}} onAdded={() => {}} /> + {}} onAdded={onAdded} /> , ); }); @@ -167,3 +170,46 @@ test("a late URL for an abandoned provider cannot overwrite the one already show expect(host.querySelector(".login-url-block-text")?.textContent).toBe(B_URL); expect(host.textContent).not.toContain(A_URL); }); + +test("a login error wins over a retained OAuth credential", async () => { + const added: string[] = []; + oauthStatus = { + loggedIn: true, + error: "The credential was saved, but the provider entry was not written.", + }; + const realSetTimeout = globalThis.setTimeout; + const timeoutSpy = spyOn(globalThis, "setTimeout").mockImplementation((( + callback: (...args: unknown[]) => void, + delay?: number, + ...args: unknown[] + ) => { + if (delay === OAUTH_LOGIN_POLL_INTERVAL_MS) { + queueMicrotask(() => callback(...args)); + return 0 as unknown as ReturnType; + } + return realSetTimeout(callback, delay, ...args); + }) as typeof setTimeout); + + try { + await mountModal(name => added.push(name)); + await act(async () => { + clickByText("Claude"); + await new Promise((r) => setTimeout(r, 20)); + }); + await act(async () => { + clickByText("Log in with Claude"); + await new Promise((r) => setTimeout(r, 20)); + }); + + expect(pendingLogins).toHaveLength(1); + await act(async () => { + pendingLogins.shift()!(A_URL); + await new Promise((r) => setTimeout(r, 40)); + }); + + expect(added).toEqual([]); + expect(host.textContent).toContain("provider entry was not written"); + } finally { + timeoutSpy.mockRestore(); + } +}); diff --git a/src/cli/account-auth.ts b/src/cli/account-auth.ts index ca29684353..79de3e67b0 100644 --- a/src/cli/account-auth.ts +++ b/src/cli/account-auth.ts @@ -137,11 +137,11 @@ async function login(argv: string[], deps: RuntimeApiDeps): Promise { for (let attempt = 0; attempt < 100; attempt++) { await Bun.sleep(2_000); const state = await runtimeRequest>(`/api/oauth/status?provider=${encodeURIComponent(provider)}`, {}, deps); + if (state.error) throw new CliUsageError(String(state.error)); if (state.loggedIn === true) { printData(state, wantsJson, [`Logged in to ${provider}.`]); return; } - if (state.error) throw new CliUsageError(String(state.error)); } throw new CliUsageError("login timed out"); } diff --git a/src/cli/provider.ts b/src/cli/provider.ts index dd218be950..410c80dd18 100644 --- a/src/cli/provider.ts +++ b/src/cli/provider.ts @@ -15,6 +15,7 @@ import { providerConfigSeed } from "../providers/derive"; import type { OcxProviderConfig } from "../types"; import { findLiveProxy } from "../server/proxy-liveness"; import { syncModelsToCodex } from "../codex/sync"; +import { codexAccountNamespaceProviderCollisionError } from "../codex/account-namespace-match"; // --------------------------------------------------------------------------- // Arg helpers @@ -153,6 +154,12 @@ async function handleAdd(args: string[]): Promise { const config = loadConfig(); + const namespaceCollision = codexAccountNamespaceProviderCollisionError(config.codexAccountNamespaces, name); + if (namespaceCollision) { + console.error(`Error: ${namespaceCollision}.`); + process.exit(1); + } + if (hasOwnProvider(config.providers, name) && !force) { console.error(`Provider "${name}" already exists. Use --force to overwrite.`); process.exit(1); diff --git a/src/codex/account-id.ts b/src/codex/account-id.ts new file mode 100644 index 0000000000..5753cd056f --- /dev/null +++ b/src/codex/account-id.ts @@ -0,0 +1,34 @@ +/** Canonical persisted Codex pool-account id format. */ +export const CODEX_ACCOUNT_ID_RE = /^[A-Za-z0-9._-]{1,64}$/; + +// Account credentials are persisted in JSON object maps. These names have +// special meaning on ordinary JavaScript objects and must never be admitted as +// user-controlled keys. +const RESERVED_CODEX_ACCOUNT_IDS = new Set(["__proto__", "prototype", "constructor"]); + +/** + * Stable internal id under which the Codex Desktop login participates in account rotation. + * It is reserved and must never be used as a public namespace target or pool-account id. + */ +export const MAIN_CODEX_ACCOUNT_ID = "__main__"; + +export function isValidCodexAccountId(accountId: unknown): accountId is string { + return typeof accountId === "string" + && accountId !== MAIN_CODEX_ACCOUNT_ID + && CODEX_ACCOUNT_ID_RE.test(accountId) + && !RESERVED_CODEX_ACCOUNT_IDS.has(accountId.toLowerCase()); +} + +type CodexAccountIdentityRow = { id: string; isMain: boolean }; + +/** Legacy invalid rows remain loadable for cleanup, but never participate in routing. */ +export function isSelectableCodexPoolAccount(account: CodexAccountIdentityRow): boolean { + return !account.isMain && isValidCodexAccountId(account.id); +} + +/** A pre-validation pool row that collides with the internal Desktop-account sentinel. */ +export function hasLegacyMainCodexPoolAccount( + accounts: readonly CodexAccountIdentityRow[] | undefined, +): boolean { + return accounts?.some(account => !account.isMain && account.id === MAIN_CODEX_ACCOUNT_ID) ?? false; +} diff --git a/src/codex/account-lifecycle.ts b/src/codex/account-lifecycle.ts index 9186ea85b2..b6078bf052 100644 --- a/src/codex/account-lifecycle.ts +++ b/src/codex/account-lifecycle.ts @@ -45,7 +45,8 @@ export function resetMainCodexAccountIdentityTrackingForTests(): void { export function deleteCodexAccount(runtimeConfig: OcxConfig, accountId: string): void { removeCodexAccountCredential(accountId); - runtimeConfig.codexAccounts = (runtimeConfig.codexAccounts ?? []).filter(account => account.id !== accountId); + runtimeConfig.codexAccounts = (runtimeConfig.codexAccounts ?? []) + .filter(account => account.isMain || account.id !== accountId); if (runtimeConfig.activeCodexAccountId === accountId) runtimeConfig.activeCodexAccountId = undefined; purgeCodexAccountRuntimeState(accountId); invalidateCodexWebSocketsForAccount(accountId); diff --git a/src/codex/account-namespace-match.ts b/src/codex/account-namespace-match.ts new file mode 100644 index 0000000000..af56555eea --- /dev/null +++ b/src/codex/account-namespace-match.ts @@ -0,0 +1,63 @@ +import { isValidCodexAccountId } from "./account-id"; + +/** Config-only sentinel for the Codex Desktop account; outside the pool-account id grammar. */ +export const MAIN_CODEX_ACCOUNT_NAMESPACE_TARGET = "@main"; + +export const CODEX_ACCOUNT_NAMESPACE_COMBO_ALIAS_COLLISION_ERROR = + "combo alias must not use a configured Codex account namespace"; + +const CODEX_ACCOUNT_ID_NAMESPACE_COLLISION_ERROR = + "account id must not collide with a configured Codex account namespace"; + +/** Provider ids are compared case-insensitively at namespace admission boundaries. */ +export function codexProviderNamespaceKey(value: string): string { + return value.toLowerCase(); +} + +export function hasCodexAccountNamespace( + namespaces: unknown, + namespace: string, +): boolean { + return !!namespaces + && typeof namespaces === "object" + && !Array.isArray(namespaces) + && Object.hasOwn(namespaces, namespace); +} + +export function codexAccountNamespaceProviderCollisionError( + namespaces: unknown, + providerName: string, +): string | undefined { + const normalizedProvider = codexProviderNamespaceKey(providerName); + const collides = !!namespaces + && typeof namespaces === "object" + && !Array.isArray(namespaces) + && Object.keys(namespaces) + .some(namespace => codexProviderNamespaceKey(namespace) === normalizedProvider); + return collides + ? "provider name must not collide with a configured Codex account namespace" + : undefined; +} + +export function codexAccountIdNamespaceCollisionError( + namespaces: unknown, + accountId: string, +): string | undefined { + return hasCodexAccountNamespace(namespaces, accountId) + ? CODEX_ACCOUNT_ID_NAMESPACE_COLLISION_ERROR + : undefined; +} + +export function codexAccountNamespaceForModel( + namespaces: unknown, + modelId: string, +): string | undefined { + const slash = modelId.indexOf("/"); + if (slash <= 0) return undefined; + const namespace = modelId.slice(0, slash); + return hasCodexAccountNamespace(namespaces, namespace) ? namespace : undefined; +} + +export function isValidCodexAccountNamespaceTarget(accountId: unknown): accountId is string { + return accountId === MAIN_CODEX_ACCOUNT_NAMESPACE_TARGET || isValidCodexAccountId(accountId); +} diff --git a/src/codex/account-namespaces.ts b/src/codex/account-namespaces.ts new file mode 100644 index 0000000000..6c41850b54 --- /dev/null +++ b/src/codex/account-namespaces.ts @@ -0,0 +1,149 @@ +import type { CodexAccount, OcxConfig } from "../types"; +import { COMBO_NAMESPACE } from "../combos/types"; +import { OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers"; +import { + CODEX_ACCOUNT_LOG_LABEL_RE, + createCodexAccountLogLabel, + fallbackCodexAccountLogLabel, +} from "./account-label"; +import { isValidCodexAccountId, MAIN_CODEX_ACCOUNT_ID } from "./account-id"; +import { + codexAccountIdNamespaceCollisionError, + codexProviderNamespaceKey, + MAIN_CODEX_ACCOUNT_NAMESPACE_TARGET, +} from "./account-namespace-match"; + +export { isValidCodexAccountNamespaceTarget } from "./account-namespace-match"; + +const RESERVED_NAMESPACE_KEYS = new Set([ + "__proto__", + "prototype", + "constructor", + COMBO_NAMESPACE, + OPENAI_CODEX_PROVIDER_ID, +].map(codexProviderNamespaceKey)); +const PUBLIC_ACCOUNT_SELECTOR_MAX_ATTEMPTS = 16; + +function comboAliasNamespaces(config: Pick): string[] { + return Object.values(config.combos ?? {}).flatMap((combo) => { + const alias = typeof combo?.alias === "string" ? combo.alias.trim() : ""; + const slash = alias.indexOf("/"); + return slash > 0 ? [alias.slice(0, slash)] : []; + }); +} + +function privateAccountSelectorCandidates(accountIds: Iterable): Set { + const candidates = new Set(); + for (const accountId of accountIds) { + candidates.add(accountId); + candidates.add(fallbackCodexAccountLogLabel(accountId)); + } + return candidates; +} + +function defaultPublicAccountSelector( + account: Pick, + allPrivateCandidates: ReadonlySet, +): string { + const privateCandidates = new Set(allPrivateCandidates); + privateCandidates.add(account.id); + privateCandidates.add(fallbackCodexAccountLogLabel(account.id)); + if (CODEX_ACCOUNT_LOG_LABEL_RE.test(account.logLabel ?? "") + && !privateCandidates.has(account.logLabel!)) return account.logLabel!; + + // Legacy or hand-edited rows may predate persisted random log labels. Allocate a fresh public + // selector rather than exposing the stable, id-derived fallback used only to redact old logs. + for (let attempt = 0; attempt < PUBLIC_ACCOUNT_SELECTOR_MAX_ATTEMPTS; attempt += 1) { + const selector = createCodexAccountLogLabel(privateCandidates); + if (!privateCandidates.has(selector)) return selector; + } + throw new Error("Unable to allocate a unique Codex account selector"); +} + +function claimNamespace(requested: string, used: Set): string { + let namespace = requested; + let suffix = 2; + while (used.has(namespace)) namespace = `${requested}-${suffix++}`; + used.add(namespace); + return namespace; +} + +function occupiedNamespaces(config: Pick): Set { + return new Set([ + ...Object.keys(config.providers).map(codexProviderNamespaceKey), + ...comboAliasNamespaces(config), + ...RESERVED_NAMESPACE_KEYS, + ]); +} + +/** Build an initial account-selector map without deriving public selectors from aliases or ids. */ +export function defaultCodexAccountNamespaces( + config: Pick, +): Record { + const namespaces: Record = {}; + const used = occupiedNamespaces(config); + const accounts = config.codexAccounts ?? []; + const privateCandidates = privateAccountSelectorCandidates( + accounts + .filter(account => account.id !== MAIN_CODEX_ACCOUNT_ID) + .map(account => account.id), + ); + for (const candidate of privateCandidates) used.add(candidate); + + namespaces[claimNamespace("main", used)] = MAIN_CODEX_ACCOUNT_NAMESPACE_TARGET; + for (const account of accounts) { + if (account.isMain || !isValidCodexAccountId(account.id)) continue; + const namespace = claimNamespace(defaultPublicAccountSelector(account, privateCandidates), used); + namespaces[namespace] = account.id; + } + return namespaces; +} + +/** + * Add one account to a generated map without renaming or replacing explicit existing entries. + * The account-creation layer must reject a new id that already equals an existing selector key. + * A true result means the map was mutated in place; callers must persist the updated config. + */ +export function appendDefaultCodexAccountNamespace( + config: Pick, + account: Pick, +): boolean { + const namespaces = config.codexAccountNamespaces; + if (account.isMain + || !isValidCodexAccountId(account.id) + || !namespaces + || Object.keys(namespaces).length === 0 + || codexAccountIdNamespaceCollisionError(namespaces, account.id) + || Object.values(namespaces).includes(account.id)) return false; + + const used = occupiedNamespaces(config); + for (const namespace of Object.keys(namespaces)) used.add(namespace); + const privateCandidates = privateAccountSelectorCandidates([ + ...(config.codexAccounts ?? []) + .filter(existing => existing.id !== MAIN_CODEX_ACCOUNT_ID) + .map(existing => existing.id), + ...Object.values(namespaces), + account.id, + ]); + for (const candidate of privateCandidates) used.add(candidate); + const namespace = claimNamespace(defaultPublicAccountSelector(account, privateCandidates), used); + namespaces[namespace] = account.id; + return true; +} + +export function isMainCodexAccountTarget(accountId: string): boolean { + return accountId === MAIN_CODEX_ACCOUNT_NAMESPACE_TARGET || accountId === MAIN_CODEX_ACCOUNT_ID; +} + +function normalizeCodexAccountNamespaceTarget(accountId: string): string { + return isMainCodexAccountTarget(accountId) + ? MAIN_CODEX_ACCOUNT_ID + : accountId; +} + +export function codexAccountNamespaceEntries( + config: Pick, +): Array<[string, string]> { + return Object.entries(config.codexAccountNamespaces ?? {}) + .map(([namespace, accountId]) => [namespace, normalizeCodexAccountNamespaceTarget(accountId)]); +} diff --git a/src/codex/account-usability.ts b/src/codex/account-usability.ts index 9d34d465b0..66141c86c0 100644 --- a/src/codex/account-usability.ts +++ b/src/codex/account-usability.ts @@ -1,14 +1,19 @@ import { getCodexAccountCredential } from "./account-store"; import { isAccountNeedsReauth } from "./account-runtime-state"; import { MAIN_CODEX_ACCOUNT_ID, isMainAccountTokenLive } from "./main-account"; +import { hasLegacyMainCodexPoolAccount, isSelectableCodexPoolAccount } from "./account-id"; import type { OcxConfig } from "../types"; export function isCodexAccountUsable(config: OcxConfig, accountId: string): boolean { if (accountId === MAIN_CODEX_ACCOUNT_ID) { + // A legacy pool row with the sentinel makes an active `__main__` ambiguous. + // Fail closed until the authenticated compatibility-delete path removes it. + if (hasLegacyMainCodexPoolAccount(config.codexAccounts)) return false; // Main account: credential is the read-only ~/.codex/auth.json token (Option A). return isMainAccountTokenLive() && !isAccountNeedsReauth(accountId); } - const exists = (config.codexAccounts ?? []).some(account => !account.isMain && account.id === accountId); + const exists = (config.codexAccounts ?? []) + .some(account => isSelectableCodexPoolAccount(account) && account.id === accountId); if (!exists) return false; if (isAccountNeedsReauth(accountId)) return false; return !!getCodexAccountCredential(accountId); diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts index 73da7ec1f4..540f24f550 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -61,6 +61,13 @@ import { type OAuthAccountHealth, type OAuthHealthLabel, } from "../oauth/health"; +import { + CODEX_ACCOUNT_ID_RE, + hasLegacyMainCodexPoolAccount, + isSelectableCodexPoolAccount, + isValidCodexAccountId, +} from "./account-id"; +import { codexAccountIdNamespaceCollisionError } from "./account-namespace-match"; function jsonResponse(data: unknown, status = 200): Response { return new Response(JSON.stringify(data), { @@ -69,14 +76,32 @@ function jsonResponse(data: unknown, status = 200): Response { }); } -const ACCOUNT_ID_RE = /^[a-zA-Z0-9._-]{1,64}$/; const MANUAL_IMPORT_ENV = "OPENCODEX_ENABLE_UNVERIFIED_CODEX_IMPORT"; const codexAuthLoginState = new Map(); function configuredPoolAccount(config: OcxConfig, accountId: string): CodexAccount | null { - if (!ACCOUNT_ID_RE.test(accountId)) return null; - return (config.codexAccounts ?? []).find(account => account.id === accountId && !account.isMain) ?? null; + if (!isValidCodexAccountId(accountId)) return null; + return (config.codexAccounts ?? []) + .find(account => account.id === accountId && isSelectableCodexPoolAccount(account)) ?? null; +} + +function codexAccountPersistenceConflict( + config: OcxConfig, + accountId: string, + mode: "create" | "reauth", +): string | undefined { + if (mode === "reauth") { + return configuredPoolAccount(config, accountId) + ? undefined + : "Pool account was removed while login was in progress. Add it again as a new account."; + } + const namespaceCollision = codexAccountIdNamespaceCollisionError(config.codexAccountNamespaces, accountId); + if (namespaceCollision) return namespaceCollision; + return (config.codexAccounts ?? []).some(account => account.id === accountId) + || Boolean(getCodexAccountCredential(accountId)) + ? `Account id already exists: ${accountId}` + : undefined; } function isThirtyDayOnlyPlan(plan: string | null | undefined): boolean { @@ -127,11 +152,14 @@ async function resolveResetCreditAuth( | { ok: false; response: Response } > { if (accountId === MAIN_CODEX_ACCOUNT_ID) { + if (hasLegacyMainCodexPoolAccount(runtimeConfig.codexAccounts)) { + return { ok: false, response: jsonResponse({ error: "Remove the legacy __main__ pool row before using the Desktop account" }, 409) }; + } const tokens = readCodexTokens(); if (!tokens) return { ok: false, response: jsonResponse({ error: "Main Codex account not logged in" }, 401) }; return { ok: true, isMain: true, accessToken: tokens.access_token, chatgptAccountId: tokens.account_id }; } - if (!ACCOUNT_ID_RE.test(accountId)) { + if (!isValidCodexAccountId(accountId)) { return { ok: false, response: jsonResponse({ error: "Invalid account id format" }, 400) }; } if (!configuredPoolAccount(runtimeConfig, accountId)) { @@ -447,7 +475,7 @@ export async function primeCodexPoolQuotas(config: OcxConfig, reason: string): P reconcileMainCodexAccountRuntimeState(); primeInFlight = (async () => { const runtimeConfig = getRuntimeConfig(config); - const pool = (runtimeConfig.codexAccounts ?? []).filter(a => !a.isMain); + const pool = (runtimeConfig.codexAccounts ?? []).filter(isSelectableCodexPoolAccount); const stale = pool.filter(a => { const q = getAccountQuota(a.id); return !q || Date.now() - q.updatedAt >= POOL_CACHE_TTL; @@ -479,7 +507,7 @@ export function clearCodexQuotaPrimeState(): void { export async function listCodexAuthAccounts(config: OcxConfig, forceRefresh = false): Promise { const runtimeConfig = getRuntimeConfig(config); - const poolAccounts = (runtimeConfig.codexAccounts ?? []).filter(a => !a.isMain); + const poolAccounts = (runtimeConfig.codexAccounts ?? []).filter(isSelectableCodexPoolAccount); const mainInfo = await fetchMainAccountInfo(forceRefresh); const withQuota = await mapWithConcurrency(poolAccounts, POOL_QUOTA_REFRESH_CONCURRENCY, async a => { const cred = getCodexAccountCredential(a.id); @@ -526,17 +554,15 @@ export async function handleCodexAuthAPI( if (!body.id || !body.email || !body.accessToken || !body.refreshToken || !body.chatgptAccountId) { return jsonResponse({ error: "Missing required fields" }, 400); } - if (!ACCOUNT_ID_RE.test(body.id)) { + if (!isValidCodexAccountId(body.id)) { return jsonResponse({ error: "Invalid account id format" }, 400); } if (body.accessToken.length > 10_000 || body.refreshToken.length > 10_000) { return jsonResponse({ error: "Input too large" }, 400); } const runtimeConfig = getRuntimeConfig(config); - const accounts = runtimeConfig.codexAccounts ?? []; - if (accounts.some(a => a.id === body.id) || getCodexAccountCredential(body.id)) { - return jsonResponse({ error: `Account id already exists: ${body.id}` }, 400); - } + const preflightConflict = codexAccountPersistenceConflict(runtimeConfig, body.id, "create"); + if (preflightConflict) return jsonResponse({ error: preflightConflict }, 400); // 1.1: Duplicate check is scoped by personal vs workspace plan bucket. const derivedAccountId = extractAccountId(undefined, body.accessToken) ?? body.chatgptAccountId; const collision = checkAccountIdCollision(derivedAccountId, body.email, body.plan); @@ -548,6 +574,9 @@ export async function handleCodexAuthAPI( const exp = typeof payload?.exp === "number" ? payload.exp * 1000 : Date.now() + 3600_000; const warmup = await verifyCodexAccountWarmup(body.id, body.accessToken, derivedAccountId); if (!warmup.ok) return warmup.response; + const latestConfig = getRuntimeConfig(config); + const commitConflict = codexAccountPersistenceConflict(latestConfig, body.id, "create"); + if (commitConflict) return jsonResponse({ error: commitConflict }, 400); saveCodexAccountCredential(body.id, { accessToken: body.accessToken, refreshToken: body.refreshToken, @@ -556,9 +585,10 @@ export async function handleCodexAuthAPI( }); markCodexAccountValidated(body.id, warmup.validatedAt); clearAccountNeedsReauth(body.id); + const accounts = latestConfig.codexAccounts ?? []; accounts.push(withCodexAccountLogLabel({ id: body.id, email: body.email, plan: body.plan, isMain: false }, accounts)); - runtimeConfig.codexAccounts = accounts; - saveRuntimeConfig(config, runtimeConfig); + latestConfig.codexAccounts = accounts; + saveRuntimeConfig(config, latestConfig); return jsonResponse({ ok: true }); } @@ -566,6 +596,11 @@ export async function handleCodexAuthAPI( const id = url.searchParams.get("id"); if (!id) return jsonResponse({ error: "Missing id" }, 400); const runtimeConfig = getRuntimeConfig(config); + const isLegacyPoolAccount = CODEX_ACCOUNT_ID_RE.test(id) + && (runtimeConfig.codexAccounts ?? []).some(account => !account.isMain && account.id === id); + if (!isValidCodexAccountId(id) && !isLegacyPoolAccount) { + return jsonResponse({ error: "Invalid account id format" }, 400); + } deleteCodexAccount(runtimeConfig, id); saveRuntimeConfig(config, runtimeConfig); return jsonResponse({ ok: true }); @@ -575,8 +610,8 @@ export async function handleCodexAuthAPI( const body = await req.json().catch(() => ({})) as { id?: unknown; alias?: unknown }; const id = typeof body.id === "string" ? body.id.trim() : ""; const alias = typeof body.alias === "string" ? body.alias.trim() : ""; - if (!id || !ACCOUNT_ID_RE.test(id)) return jsonResponse({ error: "Invalid account id format" }, 400); if (id === MAIN_CODEX_ACCOUNT_ID) return jsonResponse({ error: "Main Codex account alias is not configurable" }, 400); + if (!isValidCodexAccountId(id)) return jsonResponse({ error: "Invalid account id format" }, 400); if (typeof body.alias !== "string" || alias.length > 80 || /[\x00-\x1f\x7f]/.test(alias)) { return jsonResponse({ error: "Alias must be a string of at most 80 printable characters" }, 400); } @@ -600,7 +635,9 @@ export async function handleCodexAuthAPI( if (url.pathname === "/api/codex-auth/accounts/clear-cooldown" && req.method === "POST") { const body = await req.json().catch(() => ({})) as { id?: unknown }; const id = typeof body.id === "string" ? body.id.trim() : ""; - if (!id || !ACCOUNT_ID_RE.test(id)) return jsonResponse({ error: "Invalid account id format" }, 400); + if (id !== MAIN_CODEX_ACCOUNT_ID && !isValidCodexAccountId(id)) { + return jsonResponse({ error: "Invalid account id format" }, 400); + } return jsonResponse({ ok: true, id, cleared: clearCodexAccountCooldown(id) }); } @@ -608,8 +645,13 @@ export async function handleCodexAuthAPI( let body: { accountId: string | null }; try { body = (await req.json()) as typeof body; } catch { return jsonResponse({ error: "Invalid JSON" }, 400); } const runtimeConfig = getRuntimeConfig(config); + if (body.accountId === MAIN_CODEX_ACCOUNT_ID && hasLegacyMainCodexPoolAccount(runtimeConfig.codexAccounts)) { + return jsonResponse({ error: "Remove the legacy __main__ pool row before selecting the Desktop account" }, 409); + } if (body.accountId != null && body.accountId !== MAIN_CODEX_ACCOUNT_ID) { - const exists = (runtimeConfig.codexAccounts ?? []).some(a => a.id === body.accountId); + if (!isValidCodexAccountId(body.accountId)) return jsonResponse({ error: "Invalid account id format" }, 400); + const exists = (runtimeConfig.codexAccounts ?? []) + .some(account => isSelectableCodexPoolAccount(account) && account.id === body.accountId); if (!exists) return jsonResponse({ error: "Account not found" }, 400); } runtimeConfig.activeCodexAccountId = body.accountId ?? undefined; @@ -782,15 +824,15 @@ export async function handleCodexAuthAPI( const body = (await req.json().catch(() => ({}))) as { id?: string; reauth?: boolean }; const requestedAccountId = body.id?.trim(); const reauth = body.reauth === true; - if (requestedAccountId && !ACCOUNT_ID_RE.test(requestedAccountId)) { + if (requestedAccountId && !isValidCodexAccountId(requestedAccountId)) { return jsonResponse({ error: "Invalid account id format" }, 400); } const accountId = requestedAccountId || `chatgpt-${Date.now()}`; const runtimeConfig = getRuntimeConfig(config); - const exists = (runtimeConfig.codexAccounts ?? []).some(a => a.id === accountId) || Boolean(getCodexAccountCredential(accountId)); - if (exists && !reauth) { - return jsonResponse({ error: `Account id already exists: ${accountId}` }, 400); - } + const preflightConflict = !reauth + ? codexAccountPersistenceConflict(runtimeConfig, accountId, "create") + : undefined; + if (preflightConflict) return jsonResponse({ error: preflightConflict }, 400); if (reauth) { if (!requestedAccountId) return jsonResponse({ error: "id required for reauth" }, 400); if (!configuredPoolAccount(runtimeConfig, accountId)) { @@ -908,6 +950,24 @@ export async function handleCodexAuthAPI( break; } + const latestConfig = getRuntimeConfig(config); + const accounts = latestConfig.codexAccounts ?? []; + const existingIdx = accounts.findIndex(account => account.id === accountId); + const commitConflict = codexAccountPersistenceConflict( + latestConfig, + accountId, + reauth ? "reauth" : "create", + ); + if (commitConflict) { + codexAuthLoginState.set(flowId, { + status: "error", + error: commitConflict, + doneAt: Date.now(), + }); + completed = true; + break; + } + saveCodexAccountCredential(accountId, { accessToken: cred.access, refreshToken: cred.refresh, @@ -920,15 +980,12 @@ export async function handleCodexAuthAPI( setAccountQuotaFromParsed(accountId, quota); } - const latestConfig = getRuntimeConfig(config); - const accounts = latestConfig.codexAccounts ?? []; - const existingIdx = accounts.findIndex(a => a.id === accountId); if (existingIdx >= 0) { // Keep the pool id stable; refresh display metadata after a successful login/reauth. accounts[existingIdx] = withCodexAccountLogLabel({ ...accounts[existingIdx], email, - plan, + plan: plan ?? accounts[existingIdx].plan, isMain: false, }, accounts); latestConfig.codexAccounts = accounts; diff --git a/src/codex/auth-collision.ts b/src/codex/auth-collision.ts index e737dc0d92..a7d10e7c97 100644 --- a/src/codex/auth-collision.ts +++ b/src/codex/auth-collision.ts @@ -4,6 +4,7 @@ import { getCodexAccountCredential } from "./account-store"; import { loadConfig } from "../config"; import { resolveCodexHomeDir } from "./home"; import { extractAccountId } from "../oauth/chatgpt"; +import { isSelectableCodexPoolAccount } from "./account-id"; export interface CodexTokens { access_token: string; @@ -94,7 +95,7 @@ export function checkAccountIdCollision( const candidateWorkspace = isWorkspacePlan(plan); for (const account of loadConfig().codexAccounts ?? []) { if (excludeAccountId && account.id === excludeAccountId) continue; - if (account.isMain) continue; + if (!isSelectableCodexPoolAccount(account)) continue; if (isWorkspacePlan(account.plan) !== candidateWorkspace) continue; const cred = getCodexAccountCredential(account.id); const poolEmail = normalizedEmail(account.email); diff --git a/src/codex/main-account.ts b/src/codex/main-account.ts index 6e98e9a765..495ad39300 100644 --- a/src/codex/main-account.ts +++ b/src/codex/main-account.ts @@ -1,14 +1,8 @@ import { readCodexTokens } from "./auth-collision"; import { decodeJwtPayload } from "../oauth/chatgpt"; +import { MAIN_CODEX_ACCOUNT_ID } from "./account-id"; -/** - * Stable id under which the "main" Codex account (the Codex CLI login stored in - * ~/.codex/auth.json) participates in opencodex's account rotation. The main account is - * NOT imported into the managed credential store (Option A): its token is read-only from - * auth.json, so opencodex never refreshes it — an expired token surfaces as a reauth - * notice (re-login via the Codex CLI) rather than a background refresh. - */ -export const MAIN_CODEX_ACCOUNT_ID = "__main__"; +export { MAIN_CODEX_ACCOUNT_ID } from "./account-id"; /** * Main account plan (e.g. "plus", "go", "free", "team"), populated from the WHAM usage diff --git a/src/codex/routing.ts b/src/codex/routing.ts index a54eb90104..b359d07d12 100644 --- a/src/codex/routing.ts +++ b/src/codex/routing.ts @@ -16,6 +16,7 @@ import { } from "./pool-rotation"; import { CODEX_UNKNOWN_USAGE_SCORE, getAccountQuota } from "./quota"; import { MAIN_CODEX_ACCOUNT_ID, getMainAccountPlan } from "./main-account"; +import { isSelectableCodexPoolAccount } from "./account-id"; import type { OcxConfig } from "../types"; type ThreadAffinityEntry = { @@ -187,7 +188,8 @@ export type CodexUpstreamOutcomeMeta = { function hasConfiguredPoolAccount(config: OcxConfig, accountId: string): boolean { if (accountId === MAIN_CODEX_ACCOUNT_ID) return isCodexAccountUsable(config, accountId); - return (config.codexAccounts ?? []).some(account => !account.isMain && account.id === accountId); + return (config.codexAccounts ?? []) + .some(account => isSelectableCodexPoolAccount(account) && account.id === accountId); } export function clearThreadAccountMap(): void { @@ -691,7 +693,9 @@ function getEligiblePoolAccounts( quotaScope?: CodexQuotaScope, ): string[] { const ids = (config.codexAccounts ?? []) - .filter(account => !account.isMain && account.id !== excludeId && !isAccountNeedsReauth(account.id)) + .filter(account => isSelectableCodexPoolAccount(account) + && account.id !== excludeId + && !isAccountNeedsReauth(account.id)) .filter(account => getCodexQuotaHealthSnapshot(account.id, quotaScope, now) === null) .filter(account => !isCodexAccountSoftAvoided(account.id, now)) .filter(account => isCodexAccountUsable(config, account.id)) @@ -850,7 +854,8 @@ function pickUnboundStrategyAccount( export function getPoolAccountPlan(config: OcxConfig, accountId: string): string | undefined { if (accountId === MAIN_CODEX_ACCOUNT_ID) return getMainAccountPlan(); - return (config.codexAccounts ?? []).find(account => !account.isMain && account.id === accountId)?.plan; + return (config.codexAccounts ?? []) + .find(account => isSelectableCodexPoolAccount(account) && account.id === accountId)?.plan; } function pickLowerUsageAccount( @@ -1392,6 +1397,7 @@ export function formatCodexProviderForLog(providerName: string, accountId: strin // same physical account as the "main" passthrough (null accountId). Log both under the base provider // name so usage/tokens aggregate into a single row instead of splitting into `chatgpt` + `chatgpt-main`. if (accountId === MAIN_CODEX_ACCOUNT_ID) return providerName; - const account = (config.codexAccounts ?? []).find(a => !a.isMain && a.id === accountId); + const account = (config.codexAccounts ?? []) + .find(candidate => isSelectableCodexPoolAccount(candidate) && candidate.id === accountId); return account ? `${providerName}-${codexAccountLogLabel(account)}` : providerName; } diff --git a/src/config.ts b/src/config.ts index 1168e0e4e4..c3ca1a4c2e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -3,7 +3,14 @@ import { copyFileSync, existsSync, linkSync, mkdirSync, readFileSync, renameSync import { homedir } from "node:os"; import { join, resolve } from "node:path"; import * as z from "zod/v4"; -import { comboConfigIssues } from "./combos/types"; +import { + CODEX_ACCOUNT_NAMESPACE_COMBO_ALIAS_COLLISION_ERROR, + codexAccountNamespaceForModel, + codexProviderNamespaceKey, + isValidCodexAccountNamespaceTarget, + MAIN_CODEX_ACCOUNT_NAMESPACE_TARGET, +} from "./codex/account-namespace-match"; +import { COMBO_NAMESPACE, comboConfigIssues } from "./combos/types"; import { hardenSecretDir, hardenSecretPath } from "./lib/windows-secret-acl"; import { providerDestinationConfigError } from "./lib/destination-policy"; import { openRouterRoutingConfigError } from "./providers/openrouter-routing"; @@ -15,7 +22,7 @@ import { type OcxConfig, type OcxProviderConfig, } from "./types"; -import { isCanonicalOpenAiForwardProvider } from "./providers/openai-tiers"; +import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "./providers/openai-tiers"; import { parseDesktopProfile } from "./claude/desktop-profile"; import { isCodexReasoningEffort, modelRecordValue } from "./reasoning-effort"; @@ -525,6 +532,52 @@ export function modelAdapterRecordConfigError( return null; } +const CODEX_ACCOUNT_NAMESPACES_RECORD_ERROR = + "codexAccountNamespaces must be a plain object mapping account selectors to Codex account ids"; +const CODEX_ACCOUNT_NAMESPACE_KEY_ERROR = + "account selectors must use 1-64 letters, numbers, dots, underscores, or hyphens and cannot be reserved JavaScript object keys"; +const CODEX_ACCOUNT_NAMESPACE_TARGET_ERROR = + "account selector targets must be @main or valid Codex pool-account ids"; +const CODEX_ACCOUNT_NAMESPACE_ACCOUNT_ID_COLLISION_ERROR = + "account selectors must not collide with configured Codex pool-account ids or account selector targets"; + +function configuredCodexPoolAccountIds(value: unknown): Set { + const accountIds = new Set(); + if (!Array.isArray(value)) return accountIds; + for (const account of value) { + if (!account || typeof account !== "object" || Array.isArray(account)) continue; + const { id, isMain } = account as { id?: unknown; isMain?: unknown }; + if (typeof id === "string" && isMain !== true) accountIds.add(id); + } + return accountIds; +} + +const codexAccountNamespacesSchema = z.custom>( + (value): value is Record => !!value + && typeof value === "object" + && !Array.isArray(value) + && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null), + { error: CODEX_ACCOUNT_NAMESPACES_RECORD_ERROR }, +).superRefine((accountNamespaces, ctx) => { + // Inspect raw own entries before z.record parses them; Zod omits __proto__ record keys. + for (const [namespace, accountId] of Object.entries(accountNamespaces)) { + if (!isValidProviderName(namespace)) { + ctx.addIssue({ + code: "custom", + path: [namespace], + message: CODEX_ACCOUNT_NAMESPACE_KEY_ERROR, + }); + } + if (!isValidCodexAccountNamespaceTarget(accountId)) { + ctx.addIssue({ + code: "custom", + path: [namespace], + message: CODEX_ACCOUNT_NAMESPACE_TARGET_ERROR, + }); + } + } +}).pipe(z.record(z.string(), z.string())); + const configSchema = z.object({ port: z.number().int().min(0).max(65535).default(10100), providers: z.record(z.string(), providerConfigSchema), @@ -541,6 +594,7 @@ const configSchema = z.object({ injectionEffort: z.string().optional().catch(undefined), syncCodexSubagentDefaults: z.boolean().optional().catch(undefined), codexShimAutoRestore: z.boolean().optional(), + codexAccountNamespaces: codexAccountNamespacesSchema.optional(), // Model ids excluded from the Grok Build managed block (dashboard switches). grokExcludedModels: z.array(z.string()).optional(), // Invalid values degrade to undefined ("auto") instead of failing the whole @@ -562,6 +616,36 @@ const configSchema = z.object({ }); } } + + const accountNamespaces = config.codexAccountNamespaces; + if (accountNamespaces) { + const configuredAccountIds = configuredCodexPoolAccountIds(config.codexAccounts); + const configuredProviderNamespaces = new Set([ + COMBO_NAMESPACE, + OPENAI_CODEX_PROVIDER_ID, + ...Object.keys(config.providers), + ].map(codexProviderNamespaceKey)); + const namespaceTargets = new Set( + Object.values(accountNamespaces) + .filter(accountId => accountId !== MAIN_CODEX_ACCOUNT_NAMESPACE_TARGET), + ); + for (const namespace of Object.keys(accountNamespaces)) { + if (configuredProviderNamespaces.has(codexProviderNamespaceKey(namespace))) { + ctx.addIssue({ + code: "custom", + path: ["codexAccountNamespaces", namespace], + message: "account selectors must not collide with configured provider or combo namespaces", + }); + } + if (configuredAccountIds.has(namespace) || namespaceTargets.has(namespace)) { + ctx.addIssue({ + code: "custom", + path: ["codexAccountNamespaces", namespace], + message: CODEX_ACCOUNT_NAMESPACE_ACCOUNT_ID_COLLISION_ERROR, + }); + } + } + } for (const name of Object.keys(config.providers)) { if (!isValidProviderName(name)) { ctx.addIssue({ @@ -731,6 +815,16 @@ const configSchema = z.object({ ctx.addIssue({ code: "custom", path: ["combos"], message: "combos must be an object" }); } else { for (const [id, raw] of Object.entries(combos as Record)) { + const alias = raw && typeof raw === "object" && !Array.isArray(raw) + ? (raw as { alias?: unknown }).alias + : undefined; + if (typeof alias === "string" && codexAccountNamespaceForModel(accountNamespaces, alias.trim())) { + ctx.addIssue({ + code: "custom", + path: ["combos", id, "alias"], + message: CODEX_ACCOUNT_NAMESPACE_COMBO_ALIAS_COLLISION_ERROR, + }); + } // Pass the full map so cross-combo rules (alias uniqueness) apply at load time // too, not just via the management API; each combo is excluded from its own check. for (const issue of comboConfigIssues(id, raw, config.providers, { @@ -1038,6 +1132,16 @@ export function websocketsEnabled(config: Pick): boolea */ const claudeCodeBaseline = new WeakMap(); +/** + * The live config retains the address of the socket Bun actually opened, while + * this map retains the operator's desired address for the next process start. + * Keeping them separate prevents an unrelated live save from restoring a stale + * externally exposed bind after OAuth adopted a newer loopback disk config. + */ +type PersistedServerBinding = Pick; + +const persistedLiveServerBinding = new WeakMap(); + /** * Arm the baseline for a long-lived config. MANDATORY at `startServer`, not lazy on * first save — arming lazily would lose exactly the hand edit made before that first @@ -1074,6 +1178,116 @@ function deepEqual(a: unknown, b: unknown): boolean { return true; } +const MISSING_CONFIG_VALUE = Symbol("missing-config-value"); +type ConfigMergeValue = unknown | typeof MISSING_CONFIG_VALUE; + +function isPlainConfigRecord(value: ConfigMergeValue): value is Record { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function ownConfigValue(record: Record, key: string): ConfigMergeValue { + return Object.hasOwn(record, key) ? record[key] : MISSING_CONFIG_VALUE; +} + +function cloneConfigValue(value: ConfigMergeValue): ConfigMergeValue { + return value === MISSING_CONFIG_VALUE ? value : structuredClone(value); +} + +function reconcileConfigRecord( + live: Record, + baseline: Record, + persisted: Record, + skippedKeys?: ReadonlySet, +): void { + const keys = new Set([...Object.keys(baseline), ...Object.keys(live), ...Object.keys(persisted)]); + for (const key of keys) { + if (skippedKeys?.has(key)) continue; + const merged = reconcileConfigValue( + ownConfigValue(baseline, key), + ownConfigValue(live, key), + ownConfigValue(persisted, key), + ); + if (merged === MISSING_CONFIG_VALUE) delete live[key]; + else live[key] = merged; + } +} + +function reconcileConfigValue( + baseline: ConfigMergeValue, + live: ConfigMergeValue, + persisted: ConfigMergeValue, +): ConfigMergeValue { + const liveChanged = !deepEqual(live, baseline); + const persistedChanged = !deepEqual(persisted, baseline); + + if (!liveChanged) { + if (live !== MISSING_CONFIG_VALUE && Array.isArray(live) && Array.isArray(persisted)) { + live.splice(0, live.length, ...structuredClone(persisted)); + return live; + } + if (isPlainConfigRecord(live) && isPlainConfigRecord(persisted)) { + reconcileConfigRecord( + live, + isPlainConfigRecord(baseline) ? baseline : {}, + persisted, + ); + return live; + } + return cloneConfigValue(persisted); + } + + if (!persistedChanged) return live; + + if (isPlainConfigRecord(live) + && isPlainConfigRecord(persisted) + && (baseline === MISSING_CONFIG_VALUE || isPlainConfigRecord(baseline))) { + reconcileConfigRecord( + live, + isPlainConfigRecord(baseline) ? baseline : {}, + persisted, + ); + } + // Same-leaf conflicts prefer the pending live management mutation. + return live; +} + +/** + * Reconcile an async OAuth disk commit into the shared live config without erasing + * management mutations that have not saved yet. The baseline is a normalized disk + * snapshot from immediately before login; disjoint object edits merge recursively, + * while same-leaf conflicts prefer live state. + */ +export function reconcileLiveConfigFromDisk(config: OcxConfig, persistedBaseline: OcxConfig): void { + const diagnostics = readConfigDiagnostics(); + if (diagnostics.source === "fallback") { + throw new Error(`OAuth config reconciliation failed: ${diagnostics.error ?? "invalid config file"}`); + } + const persisted = diagnostics.config; + const claudeGuardArmed = claudeCodeBaseline.has(config); + const pendingLiveClaudeMutation = claudeGuardArmed + && !deepEqual(config.claudeCode, claudeCodeBaseline.get(config)); + + persistedLiveServerBinding.set(config, { + port: persisted.port, + ...(persisted.hostname !== undefined ? { hostname: persisted.hostname } : {}), + }); + + reconcileConfigRecord( + config as unknown as Record, + persistedBaseline as unknown as Record, + persisted as unknown as Record, + new Set(["hostname", "port", ...(claudeGuardArmed ? ["claudeCode"] : [])]), + ); + + if (claudeGuardArmed && !pendingLiveClaudeMutation) { + if (persisted.claudeCode === undefined) delete config.claudeCode; + else config.claudeCode = structuredClone(persisted.claudeCode); + claudeCodeBaseline.set(config, structuredClone(config.claudeCode)); + } +} + /** The literal file, with no schema merge or default injection. */ function readRawConfigJson(): Record | undefined { try { @@ -1089,6 +1303,28 @@ function readRawConfigJson(): Record | undefined { } } +/** + * Read only schema-valid binding fields from the literal file. Missing fields mean + * their schema defaults; malformed fields keep the last known persisted value. + */ +function readPersistedServerBinding( + raw: Record, + baseline: PersistedServerBinding, +): PersistedServerBinding { + const port = raw.port === undefined + ? 10100 + : (typeof raw.port === "number" + && Number.isInteger(raw.port) + && raw.port >= 0 + && raw.port <= 65535 + ? raw.port + : baseline.port); + const hostname = raw.hostname === undefined + ? undefined + : (typeof raw.hostname === "string" ? raw.hostname : baseline.hostname); + return { port, ...(hostname !== undefined ? { hostname } : {}) }; +} + /** * The save entry point for every writer holding a LIVE server config. * @@ -1103,8 +1339,11 @@ function readRawConfigJson(): Record | undefined { * guarantee. */ export function saveConfigPreservingClaudeCode(config: OcxConfig): void { + const bindingBaseline = persistedLiveServerBinding.get(config); + const onDisk = claudeCodeBaseline.has(config) || bindingBaseline + ? readRawConfigJson() + : undefined; if (claudeCodeBaseline.has(config)) { - const onDisk = readRawConfigJson(); if (onDisk !== undefined) { const baseline = claudeCodeBaseline.get(config); const diskChanged = !deepEqual(onDisk.claudeCode, baseline); @@ -1114,7 +1353,18 @@ export function saveConfigPreservingClaudeCode(config: OcxConfig): void { } } } - saveConfig(config); + const persistedBinding = bindingBaseline && onDisk + ? readPersistedServerBinding(onDisk, bindingBaseline) + : bindingBaseline; + if (persistedBinding) { + const persistedConfig: OcxConfig = { ...config, port: persistedBinding.port }; + if (persistedBinding.hostname === undefined) delete persistedConfig.hostname; + else persistedConfig.hostname = persistedBinding.hostname; + saveConfig(persistedConfig); + persistedLiveServerBinding.set(config, persistedBinding); + } else { + saveConfig(config); + } if (claudeCodeBaseline.has(config)) { claudeCodeBaseline.set(config, structuredClone(config.claudeCode)); } diff --git a/src/oauth/index.ts b/src/oauth/index.ts index c6dca4bc0e..5ebaa320c6 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -38,6 +38,7 @@ export { type OAuthHealthLabel, } from "./health"; export { OAUTH_REFRESH_LOCK_WAIT_MS, peekAuthStore, peekOAuthRefreshIntent } from "./store"; +import { codexAccountNamespaceProviderCollisionError } from "../codex/account-namespace-match"; const REFRESH_SKEW_MS = 60_000; export interface OAuthAccessSnapshot { @@ -60,6 +61,11 @@ function cached(p:string,a:string,c:OAuthCredentials,now:()=>number){const k=ver export interface LoginOpts { forceLogin?: boolean; /** When set, persist into this account slot and require matching identity. */ reauthAccountId?: string } +export interface LoginFlowLifecycle { + /** Runs after background credential/config persistence settles, before status becomes done. */ + onSettled?: () => void | Promise; +} + interface OAuthProviderDef { login(ctrl: OAuthController, opts?: LoginOpts): Promise; refresh( @@ -617,6 +623,8 @@ export function upsertOAuthProvider(config: OcxConfig, provider: string): void { if (provider === "chatgpt") return; const def = OAUTH_PROVIDERS[provider]; if (!def) return; + const namespaceCollision = codexAccountNamespaceProviderCollisionError(config.codexAccountNamespaces, provider); + if (namespaceCollision) throw new Error(namespaceCollision); const existing = config.providers[provider]; const next: OcxProviderConfig = { ...def.providerConfig }; if (existing && getProviderRegistryEntry(provider)?.allowKeyAuthOverride === true) { @@ -681,6 +689,16 @@ export async function runLogin( ): Promise { const def = OAUTH_PROVIDERS[provider]; if (!def) throw new UnsupportedOAuthProviderError(provider); + const loadLatestConfig = deps.loadConfig ?? loadConfig; + const saveLatestConfig = deps.saveConfig ?? saveConfig; + if (provider !== "chatgpt") { + const preflightConfig = loadLatestConfig(); + const namespaceCollision = codexAccountNamespaceProviderCollisionError( + preflightConfig.codexAccountNamespaces, + provider, + ); + if (namespaceCollision) throw new Error(namespaceCollision); + } // loginKiro keys its pending CLI-session transaction by object identity. Keep this exact object // for settlement even when source normalization below creates a derived credential object. const shouldRollbackKiroAccounts = provider === "kiro" && opts?.forceLogin === true; @@ -691,6 +709,12 @@ export async function runLogin( const cred: OAuthCredentials = rawCred.source ? rawCred : { ...rawCred, source: "oauth" }; const settleKiroTransaction = deps.settleKiroLoginTransaction ?? settleKiroLoginTransaction; try { + // Validate the provider row before credential persistence. A namespace claimed during the + // credential write is handled again below before the latest row is re-upserted. + if (provider !== "chatgpt") { + const preCommitConfig = loadLatestConfig(); + upsertOAuthProvider(preCommitConfig, provider); + } if (opts?.reauthAccountId) { const existing = getAccountCredential(provider, opts.reauthAccountId); if (!existing) throw new Error(`Unknown account for reauth: ${opts.reauthAccountId}`); @@ -712,9 +736,21 @@ export async function runLogin( }); } if (provider !== "chatgpt") { - const config = (deps.loadConfig ?? loadConfig)(); - upsertOAuthProvider(config, provider); - (deps.saveConfig ?? saveConfig)(config); + // Re-run against post-credential state so same-provider API-key additions, removals, + // and active-key switches survive. A late namespace claim wins over provider creation. + const latestConfig = loadLatestConfig(); + const lateCollision = codexAccountNamespaceProviderCollisionError( + latestConfig.codexAccountNamespaces, + provider, + ); + if (lateCollision) { + throw new Error( + `${lateCollision}. The credential for "${provider}" was saved, but the provider entry was not written. ` + + "Rename the account selector, then re-run the login.", + ); + } + upsertOAuthProvider(latestConfig, provider); + saveLatestConfig(latestConfig); } } catch (error) { const errors: unknown[] = [error]; @@ -894,7 +930,11 @@ export function cancelLoginFlow(provider: string): boolean { return true; } -export async function startLoginFlow(provider: string, opts?: LoginOpts): Promise<{ url: string; instructions?: string; deviceCode?: string }> { +export async function startLoginFlow( + provider: string, + opts?: LoginOpts, + lifecycle?: LoginFlowLifecycle, +): Promise<{ url: string; instructions?: string; deviceCode?: string }> { const def = OAUTH_PROVIDERS[provider]; if (!def) throw new UnsupportedOAuthProviderError(provider); const existing = loginState.get(provider); @@ -917,22 +957,44 @@ export async function startLoginFlow(provider: string, opts?: LoginOpts): Promis onManualCodeInput: (expectedState?: string) => waitForManualLoginCode(provider, abort.signal, expectedState), signal: abort.signal, }; - // Background: runLogin persists the credential + upserts the provider entry to disk config. - runLogin(provider, ctrl, opts) - .then(() => { + const settle = async (error?: unknown): Promise => { + let finalError = error; + try { + await lifecycle?.onSettled?.(); + } catch (settleError) { + // A successful credential/config commit is not fully live until its owner reconciles the + // runtime config. For an already-failed login, keep the original recovery error. + if (finalError === undefined) finalError = settleError; + } + if (finalError === undefined) { loginAbort.delete(provider); clearManualCodeSlot(provider); loginState.set(provider, { done: true }); // Local-token import (grok-cli / Claude Code keychain) completes WITHOUT firing onAuth — // resolve so the GUI call returns instead of hanging. if (!urlResolved) resolve({ url: "", instructions: "Logged in via an existing local CLI/keychain token — no browser needed." }); - }) - .catch((e: unknown) => { - loginAbort.delete(provider); - clearManualCodeSlot(provider); - const msg = e instanceof Error ? e.message : String(e); - loginState.set(provider, { done: true, error: msg }); - if (!urlResolved) reject(e); - }); + return; + } + + const e = finalError; + loginAbort.delete(provider); + clearManualCodeSlot(provider); + const msg = e instanceof Error ? e.message : String(e); + loginState.set(provider, { done: true, error: msg }); + if (!urlResolved) reject(e); + }; + // Background: runLogin persists the credential + provider entry to disk. The lifecycle hook + // lets a long-lived server config adopt that settled state before clients observe done=true. + void runLogin(provider, ctrl, opts).then( + () => settle(), + (e: unknown) => settle(e), + ).catch((e: unknown) => { + // settle catches lifecycle failures, so this is only a defensive promise-boundary guard. + loginAbort.delete(provider); + clearManualCodeSlot(provider); + const msg = e instanceof Error ? e.message : String(e); + loginState.set(provider, { done: true, error: msg }); + if (!urlResolved) reject(e); + }); }); } diff --git a/src/oauth/login-cli.ts b/src/oauth/login-cli.ts index cf225b9cef..d652884153 100644 --- a/src/oauth/login-cli.ts +++ b/src/oauth/login-cli.ts @@ -5,6 +5,7 @@ import { findLiveProxy, probeHostname } from "../server/proxy-liveness"; import { isPublicOAuthProvider, listOAuthProviders, runLogin } from "./index"; import { KEY_LOGIN_PROVIDERS, isKeyLoginProvider, validateApiKey, type KeyLoginProvider } from "./key-providers"; import type { OcxProviderConfig } from "../types"; +import { codexAccountNamespaceProviderCollisionError } from "../codex/account-namespace-match"; export function runningProxyUpdateHeaders(): Headers { const headers = new Headers({ "Content-Type": "application/json" }); @@ -106,6 +107,12 @@ export function providerConfigFromKeyLoginProvider(def: KeyLoginProvider, key: s async function handleKeyLogin(name: string): Promise { const def = KEY_LOGIN_PROVIDERS[name]; + const preflightConfig = loadConfig(); + const namespaceCollision = codexAccountNamespaceProviderCollisionError(preflightConfig.codexAccountNamespaces, name); + if (namespaceCollision) { + console.error(`Error: ${namespaceCollision}.`); + process.exit(1); + } console.log(`\n🔑 ${def.label} — opening ${def.dashboardUrl} so you can create/copy an API key...`); openUrl(def.dashboardUrl); const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); @@ -135,6 +142,11 @@ async function handleKeyLogin(name: string): Promise { } const provider = providerConfigFromKeyLoginProvider(def, key, baseUrl); const config = loadConfig(); + const commitCollision = codexAccountNamespaceProviderCollisionError(config.codexAccountNamespaces, name); + if (commitCollision) { + console.error(`Error: ${commitCollision}.`); + process.exit(1); + } config.providers[name] = provider; saveConfig(config); await notifyRunningProxy(name, provider); diff --git a/src/providers/alibaba-region-migration.ts b/src/providers/alibaba-region-migration.ts index 629d6fe56d..8d488f246e 100644 --- a/src/providers/alibaba-region-migration.ts +++ b/src/providers/alibaba-region-migration.ts @@ -2,6 +2,7 @@ import { ALIBABA_INTL_BASE_URL_CHOICES } from "./base-url-choices"; import { providerConfigSeed } from "./derive"; import { rewriteProviderReferences } from "./provider-id-rewrite"; import { PROVIDER_REGISTRY } from "./registry"; +import { codexAccountNamespaceProviderCollisionError } from "../codex/account-namespace-match"; import type { OcxConfig, OcxProviderConfig } from "../types"; const BEIJING_ID = "alibaba-token-plan"; @@ -91,9 +92,10 @@ function buildIntlRow(source: OcxProviderConfig): OcxProviderConfig { * those contracts across. That override fix shipped once, was backed out twice, * and was refused again when PR #459 closed. * - * Refuses to act when the destination already holds a value — a provider row or a - * key the reference rewrite would land on — because merging two settings is a - * user decision, not a migration's. + * Refuses to act when the destination is reserved by an account namespace or + * already holds a value — a provider row or a key the reference rewrite would + * land on — because choosing which setting wins is a user decision, not a + * migration's. */ export function projectAlibabaRegionMigration(config: OcxConfig): AlibabaRegionMigrationProjection { const beijing = config.providers[BEIJING_ID]; @@ -101,6 +103,17 @@ export function projectAlibabaRegionMigration(config: OcxConfig): AlibabaRegionM if (!beijing || !savedBaseUrl || !isInternationalEndpoint(savedBaseUrl)) { return { config, changed: false, warnings: [] }; } + if (codexAccountNamespaceProviderCollisionError(config.codexAccountNamespaces, INTL_ID)) { + return { + config, + changed: false, + warnings: [ + `provider "${BEIJING_ID}" needs to move to "${INTL_ID}", but that destination is reserved ` + + `by a configured Codex account namespace. Nothing was changed. Rename the account ` + + `selector or move the provider manually, then restart.`, + ], + }; + } if (config.providers[INTL_ID]) { return { config, diff --git a/src/server/management/combo-routes.ts b/src/server/management/combo-routes.ts index f7942e87ad..57c51e5bbb 100644 --- a/src/server/management/combo-routes.ts +++ b/src/server/management/combo-routes.ts @@ -29,6 +29,10 @@ import { providerCodexAccountMode } from "../../providers/registry"; import { routedSlug, slugEquals } from "../../providers/slug-codec"; import { clearProviderQuotaCache, fetchProviderQuotaReports } from "../../providers/quota"; import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; +import { + CODEX_ACCOUNT_NAMESPACE_COMBO_ALIAS_COLLISION_ERROR, + codexAccountNamespaceForModel, +} from "../../codex/account-namespace-match"; import { clearThreadAccountMap } from "../../codex/routing"; import { primeCodexPoolQuotas } from "../../codex/auth-api"; import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "../../providers/context-cap"; @@ -123,6 +127,9 @@ export async function handleComboRoutes(ctx: ManagementContext): Promise { const { req, url, config, deps, refreshCodexCatalogBestEffort, syncClaudeAgentDefsBestEffort } = ctx; @@ -86,6 +88,8 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< const body = await req.json().catch(() => ({})) as { provider?: string; addAccount?: boolean; accountId?: string; reauth?: boolean }; const provider = (body.provider ?? "").trim().toLowerCase(); if (!isPublicOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400); + const namespaceCollision = codexAccountNamespaceProviderCollisionError(config.codexAccountNamespaces, provider); + if (namespaceCollision) return jsonResponse({ error: namespaceCollision }, 409); const accountId = body.accountId?.trim(); const reauth = body.reauth === true || Boolean(accountId); try { @@ -96,12 +100,19 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< return jsonResponse({ error: "Unknown account for reauth" }, 404); } } + // Use persisted state, not the live object, as the merge base: another management + // request may already have mutated live config and yielded before its save. + const persistedBaseline = readConfigDiagnostics().config; // addAccount / reauth forces a fresh browser identity (skips local-CLI token import). const { url: authUrl, instructions, deviceCode } = await startLoginFlow(provider, { forceLogin: body.addAccount === true || reauth, ...(accountId ? { reauthAccountId: accountId } : {}), + }, { + // startLoginFlow returns the authorization URL before background persistence completes. + // Three-way reconcile settled disk changes so a failed login cannot leave a provider + // live-only and an in-flight management mutation cannot be erased before it saves. + onSettled: () => reconcileLiveConfigFromDisk(config, persistedBaseline), }); - upsertOAuthProvider(config, provider); // mutate LIVE config — routing sees it without restart if (authUrl && !deviceCode) { // Open the browser server-side (the proxy runs on the user's machine) — the GUI's // window.open is popup-blocked because it runs after an await, not a direct click. diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 53d17ff108..f4b0fed2b7 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -29,6 +29,7 @@ import { providerCodexAccountMode } from "../../providers/registry"; import { routedSlug, slugEquals } from "../../providers/slug-codec"; import { clearProviderQuotaCache, fetchProviderQuotaReports } from "../../providers/quota"; import { CODEX_FORWARD_BASE_URL, isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; +import { codexAccountNamespaceProviderCollisionError } from "../../codex/account-namespace-match"; import { clearThreadAccountMap } from "../../codex/routing"; import { primeCodexPoolQuotas } from "../../codex/auth-api"; import { getProviderDiscoveryStatus } from "../../codex/model-cache"; @@ -99,6 +100,10 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise; /** Active pool account id for next session. undefined = main (passthrough as-is). */ activeCodexAccountId?: string; /** Auto-switch threshold (0-100). Default 80. 0 = disabled. */ diff --git a/tests/alibaba-region-migration.test.ts b/tests/alibaba-region-migration.test.ts index 54db6fae97..3b56fb8be7 100644 --- a/tests/alibaba-region-migration.test.ts +++ b/tests/alibaba-region-migration.test.ts @@ -20,6 +20,13 @@ function migratableConfig(): OcxConfig { } as unknown as OcxConfig; } +function namespaceCollidingConfig(): OcxConfig { + return { + ...migratableConfig(), + codexAccountNamespaces: { "alibaba-token-plan-intl": "pool-a" }, + }; +} + test("moves a Beijing entry holding an international endpoint", () => { const config = migratableConfig(); // Beijing catalog fields, as `ocx provider add` would have persisted them. @@ -94,6 +101,51 @@ test("refuses to merge when the intl entry exists, and says why", () => { expect(projection.warnings[0]).toContain("already exists"); }); +test("refuses to replace an account namespace with the intl provider", () => { + const config = namespaceCollidingConfig(); + const before = structuredClone(config); + + const projection = projectAlibabaRegionMigration(config); + expect(projection.changed).toBe(false); + expect(projection.config).toEqual(before); + expect(projection.warnings).toHaveLength(1); + expect(projection.warnings[0]).toContain("reserved by a configured Codex account namespace"); + expect(projection.warnings[0]).toContain("Rename the account selector"); +}); + +test("refuses a mixed-case account namespace that owns the intl provider id", () => { + const config = migratableConfig(); + config.codexAccountNamespaces = { "ALIBABA-TOKEN-PLAN-INTL": "pool-a" }; + const before = structuredClone(config); + + const projection = projectAlibabaRegionMigration(config); + + expect(projection.changed).toBe(false); + expect(projection.config).toEqual(before); + expect(projection.warnings).toHaveLength(1); + expect(projection.warnings[0]).toContain("reserved by a configured Codex account namespace"); +}); + +test("a namespace-blocked migration remains valid across reload", () => { + const projection = projectAlibabaRegionMigration(namespaceCollidingConfig()); + expect(projection.changed).toBe(false); + + const home = mkdtempSync(join(tmpdir(), "ocx-alibaba-namespace-")); + const prev = process.env.OPENCODEX_HOME; + process.env.OPENCODEX_HOME = home; + try { + saveConfig(projection.config); + const reloaded = loadConfig(); + expect(reloaded.providers["alibaba-token-plan"]).toBeDefined(); + expect(reloaded.providers["alibaba-token-plan-intl"]).toBeUndefined(); + expect(reloaded.codexAccountNamespaces).toEqual({ "alibaba-token-plan-intl": "pool-a" }); + } finally { + if (prev === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = prev; + rmSync(home, { recursive: true, force: true }); + } +}); + test("aborts without changing anything when a destination key is occupied", () => { const config = migratableConfig(); config.providerContextCaps = { "alibaba-token-plan": 500_000, "alibaba-token-plan-intl": 900_000 }; diff --git a/tests/alibaba-region-startup.test.ts b/tests/alibaba-region-startup.test.ts index e366c0d92e..eda003d503 100644 --- a/tests/alibaba-region-startup.test.ts +++ b/tests/alibaba-region-startup.test.ts @@ -22,6 +22,13 @@ function collidingConfig(): OcxConfig { return config; } +function namespaceCollidingConfig(): OcxConfig { + return { + ...migratableConfig(), + codexAccountNamespaces: { "alibaba-token-plan-intl": "pool-a" }, + }; +} + test("backs up strictly before saving, exactly once, when the projection changed", () => { const order: string[] = []; const saved: OcxConfig[] = []; @@ -57,6 +64,27 @@ test("a no-op never backs up or saves, but a collision still warns", () => { expect(warnings[0]).toContain("[alibaba-region-migration]"); }); +test("an account namespace collision warns without backing up or saving", () => { + const order: string[] = []; + const warnings: string[] = []; + const originalWarn = console.warn; + console.warn = (...args: unknown[]) => { warnings.push(args.map(String).join(" ")); }; + try { + const config = namespaceCollidingConfig(); + const result = runAlibabaRegionStartupMigration(config, { + project: projectAlibabaRegionMigration, + backup: () => { order.push("backup"); }, + save: () => { order.push("save"); }, + }); + expect(result).toBe(config); + } finally { + console.warn = originalWarn; + } + expect(order).toEqual([]); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain("reserved by a configured Codex account namespace"); +}); + test("a backup failure prevents the migration from saving", () => { // The fail-closed posture: no rollback point, no credential rewrite. The throw // propagates out of startServer, the same stance the OpenAI tier migration takes. diff --git a/tests/cli-account.test.ts b/tests/cli-account.test.ts index 751e8b2496..b692ddb854 100644 --- a/tests/cli-account.test.ts +++ b/tests/cli-account.test.ts @@ -1,4 +1,4 @@ -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { PassThrough, Readable } from "node:stream"; import { cmdAccount, classifyAccount, formatAccountTable, type AccountDeps } from "../src/cli/account"; import type { AccountStdin } from "../src/cli/account-api"; @@ -43,6 +43,7 @@ let lastDeletedType: "codex" | "oauth" | "api-key" | null = null; let codexAccounts: Array> = []; let oauthAccounts: Array> = []; let oauthActiveId: string | null = "acct_1"; +let oauthLoginStatus: Record = { loggedIn: false }; let keyEntries: Array> = []; let keyActiveId: string | null = "key_1"; let logs: string[] = []; @@ -285,6 +286,10 @@ async function mockManagementApi(req: Request): Promise { return json({ ok: true, accepted: true }); } + if (req.method === "GET" && url.pathname === "/api/oauth/status") { + return json(oauthLoginStatus); + } + return json({ error: `unhandled mock endpoint: ${req.method} ${url.pathname}` }, 404); } @@ -348,6 +353,7 @@ beforeEach(() => { { id: "acct_2" }, ]; oauthActiveId = "acct_1"; + oauthLoginStatus = { loggedIn: false }; keyEntries = [{ id: "key_1", label: "personal", @@ -1271,5 +1277,24 @@ describe("ocx account CLI (issue #180 matrix)", () => { expect(result.code).toBe(0); expect(requests.some(request => request.path === "/api/oauth/login/code")).toBe(false); }); + + }); + + test("39: a login error wins over a retained OAuth credential", async () => { + oauthLoginStatus = { + loggedIn: true, + done: true, + error: "The credential was saved, but the provider entry was not written.", + }; + const sleepSpy = spyOn(Bun, "sleep").mockImplementation(async () => {}); + try { + const result = await run(["login", "anthropic"]); + + expect(result.code).toBe(2); + expect(result.stderr).toContain("provider entry was not written"); + expect(result.stdout).not.toContain("Logged in to anthropic"); + } finally { + sleepSpy.mockRestore(); + } }); }); diff --git a/tests/cli-provider.test.ts b/tests/cli-provider.test.ts index 2e24a2d8b6..cf52454df3 100644 --- a/tests/cli-provider.test.ts +++ b/tests/cli-provider.test.ts @@ -97,6 +97,38 @@ describe("ocx provider", () => { } }); + test("provider add rejects a configured Codex account namespace without mutating config", () => { + const { dir, configPath } = freshConfig({ + codexAccountNamespaces: { deepseek: "side-account-id" }, + }); + try { + const before = readFileSync(configPath, "utf8"); + const result = runCli(["provider", "add", "deepseek", "--api-key", "sk-test"], { OPENCODEX_HOME: dir }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("must not collide with a configured Codex account namespace"); + expect(readFileSync(configPath, "utf8")).toBe(before); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test.each(["xai", "deepseek"])("login %s rejects a configured Codex account namespace before prompting", provider => { + const { dir, configPath } = freshConfig({ + codexAccountNamespaces: { [provider]: "side-account-id" }, + }); + try { + const before = readFileSync(configPath, "utf8"); + const result = runCli(["login", provider], { OPENCODEX_HOME: dir }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("must not collide with a configured Codex account namespace"); + expect(readFileSync(configPath, "utf8")).toBe(before); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + test("provider add custom provider requires --adapter and --base-url", () => { const { dir } = freshConfig(); try { diff --git a/tests/codex-account-namespaces.test.ts b/tests/codex-account-namespaces.test.ts new file mode 100644 index 0000000000..5083f7c99c --- /dev/null +++ b/tests/codex-account-namespaces.test.ts @@ -0,0 +1,349 @@ +import { describe, expect, spyOn, test } from "bun:test"; +import * as accountLabels from "../src/codex/account-label"; +import { + CODEX_ACCOUNT_LOG_LABEL_RE, + fallbackCodexAccountLogLabel, +} from "../src/codex/account-label"; +import { + codexAccountIdNamespaceCollisionError, + codexAccountNamespaceProviderCollisionError, + codexAccountNamespaceForModel, + hasCodexAccountNamespace, +} from "../src/codex/account-namespace-match"; +import { + appendDefaultCodexAccountNamespace, + codexAccountNamespaceEntries, + defaultCodexAccountNamespaces, + isMainCodexAccountTarget, + isValidCodexAccountNamespaceTarget, +} from "../src/codex/account-namespaces"; +import { MAIN_CODEX_ACCOUNT_ID } from "../src/codex/main-account"; + +describe("Codex account namespace foundations", () => { + test("uses persisted random labels without deriving selectors from aliases or emails", () => { + const privateAlias = "jun@example.test"; + const privateEmail = "account@example.test"; + const privateId = "stored-account-id"; + const namespaces = defaultCodexAccountNamespaces({ + providers: {}, + codexAccounts: [{ + id: privateId, + email: privateEmail, + alias: privateAlias, + logLabel: "p111111", + isMain: false, + }], + }); + + expect(namespaces).toEqual({ main: "@main", p111111: privateId }); + const publicSelectors = Object.keys(namespaces).join(","); + expect(publicSelectors).not.toContain(privateAlias); + expect(publicSelectors).not.toContain(privateEmail); + expect(publicSelectors).not.toContain(privateId); + }); + + test("gives legacy accounts an independent random selector", () => { + const privateId = "legacy-stored-account-id"; + const namespaces = defaultCodexAccountNamespaces({ + providers: {}, + codexAccounts: [{ + id: privateId, + email: "legacy@example.test", + alias: "Legacy Display Name", + isMain: false, + }], + }); + const selector = Object.entries(namespaces) + .find(([, accountId]) => accountId === privateId)?.[0]; + + expect(selector).toMatch(CODEX_ACCOUNT_LOG_LABEL_RE); + expect(selector).not.toBe(fallbackCodexAccountLogLabel(privateId)); + expect(selector).not.toContain(privateId); + }); + + test("fails after bounded attempts when no private-safe selector can be allocated", () => { + const labelSpy = spyOn(accountLabels, "createCodexAccountLogLabel").mockReturnValue("p111111"); + try { + expect(() => defaultCodexAccountNamespaces({ + providers: {}, + codexAccounts: [{ id: "p111111", logLabel: "legacy", isMain: false }], + })).toThrow("Unable to allocate a unique Codex account selector"); + expect(labelSpy).toHaveBeenCalledTimes(16); + } finally { + labelSpy.mockRestore(); + } + }); + + test("never reuses a private account id or its deterministic fallback as a selector", () => { + const pShapedId = "p222222"; + const fallbackId = "legacy-private-account-id"; + const deterministicFallback = fallbackCodexAccountLogLabel(fallbackId); + const namespaces = defaultCodexAccountNamespaces({ + providers: {}, + codexAccounts: [ + { id: pShapedId, logLabel: pShapedId, isMain: false }, + { id: fallbackId, logLabel: deterministicFallback, isMain: false }, + ], + }); + + const selectorFor = (accountId: string) => Object.entries(namespaces) + .find(([, target]) => target === accountId)?.[0]; + expect(selectorFor(pShapedId)).toMatch(CODEX_ACCOUNT_LOG_LABEL_RE); + expect(selectorFor(pShapedId)).not.toBe(pShapedId); + expect(selectorFor(fallbackId)).toMatch(CODEX_ACCOUNT_LOG_LABEL_RE); + expect(selectorFor(fallbackId)).not.toBe(deterministicFallback); + }); + + test("does not expose another account id or deterministic fallback as a selector", () => { + const pShapedPrivateId = "p444444"; + const fallbackOwnerId = "other-private-account-id"; + const otherFallback = fallbackCodexAccountLogLabel(fallbackOwnerId); + const namespaces = defaultCodexAccountNamespaces({ + providers: {}, + codexAccounts: [ + { id: "first-account", logLabel: pShapedPrivateId, isMain: false }, + { id: pShapedPrivateId, logLabel: "p555555", isMain: false }, + { id: "third-account", logLabel: otherFallback, isMain: false }, + { id: fallbackOwnerId, logLabel: "p666666", isMain: false }, + ], + }); + + const publicSelectors = Object.keys(namespaces); + expect(publicSelectors).not.toContain(pShapedPrivateId); + expect(publicSelectors).not.toContain(otherFallback); + }); + + test("suffix allocation cannot land on another private account id", () => { + const privateSuffix = "p888888-2"; + const namespaces = defaultCodexAccountNamespaces({ + providers: { + p888888: { adapter: "openai-chat", baseUrl: "https://example.test/v1" }, + }, + codexAccounts: [ + { id: "first-account", logLabel: "p888888", isMain: false }, + { id: privateSuffix, logLabel: "p999999", isMain: false }, + ], + }); + + expect(Object.keys(namespaces)).not.toContain(privateSuffix); + expect(Object.entries(namespaces).find(([, target]) => target === "first-account")?.[0]) + .toBe("p888888-3"); + }); + + test("avoids provider and combo prefixes when allocating defaults", () => { + const namespaces = defaultCodexAccountNamespaces({ + providers: { p111111: { adapter: "openai-chat", baseUrl: "https://example.test/v1" } }, + combos: { + primary: { + alias: "main/gpt-5.5", + targets: [{ provider: "p111111", model: "gpt-5.5" }], + }, + }, + codexAccounts: [{ + id: "stored-account-id", + email: "account@example.test", + logLabel: "p111111", + isMain: false, + }], + }); + + expect(namespaces).toEqual({ "main-2": "@main", "p111111-2": "stored-account-id" }); + }); + + test("avoids provider names case-insensitively when allocating defaults", () => { + expect(defaultCodexAccountNamespaces({ + providers: { + Main: { adapter: "openai-chat", baseUrl: "https://example.test/v1" }, + }, + })).toEqual({ "main-2": "@main" }); + }); + + test("appends a safe selector without rewriting an explicit map", () => { + const codexAccountNamespaces = { chosen: "existing-account-id", mainAccount: "@main" }; + const config = { providers: {}, codexAccountNamespaces }; + const account = { + id: "new-account-id", + email: "private@example.test", + alias: "Private Display Alias", + logLabel: "p222222", + isMain: false, + }; + + expect(appendDefaultCodexAccountNamespace(config, account)).toBe(true); + expect(config.codexAccountNamespaces).toEqual({ + chosen: "existing-account-id", + mainAccount: "@main", + p222222: "new-account-id", + }); + expect(appendDefaultCodexAccountNamespace(config, account)).toBe(false); + }); + + test("refuses to append an account id already owned by a selector key", () => { + const codexAccountNamespaces = { work: "existing-account-id", mainAccount: "@main" }; + const config = { providers: {}, codexAccountNamespaces }; + + expect(appendDefaultCodexAccountNamespace(config, { + id: "work", + logLabel: "p232323", + isMain: false, + })).toBe(false); + expect(config.codexAccountNamespaces).toEqual({ + work: "existing-account-id", + mainAccount: "@main", + }); + }); + + test("refuses to append the Desktop main account", () => { + const codexAccountNamespaces = { mainAccount: "@main" }; + const config = { providers: {}, codexAccountNamespaces }; + + expect(appendDefaultCodexAccountNamespace(config, { + id: "desktop-id", + logLabel: "p242424", + isMain: true, + })).toBe(false); + expect(config.codexAccountNamespaces).toEqual({ mainAccount: "@main" }); + }); + + test("never generates targets for legacy account ids rejected by the namespace schema", () => { + expect(defaultCodexAccountNamespaces({ + providers: {}, + codexAccounts: [ + { id: "constructor", logLabel: "p242425", isMain: false }, + { id: "valid-account", logLabel: "p242426", isMain: false }, + ], + })).toEqual({ main: "@main", p242426: "valid-account" }); + + const config = { + providers: {}, + codexAccountNamespaces: { mainAccount: "@main" }, + }; + expect(appendDefaultCodexAccountNamespace(config, { + id: "Constructor", + logLabel: "p242427", + isMain: false, + })).toBe(false); + expect(config.codexAccountNamespaces).toEqual({ mainAccount: "@main" }); + }); + + test("append avoids every existing private target and deterministic fallback", () => { + const existingTarget = "p777777"; + const fallbackOwnerId = "existing-private-account-id"; + const fallback = fallbackCodexAccountLogLabel(fallbackOwnerId); + + for (const logLabel of [existingTarget, fallback]) { + const config = { + providers: {}, + codexAccountNamespaces: { + existing: existingTarget, + fallbackOwner: fallbackOwnerId, + mainAccount: "@main", + }, + }; + const newAccountId = `new-account-${logLabel}`; + expect(appendDefaultCodexAccountNamespace(config, { + id: newAccountId, + logLabel, + isMain: false, + })).toBe(true); + const selector = Object.entries(config.codexAccountNamespaces) + .find(([, target]) => target === newAccountId)?.[0]; + expect(selector).toMatch(CODEX_ACCOUNT_LOG_LABEL_RE); + expect(selector).not.toBe(logLabel); + } + }); + + test("append protects pool accounts omitted from an intentionally incomplete map", () => { + const unmappedPrivateId = "p121212"; + const config = { + providers: { + p343434: { adapter: "openai-chat", baseUrl: "https://example.test/v1" }, + }, + codexAccounts: [ + { id: unmappedPrivateId, logLabel: "p565656", isMain: false }, + { id: "p343434-2", logLabel: "p787878", isMain: false }, + ], + codexAccountNamespaces: { mainAccount: "@main" }, + }; + + expect(appendDefaultCodexAccountNamespace(config, { + id: "new-account-id", + logLabel: unmappedPrivateId, + isMain: false, + })).toBe(true); + const selector = Object.entries(config.codexAccountNamespaces) + .find(([, target]) => target === "new-account-id")?.[0]; + expect(selector).toMatch(CODEX_ACCOUNT_LOG_LABEL_RE); + expect(selector).not.toBe(unmappedPrivateId); + + expect(appendDefaultCodexAccountNamespace(config, { + id: "second-new-account-id", + logLabel: "p343434", + isMain: false, + })).toBe(true); + const suffixedSelector = Object.entries(config.codexAccountNamespaces) + .find(([, target]) => target === "second-new-account-id")?.[0]; + expect(suffixedSelector).toBe("p343434-3"); + }); + + test("keeps empty maps inert and distinguishes a pool id named main from the Desktop account", () => { + const empty = { providers: {}, codexAccountNamespaces: {} as Record }; + expect(appendDefaultCodexAccountNamespace(empty, { + id: "side-account-id", + email: "side@example.test", + logLabel: "p333333", + isMain: false, + })).toBe(false); + expect(empty.codexAccountNamespaces).toEqual({}); + + expect(defaultCodexAccountNamespaces({ + providers: {}, + codexAccounts: [ + { id: "main", email: "first@example.test", logLabel: "p454545", isMain: false }, + { id: MAIN_CODEX_ACCOUNT_ID, email: "second@example.test", isMain: false }, + { id: "desktop-row", email: "third@example.test", logLabel: "p464646", isMain: true }, + ], + })).toEqual({ "main-2": "@main", p454545: "main" }); + }); + + test("matches route and account namespaces exactly but provider namespaces case-insensitively", () => { + const inherited = Object.create({ inherited: "account-id" }) as Record; + inherited.side = "side-account-id"; + + expect(hasCodexAccountNamespace(inherited, "side")).toBe(true); + expect(hasCodexAccountNamespace(inherited, "inherited")).toBe(false); + expect(codexAccountNamespaceForModel(inherited, "side/gpt-5.5")).toBe("side"); + expect(codexAccountNamespaceForModel(inherited, "Side/gpt-5.5")).toBeUndefined(); + expect(codexAccountNamespaceForModel(inherited, "gpt-5.5")).toBeUndefined(); + expect(codexAccountNamespaceProviderCollisionError(inherited, "side")) + .toBe("provider name must not collide with a configured Codex account namespace"); + expect(codexAccountNamespaceProviderCollisionError(inherited, "SIDE")) + .toBe("provider name must not collide with a configured Codex account namespace"); + expect(codexAccountNamespaceProviderCollisionError(inherited, "inherited")).toBeUndefined(); + expect(codexAccountIdNamespaceCollisionError(inherited, "side")) + .toBe("account id must not collide with a configured Codex account namespace"); + expect(codexAccountIdNamespaceCollisionError(inherited, "Side")).toBeUndefined(); + expect(codexAccountIdNamespaceCollisionError(inherited, "inherited")).toBeUndefined(); + }); + + test("normalizes only the explicit main sentinel and keeps a pool id named main literal", () => { + expect(isMainCodexAccountTarget("@main")).toBe(true); + expect(isMainCodexAccountTarget("main")).toBe(false); + expect(isMainCodexAccountTarget(MAIN_CODEX_ACCOUNT_ID)).toBe(true); + expect(isValidCodexAccountNamespaceTarget("@main")).toBe(true); + expect(isValidCodexAccountNamespaceTarget(MAIN_CODEX_ACCOUNT_ID)).toBe(false); + expect(isValidCodexAccountNamespaceTarget("side-account_2.test")).toBe(true); + expect(isValidCodexAccountNamespaceTarget(" account ")).toBe(false); + expect(isValidCodexAccountNamespaceTarget("account/id")).toBe(false); + for (const reserved of ["__proto__", "prototype", "constructor", "Constructor"]) { + expect(isValidCodexAccountNamespaceTarget(reserved)).toBe(false); + } + expect(codexAccountNamespaceEntries({ + codexAccountNamespaces: { primary: "@main", poolNamedMain: "main", side: "side-id" }, + })).toEqual([ + ["primary", MAIN_CODEX_ACCOUNT_ID], + ["poolNamedMain", "main"], + ["side", "side-id"], + ]); + }); +}); diff --git a/tests/codex-auth-api.test.ts b/tests/codex-auth-api.test.ts index 585d6eb6cc..12d38b36d9 100644 --- a/tests/codex-auth-api.test.ts +++ b/tests/codex-auth-api.test.ts @@ -9,7 +9,12 @@ import { markAccountNeedsReauth, isAccountNeedsReauth, clearAccountNeedsReauth, clearAccountQuota, maskEmail, } from "../src/codex/auth-api"; -import { getCodexAccountCredential, readCodexAccountRecord, saveCodexAccountCredential } from "../src/codex/account-store"; +import { + getCodexAccountCredential, + listCodexAccountIds, + readCodexAccountRecord, + saveCodexAccountCredential, +} from "../src/codex/account-store"; import { getCodexUpstreamHealth, recordCodexUpstreamOutcome, @@ -24,6 +29,7 @@ import type { OcxConfig } from "../src/types"; import type { WsData } from "../src/server/ws-bridge"; import { MAIN_CODEX_ACCOUNT_ID } from "../src/codex/main-account"; import { + deleteCodexAccount, reconcileMainCodexAccountRuntimeState, resetMainCodexAccountIdentityTrackingForTests, } from "../src/codex/account-lifecycle"; @@ -80,6 +86,84 @@ function mockCodexWarmupSuccess(): { calls: () => number } { return { calls: () => calls }; } +async function completeMockCodexOAuth(options: { + config: OcxConfig; + requestBody: { id: string; reauth?: boolean }; + oauthAccountId: string; + email: string; + onWarmup: () => void; + usageResponse?: () => Response; +}): Promise<{ startStatus: number; state: { status: string; error?: string } }> { + const oauth = await import("../src/oauth"); + const oauthStore = await import("../src/oauth/store"); + const openUrlMod = await import("../src/lib/open-url"); + await oauthStore.saveCredential("chatgpt", { + access: `access-${options.requestBody.id}`, + refresh: `refresh-${options.requestBody.id}`, + expires: Date.now() + 5 * 60_000, + email: options.email, + accountId: options.oauthAccountId, + }); + const startSpy = spyOn(oauth, "startLoginFlow").mockResolvedValue({ url: "https://example.test/oauth" }); + const statusSpy = spyOn(oauth, "getLoginStatus").mockReturnValue({ + done: true, + loggedIn: true, + } as ReturnType); + const openSpy = spyOn(openUrlMod, "openUrl").mockImplementation(() => {}); + // Mirrors the login-status poll delay in auth-api.ts; other timers are intentionally dropped. + const CODEX_OAUTH_LOGIN_POLL_INTERVAL_MS = 2_000; + const timeoutSpy = spyOn(globalThis, "setTimeout").mockImplementation((( + callback: (...args: unknown[]) => void, + delay?: number, + ...args: unknown[] + ) => { + if (delay === CODEX_OAUTH_LOGIN_POLL_INTERVAL_MS) queueMicrotask(() => callback(...args)); + return 0 as unknown as ReturnType; + }) as typeof setTimeout); + globalThis.fetch = (async (input: RequestInfo | URL) => { + const target = String(input); + if (target === "https://chatgpt.com/backend-api/wham/usage") { + return options.usageResponse?.() + ?? new Response(JSON.stringify({ email: options.email, plan_type: "pro" }), { status: 200 }); + } + if (target === "https://chatgpt.com/backend-api/codex/responses") { + options.onWarmup(); + return new Response('event: response.completed\ndata: {"type":"response.completed"}\n\n', { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }); + } + return previousFetch(input); + }) as typeof fetch; + + try { + const req = new Request("http://localhost/api/codex-auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(options.requestBody), + }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), options.config); + const started = await resp!.json() as { flowId: string }; + for (let attempt = 0; attempt < 500; attempt += 1) { + const statusReq = new Request( + `http://localhost/api/codex-auth/login-status?flowId=${started.flowId}`, + { method: "GET" }, + ); + const statusResp = await handleCodexAuthAPI(statusReq, new URL(statusReq.url), options.config); + const state = await statusResp!.json() as { status: string; error?: string }; + if (state.status !== "pending") return { startStatus: resp!.status, state }; + await new Promise(resolve => queueMicrotask(resolve)); + } + throw new Error(`Timed out waiting for Codex OAuth flow ${started.flowId}`); + } finally { + globalThis.fetch = previousFetch; + timeoutSpy.mockRestore(); + startSpy.mockRestore(); + statusSpy.mockRestore(); + openSpy.mockRestore(); + } +} + function seedPoolAccount( config: OcxConfig, account: { @@ -938,6 +1022,25 @@ describe("codex-auth API", () => { expect(body.error).toContain("Invalid account id"); }); + test.each([ + MAIN_CODEX_ACCOUNT_ID, + "__proto__", + "prototype", + "constructor", + "Constructor", + ])("POST /api/codex-auth/accounts rejects reserved account id %s", async (accountId) => { + enableManualImport(); + const req = new Request("http://localhost/api/codex-auth/accounts", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(manualImportBody({ id: accountId })), + }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), makeConfig()); + expect(resp!.status).toBe(400); + expect(await resp!.json()).toMatchObject({ error: "Invalid account id format" }); + expect(getCodexAccountCredential(accountId)).toBeNull(); + }); + test("POST /api/codex-auth/accounts rejects invalid JSON when manual import is explicitly enabled", async () => { enableManualImport(); const req = new Request("http://localhost/api/codex-auth/accounts", { @@ -1068,6 +1171,62 @@ describe("codex-auth API", () => { }); }); + test("POST /api/codex-auth/accounts rejects an id owned by a namespace before warmup", async () => { + enableManualImport(); + let fetched = false; + globalThis.fetch = (async () => { + fetched = true; + return new Response("unexpected", { status: 500 }); + }) as typeof fetch; + const config = makeConfig({ codexAccountNamespaces: { work: "pool-a" } }); + const before = structuredClone(config); + const req = new Request("http://localhost/api/codex-auth/accounts", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(manualImportBody({ id: "work" })), + }); + + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + + expect(resp!.status).toBe(400); + expect(await resp!.json()).toMatchObject({ + error: "account id must not collide with a configured Codex account namespace", + }); + expect(fetched).toBe(false); + expect(config).toEqual(before); + expect(getCodexAccountCredential("work")).toBeNull(); + }); + + test("manual import rechecks namespace ownership after warmup before persistence", async () => { + enableManualImport(); + const config = makeConfig(); + globalThis.fetch = (async (input: RequestInfo | URL) => { + if (String(input) === "https://chatgpt.com/backend-api/codex/responses") { + config.codexAccountNamespaces = { "manual-race": "pool-a" }; + return new Response('event: response.completed\ndata: {"type":"response.completed"}\n\n', { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }); + } + return previousFetch(input); + }) as typeof fetch; + const req = new Request("http://localhost/api/codex-auth/accounts", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(manualImportBody({ id: "manual-race" })), + }); + + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + + expect(resp!.status).toBe(400); + expect(await resp!.json()).toMatchObject({ + error: "account id must not collide with a configured Codex account namespace", + }); + expect(config.codexAccounts).toEqual([]); + expect(config.codexAccountNamespaces).toEqual({ "manual-race": "pool-a" }); + expect(getCodexAccountCredential("manual-race")).toBeNull(); + }); + test("PUT /api/codex-auth/auto-switch rejects invalid threshold", async () => { for (const bad of [-1, 101, 50.5, "abc"]) { const req = new Request("http://localhost/api/codex-auth/auto-switch", { @@ -1126,6 +1285,17 @@ describe("codex-auth API", () => { expect(config.activeCodexAccountId).toBe("work"); }); + test("PUT /api/codex-auth/accounts/alias preserves the dedicated main-account error", async () => { + const req = new Request("http://localhost/api/codex-auth/accounts/alias", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: MAIN_CODEX_ACCOUNT_ID, alias: "Desktop" }), + }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), makeConfig()); + expect(resp!.status).toBe(400); + expect(await resp!.json()).toMatchObject({ error: "Main Codex account alias is not configurable" }); + }); + test("PUT /api/codex-auth/auto-switch mutates live runtime config", async () => { const config = makeConfig({ autoSwitchThreshold: 80 }); const req = new Request("http://localhost/api/codex-auth/auto-switch", { @@ -1192,6 +1362,98 @@ describe("codex-auth API", () => { expect(getTrackedCodexWebSocketCountForAccount("pool-delete")).toBe(0); }); + test.each([ + MAIN_CODEX_ACCOUNT_ID, + "__proto__", + "__PrOtO__", + "prototype", + "PROTOTYPE", + "constructor", + "Constructor", + ])("DELETE /api/codex-auth/accounts rejects reserved account id %s without mutation", async (accountId) => { + const config = makeConfig({ + activeCodexAccountId: "pool-untouched", + codexAccounts: [{ id: "pool-untouched", email: "pool-untouched@example.test", isMain: false }], + }); + saveCodexAccountCredential("pool-untouched", { + accessToken: "access-untouched", + refreshToken: "refresh-untouched", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "acct-untouched", + }); + const beforeConfig = structuredClone(config); + const beforeCredential = getCodexAccountCredential("pool-untouched"); + + const req = new Request( + `http://localhost/api/codex-auth/accounts?id=${encodeURIComponent(accountId)}`, + { method: "DELETE" }, + ); + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + + expect(resp!.status).toBe(400); + expect(await resp!.json()).toMatchObject({ error: "Invalid account id format" }); + expect(config).toEqual(beforeConfig); + expect(listCodexAccountIds()).toEqual(["pool-untouched"]); + expect(getCodexAccountCredential("pool-untouched")).toEqual(beforeCredential); + }); + + test("DELETE /api/codex-auth/accounts removes a configured legacy reserved account", async () => { + const accountId = "Constructor"; + const config = makeConfig({ + activeCodexAccountId: accountId, + codexAccounts: [{ id: accountId, email: "legacy@example.test", isMain: false }], + }); + saveCodexAccountCredential(accountId, { + accessToken: "legacy-access", + refreshToken: "legacy-refresh", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "legacy-account", + }); + + const req = new Request( + `http://localhost/api/codex-auth/accounts?id=${encodeURIComponent(accountId)}`, + { method: "DELETE" }, + ); + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + + expect(resp!.status).toBe(200); + expect(config.codexAccounts).toEqual([]); + expect(config.activeCodexAccountId).toBeUndefined(); + expect(getCodexAccountCredential(accountId)).toBeNull(); + }); + + test("legacy non-main __main__ is quarantined and removable without touching Desktop auth", async () => { + const config = makeConfig({ + activeCodexAccountId: MAIN_CODEX_ACCOUNT_ID, + codexAccounts: [{ id: MAIN_CODEX_ACCOUNT_ID, email: "invalid-legacy@example.test", isMain: false }], + }); + saveCodexAccountCredential(MAIN_CODEX_ACCOUNT_ID, { + accessToken: "legacy-pool-access", + refreshToken: "legacy-pool-refresh", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "legacy-pool-account", + }); + writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ + tokens: { access_token: "desktop-access", account_id: "desktop-account" }, + })); + + expect(getMainChatgptAccountId()).toBe("desktop-account"); + expect(resolveCodexAccountForThread("legacy-main-row", config)).toBeNull(); + + const req = new Request( + `http://localhost/api/codex-auth/accounts?id=${encodeURIComponent(MAIN_CODEX_ACCOUNT_ID)}`, + { method: "DELETE" }, + ); + + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + + expect(resp!.status).toBe(200); + expect(config.codexAccounts).toEqual([]); + expect(config.activeCodexAccountId).toBeUndefined(); + expect(getCodexAccountCredential(MAIN_CODEX_ACCOUNT_ID)).toBeNull(); + expect(getMainChatgptAccountId()).toBe("desktop-account"); + }); + test("GET /api/codex-auth/login-status returns idle by default", async () => { const req = new Request("http://localhost/api/codex-auth/login-status", { method: "GET" }); const url = new URL(req.url); @@ -1391,6 +1653,23 @@ describe("codex-auth API", () => { expect(data.error).toContain("Invalid account id"); }); + test.each([ + MAIN_CODEX_ACCOUNT_ID, + "__proto__", + "prototype", + "constructor", + "Constructor", + ])("POST /api/codex-auth/login rejects reserved account id %s before OAuth starts", async (accountId) => { + const req = new Request("http://localhost/api/codex-auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: accountId }), + }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), makeConfig()); + expect(resp!.status).toBe(400); + expect(await resp!.json()).toMatchObject({ error: "Invalid account id format" }); + }); + test("POST /api/codex-auth/login rejects duplicate account id before OAuth starts", async () => { saveCodexAccountCredential("existing", { accessToken: "tok", @@ -1425,6 +1704,30 @@ describe("codex-auth API", () => { expect(data.error).toContain("Account id already exists"); }); + test("POST /api/codex-auth/login rejects an id owned by a namespace before OAuth starts", async () => { + const oauth = await import("../src/oauth"); + const startSpy = spyOn(oauth, "startLoginFlow"); + try { + const config = makeConfig({ codexAccountNamespaces: { work: "pool-a" } }); + const req = new Request("http://localhost/api/codex-auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: "work" }), + }); + + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + + expect(resp!.status).toBe(400); + expect(await resp!.json()).toMatchObject({ + error: "account id must not collide with a configured Codex account namespace", + }); + expect(startSpy).not.toHaveBeenCalled(); + expect(getCodexAccountCredential("work")).toBeNull(); + } finally { + startSpy.mockRestore(); + } + }); + test("POST /api/codex-auth/login without reauth still rejects existing account id", async () => { const config = makeConfig({ codexAccounts: [{ id: "pool-no-reauth", email: "pool-no-reauth@example.test", isMain: false }], @@ -1481,6 +1784,7 @@ describe("codex-auth API", () => { test("POST /api/codex-auth/login with reauth does not reject existing pool account id", async () => { const config = makeConfig({ + codexAccountNamespaces: { "pool-reauth": "other-account" }, codexAccounts: [{ id: "pool-reauth", email: "pool-reauth@example.test", isMain: false }], }); saveCodexAccountCredential("pool-reauth", { @@ -1510,6 +1814,7 @@ describe("codex-auth API", () => { expect(resp!.status).not.toBe(400); const data = await resp!.json() as { ok?: boolean; flowId?: string; error?: string }; expect(data.error ?? "").not.toContain("already exists"); + expect(data.error ?? "").not.toContain("namespace"); expect(data.ok).toBe(true); expect(data.flowId).toBeTruthy(); expect(startSpy).toHaveBeenCalled(); @@ -1520,6 +1825,104 @@ describe("codex-auth API", () => { } }); + test("OAuth creation rejects a namespace claimed during warmup without persisting", async () => { + const config = makeConfig(); + const result = await completeMockCodexOAuth({ + config, + requestBody: { id: "oauth-race" }, + oauthAccountId: "acct-oauth-race", + email: "oauth-race@example.test", + onWarmup: () => { + config.codexAccountNamespaces = { "oauth-race": "pool-a" }; + }, + }); + + expect(result.startStatus).toBe(200); + expect(result.state).toMatchObject({ + status: "error", + error: "account id must not collide with a configured Codex account namespace", + }); + expect(config.codexAccounts).toEqual([]); + expect(config.codexAccountNamespaces).toEqual({ "oauth-race": "pool-a" }); + expect(getCodexAccountCredential("oauth-race")).toBeNull(); + }); + + test("OAuth reauth cannot recreate an account deleted during warmup", async () => { + const config = makeConfig({ + codexAccounts: [{ id: "reauth-race", email: "reauth-race@example.test", isMain: false }], + }); + saveCodexAccountCredential("reauth-race", { + accessToken: "old-reauth-access", + refreshToken: "old-reauth-refresh", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "acct-reauth-race", + }); + const result = await completeMockCodexOAuth({ + config, + requestBody: { id: "reauth-race", reauth: true }, + oauthAccountId: "acct-reauth-race", + email: "reauth-race@example.test", + onWarmup: () => deleteCodexAccount(config, "reauth-race"), + }); + + expect(result.startStatus).toBe(200); + expect(result.state).toMatchObject({ + status: "error", + error: "Pool account was removed while login was in progress. Add it again as a new account.", + }); + expect(config.codexAccounts).toEqual([]); + expect(getCodexAccountCredential("reauth-race")).toBeNull(); + }); + + test("deleteCodexAccount preserves a main account with the requested id", () => { + const mainAccount = { + id: "shared-account-id", + email: "main@example.test", + isMain: true, + }; + const config = makeConfig({ codexAccounts: [mainAccount] }); + saveCodexAccountCredential("shared-account-id", { + accessToken: "main-access", + refreshToken: "main-refresh", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "shared-account-id", + }); + + deleteCodexAccount(config, "shared-account-id"); + + expect(config.codexAccounts).toEqual([mainAccount]); + expect(getCodexAccountCredential("shared-account-id")).toBeNull(); + }); + + test("OAuth reauth preserves the stored plan when the usage probe fails", async () => { + const config = makeConfig({ + codexAccounts: [{ + id: "reauth-plan", + email: "reauth-plan@example.test", + plan: "business", + isMain: false, + }], + }); + saveCodexAccountCredential("reauth-plan", { + accessToken: "old-plan-access", + refreshToken: "old-plan-refresh", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "acct-reauth-plan", + }); + + const result = await completeMockCodexOAuth({ + config, + requestBody: { id: "reauth-plan", reauth: true }, + oauthAccountId: "acct-reauth-plan", + email: "reauth-plan@example.test", + onWarmup: () => {}, + usageResponse: () => new Response("unavailable", { status: 503 }), + }); + + expect(result.state).toMatchObject({ status: "done" }); + expect(config.codexAccounts?.[0]?.plan).toBe("business"); + }); + test("OAuth pool login excludes self from collision check when reauth", async () => { const source = await Bun.file("src/codex/auth-api.ts").text(); expect(source).toContain("checkAccountIdCollision(oauthAccountId, email, plan, reauth ? accountId : undefined)"); diff --git a/tests/combo-management-api.test.ts b/tests/combo-management-api.test.ts index 5bbf5a0afb..6d0135755d 100644 --- a/tests/combo-management-api.test.ts +++ b/tests/combo-management-api.test.ts @@ -294,6 +294,27 @@ describe("combo management API", () => { }); }); + test("PUT rejects aliases owned by a Codex account namespace without mutating config", async () => { + await withTempHome(async () => { + const config = baseConfig({ codexAccountNamespaces: { side: "side-account-id" } }); + saveConfig(config); + const beforeMemory = structuredClone(config); + const beforeDisk = readFileSync(getConfigPath(), "utf8"); + + const response = await comboApi(config, "PUT", "/api/combos", { + id: "intentional", + combo: { ...VALID_COMBO, alias: "side/gpt-5.5" }, + }); + + expect(response?.status).toBe(409); + expect(await responseJson(response)).toEqual({ + error: "combo alias must not use a configured Codex account namespace", + }); + expect(config).toEqual(beforeMemory); + expect(readFileSync(getConfigPath(), "utf8")).toBe(beforeDisk); + }); + }); + test("PUT rejects invalid and duplicate aliases without memory or disk mutation", async () => { await withTempHome(async () => { const config = baseConfig({ diff --git a/tests/config-user-edits.test.ts b/tests/config-user-edits.test.ts index 87331c3260..b3d4897f00 100644 --- a/tests/config-user-edits.test.ts +++ b/tests/config-user-edits.test.ts @@ -6,6 +6,7 @@ import { armClaudeCodeBaseline, getConfigPath, loadConfig, + reconcileLiveConfigFromDisk, saveConfig, saveConfigPreservingClaudeCode, } from "../src/config"; @@ -136,6 +137,42 @@ test("our own change wins a conflict and rebases the baseline", () => { expect((diskConfig().claudeCode as Record).authMode).toBe("proxy"); }); +test("OAuth reconciliation keeps a pending live Claude subtree authoritative", () => { + const live = loadConfig(); + armClaudeCodeBaseline(live); + const persistedBaseline = loadConfig(); + live.claudeCode = { authMode: "subscription", systemEnv: true }; + live.disabledModels = ["pending/model"]; + writeDiskConfig({ + claudeCode: { authMode: "proxy" }, + contextCapValue: 240_000, + }); + + reconcileLiveConfigFromDisk(live, persistedBaseline); + + expect(live.claudeCode).toEqual({ authMode: "subscription", systemEnv: true }); + expect(live.disabledModels).toEqual(["pending/model"]); + expect(live.contextCapValue).toBe(240_000); + + saveConfigPreservingClaudeCode(live); + expect(diskConfig().claudeCode).toEqual({ authMode: "subscription", systemEnv: true }); + expect(diskConfig().disabledModels).toEqual(["pending/model"]); + expect(diskConfig().contextCapValue).toBe(240_000); +}); + +test("OAuth reconciliation adopts a guarded Claude edit that predates its disk snapshot", () => { + const live = loadConfig(); + armClaudeCodeBaseline(live); + writeDiskConfig({ claudeCode: { authMode: "proxy" } }); + const persistedBaseline = loadConfig(); + + reconcileLiveConfigFromDisk(live, persistedBaseline); + + expect(live.claudeCode).toEqual({ authMode: "proxy" }); + saveConfigPreservingClaudeCode(live); + expect(diskConfig().claudeCode).toEqual({ authMode: "proxy" }); +}); + // Structural compare, not JSON.stringify: key order must not fake an external edit. test("a key-order-only difference is not treated as an external edit", () => { const live = loadConfig(); diff --git a/tests/config.test.ts b/tests/config.test.ts index 54cfa7292b..9873b2bf51 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -66,6 +66,25 @@ function writeResponsesPathConfig(responsesPath: string): void { }); } +function writeAccountNamespaceConfig( + codexAccountNamespaces: unknown, + overrides: Record = {}, +): void { + writeConfig({ + port: 10100, + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + }, + }, + defaultProvider: "openai", + codexAccountNamespaces, + ...overrides, + }); +} + describe("opencodex config defaults", () => { test("atomic rename retries transient Windows sharing violations", () => { const sleeps: number[] = []; @@ -991,6 +1010,170 @@ describe("opencodex config defaults", () => { expect(isValidProviderName("constructor")).toBe(false); }); + test("persists an explicit Codex account selector map without enabling it by default", () => { + const selectors = { + desktop: "@main", + work: "work-account", + legacy: "work-account", + poolNamedMain: "main", + }; + writeAccountNamespaceConfig(selectors); + + const diagnostics = readConfigDiagnostics(); + expect(diagnostics.error).toBeNull(); + expect(diagnostics.config.codexAccountNamespaces).toEqual(selectors); + expect(Object.hasOwn(getDefaultConfig(), "codexAccountNamespaces")).toBe(false); + }); + + test("validates Claude Desktop profiles and Codex account selectors independently", () => { + const desktopProfile = { + version: 1, + assignments: {}, + defaults: { opus: null, fable: null, sonnet: null, haiku: null }, + }; + writeAccountNamespaceConfig({ main: "@main" }, { claudeCode: { desktopProfile } }); + expect(readConfigDiagnostics()).toMatchObject({ + error: null, + config: { claudeCode: { desktopProfile }, codexAccountNamespaces: { main: "@main" } }, + }); + + writeAccountNamespaceConfig({ main: "@main" }, { + claudeCode: { desktopProfile: { ...desktopProfile, version: 2 } }, + }); + expect(readConfigDiagnostics().error).toContain("claudeCode.desktopProfile"); + + writeAccountNamespaceConfig({ "bad/selector": "account-id" }, { claudeCode: { desktopProfile } }); + expect(readConfigDiagnostics().error).toContain("codexAccountNamespaces.bad/selector"); + }); + + test.each([ + ["null", null], + ["an array", []], + ["a string", "main"], + ] as const)("rejects Codex account selectors stored as %s", (_label, selectors) => { + writeAccountNamespaceConfig(selectors); + + const diagnostics = readConfigDiagnostics(); + expect(diagnostics.source).toBe("fallback"); + expect(diagnostics.error).toContain("codexAccountNamespaces must be a plain object"); + }); + + test.each([ + ["blank", "", "side-account"], + ["surrounding whitespace", " side", "side-account"], + ["a slash", "side/account", "side-account"], + ["a reserved prototype key", "__proto__", "side-account"], + ["a reserved constructor key", "constructor", "side-account"], + ["an empty target", "side", ""], + ["the internal main account id", "side", "__main__"], + ["a reserved prototype target", "side", "__proto__"], + ["a reserved prototype-name target", "side", "prototype"], + ["a reserved constructor target", "side", "Constructor"], + ["a target with whitespace", "side", "side account"], + ["a target with a slash", "side", "account/id"], + ["an overlong target", "side", "a".repeat(65)], + ["a non-string target", "side", 42], + ] as const)("rejects %s in the Codex account selector map", (_label, selector, target) => { + writeAccountNamespaceConfig(Object.fromEntries([[selector, target]])); + + const diagnostics = readConfigDiagnostics(); + expect(diagnostics.source).toBe("fallback"); + expect(diagnostics.error).toContain(`codexAccountNamespaces.${selector}`); + }); + + test.each([ + [ + "a configured provider", + { side: "side-account" }, + { + providers: { + side: { adapter: "openai-chat", baseUrl: "https://side.example.test/v1" }, + }, + defaultProvider: "side", + }, + "must not collide", + ], + [ + "a configured provider with different casing", + { SIDE: "side-account" }, + { + providers: { + side: { adapter: "openai-chat", baseUrl: "https://side.example.test/v1" }, + }, + defaultProvider: "side", + }, + "must not collide", + ], + ["the combo namespace", { combo: "side-account" }, {}, "must not collide"], + ["the combo namespace with different casing", { Combo: "side-account" }, {}, "must not collide"], + ["the canonical OpenAI namespace with different casing", { OpenAI: "side-account" }, {}, "must not collide"], + [ + "the canonical OpenAI provider namespace before legacy migration", + { openai: "side-account" }, + { + providers: { + "openai-multi": { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + }, + }, + defaultProvider: "openai-multi", + }, + "must not collide", + ], + [ + "a combo alias prefix", + { side: "side-account" }, + { + combos: { + intentional: { + alias: "side/gpt-5.5", + targets: [{ provider: "openai", model: "gpt-5.5" }], + }, + }, + }, + "combo alias must not use a configured Codex account namespace", + ], + [ + "a whitespace-padded combo alias prefix", + { side: "side-account" }, + { + combos: { + intentional: { + alias: " side/gpt-5.5 ", + targets: [{ provider: "openai", model: "gpt-5.5" }], + }, + }, + }, + "combo alias must not use a configured Codex account namespace", + ], + [ + "a configured pool account id", + { work: "pool-a" }, + { + codexAccounts: [{ + id: "work", + email: "work@example.test", + isMain: false, + }], + }, + "must not collide with configured Codex pool-account ids or account selector targets", + ], + [ + "another selector target", + { primary: "side", side: "pool-a" }, + {}, + "must not collide with configured Codex pool-account ids or account selector targets", + ], + ] as const)("rejects a Codex account selector colliding with %s", (_label, selectors, overrides, error) => { + writeAccountNamespaceConfig(selectors, overrides); + + const diagnostics = readConfigDiagnostics(); + expect(diagnostics.source).toBe("fallback"); + expect(diagnostics.error).toContain(error); + }); + test("backs up config when defaultProvider only exists on Object prototype", () => { writeConfig({ port: 10100, diff --git a/tests/kiro-review-regressions.test.ts b/tests/kiro-review-regressions.test.ts index 9f11edb1b7..b1e18f2d91 100644 --- a/tests/kiro-review-regressions.test.ts +++ b/tests/kiro-review-regressions.test.ts @@ -221,7 +221,13 @@ describe("Kiro review regressions", () => { OAUTH_PROVIDERS.kiro.login = originalLogin; } - expect(events).toEqual(["load-config", "save-config", "settle:false"]); + expect(events).toEqual([ + "load-config", // namespace preflight before browser/CLI auth + "load-config", // provider validation before credential persistence + "load-config", // latest-row upsert after credential persistence + "save-config", + "settle:false", + ]); expect(getAccountSet("kiro")?.activeAccountId).toBe(previousActive); expect(getAccountSet("kiro")?.accounts).toHaveLength(1); expect(getAccountCredential("kiro", previousActive)).toMatchObject({ diff --git a/tests/management-provider-validation.test.ts b/tests/management-provider-validation.test.ts index dd11eacb2b..4f9b15abd2 100644 --- a/tests/management-provider-validation.test.ts +++ b/tests/management-provider-validation.test.ts @@ -517,6 +517,44 @@ describe("provider management validation", () => { } }); + test("provider management rejects names owned by a Codex account namespace without mutating config", async () => { + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + const cfg = { + ...config("127.0.0.1"), + codexAccountNamespaces: { side: "side-account-id" }, + }; + saveConfig(cfg); + const beforeMemory = structuredClone(cfg); + const beforeDisk = readFileSync(join(TEST_DIR, "config.json"), "utf8"); + + const requestUrl = new URL("http://127.0.0.1/api/providers"); + const response = await handleManagementAPI( + new Request(requestUrl, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: "side", + provider: { + adapter: "openai-chat", + baseUrl: "https://side.example.test/v1", + }, + }), + }), + requestUrl, + cfg, + { refreshCodexCatalog: async () => {} }, + ); + + expect(response?.status).toBe(409); + expect(await response?.json()).toEqual({ + error: "provider name must not collide with a configured Codex account namespace", + }); + expect(cfg).toEqual(beforeMemory); + expect(readFileSync(join(TEST_DIR, "config.json"), "utf8")).toBe(beforeDisk); + }); + test("provider management rejects base URLs with embedded credentials", async () => { if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); mkdirSync(TEST_DIR, { recursive: true }); diff --git a/tests/oauth-public-surface.test.ts b/tests/oauth-public-surface.test.ts index aa699e4170..43668008d0 100644 --- a/tests/oauth-public-surface.test.ts +++ b/tests/oauth-public-surface.test.ts @@ -1,18 +1,24 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { mkdirSync, rmSync } from "node:fs"; import { join } from "node:path"; import { + clearLoginState, + getLoginStatus, isOAuthProvider, isPublicOAuthProvider, listOAuthProviders, OAUTH_PROVIDERS, runLogin, + startLoginFlow, upsertOAuthProvider, } from "../src/oauth"; import { handleManagementAPI } from "../src/server/management-api"; import type { OcxConfig } from "../src/types"; import type { OAuthController } from "../src/oauth/types"; import { getCredential } from "../src/oauth/store"; +import * as oauthStore from "../src/oauth/store"; +import { armClaudeCodeBaseline, loadConfig, saveConfig, saveConfigPreservingClaudeCode } from "../src/config"; +import { isApiAuthRequired, requireApiAuth } from "../src/server/auth-cors"; const TEST_DIR = join(import.meta.dir, ".tmp-oauth-public-surface"); const previousHome = process.env.OPENCODEX_HOME; @@ -32,17 +38,28 @@ function config(): OcxConfig { } beforeEach(() => { + clearLoginState("xai"); rmSync(TEST_DIR, { recursive: true, force: true }); mkdirSync(TEST_DIR, { recursive: true }); process.env.OPENCODEX_HOME = TEST_DIR; }); afterEach(() => { + clearLoginState("xai"); if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; rmSync(TEST_DIR, { recursive: true, force: true }); }); +async function waitForOAuthDone(provider: string): Promise> { + for (let attempt = 0; attempt < 200; attempt += 1) { + const status = getLoginStatus(provider); + if (status.done) return status; + await Bun.sleep(5); + } + throw new Error(`OAuth login for ${provider} did not settle`); +} + describe("legacy ChatGPT OAuth public-surface exclusion", () => { test("keeps low-level compatibility but excludes public discovery", () => { expect(isOAuthProvider("chatgpt")).toBe(true); @@ -99,4 +116,421 @@ describe("legacy ChatGPT OAuth public-surface exclusion", () => { expect(getCredential("chatgpt")?.access).toBe("legacy-access"); expect(cfg.providers.chatgpt).toBeUndefined(); }); + + test("OAuth provider creation rejects account namespace collisions before login or mutation", async () => { + const cfg = config(); + cfg.codexAccountNamespaces = { XAI: "side-account-id" }; + const before = structuredClone(cfg.providers); + + expect(() => upsertOAuthProvider(cfg, "xai")).toThrow(/must not collide with a configured Codex account namespace/); + expect(cfg.providers).toEqual(before); + + const routeReq = new Request("http://localhost/api/oauth/login", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ provider: "xai" }), + }); + const routeResponse = await handleManagementAPI(routeReq, new URL(routeReq.url), cfg); + expect(routeResponse?.status).toBe(409); + expect(await routeResponse?.json()).toEqual({ + error: "provider name must not collide with a configured Codex account namespace", + }); + + saveConfig(cfg); + const originalLogin = OAUTH_PROVIDERS.xai.login; + let loginCalls = 0; + OAUTH_PROVIDERS.xai.login = async () => { + loginCalls += 1; + return { access: "must-not-save", refresh: "must-not-save" }; + }; + try { + await expect(runLogin("xai", {} as OAuthController)).rejects.toThrow( + /must not collide with a configured Codex account namespace/, + ); + } finally { + OAUTH_PROVIDERS.xai.login = originalLogin; + } + expect(loginCalls).toBe(0); + expect(getCredential("xai")).toBeNull(); + + saveConfig(config()); + OAUTH_PROVIDERS.xai.login = async () => { + loginCalls += 1; + const changedDuringLogin = config(); + changedDuringLogin.codexAccountNamespaces = { xai: "side-account-id" }; + saveConfig(changedDuringLogin); + return { access: "must-not-save", refresh: "must-not-save" }; + }; + try { + await expect(runLogin("xai", {} as OAuthController)).rejects.toThrow( + /must not collide with a configured Codex account namespace/, + ); + } finally { + OAUTH_PROVIDERS.xai.login = originalLogin; + } + expect(loginCalls).toBe(1); + expect(getCredential("xai")).toBeNull(); + }); + + test("OAuth provider creation preserves a namespace claimed after credential persistence", async () => { + saveConfig(config()); + const originalLogin = OAUTH_PROVIDERS.xai.login; + const originalSaveCredential = oauthStore.saveCredential; + let changedAfterCredential = false; + let credentialWrites = 0; + OAUTH_PROVIDERS.xai.login = async () => ({ + access: "post-check-access", + refresh: "post-check-refresh", + accountId: "post-check-account", + expires: Date.now() + 60_000, + }); + const saveSpy = spyOn(oauthStore, "saveCredential").mockImplementation(async (provider, credential) => { + credentialWrites += 1; + await originalSaveCredential(provider, credential); + const changed = config(); + changed.defaultProvider = "concurrent"; + changed.providers.concurrent = { + adapter: "openai-chat", + baseUrl: "https://concurrent.example.test/v1", + }; + changed.codexAccountNamespaces = { + XAI: "side-account-id", + retained: "retained-account-id", + }; + saveConfig(changed); + changedAfterCredential = true; + }); + + try { + await expect(runLogin("xai", {} as OAuthController)).rejects.toThrow( + /credential for "xai" was saved, but the provider entry was not written/, + ); + } finally { + OAUTH_PROVIDERS.xai.login = originalLogin; + saveSpy.mockRestore(); + } + + expect(changedAfterCredential).toBe(true); + expect(credentialWrites).toBe(1); + expect(getCredential("xai")?.access).toBe("post-check-access"); + const persistedConfig = loadConfig(); + expect(persistedConfig).toMatchObject({ + defaultProvider: "concurrent", + providers: { + concurrent: { + adapter: "openai-chat", + baseUrl: "https://concurrent.example.test/v1", + }, + }, + codexAccountNamespaces: { + XAI: "side-account-id", + retained: "retained-account-id", + }, + }); + expect(persistedConfig.providers.xai).toBeUndefined(); + }); + + test("OAuth provider creation preserves same-provider key changes during credential persistence", async () => { + const seeded = config(); + seeded.providers.xai = { + ...OAUTH_PROVIDERS.xai.providerConfig, + authMode: "key", + apiKey: "test-key-a", + apiKeyPool: [{ id: "key-a", key: "test-key-a" }], + }; + saveConfig(seeded); + const originalLogin = OAUTH_PROVIDERS.xai.login; + const originalSaveCredential = oauthStore.saveCredential; + OAUTH_PROVIDERS.xai.login = async () => ({ + access: "same-provider-access", + refresh: "same-provider-refresh", + accountId: "same-provider-account", + expires: Date.now() + 60_000, + }); + const saveSpy = spyOn(oauthStore, "saveCredential").mockImplementation(async (provider, credential) => { + await originalSaveCredential(provider, credential); + const changed = loadConfig(); + changed.providers.xai = { + ...changed.providers.xai!, + authMode: "key", + apiKey: "test-key-b", + apiKeyPool: [ + { id: "key-a", key: "test-key-a" }, + { id: "key-b", key: "test-key-b" }, + ], + }; + saveConfig(changed); + }); + + try { + await runLogin("xai", {} as OAuthController); + } finally { + OAUTH_PROVIDERS.xai.login = originalLogin; + saveSpy.mockRestore(); + } + + expect(loadConfig().providers.xai).toMatchObject({ + authMode: "key", + apiKey: "test-key-b", + apiKeyPool: [ + { id: "key-a", key: "test-key-a" }, + { id: "key-b", key: "test-key-b" }, + ], + }); + }); + + test("management OAuth activates the live provider only after persistence succeeds", async () => { + const liveConfig = config(); + saveConfig(liveConfig); + const originalLogin = OAUTH_PROVIDERS.xai.login; + let releaseLogin!: () => void; + const loginGate = new Promise((resolve) => { releaseLogin = resolve; }); + OAUTH_PROVIDERS.xai.login = async (ctrl) => { + ctrl.onAuth({ + url: "https://auth.example.test/authorize", + deviceCode: "test-device-code", + }); + await loginGate; + return { + access: "successful-access", + refresh: "successful-refresh", + accountId: "successful-account", + expires: Date.now() + 60_000, + }; + }; + + try { + const request = new Request("http://localhost/api/oauth/login", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ provider: "xai" }), + }); + const response = await handleManagementAPI(request, new URL(request.url), liveConfig); + expect(response?.status).toBe(200); + expect(liveConfig.providers.xai).toBeUndefined(); + expect(getLoginStatus("xai").done).toBe(false); + + releaseLogin(); + const status = await waitForOAuthDone("xai"); + expect(status.error).toBeUndefined(); + expect(status.loggedIn).toBe(true); + expect(liveConfig.providers.xai).toEqual(loadConfig().providers.xai); + expect(liveConfig.providers.xai).toBeDefined(); + } finally { + releaseLogin(); + OAUTH_PROVIDERS.xai.login = originalLogin; + clearLoginState("xai"); + } + }); + + test("management OAuth merges its provider row with a pending live provider edit", async () => { + const liveConfig = config(); + saveConfig(liveConfig); + liveConfig.providers.xai = { + ...OAUTH_PROVIDERS.xai.providerConfig, + selectedModels: ["pending-model"], + }; + const originalLogin = OAUTH_PROVIDERS.xai.login; + OAUTH_PROVIDERS.xai.login = async (ctrl) => { + ctrl.onAuth({ + url: "https://auth.example.test/authorize", + deviceCode: "same-provider-device-code", + }); + return { + access: "same-provider-access", + refresh: "same-provider-refresh", + accountId: "same-provider-account", + expires: Date.now() + 60_000, + }; + }; + + try { + const request = new Request("http://localhost/api/oauth/login", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ provider: "xai" }), + }); + const response = await handleManagementAPI(request, new URL(request.url), liveConfig); + expect(response?.status).toBe(200); + + const status = await waitForOAuthDone("xai"); + expect(status).toMatchObject({ done: true, loggedIn: true }); + expect(status.error).toBeUndefined(); + expect(liveConfig.providers.xai).toMatchObject({ + ...loadConfig().providers.xai, + selectedModels: ["pending-model"], + }); + + saveConfigPreservingClaudeCode(liveConfig); + expect(loadConfig().providers.xai?.selectedModels).toEqual(["pending-model"]); + } finally { + OAUTH_PROVIDERS.xai.login = originalLogin; + clearLoginState("xai"); + } + }); + + test("OAuth settlement preserves the original login failure", async () => { + saveConfig(config()); + const originalLogin = OAUTH_PROVIDERS.xai.login; + OAUTH_PROVIDERS.xai.login = async (ctrl) => { + ctrl.onAuth({ + url: "https://auth.example.test/authorize", + deviceCode: "failed-login-device-code", + }); + throw new Error("browser flow aborted"); + }; + + try { + await startLoginFlow("xai", undefined, { + onSettled: () => { throw new Error("runtime reconciliation failed"); }, + }); + const status = await waitForOAuthDone("xai"); + expect(status.done).toBe(true); + expect(status.error).toBe("browser flow aborted"); + } finally { + OAUTH_PROVIDERS.xai.login = originalLogin; + clearLoginState("xai"); + } + }); + + test("OAuth settlement reports reconciliation failure after a successful login", async () => { + saveConfig(config()); + const originalLogin = OAUTH_PROVIDERS.xai.login; + OAUTH_PROVIDERS.xai.login = async (ctrl) => { + ctrl.onAuth({ + url: "https://auth.example.test/authorize", + deviceCode: "successful-login-device-code", + }); + return { + access: "successful-access", + refresh: "successful-refresh", + accountId: "successful-account", + expires: Date.now() + 60_000, + }; + }; + + try { + await startLoginFlow("xai", undefined, { + onSettled: () => { throw new Error("runtime reconciliation failed"); }, + }); + const status = await waitForOAuthDone("xai"); + expect(status.done).toBe(true); + expect(status.error).toBe("runtime reconciliation failed"); + } finally { + OAUTH_PROVIDERS.xai.login = originalLogin; + clearLoginState("xai"); + } + }); + + test("management OAuth safely reconciles live config after a late namespace claim", async () => { + const liveConfig = config(); + liveConfig.hostname = "0.0.0.0"; + liveConfig.port = 10444; + liveConfig.claudeCode = { authMode: "subscription" }; + saveConfig(liveConfig); + armClaudeCodeBaseline(liveConfig); + // Model visibility has mutated the shared object but yielded before saving. OAuth + // must not replace it with the pre-mutation disk snapshot when login settles. + liveConfig.disabledModels = ["pending/provider-model"]; + const originalLogin = OAUTH_PROVIDERS.xai.login; + const originalSaveCredential = oauthStore.saveCredential; + OAUTH_PROVIDERS.xai.login = async (ctrl) => { + ctrl.onAuth({ + url: "https://auth.example.test/authorize", + deviceCode: "test-device-code", + }); + return { + access: "route-collision-access", + refresh: "route-collision-refresh", + accountId: "route-collision-account", + expires: Date.now() + 60_000, + }; + }; + const saveSpy = spyOn(oauthStore, "saveCredential").mockImplementation(async (provider, credential) => { + await originalSaveCredential(provider, credential); + const concurrentConfig = config(); + concurrentConfig.defaultProvider = "concurrent"; + concurrentConfig.providers.concurrent = { + adapter: "openai-chat", + baseUrl: "https://concurrent.example.test/v1", + }; + concurrentConfig.codexAccountNamespaces = { + XAI: "side-account-id", + retained: "retained-account-id", + }; + concurrentConfig.claudeCode = { authMode: "proxy" }; + saveConfig(concurrentConfig); + }); + + try { + const request = new Request("http://localhost/api/oauth/login", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ provider: "xai" }), + }); + const response = await handleManagementAPI(request, new URL(request.url), liveConfig); + expect(response?.status).toBe(200); + + const status = await waitForOAuthDone("xai"); + expect(status.loggedIn).toBe(true); + expect(status.error).toMatch(/credential for "xai" was saved, but the provider entry was not written/); + expect(getCredential("xai")?.access).toBe("route-collision-access"); + expect(liveConfig).toMatchObject({ + defaultProvider: "concurrent", + providers: { + concurrent: { + adapter: "openai-chat", + baseUrl: "https://concurrent.example.test/v1", + }, + }, + codexAccountNamespaces: { + XAI: "side-account-id", + retained: "retained-account-id", + }, + }); + expect(liveConfig.providers.xai).toBeUndefined(); + expect(liveConfig.claudeCode?.authMode).toBe("proxy"); + expect(liveConfig.disabledModels).toEqual(["pending/provider-model"]); + // Reconciliation must not make the externally bound socket look loopback-only. + expect(liveConfig.hostname).toBe("0.0.0.0"); + expect(liveConfig.port).toBe(10444); + expect(isApiAuthRequired(liveConfig)).toBe(true); + const forgedLoopbackRequest = new Request("http://localhost:10444/api/config", { + headers: { host: "localhost:10444" }, + }); + expect(requireApiAuth(forgedLoopbackRequest, liveConfig, "management")?.status).toBe(401); + expect(loadConfig().providers.xai).toBeUndefined(); + + // A second disk edit must be compared with the state OAuth just adopted, not + // the stale startup baseline, or this unrelated save would restore "proxy". + const editedAgain = loadConfig(); + editedAgain.hostname = "127.0.0.1"; + editedAgain.port = 11445; + editedAgain.claudeCode = { authMode: "subscription", systemEnv: true }; + saveConfig(editedAgain); + + saveConfigPreservingClaudeCode(liveConfig); + const afterLaterSave = loadConfig(); + expect(afterLaterSave.codexAccountNamespaces).toEqual({ + XAI: "side-account-id", + retained: "retained-account-id", + }); + expect(afterLaterSave.providers.concurrent).toBeDefined(); + expect(afterLaterSave.providers.xai).toBeUndefined(); + expect(afterLaterSave.disabledModels).toEqual(["pending/provider-model"]); + expect(afterLaterSave.claudeCode).toEqual({ authMode: "subscription", systemEnv: true }); + expect(liveConfig.claudeCode).toEqual({ authMode: "subscription", systemEnv: true }); + // Runtime admission remains tied to the open socket, but the next-start + // binding adopted from disk must survive this unrelated live save. + expect(liveConfig.hostname).toBe("0.0.0.0"); + expect(liveConfig.port).toBe(10444); + expect(afterLaterSave.hostname).toBe("127.0.0.1"); + expect(afterLaterSave.port).toBe(11445); + expect(loadConfig()).toMatchObject({ hostname: "127.0.0.1", port: 11445 }); + } finally { + OAUTH_PROVIDERS.xai.login = originalLogin; + saveSpy.mockRestore(); + clearLoginState("xai"); + } + }); }); diff --git a/tests/server-auth.test.ts b/tests/server-auth.test.ts index 2ad8bb630d..3c6dc51867 100644 --- a/tests/server-auth.test.ts +++ b/tests/server-auth.test.ts @@ -300,6 +300,7 @@ describe("server local API auth", () => { test("safeConfigDTO redacts provider secrets and exposes booleans", () => { const unsafe = config("127.0.0.1"); unsafe.openaiProviderTierVersion = 1; + unsafe.codexAccountNamespaces = { side: "private-account-id" }; Object.assign(unsafe.providers.openai as unknown as Record, { apiKeyPool: [{ id: "pool-id", key: "pool-secret", label: "private-pool-label" }], modelMaxInputTokens: { "gpt-test": 1000 }, @@ -321,6 +322,7 @@ describe("server local API auth", () => { "virtualModels", "codexAuthContext", "selectedForwardHeaders", "sidecarOutcomeRecorder", "recorder-runtime", "_codexAccountOverride", "_codexAccountRequired", "runtime-token", "override-token", + "codexAccountNamespaces", "private-account-id", ]) expect(serialized).not.toContain(forbidden); expect(dto.providers.openai).toMatchObject({ adapter: "openai-chat", From a0873ba3b6c28fe86be3267e3a04afbc55cf39ff Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Wed, 29 Jul 2026 08:03:27 +0200 Subject: [PATCH 2/2] feat(codex): account pause controls and bulk exhaustion (#667) Maintainer takeover of #565. Persisted pause exclusion, bulk pause-exhausted, GUI controls, docs. Integrated with account namespaces and Spark quota scopes on current dev. --- README.md | 6 +- .../docs/ja/reference/configuration.md | 5 + .../docs/ko/reference/configuration.md | 5 + .../content/docs/reference/configuration.md | 9 + .../docs/ru/reference/configuration.md | 7 + .../docs/zh-cn/reference/configuration.md | 5 + gui/src/components/CodexAccountPool.tsx | 43 +- .../components/codex-account-pool-cards.tsx | 29 +- .../codex-account-pool-main-card.tsx | 71 +++- gui/src/hooks/useCodexAccountPool.ts | 83 +++- gui/src/i18n/de.ts | 13 + gui/src/i18n/en.ts | 13 + gui/src/i18n/ja.ts | 13 + gui/src/i18n/ko.ts | 13 + gui/src/i18n/ru.ts | 13 + gui/src/i18n/zh.ts | 13 + gui/src/icons.tsx | 2 + .../active-account-reauth-aggregate.test.ts | 3 +- .../codex-account-pool-behaviour.test.tsx | 143 ++++++- .../codex-account-pool-controller.test.ts | 26 +- .../codex-account-pool-toast-tone.test.tsx | 7 +- src/codex/account-lifecycle.ts | 2 + src/codex/account-pause.ts | 20 + src/codex/auth-api.ts | 170 +++++++- src/codex/quota.ts | 15 + src/codex/routing.ts | 35 +- src/codex/subagent-model-fallback.ts | 2 + src/config.ts | 1 + src/types.ts | 2 + structure/08_openai-provider-tiers.md | 7 + tests/codex-auth-api.test.ts | 381 +++++++++++++++++- tests/codex-auth-context.test.ts | 31 ++ tests/codex-routing.test.ts | 66 ++- tests/config.test.ts | 24 +- tests/provider-workspace-auth.test.ts | 4 +- tests/rate-limit-reset-credits.test.ts | 2 +- 36 files changed, 1237 insertions(+), 47 deletions(-) create mode 100644 src/codex/account-pause.ts diff --git a/README.md b/README.md index 3e3decc30a..b4e1196398 100644 --- a/README.md +++ b/README.md @@ -230,11 +230,15 @@ next Codex session. opencodex keeps these behaviors: - **Existing sessions keep affinity.** A thread id is bound to the selected account and reused on later turns, so a long request or a mobile/SSH-attached session keeps using the same account. + Pausing an account clears its affinity map: in-flight requests keep captured credentials, but + subsequent turns are re-routed and cannot reuse the paused account. - **New sessions can auto-route.** When auto-switch is enabled, opencodex compares the hottest known quota window across 5h, weekly, and 30d usage, then picks a lower-usage eligible account for new sessions once the active account crosses the threshold. - **Quota lookup is built in.** The dashboard can refresh all account quotas in one click, and the - request log labels pool traffic with non-PII account ordinals. + request log labels pool traffic with non-PII account ordinals. **Pause exhausted** refreshes + eligible accounts that have credentials and pauses only those whose relevant quota window is + freshly confirmed at 100%; accounts without credentials and unknown or failed refreshes stay unchanged. - **Failures fail closed.** Token failures mark reauthentication instead of falling back to another credential silently; 429 quota responses put the account in cooldown and can fail over future work to another eligible pool account. diff --git a/docs-site/src/content/docs/ja/reference/configuration.md b/docs-site/src/content/docs/ja/reference/configuration.md index 10e6c10c9f..f987855564 100644 --- a/docs-site/src/content/docs/ja/reference/configuration.md +++ b/docs-site/src/content/docs/ja/reference/configuration.md @@ -51,6 +51,7 @@ namespaced selected id を bare id に変えます。 | `codexShimAutoRestore?` | `boolean` | `true` | 完了した外部 Codex 更新で以前にインストールした shim が置換された場合に復元します。無効にするには `false`、またはプロセスで `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0` を設定します。 | | `syncResumeHistory?` | `boolean` | `true` | 戻せる Codex App 履歴互換モード。opencodex は元の Codex thread metadata をバックアップし、旧 OpenAI interactive row を `opencodex` に再マッピングし、opencodex が作成した `exec` row を App に見えるソースとして一時的に昇格します。`ocx stop` / `ocx restore` はバックアップした OpenAI row を復元し、残った opencodex user thread を OpenAI に戻し、ネイティブ Codex が `config.toml` からプロキシを削除した後でも開き続けられるようにします。オフにするには `false` に設定します。 | | `codexAccounts?` | `CodexAccount[]` | `[]` | Codex Auth ダッシュボードが管理する ChatGPT/Codex pool アカウント metadata。secret は `codex-accounts.json` に別途置きます。 | +| `pausedCodexAccountIds?` | `string[]` | `[]` | Codex Auth で再開するまで、今後のすべての Pool 選択から除外するアカウント ID。メインを一時停止した場合は `__main__` も含みます。 | | `codexAccountNamespaces?` | `Record` | — | 公開 model selector namespace から保存済み Codex アカウント target への任意 map。この foundation layer は map を検証・保存しますが、picker row の追加や routing の変更は行いません。 | | `activeCodexAccountId?` | `string` | — | 手動選択した pool アカウント。既存 thread affinity を消去して次のリクエストから適用し、処理中のリクエストは現在のアカウントを維持します。 | | `autoSwitchThreshold?` | `number` | `80` | 新しいセッション自動切替用の使用量百分率 threshold。既知の 5 時間、週次、30 日 quota window のうち最も高いスコアを使います。`0` なら quota 自動切替をオフにします。`quota` 戦略と `fill-first` の drain threshold にも使います。 | @@ -85,6 +86,10 @@ pool アカウントの追加と quota 更新はダッシュボードの **Codex ないアカウント metadata だけを保存し、access/refresh token は強化された Codex アカウント credential store に別途 保管します。既存 thread id はアカウント affinity を維持し、新しいセッションは `accountPoolStrategy`、quota、cooldown、health に 応じて自動ルーティングされます。 +一時停止したアカウントと quota metadata は表示されたままですが、自動切り替え、再試行/failover 選択、cooldown 復旧プローブ、手動有効化の対象外です。 +一時停止するとそのアカウントの thread affinity map も消去されます。処理中のリクエストは取得済み credential を維持しますが、以降のターンは再ルーティングされ、一時停止中のアカウントは再利用できません。 +状態は再起動後も保持され、すべてのアカウントが一時停止中なら Pool ルーティングは別のアカウントを暗黙に選ばず失敗します。 +**上限到達を一括停止** は credential がある適格アカウントだけを先に更新し、関連する quota window が今回 100% と確認できたアカウントだけを停止します。credential がないアカウントや、quota が不明、または更新に失敗したアカウントは変更しません。 **rotation 戦略**(新しいセッションのみ;bound thread は不変):`quota`(既定)— `autoSwitchThreshold` 超過時に最小 usage を選択;`round-robin` — 均等分散、`accountPoolStickyLimit`(既定 `1`、1–100)で 1 選択あたりの成功 bind 数;`fill-first` — アクティブアカウントを cooldown、再認証、または threshold まで使い切り(未知 usage は強制切替しない)後、安定ソート順で次へ。rotation は provider enforcement を回避しません — 複数アカウント利用は ToS 違反の可能性があります。 ::: diff --git a/docs-site/src/content/docs/ko/reference/configuration.md b/docs-site/src/content/docs/ko/reference/configuration.md index dc55a2de94..c3fa6f50c5 100644 --- a/docs-site/src/content/docs/ko/reference/configuration.md +++ b/docs-site/src/content/docs/ko/reference/configuration.md @@ -52,6 +52,7 @@ namespaced selected id를 bare id로 바꿉니다. | `codexShimAutoRestore?` | `boolean` | `true` | 완료된 외부 Codex 업데이트가 이전에 설치한 shim을 교체하면 자동으로 복구합니다. 끄려면 `false`로 설정하거나 프로세스에 `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0`을 설정합니다. | | `syncResumeHistory?` | `boolean` | `true` | 되돌릴 수 있는 Codex App 기록 호환 모드. opencodex가 원래 Codex thread metadata를 백업하고, 예전 OpenAI interactive row를 `opencodex`로 재매핑하며, opencodex가 만든 `exec` row를 App에 보이는 source로 잠시 승격합니다. `ocx stop` / `ocx restore`는 백업한 OpenAI row를 복원하고 남은 opencodex user thread를 OpenAI로 돌려 네이티브 Codex가 `config.toml`에서 프록시를 제거한 뒤에도 이어서 열 수 있게 합니다. 끄려면 `false`로 설정합니다. | | `codexAccounts?` | `CodexAccount[]` | `[]` | Codex Auth 대시보드에서 관리하는 ChatGPT/Codex pool 계정 metadata. secret은 `codex-accounts.json`에 따로 둡니다. | +| `pausedCodexAccountIds?` | `string[]` | `[]` | Codex Auth에서 재개할 때까지 이후의 모든 Pool 선택에서 제외할 계정 ID. 메인 계정을 일시 중지하면 `__main__`도 포함됩니다. | | `codexAccountNamespaces?` | `Record` | — | 공개 model selector namespace에서 저장된 Codex 계정 target으로 연결하는 선택적 map입니다. 이 foundation layer는 map을 검증하고 저장하지만 picker row를 추가하거나 routing을 변경하지 않습니다. | | `activeCodexAccountId?` | `string` | — | 수동으로 선택한 pool 계정. 선택 시 기존 thread affinity를 지우고 다음 요청부터 적용하며, 진행 중인 요청은 기존 계정을 유지합니다. | | `autoSwitchThreshold?` | `number` | `80` | 새 세션 자동 전환용 사용량 백분율 threshold. 알려진 5시간, 주간, 30일 quota window 중 가장 높은 점수를 씁니다. `0`이면 quota 자동 전환을 끕니다. `quota` 전략과 `fill-first` drain threshold에도 사용됩니다. | @@ -87,6 +88,10 @@ pool 계정 추가와 quota 갱신은 대시보드의 **Codex Auth** 페이지 아닌 계정 metadata만 저장하고, access/refresh token은 강화된 Codex 계정 credential store에 따로 보관합니다. 기존 thread id는 계정 affinity를 유지하며, 새 세션은 `accountPoolStrategy`, quota, cooldown, health에 따라 자동 라우팅됩니다. +일시 중지된 계정과 quota metadata는 계속 표시되지만 자동 전환, 재시도/failover 선택, cooldown 복구 probe, 수동 활성화에서는 제외됩니다. +일시 중지는 해당 계정의 thread affinity map도 지웁니다. 진행 중인 요청은 이미 확보한 credential을 유지하지만, 이후 턴은 다시 라우팅되며 일시 중지된 계정은 재사용할 수 없습니다. +상태는 재시작 후에도 유지되며, 모든 계정이 일시 중지되면 Pool 라우팅은 계정을 몰래 선택하지 않고 실패합니다. +**한도 도달 계정 일시 중지**는 credential이 있는 적격 계정만 먼저 새로고친 뒤 관련 quota window가 이번 응답에서 100%로 확인된 계정만 일시 중지합니다. credential이 없는 계정과 quota가 없거나 새로고침에 실패한 계정은 변경하지 않습니다. **rotation 전략**(새 세션만; bound thread는 변경 없음): `quota`(기본) — `autoSwitchThreshold` 초과 시 최저 usage 선택; `round-robin` — 균등 분배, `accountPoolStickyLimit`(기본 `1`, 1–100)로 한 선택당 diff --git a/docs-site/src/content/docs/reference/configuration.md b/docs-site/src/content/docs/reference/configuration.md index 4a4fb90b23..700cfd5d74 100644 --- a/docs-site/src/content/docs/reference/configuration.md +++ b/docs-site/src/content/docs/reference/configuration.md @@ -57,6 +57,7 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids. | `codexShimAutoRestore?` | `boolean` | `true` | Restore a previously installed Codex shim when a completed external Codex update replaces it. Set `false`, or set `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0` for a process-level opt-out. | | `syncResumeHistory?` | `boolean` | `true` | Reversible Codex App history compatibility mode. opencodex backs up original Codex thread metadata, remaps old OpenAI interactive rows to `opencodex`, and temporarily promotes opencodex-created `exec` rows to an app-visible source. `ocx stop` / `ocx restore` restore backed-up OpenAI rows and eject remaining opencodex user threads to OpenAI so native Codex can resume them after the proxy is removed from `config.toml`. Set `false` to opt out. | | `codexAccounts?` | `CodexAccount[]` | `[]` | ChatGPT/Codex pool account metadata managed by the Codex Auth dashboard. Secrets live separately in `codex-accounts.json`. | +| `pausedCodexAccountIds?` | `string[]` | `[]` | Accounts excluded from every future Pool selection until resumed in Codex Auth. Includes the main `__main__` account when paused. | | `codexAccountNamespaces?` | `Record` | — | Optional public model-selector namespace → stored Codex account target map. This foundation layer validates and persists the map but does not add picker rows or change routing. | | `activeCodexAccountId?` | `string` | — | Manually selected Pool account. Selection clears existing thread affinity and applies to the next request; in-flight requests keep their captured account. | | `autoSwitchThreshold?` | `number` | `80` | Usage percent threshold for new-session auto-switching. The score uses the hottest known 5h, weekly, or 30d quota window. Set `0` to disable quota auto-switching. Used by the `quota` strategy and as the drain threshold for `fill-first`. | @@ -115,6 +116,14 @@ credential store. Existing thread ids keep account affinity, while new sessions `accountPoolStrategy`, quota, cooldown, and health. A pre-stream upstream **429**/**402** on one pool account is retried once on an eligible alternate account in the same request (so Codex CLI does not stall on a depleted primary while another account still has quota). +Pause keeps an account and its quota metadata visible, but excludes it from automatic switching, +retry/failover selection, cooldown recovery probes, and manual activation. Pausing also clears that +account's thread-affinity map: in-flight requests keep their captured credentials, but subsequent +turns are re-routed and cannot reuse the paused account. The exclusion survives +restarts; if every account is paused, Pool routing fails instead of silently selecting one. +**Pause exhausted** first refreshes eligible accounts that have credentials available and pauses +only those whose relevant quota window is freshly confirmed at 100%; accounts without credentials +and unknown or failed quota refreshes are left unchanged. ::: **Rotation strategies** (new sessions only; bound threads are unchanged): diff --git a/docs-site/src/content/docs/ru/reference/configuration.md b/docs-site/src/content/docs/ru/reference/configuration.md index 97db567f8f..d90d447660 100644 --- a/docs-site/src/content/docs/ru/reference/configuration.md +++ b/docs-site/src/content/docs/ru/reference/configuration.md @@ -56,6 +56,7 @@ opencodex настраивается файлом `~/.opencodex/config.json`. Е | `codexShimAutoRestore?` | `boolean` | `true` | Восстанавливает ранее установленный shim после того, как завершённое внешнее обновление Codex заменило его. Для отключения задайте `false` или установите процессу `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0`. | | `syncResumeHistory?` | `boolean` | `true` | Обратимый режим совместимости истории Codex App. opencodex резервирует исходные метаданные потоков Codex, переназначает старые интерактивные строки OpenAI на `opencodex` и временно повышает созданные opencodex строки `exec` до видимого в приложении источника. `ocx stop` / `ocx restore` восстанавливают зарезервированные строки OpenAI и возвращают оставшиеся пользовательские потоки opencodex обратно к OpenAI, чтобы нативный Codex мог возобновлять их после удаления прокси из `config.toml`. Установите `false`, чтобы отказаться. | | `codexAccounts?` | `CodexAccount[]` | `[]` | Метаданные аккаунтов пула ChatGPT/Codex, управляемые дашбордом Codex Auth. Секреты хранятся отдельно в `codex-accounts.json`. | +| `pausedCodexAccountIds?` | `string[]` | `[]` | ID аккаунтов, исключённых из всех будущих выборов Pool до возобновления в Codex Auth. При паузе основного аккаунта включает `__main__`. | | `codexAccountNamespaces?` | `Record` | — | Необязательная map публичного namespace селектора модели на сохранённую цель аккаунта Codex. Этот foundation layer проверяет и сохраняет map, но не добавляет строки picker и не меняет routing. | | `activeCodexAccountId?` | `string` | — | Вручную выбранный аккаунт пула. Выбор очищает существующие привязки потоков и действует со следующего запроса; выполняющиеся запросы сохраняют захваченный аккаунт. | | `autoSwitchThreshold?` | `number` | `80` | Порог процента использования для автопереключения новых сессий. Оценка использует самое «горячее» из известных окон квоты — 5-часовое, недельное или 30-дневное. Установите `0`, чтобы отключить автопереключение по квоте. Используется стратегией `quota` и как порог исчерпания для `fill-first`. | @@ -94,6 +95,12 @@ raw id аккаунтов и email приватными, а селектор и защищённом хранилище учётных данных аккаунтов Codex. Существующие id потоков сохраняют привязку к аккаунту; новые сессии маршрутизируются по `accountPoolStrategy`, квоте, cooldown и работоспособности. +Приостановленный аккаунт и его метаданные квоты остаются видимыми, но исключаются из автоматического переключения, +повторов/failover, проб восстановления cooldown и ручной активации. Пауза также очищает карту affinity потоков +этого аккаунта: выполняющиеся запросы сохраняют захваченные учётные данные, но последующие ходы +перемаршрутизируются и не могут повторно использовать приостановленный аккаунт. Состояние сохраняется после перезапуска; +если приостановлены все аккаунты, маршрутизация Pool завершается ошибкой, а не выбирает аккаунт скрытно. +**Приостановить исчерпанные** сначала обновляет только подходящие аккаунты с доступными учётными данными и приостанавливает только те, для которых актуальное окно квоты в этом ответе подтверждено на уровне 100%. Аккаунты без учётных данных, с неизвестной квотой или неудачным обновлением не меняются. **Стратегии ротации** (только новые сессии; привязанные потоки не меняются): `quota` (по умолчанию) — выбор наименьшего usage при превышении `autoSwitchThreshold`; `round-robin` — равномерное diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration.md b/docs-site/src/content/docs/zh-cn/reference/configuration.md index a14bfe139c..0c0e2a5e53 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration.md @@ -50,6 +50,7 @@ no-replace 方式创建 `config.json.pre-openai-tiers-v2.bak`,并把已知旧 | `codexShimAutoRestore?` | `boolean` | `true` | 已完成的外部 Codex 更新替换此前安装的 shim 时自动恢复。若要关闭,请设为 `false`,或为进程设置 `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0`。 | | `syncResumeHistory?` | `boolean` | `true` | 可逆的 Codex App 历史兼容模式。opencodex 会备份原始 Codex thread metadata,把旧 OpenAI interactive row 重映射到 `opencodex`,并暂时把 opencodex 创建的 `exec` row 提升成 App 可见 source。`ocx stop` / `ocx restore` 会恢复已备份的 OpenAI row,并把剩余 opencodex user thread 转回 OpenAI,使原生 Codex 在从 `config.toml` 移除代理后仍能继续这些 thread。设为 `false` 可退出该模式。 | | `codexAccounts?` | `CodexAccount[]` | `[]` | Codex Auth 仪表盘管理的 ChatGPT/Codex pool account metadata。secret 单独存放在 `codex-accounts.json`。 | +| `pausedCodexAccountIds?` | `string[]` | `[]` | 在 Codex Auth 中恢复前,不参与任何后续 Pool 选择的账号 ID。暂停主账号时也包含 `__main__`。 | | `codexAccountNamespaces?` | `Record` | — | 可选的公开 model selector namespace 到已保存 Codex account target 的映射。此 foundation layer 只验证并持久化该映射,不会添加 picker row 或改变 routing。 | | `activeCodexAccountId?` | `string` | — | 手动选择的 pool account。选择时清除已有 thread affinity,并从下一次请求开始生效;进行中的请求保留原账号。 | | `autoSwitchThreshold?` | `number` | `80` | 新 session 自动切换的 usage 百分比 threshold。分数取已知 5 小时、周或 30 天 quota window 中最高的一项。设为 `0` 可禁用 quota 自动切换。`quota` 策略和 `fill-first` 的耗尽 threshold 都会用到。 | @@ -82,6 +83,10 @@ pool account id(不能是内部 `__main__`),或用 `"@main"` 表示 Codex 请在仪表盘 **Codex Auth** 页面添加 pool account 并刷新 quota。配置只保存非 secret account metadata;access/refresh token 存放在加固的 Codex account credential store 中。已有 thread id 会 保留 account affinity;新 session 按 `accountPoolStrategy`、quota、cooldown 和 health 自动路由。 +暂停后仍会显示账号及其 quota metadata,但不会参与自动切换、重试/failover 选择、cooldown 恢复探测或手动激活。 +暂停还会清除该账号的 thread affinity map:进行中的请求保留已捕获的 credential,但后续 turn 会重新路由,无法再使用已暂停账号。 +暂停状态会跨重启保留;如果所有账号均已暂停,Pool 路由会明确失败,而不会暗中选择某个账号。 +**暂停已达上限账号** 会先刷新有 credential 的合格账号,只暂停相关 quota window 本次明确返回 100% 的账号;无 credential、未知额度或刷新失败的账号保持不变。 **轮换策略**(仅新 session;已绑定 thread 不变):`quota`(默认)— 活跃账号 usage 超过 `autoSwitchThreshold` 时选最低者;`round-robin` — 均分,`accountPoolStickyLimit`(默认 `1`, diff --git a/gui/src/components/CodexAccountPool.tsx b/gui/src/components/CodexAccountPool.tsx index 2a52c310d3..c5a212e214 100644 --- a/gui/src/components/CodexAccountPool.tsx +++ b/gui/src/components/CodexAccountPool.tsx @@ -29,7 +29,7 @@ const DOCTOR_CMD = "ocx doctor"; * Auth page (WP060). `accountModeState` arrives as a prop (the parent owns the * /api/config fetch); `banner` is an optional slot rendered above the main card * (the Codex Auth page passes its mode banner); `embedded` (WP090) omits page - * chrome — currently a no-op stub reserved for the Providers workspace. + * title chrome while retaining the shared account actions in the Providers workspace. */ export default function CodexAccountPool({ apiBase, accountModeState = null, banner = null, embedded = false, onActiveNeedsReauthChange, controller: injectedController }: { apiBase: string; @@ -55,7 +55,7 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban // but stays inert (no load, no polling) whenever a shared controller was injected. const ownController = useCodexAccountPool(apiBase, !injectedController); const controller = injectedController ?? ownController; - const { accounts, activeId, loadState, switchingId, load } = controller; + const { accounts, activeId, loadState, switchingId, pauseUpdatingId, pausingExhausted, load } = controller; const [confirm, setConfirm] = useState(null); const [showAdd, setShowAdd] = useState(false); const [reauthId, setReauthId] = useState(null); @@ -103,7 +103,7 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban const activePoolAccount = activeId && activeId !== "__main__" ? accounts.find(a => a.id === activeId) : null; - const activePoolNeedsReauth = accountNeedsReauth(activePoolAccount); + const activePoolNeedsReauth = !activePoolAccount?.paused && accountNeedsReauth(activePoolAccount); useEffect(() => { onActiveNeedsReauthChange?.(activePoolNeedsReauth); @@ -156,6 +156,20 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban setToast(t(result.ok ? "prov.aliasSaved" : "prov.aliasSaveFailed")); }; + const togglePaused = async (account: CodexAccountEntry) => { + const paused = !account.paused; + const result = await controller.setAccountPaused(account.id, paused); + if (!result.ok && result.reason === "busy") return; + setConfirm(current => current?.id === account.id ? null : current); + setToastError(!result.ok); + setToast(t(result.ok + ? paused ? "codexAuth.pauseSucceeded" : "codexAuth.resumeSucceeded" + : paused ? "codexAuth.pauseFailed" : "codexAuth.resumeFailed", { + email: account.alias ?? account.email, + })); + setTimeout(() => setToast(""), 5000); + }; + const remove = async (id: string) => { const label = accounts.find(account => account.id === id)?.email ?? t("pws.accountOrdinal", { count: "1" }); if (!window.confirm(t("codexAuth.removeConfirm", { id: label }))) return; @@ -178,6 +192,18 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban } }; + const pauseExhausted = async () => { + const result = await controller.pauseExhaustedAccounts(); + if (!result.ok && result.reason === "busy") return; + setToastError(!result.ok); + setToast(result.ok + ? result.pausedCount > 0 + ? t("codexAuth.pauseExhaustedSucceeded", { count: String(result.pausedCount) }) + : t("codexAuth.pauseExhaustedNone") + : t("codexAuth.pauseExhaustedFailed")); + setTimeout(() => setToast(""), 5000); + }; + const openResetPopup = async (account: CodexAccountEntry) => { setResetPopup(account); setResetConfirm(false); @@ -216,7 +242,7 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban const main = accounts.find(a => a.isMain); const pool = accounts.filter(a => !a.isMain); - const isMainActive = !activeId || activeId === "__main__"; + const isMainActive = !main?.paused && (!activeId || activeId === "__main__"); const switchActionLabel = t(accountModeState === "direct" ? "codexAuth.prepareForPool" : "codexAuth.setAsNext"); return ( @@ -225,7 +251,10 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban t={t} embedded={embedded} refreshingQuota={refreshingQuota} + pausingExhausted={pausingExhausted} + pauseBusy={pauseUpdatingId !== null || pausingExhausted} onRefresh={() => { void refreshQuotas(); }} + onPauseExhausted={() => { void pauseExhausted(); }} /> {toast && {toast}} @@ -247,6 +276,9 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban threshold={autoSwitch.threshold ?? 0} switchActionLabel={switchActionLabel} onSwitch={setConfirm} + onTogglePause={togglePaused} + pauseUpdatingId={pauseUpdatingId} + pauseBusy={pauseUpdatingId !== null || pausingExhausted} onOpenReset={openResetPopup} onCopyDoctor={copyDoctor} doctorCopyOutcomeFor={doctorCopy.outcomeFor} @@ -274,6 +306,9 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban threshold={autoSwitch.threshold ?? 0} onOpenReset={openResetPopup} onSwitch={setConfirm} + onTogglePause={togglePaused} + pauseUpdatingId={pauseUpdatingId} + pauseBusy={pauseUpdatingId !== null || pausingExhausted} onReauth={openReauth} onEditAlias={editAlias} onRemove={remove} diff --git a/gui/src/components/codex-account-pool-cards.tsx b/gui/src/components/codex-account-pool-cards.tsx index 478b0e8a21..605f3a4585 100644 --- a/gui/src/components/codex-account-pool-cards.tsx +++ b/gui/src/components/codex-account-pool-cards.tsx @@ -1,5 +1,5 @@ import { useT } from "../i18n/shared"; -import { IconAlert, IconX } from "../icons"; +import { IconAlert, IconPause, IconPlay, IconX } from "../icons"; import { displayAccountId } from "../lib/privacy"; import type { CodexAccountEntry } from "./codex-account-pool-types"; import type { CodexAccountModeState } from "../codex-multi-state"; @@ -23,6 +23,9 @@ export function CodexAccountPoolCards({ threshold, onOpenReset, onSwitch, + onTogglePause, + pauseUpdatingId, + pauseBusy, onReauth, onEditAlias, onRemove, @@ -36,6 +39,9 @@ export function CodexAccountPoolCards({ threshold: number; onOpenReset: (account: CodexAccountEntry) => void; onSwitch: (account: CodexAccountEntry) => void; + onTogglePause: (account: CodexAccountEntry) => void; + pauseUpdatingId: string | null; + pauseBusy: boolean; onReauth: (id: string) => void; onEditAlias: (account: CodexAccountEntry) => void; onRemove: (id: string) => void; @@ -43,7 +49,7 @@ export function CodexAccountPoolCards({ doctorCopyOutcomeFor?: (accountId: string) => "copied" | "unavailable" | null; }) { const t = useT(); - const isNext = (id: string) => activeId === id; + const isNext = (account: CodexAccountEntry) => !account.paused && activeId === account.id; return ( <> @@ -54,24 +60,25 @@ export function CodexAccountPoolCards({ const healthLabel = formatOAuthHealthLabel(t, a.health); const healthSummary = formatOAuthHealthSummary(t, "codex", a.id, a.health); return ( -
+
- + {a.alias ?? a.email} {a.plan && {a.plan}} + {a.paused && {t("codexAuth.paused")}} onOpenReset(a)} /> {healthLabel && ( {healthLabel} )} {showReauth && !healthLabel && {t("codexAuth.needsReauth")}} - {isNext(a.id) && !showReauth && !inCooldown && ( + {isNext(a) && !showReauth && !inCooldown && ( {t(accountModeState === "direct" ? "codexAuth.poolPrepared" : "codexAuth.nextSession")} )} - {!isNext(a.id) && !showReauth && !inCooldown && ( + {!a.paused && !isNext(a) && !showReauth && !inCooldown && ( @@ -86,6 +93,15 @@ export function CodexAccountPoolCards({ {doctorCopyButtonLabel(t, doctorCopyOutcomeFor?.(a.id))} )} + @@ -103,6 +119,7 @@ export function CodexAccountPoolCards({ {healthSummary && (
{healthSummary}
)} + {a.paused &&
{t("codexAuth.pausedHint")}
} {inCooldown && (
{t("pws.healthCooldownHint")}
)} diff --git a/gui/src/components/codex-account-pool-main-card.tsx b/gui/src/components/codex-account-pool-main-card.tsx index 7f205a4da2..97fd176e5d 100644 --- a/gui/src/components/codex-account-pool-main-card.tsx +++ b/gui/src/components/codex-account-pool-main-card.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from "react"; -import { IconLock, IconRefresh } from "../icons"; +import { IconLock, IconPause, IconPlay, IconRefresh } from "../icons"; import QuotaBars from "./QuotaBars"; import { CodexTicketBadge } from "./codex-account-pool-helpers"; import type { CodexAccountEntry } from "./codex-account-pool-types"; @@ -23,6 +23,9 @@ export function CodexAccountPoolMainCard({ threshold, switchActionLabel, onSwitch, + onTogglePause, + pauseUpdatingId, + pauseBusy, onOpenReset, onCopyDoctor, doctorCopyOutcomeFor, @@ -34,6 +37,9 @@ export function CodexAccountPoolMainCard({ threshold: number; switchActionLabel: string; onSwitch: (entry: CodexAccountEntry) => void; + onTogglePause: (entry: CodexAccountEntry) => void; + pauseUpdatingId: string | null; + pauseBusy: boolean; onOpenReset: (account: CodexAccountEntry) => void; onCopyDoctor?: (accountId: string) => void; doctorCopyOutcomeFor?: (accountId: string) => "copied" | "unavailable" | null; @@ -45,6 +51,7 @@ export function CodexAccountPoolMainCard({ email: main?.email || mainFallbackLabel, plan: main?.plan, isMain: true, + paused: main?.paused ?? false, hasCredential: true, quota: main?.quota ?? null, }; @@ -62,17 +69,20 @@ export function CodexAccountPoolMainCard({ {t("codexAuth.mainAccount")} {main && onOpenReset({ ...main, id: "__main__" } as CodexAccountEntry)} />} + {main?.paused && {t("codexAuth.paused")}} {healthLabel && ( {healthLabel} )} {showReauth && !healthLabel && {t("codexAuth.needsReauth")}} - - {isMainActive - ? t(accountModeState === "direct" ? "codexAuth.poolPrepared" : "codexAuth.nextSession") - : t("codexAuth.current")} - + {!main?.paused && ( + + {isMainActive + ? t(accountModeState === "direct" ? "codexAuth.poolPrepared" : "codexAuth.nextSession") + : t("codexAuth.current")} + + )} - {!isMainActive && !showReauth && !inCooldown && ( + {!main?.paused && !isMainActive && !showReauth && !inCooldown && ( @@ -82,12 +92,24 @@ export function CodexAccountPoolMainCard({ {doctorCopyButtonLabel(t, doctorCopyOutcomeFor?.(mainId))} )} + {main && ( + + )} {t("codexAuth.appLogin")}
{main?.email || t("codexAuth.appLogin")}{main?.plan ? ` · ${main.plan}` : ""}
{healthSummary && (
{healthSummary}
)} + {main?.paused &&
{t("codexAuth.pausedHint")}
} {inCooldown && (
{t("pws.healthCooldownHint")}
)} @@ -102,20 +124,43 @@ export function CodexAccountPoolPageHead({ t, embedded, refreshingQuota, + pausingExhausted, + pauseBusy, onRefresh, + onPauseExhausted, }: { t: TFn; embedded: boolean; refreshingQuota: boolean; + pausingExhausted: boolean; + pauseBusy?: boolean; onRefresh: () => void; + onPauseExhausted: () => void; }) { - if (embedded) return null; return ( -
-

{t("nav.codexAuth")}

- +
+ {!embedded &&

{t("nav.codexAuth")}

} +
+ + +
); } diff --git a/gui/src/hooks/useCodexAccountPool.ts b/gui/src/hooks/useCodexAccountPool.ts index f23b7f73c8..3c1d08fd61 100644 --- a/gui/src/hooks/useCodexAccountPool.ts +++ b/gui/src/hooks/useCodexAccountPool.ts @@ -21,6 +21,8 @@ export interface CodexAccountEntry { plan?: string; /** Required, not optional: the API always distinguishes the app-login row. */ isMain: boolean; + /** Persisted routing exclusion. Paused accounts remain visible but cannot be selected. */ + paused: boolean; hasCredential: boolean; quota: AccountQuota | null; needsReauth?: boolean; @@ -56,10 +58,14 @@ export interface CodexAccountPoolController { activeId: string | null; loadState: CodexAccountLoadState; switchingId: string | null; + pauseUpdatingId: string | null; + pausingExhausted: boolean; activeNeedsReauth: boolean; load(refreshQuota?: boolean): Promise; switchAccount(id: string | null): Promise>; + setAccountPaused(id: string, paused: boolean): Promise; + pauseExhaustedAccounts(): Promise>; saveAlias(id: string, alias: string): Promise; removeAccount(id: string): Promise; syncAfterAccountAdded(): Promise; @@ -78,6 +84,8 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou const [activeId, setActiveId] = useState(null); const [loadState, setLoadState] = useState("loading"); const [switchingId, setSwitchingId] = useState(null); + const [pauseUpdatingId, setPauseUpdatingId] = useState(null); + const [pausingExhausted, setPausingExhausted] = useState(false); // Pause leases live in a ref: pausing must not re-render, and the effect below reads // the live set rather than a captured snapshot. const [pauseCount, setPauseCount] = useState(0); @@ -91,6 +99,7 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou // load already finished read it to seed their UI instead of waiting a poll interval. const lastThresholdRef = useRef<{ value: unknown } | null>(null); const switchingRef = useRef(null); + const pauseMutationRef = useRef<"bulk" | { accountId: string } | null>(null); const subscribeLoadObserver = useCallback((observer: CodexAccountLoadObserver) => { observersRef.current.add(observer); @@ -230,6 +239,73 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou } }, [apiBase, load]); + const setAccountPaused = useCallback(async (id: string, paused: boolean) => { + if (pauseMutationRef.current) return { ok: false, reason: "busy" } as const; + pauseMutationRef.current = { accountId: id }; + setPauseUpdatingId(id); + try { + const response = await fetch(`${apiBase}/api/codex-auth/accounts/pause`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id, paused }), + }); + if (!response.ok) return { ok: false, reason: "request" } as const; + const raw = await response.json().catch(() => ({})); + const result = (raw && typeof raw === "object" ? raw : {}) as { activeCodexAccountId?: string | null }; + setAccounts(current => current.map(account => ( + account.id === id || (id === "__main__" && account.isMain) + ? { ...account, paused } + : account + ))); + if (Object.prototype.hasOwnProperty.call(result, "activeCodexAccountId")) { + const nextActiveId = result.activeCodexAccountId ?? null; + pendingActiveIdRef.current = { id: nextActiveId }; + setActiveId(nextActiveId); + } + void load(); + return { ok: true } as const; + } catch { + return { ok: false, reason: "request" } as const; + } finally { + pauseMutationRef.current = null; + setPauseUpdatingId(null); + } + }, [apiBase, load]); + + const pauseExhaustedAccounts = useCallback(async () => { + if (pauseMutationRef.current) return { ok: false, reason: "busy" } as const; + pauseMutationRef.current = "bulk"; + setPausingExhausted(true); + try { + const response = await fetch(`${apiBase}/api/codex-auth/accounts/pause-exhausted`, { method: "PUT" }); + if (!response.ok) return { ok: false, reason: "request" } as const; + const raw = await response.json().catch(() => ({})); + const result = (raw && typeof raw === "object" ? raw : {}) as { + pausedAccountIds?: string[]; + pausedCount?: number; + activeCodexAccountId?: string | null; + }; + const pausedIds = new Set(result.pausedAccountIds ?? []); + setAccounts(current => current.map(account => ( + pausedIds.has(account.id) || (pausedIds.has("__main__") && account.isMain) + ? { ...account, paused: true } + : account + ))); + if (Object.prototype.hasOwnProperty.call(result, "activeCodexAccountId")) { + const nextActiveId = result.activeCodexAccountId ?? null; + pendingActiveIdRef.current = { id: nextActiveId }; + setActiveId(nextActiveId); + } + void load(); + return { ok: true, pausedCount: result.pausedCount ?? pausedIds.size } as const; + } catch { + return { ok: false, reason: "request" } as const; + } finally { + pauseMutationRef.current = null; + setPausingExhausted(false); + } + }, [apiBase, load]); + const removeAccount = useCallback(async (id: string) => { try { const response = await fetch( @@ -254,16 +330,21 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou : null; const mainAccount = accounts.find(a => a.isMain); // Include health-only reauth so Providers overview attention matches row CTAs. - const activeNeedsReauth = accountNeedsReauth(activePoolAccount ?? mainAccount); + const activeAccount = activePoolAccount ?? mainAccount; + const activeNeedsReauth = !activeAccount?.paused && accountNeedsReauth(activeAccount); return { accounts, activeId, loadState, switchingId, + pauseUpdatingId, + pausingExhausted, activeNeedsReauth, load, switchAccount, + setAccountPaused, + pauseExhaustedAccounts, saveAlias, removeAccount, syncAfterAccountAdded, diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 28bd1134b6..5585959d24 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -649,7 +649,20 @@ export const de: Record = { "codexAuth.refreshingQuota": "Aktualisiere…", "codexAuth.quotaRefreshed": "Kontingente aktualisiert", "codexAuth.quotaRefreshFailed": "Kontingente konnten nicht aktualisiert werden", + "codexAuth.pauseExhausted": "Ausgeschöpfte pausieren", + "codexAuth.pausingExhausted": "Kontingente werden geprüft…", + "codexAuth.pauseExhaustedSucceeded": "Konten am Limit pausiert: {count}", + "codexAuth.pauseExhaustedNone": "Keine Konten mit bestätigter 100-%-Nutzung.", + "codexAuth.pauseExhaustedFailed": "Ausgeschöpfte Konten konnten nicht geprüft und pausiert werden.", "codexAuth.noPool": "Noch keine Pool-Konten hinzugefügt.", + "codexAuth.pause": "Pausieren", + "codexAuth.resume": "Fortsetzen", + "codexAuth.paused": "PAUSIERT", + "codexAuth.pauseSucceeded": "{email} ist pausiert", + "codexAuth.resumeSucceeded": "{email} ist wieder im Pool verfügbar", + "codexAuth.pauseFailed": "{email} konnte nicht pausiert werden. Es wurde nichts geändert.", + "codexAuth.resumeFailed": "{email} konnte nicht fortgesetzt werden. Es wurde nichts geändert.", + "codexAuth.pausedHint": "Bis zur Fortsetzung von automatischem Wechsel, Wiederholungen, Cooldown-Wiederherstellung und manueller Auswahl ausgeschlossen.", "codexAuth.fiveHour": "5 Std.", "codexAuth.weekly": "Woche", "codexAuth.monthly": "30d", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index ff26ffc50d..acffc51e48 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1063,7 +1063,20 @@ export const en = { "codexAuth.refreshingQuota": "Refreshing...", "codexAuth.quotaRefreshed": "Quotas refreshed", "codexAuth.quotaRefreshFailed": "Failed to refresh quotas", + "codexAuth.pauseExhausted": "Pause exhausted", + "codexAuth.pausingExhausted": "Checking quotas...", + "codexAuth.pauseExhaustedSucceeded": "Accounts at the limit paused: {count}", + "codexAuth.pauseExhaustedNone": "No accounts have confirmed 100% usage.", + "codexAuth.pauseExhaustedFailed": "Failed to check and pause exhausted accounts.", "codexAuth.noPool": "No pool accounts added yet.", + "codexAuth.pause": "Pause", + "codexAuth.resume": "Resume", + "codexAuth.paused": "PAUSED", + "codexAuth.pauseSucceeded": "{email} is paused", + "codexAuth.resumeSucceeded": "{email} is available to the pool again", + "codexAuth.pauseFailed": "Could not pause {email}. Nothing was changed.", + "codexAuth.resumeFailed": "Could not resume {email}. Nothing was changed.", + "codexAuth.pausedHint": "Excluded from automatic switching, retries, cooldown recovery, and manual selection until resumed.", "codexAuth.fiveHour": "5h", "codexAuth.weekly": "Week", "codexAuth.monthly": "30d", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index cda6bea9e9..124521f3ce 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1020,7 +1020,20 @@ export const ja: Record = { "codexAuth.refreshingQuota": "更新中...", "codexAuth.quotaRefreshed": "クォータを更新しました", "codexAuth.quotaRefreshFailed": "クォータの更新に失敗しました", + "codexAuth.pauseExhausted": "上限到達を一括停止", + "codexAuth.pausingExhausted": "クォータを確認中...", + "codexAuth.pauseExhaustedSucceeded": "上限に達したアカウントを停止しました: {count}", + "codexAuth.pauseExhaustedNone": "使用率 100% が確認されたアカウントはありません。", + "codexAuth.pauseExhaustedFailed": "上限到達アカウントの確認と停止に失敗しました。", "codexAuth.noPool": "まだプールアカウントは追加されていません。", + "codexAuth.pause": "一時停止", + "codexAuth.resume": "再開", + "codexAuth.paused": "一時停止中", + "codexAuth.pauseSucceeded": "{email} を一時停止しました", + "codexAuth.resumeSucceeded": "{email} をアカウントプールに戻しました", + "codexAuth.pauseFailed": "{email} を一時停止できませんでした。変更はありません。", + "codexAuth.resumeFailed": "{email} を再開できませんでした。変更はありません。", + "codexAuth.pausedHint": "再開するまで、自動切り替え、再試行、クールダウン復旧、手動選択の対象外です。", "codexAuth.fiveHour": "5時間", "codexAuth.weekly": "週", "codexAuth.monthly": "30日", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 729607b476..36486d9d73 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -666,7 +666,20 @@ export const ko: Record = { "codexAuth.refreshingQuota": "새로고침 중...", "codexAuth.quotaRefreshed": "할당량을 다시 조회했습니다", "codexAuth.quotaRefreshFailed": "할당량 재조회에 실패했습니다", + "codexAuth.pauseExhausted": "한도 도달 계정 일시 중지", + "codexAuth.pausingExhausted": "할당량 확인 중...", + "codexAuth.pauseExhaustedSucceeded": "한도에 도달해 일시 중지된 계정: {count}", + "codexAuth.pauseExhaustedNone": "사용량 100%가 확인된 계정이 없습니다.", + "codexAuth.pauseExhaustedFailed": "한도 도달 계정을 확인하고 일시 중지하지 못했습니다.", "codexAuth.noPool": "풀 계정이 아직 없습니다.", + "codexAuth.pause": "일시 중지", + "codexAuth.resume": "재개", + "codexAuth.paused": "일시 중지됨", + "codexAuth.pauseSucceeded": "{email} 계정을 일시 중지했습니다", + "codexAuth.resumeSucceeded": "{email} 계정을 풀에서 다시 사용할 수 있습니다", + "codexAuth.pauseFailed": "{email} 계정을 일시 중지하지 못했습니다. 변경 사항이 없습니다.", + "codexAuth.resumeFailed": "{email} 계정을 재개하지 못했습니다. 변경 사항이 없습니다.", + "codexAuth.pausedHint": "재개할 때까지 자동 전환, 재시도, 쿨다운 복구 및 수동 선택에서 제외됩니다.", "codexAuth.fiveHour": "5시간", "codexAuth.weekly": "주간", "codexAuth.monthly": "30일", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 26bf8f81c8..2d21320ed8 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1062,7 +1062,20 @@ export const ru: Record = { "codexAuth.refreshingQuota": "Обновление...", "codexAuth.quotaRefreshed": "Квоты обновлены", "codexAuth.quotaRefreshFailed": "Не удалось обновить квоты", + "codexAuth.pauseExhausted": "Приостановить исчерпанные", + "codexAuth.pausingExhausted": "Проверка квот...", + "codexAuth.pauseExhaustedSucceeded": "Приостановлено аккаунтов на лимите: {count}", + "codexAuth.pauseExhaustedNone": "Нет аккаунтов с подтверждённым использованием 100%.", + "codexAuth.pauseExhaustedFailed": "Не удалось проверить и приостановить исчерпанные аккаунты.", "codexAuth.noPool": "В пул ещё не добавлено ни одного аккаунта.", + "codexAuth.pause": "Приостановить", + "codexAuth.resume": "Возобновить", + "codexAuth.paused": "ПРИОСТАНОВЛЕН", + "codexAuth.pauseSucceeded": "Аккаунт {email} приостановлен", + "codexAuth.resumeSucceeded": "Аккаунт {email} снова доступен в пуле", + "codexAuth.pauseFailed": "Не удалось приостановить {email}. Изменений нет.", + "codexAuth.resumeFailed": "Не удалось возобновить {email}. Изменений нет.", + "codexAuth.pausedHint": "До возобновления исключён из автоматического переключения, повторов, восстановления после задержки и ручного выбора.", "codexAuth.fiveHour": "5 ч", "codexAuth.weekly": "Неделя", "codexAuth.monthly": "30 дн.", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index b75cfaa2a2..3dad4a8e54 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -666,7 +666,20 @@ export const zh: Record = { "codexAuth.refreshingQuota": "刷新中...", "codexAuth.quotaRefreshed": "额度已刷新", "codexAuth.quotaRefreshFailed": "额度刷新失败", + "codexAuth.pauseExhausted": "暂停已达上限账号", + "codexAuth.pausingExhausted": "正在检查额度...", + "codexAuth.pauseExhaustedSucceeded": "已暂停 {count} 个达到上限的账号", + "codexAuth.pauseExhaustedNone": "没有确认达到 100% 用量的账号。", + "codexAuth.pauseExhaustedFailed": "无法检查并暂停已达上限账号。", "codexAuth.noPool": "尚未添加池账号。", + "codexAuth.pause": "暂停", + "codexAuth.resume": "恢复", + "codexAuth.paused": "已暂停", + "codexAuth.pauseSucceeded": "已暂停 {email}", + "codexAuth.resumeSucceeded": "{email} 已重新加入账号池", + "codexAuth.pauseFailed": "无法暂停 {email},未做任何更改。", + "codexAuth.resumeFailed": "无法恢复 {email},未做任何更改。", + "codexAuth.pausedHint": "恢复前不会参与自动切换、重试、冷却恢复或手动选择。", "codexAuth.fiveHour": "5 小时", "codexAuth.weekly": "每周", "codexAuth.monthly": "30天", diff --git a/gui/src/icons.tsx b/gui/src/icons.tsx index 563dbc6a98..0e807d197b 100644 --- a/gui/src/icons.tsx +++ b/gui/src/icons.tsx @@ -21,6 +21,8 @@ export const IconCheck = (p: P) => ( (); export const IconPlus = (p: P) => (); export const IconRefresh = (p: P) => (); +export const IconPause = (p: P) => (); +export const IconPlay = (p: P) => (); export const IconTrash = (p: P) => (); export const IconAlert = (p: P) => (); export const IconInfo = (p: P) => (); diff --git a/gui/tests/active-account-reauth-aggregate.test.ts b/gui/tests/active-account-reauth-aggregate.test.ts index e606c2347c..fee1ba6f02 100644 --- a/gui/tests/active-account-reauth-aggregate.test.ts +++ b/gui/tests/active-account-reauth-aggregate.test.ts @@ -21,7 +21,8 @@ describe("Codex callback aggregate path", () => { test("shared Codex controller derives activeNeedsReauth from health as well", async () => { const hook = await Bun.file(new URL("../src/hooks/useCodexAccountPool.ts", import.meta.url)).text(); - expect(hook).toContain("accountNeedsReauth(activePoolAccount ?? mainAccount)"); + expect(hook).toContain("const activeAccount = activePoolAccount ?? mainAccount"); + expect(hook).toContain("!activeAccount?.paused && accountNeedsReauth(activeAccount)"); expect(hook).not.toMatch(/activePoolAccount\s*\?\s*Boolean\(activePoolAccount\.needsReauth\)/); }); diff --git a/gui/tests/codex-account-pool-behaviour.test.tsx b/gui/tests/codex-account-pool-behaviour.test.tsx index 84a58bb0c9..2b02a4516f 100644 --- a/gui/tests/codex-account-pool-behaviour.test.tsx +++ b/gui/tests/codex-account-pool-behaviour.test.tsx @@ -22,6 +22,10 @@ let calls: string[] = []; let originalFetch: typeof globalThis.fetch; let accounts: unknown[] = []; let threshold = 80; +let nextAccountsResponseGate: Promise | null = null; +let pauseResponseActiveId: string | null = null; +let bulkPausedAccountIds: string[] = ["a2"]; +let bulkResponseActiveId: string | null = null; beforeEach(() => { previous = Object.fromEntries(globals.map((k) => [k, Reflect.get(globalThis, k)])) as typeof previous; @@ -36,13 +40,47 @@ beforeEach(() => { originalFetch = globalThis.fetch; calls = []; - accounts = [{ id: "a1", email: "account-one", isMain: true, hasCredential: true, quota: null }]; + nextAccountsResponseGate = null; + pauseResponseActiveId = null; + bulkPausedAccountIds = ["a2"]; + bulkResponseActiveId = null; + accounts = [{ id: "a1", email: "account-one", isMain: true, paused: false, hasCredential: true, quota: null }]; Object.defineProperty(globalThis, "fetch", { configurable: true, value: async (url: string, init?: RequestInit) => { const path = String(url).split("/api/")[1] ?? String(url); calls.push(`${init?.method ?? "GET"} ${path}`); + if (path === "codex-auth/accounts/pause") { + const body = JSON.parse(String(init?.body)) as { id: string; paused: boolean }; + accounts = accounts.map(account => ( + typeof account === "object" && account !== null && "id" in account + && (account.id === body.id || (body.id === "__main__" && "isMain" in account && account.isMain === true)) + ? { ...account, paused: body.paused } + : account + )); + return { ok: true, json: async () => ({ activeCodexAccountId: pauseResponseActiveId }) } as unknown as Response; + } + if (path === "codex-auth/accounts/pause-exhausted") { + const pausedIds = new Set(bulkPausedAccountIds); + accounts = accounts.map(account => ( + typeof account === "object" && account !== null && "id" in account + && (pausedIds.has(String(account.id)) || (pausedIds.has("__main__") && "isMain" in account && account.isMain === true)) + ? { ...account, paused: true } + : account + )); + return { + ok: true, + json: async () => ({ + pausedAccountIds: bulkPausedAccountIds, + pausedCount: bulkPausedAccountIds.length, + activeCodexAccountId: bulkResponseActiveId, + }), + } as unknown as Response; + } if (path.startsWith("codex-auth/accounts")) { + const gate = nextAccountsResponseGate; + nextAccountsResponseGate = null; + if (gate) await gate; return { ok: true, json: async () => ({ accounts }) } as unknown as Response; } if (path.startsWith("codex-auth/active")) { @@ -105,6 +143,109 @@ test("an inert controller issues no requests at all", async () => { expect(calls.length).toBe(0); }); +test("pausing an account writes the persisted endpoint and updates shared state", async () => { + const seen = await mountController(); + + await act(async () => { + expect(await seen.current!.setAccountPaused("a1", true)).toEqual({ ok: true }); + }); + await act(async () => { await new Promise((r) => setTimeout(r, 30)); }); + + expect(calls).toContain("PUT codex-auth/accounts/pause"); + expect(seen.current!.accounts[0]?.paused).toBe(true); + expect(seen.current!.activeId).toBeNull(); +}); + +test("pausing the main sentinel updates its distinct account row before reload", async () => { + const seen = await mountController(); + let releaseReload!: () => void; + nextAccountsResponseGate = new Promise(resolve => { releaseReload = resolve; }); + + await act(async () => { + expect(await seen.current!.setAccountPaused("__main__", true)).toEqual({ ok: true }); + }); + + expect(calls).toContain("PUT codex-auth/accounts/pause"); + expect(seen.current!.accounts.find(account => account.isMain)?.id).toBe("a1"); + expect(seen.current!.accounts.find(account => account.isMain)?.paused).toBe(true); + + await act(async () => { + releaseReload(); + await new Promise((resolve) => setTimeout(resolve, 30)); + }); +}); + +test("pausing stores the actual fallback account returned by the API", async () => { + accounts = [ + { id: "a1", email: "main", isMain: true, paused: false, hasCredential: true, quota: null }, + { id: "a2", email: "next", isMain: false, paused: false, hasCredential: true, quota: null }, + ]; + pauseResponseActiveId = "a2"; + const seen = await mountController(); + + await act(async () => { + expect(await seen.current!.setAccountPaused("__main__", true)).toEqual({ ok: true }); + }); + + expect(seen.current!.activeId).toBe("a2"); +}); + +test("a paused main account does not contribute active reauth state", async () => { + accounts = [ + { id: "a1", email: "main", isMain: true, paused: true, hasCredential: true, needsReauth: true, quota: null }, + { id: "a2", email: "next", isMain: false, paused: false, hasCredential: true, quota: null }, + ]; + const seen = await mountController(); + + expect(seen.current!.activeId).toBeNull(); + expect(seen.current!.activeNeedsReauth).toBe(false); +}); + +test("bulk pausing writes one endpoint and updates every returned account", async () => { + accounts = [ + { id: "a1", email: "account-one", isMain: true, paused: false, hasCredential: true, quota: null }, + { id: "a2", email: "account-two", isMain: false, paused: false, hasCredential: true, quota: null }, + ]; + const seen = await mountController(); + + await act(async () => { + expect(await seen.current!.pauseExhaustedAccounts()).toEqual({ ok: true, pausedCount: 1 }); + }); + await act(async () => { await new Promise((r) => setTimeout(r, 30)); }); + + expect(calls).toContain("PUT codex-auth/accounts/pause-exhausted"); + expect(seen.current!.accounts.find(account => account.id === "a2")?.paused).toBe(true); + expect(seen.current!.pausingExhausted).toBe(false); +}); + +test("bulk pausing translates the main sentinel to its distinct account row", async () => { + accounts = [ + { id: "a1", email: "main", isMain: true, paused: false, hasCredential: true, quota: null }, + { id: "a2", email: "pool", isMain: false, paused: false, hasCredential: true, quota: null }, + ]; + bulkPausedAccountIds = ["__main__"]; + bulkResponseActiveId = "a2"; + const seen = await mountController(); + let releaseReload!: () => void; + nextAccountsResponseGate = new Promise(resolve => { releaseReload = resolve; }); + + await act(async () => { + expect(await seen.current!.pauseExhaustedAccounts()).toEqual({ ok: true, pausedCount: 1 }); + }); + + expect(seen.current!.accounts.find(account => account.isMain)?.id).toBe("a1"); + expect(seen.current!.accounts.find(account => account.isMain)?.paused).toBe(true); + expect(seen.current!.activeId).toBe("a2"); + + await act(async () => { + releaseReload(); + await new Promise((resolve) => setTimeout(resolve, 30)); + }); + + expect(seen.current!.accounts.find(account => account.isMain)?.paused).toBe(true); + expect(seen.current!.activeId).toBe("a2"); +}); + test("two pause holders both have to release before polling resumes", async () => { const seen = await mountController(); const controller = seen.current!; diff --git a/gui/tests/codex-account-pool-controller.test.ts b/gui/tests/codex-account-pool-controller.test.ts index c27637aaba..ae2f65722b 100644 --- a/gui/tests/codex-account-pool-controller.test.ts +++ b/gui/tests/codex-account-pool-controller.test.ts @@ -14,8 +14,8 @@ test("the controller is the single data owner and exposes the agreed contract", // Data layer (Q6): list / active / loading / switching plus the mutating actions. for (const member of [ - "accounts", "activeId", "loadState", "switchingId", "activeNeedsReauth", - "load", "switchAccount", "saveAlias", "removeAccount", "syncAfterAccountAdded", + "accounts", "activeId", "loadState", "switchingId", "pauseUpdatingId", "pausingExhausted", "activeNeedsReauth", + "load", "switchAccount", "setAccountPaused", "pauseExhaustedAccounts", "saveAlias", "removeAccount", "syncAfterAccountAdded", ]) { expect(hook).toContain(member); } @@ -31,6 +31,28 @@ test("the controller is the single data owner and exposes the agreed contract", expect(hook).not.toContain("load(refreshQuota?: boolean, observer"); }); +test("main and added account cards expose the same persisted pause control", async () => { + const pool = await read("../src/components/CodexAccountPool.tsx"); + const mainCard = await read("../src/components/codex-account-pool-main-card.tsx"); + const addedCards = await read("../src/components/codex-account-pool-cards.tsx"); + + expect(pool).toContain("controller.setAccountPaused(account.id, paused)"); + expect(mainCard).toContain("onTogglePause(mainSwitchEntry)"); + expect(addedCards).toContain("onTogglePause(a)"); + expect(mainCard).toContain('t(main.paused ? "codexAuth.resume" : "codexAuth.pause")'); + expect(addedCards).toContain('t(a.paused ? "codexAuth.resume" : "codexAuth.pause")'); +}); + +test("the pool header exposes one bulk action backed by the atomic endpoint", async () => { + const pool = await read("../src/components/CodexAccountPool.tsx"); + const mainCard = await read("../src/components/codex-account-pool-main-card.tsx"); + const hook = await read("../src/hooks/useCodexAccountPool.ts"); + + expect(pool).toContain("controller.pauseExhaustedAccounts()"); + expect(mainCard).toContain('t("codexAuth.pauseExhausted")'); + expect(hook).toContain("/api/codex-auth/accounts/pause-exhausted"); +}); + test("pause is a token lease, so two holders cannot cancel each other", async () => { const hook = await read("../src/hooks/useCodexAccountPool.ts"); diff --git a/gui/tests/codex-account-pool-toast-tone.test.tsx b/gui/tests/codex-account-pool-toast-tone.test.tsx index af2db2f8fb..45ec9fc089 100644 --- a/gui/tests/codex-account-pool-toast-tone.test.tsx +++ b/gui/tests/codex-account-pool-toast-tone.test.tsx @@ -22,6 +22,7 @@ const account: CodexAccountEntry = { id: "pool-1", email: "pool@example.test", isMain: false, + paused: false, hasCredential: true, quota: { resetCredits: 2, updatedAt: 1 }, }; @@ -29,15 +30,19 @@ const account: CodexAccountEntry = { function makeController(overrides: Partial = {}): CodexAccountPoolController { return { accounts: [ - { id: "main", email: "main@example.test", isMain: true, hasCredential: true, quota: null }, + { id: "main", email: "main@example.test", isMain: true, paused: false, hasCredential: true, quota: null }, account, ], activeId: null, loadState: "ready", switchingId: null, + pauseUpdatingId: null, + pausingExhausted: false, activeNeedsReauth: false, load: async () => true, switchAccount: async () => ({ ok: true, activeId: null }), + setAccountPaused: async () => ({ ok: true }), + pauseExhaustedAccounts: async () => ({ ok: true, pausedCount: 0 }), saveAlias: async () => ({ ok: true }), removeAccount: async () => ({ ok: false, reason: "request" }), syncAfterAccountAdded: async () => ({ ok: true }), diff --git a/src/codex/account-lifecycle.ts b/src/codex/account-lifecycle.ts index b6078bf052..35831af553 100644 --- a/src/codex/account-lifecycle.ts +++ b/src/codex/account-lifecycle.ts @@ -6,6 +6,7 @@ import { clearAccountQuota } from "./quota"; import { clearCodexUpstreamHealthForAccount, clearThreadAccountMapForAccount } from "./routing"; import { invalidateCodexWebSocketsForAccount } from "./websocket-registry"; import { clearMainAccountInfoCache } from "./main-account-cache"; +import { forgetCodexAccountPause } from "./account-pause"; import type { OcxConfig } from "../types"; let observedMainChatgptAccountId: string | undefined; @@ -47,6 +48,7 @@ export function deleteCodexAccount(runtimeConfig: OcxConfig, accountId: string): removeCodexAccountCredential(accountId); runtimeConfig.codexAccounts = (runtimeConfig.codexAccounts ?? []) .filter(account => account.isMain || account.id !== accountId); + forgetCodexAccountPause(runtimeConfig, accountId); if (runtimeConfig.activeCodexAccountId === accountId) runtimeConfig.activeCodexAccountId = undefined; purgeCodexAccountRuntimeState(accountId); invalidateCodexWebSocketsForAccount(accountId); diff --git a/src/codex/account-pause.ts b/src/codex/account-pause.ts new file mode 100644 index 0000000000..8e73a1bcfd --- /dev/null +++ b/src/codex/account-pause.ts @@ -0,0 +1,20 @@ +import type { OcxConfig } from "../types"; + +/** Whether an account is administratively excluded from future pool selection. */ +export function isCodexAccountPaused(config: OcxConfig, accountId: string): boolean { + return config.pausedCodexAccountIds?.includes(accountId) ?? false; +} + +/** Persist the account's pool eligibility without changing credentials or runtime health. */ +export function setCodexAccountPaused(config: OcxConfig, accountId: string, paused: boolean): void { + const pausedIds = new Set(config.pausedCodexAccountIds ?? []); + if (paused) pausedIds.add(accountId); + else pausedIds.delete(accountId); + + if (pausedIds.size > 0) config.pausedCodexAccountIds = [...pausedIds]; + else delete config.pausedCodexAccountIds; +} + +export function forgetCodexAccountPause(config: OcxConfig, accountId: string): void { + setCodexAccountPaused(config, accountId, false); +} diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts index 540f24f550..f2cea1bdea 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -3,6 +3,7 @@ import { withCodexAccountLogLabel } from "./account-label"; import { getCodexAccountCredential, getValidCodexToken, + isCodexAccountGenerationLive, markCodexAccountValidated, saveCodexAccountCredential, CodexCredentialGenerationConflictError, @@ -10,13 +11,20 @@ import { TokenRefreshError, } from "./account-store"; import { deleteCodexAccount, reconcileMainCodexAccountRuntimeState } from "./account-lifecycle"; +import { isCodexAccountPaused, setCodexAccountPaused } from "./account-pause"; +import { + clearCodexAccountCooldown, + clearThreadAccountMapForAccount, + getEffectiveActiveCodexAccountId, + reconcileCodexActiveAfterExclusion, + resetCodexRoutingForManualSelection, +} from "./routing"; import { normalizeAccountPoolStickyLimit, normalizeAccountPoolStrategy, parseAccountPoolStickyLimit, parseAccountPoolStrategy, } from "./pool-rotation"; -import { clearCodexAccountCooldown, getEffectiveActiveCodexAccountId, resetCodexRoutingForManualSelection } from "./routing"; import { checkAccountIdCollision, getMainChatgptAccountId, readCodexTokens, readCodexTokensResult } from "./auth-collision"; export { checkAccountIdCollision, getMainChatgptAccountId } from "./auth-collision"; export { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime-state"; @@ -24,6 +32,7 @@ import { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth } import { clearAccountQuota, getAccountQuota, + isCodexQuotaExhausted, listAccountQuotas, parseUsageQuota, setAccountQuotaFromParsed, @@ -40,7 +49,7 @@ export { updateAccountQuota, } from "./quota"; import { extractAccountId, decodeJwtPayload } from "../oauth/chatgpt"; -import { MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "./main-account"; +import { getMainAccountPlan, MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "./main-account"; import { clearMainAccountInfoCache, getMainAccountInfoCache, @@ -126,6 +135,7 @@ function poolAccountDto( account: CodexAccount, quotaResult: PoolQuotaResult, hasCredential: boolean, + paused: boolean, ): CodexAuthAccountDto { const quota = quotaForPlan(quotaResult.quota, account.plan); const needsReauth = !hasCredential || quotaResult.needsReauth || isAccountNeedsReauth(account.id); @@ -137,6 +147,7 @@ function poolAccountDto( ...(account.plan !== undefined ? { plan: account.plan } : {}), ...(account.logLabel !== undefined ? { logLabel: account.logLabel } : {}), isMain: false, + paused, quota: quota ? { ...quota } : null, needsReauth, hasCredential, @@ -242,6 +253,10 @@ const MAIN_CACHE_TTL = 5 * 60_000; const POOL_CACHE_TTL = 5 * 60_000; const POOL_QUOTA_REFRESH_CONCURRENCY = 4; +function nonEmptyPlan(value: unknown): string | null { + return typeof value === "string" && value.trim() !== "" ? value : null; +} + function isRuntimeConfig(config: OcxConfig): boolean { return !!config && typeof config === "object" && !!config.providers; } @@ -305,6 +320,8 @@ async function isTerminalMainAuthResponse(resp: Response): Promise { interface MainAccountInfoFetchResult { info: MainAccountInfo; + /** Present only when this call freshly parsed a WHAM usage response. */ + freshQuota?: Omit; /** Present only when this call's WHAM response included `rate_limit_reset_credits.available_count`. */ freshResetCredits?: number; } @@ -365,11 +382,12 @@ async function fetchMainAccountInfoAttempt(forceRefresh: boolean, retriesRemaini const data = (await resp.json()) as WhamUsageResponse; const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining); if (retried) return retried; - const quota = parseUsageQuota(data); + const plan = nonEmptyPlan(data.plan_type) ?? nonEmptyPlan(cached?.plan) ?? nonEmptyPlan(getMainAccountPlan()); + const quota = parseUsageQuota({ ...data, ...(plan ? { plan_type: plan } : {}) }); const freshResetCredits = quota?.resetCredits; const result = { email: data.email ?? null, - plan: data.plan_type ?? null, + plan, quota, ts: Date.now(), }; @@ -383,6 +401,7 @@ async function fetchMainAccountInfoAttempt(forceRefresh: boolean, retriesRemaini } return { info: result, + ...(quota ? { freshQuota: quota } : {}), ...(freshResetCredits !== undefined ? { freshResetCredits } : {}), }; } catch { @@ -394,6 +413,12 @@ async function fetchMainAccountInfoAttempt(forceRefresh: boolean, retriesRemaini interface PoolQuotaResult { quota: StoredAccountQuota | null; needsReauth: boolean; + /** Present only when this call freshly parsed a WHAM usage response. */ + freshQuota?: Omit; + /** Present only when this call's WHAM response included a non-empty `plan_type`. */ + freshPlan?: string; + /** Credential generation used by this fresh quota request. */ + freshCredentialGeneration?: number; /** Present only when this call's WHAM response included `rate_limit_reset_credits.available_count`. */ freshResetCredits?: number; } @@ -405,6 +430,7 @@ export interface CodexAuthAccountDto { plan?: string | null; logLabel?: string; isMain: boolean; + paused: boolean; quota: (StoredAccountQuota | (Omit & { updatedAt: number })) | null; needsReauth?: boolean; hasCredential: boolean; @@ -420,20 +446,27 @@ async function fetchPoolAccountQuota(accountId: string, forceRefresh = false, co return { quota: existing, needsReauth: false }; } try { - const { accessToken, chatgptAccountId } = await getValidCodexToken(accountId); + const { accessToken, chatgptAccountId, generation } = await getValidCodexToken(accountId); const resp = await fetch("https://chatgpt.com/backend-api/wham/usage", { headers: { Authorization: `Bearer ${accessToken}`, "ChatGPT-Account-Id": chatgptAccountId }, signal: AbortSignal.timeout(8000), }); if (!resp.ok) return { quota: existing ?? null, needsReauth: resp.status === 401 }; const data = (await resp.json()) as WhamUsageResponse; - const quota = parseUsageQuota({ ...data, plan_type: data.plan_type ?? configuredPlan }); + const freshPlan = nonEmptyPlan(data.plan_type) ?? undefined; + const quota = parseUsageQuota({ ...data, plan_type: freshPlan ?? configuredPlan }); const freshResetCredits = quota?.resetCredits; if (!quota) return { quota: existing ?? null, needsReauth: false }; + if (!isCodexAccountGenerationLive(accountId, generation)) { + return { quota: getAccountQuota(accountId), needsReauth: false }; + } setAccountQuotaFromParsed(accountId, quota); return { quota: getAccountQuota(accountId), needsReauth: false, + freshQuota: quota, + freshCredentialGeneration: generation, + ...(freshPlan !== undefined ? { freshPlan } : {}), ...(freshResetCredits !== undefined ? { freshResetCredits } : {}), }; } catch (e) { @@ -514,7 +547,7 @@ export async function listCodexAuthAccounts(config: OcxConfig, forceRefresh = fa const quotaResult = cred ? await fetchPoolAccountQuota(a.id, forceRefresh, a.plan) : { quota: null, needsReauth: true }; - return poolAccountDto(a, quotaResult, !!cred); + return poolAccountDto(a, quotaResult, !!cred, isCodexAccountPaused(runtimeConfig, a.id)); }); const hasMainCredential = readCodexTokens() !== null; const mainNeedsReauth = !hasMainCredential || isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); @@ -527,6 +560,7 @@ export async function listCodexAuthAccounts(config: OcxConfig, forceRefresh = fa email: maskEmail(mainInfo.email) ?? "Codex App login", plan: mainInfo.plan, isMain: true, + paused: isCodexAccountPaused(runtimeConfig, MAIN_CODEX_ACCOUNT_ID), hasCredential: hasMainCredential, needsReauth: mainNeedsReauth, quota: mainInfo.quota ? { ...quotaForPlan({ ...mainInfo.quota, updatedAt: Date.now() }, mainInfo.plan) } : null, @@ -535,6 +569,69 @@ export async function listCodexAuthAccounts(config: OcxConfig, forceRefresh = fa return [main, ...withQuota]; } +interface PauseExhaustedResult { + pausedAccountIds: string[]; + checkedAccountCount: number; + failedAccountCount: number; +} + +function selectFallbackAfterPause(config: OcxConfig, pausedActiveId: string): void { + reconcileCodexActiveAfterExclusion(config, pausedActiveId); +} + +async function pauseExhaustedCodexAccounts(config: OcxConfig): Promise { + const poolAccounts = (config.codexAccounts ?? []).filter(account => !account.isMain); + const mainAttempted = readCodexTokens() !== null; + const [mainResult, poolResults] = await Promise.all([ + fetchMainAccountInfoAttempt(true, 1), + mapWithConcurrency(poolAccounts, POOL_QUOTA_REFRESH_CONCURRENCY, async account => { + if (!getCodexAccountCredential(account.id)) return { account, quotaResult: null }; + return { + account, + quotaResult: await fetchPoolAccountQuota(account.id, true, account.plan), + }; + }), + ]); + + let checkedAccountCount = 0; + let failedAccountCount = 0; + const exhaustedIds: string[] = []; + if (mainAttempted) { + if (mainResult.freshQuota && mainResult.info.plan) { + checkedAccountCount += 1; + if ( + !isCodexAccountPaused(config, MAIN_CODEX_ACCOUNT_ID) + && isCodexQuotaExhausted(mainResult.freshQuota, mainResult.info.plan) + ) { + exhaustedIds.push(MAIN_CODEX_ACCOUNT_ID); + } + } else { + failedAccountCount += 1; + } + } + for (const { account, quotaResult } of poolResults) { + const currentAccount = (config.codexAccounts ?? []).find(candidate => candidate.id === account.id && !candidate.isMain); + if (!currentAccount) continue; + const generation = quotaResult?.freshCredentialGeneration; + const plan = quotaResult?.freshPlan ?? currentAccount.plan; + if (!quotaResult?.freshQuota || generation === undefined || !isCodexAccountGenerationLive(account.id, generation) || !plan) { + failedAccountCount += 1; + continue; + } + checkedAccountCount += 1; + if (!isCodexAccountPaused(config, account.id) && isCodexQuotaExhausted(quotaResult.freshQuota, plan)) { + exhaustedIds.push(account.id); + } + } + + for (const id of exhaustedIds) { + setCodexAccountPaused(config, id, true); + clearThreadAccountMapForAccount(id); + } + for (const id of exhaustedIds) selectFallbackAfterPause(config, id); + return { pausedAccountIds: exhaustedIds, checkedAccountCount, failedAccountCount }; +} + export async function handleCodexAuthAPI( req: Request, url: URL, @@ -624,6 +721,59 @@ export async function handleCodexAuthAPI( return jsonResponse({ ok: true, id, alias: alias || null }); } + if (url.pathname === "/api/codex-auth/accounts/pause" && req.method === "PUT") { + const body = await req.json().catch(() => ({})) as { id?: unknown; paused?: unknown }; + const id = typeof body.id === "string" ? body.id.trim() : ""; + if (id !== MAIN_CODEX_ACCOUNT_ID && !isValidCodexAccountId(id)) { + return jsonResponse({ error: "Invalid account id format" }, 400); + } + if (typeof body.paused !== "boolean") return jsonResponse({ error: "paused must be a boolean" }, 400); + + const runtimeConfig = getRuntimeConfig(config); + const exists = id === MAIN_CODEX_ACCOUNT_ID + || (runtimeConfig.codexAccounts ?? []).some(account => isSelectableCodexPoolAccount(account) && account.id === id); + if (!exists) return jsonResponse({ error: "Account not found" }, 404); + + setCodexAccountPaused(runtimeConfig, id, body.paused); + if (body.paused) { + clearThreadAccountMapForAccount(id); + selectFallbackAfterPause(runtimeConfig, id); + } + saveRuntimeConfig(config, runtimeConfig); + return jsonResponse({ + ok: true, + id, + paused: body.paused, + activeCodexAccountId: getEffectiveActiveCodexAccountId(runtimeConfig) ?? null, + appliesImmediately: true, + }); + } + + if (url.pathname === "/api/codex-auth/accounts/pause-exhausted" && req.method === "PUT") { + const runtimeConfig = getRuntimeConfig(config); + const result = await pauseExhaustedCodexAccounts(runtimeConfig); + const { pausedAccountIds, checkedAccountCount, failedAccountCount } = result; + if (checkedAccountCount === 0 && failedAccountCount > 0) { + return jsonResponse({ + ok: false, + error: "Failed to refresh any Codex account quota", + checkedAccountCount, + failedAccountCount, + }, 502); + } + if (pausedAccountIds.length > 0) saveRuntimeConfig(config, runtimeConfig); + return jsonResponse({ + ok: true, + pausedAccountIds, + pausedCount: pausedAccountIds.length, + checkedAccountCount, + failedAccountCount, + complete: failedAccountCount === 0, + activeCodexAccountId: getEffectiveActiveCodexAccountId(runtimeConfig) ?? null, + appliesImmediately: true, + }); + } + // Manual escape from a quota cooldown. Injected Codex routing makes this proxy the only // model path for Codex Desktop, so a cooldown that outlives the real upstream limit // otherwise leaves editing config.toml as the user's only recovery. @@ -645,9 +795,13 @@ export async function handleCodexAuthAPI( let body: { accountId: string | null }; try { body = (await req.json()) as typeof body; } catch { return jsonResponse({ error: "Invalid JSON" }, 400); } const runtimeConfig = getRuntimeConfig(config); + const targetAccountId = body.accountId ?? MAIN_CODEX_ACCOUNT_ID; if (body.accountId === MAIN_CODEX_ACCOUNT_ID && hasLegacyMainCodexPoolAccount(runtimeConfig.codexAccounts)) { return jsonResponse({ error: "Remove the legacy __main__ pool row before selecting the Desktop account" }, 409); } + if (isCodexAccountPaused(runtimeConfig, targetAccountId)) { + return jsonResponse({ error: "Account is paused" }, 409); + } if (body.accountId != null && body.accountId !== MAIN_CODEX_ACCOUNT_ID) { if (!isValidCodexAccountId(body.accountId)) return jsonResponse({ error: "Invalid account id format" }, 400); const exists = (runtimeConfig.codexAccounts ?? []) @@ -655,7 +809,7 @@ export async function handleCodexAuthAPI( if (!exists) return jsonResponse({ error: "Account not found" }, 400); } runtimeConfig.activeCodexAccountId = body.accountId ?? undefined; - resetCodexRoutingForManualSelection(body.accountId ?? MAIN_CODEX_ACCOUNT_ID); + resetCodexRoutingForManualSelection(targetAccountId); saveRuntimeConfig(config, runtimeConfig); return jsonResponse({ ok: true, activeCodexAccountId: body.accountId, appliesImmediately: true }); } diff --git a/src/codex/quota.ts b/src/codex/quota.ts index f500b3641d..5fc147116c 100644 --- a/src/codex/quota.ts +++ b/src/codex/quota.ts @@ -33,6 +33,21 @@ const MONTHLY_WINDOW_MIN_MINUTES = MONTHLY_WINDOW_MIN_SECONDS / 60; const accountQuota = new Map(); export const CODEX_UNKNOWN_USAGE_SCORE = 100; +export const CODEX_EXHAUSTED_USAGE_PERCENT = 100; + +export function isCodexQuotaExhausted( + quota: Pick | null, + plan?: string | null, +): boolean { + if (!quota) return false; + const normalizedPlan = plan?.trim().toLowerCase(); + const values = normalizedPlan === "go" || normalizedPlan === "free" + ? [quota.monthlyPercent] + : [quota.weeklyPercent, quota.monthlyPercent]; + return values.some(value => typeof value === "number" + && Number.isFinite(value) + && value >= CODEX_EXHAUSTED_USAGE_PERCENT); +} export function normalizeUsagePercent(value: unknown): number | undefined { const numeric = typeof value === "number" diff --git a/src/codex/routing.ts b/src/codex/routing.ts index b359d07d12..bbc13f8448 100644 --- a/src/codex/routing.ts +++ b/src/codex/routing.ts @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"; import { saveConfigPreservingClaudeCode } from "../config"; import { isCodexAccountGenerationLive, readCodexAccountRecord } from "./account-store"; import { codexAccountLogLabel } from "./account-label"; +import { isCodexAccountPaused } from "./account-pause"; import { isCodexAccountUsable } from "./account-usability"; import { isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime-state"; import { @@ -589,7 +590,8 @@ function isCodexAccountSelectable( now: number, quotaScope?: CodexQuotaScope, ): boolean { - return getCodexQuotaHealthSnapshot(accountId, quotaScope, now) === null + return !isCodexAccountPaused(config, accountId) + && getCodexQuotaHealthSnapshot(accountId, quotaScope, now) === null && !isCodexAccountSoftAvoided(accountId, now) && isCodexAccountUsable(config, accountId); } @@ -695,6 +697,7 @@ function getEligiblePoolAccounts( const ids = (config.codexAccounts ?? []) .filter(account => isSelectableCodexPoolAccount(account) && account.id !== excludeId + && !isCodexAccountPaused(config, account.id) && !isAccountNeedsReauth(account.id)) .filter(account => getCodexQuotaHealthSnapshot(account.id, quotaScope, now) === null) .filter(account => !isCodexAccountSoftAvoided(account.id, now)) @@ -704,6 +707,7 @@ function getEligiblePoolAccounts( // first-class rotation candidate when its read-only token is usable (Option A). if ( excludeId !== MAIN_CODEX_ACCOUNT_ID + && !isCodexAccountPaused(config, MAIN_CODEX_ACCOUNT_ID) && !isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID) && getCodexQuotaHealthSnapshot(MAIN_CODEX_ACCOUNT_ID, quotaScope, now) === null && !isCodexAccountSoftAvoided(MAIN_CODEX_ACCOUNT_ID, now) @@ -948,6 +952,29 @@ function promoteActiveCodexAccount(config: OcxConfig, accountId: string): void { rememberActiveCodexAccount(config, accountId); } +/** + * Reconcile the effective active account after an administrative exclusion such as pause. + * The operator's persisted selection is cleared when it names the excluded account; quota + * keeps its historical persisted promotion, while rotating strategies retain the replacement + * only in the process-local cursor. + */ +export function reconcileCodexActiveAfterExclusion( + config: OcxConfig, + excludedAccountId: string, + now = Date.now(), +): string | null { + const wasEffective = (getEffectiveActiveCodexAccountId(config) ?? MAIN_CODEX_ACCOUNT_ID) === excludedAccountId; + if (config.activeCodexAccountId === excludedAccountId) { + config.activeCodexAccountId = undefined; + } + if (!wasEffective) return getEffectiveActiveCodexAccountId(config) ?? null; + + runtimeActiveCodexAccountId = undefined; + const fallback = pickAlternateCodexAccount(config, excludedAccountId, now); + if (fallback) promoteActiveCodexAccount(config, fallback); + return fallback; +} + function isUnknownUsage(usage: number): boolean { return usage >= CODEX_UNKNOWN_USAGE_SCORE; } @@ -1056,7 +1083,7 @@ export function previewCodexAccountForRequest( if (!isCodexAccountSelectable(config, active, now, quotaScope)) { const fallback = pickLowestUsageCodexAccount(config, active, now, quotaScope); if (fallback) active = fallback; - else if (hasConfiguredPoolAccount(config, active)) return active; + else if (hasConfiguredPoolAccount(config, active) && !isCodexAccountPaused(config, active)) return active; else return null; } @@ -1074,6 +1101,7 @@ export function previewCodexAccountForRequest( if (!isCodexAccountUsable(config, active)) { return hasConfiguredPoolAccount(config, active) ? active : null; } + if (isCodexAccountPaused(config, active)) return null; if (getCodexQuotaHealthSnapshot(active, quotaScope, now)) { return hasConfiguredPoolAccount(config, active) ? active : null; } @@ -1150,7 +1178,7 @@ export function resolveCodexAccountForThreadDetailed( if (fallback) { if (!isIndependentCodexQuotaScope(quotaScope)) setActiveCodexAccount(config, fallback); active = fallback; - } else if (hasConfiguredPoolAccount(config, active)) { + } else if (hasConfiguredPoolAccount(config, active) && !isCodexAccountPaused(config, active)) { return { status: "selected", accountId: active }; } else { return { status: "none" }; @@ -1161,6 +1189,7 @@ export function resolveCodexAccountForThreadDetailed( if (!isCodexAccountUsable(config, active)) { return hasConfiguredPoolAccount(config, active) ? { status: "selected", accountId: active } : { status: "none" }; } + if (isCodexAccountPaused(config, active)) return { status: "none" }; if (getCodexQuotaHealthSnapshot(active, quotaScope, now)) { return hasConfiguredPoolAccount(config, active) ? { status: "selected", accountId: active } : { status: "none" }; } diff --git a/src/codex/subagent-model-fallback.ts b/src/codex/subagent-model-fallback.ts index ec07d51db3..6e936ec222 100644 --- a/src/codex/subagent-model-fallback.ts +++ b/src/codex/subagent-model-fallback.ts @@ -20,6 +20,7 @@ import { isCodexAccountInCooldown, } from "./routing"; import { isCodexAccountUsable } from "./account-usability"; +import { isCodexAccountPaused } from "./account-pause"; import { slugEquals } from "../providers/slug-codec"; import { isThreadSpawnRequest } from "../server/effort-policy"; import { PROVIDER_REGISTRY } from "../providers/registry"; @@ -187,6 +188,7 @@ export function isSubagentModelUnavailable( // route (canonical openai defaults to pool even when codexAccountMode is omitted). const resolvedAccountId = resolvePoolFallbackAccountId(config, accountId); if (!resolvedAccountId) return true; + if (isCodexAccountPaused(config, resolvedAccountId)) return true; if (!isCodexAccountUsable(config, resolvedAccountId)) return true; if ( isCodexAccountInCooldown(resolvedAccountId, now) diff --git a/src/config.ts b/src/config.ts index c3ca1a4c2e..a30ee879bc 100644 --- a/src/config.ts +++ b/src/config.ts @@ -594,6 +594,7 @@ const configSchema = z.object({ injectionEffort: z.string().optional().catch(undefined), syncCodexSubagentDefaults: z.boolean().optional().catch(undefined), codexShimAutoRestore: z.boolean().optional(), + pausedCodexAccountIds: z.array(z.string().regex(/^[a-zA-Z0-9._-]{1,64}$/)).optional(), codexAccountNamespaces: codexAccountNamespacesSchema.optional(), // Model ids excluded from the Grok Build managed block (dashboard switches). grokExcludedModels: z.array(z.string()).optional(), diff --git a/src/types.ts b/src/types.ts index 98612468fe..12f5f1722e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -680,6 +680,8 @@ export interface OcxConfig { search?: OcxSearchConfig; /** Codex multi-account pool. */ codexAccounts?: CodexAccount[]; + /** Account ids administratively excluded from future pool selection until resumed. */ + pausedCodexAccountIds?: string[]; /** * Public model-selector namespaces bound to one Codex account. Values are stored account ids; * `"@main"` selects the Codex Desktop/main auth.json account. Account display aliases diff --git a/structure/08_openai-provider-tiers.md b/structure/08_openai-provider-tiers.md index a0e403eb59..8a0484d6c5 100644 --- a/structure/08_openai-provider-tiers.md +++ b/structure/08_openai-provider-tiers.md @@ -23,6 +23,13 @@ An explicit `Retry-After` or an unclassified quota 429 is account-wide. A reset- the shared native group (including GPT-5.6 Terra/Luna). This allows a same-account combo to test an independent quota without allowing fallbacks that share the exhausted quota. +`pausedCodexAccountIds` is a persisted Pool eligibility boundary. A paused added account or the +stable `__main__` alias remains visible for maintenance and quota reads, but is excluded from new +affinity, quota rotation, cooldown probes, transient failover, and manual activation. In-flight +requests keep their captured credential. An all-paused pool fails closed. +The dashboard's bulk pause action refreshes all account quotas and mutates only accounts whose +plan-relevant window is freshly confirmed at exactly 100%; unknown and failed refreshes are skipped. + ```text gpt-5.6-sol # openai; Pool or Direct follows the provider option openai-apikey/gpt-5.6-sol # OpenAI API key diff --git a/tests/codex-auth-api.test.ts b/tests/codex-auth-api.test.ts index 12d38b36d9..0b7fa96540 100644 --- a/tests/codex-auth-api.test.ts +++ b/tests/codex-auth-api.test.ts @@ -7,7 +7,7 @@ import { handleCodexAuthAPI, updateAccountQuota, getAccountQuota, checkAccountIdCollision, getMainChatgptAccountId, markAccountNeedsReauth, isAccountNeedsReauth, clearAccountNeedsReauth, clearAccountQuota, - maskEmail, + clearMainAccountInfoCache, maskEmail, } from "../src/codex/auth-api"; import { getCodexAccountCredential, @@ -16,10 +16,14 @@ import { saveCodexAccountCredential, } from "../src/codex/account-store"; import { + clearCodexUpstreamHealth, + clearThreadAccountMap, getCodexUpstreamHealth, recordCodexUpstreamOutcome, + resetCodexRoutingForManualSelection, resolveCodexAccountForThread, } from "../src/codex/routing"; +import { clearPoolRotationState } from "../src/codex/pool-rotation"; import { clearCodexWebSocketRegistry, getTrackedCodexWebSocketCountForAccount, @@ -27,7 +31,7 @@ import { } from "../src/codex/websocket-registry"; import type { OcxConfig } from "../src/types"; import type { WsData } from "../src/server/ws-bridge"; -import { MAIN_CODEX_ACCOUNT_ID } from "../src/codex/main-account"; +import { MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "../src/codex/main-account"; import { deleteCodexAccount, reconcileMainCodexAccountRuntimeState, @@ -201,6 +205,11 @@ beforeEach(() => { clearAccountNeedsReauth("__main__"); clearAccountQuota(); clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); + clearMainAccountInfoCache(); + setMainAccountPlan(null); + clearCodexUpstreamHealth(); + clearThreadAccountMap(); + clearPoolRotationState(); clearCodexWebSocketRegistry(); resetMainCodexAccountIdentityTrackingForTests(); }); @@ -209,6 +218,11 @@ afterEach(() => { clearAccountNeedsReauth("__main__"); clearAccountQuota(); clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); + clearMainAccountInfoCache(); + setMainAccountPlan(null); + clearCodexUpstreamHealth(); + clearThreadAccountMap(); + clearPoolRotationState(); clearCodexWebSocketRegistry(); globalThis.fetch = previousFetch; if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; @@ -1268,6 +1282,367 @@ describe("codex-auth API", () => { expect(config.activeCodexAccountId).toBe("pool-next"); }); + test("PUT /api/codex-auth/accounts/pause persists exclusion and applies to the next request", async () => { + const config = makeConfig({ + codexAccounts: [ + { id: "pool-active", email: "active@example.test", isMain: false }, + { id: "pool-next", email: "next@example.test", isMain: false }, + ], + activeCodexAccountId: "pool-active", + }); + seedPoolAccount(config, { id: "pool-extra", email: "extra@example.test" }); + saveCodexAccountCredential("pool-active", { + accessToken: "access-active", + refreshToken: "refresh-active", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "acct-active", + }); + saveCodexAccountCredential("pool-next", { + accessToken: "access-next", + refreshToken: "refresh-next", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "acct-next", + }); + updateAccountQuota("pool-active", 5); + updateAccountQuota("pool-next", 10); + updateAccountQuota("pool-extra", 20); + expect(resolveCodexAccountForThread("pause-thread", config)).toBe("pool-active"); + + const req = new Request("http://localhost/api/codex-auth/accounts/pause", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: "pool-active", paused: true }), + }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + + expect(resp!.status).toBe(200); + expect(await resp!.json()).toMatchObject({ id: "pool-active", paused: true, activeCodexAccountId: "pool-next" }); + expect(config.pausedCodexAccountIds).toEqual(["pool-active"]); + expect(config.activeCodexAccountId).toBe("pool-next"); + expect(resolveCodexAccountForThread("pause-thread", config)).toBe("pool-next"); + }); + + test("pausing the runtime-active round-robin account promotes an eligible replacement", async () => { + const config = makeConfig({ + codexAccounts: [ + { id: "pool-active", email: "active@example.test", isMain: false }, + { id: "pool-next", email: "next@example.test", isMain: false }, + ], + activeCodexAccountId: "pool-active", + accountPoolStrategy: "round-robin", + accountPoolStickyLimit: 2, + }); + for (const id of ["pool-active", "pool-next"]) { + saveCodexAccountCredential(id, { + accessToken: `access-${id}`, + refreshToken: `refresh-${id}`, + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: `acct-${id}`, + }); + } + resetCodexRoutingForManualSelection("pool-active"); + expect(resolveCodexAccountForThread("round-robin-pause", config)).toBe("pool-active"); + + const req = new Request("http://localhost/api/codex-auth/accounts/pause", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: "pool-active", paused: true }), + }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + + expect(resp!.status).toBe(200); + expect(await resp!.json()).toMatchObject({ + id: "pool-active", + paused: true, + activeCodexAccountId: "pool-next", + }); + expect(config.activeCodexAccountId).toBeUndefined(); + expect(resolveCodexAccountForThread("round-robin-pause", config)).toBe("pool-next"); + }); + + test("pausing a persisted non-active round-robin selection preserves the runtime account", async () => { + const config = makeConfig({ + codexAccounts: [ + { id: "pool-persisted", email: "persisted@example.test", isMain: false }, + { id: "pool-runtime", email: "runtime@example.test", isMain: false }, + ], + activeCodexAccountId: "pool-persisted", + accountPoolStrategy: "round-robin", + accountPoolStickyLimit: 2, + }); + for (const id of ["pool-persisted", "pool-runtime"]) { + saveCodexAccountCredential(id, { + accessToken: `access-${id}`, + refreshToken: `refresh-${id}`, + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: `acct-${id}`, + }); + } + resetCodexRoutingForManualSelection("pool-runtime"); + expect(resolveCodexAccountForThread("runtime-selection", config)).toBe("pool-runtime"); + + const req = new Request("http://localhost/api/codex-auth/accounts/pause", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: "pool-persisted", paused: true }), + }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + + expect(resp!.status).toBe(200); + expect(await resp!.json()).toMatchObject({ + id: "pool-persisted", + paused: true, + activeCodexAccountId: "pool-runtime", + }); + expect(config.activeCodexAccountId).toBeUndefined(); + expect(resolveCodexAccountForThread("runtime-selection", config)).toBe("pool-runtime"); + }); + + test("PUT /api/codex-auth/accounts/pause-exhausted pauses only freshly confirmed exhausted accounts", async () => { + const config = makeConfig({ + codexAccounts: [ + { id: "exhausted", email: "exhausted@example.test", plan: "plus", isMain: false }, + { id: "available", email: "available@example.test", plan: "plus", isMain: false }, + { id: "free-weekly-only", email: "free@example.test", plan: "free", isMain: false }, + { id: "upgraded-plus", email: "upgraded@example.test", plan: "free", isMain: false }, + { id: "stale-unknown", email: "stale@example.test", plan: "plus", isMain: false }, + { id: "already-paused", email: "paused@example.test", plan: "plus", isMain: false }, + ], + activeCodexAccountId: "exhausted", + pausedCodexAccountIds: ["already-paused"], + }); + for (const account of config.codexAccounts ?? []) { + saveCodexAccountCredential(account.id, { + accessToken: `access-${account.id}`, + refreshToken: `refresh-${account.id}`, + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: account.id, + }); + } + updateAccountQuota("stale-unknown", 100); + expect(resolveCodexAccountForThread("bulk-pause-thread", config)).toBe("exhausted"); + + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + const accountId = new Headers(init?.headers).get("ChatGPT-Account-Id"); + if (accountId === "stale-unknown") return new Response(null, { status: 502 }); + const weekly = accountId === "exhausted" || accountId === "already-paused" || accountId === "free-weekly-only" || accountId === "upgraded-plus" + ? 100 + : 72; + const monthly = accountId === "free-weekly-only" || accountId === "upgraded-plus" ? 20 : 40; + return Response.json({ + plan_type: accountId === "free-weekly-only" ? "free" : "plus", + rate_limit: { + primary_window: { used_percent: weekly, limit_window_seconds: 7 * 24 * 60 * 60 }, + tertiary_window: { used_percent: monthly, limit_window_seconds: 30 * 24 * 60 * 60 }, + }, + }); + }) as typeof fetch; + + const req = new Request("http://localhost/api/codex-auth/accounts/pause-exhausted", { method: "PUT" }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + + expect(resp!.status).toBe(200); + expect(await resp!.json()).toMatchObject({ + pausedAccountIds: ["exhausted", "upgraded-plus"], + pausedCount: 2, + activeCodexAccountId: "free-weekly-only", + checkedAccountCount: 5, + failedAccountCount: 1, + complete: false, + appliesImmediately: true, + }); + expect(config.pausedCodexAccountIds).toEqual(["already-paused", "exhausted", "upgraded-plus"]); + expect(config.activeCodexAccountId).toBe("free-weekly-only"); + expect(resolveCodexAccountForThread("bulk-pause-thread", config)).toBe("free-weekly-only"); + }); + + test("bulk pause preserves a known Free main plan when WHAM omits plan_type", async () => { + writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ + tokens: { access_token: "main-access", account_id: "main-account" }, + })); + let call = 0; + globalThis.fetch = (async () => { + call += 1; + return Response.json({ + email: "main@example.test", + ...(call === 1 ? { plan_type: "free" } : {}), + rate_limit: { + primary_window: { used_percent: call === 1 ? 10 : 100, limit_window_seconds: 7 * 24 * 60 * 60 }, + tertiary_window: { used_percent: 20, limit_window_seconds: 30 * 24 * 60 * 60 }, + }, + }); + }) as typeof fetch; + const config = makeConfig(); + + const prime = new Request("http://localhost/api/codex-auth/accounts?refresh=1"); + expect((await handleCodexAuthAPI(prime, new URL(prime.url), config))!.status).toBe(200); + const req = new Request("http://localhost/api/codex-auth/accounts/pause-exhausted", { method: "PUT" }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + + expect(resp!.status).toBe(200); + expect(await resp!.json()).toMatchObject({ + pausedAccountIds: [], + pausedCount: 0, + checkedAccountCount: 1, + failedAccountCount: 0, + }); + expect(config.pausedCodexAccountIds).toBeUndefined(); + }); + + test("bulk pause fails closed when the main quota plan cannot be established", async () => { + writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ + tokens: { access_token: "main-access", account_id: "main-account" }, + })); + globalThis.fetch = (async () => Response.json({ + email: "main@example.test", + rate_limit: { + primary_window: { used_percent: 100, limit_window_seconds: 7 * 24 * 60 * 60 }, + tertiary_window: { used_percent: 20, limit_window_seconds: 30 * 24 * 60 * 60 }, + }, + })) as typeof fetch; + const config = makeConfig(); + const req = new Request("http://localhost/api/codex-auth/accounts/pause-exhausted", { method: "PUT" }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + + expect(resp!.status).toBe(502); + expect(await resp!.json()).toMatchObject({ ok: false, checkedAccountCount: 0, failedAccountCount: 1 }); + expect(config.pausedCodexAccountIds).toBeUndefined(); + }); + + test("bulk pause reports an error when every quota refresh fails", async () => { + const config = makeConfig({ + codexAccounts: [{ id: "offline", email: "offline@example.test", plan: "plus", isMain: false }], + }); + saveCodexAccountCredential("offline", { + accessToken: "offline-access", + refreshToken: "offline-refresh", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "offline", + }); + globalThis.fetch = (async () => new Response(null, { status: 502 })) as typeof fetch; + const req = new Request("http://localhost/api/codex-auth/accounts/pause-exhausted", { method: "PUT" }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + + expect(resp!.status).toBe(502); + expect(await resp!.json()).toMatchObject({ ok: false, checkedAccountCount: 0, failedAccountCount: 1 }); + expect(config.pausedCodexAccountIds).toBeUndefined(); + }); + + test("bulk pause discards an exhausted result after the account is deleted and recreated", async () => { + const config = makeConfig({ + codexAccounts: [{ id: "reused", email: "old@example.test", plan: "plus", isMain: false }], + }); + saveCodexAccountCredential("reused", { + accessToken: "old-access", + refreshToken: "old-refresh", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "old-account", + }); + let releaseFetch!: () => void; + let markFetchStarted!: () => void; + const fetchStarted = new Promise(resolve => { markFetchStarted = resolve; }); + const fetchGate = new Promise(resolve => { releaseFetch = resolve; }); + globalThis.fetch = (async () => { + markFetchStarted(); + await fetchGate; + return Response.json({ + plan_type: "plus", + rate_limit: { primary_window: { used_percent: 100 } }, + }); + }) as typeof fetch; + + const bulkReq = new Request("http://localhost/api/codex-auth/accounts/pause-exhausted", { method: "PUT" }); + const bulkPromise = handleCodexAuthAPI(bulkReq, new URL(bulkReq.url), config); + await fetchStarted; + const deleteReq = new Request("http://localhost/api/codex-auth/accounts?id=reused", { method: "DELETE" }); + expect((await handleCodexAuthAPI(deleteReq, new URL(deleteReq.url), config))!.status).toBe(200); + config.codexAccounts = [{ id: "reused", email: "new@example.test", plan: "plus", isMain: false }]; + saveCodexAccountCredential("reused", { + accessToken: "new-access", + refreshToken: "new-refresh", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "new-account", + }); + releaseFetch(); + const resp = await bulkPromise; + + expect(resp!.status).toBe(502); + expect(config.pausedCodexAccountIds).toBeUndefined(); + expect(getAccountQuota("reused")).toBeNull(); + }); + + test("PUT /api/codex-auth/accounts/pause-exhausted includes a freshly exhausted main account", async () => { + writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ + tokens: { access_token: "main-access", account_id: "main-account" }, + })); + globalThis.fetch = (async () => Response.json({ + email: "main@example.test", + plan_type: "plus", + rate_limit: { primary_window: { used_percent: 100 } }, + })) as typeof fetch; + const config = makeConfig(); + const req = new Request("http://localhost/api/codex-auth/accounts/pause-exhausted", { method: "PUT" }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + + expect(await resp!.json()).toMatchObject({ + pausedAccountIds: [MAIN_CODEX_ACCOUNT_ID], + pausedCount: 1, + }); + expect(config.pausedCodexAccountIds).toEqual([MAIN_CODEX_ACCOUNT_ID]); + }); + + test("PUT /api/codex-auth/active rejects null when the effective main account is paused", async () => { + const config = makeConfig({ pausedCodexAccountIds: [MAIN_CODEX_ACCOUNT_ID] }); + const req = new Request("http://localhost/api/codex-auth/active", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ accountId: null }), + }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + + expect(resp!.status).toBe(409); + expect(await resp!.json()).toEqual({ error: "Account is paused" }); + expect(config.activeCodexAccountId).toBeUndefined(); + }); + + test("resuming restores eligibility and manual activation rejects paused accounts", async () => { + const config = makeConfig({ + codexAccounts: [{ id: "work", email: "work@example.test", isMain: false }], + pausedCodexAccountIds: ["work", MAIN_CODEX_ACCOUNT_ID], + }); + const activateReq = new Request("http://localhost/api/codex-auth/active", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ accountId: "work" }), + }); + const activateResp = await handleCodexAuthAPI(activateReq, new URL(activateReq.url), config); + expect(activateResp!.status).toBe(409); + expect(config.activeCodexAccountId).toBeUndefined(); + + const resumeReq = new Request("http://localhost/api/codex-auth/accounts/pause", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: "work", paused: false }), + }); + const resumeResp = await handleCodexAuthAPI(resumeReq, new URL(resumeReq.url), config); + + expect(resumeResp!.status).toBe(200); + expect(config.pausedCodexAccountIds).toEqual([MAIN_CODEX_ACCOUNT_ID]); + }); + + test("account list exposes persisted pause state for main and added accounts", async () => { + const config = makeConfig({ + codexAccounts: [{ id: "work", email: "work@example.test", isMain: false }], + pausedCodexAccountIds: [MAIN_CODEX_ACCOUNT_ID, "work"], + }); + const req = new Request("http://localhost/api/codex-auth/accounts"); + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + const data = await resp!.json() as { accounts: Array<{ id: string; paused: boolean }> }; + + expect(data.accounts.find(account => account.id === MAIN_CODEX_ACCOUNT_ID)?.paused).toBe(true); + expect(data.accounts.find(account => account.id === "work")?.paused).toBe(true); + }); + test("PUT /api/codex-auth/accounts/alias changes display metadata only", async () => { const config = makeConfig({ codexAccounts: [{ id: "work", email: "work@example.test", plan: "plus", isMain: false }], @@ -1324,6 +1699,7 @@ describe("codex-auth API", () => { recordCodexUpstreamOutcome(config, "pool-delete", 500); expect(getCodexUpstreamHealth("pool-delete")).not.toBeNull(); markAccountNeedsReauth("pool-delete"); + config.pausedCodexAccountIds = ["pool-delete"]; const closed: { code?: number; reason?: string }[] = []; let cancelled = false; const ws = { @@ -1352,6 +1728,7 @@ describe("codex-auth API", () => { expect(resp!.status).toBe(200); expect(config.codexAccounts).toEqual([]); expect(config.activeCodexAccountId).toBeUndefined(); + expect(config.pausedCodexAccountIds).toBeUndefined(); expect(getCodexAccountCredential("pool-delete")).toBeNull(); expect(getAccountQuota("pool-delete")).toBeNull(); expect(isAccountNeedsReauth("pool-delete")).toBe(false); diff --git a/tests/codex-auth-context.test.ts b/tests/codex-auth-context.test.ts index 98c6dcfcc0..16c9a921d2 100644 --- a/tests/codex-auth-context.test.ts +++ b/tests/codex-auth-context.test.ts @@ -167,6 +167,37 @@ describe("Codex auth context", () => { )).rejects.toBeInstanceOf(CodexPoolAuthenticationError); }); + test("pause excludes new auth selection without invalidating an in-flight context", async () => { + const cfg = config(); + cfg.codexAccounts?.push({ + id: "pool-b", + email: "pool-b@example.test", + isMain: false, + chatgptAccountId: "pool_b_acc", + }); + saveCodexAccountCredential("pool-a", { + accessToken: "pool_a_token", + refreshToken: "pool_a_refresh", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "pool_a_acc", + }); + saveCodexAccountCredential("pool-b", { + accessToken: "pool_b_token", + refreshToken: "pool_b_refresh", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "pool_b_acc", + }); + + const captured = await resolveCodexAuthContext(new Headers(), cfg, "pool"); + expect(captured).toMatchObject({ kind: "pool", accountId: "pool-a" }); + + cfg.pausedCodexAccountIds = ["pool-a"]; + + expect(isCodexAuthContextUsable(captured, cfg)).toBe(true); + await expect(resolveCodexAuthContext(new Headers(), cfg, "pool")) + .resolves.toMatchObject({ kind: "pool", accountId: "pool-b" }); + }); + test("selected pool headers replace inbound main auth", () => { const headers = headersForCodexAuthContext( new Headers({ authorization: "Bearer main_token", "chatgpt-account-id": "main_acc", "openai-beta": "responses=experimental" }), diff --git a/tests/codex-routing.test.ts b/tests/codex-routing.test.ts index 8e11154eed..351e98e840 100644 --- a/tests/codex-routing.test.ts +++ b/tests/codex-routing.test.ts @@ -39,7 +39,7 @@ import { parseUsageQuota, updateAccountQuota, } from "../src/codex/auth-api"; -import { CODEX_UNKNOWN_USAGE_SCORE } from "../src/codex/quota"; +import { CODEX_UNKNOWN_USAGE_SCORE, isCodexQuotaExhausted } from "../src/codex/quota"; import { MAIN_CODEX_ACCOUNT_ID } from "../src/codex/main-account"; import { routeModel } from "../src/router"; import { consumeForInspection } from "../src/server/relay"; @@ -129,6 +129,16 @@ describe("codex routing", () => { expect(computeCodexUsageScore({})).toBe(CODEX_UNKNOWN_USAGE_SCORE); }); + test("bulk pause exhaustion requires an explicit 100% relevant window", () => { + expect(isCodexQuotaExhausted(null, "plus")).toBe(false); + expect(isCodexQuotaExhausted({}, "plus")).toBe(false); + expect(isCodexQuotaExhausted({ weeklyPercent: 99.9 }, "plus")).toBe(false); + expect(isCodexQuotaExhausted({ weeklyPercent: 100 }, "plus")).toBe(true); + expect(isCodexQuotaExhausted({ monthlyPercent: 100 }, "plus")).toBe(true); + expect(isCodexQuotaExhausted({ weeklyPercent: 100, monthlyPercent: 20 }, "free")).toBe(false); + expect(isCodexQuotaExhausted({ weeklyPercent: 20, monthlyPercent: 100 }, "go")).toBe(true); + }); + test("weekly threshold breach switches new threads", () => { const config = makeConfig(); updateAccountQuota("a", 85); @@ -162,6 +172,21 @@ describe("codex routing", () => { expect(resolveCodexAccountForThread("after-success", config)).toBe("a"); }); + test("paused main account is excluded even when it is the active and lowest-usage candidate", () => { + writeFileSync(join(TEST_DIR, "auth.json"), JSON.stringify({ + tokens: { access_token: "main-access", account_id: "main-chatgpt-id" }, + })); + const config = makeConfig({ + codexAccounts: [{ id: "a", email: "a@test", isMain: false }], + activeCodexAccountId: MAIN_CODEX_ACCOUNT_ID, + pausedCodexAccountIds: [MAIN_CODEX_ACCOUNT_ID], + }); + updateAccountQuota(MAIN_CODEX_ACCOUNT_ID, 1); + updateAccountQuota("a", 20); + + expect(resolveCodexAccountForThread("paused-main", config)).toBe("a"); + }); + test("go plan pool switching ignores the weekly window", () => { const config = makeConfig({ codexAccounts: [ @@ -194,6 +219,25 @@ describe("codex routing", () => { expect(pickLowestUsageCodexAccount(config)).toBe("b"); }); + test("paused accounts are excluded from new selection and existing affinity reuse", () => { + const config = makeConfig(); + updateAccountQuota("a", 10); + updateAccountQuota("b", 20); + expect(resolveCodexAccountForThread("paused-affinity", config)).toBe("a"); + + config.pausedCodexAccountIds = ["a"]; + + expect(pickLowestUsageCodexAccount(config)).toBe("b"); + expect(resolveCodexAccountForThread("paused-affinity", config)).toBe("b"); + }); + + test("all paused accounts fail closed instead of falling back to a configured account", () => { + const config = makeConfig({ pausedCodexAccountIds: ["a", "b"] }); + + expect(pickLowestUsageCodexAccount(config)).toBeNull(); + expect(resolveCodexAccountForThread("all-paused", config)).toBeNull(); + }); + test("upstream outcome classifier separates caller, credential, and transient failures", () => { expect(classifyCodexUpstreamOutcome(200)).toBe("success"); expect(classifyCodexUpstreamOutcome(401)).toBe("credential"); @@ -357,6 +401,26 @@ describe("codex routing", () => { expect(resolveCodexAccountForThread("scoped-thread", config, now + 4, "spark")).toBe("b"); }); + test("429 fallback skips paused candidates", () => { + const config = makeConfig({ + codexAccounts: [ + { id: "a", email: "a@test", isMain: false }, + { id: "b", email: "b@test", isMain: false }, + { id: "c", email: "c@test", isMain: false }, + ], + pausedCodexAccountIds: ["b"], + }); + saveTestCredential("c"); + updateAccountQuota("a", 10); + updateAccountQuota("b", 1); + updateAccountQuota("c", 30); + + recordCodexUpstreamOutcome(config, "a", 429, { retryAfter: "60" }); + + expect(config.activeCodexAccountId).toBe("c"); + expect(resolveCodexAccountForThread("quota-skip-paused", config)).toBe("c"); + }); + test("2xx responses clear transient failures without clearing an unexpired cooldown", () => { const config = makeConfig(); const now = 1_800_000_000_000; diff --git a/tests/config.test.ts b/tests/config.test.ts index 9873b2bf51..d60a1c5193 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -251,7 +251,6 @@ describe("opencodex config defaults", () => { }, defaultProvider: "openai", }; - writeConfig({ ...base, injectionModel: "gpt-5.6-terra", @@ -331,6 +330,29 @@ describe("opencodex config defaults", () => { expect(loadConfig().syncCodexSubagentDefaults).toBeUndefined(); }); + test("paused Codex account ids persist and reject malformed values", () => { + const base = { + port: 10100, + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + }, + }, + defaultProvider: "openai", + }; + writeConfig({ ...base, pausedCodexAccountIds: ["__main__", "pool-a"] }); + expect(loadConfig().pausedCodexAccountIds).toEqual(["__main__", "pool-a"]); + + for (const invalid of ["pool-a", ["bad/account"], [1]]) { + writeConfig({ ...base, pausedCodexAccountIds: invalid }); + const diagnostics = readConfigDiagnostics(); + expect(diagnostics.source).toBe("fallback"); + expect(diagnostics.error).toContain("pausedCodexAccountIds"); + } + }); + test("loads valid config from OPENCODEX_HOME", () => { writeConfig({ port: 12345, diff --git a/tests/provider-workspace-auth.test.ts b/tests/provider-workspace-auth.test.ts index 5d203d3f15..ed69de67ba 100644 --- a/tests/provider-workspace-auth.test.ts +++ b/tests/provider-workspace-auth.test.ts @@ -194,7 +194,9 @@ describe("workspace account integration seam", () => { // Health-only reauth_required must reach both aggregate surfaces. expect(pool).toContain("onActiveNeedsReauthChange?.(activePoolNeedsReauth)"); - expect(hook).toContain("accountNeedsReauth(activePoolAccount ?? mainAccount)"); + expect(hook).toContain("accountNeedsReauth(activeAccount)"); + expect(hook).toContain("!activeAccount?.paused &&"); + expect(hook).toContain("activePoolAccount ?? mainAccount"); expect(page).toContain("accountNeedsReauth(active)"); // WP3: background refresh pauses through a token lease, not a boolean read of the // modal flag. Two holders must both release before polling resumes. diff --git a/tests/rate-limit-reset-credits.test.ts b/tests/rate-limit-reset-credits.test.ts index bfe9eaa767..9d66dc1f65 100644 --- a/tests/rate-limit-reset-credits.test.ts +++ b/tests/rate-limit-reset-credits.test.ts @@ -263,7 +263,7 @@ describe("rate-limit reset credits", () => { 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.id) && !showReauth && !inCooldown && ("); + expect(source).toContain("{isNext(a) && !showReauth && !inCooldown && ("); 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; }");