From 8e8fa7a3e8ea67447988eac653a9c14e6f602a96 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:44:27 +0800 Subject: [PATCH 1/6] fix(codex): synchronize manual account pause across matching main and pool entries --- .../fr/reference/configuration/providers.md | 2 +- .../ja/reference/configuration/providers.md | 2 +- .../ko/reference/configuration/providers.md | 2 +- .../docs/reference/configuration/providers.md | 2 +- .../content/docs/reference/management-api.md | 2 +- .../ru/reference/configuration/providers.md | 2 +- .../tr/reference/configuration/providers.md | 2 +- .../reference/configuration/providers.md | 2 +- .../docs/zh-cn/reference/management-api.md | 2 +- .../reference/configuration/providers.md | 2 +- gui/src/hooks/useCodexAccountPool.ts | 15 +- gui/src/i18n/de.ts | 2 +- gui/src/i18n/en.ts | 2 +- gui/src/i18n/fr.ts | 2 +- gui/src/i18n/ja.ts | 2 +- gui/src/i18n/ko.ts | 2 +- gui/src/i18n/pt.ts | 2 +- gui/src/i18n/ru.ts | 2 +- gui/src/i18n/tr.ts | 2 +- gui/src/i18n/vi.ts | 2 +- gui/src/i18n/zh-TW.ts | 2 +- gui/src/i18n/zh.ts | 2 +- .../codex-account-pool-behaviour.test.tsx | 37 ++- scripts/test-layout/layout.json | 1 + src/codex/auth-api/account-pause-group.ts | 71 ++++++ src/codex/auth-api/routes.ts | 30 ++- structure/gui-and-management-api.md | 7 + structure/providers/openai-accounts.md | 12 + structure/providers/openai-tiers.md | 3 + .../codex-linked-account-pause.test.ts | 214 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 1 + 31 files changed, 393 insertions(+), 40 deletions(-) create mode 100644 src/codex/auth-api/account-pause-group.ts create mode 100644 tests/codex-integration/codex-linked-account-pause.test.ts diff --git a/docs-site/src/content/docs/fr/reference/configuration/providers.md b/docs-site/src/content/docs/fr/reference/configuration/providers.md index 2bdf799069b..32adbfb434b 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/fr/reference/configuration/providers.md @@ -32,7 +32,7 @@ Après une inscription ou une connexion OAuth dans l’interface, une boîte de | `providerContextCapValues?` | `Record` | `{}` | Dernières limites sélectionnées par fournisseur, conservées après désactivation. Ces valeurs n’activent aucun plafond. Une valeur active est prioritaire sur une valeur mémorisée. | | `contextCapValue?` | `number` | `350000` | Valeur par défaut lors de la première activation. Les activations suivantes restaurent la sélection du fournisseur. Modifier la valeur globale avec `setAll: true` ne modifie que les plafonds actifs ; `setAll: true` sans valeur active tous les fournisseurs configurés à la valeur globale actuelle. | | `codexAccounts?` | `CodexAccount[]` | `[]` | Métadonnées du compte pool ChatGPT/Codex gérées par Codex Auth. Les secrets vivent séparément dans `codex-accounts.json`. | -| `pausedCodexAccountIds?` | `string[]` | `[]` | Comptes exclus de la sélection du pool jusqu'à la reprise, y compris le compte principal `__main__` lorsqu'il est mis en pause. | +| `pausedCodexAccountIds?` | `string[]` | `[]` | Comptes exclus de la sélection du pool jusqu'à la reprise, y compris le compte principal `__main__` lorsqu'il est mis en pause. La pause et la reprise manuelles mettent aussi à jour les entrées principales et du pool existantes du même compte et espace de travail. | | `codexAccountNamespaces?` | `Record` | — | Mappage facultatif d’un sélecteur de modèle public arbitraire vers une cible de compte Codex stockée. Lorsque les lignes du sélecteur qualifié par compte sont activées, chaque sélecteur dont la cible est présente ajoute des lignes `/` distinctes au sélecteur Codex ; chaque ligne utilise uniquement ce compte. Dès qu'un sélecteur est actif, les lignes natives non qualifiées sont masquées dans le sélecteur, mais leurs identifiants restent routables et figurent toujours dans la réponse brute de `/v1/models`, sauf désactivation explicite. | | `codexAccountPickerEnabled?` | `boolean` | désactivé lorsque la carte est vide | Contrôle si les mappages `codexAccountNamespaces` éligibles génèrent des lignes de sélecteur Codex qualifiées pour le compte. `true` permet aux lignes mappées d'apparaître. Si elle est omise avec une carte non vide, elle est traitée comme activée pour des raisons de compatibilité ascendante ; si la carte est vide, elle est éteinte. `false` masque les lignes générées et restaure les lignes nues du sélecteur natif sans supprimer les mappages ni désactiver le routage exact `/`. | | `activeCodexAccountId?` | `string` | — | Compte de pool sélectionné manuellement pour la prochaine demande. La sélection efface l'affinité des threads ; les demandes en cours conservent les informations d’identification capturées. | diff --git a/docs-site/src/content/docs/ja/reference/configuration/providers.md b/docs-site/src/content/docs/ja/reference/configuration/providers.md index 04f1868680c..4ae7448950a 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -31,7 +31,7 @@ GUI で登録または OAuth ログインが完了すると、Models ページ | `providerContextCapValues?` | `Record` | `{}` | プロバイダーごとに最後に選択した上限。無効にしても保持され、この値だけで上限が有効になることはありません。有効な値が保存済みの値より優先されます。 | | `contextCapValue?` | `number` | `350000` | 初回の有効化で使う既定値。再び有効にすると、そのプロバイダーの選択値を復元します。`setAll: true` とともにグローバル値を変更すると、有効な上限だけを更新します。値を指定せずに `setAll: true` を送ると、設定済みの全プロバイダーの上限を現在のグローバル値で有効にします。 | | `codexAccounts?` | `CodexAccount[]` | `[]` | ChatGPT/Codex プール アカウントのメタデータは Codex Auth によって管理されます。秘密は`codex-accounts.json`に別に住んでいます。 | -| `pausedCodexAccountIds?` | `string[]` | `[]` |再開するまでプールの選択から除外されるアカウント (一時停止時のメイン `__main__` アカウントを含む)。 | +| `pausedCodexAccountIds?` | `string[]` | `[]` |再開するまでプールの選択から除外されるアカウント (一時停止時のメイン `__main__` アカウントを含む)。 手動の一時停止と再開は、同じアカウント・ワークスペースのメインログインとプール内の既存エントリにも反映されます。 | | `codexAccountNamespaces?` | `Record` | — | 任意の公開 model selector を保存済み Codex アカウント target に対応付ける任意の map。account-qualified picker row が有効な場合、target が存在する各 selector は Codex picker に個別の `/` row を追加し、各 row はそのアカウントだけを使用します。selector が 1 つでも有効な場合、bare native row は picker で非表示になりますが、明示的に無効化されない限り id は引き続き routing でき、raw `/v1/models` にも表示されます。 | | `codexAccountPickerEnabled?` | `boolean` | map が空なら off | 有効な `codexAccountNamespaces` mapping から account-qualified Codex picker row を生成するかを制御します。`true` は mapping された行の表示を許可します。空でない map で省略した場合は後方互換性のため有効として扱われ、map が空なら off です。`false` は mapping を削除せず、明示的な `/` routing も無効にせずに、生成行を非表示にして picker の bare native 行を復元します。 | | `activeCodexAccountId?` | `string` | — |次のリクエスト用に手動で選択されたプール アカウント。選択するとスレッドのアフィニティがクリアされます。実行中のリクエストでは、取得された資格情報が保持されます。 | diff --git a/docs-site/src/content/docs/ko/reference/configuration/providers.md b/docs-site/src/content/docs/ko/reference/configuration/providers.md index f78de15e39f..0c3494ff3ac 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -31,7 +31,7 @@ GUI에서 등록이나 OAuth 로그인을 마치면 Models 페이지로 이동 | `providerContextCapValues?` | `Record` | `{}` | 공급자별로 마지막에 선택한 상한입니다. 꺼도 선택값이 남으며, 저장된 값만으로는 상한이 활성화되지 않습니다. 활성 값이 저장된 선택값보다 우선합니다. | | `contextCapValue?` | `number` | `350000` | 처음 켤 때 쓰는 기본값입니다. 다시 켜면 공급자별 선택값을 복원합니다. `setAll: true`와 함께 전역 값을 바꾸면 활성 상한만 갱신합니다. 값 없이 `setAll: true`를 보내면 설정된 모든 공급자의 상한을 현재 전역 값으로 켭니다. | | `codexAccounts?` | `CodexAccount[]` | `[]` | Codex Auth가 관리하는 ChatGPT/Codex 풀 계정 메타데이터입니다. 비밀 정보는 `codex-accounts.json`에 따로 저장됩니다. | -| `pausedCodexAccountIds?` | `string[]` | `[]` | 일시 중지된 `__main__` 계정을 포함해, 재개될 때까지 Pool 선택에서 제외되는 계정입니다. | +| `pausedCodexAccountIds?` | `string[]` | `[]` | 일시 중지된 `__main__` 계정을 포함해, 재개될 때까지 Pool 선택에서 제외되는 계정입니다. 수동 일시 중지 및 재개는 같은 계정과 워크스페이스의 기존 기본 로그인 및 풀 항목에도 적용됩니다. | | `codexAccountNamespaces?` | `Record` | — | 임의의 공개 model selector를 저장된 Codex 계정 target에 연결하는 선택적 map입니다. 계정 한정 선택기 행이 활성화되어 있으면 target이 존재하는 각 selector는 Codex picker에 별도의 `/` row를 추가하며, 각 row는 해당 계정만 사용합니다. selector가 하나라도 활성화되면 bare native row는 picker에서 숨겨지지만, 명시적으로 비활성화하지 않는 한 해당 id는 계속 routing 가능하고 raw `/v1/models`에 표시됩니다. | | `codexAccountPickerEnabled?` | `boolean` | map이 비어 있으면 꺼짐 | 유효한 `codexAccountNamespaces` 매핑에서 account-qualified Codex 선택기 행을 생성할지 제어합니다. `true`는 매핑된 행의 표시를 허용합니다. 비어 있지 않은 map에서 생략하면 이전 버전과의 호환성을 위해 활성화된 것으로 취급되며, map이 비어 있으면 꺼집니다. `false`는 매핑을 삭제하거나 명시적 `/` 라우팅을 비활성화하지 않은 채 생성 행을 숨기고 선택기에 bare native 행을 복원합니다. | | `activeCodexAccountId?` | `string` | — | 다음 요청에 수동으로 선택한 Pool 계정입니다. 선택하면 thread 결속이 해제되며, 진행 중인 요청은 캡처한 자격 증명을 유지합니다. | diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index d23c45edb22..ce28f9ffaa3 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -44,7 +44,7 @@ separate. Full request URLs such as `/api/v1/responses` are not provider base UR | `providerContextCapValues?` | `Record` | `{}` | Last selected provider limits, retained while disabled. These values do not activate a cap. An enabled value takes precedence over a remembered value. | | `contextCapValue?` | `number` | `350000` | Default used on first enable. A later enable restores the selected provider value. Updating the global value with `setAll: true` changes enabled caps only; `setAll: true` without a value enables all configured providers at the current global value. | | `codexAccounts?` | `CodexAccount[]` | `[]` | ChatGPT/Codex pool account metadata managed by Codex Auth. Secrets live separately in `codex-accounts.json`. | -| `pausedCodexAccountIds?` | `string[]` | `[]` | Accounts excluded from Pool selection until resumed, including the main `__main__` account when paused. | +| `pausedCodexAccountIds?` | `string[]` | `[]` | Accounts excluded from Pool selection until resumed, including the main `__main__` account when paused. Manual pause and resume also update existing main and pool entries for the same account and workspace. | | `creditCodexAccountIds?` | `string[]` | `[]` | Accounts allowed to keep serving from ChatGPT credits after a usage limit, including the main `__main__` account. Upstream does not refuse an account that holds credits at 100%; it serves the request and draws the balance. Spending is opt-in: an account not listed here is skipped by selection while one of its usage windows (weekly or monthly, only monthly on 30-day plans, or the 5-hour window) reads 100% with its reset still ahead, and returns once that reset passes. A weekly or monthly reading without a reset time does not hold the account; a 5-hour reading at 100% without a reset holds it only while that reading is still fresh. When no other account is available, automatic selection finds none rather than spending credits. An unlisted `__main__` is also refused, like a hard-lock refusal, when a request names it or carries its credential. Listing `__main__` does not lift the main-account hard lock (on by default at 98%), which still stops the main login first; turn the lock off to let the main account spend credits (a lock at 100% still stops it at 100%). New accounts start unlisted. Managed by the **Use credits** switch in the Codex Auth header and the **Use credits after limit** switch in each account card's **⋯** menu. | | `codexQuotaAutoRefresh?` | `Record` | `{}` | Per-Codex-login-account opt-in for automatic `fiveHour` and `weekly` window activation in Pool mode; Direct mode does not run this worker. In Providers/Codex Auth **Advanced settings**, one control enables or disables both supported windows across all current main and added accounts. New accounts are not opted in automatically. Enable skips windows absent from live WHAM data; disable also clears stale enabled windows. The UI reuses granular `/api/settings` writes, reconciles partial failures, and retries the original ON/OFF intent without replacing unrelated settings or completed reset markers. The API still rejects enabling an unavailable window with HTTP 409. At a reported reset time, opencodex sends one minimal non-stored Codex message using that account's quota and persists the activated timestamp. This does not apply to API-key providers. | | `codexAccountNamespaces?` | `Record` | — | Optional map from an arbitrary public model selector to a stored Codex account target. When account-qualified picker rows are enabled, each selector whose target is present adds separate `/` rows to the Codex picker; each row uses only that account. With any selector active, bare native rows are hidden in the picker, but their ids remain routable and listed by raw `/v1/models` unless explicitly disabled. | diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index 1378a13f20e..4664d425d26 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -677,7 +677,7 @@ manager. Its routes are: | --- | --- | --- | | `GET, POST, DELETE /api/codex-auth/accounts` | List/refresh or delete Codex accounts. POST is retained as a disabled compatibility endpoint; successful DELETE responses include `catalogRefreshPending`. | POST always returns 403 `manual_import_disabled`; 400 invalid DELETE input | | `PUT /api/codex-auth/accounts/alias` | Set or clear an account alias | 400 invalid account/alias | -| `PUT /api/codex-auth/accounts/pause` | Pause or resume one account | 400 invalid account/state; 404 missing account | +| `PUT /api/codex-auth/accounts/pause` | Manually pause or resume an account and its existing matching main/pool entries; returns `affectedAccountIds` | 400 invalid account/state; 404 missing account; 503 main identity busy or unreadable | | `PUT /api/codex-auth/accounts/pause-exhausted` | Pause accounts whose quota is exhausted | Mutation-lock failures become 503 | | `PUT /api/codex-auth/accounts/credits` | Allow or stop spending ChatGPT credits after the usage limit. Body `{ id, creditsAfterLimit }` for one account, including `__main__`: true adds the id to `creditCodexAccountIds`, false removes it. Body `{ all }` for the global switch: true lists `__main__` and every pool account, false clears the list. Applies to the next selection. | 400 invalid id or non-boolean value; 404 missing account | | `PUT /api/settings` with `codexQuotaAutoRefresh: { id, window, enabled }` | Enable or disable 5-hour or weekly automatic window activation for one account | 400 invalid id/window/state; 404 missing account; 409 unavailable window | diff --git a/docs-site/src/content/docs/ru/reference/configuration/providers.md b/docs-site/src/content/docs/ru/reference/configuration/providers.md index 7070027d795..35f25ae4f52 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -32,7 +32,7 @@ ocx models provider openrouter on | `providerContextCapValues?` | `Record` | `{}` | Последние выбранные лимиты по провайдерам, сохраняемые после отключения. Эти значения сами по себе не включают ограничение. Активное значение имеет приоритет над сохранённым. | | `contextCapValue?` | `number` | `350000` | Значение по умолчанию при первом включении. При повторном включении восстанавливается выбранное значение провайдера. Изменение глобального значения с `setAll: true` обновляет только активные лимиты; `setAll: true` без значения включает лимиты всех настроенных провайдеров с текущим глобальным значением. | | `codexAccounts?` | `CodexAccount[]` | `[]` | Метаданные аккаунтов пула ChatGPT/Codex, которыми управляет Codex Auth. Секреты живут отдельно в `codex-accounts.json`. | -| `pausedCodexAccountIds?` | `string[]` | `[]` | Аккаунты, исключённые из выбора Pool до снятия паузы, включая основной аккаунт `__main__`, если он поставлен на паузу. | +| `pausedCodexAccountIds?` | `string[]` | `[]` | Аккаунты, исключённые из выбора Pool до снятия паузы, включая основной аккаунт `__main__`, если он поставлен на паузу. Ручная приостановка и возобновление также обновляют существующие записи основного входа и пула для той же учётной записи и рабочего пространства. | | `codexAccountNamespaces?` | `Record` | — | Необязательное сопоставление произвольного публичного селектора модели с сохранённым аккаунтом Codex. Когда строки picker'а с указанием аккаунта включены, каждый селектор с существующей целью добавляет в model picker Codex отдельные строки `/`; каждая строка использует только этот аккаунт. Если активен хотя бы один селектор, bare native-строки скрываются в picker, но их id остаются маршрутизируемыми и перечисляются raw `/v1/models`, если они не отключены явно. | | `codexAccountPickerEnabled?` | `boolean` | выкл. при пустой map | Управляет созданием account-qualified строк picker'а Codex из подходящих сопоставлений `codexAccountNamespaces`. `true` разрешает показывать сопоставленные строки. Если поле не задано при непустой map, функция считается включённой для обратной совместимости; при пустой map она выключена. `false` скрывает созданные строки и возвращает bare native-строки в picker, не удаляя сопоставления и не отключая точную маршрутизацию `/`. | | `activeCodexAccountId?` | `string` | — | Вручную выбранный аккаунт Pool для следующего запроса. Выбор очищает thread affinity; in-flight-запросы сохраняют уже захваченные credential'ы. | diff --git a/docs-site/src/content/docs/tr/reference/configuration/providers.md b/docs-site/src/content/docs/tr/reference/configuration/providers.md index c54cf2a12bb..e351ffe2c3d 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/tr/reference/configuration/providers.md @@ -32,7 +32,7 @@ Arayüzde kayıt veya OAuth girişi tamamlanınca Models sayfasını açan bir b | `providerContextCapValues?` | `Record` | `{}` | Sağlayıcı başına son seçilen sınırlar; devre dışı bırakıldığında da saklanır. Bu değerler tek başına sınırı etkinleştirmez. Etkin değer, saklanan değerden önceliklidir. | | `contextCapValue?` | `number` | `350000` | İlk etkinleştirmede kullanılan varsayılan değer. Sonraki etkinleştirmelerde sağlayıcının seçimi geri yüklenir. Genel değeri `setAll: true` ile güncellemek yalnızca etkin sınırları değiştirir; değer olmadan `setAll: true`, yapılandırılmış tüm sağlayıcıların sınırlarını geçerli genel değerle etkinleştirir. | | `codexAccounts?` | `CodexAccount[]` | `[]` | Codex Auth tarafından yönetilen ChatGPT/Codex havuz hesabı meta verileri. Sırlar ayrı olarak `codex-accounts.json` içinde yer alır. | -| `pausedCodexAccountIds?` | `string[]` | `[]` | Duraklatıldığında ana `__main__` hesabı da dahil olmak üzere, devam ettirilene kadar Havuz seçiminden hariç tutulan hesaplar. | +| `pausedCodexAccountIds?` | `string[]` | `[]` | Duraklatıldığında ana `__main__` hesabı da dahil olmak üzere, devam ettirilene kadar Havuz seçiminden hariç tutulan hesaplar. Elle duraklatma ve devam ettirme, aynı hesap ve çalışma alanının mevcut ana giriş ve havuz kayıtlarını da günceller. | | `codexAccountNamespaces?` | `Record` | — | İsteğe bağlı olarak rastgele bir genel model seçiciden saklanan bir Codex hesap hedefine eşleme. Hesap nitelikli seçici satırları etkinleştirildiğinde, hedefi mevcut olan her seçici, Codex seçicisine ayrı `/` satırları ekler; her satır yalnızca o hesabı kullanır. Herhangi bir seçici etkinken, yalın yerel satırlar seçicide gizlenir, ancak açıkça devre dışı bırakılmadıkça kimlikleri yönlendirilebilir kalır ve ham `/v1/models` tarafından listelenir. | | `codexAccountPickerEnabled?` | `boolean` | harita boşken kapalı | Uygun `codexAccountNamespaces` eşlemelerinin hesap nitelikli Codex seçici satırları oluşturup oluşturmayacağını denetler. `true`, eşlenen satırların görünmesine izin verir. Boş olmayan bir haritayla atlanırsa, geriye dönük uyumluluk için etkin olarak değerlendirilir; harita boşsa kapalıdır. `false`, eşlemeleri silmeden veya tam `/` yönlendirmesini devre dışı bırakmadan oluşturulan satırları gizler ve yalın yerel seçici satırlarını geri yükler. | | `activeCodexAccountId?` | `string` | — | Sonraki istek için manuel olarak seçilen Havuz hesabı. Seçim iş parçacığı bağlılığını temizler; devam eden istekler yakalanan kimlik bilgilerini korur. | diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md index fa9e46f2fc2..39e62f9de1a 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md @@ -31,7 +31,7 @@ ocx models provider openrouter on | `providerContextCapValues?` | `Record` | `{}` | 各提供商最后选择的上限,关闭后仍保留。仅保存这些值不会启用上限。有效值优先于保存的选择值。 | | `contextCapValue?` | `number` | `350000` | 首次开启时使用的默认值。再次开启时恢复该提供商的选择值。修改全局值时附带 `setAll: true` 只会更新已开启的上限;不带值的 `setAll: true` 会按当前全局值开启所有已配置提供商的上限。 | | `codexAccounts?` | `CodexAccount[]` | `[]` | 由 Codex Auth 管理的 ChatGPT/Codex 池账户元数据。密钥单独存放在 `codex-accounts.json` 中。 | -| `pausedCodexAccountIds?` | `string[]` | `[]` | 在恢复之前从 Pool 选择中排除的账户,包括被暂停时的主 `__main__` 账户。 | +| `pausedCodexAccountIds?` | `string[]` | `[]` | 在恢复之前从 Pool 选择中排除的账户,包括被暂停时的主 `__main__` 账户。 手动暂停或恢复会同步更新同一账号、同一工作区的主登录与池内已有入口。 | | `codexAccountNamespaces?` | `Record` | — | 将任意公开 model selector 映射到已保存 Codex account target 的可选配置。启用账户限定的选择器行后,target 存在的每个 selector 都会在 Codex picker 中添加独立的 `/` row,且每个 row 只使用对应账户。只要有 selector 生效,bare native row 就会在 picker 中隐藏;但除非显式禁用,其 id 仍可路由,并继续列在 raw `/v1/models` 中。 | | `codexAccountPickerEnabled?` | `boolean` | 映射为空时关闭 | 控制是否根据有效的 `codexAccountNamespaces` 映射生成账户限定的 Codex 选择器行。`true` 允许显示映射行。在非空映射中省略此字段时,为保持向后兼容会视为已启用;映射为空时则关闭。`false` 会隐藏生成行并恢复选择器中的裸原生行,但不会删除映射,也不会禁用精确的 `/` 路由。 | | `activeCodexAccountId?` | `string` | — | 为下一次请求手动选定的 Pool 账户。选择会清除线程亲和性;进行中的请求会保留捕获到的凭据。 | diff --git a/docs-site/src/content/docs/zh-cn/reference/management-api.md b/docs-site/src/content/docs/zh-cn/reference/management-api.md index f02cf6e6d5d..abcdb26b4f9 100644 --- a/docs-site/src/content/docs/zh-cn/reference/management-api.md +++ b/docs-site/src/content/docs/zh-cn/reference/management-api.md @@ -313,7 +313,7 @@ OpenAI 也遵循此规则:开关不会选择特殊的 922k 模式。有效上 | --- | --- | --- | | `GET, POST, DELETE /api/codex-auth/accounts` | 列出/刷新或删除 Codex 账户。POST 仅作为已禁用的兼容端点保留;成功的 DELETE 响应包含 `catalogRefreshPending`。 | POST 始终返回 403 `manual_import_disabled`;DELETE 输入无效时返回 400 | | `PUT /api/codex-auth/accounts/alias` | 设置或清除账户别名 | 400 账户/别名无效 | -| `PUT /api/codex-auth/accounts/pause` | 暂停或恢复一个账户 | 400 账户/状态无效;404 缺少账户 | +| `PUT /api/codex-auth/accounts/pause` | 手动暂停或恢复账户及同身份的已有主登录/池内入口;返回 `affectedAccountIds` | 400 账户/状态无效;404 缺少账户;503 主登录身份忙碌或无法读取 | | `PUT /api/codex-auth/accounts/pause-exhausted` | 暂停配额已耗尽的账户 | 变更锁失败会变成 503 | | `POST /api/codex-auth/accounts/clear-cooldown` | 清除一个账户或所有账户的运行时冷却 | 400 id 无效 | | `GET, PUT /api/codex-auth/active` | 读取或选择当前活跃账户 | 400 账户无效或缺失;409 暂停/旧行冲突 | diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md index f3d3a121179..78d0118dd20 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md @@ -31,7 +31,7 @@ ocx models provider openrouter on | `providerContextCapValues?` | `Record` | `{}` | 各供應商最後選擇的上限,停用後仍保留。僅儲存這些值不會啟用上限。生效中的值優先於儲存的選擇值。 | | `contextCapValue?` | `number` | `350000` | 首次啟用時使用的預設值。再次啟用時恢復該供應商的選擇值。修改全域值時附帶 `setAll: true` 只會更新已啟用的上限;不帶值的 `setAll: true` 會以目前全域值啟用所有已設定供應商的上限。 | | `codexAccounts?` | `CodexAccount[]` | `[]` | 由 Codex Auth 管理的 ChatGPT/Codex 池帳號中繼資料。秘密分別存在 `codex-accounts.json`。 | -| `pausedCodexAccountIds?` | `string[]` | `[]` | 被排除於池選擇直到恢復的帳號,包含暫停時的 main `__main__` 帳號。 | +| `pausedCodexAccountIds?` | `string[]` | `[]` | 被排除於池選擇直到恢復的帳號,包含暫停時的 main `__main__` 帳號。 手動暫停或恢復會同步更新同一帳號、同一工作區的主登入與池內既有入口。 | | `codexAccountNamespaces?` | `Record` | — | 公開模型選擇器命名空間到已儲存 Codex 帳號目標。這會驗證並持久化映射,但不會自行新增 picker 列或變更路由。 | | `activeCodexAccountId?` | `string` | — | 為下一個請求手動選擇的池帳號。選擇清除執行緒親和性;進行中的請求保留擷取的憑證。 | | `autoSwitchThreshold?` | `number` | `80` | 主動切換的用量閾值。`quota` 可在下一個請求時重新評估未綁定任務。綁定任務預設(`pool.cacheAffinity`)在越過閾值後仍會保留帳號,直到該帳號耗盡或無法繼續服務,並且只改綁到確有額度餘裕且用量嚴格更低的帳號。將 `pool.cacheAffinity` 設為 `false` 才會在此閾值重新評估綁定任務。`fill-first` 僅將其用作未綁定指派的排空點;一般 `round-robin` 選擇不使用它。分數使用最熱的已知 5h、週或 30d 配額視窗。`0` 僅停用基於用量的主動切換,而非未綁定指派或失敗復原。 | diff --git a/gui/src/hooks/useCodexAccountPool.ts b/gui/src/hooks/useCodexAccountPool.ts index dbf5db18610..a5a3206eb50 100644 --- a/gui/src/hooks/useCodexAccountPool.ts +++ b/gui/src/hooks/useCodexAccountPool.ts @@ -539,9 +539,16 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou }); 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 }; + const result = (raw && typeof raw === "object" ? raw : {}) as { + activeCodexAccountId?: string | null; + affectedAccountIds?: unknown; + }; + const affectedIds = new Set(Array.isArray(result.affectedAccountIds) + ? result.affectedAccountIds.filter((value): value is string => typeof value === "string") + : [id]); + affectedIds.add(id); setAccounts(current => current.map(account => ( - account.id === id || (id === "__main__" && account.isMain) + affectedIds.has(account.id) || (affectedIds.has("__main__") && account.isMain) ? { ...account, paused } : account ))); @@ -555,9 +562,9 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou } // Deliberately NOT cross-gated against the switch and order writes, even though // pausing the pinned account also releases the pin. This edge is conditional on - // the pin still naming `id`, which makes it order-robust: whichever response + // the pin still naming an affected account, which makes it order-robust: whichever response // lands last, the client agrees with the server. - if (paused) setActivePinnedId(current => current === id ? null : current); + if (paused) setActivePinnedId(current => current !== null && affectedIds.has(current) ? null : current); void load(); return { ok: true } as const; } catch { diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 254a77afcf4..a96964d94e7 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1837,7 +1837,7 @@ export const de: Record = { "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.pausedHint": "Bis zur Fortsetzung von automatischem Wechsel, Wiederholungen, Cooldown-Wiederherstellung und manueller Auswahl ausgeschlossen. Manuelles Pausieren und Fortsetzen aktualisiert auch vorhandene Haupt- und Pool-Einträge desselben Kontos im selben Arbeitsbereich.", "codexAuth.creditsAfterLimit": "Credits nach dem Limit nutzen", "codexAuth.creditsAfterLimitHint": "Standardmäßig aus: Ein Konto, dessen Nutzungsfenster 100 % erreicht, wird bis zum Zurücksetzen ausgewechselt, sodass seine ChatGPT-Credits nicht verbraucht werden. Schalte ein Konto ein, damit es mit seinen Credits weiterarbeitet. Neue Konten starten ausgeschaltet.", "codexAuth.creditsAfterLimitAria": "Credits nach dem Nutzungslimit nutzen für {email}", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index cecfbdc73b2..1fb521cc965 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -2448,7 +2448,7 @@ export const en = { "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.pausedHint": "Excluded from automatic switching, retries, cooldown recovery, and manual selection until resumed. Manual pause and resume also update existing main and pool entries for the same account and workspace.", "codexAuth.creditsAfterLimit": "Use credits after limit", "codexAuth.creditsAfterLimitHint": "Off by default: an account whose usage window reaches 100% is switched out until it resets, so its ChatGPT credits are not spent. Turn an account on to let it keep working from its credits. New accounts start off.", "codexAuth.creditsAfterLimitAria": "Use credits after the usage limit for {email}", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index d24dfd7227b..6e533eb269c 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -2375,7 +2375,7 @@ export const fr: Record = { "codexAuth.resumeSucceeded": "{email} est de nouveau disponible dans le groupe", "codexAuth.pauseFailed": "Impossible de suspendre {email}. Aucune modification apportée.", "codexAuth.resumeFailed": "Impossible de réactiver {email}. Aucune modification apportée.", - "codexAuth.pausedHint": "Exclu du changement automatique, des nouvelles tentatives, de la récupération après délai et de la sélection manuelle jusqu’à sa réactivation.", + "codexAuth.pausedHint": "Exclu du changement automatique, des nouvelles tentatives, de la récupération après délai et de la sélection manuelle jusqu’à sa réactivation. La pause et la reprise manuelles mettent aussi à jour les entrées principales et du pool existantes du même compte et espace de travail.", "codexAuth.creditsAfterLimit": "Utiliser les crédits après la limite", "codexAuth.creditsAfterLimitHint": "Désactivé par défaut : un compte dont une fenêtre d’utilisation atteint 100 % est remplacé jusqu’à sa réinitialisation, et ses crédits ChatGPT ne sont pas dépensés. Activez un compte pour qu’il continue avec ses crédits. Les nouveaux comptes démarrent désactivés.", "codexAuth.creditsAfterLimitAria": "Utiliser les crédits après la limite d’utilisation pour {email}", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 340a65593c3..fec9d5381c8 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -2270,7 +2270,7 @@ export const ja: Record = { "codexAuth.resumeSucceeded": "{email} をアカウントプールに戻しました", "codexAuth.pauseFailed": "{email} を一時停止できませんでした。変更はありません。", "codexAuth.resumeFailed": "{email} を再開できませんでした。変更はありません。", - "codexAuth.pausedHint": "再開するまで、自動切り替え、再試行、クールダウン復旧、手動選択の対象外です。", + "codexAuth.pausedHint": "再開するまで、自動切り替え、再試行、クールダウン復旧、手動選択の対象外です。 手動の一時停止と再開は、同じアカウント・ワークスペースのメインログインとプール内の既存エントリにも反映されます。", "codexAuth.creditsAfterLimit": "上限後にクレジットを使用", "codexAuth.creditsAfterLimitHint": "既定はオフです。使用量ウィンドウが100%に達したアカウントはリセットまで別のアカウントに切り替わり、ChatGPTクレジットを消費しません。オンにしたアカウントだけが上限後もクレジットで使われます。新しいアカウントはオフで始まります。", "codexAuth.creditsAfterLimitAria": "{email} の上限後クレジット使用", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index a4f9993543d..289637b23cf 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1881,7 +1881,7 @@ export const ko: Record = { "codexAuth.resumeSucceeded": "{email} 계정을 풀에서 다시 사용할 수 있습니다", "codexAuth.pauseFailed": "{email} 계정을 일시 중지하지 못했습니다. 변경 사항이 없습니다.", "codexAuth.resumeFailed": "{email} 계정을 재개하지 못했습니다. 변경 사항이 없습니다.", - "codexAuth.pausedHint": "재개할 때까지 자동 전환, 재시도, 쿨다운 복구 및 수동 선택에서 제외됩니다.", + "codexAuth.pausedHint": "재개할 때까지 자동 전환, 재시도, 쿨다운 복구 및 수동 선택에서 제외됩니다. 수동 일시 중지 및 재개는 같은 계정과 워크스페이스의 기존 기본 로그인 및 풀 항목에도 적용됩니다.", "codexAuth.creditsAfterLimit": "한도 도달 후 크레딧 사용", "codexAuth.creditsAfterLimitHint": "기본은 꺼짐입니다. 사용량이 100%에 도달한 계정은 리셋될 때까지 다른 계정으로 전환되어 ChatGPT 크레딧을 쓰지 않습니다. 켠 계정만 한도 이후에도 크레딧으로 계속 사용됩니다. 새로 추가한 계정은 꺼진 상태로 시작합니다.", "codexAuth.creditsAfterLimitAria": "{email} 계정의 한도 도달 후 크레딧 사용", diff --git a/gui/src/i18n/pt.ts b/gui/src/i18n/pt.ts index f704e8ebdf0..4f80304481d 100644 --- a/gui/src/i18n/pt.ts +++ b/gui/src/i18n/pt.ts @@ -2422,7 +2422,7 @@ export const pt: Record = { "codexAuth.resumeSucceeded": "{email} voltou a ficar disponível para o pool", "codexAuth.pauseFailed": "Não foi possível pausar {email}. Nada foi alterado.", "codexAuth.resumeFailed": "Não foi possível retomar {email}. Nada foi alterado.", - "codexAuth.pausedHint": "Excluída da troca automática, das novas tentativas, da recuperação de cooldown e da seleção manual até ser retomada.", + "codexAuth.pausedHint": "Excluída da troca automática, das novas tentativas, da recuperação de cooldown e da seleção manual até ser retomada. A pausa e a retomada manuais também atualizam as entradas principais e do pool existentes para a mesma conta e espaço de trabalho.", "codexAuth.creditsAfterLimit": "Usar créditos após o limite", "codexAuth.creditsAfterLimitHint": "Desativado por padrão: uma conta cuja janela de uso chega a 100% é retirada de uso até a janela reiniciar, para que seus créditos do ChatGPT não sejam gastos. Ative em uma conta para que ela continue trabalhando com seus créditos. Contas novas começam desativadas.", "codexAuth.creditsAfterLimitAria": "Usar créditos após o limite de uso para {email}", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 4260be9a684..199bbcb2f45 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -2361,7 +2361,7 @@ export const ru: Record = { "codexAuth.resumeSucceeded": "Аккаунт {email} снова доступен в пуле", "codexAuth.pauseFailed": "Не удалось приостановить {email}. Изменений нет.", "codexAuth.resumeFailed": "Не удалось возобновить {email}. Изменений нет.", - "codexAuth.pausedHint": "До возобновления исключён из автоматического переключения, повторов, восстановления после задержки и ручного выбора.", + "codexAuth.pausedHint": "До возобновления исключён из автоматического переключения, повторов, восстановления после задержки и ручного выбора. Ручная приостановка и возобновление также обновляют существующие записи основного входа и пула для той же учётной записи и рабочего пространства.", "codexAuth.creditsAfterLimit": "Кредиты после лимита", "codexAuth.creditsAfterLimitHint": "По умолчанию выключено: аккаунт, у которого окно использования достигло 100%, заменяется до сброса, и его кредиты ChatGPT не тратятся. Включите эту настройку для аккаунта, чтобы он продолжал работать за счёт кредитов. Новые аккаунты начинают с выключенной настройкой.", "codexAuth.creditsAfterLimitAria": "Кредиты после лимита использования для {email}", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 1c1b3ec2320..bc4895d2446 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -2399,7 +2399,7 @@ export const tr: Record = { "codexAuth.resumeSucceeded": "{email} tekrar havuza alındı", "codexAuth.pauseFailed": "{email} duraklatılamadı.", "codexAuth.resumeFailed": "{email} devam ettirilemedi.", - "codexAuth.pausedHint": "Devam ettirilene kadar otomatik seçimden hariç tutulur.", + "codexAuth.pausedHint": "Devam ettirilene kadar otomatik seçimden hariç tutulur. Elle duraklatma ve devam ettirme, aynı hesap ve çalışma alanının mevcut ana giriş ve havuz kayıtlarını da günceller.", "codexAuth.creditsAfterLimit": "Limitten sonra kredi kullan", "codexAuth.creditsAfterLimitHint": "Varsayılan olarak kapalı: kullanım penceresi %100’e ulaşan hesap sıfırlanana kadar otomatik seçim dışında kalır ve ChatGPT kredileri harcanmaz. Bir hesabın kredileriyle çalışmaya devam etmesi için bu ayarı açın. Yeni hesaplar kapalı başlar.", "codexAuth.creditsAfterLimitAria": "{email} için kullanım limitinden sonra kredi kullan", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index be623d39883..6456b6db7df 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -2403,7 +2403,7 @@ export const vi: Record = { "codexAuth.resumeSucceeded": "{email} đã có mặt lại trong pool", "codexAuth.pauseFailed": "Không thể ngưng {email}. Không có thay đổi nào.", "codexAuth.resumeFailed": "Không thể tiếp tục {email}. Không có thay đổi nào.", - "codexAuth.pausedHint": "Được loại trừ khỏi luân phiên tự động, thử lại, hồi phục sau thời gian chờ, và lựa chọn thủ công cho đến khi tiếp tục.", + "codexAuth.pausedHint": "Được loại trừ khỏi luân phiên tự động, thử lại, hồi phục sau thời gian chờ, và lựa chọn thủ công cho đến khi tiếp tục. Tạm dừng và tiếp tục thủ công cũng cập nhật các mục đăng nhập chính và nhóm hiện có của cùng tài khoản và không gian làm việc.", "codexAuth.creditsAfterLimit": "Dùng tín dụng sau giới hạn", "codexAuth.creditsAfterLimitHint": "Mặc định tắt: tài khoản có cửa sổ sử dụng đạt 100% sẽ được thay thế cho đến khi đặt lại, nên tín dụng ChatGPT của nó không bị tiêu. Bật một tài khoản để nó tiếp tục chạy bằng tín dụng. Tài khoản mới bắt đầu ở trạng thái tắt.", "codexAuth.creditsAfterLimitAria": "Dùng tín dụng sau giới hạn sử dụng cho {email}", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index a5e63c9d0c1..cdf44ec6625 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -1792,7 +1792,7 @@ export const zhTW: Record = { "codexAuth.resumeSucceeded": "{email} 已重新加入帳號池", "codexAuth.pauseFailed": "無法暫停 {email},未做任何變更。", "codexAuth.resumeFailed": "無法恢復 {email},未做任何變更。", - "codexAuth.pausedHint": "恢復前不會參與自動切換、重試、冷卻恢復或手動選擇。", + "codexAuth.pausedHint": "恢復前不會參與自動切換、重試、冷卻恢復或手動選擇。 手動暫停或恢復會同步更新同一帳號、同一工作區的主登入與池內既有入口。", "codexAuth.creditsAfterLimit": "達到上限後使用額度", "codexAuth.creditsAfterLimitHint": "預設關閉:用量視窗達到 100% 的帳戶會被切換出去,直到重設,不消耗其 ChatGPT 額度。開啟某個帳戶後,它在達到上限後仍會使用額度繼續運作。新帳戶預設關閉。", "codexAuth.creditsAfterLimitAria": "{email} 達到上限後使用額度", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index b99ce665365..6dcc399ddf1 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1854,7 +1854,7 @@ export const zh: Record = { "codexAuth.resumeSucceeded": "{email} 已重新加入账号池", "codexAuth.pauseFailed": "无法暂停 {email},未做任何更改。", "codexAuth.resumeFailed": "无法恢复 {email},未做任何更改。", - "codexAuth.pausedHint": "恢复前不会参与自动切换、重试、冷却恢复或手动选择。", + "codexAuth.pausedHint": "恢复前不会参与自动切换、重试、冷却恢复或手动选择。 手动暂停或恢复会同步更新同一账号、同一工作区的主登录与池内已有入口。", "codexAuth.creditsAfterLimit": "达到上限后使用额度", "codexAuth.creditsAfterLimitHint": "默认关闭:用量窗口达到 100% 的账户会被切换出去,直到重置,不消耗其 ChatGPT 额度。打开某个账户后,它在达到上限后仍会使用额度继续工作。新账户默认关闭。", "codexAuth.creditsAfterLimitAria": "{email} 达到上限后使用额度", diff --git a/gui/tests/codex-account-pool-behaviour.test.tsx b/gui/tests/codex-account-pool-behaviour.test.tsx index 3a4422ea2ee..ea2247b9ef7 100644 --- a/gui/tests/codex-account-pool-behaviour.test.tsx +++ b/gui/tests/codex-account-pool-behaviour.test.tsx @@ -26,6 +26,7 @@ let usageAccounts: unknown[] = []; let threshold = 80; let nextAccountsResponseGate: Promise | null = null; let pauseResponseActiveId: string | null = null; +let pauseAffectedAccountIds: string[] | undefined; let bulkPausedAccountIds: string[] = ["a2"]; let bulkResponseActiveId: string | null = null; let priorityResponseOk = true; @@ -54,6 +55,7 @@ beforeEach(() => { calls = []; nextAccountsResponseGate = null; pauseResponseActiveId = null; + pauseAffectedAccountIds = undefined; bulkPausedAccountIds = ["a2"]; bulkResponseActiveId = null; priorityResponseOk = true; @@ -132,14 +134,15 @@ beforeEach(() => { nextPauseResponseGate = null; if (gate) await gate; const body = JSON.parse(String(init?.body)) as { id: string; paused: boolean }; + const affectedIds = new Set(pauseAffectedAccountIds ?? [body.id]); 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)) + && (affectedIds.has(String(account.id)) || (affectedIds.has("__main__") && "isMain" in account && account.isMain === true)) ? { ...account, paused: body.paused } : account )); - if (body.paused && activePinnedAccountId === body.id) activePinnedAccountId = null; - return { ok: true, json: async () => ({ activeCodexAccountId: pauseResponseActiveId }) } as unknown as Response; + if (body.paused && activePinnedAccountId && affectedIds.has(activePinnedAccountId)) activePinnedAccountId = null; + return { ok: true, json: async () => ({ activeCodexAccountId: pauseResponseActiveId, affectedAccountIds: pauseAffectedAccountIds }) } as unknown as Response; } if (path === "codex-auth/accounts/pause-exhausted") { const pausedIds = new Set(bulkPausedAccountIds); @@ -345,6 +348,34 @@ test("pausing an account writes the persisted endpoint and updates shared state" expect(seen.current!.activeId).toBeNull(); }); +for (const selectedId of ["__main__", "duplicate-login"]) { + test(`linked pause and resume update both cards before reload from ${selectedId}`, async () => { + accounts = [ + { id: "a1", email: "main", isMain: true, paused: false, priority: 0, hasCredential: true, quota: null }, + { id: "duplicate-login", email: "duplicate", isMain: false, paused: false, priority: 0, hasCredential: true, quota: null }, + { id: "other-workspace", email: "other", isMain: false, paused: false, priority: 0, hasCredential: true, quota: null }, + ]; + activePinnedAccountId = "duplicate-login"; + pauseAffectedAccountIds = ["__main__", "duplicate-login"]; + const seen = await mountController(); + let releaseReload!: () => void; + nextAccountsResponseGate = new Promise(resolve => { releaseReload = resolve; }); + try { + await act(async () => { + expect(await seen.current!.setAccountPaused(selectedId, true)).toEqual({ ok: true }); + }); + expect(seen.current!.accounts.map(account => account.paused)).toEqual([true, true, false]); + expect(seen.current!.activePinnedId).toBeNull(); + await act(async () => { + expect(await seen.current!.setAccountPaused(selectedId === "__main__" ? "duplicate-login" : "__main__", false)).toEqual({ ok: true }); + }); + expect(seen.current!.accounts.map(account => account.paused)).toEqual([false, false, false]); + } finally { + await act(async () => { releaseReload(); await new Promise(resolve => setTimeout(resolve, 30)); }); + } + }); +} + test("pausing the main sentinel updates its distinct account row before reload", async () => { const seen = await mountController(); let releaseReload!: () => void; diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 61c524498e8..cebc0ac55b7 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -529,6 +529,7 @@ "codex-app-server-processes.test.ts": "codex-integration", "codex-app-server-restart-service.test.ts": "codex-integration", "codex-auth-api.test.ts": "codex-integration", + "codex-linked-account-pause.test.ts": "codex-integration", "codex-auth-cancel-ownership.test.ts": "codex-integration", "codex-auth-collision.test.ts": "codex-integration", "codex-auth-context.test.ts": "codex-integration", diff --git a/src/codex/auth-api/account-pause-group.ts b/src/codex/auth-api/account-pause-group.ts new file mode 100644 index 00000000000..9dfaf0d348c --- /dev/null +++ b/src/codex/auth-api/account-pause-group.ts @@ -0,0 +1,71 @@ +import type { OcxConfig } from "../../types"; +import { inspectChatGptDomainClaim, extractEmail } from "../../oauth/chatgpt"; +import { MAIN_CODEX_ACCOUNT_ID, isSelectableCodexPoolAccount } from "../account-id"; +import { readCodexTokensResult } from "../auth-collision"; +import { getCodexAccountCredential } from "../account-store"; +import { tryAcquireNativeMainProfileClaim } from "../native-main-admission"; +import { isNativeMainClaimUnavailable, nativeMainProfileBusyResponse, withNativeMainCredentialClaim } from "./http"; + +type AccountIdentity = { accountId: string; email: string }; + +function identity(accountId: unknown, email: unknown): AccountIdentity | undefined { + const normalizedEmail = typeof email === "string" ? email.trim().toLowerCase() : undefined; + return typeof accountId === "string" && accountId.trim() && normalizedEmail + ? { accountId: accountId.trim(), email: normalizedEmail } + : undefined; +} + +/** Resolve only existing entries, under native-main ownership, never on the request path. */ +function linkedAccountIds(config: OcxConfig, selectedId: string): string[] | undefined { + const main = readCodexTokensResult(); + // Unknown main identity must not produce a successful but incomplete pause. + if (main.status === "unreadable" || main.status === "invalid") return undefined; + const identities = new Map(); + if (main.status === "ok") { + const tokens = main.tokens; + if (typeof tokens.access_token !== "string" || typeof tokens.account_id !== "string" + || (tokens.id_token !== undefined && typeof tokens.id_token !== "string")) return undefined; + const claims = [tokens.id_token, tokens.access_token] + .filter((token): token is string => typeof token === "string") + .map(inspectChatGptDomainClaim); + if (claims.some(claim => claim.kind === "invalid")) return undefined; + const claimIds = claims.flatMap(claim => claim.kind === "valid" ? [claim.accountId] : []); + // organizations[] describes memberships, not the selected workspace. Never use its first row. + const accountId = tokens.account_id.trim() || claimIds[0]; + if (claimIds.some(claim => claim !== accountId)) return undefined; + identities.set(MAIN_CODEX_ACCOUNT_ID, identity(accountId, extractEmail(tokens.id_token, tokens.access_token))); + } + for (const account of config.codexAccounts ?? []) { + if (!isSelectableCodexPoolAccount(account)) continue; + const credential = getCodexAccountCredential(account.id); + identities.set(account.id, identity(credential?.chatgptAccountId, account.email)); + } + const selected = identities.get(selectedId); + // Missing identity evidence never links unrelated logins, including members of one workspace. + if (!selected) return [selectedId]; + return [selectedId, ...[...identities].flatMap(([id, candidate]) => ( + id !== selectedId && candidate?.accountId === selected.accountId && candidate.email === selected.email + ? [id] : [] + ))]; +} + +/** Keep discovery, publication and persistence inside the same physical-main claim. */ +export async function withCodexAccountPauseGroup( + config: OcxConfig, + selectedId: string, + publish: (accountIds: string[]) => Response, +): Promise { + const lease = tryAcquireNativeMainProfileClaim(); + if (!lease) return nativeMainProfileBusyResponse(); + try { + return await withNativeMainCredentialClaim(async () => { + const ids = linkedAccountIds(config, selectedId); + return ids ? publish(ids) : nativeMainProfileBusyResponse(); + }); + } catch (error) { + if (isNativeMainClaimUnavailable(error)) return nativeMainProfileBusyResponse(); + throw error; + } finally { + lease.release(); + } +} diff --git a/src/codex/auth-api/routes.ts b/src/codex/auth-api/routes.ts index f52b289c7f8..f2546328249 100644 --- a/src/codex/auth-api/routes.ts +++ b/src/codex/auth-api/routes.ts @@ -24,6 +24,7 @@ import { inspectResetCredits, consumeResetCredits } from "./reset-credit-service import { getRuntimeConfig, saveRuntimeConfig, configuredPoolAccount } from "./runtime-config"; import { captureConfigTopLevelRollback } from "../../config/rebase-provenance"; import { getEffectiveCodexAutoSwitchThreshold, isCodexAccountAutoSwitchThresholdKey, parseCodexAutoSwitchThreshold, setCodexAccountAutoSwitchThresholdOverride } from "../account-auto-switch"; +import { withCodexAccountPauseGroup } from "./account-pause-group"; export async function handleCodexAuthAPI( req: Request, @@ -100,18 +101,23 @@ export async function handleCodexAuthAPI( || (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, + const paused = body.paused; + return withCodexAccountPauseGroup(runtimeConfig, id, accountIds => { + // Publish every exclusion before reconciling: a duplicate must never become the fallback. + for (const accountId of accountIds) setCodexAccountPaused(runtimeConfig, accountId, paused); + if (paused) { + for (const accountId of accountIds) clearThreadAccountMapForAccount(accountId); + for (const accountId of accountIds) selectFallbackAfterPause(runtimeConfig, accountId); + } + saveRuntimeConfig(config, runtimeConfig); + return jsonResponse({ + ok: true, + id, + paused, + affectedAccountIds: accountIds, + activeCodexAccountId: getEffectiveActiveCodexAccountId(runtimeConfig) ?? null, + appliesImmediately: true, + }); }); } diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 1b67b1e5b8e..e48c79141c6 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -215,6 +215,13 @@ be treated as implemented: ## API ownership +`PUT /api/codex-auth/accounts/pause` manually pauses or resumes all existing entries whose account +and workspace identity is confirmed to match, including native main and a pool duplicate. Its +`affectedAccountIds` response contains only the existing opaque entry handles; emails and physical +account ids are not serialized. The account controller applies the response to every affected card +and conditionally clears a pin naming any paused member before refreshing. Older servers without +this field retain the single-entry client behavior. See [OpenAI account operations](providers/openai-accounts.md#manual-account-pause-and-resume). + Model rows keep stored custom overrides separate from their effective `exportMetadata` projection. `src/server/management/model-row-export-metadata.ts` resolves inherited limits and capabilities; the [client export contract](clients/integrations.md#owned-catalog-convergence) prevents picker-only diff --git a/structure/providers/openai-accounts.md b/structure/providers/openai-accounts.md index 4ed82b37bca..301417ed9e4 100644 --- a/structure/providers/openai-accounts.md +++ b/structure/providers/openai-accounts.md @@ -256,6 +256,18 @@ API-key and custom forward destinations preserve their metadata. See [Responses Listener startup diagnostics follow [the runtime lifecycle contract](../runtime.md#lifecycle); malformed optional listener blocks follow [config loading](../config.md#config-surface). +## Manual account pause and resume + +Manual pause/resume in `src/codex/auth-api/account-pause-group.ts` resolves existing native-main +and pool entries by the full ChatGPT account/workspace id and normalized email. Matching entries +share the operation; equal emails in different workspaces and different members of one workspace +remain independent. Missing identity evidence never links entries. Main discovery, group publication +and config persistence hold both native-main admission and the cross-process shared claim; a busy +or unreadable main identity returns 503 without publishing a partial group. All matching exclusions +are set before active-account reconciliation, so a duplicate cannot be selected as the fallback. +The persisted format remains `pausedCodexAccountIds`; routing performs no extra identity reads. +Automatic quota-protection and bulk-exhaustion policies retain their existing per-entry decisions. + ## Automatic pool plan exclusions `src/codex/routing/selection.ts` applies optional `codexPool.excludedPlans` to both candidate selection and existing active/affined accounts. An all-excluded pool returns no automatic candidate, including preview and configured-account fallback. Native main remains exempt and unknown plans remain eligible. Explicit account-qualified routes retain pause, credential and entitlement checks while bypassing only this automatic policy. diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index 7342ea65071..99202c57eee 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -178,6 +178,9 @@ alone for the rotation gate and fails closed, so only positive evidence changes 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. +Manual pause/resume synchronizes existing entries with a confirmed matching account and workspace, +including a duplicated main login. [Account operations](openai-accounts.md#manual-account-pause-and-resume) +owns identity matching and publication; automatic quota policies retain their per-entry decisions. 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. diff --git a/tests/codex-integration/codex-linked-account-pause.test.ts b/tests/codex-integration/codex-linked-account-pause.test.ts new file mode 100644 index 00000000000..68d8420b793 --- /dev/null +++ b/tests/codex-integration/codex-linked-account-pause.test.ts @@ -0,0 +1,214 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { OcxConfig } from "../../src/types"; +import { loadConfig } from "../../src/config"; +import { flushConfigDirHardeningForTests } from "../../src/config/paths"; +import { handleCodexAuthAPI, clearAccountQuota, updateAccountQuota } from "../../src/codex/auth-api"; +import { getCodexAccountCredential, saveCodexAccountCredential } from "../../src/codex/account-store"; +import { MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "../../src/codex/main-account"; +import { clearCodexUpstreamHealth, clearThreadAccountMap, resolveCodexAccountForThread } from "../../src/codex/routing"; +import { clearPoolRotationState } from "../../src/codex/pool-rotation"; +import { pinnedCodexAccountId, setCodexAccountPin } from "../../src/codex/account-priority"; +import { acquireNativeMainProfileDrain, resetLifecycleDrainStateForTests } from "../../src/server/lifecycle"; +import { setAsyncIcaclsRunnerForTests, setIcaclsRunnerForTests } from "../../src/lib/windows-secret-acl"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { withNativeMainExclusiveClaim } from "../../src/codex/native-main-claim"; +import { resolveNativeProfileContext } from "../../src/codex/native-profile-store"; + +let TEST_DIR: string; +let TEST_CODEX_HOME: string; +let previousHome: string | undefined; +let previousCodexHome: string | undefined; +const aclOk = { success: true, exitCode: 0, timedOut: false, stdout: "" }; + +function makeConfig(overrides: Partial = {}): OcxConfig { + return { port: 10100, providers: {}, defaultProvider: "openai", codexAccounts: [], ...overrides }; +} + +function seedPoolAccount(config: OcxConfig, account: { id: string; email: string; chatgptAccountId?: string }): void { + config.codexAccounts!.push({ id: account.id, email: account.email, isMain: false }); + saveCodexAccountCredential(account.id, { + accessToken: `access-${account.id}`, refreshToken: `refresh-${account.id}`, + expiresAt: Date.now() + 300_000, chatgptAccountId: account.chatgptAccountId ?? `acct-${account.id}`, + }); +} + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + previousCodexHome = process.env.CODEX_HOME; + TEST_DIR = mkdtempSync(join(tmpdir(), "ocx-linked-pause-")); + TEST_CODEX_HOME = join(TEST_DIR, "codex"); + mkdirSync(TEST_CODEX_HOME); + process.env.OPENCODEX_HOME = TEST_DIR; + process.env.CODEX_HOME = TEST_CODEX_HOME; + setIcaclsRunnerForTests(() => aclOk); + setAsyncIcaclsRunnerForTests(async () => aclOk); + resetLifecycleDrainStateForTests(); + clearThreadAccountMap(); + clearCodexUpstreamHealth(); + clearPoolRotationState(); + clearAccountQuota(); + setMainAccountPlan(null); +}); + +afterEach(async () => { + resetLifecycleDrainStateForTests(); + clearThreadAccountMap(); + clearCodexUpstreamHealth(); + clearPoolRotationState(); + clearAccountQuota(); + setMainAccountPlan(null); + await flushConfigDirHardeningForTests(); + setIcaclsRunnerForTests(null); + setAsyncIcaclsRunnerForTests(null); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + removeTreeWithRetry(TEST_DIR); +}); + +for (const selectedId of [MAIN_CODEX_ACCOUNT_ID, "same-login"]) { + test(`manual pause links main and pool identity from ${selectedId}`, async () => { + const config = makeConfig({ activeCodexAccountId: "same-login" }); + const idToken = `header.${Buffer.from(JSON.stringify({ + email: "SAME@example.test", chatgpt_account_id: "personal-scope", + })).toString("base64url")}.signature`; + const auth = JSON.stringify({ tokens: { + access_token: "main-linked-access", account_id: "personal-scope", id_token: idToken, + } }); + writeFileSync(join(TEST_CODEX_HOME, "auth.json"), auth); + seedPoolAccount(config, { id: "same-login", email: " same@example.test ", chatgptAccountId: "personal-scope" }); + seedPoolAccount(config, { id: "other-workspace", email: "same@example.test", chatgptAccountId: "team-scope" }); + seedPoolAccount(config, { id: "other-member", email: "other@example.test", chatgptAccountId: "personal-scope" }); + updateAccountQuota("same-login", 1); + updateAccountQuota("other-workspace", 10); + updateAccountQuota("other-member", 20); + setCodexAccountPin(config, "same-login"); + expect(resolveCodexAccountForThread("linked-existing-thread", config)).toBe("same-login"); + + const changePause = async (id: string, paused: boolean) => { + const req = new Request("http://localhost/api/codex-auth/accounts/pause", { + method: "PUT", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id, paused }), + }); + const response = await handleCodexAuthAPI(req, new URL(req.url), config); + expect(response!.status).toBe(200); + return response!.json(); + }; + const result = await changePause(selectedId, true); + expect(resolveCodexAccountForThread("linked-existing-thread", config)).toBe("other-workspace"); + expect(resolveCodexAccountForThread("linked-new-thread", config)).toBe("other-workspace"); + expect(new Set(config.pausedCodexAccountIds)).toEqual(new Set([MAIN_CODEX_ACCOUNT_ID, "same-login"])); + expect(new Set(result.affectedAccountIds)).toEqual(new Set([MAIN_CODEX_ACCOUNT_ID, "same-login"])); + expect(new Set(loadConfig().pausedCodexAccountIds)).toEqual(new Set(config.pausedCodexAccountIds)); + expect(pinnedCodexAccountId(config)).toBeUndefined(); + expect(readFileSync(join(TEST_CODEX_HOME, "auth.json"), "utf8")).toBe(auth); + expect(getCodexAccountCredential("same-login")?.accessToken).toBe("access-same-login"); + + // Resume through the other card, preserving pauses belonging to unrelated identities. + config.pausedCodexAccountIds!.push("other-member"); + await changePause(selectedId === MAIN_CODEX_ACCOUNT_ID ? "same-login" : MAIN_CODEX_ACCOUNT_ID, false); + expect(config.pausedCodexAccountIds).toEqual(["other-member"]); + expect(loadConfig().pausedCodexAccountIds).toEqual(["other-member"]); + }); +} + +test("manual pause cannot publish a partial identity group while native main is draining", async () => { + const config = makeConfig(); + seedPoolAccount(config, { id: "pause-drain", email: "drain@example.test" }); + const drain = acquireNativeMainProfileDrain("linked-pause-test"); + try { + const req = new Request("http://localhost/api/codex-auth/accounts/pause", { + method: "PUT", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: "pause-drain", paused: true }), + }); + const response = await handleCodexAuthAPI(req, new URL(req.url), config); + expect(response!.status).toBe(503); + expect(config.pausedCodexAccountIds).toBeUndefined(); + expect(existsSync(join(TEST_DIR, "config.json"))).toBe(false); + } finally { + drain.release(); + } +}); + +test("manual pause does not link a main login whose email identity is unknown", async () => { + const config = makeConfig(); + writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ + tokens: { access_token: "opaque-main", account_id: "shared-workspace" }, + })); + seedPoolAccount(config, { id: "known-member", email: "member@example.test", chatgptAccountId: "shared-workspace" }); + const req = new Request("http://localhost/api/codex-auth/accounts/pause", { + method: "PUT", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: "known-member", paused: true }), + }); + const response = await handleCodexAuthAPI(req, new URL(req.url), config); + expect(response!.status).toBe(200); + expect(config.pausedCodexAccountIds).toEqual(["known-member"]); +}); + +test("manual pause refuses an unreadable main identity without changing pause state", async () => { + const config = makeConfig(); + writeFileSync(join(TEST_CODEX_HOME, "auth.json"), "invalid-json"); + seedPoolAccount(config, { id: "pause-invalid", email: "invalid@example.test" }); + const req = new Request("http://localhost/api/codex-auth/accounts/pause", { + method: "PUT", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: "pause-invalid", paused: true }), + }); + const response = await handleCodexAuthAPI(req, new URL(req.url), config); + expect(response!.status).toBe(503); + expect(config.pausedCodexAccountIds).toBeUndefined(); +}); + +test("manual pause respects the cross-process native-main switch claim", async () => { + const config = makeConfig(); + seedPoolAccount(config, { id: "pause-exclusive", email: "exclusive@example.test" }); + await withNativeMainExclusiveClaim(resolveNativeProfileContext(), async () => { + const req = new Request("http://localhost/api/codex-auth/accounts/pause", { + method: "PUT", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: "pause-exclusive", paused: true }), + }); + const response = await handleCodexAuthAPI(req, new URL(req.url), config); + expect(response!.status).toBe(503); + expect(config.pausedCodexAccountIds).toBeUndefined(); + expect(existsSync(join(TEST_DIR, "config.json"))).toBe(false); + }); +}); + +test("manual pause refuses conflicting native-main workspace claims", async () => { + const config = makeConfig(); + const idToken = `header.${Buffer.from(JSON.stringify({ + email: "same@example.test", chatgpt_account_id: "different-scope", + })).toString("base64url")}.signature`; + writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ + tokens: { access_token: "opaque-main", account_id: "wire-scope", id_token: idToken }, + })); + seedPoolAccount(config, { id: "pause-conflict", email: "same@example.test", chatgptAccountId: "different-scope" }); + const req = new Request("http://localhost/api/codex-auth/accounts/pause", { + method: "PUT", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: "pause-conflict", paused: true }), + }); + const response = await handleCodexAuthAPI(req, new URL(req.url), config); + expect(response!.status).toBe(503); + expect(config.pausedCodexAccountIds).toBeUndefined(); +}); + +test("manual pause matches the selected workspace instead of the first organization membership", async () => { + const config = makeConfig(); + const idToken = `header.${Buffer.from(JSON.stringify({ + email: "same@example.test", organizations: [{ id: "other-membership" }], + })).toString("base64url")}.signature`; + writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ + tokens: { access_token: "opaque-main", account_id: "selected-workspace", id_token: idToken }, + })); + seedPoolAccount(config, { id: "selected-login", email: "same@example.test", chatgptAccountId: "selected-workspace" }); + const req = new Request("http://localhost/api/codex-auth/accounts/pause", { + method: "PUT", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: MAIN_CODEX_ACCOUNT_ID, paused: true }), + }); + const response = await handleCodexAuthAPI(req, new URL(req.url), config); + expect(response!.status).toBe(200); + expect(new Set(config.pausedCodexAccountIds)).toEqual(new Set([MAIN_CODEX_ACCOUNT_ID, "selected-login"])); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index e50b3203b50..fd0774b97a1 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -334,6 +334,7 @@ "codex-affinity-debug.test.ts": "codex-integration", "codex-app-server-path-spaces.test.ts": "codex-integration", "codex-app-server-processes.test.ts": "codex-integration", "codex-app-server-restart-service.test.ts": "codex-integration", "codex-auth-api.test.ts": "codex-integration", + "codex-linked-account-pause.test.ts": "codex-integration", "codex-auth-cancel-ownership.test.ts": "codex-integration", "codex-auth-collision.test.ts": "codex-integration", "codex-auth-context.test.ts": "codex-integration", "codex-auth-modal-status.test.ts": "gui", "codex-catalog-admission.test.ts": "codex-integration", "codex-catalog-exclusions.test.ts": "codex-integration", From ac7aad571c5287781d0ec3cea71cc3560ecd81cc Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Mon, 5 Oct 2026 17:06:39 +0800 Subject: [PATCH 2/6] refactor(codex): keep linked account pause changes in backend --- gui/src/hooks/useCodexAccountPool.ts | 15 ++------ gui/src/i18n/de.ts | 2 +- gui/src/i18n/en.ts | 2 +- gui/src/i18n/fr.ts | 2 +- gui/src/i18n/ja.ts | 2 +- gui/src/i18n/ko.ts | 2 +- gui/src/i18n/pt.ts | 2 +- gui/src/i18n/ru.ts | 2 +- gui/src/i18n/tr.ts | 2 +- gui/src/i18n/vi.ts | 2 +- gui/src/i18n/zh-TW.ts | 2 +- gui/src/i18n/zh.ts | 2 +- .../codex-account-pool-behaviour.test.tsx | 37 ++----------------- structure/gui-and-management-api.md | 5 +-- 14 files changed, 20 insertions(+), 59 deletions(-) diff --git a/gui/src/hooks/useCodexAccountPool.ts b/gui/src/hooks/useCodexAccountPool.ts index a5a3206eb50..dbf5db18610 100644 --- a/gui/src/hooks/useCodexAccountPool.ts +++ b/gui/src/hooks/useCodexAccountPool.ts @@ -539,16 +539,9 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou }); 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; - affectedAccountIds?: unknown; - }; - const affectedIds = new Set(Array.isArray(result.affectedAccountIds) - ? result.affectedAccountIds.filter((value): value is string => typeof value === "string") - : [id]); - affectedIds.add(id); + const result = (raw && typeof raw === "object" ? raw : {}) as { activeCodexAccountId?: string | null }; setAccounts(current => current.map(account => ( - affectedIds.has(account.id) || (affectedIds.has("__main__") && account.isMain) + account.id === id || (id === "__main__" && account.isMain) ? { ...account, paused } : account ))); @@ -562,9 +555,9 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou } // Deliberately NOT cross-gated against the switch and order writes, even though // pausing the pinned account also releases the pin. This edge is conditional on - // the pin still naming an affected account, which makes it order-robust: whichever response + // the pin still naming `id`, which makes it order-robust: whichever response // lands last, the client agrees with the server. - if (paused) setActivePinnedId(current => current !== null && affectedIds.has(current) ? null : current); + if (paused) setActivePinnedId(current => current === id ? null : current); void load(); return { ok: true } as const; } catch { diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index a96964d94e7..254a77afcf4 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1837,7 +1837,7 @@ export const de: Record = { "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. Manuelles Pausieren und Fortsetzen aktualisiert auch vorhandene Haupt- und Pool-Einträge desselben Kontos im selben Arbeitsbereich.", + "codexAuth.pausedHint": "Bis zur Fortsetzung von automatischem Wechsel, Wiederholungen, Cooldown-Wiederherstellung und manueller Auswahl ausgeschlossen.", "codexAuth.creditsAfterLimit": "Credits nach dem Limit nutzen", "codexAuth.creditsAfterLimitHint": "Standardmäßig aus: Ein Konto, dessen Nutzungsfenster 100 % erreicht, wird bis zum Zurücksetzen ausgewechselt, sodass seine ChatGPT-Credits nicht verbraucht werden. Schalte ein Konto ein, damit es mit seinen Credits weiterarbeitet. Neue Konten starten ausgeschaltet.", "codexAuth.creditsAfterLimitAria": "Credits nach dem Nutzungslimit nutzen für {email}", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 1fb521cc965..cecfbdc73b2 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -2448,7 +2448,7 @@ export const en = { "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. Manual pause and resume also update existing main and pool entries for the same account and workspace.", + "codexAuth.pausedHint": "Excluded from automatic switching, retries, cooldown recovery, and manual selection until resumed.", "codexAuth.creditsAfterLimit": "Use credits after limit", "codexAuth.creditsAfterLimitHint": "Off by default: an account whose usage window reaches 100% is switched out until it resets, so its ChatGPT credits are not spent. Turn an account on to let it keep working from its credits. New accounts start off.", "codexAuth.creditsAfterLimitAria": "Use credits after the usage limit for {email}", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 6e533eb269c..d24dfd7227b 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -2375,7 +2375,7 @@ export const fr: Record = { "codexAuth.resumeSucceeded": "{email} est de nouveau disponible dans le groupe", "codexAuth.pauseFailed": "Impossible de suspendre {email}. Aucune modification apportée.", "codexAuth.resumeFailed": "Impossible de réactiver {email}. Aucune modification apportée.", - "codexAuth.pausedHint": "Exclu du changement automatique, des nouvelles tentatives, de la récupération après délai et de la sélection manuelle jusqu’à sa réactivation. La pause et la reprise manuelles mettent aussi à jour les entrées principales et du pool existantes du même compte et espace de travail.", + "codexAuth.pausedHint": "Exclu du changement automatique, des nouvelles tentatives, de la récupération après délai et de la sélection manuelle jusqu’à sa réactivation.", "codexAuth.creditsAfterLimit": "Utiliser les crédits après la limite", "codexAuth.creditsAfterLimitHint": "Désactivé par défaut : un compte dont une fenêtre d’utilisation atteint 100 % est remplacé jusqu’à sa réinitialisation, et ses crédits ChatGPT ne sont pas dépensés. Activez un compte pour qu’il continue avec ses crédits. Les nouveaux comptes démarrent désactivés.", "codexAuth.creditsAfterLimitAria": "Utiliser les crédits après la limite d’utilisation pour {email}", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index fec9d5381c8..340a65593c3 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -2270,7 +2270,7 @@ export const ja: Record = { "codexAuth.resumeSucceeded": "{email} をアカウントプールに戻しました", "codexAuth.pauseFailed": "{email} を一時停止できませんでした。変更はありません。", "codexAuth.resumeFailed": "{email} を再開できませんでした。変更はありません。", - "codexAuth.pausedHint": "再開するまで、自動切り替え、再試行、クールダウン復旧、手動選択の対象外です。 手動の一時停止と再開は、同じアカウント・ワークスペースのメインログインとプール内の既存エントリにも反映されます。", + "codexAuth.pausedHint": "再開するまで、自動切り替え、再試行、クールダウン復旧、手動選択の対象外です。", "codexAuth.creditsAfterLimit": "上限後にクレジットを使用", "codexAuth.creditsAfterLimitHint": "既定はオフです。使用量ウィンドウが100%に達したアカウントはリセットまで別のアカウントに切り替わり、ChatGPTクレジットを消費しません。オンにしたアカウントだけが上限後もクレジットで使われます。新しいアカウントはオフで始まります。", "codexAuth.creditsAfterLimitAria": "{email} の上限後クレジット使用", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 289637b23cf..a4f9993543d 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1881,7 +1881,7 @@ export const ko: Record = { "codexAuth.resumeSucceeded": "{email} 계정을 풀에서 다시 사용할 수 있습니다", "codexAuth.pauseFailed": "{email} 계정을 일시 중지하지 못했습니다. 변경 사항이 없습니다.", "codexAuth.resumeFailed": "{email} 계정을 재개하지 못했습니다. 변경 사항이 없습니다.", - "codexAuth.pausedHint": "재개할 때까지 자동 전환, 재시도, 쿨다운 복구 및 수동 선택에서 제외됩니다. 수동 일시 중지 및 재개는 같은 계정과 워크스페이스의 기존 기본 로그인 및 풀 항목에도 적용됩니다.", + "codexAuth.pausedHint": "재개할 때까지 자동 전환, 재시도, 쿨다운 복구 및 수동 선택에서 제외됩니다.", "codexAuth.creditsAfterLimit": "한도 도달 후 크레딧 사용", "codexAuth.creditsAfterLimitHint": "기본은 꺼짐입니다. 사용량이 100%에 도달한 계정은 리셋될 때까지 다른 계정으로 전환되어 ChatGPT 크레딧을 쓰지 않습니다. 켠 계정만 한도 이후에도 크레딧으로 계속 사용됩니다. 새로 추가한 계정은 꺼진 상태로 시작합니다.", "codexAuth.creditsAfterLimitAria": "{email} 계정의 한도 도달 후 크레딧 사용", diff --git a/gui/src/i18n/pt.ts b/gui/src/i18n/pt.ts index 4f80304481d..f704e8ebdf0 100644 --- a/gui/src/i18n/pt.ts +++ b/gui/src/i18n/pt.ts @@ -2422,7 +2422,7 @@ export const pt: Record = { "codexAuth.resumeSucceeded": "{email} voltou a ficar disponível para o pool", "codexAuth.pauseFailed": "Não foi possível pausar {email}. Nada foi alterado.", "codexAuth.resumeFailed": "Não foi possível retomar {email}. Nada foi alterado.", - "codexAuth.pausedHint": "Excluída da troca automática, das novas tentativas, da recuperação de cooldown e da seleção manual até ser retomada. A pausa e a retomada manuais também atualizam as entradas principais e do pool existentes para a mesma conta e espaço de trabalho.", + "codexAuth.pausedHint": "Excluída da troca automática, das novas tentativas, da recuperação de cooldown e da seleção manual até ser retomada.", "codexAuth.creditsAfterLimit": "Usar créditos após o limite", "codexAuth.creditsAfterLimitHint": "Desativado por padrão: uma conta cuja janela de uso chega a 100% é retirada de uso até a janela reiniciar, para que seus créditos do ChatGPT não sejam gastos. Ative em uma conta para que ela continue trabalhando com seus créditos. Contas novas começam desativadas.", "codexAuth.creditsAfterLimitAria": "Usar créditos após o limite de uso para {email}", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 199bbcb2f45..4260be9a684 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -2361,7 +2361,7 @@ export const ru: Record = { "codexAuth.resumeSucceeded": "Аккаунт {email} снова доступен в пуле", "codexAuth.pauseFailed": "Не удалось приостановить {email}. Изменений нет.", "codexAuth.resumeFailed": "Не удалось возобновить {email}. Изменений нет.", - "codexAuth.pausedHint": "До возобновления исключён из автоматического переключения, повторов, восстановления после задержки и ручного выбора. Ручная приостановка и возобновление также обновляют существующие записи основного входа и пула для той же учётной записи и рабочего пространства.", + "codexAuth.pausedHint": "До возобновления исключён из автоматического переключения, повторов, восстановления после задержки и ручного выбора.", "codexAuth.creditsAfterLimit": "Кредиты после лимита", "codexAuth.creditsAfterLimitHint": "По умолчанию выключено: аккаунт, у которого окно использования достигло 100%, заменяется до сброса, и его кредиты ChatGPT не тратятся. Включите эту настройку для аккаунта, чтобы он продолжал работать за счёт кредитов. Новые аккаунты начинают с выключенной настройкой.", "codexAuth.creditsAfterLimitAria": "Кредиты после лимита использования для {email}", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index bc4895d2446..1c1b3ec2320 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -2399,7 +2399,7 @@ export const tr: Record = { "codexAuth.resumeSucceeded": "{email} tekrar havuza alındı", "codexAuth.pauseFailed": "{email} duraklatılamadı.", "codexAuth.resumeFailed": "{email} devam ettirilemedi.", - "codexAuth.pausedHint": "Devam ettirilene kadar otomatik seçimden hariç tutulur. Elle duraklatma ve devam ettirme, aynı hesap ve çalışma alanının mevcut ana giriş ve havuz kayıtlarını da günceller.", + "codexAuth.pausedHint": "Devam ettirilene kadar otomatik seçimden hariç tutulur.", "codexAuth.creditsAfterLimit": "Limitten sonra kredi kullan", "codexAuth.creditsAfterLimitHint": "Varsayılan olarak kapalı: kullanım penceresi %100’e ulaşan hesap sıfırlanana kadar otomatik seçim dışında kalır ve ChatGPT kredileri harcanmaz. Bir hesabın kredileriyle çalışmaya devam etmesi için bu ayarı açın. Yeni hesaplar kapalı başlar.", "codexAuth.creditsAfterLimitAria": "{email} için kullanım limitinden sonra kredi kullan", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index 6456b6db7df..be623d39883 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -2403,7 +2403,7 @@ export const vi: Record = { "codexAuth.resumeSucceeded": "{email} đã có mặt lại trong pool", "codexAuth.pauseFailed": "Không thể ngưng {email}. Không có thay đổi nào.", "codexAuth.resumeFailed": "Không thể tiếp tục {email}. Không có thay đổi nào.", - "codexAuth.pausedHint": "Được loại trừ khỏi luân phiên tự động, thử lại, hồi phục sau thời gian chờ, và lựa chọn thủ công cho đến khi tiếp tục. Tạm dừng và tiếp tục thủ công cũng cập nhật các mục đăng nhập chính và nhóm hiện có của cùng tài khoản và không gian làm việc.", + "codexAuth.pausedHint": "Được loại trừ khỏi luân phiên tự động, thử lại, hồi phục sau thời gian chờ, và lựa chọn thủ công cho đến khi tiếp tục.", "codexAuth.creditsAfterLimit": "Dùng tín dụng sau giới hạn", "codexAuth.creditsAfterLimitHint": "Mặc định tắt: tài khoản có cửa sổ sử dụng đạt 100% sẽ được thay thế cho đến khi đặt lại, nên tín dụng ChatGPT của nó không bị tiêu. Bật một tài khoản để nó tiếp tục chạy bằng tín dụng. Tài khoản mới bắt đầu ở trạng thái tắt.", "codexAuth.creditsAfterLimitAria": "Dùng tín dụng sau giới hạn sử dụng cho {email}", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index cdf44ec6625..a5e63c9d0c1 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -1792,7 +1792,7 @@ export const zhTW: Record = { "codexAuth.resumeSucceeded": "{email} 已重新加入帳號池", "codexAuth.pauseFailed": "無法暫停 {email},未做任何變更。", "codexAuth.resumeFailed": "無法恢復 {email},未做任何變更。", - "codexAuth.pausedHint": "恢復前不會參與自動切換、重試、冷卻恢復或手動選擇。 手動暫停或恢復會同步更新同一帳號、同一工作區的主登入與池內既有入口。", + "codexAuth.pausedHint": "恢復前不會參與自動切換、重試、冷卻恢復或手動選擇。", "codexAuth.creditsAfterLimit": "達到上限後使用額度", "codexAuth.creditsAfterLimitHint": "預設關閉:用量視窗達到 100% 的帳戶會被切換出去,直到重設,不消耗其 ChatGPT 額度。開啟某個帳戶後,它在達到上限後仍會使用額度繼續運作。新帳戶預設關閉。", "codexAuth.creditsAfterLimitAria": "{email} 達到上限後使用額度", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 6dcc399ddf1..b99ce665365 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1854,7 +1854,7 @@ export const zh: Record = { "codexAuth.resumeSucceeded": "{email} 已重新加入账号池", "codexAuth.pauseFailed": "无法暂停 {email},未做任何更改。", "codexAuth.resumeFailed": "无法恢复 {email},未做任何更改。", - "codexAuth.pausedHint": "恢复前不会参与自动切换、重试、冷却恢复或手动选择。 手动暂停或恢复会同步更新同一账号、同一工作区的主登录与池内已有入口。", + "codexAuth.pausedHint": "恢复前不会参与自动切换、重试、冷却恢复或手动选择。", "codexAuth.creditsAfterLimit": "达到上限后使用额度", "codexAuth.creditsAfterLimitHint": "默认关闭:用量窗口达到 100% 的账户会被切换出去,直到重置,不消耗其 ChatGPT 额度。打开某个账户后,它在达到上限后仍会使用额度继续工作。新账户默认关闭。", "codexAuth.creditsAfterLimitAria": "{email} 达到上限后使用额度", diff --git a/gui/tests/codex-account-pool-behaviour.test.tsx b/gui/tests/codex-account-pool-behaviour.test.tsx index ea2247b9ef7..3a4422ea2ee 100644 --- a/gui/tests/codex-account-pool-behaviour.test.tsx +++ b/gui/tests/codex-account-pool-behaviour.test.tsx @@ -26,7 +26,6 @@ let usageAccounts: unknown[] = []; let threshold = 80; let nextAccountsResponseGate: Promise | null = null; let pauseResponseActiveId: string | null = null; -let pauseAffectedAccountIds: string[] | undefined; let bulkPausedAccountIds: string[] = ["a2"]; let bulkResponseActiveId: string | null = null; let priorityResponseOk = true; @@ -55,7 +54,6 @@ beforeEach(() => { calls = []; nextAccountsResponseGate = null; pauseResponseActiveId = null; - pauseAffectedAccountIds = undefined; bulkPausedAccountIds = ["a2"]; bulkResponseActiveId = null; priorityResponseOk = true; @@ -134,15 +132,14 @@ beforeEach(() => { nextPauseResponseGate = null; if (gate) await gate; const body = JSON.parse(String(init?.body)) as { id: string; paused: boolean }; - const affectedIds = new Set(pauseAffectedAccountIds ?? [body.id]); accounts = accounts.map(account => ( typeof account === "object" && account !== null && "id" in account - && (affectedIds.has(String(account.id)) || (affectedIds.has("__main__") && "isMain" in account && account.isMain === true)) + && (account.id === body.id || (body.id === "__main__" && "isMain" in account && account.isMain === true)) ? { ...account, paused: body.paused } : account )); - if (body.paused && activePinnedAccountId && affectedIds.has(activePinnedAccountId)) activePinnedAccountId = null; - return { ok: true, json: async () => ({ activeCodexAccountId: pauseResponseActiveId, affectedAccountIds: pauseAffectedAccountIds }) } as unknown as Response; + if (body.paused && activePinnedAccountId === body.id) activePinnedAccountId = null; + return { ok: true, json: async () => ({ activeCodexAccountId: pauseResponseActiveId }) } as unknown as Response; } if (path === "codex-auth/accounts/pause-exhausted") { const pausedIds = new Set(bulkPausedAccountIds); @@ -348,34 +345,6 @@ test("pausing an account writes the persisted endpoint and updates shared state" expect(seen.current!.activeId).toBeNull(); }); -for (const selectedId of ["__main__", "duplicate-login"]) { - test(`linked pause and resume update both cards before reload from ${selectedId}`, async () => { - accounts = [ - { id: "a1", email: "main", isMain: true, paused: false, priority: 0, hasCredential: true, quota: null }, - { id: "duplicate-login", email: "duplicate", isMain: false, paused: false, priority: 0, hasCredential: true, quota: null }, - { id: "other-workspace", email: "other", isMain: false, paused: false, priority: 0, hasCredential: true, quota: null }, - ]; - activePinnedAccountId = "duplicate-login"; - pauseAffectedAccountIds = ["__main__", "duplicate-login"]; - const seen = await mountController(); - let releaseReload!: () => void; - nextAccountsResponseGate = new Promise(resolve => { releaseReload = resolve; }); - try { - await act(async () => { - expect(await seen.current!.setAccountPaused(selectedId, true)).toEqual({ ok: true }); - }); - expect(seen.current!.accounts.map(account => account.paused)).toEqual([true, true, false]); - expect(seen.current!.activePinnedId).toBeNull(); - await act(async () => { - expect(await seen.current!.setAccountPaused(selectedId === "__main__" ? "duplicate-login" : "__main__", false)).toEqual({ ok: true }); - }); - expect(seen.current!.accounts.map(account => account.paused)).toEqual([false, false, false]); - } finally { - await act(async () => { releaseReload(); await new Promise(resolve => setTimeout(resolve, 30)); }); - } - }); -} - test("pausing the main sentinel updates its distinct account row before reload", async () => { const seen = await mountController(); let releaseReload!: () => void; diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index e48c79141c6..c268f9cd155 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -218,9 +218,8 @@ be treated as implemented: `PUT /api/codex-auth/accounts/pause` manually pauses or resumes all existing entries whose account and workspace identity is confirmed to match, including native main and a pool duplicate. Its `affectedAccountIds` response contains only the existing opaque entry handles; emails and physical -account ids are not serialized. The account controller applies the response to every affected card -and conditionally clears a pin naming any paused member before refreshing. Older servers without -this field retain the single-entry client behavior. See [OpenAI account operations](providers/openai-accounts.md#manual-account-pause-and-resume). +account ids are not serialized. Clients can consume these handles or reload the account list to +observe the synchronized state. See [OpenAI account operations](providers/openai-accounts.md#manual-account-pause-and-resume). Model rows keep stored custom overrides separate from their effective `exportMetadata` projection. `src/server/management/model-row-export-metadata.ts` resolves inherited limits and capabilities; From 359ddbb79a2a3ebfb933cf830d1d9879b89544cd Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 5 Oct 2026 22:40:47 +0900 Subject: [PATCH 3/6] fix(codex): bound linked pause identity reads and allow pool-only homes Read the main auth file through the bounded reader under the pinned claim path, keep pool-only pause/resume working when the Codex home is absent or holds an API-key-only login, and fail closed on unreadable or malformed main credentials. Sync the six translated management API rows. --- .../docs/fr/reference/management-api.md | 2 +- .../docs/ja/reference/management-api.md | 2 +- .../docs/ko/reference/management-api.md | 2 +- .../docs/ru/reference/management-api.md | 2 +- .../docs/tr/reference/management-api.md | 2 +- .../docs/zh-tw/reference/management-api.md | 2 +- src/codex/auth-api/account-pause-group.ts | 34 +++++-- src/codex/auth-collision.ts | 14 ++- structure/gui-and-management-api.md | 4 +- structure/providers/openai-accounts.md | 9 +- .../codex-linked-account-pause.test.ts | 97 ++++++++++++++++++- 11 files changed, 148 insertions(+), 22 deletions(-) diff --git a/docs-site/src/content/docs/fr/reference/management-api.md b/docs-site/src/content/docs/fr/reference/management-api.md index eba9d89fd46..9e7a6ec106f 100644 --- a/docs-site/src/content/docs/fr/reference/management-api.md +++ b/docs-site/src/content/docs/fr/reference/management-api.md @@ -371,7 +371,7 @@ Codex. Ses routes sont les suivantes : | --- | --- | --- | | `GET, POST, DELETE /api/codex-auth/accounts` | Répertorier, actualiser ou supprimer des comptes Codex. POST est conservé comme point de terminaison de compatibilité désactivé ; les réponses DELETE réussies incluent `catalogRefreshPending`. | POST renvoie toujours 403 `manual_import_disabled` ; 400 entrée DELETE invalide | | `PUT /api/codex-auth/accounts/alias` | Définir ou supprimer un alias de compte | 400 invalide account/alias | -| `PUT /api/codex-auth/accounts/pause` | Suspendre ou reprendre un compte | 400 invalide account/state ; 404 compte manquant | +| `PUT /api/codex-auth/accounts/pause` | Suspendre ou reprendre manuellement un compte et ses entrées principales ou du pool existantes de même identité ; renvoie `affectedAccountIds` | 400 compte/état invalide ; 404 compte introuvable ; 503 identité du compte principal occupée ou illisible | | `PUT /api/codex-auth/accounts/pause-exhausted` | Suspendre les comptes dont le quota est épuisé | Les échecs de verrouillage de mutation deviennent 503 | | `POST /api/codex-auth/accounts/clear-cooldown` | Effacer le temps de recharge d'exécution pour un compte ou tous les comptes | 400 identifiant invalide | | `GET, PUT /api/codex-auth/active` | Lire ou sélectionner le compte actif | 400 compte invalide ou manquant ; 409 conflit avec un compte suspendu ou une ancienne ligne | diff --git a/docs-site/src/content/docs/ja/reference/management-api.md b/docs-site/src/content/docs/ja/reference/management-api.md index 3d8018ae3dc..25eb7807eab 100644 --- a/docs-site/src/content/docs/ja/reference/management-api.md +++ b/docs-site/src/content/docs/ja/reference/management-api.md @@ -320,7 +320,7 @@ CLI コマンドは Anthropic OAuth アカウントを id または一意の別 | --- | --- | --- | | `GET, POST, DELETE /api/codex-auth/accounts` | Codex アカウントの一覧表示/更新または削除。POST は無効化された互換エンドポイントとしてのみ残り、成功した DELETE は `catalogRefreshPending` を返します。 | POST は常に 403 `manual_import_disabled`。DELETE の入力が無効な場合は 400。 | | `PUT /api/codex-auth/accounts/alias` |アカウント エイリアスの設定またはクリア | 400 無効なアカウント/エイリアス | -| `PUT /api/codex-auth/accounts/pause` | 1 つのアカウントを一時停止または再開する | 400 無効なアカウント/状態。 404 アカウントが見つかりません | +| `PUT /api/codex-auth/accounts/pause` | アカウントと、同じ ID 情報を持つ既存のメイン/プールのエントリを手動で一時停止または再開する。`affectedAccountIds` を返す | 400 無効なアカウント/状態、404 アカウントが見つからない、503 メインの ID 情報が使用中または読み取り不能 | | `PUT /api/codex-auth/accounts/pause-exhausted` |クォータを使い果たしたアカウントを一時停止する |ミューテーションロックの失敗は 503 になります | | `POST /api/codex-auth/accounts/clear-cooldown` | 1 つのアカウントまたはすべてのアカウントのランタイム クールダウンをクリアする | 400 無効な ID | | `GET, PUT /api/codex-auth/active` |アクティブなアカウントを読み取るか選択します | 400 アカウントが無効または欠落しています。 409 一時停止/レガシー行の競合 | diff --git a/docs-site/src/content/docs/ko/reference/management-api.md b/docs-site/src/content/docs/ko/reference/management-api.md index bdabc85256a..ed7d0292fa8 100644 --- a/docs-site/src/content/docs/ko/reference/management-api.md +++ b/docs-site/src/content/docs/ko/reference/management-api.md @@ -332,7 +332,7 @@ OpenAI도 같은 규칙을 따르며, 스위치를 켠다고 별도의 922k 모 | --- | --- | --- | | `GET, POST, DELETE /api/codex-auth/accounts` | Codex account를 나열/갱신하거나 삭제합니다. POST는 비활성화된 호환성 endpoint로만 유지되며, 성공한 DELETE는 `catalogRefreshPending`를 포함합니다. | POST는 항상 403 `manual_import_disabled`; DELETE 입력이 잘못되면 400 | | `PUT /api/codex-auth/accounts/alias` | 계정 alias를 설정하거나 지웁니다 | 400 잘못된 account/alias | -| `PUT /api/codex-auth/accounts/pause` | 계정 하나를 일시 중지하거나 재개합니다 | 400 잘못된 account/state; 404 누락된 account | +| `PUT /api/codex-auth/accounts/pause` | 계정과 동일한 로그인 정보를 가진 기존 메인/풀 항목을 수동으로 일시 중지하거나 재개합니다. `affectedAccountIds`를 반환합니다 | 400 잘못된 계정/상태; 404 계정 없음; 503 메인 계정의 로그인 정보가 사용 중이거나 읽을 수 없음 | | `PUT /api/codex-auth/accounts/pause-exhausted` | quota가 소진된 account를 일시 중지합니다 | mutation-lock 실패는 503이 됩니다 | | `POST /api/codex-auth/accounts/clear-cooldown` | account 하나 또는 모든 account의 runtime cooldown을 지웁니다 | 400 잘못된 id | | `GET, PUT /api/codex-auth/active` | 활성 account를 읽거나 선택합니다 | 400 잘못되었거나 누락된 account; 409 paused/legacy-row 충돌 | diff --git a/docs-site/src/content/docs/ru/reference/management-api.md b/docs-site/src/content/docs/ru/reference/management-api.md index a3f4c294220..77b456ab70c 100644 --- a/docs-site/src/content/docs/ru/reference/management-api.md +++ b/docs-site/src/content/docs/ru/reference/management-api.md @@ -359,7 +359,7 @@ picker изменилась. `catalogRefreshPending: true` в успешном | --- | --- | --- | | `GET, POST, DELETE /api/codex-auth/accounts` | Показать/обновить список либо удалить аккаунты Codex. POST сохранён только как отключённый endpoint совместимости; успешный DELETE включает `catalogRefreshPending`. | POST всегда возвращает 403 `manual_import_disabled`; 400 при неверных данных DELETE | | `PUT /api/codex-auth/accounts/alias` | Задать или очистить alias аккаунта | 400 invalid account/alias | -| `PUT /api/codex-auth/accounts/pause` | Поставить один аккаунт на паузу или снять её | 400 invalid account/state; 404 missing account | +| `PUT /api/codex-auth/accounts/pause` | Вручную приостановить или возобновить аккаунт и существующие записи основного аккаунта и пула с той же идентичностью; возвращает `affectedAccountIds` | 400 неверный аккаунт/состояние; 404 аккаунт не найден; 503 данные идентичности основного аккаунта заняты или недоступны для чтения | | `PUT /api/codex-auth/accounts/pause-exhausted` | Поставить на паузу аккаунты с исчерпанной квотой | Сбои mutation-lock превращаются в 503 | | `POST /api/codex-auth/accounts/clear-cooldown` | Очистить runtime cooldown для одного аккаунта или для всех | 400 invalid id | | `GET, PUT /api/codex-auth/active` | Прочитать или выбрать активный аккаунт | 400 invalid or missing account; 409 paused/legacy-row conflict | diff --git a/docs-site/src/content/docs/tr/reference/management-api.md b/docs-site/src/content/docs/tr/reference/management-api.md index a2e1ad81366..2199bce119f 100644 --- a/docs-site/src/content/docs/tr/reference/management-api.md +++ b/docs-site/src/content/docs/tr/reference/management-api.md @@ -390,7 +390,7 @@ devreder. Rotaları şunlardır: | --- | --- | --- | | `GET, POST, DELETE /api/codex-auth/accounts` | Codex hesaplarını listeleyin/yenileyin veya silin. POST devre dışı bırakılmış bir uyumluluk uç noktası olarak tutulur; başarılı DELETE yanıtları `catalogRefreshPending` içerir. | POST her zaman 403 `manual_import_disabled` döndürür; 400 geçersiz DELETE girdisi | | `PUT /api/codex-auth/accounts/alias` | Bir hesap takma adını ayarlayın veya temizleyin | 400 geçersiz hesap/takma ad | -| `PUT /api/codex-auth/accounts/pause` | Bir hesabı duraklatın veya devam ettirin | 400 geçersiz hesap/durum; 404 eksik hesap | +| `PUT /api/codex-auth/accounts/pause` | Bir hesabı ve aynı kimliğe sahip mevcut ana hesap/havuz kayıtlarını elle duraklatın veya devam ettirin; `affectedAccountIds` döndürür | 400 geçersiz hesap/durum; 404 hesap bulunamadı; 503 ana hesap kimliği kullanımda veya okunamıyor | | `PUT /api/codex-auth/accounts/pause-exhausted` | Kotası tükenen hesapları duraklatın | Mutasyon kilidi arızaları 503 olur | | `POST /api/codex-auth/accounts/clear-cooldown` | Bir hesap veya tüm hesaplar için çalışma zamanı soğuma süresini temizleyin | 400 geçersiz kimlik | | `GET, PUT /api/codex-auth/active` | Aktif hesabı okuyun veya seçin | 400 geçersiz veya eksik hesap; 409 duraklatılmış/eski satır çakışması | diff --git a/docs-site/src/content/docs/zh-tw/reference/management-api.md b/docs-site/src/content/docs/zh-tw/reference/management-api.md index 02aa615fa60..8bbfae3db3c 100644 --- a/docs-site/src/content/docs/zh-tw/reference/management-api.md +++ b/docs-site/src/content/docs/zh-tw/reference/management-api.md @@ -303,7 +303,7 @@ OpenAI 也遵循此規則:開關不會選擇特殊的 922k 模式。生效中 | --- | --- | --- | | `GET, POST, DELETE /api/codex-auth/accounts` | 列出/重新整理或刪除 Codex 帳號。POST 僅保留為已停用的相容 endpoint;成功的 DELETE 回應包含 `catalogRefreshPending`。 | POST 一律回傳 403 `manual_import_disabled`;DELETE 輸入無效時回傳 400 | | `PUT /api/codex-auth/accounts/alias` | 設定或清除帳號別名 | 400 無效帳號/別名 | -| `PUT /api/codex-auth/accounts/pause` | 暫停或恢復一個帳號 | 400 無效帳號/狀態;404 缺失帳號 | +| `PUT /api/codex-auth/accounts/pause` | 手動暫停或恢復帳號及身分相符的既有主帳號/帳號池項目;回傳 `affectedAccountIds` | 400 無效帳號/狀態;404 找不到帳號;503 主帳號身分資料使用中或無法讀取 | | `PUT /api/codex-auth/accounts/pause-exhausted` | 暫停配額耗盡的帳號 | 變更鎖失敗變為 503 | | `POST /api/codex-auth/accounts/clear-cooldown` | 清除一個或所有帳號的 runtime 冷卻 | 400 無效 id | | `GET, PUT /api/codex-auth/active` | 讀取或選擇現用帳號 | 400 無效或缺失帳號;409 暫停/舊列衝突 | diff --git a/src/codex/auth-api/account-pause-group.ts b/src/codex/auth-api/account-pause-group.ts index 9dfaf0d348c..4ec967f4178 100644 --- a/src/codex/auth-api/account-pause-group.ts +++ b/src/codex/auth-api/account-pause-group.ts @@ -1,10 +1,16 @@ +import { lstatSync } from "node:fs"; import type { OcxConfig } from "../../types"; import { inspectChatGptDomainClaim, extractEmail } from "../../oauth/chatgpt"; import { MAIN_CODEX_ACCOUNT_ID, isSelectableCodexPoolAccount } from "../account-id"; import { readCodexTokensResult } from "../auth-collision"; +import type { CodexTokenReadResult } from "../auth-collision"; import { getCodexAccountCredential } from "../account-store"; +import { resolveCodexHomeDir } from "../home"; import { tryAcquireNativeMainProfileClaim } from "../native-main-admission"; -import { isNativeMainClaimUnavailable, nativeMainProfileBusyResponse, withNativeMainCredentialClaim } from "./http"; +import { withNativeMainSharedClaim } from "../native-main-claim"; +import { resolveNativeProfileContext } from "../native-profile-store"; +import { NativeProfileError } from "../native-profile-types"; +import { isNativeMainClaimUnavailable, nativeMainProfileBusyResponse } from "./http"; type AccountIdentity = { accountId: string; email: string }; @@ -16,8 +22,7 @@ function identity(accountId: unknown, email: unknown): AccountIdentity | undefin } /** Resolve only existing entries, under native-main ownership, never on the request path. */ -function linkedAccountIds(config: OcxConfig, selectedId: string): string[] | undefined { - const main = readCodexTokensResult(); +function linkedAccountIds(config: OcxConfig, selectedId: string, main: CodexTokenReadResult): string[] | undefined { // Unknown main identity must not produce a successful but incomplete pause. if (main.status === "unreadable" || main.status === "invalid") return undefined; const identities = new Map(); @@ -49,7 +54,7 @@ function linkedAccountIds(config: OcxConfig, selectedId: string): string[] | und ))]; } -/** Keep discovery, publication and persistence inside the same physical-main claim. */ +/** Hold admission throughout; claim the physical main whenever its home exists. */ export async function withCodexAccountPauseGroup( config: OcxConfig, selectedId: string, @@ -58,12 +63,27 @@ export async function withCodexAccountPauseGroup( const lease = tryAcquireNativeMainProfileClaim(); if (!lease) return nativeMainProfileBusyResponse(); try { - return await withNativeMainCredentialClaim(async () => { - const ids = linkedAccountIds(config, selectedId); + const codexHome = resolveCodexHomeDir(); + try { + lstatSync(codexHome); + } catch (error) { + // Only positive absence permits Pool-only publication. A dangling symlink or access + // failure is not absence; do not create a home just to coordinate an unused main login. + if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") return nativeMainProfileBusyResponse(); + const ids = linkedAccountIds(config, selectedId, { status: "missing" }); + return ids ? publish(ids) : nativeMainProfileBusyResponse(); + } + const context = resolveNativeProfileContext({ codexHome }); + return await withNativeMainSharedClaim(context, async () => { + const main = readCodexTokensResult(context.authPath, { bounded: true, allowApiKeyOnly: true }); + const ids = linkedAccountIds(config, selectedId, main); return ids ? publish(ids) : nativeMainProfileBusyResponse(); }); } catch (error) { - if (isNativeMainClaimUnavailable(error)) return nativeMainProfileBusyResponse(); + if (isNativeMainClaimUnavailable(error) + || (error instanceof NativeProfileError && error.code === "CODEX_HOME_UNAVAILABLE")) { + return nativeMainProfileBusyResponse(); + } throw error; } finally { lease.release(); diff --git a/src/codex/auth-collision.ts b/src/codex/auth-collision.ts index 1dc17e897e0..b18567d3e59 100644 --- a/src/codex/auth-collision.ts +++ b/src/codex/auth-collision.ts @@ -22,7 +22,7 @@ export interface CodexTokens { */ export type CodexTokenReadResult = | { status: "ok"; tokens: CodexTokens } - | { status: "missing" | "invalid" | "unreadable" }; + | { status: "missing" | "invalid" | "unreadable" | "api-key-only" }; function hasErrnoCode(error: unknown, code: string): boolean { return typeof error === "object" && error !== null && "code" in error @@ -36,11 +36,12 @@ function hasErrnoCode(error: unknown, code: string): boolean { * `bounded` opts into the native-profile bounded reader (regular file, size-capped, no-follow, * non-blocking) for startup observation paths that run inside the owner claim; bounded violations * classify as `unreadable`. Legacy callers keep the unbounded read. + * `allowApiKeyOnly` distinguishes a valid non-ChatGPT login without changing legacy callers. * Never returns or logs the raw error or any token material. */ export function readCodexTokensResult( authPath = join(resolveCodexHomeDir(), "auth.json"), - options?: { bounded?: boolean }, + options?: { bounded?: boolean; allowApiKeyOnly?: boolean }, ): CodexTokenReadResult { let raw: string; try { @@ -52,8 +53,15 @@ export function readCodexTokensResult( } try { const j = JSON.parse(raw) as { - tokens?: { access_token?: string; account_id?: string; id_token?: string }; + auth_mode?: unknown; + OPENAI_API_KEY?: unknown; + tokens?: { access_token?: string; account_id?: string; id_token?: string } | null; }; + if (options?.allowApiKeyOnly === true && j && typeof j === "object" && !Array.isArray(j) + && j.tokens == null && (j.auth_mode === undefined || j.auth_mode === "api_key") + && typeof j.OPENAI_API_KEY === "string" && j.OPENAI_API_KEY.trim()) { + return { status: "api-key-only" }; + } if (!j?.tokens?.access_token) return { status: "invalid" }; return { status: "ok", diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 6cf1cd9abea..dd71d8f2d7d 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -222,8 +222,8 @@ be treated as implemented: `PUT /api/codex-auth/accounts/pause` manually pauses or resumes all existing entries whose account and workspace identity is confirmed to match, including native main and a pool duplicate. Its `affectedAccountIds` response contains only the existing opaque entry handles; emails and physical -account ids are not serialized. Clients can consume these handles or reload the account list to -observe the synchronized state. See [OpenAI account operations](providers/openai-accounts.md#manual-account-pause-and-resume). +account ids are not serialized. Absent homes and API-key-only main logins allow Pool-only grouping; +busy/unreadable main identity returns 503. See [OpenAI account operations](providers/openai-accounts.md#manual-account-pause-and-resume). Model rows keep stored custom overrides separate from their effective `exportMetadata` projection. `src/server/management/model-row-export-metadata.ts` resolves inherited limits and capabilities; diff --git a/structure/providers/openai-accounts.md b/structure/providers/openai-accounts.md index 301417ed9e4..ef35cbdb596 100644 --- a/structure/providers/openai-accounts.md +++ b/structure/providers/openai-accounts.md @@ -262,9 +262,12 @@ Manual pause/resume in `src/codex/auth-api/account-pause-group.ts` resolves exis and pool entries by the full ChatGPT account/workspace id and normalized email. Matching entries share the operation; equal emails in different workspaces and different members of one workspace remain independent. Missing identity evidence never links entries. Main discovery, group publication -and config persistence hold both native-main admission and the cross-process shared claim; a busy -or unreadable main identity returns 503 without publishing a partial group. All matching exclusions -are set before active-account reconciliation, so a duplicate cannot be selected as the fallback. +and config persistence hold native-main admission and, when the home exists, the cross-process +shared claim. Main reads use the claim's pinned auth path and bounded regular-file reader. A positively +absent home or valid API-key-only envelope has no ChatGPT main identity: Pool-only grouping proceeds, +and main remains independently addressable by id. An inaccessible existing home, busy claim, malformed +credentials, or unreadable/nonregular/oversized auth file returns 503 before any group publication. +All matching exclusions are set before active-account reconciliation, so a duplicate cannot be the fallback. The persisted format remains `pausedCodexAccountIds`; routing performs no extra identity reads. Automatic quota-protection and bulk-exhaustion policies retain their existing per-entry decisions. diff --git a/tests/codex-integration/codex-linked-account-pause.test.ts b/tests/codex-integration/codex-linked-account-pause.test.ts index 68d8420b793..76b990c810b 100644 --- a/tests/codex-integration/codex-linked-account-pause.test.ts +++ b/tests/codex-integration/codex-linked-account-pause.test.ts @@ -15,7 +15,7 @@ import { acquireNativeMainProfileDrain, resetLifecycleDrainStateForTests } from import { setAsyncIcaclsRunnerForTests, setIcaclsRunnerForTests } from "../../src/lib/windows-secret-acl"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { withNativeMainExclusiveClaim } from "../../src/codex/native-main-claim"; -import { resolveNativeProfileContext } from "../../src/codex/native-profile-store"; +import { MAX_AUTH_BYTES, resolveNativeProfileContext } from "../../src/codex/native-profile-store"; let TEST_DIR: string; let TEST_CODEX_HOME: string; @@ -212,3 +212,98 @@ test("manual pause matches the selected workspace instead of the first organizat expect(response!.status).toBe(200); expect(new Set(config.pausedCodexAccountIds)).toEqual(new Set([MAIN_CODEX_ACCOUNT_ID, "selected-login"])); }); + +for (const mainState of ["missing-home", "api-key-only", "api-key-null-tokens"] as const) { + test(`manual pause and resume group Pool-only identities with ${mainState}`, async () => { + const config = makeConfig(); + if (mainState === "missing-home") { + process.env.CODEX_HOME = join(TEST_DIR, "absent-codex-home"); + } else { + writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ + OPENAI_API_KEY: "test-only-api-key", + ...(mainState === "api-key-null-tokens" ? { auth_mode: "api_key", tokens: null } : {}), + })); + } + seedPoolAccount(config, { id: "pool-only", email: "same@example.test", chatgptAccountId: "pool-scope" }); + seedPoolAccount(config, { id: "pool-copy", email: "SAME@example.test", chatgptAccountId: "pool-scope" }); + seedPoolAccount(config, { id: "pool-other", email: "same@example.test", chatgptAccountId: "other-scope" }); + for (const paused of [true, false]) { + const req = new Request("http://localhost/api/codex-auth/accounts/pause", { + method: "PUT", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: "pool-only", paused }), + }); + const response = await handleCodexAuthAPI(req, new URL(req.url), config); + expect(response!.status).toBe(200); + expect(new Set((await response!.json()).affectedAccountIds)).toEqual(new Set(["pool-only", "pool-copy"])); + expect(new Set(config.pausedCodexAccountIds)).toEqual(new Set(paused ? ["pool-only", "pool-copy"] : [])); + expect(loadConfig().pausedCodexAccountIds).toEqual(config.pausedCodexAccountIds); + } + // Main remains addressable by id but has no identity to link to the Pool. + const req = new Request("http://localhost/api/codex-auth/accounts/pause", { + method: "PUT", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: MAIN_CODEX_ACCOUNT_ID, paused: true }), + }); + const response = await handleCodexAuthAPI(req, new URL(req.url), config); + expect(response!.status).toBe(200); + expect((await response!.json()).affectedAccountIds).toEqual([MAIN_CODEX_ACCOUNT_ID]); + expect(config.pausedCodexAccountIds).toEqual([MAIN_CODEX_ACCOUNT_ID]); + if (mainState === "missing-home") expect(existsSync(process.env.CODEX_HOME!)).toBe(false); + }); +} + +for (const mainState of ["oversized", "directory", "invalid-home", "api-key-malformed-tokens", "api-key-chatgpt-mode"] as const) { + test(`manual pause rejects ${mainState} before publication`, async () => { + const config = makeConfig({ activeCodexAccountId: "pause-unsafe" }); + seedPoolAccount(config, { id: "pause-unsafe", email: "unsafe@example.test" }); + setCodexAccountPin(config, "pause-unsafe"); + const before = JSON.stringify(config); + const authPath = join(TEST_CODEX_HOME, "auth.json"); + if (mainState === "directory") mkdirSync(authPath); + else if (mainState === "invalid-home") { + const homePath = join(TEST_DIR, "non-directory-home"); + writeFileSync(homePath, "not-a-directory"); + process.env.CODEX_HOME = homePath; + } else if (mainState === "oversized") { + // Valid ChatGPT JSON would succeed with the old unbounded reader. + writeFileSync(authPath, JSON.stringify({ + tokens: { access_token: "opaque-main", account_id: "main-scope" }, + padding: " ".repeat(MAX_AUTH_BYTES), + })); + } else { + writeFileSync(authPath, JSON.stringify({ + OPENAI_API_KEY: "test-only-api-key", + ...(mainState === "api-key-malformed-tokens" ? { tokens: {} } : { auth_mode: "chatgpt" }), + })); + } + const req = new Request("http://localhost/api/codex-auth/accounts/pause", { + method: "PUT", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: "pause-unsafe", paused: true }), + }); + const response = await handleCodexAuthAPI(req, new URL(req.url), config); + expect(response!.status).toBe(503); + expect(response!.headers.get("Retry-After")).toBe("1"); + expect(JSON.stringify(config)).toBe(before); + expect(config.pausedCodexAccountIds).toBeUndefined(); + expect(pinnedCodexAccountId(config)).toBe("pause-unsafe"); + expect(existsSync(join(TEST_DIR, "config.json"))).toBe(false); + }); +} + +test("Pool-only pause with an absent home still respects native-main admission", async () => { + process.env.CODEX_HOME = join(TEST_DIR, "absent-codex-home"); + const config = makeConfig(); + seedPoolAccount(config, { id: "absent-drain", email: "drain@example.test" }); + const drain = acquireNativeMainProfileDrain("absent-home-pause-test"); + try { + const req = new Request("http://localhost/api/codex-auth/accounts/pause", { + method: "PUT", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: "absent-drain", paused: true }), + }); + const response = await handleCodexAuthAPI(req, new URL(req.url), config); + expect(response!.status).toBe(503); + expect(config.pausedCodexAccountIds).toBeUndefined(); + expect(existsSync(join(TEST_DIR, "config.json"))).toBe(false); + } finally { + drain.release(); + } +}); From 8d05e3510bb8f52b6b226f25c1e8357da4402d95 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 5 Oct 2026 22:46:46 +0900 Subject: [PATCH 4/6] fix(codex): refuse linked pause when main tokens name different members --- src/codex/auth-api/account-pause-group.ts | 6 +++++- structure/providers/openai-accounts.md | 1 + .../codex-linked-account-pause.test.ts | 18 ++++++++++++++++++ 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/codex/auth-api/account-pause-group.ts b/src/codex/auth-api/account-pause-group.ts index 4ec967f4178..fff4ee363fb 100644 --- a/src/codex/auth-api/account-pause-group.ts +++ b/src/codex/auth-api/account-pause-group.ts @@ -38,7 +38,11 @@ function linkedAccountIds(config: OcxConfig, selectedId: string, main: CodexToke // organizations[] describes memberships, not the selected workspace. Never use its first row. const accountId = tokens.account_id.trim() || claimIds[0]; if (claimIds.some(claim => claim !== accountId)) return undefined; - identities.set(MAIN_CODEX_ACCOUNT_ID, identity(accountId, extractEmail(tokens.id_token, tokens.access_token))); + // Both tokens must name the same member; otherwise the bearer's identity is unknown. + const emails = new Set([extractEmail(tokens.id_token), extractEmail(undefined, tokens.access_token)] + .flatMap(email => email?.trim() ? [email.trim().toLowerCase()] : [])); + if (emails.size > 1) return undefined; + identities.set(MAIN_CODEX_ACCOUNT_ID, identity(accountId, [...emails][0])); } for (const account of config.codexAccounts ?? []) { if (!isSelectableCodexPoolAccount(account)) continue; diff --git a/structure/providers/openai-accounts.md b/structure/providers/openai-accounts.md index ef35cbdb596..e83e981f0c0 100644 --- a/structure/providers/openai-accounts.md +++ b/structure/providers/openai-accounts.md @@ -262,6 +262,7 @@ Manual pause/resume in `src/codex/auth-api/account-pause-group.ts` resolves exis and pool entries by the full ChatGPT account/workspace id and normalized email. Matching entries share the operation; equal emails in different workspaces and different members of one workspace remain independent. Missing identity evidence never links entries. Main discovery, group publication +Main's ID and access tokens must agree on both workspace and member email; a disagreement returns 503. and config persistence hold native-main admission and, when the home exists, the cross-process shared claim. Main reads use the claim's pinned auth path and bounded regular-file reader. A positively absent home or valid API-key-only envelope has no ChatGPT main identity: Pool-only grouping proceeds, diff --git a/tests/codex-integration/codex-linked-account-pause.test.ts b/tests/codex-integration/codex-linked-account-pause.test.ts index 76b990c810b..9996a224ae6 100644 --- a/tests/codex-integration/codex-linked-account-pause.test.ts +++ b/tests/codex-integration/codex-linked-account-pause.test.ts @@ -195,6 +195,24 @@ test("manual pause refuses conflicting native-main workspace claims", async () = expect(config.pausedCodexAccountIds).toBeUndefined(); }); +test("manual pause refuses main tokens that name different members of one workspace", async () => { + const config = makeConfig(); + const token = (email: string) => `header.${Buffer.from(JSON.stringify({ + email, chatgpt_account_id: "shared-scope", + })).toString("base64url")}.signature`; + writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ + tokens: { access_token: token("bob@example.test"), account_id: "shared-scope", id_token: token("alice@example.test") }, + })); + seedPoolAccount(config, { id: "alice-login", email: "alice@example.test", chatgptAccountId: "shared-scope" }); + const req = new Request("http://localhost/api/codex-auth/accounts/pause", { + method: "PUT", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: "alice-login", paused: true }), + }); + const response = await handleCodexAuthAPI(req, new URL(req.url), config); + expect(response!.status).toBe(503); + expect(config.pausedCodexAccountIds).toBeUndefined(); +}); + test("manual pause matches the selected workspace instead of the first organization membership", async () => { const config = makeConfig(); const idToken = `header.${Buffer.from(JSON.stringify({ From 6088a0a8e52731f5ab57d0edf4815a56305e1a18 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 5 Oct 2026 22:46:54 +0900 Subject: [PATCH 5/6] docs(codex): fix linked pause identity sentence --- structure/providers/openai-accounts.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/structure/providers/openai-accounts.md b/structure/providers/openai-accounts.md index e83e981f0c0..d8872d97542 100644 --- a/structure/providers/openai-accounts.md +++ b/structure/providers/openai-accounts.md @@ -261,8 +261,8 @@ Listener startup diagnostics follow [the runtime lifecycle contract](../runtime. Manual pause/resume in `src/codex/auth-api/account-pause-group.ts` resolves existing native-main and pool entries by the full ChatGPT account/workspace id and normalized email. Matching entries share the operation; equal emails in different workspaces and different members of one workspace -remain independent. Missing identity evidence never links entries. Main discovery, group publication -Main's ID and access tokens must agree on both workspace and member email; a disagreement returns 503. +remain independent. Missing identity evidence never links entries. Main's ID and access tokens must +agree on both workspace and member email; a disagreement returns 503. Main discovery, group publication and config persistence hold native-main admission and, when the home exists, the cross-process shared claim. Main reads use the claim's pinned auth path and bounded regular-file reader. A positively absent home or valid API-key-only envelope has no ChatGPT main identity: Pool-only grouping proceeds, From 3468db3355c62c2a6d5e6f838758174fa1b17bf1 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 5 Oct 2026 23:23:44 +0900 Subject: [PATCH 6/6] refactor(codex): keep API-key-only main recognition inside the pause module Revert the shared auth-collision reader to dev and recognize a valid API-key-only auth.json through the same bounded reader in the pause group, so the change stays off the restricted credential surface. --- src/codex/auth-api/account-pause-group.ts | 24 ++++++++++++++++++++--- src/codex/auth-collision.ts | 14 +++---------- 2 files changed, 24 insertions(+), 14 deletions(-) diff --git a/src/codex/auth-api/account-pause-group.ts b/src/codex/auth-api/account-pause-group.ts index fff4ee363fb..878f009dc72 100644 --- a/src/codex/auth-api/account-pause-group.ts +++ b/src/codex/auth-api/account-pause-group.ts @@ -8,11 +8,29 @@ import { getCodexAccountCredential } from "../account-store"; import { resolveCodexHomeDir } from "../home"; import { tryAcquireNativeMainProfileClaim } from "../native-main-admission"; import { withNativeMainSharedClaim } from "../native-main-claim"; -import { resolveNativeProfileContext } from "../native-profile-store"; +import { MAX_AUTH_BYTES, readBounded, resolveNativeProfileContext } from "../native-profile-store"; import { NativeProfileError } from "../native-profile-types"; import { isNativeMainClaimUnavailable, nativeMainProfileBusyResponse } from "./http"; type AccountIdentity = { accountId: string; email: string }; +type MainAuth = CodexTokenReadResult | { status: "api-key-only" }; + +/** A valid API-key login has no ChatGPT identity; anything else unreadable stays unknown. */ +function readMainAuth(authPath: string): MainAuth { + const main = readCodexTokensResult(authPath, { bounded: true }); + if (main.status !== "invalid") return main; + try { + const j: unknown = JSON.parse(readBounded(authPath, MAX_AUTH_BYTES).toString("utf-8")); + if (j === null || typeof j !== "object" || Array.isArray(j)) return main; + const envelope = j as { auth_mode?: unknown; OPENAI_API_KEY?: unknown; tokens?: unknown }; + const apiKeyOnly = envelope.tokens == null + && (envelope.auth_mode === undefined || envelope.auth_mode === "api_key") + && typeof envelope.OPENAI_API_KEY === "string" && envelope.OPENAI_API_KEY.trim() !== ""; + return apiKeyOnly ? { status: "api-key-only" } : main; + } catch { + return main; + } +} function identity(accountId: unknown, email: unknown): AccountIdentity | undefined { const normalizedEmail = typeof email === "string" ? email.trim().toLowerCase() : undefined; @@ -22,7 +40,7 @@ function identity(accountId: unknown, email: unknown): AccountIdentity | undefin } /** Resolve only existing entries, under native-main ownership, never on the request path. */ -function linkedAccountIds(config: OcxConfig, selectedId: string, main: CodexTokenReadResult): string[] | undefined { +function linkedAccountIds(config: OcxConfig, selectedId: string, main: MainAuth): string[] | undefined { // Unknown main identity must not produce a successful but incomplete pause. if (main.status === "unreadable" || main.status === "invalid") return undefined; const identities = new Map(); @@ -79,7 +97,7 @@ export async function withCodexAccountPauseGroup( } const context = resolveNativeProfileContext({ codexHome }); return await withNativeMainSharedClaim(context, async () => { - const main = readCodexTokensResult(context.authPath, { bounded: true, allowApiKeyOnly: true }); + const main = readMainAuth(context.authPath); const ids = linkedAccountIds(config, selectedId, main); return ids ? publish(ids) : nativeMainProfileBusyResponse(); }); diff --git a/src/codex/auth-collision.ts b/src/codex/auth-collision.ts index b18567d3e59..1dc17e897e0 100644 --- a/src/codex/auth-collision.ts +++ b/src/codex/auth-collision.ts @@ -22,7 +22,7 @@ export interface CodexTokens { */ export type CodexTokenReadResult = | { status: "ok"; tokens: CodexTokens } - | { status: "missing" | "invalid" | "unreadable" | "api-key-only" }; + | { status: "missing" | "invalid" | "unreadable" }; function hasErrnoCode(error: unknown, code: string): boolean { return typeof error === "object" && error !== null && "code" in error @@ -36,12 +36,11 @@ function hasErrnoCode(error: unknown, code: string): boolean { * `bounded` opts into the native-profile bounded reader (regular file, size-capped, no-follow, * non-blocking) for startup observation paths that run inside the owner claim; bounded violations * classify as `unreadable`. Legacy callers keep the unbounded read. - * `allowApiKeyOnly` distinguishes a valid non-ChatGPT login without changing legacy callers. * Never returns or logs the raw error or any token material. */ export function readCodexTokensResult( authPath = join(resolveCodexHomeDir(), "auth.json"), - options?: { bounded?: boolean; allowApiKeyOnly?: boolean }, + options?: { bounded?: boolean }, ): CodexTokenReadResult { let raw: string; try { @@ -53,15 +52,8 @@ export function readCodexTokensResult( } try { const j = JSON.parse(raw) as { - auth_mode?: unknown; - OPENAI_API_KEY?: unknown; - tokens?: { access_token?: string; account_id?: string; id_token?: string } | null; + tokens?: { access_token?: string; account_id?: string; id_token?: string }; }; - if (options?.allowApiKeyOnly === true && j && typeof j === "object" && !Array.isArray(j) - && j.tokens == null && (j.auth_mode === undefined || j.auth_mode === "api_key") - && typeof j.OPENAI_API_KEY === "string" && j.OPENAI_API_KEY.trim()) { - return { status: "api-key-only" }; - } if (!j?.tokens?.access_token) return { status: "invalid" }; return { status: "ok",