diff --git a/docs-site/src/content/docs/ja/reference/configuration/server.md b/docs-site/src/content/docs/ja/reference/configuration/server.md index f7f1665247e..cb3c56adb68 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/server.md +++ b/docs-site/src/content/docs/ja/reference/configuration/server.md @@ -26,6 +26,7 @@ description: リスナー、リモート アクセス、アドミッション | `serviceHealthRecovery.enabled?` | `boolean` | `false` | バックグラウンドサービス向けのオプトイン復旧。小さな監視プロセスが `/healthz` を確認し、プロキシが生きたまま連続プローブに約 1 分間まったく応答しない場合に終了させ、サービスマネージャー(タスク スケジューラ、launchd、systemd、WinSW)に再起動させます。管理対象サービスの子プロセスでのみ起動し、停止したプロセスが保持していたリクエストは再送しません。1 時間あたり最大 3 回。再起動が必要です。自動復旧には数値のバインドアドレス(または既定の localhost/ワイルドカード マッピング)が必要です。DNS バインド名は監視されません。記録されたアドレスまたはポートが変更されると監視が停止します。 | | `codexAutoStart?` | `boolean` | `true` | Codex を起動する前に、Codex シムで `ocx ensure` を実行させます。 False を指定すると、操作が行われないことが保証されます。 | | `codexShimAutoRestore?` | `boolean` | `true` |完了した外部 Codex アップデートによってインストールされたシムが置き換えられた後、インストールされているシムを復元します。環境オプトアウト: `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0`。 | +| `nativeMainRefresh?` | `"codex" \| "opencodex"` | auto | `CODEX_HOME` 内のネイティブ メイン ChatGPT ログインを更新する主体を選択します。キーリングの応答はその照会だけに適用されます。トークンがなければその認証情報の準備は失敗しますが、次の照会が同じトークンを返しても利用できます。永続的な拒否状態は保持せず、準備済みのリクエストは完了できます。ファイルベースの動作は変わりません。自動選択を含む動作の詳細は[英語版 Server fields](/reference/configuration/server/#server-fields)を参照してください。 | | `syncResumeHistory?` | `boolean` | `true` | Codex App 履歴の互換性を元に戻すことができます。元のメタデータは `ocx stop` / `ocx restore` によってバックアップおよび復元されます。 | | `shadowCallIntercept?` | `{ enabled?: boolean; model?: string; sourceModels?: string[] }` |オフ |認識された Codex ヘルパー/シャドウ呼び出しを、リクエストに設定された推論エフォートを維持したまま選択したモデルにリダイレクトします。デフォルトのソースプレフィックスは `gpt-6-luna`, `gpt-5.6-luna` です。0.144.x 以前のクライアントでは `gpt-5.4-mini` が使われており、`sourceModels` で復元できます。 | | `webSearchSidecar?` | `OcxWebSearchSidecarConfig` |使用可能な場合はオン | Web 検索サイドカー オプション。 | diff --git a/docs-site/src/content/docs/ko/reference/configuration/server.md b/docs-site/src/content/docs/ko/reference/configuration/server.md index 145679df1e4..4a180b9a63f 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/server.md +++ b/docs-site/src/content/docs/ko/reference/configuration/server.md @@ -26,6 +26,7 @@ description: 리스너, 원격 접근, admission 키, 타임아웃, 저장소, | `serviceHealthRecovery.enabled?` | `boolean` | `false` | 백그라운드 서비스용 선택적 복구입니다. 작은 감시 프로세스가 `/healthz`를 확인하고, 프록시가 살아 있지만 연속 프로브에 약 1분 동안 전혀 응답하지 않으면 종료해 서비스 관리자(작업 스케줄러, launchd, systemd, WinSW)가 다시 시작하게 합니다. 관리되는 서비스 자식 프로세스에서만 시작되며, 멈춘 프로세스가 처리 중이던 요청은 재전송하지 않습니다. 시간당 최대 3회. 재시작이 필요합니다. 자동 복구에는 숫자 바인드 주소(또는 기본 localhost/와일드카드 매핑)가 필요합니다. DNS 바인드 이름은 모니터링하지 않습니다. 기록된 주소 또는 포트가 변경되면 모니터링이 중지됩니다. | | `codexAutoStart?` | `boolean` | `true` | Codex shim이 Codex를 실행하기 전에 `ocx ensure`를 돌리도록 허용합니다. `false`이면 ensure는 아무 작업도 하지 않습니다. | | `codexShimAutoRestore?` | `boolean` | `true` | 완료된 외부 Codex 업데이트가 설치된 shim을 교체한 뒤 복원합니다. 환경 변수로 끌 수 있습니다: `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0`. | +| `nativeMainRefresh?` | `"codex" \| "opencodex"` | auto | `CODEX_HOME`의 네이티브 메인 ChatGPT 로그인을 갱신할 주체를 고릅니다. 키링 조회 결과는 해당 조회에만 적용됩니다. 토큰이 없으면 해당 자격 증명 준비는 실패하며, 다음 조회가 같은 토큰을 반환해도 사용할 수 있습니다. 영구 거부 상태는 저장하지 않으며 이미 준비된 요청은 완료할 수 있습니다. 파일 기반 동작은 유지됩니다. 자동 선택을 포함한 전체 동작은 [Server fields(영문)](/reference/configuration/server/#server-fields)를 참조하세요. | | `syncResumeHistory?` | `boolean` | `true` | 되돌릴 수 있는 Codex App history 호환성입니다. 원래 메타데이터는 `ocx stop` / `ocx restore`가 백업하고 복원합니다. | | `shadowCallIntercept?` | `{ enabled?: boolean; model?: string; sourceModels?: string[] }` | off | 인식된 Codex 보조/섀도 호출을 요청에 설정된 reasoning effort를 유지한 채 선택한 모델로 다시 보냅니다. 기본 source prefix는 `gpt-6-luna`, `gpt-5.6-luna`입니다. 0.144.x 이하의 이전 클라이언트는 `gpt-5.4-mini`를 사용했으며 `sourceModels`로 복원할 수 있습니다. | | `webSearchSidecar?` | `OcxWebSearchSidecarConfig` | on when usable | 웹 검색 사이드카 옵션입니다. | diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index ae044e3faa9..2a394d9d697 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -43,7 +43,7 @@ runs helper features around provider requests. | `codexMainAccountHardLock?` | `boolean` | `true` | Block new identity-matched main-account requests at their per-window threshold. Only explicit `false` opts out. See [main-account protection](/reference/cli/providers-accounts/#main-account-quota-protection). | | `codexMainAccountHardLockThresholds?` | `{ short?: number; long?: number }` | `{ short: 90, long: 98 }` | Independent 5h and weekly/monthly thresholds. Integers 80–100, with `short <= long`. Invalid persisted fields fall back to defaults; the settings API rejects invalid writes before mutation. | | `codexMainAccountHardLockFallback?` | `{ enabled?: boolean; model?: string; reasoningEffort?: string }` | off | Extended hard-lock settings: while the lock blocks and no Codex account can serve the request, qualifying native-Codex-model requests reroute to `model` with `reasoningEffort` instead of failing. `enabled: false` pauses the reroute and keeps the model. An absent section or model keeps the refusal behavior; a usable Pool account still serves first. `reasoningEffort` must be a declared effort; invalid persisted sections degrade to off and the settings API rejects invalid writes before mutation. See [main-account protection](/reference/cli/providers-accounts/#main-account-quota-protection). | -| `nativeMainRefresh?` | `"codex" \| "opencodex"` | auto | Who refreshes the native main ChatGPT login in `CODEX_HOME`. `"codex"` asks the Codex binary (app-server `getAuthStatus`, Codex 0.130.0 or newer, found through the same runtime resolution as `ocx sync`, including the ChatGPT app bundle) and never writes `auth.json`; this also works when Codex stores its login in the OS keyring. `"opencodex"` keeps OpenCodex's guarded `auth.json` writer. Unset picks `"codex"` when a supported binary is found; without one, both unset and `"codex"` fall back to the guarded writer. `ocx doctor` shows the active mode under Native main refresh. | +| `nativeMainRefresh?` | `"codex" \| "opencodex"` | auto | Who refreshes the native main ChatGPT login in `CODEX_HOME`. `"codex"` asks the Codex binary (app-server `getAuthStatus`, Codex 0.130.0 or newer, found through the same runtime resolution as `ocx sync`, including the ChatGPT app bundle) and never writes `auth.json`; this also works when Codex stores its login in the OS keyring. When the file is absent, native Pool and stored Direct requests consult the current Codex login, including after restart; logout and account changes take effect at the next credential lookup. Each keyring answer is authoritative for that lookup: a missing or withheld token makes that materialization unavailable, and a later answer may use even the same bearer. No permanent keyring refusal is retained; requests already holding a bearer may finish without a send-time recheck. File-backed credential behavior is unchanged. A verified account change clears the previous account's quota restrictions before selection. On the first lookup after restart, a valid saved quota record must identify a different account before old restrictions are removed. The same account keeps its restrictions, and manual pause settings remain unchanged. Account-card quota reads, startup Pool priming and idle main-quota recovery also use the owned keyring login; passive recovery preserves the traffic reauthentication quarantine. `"opencodex"` keeps OpenCodex's guarded `auth.json` writer. Unset picks `"codex"` when a supported binary is found; without one, both unset and `"codex"` fall back to the guarded writer. `ocx doctor` shows the active mode under Native main refresh. | | `resetCreditAutoRedeem?` | `{ enabled?: boolean; leadTimeMinutes?: number }` | off | Opt-in: redeem the main Codex account's soonest-expiring reset credit `leadTimeMinutes` (1–60, default 10) before it expires. Every attempt re-reads the upstream credit list first and skips when the credit is gone (for example, redeemed by hand); the `redeem_request_id` is journaled in `$OPENCODEX_HOME/reset-credit-auto-redeem.json` before the call so a crash replays the same idempotent request instead of spending a second credit. Servers sharing this configuration directory coordinate reservations and settlements so one process does not replace another's request record. Logs carry a hashed account key only. | | `syncResumeHistory?` | `boolean` | `true` | Reversible Codex App history compatibility. Original metadata is backed up and restored by `ocx stop` / `ocx restore`. | | `spendPoolAliases?` | `Record` | unset | Explicit historical salted pool-alias to canonical provider mappings. Keep this key at the top level, outside `spend`. See [Historical pool continuity](#historical-pool-continuity). | diff --git a/docs-site/src/content/docs/ru/reference/configuration/server.md b/docs-site/src/content/docs/ru/reference/configuration/server.md index d66c298b873..27f00766361 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/server.md +++ b/docs-site/src/content/docs/ru/reference/configuration/server.md @@ -27,6 +27,7 @@ description: Listener, удалённый доступ, admission key, тайм | `serviceHealthRecovery.enabled?` | `boolean` | `false` | Необязательное восстановление фоновой службы: небольшой процесс-наблюдатель опрашивает `/healthz` и, если прокси жив, но около минуты не отвечает на идущие подряд пробы, завершает его, чтобы менеджер службы (Планировщик заданий, launchd, systemd, WinSW) перезапустил его. Запускается только в дочернем процессе управляемой службы; запросы зависшего процесса не повторяются. Не более трёх восстановлений в час. Требуется перезапуск. Для автоматического восстановления требуется числовой адрес привязки (или сопоставление localhost/подстановочного адреса по умолчанию); DNS-имена привязки не отслеживаются. Изменение записанного адреса или порта останавливает наблюдение. | | `codexAutoStart?` | `boolean` | `true` | Разрешает shim'у Codex запускать `ocx ensure` перед стартом Codex. При false `ensure` становится no-op. | | `codexShimAutoRestore?` | `boolean` | `true` | Восстанавливает установленный shim после завершённого внешнего обновления Codex, которое заменило его. Для отключения через окружение: `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0`. | +| `nativeMainRefresh?` | `"codex" \| "opencodex"` | auto | Определяет, кто обновляет основной нативный вход ChatGPT в `CODEX_HOME`. Ответ хранилища ключей действует только для текущего запроса учётных данных: без токена подготовка не выполняется, но следующий ответ может снова разрешить тот же токен. Постоянный отказ не сохраняется; уже подготовленные запросы могут завершиться. Поведение файловых учётных данных не меняется. Полное поведение, включая автоматический выбор, см. в [английской таблице Server fields](/reference/configuration/server/#server-fields). | | `syncResumeHistory?` | `boolean` | `true` | Обратимый режим совместимости истории Codex App. Исходные metadata резервируются и восстанавливаются через `ocx stop` / `ocx restore`. | | `shadowCallIntercept?` | `{ enabled?: boolean; model?: string; sourceModels?: string[] }` | off | Перенаправляет распознанные helper/shadow-call'ы Codex на выбранную модель с сохранением настроенного для запроса reasoning effort. Source-prefix по умолчанию: `gpt-6-luna`, `gpt-5.6-luna`; клиенты до 0.144.x включительно использовали `gpt-5.4-mini`, который можно восстановить через `sourceModels`. | | `webSearchSidecar?` | `OcxWebSearchSidecarConfig` | on when usable | Настройки sidecar'а web-search. | diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/server.md b/docs-site/src/content/docs/zh-cn/reference/configuration/server.md index 821de23773b..dabdc65c6fd 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/server.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/server.md @@ -27,6 +27,7 @@ description: 监听、远程访问、准入密钥、超时、存储、侧车、 | `serviceHealthRecovery.enabled?` | `boolean` | `false` | 后台服务的可选恢复:一个小型守护进程探测 `/healthz`,当代理进程仍存活但在连续探测中约一分钟毫无响应时将其终止,由服务管理器(任务计划程序、launchd、systemd、WinSW)重新启动。仅受管服务子进程会启动它;卡住进程持有的请求不会重放。每小时最多 3 次。需要重启。自动恢复需要数字绑定地址(或默认的 localhost/通配符映射);不监控 DNS 绑定名称。记录的地址或端口发生更改时会停止监控。 | | `codexAutoStart?` | `boolean` | `true` | 允许 Codex shim 在启动 Codex 之前运行 `ocx ensure`。设为 false 会让 ensure 变成无操作。 | | `codexShimAutoRestore?` | `boolean` | `true` | 在完成外部 Codex 更新并覆盖安装的 shim 之后恢复该 shim。环境退出开关:`OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0`。 | +| `nativeMainRefresh?` | `"codex" \| "opencodex"` | auto | 选择由谁刷新 `CODEX_HOME` 中的原生主 ChatGPT 登录。密钥环应答只对本次查询有效:缺失或被扣留的令牌使本次凭据准备失败;后续查询即使返回相同令牌也可以使用。不保留永久拒绝状态,已取得令牌的请求可继续完成。文件凭据行为保持不变。包括自动选择在内的完整行为见[英文 Server fields](/reference/configuration/server/#server-fields)。 | | `syncResumeHistory?` | `boolean` | `true` | 可逆的 Codex App 历史兼容性。原始元数据会被备份,并由 `ocx stop` / `ocx restore` 恢复。 | | `shadowCallIntercept?` | `{ enabled?: boolean; model?: string; sourceModels?: string[] }` | off | 将识别出的 Codex 辅助/影子调用重定向到选定模型,并保留为请求配置的推理强度。默认源前缀为 `gpt-6-luna`, `gpt-5.6-luna`;0.144.x 及更早客户端使用 `gpt-5.4-mini`,可通过 `sourceModels` 恢复。 | | `webSearchSidecar?` | `OcxWebSearchSidecarConfig` | 在可用时启用 | Web 搜索侧车选项。 | diff --git a/src/codex/account-lifecycle.ts b/src/codex/account-lifecycle.ts index b150355cc08..44000f2bc03 100644 --- a/src/codex/account-lifecycle.ts +++ b/src/codex/account-lifecycle.ts @@ -12,7 +12,7 @@ import { removeCodexAccountCredential } from "./account-store"; import { clearAccountNeedsReauth } from "./account-runtime-state"; import { readMainAuth } from "./auth-collision"; import { MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "./main-account"; -import { clearAccountQuota } from "./quota"; +import { clearAccountQuota, discardMismatchedMainQuota } from "./quota"; import { forgetManualPreference } from "./routing/active-account"; import { deleteAllHealthForAccount } from "./routing/health-store"; import { forgetCodexFailureWindow } from "./routing/failure-window"; @@ -20,7 +20,7 @@ import { clearThreadAccountMapForAccount } from "./routing/thread-affinity"; import { clearCodexPoolRefreshFailure } from "./pool-refresh-backoff"; import { invalidateCodexWebSocketsForAccount } from "./websocket-registry"; -import { clearMainAccountCredentialPresence, clearMainAccountInfoCache, observeMainQuotaCredential, observeMainQuotaIdentity } from "./main-account-cache"; +import { clearMainAccountCredentialPresence, clearMainAccountInfoCache, observeMainQuotaCredential, observeMainQuotaIdentity, type MainQuotaWriter } from "./main-account-cache"; import { extractAccountId, extractAccountIdClaims } from "../oauth/chatgpt"; import { forgetCodexAccountPause } from "./account-pause"; import { forgetCodexAccountCreditUse } from "./account-credit-use"; @@ -73,6 +73,28 @@ function purgeMainCodexAccountRuntimeState(): void { invalidateCodexWebSocketsForAccount(MAIN_CODEX_ACCOUNT_ID); } +function observeMainCodexAccountIdentity(accountId: string): boolean { + const previousAccountId = observedMainChatgptAccountId; + observedMainChatgptAccountId = accountId; + const changed = previousAccountId !== undefined && previousAccountId !== accountId; + if (changed) purgeMainCodexAccountRuntimeState(); + observeMainQuotaIdentity(accountId); + discardMismatchedMainQuota(); + return changed; +} + +/** Only the pinned-home, exclusively claimed Codex RPC may supply this credential. */ +export function observeCodexOwnedMainAccountCredential(accessToken: string): MainQuotaWriter | undefined { + const identity = extractAccountIdClaims(accessToken); + // An opaque or conflicting answer cannot retire a previously verified identity or its policy. + if (!identity.accountId || identity.conflict) return undefined; + observeMainCodexAccountIdentity(identity.accountId); + const tokens = { access_token: accessToken, account_id: identity.accountId }; + observeSignedInCodexIdentity(tokens); + observeMainCodexCollisionIdentity(tokens); + return observeMainQuotaCredential(accessToken, identity.accountId); +} + /** * The main Codex login is stored under the stable `__main__` alias, while * `~/.codex/auth.json` can be replaced with credentials for another physical @@ -89,18 +111,9 @@ export function reconcileMainCodexAccountRuntimeState(): boolean { // A missing/malformed auth.json is an unknown identity, not a confirmed account switch. Keep the // prior observation and its safety state until a real account id can be read again. if (currentAccountId === null) return false; - const previousAccountId = observedMainChatgptAccountId; - observedMainChatgptAccountId = currentAccountId; - if (previousAccountId === undefined || previousAccountId === currentAccountId) { - observeMainQuotaIdentity(currentAccountId); - if (read.status === "ok") observeMainCodexCollisionIdentity(read.tokens); - return false; - } - - purgeMainCodexAccountRuntimeState(); - observeMainQuotaIdentity(currentAccountId); + const changed = observeMainCodexAccountIdentity(currentAccountId); if (read.status === "ok") observeMainCodexCollisionIdentity(read.tokens); - return true; + return changed; } /** @@ -134,6 +147,7 @@ export function initializeMainAccountPolicyBinding(authPath: string): boolean { if (previousAccountId !== undefined && previousAccountId !== accountId) purgeMainCodexAccountRuntimeState(); observeSignedInCodexIdentity(tokens); observeMainQuotaIdentity(accountId); + discardMismatchedMainQuota(); observeMainCodexCollisionIdentity(tokens); return observeMainQuotaCredential(tokens.access_token, accountId) !== undefined; } diff --git a/src/codex/auth-api/main-account-probe.ts b/src/codex/auth-api/main-account-probe.ts index d3aebe4da53..1ed67345060 100644 --- a/src/codex/auth-api/main-account-probe.ts +++ b/src/codex/auth-api/main-account-probe.ts @@ -1,3 +1,5 @@ +import { existsSync } from "node:fs"; +import { join } from "node:path"; import { parseCodexCredits, rememberCodexCredits } from "../credits"; import { fetchCodexUsage } from "../quota-query-backoff"; import type { CodexUsageOwner } from "../quota-query-backoff"; @@ -11,8 +13,9 @@ import type { StoredAccountQuota, WhamUsageResponse } from "../quota"; import { reconcileMainCodexAccountRuntimeState } from "../account-lifecycle"; import { getMainChatgptAccountId, readCodexTokensResult } from "../auth-collision"; import { clearAccountNeedsReauth, markAccountNeedsReauth } from "../account-runtime-state"; -import { extractAccountId } from "../../oauth/chatgpt"; -import { getMainAccountPlan, isMainAccountTokenVerifiablyLive, MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "../main-account"; +import { decodeJwtPayload, extractAccountId } from "../../oauth/chatgpt"; +import { getMainAccountPlan, getValidMainAccountToken, isMainAccountTokenVerifiablyLive, mainAccountTokenIsCurrent, mainAccountTokenQuotaWriter, MainAccountTokenRefreshError, MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan, type MainAccountToken } from "../main-account"; +import { resolveCodexHomeDir } from "../home"; import { captureConfigGeneration } from "../../lib/state-store-sweeper"; import { captureMainAccountIdentityGeneration, clearMainAccountInfoCache, getMainAccountInfoCache, getMainQuotaCredentialGeneration, isMainAccountIdentityGenerationLive, isMainQuotaWriterLive, matchesMainQuotaCredential, observeMainQuotaCredential, setMainAccountCredentialPresence, setMainAccountInfoCache } from "../main-account-cache"; import type { MainQuotaWriter, MainAccountInfo } from "../main-account-cache"; @@ -149,10 +152,19 @@ export async function fetchMainAccountInfo(forceRefresh = false, config?: OcxCon export interface MainAccountInfoProbeOptions { /** Metadata-only renewal never sets or clears the traffic reauth quarantine. */ passive?: boolean; + /** Exact owned result obtained before entering WHAM's shared credential claim. Never serialized. */ + preparedToken?: MainAccountToken | null; } export const EMPTY_MAIN_ACCOUNT_INFO: MainAccountInfo = { email: null, plan: null, quota: null }; +/** Caller holds the runtime lease, but no shared credential claim: Codex may need an exclusive claim. */ +export async function prepareMainAccountInfoToken(): Promise { + const home = resolveCodexHomeDir(); + if (!existsSync(home) || existsSync(join(home, "auth.json"))) return undefined; + return getValidMainAccountToken({ preserveReauth: true }); +} + export async function retryMainAccountInfoIfIdentityChanged( requestAccountId: string | null, retriesRemaining: number, @@ -189,9 +201,23 @@ export async function fetchMainAccountInfoAttempt( }; } try { + let preparedToken = options.preparedToken; + if (!nativeMainSharedClaimHeld && preparedToken === undefined) { + const home = resolveCodexHomeDir(); + const generation = captureMainAccountIdentityGeneration(); + try { preparedToken = await prepareMainAccountInfoToken(); } + catch (error) { + const absent = error instanceof MainAccountTokenRefreshError && (error.reason === "reauth" || error.credentialObservedAbsent) + && home === resolveCodexHomeDir() && isMainAccountIdentityGenerationLive(generation); + if (absent) setMainAccountCredentialPresence(false); + return { info: getMainAccountInfoCache() ?? EMPTY_MAIN_ACCOUNT_INFO, + credentialChecked: absent, hasCredential: false, identityGeneration: captureMainAccountIdentityGeneration() }; + } + } + const probeOptions = preparedToken === undefined ? options : { ...options, preparedToken }; const paced = sharedMainQuotaPacingEnabled(config ?? loadConfig()); const operation = async () => ({ - ...await fetchMainAccountInfoWhileOwned(forceRefresh, retriesRemaining, nativeMainLease, explicitRefresh, postReset, paced, options), + ...await fetchMainAccountInfoWhileOwned(forceRefresh, retriesRemaining, nativeMainLease, explicitRefresh, postReset, paced, probeOptions), identityGeneration: captureMainAccountIdentityGeneration(), }); if (nativeMainSharedClaimHeld) return await operation(); @@ -235,20 +261,28 @@ export async function fetchMainAccountInfoWhileOwned( options: MainAccountInfoProbeOptions = {}, ): Promise { const writerGeneration = captureConfigGeneration(); - reconcileMainCodexAccountRuntimeState(); const tokenRead = readCodexTokensResult(); - setMainAccountCredentialPresence(tokenRead.status === "ok"); - if (tokenRead.status !== "ok") { + if (tokenRead.status === "missing" && options.preparedToken && !mainAccountTokenIsCurrent(options.preparedToken)) { + // A stale/copy handoff proves nothing about the current login, including its presence. + return { info: getMainAccountInfoCache() ?? EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: false, hasCredential: false }; + } + const owned = tokenRead.status === "missing" && options.preparedToken + && mainAccountTokenIsCurrent(options.preparedToken) ? options.preparedToken : undefined; + // The owned RPC already reconciled identity; a missing file must not erase that observation. + if (!owned) reconcileMainCodexAccountRuntimeState(); + const tokens = tokenRead.status === "ok" ? tokenRead.tokens : owned + ? { access_token: owned.accessToken, account_id: owned.chatgptAccountId, id_token: undefined } : null; + setMainAccountCredentialPresence(tokens !== null); + if (!tokens) { // A local read failure is NOT proof of sign-out: a missing file can be a non-atomic rewrite // gap, and malformed JSON can be a half-written file. Clearing the cache and marking the // account for reauth here destroyed healthy email/plan/quota state and pinned a working // account as unusable. Preserve what we already know and let the caller retry; request - // routing stays fail-closed because getMainAccountToken() re-reads the file itself, and the - // account DTO still reports hasCredential=false while the file is unreadable. + // routing stays fail-closed through its owned credential resolver, and the account DTO + // reports hasCredential=false without either a readable file or a verified keyring handoff. const preserved = getMainAccountInfoCache(); return { info: preserved ?? EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: false }; } - const tokens = tokenRead.tokens; const requestAccountId = extractAccountId(tokens.id_token, tokens.access_token) ?? (tokens.account_id || null); const cached = getMainAccountInfoCache(); if (!forceRefresh && cached && Date.now() - cached.ts < MAIN_CACHE_TTL) { @@ -256,13 +290,15 @@ export async function fetchMainAccountInfoWhileOwned( } // Bind quota to the owned credential and the account actually selected by WHAM's header. // A conflicting legacy token/account tuple is not evidence for the new policy. - const mainQuotaWriter = requestAccountId === tokens.account_id + const mainQuotaWriter = owned ? mainAccountTokenQuotaWriter(owned) : requestAccountId === tokens.account_id ? observeMainQuotaCredential(tokens.access_token, tokens.account_id) : undefined; const mainQuotaCredentialGeneration = getMainQuotaCredentialGeneration(); /** A disk replacement may have no second probe to advance the credential generation. */ const credentialIsCurrent = (): boolean => { const current = readCodexTokensResult(undefined, { bounded: true }); + if (owned) return current.status === "missing" && mainAccountTokenIsCurrent(owned) + && mainQuotaCredentialGeneration === getMainQuotaCredentialGeneration(); if (current.status !== "ok") return false; const effectiveAccountId = extractAccountId(current.tokens.id_token, current.tokens.access_token) ?? (current.tokens.account_id || null); @@ -313,7 +349,10 @@ export async function fetchMainAccountInfoWhileOwned( const resp = admission.response; quotaPhase = "publish"; if (!resp.ok) { - const authFailure = await classifyMainAuthResponse(resp, isMainAccountTokenVerifiablyLive()); + const ownedExpiry = owned ? decodeJwtPayload(owned.accessToken)?.exp : undefined; + const authFailure = await classifyMainAuthResponse(resp, owned + ? typeof ownedExpiry === "number" && ownedExpiry * 1000 > Date.now() + : isMainAccountTokenVerifiablyLive()); const terminalAuthFailure = authFailure.terminal; const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease, explicitRefresh, paced, options); if (retried) return retried; diff --git a/src/codex/auth-api/pool-mode-gate.ts b/src/codex/auth-api/pool-mode-gate.ts index 7907a1e6c14..d3660025a7b 100644 --- a/src/codex/auth-api/pool-mode-gate.ts +++ b/src/codex/auth-api/pool-mode-gate.ts @@ -10,7 +10,7 @@ import { reconcileMainCodexAccountRuntimeState } from "../account-lifecycle"; import { claimDueCodexQuotaRecoveryProbes, settleCodexQuotaRecoveryProbe } from "../routing"; import { readCodexTokens } from "../auth-collision"; import { isAccountNeedsReauth } from "../account-runtime-state"; -import { getValidMainAccountToken, MAIN_CODEX_ACCOUNT_ID, getMainAccountPlan } from "../main-account"; +import { getValidMainAccountToken, mainAccountTokenIsCurrent, mainAccountTokenQuotaWriter, MAIN_CODEX_ACCOUNT_ID, getMainAccountPlan } from "../main-account"; import { captureConfigGeneration, registerStateSweepAfterTick } from "../../lib/state-store-sweeper"; import { observeMainQuotaCredential, getMainQuotaCredentialGeneration, captureMainAccountIdentityGeneration, isMainAccountIdentityGenerationLive } from "../main-account-cache"; import { getMainAccountHardLockStatus, latestMainBlockingResetAt } from "../main-account-hard-lock"; @@ -22,7 +22,7 @@ import type { AdmissionLease } from "../../lib/admission"; import { tryAcquireNativeMainProfileClaim } from "../native-main-admission"; import { withNativeMainCredentialClaim, isNativeMainClaimUnavailable } from "./http"; import type { PoolQuotaResult } from "./pool-quota-probe"; -import { fetchMainAccountInfoAttempt, fetchMainAccountInfo, MAIN_CACHE_TTL } from "./main-account-probe"; +import { fetchMainAccountInfoAttempt, fetchMainAccountInfo, prepareMainAccountInfoToken, MAIN_CACHE_TTL } from "./main-account-probe"; import { fetchPoolAccountQuota, PoolQuotaProbeBusyError, POOL_CACHE_TTL, POOL_QUOTA_REFRESH_CONCURRENCY } from "./pool-quota-probe"; import { getRuntimeConfig, configuredPoolAccount, mapWithConcurrency } from "./runtime-config"; import { trackBackgroundWork } from "../../lib/background-work"; @@ -183,12 +183,15 @@ export async function runMainAccountHardLockRecovery(config: OcxConfig): Promise || isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)) return; const identityGeneration = captureMainAccountIdentityGeneration(); const passive = getMainAccountHardLockStatus(config).state !== "blocked"; + let prepared: Awaited>; try { // Refresh can require an exclusive credential claim: never hold WHAM's shared // claim while obtaining a valid token. The runtime lease spans both operations. - const prepared = await getValidMainAccountToken({ preserveReauth: true }); + prepared = await getValidMainAccountToken({ preserveReauth: true }); if (!prepared) return; - observeMainQuotaCredential(prepared.accessToken, prepared.chatgptAccountId); + if (mainAccountTokenQuotaWriter(prepared)) { + if (!mainAccountTokenIsCurrent(prepared)) return; + } else observeMainQuotaCredential(prepared.accessToken, prepared.chatgptAccountId); } catch { // Native refresh retains its own grant-scoped refusal; global quarantine would outlive it. return; @@ -214,7 +217,7 @@ export async function runMainAccountHardLockRecovery(config: OcxConfig): Promise after: currentQueryAfter }; return; } - const result = await fetchMainAccountInfoAttempt(true, 1, lease, false, false, false, config, { passive }); + const result = await fetchMainAccountInfoAttempt(true, 1, lease, false, false, false, config, { passive, preparedToken: prepared }); // Never charge a replacement credential for a late result from its predecessor. if (isMainAccountIdentityGenerationLive(identityGeneration) && credential === getMainQuotaCredentialGeneration()) { @@ -359,21 +362,26 @@ export async function primeCodexPoolQuotas( if (!mainLease) return; try { try { + // The Codex RPC owns an exclusive claim; finish it before WHAM's shared claim. + const preparedToken = await prepareMainAccountInfoToken(); await withNativeMainCredentialClaim(async () => { // Keep one local owner and one cross-process reader from physical // identity reconciliation through WHAM and all quota publication. - (options.reconcileMainAccount ?? reconcileMainCodexAccountRuntimeState)(); + if (preparedToken) { + if (!mainAccountTokenIsCurrent(preparedToken)) return; + } else (options.reconcileMainAccount ?? reconcileMainCodexAccountRuntimeState)(); const quota = getAccountQuota(MAIN_CODEX_ACCOUNT_ID); const observationStale = reason === "priority-failback" && quota && !codexQuotaHasFreshUsage(quota, getMainAccountPlan(), Date.now(), CODEX_PRIORITY_FAILBACK_REFRESH_MS); if (quota && (reason !== "priority-failback" || (!observationStale && Date.now() - quota.updatedAt < MAIN_CACHE_TTL))) return; - if (!(options.readMainTokens ?? readCodexTokens)()) return; + if (!(options.readMainTokens ?? readCodexTokens)() + && !(preparedToken && mainAccountTokenIsCurrent(preparedToken))) return; const bypassCachedQuota = !!observationStale; if (options.fetchMainInfo) await options.fetchMainInfo(bypassCachedQuota); // Cache bypass is passive observation, never an explicit reauthentication recovery. else await fetchMainAccountInfoAttempt(bypassCachedQuota, 1, mainLease, true, - false, false, config); + false, false, config, { preparedToken }); }); } catch (error) { if (!isNativeMainClaimUnavailable(error)) throw error; diff --git a/src/codex/auth-context.ts b/src/codex/auth-context.ts index 8a9f2d6b444..660e4f61026 100644 --- a/src/codex/auth-context.ts +++ b/src/codex/auth-context.ts @@ -26,7 +26,9 @@ import { MainAccountTokenRefreshError, MainAuthJsonChangedDuringRefreshError, getMainAccountToken, + getMainAccountCredentialStatus, getValidMainAccountToken, + mainAccountTokenQuotaWriter, isMainAccountTokenLive, type NativeMainRefreshDependencies, } from "./main-account"; @@ -360,8 +362,7 @@ export function codexTransientProbeGrant(ctx: CodexAuthContext | undefined): Tra } /** - * Hand back a probe lease for a request that will not reach upstream. Safe to - * call with a context that holds no lease. + * Hand back a probe lease for a request that will not reach upstream. Safe to call with a context that holds no lease. * * BOTH leases, deliberately. A context can carry the quota-cooldown probe or the transient-hold * probe, and every one of the ~30 call sites that already hands back the first is a path where @@ -570,8 +571,7 @@ export class CodexMainAccountCreditsOffError extends CodexAccountCooldownError { readonly resetAt?: number; constructor(resetAt?: number, creditRecovery = false) { - // Keep the included reset as policy evidence, but let consented callers retry - // within the base metadata recovery interval even when backoff grows longer. + // Keep the included reset as policy evidence, but let consented callers retry within the base metadata recovery interval even when backoff grows longer. super(MAIN_CODEX_ACCOUNT_ID, creditRecovery ? Math.min(resetAt ?? Infinity, Date.now() + nextQuotaQueryDelay()) : resetAt ?? 0); this.name = "CodexMainAccountCreditsOffError"; @@ -904,6 +904,10 @@ function observeSelectedMainCredential( token: { accessToken: string; chatgptAccountId: string }, writer: MainQuotaWriter | undefined, ): MainQuotaWriter | undefined { + // A claimed RPC can observe a replacement identity while the caller awaits it. Only that + // exact result carries the replacement writer; stale results keep their stale fence. + const ownedWriter = mainAccountTokenQuotaWriter(token); + if (ownedWriter) return ownedWriter; if (!writer) return undefined; // Carry an explicitly stale writer through to quota's rejection fence; turning it into an // untagged write would instead invalidate the replacement account's trusted observation. @@ -1249,6 +1253,14 @@ async function resolveCodexAuthContextUnannotated( throw new CodexMainProfileDrainingError(); } if (isMainAccountHardLockEnabled(policy)) reconcileMainCodexAccountRuntimeState(); + if (!getMainAccountToken()) { + try { + await (options.getValidMainAccountToken ?? getValidMainAccountToken)({ + signal: options.signal, ...(options.nativeMainRefreshDependencies ?? {}), + }); + } catch (cause) { throw new CodexAuthContextError(MAIN_CODEX_ACCOUNT_ID, cause); } + options.signal?.throwIfAborted(); + } assertMainAccountPolicy(policy); if (options.modelId && ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(options.modelId)) { const entitled = entitledCodexAccountIdsForModel( @@ -1333,6 +1345,7 @@ async function resolveCodexAuthContextUnannotated( const nativeMainSelectionOnly = !nativeMainTrafficBlocked && selectionAdmission?.mainProfileDraining === true; let accountId: string; + let mainDiscoveryFailure: unknown; // Answering this with the manual-pin predicate made `codexAccountUnusableReason` report // `main_credential_unavailable` for every UNPINNED request, so `getEligiblePoolAccounts` never // listed main and the strategy compared only the stored accounts. With one stored sibling the @@ -1349,6 +1362,20 @@ async function resolveCodexAuthContextUnannotated( && callerOwnsAnyCooledPoolSubscription(headers, config, quotaScope), }); try { + // A fileless login can change identity even while its previous expiry observation is usable. + // The selector discovers it before identity-bound quota or hard-lock policy; dispatch still revalidates it. + if (selectionAdmission && !nativeMainReadsForbidden && !options.isMainAccountTokenLive + && (fixedAccountId === undefined || fixedAccountId === MAIN_CODEX_ACCOUNT_ID) + && options.excludeAccountId !== MAIN_CODEX_ACCOUNT_ID + && !policy.pausedCodexAccountIds?.includes(MAIN_CODEX_ACCOUNT_ID) + && (!getMainAccountToken() || !getMainAccountCredentialStatus().usable)) { + try { + await (options.getValidMainAccountToken ?? getValidMainAccountToken)({ + signal: options.signal, ...(options.nativeMainRefreshDependencies ?? {}), + }); + } catch (cause) { mainDiscoveryFailure = cause; } + options.signal?.throwIfAborted(); + } const excludeAccountIds = nativeMainReadsForbidden ? new Set([MAIN_CODEX_ACCOUNT_ID]) : undefined; @@ -1461,6 +1488,11 @@ async function resolveCodexAuthContextUnannotated( return context; } if (!selected) { + // After an entitlement-await recovery, current selection evidence outranks the saved + // probe failure; retain it only while main is still unusable (as for fixed selection). + if (mainDiscoveryFailure !== undefined && !getMainAccountCredentialStatus().usable) { + throw new CodexAuthContextError(MAIN_CODEX_ACCOUNT_ID, mainDiscoveryFailure); + } // A retry that excluded a failed Pool account may still use the validated caller-owned // main credential. Treating every exclusion as if main itself had failed strands a healthy // native bearer after the first Pool attempt. Preserve the exactly-once boundary by refusing @@ -1544,6 +1576,11 @@ async function resolveCodexAuthContextUnannotated( ); } if (fixedAccountId !== undefined) { + // Preserve exact-main discovery errors only while the current credential remains unusable, as above. + if (accountId === MAIN_CODEX_ACCOUNT_ID && mainDiscoveryFailure !== undefined + && !getMainAccountCredentialStatus().usable) { + throw new CodexAuthContextError(MAIN_CODEX_ACCOUNT_ID, mainDiscoveryFailure); + } if (policy.pausedCodexAccountIds?.includes(accountId)) { throw new CodexPoolAuthenticationError("Selected Codex account is unavailable"); } diff --git a/src/codex/main-account.ts b/src/codex/main-account.ts index f45c6ccd500..44f9b2ce6ce 100644 --- a/src/codex/main-account.ts +++ b/src/codex/main-account.ts @@ -1,9 +1,9 @@ -import { observeMainDecisionCredentialUsable } from "./main-account-cache"; +import { captureMainAccountIdentityGeneration, getMainQuotaCredentialGeneration, isMainAccountIdentityGenerationLive, isMainQuotaWriterLive, matchesMainQuotaCredential, observeMainDecisionCredentialUsable, type MainQuotaWriter } from "./main-account-cache"; import { classifyChatgptRefreshFailure, noteChatgptRefreshFailure } from "./chatgpt-refresh-failure"; import { readBoundedResponseBody } from "../lib/bounded-body"; import { createHash } from "node:crypto"; -import { readFileSync, statSync } from "node:fs"; -import { join } from "node:path"; +import { lstatSync, readFileSync, statSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; import { readCodexTokens } from "./auth-collision"; import { CHATGPT_CLIENT_ID, @@ -31,6 +31,7 @@ import { import { advanceCodexCredentialMutationEpoch } from "./credential-mutation-epoch"; import { withNativeMainExclusiveClaim } from "./native-main-claim"; import { resolveNativeProfileContext } from "./native-profile-store"; +import { NativeProfileError } from "./native-profile-types"; import { isNativeMainTrafficBlocked } from "./native-profile-startup"; import { loadConfig } from "../config"; import { @@ -50,6 +51,29 @@ let mainAccountPlan: string | null = null; let jwtPlanAttempted = false; const MAIN_TOKEN_REFRESH_SKEW_MS = 60_000; let beforeMainAuthJsonRenameForTests: (() => void) | null = null; +let codexOwnedMainCredential: { home: string; expiresAt: number } | null = null; +export type MainAccountToken = Readonly<{ accessToken: string; chatgptAccountId: string }>; +const codexOwnedQuotaWriters = new WeakMap(); + +/** Exact RPC result provenance; copied/request-created tokens cannot recapture native quota ownership. */ +export function mainAccountTokenQuotaWriter(token: MainAccountToken): MainQuotaWriter | undefined { + return codexOwnedQuotaWriters.get(token)?.writer; +} + +/** A metadata probe may use only the exact owned observation while its home and credential remain current. */ +export function mainAccountTokenIsCurrent(token: MainAccountToken): boolean { + const proof = codexOwnedQuotaWriters.get(token); + return proof !== undefined && proof.home === resolveCodexHomeDir() + && codexOwnedMainCredential?.home === proof.home + && codexOwnedMainCredential.expiresAt > Date.now() + && proof.credentialGeneration === getMainQuotaCredentialGeneration() + && isMainQuotaWriterLive(proof.writer) + && matchesMainQuotaCredential(token.accessToken, token.chatgptAccountId); +} + +function forgetCodexOwnedMainUsability(home: string): void { + if (codexOwnedMainCredential?.home === home) codexOwnedMainCredential = null; +} type MainAuthJsonCredential = { path: string; @@ -86,11 +110,13 @@ export class MainAuthJsonChangedDuringRefreshError extends Error { } export class MainAccountTokenRefreshError extends Error { - constructor(readonly reason: "reauth" | "transient", options?: ErrorOptions) { + readonly credentialObservedAbsent: boolean; + constructor(readonly reason: "reauth" | "transient", options?: ErrorOptions & { credentialObservedAbsent?: boolean }) { super(reason === "reauth" ? "Codex main account needs reauthentication" : "Codex main token refresh did not complete", options); this.name = "MainAccountTokenRefreshError"; + this.credentialObservedAbsent = options?.credentialObservedAbsent === true; } } @@ -247,9 +273,12 @@ export interface MainAccountCredentialStatus { /** One owned physical observation; no credential material escapes this view. */ export function getMainAccountCredentialStatus(now = Date.now()): MainAccountCredentialStatus { const current = readMainAuthJsonCredential(); + const owned = !current && codexOwnedMainCredential?.home === resolveCodexHomeDir() ? codexOwnedMainCredential : null; const refreshGrantRejected = credentialRefreshGrantRejected(current); const hasRefreshGrant = !!current?.refreshToken && !refreshGrantRejected; - const usable = !refreshGrantRejected && (hasRefreshGrant || mainAccessTokenFresh(current?.accessToken, now, 0)); + const ownedUsable = !!owned && owned.expiresAt > now; + const usable = !refreshGrantRejected + && (hasRefreshGrant || mainAccessTokenFresh(current?.accessToken, now, 0) || ownedUsable); observeMainDecisionCredentialUsable(usable); return { refreshGrantRejected, @@ -450,17 +479,83 @@ export function beginNativeMainReauth(): { }; } +/** + * True only when CODEX_HOME provably does not exist: the resolved path itself + * has no directory entry and the nearest existing ancestor resolves to a real + * directory. resolve() normalizes the string without dereferencing links, so + * lstat sees each component itself -- an existing entry still counts as + * existing, and a link in the chain must stat to a directory before the absence + * below it counts as genuine. EACCES, a file where a directory belongs, and a + * dangling link all stay unproven, which keeps the caller fail closed. + */ +function codexHomeProvenMissing(codexHome: string): boolean { + const target = resolve(codexHome); + let cursor = target; + for (;;) { + try { + lstatSync(cursor); + break; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") return false; + const parent = dirname(cursor); + if (parent === cursor) return false; + cursor = parent; + } + } + if (cursor === target) return false; + try { + return statSync(cursor).isDirectory(); + } catch { + return false; + } +} + /** Resolve the main account access token from the owned auth.json read, refreshing through the token endpoint when needed, or return null when no usable credential exists. */ async function resolveMainAccountToken( dependencies: NativeMainRefreshDependencies = {}, rejectedAccessToken?: string, ): Promise<{ accessToken: string; chatgptAccountId: string } | null> { if (dependencies.signal?.aborted) throw dependencies.signal.reason; + const codexHome = resolveCodexHomeDir(); const initial = readMainAuthJsonCredential(); // Every skip below ends a forced refresh WITHOUT asking the token endpoint anything, which from // the outside is indistinguishable from a refusal: same failed request, no write, no verdict. // A forced refresh is the only caller that passes `rejectedAccessToken`, so only it reports. if (!initial) { + const refreshMode = await resolveNativeMainRefreshMode(loadConfig()); + if (refreshMode.mode === "codex") { + const refreshTimeout = AbortSignal.timeout(30_000); + const signal = dependencies.signal + ? AbortSignal.any([dependencies.signal, refreshTimeout]) + : refreshTimeout; + try { + return await withNativeMainExclusiveClaim(resolveNativeProfileContext({ codexHome }), () => refreshThroughCodex( + null, rejectedAccessToken, dependencies, refreshTimeout, + dependencies.codexAuthStatus ?? (options => requestCodexAuthStatus(refreshMode.command!, { ...options, codexHome })), + codexHome, rejectedAccessToken !== undefined, + ), { waitMs: 30_000, signal }); + } catch (cause) { + forgetCodexOwnedMainUsability(codexHome); + // A proven-absent home is the same observed absence as a missing + // auth.json: no credential exists to claim or to refresh. Every other + // CODEX_HOME_UNAVAILABLE cause -- an entry that exists but cannot be + // verified, a dangling link, an untraversable parent -- stays fail + // closed; a blanket existsSync check would misclassify them as absence. + if (cause instanceof NativeProfileError && cause.code === "CODEX_HOME_UNAVAILABLE" + && codexHomeProvenMissing(codexHome)) { + // Mode discovery awaited: absence must still belong to this caller and home. + dependencies.signal?.throwIfAborted(); + if (resolveCodexHomeDir() !== codexHome) throw new MainAuthJsonChangedDuringRefreshError(); + if (rejectedAccessToken !== undefined) noteNativeMainRefresh("skipped-no-credential"); + return null; + } + if (refreshTimeout.aborted && !dependencies.signal?.aborted) { + throw new MainAccountTokenRefreshError("transient", { cause }); + } + throw cause; + } + } + forgetCodexOwnedMainUsability(codexHome); if (rejectedAccessToken !== undefined) noteNativeMainRefresh("skipped-no-credential"); return null; } @@ -507,8 +602,9 @@ async function resolveMainAccountToken( // locks and then reaching for the same claim would deadlock. try { return await withNativeMainExclusiveClaim( - resolveNativeProfileContext(), + resolveNativeProfileContext({ codexHome }), () => withCodexRefreshFileLock(lockKey, signal, async () => { + if (resolveCodexHomeDir() !== codexHome) throw new MainAuthJsonChangedDuringRefreshError(); const locked = readMainAuthJsonCredential(); if (!locked) throw new MainAuthJsonChangedDuringRefreshError(); if (credentialRefreshGrantRejected(locked)) throw new MainAccountTokenRefreshError("reauth"); @@ -527,7 +623,7 @@ async function resolveMainAccountToken( if (refreshMode?.mode === "codex") { const command = refreshMode.command!; return refreshThroughCodex(locked, rejectedAccessToken, dependencies, refreshTimeout, - dependencies.codexAuthStatus ?? (options => requestCodexAuthStatus(command, options))); + dependencies.codexAuthStatus ?? (options => requestCodexAuthStatus(command, { ...options, codexHome })), codexHome); } const refresh = dependencies.refreshToken ?? refreshNativeMainGrant; let refreshed: OAuthCredentials; @@ -609,32 +705,62 @@ function inFlightMainAccountToken( * keyring save succeeds, so the RPC answer is the only place the new token exists for us. * The call runs on the refresh timeout alone: killing app-server mid-exchange could retire * the grant before Codex saves its successor. + * + * Returns null when an ordinary read (no locked file credential, nothing upstream rejected) + * observes that Codex has no ChatGPT login at all -- an observed absence, not a refresh failure. */ async function refreshThroughCodex( - locked: MainAuthJsonCredential, + locked: MainAuthJsonCredential | null, rejectedAccessToken: string | undefined, dependencies: NativeMainRefreshDependencies, refreshTimeout: AbortSignal, authStatus: NonNullable, -): Promise<{ accessToken: string; chatgptAccountId: string }> { + codexHome: string, + refresh = true, +): Promise<{ accessToken: string; chatgptAccountId: string } | null> { + // Deferred to keep lifecycle's cache/plan cleanup off this module's load-time import cycle. + const { observeCodexOwnedMainAccountCredential } = await import("./account-lifecycle"); + if (resolveCodexHomeDir() !== codexHome) throw new MainAuthJsonChangedDuringRefreshError(); + const identityGeneration = captureMainAccountIdentityGeneration(); + // This observation is only selection evidence. A later request must ask Codex again: + // JWT expiry cannot detect logout or an external keyring account switch. + forgetCodexOwnedMainUsability(codexHome); let status: CodexAuthStatus; + let didRefresh = refresh; try { - status = await authStatus({ refresh: true, signal: refreshTimeout }); + const readSignal = dependencies.signal ? AbortSignal.any([dependencies.signal, refreshTimeout]) : refreshTimeout; + status = await authStatus({ refresh, signal: refresh ? refreshTimeout : readSignal }); + if (!refresh && status.authMethod === "chatgpt" && status.authToken + && !mainAccessTokenFresh(status.authToken, Date.now(), MAIN_TOKEN_REFRESH_SKEW_MS)) { + dependencies.signal?.throwIfAborted(); + didRefresh = true; + status = await authStatus({ refresh: true, signal: refreshTimeout }); + } } catch (cause) { + if (!refresh && dependencies.signal?.aborted) throw dependencies.signal.reason; noteNativeMainRefresh("codex-unavailable"); throw new MainAccountTokenRefreshError("transient", { cause }); } + if (resolveCodexHomeDir() !== codexHome || !isMainAccountIdentityGenerationLive(identityGeneration)) { + throw new MainAuthJsonChangedDuringRefreshError(); + } if (status.authMethod === "chatgpt" && !status.authToken) { - // The only permanent-failure signal the response carries: Codex withholds the token once - // its refresh was refused for good. The specific OAuth code is not on the wire. + // A keyring answer is authoritative for this lookup only. Keep the existing file-backed + // grant rejection, but retain no keyring refusal that could override a later RPC answer. noteNativeMainRefresh("codex-reauth"); - markMainRefreshGrantRejected(mainRefreshGrantKey(locked)!); - throw new MainAccountTokenRefreshError("reauth"); + const grantKey = locked && mainRefreshGrantKey(locked); + if (grantKey) markMainRefreshGrantRejected(grantKey); + throw new MainAccountTokenRefreshError("reauth", { credentialObservedAbsent: true }); } const token = status.authToken; if (status.authMethod !== "chatgpt" || !token) { noteNativeMainRefresh("codex-no-chatgpt-login"); - throw new MainAccountTokenRefreshError("transient"); + // An ordinary read with no locked credential and nothing rejected observed a real + // absence, not a refresh failure: report no credential instead of a retryable error. + // Once the expiring-bearer answer above promoted this lookup into a real refresh, the + // same no-login outcome is a refresh failure again. + if (locked === null && rejectedAccessToken === undefined && !didRefresh) return null; + throw new MainAccountTokenRefreshError("transient", { credentialObservedAbsent: true }); } // A transient Codex refresh failure still answers with its cached bearer. if (token === rejectedAccessToken || !mainAccessTokenFresh(token, Date.now(), 0)) { @@ -642,9 +768,17 @@ async function refreshThroughCodex( throw new MainAccountTokenRefreshError("transient"); } noteNativeMainRefresh("codex-ok"); - if (dependencies.preserveReauth !== true) clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); if (dependencies.signal?.aborted) throw dependencies.signal.reason; - return { accessToken: token, chatgptAccountId: extractAccountId(undefined, token) ?? locked.chatgptAccountId }; + const result = Object.freeze({ + accessToken: token, + chatgptAccountId: extractAccountId(undefined, token) ?? locked?.chatgptAccountId ?? "", + }); + const writer = observeCodexOwnedMainAccountCredential(token); + if (writer) codexOwnedQuotaWriters.set(result, { writer, home: codexHome, credentialGeneration: getMainQuotaCredentialGeneration() }); + if (writer && dependencies.preserveReauth !== true) clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); + const exp = decodeJwtPayload(token)?.exp; + codexOwnedMainCredential = { home: codexHome, expiresAt: typeof exp === "number" ? exp * 1000 : Infinity }; + return result; } /** Refresh the CLI-owned native credential before upstream I/O and publish it atomically. */ diff --git a/src/codex/quota.ts b/src/codex/quota.ts index 5eebba7782b..fe2bbbc10ba 100644 --- a/src/codex/quota.ts +++ b/src/codex/quota.ts @@ -827,6 +827,15 @@ export function getMainPolicyQuota(): StoredAccountQuota | null { return structuredClone(mainPolicyQuota.quota); } +/** An owned identity observation may retire legacy main quota only when its retained policy tag proves replacement. */ +export function discardMismatchedMainQuota(): void { + hydrateAccountQuotasFromDisk(); + const observedIdentity = getObservedMainQuotaIdentityKey(); + // Untagged/malformed snapshots and a matching identity retain their existing restrictions. + if (observedIdentity !== undefined && mainPolicyQuota + && mainPolicyQuota.identityKey !== observedIdentity) clearAccountQuota(MAIN_CODEX_ACCOUNT_ID); +} + export function listAccountQuotas(): IterableIterator<[string, StoredAccountQuota]> { hydrateAccountQuotasFromDisk(); return accountQuota.entries(); diff --git a/src/server/audio-upstream.ts b/src/server/audio-upstream.ts index 5eee3620aed..076dbee2649 100644 --- a/src/server/audio-upstream.ts +++ b/src/server/audio-upstream.ts @@ -1,15 +1,18 @@ import { formatErrorResponse } from "../bridge"; import { CodexAccountCooldownError, + CodexAuthContextError, CodexMainProfileDrainingError, codexMainProfileDrainingResponse, cooldownErrorResponse, isCodexAuthContextUsable, - materializeCodexUpstreamAuth, + materializeCodexUpstreamAuthAsync, releaseCodexAuthContextProbeLease, resolveCodexAuthContext, type CodexAuthContext, } from "../codex/auth-context"; +import { MAIN_CODEX_ACCOUNT_ID, MainAccountTokenRefreshError, MainAuthJsonChangedDuringRefreshError } from "../codex/main-account"; +import { NativeProfileError } from "../codex/native-profile-types"; import { formatCodexProviderForLog } from "../codex/routing"; import type { AdmissionLease } from "../lib/admission"; import { captureExplicitOpenAiCallerAuth, resolveFirstUsableOpenAiSidecar, selectOpenAiImagesProvider } from "../providers/openai-sidecar"; @@ -25,6 +28,7 @@ import { import { codexAccountSelectionForTurn } from "./lifecycle"; import type { RequestLogContext } from "./request-log"; import { authorizationCredential } from "../lib/authorization-scheme"; +import { nativeMainRefreshFailureResponse } from "./responses/codex-auth-error"; export const TRANSCRIPTION_MODEL = "gpt-4o-transcribe"; export const LIVE_AUDIO_MODEL = "gpt-live-1-codex"; @@ -103,10 +107,11 @@ export async function resolveAudioUpstream( signal: options.signal, }); options.signal?.throwIfAborted(); - selected = materializeCodexUpstreamAuth(headers, context, { + selected = await materializeCodexUpstreamAuthAsync(headers, context, { config, admission: options.admission, substituteMainCredential: true, + signal: options.signal, }); } else { const resolved = await resolveFirstUsableOpenAiSidecar(candidates.forwardCandidates, headers, config, { @@ -172,6 +177,9 @@ export async function resolveAudioUpstream( releaseCodexAuthContextProbeLease(context); if (error instanceof CodexAccountCooldownError) return cooldownErrorResponse(error); if (error instanceof CodexMainProfileDrainingError) return codexMainProfileDrainingResponse(); + const mainCause = error instanceof CodexAuthContextError && error.accountId === MAIN_CODEX_ACCOUNT_ID ? error.cause : error; + if (mainCause instanceof MainAccountTokenRefreshError || mainCause instanceof MainAuthJsonChangedDuringRefreshError + || (mainCause instanceof NativeProfileError && mainCause.retryable)) return nativeMainRefreshFailureResponse(mainCause); return formatErrorResponse(401, "authentication_error", "Audio account authentication unavailable; reconnect the selected OpenAI account"); } } diff --git a/src/server/responses/compact.ts b/src/server/responses/compact.ts index f9d687e1e09..49c0db6e688 100644 --- a/src/server/responses/compact.ts +++ b/src/server/responses/compact.ts @@ -80,6 +80,7 @@ import { } from "../../codex/auth-context"; import { forceRefreshMainAccountToken, + mainAccountTokenQuotaWriter, type NativeMainRefreshDependencies, } from "../../codex/main-account"; import { @@ -371,6 +372,7 @@ async function refreshNativeMainCompactContext(args: { ...authCtx, accessToken: refreshed.accessToken, chatgptAccountId: refreshed.chatgptAccountId, + mainQuotaWriter: mainAccountTokenQuotaWriter(refreshed) ?? authCtx.mainQuotaWriter, }; const refreshedProvider = applyCodexAuthContextToProvider( stripCodexRuntimeProviderFields(provider), diff --git a/src/server/responses/core-auth.ts b/src/server/responses/core-auth.ts index 2b60fe00814..6f3990d4cc7 100644 --- a/src/server/responses/core-auth.ts +++ b/src/server/responses/core-auth.ts @@ -42,7 +42,7 @@ import type { RequestLogContext } from "../request-log"; import { markLocalRequestLogRefusal } from "../request-log"; import { CODEX_POOL_REFRESH_INCOMPLETE_LOG_REASON } from "../../codex/pool-refresh-backoff"; import { codexAuthContextLogLabel } from "../../codex/account-label"; -import { forceRefreshMainAccountToken } from "../../codex/main-account"; +import { forceRefreshMainAccountToken, mainAccountTokenQuotaWriter } from "../../codex/main-account"; import { rebindPoolCreditPolicy } from "../../codex/pool-credit-policy"; import { safeCallerSessionId } from "../caller-session-identity"; import { copyResponsesClientMetadata } from "../../adapters/openai-responses/transport"; @@ -578,6 +578,7 @@ export async function refreshNativeMainForwardAuth(args: { ...authCtx, accessToken: refreshed.accessToken, chatgptAccountId: refreshed.chatgptAccountId, + mainQuotaWriter: mainAccountTokenQuotaWriter(refreshed) ?? authCtx.mainQuotaWriter, }; const provider = applyCodexAuthContextToProvider( stripCodexRuntimeProviderFields(route.provider), diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index e6dd2008d94..24f050c6df6 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -24,9 +24,9 @@ is scoped to canonical ChatGPT Responses forwarding; other source-area behavior `src/server/audio-transcriptions.ts` owns `POST /v1/audio/transcriptions`, independently of Responses and Chat conversion. `src/server/audio-upstream.ts` resolves explicit data-plane keys on both listeners and substitutes stored OpenAI credentials. Direct stored-main access claims -the enclosing admission lease and derives its account header only from that stored credential; +the enclosing admission lease, asynchronously resolves the current file or Codex-owned keyring credential under the request signal, and derives its account header only from that credential; caller-supplied account selection is never retained. Pool uses the existing sidecar account resolver. A selected -ChatGPT authentication failure never falls through to the paid OpenAI provider. +ChatGPT authentication failure never falls through to the paid OpenAI provider; transient native-main lookup failures retain the shared retryable 503 response. The bounded multipart input accepts one nonempty file up to 25,000,000 bytes within a 32 MiB body, required model, and optional prompt, language and JSON/text response format. Unknown or diff --git a/structure/providers/openai-accounts.md b/structure/providers/openai-accounts.md index 929c66ec71f..0e9a8c37985 100644 --- a/structure/providers/openai-accounts.md +++ b/structure/providers/openai-accounts.md @@ -304,20 +304,20 @@ grant rather than rotating the existing grant and keeps its pre-publication fenc ## Native-main refresh ownership -`src/codex/native-main-refresh-mode.ts` decides who refreshes the native main -login (#2999). `nativeMainRefresh: "codex"`, the default whenever the Codex -runtime resolved by `src/codex/runtime.ts` is 0.130.0 or newer, starts a -short-lived `codex -c model_provider="openai" app-server` under the same -CODEX_HOME and calls `getAuthStatus` with `includeToken` and `refreshToken`. -OpenCodex uses the returned `authToken` for the request and writes nothing; it -does not read auth.json again afterwards, because Codex in keyring mode deletes -the file. A `chatgpt` method with a null token is the only permanent-failure -signal on the wire and marks the grant rejected (reauth). An unreachable binary, -a non-ChatGPT method, or the same or an expired bearer is transient. The -exchange runs on the refresh timeout alone, under the same claim and grant lock -as the writer. `"opencodex"`, a missing binary, or an older binary keeps the -guarded writer above. While the test home guard is armed, auto resolution -never spawns Codex. `ocx doctor` prints the active mode and its reason. +`src/codex/native-main-refresh-mode.ts` selects the refresh owner. Auto uses Codex 0.130.0 or newer, resolved by `src/codex/runtime.ts`. +Codex mode starts a short-lived `codex -c model_provider="openai" app-server` and calls `getAuthStatus` with `includeToken` and `refreshToken`. +OpenCodex uses the returned bearer for that request, writes nothing and does not reread auth.json after refresh: keyring mode deletes it. + +With no file credential, every lookup reads the current login with `refreshToken: false`; an expiring or explicitly rejected bearer requests refresh. +Only home-scoped expiry metadata survives the lookup. Logout, account switches and unavailable reads cannot reuse an earlier bearer; a weak association gives the exact immutable RPC result its owned quota writer, home and credential generation without retaining the result or token. +Each keyring RPC answer is authoritative for its lookup: a withheld token makes that materialization unavailable, while a later answer can use even the same bearer. No keyring refusal registry, bearer fingerprint history or permanent refusal survives; already-materialized requests can finish without a physical-send recheck. File-backed grant rejection stays unchanged. +An unreachable binary, unchanged rejected bearer or expired bearer is transient. A non-ChatGPT or missing login answers an ordinary lookup as an observed absence, while a rejected-token or file-locked refresh still reports it transiently. Read-only lookups honor caller cancellation. +Actual refresh runs on its independent 30-second timeout so Codex can save its grant. Both paths pin CODEX_HOME and hold its exclusive claim; home and identity-generation changes reject a delayed observation. +Only file-backed refresh has a local grant fingerprint lock; keyring claim deadlines are transient. `"opencodex"`, missing or old Codex keeps the guarded writer. + +Fileless Pool and stored-Direct requests discover the current identity before selection or quota policy, even when prior expiry metadata is usable or the previous account is hard-locked; the selected token is revalidated before dispatch. +A discovery failure is retained as the request's own classified cause for an empty candidacy and for the exact `__main__` selector alike, rather than degrading to a blanket unavailability refusal. It stays evidence only while the credential remains unverifiable: a credential recovered while model-entitlement resolution is in flight is re-checked live, so an already-healthy login answers with the current selection outcome instead of the stale probe error. `src/codex/account-lifecycle.ts` applies the same identity transition to a validated owned RPC as to an owned file read: a changed account purges prior runtime/quota state and retires its writers, while unchanged or ambiguous identity preserves its quota policy and ambiguous identity cannot clear reauth quarantine. On the first owned observation after restart, `src/codex/quota.ts` discards legacy main quota only when a valid retained policy tag proves a different account; matching, missing or invalid tags retain their restrictions, and other accounts remain untouched. Startup, drain, caller-owned credential and Pool manual-pause fences remain; async stored-Direct materialization, including audio, uses the same resolver, while synchronous readers remain file-only. +Account-card metadata, initial Pool quota priming and idle main-quota recovery also use the owned keyring login. `src/codex/auth-api/main-account-probe.ts` obtains an absent-file credential under the runtime lease before taking WHAM's shared claim; recovery reuses its exact prepared result. The handoff must still match the current home, identity writer, bearer HMAC and credential generation, and auth.json must remain absent. Copies, credential ABA, a changed home, a later file credential and stale responses cannot publish quota or reset-recovery proof; an invalid handoff cannot overwrite current credential presence. Verified RPC identity is retained instead of erased by a missing-file reconciliation. A confirmed current-home logout/refusal reports credential absence, while transport uncertainty preserves the prior observation; request error classification is unchanged. Passive preparation and WHAM reads preserve traffic quarantine and manual pause; explicit refresh retains its existing recovery meaning. `tests/codex-integration/main-account-credit-renewal.test.ts` and `tests/codex-integration/native-main-refresh-mode.test.ts` cover these boundaries. Armed test-home guards prevent auto-spawn; `ocx doctor` prints the active mode and reason. ## Preserve signed-in quota diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index b32d4db3a1f..816a1e9106c 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -317,8 +317,7 @@ default-on main-account hard lock retains its separate local admission policy. R warmup remains conservative and does not spend paid credits to validate an exhausted account. Stored-account refusals in `src/codex/auth-context.ts` preserve the included-window `resetAt`. For opted-in accounts with stale, missing or incomplete credits, `src/codex/pool-credit-policy.ts` supplies a retry hint at the earlier of that reset and the 300-second base cadence from `src/codex/quota-query-backoff.ts`; the latest live account-wide or selected-scope cooldown takes precedence, without importing unrelated model-group holds. Numeric and HTTP-date Retry-After input retains the existing 24-hour parser ceiling. -HTTP remains 429 and admission stays closed. Formatting never triggers a query, clears cooldown or switches accounts; query pacing remains independent. Opt-out, legacy null, known spending/overage vetoes and finite nonpositive balances without unlimited entitlement retain reset-bound advice as evidence ages. Parser-proven unavailable null carries `creditObservationUnavailable: true` through merges and persistence; replacement clears that provenance. It changes retry advice only and never authorizes spending. The same live-policy classification applies after token preparation, at materialization/dispatch and through `src/providers/openai-sidecar-credit.ts`. Main policy and ordinary upstream 429 formatting are unchanged. -Coverage: `tests/codex-integration/codex-credit-retry-after.test.ts` and `tests/codex-integration/codex-credit-scoped-retry.test.ts`. +HTTP remains 429 and admission stays closed. Formatting never triggers a query, clears cooldown or switches accounts; query pacing remains independent. Opt-out, legacy null, known spending/overage vetoes and finite nonpositive balances without unlimited entitlement retain reset-bound advice as evidence ages. Parser-proven unavailable null carries `creditObservationUnavailable: true` through merges and persistence; replacement clears that provenance. It changes retry advice only and never authorizes spending. The same live-policy classification applies after token preparation, at materialization/dispatch and through `src/providers/openai-sidecar-credit.ts`. Main policy and ordinary upstream 429 formatting are unchanged. Coverage: `tests/codex-integration/codex-credit-retry-after.test.ts` and `tests/codex-integration/codex-credit-scoped-retry.test.ts`. For an opted-in, unpaused main account with a currently full usage window, the existing `src/codex/auth-api/pool-mode-gate.ts` recovery sweep renews credit observations from three minutes of age, leaving time for token preparation before the five-minute freshness limit. Missing, zero, @@ -337,6 +336,7 @@ identity retries: WHAM success and terminal 401/403 responses never set or clear this mode. Native token preparation also preserves the traffic quarantine. Other callers retain the existing auth behavior. Failed or incomplete observations never renew the credit clock; refreshing usage does not redeem reset credits or validate pending accounts through inference. +For keyring-only main, the exact owned token is handed from exclusive preparation to the shared WHAM read with home, identity, credential-generation and bearer checks; no second RPC runs under the shared claim. Stale or copied handoffs and late responses cannot replace quota or revoke a newer login's quarantine. Coverage: `tests/codex-integration/main-account-credit-renewal.test.ts`. Stored accounts, including explicit namespace targets in Direct mode, use the separate [pool credit renewal](pool-credit-renewal.md) contract; their fresh spendable-evidence requirement is identical. Credit parsing, expiry, partial updates and reset-ticket separation are covered in @@ -444,7 +444,7 @@ consent is covered by `tests/codex-integration/main-account-credit-renewal.test. The policy reads a separately retained identity-tagged quota snapshot, so the legacy rotation cache's six-hour expiry does not silently release a known block. A confirmed account transition -invalidates old evidence. Request-owned bearers are matched only against a credential and effective +invalidates old evidence, including an account change verified through [the owned Codex RPC](openai-accounts.md#native-main-refresh-ownership). A valid persisted policy tag for another account also retires the legacy main selection quota at the first owned observation after restart; same-account and untagged quota remain. Request-owned bearers are matched only against a credential and effective workspace already observed under native ownership; an unrelated or unmatched keyring credential is not attributed to stored main and introduces no physical-main read. Credential equality tags remain process-local and never enter disk, logs, or management DTOs. diff --git a/structure/runtime.md b/structure/runtime.md index de02393c164..7efff1e8f22 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -152,7 +152,7 @@ there. Feature code is grouped by responsibility: while `index/serve-options.ts` owns route ordering; `responses.ts` and `responses/core.ts` compose Responses handling from the owners inventoried in [Responses transport](transports/responses.md), and `responses/compact.ts` and `responses/compaction-retention.ts` own v1/v2 compaction under the [routed reasoning-retention contract](transports/responses-failover.md#compaction-routing-overrides) implemented by `src/responses/reasoning-retention.ts`; `images.ts` owns the standalone Images relay; -`responses/codex-auth-error.ts` owns the shared Responses/compact Codex auth-context HTTP mapping. +`responses/codex-auth-error.ts` owns the shared Responses/compact Codex auth-context HTTP mapping and audio's native-main refresh refusals. Model entitlement denial is a 400 request error and temporary exhaustion of every model-capable account is a retryable 429; neither is reported as an invalid API key. Images, Live, and Search reuse that model-availability mapping while retaining their existing credential handling. Account diff --git a/tests/codex-integration/main-account-credit-renewal.test.ts b/tests/codex-integration/main-account-credit-renewal.test.ts index 10a26d01edc..3eb70aff3ea 100644 --- a/tests/codex-integration/main-account-credit-renewal.test.ts +++ b/tests/codex-integration/main-account-credit-renewal.test.ts @@ -1,20 +1,22 @@ import { afterEach, beforeEach, expect, mock, spyOn, test } from "bun:test"; -import { mkdtempSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { CodexMainAccountCreditsOffError, resolveCodexAuthContext } from "../../src/codex/auth-context"; import { fetchMainAccountInfoAttempt } from "../../src/codex/auth-api/main-account-probe"; -import { registerCodexCooldownRecoveryProbeWorker, runMainAccountHardLockRecovery } from "../../src/codex/auth-api/pool-mode-gate"; +import { clearCodexQuotaPrimeState, primeCodexPoolQuotas, registerCodexCooldownRecoveryProbeWorker, runMainAccountHardLockRecovery } from "../../src/codex/auth-api/pool-mode-gate"; import { MAIN_CODEX_ACCOUNT_ID as MAIN } from "../../src/codex/account-id"; import { reconcileMainCodexAccountRuntimeState, resetMainCodexAccountIdentityTrackingForTests } from "../../src/codex/account-lifecycle"; import { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth } from "../../src/codex/account-runtime-state"; -import { captureMainQuotaWriter, clearMainAccountInfoCache, getMainAccountInfoCache, isMainAccountIdentityGenerationLive, observeMainQuotaCredential, setMainAccountInfoCache } from "../../src/codex/main-account-cache"; +import { captureMainQuotaWriter, clearMainAccountInfoCache, getMainAccountCredentialPresence, getMainAccountInfoCache, isMainAccountIdentityGenerationLive, isMainQuotaWriterLive, observeMainQuotaCredential, setMainAccountCredentialPresence, setMainAccountInfoCache } from "../../src/codex/main-account-cache"; +import { getObservedMainCodexCollisionIdentity } from "../../src/codex/collision-identity"; import { clearAccountQuota, getAccountQuota, getMainPolicyQuota, setAccountQuotaFromParsed } from "../../src/codex/quota"; import { hasSpendableCodexCredits, type CodexSpendableCredits } from "../../src/codex/quota-types"; import { resetQuotaQueryBackoffForTests } from "../../src/codex/quota-query-backoff"; import { clearCodexUpstreamHealth } from "../../src/codex/routing"; import { flushConfigDirHardeningForTests } from "../../src/config/paths"; import * as mainAccount from "../../src/codex/main-account"; +import * as nativeRefresh from "../../src/codex/native-main-refresh-mode"; import * as sweeper from "../../src/lib/state-store-sweeper"; import * as quotaQueries from "../../src/codex/quota-query-backoff"; import { setAsyncIcaclsRunnerForTests, setIcaclsRunnerForTests } from "../../src/lib/windows-secret-acl"; @@ -130,6 +132,8 @@ beforeEach(() => { afterEach(async () => { mock.restore(); + nativeRefresh.setNativeMainRefreshResolutionForTests(null); + clearCodexQuotaPrimeState(); globalThis.fetch = previousFetch; clearAccountQuota(); clearAccountNeedsReauth(MAIN); @@ -149,6 +153,142 @@ afterEach(async () => { removeTreeWithRetry(home); }); +function keyringLogin(id = accountId) { + rmSync(join(home, "auth.json")); + nativeRefresh.setNativeMainRefreshResolutionForTests({ + mode: "codex", configured: "auto", reason: "codex-compatible", command: "/fixture/codex", version: "0.162.1", + }); + return spyOn(nativeRefresh, "requestCodexAuthStatus").mockImplementation(async () => ({ + authMethod: "chatgpt", authToken: bearer(id), requiresOpenaiAuth: true, + })); +} + +test("keyring-only metadata probing discovers the owned credential and publishes quota", async () => { + clearAccountQuota(MAIN); + const auth = keyringLogin("fixture-keyring"); + fetchWith(async (_url, init) => { + const headers = new Headers(init?.headers); + expect(headers.get("chatgpt-account-id")).toBe("fixture-keyring"); + expect(headers.get("authorization")).toBe(`Bearer ${bearer("fixture-keyring")}`); + return usage(); + }); + const result = await fetchMainAccountInfoAttempt(true, 1, undefined, false, true, false, cfg); + expect(result).toMatchObject({ credentialChecked: true, hasCredential: true, quotaRefresh: { status: "ok" } }); + expect(result.info.plan).toBe("plus"); + expect(getMainPolicyQuota()?.weeklyPercent).toBe(100); + expect(getObservedMainCodexCollisionIdentity()?.accountId).toBe("fixture-keyring"); + expect(auth).toHaveBeenCalledTimes(1); + expect(calls).toEqual([whamUrl]); + expect(existsSync(join(home, "auth.json"))).toBe(false); + expect(getNativeMainProfileRequestCount()).toBe(0); +}); + +test("keyring-only credit renewal reuses its prepared credential for WHAM", async () => { + const auth = keyringLogin(); + await runMainAccountHardLockRecovery(cfg); + expect(calls).toEqual([whamUrl]); + expect(auth).toHaveBeenCalledTimes(1); + expect(getMainPolicyQuota()?.credits?.observedAt).toBe(now); + expect(getNativeMainProfileRequestCount()).toBe(0); +}); + +test("keyring-only Pool priming observes quota before any traffic", async () => { + clearAccountQuota(MAIN); + const auth = keyringLogin(); + await primeCodexPoolQuotas(cfg, "startup"); + expect(calls).toEqual([whamUrl]); + expect(auth).toHaveBeenCalledTimes(1); + expect(getMainPolicyQuota()?.credits?.observedAt).toBe(now); + expect(getObservedMainCodexCollisionIdentity()?.accountId).toBe(accountId); + expect(getNativeMainProfileRequestCount()).toBe(0); +}); + +test("keyring-only passive metadata preserves traffic quarantine and manual pause", async () => { + keyringLogin(); + markAccountNeedsReauth(MAIN); + cfg.pausedCodexAccountIds = [MAIN]; + const result = await fetchMainAccountInfoAttempt(true, 1, undefined, false, true, false, cfg, { passive: true }); + expect(result).toMatchObject({ credentialChecked: true, hasCredential: true, quotaRefresh: { status: "ok" } }); + expect(isAccountNeedsReauth(MAIN)).toBe(true); + expect(cfg.pausedCodexAccountIds).toEqual([MAIN]); + expect(getNativeMainProfileRequestCount()).toBe(0); +}); + +test.each(["copy", "credential-aba", "home"])("keyring-only stale handoff cannot overwrite credential presence (%s)", async change => { + const auth = keyringLogin(); + const prepared = (await mainAccount.getValidMainAccountToken({ preserveReauth: true }))!; + expect(mainAccount.mainAccountTokenIsCurrent(prepared)).toBe(true); + setMainAccountCredentialPresence(true); + let candidate = prepared; + if (change === "copy") candidate = { ...prepared }; + if (change === "credential-aba") { + observeMainQuotaCredential(bearer(accountId, true), accountId); + observeMainQuotaCredential(prepared.accessToken, prepared.chatgptAccountId); + } + if (change === "home") { + process.env.CODEX_HOME = join(home, "replacement-home"); + mkdirSync(process.env.CODEX_HOME); + } + expect(mainAccount.mainAccountTokenIsCurrent(candidate)).toBe(false); + const before = getMainPolicyQuota(); + const result = await fetchMainAccountInfoAttempt(true, 0, undefined, false, true, false, cfg, { preparedToken: candidate }); + expect(result).toMatchObject({ credentialChecked: false, hasCredential: false }); + expect(getMainAccountCredentialPresence()).toBe(true); + expect(getMainPolicyQuota()).toEqual(before); + expect(calls).toEqual([]); + expect(auth).toHaveBeenCalledTimes(1); +}); + +test("keyring-only late usage cannot publish or clear a replacement login's quarantine", async () => { + keyringLogin(); + const entered = deferred(), response = deferred(); + fetchWith(async () => { entered.resolve(); return response.promise; }); + const pending = fetchMainAccountInfoAttempt(true, 0, undefined, false, true, false, cfg); + await entered.promise; + const oldWriter = captureMainQuotaWriter(accountId)!; + writeMain("fixture-replacement"); + setAccountQuotaFromParsed(MAIN, { weeklyPercent: 7 }, undefined, captureMainQuotaWriter("fixture-replacement")); + markAccountNeedsReauth(MAIN); + const before = getMainPolicyQuota(); + response.resolve(usage()); + const result = await pending; + expect(isMainQuotaWriterLive(oldWriter)).toBe(false); + expect(result.freshQuota).toBeUndefined(); + expect(result.resetRecoveryProof).toBeUndefined(); + expect(getMainPolicyQuota()).toEqual(before); + expect(isAccountNeedsReauth(MAIN)).toBe(true); + expect(getNativeMainProfileRequestCount()).toBe(0); +}); + +test("keyring-only live bearer treats an unclassified WHAM 401 as transient", async () => { + keyringLogin(); + fetchWith(async () => Response.json({}, { status: 401 })); + const result = await fetchMainAccountInfoAttempt(true, 0, undefined, false, false, false, cfg); + expect(result.quotaRefresh).toMatchObject({ status: "http_error", httpStatus: 401 }); + expect(result.terminalAuthFailure).toBeUndefined(); + expect(isAccountNeedsReauth(MAIN)).toBe(false); +}); + +test.each(["unavailable", "refused", "logged-out"])("keyring-only credential observation distinguishes absence from failure (%s)", async outcome => { + const auth = keyringLogin(); + await mainAccount.getValidMainAccountToken({ preserveReauth: true }); + setMainAccountCredentialPresence(true); + markAccountNeedsReauth(MAIN); + const before = getMainPolicyQuota(); + auth.mockImplementation(async () => { + if (outcome === "unavailable") throw new Error("fixture RPC unavailable"); + return { authMethod: outcome === "refused" ? "chatgpt" : null, authToken: null, requiresOpenaiAuth: true }; + }); + const result = await fetchMainAccountInfoAttempt(true, 0, undefined, false, false, false, cfg, { passive: true }); + expect(result.credentialChecked).toBe(outcome !== "unavailable"); + expect(result.hasCredential).toBe(false); + expect(getMainAccountCredentialPresence()).toBe(outcome === "unavailable"); + expect(getMainPolicyQuota()).toEqual(before); + expect(isAccountNeedsReauth(MAIN)).toBe(true); + expect(calls).toEqual([]); + expect(getNativeMainProfileRequestCount()).toBe(0); +}); + test("without renewal, five-minute credit expiry produces a local 429", async () => { seed({ hasCredits: true, balance: 5, observedAt: now }); await expect(resolveCodexAuthContext(new Headers(), cfg, "pool")).resolves.toMatchObject({ kind: "main-pool" }); diff --git a/tests/codex-integration/main-quota-provenance.test.ts b/tests/codex-integration/main-quota-provenance.test.ts index bb227bcd716..af5ae6339e2 100644 --- a/tests/codex-integration/main-quota-provenance.test.ts +++ b/tests/codex-integration/main-quota-provenance.test.ts @@ -46,6 +46,11 @@ const QUOTA_PROVENANCE_IMPORT_PROLOGUE = ` import { getAccountQuota, getMainPolicyQuota } from ${JSON.stringify(repoPath("src/codex/quota.ts"))}; import { observeMainQuotaIdentity, matchesMainQuotaCredential } from ${JSON.stringify(repoPath("src/codex/main-account-cache.ts"))}; `; +const KEYRING_QUOTA_RESTART_IMPORTS = `${QUOTA_PROVENANCE_IMPORT_PROLOGUE} + import { resolveCodexAuthContext } from ${JSON.stringify(repoPath("src/codex/auth-context.ts"))}; + import { initializeMainAccountPolicyBinding } from ${JSON.stringify(repoPath("src/codex/account-lifecycle.ts"))}; + import { setNativeMainRefreshResolutionForTests } from ${JSON.stringify(repoPath("src/codex/native-main-refresh-mode.ts"))}; +`; let testDir: string; let previousHome: string | undefined; @@ -341,8 +346,84 @@ describe("main policy quota durability and lifecycle", () => { source: QUOTA_PROVENANCE_IMPORT_PROLOGUE, cwd: repoRoot(), }); + await warmModuleGraph({ graph: "codex/keyring-quota-restart-eval", source: KEYRING_QUOTA_RESTART_IMPORTS, cwd: repoRoot() }); }, COLD_SPAWN_WARMUP_HOOK_BUDGET_MS); + for (const scenario of [ + { name: "verified replacement", account: "fixture-main-b", tag: "known", selected: true }, + { name: "same account", account: "fixture-main-a", tag: "known", selected: false }, + { name: "untagged quota", account: "fixture-main-b", tag: "missing", selected: false }, + { name: "invalid quota tag", account: "fixture-main-b", tag: "invalid", selected: false }, + { name: "unknown RPC identity", account: "", tag: "known", selected: false }, + { name: "manually paused", account: "fixture-main-b", tag: "known", selected: false, paused: true }, + { name: "startup file replacement", account: "fixture-main-b", tag: "known", selected: true, source: "file" }, + { name: "startup same-account file", account: "fixture-main-a", tag: "known", selected: false, source: "file" }, + ]) { + test(`cold keyring Pool preserves quota unless replacement is proven: ${scenario.name}`, () => { + const writer = writerFor("fixture-main-a"); + setAccountQuotaFromParsed(MAIN, { weeklyPercent: 100, weeklyResetAt: Date.now() / 1000 + 3600 }, undefined, writer); + setAccountQuotaFromParsed("fixture-stored", { weeklyPercent: 33 }); + const snapshot = JSON.parse(flushPersistence()); + if (scenario.tag === "missing") delete snapshot.mainPolicyQuota; + if (scenario.tag === "invalid") snapshot.mainPolicyQuota.identityKey = "invalid-fixture-identity"; + writeSnapshot(snapshot); + const script = ` + ${KEYRING_QUOTA_RESTART_IMPORTS} + globalThis.fetch = async () => { throw new Error("unexpected fixture network request"); }; + setNativeMainRefreshResolutionForTests({ mode: "codex", configured: "auto", reason: "codex-compatible", command: "/fixture/codex", version: "0.162.1" }); + const before = getAccountQuota("__main__"); + const account = ${JSON.stringify(scenario.account)}; + const token = account ? "header." + Buffer.from(JSON.stringify({ exp: Math.floor(Date.now() / 1000) + 3600, + "https://api.openai.com/auth": { chatgpt_account_id: account } })).toString("base64url") + ".fixture" : "opaque-fixture-token"; + const fileOwned = ${JSON.stringify(scenario.source === "file")}; + let initialized; + if (fileOwned) { + const authPath = process.env.CODEX_HOME + "/auth.json"; + await Bun.write(authPath, JSON.stringify({ tokens: { access_token: token, account_id: account } })); + initialized = initializeMainAccountPolicyBinding(authPath); + } + let reads = 0; + const config = { port: 10100, defaultProvider: "openai", codexAccounts: [], codexMainAccountHardLock: true, + pausedCodexAccountIds: ${JSON.stringify(scenario.paused ? [MAIN] : [])}, + providers: { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" } } }; + let selected = false; + let failure; + try { + const result = await resolveCodexAuthContext(new Headers(), config, "pool", { + nativeMainRefreshDependencies: { codexAuthStatus: async () => { reads++; return { authMethod: "chatgpt", authToken: token, requiresOpenaiAuth: true }; } }, + primeCodexPoolQuotas: async () => {}, + beginCodexAccountSelection: () => ({ mainProfileDraining: false, claimMainProfile: () => true, release() {} }), + }); + selected = result.kind === "main-pool" && result.chatgptAccountId === account; + } catch (error) { failure = error.name; } + console.log(JSON.stringify({ fixture: "cold-keyring-quota", before, selected, failure, reads, initialized, + legacy: getAccountQuota("__main__"), policy: getMainPolicyQuota(), stored: getAccountQuota("fixture-stored"), + paused: config.pausedCodexAccountIds })); + `; + const child = Bun.spawnSync({ cmd: [process.execPath, "--eval", script], cwd: repoRoot(), env: process.env, + timeout: process.platform === "win32" ? SPAWN_BUDGET_MS - INTERNAL_DEADLINE_MS : 10_000 }); + expect(child.exitCode).toBe(0); + const line = child.stdout.toString().split("\n").find(row => row.startsWith('{"fixture":"cold-keyring-quota"')); + expect(line).toBeDefined(); + const result = JSON.parse(line!); + expect(result.before.weeklyPercent).toBe(100); + expect(result.selected).toBe(scenario.selected); + expect(result.stored.weeklyPercent).toBe(33); + expect(result.paused).toEqual(scenario.paused ? [MAIN] : []); + if (scenario.source === "file") expect(result.initialized).toBe(true); + if (scenario.selected) { + expect(result.reads).toBe(scenario.source === "file" ? 0 : 2); + expect(result.legacy).toBeNull(); + expect(result.policy).toBeNull(); + } else { + expect(result.failure).toBeDefined(); + expect(result.reads).toBe(scenario.paused || scenario.source === "file" ? 0 : 1); + expect(result.legacy.weeklyPercent).toBe(100); + if (scenario.account === "fixture-main-a") expect(result.policy.weeklyPercent).toBe(100); + } + }, SPAWN_BUDGET_MS); + } + for (const unavailable of [true, false]) { test(`persisted ${unavailable ? "unavailable" : "legacy"} null remains identity-bound and cannot authorize spending`, () => { const writer = writerFor(); diff --git a/tests/codex-integration/native-main-refresh-mode.test.ts b/tests/codex-integration/native-main-refresh-mode.test.ts index 3c0ad23b015..b64d441cf74 100644 --- a/tests/codex-integration/native-main-refresh-mode.test.ts +++ b/tests/codex-integration/native-main-refresh-mode.test.ts @@ -1,7 +1,8 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { chmodSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import * as nativeMainRefreshMode from "../../src/codex/native-main-refresh-mode"; import { CODEX_AUTH_STATUS_MIN_VERSION, CodexAuthStatusError, @@ -14,11 +15,26 @@ import { } from "../../src/codex/native-main-refresh-mode"; import { forceRefreshMainAccountToken, + getMainAccountCredentialStatus, getValidMainAccountToken, isMainAccountRefreshGrantRejected, + MAIN_CODEX_ACCOUNT_ID, + MainAuthJsonChangedDuringRefreshError, MainAccountTokenRefreshError, } from "../../src/codex/main-account"; +import { saveCodexAccountCredential } from "../../src/codex/account-store"; +import { NATIVE_GPT6_ASTRA_MINOR_MODEL } from "../../src/codex/catalog/native-models"; import { codexCredentialMutationEpoch } from "../../src/codex/credential-mutation-epoch"; +import { resolveCodexAuthContext, materializeCodexUpstreamAuthAsync, CodexAuthContextError, CodexMainAccountHardLockError, CodexMainProfileDrainingError, CodexModelAvailabilityError, CodexPoolAuthenticationError } from "../../src/codex/auth-context"; +import { applyConfirmedMainCodexAccountTransition, reconcileMainCodexAccountRuntimeState, resetMainCodexAccountIdentityTrackingForTests } from "../../src/codex/account-lifecycle"; +import { captureMainQuotaWriter, isMainQuotaDispatchLive, isMainQuotaWriterLive, matchesMainQuotaCredential, observeMainQuotaCredential } from "../../src/codex/main-account-cache"; +import { clearAccountQuota, getMainPolicyQuota, setAccountQuotaFromParsed } from "../../src/codex/quota"; +import { getMainAccountHardLockStatus } from "../../src/codex/main-account-hard-lock"; +import { clearThreadAccountMap } from "../../src/codex/routing"; +import { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth } from "../../src/codex/account-runtime-state"; +import { withNativeMainSharedClaim } from "../../src/codex/native-main-claim"; +import { resolveNativeProfileContext } from "../../src/codex/native-profile-store"; +import type { OcxConfig } from "../../src/types"; import type { ResolveCodexRuntimeResult } from "../../src/codex/runtime"; import { fakeChatGptJwt } from "../helpers/agent-task-recovery"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -238,15 +254,521 @@ describe("main account in codex refresh mode (#2999)", () => { setNativeMainRefreshResolutionForTests(CODEX_MODE); seedAuth(expired()); const fresh = fakeChatGptJwt("acct-other"); - const { codexAuthStatus } = seam(() => { - rmSync(join(home, "auth.json")); + const { calls, codexAuthStatus } = seam(() => { + rmSync(join(home, "auth.json"), { force: true }); return { authMethod: "chatgpt", authToken: fresh, requiresOpenaiAuth: true }; }); expect(await getValidMainAccountToken({ codexAuthStatus })).toEqual({ accessToken: fresh, chatgptAccountId: "acct-other" }); + expect(await getValidMainAccountToken({ codexAuthStatus })).toEqual({ accessToken: fresh, chatgptAccountId: "acct-other" }); + expect(calls).toEqual([{ refresh: true }, { refresh: false }]); + }); + + test("a keyring-only login is discovered without an earlier file-backed refresh", async () => { + setNativeMainRefreshResolutionForTests(CODEX_MODE); + const fresh = fakeChatGptJwt("acct-main"); + const { calls, codexAuthStatus } = seam(() => ({ authMethod: "chatgpt", authToken: fresh, requiresOpenaiAuth: true })); + + expect(await getValidMainAccountToken({ codexAuthStatus })).toEqual({ accessToken: fresh, chatgptAccountId: "acct-main" }); + expect(calls).toEqual([{ refresh: false }]); + }); + + test.each([false, true])("a withheld lookup only refuses its own materialization (forced=%s)", async forced => { + setNativeMainRefreshResolutionForTests(CODEX_MODE); + const bearer = fakeChatGptJwt("acct-per-lookup"); + let withheld = true; + const { calls, codexAuthStatus } = seam(() => ({ + authMethod: "chatgpt", authToken: withheld ? null : bearer, requiresOpenaiAuth: true, + })); + const inbound = new Headers({ authorization: "Bearer ocx_data_fixture" }); + const context = { kind: "main" as const, accountId: null }; + const options = { substituteMainCredential: true, nativeMainRefreshDependencies: { codexAuthStatus } }; + if (forced) { + await expect(forceRefreshMainAccountToken(bearer, { codexAuthStatus })).rejects.toMatchObject({ reason: "reauth" }); + } else { + await expect(materializeCodexUpstreamAuthAsync(inbound, context, options)).rejects.toMatchObject({ reason: "reauth" }); + } + expect(getMainAccountCredentialStatus()).toMatchObject({ refreshGrantRejected: false, usable: false }); + withheld = false; + const selected = await materializeCodexUpstreamAuthAsync(inbound, context, options); + expect(selected.get("authorization")).toBe(`Bearer ${bearer}`); + expect(selected.get("chatgpt-account-id")).toBe("acct-per-lookup"); + expect(calls).toEqual([{ refresh: forced }, { refresh: false }]); + }); + + test("Pool discovers a keyring-only main login before synchronous candidacy", async () => { + setNativeMainRefreshResolutionForTests(CODEX_MODE); + clearThreadAccountMap(); + clearAccountNeedsReauth("__main__"); + const fresh = fakeChatGptJwt("acct-keyring-pool"); + const { calls, codexAuthStatus } = seam(() => ({ authMethod: "chatgpt", authToken: fresh, requiresOpenaiAuth: true })); + const config: OcxConfig = { + port: 10100, defaultProvider: "openai", codexAccounts: [], codexMainAccountHardLock: false, + providers: { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" } }, + }; + let claimed = false; + const context = await resolveCodexAuthContext(new Headers(), config, "pool", { + modelId: "gpt-5.5", nativeMainRefreshDependencies: { codexAuthStatus }, + beginCodexAccountSelection: () => ({ mainProfileDraining: false, + claimMainProfile: () => { claimed = true; return true; }, release() {} }), + }); + expect(context).toMatchObject({ kind: "main-pool", accessToken: fresh, chatgptAccountId: "acct-keyring-pool" }); + expect(claimed).toBe(true); + expect(calls).toEqual([{ refresh: false }, { refresh: false }]); + }); + + test("stored Direct materialization uses the currently verified keyring account", async () => { + setNativeMainRefreshResolutionForTests(CODEX_MODE); + const fresh = fakeChatGptJwt("acct-keyring-direct"); + const { codexAuthStatus } = seam(() => ({ authMethod: "chatgpt", authToken: fresh, requiresOpenaiAuth: true })); + const inbound = new Headers({ authorization: "Bearer ocx_data_fixture", "chatgpt-account-id": "caller-account" }); + const selected = await materializeCodexUpstreamAuthAsync(inbound, { kind: "main", accountId: null }, { + substituteMainCredential: true, nativeMainRefreshDependencies: { codexAuthStatus }, + }); + expect(selected.get("authorization")).toBe(`Bearer ${fresh}`); + expect(selected.get("chatgpt-account-id")).toBe("acct-keyring-direct"); + expect(inbound.get("chatgpt-account-id")).toBe("caller-account"); + }); + + test("a cold keyring discovery transport failure retains its retryable auth-context cause", async () => { + setNativeMainRefreshResolutionForTests(CODEX_MODE); + clearThreadAccountMap(); + clearAccountNeedsReauth("__main__"); + const config: OcxConfig = { + port: 10100, defaultProvider: "openai", codexAccounts: [], codexMainAccountHardLock: false, + providers: { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" } }, + }; + const failure = await resolveCodexAuthContext(new Headers(), config, "pool", { + nativeMainRefreshDependencies: { codexAuthStatus: async () => { throw new CodexAuthStatusError("fixture unavailable"); } }, + beginCodexAccountSelection: () => ({ mainProfileDraining: false, claimMainProfile: () => true, release() {} }), + }).catch(error => error); + expect(failure).toBeInstanceOf(CodexAuthContextError); + expect(failure.cause).toMatchObject({ reason: "transient" }); + }); + + test("a cold keyring discovery failure reaches the exact __main__ selector as its retryable cause", async () => { + setNativeMainRefreshResolutionForTests(CODEX_MODE); + clearThreadAccountMap(); + clearAccountNeedsReauth("__main__"); + const config: OcxConfig = { + port: 10100, defaultProvider: "openai", codexAccounts: [], codexMainAccountHardLock: false, + providers: { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" } }, + }; + const failure = await resolveCodexAuthContext(new Headers(), config, "pool", { + accountId: MAIN_CODEX_ACCOUNT_ID, + nativeMainRefreshDependencies: { codexAuthStatus: async () => { throw new CodexAuthStatusError("fixture unavailable"); } }, + beginCodexAccountSelection: () => ({ mainProfileDraining: false, claimMainProfile: () => true, release() {} }), + }).catch(error => error); + expect(failure).toBeInstanceOf(CodexAuthContextError); + expect(failure).not.toBeInstanceOf(CodexPoolAuthenticationError); + expect(failure.cause).toMatchObject({ reason: "transient" }); + }); + + test("an ordinary keyring read with no Codex login reports absence for the exact __main__ selector", async () => { + setNativeMainRefreshResolutionForTests(CODEX_MODE); + clearThreadAccountMap(); + clearAccountNeedsReauth("__main__"); + const { codexAuthStatus } = seam(() => ({ authMethod: null, authToken: null, requiresOpenaiAuth: true })); + const config: OcxConfig = { + port: 10100, defaultProvider: "openai", codexAccounts: [], codexMainAccountHardLock: false, + providers: { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" } }, + }; + const failure = await resolveCodexAuthContext(new Headers(), config, "pool", { + accountId: MAIN_CODEX_ACCOUNT_ID, + nativeMainRefreshDependencies: { codexAuthStatus }, + beginCodexAccountSelection: () => ({ mainProfileDraining: false, claimMainProfile: () => true, release() {} }), + }).catch(error => error); + expect(failure).toBeInstanceOf(CodexPoolAuthenticationError); + expect(failure).not.toBeInstanceOf(CodexAuthContextError); + expect(getMainAccountCredentialStatus().usable).toBe(false); + }); + + test("an ordinary keyring read with no Codex login reports absence in Pool mode", async () => { + setNativeMainRefreshResolutionForTests(CODEX_MODE); + clearThreadAccountMap(); + clearAccountNeedsReauth("__main__"); + const { codexAuthStatus } = seam(() => ({ authMethod: null, authToken: null, requiresOpenaiAuth: true })); + const config: OcxConfig = { + port: 10100, defaultProvider: "openai", codexAccounts: [], codexMainAccountHardLock: false, + providers: { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" } }, + }; + const failure = await resolveCodexAuthContext(new Headers(), config, "pool", { + nativeMainRefreshDependencies: { codexAuthStatus }, + beginCodexAccountSelection: () => ({ mainProfileDraining: false, claimMainProfile: () => true, release() {} }), + }).catch(error => error); + expect(failure).toBeInstanceOf(CodexPoolAuthenticationError); + expect(failure).not.toBeInstanceOf(CodexAuthContextError); + }); + + test("an ordinary fileless read with no Codex login returns null instead of a refresh failure", async () => { + setNativeMainRefreshResolutionForTests(CODEX_MODE); + const { calls, codexAuthStatus } = seam(() => ({ authMethod: null, authToken: null, requiresOpenaiAuth: true })); + expect(await getValidMainAccountToken({ codexAuthStatus })).toBeNull(); + expect(calls).toEqual([{ refresh: false }]); + expect(getMainAccountCredentialStatus().usable).toBe(false); + }); + + test("a missing CODEX_HOME is the same observed absence: no claim, no RPC, null", async () => { + setNativeMainRefreshResolutionForTests(CODEX_MODE); + process.env.CODEX_HOME = join(root, "never-created", "codex-home"); + const { calls, codexAuthStatus } = seam(() => { throw new Error("must not be called"); }); + expect(await getValidMainAccountToken({ codexAuthStatus })).toBeNull(); + expect(calls).toEqual([]); + }); + + test("a forced fileless refresh over a missing CODEX_HOME still reports the absence", async () => { + setNativeMainRefreshResolutionForTests(CODEX_MODE); + process.env.CODEX_HOME = join(root, "never-created"); + const { calls, codexAuthStatus } = seam(() => { throw new Error("must not be called"); }); + expect(await forceRefreshMainAccountToken(expired(), { codexAuthStatus })).toBeNull(); + expect(calls).toEqual([]); + }); + + test.each(["cancel", "home-change"] as const)("missing-home absence does not mask %s during mode discovery", async change => { + process.env.CODEX_HOME = join(root, "never-created"); + const controller = new AbortController(); + const cancellation = new Error("cancelled during mode discovery"); + let started!: () => void; + let release!: () => void; + const entered = new Promise(resolve => { started = resolve; }); + const held = new Promise(resolve => { release = () => resolve(CODEX_MODE); }); + const mode = spyOn(nativeMainRefreshMode, "resolveNativeMainRefreshMode").mockImplementation(() => { + started(); + return held; + }); + const { calls, codexAuthStatus } = seam(() => { throw new Error("must not be called"); }); + const observed = getValidMainAccountToken({ codexAuthStatus, signal: controller.signal }).then( + value => ({ kind: "resolved" as const, value }), + (error: unknown) => ({ kind: "rejected" as const, error }), + ); + try { + await entered; + if (change === "cancel") controller.abort(cancellation); + else process.env.CODEX_HOME = home; + release(); + const outcome = await observed; + expect(outcome.kind).toBe("rejected"); + if (outcome.kind !== "rejected") throw new Error("expected the pending lookup to reject"); + if (change === "cancel") expect(outcome.error).toBe(cancellation); + else expect(outcome.error).toBeInstanceOf(MainAuthJsonChangedDuringRefreshError); + expect(calls).toEqual([]); + } finally { + release(); + await observed; + mode.mockRestore(); + } + }); + + test("a CODEX_HOME that exists but is not a directory stays fail closed", async () => { + setNativeMainRefreshResolutionForTests(CODEX_MODE); + const file = join(root, "codex-home-is-a-file"); + writeFileSync(file, "not a directory"); + process.env.CODEX_HOME = file; + const { calls, codexAuthStatus } = seam(() => { throw new Error("must not be called"); }); + const before = readFileSync(file); + const failure = await getValidMainAccountToken({ codexAuthStatus }).then( + () => { throw new Error("expected a non-directory CODEX_HOME to be rejected"); }, + (error: unknown) => error, + ); + expect(failure).toBeInstanceOf(Error); + // Linux can reject file/auth.json at lstat before the profile-context validator runs. + expect(["CODEX_HOME_UNAVAILABLE", "ENOTDIR"]).toContain((failure as NodeJS.ErrnoException).code); + expect(calls).toEqual([]); + expect(readFileSync(file)).toEqual(before); + }); + + test("a dangling CODEX_HOME link stays fail closed, including through a dangling parent", async () => { + setNativeMainRefreshResolutionForTests(CODEX_MODE); + const dangling = join(root, "dangling-home"); + symlinkSync(join(root, "gone"), dangling, process.platform === "win32" ? "junction" : "dir"); + process.env.CODEX_HOME = dangling; + const { calls, codexAuthStatus } = seam(() => { throw new Error("must not be called"); }); + await expect(getValidMainAccountToken({ codexAuthStatus })) + .rejects.toMatchObject({ code: "CODEX_HOME_UNAVAILABLE" }); + + // A missing child under a dangling parent must not read as an absent home: + // the entry that broke the chain exists, so the verdict stays unsafe. + process.env.CODEX_HOME = join(dangling, "child"); + await expect(getValidMainAccountToken({ codexAuthStatus })) + .rejects.toMatchObject({ code: "CODEX_HOME_UNAVAILABLE" }); + expect(calls).toEqual([]); + }); + + test("an expiring-bearer promotion turns the same no-login back into a refresh failure", async () => { + setNativeMainRefreshResolutionForTests(CODEX_MODE); + const answers: CodexAuthStatus[] = [ + { authMethod: "chatgpt", authToken: expired(), requiresOpenaiAuth: true }, + { authMethod: null, authToken: null, requiresOpenaiAuth: true }, + ]; + let index = 0; + const { calls, codexAuthStatus } = seam(() => answers[index++]); + const failure = await getValidMainAccountToken({ codexAuthStatus }).catch(error => error); + expect(failure).toBeInstanceOf(MainAccountTokenRefreshError); + expect((failure as MainAccountTokenRefreshError).reason).toBe("transient"); + expect(calls).toEqual([{ refresh: false }, { refresh: true }]); }); - test("a chatgpt login with a withheld token means reauthentication", async () => { + test("an expiring-bearer promotion keeps a withheld grant at reauth", async () => { + setNativeMainRefreshResolutionForTests(CODEX_MODE); + const answers: CodexAuthStatus[] = [ + { authMethod: "chatgpt", authToken: expired(), requiresOpenaiAuth: true }, + { authMethod: "chatgpt", authToken: null, requiresOpenaiAuth: true }, + ]; + let index = 0; + const { calls, codexAuthStatus } = seam(() => answers[index++]); + const failure = await getValidMainAccountToken({ codexAuthStatus }).catch(error => error); + expect(failure).toBeInstanceOf(MainAccountTokenRefreshError); + expect((failure as MainAccountTokenRefreshError).reason).toBe("reauth"); + expect(calls).toEqual([{ refresh: false }, { refresh: true }]); + }); + + test("a forced fileless refresh still reports no-login as transient", async () => { + setNativeMainRefreshResolutionForTests(CODEX_MODE); + const { calls, codexAuthStatus } = seam(() => ({ authMethod: null, authToken: null, requiresOpenaiAuth: true })); + const failure = await forceRefreshMainAccountToken(expired(), { codexAuthStatus }).catch(error => error); + expect(failure).toBeInstanceOf(MainAccountTokenRefreshError); + expect((failure as MainAccountTokenRefreshError).reason).toBe("transient"); + expect(calls).toEqual([{ refresh: true }]); + }); + + test("a refused main-profile claim outranks a saved discovery failure", async () => { + setNativeMainRefreshResolutionForTests(CODEX_MODE); + clearThreadAccountMap(); + clearAccountNeedsReauth("__main__"); + const config: OcxConfig = { + port: 10100, defaultProvider: "openai", codexAccounts: [], codexMainAccountHardLock: false, + providers: { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" } }, + }; + const failure = await resolveCodexAuthContext(new Headers(), config, "pool", { + accountId: MAIN_CODEX_ACCOUNT_ID, + nativeMainRefreshDependencies: { codexAuthStatus: async () => { throw new CodexAuthStatusError("fixture unavailable"); } }, + beginCodexAccountSelection: () => ({ mainProfileDraining: false, claimMainProfile: () => false, release() {} }), + }).catch(error => error); + expect(failure).toBeInstanceOf(CodexMainProfileDrainingError); + }); + + test("a stale discovery failure does not refuse an exact __main__ selector once the credential recovers", async () => { + setNativeMainRefreshResolutionForTests(CODEX_MODE); + clearThreadAccountMap(); + clearAccountNeedsReauth("__main__"); + const fresh = fakeChatGptJwt("acct-main"); + let recovered = false; + const { codexAuthStatus } = seam(() => { + if (!recovered) throw new CodexAuthStatusError("fixture unavailable"); + return { authMethod: "chatgpt", authToken: fresh, requiresOpenaiAuth: true }; + }); + const config: OcxConfig = { + port: 10100, defaultProvider: "openai", codexAccounts: [], codexMainAccountHardLock: false, + providers: { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" } }, + }; + const context = await resolveCodexAuthContext(new Headers(), config, "pool", { + accountId: MAIN_CODEX_ACCOUNT_ID, + modelId: NATIVE_GPT6_ASTRA_MINOR_MODEL, + nativeMainRefreshDependencies: { codexAuthStatus }, + resolveCodexModelEntitlements: async () => { + // A real recovery landing during the entitlement await: the read below goes through + // the production credential path, so the saved probe error above is stale evidence. + recovered = true; + await getValidMainAccountToken({ codexAuthStatus }); + return { + modelsByAccount: new Map([[MAIN_CODEX_ACCOUNT_ID, new Set([NATIVE_GPT6_ASTRA_MINOR_MODEL])]]), + clientVersionByAccount: new Map(), + confirmedAccountIds: new Set([MAIN_CODEX_ACCOUNT_ID]), + credentialIdentities: new Map(), + }; + }, + beginCodexAccountSelection: () => ({ mainProfileDraining: false, claimMainProfile: () => true, release() {} }), + }); + expect(context).toMatchObject({ kind: "main-pool", accountId: MAIN_CODEX_ACCOUNT_ID, accessToken: fresh }); + }); + + test("a stale discovery failure does not outrank the current selection answer when nothing can serve", async () => { + setNativeMainRefreshResolutionForTests(CODEX_MODE); + clearThreadAccountMap(); + clearAccountNeedsReauth("__main__"); + const fresh = fakeChatGptJwt("acct-main"); + let recovered = false; + const { codexAuthStatus } = seam(() => { + if (!recovered) throw new CodexAuthStatusError("fixture unavailable"); + return { authMethod: "chatgpt", authToken: fresh, requiresOpenaiAuth: true }; + }); + const config: OcxConfig = { + port: 10100, defaultProvider: "openai", codexAccounts: [], codexMainAccountHardLock: false, + providers: { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" } }, + }; + const failure = await resolveCodexAuthContext(new Headers(), config, "pool", { + modelId: NATIVE_GPT6_ASTRA_MINOR_MODEL, + nativeMainRefreshDependencies: { codexAuthStatus }, + resolveCodexModelEntitlements: async () => { + // Same mid-await recovery as above: the credential verifies healthy, so the saved + // probe error is stale. The roster admits nobody for this model, so main stays + // out of candidacy on CURRENT eligibility evidence and the honest refusal is the + // selection's own answer, not the stale transient. + recovered = true; + await getValidMainAccountToken({ codexAuthStatus }); + return { + modelsByAccount: new Map(), + clientVersionByAccount: new Map(), + confirmedAccountIds: new Set(), + credentialIdentities: new Map(), + }; + }, + beginCodexAccountSelection: () => ({ mainProfileDraining: false, claimMainProfile: () => true, release() {} }), + }).catch(error => error); + expect(failure).toBeInstanceOf(CodexModelAvailabilityError); + expect(failure).not.toBeInstanceOf(CodexAuthContextError); + }); + + test("a failed main discovery still lets a healthy pool account serve the request", async () => { + setNativeMainRefreshResolutionForTests(CODEX_MODE); + clearThreadAccountMap(); + const previousHome = process.env.OPENCODEX_HOME; + process.env.OPENCODEX_HOME = join(root, "opencodex-home"); + mkdirSync(process.env.OPENCODEX_HOME); + try { + saveCodexAccountCredential("pool-a", { + accessToken: "pool-a-bearer", + refreshToken: "pool-a-refresh", + expiresAt: Date.now() + 3_600_000, + chatgptAccountId: "pool_acc", + }); + const config: OcxConfig = { + port: 10100, defaultProvider: "openai", codexMainAccountHardLock: false, + activeCodexAccountId: "pool-a", + codexAccounts: [{ id: "pool-a", email: "pool@example.test", isMain: false, chatgptAccountId: "pool_acc" }], + providers: { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" } }, + }; + const context = await resolveCodexAuthContext(new Headers(), config, "pool", { + nativeMainRefreshDependencies: { codexAuthStatus: async () => { throw new CodexAuthStatusError("fixture unavailable"); } }, + beginCodexAccountSelection: () => ({ mainProfileDraining: false, claimMainProfile: () => true, release() {} }), + }); + expect(context).toMatchObject({ kind: "pool", accountId: "pool-a" }); + } finally { + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + } + }); + + test("a near-expiry keyring read refreshes once through Codex", async () => { + setNativeMainRefreshResolutionForTests(CODEX_MODE); + const fresh = fakeChatGptJwt("acct-main"); + const calls: boolean[] = []; + const token = await getValidMainAccountToken({ codexAuthStatus: async ({ refresh }) => { + calls.push(refresh); + return { authMethod: "chatgpt", authToken: refresh ? fresh : expired(), requiresOpenaiAuth: true }; + } }); + expect(token?.accessToken).toBe(fresh); + expect(calls).toEqual([false, true]); + }); + + test("a keyring result is never attributed to a different home after an awaited read", async () => { + setNativeMainRefreshResolutionForTests(CODEX_MODE); + const otherHome = join(root, "other-codex-home"); + mkdirSync(otherHome); + const { codexAuthStatus } = seam(() => { + process.env.CODEX_HOME = otherHome; + return { authMethod: "chatgpt", authToken: fakeChatGptJwt("acct-old-home"), requiresOpenaiAuth: true }; + }); + await expect(getValidMainAccountToken({ codexAuthStatus })).rejects.toBeInstanceOf(MainAuthJsonChangedDuringRefreshError); + expect(getMainAccountCredentialStatus().usable).toBe(false); + process.env.CODEX_HOME = home; + expect(getMainAccountCredentialStatus().usable).toBe(false); + }); + + test("cancellation interrupts a read-only keyring lookup without starting a refresh", async () => { + setNativeMainRefreshResolutionForTests(CODEX_MODE); + const controller = new AbortController(); + const reason = new Error("fixture caller disconnected"); + let started!: () => void; + const entered = new Promise(resolve => { started = resolve; }); + const calls: boolean[] = []; + const pending = getValidMainAccountToken({ signal: controller.signal, + codexAuthStatus: ({ refresh, signal }) => new Promise((_resolve, reject) => { + calls.push(refresh); + signal.addEventListener("abort", () => reject(signal.reason), { once: true }); + started(); + }), + }); + await entered; + controller.abort(reason); + await expect(pending).rejects.toBe(reason); + expect(calls).toEqual([false]); + expect(getMainAccountCredentialStatus().usable).toBe(false); + }); + + test("a keyring claim deadline is transient and never starts Codex while a reader owns the home", async () => { + setNativeMainRefreshResolutionForTests(CODEX_MODE); + let started!: () => void; + let release!: () => void; + const entered = new Promise(resolve => { started = resolve; }); + const held = new Promise(resolve => { release = resolve; }); + const reader = withNativeMainSharedClaim(resolveNativeProfileContext(), async () => { started(); await held; }); + await entered; + const originalTimeout = AbortSignal.timeout.bind(AbortSignal); + const timeout = spyOn(AbortSignal, "timeout").mockImplementation(ms => originalTimeout(ms === 30_000 ? 10 : ms)); + const { calls, codexAuthStatus } = seam(() => ({ authMethod: "chatgpt", authToken: fakeChatGptJwt("acct-main"), requiresOpenaiAuth: true })); + try { + await expect(getValidMainAccountToken({ codexAuthStatus })).rejects.toMatchObject({ reason: "transient" }); + expect(calls).toEqual([]); + } finally { + timeout.mockRestore(); + release(); + await reader; + } + }); + + test("a subsequent keyring read follows an external account switch", async () => { + setNativeMainRefreshResolutionForTests(CODEX_MODE); + seedAuth(expired()); + let current = fakeChatGptJwt("acct-main"); + const { calls, codexAuthStatus } = seam(() => { + rmSync(join(home, "auth.json"), { force: true }); + return { authMethod: "chatgpt", authToken: current, requiresOpenaiAuth: true }; + }); + expect((await getValidMainAccountToken({ codexAuthStatus }))?.chatgptAccountId).toBe("acct-main"); + current = fakeChatGptJwt("acct-switched"); + + expect(await getValidMainAccountToken({ codexAuthStatus })).toEqual({ accessToken: current, chatgptAccountId: "acct-switched" }); + expect(calls).toEqual([{ refresh: true }, { refresh: false }]); + }); + + test("external logout invalidates the previously observed keyring bearer", async () => { + setNativeMainRefreshResolutionForTests(CODEX_MODE); + seedAuth(expired()); + let status: CodexAuthStatus = { authMethod: "chatgpt", authToken: fakeChatGptJwt("acct-main"), requiresOpenaiAuth: true }; + const { codexAuthStatus } = seam(() => { + rmSync(join(home, "auth.json"), { force: true }); + return status; + }); + await getValidMainAccountToken({ codexAuthStatus }); + status = { authMethod: null, authToken: null, requiresOpenaiAuth: true }; + + expect(await getValidMainAccountToken({ codexAuthStatus })).toBeNull(); + expect(getMainAccountCredentialStatus().usable).toBe(false); + }); + + test("a later authoritative keyring lookup can return the same bearer after withholding it", async () => { + setNativeMainRefreshResolutionForTests(CODEX_MODE); + seedAuth(expired()); + const rejected = fakeChatGptJwt("acct-main"); + let status: CodexAuthStatus = { authMethod: "chatgpt", authToken: rejected, requiresOpenaiAuth: true }; + const { codexAuthStatus } = seam(() => { + rmSync(join(home, "auth.json"), { force: true }); + return status; + }); + await getValidMainAccountToken({ codexAuthStatus }); + status = { authMethod: "chatgpt", authToken: null, requiresOpenaiAuth: true }; + + await expect(forceRefreshMainAccountToken(rejected, { codexAuthStatus })).rejects.toMatchObject({ reason: "reauth" }); + expect(getMainAccountCredentialStatus().usable).toBe(false); + expect(isMainAccountRefreshGrantRejected()).toBe(false); + await expect(getValidMainAccountToken({ codexAuthStatus })).rejects.toMatchObject({ reason: "reauth" }); + status = { authMethod: "chatgpt", authToken: rejected, requiresOpenaiAuth: true }; + expect((await getValidMainAccountToken({ codexAuthStatus }))?.accessToken).toBe(rejected); + expect(isMainAccountRefreshGrantRejected()).toBe(false); + }); + + test("a chatgpt login with a withheld token keeps the file-backed grant rejection", async () => { setNativeMainRefreshResolutionForTests(CODEX_MODE); const original = seedAuth(expired()); const { codexAuthStatus } = seam(() => ({ authMethod: "chatgpt", authToken: null, requiresOpenaiAuth: true })); @@ -306,3 +828,207 @@ describe("main account in codex refresh mode (#2999)", () => { expect(JSON.parse(readFileSync(join(home, "auth.json"), "utf8")).tokens.refresh_token).toBe("rotated"); }); }); + +describe("owned keyring account identity and quota", () => { + let previousHome: string | undefined; + + beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + process.env.OPENCODEX_HOME = join(root, "opencodex-home"); + mkdirSync(process.env.OPENCODEX_HOME); + resetMainCodexAccountIdentityTrackingForTests(); + clearAccountQuota(); + clearThreadAccountMap(); + clearAccountNeedsReauth("__main__"); + setNativeMainRefreshResolutionForTests(CODEX_MODE); + }); + + afterEach(() => { + clearAccountQuota(); + resetMainCodexAccountIdentityTrackingForTests(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + }); + + function config(): OcxConfig { + return { port: 10100, defaultProvider: "openai", codexAccounts: [], codexMainAccountHardLock: true, + providers: { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" } } }; + } + + function oldAccount(percent = 99) { + const accessToken = fakeChatGptJwt("acct-main"); + seedAuth(accessToken); + reconcileMainCodexAccountRuntimeState(); + const writer = observeMainQuotaCredential(accessToken, "acct-main")!; + expect(writer).toBeDefined(); + setAccountQuotaFromParsed("__main__", { weeklyPercent: percent, weeklyResetAt: Date.now() / 1000 + 3600 }, undefined, writer); + rmSync(join(home, "auth.json")); + return { accessToken, writer }; + } + + function answer(accessToken: string) { + const calls: boolean[] = []; + return { calls, codexAuthStatus: async ({ refresh }: { refresh: boolean; signal: AbortSignal }) => { + calls.push(refresh); + return { authMethod: "chatgpt", authToken: accessToken, requiresOpenaiAuth: true } as CodexAuthStatus; + } }; + } + + const selection = () => ({ mainProfileDraining: false, claimMainProfile: () => true, release() {} }); + + test("a verified keyring switch retires the old quota writer and binds the new credential", async () => { + const old = oldAccount(); + const fresh = fakeChatGptJwt("acct-keyring-new"); + const { codexAuthStatus } = answer(fresh); + expect(await getValidMainAccountToken({ codexAuthStatus })).toEqual({ accessToken: fresh, chatgptAccountId: "acct-keyring-new" }); + expect(isMainQuotaWriterLive(old.writer)).toBe(false); + expect(matchesMainQuotaCredential(old.accessToken, "acct-main")).toBe(false); + expect(matchesMainQuotaCredential(fresh, "acct-keyring-new")).toBe(true); + const nextWriter = captureMainQuotaWriter("acct-keyring-new"); + expect(nextWriter).toBeDefined(); + expect(getMainPolicyQuota()).toBeNull(); + setAccountQuotaFromParsed("__main__", { weeklyPercent: 5 }, undefined, nextWriter); + setAccountQuotaFromParsed("__main__", { weeklyPercent: 100 }, undefined, old.writer); + expect(getMainPolicyQuota()?.weeklyPercent).toBe(5); + }); + + test("stored Direct materialization binds its new keyring dispatch after an awaited identity switch", async () => { + oldAccount(); + const fresh = fakeChatGptJwt("acct-keyring-new"); + const { codexAuthStatus } = answer(fresh); + const context: { kind: "main"; accountId: null; mainQuotaDispatch?: import("../../src/codex/main-account-cache").MainQuotaDispatch } = { kind: "main", accountId: null }; + const headers = await materializeCodexUpstreamAuthAsync(new Headers({ authorization: "Bearer fixture-admission" }), context, { + config: config(), substituteMainCredential: true, nativeMainRefreshDependencies: { codexAuthStatus }, + }); + expect(headers.get("authorization")).toBe(`Bearer ${fresh}`); + expect(headers.get("chatgpt-account-id")).toBe("acct-keyring-new"); + expect(context.mainQuotaDispatch).toBeDefined(); + expect(isMainQuotaDispatchLive(context.mainQuotaDispatch!)).toBe(true); + }); + + test.each([true, false])("stored Direct discovers a replacement before old policy checks (hard lock=%s)", async hardLock => { + oldAccount(100); + const cfg = { ...config(), codexMainAccountHardLock: hardLock }; + const { calls, codexAuthStatus } = answer(fakeChatGptJwt("acct-keyring-new")); + await expect(resolveCodexAuthContext(new Headers({ authorization: "Bearer fixture-admission" }), cfg, "direct", { + substituteMainCredentialForDirect: true, nativeMainRefreshDependencies: { codexAuthStatus }, + beginCodexAccountSelection: selection, + })).resolves.toMatchObject({ kind: "main", accountId: null }); + expect(calls).toEqual([false]); + expect(getMainPolicyQuota()).toBeNull(); + }); + + test.each([false, true])("Pool observes a replacement before old quota selection (prior keyring lookup=%s)", async warm => { + const old = oldAccount(); + if (warm) { + await getValidMainAccountToken({ codexAuthStatus: answer(old.accessToken).codexAuthStatus }); + expect(getMainAccountCredentialStatus().usable).toBe(true); + } + const cfg = config(); + const before = structuredClone(cfg); + const fresh = fakeChatGptJwt("acct-keyring-new"); + const { calls, codexAuthStatus } = answer(fresh); + const context = await resolveCodexAuthContext(new Headers(), cfg, "pool", { + modelId: "gpt-5.5", nativeMainRefreshDependencies: { codexAuthStatus }, + beginCodexAccountSelection: selection, primeCodexPoolQuotas: async () => {}, + }); + expect(context).toMatchObject({ kind: "main-pool", accessToken: fresh, chatgptAccountId: "acct-keyring-new" }); + expect(context.kind === "main-pool" && context.mainQuotaWriter).toBeDefined(); + if (context.kind === "main-pool") expect(isMainQuotaWriterLive(context.mainQuotaWriter!)).toBe(true); + expect(calls).toEqual([false, false]); + // Ordinary Pool selection records its active slot; operator policy stays unchanged. + expect(cfg).toEqual({ ...before, activeCodexAccountId: "__main__" }); + expect(isMainQuotaWriterLive(old.writer)).toBe(false); + }); + + test("the unchanged keyring account retains its hard lock", async () => { + const old = oldAccount(); + const { codexAuthStatus } = answer(old.accessToken); + await getValidMainAccountToken({ codexAuthStatus }); + expect(getMainAccountHardLockStatus(config()).state).toBe("blocked"); + expect(isMainQuotaWriterLive(old.writer)).toBe(true); + await expect(materializeCodexUpstreamAuthAsync(new Headers(), { kind: "main", accountId: null }, { + config: config(), substituteMainCredential: true, nativeMainRefreshDependencies: { codexAuthStatus }, + })).rejects.toBeInstanceOf(CodexMainAccountHardLockError); + expect(getMainPolicyQuota()?.weeklyPercent).toBe(99); + }); + + test("Pool acquisition carries the specifically owned replacement writer after discovery", async () => { + const old = oldAccount(5); + const fresh = fakeChatGptJwt("acct-keyring-new"); + let reads = 0; + const context = await resolveCodexAuthContext(new Headers(), config(), "pool", { + nativeMainRefreshDependencies: { codexAuthStatus: async () => ({ + authMethod: "chatgpt", authToken: ++reads === 1 ? old.accessToken : fresh, requiresOpenaiAuth: true, + }) }, + beginCodexAccountSelection: selection, primeCodexPoolQuotas: async () => {}, + }); + expect(reads).toBe(2); + expect(context).toMatchObject({ kind: "main-pool", chatgptAccountId: "acct-keyring-new" }); + expect(isMainQuotaWriterLive(old.writer)).toBe(false); + expect(context.kind === "main-pool" && context.mainQuotaWriter).toEqual(captureMainQuotaWriter("acct-keyring-new")); + if (context.kind === "main-pool") { + setAccountQuotaFromParsed("__main__", { weeklyPercent: 7 }, undefined, context.mainQuotaWriter); + } + expect(getMainPolicyQuota()?.weeklyPercent).toBe(7); + }); + + test("an incoming Direct bearer cannot replace the owned quota identity", async () => { + const old = oldAccount(); + const incoming = fakeChatGptJwt("acct-caller-only"); + const { calls, codexAuthStatus } = answer(incoming); + await expect(resolveCodexAuthContext(new Headers({ authorization: `Bearer ${incoming}` }), config(), "direct", { + nativeMainRefreshDependencies: { codexAuthStatus }, beginCodexAccountSelection: selection, + })).resolves.toMatchObject({ kind: "main", accountId: null }); + expect(calls).toEqual([]); + expect(isMainQuotaWriterLive(old.writer)).toBe(true); + expect(matchesMainQuotaCredential(incoming, "acct-caller-only")).toBe(false); + expect(getMainPolicyQuota()?.weeklyPercent).toBe(99); + }); + + test("a late RPC cannot overwrite a newer native identity generation", async () => { + const old = oldAccount(); + await expect(getValidMainAccountToken({ codexAuthStatus: async () => { + applyConfirmedMainCodexAccountTransition("acct-main", "acct-concurrent"); + return { authMethod: "chatgpt", authToken: fakeChatGptJwt("acct-keyring-new"), requiresOpenaiAuth: true }; + } })).rejects.toBeInstanceOf(MainAuthJsonChangedDuringRefreshError); + expect(isMainQuotaWriterLive(old.writer)).toBe(false); + expect(captureMainQuotaWriter("acct-concurrent")).toBeDefined(); + expect(captureMainQuotaWriter("acct-keyring-new")).toBeUndefined(); + }); + + test.each(["unavailable", "missing-id", "conflicting-id", "cancelled", "home-changed"])("uncertain keyring evidence preserves old quota policy (%s)", async scenario => { + const old = oldAccount(); + markAccountNeedsReauth("__main__"); + const controller = new AbortController(); + const otherHome = join(root, "other-home"); + mkdirSync(otherHome); + const token = scenario === "missing-id" ? "opaque-fixture-token" : scenario === "conflicting-id" + ? fakeChatGptJwt("acct-keyring-new", { chatgpt_account_id: "acct-conflict" }) + : fakeChatGptJwt("acct-keyring-new"); + await getValidMainAccountToken({ signal: controller.signal, codexAuthStatus: async () => { + if (scenario === "unavailable") throw new CodexAuthStatusError("fixture unavailable"); + if (scenario === "cancelled") controller.abort(new Error("fixture cancelled")); + if (scenario === "home-changed") process.env.CODEX_HOME = otherHome; + return { authMethod: "chatgpt", authToken: token, requiresOpenaiAuth: true }; + } }).catch(() => null); + process.env.CODEX_HOME = home; + expect(isMainQuotaWriterLive(old.writer)).toBe(true); + expect(getMainAccountHardLockStatus(config()).state).toBe("blocked"); + expect(getMainPolicyQuota()?.weeklyPercent).toBe(99); + expect(isAccountNeedsReauth("__main__")).toBe(true); + }); + + test("a manual main pause prevents discovery and remains configured", async () => { + oldAccount(); + const cfg = { ...config(), pausedCodexAccountIds: ["__main__"] }; + const { calls, codexAuthStatus } = answer(fakeChatGptJwt("acct-keyring-new")); + await expect(resolveCodexAuthContext(new Headers(), cfg, "pool", { + nativeMainRefreshDependencies: { codexAuthStatus }, beginCodexAccountSelection: selection, + primeCodexPoolQuotas: async () => {}, + })).rejects.toBeDefined(); + expect(calls).toEqual([]); + expect(cfg.pausedCodexAccountIds).toEqual(["__main__"]); + expect(getMainPolicyQuota()?.weeklyPercent).toBe(99); + }); +}); diff --git a/tests/responses/responses-native-main-refresh.test.ts b/tests/responses/responses-native-main-refresh.test.ts index 5a894e6355f..293ac73b37c 100644 --- a/tests/responses/responses-native-main-refresh.test.ts +++ b/tests/responses/responses-native-main-refresh.test.ts @@ -22,6 +22,9 @@ import type { RequestLogContext } from "../../src/server/request-log"; import type { OcxConfig } from "../../src/types"; import { captureCallerDirectAuth } from "../../src/providers/caller-authorization"; import { fakeChatGptJwt } from "../helpers/agent-task-recovery"; +import { setNativeMainRefreshResolutionForTests } from "../../src/codex/native-main-refresh-mode"; +import { clearAccountQuota, getAccountQuota, getMainPolicyQuota } from "../../src/codex/quota"; +import { resetMainCodexAccountIdentityTrackingForTests } from "../../src/codex/account-lifecycle"; import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { setAsyncIcaclsRunnerForTests, setIcaclsRunnerForTests } from "../../src/lib/windows-secret-acl"; @@ -105,6 +108,9 @@ beforeEach(() => { }); afterEach(() => { + setNativeMainRefreshResolutionForTests(null); + clearAccountQuota(MAIN_CODEX_ACCOUNT_ID); + resetMainCodexAccountIdentityTrackingForTests(); // Release before restoring or removing the home to prevent Windows removal failures and POSIX unlinked databases. releaseSpendHome?.(); releaseSpendHome = undefined; @@ -154,6 +160,63 @@ function install401ThenRefreshHarness(): { sends: string[]; refreshes: string[] } describe("native main 401 refresh and replay", () => { + test.each(["/v1/responses", "/v1/responses/compact"] as const)( + "%s publishes retry quota for a keyring account replacement after 401", async path => { + clearAccountQuota(MAIN_CODEX_ACCOUNT_ID); + resetMainCodexAccountIdentityTrackingForTests(); + fs.rmSync(join(home, "auth.json")); + setNativeMainRefreshResolutionForTests({ + mode: "codex", configured: "auto", reason: "codex-compatible", command: "/fixture/codex", version: "0.162.1", + }); + const first = fakeChatGptJwt("retry-account-a"); + const replacement = fakeChatGptJwt("retry-account-b"); + if (path.endsWith("compact")) saveCodexAccountCredential(OTHER_ACCOUNT_ID, { + accessToken: "other-access", refreshToken: "other-refresh", expiresAt: Date.now() + 3_600_000, + chatgptAccountId: "account-other", + }); + let token = first; + const refreshes: boolean[] = []; + const nativeMainRefreshDependencies = { codexAuthStatus: async ({ refresh }: { refresh: boolean }) => { + refreshes.push(refresh); + if (refresh) token = replacement; + return { authMethod: "chatgpt", authToken: token, requiresOpenaiAuth: true }; + } }; + const sends: Headers[] = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + if (!url.pathname.endsWith("/responses") && !url.pathname.endsWith("/responses/compact")) { + return Response.json({ rate_limit: { primary_window: { used_percent: 10 } } }); + } + sends.push(new Headers(init?.headers)); + if (sends.length === 1) return Response.json({ error: { message: "expired bearer" } }, { status: 401 }); + if (sends.length === 3) return Response.json({ id: "resp_other", object: "response", status: "completed", output: [] }); + return Response.json({ id: "resp_replacement", object: "response", status: "completed", output: [] }, { + status: path.endsWith("compact") ? 429 : 200, + headers: { "x-codex-primary-used-percent": "37", "x-codex-primary-window-minutes": "300", + "x-codex-primary-reset-at": String(Math.floor(Date.now() / 1000) + 600) }, + }); + }) as typeof fetch; + const req = request(path); + const cfg = config({ secondAccount: path.endsWith("compact") }); + await getValidMainAccountToken(nativeMainRefreshDependencies); + const log = { model: "", provider: "" } as RequestLogContext; + const response = path === "/v1/responses" + ? await handleResponses(req, cfg, log, { nativeMainRefreshDependencies }) + : await handleResponsesCompact(req, cfg, log, undefined, undefined, { nativeMainRefreshDependencies }); + const responseBody = await response.text(); + expect({ status: response.status, error: response.status === 200 ? undefined : responseBody }).toEqual({ status: 200, error: undefined }); + expect(sends.map(headers => headers.get("authorization"))).toEqual([ + `Bearer ${first}`, `Bearer ${replacement}`, ...(path.endsWith("compact") ? ["Bearer other-access"] : []), + ]); + expect(sends.map(headers => headers.get("chatgpt-account-id"))).toEqual([ + "retry-account-a", "retry-account-b", ...(path.endsWith("compact") ? ["account-other"] : []), + ]); + expect(refreshes.filter(Boolean)).toHaveLength(1); + expect(getAccountQuota(MAIN_CODEX_ACCOUNT_ID)?.shortPercent).toBe(37); + expect(getMainPolicyQuota()?.shortPercent).toBe(37); + }, + ); + test.each(["/v1/responses", "/v1/responses/compact"] as const)( "%s strips caller account identity when a bearer key selects stored Direct", async path => { diff --git a/tests/server/audio-transcriptions.test.ts b/tests/server/audio-transcriptions.test.ts index 7141cc1fe0c..1144dddaaa5 100644 --- a/tests/server/audio-transcriptions.test.ts +++ b/tests/server/audio-transcriptions.test.ts @@ -11,6 +11,7 @@ import { waitForNativeMainStartupGate } from "../../src/codex/native-profile-sta import { clearCodexUpstreamHealth, clearThreadAccountMap } from "../../src/codex/routing"; import * as routing from "../../src/codex/routing"; import * as authContext from "../../src/codex/auth-context"; +import * as nativeMainRefresh from "../../src/codex/native-main-refresh-mode"; import { startServer } from "../../src/server"; import { handleAudioTranscriptions, AUDIO_BODY_MAX_BYTES, AUDIO_FILE_MAX_BYTES } from "../../src/server/audio-transcriptions"; import { resetLifecycleDrainStateForTests } from "../../src/server/lifecycle"; @@ -284,6 +285,52 @@ describe("standalone transcription API", () => { expect((await captured[0]!.formData()).get("model")).toBeNull(); }); + test("stored Direct transcription discovers a keyring-only login", async () => { + const token = fakeChatGptJwt({ chatgpt_account_id: "fixture-keyring-account" }); + nativeMainRefresh.setNativeMainRefreshResolutionForTests({ + mode: "codex", configured: "auto", reason: "codex-compatible", command: "/fake/codex", version: "0.162.1", + }); + const authStatus = spyOn(nativeMainRefresh, "requestCodexAuthStatus").mockResolvedValue({ + authMethod: "chatgpt", authToken: token, requiresOpenaiAuth: true, + }); + try { + const cfg = config(); + cfg.defaultProvider = "openai"; + cfg.providers = { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", codexAccountMode: "direct" } }; + saveConfig(cfg); + expect((await request()).status).toBe(200); + expect(captured).toHaveLength(1); + expect(captured[0]!.headers.get("authorization")).toBe(`Bearer ${token}`); + expect(captured[0]!.headers.get("chatgpt-account-id")).toBe("fixture-keyring-account"); + } finally { + nativeMainRefresh.setNativeMainRefreshResolutionForTests(null); + authStatus.mockRestore(); + } + }); + + test("a temporary keyring lookup failure is retryable without sending audio", async () => { + nativeMainRefresh.setNativeMainRefreshResolutionForTests({ + mode: "codex", configured: "auto", reason: "codex-compatible", command: "/fake/codex", version: "0.162.1", + }); + const authStatus = spyOn(nativeMainRefresh, "requestCodexAuthStatus").mockRejectedValue( + new nativeMainRefresh.CodexAuthStatusError("fixture private lookup detail"), + ); + try { + const cfg = config(); + cfg.defaultProvider = "openai"; + cfg.providers = { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", codexAccountMode: "direct" } }; + saveConfig(cfg); + const response = await request(); + expect(response.status).toBe(503); + expect(response.headers.get("retry-after")).toBe("1"); + expect(await response.text()).not.toContain("fixture private lookup detail"); + expect(captured).toHaveLength(0); + } finally { + nativeMainRefresh.setNativeMainRefreshResolutionForTests(null); + authStatus.mockRestore(); + } + }); + test("stored Direct credentials never inherit a caller account ID", async () => { writeFileSync(join(codex.path, "auth.json"), JSON.stringify({ tokens: { access_token: "fixture-main-access" } })); clearMainAccountInfoCache();