From e6c45d10bd11cb858fdfba1f0e9d4a08a60da5fe Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Thu, 13 Aug 2026 05:39:01 +0900 Subject: [PATCH] fix(codex): remove raw-token account import --- .../docs/ja/reference/management-api.md | 6 +- .../docs/ko/reference/management-api.md | 6 +- .../content/docs/reference/management-api.md | 6 +- .../docs/ru/reference/management-api.md | 6 +- .../docs/zh-cn/reference/management-api.md | 6 +- .../docs/zh-tw/reference/management-api.md | 2 +- src/codex/auth-api.ts | 76 +- tests/codex-auth-api.test.ts | 648 +++--------------- 8 files changed, 120 insertions(+), 636 deletions(-) diff --git a/docs-site/src/content/docs/ja/reference/management-api.md b/docs-site/src/content/docs/ja/reference/management-api.md index 7e903d8749..7982a6341c 100644 --- a/docs-site/src/content/docs/ja/reference/management-api.md +++ b/docs-site/src/content/docs/ja/reference/management-api.md @@ -206,7 +206,7 @@ Authorization: Bearer |メソッドとパス |目的 |注目すべきエラー | | --- | --- | --- | -| `GET, POST, DELETE /api/codex-auth/accounts` | Codex アカウントの一覧表示/更新、必要に応じてインポート、削除。成功した POST/DELETE は `catalogRefreshPending` を返します。 | 400 無効な入力。手動インポートは無効にすることができます。 | +| `GET, POST, DELETE /api/codex-auth/accounts` | Codex アカウントの一覧表示/更新または削除。POST は無効化された互換エンドポイントとしてのみ残り、成功した DELETE は `catalogRefreshPending` を返します。 | POST は常に 403 `manual_import_disabled`。DELETE の入力が無効な場合は 400。 | | `PUT /api/codex-auth/accounts/alias` |アカウント エイリアスの設定またはクリア | 400 無効なアカウント/エイリアス | | `PUT /api/codex-auth/accounts/pause` | 1 つのアカウントを一時停止または再開する | 400 無効なアカウント/状態。 404 アカウントが見つかりません | | `PUT /api/codex-auth/accounts/pause-exhausted` |クォータを使い果たしたアカウントを一時停止する |ミューテーションロックの失敗は 503 になります | @@ -223,8 +223,8 @@ Authorization: Bearer | `POST /api/codex-auth/login/cancel` | Codex ログイン フローをキャンセルする | — | | `GET /api/codex-auth/login-status` |フローまたはアカウントのログイン状態をポーリングする。新規アカウント完了時は回復が必要な場合だけ `catalogRefreshPending: true` を含みます。 |不明なフローは `expired` を報告します。アクティブなフローは `idle` を報告しません | -新規 account の config row は保存されたものの credential setup を完了できない場合、manual POST は -HTTP 500 を返し、OAuth の `login-status` は `status: "error"` を報告します。どちらも +新規 account の config row は保存されたものの credential setup を完了できない場合、OAuth の +`login-status` は `status: "error"` と `code: "codex_credential_persistence_failed"`、`accountId`、`needsReauth: true`、必要に応じて `catalogRefreshPending: true` を含み、storage error の詳細は公開しません。account row は保存済みなので、 account 作成を再試行する前に再認証するか削除してください。 diff --git a/docs-site/src/content/docs/ko/reference/management-api.md b/docs-site/src/content/docs/ko/reference/management-api.md index da0b170854..b0ed3dac31 100644 --- a/docs-site/src/content/docs/ko/reference/management-api.md +++ b/docs-site/src/content/docs/ko/reference/management-api.md @@ -206,7 +206,7 @@ Authorization: Bearer | Method and path | 목적 | 주요 오류 | | --- | --- | --- | -| `GET, POST, DELETE /api/codex-auth/accounts` | Codex account를 나열/갱신, 선택적으로 가져오기, 또는 삭제합니다. 성공한 POST/DELETE는 `catalogRefreshPending`를 포함합니다. | 400 잘못된 입력; 수동 가져오기를 비활성화할 수 있음 | +| `GET, POST, DELETE /api/codex-auth/accounts` | Codex account를 나열/갱신하거나 삭제합니다. POST는 비활성화된 호환성 endpoint로만 유지되며, 성공한 DELETE는 `catalogRefreshPending`를 포함합니다. | POST는 항상 403 `manual_import_disabled`; DELETE 입력이 잘못되면 400 | | `PUT /api/codex-auth/accounts/alias` | 계정 alias를 설정하거나 지웁니다 | 400 잘못된 account/alias | | `PUT /api/codex-auth/accounts/pause` | 계정 하나를 일시 중지하거나 재개합니다 | 400 잘못된 account/state; 404 누락된 account | | `PUT /api/codex-auth/accounts/pause-exhausted` | quota가 소진된 account를 일시 중지합니다 | mutation-lock 실패는 503이 됩니다 | @@ -223,8 +223,8 @@ Authorization: Bearer | `POST /api/codex-auth/login/cancel` | Codex 로그인 흐름을 취소합니다 | — | | `GET /api/codex-auth/login-status` | 흐름 또는 account 로그인 상태를 조회합니다. 새 계정 완료 시 복구가 필요할 때만 `catalogRefreshPending: true`를 포함합니다. | 알 수 없는 흐름은 `expired`로 보고되며, 활성 흐름이 없으면 `idle`로 보고됩니다 | -새 account의 config row는 저장되었지만 credential setup을 완료하지 못하면 manual POST는 HTTP 500을 -반환하고 OAuth `login-status`는 `status: "error"`를 보고합니다. 두 응답 모두 +새 account의 config row는 저장되었지만 credential setup을 완료하지 못하면 OAuth `login-status`는 +`status: "error"`를 보고하며 `code: "codex_credential_persistence_failed"`, `accountId`, `needsReauth: true`, 필요한 경우 `catalogRefreshPending: true`를 포함하며 storage error 세부 정보는 노출하지 않습니다. account row는 저장된 상태이므로 account 생성을 다시 시도하기 전에 재인증하거나 삭제하십시오. diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index fdc6ceedc2..40a01b5767 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -239,7 +239,7 @@ manager. Its routes are: | Method and path | Purpose | Notable errors | | --- | --- | --- | -| `GET, POST, DELETE /api/codex-auth/accounts` | List/refresh, optionally import, or delete Codex accounts. Successful POST/DELETE responses include `catalogRefreshPending`. | 400 invalid input; manual import can be disabled | +| `GET, POST, DELETE /api/codex-auth/accounts` | List/refresh or delete Codex accounts. POST is retained as a disabled compatibility endpoint; successful DELETE responses include `catalogRefreshPending`. | POST always returns 403 `manual_import_disabled`; 400 invalid DELETE input | | `PUT /api/codex-auth/accounts/alias` | Set or clear an account alias | 400 invalid account/alias | | `PUT /api/codex-auth/accounts/pause` | Pause or resume one account | 400 invalid account/state; 404 missing account | | `PUT /api/codex-auth/accounts/pause-exhausted` | Pause accounts whose quota is exhausted | Mutation-lock failures become 503 | @@ -256,8 +256,8 @@ manager. Its routes are: | `POST /api/codex-auth/login/cancel` | Cancel a Codex login flow | — | | `GET /api/codex-auth/login-status` | Poll a flow or account login state. A completed new-account flow includes `catalogRefreshPending: true` only when recovery is needed. | Unknown flows report `expired`; no active flow reports `idle` | -If a new account config row is saved but credential setup cannot finish, the manual POST returns -HTTP 500 and OAuth `login-status` reports `status: "error"`. Both use +If a new account config row is saved but credential setup cannot finish, OAuth `login-status` reports +`status: "error"` with `code: "codex_credential_persistence_failed"`, `accountId`, `needsReauth: true`, and optional `catalogRefreshPending: true`; storage-error details are not exposed. The account row remains saved: reauthenticate or delete it before retrying account creation. diff --git a/docs-site/src/content/docs/ru/reference/management-api.md b/docs-site/src/content/docs/ru/reference/management-api.md index f6e8c74ae1..860fb742d6 100644 --- a/docs-site/src/content/docs/ru/reference/management-api.md +++ b/docs-site/src/content/docs/ru/reference/management-api.md @@ -230,7 +230,7 @@ picker изменилась. `catalogRefreshPending: true` в успешном | Метод и путь | Назначение | Особые ошибки | | --- | --- | --- | -| `GET, POST, DELETE /api/codex-auth/accounts` | Показать/обновить список, по желанию импортировать, либо удалить аккаунты Codex. Успешные POST/DELETE включают `catalogRefreshPending`. | 400 invalid input; manual import can be disabled | +| `GET, POST, DELETE /api/codex-auth/accounts` | Показать/обновить список либо удалить аккаунты Codex. POST сохранён только как отключённый endpoint совместимости; успешный DELETE включает `catalogRefreshPending`. | POST всегда возвращает 403 `manual_import_disabled`; 400 при неверных данных DELETE | | `PUT /api/codex-auth/accounts/alias` | Задать или очистить alias аккаунта | 400 invalid account/alias | | `PUT /api/codex-auth/accounts/pause` | Поставить один аккаунт на паузу или снять её | 400 invalid account/state; 404 missing account | | `PUT /api/codex-auth/accounts/pause-exhausted` | Поставить на паузу аккаунты с исчерпанной квотой | Сбои mutation-lock превращаются в 503 | @@ -247,8 +247,8 @@ picker изменилась. `catalogRefreshPending: true` в успешном | `POST /api/codex-auth/login/cancel` | Отменить login-flow Codex | — | | `GET /api/codex-auth/login-status` | Опрашивать flow или login-state аккаунта. Завершение нового аккаунта включает `catalogRefreshPending: true` только при необходимости восстановления. | Неизвестные flow'ы сообщаются как `expired`; отсутствие активного flow — как `idle` | -Если config row нового аккаунта сохранён, но credential setup не завершён, manual POST возвращает -HTTP 500, а OAuth `login-status` сообщает `status: "error"`. Оба ответа содержат +Если config row нового аккаунта сохранён, но credential setup не завершён, OAuth `login-status` +сообщает `status: "error"` и содержит `code: "codex_credential_persistence_failed"`, `accountId`, `needsReauth: true` и при необходимости `catalogRefreshPending: true`; детали storage error не раскрываются. Account row остаётся сохранённым: перед повторным созданием аккаунта выполните reauthentication или удалите его. diff --git a/docs-site/src/content/docs/zh-cn/reference/management-api.md b/docs-site/src/content/docs/zh-cn/reference/management-api.md index 4e611356e4..ab06540139 100644 --- a/docs-site/src/content/docs/zh-cn/reference/management-api.md +++ b/docs-site/src/content/docs/zh-cn/reference/management-api.md @@ -208,7 +208,7 @@ Authorization: Bearer | 方法和路径 | 用途 | 典型错误 | | --- | --- | --- | -| `GET, POST, DELETE /api/codex-auth/accounts` | 列出/刷新,可选导入,或删除 Codex 账户。成功的 POST/DELETE 响应包含 `catalogRefreshPending`。 | 400 输入无效;手动导入可能被禁用 | +| `GET, POST, DELETE /api/codex-auth/accounts` | 列出/刷新或删除 Codex 账户。POST 仅作为已禁用的兼容端点保留;成功的 DELETE 响应包含 `catalogRefreshPending`。 | POST 始终返回 403 `manual_import_disabled`;DELETE 输入无效时返回 400 | | `PUT /api/codex-auth/accounts/alias` | 设置或清除账户别名 | 400 账户/别名无效 | | `PUT /api/codex-auth/accounts/pause` | 暂停或恢复一个账户 | 400 账户/状态无效;404 缺少账户 | | `PUT /api/codex-auth/accounts/pause-exhausted` | 暂停配额已耗尽的账户 | 变更锁失败会变成 503 | @@ -225,8 +225,8 @@ Authorization: Bearer | `POST /api/codex-auth/login/cancel` | 取消一个 Codex 登录流程 | — | | `GET /api/codex-auth/login-status` | 轮询某个流程或账户登录状态。新账号流程完成时,仅在需要恢复时包含 `catalogRefreshPending: true`。 | 未知流程报告为 `expired`;没有活跃流程时报告为 `idle` | -如果新账号的 config row 已保存但 credential setup 未能完成,manual POST 会返回 HTTP 500,OAuth -`login-status` 会报告 `status: "error"`。两者都包含 +如果新账号的 config row 已保存但 credential setup 未能完成,OAuth `login-status` 会报告 +`status: "error"`,并包含 `code: "codex_credential_persistence_failed"`、`accountId`、`needsReauth: true`,并在需要时包含 `catalogRefreshPending: true`;底层 storage error 详情不会暴露。account row 会保持已保存状态;再次 创建账号前,请重新认证或删除该账号。 diff --git a/docs-site/src/content/docs/zh-tw/reference/management-api.md b/docs-site/src/content/docs/zh-tw/reference/management-api.md index 876e331ab4..b93b197245 100644 --- a/docs-site/src/content/docs/zh-tw/reference/management-api.md +++ b/docs-site/src/content/docs/zh-tw/reference/management-api.md @@ -201,7 +201,7 @@ Session 簽發在需要 data-plane 認證時停用,這包含遠端綁定。遠 | 方法與路徑 | 用途 | Notable errors | | --- | --- | --- | -| `GET, POST, DELETE /api/codex-auth/accounts` | 列出/重新整理、可選擇匯入或刪除 Codex 帳號 | 400 無效輸入;手動匯入可被停用 | +| `GET, POST, DELETE /api/codex-auth/accounts` | 列出/重新整理或刪除 Codex 帳號。POST 僅保留為已停用的相容 endpoint;成功的 DELETE 回應包含 `catalogRefreshPending`。 | POST 一律回傳 403 `manual_import_disabled`;DELETE 輸入無效時回傳 400 | | `PUT /api/codex-auth/accounts/alias` | 設定或清除帳號別名 | 400 無效帳號/別名 | | `PUT /api/codex-auth/accounts/pause` | 暫停或恢復一個帳號 | 400 無效帳號/狀態;404 缺失帳號 | | `PUT /api/codex-auth/accounts/pause-exhausted` | 暫停配額耗盡的帳號 | 變更鎖失敗變為 503 | diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts index 87c849c638..c233630aa2 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -82,7 +82,7 @@ export { setAccountQuotaFromParsed, updateAccountQuota, } from "./quota"; -import { extractAccountId, decodeJwtPayload } from "../oauth/chatgpt"; +import { extractAccountId } from "../oauth/chatgpt"; import { getMainAccountPlan, MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "./main-account"; import { captureConfigGeneration, registerStateSweepAfterTick } from "../lib/state-store-sweeper"; import { reconcileLiveStateStores } from "../lib/state-store-registrations"; @@ -147,7 +147,6 @@ function nativeMainProfileBusyResponse(): Response { return response; } -const MANUAL_IMPORT_ENV = "OPENCODEX_ENABLE_UNVERIFIED_CODEX_IMPORT"; const CODEX_CREDENTIAL_PERSISTENCE_ERROR = "Account was saved, but credential setup did not complete. Reauthenticate or remove the account."; const CODEX_CREDENTIAL_PERSISTENCE_CODE = "codex_credential_persistence_failed"; @@ -375,10 +374,6 @@ async function readResetCreditJson( } } -export function isUnverifiedCodexImportEnabled(): boolean { - return process.env[MANUAL_IMPORT_ENV] === "1"; -} - function manualImportDisabledResponse(): Response { return jsonResponse({ error: "Manual Codex account import is disabled. Use OAuth login to add a pool account.", @@ -1368,74 +1363,7 @@ export async function handleCodexAuthAPI( } if (url.pathname === "/api/codex-auth/accounts" && req.method === "POST") { - if (!isUnverifiedCodexImportEnabled()) return manualImportDisabledResponse(); - - let body: { id: string; email: string; plan?: unknown; accessToken: string; refreshToken: string; chatgptAccountId: string }; - try { body = (await req.json()) as typeof body; } catch { return jsonResponse({ error: "Invalid JSON" }, 400); } - if (!body.id || !body.email || !body.accessToken || !body.refreshToken || !body.chatgptAccountId) { - return jsonResponse({ error: "Missing required fields" }, 400); - } - if (!isValidCodexAccountId(body.id)) { - return jsonResponse({ error: "Invalid account id format" }, 400); - } - if (body.accessToken.length > 10_000 || body.refreshToken.length > 10_000) { - return jsonResponse({ error: "Input too large" }, 400); - } - const runtimeConfig = getRuntimeConfig(config); - const preflightConflict = codexAccountPersistenceConflict(runtimeConfig, body.id, "create"); - if (preflightConflict) return jsonResponse({ error: preflightConflict }, 400); - // 1.1: Duplicate check is scoped by personal vs workspace plan bucket. - const plan = codexPlanValue(body.plan); - const derivedAccountId = extractAccountId(undefined, body.accessToken) ?? body.chatgptAccountId; - const collision = checkAccountIdCollision(derivedAccountId, body.email, plan); - if (collision.collision) { - return jsonResponse({ error: collision.reason }, 400); - } - // 4.2: use JWT exp for expiresAt instead of hardcoded 1 hour - const payload = decodeJwtPayload(body.accessToken); - const exp = typeof payload?.exp === "number" ? payload.exp * 1000 : Date.now() + 3600_000; - const warmup = await verifyCodexAccountWarmup(body.id, body.accessToken, derivedAccountId); - if (!warmup.ok) return warmup.response; - const latestConfig = getRuntimeConfig(config); - const commitConflict = codexAccountPersistenceConflict(latestConfig, body.id, "create"); - if (commitConflict) return jsonResponse({ error: commitConflict }, 400); - const addedAccount = withCodexAccountLogLabel( - { - id: body.id, - email: body.email, - ...(plan !== undefined ? { plan } : {}), - isMain: false, - }, - latestConfig.codexAccounts ?? [], - ); - const persistence = persistNewCodexAccount( - config, - latestConfig, - addedAccount, - { - credential: { - accessToken: body.accessToken, - refreshToken: body.refreshToken, - expiresAt: exp, - chatgptAccountId: derivedAccountId, - }, - validatedAt: warmup.validatedAt, - }, - ); - reconcileLiveStateStores(); - if (persistence.status === "publication-failed") markAccountNeedsReauth(body.id); - const catalogRefresh = await convergeAccountNamespaceCatalog( - latestConfig, - persistence.pickerVisibilityChanged, - convergeCodexCatalog, - ); - if (persistence.status === "publication-failed") { - return jsonResponse({ - ok: false, - ...codexCredentialPersistenceFailure(body.id, catalogRefresh.catalogRefreshPending === true), - }, 500); - } - return jsonResponse({ ok: true, ...catalogRefresh }); + return manualImportDisabledResponse(); } if (url.pathname === "/api/codex-auth/accounts" && req.method === "DELETE") { diff --git a/tests/codex-auth-api.test.ts b/tests/codex-auth-api.test.ts index e2d8c00790..1496f3bc9a 100644 --- a/tests/codex-auth-api.test.ts +++ b/tests/codex-auth-api.test.ts @@ -8,7 +8,7 @@ import { getNativeMainProfileRequestCount, resetLifecycleDrainStateForTests, } from "../src/server/lifecycle"; -import { CODEX_ACCOUNT_LOG_LABEL_RE, fallbackCodexAccountLogLabel } from "../src/codex/account-label"; +import { fallbackCodexAccountLogLabel } from "../src/codex/account-label"; import { handleCodexAuthAPI, updateAccountQuota, getAccountQuota, checkAccountIdCollision, getMainChatgptAccountId, @@ -53,7 +53,6 @@ import { ConfigMutationLockError, getConfigPath, loadConfig, - readConfigGeneration, saveConfig, setPersistedConfigMutationBeforeCommitForTests, } from "../src/config"; @@ -74,7 +73,6 @@ import { BOUNDED_BODY_MAX_BYTES } from "../src/lib/bounded-body"; const TEST_DIR = join(import.meta.dir, ".tmp-codex-auth-api-test"); const TEST_CODEX_HOME = join(TEST_DIR, "codex"); const MANUAL_IMPORT_ENV = "OPENCODEX_ENABLE_UNVERIFIED_CODEX_IMPORT"; -const WARMUP_INPUT = [{ type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }]; let previousOpencodexHome: string | undefined; let previousCodexHome: string | undefined; let previousManualImportEnv: string | undefined; @@ -105,24 +103,6 @@ function manualImportBody(overrides: Record = {}): Record number } { - let calls = 0; - globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { - if (String(input) === "https://chatgpt.com/backend-api/codex/responses") { - calls += 1; - const body = JSON.parse(String(init?.body)) as Record; - expect(body).toMatchObject({ model: "gpt-5.4-mini", input: WARMUP_INPUT, stream: true, store: false }); - expect(body).not.toHaveProperty("max_output_tokens"); - return new Response('event: response.completed\ndata: {"type":"response.completed"}\n\n', { - status: 200, - headers: { "Content-Type": "text/event-stream" }, - }); - } - return previousFetch(input, init); - }) as typeof fetch; - return { calls: () => calls }; -} - async function completeMockCodexOAuth(options: { config: OcxConfig; requestBody: { id: string; reauth?: boolean }; @@ -1016,7 +996,8 @@ describe("codex-auth API", () => { expect(getCodexAccountCredential("manual-disabled")).toBeNull(); }); - test("POST /api/codex-auth/accounts returns manual-import disabled before parsing JSON", async () => { + test("POST /api/codex-auth/accounts ignores the legacy opt-in before parsing JSON", async () => { + enableManualImport(); const req = new Request("http://localhost/api/codex-auth/accounts", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -1029,36 +1010,27 @@ describe("codex-auth API", () => { expect(body.code).toBe("manual_import_disabled"); }); - test("POST /api/codex-auth/accounts rejects missing fields when manual import is explicitly enabled", async () => { + test("POST /api/codex-auth/accounts ignores the legacy opt-in and performs no work", async () => { enableManualImport(); + let fetched = false; + globalThis.fetch = (async () => { + fetched = true; + return new Response("unexpected", { status: 500 }); + }) as typeof fetch; + const config = makeConfig(); + const before = structuredClone(config); const req = new Request("http://localhost/api/codex-auth/accounts", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ id: "test" }), + body: JSON.stringify(manualImportBody({ id: "manual-opt-in-ignored" })), }); - const url = new URL(req.url); - const resp = await handleCodexAuthAPI(req, url, {} as any); - expect(resp!.status).toBe(400); - }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); - test("POST /api/codex-auth/accounts rejects oversized input when manual import is explicitly enabled", async () => { - enableManualImport(); - const req = new Request("http://localhost/api/codex-auth/accounts", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - id: "a".repeat(65), - email: "test@test.com", - accessToken: "tok", - refreshToken: "ref", - chatgptAccountId: "acc", - }), - }); - const url = new URL(req.url); - const resp = await handleCodexAuthAPI(req, url, {} as any); - expect(resp!.status).toBe(400); - const body = await resp!.json() as { error: string }; - expect(body.error).toMatch(/too large|Invalid account id/i); + expect(resp!.status).toBe(403); + expect(await resp!.json()).toMatchObject({ code: "manual_import_disabled" }); + expect(fetched).toBe(false); + expect(config).toEqual(before); + expect(getCodexAccountCredential("manual-opt-in-ignored")).toBeNull(); }); test("GET /api/codex-auth/active returns expected shape", async () => { @@ -2411,345 +2383,6 @@ describe("codex-auth API", () => { expect(resp).toBeNull(); }); - test("POST /api/codex-auth/accounts rejects invalid id format when manual import is explicitly enabled", async () => { - enableManualImport(); - const req = new Request("http://localhost/api/codex-auth/accounts", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - id: "bad id with spaces!", - email: "test@test.com", - accessToken: "tok", - refreshToken: "ref", - chatgptAccountId: "acc", - }), - }); - const url = new URL(req.url); - const resp = await handleCodexAuthAPI(req, url, {} as any); - expect(resp!.status).toBe(400); - const body = await resp!.json() as { error: string }; - expect(body.error).toContain("Invalid account id"); - }); - - test.each([ - MAIN_CODEX_ACCOUNT_ID, - "__proto__", - "prototype", - "constructor", - "Constructor", - ])("POST /api/codex-auth/accounts rejects reserved account id %s", async (accountId) => { - enableManualImport(); - const req = new Request("http://localhost/api/codex-auth/accounts", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(manualImportBody({ id: accountId })), - }); - const resp = await handleCodexAuthAPI(req, new URL(req.url), makeConfig()); - expect(resp!.status).toBe(400); - expect(await resp!.json()).toMatchObject({ error: "Invalid account id format" }); - expect(getCodexAccountCredential(accountId)).toBeNull(); - }); - - test("POST /api/codex-auth/accounts rejects invalid JSON when manual import is explicitly enabled", async () => { - enableManualImport(); - const req = new Request("http://localhost/api/codex-auth/accounts", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: "not json", - }); - const url = new URL(req.url); - const resp = await handleCodexAuthAPI(req, url, {} as any); - expect(resp!.status).toBe(400); - const body = await resp!.json() as { error: string }; - expect(body.error).toBe("Invalid JSON"); - }); - - test("POST /api/codex-auth/accounts imports only when manual import is explicitly enabled", async () => { - enableManualImport(); - const warmup = mockCodexWarmupSuccess(); - const config = makeConfig(); - const req = new Request("http://localhost/api/codex-auth/accounts", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(manualImportBody({ id: "manual-enabled", plan: { tier: "go" } })), - }); - const resp = await handleCodexAuthAPI(req, new URL(req.url), config); - - expect(resp!.status).toBe(200); - expect(config.codexAccounts?.map(a => a.id)).toEqual(["manual-enabled"]); - expect(config.codexAccounts?.[0]).not.toHaveProperty("plan"); - expect(config.codexAccounts?.[0]?.logLabel).toMatch(CODEX_ACCOUNT_LOG_LABEL_RE); - expect(getCodexAccountCredential("manual-enabled")).toMatchObject({ - accessToken: "access-manual-test", - refreshToken: "refresh-manual-test", - chatgptAccountId: "acct-manual-test", - }); - expect(readCodexAccountRecord("manual-enabled")?.lastCodexValidationStatus).toBe("ok"); - expect(readCodexAccountRecord("manual-enabled")?.lastCodexValidatedAt).toBeNumber(); - expect(warmup.calls()).toBe(1); - }); - - test("manual add publishes neither account state nor a selector before config commit", async () => { - enableManualImport(); - mockCodexWarmupSuccess(); - const accountId = "manual-picker-lock-busy"; - const config = makeConfig({ - codexAccountNamespaces: { desktop: "@main" }, - codexAccountPickerEnabled: true, - }); - saveConfig(structuredClone(config)); - markAccountNeedsReauth(accountId); - let convergenceCalls = 0; - const saveSpy = spyOn(configModule, "saveConfigPreservingClaudeCode") - .mockImplementation(candidate => { - expect(candidate).toBe(config); - expect(getCodexAccountCredential(accountId)).toBeNull(); - expect(readCodexAccountRecord(accountId)).toBeNull(); - expect(isAccountNeedsReauth(accountId)).toBe(true); - throw new ConfigMutationLockError("test config commit failed"); - }); - - try { - const req = new Request("http://localhost/api/codex-auth/accounts", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(manualImportBody({ id: accountId })), - }); - await expect(handleCodexAuthAPI(req, new URL(req.url), config, async () => { - convergenceCalls += 1; - return { status: "committed", changed: false, degraded: false, notices: [] }; - })).rejects.toBeInstanceOf(ConfigMutationLockError); - - expect(config.codexAccounts).toEqual([]); - expect(config.codexAccountNamespaces).toEqual({ desktop: "@main" }); - expect(Object.values(config.codexAccountNamespaces ?? {})).not.toContain(accountId); - expect(loadConfig()).toMatchObject({ - codexAccounts: [], - codexAccountNamespaces: { desktop: "@main" }, - }); - expect(getCodexAccountCredential(accountId)).toBeNull(); - expect(readCodexAccountRecord(accountId)).toBeNull(); - expect(isAccountNeedsReauth(accountId)).toBe(true); - expect(convergenceCalls).toBe(0); - } finally { - saveSpy.mockRestore(); - } - }); - - test("manual add exposes its durable account for recovery when credential publication fails", async () => { - enableManualImport(); - mockCodexWarmupSuccess(); - const accountId = "manual-picker-credential-fail"; - const config = makeConfig({ - codexAccountNamespaces: { desktop: "@main" }, - codexAccountPickerEnabled: true, - }); - setLiveStateStoreConfig(config); - saveConfig(structuredClone(config)); - const beforeGeneration = readConfigGeneration(); - if (beforeGeneration.kind !== "ready") throw new Error("config generation unavailable before test"); - let convergenceCalls = 0; - const credentialSpy = spyOn(accountStoreModule, "saveCodexAccountCredential") - .mockImplementation(() => { - expect(config.codexAccounts?.map(account => account.id)).toEqual([accountId]); - expect(Object.values(config.codexAccountNamespaces ?? {})).toContain(accountId); - throw new Error("private credential detail Bearer private-token /private/codex-accounts.json"); - }); - - try { - const req = new Request("http://localhost/api/codex-auth/accounts", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(manualImportBody({ id: accountId })), - }); - const response = await handleCodexAuthAPI(req, new URL(req.url), config, async () => { - convergenceCalls += 1; - expect(config.codexAccounts?.map(account => account.id)).toEqual([accountId]); - expect(getCodexAccountCredential(accountId)).toBeNull(); - return { status: "skipped", reason: "busy", retryable: true }; - }); - expect(response!.status).toBe(500); - const responseBody = await response!.json(); - expect(responseBody).toEqual({ - ok: false, - error: "Account was saved, but credential setup did not complete. Reauthenticate or remove the account.", - code: "codex_credential_persistence_failed", - accountId, - needsReauth: true, - catalogRefreshPending: true, - }); - expect(JSON.stringify(responseBody)).not.toContain("private-token"); - expect(JSON.stringify(responseBody)).not.toContain("codex-accounts.json"); - - expect(config.codexAccounts?.map(account => account.id)).toEqual([accountId]); - expect(Object.values(config.codexAccountNamespaces ?? {})).toContain(accountId); - expect(loadConfig().codexAccounts?.map(account => account.id)).toEqual([accountId]); - expect(Object.values(loadConfig().codexAccountNamespaces ?? {})).toContain(accountId); - expect(getCodexAccountCredential(accountId)).toBeNull(); - expect(readCodexAccountRecord(accountId)).toBeNull(); - expect(isAccountNeedsReauth(accountId)).toBe(true); - expect((await listCodexAuthAccounts(config)).find(account => account.id === accountId)) - .toMatchObject({ needsReauth: true }); - expect(convergenceCalls).toBe(1); - expect(readConfigGeneration()).toEqual({ - kind: "ready", - generation: { value: beforeGeneration.generation.value + 1 }, - }); - } finally { - credentialSpy.mockRestore(); - } - }); - - test("manual add marks a partially published credential for reauthentication", async () => { - enableManualImport(); - mockCodexWarmupSuccess(); - const accountId = "manual-picker-validation-fail"; - const config = makeConfig({ - codexAccountNamespaces: { desktop: "@main" }, - codexAccountPickerEnabled: true, - }); - setLiveStateStoreConfig(config); - saveConfig(structuredClone(config)); - let convergenceCalls = 0; - const validationSpy = spyOn(accountStoreModule, "markCodexAccountValidated") - .mockImplementation(() => { - throw new Error("private validation detail /private/codex-accounts.json"); - }); - - try { - const req = new Request("http://localhost/api/codex-auth/accounts", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(manualImportBody({ id: accountId })), - }); - const response = await handleCodexAuthAPI(req, new URL(req.url), config, async () => { - convergenceCalls += 1; - return { status: "committed", changed: true, degraded: false, notices: [] }; - }); - expect(response!.status).toBe(500); - const responseBody = await response!.json(); - expect(responseBody).toEqual({ - ok: false, - error: "Account was saved, but credential setup did not complete. Reauthenticate or remove the account.", - code: "codex_credential_persistence_failed", - accountId, - needsReauth: true, - }); - expect(JSON.stringify(responseBody)).not.toContain("codex-accounts.json"); - expect(config.codexAccounts?.map(account => account.id)).toEqual([accountId]); - expect(getCodexAccountCredential(accountId)).not.toBeNull(); - expect(readCodexAccountRecord(accountId)?.lastCodexValidationStatus).toBeUndefined(); - expect(getAccountQuota(accountId)).toBeNull(); - expect(isAccountNeedsReauth(accountId)).toBe(true); - expect((await listCodexAuthAccounts(config)).find(account => account.id === accountId)) - .toMatchObject({ needsReauth: true }); - expect(convergenceCalls).toBe(1); - } finally { - validationSpy.mockRestore(); - } - }); - - test.each([ - [ - "committed", - { status: "committed", changed: true, degraded: false, notices: [] } satisfies CatalogDisposition, - false, - ], - [ - "deferred", - { status: "skipped", reason: "busy", retryable: true } satisfies CatalogDisposition, - true, - ], - ] as const)("UI-managed manual add is durable before %s convergence", async (_label, disposition, pending) => { - enableManualImport(); - mockCodexWarmupSuccess(); - const accountId = pending ? "manual-picker-pending" : "manual-picker-ready"; - const config = makeConfig({ - codexAccountNamespaces: { desktop: "@main" }, - codexAccountPickerEnabled: true, - }); - let convergenceCalls = 0; - const req = new Request("http://localhost/api/codex-auth/accounts", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(manualImportBody({ id: accountId })), - }); - const response = await handleCodexAuthAPI(req, new URL(req.url), config, async () => { - convergenceCalls += 1; - const persisted = JSON.parse(readFileSync(getConfigPath(), "utf8")) as OcxConfig; - expect(persisted.codexAccounts?.some(account => account.id === accountId)).toBe(true); - expect(getCodexAccountCredential(accountId)).not.toBeNull(); - return disposition; - }); - - const binding = Object.entries(config.codexAccountNamespaces ?? {}) - .find(([, target]) => target === accountId); - expect(response!.status).toBe(200); - expect(await response!.json()).toEqual({ ok: true, catalogRefreshPending: pending }); - expect(binding?.[0]).toMatch(CODEX_ACCOUNT_LOG_LABEL_RE); - expect(binding?.[0]).not.toContain(accountId); - expect(convergenceCalls).toBe(1); - }); - - test("manual add projects a convergence result to only the pending boolean", async () => { - enableManualImport(); - mockCodexWarmupSuccess(); - const privateDetail = "Bearer private-token acct-private /private/catalog/path"; - const config = makeConfig({ - codexAccountNamespaces: { desktop: "@main" }, - codexAccountPickerEnabled: true, - }); - const req = new Request("http://localhost/api/codex-auth/accounts", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(manualImportBody({ id: "manual-picker-sanitized" })), - }); - const response = await handleCodexAuthAPI(req, new URL(req.url), config, async () => ({ - status: "failed", - reason: "disk", - phase: "commit", - retryable: false, - partialWrite: true, - privateDetail, - toJSON: () => ({ privateDetail }), - } as unknown as CatalogDisposition)); - const body = await response!.json(); - - expect(body).toEqual({ ok: true, catalogRefreshPending: true }); - expect(JSON.stringify(body)).not.toContain(privateDetail); - }); - - test("manual maps stay manual while a disabled UI-managed map tracks new accounts", async () => { - enableManualImport(); - mockCodexWarmupSuccess(); - let convergenceCalls = 0; - for (const [accountId, enabled, expectedBinding] of [ - ["manual-map-add", undefined, false], - ["hidden-picker-add", false, true], - ] as const) { - const config = makeConfig({ - codexAccountNamespaces: { desktop: "@main" }, - ...(enabled === undefined ? {} : { codexAccountPickerEnabled: enabled }), - }); - const req = new Request("http://localhost/api/codex-auth/accounts", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(manualImportBody({ - id: accountId, - email: `${accountId}@example.test`, - chatgptAccountId: `acct-${accountId}`, - })), - }); - const response = await handleCodexAuthAPI(req, new URL(req.url), config, async () => { - convergenceCalls += 1; - return { status: "committed", changed: false, degraded: false, notices: [] }; - }); - expect(await response!.json()).toEqual({ ok: true, catalogRefreshPending: false }); - expect(Object.values(config.codexAccountNamespaces ?? {}).includes(accountId)).toBe(expectedBinding); - } - expect(convergenceCalls).toBe(0); - }); - test.each([ ["enabled matching binding", true, "lifecycle-delete", true], ["disabled matching binding", false, "lifecycle-delete", false], @@ -2767,9 +2400,7 @@ describe("codex-auth API", () => { expect(config.codexAccountNamespaces).toEqual({ team: target }); }); - test("enabled picker deletion retains its selector and converges after delete and re-add", async () => { - enableManualImport(); - mockCodexWarmupSuccess(); + test("enabled picker deletion retains its selector across an OAuth account re-add", async () => { const accountId = "picker-delete"; const config = makeConfig({ codexAccounts: [{ id: accountId, email: "delete@example.test", isMain: false }], @@ -2811,171 +2442,22 @@ describe("codex-auth API", () => { expect(config.codexAccountNamespaces).toEqual({ team: accountId }); expect(config.codexAccounts).toEqual([]); - const addReq = new Request("http://localhost/api/codex-auth/accounts", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(manualImportBody({ id: accountId })), - }); - const added = await handleCodexAuthAPI( - addReq, - new URL(addReq.url), + const added = await completeMockCodexOAuth({ config, + requestBody: { id: accountId }, + oauthAccountId: "delete-chatgpt-id", + email: "delete@example.test", + onWarmup: () => {}, convergeCodexCatalog, - ); - expect(await added!.json()).toEqual({ ok: true, catalogRefreshPending: false }); + }); + + expect(added.state).toMatchObject({ status: "done" }); + expect(added.state.catalogRefreshPending).toBeUndefined(); expect(config.codexAccountNamespaces).toEqual({ team: accountId }); expect(config.codexAccounts?.map(account => account.id)).toEqual([accountId]); expect(convergences).toBe(2); }); - test("POST /api/codex-auth/accounts allows a pool account matching the main login", async () => { - enableManualImport(); - mockCodexWarmupSuccess(); - writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ - tokens: { - access_token: "not-a-jwt", - account_id: "acct-main-login", - }, - })); - const config = makeConfig(); - const req = new Request("http://localhost/api/codex-auth/accounts", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(manualImportBody({ - id: "manual-main-match", - chatgptAccountId: "acct-main-login", - })), - }); - const resp = await handleCodexAuthAPI(req, new URL(req.url), config); - - expect(resp!.status).toBe(200); - expect(config.codexAccounts?.map(a => a.id)).toEqual(["manual-main-match"]); - expect(getCodexAccountCredential("manual-main-match")?.chatgptAccountId).toBe("acct-main-login"); - }); - - test("POST /api/codex-auth/accounts rejects manual import when Codex warmup fails", async () => { - enableManualImport(); - globalThis.fetch = (async (input: RequestInfo | URL) => { - if (String(input) === "https://chatgpt.com/backend-api/codex/responses") { - return new Response("raw upstream token-like text", { status: 401 }); - } - return previousFetch(input); - }) as typeof fetch; - const config = makeConfig(); - const req = new Request("http://localhost/api/codex-auth/accounts", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(manualImportBody({ id: "manual-warmup-fail" })), - }); - const resp = await handleCodexAuthAPI(req, new URL(req.url), config); - const body = await resp!.json() as { error: string; code: string; reason: string }; - - expect(resp!.status).toBe(401); - expect(body).toMatchObject({ code: "codex_warmup_failed", reason: "http_status:401" }); - expect(JSON.stringify(body)).not.toContain("raw upstream token-like text"); - expect(config.codexAccounts?.map(a => a.id)).toEqual([]); - expect(getCodexAccountCredential("manual-warmup-fail")).toBeNull(); - }); - - test("POST /api/codex-auth/accounts rejects duplicate runtime alias before writing credentials", async () => { - enableManualImport(); - const config = makeConfig({ - codexAccounts: [{ id: "manual-existing", email: "existing@example.test", isMain: false }], - }); - const req = new Request("http://localhost/api/codex-auth/accounts", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(manualImportBody({ id: "manual-existing" })), - }); - const resp = await handleCodexAuthAPI(req, new URL(req.url), config); - const body = await resp!.json() as { error: string }; - - expect(resp!.status).toBe(400); - expect(body.error).toBe("Account id already exists: manual-existing"); - expect(getCodexAccountCredential("manual-existing")).toBeNull(); - }); - - test("POST /api/codex-auth/accounts rejects duplicate credential alias before overwrite", async () => { - enableManualImport(); - saveCodexAccountCredential("manual-existing", { - accessToken: "old-access", - refreshToken: "old-refresh", - expiresAt: Date.now() + 5 * 60_000, - chatgptAccountId: "old-account", - }); - const req = new Request("http://localhost/api/codex-auth/accounts", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(manualImportBody({ id: "manual-existing" })), - }); - const resp = await handleCodexAuthAPI(req, new URL(req.url), makeConfig()); - const body = await resp!.json() as { error: string }; - - expect(resp!.status).toBe(400); - expect(body.error).toBe("Account id already exists: manual-existing"); - expect(getCodexAccountCredential("manual-existing")).toMatchObject({ - accessToken: "old-access", - refreshToken: "old-refresh", - chatgptAccountId: "old-account", - }); - }); - - test("POST /api/codex-auth/accounts rejects an id owned by a namespace before warmup", async () => { - enableManualImport(); - let fetched = false; - globalThis.fetch = (async () => { - fetched = true; - return new Response("unexpected", { status: 500 }); - }) as typeof fetch; - const config = makeConfig({ codexAccountNamespaces: { work: "pool-a" } }); - const before = structuredClone(config); - const req = new Request("http://localhost/api/codex-auth/accounts", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(manualImportBody({ id: "work" })), - }); - - const resp = await handleCodexAuthAPI(req, new URL(req.url), config); - - expect(resp!.status).toBe(400); - expect(await resp!.json()).toMatchObject({ - error: "account id must not collide with a configured Codex account namespace", - }); - expect(fetched).toBe(false); - expect(config).toEqual(before); - expect(getCodexAccountCredential("work")).toBeNull(); - }); - - test("manual import rechecks namespace ownership after warmup before persistence", async () => { - enableManualImport(); - const config = makeConfig(); - globalThis.fetch = (async (input: RequestInfo | URL) => { - if (String(input) === "https://chatgpt.com/backend-api/codex/responses") { - config.codexAccountNamespaces = { "manual-race": "pool-a" }; - return new Response('event: response.completed\ndata: {"type":"response.completed"}\n\n', { - status: 200, - headers: { "Content-Type": "text/event-stream" }, - }); - } - return previousFetch(input); - }) as typeof fetch; - const req = new Request("http://localhost/api/codex-auth/accounts", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(manualImportBody({ id: "manual-race" })), - }); - - const resp = await handleCodexAuthAPI(req, new URL(req.url), config); - - expect(resp!.status).toBe(400); - expect(await resp!.json()).toMatchObject({ - error: "account id must not collide with a configured Codex account namespace", - }); - expect(config.codexAccounts).toEqual([]); - expect(config.codexAccountNamespaces).toEqual({ "manual-race": "pool-a" }); - expect(getCodexAccountCredential("manual-race")).toBeNull(); - }); - test("PUT /api/codex-auth/auto-switch rejects invalid threshold", async () => { for (const bad of [-1, 101, 50.5, "abc"]) { const req = new Request("http://localhost/api/codex-auth/auto-switch", { @@ -4366,6 +3848,80 @@ describe("codex-auth API", () => { } }); + test("OAuth creation marks a published credential for reauthentication when validation fails", async () => { + const accountId = "oauth-picker-validation-fail"; + const config = makeConfig({ + codexAccountNamespaces: { desktop: "@main" }, + codexAccountPickerEnabled: true, + }); + setLiveStateStoreConfig(config); + let convergenceCalls = 0; + const validationSpy = spyOn(accountStoreModule, "markCodexAccountValidated") + .mockImplementation(() => { + throw new Error("private validation detail /private/codex-accounts.json"); + }); + + try { + const result = await completeMockCodexOAuth({ + config, + requestBody: { id: accountId }, + oauthAccountId: "oauth-picker-validation-chatgpt-id", + email: "oauth-picker-validation@example.test", + onWarmup: () => {}, + convergeCodexCatalog: async () => { + convergenceCalls += 1; + return { status: "committed", changed: true, degraded: false, notices: [] }; + }, + }); + + expect(result.startStatus).toBe(200); + expect(result.state).toMatchObject({ + status: "error", + error: "Account was saved, but credential setup did not complete. Reauthenticate or remove the account.", + code: "codex_credential_persistence_failed", + accountId, + needsReauth: true, + }); + expect(JSON.stringify(result.state)).not.toContain("codex-accounts.json"); + expect(config.codexAccounts?.map(account => account.id)).toEqual([accountId]); + expect(getCodexAccountCredential(accountId)).not.toBeNull(); + expect(readCodexAccountRecord(accountId)?.lastCodexValidationStatus).toBeUndefined(); + expect(isAccountNeedsReauth(accountId)).toBe(true); + expect(convergenceCalls).toBe(1); + } finally { + validationSpy.mockRestore(); + } + }); + + test.each([ + ["legacy manual map", undefined, false], + ["dashboard-managed hidden picker", false, true], + ] as const)("OAuth creation preserves namespace ownership for %s", async (_case, enabled, expectedBinding) => { + const accountId = enabled === undefined ? "oauth-manual-map" : "oauth-hidden-picker"; + const config = makeConfig({ + codexAccountNamespaces: { desktop: "@main" }, + ...(enabled === undefined ? {} : { codexAccountPickerEnabled: enabled }), + }); + let convergenceCalls = 0; + + const result = await completeMockCodexOAuth({ + config, + requestBody: { id: accountId }, + oauthAccountId: `acct-${accountId}`, + email: `${accountId}@example.test`, + onWarmup: () => {}, + convergeCodexCatalog: async () => { + convergenceCalls += 1; + return { status: "committed", changed: false, degraded: false, notices: [] }; + }, + }); + + expect(result.state).toMatchObject({ status: "done" }); + expect(result.state.catalogRefreshPending).toBeUndefined(); + expect(Object.values(config.codexAccountNamespaces ?? {}).includes(accountId)).toBe(expectedBinding); + expect(convergenceCalls).toBe(0); + }); + test("OAuth add publishes neither account state nor a selector before config commit", async () => { const accountId = "oauth-picker-lock-busy"; const config = makeConfig({