diff --git a/docs-site/src/content/docs/fr/reference/cli/providers-accounts.md b/docs-site/src/content/docs/fr/reference/cli/providers-accounts.md index 89d1e6773f6..25e82d8de21 100644 --- a/docs-site/src/content/docs/fr/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/fr/reference/cli/providers-accounts.md @@ -164,6 +164,15 @@ et la colonne plan/label recouvre le plan, l'e-mail masqué, l'étiquette et la ### `ocx account list [provider] [--json] [--all]` +Les comptes ChatGPT enregistrés dans le pool restent consultables sur les hôtes sans répertoire +Codex natif, y compris dans le tableau de bord et lors de l'actualisation des comptes. Cette consultation +ne crée pas de répertoire Codex et ne déconnecte pas le compte principal natif. Désactiver l'intégration +du client Codex ne désactive pas l'observation du quota d'une connexion native existante. Un chemin +`CODEX_HOME` explicite invalide continue de produire une erreur de configuration. Un stockage +`OPENCODEX_HOME` non sûr produit une erreur même si le répertoire Codex natif est absent. La résolution +du chemin Codex par défaut renvoie également une erreur s'il désigne un fichier, un lien symbolique +dont la cible n'existe pas, ou si son répertoire parent ne peut pas être traversé. + Sans fournisseur, répertorie le groupe de comptes Codex, les comptes OAuth et les groupes de clés API configurés. Les fournisseurs vides sont ignorés à moins que `--all` soit présent. Avec un fournisseur, répertorie uniquement cette famille d’informations d’identification. La sortie destinée aux utilisateurs utilise `PROVIDER TYPE ID PLAN/LABEL PRIORITY STATUS` ; une ligne Codex sélectionnée manuellement porte la mention diff --git a/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md b/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md index f920a33c459..7e2a2c06150 100644 --- a/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md @@ -131,6 +131,14 @@ Codex pool selection applies to the next request after clearing existing affinit ### `ocx account list [provider] [--json] [--all] [--quota [--refresh]]` +保存済みの ChatGPT プールアカウントは、ネイティブ Codex ホームがないホストでも、 +ダッシュボードやアカウント更新時を含めて一覧表示できます。一覧表示によって Codex ホームが +作成されたり、ネイティブのメインアカウントからサインアウトしたりすることはありません。 +Codex クライアント連携を無効にしても、既存のネイティブログインのクォータ観測は無効になりません。 +明示的に指定した `CODEX_HOME` のパスが無効な場合は、引き続き設定エラーを返します。 +`OPENCODEX_HOME` のストレージが安全でない場合は、ネイティブ Codex ホームがなくてもエラーを返します。 +既定の Codex ホームのパス解決では、ファイル、リンク先が存在しないシンボリックリンク、辿れない親ディレクトリもエラーになります。 + プロバイダーを使用しない場合、Codex プール、OAuth アカウント、および設定された API キー プールが一覧表示されます。 `--all` が存在しない限り、空のプロバイダーはスキップされます。プロバイダーを使用すると、その資格情報ファミリーのみがリストされます。人間の出力では `PROVIDER TYPE ID PLAN/LABEL PRIORITY STATUS` を使用します。手動で選択した Codex 行には `selected` というマークが付けられます。利用可能な Kiro アカウントが 2 つ以上保存されている場合、既定では 429 を受けると別のアカウントへ自動的に切り替え、既知の残り利用枠が最も多いアカウントを優先します。この切り替えはアカウントの存在によって有効になり、無効にはできません。`oauthAccountFailover.enabled: false` が断るのは 429 復旧ではなく、送信前にアカウントを選ぶ動作だけです。`ocx account login kiro` はアカウントを 1 件ずつプールへ追加します。結果が空であっても成功です。 `--json` は次を返します: ```text diff --git a/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md b/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md index a60a7f21685..a0dae108083 100644 --- a/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md @@ -241,12 +241,28 @@ Codex pool selection applies to the next request after clearing existing affinit ### `ocx account list [provider] [--json] [--all] [--quota [--refresh]]` +저장된 ChatGPT 풀 계정은 네이티브 Codex 홈이 없는 호스트에서도 대시보드와 계정 새로 고침을 +포함해 계속 나열할 수 있습니다. 목록 조회는 Codex 홈을 만들거나 네이티브 메인 계정에서 +로그아웃하지 않습니다. Codex 클라이언트 통합을 비활성화해도 기존 네이티브 로그인의 할당량 +관측은 비활성화되지 않습니다. 명시적으로 지정한 `CODEX_HOME` 경로가 유효하지 않으면 계속 +설정 오류를 반환합니다. `OPENCODEX_HOME` 저장소가 안전하지 않으면 네이티브 Codex 홈이 +없어도 오류를 반환합니다. +기본 Codex 홈의 경로를 확인할 때도 파일, 대상이 없는 심볼릭 링크, 탐색할 수 없는 상위 디렉터리는 오류를 발생시킵니다. + 제공자를 지정하지 않으면 Codex 풀, OAuth 계정, 설정된 API 키 풀을 나열합니다. `--all`이 없으면 비어 있는 제공자는 건너뜁니다. 제공자를 지정하면 해당 자격 증명 계열만 나열합니다. 사람이 보는 출력은 `PROVIDER TYPE ID PLAN/LABEL PRIORITY STATUS` 형식을 사용하며, 수동으로 선택한 Codex 행에는 `selected`가 표시됩니다. 사용 가능한 Kiro 계정이 두 개 이상 저장되어 있으면 기본적으로 429 응답 시 다른 계정으로 자동 전환하며, 알려진 잔여 할당량이 가장 많은 계정을 우선합니다. 이 전환은 계정 존재만으로 활성화되며 끌 수 없습니다. `oauthAccountFailover.enabled: false`는 429 복구가 아니라 요청을 보내기 전 계정을 고르는 동작만 거부합니다. `ocx account login kiro`는 계정을 한 번에 하나씩 풀에 추가합니다. 빈 결과도 성공입니다. `--json`은 다음을 반환합니다: ```text { accounts: AccountRow[], notes: string[] } ``` +Codex JSON 행에는 등록된 `id`와 같은 `slotId`, 그리고 `native-main` 또는 `pool`인 +`slotKind`가 포함됩니다. 프록시가 상위 계정과 사용자 식별값을 모두 확인하면 +`uai1_` 뒤에 소문자 16진수 64자리가 붙는 `upstreamAccountKey`도 제공합니다. +같은 `openai` 제공자에서 키가 같으면 한 상위 사용자의 슬롯이며, 각 슬롯과 한도 상태는 +그대로 유지해야 합니다. 키는 이 설치 환경에 한정되고 관리자 토큰이 바뀌면 달라질 수 +있습니다. 키가 없을 때는 가려진 이메일이나 한도가 비슷하다는 이유로 묶지 않습니다. +상위 계정 ID·사용자 식별값·인증 정보 원문은 목록에 포함되지 않습니다. + ### `ocx account current [--json]` 활성 계정이나 키를 보여줍니다. 수동 고정이 없는 Codex 풀은 우선순위를 고려한 자동 선택을 보고합니다. 가장 우선순위가 높은 적격 tier를 고르고, 그 tier 안에서 할당량 라우팅 기준으로 가장 적게 사용한 항목을 선택합니다. 활성 자격 증명이 없는 다른 계열은 그 상태를 보고하고도 종료 코드 0으로 끝납니다. `--json`은 다음을 반환합니다: diff --git a/docs-site/src/content/docs/reference/cli/providers-accounts.md b/docs-site/src/content/docs/reference/cli/providers-accounts.md index 30a9732d990..46a343e2b85 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -674,6 +674,13 @@ changes Orca files, or changes the active pool account. ### `ocx account list [provider] [--json] [--all] [--quota [--refresh]]` +Stored ChatGPT pool accounts remain listable on hosts without a native Codex home, including in the +dashboard and on account refresh. Listing does not create a Codex home or sign out the native main +account. Disabling the Codex client integration does not disable an existing native login's quota +observation; invalid explicit `CODEX_HOME` paths still report a configuration error. Unsafe +`OPENCODEX_HOME` storage reports an error even when the native Codex home is missing. +Default-home resolution also reports errors for files, dangling links, and parent directories that cannot be traversed. + Without a provider, lists the Codex pool, OAuth accounts, and configured API-key pools. Empty providers are skipped unless `--all` is present. With a provider, lists only that credential family. Human output uses `PROVIDER TYPE ID PLAN/LABEL PRIORITY STATUS`; a manually chosen Codex row is marked @@ -691,6 +698,14 @@ returns: { accounts: AccountRow[], notes: string[] } ``` +Codex JSON rows include `slotId` (the registered `id`) and `slotKind` (`native-main` or +`pool`). When the proxy can verify both the upstream account and user identity, it also +includes `upstreamAccountKey` (`uai1_` followed by 64 lowercase hexadecimal characters). +Equal keys within the `openai` provider identify slots for the same upstream user; keep +each slot and its quota state separately. The key is local to this installation and can +change when its administrator token rotates. If it is absent, do not infer a match from +masked email or quota values. Raw upstream IDs, user claims and credentials are not listed. + An empty provider listing names the next command in human output and JSON `notes`: `ocx account login ` for OAuth/Codex, or `ocx account add-key ` with a human-controlled piped stdin source for API keys. An empty global listing diff --git a/docs-site/src/content/docs/ru/reference/cli/providers-accounts.md b/docs-site/src/content/docs/ru/reference/cli/providers-accounts.md index 87a58230f1d..c1a53a5103f 100644 --- a/docs-site/src/content/docs/ru/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/ru/reference/cli/providers-accounts.md @@ -154,6 +154,14 @@ label и masked key. ### `ocx account list [provider] [--json] [--all]` +Сохранённые аккаунты пула ChatGPT доступны для просмотра даже на хостах без нативного каталога +Codex, в том числе в dashboard и при обновлении аккаунтов. Просмотр списка не создаёт каталог +Codex и не выполняет выход из нативного основного аккаунта. Отключение интеграции клиента Codex +не отключает наблюдение за квотой существующего нативного входа. Некорректный явно заданный +путь `CODEX_HOME` по-прежнему вызывает ошибку конфигурации. Небезопасное хранилище +`OPENCODEX_HOME` вызывает ошибку даже при отсутствии нативного каталога Codex. +При разрешении пути к каталогу Codex по умолчанию также возвращается ошибка, если он является файлом, ссылкой на отсутствующий объект или его родительский каталог не позволяет пройти по пути. + Без провайдера команда показывает пул Codex, OAuth-аккаунты и настроенные API-key pool'ы. Пустые провайдеры пропускаются, если не задан `--all`. С провайдером выводится только это семейство credential'ов. Human-output использует формат diff --git a/docs-site/src/content/docs/tr/reference/cli/providers-accounts.md b/docs-site/src/content/docs/tr/reference/cli/providers-accounts.md index acb18d2c2a1..184e193ca23 100644 --- a/docs-site/src/content/docs/tr/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/tr/reference/cli/providers-accounts.md @@ -175,6 +175,14 @@ kullanılamadığında atlanır): ### `ocx account list [provider] [--json] [--all]` +Kayıtlı ChatGPT havuz hesapları, yerel Codex ana dizini olmayan makinelerde de listelenebilir; +buna pano ve hesap yenileme işlemleri dahildir. Listeleme, Codex ana dizini oluşturmaz ve yerel ana +hesabın oturumunu kapatmaz. Codex istemci entegrasyonunu devre dışı bırakmak, mevcut bir yerel oturumun +kota gözlemini devre dışı bırakmaz. Açıkça belirtilmiş geçersiz `CODEX_HOME` yolları yine yapılandırma +hatası döndürür. Güvenli olmayan `OPENCODEX_HOME` depolaması, yerel Codex ana dizini eksik olsa bile +hata döndürür. Varsayılan Codex ana dizini yolu çözümlenirken dosyalar, hedefi bulunmayan sembolik +bağlantılar ve geçiş yapılamayan üst dizinler de hataya neden olur. + Bir sağlayıcı olmadan Codex havuzunu, OAuth hesaplarını ve yapılandırılmış API anahtarı havuzlarını listeler. `--all` bulunmadıkça boş sağlayıcılar atlanır. Bir sağlayıcı ile yalnızca bu kimlik bilgisi ailesini listeler. İnsan çıktısı diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/providers-accounts.md b/docs-site/src/content/docs/zh-cn/reference/cli/providers-accounts.md index 204e22636e0..d30ce4d9ea7 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/providers-accounts.md @@ -149,6 +149,12 @@ OAuth 账号会显示为 `Account N`,而 plan/label 列会在 plan、屏蔽后 ### `ocx account list [provider] [--json] [--all] [--quota [--refresh]]` +即使主机上没有原生 Codex 主目录,保存的 ChatGPT 池账号仍可列出,包括在仪表盘中和刷新账号时。 +查询列表不会创建 Codex 主目录,也不会退出原生主账号。禁用 Codex 客户端集成不会停止 +观测已有原生登录的配额;显式设置的 `CODEX_HOME` 路径无效时,仍会报告配置错误。 +如果 `OPENCODEX_HOME` 存储不安全,即使没有原生 Codex 主目录,也会报告错误。 +解析默认 Codex 主目录路径时,如果该路径是文件、目标不存在的符号链接,或父目录无法遍历,也会报告错误。 + 不指定提供方时,会列出 Codex 池、OAuth 账号和已配置的 API 密钥池。除非提供 `--all`,否则会跳过空的提供方。指定提供方时,只列出该凭据家族。人类可读输出 使用 `PROVIDER TYPE ID PLAN/LABEL PRIORITY STATUS`;手动选中的 Codex 行会标记为 `selected`。 diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/providers-accounts.md b/docs-site/src/content/docs/zh-tw/reference/cli/providers-accounts.md index 1fcf6399416..e5e3c6dd68f 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli/providers-accounts.md @@ -113,6 +113,11 @@ Codex 池選擇套用於清除既有親和性後的下一個請求;進行中 ### `ocx account list [provider] [--json] [--all] [--quota [--refresh]]` +即使主機沒有原生 Codex 主目錄,仍可列出已儲存的 ChatGPT 池帳號,包括在儀表板中及重新整理帳號時。 +列出帳號不會建立 Codex 主目錄,也不會將原生主帳號登出。停用 Codex 用戶端整合不會停用對既有原生登入的額度觀測; +明確設定但無效的 `CODEX_HOME` 路徑仍會回報設定錯誤。若 `OPENCODEX_HOME` 儲存空間不安全,即使原生 Codex 主目錄不存在,也會回報錯誤。 +解析預設 Codex 主目錄路徑時,若該路徑是檔案、目標不存在的符號連結,或父目錄無法遍歷,也會回報錯誤。 + 未指定供應商時,列出 Codex 池、OAuth 帳號與已設定的 API-key 池。除非存在 `--all`,否則空的供應商會被跳過。指定供應商時,僅列出該憑證家族。人類輸出使用 `PROVIDER TYPE ID PLAN/LABEL STATUS`;手動選擇的 Codex 列標記為 `selected`。當儲存了兩個以上符合資格的 Kiro 帳號時,預設情況下 429 會自動輪換至另一個帳號,並優先選擇已知剩餘額度最多的帳號;輪換由帳號存在與否驅動,且無法關閉;`oauthAccountFailover.enabled: false` 拒絕的是送出前的帳號優選,而非 429 復原。`ocx account login kiro` 每次將一個帳號加入池中。空結果仍為成功。`--json` 回傳: ```text diff --git a/scripts/test-layout/layout-codex-collision.json b/scripts/test-layout/layout-codex-collision.json index c4709d021d7..d35f56aaaf7 100644 --- a/scripts/test-layout/layout-codex-collision.json +++ b/scripts/test-layout/layout-codex-collision.json @@ -1,5 +1,6 @@ { "codex-stored-main-duplicate.test.ts": "codex-integration", + "codex-account-slot-identity.test.ts": "codex-integration", "codex-pool-caller-fallback-policy.test.ts": "codex-integration", "codex-windows-current-sid.test.ts": "codex-integration" } diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index e2ec2449dfd..a98ef2bb7f2 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -65,7 +65,7 @@ "jev-decision-scope.test.ts": "server", "server-combo-plan-model-refusal.test.ts": "server", "codex-main-token-invalidated.test.ts": "codex-integration", - "codex-main-grant-refusal.test.ts": "codex-integration", + "codex-main-grant-refusal.test.ts": "codex-integration", "codex-auth-headless-accounts.test.ts": "codex-integration", "responses-alternate-main-cancellation.test.ts": "responses", "codex-shim-path-readiness.test.ts": "codex-integration", "codex-shim-fnm.test.ts": "codex-integration", @@ -466,7 +466,7 @@ "claude-system-env-auto.test.ts": "claude-integration", "cleanup-orphaned-workflows.test.ts": "ci-workflows", "clearable-deadline.test.ts": "lib", - "cli-account-alias-target.test.ts": "cli", + "cli-account-alias-target.test.ts": "cli", "cli-account-slot-identity.test.ts": "cli", "cli-account-cancel-flow.test.ts": "cli", "cli-account-kiro-device.test.ts": "cli", "cli-account-orca-import.test.ts": "cli", "cli-account-pin-drain.test.ts": "cli", diff --git a/src/cli/account-api.ts b/src/cli/account-api.ts index 6ea43d96d9c..7a009dcaefd 100644 --- a/src/cli/account-api.ts +++ b/src/cli/account-api.ts @@ -23,6 +23,9 @@ export interface AccountRow { provider: string; type: AccountType; id: string; + slotId?: string; + slotKind?: "native-main" | "pool"; + upstreamAccountKey?: string; label?: string; email?: string; /** @@ -329,6 +332,9 @@ export interface ProviderQuotaReportDto { interface CodexAccountDto { id: string; + slotId?: string; + slotKind?: "native-main" | "pool"; + upstreamAccountKey?: string; alias?: string; email?: string; plan?: string; @@ -401,6 +407,11 @@ export async function fetchCodexRows( provider: "openai", type: "codex" as const, id: a.id, + slotId: a.id, + slotKind: a.id === "__main__" ? "native-main" as const : "pool" as const, + ...(a.slotId === a.id && typeof a.upstreamAccountKey === "string" + && /^uai1_[0-9a-f]{64}$/.test(a.upstreamAccountKey) + ? { upstreamAccountKey: a.upstreamAccountKey } : {}), label: a.alias ?? a.plan ?? a.email, email: a.email, plan: a.plan, diff --git a/src/codex/auth-api/account-identity.ts b/src/codex/auth-api/account-identity.ts new file mode 100644 index 00000000000..53d8f296357 --- /dev/null +++ b/src/codex/auth-api/account-identity.ts @@ -0,0 +1,14 @@ +import { createHmac } from "node:crypto"; + +/** A local, versioned join key for two slots backed by the same upstream user. */ +export function codexUpstreamAccountKey( + adminToken: string | null, + accountId: string | null | undefined, + userId: string | null | undefined, +): string | undefined { + if (!adminToken || !accountId || !userId || accountId.length > 512 || userId.length > 512) return undefined; + return `uai1_${createHmac("sha256", adminToken) + .update("opencodex-upstream-account-v1\0") + .update(JSON.stringify([accountId, userId])) + .digest("hex")}`; +} diff --git a/src/codex/auth-api/account-list.ts b/src/codex/auth-api/account-list.ts index c3046363f1f..f97e58c5232 100644 --- a/src/codex/auth-api/account-list.ts +++ b/src/codex/auth-api/account-list.ts @@ -28,12 +28,16 @@ import type { OAuthAccountHealth, OAuthHealthLabel } from "../../oauth/health"; import { isSelectableCodexPoolAccount } from "../account-id"; import type { AdmissionLease } from "../../lib/admission"; import { tryAcquireNativeMainProfileClaim } from "../native-main-admission"; -import { withNativeMainCredentialClaim, isNativeMainClaimUnavailable } from "./http"; +import { withNativeMainCredentialClaim, isNativeMainClaimUnavailable, isNativeMainObservationUnavailable } from "./http"; import { fetchMainAccountInfoAttempt, EMPTY_MAIN_ACCOUNT_INFO, mainResetCreditsForCurrentIdentity } from "./main-account-probe"; import { fetchPoolAccountQuota, PoolQuotaProbeBusyError, POOL_QUOTA_REFRESH_CONCURRENCY } from "./pool-quota-probe"; import type { PoolQuotaResult } from "./pool-quota-probe"; import { getRuntimeConfig, configuredPoolAccount, mapWithConcurrency } from "./runtime-config"; import { isPassiveRead } from "../../lib/passive-read"; +import { configuredAdminToken } from "../../lib/admin-secrets"; +import { nativeUserIdClaims } from "../reserve-availability"; +import { extractAccountIdClaims } from "../../oauth/chatgpt"; +import { codexUpstreamAccountKey } from "./account-identity"; import { CODEX_MAIN_DUPLICATE_ACTION, configuredCodexMainDuplicate, getObservedMainCodexCollisionIdentity } from "../collision-identity"; export function quotaForPlan | StoredAccountQuota | null>( @@ -109,6 +113,7 @@ export function poolAccountDto( paused: boolean, priority: number, maskEmails: boolean, + upstreamAccountKey?: string, ): CodexAuthAccountDto { const plan = codexPlanValue(account.plan); const quota = quotaForPlan(quotaResult.quota, plan); @@ -136,6 +141,9 @@ export function poolAccountDto( const duplicateMain = configuredCodexMainDuplicate(config, account.id); return { id: account.id, + slotId: account.id, + slotKind: "pool", + ...(upstreamAccountKey ? { upstreamAccountKey } : {}), email: projectEmail(account.email, maskEmails) ?? account.email, ...(account.alias !== undefined ? { alias: account.alias } : {}), ...(plan !== undefined ? { plan } : {}), @@ -166,6 +174,11 @@ export function poolAccountDto( export interface CodexAuthAccountDto { id: string; + /** Registered slot identity; equal to id, including __main__ for native Codex. */ + slotId: string; + slotKind: "native-main" | "pool"; + /** Opaque local join key, present only with account and user claims. */ + upstreamAccountKey?: string; alias?: string; email: string; plan?: string | null; @@ -285,6 +298,7 @@ export async function listCodexAuthAccountsSnapshot( pruneCodexCredits([MAIN_CODEX_ACCOUNT_ID, ...poolAccounts.map(account => account.id)]); // One redaction decision for the whole snapshot, read once from the operator's config (#3859). const maskEmails = emailMaskingEnabled(runtimeConfig); + const identitySecret = configuredAdminToken(); // A passive read (a Remote Link Child's, #6959) reports what this machine already observed: no // token refresh, no WHAM probe, and so none of the plan or credential writes they make. const passive = isPassiveRead(); @@ -292,6 +306,7 @@ export async function listCodexAuthAccountsSnapshot( ? { info: getMainAccountInfoCache() ?? EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: false, hasCredential: false, identityGeneration: captureMainAccountIdentityGeneration() } : await fetchMainAccountInfoAttempt(forceRefresh, 1, undefined, false, forceRefresh, false, runtimeConfig); + const observedMainIdentity = getObservedMainCodexCollisionIdentity(); const refreshedPool = await mapWithConcurrency(poolAccounts, POOL_QUOTA_REFRESH_CONCURRENCY, async account => { const cred = getCodexAccountCredential(account.id); let quotaResult: PoolQuotaResult; @@ -363,6 +378,10 @@ export async function listCodexAuthAccountsSnapshot( const dtoAccount = generationLive && quotaResult.freshPlan ? { ...currentAccount, plan: quotaResult.freshPlan } : currentAccount; + const user = nativeUserIdClaims(currentCredential.accessToken); + const accountClaim = extractAccountIdClaims(currentCredential.accessToken); + const verifiedAccountId = !accountClaim.conflict && accountClaim.accountId === currentCredential.chatgptAccountId + ? accountClaim.accountId : undefined; return [poolAccountDto( runtimeConfig, dtoAccount, @@ -371,6 +390,8 @@ export async function listCodexAuthAccountsSnapshot( isCodexAccountPaused(runtimeConfig, accountId), getCodexAccountPriority(runtimeConfig, accountId), maskEmails, + codexUpstreamAccountKey(identitySecret, verifiedAccountId, + user.conflict ? undefined : user.userId), )]; }); const projectSnapshot = (mainRefreshRefused = false): CodexAuthAccountsSnapshot => { @@ -402,8 +423,14 @@ export async function listCodexAuthAccountsSnapshot( ? mainResult.terminalAuthFailure === true && liveQuotaRefresh?.status === "http_error" ? "unauthorized" : "refresh_failed" : mainHealth.status === "reauth_required" ? mainHealth.reason : undefined; + const mainIdentity = mainSnapshotLive && getObservedMainCodexCollisionIdentity() === observedMainIdentity + ? observedMainIdentity : undefined; + const mainAccountKey = codexUpstreamAccountKey(identitySecret, mainIdentity?.accountId, mainIdentity?.userId); const main: CodexAuthAccountDto = { id: MAIN_CODEX_ACCOUNT_ID, + slotId: MAIN_CODEX_ACCOUNT_ID, + slotKind: "native-main", + ...(mainAccountKey ? { upstreamAccountKey: mainAccountKey } : {}), email: projectEmail(mainInfo.email, maskEmails) ?? "Codex App login", plan: mainInfo.plan, ...(liveQuotaRefresh ? { quotaRefresh: liveQuotaRefresh } : {}), @@ -442,7 +469,7 @@ export async function listCodexAuthAccountsSnapshot( try { return await withNativeMainCredentialClaim(async () => projectSnapshot(isMainAccountRefreshGrantRejected())); } catch (error) { - if (isNativeMainClaimUnavailable(error)) return projectSnapshot(); + if (isNativeMainObservationUnavailable(error)) return projectSnapshot(); throw error; } finally { projectionLease.release(); diff --git a/src/codex/auth-api/http.ts b/src/codex/auth-api/http.ts index 55c6983178a..61fe8c24199 100644 --- a/src/codex/auth-api/http.ts +++ b/src/codex/auth-api/http.ts @@ -1,3 +1,5 @@ +import { codexHomeIsAbsent } from "../codex-home-owner"; +import { getCodexHome } from "../paths"; import { withNativeMainSharedClaim } from "../native-main-claim"; import { resolveNativeProfileContext } from "../native-profile-store"; import { NativeProfileError } from "../native-profile-types"; @@ -7,6 +9,17 @@ export function isNativeMainClaimUnavailable(error: unknown): error is NativePro && (error.code === "NATIVE_MAIN_CLAIM_BUSY" || error.code === "NATIVE_MAIN_CLAIM_UNAVAILABLE"); } +/** Missing native homes leave reads unobserved; credential operations still require their claim. */ +export function isNativeMainObservationUnavailable(error: unknown): error is NativeProfileError { + if (isNativeMainClaimUnavailable(error)) return true; + if (!(error instanceof NativeProfileError) || error.code !== "CODEX_HOME_UNAVAILABLE") return false; + try { + return codexHomeIsAbsent(getCodexHome()); + } catch { + return false; + } +} + export function withNativeMainCredentialClaim(operation: () => Promise): Promise { return withNativeMainSharedClaim(resolveNativeProfileContext(), operation); } diff --git a/src/codex/auth-api/main-account-probe.ts b/src/codex/auth-api/main-account-probe.ts index a8ae999f06f..d3aebe4da53 100644 --- a/src/codex/auth-api/main-account-probe.ts +++ b/src/codex/auth-api/main-account-probe.ts @@ -23,7 +23,7 @@ import type { AdmissionLease } from "../../lib/admission"; import { nonEmptyPlan } from "./runtime-config"; import { tryAcquireNativeMainProfileClaim } from "../native-main-admission"; import { WHAM_REQUEST_TIMEOUT_MS } from "../quota-recovery-timing"; -import { withNativeMainCredentialClaim, isNativeMainClaimUnavailable } from "./http"; +import { withNativeMainCredentialClaim, isNativeMainObservationUnavailable } from "./http"; import { readMainAuthErrorCode, currentQuotaDispatchSequence, nextQuotaDispatchSequence, isQuotaDispatchCurrent, publishQuotaDispatch } from "./pool-quota-probe"; /** @@ -198,7 +198,7 @@ export async function fetchMainAccountInfoAttempt( try { return await withNativeMainCredentialClaim(operation); } catch (error) { - if (isNativeMainClaimUnavailable(error)) { + if (isNativeMainObservationUnavailable(error)) { return { info: EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: false, diff --git a/src/codex/collision-identity.ts b/src/codex/collision-identity.ts index 69124e0df9e..9d899f945e8 100644 --- a/src/codex/collision-identity.ts +++ b/src/codex/collision-identity.ts @@ -1,12 +1,14 @@ -import { extractAccountIdClaims, extractEmail } from "../oauth/chatgpt"; +import { decodeJwtPayload, extractAccountIdClaims, extractEmail } from "../oauth/chatgpt"; import type { CodexAccount, OcxConfig } from "../types"; import { getCodexAccountCredential } from "./account-store"; import type { CodexTokens } from "./auth-collision"; import { captureMainAccountIdentityGeneration, isMainAccountIdentityGenerationLive } from "./main-account-cache"; import { codexPlanKey, extractChatgptPlanType } from "./plan"; +import { nativeUserIdClaims } from "./reserve-availability"; export interface CodexCollisionIdentity { accountId: string; + userId?: string; email?: string | null; plan?: unknown; } @@ -34,19 +36,42 @@ function homeScope(): string { let observed: { identity: CodexCollisionIdentity; homeScope: string; generation: number } | undefined; +function suppressObservedUserId(): void { + if (!observed || observed.homeScope !== homeScope() + || !isMainAccountIdentityGenerationLive(observed.generation)) return; + observed = { ...observed, identity: { ...observed.identity, userId: undefined } }; +} + /** Only existing owned lifecycle observations may publish; never call with a request bearer. */ export function observeMainCodexCollisionIdentity(tokens?: CodexTokens): void { if (!tokens) { observed = undefined; return; } if (typeof tokens.access_token !== "string" || typeof tokens.account_id !== "string" - || (tokens.id_token !== undefined && typeof tokens.id_token !== "string")) return; + || (tokens.id_token !== undefined && typeof tokens.id_token !== "string")) { + suppressObservedUserId(); + return; + } const claims = [extractAccountIdClaims(tokens.id_token), extractAccountIdClaims(tokens.access_token)]; - if (claims.some(claim => claim.conflict)) return; + if (claims.some(claim => claim.conflict)) { suppressObservedUserId(); return; } const accountId = tokens.account_id || claims.find(claim => claim.accountId)?.accountId; - if (!accountId || claims.some(claim => claim.accountId && claim.accountId !== accountId)) return; + if (!accountId || claims.some(claim => claim.accountId && claim.accountId !== accountId)) { + suppressObservedUserId(); + return; + } const emails = new Set([extractEmail(tokens.id_token), extractEmail(undefined, tokens.access_token)] .flatMap(email => normalizedEmail(email) ? [normalizedEmail(email)!] : [])); - if (emails.size > 1) return; + if (emails.size > 1) { suppressObservedUserId(); return; } + const idPayload: unknown = tokens.id_token === undefined ? undefined : decodeJwtPayload(tokens.id_token); + const idTokenMalformed = tokens.id_token !== undefined + && (idPayload === null || typeof idPayload !== "object" || Array.isArray(idPayload)); + const users = [tokens.id_token ? nativeUserIdClaims(tokens.id_token) : undefined, nativeUserIdClaims(tokens.access_token)]; + const userIds = new Set(users.flatMap(user => user?.userId ? [user.userId] : [])); + // A join key needs one token claiming both the account and the user, as a Pool access token does; + // auth.json account_id alone, or claims split across a mixed token set, prove no pairing. + const bound = claims.some((claim, index) => claim.accountId === accountId && users[index]?.userId); + const userId = !idTokenMalformed && !users.some(user => user?.conflict) && bound && userIds.size === 1 + ? [...userIds][0] : undefined; observed = { identity: { accountId, email: [...emails][0], + ...(userId ? { userId } : {}), plan: extractChatgptPlanType(tokens.id_token, tokens.access_token) }, homeScope: homeScope(), generation: captureMainAccountIdentityGeneration() }; } diff --git a/src/codex/native-profile-store.ts b/src/codex/native-profile-store.ts index 7ab797761d6..ae798a55184 100644 --- a/src/codex/native-profile-store.ts +++ b/src/codex/native-profile-store.ts @@ -353,8 +353,9 @@ export function assertNativeProfileLockPath(context: NativeProfileContext): void } export function resolveNativeProfileContext(options: { codexHome?: string; configDir?: string } = {}): NativeProfileContext { - const codexHome = canonicalExistingDirectory(options.codexHome ?? getCodexHome(), "The effective CODEX_HOME"); + // Read-only callers may tolerate an absent native home, but never unsafe OpenCodex storage. const configDir = canonicalizeDirectoryPath(options.configDir ?? getConfigDir()); + const codexHome = canonicalExistingDirectory(options.codexHome ?? getCodexHome(), "The effective CODEX_HOME"); const homeId = nativeProfileHomeId(codexHome); const foldedHomeId = process.platform === "win32" ? legacyWindowsNativeProfileHomeId(codexHome) : homeId; const instanceId = createHash("sha256").update(DOMAIN_INSTANCE).update(configDir).digest("hex"); diff --git a/structure/providers/openai-accounts.md b/structure/providers/openai-accounts.md index f9c7131904c..961cee4f8ac 100644 --- a/structure/providers/openai-accounts.md +++ b/structure/providers/openai-accounts.md @@ -37,6 +37,7 @@ preserving a stale one would block every later migration. ## Model and wire identity +Account-list rows retain each registered `slotId` and `slotKind`; optional `upstreamAccountKey` is a versioned HMAC of the ChatGPT account ID and user claim under the local administrator token. Native identity comes from the owned observation without passive credential reads; missing or conflicting claims omit the key, the native key needs one token that claims both the matching account and the user (an `auth.json` `account_id` alone, or account and user claims split across the ID and access tokens, omit it), Pool rows need the access token's matching account claim, and token rotation changes it. No raw identity or credential is serialized. The optional [native reasoning retention policy](../transports/native-reasoning-retention.md#runtime-boundary) can permit reasoning ciphertext forwarding across a known model or account change on the same canonical ChatGPT destination. It does not change account selection, entitlement, cache isolation, continuation-id ownership or uploaded-file retention; cross-account acceptance is not guaranteed. Native Spark membership and its model-specific request/tool exceptions are removed; the shared @@ -250,9 +251,8 @@ restored from the Accounts picker or Codex Auth through gated recovery: missing from the canonical preset, disabled canonical rows are re-enabled without replacing saved mode or model settings, and noncanonical `openai` rows never receive that recovery path. -`GET /api/codex-auth/accounts?refresh=1` treats missing main credentials, HTTP 401, and allowlisted -terminal 403 codes as `needsReauth`; generic permission failures remain non-terminal, and a -successful main usage refresh clears the runtime mark. +Account listing and explicit refresh return stored pool accounts without creating a missing native `CODEX_HOME`; an unchecked main observation leaves credential/reauth state unchanged. When the native home is accessible, `GET /api/codex-auth/accounts?refresh=1` treats missing main credentials, HTTP 401 responses with a known terminal code, unrecognized HTTP 401 responses when the access token is not verifiably live, and allowlisted terminal 403 responses as `needsReauth`; successful refresh clears the runtime mark. +Generic quota permission failures remain non-terminal; missing-home fallback requires proven absence, so non-directory homes, dangling links, and parent directories that cannot be traversed still fail closed for both default and explicit home paths. Native-profile context resolution validates OpenCodex storage before the native home, so unsafe storage fails closed even when that home is absent; credential operations retain their strict claim requirements. `src/codex/chatgpt-refresh-failure.ts` classifies token-endpoint failures for both stored-pool and native-main refresh. Exact structured terminal codes apply only to authentication-error diff --git a/tests/cli/cli-account-slot-identity.test.ts b/tests/cli/cli-account-slot-identity.test.ts new file mode 100644 index 00000000000..3ae3544d861 --- /dev/null +++ b/tests/cli/cli-account-slot-identity.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, test } from "bun:test"; +import { cmdAccount } from "../../src/cli/account"; +import type { AccountDeps } from "../../src/cli/account-api"; +import type { OcxConfig } from "../../src/types"; + +/** + * `ocx account list --json` carries the slot identity contract from the account-list API. + * Kept out of the #180 matrix file, which sits at its file-size ratchet cap. + */ +function deps(accounts: Array>): AccountDeps { + return { + baseUrl: "http://127.0.0.1:10100", + loadConfigImpl: () => ({ + port: 10100, + defaultProvider: "openai", + providers: { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", codexAccountMode: "pool" } }, + }) as unknown as OcxConfig, + fetchImpl: (async (url: string | URL | Request) => { + const path = new URL(String(url)).pathname; + if (path === "/api/codex-auth/accounts") return Response.json({ accounts }); + if (path === "/api/codex-auth/active") return Response.json({ activeCodexAccountId: "__main__" }); + return Response.json({}); + }) as unknown as typeof fetch, + }; +} + +async function runJson(args: string[], accounts: Array>) { + const lines: string[] = []; + const log = console.log; + console.log = (...parts: unknown[]) => { lines.push(parts.map(String).join(" ")); }; + try { + const code = await cmdAccount(args, deps(accounts)); + return { code, rows: (JSON.parse(lines.join("\n")) as { accounts: Array> }).accounts }; + } finally { + console.log = log; + } +} + +describe("ocx account list: slot identity", () => { + test("official --json output carries the upstream join contract", async () => { + const key = `uai1_${"a".repeat(64)}`; + const result = await runJson(["list", "openai", "--json"], [ + { id: "__main__", slotId: "__main__", slotKind: "native-main", upstreamAccountKey: key, + email: "main@example.test", plan: "pro" }, + { id: "chatgpt_1", slotId: "chatgpt_1", slotKind: "pool", upstreamAccountKey: key, + email: "pool@example.test", plan: "pro" }, + ]); + expect(result.code).toBe(0); + expect(result.rows.filter(row => row.provider === "openai") + .map(row => [row.slotId, row.slotKind, row.upstreamAccountKey])).toEqual([ + ["__main__", "native-main", key], + ["chatgpt_1", "pool", key], + ]); + }); +}); diff --git a/tests/cli/cli-dto-fidelity.test.ts b/tests/cli/cli-dto-fidelity.test.ts index bae0bff7d36..468df021518 100644 --- a/tests/cli/cli-dto-fidelity.test.ts +++ b/tests/cli/cli-dto-fidelity.test.ts @@ -193,6 +193,35 @@ describe("#2703 the projection does not strip the 5h window", () => { }); }); +describe("Codex account identity projection", () => { + test("keeps validated join keys and slot identities while accepting older servers", async () => { + const key = `uai1_${"a".repeat(64)}`; + const fetchImpl = (async (url: string | URL | Request) => Response.json( + String(url).includes("/api/codex-auth/active") + ? { activeCodexAccountId: "__main__" } + : { accounts: [ + { id: "__main__", slotId: "__main__", slotKind: "native-main", upstreamAccountKey: key }, + { id: "pool-a", slotId: "pool-a", slotKind: "pool", upstreamAccountKey: key }, + { id: "pool-b", slotId: "other-slot", upstreamAccountKey: key }, + { id: "pool-legacy" }, + { id: "pool-object", slotId: "pool-object", upstreamAccountKey: { toString: null, valueOf: null } }, + { id: "pool-array", slotId: "pool-array", upstreamAccountKey: [key] }, + ] }, + )) as unknown as typeof fetch; + const { fetchCodexRows } = await import("../../src/cli/account-api"); + const result = await fetchCodexRows({ baseUrl: "http://127.0.0.1:10100", fetchImpl }, + "http://127.0.0.1:10100"); + expect(result.rows.map(row => [row.slotId, row.slotKind, row.upstreamAccountKey])).toEqual([ + ["__main__", "native-main", key], + ["pool-a", "pool", key], + ["pool-b", "pool", undefined], + ["pool-legacy", "pool", undefined], + ["pool-object", "pool", undefined], + ["pool-array", "pool", undefined], + ]); + }); +}); + describe("#2705 access key usage columns", () => { async function listOutput(payload: Record): Promise { const { handleAccessCommand } = await import("../../src/cli/access"); diff --git a/tests/codex-integration/codex-account-slot-identity.test.ts b/tests/codex-integration/codex-account-slot-identity.test.ts new file mode 100644 index 00000000000..e62a1f0f807 --- /dev/null +++ b/tests/codex-integration/codex-account-slot-identity.test.ts @@ -0,0 +1,174 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { saveCodexAccountCredential } from "../../src/codex/account-store"; +import { codexAccountUnusableReason } from "../../src/codex/account-usability"; +import { reconcileMainCodexAccountRuntimeState, resetMainCodexAccountIdentityTrackingForTests } from "../../src/codex/account-lifecycle"; +import type { CodexAuthAccountDto } from "../../src/codex/auth-api/account-list"; +import { handleCodexAuthAPI } from "../../src/codex/auth-api/routes"; +import { clearMainAccountInfoCache } from "../../src/codex/main-account-cache"; +import { setMainAccountPlan } from "../../src/codex/main-account"; +import { clearAccountQuota } from "../../src/codex/quota"; +import { clearCodexUpstreamHealth, clearThreadAccountMap } from "../../src/codex/routing"; +import { runPassiveRead } from "../../src/lib/passive-read"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +/** + * Security proof for the slot identity fields on the account-list API: `slotId`, `slotKind` + * and the opaque `upstreamAccountKey` must carry no upstream account ID, user ID, token, or the + * local admin secret that keys the join. Every sentinel below is distinctive so a substring + * search over the serialized response is a meaningful check. + */ +const ACCOUNT = "acct-sentinel-0f3a9d"; +const USER = "user-sentinel-8c1d42"; +const EMAIL = "slot-proof@example.test"; +const POOL_ID = "slot-proof-pool"; +const NATIVE_REFRESH = "native-refresh-sentinel-5b7e"; +const POOL_REFRESH = "pool-refresh-sentinel-2e9c"; +const ADMIN_TOKEN = `ocx_admin_${"Q7".repeat(21)}Z`; +const ROTATED_ADMIN_TOKEN = `ocx_admin_${"R4".repeat(21)}Y`; +const KEY_FORMAT = /^uai1_[0-9a-f]{64}$/; + +let home: string; +let saved: Record<"CODEX_HOME" | "OPENCODEX_HOME" | "OPENCODEX_ADMIN_AUTH_TOKEN", string | undefined>; +let originalFetch: typeof fetch; +let config: OcxConfig; + +function jwt(options: { account?: string; user?: string; label: string }): string { + return `header.${Buffer.from(JSON.stringify({ + exp: Math.floor(Date.now() / 1000) + 3600, + ...(options.account ? { chatgpt_account_id: options.account } : {}), + "https://api.openai.com/auth": { chatgpt_plan_type: "plus", ...(options.user ? { chatgpt_user_id: options.user } : {}) }, + "https://api.openai.com/profile": { email: EMAIL }, + jti: options.label, + })).toString("base64url")}.sig-${options.label}`; +} + +function writeAdminToken(token: string): void { + writeFileSync(join(process.env.OPENCODEX_HOME!, "admin-api-token"), `${token}\n`); +} + +function writeNative(tokens: Record): void { + writeFileSync(join(process.env.CODEX_HOME!, "auth.json"), JSON.stringify({ tokens })); + reconcileMainCodexAccountRuntimeState(); +} + +function savePool(accessToken: string): void { + saveCodexAccountCredential(POOL_ID, { accessToken, refreshToken: POOL_REFRESH, + chatgptAccountId: ACCOUNT, expiresAt: Date.now() + 3_600_000 }); +} + +async function listOverHttp(): Promise<{ body: string; accounts: CodexAuthAccountDto[] }> { + const url = new URL("http://localhost/api/codex-auth/accounts"); + const response = await runPassiveRead(() => handleCodexAuthAPI(new Request(url), url, config)); + expect(response?.status).toBe(200); + const body = await response!.text(); + return { body, accounts: (JSON.parse(body) as { accounts: CodexAuthAccountDto[] }).accounts }; +} + +function resetRuntime(): void { + clearThreadAccountMap(); + clearCodexUpstreamHealth(); + clearAccountQuota(); + clearMainAccountInfoCache(); + setMainAccountPlan(null); + resetMainCodexAccountIdentityTrackingForTests(); +} + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "ocx-slot-identity-")); + saved = { CODEX_HOME: process.env.CODEX_HOME, OPENCODEX_HOME: process.env.OPENCODEX_HOME, + OPENCODEX_ADMIN_AUTH_TOKEN: process.env.OPENCODEX_ADMIN_AUTH_TOKEN }; + process.env.CODEX_HOME = join(home, "codex"); + process.env.OPENCODEX_HOME = join(home, "ocx"); + delete process.env.OPENCODEX_ADMIN_AUTH_TOKEN; + mkdirSync(process.env.CODEX_HOME, { recursive: true }); + mkdirSync(process.env.OPENCODEX_HOME, { recursive: true }); + originalFetch = globalThis.fetch; + // A passive read must not probe upstream; any request here is a test failure. + globalThis.fetch = (async () => { throw new Error("unexpected network request"); }) as unknown as typeof fetch; + config = { port: 10100, providers: {}, defaultProvider: "openai", codexMainAccountHardLock: false, + codexAccounts: [{ id: POOL_ID, email: EMAIL, plan: "plus", isMain: false }] }; + resetRuntime(); +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + resetRuntime(); + for (const [name, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + removeTreeWithRetry(home); +}); + +describe("Codex account slot identity metadata", () => { + test("the account-list response carries no upstream identifiers, tokens, or admin secret", async () => { + writeAdminToken(ADMIN_TOKEN); + const nativeAccess = jwt({ account: ACCOUNT, user: USER, label: "native-access" }); + const nativeId = jwt({ account: ACCOUNT, user: USER, label: "native-id" }); + const poolAccess = jwt({ account: ACCOUNT, user: USER, label: "pool-access" }); + writeNative({ access_token: nativeAccess, id_token: nativeId, refresh_token: NATIVE_REFRESH, account_id: ACCOUNT }); + savePool(poolAccess); + + const { body, accounts } = await listOverHttp(); + const main = accounts.find(row => row.id === "__main__")!; + const pool = accounts.find(row => row.id === POOL_ID)!; + expect(main).toMatchObject({ slotId: "__main__", slotKind: "native-main" }); + expect(pool).toMatchObject({ slotId: POOL_ID, slotKind: "pool" }); + expect(main.upstreamAccountKey).toMatch(KEY_FORMAT); + expect(pool.upstreamAccountKey).toBe(main.upstreamAccountKey); + + const secrets = [ACCOUNT, USER, NATIVE_REFRESH, POOL_REFRESH, ADMIN_TOKEN, nativeAccess, nativeId, poolAccess, + ...[nativeAccess, nativeId, poolAccess].flatMap(token => token.split(".").slice(1))]; + for (const secret of secrets) expect(body).not.toContain(secret); + + // The key is keyed by the local admin secret: rotating it changes every key, so a holder of + // the identifiers alone cannot compute or confirm one. + writeAdminToken(ROTATED_ADMIN_TOKEN); + const rotated = await listOverHttp(); + const rotatedMain = rotated.accounts.find(row => row.id === "__main__")!; + expect(rotatedMain.upstreamAccountKey).toMatch(KEY_FORMAT); + expect(rotatedMain.upstreamAccountKey).not.toBe(main.upstreamAccountKey); + expect(rotated.body).not.toContain(ROTATED_ADMIN_TOKEN); + }); + + test("a native account_id without any token account claim yields no join key", async () => { + writeAdminToken(ADMIN_TOKEN); + writeNative({ access_token: jwt({ user: USER, label: "native-unclaimed" }), account_id: ACCOUNT }); + savePool(jwt({ account: ACCOUNT, user: USER, label: "pool-claimed" })); + + const { accounts } = await listOverHttp(); + expect(accounts.find(row => row.id === "__main__")).toMatchObject({ slotId: "__main__", slotKind: "native-main" }); + expect(accounts.find(row => row.id === "__main__")?.upstreamAccountKey).toBeUndefined(); + expect(accounts.find(row => row.id === POOL_ID)?.upstreamAccountKey).toMatch(KEY_FORMAT); + // The unverified account ID still guards against storing the native login as a Pool duplicate. + expect(codexAccountUnusableReason(config, POOL_ID)).toBe("duplicate_main"); + }); + + test("account and user claims split across native tokens yield no join key", async () => { + writeAdminToken(ADMIN_TOKEN); + // A stale or mixed token set: the ID token proves only the account, the access token only the user. + writeNative({ id_token: jwt({ account: ACCOUNT, label: "native-id-account-only" }), + access_token: jwt({ user: USER, label: "native-access-user-only" }), account_id: ACCOUNT }); + savePool(jwt({ account: ACCOUNT, user: USER, label: "pool-claimed" })); + + const { accounts } = await listOverHttp(); + expect(accounts.find(row => row.id === "__main__")?.upstreamAccountKey).toBeUndefined(); + expect(accounts.find(row => row.id === POOL_ID)?.upstreamAccountKey).toMatch(KEY_FORMAT); + }); + + test("one native token claiming both account and user still joins the Pool row", async () => { + writeAdminToken(ADMIN_TOKEN); + writeNative({ id_token: jwt({ account: ACCOUNT, user: USER, label: "native-id-bound" }), + access_token: jwt({ user: USER, label: "native-access-user" }), account_id: ACCOUNT }); + savePool(jwt({ account: ACCOUNT, user: USER, label: "pool-claimed" })); + + const { accounts } = await listOverHttp(); + const mainKey = accounts.find(row => row.id === "__main__")?.upstreamAccountKey; + expect(mainKey).toMatch(KEY_FORMAT); + expect(accounts.find(row => row.id === POOL_ID)?.upstreamAccountKey).toBe(mainKey); + }); +}); diff --git a/tests/codex-integration/codex-auth-headless-accounts.test.ts b/tests/codex-integration/codex-auth-headless-accounts.test.ts new file mode 100644 index 00000000000..d5d1c815298 --- /dev/null +++ b/tests/codex-integration/codex-auth-headless-accounts.test.ts @@ -0,0 +1,277 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { getCodexAccountCredential, saveCodexAccountCredential } from "../../src/codex/account-store"; +import { resetMainCodexAccountIdentityTrackingForTests } from "../../src/codex/account-lifecycle"; +import { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth } from "../../src/codex/account-runtime-state"; +import type { CodexAuthAccountDto } from "../../src/codex/auth-api/account-list"; +import { withNativeMainCredentialClaim } from "../../src/codex/auth-api/http"; +import { handleCodexAuthAPI } from "../../src/codex/auth-api/routes"; +import * as codexHome from "../../src/codex/home"; +import { + captureMainAccountIdentityGeneration, clearMainAccountInfoCache, + getMainAccountCredentialPresence, getMainAccountInfoCache, +} from "../../src/codex/main-account-cache"; +import { MAIN_CODEX_ACCOUNT_ID } from "../../src/codex/main-account"; +import { getCodexHome } from "../../src/codex/paths"; +import { clearAccountQuota } from "../../src/codex/quota"; +import { resetQuotaQueryBackoffForTests } from "../../src/codex/quota-query-backoff"; +import { resetLifecycleDrainStateForTests } from "../../src/server/lifecycle"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +let root: string; +let nativeHome: string; +let previousOcxHome: string | undefined; +let previousCodexHome: string | undefined; +let defaultHomeSpy: { mockRestore(): void } | undefined; +let originalFetch: typeof fetch; +const poolId = "headless-pool"; +const originalDefaultCodexHome = codexHome.defaultCodexHome; + +function isolateDefaultHome(userHome: string): void { + // Keep real filesystem resolution while excluding unrelated Windows homes on WSL. + defaultHomeSpy = spyOn(codexHome, "defaultCodexHome").mockImplementation(() => originalDefaultCodexHome({ + homedir: () => userHome, env: {}, procVersion: "", release: "", + })); +} + +function config(codexIntegration = false): OcxConfig { + return { + port: 10100, defaultProvider: "openai", providers: {}, + clientIntegrations: { codex: codexIntegration }, + codexAccounts: [{ id: poolId, email: "pool@example.test", plan: "plus", isMain: false }], + }; +} + +function savePool() { + saveCodexAccountCredential(poolId, { + accessToken: "fixture-pool-access", refreshToken: "fixture-pool-refresh", + chatgptAccountId: "fixture-pool-identity", expiresAt: Date.now() + 3_600_000, + }); + return getCodexAccountCredential(poolId); +} + +function usageResponse(): Response { + return Response.json({ + plan_type: "plus", + rate_limit: { primary_window: { used_percent: 17, limit_window_seconds: 18_000 } }, + }); +} + +async function listAccounts(cfg: OcxConfig, method = "GET", suffix = "") { + const url = new URL(`http://localhost/api/codex-auth/accounts${suffix}`); + const response = await handleCodexAuthAPI(new Request(url, { method }), url, cfg); + expect(response?.status).toBe(200); + return (await response!.json() as { accounts: CodexAuthAccountDto[] }).accounts; +} + +function resetRuntimeState() { + clearMainAccountInfoCache(); + clearAccountQuota(); + clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); + clearAccountNeedsReauth(poolId); + resetMainCodexAccountIdentityTrackingForTests(); + resetQuotaQueryBackoffForTests(); + resetLifecycleDrainStateForTests(); +} + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "ocx-headless-accounts-")); + nativeHome = join(root, "missing-codex-home"); + previousOcxHome = process.env.OPENCODEX_HOME; + previousCodexHome = process.env.CODEX_HOME; + process.env.OPENCODEX_HOME = join(root, "ocx"); + process.env.CODEX_HOME = nativeHome; + mkdirSync(process.env.OPENCODEX_HOME); + originalFetch = globalThis.fetch; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + if (url.origin !== "https://chatgpt.com" || !url.pathname.endsWith("/wham/usage")) { + throw new Error("unexpected fixture network request"); + } + return usageResponse(); + }) as typeof fetch; + resetRuntimeState(); +}); + +afterEach(() => { + defaultHomeSpy?.mockRestore(); + defaultHomeSpy = undefined; + globalThis.fetch = originalFetch; + resetRuntimeState(); + if (previousOcxHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOcxHome; + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + removeTreeWithRetry(root); +}); + +describe("account listing without a native Codex home", () => { + for (const integration of [false, true]) { + for (const [method, suffix] of [["GET", ""], ["GET", "?refresh=1"], ["POST", "/refresh"]]) { + test(`${method} accounts${suffix}, integration=${integration}: stored pool remains available`, async () => { + const credential = savePool(); + const accounts = await listAccounts(config(integration), method, suffix); + expect(accounts).toHaveLength(2); + expect(accounts.find(account => account.id === poolId)).toMatchObject({ + hasCredential: true, needsReauth: false, plan: "plus", quota: { shortPercent: 17 }, + }); + expect(accounts.find(account => account.id === MAIN_CODEX_ACCOUNT_ID)).toMatchObject({ + hasCredential: false, needsReauth: false, quota: null, + }); + expect(isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)).toBe(false); + expect(getCodexAccountCredential(poolId)).toEqual(credential); + expect(existsSync(nativeHome)).toBe(false); + }); + } + } + + test("an explicit non-directory CODEX_HOME still refuses account listing", async () => { + writeFileSync(nativeHome, "fixture-not-a-directory"); + const credential = savePool(); + await expect(listAccounts(config())).rejects.toThrow("not a directory"); + expect(getCodexAccountCredential(poolId)).toEqual(credential); + expect(readFileSync(nativeHome, "utf8")).toBe("fixture-not-a-directory"); + }); + + for (const [method, suffix] of [["GET", ""], ["GET", "?refresh=1"], ["POST", "/refresh"]]) { + for (const kind of ["missing", "file", "dangling-link"] as const) { + test(`${method} accounts${suffix}: default home ${kind} only tolerates proven absence`, async () => { + const userHome = join(root, "user"); + mkdirSync(userHome); + isolateDefaultHome(userHome); + delete process.env.CODEX_HOME; + nativeHome = join(userHome, ".codex"); + if (kind === "file") writeFileSync(nativeHome, "fixture-not-a-directory"); + if (kind === "dangling-link") symlinkSync(join(root, "missing-target"), nativeHome, "junction"); + expect(getCodexHome()).toBe(nativeHome); + const credential = savePool(); + + if (kind === "missing") { + const accounts = await listAccounts(config(), method, suffix); + expect(accounts.find(account => account.id === poolId)?.hasCredential).toBe(true); + expect(existsSync(nativeHome)).toBe(false); + } else { + await expect(listAccounts(config(), method, suffix)).rejects.toMatchObject({ code: "CODEX_HOME_UNAVAILABLE" }); + if (kind === "file") expect(readFileSync(nativeHome, "utf8")).toBe("fixture-not-a-directory"); + else expect(lstatSync(nativeHome).isSymbolicLink()).toBe(true); + } + expect(getCodexAccountCredential(poolId)).toEqual(credential); + expect(isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)).toBe(false); + }); + } + } + + test.skipIf(process.platform === "win32" || process.getuid?.() === 0)("an inaccessible default home is not treated as absent", async () => { + const userHome = join(root, "unreadable-user"); + mkdirSync(userHome); + nativeHome = join(userHome, ".codex"); + mkdirSync(nativeHome); + isolateDefaultHome(userHome); + delete process.env.CODEX_HOME; + const credential = savePool(); + chmodSync(userHome, 0o000); + try { + expect(getCodexHome()).toBe(nativeHome); + await expect(listAccounts(config())).rejects.toMatchObject({ code: "CODEX_HOME_UNAVAILABLE" }); + expect(getCodexAccountCredential(poolId)).toEqual(credential); + expect(isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)).toBe(false); + } finally { + chmodSync(userHome, 0o700); + } + }); + + test("a home removed during pool refresh does not fail final account projection", async () => { + mkdirSync(nativeHome); + savePool(); + globalThis.fetch = (async () => { + removeTreeWithRetry(nativeHome); + return usageResponse(); + }) as typeof fetch; + const accounts = await listAccounts(config()); + expect(accounts.find(account => account.id === poolId)).toMatchObject({ + hasCredential: true, needsReauth: false, quota: { shortPercent: 17 }, + }); + expect(existsSync(nativeHome)).toBe(false); + }); + + test("disabled client integration still observes an existing native login", async () => { + mkdirSync(nativeHome); + const auth = JSON.stringify({ tokens: { + access_token: "fixture-main-access", account_id: "fixture-main-identity", + } }); + writeFileSync(join(nativeHome, "auth.json"), auth); + savePool(); + const accounts = await listAccounts(config()); + expect(accounts.find(account => account.id === MAIN_CODEX_ACCOUNT_ID)).toMatchObject({ + hasCredential: true, needsReauth: false, plan: "plus", quota: { shortPercent: 17 }, + }); + expect(readFileSync(join(nativeHome, "auth.json"), "utf8")).toBe(auth); + }); + + test("a missing home preserves previously observed identity and reauth state", async () => { + mkdirSync(nativeHome); + writeFileSync(join(nativeHome, "auth.json"), JSON.stringify({ tokens: { + access_token: "fixture-main-access", account_id: "fixture-main-identity", + } })); + savePool(); + await listAccounts(config()); + markAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); + const generation = captureMainAccountIdentityGeneration(); + const info = getMainAccountInfoCache(); + expect(info?.quota?.shortPercent).toBe(17); + expect(getMainAccountCredentialPresence()).toBe(true); + removeTreeWithRetry(nativeHome); + + const accounts = await listAccounts(config(), "GET", "?refresh=1"); + expect(accounts.find(account => account.id === MAIN_CODEX_ACCOUNT_ID)).toMatchObject({ + hasCredential: true, needsReauth: true, quota: null, + }); + expect(accounts.find(account => account.id === poolId)).toMatchObject({ + hasCredential: true, needsReauth: false, quota: { shortPercent: 17 }, + }); + expect(captureMainAccountIdentityGeneration()).toBe(generation); + expect(getMainAccountInfoCache()).toEqual(info); + expect(getMainAccountCredentialPresence()).toBe(true); + expect(isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)).toBe(true); + expect(existsSync(nativeHome)).toBe(false); + }); + + for (const homeExists of [true, false]) { + test(`an unsafe OpenCodex storage root refuses account listing, native home exists=${homeExists}`, async () => { + if (homeExists) mkdirSync(nativeHome); + const unsafeRoot = join(root, "not-a-config-directory"); + writeFileSync(unsafeRoot, "fixture-unsafe-root"); + process.env.OPENCODEX_HOME = unsafeRoot; + await expect(listAccounts(config())).rejects.toMatchObject({ code: "PROFILE_STORAGE_UNSAFE" }); + expect(readFileSync(unsafeRoot, "utf8")).toBe("fixture-unsafe-root"); + expect(existsSync(nativeHome)).toBe(homeExists); + }); + } + + test("a home removed during refresh does not hide an unsafe storage root at projection", async () => { + mkdirSync(nativeHome); + savePool(); + const unsafeRoot = join(root, "not-a-config-directory"); + writeFileSync(unsafeRoot, "fixture-unsafe-root"); + globalThis.fetch = (async () => { + removeTreeWithRetry(nativeHome); + process.env.OPENCODEX_HOME = unsafeRoot; + return usageResponse(); + }) as typeof fetch; + await expect(listAccounts(config())).rejects.toMatchObject({ code: "PROFILE_STORAGE_UNSAFE" }); + expect(readFileSync(unsafeRoot, "utf8")).toBe("fixture-unsafe-root"); + expect(existsSync(nativeHome)).toBe(false); + }); + + test("credential operations still refuse a missing native home before executing", async () => { + const mutationPath = join(root, "must-not-be-written"); + await expect((async () => withNativeMainCredentialClaim(async () => { + writeFileSync(mutationPath, "unexpected mutation"); + }))()).rejects.toMatchObject({ code: "CODEX_HOME_UNAVAILABLE" }); + expect(existsSync(mutationPath)).toBe(false); + expect(existsSync(nativeHome)).toBe(false); + }); +}); diff --git a/tests/codex-integration/codex-stored-main-duplicate.test.ts b/tests/codex-integration/codex-stored-main-duplicate.test.ts index 4082e770537..daba4f87eaf 100644 --- a/tests/codex-integration/codex-stored-main-duplicate.test.ts +++ b/tests/codex-integration/codex-stored-main-duplicate.test.ts @@ -14,6 +14,7 @@ import { clearThreadAccountMap, clearCodexUpstreamHealth } from "../../src/codex import { clearAccountQuota } from "../../src/codex/quota"; import { clearMainAccountInfoCache } from "../../src/codex/main-account-cache"; import { setMainAccountPlan } from "../../src/codex/main-account"; +import { observeMainCodexCollisionIdentity } from "../../src/codex/collision-identity"; import type { OcxConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -23,16 +24,18 @@ let previousOcxHome: string | undefined; let config: OcxConfig; const poolId = "stored-main-duplicate"; const email = "member@example.test"; -function jwt(plan: string, member?: string): string { +function jwt(plan: string, member?: string, userId?: string, accountId = "shared-account"): string { return `header.${Buffer.from(JSON.stringify({ exp: Math.floor(Date.now() / 1000) + 3600, - "https://api.openai.com/auth": { chatgpt_plan_type: plan }, + chatgpt_account_id: accountId, + "https://api.openai.com/auth": { chatgpt_plan_type: plan, ...(userId ? { chatgpt_user_id: userId } : {}) }, ...(member ? { "https://api.openai.com/profile": { email: member } } : {}), })).toString("base64url")}.fake`; } -function native(accountId = "shared-account", plan = "plus", member: string | undefined = email, observe = true): void { +function native(accountId = "shared-account", plan = "plus", member: string | undefined = email, + observe = true, userId?: string): void { writeFileSync(join(process.env.CODEX_HOME!, "auth.json"), JSON.stringify({ - tokens: { access_token: jwt(plan, member), account_id: accountId }, + tokens: { access_token: jwt(plan, member, userId, accountId), account_id: accountId }, })); if (observe) reconcileMainCodexAccountRuntimeState(); } @@ -83,6 +86,74 @@ afterEach(() => { }); describe("stored Pool duplicate of native main", () => { + test("official rows join the same upstream user without exposing identity claims", async () => { + writeFileSync(join(process.env.OPENCODEX_HOME!, "admin-api-token"), `ocx_admin_${"x".repeat(43)}\n`); + native("shared-account", "plus", email, true, "same-user"); + saveCodexAccountCredential(poolId, { accessToken: jwt("plus", email, "same-user"), + refreshToken: "fake-refresh", chatgptAccountId: "shared-account", expiresAt: Date.now() + 3600_000 }); + const rows = await runPassiveRead(() => listCodexAuthAccounts(config)); + const main = rows.find(row => row.id === "__main__")!; + const pool = rows.find(row => row.id === poolId)!; + expect(main).toMatchObject({ slotId: "__main__", slotKind: "native-main" }); + expect(pool).toMatchObject({ slotId: poolId, slotKind: "pool" }); + expect(main.upstreamAccountKey).toMatch(/^uai1_[0-9a-f]{64}$/); + expect(pool.upstreamAccountKey).toBe(main.upstreamAccountKey); + expect(JSON.stringify(rows)).not.toContain("same-user"); + expect(JSON.stringify(rows)).not.toContain("shared-account"); + + saveCodexAccountCredential(poolId, { accessToken: jwt("plus", email, "other-user"), + refreshToken: "fake-refresh", chatgptAccountId: "shared-account", expiresAt: Date.now() + 3600_000 }); + const changed = await runPassiveRead(() => listCodexAuthAccounts(config)); + expect(changed.find(row => row.id === poolId)?.upstreamAccountKey).not.toBe(main.upstreamAccountKey); + expect(changed.find(row => row.id === "__main__")?.upstreamAccountKey).toBe(main.upstreamAccountKey); + }); + + test("a missing user claim or local secret preserves separate registered slots", async () => { + native(); + const noSecret = await runPassiveRead(() => listCodexAuthAccounts(config)); + expect(noSecret.map(row => row.slotKind)).toEqual(["native-main", "pool"]); + expect(noSecret.every(row => row.upstreamAccountKey === undefined)).toBe(true); + writeFileSync(join(process.env.OPENCODEX_HOME!, "admin-api-token"), `ocx_admin_${"x".repeat(43)}\n`); + const noClaim = await runPassiveRead(() => listCodexAuthAccounts(config)); + expect(noClaim.every(row => row.upstreamAccountKey === undefined)).toBe(true); + }); + test("conflicting native user claims and pool account claims omit join keys", async () => { + writeFileSync(join(process.env.OPENCODEX_HOME!, "admin-api-token"), `ocx_admin_${"x".repeat(43)}\n`); + writeFileSync(join(process.env.CODEX_HOME!, "auth.json"), JSON.stringify({ tokens: { + access_token: jwt("plus", email, "access-user"), + id_token: jwt("plus", email, "different-user"), + account_id: "shared-account", + } })); + reconcileMainCodexAccountRuntimeState(); + saveCodexAccountCredential(poolId, { accessToken: jwt("plus", email, "access-user", "other-account"), + refreshToken: "fake-refresh", chatgptAccountId: "shared-account", expiresAt: Date.now() + 3600_000 }); + const rows = await runPassiveRead(() => listCodexAuthAccounts(config)); + expect(rows).toHaveLength(2); + expect(rows.every(row => row.upstreamAccountKey === undefined)).toBe(true); + expect(rows.map(row => row.slotId)).toEqual(["__main__", poolId]); + }); + test("conflicting replacement native claims remove a previously observed join key", async () => { + writeFileSync(join(process.env.OPENCODEX_HOME!, "admin-api-token"), `ocx_admin_${"x".repeat(43)}\n`); + native("shared-account", "plus", email, true, "same-user"); + expect((await runPassiveRead(() => listCodexAuthAccounts(config)))[0]?.upstreamAccountKey).toMatch(/^uai1_/); + observeMainCodexCollisionIdentity({ + access_token: jwt("plus", email, "same-user", "other-account"), + account_id: "shared-account", + }); + expect((await runPassiveRead(() => listCodexAuthAccounts(config)))[0]?.upstreamAccountKey).toBeUndefined(); + }); + test("malformed present native ID token suppresses a previously observed join key", async () => { + writeFileSync(join(process.env.OPENCODEX_HOME!, "admin-api-token"), `ocx_admin_${"x".repeat(43)}\n`); + native("shared-account", "plus", email, true, "same-user"); + expect((await runPassiveRead(() => listCodexAuthAccounts(config)))[0]?.upstreamAccountKey).toMatch(/^uai1_/); + observeMainCodexCollisionIdentity({ + access_token: jwt("plus", email, "same-user"), + id_token: "malformed", + account_id: "shared-account", + }); + expect((await runPassiveRead(() => listCodexAuthAccounts(config)))[0]?.upstreamAccountKey).toBeUndefined(); + expect(codexAccountUnusableReason(config, poolId)).toBe("duplicate_main"); + }); test("existing duplicate is ineligible without deleting its credential or config row", () => { native(); expect(codexAccountUnusableReason(config, poolId)).toBe("duplicate_main"); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index fed4bb7ebc7..455466bf8d2 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -148,6 +148,9 @@ "server-combo-plan-model-refusal.test.ts": "server", "codex-main-token-invalidated.test.ts": "codex-integration", "codex-main-grant-refusal.test.ts": "codex-integration", + "codex-auth-headless-accounts.test.ts": "codex-integration", + "codex-account-slot-identity.test.ts": "codex-integration", + "cli-account-slot-identity.test.ts": "cli", "responses-alternate-main-cancellation.test.ts": "responses", "codex-shim-path-readiness.test.ts": "codex-integration", "codex-shim-fnm.test.ts": "codex-integration",