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..f22c64f1f62 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,6 +241,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 계정이 두 개 이상 저장되어 있으면 기본적으로 429 응답 시 다른 계정으로 자동 전환하며, 알려진 잔여 할당량이 가장 많은 계정을 우선합니다. 이 전환은 계정 존재만으로 활성화되며 끌 수 없습니다. `oauthAccountFailover.enabled: false`는 429 복구가 아니라 요청을 보내기 전 계정을 고르는 동작만 거부합니다. `ocx account login kiro`는 계정을 한 번에 하나씩 풀에 추가합니다. 빈 결과도 성공입니다. `--json`은 다음을 반환합니다: ```text 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 1ac81f11ec9..e88c48bbe9a 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -667,6 +667,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 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.json b/scripts/test-layout/layout.json index d9c536333c8..d8a8dedc7ce 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -66,6 +66,7 @@ "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", "responses-alternate-main-cancellation.test.ts": "responses", "codex-shim-path-readiness.test.ts": "codex-integration", "codex-shim-fnm.test.ts": "codex-integration", diff --git a/src/codex/auth-api/account-list.ts b/src/codex/auth-api/account-list.ts index c3046363f1f..4c60a58a85c 100644 --- a/src/codex/auth-api/account-list.ts +++ b/src/codex/auth-api/account-list.ts @@ -28,7 +28,7 @@ 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"; @@ -442,7 +442,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/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 52f1d0b5fe0..6f41e009937 100644 --- a/structure/providers/openai-accounts.md +++ b/structure/providers/openai-accounts.md @@ -250,9 +250,9 @@ 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/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/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 28f9d9dd49f..4b5d5da544d 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -144,6 +144,7 @@ "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", "responses-alternate-main-cancellation.test.ts": "responses", "codex-shim-path-readiness.test.ts": "codex-integration", "codex-shim-fnm.test.ts": "codex-integration",