diff --git a/docs-site/src/content/docs/ja/reference/configuration.md b/docs-site/src/content/docs/ja/reference/configuration.md index e9a42e87dd..ca636591c5 100644 --- a/docs-site/src/content/docs/ja/reference/configuration.md +++ b/docs-site/src/content/docs/ja/reference/configuration.md @@ -51,6 +51,7 @@ namespaced selected id を bare id に変えます。 | `codexShimAutoRestore?` | `boolean` | `true` | 完了した外部 Codex 更新で以前にインストールした shim が置換された場合に復元します。無効にするには `false`、またはプロセスで `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0` を設定します。 | | `syncResumeHistory?` | `boolean` | `true` | 戻せる Codex App 履歴互換モード。opencodex は元の Codex thread metadata をバックアップし、旧 OpenAI interactive row を `opencodex` に再マッピングし、opencodex が作成した `exec` row を App に見えるソースとして一時的に昇格します。`ocx stop` / `ocx restore` はバックアップした OpenAI row を復元し、残った opencodex user thread を OpenAI に戻し、ネイティブ Codex が `config.toml` からプロキシを削除した後でも開き続けられるようにします。オフにするには `false` に設定します。 | | `codexAccounts?` | `CodexAccount[]` | `[]` | Codex Auth ダッシュボードが管理する ChatGPT/Codex pool アカウント metadata。secret は `codex-accounts.json` に別途置きます。 | +| `codexAccountNamespaces?` | `Record` | — | 公開 model selector namespace から保存済み Codex アカウント target への任意 map。この foundation layer は map を検証・保存しますが、picker row の追加や routing の変更は行いません。 | | `activeCodexAccountId?` | `string` | — | 手動選択した pool アカウント。既存 thread affinity を消去して次のリクエストから適用し、処理中のリクエストは現在のアカウントを維持します。 | | `autoSwitchThreshold?` | `number` | `80` | 新しいセッション自動切替用の使用量百分率 threshold。既知の 5 時間、週次、30 日 quota window のうち最も高いスコアを使います。`0` なら quota 自動切替をオフにします。 | | `upstreamFailoverThreshold?` | `number` | `3` | 一時的な上流失敗が連続して起きたのち、以降の新しいセッションを別の適合 pool アカウントに failover する回数。`0` なら失敗ベースの failover をオフにします。 | @@ -61,6 +62,15 @@ namespaced selected id を bare id に変えます。 | `tokenGuardian?` | `OcxTokenGuardianConfig` | off | 選択型の proactive OAuth 更新と Codex アカウント warmup ポリシー。フィールドは下で説明します。 | | `corsAllowOrigins?` | `string[]` | `[]` | CORS で追加で許可する正確な origin。loopback origin は常に許可します。 | +`codexAccountNamespaces` のキーは公開 selector です。長さは 1〜64 文字、先頭と末尾は ASCII +英数字、内部には英数字、`.`、`_`、`-` を使用でき、予約済み JavaScript object 名は拒否されます。 +値は有効な pool account id(内部 `__main__` は不可)、または Codex Desktop アカウントを示す +`"@main"` です。provider と予約済み `openai` / `combo` との衝突は大文字小文字を区別せず検査され、 +namespace 付き combo alias はその namespace prefix に selector を再利用できません。設定済み pool id +や他の selector target も selector と再利用できません。raw account id と email は +非公開のままにし、selector を公開名として使ってください。この foundation layer では map は inert で、 +model picker entry の作成、session の固定、Pool / Direct routing の変更は行いません。 + `maxConcurrentThreadsPerSession` は `config.json` キーではなく `PUT /api/v2` で使う camel-case フィールドです。`ocx v2 threads ` は対応する `max_concurrent_threads_per_session` 値を Codex の `$CODEX_HOME/config.toml` 内 `[features.multi_agent_v2]` に保存します。その table ができるように v2 を先にオンにしてください。 diff --git a/docs-site/src/content/docs/ko/reference/configuration.md b/docs-site/src/content/docs/ko/reference/configuration.md index f56e6288dd..274ece799a 100644 --- a/docs-site/src/content/docs/ko/reference/configuration.md +++ b/docs-site/src/content/docs/ko/reference/configuration.md @@ -52,6 +52,7 @@ namespaced selected id를 bare id로 바꿉니다. | `codexShimAutoRestore?` | `boolean` | `true` | 완료된 외부 Codex 업데이트가 이전에 설치한 shim을 교체하면 자동으로 복구합니다. 끄려면 `false`로 설정하거나 프로세스에 `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0`을 설정합니다. | | `syncResumeHistory?` | `boolean` | `true` | 되돌릴 수 있는 Codex App 기록 호환 모드. opencodex가 원래 Codex thread metadata를 백업하고, 예전 OpenAI interactive row를 `opencodex`로 재매핑하며, opencodex가 만든 `exec` row를 App에 보이는 source로 잠시 승격합니다. `ocx stop` / `ocx restore`는 백업한 OpenAI row를 복원하고 남은 opencodex user thread를 OpenAI로 돌려 네이티브 Codex가 `config.toml`에서 프록시를 제거한 뒤에도 이어서 열 수 있게 합니다. 끄려면 `false`로 설정합니다. | | `codexAccounts?` | `CodexAccount[]` | `[]` | Codex Auth 대시보드에서 관리하는 ChatGPT/Codex pool 계정 metadata. secret은 `codex-accounts.json`에 따로 둡니다. | +| `codexAccountNamespaces?` | `Record` | — | 공개 model selector namespace에서 저장된 Codex 계정 target으로 연결하는 선택적 map입니다. 이 foundation layer는 map을 검증하고 저장하지만 picker row를 추가하거나 routing을 변경하지 않습니다. | | `activeCodexAccountId?` | `string` | — | 수동으로 선택한 pool 계정. 선택 시 기존 thread affinity를 지우고 다음 요청부터 적용하며, 진행 중인 요청은 기존 계정을 유지합니다. | | `autoSwitchThreshold?` | `number` | `80` | 새 세션 자동 전환용 사용량 백분율 threshold. 알려진 5시간, 주간, 30일 quota window 중 가장 높은 점수를 씁니다. `0`이면 quota 자동 전환을 끕니다. | | `upstreamFailoverThreshold?` | `number` | `3` | 일시적인 업스트림 실패가 연속으로 발생한 뒤, 이후 새 세션을 다른 적합한 pool 계정으로 failover할 횟수. `0`이면 실패 기반 failover를 끕니다. | @@ -62,6 +63,15 @@ namespaced selected id를 bare id로 바꿉니다. | `tokenGuardian?` | `OcxTokenGuardianConfig` | off | 선택형 proactive OAuth 갱신 및 Codex 계정 warmup 정책. 필드는 아래에 설명합니다. | | `corsAllowOrigins?` | `string[]` | `[]` | CORS에서 추가로 허용할 정확한 origin. loopback origin은 항상 허용합니다. | +`codexAccountNamespaces` 키는 공개 selector입니다. 길이는 1~64자이고 시작과 끝은 ASCII 영숫자여야 +하며, 내부에는 영숫자, `.`, `_`, `-`를 사용할 수 있습니다. 예약된 JavaScript object 이름은 거부됩니다. +값은 유효한 pool account id(내부 `__main__` 제외)이거나 Codex Desktop 계정을 나타내는 `"@main"`입니다. +provider 및 예약된 `openai` / `combo` 충돌은 대소문자를 구분하지 않고 검사하며, namespace가 있는 +combo alias는 selector를 namespace prefix로 재사용할 수 없습니다. 설정된 pool id와 다른 selector +target도 selector로 재사용할 수 없습니다. raw account id와 email은 비공개로 +유지하고 selector를 공개 이름으로 사용하세요. 이 foundation layer에서 map은 inert하며 model picker +entry 생성, session 고정, Pool / Direct routing 변경을 수행하지 않습니다. + `maxConcurrentThreadsPerSession`은 `config.json` 키가 아니라 `PUT /api/v2`에서 쓰는 camel-case 필드입니다. `ocx v2 threads `은 대응하는 `max_concurrent_threads_per_session` 값을 Codex의 `$CODEX_HOME/config.toml` 안 `[features.multi_agent_v2]`에 저장합니다. 해당 table이 생기도록 v2를 diff --git a/docs-site/src/content/docs/reference/configuration.md b/docs-site/src/content/docs/reference/configuration.md index c25f595257..8b059c8f53 100644 --- a/docs-site/src/content/docs/reference/configuration.md +++ b/docs-site/src/content/docs/reference/configuration.md @@ -57,6 +57,7 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids. | `codexShimAutoRestore?` | `boolean` | `true` | Restore a previously installed Codex shim when a completed external Codex update replaces it. Set `false`, or set `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0` for a process-level opt-out. | | `syncResumeHistory?` | `boolean` | `true` | Reversible Codex App history compatibility mode. opencodex backs up original Codex thread metadata, remaps old OpenAI interactive rows to `opencodex`, and temporarily promotes opencodex-created `exec` rows to an app-visible source. `ocx stop` / `ocx restore` restore backed-up OpenAI rows and eject remaining opencodex user threads to OpenAI so native Codex can resume them after the proxy is removed from `config.toml`. Set `false` to opt out. | | `codexAccounts?` | `CodexAccount[]` | `[]` | ChatGPT/Codex pool account metadata managed by the Codex Auth dashboard. Secrets live separately in `codex-accounts.json`. | +| `codexAccountNamespaces?` | `Record` | — | Optional public model-selector namespace → stored Codex account target map. This foundation layer validates and persists the map but does not add picker rows or change routing. | | `activeCodexAccountId?` | `string` | — | Manually selected Pool account. Selection clears existing thread affinity and applies to the next request; in-flight requests keep their captured account. | | `autoSwitchThreshold?` | `number` | `80` | Usage percent threshold for new-session auto-switching. The score uses the hottest known 5h, weekly, or 30d quota window. Set `0` to disable quota auto-switching. | | `upstreamFailoverThreshold?` | `number` | `3` | Consecutive transient upstream failures before future new sessions fail over to another eligible pool account. Set `0` to disable failure failover. | @@ -68,6 +69,15 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids. | `tokenGuardian?` | `OcxTokenGuardianConfig` | off | Optional proactive OAuth refresh and Codex-account warmup policy; fields are listed below. | | `corsAllowOrigins?` | `string[]` | `[]` | Additional exact origins allowed by CORS. Loopback origins are always allowed. | +`codexAccountNamespaces` keys are public selectors: 1–64 characters, starting and ending with an +ASCII letter or number, with letters, numbers, `.`, `_`, or `-` inside; reserved JavaScript object +names are rejected. Each value is either a valid pool-account id (never the internal `__main__`) or +`"@main"` for the Codex Desktop account. Provider and reserved `openai` / `combo` collisions are +checked case-insensitively; a namespaced combo alias cannot reuse a selector as its namespace prefix, +and configured pool ids or selector targets also cannot reuse a selector. Keep raw +account ids and emails private—the selector is the public name. In this foundation layer the map is +inert: it does not create model-picker entries, pin sessions, or alter Pool or Direct routing. + `maxConcurrentThreadsPerSession` is the camel-case field used by `PUT /api/v2`, not a `config.json` key. `ocx v2 threads ` persists the corresponding `max_concurrent_threads_per_session` value under `[features.multi_agent_v2]` in Codex's diff --git a/docs-site/src/content/docs/ru/reference/configuration.md b/docs-site/src/content/docs/ru/reference/configuration.md index 4d426322fd..1437c919d6 100644 --- a/docs-site/src/content/docs/ru/reference/configuration.md +++ b/docs-site/src/content/docs/ru/reference/configuration.md @@ -56,6 +56,7 @@ opencodex настраивается файлом `~/.opencodex/config.json`. Е | `codexShimAutoRestore?` | `boolean` | `true` | Восстанавливает ранее установленный shim после того, как завершённое внешнее обновление Codex заменило его. Для отключения задайте `false` или установите процессу `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0`. | | `syncResumeHistory?` | `boolean` | `true` | Обратимый режим совместимости истории Codex App. opencodex резервирует исходные метаданные потоков Codex, переназначает старые интерактивные строки OpenAI на `opencodex` и временно повышает созданные opencodex строки `exec` до видимого в приложении источника. `ocx stop` / `ocx restore` восстанавливают зарезервированные строки OpenAI и возвращают оставшиеся пользовательские потоки opencodex обратно к OpenAI, чтобы нативный Codex мог возобновлять их после удаления прокси из `config.toml`. Установите `false`, чтобы отказаться. | | `codexAccounts?` | `CodexAccount[]` | `[]` | Метаданные аккаунтов пула ChatGPT/Codex, управляемые дашбордом Codex Auth. Секреты хранятся отдельно в `codex-accounts.json`. | +| `codexAccountNamespaces?` | `Record` | — | Необязательная map публичного namespace селектора модели на сохранённую цель аккаунта Codex. Этот foundation layer проверяет и сохраняет map, но не добавляет строки picker и не меняет routing. | | `activeCodexAccountId?` | `string` | — | Вручную выбранный аккаунт пула. Выбор очищает существующие привязки потоков и действует со следующего запроса; выполняющиеся запросы сохраняют захваченный аккаунт. | | `autoSwitchThreshold?` | `number` | `80` | Порог процента использования для автопереключения новых сессий. Оценка использует самое «горячее» из известных окон квоты — 5-часовое, недельное или 30-дневное. Установите `0`, чтобы отключить автопереключение по квоте. | | `upstreamFailoverThreshold?` | `number` | `3` | Число подряд идущих временных сбоев вышестоящей стороны, после которого будущие новые сессии переключаются (failover) на другой подходящий аккаунт пула. Установите `0`, чтобы отключить переключение по сбоям. | @@ -66,6 +67,16 @@ opencodex настраивается файлом `~/.opencodex/config.json`. Е | `tokenGuardian?` | `OcxTokenGuardianConfig` | выкл. | Необязательная политика проактивного обновления OAuth и прогрева аккаунтов Codex; поля перечислены ниже. | | `corsAllowOrigins?` | `string[]` | `[]` | Дополнительные точные origin, разрешённые CORS. Loopback-origin разрешены всегда. | +Ключи `codexAccountNamespaces` — публичные селекторы длиной 1–64 символа. Они должны начинаться и +заканчиваться ASCII-буквой или цифрой; внутри разрешены буквы, цифры, `.`, `_` и `-`. Зарезервированные +имена объектов JavaScript запрещены. Значение — допустимый id аккаунта пула (кроме внутреннего `__main__`) +либо `"@main"` для аккаунта Codex Desktop. Коллизии с provider и зарезервированными `openai` / `combo` +проверяются без учёта регистра; namespace-префикс namespaced combo alias не может повторять селектор. +Настроенные id пула и цели других селекторов также нельзя повторно использовать как селектор. Сохраняйте +raw id аккаунтов и email приватными, а селектор используйте как публичное имя. +В этом foundation layer map инертна: она не создаёт записи model picker, не закрепляет сессии и не меняет +маршрутизацию Pool / Direct. + `maxConcurrentThreadsPerSession` — это camelCase-поле, используемое `PUT /api/v2`, а не ключ `config.json`. `ocx v2 threads ` сохраняет соответствующее значение `max_concurrent_threads_per_session` в `[features.multi_agent_v2]` в diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration.md b/docs-site/src/content/docs/zh-cn/reference/configuration.md index f41c59aea3..caac804c40 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration.md @@ -50,6 +50,7 @@ no-replace 方式创建 `config.json.pre-openai-tiers-v2.bak`,并把已知旧 | `codexShimAutoRestore?` | `boolean` | `true` | 已完成的外部 Codex 更新替换此前安装的 shim 时自动恢复。若要关闭,请设为 `false`,或为进程设置 `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0`。 | | `syncResumeHistory?` | `boolean` | `true` | 可逆的 Codex App 历史兼容模式。opencodex 会备份原始 Codex thread metadata,把旧 OpenAI interactive row 重映射到 `opencodex`,并暂时把 opencodex 创建的 `exec` row 提升成 App 可见 source。`ocx stop` / `ocx restore` 会恢复已备份的 OpenAI row,并把剩余 opencodex user thread 转回 OpenAI,使原生 Codex 在从 `config.toml` 移除代理后仍能继续这些 thread。设为 `false` 可退出该模式。 | | `codexAccounts?` | `CodexAccount[]` | `[]` | Codex Auth 仪表盘管理的 ChatGPT/Codex pool account metadata。secret 单独存放在 `codex-accounts.json`。 | +| `codexAccountNamespaces?` | `Record` | — | 可选的公开 model selector namespace 到已保存 Codex account target 的映射。此 foundation layer 只验证并持久化该映射,不会添加 picker row 或改变 routing。 | | `activeCodexAccountId?` | `string` | — | 手动选择的 pool account。选择时清除已有 thread affinity,并从下一次请求开始生效;进行中的请求保留原账号。 | | `autoSwitchThreshold?` | `number` | `80` | 新 session 自动切换的 usage 百分比 threshold。分数取已知 5 小时、周或 30 天 quota window 中最高的一项。设为 `0` 可禁用 quota 自动切换。 | | `upstreamFailoverThreshold?` | `number` | `3` | 连续发生多少次临时上游失败后,让后续新 session failover 到其他合格 pool account。设为 `0` 可禁用失败切换。 | @@ -60,6 +61,14 @@ no-replace 方式创建 `config.json.pre-openai-tiers-v2.bak`,并把已知旧 | `tokenGuardian?` | `OcxTokenGuardianConfig` | 关闭 | 可选的 proactive OAuth 刷新和 Codex account warmup 策略;字段见下文。 | | `corsAllowOrigins?` | `string[]` | `[]` | CORS 额外允许的精确 origin。loopback origin 始终允许。 | +`codexAccountNamespaces` 的 key 是公开 selector:长度为 1–64 个字符,首尾必须是 ASCII 字母或数字, +中间可使用字母、数字、`.`、`_` 或 `-`;保留的 JavaScript object 名称会被拒绝。value 必须是有效的 +pool account id(不能是内部 `__main__`),或用 `"@main"` 表示 Codex Desktop 账号。与 provider 及 +保留的 `openai` / `combo` 冲突时不区分大小写;带 namespace 的 combo alias 不能把 selector 复用为 +其 namespace prefix,已配置的 pool id 和其他 selector target 也不能复用为 selector。raw account id +与 email 应保持私密,selector 才是公开名称。在此 foundation layer 中, +该映射是 inert 的:不会创建 model picker entry、固定 session,也不会改变 Pool / Direct routing。 + `maxConcurrentThreadsPerSession` 是 `PUT /api/v2` 使用的 camel-case 字段,不是 `config.json` key。 `ocx v2 threads ` 会把对应的 `max_concurrent_threads_per_session` 值写入 Codex `$CODEX_HOME/config.toml` 的 `[features.multi_agent_v2]` 下;请先启用 v2,确保该 table 存在。 diff --git a/gui/src/components/use-add-provider-oauth.ts b/gui/src/components/use-add-provider-oauth.ts index 95e6b394a7..dfcceb4b32 100644 --- a/gui/src/components/use-add-provider-oauth.ts +++ b/gui/src/components/use-add-provider-oauth.ts @@ -2,6 +2,8 @@ import { useCallback } from "react"; import type { TFn } from "../i18n/shared"; import { readJsonIfOk } from "../fetch-json"; +export const OAUTH_LOGIN_POLL_INTERVAL_MS = 2_000; + export function useAddProviderOAuth({ apiBase, t, @@ -52,17 +54,17 @@ export function useAddProviderOAuth({ if (data.url) { setOauthUrl(data.url, providerId); setOauthMsg(t("modal.waitingLogin")); } else { setOauthMsg(data.instructions || t("modal.loggingIn")); } for (let i = 0; i < 100; i++) { - await new Promise(r => setTimeout(r, 2000)); + await new Promise(r => setTimeout(r, OAUTH_LOGIN_POLL_INTERVAL_MS)); if (!aliveRef.current) return; const sRes = await fetch(`${apiBase}/api/oauth/status?provider=${providerId}`).catch(() => null); const s = sRes ? await readJsonIfOk<{ loggedIn?: boolean; error?: string }>(sRes) : null; if (!aliveRef.current) return; - if (s?.loggedIn) { onAdded(providerId); return; } if (s?.error) { setOauthMsgTone("warn"); setOauthMsg(t("modal.loginError", { error: s.error })); return; } + if (s?.loggedIn) { onAdded(providerId); return; } } setOauthMsgTone("warn"); setOauthMsg(t("modal.loginTimeout")); diff --git a/gui/tests/add-provider-oauth-url-leak.test.tsx b/gui/tests/add-provider-oauth-url-leak.test.tsx index 9e0a04ecc5..8265f64071 100644 --- a/gui/tests/add-provider-oauth-url-leak.test.tsx +++ b/gui/tests/add-provider-oauth-url-leak.test.tsx @@ -1,9 +1,10 @@ -import { afterEach, beforeEach, expect, test } from "bun:test"; +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; import { Window } from "happy-dom"; import { act } from "react"; import type { Root } from "react-dom/client"; import { LanguageProvider } from "../src/i18n/provider"; import AddProviderModal from "../src/components/AddProviderModal"; +import { OAUTH_LOGIN_POLL_INTERVAL_MS } from "../src/components/use-add-provider-oauth"; /** * The add-provider OAuth pane renders the authorization URL so a user whose @@ -23,6 +24,7 @@ let host: HTMLElement; let root: Root | null = null; let originalFetch: typeof globalThis.fetch; let pendingLogins: Array<(url: string) => void> = []; +let oauthStatus: { loggedIn: boolean; error?: string } = { loggedIn: false }; const PRESETS = [ { id: "claude", label: "Claude", adapter: "anthropic", baseUrl: "https://api.anthropic.com", auth: "oauth", oauthProvider: "claude" }, @@ -43,6 +45,7 @@ beforeEach(() => { (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; pendingLogins = []; + oauthStatus = { loggedIn: false }; Object.defineProperty(globalThis, "fetch", { configurable: true, value: async (input: RequestInfo | URL, init?: RequestInit) => { @@ -56,7 +59,7 @@ beforeEach(() => { pendingLogins.push((authUrl: string) => resolve(Response.json({ url: authUrl }))); }); } - if (url.pathname === "/api/oauth/status") return Response.json({ loggedIn: false }); + if (url.pathname === "/api/oauth/status") return Response.json(oauthStatus); return Response.json({}); }, }); @@ -78,13 +81,13 @@ afterEach(async () => { await win.happyDOM?.close?.(); }); -async function mountModal() { +async function mountModal(onAdded: (name: string) => void = () => {}) { const { createRoot } = await import("react-dom/client"); await act(async () => { root = createRoot(host); root.render( - {}} onAdded={() => {}} /> + {}} onAdded={onAdded} /> , ); }); @@ -167,3 +170,46 @@ test("a late URL for an abandoned provider cannot overwrite the one already show expect(host.querySelector(".login-url-block-text")?.textContent).toBe(B_URL); expect(host.textContent).not.toContain(A_URL); }); + +test("a login error wins over a retained OAuth credential", async () => { + const added: string[] = []; + oauthStatus = { + loggedIn: true, + error: "The credential was saved, but the provider entry was not written.", + }; + const realSetTimeout = globalThis.setTimeout; + const timeoutSpy = spyOn(globalThis, "setTimeout").mockImplementation((( + callback: (...args: unknown[]) => void, + delay?: number, + ...args: unknown[] + ) => { + if (delay === OAUTH_LOGIN_POLL_INTERVAL_MS) { + queueMicrotask(() => callback(...args)); + return 0 as unknown as ReturnType; + } + return realSetTimeout(callback, delay, ...args); + }) as typeof setTimeout); + + try { + await mountModal(name => added.push(name)); + await act(async () => { + clickByText("Claude"); + await new Promise((r) => setTimeout(r, 20)); + }); + await act(async () => { + clickByText("Log in with Claude"); + await new Promise((r) => setTimeout(r, 20)); + }); + + expect(pendingLogins).toHaveLength(1); + await act(async () => { + pendingLogins.shift()!(A_URL); + await new Promise((r) => setTimeout(r, 40)); + }); + + expect(added).toEqual([]); + expect(host.textContent).toContain("provider entry was not written"); + } finally { + timeoutSpy.mockRestore(); + } +}); diff --git a/src/cli/account-auth.ts b/src/cli/account-auth.ts index ca29684353..79de3e67b0 100644 --- a/src/cli/account-auth.ts +++ b/src/cli/account-auth.ts @@ -137,11 +137,11 @@ async function login(argv: string[], deps: RuntimeApiDeps): Promise { for (let attempt = 0; attempt < 100; attempt++) { await Bun.sleep(2_000); const state = await runtimeRequest>(`/api/oauth/status?provider=${encodeURIComponent(provider)}`, {}, deps); + if (state.error) throw new CliUsageError(String(state.error)); if (state.loggedIn === true) { printData(state, wantsJson, [`Logged in to ${provider}.`]); return; } - if (state.error) throw new CliUsageError(String(state.error)); } throw new CliUsageError("login timed out"); } diff --git a/src/cli/provider.ts b/src/cli/provider.ts index 6c3d27c916..4e5b9d5f5d 100644 --- a/src/cli/provider.ts +++ b/src/cli/provider.ts @@ -15,6 +15,7 @@ import { providerConfigSeed } from "../providers/derive"; import type { OcxProviderConfig } from "../types"; import { findLiveProxy } from "../server/proxy-liveness"; import { syncModelsToCodex } from "../codex/sync"; +import { codexAccountNamespaceProviderCollisionError } from "../codex/account-namespace-match"; // --------------------------------------------------------------------------- // Arg helpers @@ -152,6 +153,12 @@ async function handleAdd(args: string[]): Promise { const config = loadConfig(); + const namespaceCollision = codexAccountNamespaceProviderCollisionError(config.codexAccountNamespaces, name); + if (namespaceCollision) { + console.error(`Error: ${namespaceCollision}.`); + process.exit(1); + } + if (hasOwnProvider(config.providers, name) && !force) { console.error(`Provider "${name}" already exists. Use --force to overwrite.`); process.exit(1); diff --git a/src/codex/account-id.ts b/src/codex/account-id.ts new file mode 100644 index 0000000000..5753cd056f --- /dev/null +++ b/src/codex/account-id.ts @@ -0,0 +1,34 @@ +/** Canonical persisted Codex pool-account id format. */ +export const CODEX_ACCOUNT_ID_RE = /^[A-Za-z0-9._-]{1,64}$/; + +// Account credentials are persisted in JSON object maps. These names have +// special meaning on ordinary JavaScript objects and must never be admitted as +// user-controlled keys. +const RESERVED_CODEX_ACCOUNT_IDS = new Set(["__proto__", "prototype", "constructor"]); + +/** + * Stable internal id under which the Codex Desktop login participates in account rotation. + * It is reserved and must never be used as a public namespace target or pool-account id. + */ +export const MAIN_CODEX_ACCOUNT_ID = "__main__"; + +export function isValidCodexAccountId(accountId: unknown): accountId is string { + return typeof accountId === "string" + && accountId !== MAIN_CODEX_ACCOUNT_ID + && CODEX_ACCOUNT_ID_RE.test(accountId) + && !RESERVED_CODEX_ACCOUNT_IDS.has(accountId.toLowerCase()); +} + +type CodexAccountIdentityRow = { id: string; isMain: boolean }; + +/** Legacy invalid rows remain loadable for cleanup, but never participate in routing. */ +export function isSelectableCodexPoolAccount(account: CodexAccountIdentityRow): boolean { + return !account.isMain && isValidCodexAccountId(account.id); +} + +/** A pre-validation pool row that collides with the internal Desktop-account sentinel. */ +export function hasLegacyMainCodexPoolAccount( + accounts: readonly CodexAccountIdentityRow[] | undefined, +): boolean { + return accounts?.some(account => !account.isMain && account.id === MAIN_CODEX_ACCOUNT_ID) ?? false; +} diff --git a/src/codex/account-lifecycle.ts b/src/codex/account-lifecycle.ts index 9186ea85b2..b6078bf052 100644 --- a/src/codex/account-lifecycle.ts +++ b/src/codex/account-lifecycle.ts @@ -45,7 +45,8 @@ export function resetMainCodexAccountIdentityTrackingForTests(): void { export function deleteCodexAccount(runtimeConfig: OcxConfig, accountId: string): void { removeCodexAccountCredential(accountId); - runtimeConfig.codexAccounts = (runtimeConfig.codexAccounts ?? []).filter(account => account.id !== accountId); + runtimeConfig.codexAccounts = (runtimeConfig.codexAccounts ?? []) + .filter(account => account.isMain || account.id !== accountId); if (runtimeConfig.activeCodexAccountId === accountId) runtimeConfig.activeCodexAccountId = undefined; purgeCodexAccountRuntimeState(accountId); invalidateCodexWebSocketsForAccount(accountId); diff --git a/src/codex/account-namespace-match.ts b/src/codex/account-namespace-match.ts new file mode 100644 index 0000000000..af56555eea --- /dev/null +++ b/src/codex/account-namespace-match.ts @@ -0,0 +1,63 @@ +import { isValidCodexAccountId } from "./account-id"; + +/** Config-only sentinel for the Codex Desktop account; outside the pool-account id grammar. */ +export const MAIN_CODEX_ACCOUNT_NAMESPACE_TARGET = "@main"; + +export const CODEX_ACCOUNT_NAMESPACE_COMBO_ALIAS_COLLISION_ERROR = + "combo alias must not use a configured Codex account namespace"; + +const CODEX_ACCOUNT_ID_NAMESPACE_COLLISION_ERROR = + "account id must not collide with a configured Codex account namespace"; + +/** Provider ids are compared case-insensitively at namespace admission boundaries. */ +export function codexProviderNamespaceKey(value: string): string { + return value.toLowerCase(); +} + +export function hasCodexAccountNamespace( + namespaces: unknown, + namespace: string, +): boolean { + return !!namespaces + && typeof namespaces === "object" + && !Array.isArray(namespaces) + && Object.hasOwn(namespaces, namespace); +} + +export function codexAccountNamespaceProviderCollisionError( + namespaces: unknown, + providerName: string, +): string | undefined { + const normalizedProvider = codexProviderNamespaceKey(providerName); + const collides = !!namespaces + && typeof namespaces === "object" + && !Array.isArray(namespaces) + && Object.keys(namespaces) + .some(namespace => codexProviderNamespaceKey(namespace) === normalizedProvider); + return collides + ? "provider name must not collide with a configured Codex account namespace" + : undefined; +} + +export function codexAccountIdNamespaceCollisionError( + namespaces: unknown, + accountId: string, +): string | undefined { + return hasCodexAccountNamespace(namespaces, accountId) + ? CODEX_ACCOUNT_ID_NAMESPACE_COLLISION_ERROR + : undefined; +} + +export function codexAccountNamespaceForModel( + namespaces: unknown, + modelId: string, +): string | undefined { + const slash = modelId.indexOf("/"); + if (slash <= 0) return undefined; + const namespace = modelId.slice(0, slash); + return hasCodexAccountNamespace(namespaces, namespace) ? namespace : undefined; +} + +export function isValidCodexAccountNamespaceTarget(accountId: unknown): accountId is string { + return accountId === MAIN_CODEX_ACCOUNT_NAMESPACE_TARGET || isValidCodexAccountId(accountId); +} diff --git a/src/codex/account-namespaces.ts b/src/codex/account-namespaces.ts new file mode 100644 index 0000000000..6c41850b54 --- /dev/null +++ b/src/codex/account-namespaces.ts @@ -0,0 +1,149 @@ +import type { CodexAccount, OcxConfig } from "../types"; +import { COMBO_NAMESPACE } from "../combos/types"; +import { OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers"; +import { + CODEX_ACCOUNT_LOG_LABEL_RE, + createCodexAccountLogLabel, + fallbackCodexAccountLogLabel, +} from "./account-label"; +import { isValidCodexAccountId, MAIN_CODEX_ACCOUNT_ID } from "./account-id"; +import { + codexAccountIdNamespaceCollisionError, + codexProviderNamespaceKey, + MAIN_CODEX_ACCOUNT_NAMESPACE_TARGET, +} from "./account-namespace-match"; + +export { isValidCodexAccountNamespaceTarget } from "./account-namespace-match"; + +const RESERVED_NAMESPACE_KEYS = new Set([ + "__proto__", + "prototype", + "constructor", + COMBO_NAMESPACE, + OPENAI_CODEX_PROVIDER_ID, +].map(codexProviderNamespaceKey)); +const PUBLIC_ACCOUNT_SELECTOR_MAX_ATTEMPTS = 16; + +function comboAliasNamespaces(config: Pick): string[] { + return Object.values(config.combos ?? {}).flatMap((combo) => { + const alias = typeof combo?.alias === "string" ? combo.alias.trim() : ""; + const slash = alias.indexOf("/"); + return slash > 0 ? [alias.slice(0, slash)] : []; + }); +} + +function privateAccountSelectorCandidates(accountIds: Iterable): Set { + const candidates = new Set(); + for (const accountId of accountIds) { + candidates.add(accountId); + candidates.add(fallbackCodexAccountLogLabel(accountId)); + } + return candidates; +} + +function defaultPublicAccountSelector( + account: Pick, + allPrivateCandidates: ReadonlySet, +): string { + const privateCandidates = new Set(allPrivateCandidates); + privateCandidates.add(account.id); + privateCandidates.add(fallbackCodexAccountLogLabel(account.id)); + if (CODEX_ACCOUNT_LOG_LABEL_RE.test(account.logLabel ?? "") + && !privateCandidates.has(account.logLabel!)) return account.logLabel!; + + // Legacy or hand-edited rows may predate persisted random log labels. Allocate a fresh public + // selector rather than exposing the stable, id-derived fallback used only to redact old logs. + for (let attempt = 0; attempt < PUBLIC_ACCOUNT_SELECTOR_MAX_ATTEMPTS; attempt += 1) { + const selector = createCodexAccountLogLabel(privateCandidates); + if (!privateCandidates.has(selector)) return selector; + } + throw new Error("Unable to allocate a unique Codex account selector"); +} + +function claimNamespace(requested: string, used: Set): string { + let namespace = requested; + let suffix = 2; + while (used.has(namespace)) namespace = `${requested}-${suffix++}`; + used.add(namespace); + return namespace; +} + +function occupiedNamespaces(config: Pick): Set { + return new Set([ + ...Object.keys(config.providers).map(codexProviderNamespaceKey), + ...comboAliasNamespaces(config), + ...RESERVED_NAMESPACE_KEYS, + ]); +} + +/** Build an initial account-selector map without deriving public selectors from aliases or ids. */ +export function defaultCodexAccountNamespaces( + config: Pick, +): Record { + const namespaces: Record = {}; + const used = occupiedNamespaces(config); + const accounts = config.codexAccounts ?? []; + const privateCandidates = privateAccountSelectorCandidates( + accounts + .filter(account => account.id !== MAIN_CODEX_ACCOUNT_ID) + .map(account => account.id), + ); + for (const candidate of privateCandidates) used.add(candidate); + + namespaces[claimNamespace("main", used)] = MAIN_CODEX_ACCOUNT_NAMESPACE_TARGET; + for (const account of accounts) { + if (account.isMain || !isValidCodexAccountId(account.id)) continue; + const namespace = claimNamespace(defaultPublicAccountSelector(account, privateCandidates), used); + namespaces[namespace] = account.id; + } + return namespaces; +} + +/** + * Add one account to a generated map without renaming or replacing explicit existing entries. + * The account-creation layer must reject a new id that already equals an existing selector key. + * A true result means the map was mutated in place; callers must persist the updated config. + */ +export function appendDefaultCodexAccountNamespace( + config: Pick, + account: Pick, +): boolean { + const namespaces = config.codexAccountNamespaces; + if (account.isMain + || !isValidCodexAccountId(account.id) + || !namespaces + || Object.keys(namespaces).length === 0 + || codexAccountIdNamespaceCollisionError(namespaces, account.id) + || Object.values(namespaces).includes(account.id)) return false; + + const used = occupiedNamespaces(config); + for (const namespace of Object.keys(namespaces)) used.add(namespace); + const privateCandidates = privateAccountSelectorCandidates([ + ...(config.codexAccounts ?? []) + .filter(existing => existing.id !== MAIN_CODEX_ACCOUNT_ID) + .map(existing => existing.id), + ...Object.values(namespaces), + account.id, + ]); + for (const candidate of privateCandidates) used.add(candidate); + const namespace = claimNamespace(defaultPublicAccountSelector(account, privateCandidates), used); + namespaces[namespace] = account.id; + return true; +} + +export function isMainCodexAccountTarget(accountId: string): boolean { + return accountId === MAIN_CODEX_ACCOUNT_NAMESPACE_TARGET || accountId === MAIN_CODEX_ACCOUNT_ID; +} + +function normalizeCodexAccountNamespaceTarget(accountId: string): string { + return isMainCodexAccountTarget(accountId) + ? MAIN_CODEX_ACCOUNT_ID + : accountId; +} + +export function codexAccountNamespaceEntries( + config: Pick, +): Array<[string, string]> { + return Object.entries(config.codexAccountNamespaces ?? {}) + .map(([namespace, accountId]) => [namespace, normalizeCodexAccountNamespaceTarget(accountId)]); +} diff --git a/src/codex/account-usability.ts b/src/codex/account-usability.ts index 9d34d465b0..66141c86c0 100644 --- a/src/codex/account-usability.ts +++ b/src/codex/account-usability.ts @@ -1,14 +1,19 @@ import { getCodexAccountCredential } from "./account-store"; import { isAccountNeedsReauth } from "./account-runtime-state"; import { MAIN_CODEX_ACCOUNT_ID, isMainAccountTokenLive } from "./main-account"; +import { hasLegacyMainCodexPoolAccount, isSelectableCodexPoolAccount } from "./account-id"; import type { OcxConfig } from "../types"; export function isCodexAccountUsable(config: OcxConfig, accountId: string): boolean { if (accountId === MAIN_CODEX_ACCOUNT_ID) { + // A legacy pool row with the sentinel makes an active `__main__` ambiguous. + // Fail closed until the authenticated compatibility-delete path removes it. + if (hasLegacyMainCodexPoolAccount(config.codexAccounts)) return false; // Main account: credential is the read-only ~/.codex/auth.json token (Option A). return isMainAccountTokenLive() && !isAccountNeedsReauth(accountId); } - const exists = (config.codexAccounts ?? []).some(account => !account.isMain && account.id === accountId); + const exists = (config.codexAccounts ?? []) + .some(account => isSelectableCodexPoolAccount(account) && account.id === accountId); if (!exists) return false; if (isAccountNeedsReauth(accountId)) return false; return !!getCodexAccountCredential(accountId); diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts index 5d1fba5da3..b8a3fe475e 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -55,6 +55,13 @@ import { type OAuthAccountHealth, type OAuthHealthLabel, } from "../oauth/health"; +import { + CODEX_ACCOUNT_ID_RE, + hasLegacyMainCodexPoolAccount, + isSelectableCodexPoolAccount, + isValidCodexAccountId, +} from "./account-id"; +import { codexAccountIdNamespaceCollisionError } from "./account-namespace-match"; function jsonResponse(data: unknown, status = 200): Response { return new Response(JSON.stringify(data), { @@ -63,14 +70,32 @@ function jsonResponse(data: unknown, status = 200): Response { }); } -const ACCOUNT_ID_RE = /^[a-zA-Z0-9._-]{1,64}$/; const MANUAL_IMPORT_ENV = "OPENCODEX_ENABLE_UNVERIFIED_CODEX_IMPORT"; const codexAuthLoginState = new Map(); function configuredPoolAccount(config: OcxConfig, accountId: string): CodexAccount | null { - if (!ACCOUNT_ID_RE.test(accountId)) return null; - return (config.codexAccounts ?? []).find(account => account.id === accountId && !account.isMain) ?? null; + if (!isValidCodexAccountId(accountId)) return null; + return (config.codexAccounts ?? []) + .find(account => account.id === accountId && isSelectableCodexPoolAccount(account)) ?? null; +} + +function codexAccountPersistenceConflict( + config: OcxConfig, + accountId: string, + mode: "create" | "reauth", +): string | undefined { + if (mode === "reauth") { + return configuredPoolAccount(config, accountId) + ? undefined + : "Pool account was removed while login was in progress. Add it again as a new account."; + } + const namespaceCollision = codexAccountIdNamespaceCollisionError(config.codexAccountNamespaces, accountId); + if (namespaceCollision) return namespaceCollision; + return (config.codexAccounts ?? []).some(account => account.id === accountId) + || Boolean(getCodexAccountCredential(accountId)) + ? `Account id already exists: ${accountId}` + : undefined; } function isThirtyDayOnlyPlan(plan: string | null | undefined): boolean { @@ -121,11 +146,14 @@ async function resolveResetCreditAuth( | { ok: false; response: Response } > { if (accountId === MAIN_CODEX_ACCOUNT_ID) { + if (hasLegacyMainCodexPoolAccount(runtimeConfig.codexAccounts)) { + return { ok: false, response: jsonResponse({ error: "Remove the legacy __main__ pool row before using the Desktop account" }, 409) }; + } const tokens = readCodexTokens(); if (!tokens) return { ok: false, response: jsonResponse({ error: "Main Codex account not logged in" }, 401) }; return { ok: true, isMain: true, accessToken: tokens.access_token, chatgptAccountId: tokens.account_id }; } - if (!ACCOUNT_ID_RE.test(accountId)) { + if (!isValidCodexAccountId(accountId)) { return { ok: false, response: jsonResponse({ error: "Invalid account id format" }, 400) }; } if (!configuredPoolAccount(runtimeConfig, accountId)) { @@ -441,7 +469,7 @@ export async function primeCodexPoolQuotas(config: OcxConfig, reason: string): P reconcileMainCodexAccountRuntimeState(); primeInFlight = (async () => { const runtimeConfig = getRuntimeConfig(config); - const pool = (runtimeConfig.codexAccounts ?? []).filter(a => !a.isMain); + const pool = (runtimeConfig.codexAccounts ?? []).filter(isSelectableCodexPoolAccount); const stale = pool.filter(a => { const q = getAccountQuota(a.id); return !q || Date.now() - q.updatedAt >= POOL_CACHE_TTL; @@ -473,7 +501,7 @@ export function clearCodexQuotaPrimeState(): void { export async function listCodexAuthAccounts(config: OcxConfig, forceRefresh = false): Promise { const runtimeConfig = getRuntimeConfig(config); - const poolAccounts = (runtimeConfig.codexAccounts ?? []).filter(a => !a.isMain); + const poolAccounts = (runtimeConfig.codexAccounts ?? []).filter(isSelectableCodexPoolAccount); const mainInfo = await fetchMainAccountInfo(forceRefresh); const withQuota = await mapWithConcurrency(poolAccounts, POOL_QUOTA_REFRESH_CONCURRENCY, async a => { const cred = getCodexAccountCredential(a.id); @@ -520,17 +548,15 @@ export async function handleCodexAuthAPI( if (!body.id || !body.email || !body.accessToken || !body.refreshToken || !body.chatgptAccountId) { return jsonResponse({ error: "Missing required fields" }, 400); } - if (!ACCOUNT_ID_RE.test(body.id)) { + 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 accounts = runtimeConfig.codexAccounts ?? []; - if (accounts.some(a => a.id === body.id) || getCodexAccountCredential(body.id)) { - return jsonResponse({ error: `Account id already exists: ${body.id}` }, 400); - } + 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 derivedAccountId = extractAccountId(undefined, body.accessToken) ?? body.chatgptAccountId; const collision = checkAccountIdCollision(derivedAccountId, body.email, body.plan); @@ -542,6 +568,9 @@ export async function handleCodexAuthAPI( 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); saveCodexAccountCredential(body.id, { accessToken: body.accessToken, refreshToken: body.refreshToken, @@ -550,9 +579,10 @@ export async function handleCodexAuthAPI( }); markCodexAccountValidated(body.id, warmup.validatedAt); clearAccountNeedsReauth(body.id); + const accounts = latestConfig.codexAccounts ?? []; accounts.push(withCodexAccountLogLabel({ id: body.id, email: body.email, plan: body.plan, isMain: false }, accounts)); - runtimeConfig.codexAccounts = accounts; - saveRuntimeConfig(config, runtimeConfig); + latestConfig.codexAccounts = accounts; + saveRuntimeConfig(config, latestConfig); return jsonResponse({ ok: true }); } @@ -560,6 +590,11 @@ export async function handleCodexAuthAPI( const id = url.searchParams.get("id"); if (!id) return jsonResponse({ error: "Missing id" }, 400); const runtimeConfig = getRuntimeConfig(config); + const isLegacyPoolAccount = CODEX_ACCOUNT_ID_RE.test(id) + && (runtimeConfig.codexAccounts ?? []).some(account => !account.isMain && account.id === id); + if (!isValidCodexAccountId(id) && !isLegacyPoolAccount) { + return jsonResponse({ error: "Invalid account id format" }, 400); + } deleteCodexAccount(runtimeConfig, id); saveRuntimeConfig(config, runtimeConfig); return jsonResponse({ ok: true }); @@ -569,8 +604,8 @@ export async function handleCodexAuthAPI( const body = await req.json().catch(() => ({})) as { id?: unknown; alias?: unknown }; const id = typeof body.id === "string" ? body.id.trim() : ""; const alias = typeof body.alias === "string" ? body.alias.trim() : ""; - if (!id || !ACCOUNT_ID_RE.test(id)) return jsonResponse({ error: "Invalid account id format" }, 400); if (id === MAIN_CODEX_ACCOUNT_ID) return jsonResponse({ error: "Main Codex account alias is not configurable" }, 400); + if (!isValidCodexAccountId(id)) return jsonResponse({ error: "Invalid account id format" }, 400); if (typeof body.alias !== "string" || alias.length > 80 || /[\x00-\x1f\x7f]/.test(alias)) { return jsonResponse({ error: "Alias must be a string of at most 80 printable characters" }, 400); } @@ -594,7 +629,9 @@ export async function handleCodexAuthAPI( if (url.pathname === "/api/codex-auth/accounts/clear-cooldown" && req.method === "POST") { const body = await req.json().catch(() => ({})) as { id?: unknown }; const id = typeof body.id === "string" ? body.id.trim() : ""; - if (!id || !ACCOUNT_ID_RE.test(id)) return jsonResponse({ error: "Invalid account id format" }, 400); + if (id !== MAIN_CODEX_ACCOUNT_ID && !isValidCodexAccountId(id)) { + return jsonResponse({ error: "Invalid account id format" }, 400); + } return jsonResponse({ ok: true, id, cleared: clearCodexAccountCooldown(id) }); } @@ -602,8 +639,13 @@ export async function handleCodexAuthAPI( let body: { accountId: string | null }; try { body = (await req.json()) as typeof body; } catch { return jsonResponse({ error: "Invalid JSON" }, 400); } const runtimeConfig = getRuntimeConfig(config); + if (body.accountId === MAIN_CODEX_ACCOUNT_ID && hasLegacyMainCodexPoolAccount(runtimeConfig.codexAccounts)) { + return jsonResponse({ error: "Remove the legacy __main__ pool row before selecting the Desktop account" }, 409); + } if (body.accountId != null && body.accountId !== MAIN_CODEX_ACCOUNT_ID) { - const exists = (runtimeConfig.codexAccounts ?? []).some(a => a.id === body.accountId); + if (!isValidCodexAccountId(body.accountId)) return jsonResponse({ error: "Invalid account id format" }, 400); + const exists = (runtimeConfig.codexAccounts ?? []) + .some(account => isSelectableCodexPoolAccount(account) && account.id === body.accountId); if (!exists) return jsonResponse({ error: "Account not found" }, 400); } runtimeConfig.activeCodexAccountId = body.accountId ?? undefined; @@ -734,15 +776,15 @@ export async function handleCodexAuthAPI( const body = (await req.json().catch(() => ({}))) as { id?: string; reauth?: boolean }; const requestedAccountId = body.id?.trim(); const reauth = body.reauth === true; - if (requestedAccountId && !ACCOUNT_ID_RE.test(requestedAccountId)) { + if (requestedAccountId && !isValidCodexAccountId(requestedAccountId)) { return jsonResponse({ error: "Invalid account id format" }, 400); } const accountId = requestedAccountId || `chatgpt-${Date.now()}`; const runtimeConfig = getRuntimeConfig(config); - const exists = (runtimeConfig.codexAccounts ?? []).some(a => a.id === accountId) || Boolean(getCodexAccountCredential(accountId)); - if (exists && !reauth) { - return jsonResponse({ error: `Account id already exists: ${accountId}` }, 400); - } + const preflightConflict = !reauth + ? codexAccountPersistenceConflict(runtimeConfig, accountId, "create") + : undefined; + if (preflightConflict) return jsonResponse({ error: preflightConflict }, 400); if (reauth) { if (!requestedAccountId) return jsonResponse({ error: "id required for reauth" }, 400); if (!configuredPoolAccount(runtimeConfig, accountId)) { @@ -860,6 +902,24 @@ export async function handleCodexAuthAPI( break; } + const latestConfig = getRuntimeConfig(config); + const accounts = latestConfig.codexAccounts ?? []; + const existingIdx = accounts.findIndex(account => account.id === accountId); + const commitConflict = codexAccountPersistenceConflict( + latestConfig, + accountId, + reauth ? "reauth" : "create", + ); + if (commitConflict) { + codexAuthLoginState.set(flowId, { + status: "error", + error: commitConflict, + doneAt: Date.now(), + }); + completed = true; + break; + } + saveCodexAccountCredential(accountId, { accessToken: cred.access, refreshToken: cred.refresh, @@ -872,15 +932,12 @@ export async function handleCodexAuthAPI( setAccountQuotaFromParsed(accountId, quota); } - const latestConfig = getRuntimeConfig(config); - const accounts = latestConfig.codexAccounts ?? []; - const existingIdx = accounts.findIndex(a => a.id === accountId); if (existingIdx >= 0) { // Keep the pool id stable; refresh display metadata after a successful login/reauth. accounts[existingIdx] = withCodexAccountLogLabel({ ...accounts[existingIdx], email, - plan, + plan: plan ?? accounts[existingIdx].plan, isMain: false, }, accounts); latestConfig.codexAccounts = accounts; diff --git a/src/codex/auth-collision.ts b/src/codex/auth-collision.ts index e737dc0d92..a7d10e7c97 100644 --- a/src/codex/auth-collision.ts +++ b/src/codex/auth-collision.ts @@ -4,6 +4,7 @@ import { getCodexAccountCredential } from "./account-store"; import { loadConfig } from "../config"; import { resolveCodexHomeDir } from "./home"; import { extractAccountId } from "../oauth/chatgpt"; +import { isSelectableCodexPoolAccount } from "./account-id"; export interface CodexTokens { access_token: string; @@ -94,7 +95,7 @@ export function checkAccountIdCollision( const candidateWorkspace = isWorkspacePlan(plan); for (const account of loadConfig().codexAccounts ?? []) { if (excludeAccountId && account.id === excludeAccountId) continue; - if (account.isMain) continue; + if (!isSelectableCodexPoolAccount(account)) continue; if (isWorkspacePlan(account.plan) !== candidateWorkspace) continue; const cred = getCodexAccountCredential(account.id); const poolEmail = normalizedEmail(account.email); diff --git a/src/codex/main-account.ts b/src/codex/main-account.ts index 6e98e9a765..495ad39300 100644 --- a/src/codex/main-account.ts +++ b/src/codex/main-account.ts @@ -1,14 +1,8 @@ import { readCodexTokens } from "./auth-collision"; import { decodeJwtPayload } from "../oauth/chatgpt"; +import { MAIN_CODEX_ACCOUNT_ID } from "./account-id"; -/** - * Stable id under which the "main" Codex account (the Codex CLI login stored in - * ~/.codex/auth.json) participates in opencodex's account rotation. The main account is - * NOT imported into the managed credential store (Option A): its token is read-only from - * auth.json, so opencodex never refreshes it — an expired token surfaces as a reauth - * notice (re-login via the Codex CLI) rather than a background refresh. - */ -export const MAIN_CODEX_ACCOUNT_ID = "__main__"; +export { MAIN_CODEX_ACCOUNT_ID } from "./account-id"; /** * Main account plan (e.g. "plus", "go", "free", "team"), populated from the WHAM usage diff --git a/src/codex/routing.ts b/src/codex/routing.ts index 645f4c59b7..1919b6b565 100644 --- a/src/codex/routing.ts +++ b/src/codex/routing.ts @@ -6,6 +6,7 @@ import { isCodexAccountUsable } from "./account-usability"; import { isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime-state"; import { CODEX_UNKNOWN_USAGE_SCORE, getAccountQuota } from "./quota"; import { MAIN_CODEX_ACCOUNT_ID, getMainAccountPlan } from "./main-account"; +import { isSelectableCodexPoolAccount } from "./account-id"; import type { OcxConfig } from "../types"; type ThreadAffinityEntry = { @@ -110,7 +111,8 @@ export type CodexUpstreamOutcomeMeta = { function hasConfiguredPoolAccount(config: OcxConfig, accountId: string): boolean { if (accountId === MAIN_CODEX_ACCOUNT_ID) return isCodexAccountUsable(config, accountId); - return (config.codexAccounts ?? []).some(account => !account.isMain && account.id === accountId); + return (config.codexAccounts ?? []) + .some(account => isSelectableCodexPoolAccount(account) && account.id === accountId); } export function clearThreadAccountMap(): void { @@ -441,7 +443,9 @@ function bindThreadAffinity(threadId: string, accountId: string, now: number): v function getEligiblePoolAccounts(config: OcxConfig, excludeId?: string, now = Date.now()): string[] { const ids = (config.codexAccounts ?? []) - .filter(account => !account.isMain && account.id !== excludeId && !isAccountNeedsReauth(account.id)) + .filter(account => isSelectableCodexPoolAccount(account) + && account.id !== excludeId + && !isAccountNeedsReauth(account.id)) .filter(account => !isCodexAccountInCooldown(account.id, now)) .filter(account => !isCodexAccountSoftAvoided(account.id, now)) .filter(account => isCodexAccountUsable(config, account.id)) @@ -462,7 +466,8 @@ function getEligiblePoolAccounts(config: OcxConfig, excludeId?: string, now = Da export function getPoolAccountPlan(config: OcxConfig, accountId: string): string | undefined { if (accountId === MAIN_CODEX_ACCOUNT_ID) return getMainAccountPlan(); - return (config.codexAccounts ?? []).find(account => !account.isMain && account.id === accountId)?.plan; + return (config.codexAccounts ?? []) + .find(account => isSelectableCodexPoolAccount(account) && account.id === accountId)?.plan; } function pickLowerUsageAccount(config: OcxConfig, active: string, activeUsage: number, now: number): string { @@ -838,6 +843,7 @@ export function formatCodexProviderForLog(providerName: string, accountId: strin // same physical account as the "main" passthrough (null accountId). Log both under the base provider // name so usage/tokens aggregate into a single row instead of splitting into `chatgpt` + `chatgpt-main`. if (accountId === MAIN_CODEX_ACCOUNT_ID) return providerName; - const account = (config.codexAccounts ?? []).find(a => !a.isMain && a.id === accountId); + const account = (config.codexAccounts ?? []) + .find(candidate => isSelectableCodexPoolAccount(candidate) && candidate.id === accountId); return account ? `${providerName}-${codexAccountLogLabel(account)}` : providerName; } diff --git a/src/config.ts b/src/config.ts index 68adef2962..0908b0da15 100644 --- a/src/config.ts +++ b/src/config.ts @@ -3,7 +3,14 @@ import { copyFileSync, existsSync, linkSync, mkdirSync, readFileSync, renameSync import { homedir } from "node:os"; import { join, resolve } from "node:path"; import * as z from "zod/v4"; -import { comboConfigIssues } from "./combos/types"; +import { + CODEX_ACCOUNT_NAMESPACE_COMBO_ALIAS_COLLISION_ERROR, + codexAccountNamespaceForModel, + codexProviderNamespaceKey, + isValidCodexAccountNamespaceTarget, + MAIN_CODEX_ACCOUNT_NAMESPACE_TARGET, +} from "./codex/account-namespace-match"; +import { COMBO_NAMESPACE, comboConfigIssues } from "./combos/types"; import { hardenSecretDir, hardenSecretPath } from "./lib/windows-secret-acl"; import { providerDestinationConfigError } from "./lib/destination-policy"; import { openRouterRoutingConfigError } from "./providers/openrouter-routing"; @@ -15,7 +22,7 @@ import { type OcxConfig, type OcxProviderConfig, } from "./types"; -import { isCanonicalOpenAiForwardProvider } from "./providers/openai-tiers"; +import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "./providers/openai-tiers"; import { parseDesktopProfile } from "./claude/desktop-profile"; import { isCodexReasoningEffort, modelRecordValue } from "./reasoning-effort"; @@ -507,6 +514,52 @@ export function modelAdapterRecordConfigError( return null; } +const CODEX_ACCOUNT_NAMESPACES_RECORD_ERROR = + "codexAccountNamespaces must be a plain object mapping account selectors to Codex account ids"; +const CODEX_ACCOUNT_NAMESPACE_KEY_ERROR = + "account selectors must use 1-64 letters, numbers, dots, underscores, or hyphens and cannot be reserved JavaScript object keys"; +const CODEX_ACCOUNT_NAMESPACE_TARGET_ERROR = + "account selector targets must be @main or valid Codex pool-account ids"; +const CODEX_ACCOUNT_NAMESPACE_ACCOUNT_ID_COLLISION_ERROR = + "account selectors must not collide with configured Codex pool-account ids or account selector targets"; + +function configuredCodexPoolAccountIds(value: unknown): Set { + const accountIds = new Set(); + if (!Array.isArray(value)) return accountIds; + for (const account of value) { + if (!account || typeof account !== "object" || Array.isArray(account)) continue; + const { id, isMain } = account as { id?: unknown; isMain?: unknown }; + if (typeof id === "string" && isMain !== true) accountIds.add(id); + } + return accountIds; +} + +const codexAccountNamespacesSchema = z.custom>( + (value): value is Record => !!value + && typeof value === "object" + && !Array.isArray(value) + && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null), + { error: CODEX_ACCOUNT_NAMESPACES_RECORD_ERROR }, +).superRefine((accountNamespaces, ctx) => { + // Inspect raw own entries before z.record parses them; Zod omits __proto__ record keys. + for (const [namespace, accountId] of Object.entries(accountNamespaces)) { + if (!isValidProviderName(namespace)) { + ctx.addIssue({ + code: "custom", + path: [namespace], + message: CODEX_ACCOUNT_NAMESPACE_KEY_ERROR, + }); + } + if (!isValidCodexAccountNamespaceTarget(accountId)) { + ctx.addIssue({ + code: "custom", + path: [namespace], + message: CODEX_ACCOUNT_NAMESPACE_TARGET_ERROR, + }); + } + } +}).pipe(z.record(z.string(), z.string())); + const configSchema = z.object({ port: z.number().int().min(0).max(65535).default(10100), providers: z.record(z.string(), providerConfigSchema), @@ -523,6 +576,7 @@ const configSchema = z.object({ injectionEffort: z.string().optional().catch(undefined), syncCodexSubagentDefaults: z.boolean().optional().catch(undefined), codexShimAutoRestore: z.boolean().optional(), + codexAccountNamespaces: codexAccountNamespacesSchema.optional(), // Model ids excluded from the Grok Build managed block (dashboard switches). grokExcludedModels: z.array(z.string()).optional(), // Invalid values degrade to undefined ("auto") instead of failing the whole @@ -544,6 +598,36 @@ const configSchema = z.object({ }); } } + + const accountNamespaces = config.codexAccountNamespaces; + if (accountNamespaces) { + const configuredAccountIds = configuredCodexPoolAccountIds(config.codexAccounts); + const configuredProviderNamespaces = new Set([ + COMBO_NAMESPACE, + OPENAI_CODEX_PROVIDER_ID, + ...Object.keys(config.providers), + ].map(codexProviderNamespaceKey)); + const namespaceTargets = new Set( + Object.values(accountNamespaces) + .filter(accountId => accountId !== MAIN_CODEX_ACCOUNT_NAMESPACE_TARGET), + ); + for (const namespace of Object.keys(accountNamespaces)) { + if (configuredProviderNamespaces.has(codexProviderNamespaceKey(namespace))) { + ctx.addIssue({ + code: "custom", + path: ["codexAccountNamespaces", namespace], + message: "account selectors must not collide with configured provider or combo namespaces", + }); + } + if (configuredAccountIds.has(namespace) || namespaceTargets.has(namespace)) { + ctx.addIssue({ + code: "custom", + path: ["codexAccountNamespaces", namespace], + message: CODEX_ACCOUNT_NAMESPACE_ACCOUNT_ID_COLLISION_ERROR, + }); + } + } + } for (const name of Object.keys(config.providers)) { if (!isValidProviderName(name)) { ctx.addIssue({ @@ -705,6 +789,16 @@ const configSchema = z.object({ ctx.addIssue({ code: "custom", path: ["combos"], message: "combos must be an object" }); } else { for (const [id, raw] of Object.entries(combos as Record)) { + const alias = raw && typeof raw === "object" && !Array.isArray(raw) + ? (raw as { alias?: unknown }).alias + : undefined; + if (typeof alias === "string" && codexAccountNamespaceForModel(accountNamespaces, alias.trim())) { + ctx.addIssue({ + code: "custom", + path: ["combos", id, "alias"], + message: CODEX_ACCOUNT_NAMESPACE_COMBO_ALIAS_COLLISION_ERROR, + }); + } // Pass the full map so cross-combo rules (alias uniqueness) apply at load time // too, not just via the management API; each combo is excluded from its own check. for (const issue of comboConfigIssues(id, raw, config.providers, { @@ -1012,6 +1106,16 @@ export function websocketsEnabled(config: Pick): boolea */ const claudeCodeBaseline = new WeakMap(); +/** + * The live config retains the address of the socket Bun actually opened, while + * this map retains the operator's desired address for the next process start. + * Keeping them separate prevents an unrelated live save from restoring a stale + * externally exposed bind after OAuth adopted a newer loopback disk config. + */ +type PersistedServerBinding = Pick; + +const persistedLiveServerBinding = new WeakMap(); + /** * Arm the baseline for a long-lived config. MANDATORY at `startServer`, not lazy on * first save — arming lazily would lose exactly the hand edit made before that first @@ -1048,6 +1152,116 @@ function deepEqual(a: unknown, b: unknown): boolean { return true; } +const MISSING_CONFIG_VALUE = Symbol("missing-config-value"); +type ConfigMergeValue = unknown | typeof MISSING_CONFIG_VALUE; + +function isPlainConfigRecord(value: ConfigMergeValue): value is Record { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function ownConfigValue(record: Record, key: string): ConfigMergeValue { + return Object.hasOwn(record, key) ? record[key] : MISSING_CONFIG_VALUE; +} + +function cloneConfigValue(value: ConfigMergeValue): ConfigMergeValue { + return value === MISSING_CONFIG_VALUE ? value : structuredClone(value); +} + +function reconcileConfigRecord( + live: Record, + baseline: Record, + persisted: Record, + skippedKeys?: ReadonlySet, +): void { + const keys = new Set([...Object.keys(baseline), ...Object.keys(live), ...Object.keys(persisted)]); + for (const key of keys) { + if (skippedKeys?.has(key)) continue; + const merged = reconcileConfigValue( + ownConfigValue(baseline, key), + ownConfigValue(live, key), + ownConfigValue(persisted, key), + ); + if (merged === MISSING_CONFIG_VALUE) delete live[key]; + else live[key] = merged; + } +} + +function reconcileConfigValue( + baseline: ConfigMergeValue, + live: ConfigMergeValue, + persisted: ConfigMergeValue, +): ConfigMergeValue { + const liveChanged = !deepEqual(live, baseline); + const persistedChanged = !deepEqual(persisted, baseline); + + if (!liveChanged) { + if (live !== MISSING_CONFIG_VALUE && Array.isArray(live) && Array.isArray(persisted)) { + live.splice(0, live.length, ...structuredClone(persisted)); + return live; + } + if (isPlainConfigRecord(live) && isPlainConfigRecord(persisted)) { + reconcileConfigRecord( + live, + isPlainConfigRecord(baseline) ? baseline : {}, + persisted, + ); + return live; + } + return cloneConfigValue(persisted); + } + + if (!persistedChanged) return live; + + if (isPlainConfigRecord(live) + && isPlainConfigRecord(persisted) + && (baseline === MISSING_CONFIG_VALUE || isPlainConfigRecord(baseline))) { + reconcileConfigRecord( + live, + isPlainConfigRecord(baseline) ? baseline : {}, + persisted, + ); + } + // Same-leaf conflicts prefer the pending live management mutation. + return live; +} + +/** + * Reconcile an async OAuth disk commit into the shared live config without erasing + * management mutations that have not saved yet. The baseline is a normalized disk + * snapshot from immediately before login; disjoint object edits merge recursively, + * while same-leaf conflicts prefer live state. + */ +export function reconcileLiveConfigFromDisk(config: OcxConfig, persistedBaseline: OcxConfig): void { + const diagnostics = readConfigDiagnostics(); + if (diagnostics.source === "fallback") { + throw new Error(`OAuth config reconciliation failed: ${diagnostics.error ?? "invalid config file"}`); + } + const persisted = diagnostics.config; + const claudeGuardArmed = claudeCodeBaseline.has(config); + const pendingLiveClaudeMutation = claudeGuardArmed + && !deepEqual(config.claudeCode, claudeCodeBaseline.get(config)); + + persistedLiveServerBinding.set(config, { + port: persisted.port, + ...(persisted.hostname !== undefined ? { hostname: persisted.hostname } : {}), + }); + + reconcileConfigRecord( + config as unknown as Record, + persistedBaseline as unknown as Record, + persisted as unknown as Record, + new Set(["hostname", "port", ...(claudeGuardArmed ? ["claudeCode"] : [])]), + ); + + if (claudeGuardArmed && !pendingLiveClaudeMutation) { + if (persisted.claudeCode === undefined) delete config.claudeCode; + else config.claudeCode = structuredClone(persisted.claudeCode); + claudeCodeBaseline.set(config, structuredClone(config.claudeCode)); + } +} + /** The literal file, with no schema merge or default injection. */ function readRawConfigJson(): Record | undefined { try { @@ -1063,6 +1277,28 @@ function readRawConfigJson(): Record | undefined { } } +/** + * Read only schema-valid binding fields from the literal file. Missing fields mean + * their schema defaults; malformed fields keep the last known persisted value. + */ +function readPersistedServerBinding( + raw: Record, + baseline: PersistedServerBinding, +): PersistedServerBinding { + const port = raw.port === undefined + ? 10100 + : (typeof raw.port === "number" + && Number.isInteger(raw.port) + && raw.port >= 0 + && raw.port <= 65535 + ? raw.port + : baseline.port); + const hostname = raw.hostname === undefined + ? undefined + : (typeof raw.hostname === "string" ? raw.hostname : baseline.hostname); + return { port, ...(hostname !== undefined ? { hostname } : {}) }; +} + /** * The save entry point for every writer holding a LIVE server config. * @@ -1077,8 +1313,11 @@ function readRawConfigJson(): Record | undefined { * guarantee. */ export function saveConfigPreservingClaudeCode(config: OcxConfig): void { + const bindingBaseline = persistedLiveServerBinding.get(config); + const onDisk = claudeCodeBaseline.has(config) || bindingBaseline + ? readRawConfigJson() + : undefined; if (claudeCodeBaseline.has(config)) { - const onDisk = readRawConfigJson(); if (onDisk !== undefined) { const baseline = claudeCodeBaseline.get(config); const diskChanged = !deepEqual(onDisk.claudeCode, baseline); @@ -1088,7 +1327,18 @@ export function saveConfigPreservingClaudeCode(config: OcxConfig): void { } } } - saveConfig(config); + const persistedBinding = bindingBaseline && onDisk + ? readPersistedServerBinding(onDisk, bindingBaseline) + : bindingBaseline; + if (persistedBinding) { + const persistedConfig: OcxConfig = { ...config, port: persistedBinding.port }; + if (persistedBinding.hostname === undefined) delete persistedConfig.hostname; + else persistedConfig.hostname = persistedBinding.hostname; + saveConfig(persistedConfig); + persistedLiveServerBinding.set(config, persistedBinding); + } else { + saveConfig(config); + } if (claudeCodeBaseline.has(config)) { claudeCodeBaseline.set(config, structuredClone(config.claudeCode)); } diff --git a/src/oauth/index.ts b/src/oauth/index.ts index 2eb48de773..8372501b08 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -38,6 +38,7 @@ export { type OAuthHealthLabel, } from "./health"; export { OAUTH_REFRESH_LOCK_WAIT_MS, peekAuthStore, peekOAuthRefreshIntent } from "./store"; +import { codexAccountNamespaceProviderCollisionError } from "../codex/account-namespace-match"; const REFRESH_SKEW_MS = 60_000; export interface OAuthAccessSnapshot { @@ -60,6 +61,11 @@ function cached(p:string,a:string,c:OAuthCredentials,now:()=>number){const k=ver export interface LoginOpts { forceLogin?: boolean; /** When set, persist into this account slot and require matching identity. */ reauthAccountId?: string } +export interface LoginFlowLifecycle { + /** Runs after background credential/config persistence settles, before status becomes done. */ + onSettled?: () => void | Promise; +} + interface OAuthProviderDef { login(ctrl: OAuthController, opts?: LoginOpts): Promise; refresh( @@ -614,6 +620,8 @@ export function upsertOAuthProvider(config: OcxConfig, provider: string): void { if (provider === "chatgpt") return; const def = OAUTH_PROVIDERS[provider]; if (!def) return; + const namespaceCollision = codexAccountNamespaceProviderCollisionError(config.codexAccountNamespaces, provider); + if (namespaceCollision) throw new Error(namespaceCollision); const existing = config.providers[provider]; const next: OcxProviderConfig = { ...def.providerConfig }; if (existing && getProviderRegistryEntry(provider)?.allowKeyAuthOverride === true) { @@ -678,6 +686,16 @@ export async function runLogin( ): Promise { const def = OAUTH_PROVIDERS[provider]; if (!def) throw new UnsupportedOAuthProviderError(provider); + const loadLatestConfig = deps.loadConfig ?? loadConfig; + const saveLatestConfig = deps.saveConfig ?? saveConfig; + if (provider !== "chatgpt") { + const preflightConfig = loadLatestConfig(); + const namespaceCollision = codexAccountNamespaceProviderCollisionError( + preflightConfig.codexAccountNamespaces, + provider, + ); + if (namespaceCollision) throw new Error(namespaceCollision); + } // loginKiro keys its pending CLI-session transaction by object identity. Keep this exact object // for settlement even when source normalization below creates a derived credential object. const shouldRollbackKiroAccounts = provider === "kiro" && opts?.forceLogin === true; @@ -688,6 +706,12 @@ export async function runLogin( const cred: OAuthCredentials = rawCred.source ? rawCred : { ...rawCred, source: "oauth" }; const settleKiroTransaction = deps.settleKiroLoginTransaction ?? settleKiroLoginTransaction; try { + // Validate the provider row before credential persistence. A namespace claimed during the + // credential write is handled again below before the latest row is re-upserted. + if (provider !== "chatgpt") { + const preCommitConfig = loadLatestConfig(); + upsertOAuthProvider(preCommitConfig, provider); + } if (opts?.reauthAccountId) { const existing = getAccountCredential(provider, opts.reauthAccountId); if (!existing) throw new Error(`Unknown account for reauth: ${opts.reauthAccountId}`); @@ -709,9 +733,21 @@ export async function runLogin( }); } if (provider !== "chatgpt") { - const config = (deps.loadConfig ?? loadConfig)(); - upsertOAuthProvider(config, provider); - (deps.saveConfig ?? saveConfig)(config); + // Re-run against post-credential state so same-provider API-key additions, removals, + // and active-key switches survive. A late namespace claim wins over provider creation. + const latestConfig = loadLatestConfig(); + const lateCollision = codexAccountNamespaceProviderCollisionError( + latestConfig.codexAccountNamespaces, + provider, + ); + if (lateCollision) { + throw new Error( + `${lateCollision}. The credential for "${provider}" was saved, but the provider entry was not written. ` + + "Rename the account selector, then re-run the login.", + ); + } + upsertOAuthProvider(latestConfig, provider); + saveLatestConfig(latestConfig); } } catch (error) { const errors: unknown[] = [error]; @@ -891,7 +927,11 @@ export function cancelLoginFlow(provider: string): boolean { return true; } -export async function startLoginFlow(provider: string, opts?: LoginOpts): Promise<{ url: string; instructions?: string; deviceCode?: string }> { +export async function startLoginFlow( + provider: string, + opts?: LoginOpts, + lifecycle?: LoginFlowLifecycle, +): Promise<{ url: string; instructions?: string; deviceCode?: string }> { const def = OAUTH_PROVIDERS[provider]; if (!def) throw new UnsupportedOAuthProviderError(provider); const existing = loginState.get(provider); @@ -914,22 +954,44 @@ export async function startLoginFlow(provider: string, opts?: LoginOpts): Promis onManualCodeInput: (expectedState?: string) => waitForManualLoginCode(provider, abort.signal, expectedState), signal: abort.signal, }; - // Background: runLogin persists the credential + upserts the provider entry to disk config. - runLogin(provider, ctrl, opts) - .then(() => { + const settle = async (error?: unknown): Promise => { + let finalError = error; + try { + await lifecycle?.onSettled?.(); + } catch (settleError) { + // A successful credential/config commit is not fully live until its owner reconciles the + // runtime config. For an already-failed login, keep the original recovery error. + if (finalError === undefined) finalError = settleError; + } + if (finalError === undefined) { loginAbort.delete(provider); clearManualCodeSlot(provider); loginState.set(provider, { done: true }); // Local-token import (grok-cli / Claude Code keychain) completes WITHOUT firing onAuth — // resolve so the GUI call returns instead of hanging. if (!urlResolved) resolve({ url: "", instructions: "Logged in via an existing local CLI/keychain token — no browser needed." }); - }) - .catch((e: unknown) => { - loginAbort.delete(provider); - clearManualCodeSlot(provider); - const msg = e instanceof Error ? e.message : String(e); - loginState.set(provider, { done: true, error: msg }); - if (!urlResolved) reject(e); - }); + return; + } + + const e = finalError; + loginAbort.delete(provider); + clearManualCodeSlot(provider); + const msg = e instanceof Error ? e.message : String(e); + loginState.set(provider, { done: true, error: msg }); + if (!urlResolved) reject(e); + }; + // Background: runLogin persists the credential + provider entry to disk. The lifecycle hook + // lets a long-lived server config adopt that settled state before clients observe done=true. + void runLogin(provider, ctrl, opts).then( + () => settle(), + (e: unknown) => settle(e), + ).catch((e: unknown) => { + // settle catches lifecycle failures, so this is only a defensive promise-boundary guard. + loginAbort.delete(provider); + clearManualCodeSlot(provider); + const msg = e instanceof Error ? e.message : String(e); + loginState.set(provider, { done: true, error: msg }); + if (!urlResolved) reject(e); + }); }); } diff --git a/src/oauth/login-cli.ts b/src/oauth/login-cli.ts index 1c7dfae2e3..e049b8b735 100644 --- a/src/oauth/login-cli.ts +++ b/src/oauth/login-cli.ts @@ -5,6 +5,7 @@ import { findLiveProxy, probeHostname } from "../server/proxy-liveness"; import { isPublicOAuthProvider, listOAuthProviders, runLogin } from "./index"; import { KEY_LOGIN_PROVIDERS, isKeyLoginProvider, validateApiKey, type KeyLoginProvider } from "./key-providers"; import type { OcxProviderConfig } from "../types"; +import { codexAccountNamespaceProviderCollisionError } from "../codex/account-namespace-match"; export function runningProxyUpdateHeaders(): Headers { const headers = new Headers({ "Content-Type": "application/json" }); @@ -105,6 +106,12 @@ export function providerConfigFromKeyLoginProvider(def: KeyLoginProvider, key: s async function handleKeyLogin(name: string): Promise { const def = KEY_LOGIN_PROVIDERS[name]; + const preflightConfig = loadConfig(); + const namespaceCollision = codexAccountNamespaceProviderCollisionError(preflightConfig.codexAccountNamespaces, name); + if (namespaceCollision) { + console.error(`Error: ${namespaceCollision}.`); + process.exit(1); + } console.log(`\n🔑 ${def.label} — opening ${def.dashboardUrl} so you can create/copy an API key...`); openUrl(def.dashboardUrl); const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); @@ -134,6 +141,11 @@ async function handleKeyLogin(name: string): Promise { } const provider = providerConfigFromKeyLoginProvider(def, key, baseUrl); const config = loadConfig(); + const commitCollision = codexAccountNamespaceProviderCollisionError(config.codexAccountNamespaces, name); + if (commitCollision) { + console.error(`Error: ${commitCollision}.`); + process.exit(1); + } config.providers[name] = provider; saveConfig(config); await notifyRunningProxy(name, provider); diff --git a/src/providers/alibaba-region-migration.ts b/src/providers/alibaba-region-migration.ts index 629d6fe56d..8d488f246e 100644 --- a/src/providers/alibaba-region-migration.ts +++ b/src/providers/alibaba-region-migration.ts @@ -2,6 +2,7 @@ import { ALIBABA_INTL_BASE_URL_CHOICES } from "./base-url-choices"; import { providerConfigSeed } from "./derive"; import { rewriteProviderReferences } from "./provider-id-rewrite"; import { PROVIDER_REGISTRY } from "./registry"; +import { codexAccountNamespaceProviderCollisionError } from "../codex/account-namespace-match"; import type { OcxConfig, OcxProviderConfig } from "../types"; const BEIJING_ID = "alibaba-token-plan"; @@ -91,9 +92,10 @@ function buildIntlRow(source: OcxProviderConfig): OcxProviderConfig { * those contracts across. That override fix shipped once, was backed out twice, * and was refused again when PR #459 closed. * - * Refuses to act when the destination already holds a value — a provider row or a - * key the reference rewrite would land on — because merging two settings is a - * user decision, not a migration's. + * Refuses to act when the destination is reserved by an account namespace or + * already holds a value — a provider row or a key the reference rewrite would + * land on — because choosing which setting wins is a user decision, not a + * migration's. */ export function projectAlibabaRegionMigration(config: OcxConfig): AlibabaRegionMigrationProjection { const beijing = config.providers[BEIJING_ID]; @@ -101,6 +103,17 @@ export function projectAlibabaRegionMigration(config: OcxConfig): AlibabaRegionM if (!beijing || !savedBaseUrl || !isInternationalEndpoint(savedBaseUrl)) { return { config, changed: false, warnings: [] }; } + if (codexAccountNamespaceProviderCollisionError(config.codexAccountNamespaces, INTL_ID)) { + return { + config, + changed: false, + warnings: [ + `provider "${BEIJING_ID}" needs to move to "${INTL_ID}", but that destination is reserved ` + + `by a configured Codex account namespace. Nothing was changed. Rename the account ` + + `selector or move the provider manually, then restart.`, + ], + }; + } if (config.providers[INTL_ID]) { return { config, diff --git a/src/server/management/combo-routes.ts b/src/server/management/combo-routes.ts index f7942e87ad..57c51e5bbb 100644 --- a/src/server/management/combo-routes.ts +++ b/src/server/management/combo-routes.ts @@ -29,6 +29,10 @@ import { providerCodexAccountMode } from "../../providers/registry"; import { routedSlug, slugEquals } from "../../providers/slug-codec"; import { clearProviderQuotaCache, fetchProviderQuotaReports } from "../../providers/quota"; import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; +import { + CODEX_ACCOUNT_NAMESPACE_COMBO_ALIAS_COLLISION_ERROR, + codexAccountNamespaceForModel, +} from "../../codex/account-namespace-match"; import { clearThreadAccountMap } from "../../codex/routing"; import { primeCodexPoolQuotas } from "../../codex/auth-api"; import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "../../providers/context-cap"; @@ -123,6 +127,9 @@ export async function handleComboRoutes(ctx: ManagementContext): Promise { const { req, url, config, deps, refreshCodexCatalogBestEffort, syncClaudeAgentDefsBestEffort } = ctx; @@ -80,6 +82,8 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< const body = await req.json().catch(() => ({})) as { provider?: string; addAccount?: boolean; accountId?: string; reauth?: boolean }; const provider = (body.provider ?? "").trim().toLowerCase(); if (!isPublicOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400); + const namespaceCollision = codexAccountNamespaceProviderCollisionError(config.codexAccountNamespaces, provider); + if (namespaceCollision) return jsonResponse({ error: namespaceCollision }, 409); const accountId = body.accountId?.trim(); const reauth = body.reauth === true || Boolean(accountId); try { @@ -90,12 +94,19 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< return jsonResponse({ error: "Unknown account for reauth" }, 404); } } + // Use persisted state, not the live object, as the merge base: another management + // request may already have mutated live config and yielded before its save. + const persistedBaseline = readConfigDiagnostics().config; // addAccount / reauth forces a fresh browser identity (skips local-CLI token import). const { url: authUrl, instructions, deviceCode } = await startLoginFlow(provider, { forceLogin: body.addAccount === true || reauth, ...(accountId ? { reauthAccountId: accountId } : {}), + }, { + // startLoginFlow returns the authorization URL before background persistence completes. + // Three-way reconcile settled disk changes so a failed login cannot leave a provider + // live-only and an in-flight management mutation cannot be erased before it saves. + onSettled: () => reconcileLiveConfigFromDisk(config, persistedBaseline), }); - upsertOAuthProvider(config, provider); // mutate LIVE config — routing sees it without restart if (authUrl && !deviceCode) { // Open the browser server-side (the proxy runs on the user's machine) — the GUI's // window.open is popup-blocked because it runs after an await, not a direct click. diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 7d8cd65c6c..72b4362ed5 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -29,6 +29,7 @@ import { providerCodexAccountMode } from "../../providers/registry"; import { routedSlug, slugEquals } from "../../providers/slug-codec"; import { clearProviderQuotaCache, fetchProviderQuotaReports } from "../../providers/quota"; import { CODEX_FORWARD_BASE_URL, isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; +import { codexAccountNamespaceProviderCollisionError } from "../../codex/account-namespace-match"; import { clearThreadAccountMap } from "../../codex/routing"; import { primeCodexPoolQuotas } from "../../codex/auth-api"; import { getProviderDiscoveryStatus } from "../../codex/model-cache"; @@ -98,6 +99,10 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise; /** Active pool account id for next session. undefined = main (passthrough as-is). */ activeCodexAccountId?: string; /** Auto-switch threshold (0-100). Default 80. 0 = disabled. */ diff --git a/tests/alibaba-region-migration.test.ts b/tests/alibaba-region-migration.test.ts index 54db6fae97..3b56fb8be7 100644 --- a/tests/alibaba-region-migration.test.ts +++ b/tests/alibaba-region-migration.test.ts @@ -20,6 +20,13 @@ function migratableConfig(): OcxConfig { } as unknown as OcxConfig; } +function namespaceCollidingConfig(): OcxConfig { + return { + ...migratableConfig(), + codexAccountNamespaces: { "alibaba-token-plan-intl": "pool-a" }, + }; +} + test("moves a Beijing entry holding an international endpoint", () => { const config = migratableConfig(); // Beijing catalog fields, as `ocx provider add` would have persisted them. @@ -94,6 +101,51 @@ test("refuses to merge when the intl entry exists, and says why", () => { expect(projection.warnings[0]).toContain("already exists"); }); +test("refuses to replace an account namespace with the intl provider", () => { + const config = namespaceCollidingConfig(); + const before = structuredClone(config); + + const projection = projectAlibabaRegionMigration(config); + expect(projection.changed).toBe(false); + expect(projection.config).toEqual(before); + expect(projection.warnings).toHaveLength(1); + expect(projection.warnings[0]).toContain("reserved by a configured Codex account namespace"); + expect(projection.warnings[0]).toContain("Rename the account selector"); +}); + +test("refuses a mixed-case account namespace that owns the intl provider id", () => { + const config = migratableConfig(); + config.codexAccountNamespaces = { "ALIBABA-TOKEN-PLAN-INTL": "pool-a" }; + const before = structuredClone(config); + + const projection = projectAlibabaRegionMigration(config); + + expect(projection.changed).toBe(false); + expect(projection.config).toEqual(before); + expect(projection.warnings).toHaveLength(1); + expect(projection.warnings[0]).toContain("reserved by a configured Codex account namespace"); +}); + +test("a namespace-blocked migration remains valid across reload", () => { + const projection = projectAlibabaRegionMigration(namespaceCollidingConfig()); + expect(projection.changed).toBe(false); + + const home = mkdtempSync(join(tmpdir(), "ocx-alibaba-namespace-")); + const prev = process.env.OPENCODEX_HOME; + process.env.OPENCODEX_HOME = home; + try { + saveConfig(projection.config); + const reloaded = loadConfig(); + expect(reloaded.providers["alibaba-token-plan"]).toBeDefined(); + expect(reloaded.providers["alibaba-token-plan-intl"]).toBeUndefined(); + expect(reloaded.codexAccountNamespaces).toEqual({ "alibaba-token-plan-intl": "pool-a" }); + } finally { + if (prev === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = prev; + rmSync(home, { recursive: true, force: true }); + } +}); + test("aborts without changing anything when a destination key is occupied", () => { const config = migratableConfig(); config.providerContextCaps = { "alibaba-token-plan": 500_000, "alibaba-token-plan-intl": 900_000 }; diff --git a/tests/alibaba-region-startup.test.ts b/tests/alibaba-region-startup.test.ts index e366c0d92e..eda003d503 100644 --- a/tests/alibaba-region-startup.test.ts +++ b/tests/alibaba-region-startup.test.ts @@ -22,6 +22,13 @@ function collidingConfig(): OcxConfig { return config; } +function namespaceCollidingConfig(): OcxConfig { + return { + ...migratableConfig(), + codexAccountNamespaces: { "alibaba-token-plan-intl": "pool-a" }, + }; +} + test("backs up strictly before saving, exactly once, when the projection changed", () => { const order: string[] = []; const saved: OcxConfig[] = []; @@ -57,6 +64,27 @@ test("a no-op never backs up or saves, but a collision still warns", () => { expect(warnings[0]).toContain("[alibaba-region-migration]"); }); +test("an account namespace collision warns without backing up or saving", () => { + const order: string[] = []; + const warnings: string[] = []; + const originalWarn = console.warn; + console.warn = (...args: unknown[]) => { warnings.push(args.map(String).join(" ")); }; + try { + const config = namespaceCollidingConfig(); + const result = runAlibabaRegionStartupMigration(config, { + project: projectAlibabaRegionMigration, + backup: () => { order.push("backup"); }, + save: () => { order.push("save"); }, + }); + expect(result).toBe(config); + } finally { + console.warn = originalWarn; + } + expect(order).toEqual([]); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain("reserved by a configured Codex account namespace"); +}); + test("a backup failure prevents the migration from saving", () => { // The fail-closed posture: no rollback point, no credential rewrite. The throw // propagates out of startServer, the same stance the OpenAI tier migration takes. diff --git a/tests/cli-account.test.ts b/tests/cli-account.test.ts index 751e8b2496..b692ddb854 100644 --- a/tests/cli-account.test.ts +++ b/tests/cli-account.test.ts @@ -1,4 +1,4 @@ -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { PassThrough, Readable } from "node:stream"; import { cmdAccount, classifyAccount, formatAccountTable, type AccountDeps } from "../src/cli/account"; import type { AccountStdin } from "../src/cli/account-api"; @@ -43,6 +43,7 @@ let lastDeletedType: "codex" | "oauth" | "api-key" | null = null; let codexAccounts: Array> = []; let oauthAccounts: Array> = []; let oauthActiveId: string | null = "acct_1"; +let oauthLoginStatus: Record = { loggedIn: false }; let keyEntries: Array> = []; let keyActiveId: string | null = "key_1"; let logs: string[] = []; @@ -285,6 +286,10 @@ async function mockManagementApi(req: Request): Promise { return json({ ok: true, accepted: true }); } + if (req.method === "GET" && url.pathname === "/api/oauth/status") { + return json(oauthLoginStatus); + } + return json({ error: `unhandled mock endpoint: ${req.method} ${url.pathname}` }, 404); } @@ -348,6 +353,7 @@ beforeEach(() => { { id: "acct_2" }, ]; oauthActiveId = "acct_1"; + oauthLoginStatus = { loggedIn: false }; keyEntries = [{ id: "key_1", label: "personal", @@ -1271,5 +1277,24 @@ describe("ocx account CLI (issue #180 matrix)", () => { expect(result.code).toBe(0); expect(requests.some(request => request.path === "/api/oauth/login/code")).toBe(false); }); + + }); + + test("39: a login error wins over a retained OAuth credential", async () => { + oauthLoginStatus = { + loggedIn: true, + done: true, + error: "The credential was saved, but the provider entry was not written.", + }; + const sleepSpy = spyOn(Bun, "sleep").mockImplementation(async () => {}); + try { + const result = await run(["login", "anthropic"]); + + expect(result.code).toBe(2); + expect(result.stderr).toContain("provider entry was not written"); + expect(result.stdout).not.toContain("Logged in to anthropic"); + } finally { + sleepSpy.mockRestore(); + } }); }); diff --git a/tests/cli-provider.test.ts b/tests/cli-provider.test.ts index 2e24a2d8b6..cf52454df3 100644 --- a/tests/cli-provider.test.ts +++ b/tests/cli-provider.test.ts @@ -97,6 +97,38 @@ describe("ocx provider", () => { } }); + test("provider add rejects a configured Codex account namespace without mutating config", () => { + const { dir, configPath } = freshConfig({ + codexAccountNamespaces: { deepseek: "side-account-id" }, + }); + try { + const before = readFileSync(configPath, "utf8"); + const result = runCli(["provider", "add", "deepseek", "--api-key", "sk-test"], { OPENCODEX_HOME: dir }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("must not collide with a configured Codex account namespace"); + expect(readFileSync(configPath, "utf8")).toBe(before); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test.each(["xai", "deepseek"])("login %s rejects a configured Codex account namespace before prompting", provider => { + const { dir, configPath } = freshConfig({ + codexAccountNamespaces: { [provider]: "side-account-id" }, + }); + try { + const before = readFileSync(configPath, "utf8"); + const result = runCli(["login", provider], { OPENCODEX_HOME: dir }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("must not collide with a configured Codex account namespace"); + expect(readFileSync(configPath, "utf8")).toBe(before); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + test("provider add custom provider requires --adapter and --base-url", () => { const { dir } = freshConfig(); try { diff --git a/tests/codex-account-namespaces.test.ts b/tests/codex-account-namespaces.test.ts new file mode 100644 index 0000000000..5083f7c99c --- /dev/null +++ b/tests/codex-account-namespaces.test.ts @@ -0,0 +1,349 @@ +import { describe, expect, spyOn, test } from "bun:test"; +import * as accountLabels from "../src/codex/account-label"; +import { + CODEX_ACCOUNT_LOG_LABEL_RE, + fallbackCodexAccountLogLabel, +} from "../src/codex/account-label"; +import { + codexAccountIdNamespaceCollisionError, + codexAccountNamespaceProviderCollisionError, + codexAccountNamespaceForModel, + hasCodexAccountNamespace, +} from "../src/codex/account-namespace-match"; +import { + appendDefaultCodexAccountNamespace, + codexAccountNamespaceEntries, + defaultCodexAccountNamespaces, + isMainCodexAccountTarget, + isValidCodexAccountNamespaceTarget, +} from "../src/codex/account-namespaces"; +import { MAIN_CODEX_ACCOUNT_ID } from "../src/codex/main-account"; + +describe("Codex account namespace foundations", () => { + test("uses persisted random labels without deriving selectors from aliases or emails", () => { + const privateAlias = "jun@example.test"; + const privateEmail = "account@example.test"; + const privateId = "stored-account-id"; + const namespaces = defaultCodexAccountNamespaces({ + providers: {}, + codexAccounts: [{ + id: privateId, + email: privateEmail, + alias: privateAlias, + logLabel: "p111111", + isMain: false, + }], + }); + + expect(namespaces).toEqual({ main: "@main", p111111: privateId }); + const publicSelectors = Object.keys(namespaces).join(","); + expect(publicSelectors).not.toContain(privateAlias); + expect(publicSelectors).not.toContain(privateEmail); + expect(publicSelectors).not.toContain(privateId); + }); + + test("gives legacy accounts an independent random selector", () => { + const privateId = "legacy-stored-account-id"; + const namespaces = defaultCodexAccountNamespaces({ + providers: {}, + codexAccounts: [{ + id: privateId, + email: "legacy@example.test", + alias: "Legacy Display Name", + isMain: false, + }], + }); + const selector = Object.entries(namespaces) + .find(([, accountId]) => accountId === privateId)?.[0]; + + expect(selector).toMatch(CODEX_ACCOUNT_LOG_LABEL_RE); + expect(selector).not.toBe(fallbackCodexAccountLogLabel(privateId)); + expect(selector).not.toContain(privateId); + }); + + test("fails after bounded attempts when no private-safe selector can be allocated", () => { + const labelSpy = spyOn(accountLabels, "createCodexAccountLogLabel").mockReturnValue("p111111"); + try { + expect(() => defaultCodexAccountNamespaces({ + providers: {}, + codexAccounts: [{ id: "p111111", logLabel: "legacy", isMain: false }], + })).toThrow("Unable to allocate a unique Codex account selector"); + expect(labelSpy).toHaveBeenCalledTimes(16); + } finally { + labelSpy.mockRestore(); + } + }); + + test("never reuses a private account id or its deterministic fallback as a selector", () => { + const pShapedId = "p222222"; + const fallbackId = "legacy-private-account-id"; + const deterministicFallback = fallbackCodexAccountLogLabel(fallbackId); + const namespaces = defaultCodexAccountNamespaces({ + providers: {}, + codexAccounts: [ + { id: pShapedId, logLabel: pShapedId, isMain: false }, + { id: fallbackId, logLabel: deterministicFallback, isMain: false }, + ], + }); + + const selectorFor = (accountId: string) => Object.entries(namespaces) + .find(([, target]) => target === accountId)?.[0]; + expect(selectorFor(pShapedId)).toMatch(CODEX_ACCOUNT_LOG_LABEL_RE); + expect(selectorFor(pShapedId)).not.toBe(pShapedId); + expect(selectorFor(fallbackId)).toMatch(CODEX_ACCOUNT_LOG_LABEL_RE); + expect(selectorFor(fallbackId)).not.toBe(deterministicFallback); + }); + + test("does not expose another account id or deterministic fallback as a selector", () => { + const pShapedPrivateId = "p444444"; + const fallbackOwnerId = "other-private-account-id"; + const otherFallback = fallbackCodexAccountLogLabel(fallbackOwnerId); + const namespaces = defaultCodexAccountNamespaces({ + providers: {}, + codexAccounts: [ + { id: "first-account", logLabel: pShapedPrivateId, isMain: false }, + { id: pShapedPrivateId, logLabel: "p555555", isMain: false }, + { id: "third-account", logLabel: otherFallback, isMain: false }, + { id: fallbackOwnerId, logLabel: "p666666", isMain: false }, + ], + }); + + const publicSelectors = Object.keys(namespaces); + expect(publicSelectors).not.toContain(pShapedPrivateId); + expect(publicSelectors).not.toContain(otherFallback); + }); + + test("suffix allocation cannot land on another private account id", () => { + const privateSuffix = "p888888-2"; + const namespaces = defaultCodexAccountNamespaces({ + providers: { + p888888: { adapter: "openai-chat", baseUrl: "https://example.test/v1" }, + }, + codexAccounts: [ + { id: "first-account", logLabel: "p888888", isMain: false }, + { id: privateSuffix, logLabel: "p999999", isMain: false }, + ], + }); + + expect(Object.keys(namespaces)).not.toContain(privateSuffix); + expect(Object.entries(namespaces).find(([, target]) => target === "first-account")?.[0]) + .toBe("p888888-3"); + }); + + test("avoids provider and combo prefixes when allocating defaults", () => { + const namespaces = defaultCodexAccountNamespaces({ + providers: { p111111: { adapter: "openai-chat", baseUrl: "https://example.test/v1" } }, + combos: { + primary: { + alias: "main/gpt-5.5", + targets: [{ provider: "p111111", model: "gpt-5.5" }], + }, + }, + codexAccounts: [{ + id: "stored-account-id", + email: "account@example.test", + logLabel: "p111111", + isMain: false, + }], + }); + + expect(namespaces).toEqual({ "main-2": "@main", "p111111-2": "stored-account-id" }); + }); + + test("avoids provider names case-insensitively when allocating defaults", () => { + expect(defaultCodexAccountNamespaces({ + providers: { + Main: { adapter: "openai-chat", baseUrl: "https://example.test/v1" }, + }, + })).toEqual({ "main-2": "@main" }); + }); + + test("appends a safe selector without rewriting an explicit map", () => { + const codexAccountNamespaces = { chosen: "existing-account-id", mainAccount: "@main" }; + const config = { providers: {}, codexAccountNamespaces }; + const account = { + id: "new-account-id", + email: "private@example.test", + alias: "Private Display Alias", + logLabel: "p222222", + isMain: false, + }; + + expect(appendDefaultCodexAccountNamespace(config, account)).toBe(true); + expect(config.codexAccountNamespaces).toEqual({ + chosen: "existing-account-id", + mainAccount: "@main", + p222222: "new-account-id", + }); + expect(appendDefaultCodexAccountNamespace(config, account)).toBe(false); + }); + + test("refuses to append an account id already owned by a selector key", () => { + const codexAccountNamespaces = { work: "existing-account-id", mainAccount: "@main" }; + const config = { providers: {}, codexAccountNamespaces }; + + expect(appendDefaultCodexAccountNamespace(config, { + id: "work", + logLabel: "p232323", + isMain: false, + })).toBe(false); + expect(config.codexAccountNamespaces).toEqual({ + work: "existing-account-id", + mainAccount: "@main", + }); + }); + + test("refuses to append the Desktop main account", () => { + const codexAccountNamespaces = { mainAccount: "@main" }; + const config = { providers: {}, codexAccountNamespaces }; + + expect(appendDefaultCodexAccountNamespace(config, { + id: "desktop-id", + logLabel: "p242424", + isMain: true, + })).toBe(false); + expect(config.codexAccountNamespaces).toEqual({ mainAccount: "@main" }); + }); + + test("never generates targets for legacy account ids rejected by the namespace schema", () => { + expect(defaultCodexAccountNamespaces({ + providers: {}, + codexAccounts: [ + { id: "constructor", logLabel: "p242425", isMain: false }, + { id: "valid-account", logLabel: "p242426", isMain: false }, + ], + })).toEqual({ main: "@main", p242426: "valid-account" }); + + const config = { + providers: {}, + codexAccountNamespaces: { mainAccount: "@main" }, + }; + expect(appendDefaultCodexAccountNamespace(config, { + id: "Constructor", + logLabel: "p242427", + isMain: false, + })).toBe(false); + expect(config.codexAccountNamespaces).toEqual({ mainAccount: "@main" }); + }); + + test("append avoids every existing private target and deterministic fallback", () => { + const existingTarget = "p777777"; + const fallbackOwnerId = "existing-private-account-id"; + const fallback = fallbackCodexAccountLogLabel(fallbackOwnerId); + + for (const logLabel of [existingTarget, fallback]) { + const config = { + providers: {}, + codexAccountNamespaces: { + existing: existingTarget, + fallbackOwner: fallbackOwnerId, + mainAccount: "@main", + }, + }; + const newAccountId = `new-account-${logLabel}`; + expect(appendDefaultCodexAccountNamespace(config, { + id: newAccountId, + logLabel, + isMain: false, + })).toBe(true); + const selector = Object.entries(config.codexAccountNamespaces) + .find(([, target]) => target === newAccountId)?.[0]; + expect(selector).toMatch(CODEX_ACCOUNT_LOG_LABEL_RE); + expect(selector).not.toBe(logLabel); + } + }); + + test("append protects pool accounts omitted from an intentionally incomplete map", () => { + const unmappedPrivateId = "p121212"; + const config = { + providers: { + p343434: { adapter: "openai-chat", baseUrl: "https://example.test/v1" }, + }, + codexAccounts: [ + { id: unmappedPrivateId, logLabel: "p565656", isMain: false }, + { id: "p343434-2", logLabel: "p787878", isMain: false }, + ], + codexAccountNamespaces: { mainAccount: "@main" }, + }; + + expect(appendDefaultCodexAccountNamespace(config, { + id: "new-account-id", + logLabel: unmappedPrivateId, + isMain: false, + })).toBe(true); + const selector = Object.entries(config.codexAccountNamespaces) + .find(([, target]) => target === "new-account-id")?.[0]; + expect(selector).toMatch(CODEX_ACCOUNT_LOG_LABEL_RE); + expect(selector).not.toBe(unmappedPrivateId); + + expect(appendDefaultCodexAccountNamespace(config, { + id: "second-new-account-id", + logLabel: "p343434", + isMain: false, + })).toBe(true); + const suffixedSelector = Object.entries(config.codexAccountNamespaces) + .find(([, target]) => target === "second-new-account-id")?.[0]; + expect(suffixedSelector).toBe("p343434-3"); + }); + + test("keeps empty maps inert and distinguishes a pool id named main from the Desktop account", () => { + const empty = { providers: {}, codexAccountNamespaces: {} as Record }; + expect(appendDefaultCodexAccountNamespace(empty, { + id: "side-account-id", + email: "side@example.test", + logLabel: "p333333", + isMain: false, + })).toBe(false); + expect(empty.codexAccountNamespaces).toEqual({}); + + expect(defaultCodexAccountNamespaces({ + providers: {}, + codexAccounts: [ + { id: "main", email: "first@example.test", logLabel: "p454545", isMain: false }, + { id: MAIN_CODEX_ACCOUNT_ID, email: "second@example.test", isMain: false }, + { id: "desktop-row", email: "third@example.test", logLabel: "p464646", isMain: true }, + ], + })).toEqual({ "main-2": "@main", p454545: "main" }); + }); + + test("matches route and account namespaces exactly but provider namespaces case-insensitively", () => { + const inherited = Object.create({ inherited: "account-id" }) as Record; + inherited.side = "side-account-id"; + + expect(hasCodexAccountNamespace(inherited, "side")).toBe(true); + expect(hasCodexAccountNamespace(inherited, "inherited")).toBe(false); + expect(codexAccountNamespaceForModel(inherited, "side/gpt-5.5")).toBe("side"); + expect(codexAccountNamespaceForModel(inherited, "Side/gpt-5.5")).toBeUndefined(); + expect(codexAccountNamespaceForModel(inherited, "gpt-5.5")).toBeUndefined(); + expect(codexAccountNamespaceProviderCollisionError(inherited, "side")) + .toBe("provider name must not collide with a configured Codex account namespace"); + expect(codexAccountNamespaceProviderCollisionError(inherited, "SIDE")) + .toBe("provider name must not collide with a configured Codex account namespace"); + expect(codexAccountNamespaceProviderCollisionError(inherited, "inherited")).toBeUndefined(); + expect(codexAccountIdNamespaceCollisionError(inherited, "side")) + .toBe("account id must not collide with a configured Codex account namespace"); + expect(codexAccountIdNamespaceCollisionError(inherited, "Side")).toBeUndefined(); + expect(codexAccountIdNamespaceCollisionError(inherited, "inherited")).toBeUndefined(); + }); + + test("normalizes only the explicit main sentinel and keeps a pool id named main literal", () => { + expect(isMainCodexAccountTarget("@main")).toBe(true); + expect(isMainCodexAccountTarget("main")).toBe(false); + expect(isMainCodexAccountTarget(MAIN_CODEX_ACCOUNT_ID)).toBe(true); + expect(isValidCodexAccountNamespaceTarget("@main")).toBe(true); + expect(isValidCodexAccountNamespaceTarget(MAIN_CODEX_ACCOUNT_ID)).toBe(false); + expect(isValidCodexAccountNamespaceTarget("side-account_2.test")).toBe(true); + expect(isValidCodexAccountNamespaceTarget(" account ")).toBe(false); + expect(isValidCodexAccountNamespaceTarget("account/id")).toBe(false); + for (const reserved of ["__proto__", "prototype", "constructor", "Constructor"]) { + expect(isValidCodexAccountNamespaceTarget(reserved)).toBe(false); + } + expect(codexAccountNamespaceEntries({ + codexAccountNamespaces: { primary: "@main", poolNamedMain: "main", side: "side-id" }, + })).toEqual([ + ["primary", MAIN_CODEX_ACCOUNT_ID], + ["poolNamedMain", "main"], + ["side", "side-id"], + ]); + }); +}); diff --git a/tests/codex-auth-api.test.ts b/tests/codex-auth-api.test.ts index deb3d44cd7..07964a5d3a 100644 --- a/tests/codex-auth-api.test.ts +++ b/tests/codex-auth-api.test.ts @@ -9,7 +9,12 @@ import { markAccountNeedsReauth, isAccountNeedsReauth, clearAccountNeedsReauth, clearAccountQuota, maskEmail, } from "../src/codex/auth-api"; -import { getCodexAccountCredential, readCodexAccountRecord, saveCodexAccountCredential } from "../src/codex/account-store"; +import { + getCodexAccountCredential, + listCodexAccountIds, + readCodexAccountRecord, + saveCodexAccountCredential, +} from "../src/codex/account-store"; import { getCodexUpstreamHealth, recordCodexUpstreamOutcome, @@ -24,6 +29,7 @@ import type { OcxConfig } from "../src/types"; import type { WsData } from "../src/server/ws-bridge"; import { MAIN_CODEX_ACCOUNT_ID } from "../src/codex/main-account"; import { + deleteCodexAccount, reconcileMainCodexAccountRuntimeState, resetMainCodexAccountIdentityTrackingForTests, } from "../src/codex/account-lifecycle"; @@ -80,6 +86,84 @@ function mockCodexWarmupSuccess(): { calls: () => number } { return { calls: () => calls }; } +async function completeMockCodexOAuth(options: { + config: OcxConfig; + requestBody: { id: string; reauth?: boolean }; + oauthAccountId: string; + email: string; + onWarmup: () => void; + usageResponse?: () => Response; +}): Promise<{ startStatus: number; state: { status: string; error?: string } }> { + const oauth = await import("../src/oauth"); + const oauthStore = await import("../src/oauth/store"); + const openUrlMod = await import("../src/lib/open-url"); + await oauthStore.saveCredential("chatgpt", { + access: `access-${options.requestBody.id}`, + refresh: `refresh-${options.requestBody.id}`, + expires: Date.now() + 5 * 60_000, + email: options.email, + accountId: options.oauthAccountId, + }); + const startSpy = spyOn(oauth, "startLoginFlow").mockResolvedValue({ url: "https://example.test/oauth" }); + const statusSpy = spyOn(oauth, "getLoginStatus").mockReturnValue({ + done: true, + loggedIn: true, + } as ReturnType); + const openSpy = spyOn(openUrlMod, "openUrl").mockImplementation(() => {}); + // Mirrors the login-status poll delay in auth-api.ts; other timers are intentionally dropped. + const CODEX_OAUTH_LOGIN_POLL_INTERVAL_MS = 2_000; + const timeoutSpy = spyOn(globalThis, "setTimeout").mockImplementation((( + callback: (...args: unknown[]) => void, + delay?: number, + ...args: unknown[] + ) => { + if (delay === CODEX_OAUTH_LOGIN_POLL_INTERVAL_MS) queueMicrotask(() => callback(...args)); + return 0 as unknown as ReturnType; + }) as typeof setTimeout); + globalThis.fetch = (async (input: RequestInfo | URL) => { + const target = String(input); + if (target === "https://chatgpt.com/backend-api/wham/usage") { + return options.usageResponse?.() + ?? new Response(JSON.stringify({ email: options.email, plan_type: "pro" }), { status: 200 }); + } + if (target === "https://chatgpt.com/backend-api/codex/responses") { + options.onWarmup(); + 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; + + try { + const req = new Request("http://localhost/api/codex-auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(options.requestBody), + }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), options.config); + const started = await resp!.json() as { flowId: string }; + for (let attempt = 0; attempt < 500; attempt += 1) { + const statusReq = new Request( + `http://localhost/api/codex-auth/login-status?flowId=${started.flowId}`, + { method: "GET" }, + ); + const statusResp = await handleCodexAuthAPI(statusReq, new URL(statusReq.url), options.config); + const state = await statusResp!.json() as { status: string; error?: string }; + if (state.status !== "pending") return { startStatus: resp!.status, state }; + await new Promise(resolve => queueMicrotask(resolve)); + } + throw new Error(`Timed out waiting for Codex OAuth flow ${started.flowId}`); + } finally { + globalThis.fetch = previousFetch; + timeoutSpy.mockRestore(); + startSpy.mockRestore(); + statusSpy.mockRestore(); + openSpy.mockRestore(); + } +} + function seedPoolAccount( config: OcxConfig, account: { @@ -932,6 +1016,25 @@ describe("codex-auth API", () => { 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", { @@ -1062,6 +1165,62 @@ describe("codex-auth API", () => { }); }); + 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", { @@ -1120,6 +1279,17 @@ describe("codex-auth API", () => { expect(config.activeCodexAccountId).toBe("work"); }); + test("PUT /api/codex-auth/accounts/alias preserves the dedicated main-account error", async () => { + const req = new Request("http://localhost/api/codex-auth/accounts/alias", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: MAIN_CODEX_ACCOUNT_ID, alias: "Desktop" }), + }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), makeConfig()); + expect(resp!.status).toBe(400); + expect(await resp!.json()).toMatchObject({ error: "Main Codex account alias is not configurable" }); + }); + test("PUT /api/codex-auth/auto-switch mutates live runtime config", async () => { const config = makeConfig({ autoSwitchThreshold: 80 }); const req = new Request("http://localhost/api/codex-auth/auto-switch", { @@ -1186,6 +1356,98 @@ describe("codex-auth API", () => { expect(getTrackedCodexWebSocketCountForAccount("pool-delete")).toBe(0); }); + test.each([ + MAIN_CODEX_ACCOUNT_ID, + "__proto__", + "__PrOtO__", + "prototype", + "PROTOTYPE", + "constructor", + "Constructor", + ])("DELETE /api/codex-auth/accounts rejects reserved account id %s without mutation", async (accountId) => { + const config = makeConfig({ + activeCodexAccountId: "pool-untouched", + codexAccounts: [{ id: "pool-untouched", email: "pool-untouched@example.test", isMain: false }], + }); + saveCodexAccountCredential("pool-untouched", { + accessToken: "access-untouched", + refreshToken: "refresh-untouched", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "acct-untouched", + }); + const beforeConfig = structuredClone(config); + const beforeCredential = getCodexAccountCredential("pool-untouched"); + + const req = new Request( + `http://localhost/api/codex-auth/accounts?id=${encodeURIComponent(accountId)}`, + { method: "DELETE" }, + ); + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + + expect(resp!.status).toBe(400); + expect(await resp!.json()).toMatchObject({ error: "Invalid account id format" }); + expect(config).toEqual(beforeConfig); + expect(listCodexAccountIds()).toEqual(["pool-untouched"]); + expect(getCodexAccountCredential("pool-untouched")).toEqual(beforeCredential); + }); + + test("DELETE /api/codex-auth/accounts removes a configured legacy reserved account", async () => { + const accountId = "Constructor"; + const config = makeConfig({ + activeCodexAccountId: accountId, + codexAccounts: [{ id: accountId, email: "legacy@example.test", isMain: false }], + }); + saveCodexAccountCredential(accountId, { + accessToken: "legacy-access", + refreshToken: "legacy-refresh", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "legacy-account", + }); + + const req = new Request( + `http://localhost/api/codex-auth/accounts?id=${encodeURIComponent(accountId)}`, + { method: "DELETE" }, + ); + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + + expect(resp!.status).toBe(200); + expect(config.codexAccounts).toEqual([]); + expect(config.activeCodexAccountId).toBeUndefined(); + expect(getCodexAccountCredential(accountId)).toBeNull(); + }); + + test("legacy non-main __main__ is quarantined and removable without touching Desktop auth", async () => { + const config = makeConfig({ + activeCodexAccountId: MAIN_CODEX_ACCOUNT_ID, + codexAccounts: [{ id: MAIN_CODEX_ACCOUNT_ID, email: "invalid-legacy@example.test", isMain: false }], + }); + saveCodexAccountCredential(MAIN_CODEX_ACCOUNT_ID, { + accessToken: "legacy-pool-access", + refreshToken: "legacy-pool-refresh", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "legacy-pool-account", + }); + writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ + tokens: { access_token: "desktop-access", account_id: "desktop-account" }, + })); + + expect(getMainChatgptAccountId()).toBe("desktop-account"); + expect(resolveCodexAccountForThread("legacy-main-row", config)).toBeNull(); + + const req = new Request( + `http://localhost/api/codex-auth/accounts?id=${encodeURIComponent(MAIN_CODEX_ACCOUNT_ID)}`, + { method: "DELETE" }, + ); + + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + + expect(resp!.status).toBe(200); + expect(config.codexAccounts).toEqual([]); + expect(config.activeCodexAccountId).toBeUndefined(); + expect(getCodexAccountCredential(MAIN_CODEX_ACCOUNT_ID)).toBeNull(); + expect(getMainChatgptAccountId()).toBe("desktop-account"); + }); + test("GET /api/codex-auth/login-status returns idle by default", async () => { const req = new Request("http://localhost/api/codex-auth/login-status", { method: "GET" }); const url = new URL(req.url); @@ -1385,6 +1647,23 @@ describe("codex-auth API", () => { expect(data.error).toContain("Invalid account id"); }); + test.each([ + MAIN_CODEX_ACCOUNT_ID, + "__proto__", + "prototype", + "constructor", + "Constructor", + ])("POST /api/codex-auth/login rejects reserved account id %s before OAuth starts", async (accountId) => { + const req = new Request("http://localhost/api/codex-auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ 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" }); + }); + test("POST /api/codex-auth/login rejects duplicate account id before OAuth starts", async () => { saveCodexAccountCredential("existing", { accessToken: "tok", @@ -1419,6 +1698,30 @@ describe("codex-auth API", () => { expect(data.error).toContain("Account id already exists"); }); + test("POST /api/codex-auth/login rejects an id owned by a namespace before OAuth starts", async () => { + const oauth = await import("../src/oauth"); + const startSpy = spyOn(oauth, "startLoginFlow"); + try { + const config = makeConfig({ codexAccountNamespaces: { work: "pool-a" } }); + const req = new Request("http://localhost/api/codex-auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ 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(startSpy).not.toHaveBeenCalled(); + expect(getCodexAccountCredential("work")).toBeNull(); + } finally { + startSpy.mockRestore(); + } + }); + test("POST /api/codex-auth/login without reauth still rejects existing account id", async () => { const config = makeConfig({ codexAccounts: [{ id: "pool-no-reauth", email: "pool-no-reauth@example.test", isMain: false }], @@ -1475,6 +1778,7 @@ describe("codex-auth API", () => { test("POST /api/codex-auth/login with reauth does not reject existing pool account id", async () => { const config = makeConfig({ + codexAccountNamespaces: { "pool-reauth": "other-account" }, codexAccounts: [{ id: "pool-reauth", email: "pool-reauth@example.test", isMain: false }], }); saveCodexAccountCredential("pool-reauth", { @@ -1504,6 +1808,7 @@ describe("codex-auth API", () => { expect(resp!.status).not.toBe(400); const data = await resp!.json() as { ok?: boolean; flowId?: string; error?: string }; expect(data.error ?? "").not.toContain("already exists"); + expect(data.error ?? "").not.toContain("namespace"); expect(data.ok).toBe(true); expect(data.flowId).toBeTruthy(); expect(startSpy).toHaveBeenCalled(); @@ -1514,6 +1819,104 @@ describe("codex-auth API", () => { } }); + test("OAuth creation rejects a namespace claimed during warmup without persisting", async () => { + const config = makeConfig(); + const result = await completeMockCodexOAuth({ + config, + requestBody: { id: "oauth-race" }, + oauthAccountId: "acct-oauth-race", + email: "oauth-race@example.test", + onWarmup: () => { + config.codexAccountNamespaces = { "oauth-race": "pool-a" }; + }, + }); + + expect(result.startStatus).toBe(200); + expect(result.state).toMatchObject({ + status: "error", + error: "account id must not collide with a configured Codex account namespace", + }); + expect(config.codexAccounts).toEqual([]); + expect(config.codexAccountNamespaces).toEqual({ "oauth-race": "pool-a" }); + expect(getCodexAccountCredential("oauth-race")).toBeNull(); + }); + + test("OAuth reauth cannot recreate an account deleted during warmup", async () => { + const config = makeConfig({ + codexAccounts: [{ id: "reauth-race", email: "reauth-race@example.test", isMain: false }], + }); + saveCodexAccountCredential("reauth-race", { + accessToken: "old-reauth-access", + refreshToken: "old-reauth-refresh", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "acct-reauth-race", + }); + const result = await completeMockCodexOAuth({ + config, + requestBody: { id: "reauth-race", reauth: true }, + oauthAccountId: "acct-reauth-race", + email: "reauth-race@example.test", + onWarmup: () => deleteCodexAccount(config, "reauth-race"), + }); + + expect(result.startStatus).toBe(200); + expect(result.state).toMatchObject({ + status: "error", + error: "Pool account was removed while login was in progress. Add it again as a new account.", + }); + expect(config.codexAccounts).toEqual([]); + expect(getCodexAccountCredential("reauth-race")).toBeNull(); + }); + + test("deleteCodexAccount preserves a main account with the requested id", () => { + const mainAccount = { + id: "shared-account-id", + email: "main@example.test", + isMain: true, + }; + const config = makeConfig({ codexAccounts: [mainAccount] }); + saveCodexAccountCredential("shared-account-id", { + accessToken: "main-access", + refreshToken: "main-refresh", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "shared-account-id", + }); + + deleteCodexAccount(config, "shared-account-id"); + + expect(config.codexAccounts).toEqual([mainAccount]); + expect(getCodexAccountCredential("shared-account-id")).toBeNull(); + }); + + test("OAuth reauth preserves the stored plan when the usage probe fails", async () => { + const config = makeConfig({ + codexAccounts: [{ + id: "reauth-plan", + email: "reauth-plan@example.test", + plan: "business", + isMain: false, + }], + }); + saveCodexAccountCredential("reauth-plan", { + accessToken: "old-plan-access", + refreshToken: "old-plan-refresh", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "acct-reauth-plan", + }); + + const result = await completeMockCodexOAuth({ + config, + requestBody: { id: "reauth-plan", reauth: true }, + oauthAccountId: "acct-reauth-plan", + email: "reauth-plan@example.test", + onWarmup: () => {}, + usageResponse: () => new Response("unavailable", { status: 503 }), + }); + + expect(result.state).toMatchObject({ status: "done" }); + expect(config.codexAccounts?.[0]?.plan).toBe("business"); + }); + test("OAuth pool login excludes self from collision check when reauth", async () => { const source = await Bun.file("src/codex/auth-api.ts").text(); expect(source).toContain("checkAccountIdCollision(oauthAccountId, email, plan, reauth ? accountId : undefined)"); diff --git a/tests/combo-management-api.test.ts b/tests/combo-management-api.test.ts index 5bbf5a0afb..6d0135755d 100644 --- a/tests/combo-management-api.test.ts +++ b/tests/combo-management-api.test.ts @@ -294,6 +294,27 @@ describe("combo management API", () => { }); }); + test("PUT rejects aliases owned by a Codex account namespace without mutating config", async () => { + await withTempHome(async () => { + const config = baseConfig({ codexAccountNamespaces: { side: "side-account-id" } }); + saveConfig(config); + const beforeMemory = structuredClone(config); + const beforeDisk = readFileSync(getConfigPath(), "utf8"); + + const response = await comboApi(config, "PUT", "/api/combos", { + id: "intentional", + combo: { ...VALID_COMBO, alias: "side/gpt-5.5" }, + }); + + expect(response?.status).toBe(409); + expect(await responseJson(response)).toEqual({ + error: "combo alias must not use a configured Codex account namespace", + }); + expect(config).toEqual(beforeMemory); + expect(readFileSync(getConfigPath(), "utf8")).toBe(beforeDisk); + }); + }); + test("PUT rejects invalid and duplicate aliases without memory or disk mutation", async () => { await withTempHome(async () => { const config = baseConfig({ diff --git a/tests/config-user-edits.test.ts b/tests/config-user-edits.test.ts index 87331c3260..b3d4897f00 100644 --- a/tests/config-user-edits.test.ts +++ b/tests/config-user-edits.test.ts @@ -6,6 +6,7 @@ import { armClaudeCodeBaseline, getConfigPath, loadConfig, + reconcileLiveConfigFromDisk, saveConfig, saveConfigPreservingClaudeCode, } from "../src/config"; @@ -136,6 +137,42 @@ test("our own change wins a conflict and rebases the baseline", () => { expect((diskConfig().claudeCode as Record).authMode).toBe("proxy"); }); +test("OAuth reconciliation keeps a pending live Claude subtree authoritative", () => { + const live = loadConfig(); + armClaudeCodeBaseline(live); + const persistedBaseline = loadConfig(); + live.claudeCode = { authMode: "subscription", systemEnv: true }; + live.disabledModels = ["pending/model"]; + writeDiskConfig({ + claudeCode: { authMode: "proxy" }, + contextCapValue: 240_000, + }); + + reconcileLiveConfigFromDisk(live, persistedBaseline); + + expect(live.claudeCode).toEqual({ authMode: "subscription", systemEnv: true }); + expect(live.disabledModels).toEqual(["pending/model"]); + expect(live.contextCapValue).toBe(240_000); + + saveConfigPreservingClaudeCode(live); + expect(diskConfig().claudeCode).toEqual({ authMode: "subscription", systemEnv: true }); + expect(diskConfig().disabledModels).toEqual(["pending/model"]); + expect(diskConfig().contextCapValue).toBe(240_000); +}); + +test("OAuth reconciliation adopts a guarded Claude edit that predates its disk snapshot", () => { + const live = loadConfig(); + armClaudeCodeBaseline(live); + writeDiskConfig({ claudeCode: { authMode: "proxy" } }); + const persistedBaseline = loadConfig(); + + reconcileLiveConfigFromDisk(live, persistedBaseline); + + expect(live.claudeCode).toEqual({ authMode: "proxy" }); + saveConfigPreservingClaudeCode(live); + expect(diskConfig().claudeCode).toEqual({ authMode: "proxy" }); +}); + // Structural compare, not JSON.stringify: key order must not fake an external edit. test("a key-order-only difference is not treated as an external edit", () => { const live = loadConfig(); diff --git a/tests/config.test.ts b/tests/config.test.ts index a53ae79b70..32d3617290 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -66,6 +66,25 @@ function writeResponsesPathConfig(responsesPath: string): void { }); } +function writeAccountNamespaceConfig( + codexAccountNamespaces: unknown, + overrides: Record = {}, +): void { + writeConfig({ + port: 10100, + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + }, + }, + defaultProvider: "openai", + codexAccountNamespaces, + ...overrides, + }); +} + describe("opencodex config defaults", () => { test("atomic rename retries transient Windows sharing violations", () => { const sleeps: number[] = []; @@ -971,6 +990,170 @@ describe("opencodex config defaults", () => { expect(isValidProviderName("constructor")).toBe(false); }); + test("persists an explicit Codex account selector map without enabling it by default", () => { + const selectors = { + desktop: "@main", + work: "work-account", + legacy: "work-account", + poolNamedMain: "main", + }; + writeAccountNamespaceConfig(selectors); + + const diagnostics = readConfigDiagnostics(); + expect(diagnostics.error).toBeNull(); + expect(diagnostics.config.codexAccountNamespaces).toEqual(selectors); + expect(Object.hasOwn(getDefaultConfig(), "codexAccountNamespaces")).toBe(false); + }); + + test("validates Claude Desktop profiles and Codex account selectors independently", () => { + const desktopProfile = { + version: 1, + assignments: {}, + defaults: { opus: null, fable: null, sonnet: null, haiku: null }, + }; + writeAccountNamespaceConfig({ main: "@main" }, { claudeCode: { desktopProfile } }); + expect(readConfigDiagnostics()).toMatchObject({ + error: null, + config: { claudeCode: { desktopProfile }, codexAccountNamespaces: { main: "@main" } }, + }); + + writeAccountNamespaceConfig({ main: "@main" }, { + claudeCode: { desktopProfile: { ...desktopProfile, version: 2 } }, + }); + expect(readConfigDiagnostics().error).toContain("claudeCode.desktopProfile"); + + writeAccountNamespaceConfig({ "bad/selector": "account-id" }, { claudeCode: { desktopProfile } }); + expect(readConfigDiagnostics().error).toContain("codexAccountNamespaces.bad/selector"); + }); + + test.each([ + ["null", null], + ["an array", []], + ["a string", "main"], + ] as const)("rejects Codex account selectors stored as %s", (_label, selectors) => { + writeAccountNamespaceConfig(selectors); + + const diagnostics = readConfigDiagnostics(); + expect(diagnostics.source).toBe("fallback"); + expect(diagnostics.error).toContain("codexAccountNamespaces must be a plain object"); + }); + + test.each([ + ["blank", "", "side-account"], + ["surrounding whitespace", " side", "side-account"], + ["a slash", "side/account", "side-account"], + ["a reserved prototype key", "__proto__", "side-account"], + ["a reserved constructor key", "constructor", "side-account"], + ["an empty target", "side", ""], + ["the internal main account id", "side", "__main__"], + ["a reserved prototype target", "side", "__proto__"], + ["a reserved prototype-name target", "side", "prototype"], + ["a reserved constructor target", "side", "Constructor"], + ["a target with whitespace", "side", "side account"], + ["a target with a slash", "side", "account/id"], + ["an overlong target", "side", "a".repeat(65)], + ["a non-string target", "side", 42], + ] as const)("rejects %s in the Codex account selector map", (_label, selector, target) => { + writeAccountNamespaceConfig(Object.fromEntries([[selector, target]])); + + const diagnostics = readConfigDiagnostics(); + expect(diagnostics.source).toBe("fallback"); + expect(diagnostics.error).toContain(`codexAccountNamespaces.${selector}`); + }); + + test.each([ + [ + "a configured provider", + { side: "side-account" }, + { + providers: { + side: { adapter: "openai-chat", baseUrl: "https://side.example.test/v1" }, + }, + defaultProvider: "side", + }, + "must not collide", + ], + [ + "a configured provider with different casing", + { SIDE: "side-account" }, + { + providers: { + side: { adapter: "openai-chat", baseUrl: "https://side.example.test/v1" }, + }, + defaultProvider: "side", + }, + "must not collide", + ], + ["the combo namespace", { combo: "side-account" }, {}, "must not collide"], + ["the combo namespace with different casing", { Combo: "side-account" }, {}, "must not collide"], + ["the canonical OpenAI namespace with different casing", { OpenAI: "side-account" }, {}, "must not collide"], + [ + "the canonical OpenAI provider namespace before legacy migration", + { openai: "side-account" }, + { + providers: { + "openai-multi": { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + }, + }, + defaultProvider: "openai-multi", + }, + "must not collide", + ], + [ + "a combo alias prefix", + { side: "side-account" }, + { + combos: { + intentional: { + alias: "side/gpt-5.5", + targets: [{ provider: "openai", model: "gpt-5.5" }], + }, + }, + }, + "combo alias must not use a configured Codex account namespace", + ], + [ + "a whitespace-padded combo alias prefix", + { side: "side-account" }, + { + combos: { + intentional: { + alias: " side/gpt-5.5 ", + targets: [{ provider: "openai", model: "gpt-5.5" }], + }, + }, + }, + "combo alias must not use a configured Codex account namespace", + ], + [ + "a configured pool account id", + { work: "pool-a" }, + { + codexAccounts: [{ + id: "work", + email: "work@example.test", + isMain: false, + }], + }, + "must not collide with configured Codex pool-account ids or account selector targets", + ], + [ + "another selector target", + { primary: "side", side: "pool-a" }, + {}, + "must not collide with configured Codex pool-account ids or account selector targets", + ], + ] as const)("rejects a Codex account selector colliding with %s", (_label, selectors, overrides, error) => { + writeAccountNamespaceConfig(selectors, overrides); + + const diagnostics = readConfigDiagnostics(); + expect(diagnostics.source).toBe("fallback"); + expect(diagnostics.error).toContain(error); + }); + test("backs up config when defaultProvider only exists on Object prototype", () => { writeConfig({ port: 10100, diff --git a/tests/kiro-review-regressions.test.ts b/tests/kiro-review-regressions.test.ts index 9f11edb1b7..b1e18f2d91 100644 --- a/tests/kiro-review-regressions.test.ts +++ b/tests/kiro-review-regressions.test.ts @@ -221,7 +221,13 @@ describe("Kiro review regressions", () => { OAUTH_PROVIDERS.kiro.login = originalLogin; } - expect(events).toEqual(["load-config", "save-config", "settle:false"]); + expect(events).toEqual([ + "load-config", // namespace preflight before browser/CLI auth + "load-config", // provider validation before credential persistence + "load-config", // latest-row upsert after credential persistence + "save-config", + "settle:false", + ]); expect(getAccountSet("kiro")?.activeAccountId).toBe(previousActive); expect(getAccountSet("kiro")?.accounts).toHaveLength(1); expect(getAccountCredential("kiro", previousActive)).toMatchObject({ diff --git a/tests/management-provider-validation.test.ts b/tests/management-provider-validation.test.ts index 35f56dcd80..3d6611c1ea 100644 --- a/tests/management-provider-validation.test.ts +++ b/tests/management-provider-validation.test.ts @@ -517,6 +517,44 @@ describe("provider management validation", () => { } }); + test("provider management rejects names owned by a Codex account namespace without mutating config", async () => { + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + const cfg = { + ...config("127.0.0.1"), + codexAccountNamespaces: { side: "side-account-id" }, + }; + saveConfig(cfg); + const beforeMemory = structuredClone(cfg); + const beforeDisk = readFileSync(join(TEST_DIR, "config.json"), "utf8"); + + const requestUrl = new URL("http://127.0.0.1/api/providers"); + const response = await handleManagementAPI( + new Request(requestUrl, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: "side", + provider: { + adapter: "openai-chat", + baseUrl: "https://side.example.test/v1", + }, + }), + }), + requestUrl, + cfg, + { refreshCodexCatalog: async () => {} }, + ); + + expect(response?.status).toBe(409); + expect(await response?.json()).toEqual({ + error: "provider name must not collide with a configured Codex account namespace", + }); + expect(cfg).toEqual(beforeMemory); + expect(readFileSync(join(TEST_DIR, "config.json"), "utf8")).toBe(beforeDisk); + }); + test("provider management rejects base URLs with embedded credentials", async () => { if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); mkdirSync(TEST_DIR, { recursive: true }); diff --git a/tests/oauth-public-surface.test.ts b/tests/oauth-public-surface.test.ts index aa699e4170..43668008d0 100644 --- a/tests/oauth-public-surface.test.ts +++ b/tests/oauth-public-surface.test.ts @@ -1,18 +1,24 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { mkdirSync, rmSync } from "node:fs"; import { join } from "node:path"; import { + clearLoginState, + getLoginStatus, isOAuthProvider, isPublicOAuthProvider, listOAuthProviders, OAUTH_PROVIDERS, runLogin, + startLoginFlow, upsertOAuthProvider, } from "../src/oauth"; import { handleManagementAPI } from "../src/server/management-api"; import type { OcxConfig } from "../src/types"; import type { OAuthController } from "../src/oauth/types"; import { getCredential } from "../src/oauth/store"; +import * as oauthStore from "../src/oauth/store"; +import { armClaudeCodeBaseline, loadConfig, saveConfig, saveConfigPreservingClaudeCode } from "../src/config"; +import { isApiAuthRequired, requireApiAuth } from "../src/server/auth-cors"; const TEST_DIR = join(import.meta.dir, ".tmp-oauth-public-surface"); const previousHome = process.env.OPENCODEX_HOME; @@ -32,17 +38,28 @@ function config(): OcxConfig { } beforeEach(() => { + clearLoginState("xai"); rmSync(TEST_DIR, { recursive: true, force: true }); mkdirSync(TEST_DIR, { recursive: true }); process.env.OPENCODEX_HOME = TEST_DIR; }); afterEach(() => { + clearLoginState("xai"); if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; rmSync(TEST_DIR, { recursive: true, force: true }); }); +async function waitForOAuthDone(provider: string): Promise> { + for (let attempt = 0; attempt < 200; attempt += 1) { + const status = getLoginStatus(provider); + if (status.done) return status; + await Bun.sleep(5); + } + throw new Error(`OAuth login for ${provider} did not settle`); +} + describe("legacy ChatGPT OAuth public-surface exclusion", () => { test("keeps low-level compatibility but excludes public discovery", () => { expect(isOAuthProvider("chatgpt")).toBe(true); @@ -99,4 +116,421 @@ describe("legacy ChatGPT OAuth public-surface exclusion", () => { expect(getCredential("chatgpt")?.access).toBe("legacy-access"); expect(cfg.providers.chatgpt).toBeUndefined(); }); + + test("OAuth provider creation rejects account namespace collisions before login or mutation", async () => { + const cfg = config(); + cfg.codexAccountNamespaces = { XAI: "side-account-id" }; + const before = structuredClone(cfg.providers); + + expect(() => upsertOAuthProvider(cfg, "xai")).toThrow(/must not collide with a configured Codex account namespace/); + expect(cfg.providers).toEqual(before); + + const routeReq = new Request("http://localhost/api/oauth/login", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ provider: "xai" }), + }); + const routeResponse = await handleManagementAPI(routeReq, new URL(routeReq.url), cfg); + expect(routeResponse?.status).toBe(409); + expect(await routeResponse?.json()).toEqual({ + error: "provider name must not collide with a configured Codex account namespace", + }); + + saveConfig(cfg); + const originalLogin = OAUTH_PROVIDERS.xai.login; + let loginCalls = 0; + OAUTH_PROVIDERS.xai.login = async () => { + loginCalls += 1; + return { access: "must-not-save", refresh: "must-not-save" }; + }; + try { + await expect(runLogin("xai", {} as OAuthController)).rejects.toThrow( + /must not collide with a configured Codex account namespace/, + ); + } finally { + OAUTH_PROVIDERS.xai.login = originalLogin; + } + expect(loginCalls).toBe(0); + expect(getCredential("xai")).toBeNull(); + + saveConfig(config()); + OAUTH_PROVIDERS.xai.login = async () => { + loginCalls += 1; + const changedDuringLogin = config(); + changedDuringLogin.codexAccountNamespaces = { xai: "side-account-id" }; + saveConfig(changedDuringLogin); + return { access: "must-not-save", refresh: "must-not-save" }; + }; + try { + await expect(runLogin("xai", {} as OAuthController)).rejects.toThrow( + /must not collide with a configured Codex account namespace/, + ); + } finally { + OAUTH_PROVIDERS.xai.login = originalLogin; + } + expect(loginCalls).toBe(1); + expect(getCredential("xai")).toBeNull(); + }); + + test("OAuth provider creation preserves a namespace claimed after credential persistence", async () => { + saveConfig(config()); + const originalLogin = OAUTH_PROVIDERS.xai.login; + const originalSaveCredential = oauthStore.saveCredential; + let changedAfterCredential = false; + let credentialWrites = 0; + OAUTH_PROVIDERS.xai.login = async () => ({ + access: "post-check-access", + refresh: "post-check-refresh", + accountId: "post-check-account", + expires: Date.now() + 60_000, + }); + const saveSpy = spyOn(oauthStore, "saveCredential").mockImplementation(async (provider, credential) => { + credentialWrites += 1; + await originalSaveCredential(provider, credential); + const changed = config(); + changed.defaultProvider = "concurrent"; + changed.providers.concurrent = { + adapter: "openai-chat", + baseUrl: "https://concurrent.example.test/v1", + }; + changed.codexAccountNamespaces = { + XAI: "side-account-id", + retained: "retained-account-id", + }; + saveConfig(changed); + changedAfterCredential = true; + }); + + try { + await expect(runLogin("xai", {} as OAuthController)).rejects.toThrow( + /credential for "xai" was saved, but the provider entry was not written/, + ); + } finally { + OAUTH_PROVIDERS.xai.login = originalLogin; + saveSpy.mockRestore(); + } + + expect(changedAfterCredential).toBe(true); + expect(credentialWrites).toBe(1); + expect(getCredential("xai")?.access).toBe("post-check-access"); + const persistedConfig = loadConfig(); + expect(persistedConfig).toMatchObject({ + defaultProvider: "concurrent", + providers: { + concurrent: { + adapter: "openai-chat", + baseUrl: "https://concurrent.example.test/v1", + }, + }, + codexAccountNamespaces: { + XAI: "side-account-id", + retained: "retained-account-id", + }, + }); + expect(persistedConfig.providers.xai).toBeUndefined(); + }); + + test("OAuth provider creation preserves same-provider key changes during credential persistence", async () => { + const seeded = config(); + seeded.providers.xai = { + ...OAUTH_PROVIDERS.xai.providerConfig, + authMode: "key", + apiKey: "test-key-a", + apiKeyPool: [{ id: "key-a", key: "test-key-a" }], + }; + saveConfig(seeded); + const originalLogin = OAUTH_PROVIDERS.xai.login; + const originalSaveCredential = oauthStore.saveCredential; + OAUTH_PROVIDERS.xai.login = async () => ({ + access: "same-provider-access", + refresh: "same-provider-refresh", + accountId: "same-provider-account", + expires: Date.now() + 60_000, + }); + const saveSpy = spyOn(oauthStore, "saveCredential").mockImplementation(async (provider, credential) => { + await originalSaveCredential(provider, credential); + const changed = loadConfig(); + changed.providers.xai = { + ...changed.providers.xai!, + authMode: "key", + apiKey: "test-key-b", + apiKeyPool: [ + { id: "key-a", key: "test-key-a" }, + { id: "key-b", key: "test-key-b" }, + ], + }; + saveConfig(changed); + }); + + try { + await runLogin("xai", {} as OAuthController); + } finally { + OAUTH_PROVIDERS.xai.login = originalLogin; + saveSpy.mockRestore(); + } + + expect(loadConfig().providers.xai).toMatchObject({ + authMode: "key", + apiKey: "test-key-b", + apiKeyPool: [ + { id: "key-a", key: "test-key-a" }, + { id: "key-b", key: "test-key-b" }, + ], + }); + }); + + test("management OAuth activates the live provider only after persistence succeeds", async () => { + const liveConfig = config(); + saveConfig(liveConfig); + const originalLogin = OAUTH_PROVIDERS.xai.login; + let releaseLogin!: () => void; + const loginGate = new Promise((resolve) => { releaseLogin = resolve; }); + OAUTH_PROVIDERS.xai.login = async (ctrl) => { + ctrl.onAuth({ + url: "https://auth.example.test/authorize", + deviceCode: "test-device-code", + }); + await loginGate; + return { + access: "successful-access", + refresh: "successful-refresh", + accountId: "successful-account", + expires: Date.now() + 60_000, + }; + }; + + try { + const request = new Request("http://localhost/api/oauth/login", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ provider: "xai" }), + }); + const response = await handleManagementAPI(request, new URL(request.url), liveConfig); + expect(response?.status).toBe(200); + expect(liveConfig.providers.xai).toBeUndefined(); + expect(getLoginStatus("xai").done).toBe(false); + + releaseLogin(); + const status = await waitForOAuthDone("xai"); + expect(status.error).toBeUndefined(); + expect(status.loggedIn).toBe(true); + expect(liveConfig.providers.xai).toEqual(loadConfig().providers.xai); + expect(liveConfig.providers.xai).toBeDefined(); + } finally { + releaseLogin(); + OAUTH_PROVIDERS.xai.login = originalLogin; + clearLoginState("xai"); + } + }); + + test("management OAuth merges its provider row with a pending live provider edit", async () => { + const liveConfig = config(); + saveConfig(liveConfig); + liveConfig.providers.xai = { + ...OAUTH_PROVIDERS.xai.providerConfig, + selectedModels: ["pending-model"], + }; + const originalLogin = OAUTH_PROVIDERS.xai.login; + OAUTH_PROVIDERS.xai.login = async (ctrl) => { + ctrl.onAuth({ + url: "https://auth.example.test/authorize", + deviceCode: "same-provider-device-code", + }); + return { + access: "same-provider-access", + refresh: "same-provider-refresh", + accountId: "same-provider-account", + expires: Date.now() + 60_000, + }; + }; + + try { + const request = new Request("http://localhost/api/oauth/login", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ provider: "xai" }), + }); + const response = await handleManagementAPI(request, new URL(request.url), liveConfig); + expect(response?.status).toBe(200); + + const status = await waitForOAuthDone("xai"); + expect(status).toMatchObject({ done: true, loggedIn: true }); + expect(status.error).toBeUndefined(); + expect(liveConfig.providers.xai).toMatchObject({ + ...loadConfig().providers.xai, + selectedModels: ["pending-model"], + }); + + saveConfigPreservingClaudeCode(liveConfig); + expect(loadConfig().providers.xai?.selectedModels).toEqual(["pending-model"]); + } finally { + OAUTH_PROVIDERS.xai.login = originalLogin; + clearLoginState("xai"); + } + }); + + test("OAuth settlement preserves the original login failure", async () => { + saveConfig(config()); + const originalLogin = OAUTH_PROVIDERS.xai.login; + OAUTH_PROVIDERS.xai.login = async (ctrl) => { + ctrl.onAuth({ + url: "https://auth.example.test/authorize", + deviceCode: "failed-login-device-code", + }); + throw new Error("browser flow aborted"); + }; + + try { + await startLoginFlow("xai", undefined, { + onSettled: () => { throw new Error("runtime reconciliation failed"); }, + }); + const status = await waitForOAuthDone("xai"); + expect(status.done).toBe(true); + expect(status.error).toBe("browser flow aborted"); + } finally { + OAUTH_PROVIDERS.xai.login = originalLogin; + clearLoginState("xai"); + } + }); + + test("OAuth settlement reports reconciliation failure after a successful login", async () => { + saveConfig(config()); + const originalLogin = OAUTH_PROVIDERS.xai.login; + OAUTH_PROVIDERS.xai.login = async (ctrl) => { + ctrl.onAuth({ + url: "https://auth.example.test/authorize", + deviceCode: "successful-login-device-code", + }); + return { + access: "successful-access", + refresh: "successful-refresh", + accountId: "successful-account", + expires: Date.now() + 60_000, + }; + }; + + try { + await startLoginFlow("xai", undefined, { + onSettled: () => { throw new Error("runtime reconciliation failed"); }, + }); + const status = await waitForOAuthDone("xai"); + expect(status.done).toBe(true); + expect(status.error).toBe("runtime reconciliation failed"); + } finally { + OAUTH_PROVIDERS.xai.login = originalLogin; + clearLoginState("xai"); + } + }); + + test("management OAuth safely reconciles live config after a late namespace claim", async () => { + const liveConfig = config(); + liveConfig.hostname = "0.0.0.0"; + liveConfig.port = 10444; + liveConfig.claudeCode = { authMode: "subscription" }; + saveConfig(liveConfig); + armClaudeCodeBaseline(liveConfig); + // Model visibility has mutated the shared object but yielded before saving. OAuth + // must not replace it with the pre-mutation disk snapshot when login settles. + liveConfig.disabledModels = ["pending/provider-model"]; + const originalLogin = OAUTH_PROVIDERS.xai.login; + const originalSaveCredential = oauthStore.saveCredential; + OAUTH_PROVIDERS.xai.login = async (ctrl) => { + ctrl.onAuth({ + url: "https://auth.example.test/authorize", + deviceCode: "test-device-code", + }); + return { + access: "route-collision-access", + refresh: "route-collision-refresh", + accountId: "route-collision-account", + expires: Date.now() + 60_000, + }; + }; + const saveSpy = spyOn(oauthStore, "saveCredential").mockImplementation(async (provider, credential) => { + await originalSaveCredential(provider, credential); + const concurrentConfig = config(); + concurrentConfig.defaultProvider = "concurrent"; + concurrentConfig.providers.concurrent = { + adapter: "openai-chat", + baseUrl: "https://concurrent.example.test/v1", + }; + concurrentConfig.codexAccountNamespaces = { + XAI: "side-account-id", + retained: "retained-account-id", + }; + concurrentConfig.claudeCode = { authMode: "proxy" }; + saveConfig(concurrentConfig); + }); + + try { + const request = new Request("http://localhost/api/oauth/login", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ provider: "xai" }), + }); + const response = await handleManagementAPI(request, new URL(request.url), liveConfig); + expect(response?.status).toBe(200); + + const status = await waitForOAuthDone("xai"); + expect(status.loggedIn).toBe(true); + expect(status.error).toMatch(/credential for "xai" was saved, but the provider entry was not written/); + expect(getCredential("xai")?.access).toBe("route-collision-access"); + expect(liveConfig).toMatchObject({ + defaultProvider: "concurrent", + providers: { + concurrent: { + adapter: "openai-chat", + baseUrl: "https://concurrent.example.test/v1", + }, + }, + codexAccountNamespaces: { + XAI: "side-account-id", + retained: "retained-account-id", + }, + }); + expect(liveConfig.providers.xai).toBeUndefined(); + expect(liveConfig.claudeCode?.authMode).toBe("proxy"); + expect(liveConfig.disabledModels).toEqual(["pending/provider-model"]); + // Reconciliation must not make the externally bound socket look loopback-only. + expect(liveConfig.hostname).toBe("0.0.0.0"); + expect(liveConfig.port).toBe(10444); + expect(isApiAuthRequired(liveConfig)).toBe(true); + const forgedLoopbackRequest = new Request("http://localhost:10444/api/config", { + headers: { host: "localhost:10444" }, + }); + expect(requireApiAuth(forgedLoopbackRequest, liveConfig, "management")?.status).toBe(401); + expect(loadConfig().providers.xai).toBeUndefined(); + + // A second disk edit must be compared with the state OAuth just adopted, not + // the stale startup baseline, or this unrelated save would restore "proxy". + const editedAgain = loadConfig(); + editedAgain.hostname = "127.0.0.1"; + editedAgain.port = 11445; + editedAgain.claudeCode = { authMode: "subscription", systemEnv: true }; + saveConfig(editedAgain); + + saveConfigPreservingClaudeCode(liveConfig); + const afterLaterSave = loadConfig(); + expect(afterLaterSave.codexAccountNamespaces).toEqual({ + XAI: "side-account-id", + retained: "retained-account-id", + }); + expect(afterLaterSave.providers.concurrent).toBeDefined(); + expect(afterLaterSave.providers.xai).toBeUndefined(); + expect(afterLaterSave.disabledModels).toEqual(["pending/provider-model"]); + expect(afterLaterSave.claudeCode).toEqual({ authMode: "subscription", systemEnv: true }); + expect(liveConfig.claudeCode).toEqual({ authMode: "subscription", systemEnv: true }); + // Runtime admission remains tied to the open socket, but the next-start + // binding adopted from disk must survive this unrelated live save. + expect(liveConfig.hostname).toBe("0.0.0.0"); + expect(liveConfig.port).toBe(10444); + expect(afterLaterSave.hostname).toBe("127.0.0.1"); + expect(afterLaterSave.port).toBe(11445); + expect(loadConfig()).toMatchObject({ hostname: "127.0.0.1", port: 11445 }); + } finally { + OAUTH_PROVIDERS.xai.login = originalLogin; + saveSpy.mockRestore(); + clearLoginState("xai"); + } + }); }); diff --git a/tests/server-auth.test.ts b/tests/server-auth.test.ts index 5a409f1e17..52a657336a 100644 --- a/tests/server-auth.test.ts +++ b/tests/server-auth.test.ts @@ -300,6 +300,7 @@ describe("server local API auth", () => { test("safeConfigDTO redacts provider secrets and exposes booleans", () => { const unsafe = config("127.0.0.1"); unsafe.openaiProviderTierVersion = 1; + unsafe.codexAccountNamespaces = { side: "private-account-id" }; Object.assign(unsafe.providers.openai as unknown as Record, { apiKeyPool: [{ id: "pool-id", key: "pool-secret", label: "private-pool-label" }], modelMaxInputTokens: { "gpt-test": 1000 }, @@ -321,6 +322,7 @@ describe("server local API auth", () => { "virtualModels", "codexAuthContext", "selectedForwardHeaders", "sidecarOutcomeRecorder", "recorder-runtime", "_codexAccountOverride", "_codexAccountRequired", "runtime-token", "override-token", + "codexAccountNamespaces", "private-account-id", ]) expect(serialized).not.toContain(forbidden); expect(dto.providers.openai).toMatchObject({ adapter: "openai-chat",