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 733c910157c..49dd5cdd010 100644 --- a/docs-site/src/content/docs/ja/reference/management-api.md +++ b/docs-site/src/content/docs/ja/reference/management-api.md @@ -325,7 +325,7 @@ CLI コマンドは Anthropic OAuth アカウントを id または一意の別 | `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 一時停止/レガシー行の競合 | +| `GET, PUT /api/codex-auth/active` |アクティブなアカウントを読み取るか選択します | 400 アカウントが無効または欠落しています。 409 一時停止/レガシー行の競合、または `account_selection_unavailable` | | `PUT /api/codex-auth/auto-switch` | `id` を省略した `{ threshold }` でグローバルしきい値、`{ id, threshold }` でアカウント別の上書き値を設定する。`id: '__main__'` は Codex Desktop アカウントを指定する。`id` を指定した場合、`threshold: null` は上書き値を削除してグローバル値の継承に戻す | 400 無効な ID/しきい値、404 アカウントなし | | `PUT, PATCH /api/codex-auth/pool-strategy` | Codex アカウントプールの選択戦略を更新 | 400 無効な戦略/構成 | | `PUT /api/codex-auth/failover` |アカウントのフェイルオーバーしきい値を設定する | 400 無効なしきい値 | @@ -337,6 +337,15 @@ CLI コマンドは Anthropic OAuth アカウントを id または一意の別 | `POST /api/codex-auth/login/cancel` | `{ "flowId": "..." }` で指定した保留中の Codex ログインのみキャンセルする | 400 フロー ID が未指定、不明、または保留中ではない | | `GET /api/codex-auth/login-status` |フローまたはアカウントのログイン状態をポーリングする。新規アカウント完了時は回復が必要な場合だけ `catalogRefreshPending: true` を含みます。 |不明なフローは `expired` を報告します。アクティブなフローは `idle` を報告しません | +`PUT /api/codex-auth/active` には `{ "accountId": "" }` が必要です。選択をクリアするには、 +明示的に `{ "accountId": null }` を指定します。別のプロセスが保存済みのアカウントを変更していても、 +現在のアカウントの再選択は明示的な選択として扱われます。サーバーは無関係な保存済み設定を保持し、 +ダッシュボードの読み込み後に削除または一時停止されたアカウントの選択を拒否します。保存済み設定が +利用できないか無効な場合、実行中の選択とアカウントへのアフィニティは変更されません。 +設定を再読み込みしてから再試行してください。成功レスポンスはコミット済みの選択を示します。 +既存のクォータとフェイルオーバーのルールによりピン留めが解除される場合は引き続きあります。 +`pinDrained` は、現在判明しているピン留め解除の理由を示します。 + 新規 account の config row は保存されたものの credential setup を完了できない場合、OAuth の `login-status` は `status: "error"` と `code: "codex_credential_persistence_failed"`、`accountId`、`needsReauth: true`、必要に応じて 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 f9dd67d9f89..46e59b87f1b 100644 --- a/docs-site/src/content/docs/ko/reference/management-api.md +++ b/docs-site/src/content/docs/ko/reference/management-api.md @@ -337,7 +337,7 @@ OpenAI도 같은 규칙을 따르며, 스위치를 켠다고 별도의 922k 모 | `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 충돌 | +| `GET, PUT /api/codex-auth/active` | 활성 account를 읽거나 선택합니다 | 400 잘못되었거나 누락된 account; 409 paused/legacy-row 충돌 또는 `account_selection_unavailable` | | `PUT /api/codex-auth/auto-switch` | `id`를 생략한 `{ threshold }`로 전역 임계값을, `{ id, threshold }`로 계정별 재정의 값을 설정합니다. `id: '__main__'`은 Codex Desktop 계정을 지정합니다. `id`가 지정된 경우 `threshold: null`은 재정의 값을 삭제하고 전역 임계값 상속을 복원합니다 | 400 잘못된 ID/임계값, 404 계정 없음 | | `PUT, PATCH /api/codex-auth/pool-strategy` | Codex account-pool 선택 전략을 업데이트합니다 | 400 잘못된 전략/구성 | | `PUT /api/codex-auth/failover` | account failover threshold를 설정합니다 | 400 잘못된 threshold | @@ -349,6 +349,14 @@ OpenAI도 같은 규칙을 따르며, 스위치를 켠다고 별도의 922k 모 | `POST /api/codex-auth/login/cancel` | `{ "flowId": "..." }`로 지정한 대기 중인 Codex 로그인만 취소합니다 | 400 흐름 ID 누락, 알 수 없음 또는 대기 중이 아님 | | `GET /api/codex-auth/login-status` | 흐름 또는 account 로그인 상태를 조회합니다. 새 계정 완료 시 복구가 필요할 때만 `catalogRefreshPending: true`를 포함합니다. | 알 수 없는 흐름은 `expired`로 보고되며, 활성 흐름이 없으면 `idle`로 보고됩니다 | +`PUT /api/codex-auth/active`에는 `{ "accountId": "" }`가 필요합니다. 선택을 지우려면 +`{ "accountId": null }`을 명시적으로 지정해야 합니다. 다른 프로세스가 저장된 계정을 변경했더라도 +현재 계정을 다시 선택하는 것은 명시적인 선택으로 처리됩니다. 서버는 관련 없는 저장된 설정을 +보존하고, 대시보드를 불러온 뒤 삭제되거나 일시 중지된 계정의 선택을 거부합니다. 저장된 구성을 +사용할 수 없거나 구성이 유효하지 않으면 기존 런타임 선택과 계정 어피니티는 변경되지 않습니다. +설정을 다시 불러온 뒤 재시도하십시오. 성공 응답은 커밋된 선택을 나타냅니다. 기존 quota 및 +failover 규칙에 따라 고정이 해제될 수 있으며, `pinDrained`는 현재 알려진 고정 해제 사유를 나타냅니다. + 수동 소비가 `reset`으로 확인되면 같은 계정의 새 usage를 조회하여 기존 shared reset-derived 쿨다운을 즉시 복구할 수 있습니다. 복구는 조건부입니다. 계정이 일시 정지되었거나 재인증이 필요하거나 다른 진행 중인 probe가 쿨다운을 소유하면 쿨다운은 유지됩니다. reset 이전에 시작한 diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index 5b7e28afe56..a6849177e36 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -716,7 +716,7 @@ manager. Its routes are: | `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 | | `POST /api/codex-auth/accounts/clear-cooldown` | Clear runtime cooldown for one account or all accounts | 400 invalid id | -| `GET, PUT /api/codex-auth/active` | Read or select the active account | 400 invalid or missing account; 409 paused/legacy-row conflict | +| `GET, PUT /api/codex-auth/active` | Read or select the active account | 400 invalid or missing account; 409 paused/legacy-row conflict or `account_selection_unavailable` | | `PUT /api/codex-auth/auto-switch` | Set the global quota threshold with `{ threshold }`, or an account override with `{ id, threshold }`; `null` restores global inheritance, and `__main__` selects the Desktop login | 400 invalid id/threshold; 404 missing account | | `PUT, PATCH /api/codex-auth/pool-strategy` | Update Codex account-pool selection strategy | 400 invalid strategy/config | | `PUT /api/codex-auth/failover` | Set the account failover threshold | 400 invalid threshold | @@ -729,6 +729,14 @@ manager. Its routes are: | `POST /api/codex-auth/login/cancel` | Cancel only the pending Codex login identified by `{ "flowId": "..." }` | 400 missing, unknown, or non-pending flow ID | | `GET /api/codex-auth/login-status` | Poll a flow or account login state. A completed new-account flow includes `catalogRefreshPending: true` only when recovery is needed. | Unknown flows report `expired`; no active flow reports `idle` | +`PUT /api/codex-auth/active` requires `{ "accountId": "" }` or an explicit +`{ "accountId": null }` to clear selection. Re-selecting the current account is an explicit +selection even if another process changed the saved account. The server preserves unrelated +saved settings and rejects targets removed or paused since the dashboard loaded. An unavailable +or invalid saved configuration leaves the previous live selection and affinity unchanged; reload +settings before retrying. A successful response describes the committed selection. Existing quota +and failover rules can still release a pin; `pinDrained` reports a currently known drain reason. + For reset-credit consumption, a different `operationId` supplied while the same physical account has an unfinished operation joins that operation as an alias. Its retry uses the original upstream request ID and records the outcome under that same identity, so later 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 209c7ffdf05..c4d46c323ee 100644 --- a/docs-site/src/content/docs/ru/reference/management-api.md +++ b/docs-site/src/content/docs/ru/reference/management-api.md @@ -364,7 +364,7 @@ picker изменилась. `catalogRefreshPending: true` в успешном | `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 | +| `GET, PUT /api/codex-auth/active` | Прочитать или выбрать активный аккаунт | 400 invalid or missing account; 409 paused/legacy-row conflict или `account_selection_unavailable` | | `PUT /api/codex-auth/auto-switch` | Задать глобальный порог через `{ threshold }` без `id` или переопределение аккаунта через `{ id, threshold }`; `id: '__main__'` выбирает аккаунт Codex Desktop. При указанном `id` значение `threshold: null` удаляет переопределение и восстанавливает наследование глобального порога | 400 invalid id/threshold; 404 missing account | | `PUT, PATCH /api/codex-auth/pool-strategy` | Обновить стратегию выбора в пуле аккаунтов Codex | 400 invalid strategy/config | | `PUT /api/codex-auth/failover` | Задать порог failover аккаунтов | 400 invalid threshold | @@ -376,6 +376,16 @@ picker изменилась. `catalogRefreshPending: true` в успешном | `POST /api/codex-auth/login/cancel` | Отменить только ожидающий вход Codex с `{ "flowId": "..." }` | 400 ID потока отсутствует, неизвестен или не ожидает завершения | | `GET /api/codex-auth/login-status` | Опрашивать flow или login-state аккаунта. Завершение нового аккаунта включает `catalogRefreshPending: true` только при необходимости восстановления. | Неизвестные flow'ы сообщаются как `expired`; отсутствие активного flow — как `idle` | +`PUT /api/codex-auth/active` требует `{ "accountId": "" }` или явно указанного +`{ "accountId": null }` для сброса выбора. Повторный выбор текущего аккаунта считается явным +выбором, даже если другой процесс изменил сохранённый аккаунт. Сервер сохраняет остальные +сохранённые настройки и отклоняет выбор аккаунтов, удалённых или приостановленных после загрузки +панели управления. Если сохранённая конфигурация недоступна или некорректна, текущий выбор +в работающем процессе и привязка запросов к аккаунту не меняются; перед повторной попыткой +перезагрузите настройки. Успешный ответ описывает зафиксированный выбор. Существующие правила +квот и failover по-прежнему могут снять закрепление; `pinDrained` сообщает известную на данный +момент причину снятия закрепления. + Если config row нового аккаунта сохранён, но credential setup не завершён, OAuth `login-status` сообщает `status: "error"` и содержит `code: "codex_credential_persistence_failed"`, `accountId`, `needsReauth: true` и при необходимости 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 a590806cd01..5add376205e 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 @@ -318,7 +318,7 @@ OpenAI 也遵循此规则:开关不会选择特殊的 922k 模式。有效上 | `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 暂停/旧行冲突 | +| `GET, PUT /api/codex-auth/active` | 读取或选择当前活跃账户 | 400 账户无效或缺失;409 暂停/旧行冲突或 `account_selection_unavailable` | | `PUT /api/codex-auth/auto-switch` | 使用不含 `id` 的 `{ threshold }` 设置全局阈值,或使用 `{ id, threshold }` 设置账号覆盖值;`id: '__main__'` 选择 Codex Desktop 账号。指定 `id` 时,`threshold: null` 删除该账号的覆盖值并恢复继承全局阈值 | 400 ID/阈值无效;404 账号不存在 | | `PUT, PATCH /api/codex-auth/pool-strategy` | 更新 Codex 账户池选择策略 | 400 策略/配置无效 | | `PUT /api/codex-auth/failover` | 设置账户故障转移阈值 | 400 阈值无效 | @@ -330,6 +330,13 @@ OpenAI 也遵循此规则:开关不会选择特殊的 922k 模式。有效上 | `POST /api/codex-auth/login/cancel` | 仅取消 `{ "flowId": "..." }` 指定的待处理 Codex 登录 | 400 流程 ID 缺失、未知或不在待处理状态 | | `GET /api/codex-auth/login-status` | 轮询某个流程或账户登录状态。新账号流程完成时,仅在需要恢复时包含 `catalogRefreshPending: true`。 | 未知流程报告为 `expired`;没有活跃流程时报告为 `idle` | +`PUT /api/codex-auth/active` 必须包含 `{ "accountId": "" }`,清除选择时必须显式传入 +`{ "accountId": null }`。即使其他进程更改了已保存的账户,重新选择当前账户仍会被视为明确的选择操作。 +服务器会保留其他已保存的设置,并拒绝选择在仪表盘加载后被删除或暂停的账户。如果已保存的配置不可用 +或无效,当前运行时的账户选择和会话与账户的绑定关系将保持不变;请重新加载设置后再重试。 +成功响应描述的是已提交的选择。现有的配额和故障转移规则仍可能解除账户固定;`pinDrained` 会报告 +当前已知的固定解除原因。 + 如果新账号的 config row 已保存但 credential setup 未能完成,OAuth `login-status` 会报告 `status: "error"`,并包含 `code: "codex_credential_persistence_failed"`、`accountId`、`needsReauth: true`,并在需要时包含 diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 0b7e20b7a1f..6a5a7930eed 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -523,6 +523,7 @@ "codebuddy-tool-bridge-turn.test.ts": "providers", "codebuddy-tool-bridge.test.ts": "providers", "codex-account-delete-atomicity.test.ts": "codex-integration", + "codex-account-selection-atomicity.test.ts": "codex-integration", "codex-account-label.test.ts": "codex-integration", "codex-account-mode-state.test.ts": "gui", "codex-account-namespaces.test.ts": "codex-integration", diff --git a/src/codex/auth-api/account-selection.ts b/src/codex/auth-api/account-selection.ts new file mode 100644 index 00000000000..5e33396d1fe --- /dev/null +++ b/src/codex/auth-api/account-selection.ts @@ -0,0 +1,107 @@ +import type { OcxConfig } from "../../types"; +import { getConfigPath, initializePersistedConfigIfMissing, mutatePersistedConfig } from "../../config"; +import { mergeConfigDefaults, readConfigFileSnapshot, validateConfigCandidate } from "../../config/diagnostics"; +import { adoptPersistedCodexAccountSelection, claudeCodeBaselineArmed } from "../../config/live-reconcile"; +import { deleteConfigTopLevelKey, fileBackedConfigPath, recordFileBackedConfig } from "../../config/rebase-provenance"; +import { MAIN_CODEX_ACCOUNT_ID, hasLegacyMainCodexPoolAccount, isSelectableCodexPoolAccount, isValidCodexAccountId } from "../account-id"; +import { isCodexAccountPaused } from "../account-pause"; +import { readCodexAccountRecord } from "../account-store"; +import { codexAccountPinDrainReason, resetCodexRoutingForManualSelection } from "../routing"; +import { jsonResponse } from "./http"; + +class SelectionRejected extends Error { + constructor(message: string, readonly status: number) { super(message); } +} + +function validateSelection(config: OcxConfig, accountId: string | null): void { + if (accountId === null) return; + if (accountId === MAIN_CODEX_ACCOUNT_ID && hasLegacyMainCodexPoolAccount(config.codexAccounts)) { + throw new SelectionRejected("Remove the legacy __main__ pool row before selecting the Desktop account", 409); + } + if (isCodexAccountPaused(config, accountId)) throw new SelectionRejected("Account is paused", 409); + if (accountId === MAIN_CODEX_ACCOUNT_ID) return; + if (!isValidCodexAccountId(accountId)) throw new SelectionRejected("Invalid account id format", 400); + if (!(config.codexAccounts ?? []).some(account => isSelectableCodexPoolAccount(account) && account.id === accountId)) { + throw new SelectionRejected("Account not found", 400); + } + if (readCodexAccountRecord(accountId)?.codexValidationPending) { + throw new SelectionRejected("Account validation is pending. Refresh quota after recovery to validate it.", 409); + } +} + +function applySelection(config: OcxConfig, accountId: string | null): boolean { + const changed = config.activeCodexAccountId !== (accountId ?? undefined) + || config.activeCodexAccountPinned !== (accountId ?? undefined); + if (accountId === null) { + deleteConfigTopLevelKey(config, "activeCodexAccountId"); + deleteConfigTopLevelKey(config, "activeCodexAccountPinned"); + } else { + config.activeCodexAccountId = accountId; + config.activeCodexAccountPinned = accountId; + } + return changed; +} + +/** A manual selection is an explicit command, even when equal to the live merge baseline. */ +export function persistCodexAccountSelection(config: OcxConfig, accountId: string | null): Response { + const unknown = () => jsonResponse({ + error: "Account selection could not be confirmed; reload settings before retrying", + code: "account_selection_unavailable", + }, 409); + const configPath = getConfigPath(); + const sourcePath = fileBackedConfigPath(config); + if (sourcePath !== undefined && sourcePath !== configPath) return unknown(); + let committed: OcxConfig; + let warning = false; + try { + // Do not activate a newly disk-only account that the running proxy has not adopted. + validateSelection(config, accountId); + const before = readConfigFileSnapshot(); + if (before.diagnostics.source === "default" && !fileBackedConfigPath(config) && !claudeCodeBaselineArmed(config)) { + // Preserve transient/initial callers without recreating a deleted live configuration. + // The create-only owner cannot replace a competing writer's newly published config. + const normalized = validateConfigCandidate(mergeConfigDefaults(config)); + if (!normalized.ok) return unknown(); + const initial = normalized.config; + applySelection(initial, accountId); + initializePersistedConfigIfMissing(initial); + } + const result = mutatePersistedConfig(persisted => { + // This callback is re-run under the mutation lock immediately before publication. + // A removed or paused account must not be resurrected by a stale UI snapshot. + validateSelection(config, accountId); + validateSelection(persisted, accountId); + return { changed: applySelection(persisted, accountId), value: persisted }; + }); + if (result.status === "unavailable") return unknown(); + committed = result.value; + } catch (error) { + if (error instanceof SelectionRejected) return jsonResponse({ error: error.message }, error.status); + // Atomic publication may succeed before generation/bookkeeping fails. Confirm the exact + // durable selection instead of rolling live state back behind an already published write. + const snapshot = readConfigFileSnapshot(); + if (snapshot.raw === undefined) return unknown(); + try { + const strict = validateConfigCandidate(JSON.parse(snapshot.raw.replace(/^\uFEFF/, ""))); + if (!strict.ok) return unknown(); + validateSelection(config, accountId); + validateSelection(strict.config, accountId); + if (strict.config.activeCodexAccountId !== (accountId ?? undefined) + || strict.config.activeCodexAccountPinned !== (accountId ?? undefined)) return unknown(); + committed = strict.config; + warning = true; + } catch { return unknown(); } + } + adoptPersistedCodexAccountSelection(config, committed); + recordFileBackedConfig(config, configPath); + // No failed save may clear affinity, spend the manual preference, or move the RR cursor. + resetCodexRoutingForManualSelection(accountId ?? MAIN_CODEX_ACCOUNT_ID); + const pinDrainReason = accountId === null ? undefined : codexAccountPinDrainReason(config, accountId); + return jsonResponse({ + ok: true, + activeCodexAccountId: config.activeCodexAccountId ?? null, + appliesImmediately: true, + ...(pinDrainReason !== undefined ? { pinDrained: true, pinDrainReason } : {}), + ...(warning ? { warning: "config_bookkeeping_failed" } : {}), + }); +} diff --git a/src/codex/auth-api/routes.ts b/src/codex/auth-api/routes.ts index f28c2382477..94742053385 100644 --- a/src/codex/auth-api/routes.ts +++ b/src/codex/auth-api/routes.ts @@ -1,3 +1,4 @@ +import { persistCodexAccountSelection } from "./account-selection"; import { CODEX_ACCOUNT_LOG_LABEL_RE, codexAccountLogLabel } from "../account-label"; import { poolQuotaHistoryIdentity, readCodexAccountRecord } from "../account-store"; import { estimateCodexQuotaCapacity, insufficientCodexCapacity } from "../quota-capacity"; @@ -7,13 +8,13 @@ import { getAccountQuotaHistory, listAccountQuotas } from "../quota"; import { deleteCodexAccount } from "../account-lifecycle"; import { isCodexAccountPaused, setCodexAccountPaused } from "../account-pause"; import { setAllCodexAccountsCreditsAfterLimit, setCodexAccountCreditsAfterLimit } from "../account-credit-use"; -import { clearCodexAccountPin, isCodexAccountPriorityKey, pinnedCodexAccountId, setCodexAccountPin, setCodexAccountPriority } from "../account-priority"; -import { codexAccountPinDrainReason, codexQuotaScopeForModel, clearCodexAccountCooldown, clearThreadAccountMapForAccount, getEffectiveActiveCodexAccountId, isEffectiveCodexAccountPinned, resetCodexRoutingForManualSelection } from "../routing"; +import { clearCodexAccountPin, isCodexAccountPriorityKey, pinnedCodexAccountId, setCodexAccountPriority } from "../account-priority"; +import { codexQuotaScopeForModel, clearCodexAccountCooldown, clearThreadAccountMapForAccount, getEffectiveActiveCodexAccountId, isEffectiveCodexAccountPinned } from "../routing"; import { DEFAULT_ACCOUNT_PRIORITY, MAX_ACCOUNT_PRIORITY, MIN_ACCOUNT_PRIORITY, normalizeAccountPoolStickyLimit, normalizeCodexAccountPoolStrategy, parseAccountPoolStickyLimit, parseCodexAccountPoolStrategy, parseAccountPriority } from "../pool-rotation"; import { MAIN_CODEX_ACCOUNT_ID } from "../main-account"; import { reconcileLiveStateStores } from "../../lib/state-store-registrations"; import type { OcxConfig } from "../../types"; -import { CODEX_ACCOUNT_ID_RE, hasLegacyMainCodexPoolAccount, isSelectableCodexPoolAccount, isValidCodexAccountId } from "../account-id"; +import { CODEX_ACCOUNT_ID_RE, isSelectableCodexPoolAccount, isValidCodexAccountId } from "../account-id"; import { isCodexResetCreditOperationId } from "../reset-credit-recovery"; import { listCodexAuthAccounts, selectFallbackAfterPause, pauseExhaustedCodexAccounts } from "./account-list"; import { jsonResponse, manualImportDisabledResponse } from "./http"; @@ -247,53 +248,13 @@ export async function handleCodexAuthAPI( } if (url.pathname === "/api/codex-auth/active" && req.method === "PUT") { - let body: { accountId: string | null }; - try { body = (await req.json()) as typeof body; } catch { return jsonResponse({ error: "Invalid JSON" }, 400); } - const runtimeConfig = getRuntimeConfig(config); - const targetAccountId = body.accountId ?? MAIN_CODEX_ACCOUNT_ID; - if (body.accountId === MAIN_CODEX_ACCOUNT_ID && hasLegacyMainCodexPoolAccount(runtimeConfig.codexAccounts)) { - return jsonResponse({ error: "Remove the legacy __main__ pool row before selecting the Desktop account" }, 409); - } - if (body.accountId != null && isCodexAccountPaused(runtimeConfig, targetAccountId)) { - return jsonResponse({ error: "Account is paused" }, 409); - } - if (body.accountId != null && body.accountId !== MAIN_CODEX_ACCOUNT_ID) { - if (!isValidCodexAccountId(body.accountId)) return jsonResponse({ error: "Invalid account id format" }, 400); - const exists = (runtimeConfig.codexAccounts ?? []) - .some(account => isSelectableCodexPoolAccount(account) && account.id === body.accountId); - if (!exists) return jsonResponse({ error: "Account not found" }, 400); - if (readCodexAccountRecord(body.accountId)?.codexValidationPending) { - return jsonResponse({ error: "Account validation is pending. Refresh quota after recovery to validate it." }, 409); - } + let body: unknown; + try { body = await req.json(); } catch { return jsonResponse({ error: "Invalid JSON" }, 400); } + if (!body || typeof body !== "object" || Array.isArray(body) + || !("accountId" in body) || (body.accountId !== null && typeof body.accountId !== "string")) { + return jsonResponse({ error: "accountId must be an account id or null" }, 400); } - runtimeConfig.activeCodexAccountId = body.accountId ?? undefined; - // "Use this account now" outranks selection order until the account is spent: - // persisted here rather than in resetCodexRoutingForManualSelection, which is - // runtime state only. A null id clears the selection instead of making one, so it - // must release the pin rather than record one: pinning the `targetAccountId` - // fallback would leave a pin that no effective active account matches, which - // `isEffectiveCodexAccountPinned` reports as unpinned while the tier filter still - // honours it as a ceiling — invisibly capping the pool at the main account's tier. - if (body.accountId == null) clearCodexAccountPin(runtimeConfig); - else setCodexAccountPin(runtimeConfig, targetAccountId); - resetCodexRoutingForManualSelection(targetAccountId); - saveRuntimeConfig(config, runtimeConfig); - // A pin this route accepts can still be dropped by the very next resolve, and saying - // nothing about that is what made the setting look ignored (#4521). The checks above - // refuse an account that cannot be selected at all; this reports the one remaining - // outcome they do not cover, from the same predicate routing releases on, so the two - // cannot drift. Absent means the pin survives — additive for existing clients. - // `appliesImmediately` is unchanged: it answers whether thread affinity was cleared, - // not whether the pin is durable. - const pinDrainReason = body.accountId == null - ? undefined - : codexAccountPinDrainReason(runtimeConfig, targetAccountId); - return jsonResponse({ - ok: true, - activeCodexAccountId: body.accountId, - appliesImmediately: true, - ...(pinDrainReason !== undefined ? { pinDrained: true, pinDrainReason } : {}), - }); + return persistCodexAccountSelection(getRuntimeConfig(config), body.accountId); } if (url.pathname === "/api/codex-auth/active" && req.method === "GET") { diff --git a/src/config/live-reconcile.ts b/src/config/live-reconcile.ts index d33aa9746b0..0eb2b7ec0e2 100644 --- a/src/config/live-reconcile.ts +++ b/src/config/live-reconcile.ts @@ -8,6 +8,7 @@ import { clearPendingConfigObjectChildDeletions, prepareConfigObjectChildDeletionRebase, clearPendingConfigTopLevelDeletions, + clearConfigTopLevelDeletionIntent, configHasRebaseProvenance, configRebaseDeletionKeys, CONFIG_REBASE_PROVENANCE_KEY, @@ -42,6 +43,22 @@ const claudeCodeBaseline = new WeakMap(); */ const liveConfigBaseline = new WeakMap(); +/** Adopt a committed manual selection without advancing unrelated live merge baselines. */ +export function adoptPersistedCodexAccountSelection( + config: OcxConfig, + persisted: Pick, +): void { + const keys = ["activeCodexAccountId", "activeCodexAccountPinned"] as const; + const baseline = liveConfigBaseline.get(config); + for (const key of keys) { + config[key] = persisted[key]; + // Retain known absent keys: a later disk-only selection is a new edit, not stale intent. + if (baseline) baseline[key] = persisted[key]; + } + clearConfigTopLevelDeletionIntent(config, keys); + if (baseline) clearConfigTopLevelDeletionIntent(baseline, keys); +} + /** * Adopt a committed discovery decision and its merge baseline as one synchronous step. * Visibility writers assign and save synchronously; any future writer yielding between diff --git a/src/config/rebase-provenance.ts b/src/config/rebase-provenance.ts index 96a35fd181b..d198ffc064b 100644 --- a/src/config/rebase-provenance.ts +++ b/src/config/rebase-provenance.ts @@ -141,6 +141,17 @@ export function clearPendingConfigTopLevelDeletions(config: OcxConfig): void { pendingTopLevelDeletions.delete(config); } +/** A scoped durable adoption consumes only those fields' earlier deletion intent. */ +export function clearConfigTopLevelDeletionIntent(config: OcxConfig, keys: readonly (keyof OcxConfig)[]): void { + const pending = pendingTopLevelDeletions.get(config); + for (const key of keys) pending?.delete(key); + const parsed = parsedConfigRebaseDeletionKeys(config); + if (parsed === null) return; + for (const key of keys) parsed.delete(key); + if (parsed.size === 0) delete config.configRebaseProvenance; + else config.configRebaseProvenance = { version: 1, deletedTopLevelKeys: [...parsed].sort() }; +} + export function clearPendingConfigObjectChildDeletions(config: OcxConfig): void { pendingObjectChildDeletions.delete(config); } diff --git a/structure/config.md b/structure/config.md index 13505a50bd0..5fb5041d696 100644 --- a/structure/config.md +++ b/structure/config.md @@ -54,7 +54,7 @@ as the supported-location recovery path; uncertain publication and cleanup warni the CLI. The quickstart documents inspection before retry, private-permission requirements, and fresh-location examples. Diagnostics do not introduce a fallback or alter file I/O ordering. -`src/config/persisted-mutation.ts` owns schema-valid on-disk mutations under the shared lock. +`src/config/persisted-mutation.ts` owns schema-valid on-disk mutations under the shared lock. Manual Codex selection uses that transaction rather than an inferred whole-config diff. `src/config/live-reconcile.ts` adopts the committed active/pin fields and only their merge baselines; their consumed deletion intent is cleared without consuming unrelated pending deletions. It rechecks the file before committing, retries a changed snapshot up to three times, and returns unavailable for missing, invalid, or persistently conflicting config. Its one-shot test seam and the mutation types remain re-exported through `src/config.ts`. Successful, repaired, and salvaged file loads record their source path in the private WeakMap owned by `src/config/rebase-provenance.ts`; defaults and synthetic objects do not acquire it. This metadata is never serialized. Inventory drift cannot turn a file-backed instance into a synthetic discovery writer. Read publication may use the detached policy projection described by the [catalog contract](catalog.md#shared-catalog), without changing the live merge baseline; changing the resolved config home refuses publication. diff --git a/structure/providers/openai-accounts.md b/structure/providers/openai-accounts.md index c408ce0629e..e3ac724871f 100644 --- a/structure/providers/openai-accounts.md +++ b/structure/providers/openai-accounts.md @@ -192,6 +192,14 @@ Pool mode needs stable public names and a store that survives concurrent refresh ## Sidecars, management, and UI +`src/codex/auth-api/account-selection.ts` commits manual active-account and pin changes against +the latest config under the mutation coordinator. Re-selecting the same account and explicitly +clearing selection retain their command intent; unrelated saved fields remain untouched. Both +the live roster and the current persisted roster must admit a selected account. Removed, paused, +or validation-pending targets are rejected before changing routing. Confirmed publication precedes +live selection/baseline adoption and affinity reset; an unavailable saved config leaves prior routing +intact. A post-publication bookkeeping failure adopts only a strictly verified matching selection. + The desktop restart adapter uses [Windows process ownership and installation membership](../runtime.md#codex-desktop-process-membership), independently of Pool/Direct credential selection. HTTP/SSE, Responses WebSocket, compact, images, search, and vision resolve the same account mode. diff --git a/tests/codex-integration/codex-account-selection-atomicity.test.ts b/tests/codex-integration/codex-account-selection-atomicity.test.ts new file mode 100644 index 00000000000..4867965aab1 --- /dev/null +++ b/tests/codex-integration/codex-account-selection-atomicity.test.ts @@ -0,0 +1,430 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + armClaudeCodeBaseline, deleteConfigTopLevelKey, getConfigPath, getDefaultConfig, + loadConfig, saveConfig, saveConfigPreservingClaudeCode, +} from "../../src/config"; +import { setPersistedConfigMutationBeforeCommitForTests } from "../../src/config/persisted-mutation"; +import { flushConfigDirHardeningForTests } from "../../src/config/paths"; +import * as atomicWrite from "../../src/config/atomic-write"; +import * as derivedRegistries from "../../src/config/derived-registries"; +import * as mutationLock from "../../src/config/mutation-lock"; +import { handleCodexAuthAPI } from "../../src/codex/auth-api/routes"; +import { materializeCodexUpstreamAuth, resolveCodexAuthContext } from "../../src/codex/auth-context"; +import { MAIN_CODEX_ACCOUNT_ID } from "../../src/codex/account-id"; +import { clearAccountNeedsReauth } from "../../src/codex/account-runtime-state"; +import { getCodexAccountCredential, readCodexAccountRecord, saveCodexAccountCredential } from "../../src/codex/account-store"; +import { clearPoolRotationState } from "../../src/codex/pool-rotation"; +import { clearAccountQuota, getAccountQuota, updateAccountQuota } from "../../src/codex/quota"; +import { + clearCodexUpstreamHealth, clearThreadAccountMap, getEffectiveActiveCodexAccountId, + resolveCodexAccountForThread, +} from "../../src/codex/routing"; +import { rememberActiveCodexAccount } from "../../src/codex/routing/active-account"; +import { bindThreadAffinity, getThreadAffinity } from "../../src/codex/routing/thread-affinity"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +// These are deliberately old, flat credential records. Account order and display aliases must +// never become credential identity while an upgraded installation changes its active selection. +const ACCOUNT_A = "selection-old-a"; +const ACCOUNT_B = "selection-old-b"; +const MAIN = MAIN_CODEX_ACCOUNT_ID; +const IDS = [MAIN, ACCOUNT_A, ACCOUNT_B]; +const url = new URL("http://127.0.0.1/api/codex-auth/active"); +let home: string; +let previousOcxHome: string | undefined; +let previousCodexHome: string | undefined; +let fetchSpy: ReturnType>; + +function credential(id: string) { + return { + accessToken: `fixture-access-${id}`, + refreshToken: `fixture-refresh-${id}`, + expiresAt: Date.now() + 3_600_000, + chatgptAccountId: `fixture-workspace-${id}`, + }; +} + +function clearState(): void { + clearThreadAccountMap(); + clearCodexUpstreamHealth(); + clearPoolRotationState(); + clearAccountQuota(); + for (const id of IDS) clearAccountNeedsReauth(id); +} + +beforeEach(() => { + previousOcxHome = process.env.OPENCODEX_HOME; + previousCodexHome = process.env.CODEX_HOME; + home = mkdtempSync(join(tmpdir(), "ocx-selection-atomicity-")); + process.env.OPENCODEX_HOME = join(home, "ocx"); + process.env.CODEX_HOME = join(home, "codex"); + mkdirSync(process.env.OPENCODEX_HOME); + mkdirSync(process.env.CODEX_HOME); + clearState(); + writeFileSync(join(process.env.CODEX_HOME, "auth.json"), JSON.stringify({ + tokens: { access_token: credential(MAIN).accessToken, account_id: credential(MAIN).chatgptAccountId }, + })); + writeFileSync(join(process.env.OPENCODEX_HOME, "codex-accounts.json"), JSON.stringify({ + [ACCOUNT_A]: credential(ACCOUNT_A), [ACCOUNT_B]: credential(ACCOUNT_B), + })); + fetchSpy = spyOn(globalThis, "fetch").mockImplementation(async () => { + throw new Error("Account selection fixture must not contact a provider"); + }); +}); + +afterEach(async () => { + setPersistedConfigMutationBeforeCommitForTests(null); + const requests = fetchSpy.mock.calls.length; + fetchSpy.mockRestore(); + clearState(); + await flushConfigDirHardeningForTests(); + if (previousOcxHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOcxHome; + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + removeTreeWithRetry(home); + expect(requests).toBe(0); +}); + +function seedConfig(overrides: Partial = {}): OcxConfig { + saveConfig({ + port: 10100, + defaultProvider: "test", + providers: { test: { adapter: "openai-chat", baseUrl: "http://127.0.0.1:1/v1", apiKey: "fixture-key", allowPrivateNetwork: true } }, + codexAccounts: [ + { id: ACCOUNT_A, email: "selection-a@example.test", alias: "Old main", isMain: false }, + { id: ACCOUNT_B, email: "selection-b@example.test", alias: "Old backup", isMain: false }, + ], + activeCodexAccountId: MAIN, + activeCodexAccountPinned: MAIN, + accountPoolStrategy: "quota", + autoSwitchThreshold: 0, + codexMainAccountHardLock: false, + ...overrides, + } as OcxConfig); + const live = loadConfig(); + armClaudeCodeBaseline(live); + return live; +} + +function diskConfig(): OcxConfig { + return JSON.parse(readFileSync(getConfigPath(), "utf8")) as OcxConfig; +} + +function changeDisk(patch: Partial): void { + writeFileSync(getConfigPath(), JSON.stringify({ ...diskConfig(), ...patch })); +} + +async function select(live: OcxConfig, accountId: string | null): Promise { + const req = new Request(url, { method: "PUT", headers: { "content-type": "application/json" }, + body: JSON.stringify({ accountId }) }); + const response = await handleCodexAuthAPI(req, url, live); + expect(response).not.toBeNull(); + return response!; +} + +async function expectSelection(live: OcxConfig, accountId: string | null): Promise { + const response = await handleCodexAuthAPI(new Request(url), url, live); + expect(await response!.json()).toMatchObject({ + activeCodexAccountId: accountId, pinnedAccountId: accountId, + }); + expect(live.activeCodexAccountId).toBe(accountId ?? undefined); + expect(live.activeCodexAccountPinned).toBe(accountId ?? undefined); + expect(diskConfig().activeCodexAccountId).toBe(accountId ?? undefined); + expect(diskConfig().activeCodexAccountPinned).toBe(accountId ?? undefined); +} + +async function expectUpstreamIdentity(live: OcxConfig, accountId: string): Promise { + expect(resolveCodexAccountForThread(null, live)).toBe(accountId); + const context = await resolveCodexAuthContext(new Headers(), live, "pool", { + primeCodexPoolQuotas: async () => {}, + }); + const upstream = materializeCodexUpstreamAuth(new Headers(), context, { config: live }); + expect(upstream.get("authorization")).toBe(`Bearer ${credential(accountId).accessToken}`); + expect(upstream.get("chatgpt-account-id")).toBe(credential(accountId).chatgptAccountId); +} + +function captureRouting(live: OcxConfig) { + rememberActiveCodexAccount(live, ACCOUNT_A); + bindThreadAffinity("existing-selection-thread", ACCOUNT_A, Date.now()); + updateAccountQuota(ACCOUNT_A, 37); + const configBefore = structuredClone(live); + const affinityBefore = structuredClone(getThreadAffinity("existing-selection-thread")); + const quotaBefore = structuredClone(getAccountQuota(ACCOUNT_A)); + return () => { + expect(live).toEqual(configBefore); + expect(getEffectiveActiveCodexAccountId(live)).toBe(ACCOUNT_A); + expect(getThreadAffinity("existing-selection-thread")).toEqual(affinityBefore); + expect(getAccountQuota(ACCOUNT_A)).toEqual(quotaBefore); + }; +} + +describe("Codex active selection transaction", () => { + test.each([MAIN, ACCOUNT_A])("same-value selection of %s overrides newer disk selection", async accountId => { + const live = seedConfig({ activeCodexAccountId: accountId, activeCodexAccountPinned: accountId }); + const credentialsBefore = readFileSync(join(process.env.OPENCODEX_HOME!, "codex-accounts.json"), "utf8"); + changeDisk({ activeCodexAccountId: ACCOUNT_B, activeCodexAccountPinned: ACCOUNT_B }); + const response = await select(live, accountId); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ activeCodexAccountId: accountId, appliesImmediately: true }); + await expectSelection(live, accountId); + expect(readFileSync(join(process.env.OPENCODEX_HOME!, "codex-accounts.json"), "utf8")).toBe(credentialsBefore); + await expectUpstreamIdentity(live, accountId); + // First materialization may attach quota-history metadata to a legacy record. It must + // retain its credential and generation, rather than forbidding that intended upgrade. + const legacy = JSON.parse(credentialsBefore); + expect(getCodexAccountCredential(ACCOUNT_A)).toEqual(legacy[ACCOUNT_A]); + expect(getCodexAccountCredential(ACCOUNT_B)).toEqual(legacy[ACCOUNT_B]); + expect(readCodexAccountRecord(ACCOUNT_A)?.generation).toBe(0); + }); + + test("reselecting an initially unpinned main cannot leave a main pin on another active account", async () => { + const live = seedConfig({ activeCodexAccountPinned: undefined }); + changeDisk({ activeCodexAccountId: ACCOUNT_B, activeCodexAccountPinned: ACCOUNT_B }); + expect((await select(live, MAIN)).status).toBe(200); + await expectSelection(live, MAIN); + await expectUpstreamIdentity(live, MAIN); + }); + + test("null clears newer disk selection when the baseline had no selection or pin", async () => { + const live = seedConfig({ activeCodexAccountId: undefined, activeCodexAccountPinned: undefined }); + changeDisk({ activeCodexAccountId: ACCOUNT_B, activeCodexAccountPinned: ACCOUNT_B }); + expect((await select(live, null)).status).toBe(200); + await expectSelection(live, null); + // Clear means automatic selection; a subsequent route is free to choose an account. + live.upstreamFailoverThreshold = 4; + saveConfigPreservingClaudeCode(live); + await expectSelection(live, null); + }); + + test("a scoped selection preserves newer row order, aliases, and unrelated durable settings", async () => { + const live = seedConfig({ activeCodexAccountId: ACCOUNT_A, activeCodexAccountPinned: ACCOUNT_A }); + const rows = [...live.codexAccounts!].reverse().map(row => ({ ...row, alias: `Reordered ${row.id}` })); + const credentialsBefore = readFileSync(join(process.env.OPENCODEX_HOME!, "codex-accounts.json"), "utf8"); + changeDisk({ codexAccounts: rows, activeCodexAccountId: ACCOUNT_B, activeCodexAccountPinned: ACCOUNT_B, + port: 10102, showCodexCredits: true, claudeCode: { authMode: "proxy" } }); + expect((await select(live, ACCOUNT_A)).status).toBe(200); + expect(diskConfig()).toMatchObject({ codexAccounts: rows, port: 10102, showCodexCredits: true, + claudeCode: { authMode: "proxy" } }); + expect(live.port).toBe(10100); + expect(readFileSync(join(process.env.OPENCODEX_HOME!, "codex-accounts.json"), "utf8")).toBe(credentialsBefore); + await expectUpstreamIdentity(live, ACCOUNT_A); + expect(getCodexAccountCredential(ACCOUNT_A)).toEqual(JSON.parse(credentialsBefore)[ACCOUNT_A]); + }); + + test("selection adoption advances only its baseline and preserves pending live edits", async () => { + const live = seedConfig({ activeCodexAccountId: ACCOUNT_A, activeCodexAccountPinned: ACCOUNT_A }); + live.injectionPrompt = "Pending local fixture edit"; + expect((await select(live, MAIN)).status).toBe(200); + expect(live.injectionPrompt).toBe("Pending local fixture edit"); + changeDisk({ activeCodexAccountId: ACCOUNT_B, activeCodexAccountPinned: ACCOUNT_B }); + live.upstreamFailoverThreshold = 4; + saveConfigPreservingClaudeCode(live); + expect(live.activeCodexAccountId).toBe(ACCOUNT_B); + expect(live.activeCodexAccountPinned).toBe(ACCOUNT_B); + expect(diskConfig().injectionPrompt).toBe("Pending local fixture edit"); + }); + + test("a completed clear cannot erase a later selection or consume an unrelated pending deletion", async () => { + const live = seedConfig({ injectionPrompt: "Old fixture prompt" }); + deleteConfigTopLevelKey(live, "injectionPrompt"); + expect((await select(live, null)).status).toBe(200); + await expectSelection(live, null); + // The scoped write does not publish a pending unrelated edit. + expect(diskConfig().injectionPrompt).toBe("Old fixture prompt"); + expect(live.injectionPrompt).toBeUndefined(); + changeDisk({ activeCodexAccountId: ACCOUNT_B, activeCodexAccountPinned: ACCOUNT_B, + injectionPrompt: "Newer disk fixture prompt" }); + live.upstreamFailoverThreshold = 4; + saveConfigPreservingClaudeCode(live); + await expectSelection(live, ACCOUNT_B); + expect(diskConfig().injectionPrompt).toBeUndefined(); + expect(live.injectionPrompt).toBeUndefined(); + }); + + for (const timing of ["before-request", "before-commit"] as const) { + test.each(["deleted", "paused", "validation-pending"] as const)( + `a target that becomes %s ${timing} is rejected without changing runtime routing`, async reason => { + const live = seedConfig(); + const assertRoutingUnchanged = captureRouting(live); + const invalidate = () => { + if (reason === "deleted") changeDisk({ codexAccounts: live.codexAccounts!.filter(row => row.id !== ACCOUNT_B) }); + else if (reason === "paused") changeDisk({ pausedCodexAccountIds: [ACCOUNT_B] }); + else saveCodexAccountCredential(ACCOUNT_B, getCodexAccountCredential(ACCOUNT_B)!, { validationPending: true }); + }; + if (timing === "before-request") invalidate(); + else setPersistedConfigMutationBeforeCommitForTests(invalidate); + const response = await select(live, ACCOUNT_B); + expect(response.status).toBeGreaterThanOrEqual(400); + expect(response.status).toBeLessThan(500); + assertRoutingUnchanged(); + expect(diskConfig().activeCodexAccountId).toBe(MAIN); + expect(diskConfig().activeCodexAccountPinned).toBe(MAIN); + }, + ); + } + + test.each(["missing", "invalid", "unreadable"] as const)( + "a %s durable config fails closed without rebuilding it or changing runtime", async state => { + const live = seedConfig(); + const assertRoutingUnchanged = captureRouting(live); + if (state === "invalid") writeFileSync(getConfigPath(), "{invalid-fixture"); + else { + unlinkSync(getConfigPath()); + if (state === "unreadable") mkdirSync(getConfigPath()); + } + let rejected = false; + try { rejected = !(await select(live, ACCOUNT_B)).ok; } catch { rejected = true; } + expect(rejected).toBe(true); + assertRoutingUnchanged(); + if (state === "missing") expect(existsSync(getConfigPath())).toBe(false); + if (state === "invalid") expect(readFileSync(getConfigPath(), "utf8")).toBe("{invalid-fixture"); + }, + ); + + test.each(["atomic-write", "derived-registry", "generation-bump"] as const)( + "a %s failure respects the durable publication boundary", async phase => { + const live = seedConfig(); + changeDisk({ activeCodexAccountId: ACCOUNT_B, activeCodexAccountPinned: ACCOUNT_B }); + const before = readFileSync(getConfigPath(), "utf8"); + const assertRoutingUnchanged = captureRouting(live); + const fail = () => { throw new Error("Injected account-selection persistence failure"); }; + const fault = phase === "atomic-write" + ? spyOn(atomicWrite, "atomicWriteFile").mockImplementation(fail) + : phase === "derived-registry" + ? spyOn(derivedRegistries, "refreshConfigDerivedRegistries").mockImplementation(fail) + : spyOn(mutationLock, "bumpGenerationForCooperatingConfigWrite").mockImplementation(fail); + let response: Response; + try { + response = await select(live, MAIN); + expect(fault.mock.calls.length).toBeGreaterThan(0); + } finally { + fault.mockRestore(); + } + if (phase === "atomic-write") { + expect(response.status).toBe(409); + expect(await response.json()).toMatchObject({ code: "account_selection_unavailable" }); + expect(readFileSync(getConfigPath(), "utf8")).toBe(before); + assertRoutingUnchanged(); + } else { + // Publication already succeeded. Reverting only live state would send the next turn + // to a different account than the committed selection until the process restarted. + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ activeCodexAccountId: MAIN, + warning: "config_bookkeeping_failed", appliesImmediately: true }); + await expectSelection(live, MAIN); + expect(getThreadAffinity("existing-selection-thread")).toBeUndefined(); + expect(getEffectiveActiveCodexAccountId(live)).toBe(MAIN); + await expectUpstreamIdentity(live, MAIN); + } + }, + ); + + test.each([MAIN, ACCOUNT_A])("unchanged selection of %s remains a successful no-op on disk", async accountId => { + const live = seedConfig({ activeCodexAccountId: accountId, activeCodexAccountPinned: accountId }); + bindThreadAffinity("existing-selection-thread", ACCOUNT_B, Date.now()); + const before = readFileSync(getConfigPath(), "utf8"); + expect((await select(live, accountId)).status).toBe(200); + expect(readFileSync(getConfigPath(), "utf8")).toBe(before); + expect(getThreadAffinity("existing-selection-thread")).toBeUndefined(); + await expectSelection(live, accountId); + await expectUpstreamIdentity(live, accountId); + }); + + test("a newly selected value with no competing writer still reaches its credential", async () => { + const live = seedConfig({ activeCodexAccountId: ACCOUNT_A, activeCodexAccountPinned: ACCOUNT_A }); + expect((await select(live, ACCOUNT_B)).status).toBe(200); + await expectSelection(live, ACCOUNT_B); + await expectUpstreamIdentity(live, ACCOUNT_B); + }); + + test("an unpersisted initial config can still create and select an account", async () => { + const live = getDefaultConfig(); + live.codexAccounts = [{ id: ACCOUNT_A, email: "selection-a@example.test", isMain: false }]; + live.autoSwitchThreshold = 0; + live.codexMainAccountHardLock = false; + expect(existsSync(getConfigPath())).toBe(false); + expect((await select(live, ACCOUNT_A)).status).toBe(200); + await expectSelection(live, ACCOUNT_A); + await expectUpstreamIdentity(live, ACCOUNT_A); + }); + + test("an unpersisted initial config adopts its published selection when bookkeeping fails", async () => { + const live = getDefaultConfig(); + live.codexAccounts = [{ id: ACCOUNT_A, email: "selection-a@example.test", isMain: false }]; + live.autoSwitchThreshold = 0; + live.codexMainAccountHardLock = false; + expect(existsSync(getConfigPath())).toBe(false); + const fault = spyOn(mutationLock, "bumpGenerationForCooperatingConfigWrite").mockImplementation(() => { + throw new Error("Injected initializer bookkeeping failure"); + }); + let response: Response; + try { + response = await select(live, ACCOUNT_A); + expect(fault.mock.calls.length).toBeGreaterThan(0); + } finally { + fault.mockRestore(); + } + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ activeCodexAccountId: ACCOUNT_A, + warning: "config_bookkeeping_failed", appliesImmediately: true }); + await expectSelection(live, ACCOUNT_A); + await expectUpstreamIdentity(live, ACCOUNT_A); + }); + + test("an initially transient config cannot recreate its durable file after a successful selection", async () => { + const live = getDefaultConfig(); + live.codexAccounts = [ACCOUNT_A, ACCOUNT_B].map(id => ({ id, email: `${id}@example.test`, isMain: false })); + live.autoSwitchThreshold = 0; + live.codexMainAccountHardLock = false; + expect((await select(live, ACCOUNT_A)).status).toBe(200); + const assertRoutingUnchanged = captureRouting(live); + unlinkSync(getConfigPath()); + expect((await select(live, ACCOUNT_B)).ok).toBe(false); + expect(existsSync(getConfigPath())).toBe(false); + assertRoutingUnchanged(); + }); + + test("a live config from another home cannot mutate the current home's selection", async () => { + const live = seedConfig(); + const assertRoutingUnchanged = captureRouting(live); + const originalHome = process.env.OPENCODEX_HOME!; + const originalPath = getConfigPath(); + const originalBytes = readFileSync(originalPath, "utf8"); + const otherHome = join(home, "other-ocx"); + mkdirSync(otherHome); + try { + process.env.OPENCODEX_HOME = otherHome; + const otherBytes = JSON.stringify({ ...JSON.parse(originalBytes), + activeCodexAccountId: ACCOUNT_B, activeCodexAccountPinned: ACCOUNT_B }); + writeFileSync(getConfigPath(), otherBytes); + expect((await select(live, MAIN)).ok).toBe(false); + expect(readFileSync(getConfigPath(), "utf8")).toBe(otherBytes); + expect(readFileSync(originalPath, "utf8")).toBe(originalBytes); + } finally { + process.env.OPENCODEX_HOME = originalHome; + } + assertRoutingUnchanged(); + }); + + test.each([ + { label: "null", body: null }, { label: "array", body: [] }, { label: "missing accountId", body: {} }, + { label: "boolean accountId", body: { accountId: false } }, { label: "numeric accountId", body: { accountId: 0 } }, + ])( + "malformed selection body $label cannot clear routing", async ({ body }) => { + const live = seedConfig(); + const assertRoutingUnchanged = captureRouting(live); + const before = readFileSync(getConfigPath(), "utf8"); + const req = new Request(url, { method: "PUT", headers: { "content-type": "application/json" }, + body: JSON.stringify(body) }); + const response = await handleCodexAuthAPI(req, url, live); + expect(response?.status).toBe(400); + assertRoutingUnchanged(); + expect(readFileSync(getConfigPath(), "utf8")).toBe(before); + }, + ); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 1560995bd4d..f02aad8634d 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -346,6 +346,7 @@ "codebuddy-live-acceptance.test.ts": "providers", "codebuddy-mcp-server.test.ts": "providers", "codebuddy-protocol.test.ts": "providers", "codebuddy-tool-bridge-turn.test.ts": "providers", "codebuddy-tool-bridge.test.ts": "providers", "codex-account-delete-atomicity.test.ts": "codex-integration", + "codex-account-selection-atomicity.test.ts": "codex-integration", "codex-account-label.test.ts": "codex-integration", "codex-account-mode-state.test.ts": "gui", "codex-account-namespaces.test.ts": "codex-integration", "codex-account-selection-preferences.test.ts": "codex-integration",