From 2eb22f9b85d0473d8ed1cde593905c5bb10f19cf Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sun, 30 Aug 2026 12:35:36 +0900 Subject: [PATCH 1/6] feat(server): add least-privilege GET/HEAD /v1/catalog for remote clients MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #809. A remote Codex client needs the model catalog, and the only source was GET /api/catalog behind management auth — so operators had to hand out an admin token to read a list of models. This adds the read on the data plane instead of widening /api/*, which stays exactly as restricted as before. Admission uses resolveApiAuth, the same as /v1/models and for the same stated reason: the route forwards no caller credential upstream, so the dedicated header, a recognized bearer, and x-api-key are all safe. Using resolveResponsesApiAuth would have 401'd Anthropic-SDK clients holding a valid data credential, since that transport deliberately rejects x-api-key to avoid a credential collision it does not have here. src/server/catalog-download.ts is shared with the management route so both planes serialize identical bytes; an independent second serializer would drift, and the data-plane copy is the one nobody sees in the dashboard. It adds a 32 MiB ceiling, a SHA-256 ETag with conditional 304, and Cache-Control: private, no-cache for credentialed identity-varying content. HEAD returns identical status and headers with no body. The Codex version header is passed through when authoritative and omitted rather than fabricated when not. --- src/server/auth-cors.ts | 4 ++ src/server/catalog-download.ts | 65 +++++++++++++++++++++++++++ src/server/index.ts | 62 +++++++++++++++++++++++++ src/server/management/model-routes.ts | 17 ++++--- tests/api-key-attribution.test.ts | 9 +++- 5 files changed, 150 insertions(+), 7 deletions(-) create mode 100644 src/server/catalog-download.ts diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index 9e5bcbb3d3..f8ab831e05 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -405,6 +405,10 @@ export const AUTH_MATRIX: readonly ApiAuthMatrixRow[] = [ { endpoint: "/v1/chat/completions", bearer: "accepted", dedicated: "accepted", xApiKey: "rejected" }, { endpoint: "/v1/messages", bearer: "accepted", dedicated: "accepted", xApiKey: "accepted" }, { endpoint: "/v1/models", bearer: "accepted", dedicated: "accepted", xApiKey: "accepted" }, + // #809: least-privilege catalog read for remote Codex clients. Same admission set as + // /v1/models and for the same reason — it forwards no caller credential upstream — so a + // remote client no longer needs an admin token just to read the model catalog. + { endpoint: "/v1/catalog", bearer: "accepted", dedicated: "accepted", xApiKey: "accepted" }, ]; /** Whether `token` is the environment-provided management secret. */ diff --git a/src/server/catalog-download.ts b/src/server/catalog-download.ts new file mode 100644 index 0000000000..42c4cc7649 --- /dev/null +++ b/src/server/catalog-download.ts @@ -0,0 +1,65 @@ +/** + * Shared serialization for the model catalog, used by both the management route + * (`GET /api/catalog`) and the least-privilege data-plane route + * (`GET|HEAD /v1/catalog`, issue #809). + * + * The point of the shared module is that the two routes must emit the *same + * bytes*. A remote Codex client previously had to be handed an admin token just + * to read the catalog, which is the least-privilege violation #809 is about; the + * fix is a second route on the data plane, never a widened management boundary. + * If each route serialized independently they would drift, and the data-plane + * copy is the one nobody looks at in the dashboard. + */ +import { createHash } from "node:crypto"; + +/** + * Upper bound on a serialized catalog we are willing to hold in memory and hand + * to a remote client. A materialized catalog is a few hundred KiB; anything past + * this is a corrupt or hostile file, and streaming it would be worse than + * refusing it. + */ +export const MAX_REMOTE_CATALOG_BYTES = 32 * 1024 * 1024; + +export interface SerializedCatalog { + /** Serialized catalog JSON, or null when no catalog could be materialized. */ + body: string | null; + /** Strong ETag over `body`, present only when `body` is. */ + etag?: string; + /** Byte length of `body`, present only when `body` is. */ + bytes?: number; + /** True when the catalog materialized but exceeds `MAX_REMOTE_CATALOG_BYTES`. */ + tooLarge?: boolean; +} + +export function catalogEtag(body: string): string { + return `"${createHash("sha256").update(body).digest("hex")}"`; +} + +/** + * Read and serialize the persisted catalog once. + * + * Returns `{ body: null }` for every unreadable case — absent file, unreadable + * file, malformed JSON — because `readCatalog` already collapses those into + * `null` and the routes render them identically as a 404. Distinguishing them + * here would invite one route to leak a filesystem path in an error message. + */ +export async function serializePersistedCatalog(): Promise { + const { readCatalog, readCodexCatalogPath } = await import("../codex/catalog"); + const catalog = readCatalog(readCodexCatalogPath()); + if (!catalog) return { body: null }; + const body = JSON.stringify(catalog); + const bytes = Buffer.byteLength(body, "utf8"); + if (bytes > MAX_REMOTE_CATALOG_BYTES) return { body: null, tooLarge: true, bytes }; + return { body, etag: catalogEtag(body), bytes }; +} + +/** + * The authoritative Codex version for a catalog response, or undefined. + * + * Never fabricated: when no runtime is persisted the header is omitted rather + * than guessed, so a client cannot mistake "unknown" for a specific version. + */ +export async function persistedCodexVersion(): Promise { + const { loadPersistedCodexRuntime } = await import("../codex/runtime"); + return loadPersistedCodexRuntime()?.selectedVersion ?? undefined; +} diff --git a/src/server/index.ts b/src/server/index.ts index c525dad05e..bbedaad003 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1069,7 +1069,69 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server = { + "content-type": "application/json", + // Identity-varying content behind a credential: never let a shared cache keep it. + "cache-control": "private, no-cache", + }; + if (serialized.etag) headers.ETag = serialized.etag; + const version = await persistedCodexVersion(); + if (version) headers["x-opencodex-codex-version"] = version; + // Conditional GET: a client that already holds these bytes re-validates cheaply. + const ifNoneMatch = req.headers.get("if-none-match")?.trim(); + if (serialized.etag && ifNoneMatch && ifNoneMatch === serialized.etag) { + return withCors(new Response(null, { status: 304, headers }), req, policy); + } + if (serialized.bytes !== undefined) headers["content-length"] = String(serialized.bytes); + // HEAD returns identical status and headers with no body. + return withCors( + new Response(req.method === "HEAD" ? null : serialized.body, { status: 200, headers }), + req, + policy, + ); + } + if (url.pathname === "/v1/models" && req.method === "GET") { + // #809: the catalog read sits immediately before model discovery because it shares + // that route's admission rationale exactly. Keep them adjacent so a future change to + // one is made in sight of the other. // Model discovery never forwards Authorization upstream, so the broader admission // set (Authorization / x-api-key / x-opencodex-api-key) is safe here and required by // remote OpenAI-style bearer clients and Claude gateway discovery (anthropic-version). diff --git a/src/server/management/model-routes.ts b/src/server/management/model-routes.ts index a0b570bc7f..9b821884ff 100644 --- a/src/server/management/model-routes.ts +++ b/src/server/management/model-routes.ts @@ -333,17 +333,22 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise = { "Content-Type": "application/json", ...corsHeaders(req, config), }; - const { loadPersistedCodexRuntime } = await import("../../codex/runtime"); - const version = loadPersistedCodexRuntime()?.selectedVersion; + if (serialized.etag) headers.ETag = serialized.etag; + const version = await persistedCodexVersion(); if (version) headers["x-opencodex-codex-version"] = version; - return new Response(JSON.stringify(catalog), { status: 200, headers }); + return new Response(serialized.body, { status: 200, headers }); } if (url.pathname === "/api/models" && req.method === "GET") { diff --git a/tests/api-key-attribution.test.ts b/tests/api-key-attribution.test.ts index 9ec60c6b9e..2bbe502007 100644 --- a/tests/api-key-attribution.test.ts +++ b/tests/api-key-attribution.test.ts @@ -465,7 +465,10 @@ describe("AUTH_MATRIX is true of the running server", () => { [row.xApiKey, { "x-api-key": key }], ]; for (const [disposition, headers] of cases) { - const isGet = row.endpoint === "/v1/models"; + // Read-only endpoints must be exercised with GET: sending POST would draw a 405 + // from routing and the assertions below would be testing the method guard rather + // than admission. /v1/catalog joined this set in #809. + const isGet = row.endpoint === "/v1/models" || row.endpoint === "/v1/catalog"; const res = await fetch(new URL(row.endpoint, server.url), { method: isGet ? "GET" : "POST", headers: { "content-type": "application/json", ...headers }, @@ -489,6 +492,10 @@ describe("AUTH_MATRIX is true of the running server", () => { // cell pass vacuously, so the two are told apart by their code. const body = await res.clone().json().catch(() => ({})) as { error?: { code?: string } }; expect(body.error?.code).not.toBe("not_found"); + // /v1/catalog has its own honest 404 (no materialized catalog in this fixture), + // which is admission proof rather than a missing route. Pin the distinguishing + // code so a deleted route still cannot pass here. + if (row.endpoint === "/v1/catalog") expect(body.error?.code).toBe("catalog_not_found"); } const admitted = res.status !== 401; expect({ endpoint: row.endpoint, headers: Object.keys(headers)[0], admitted }) From 1d24cc7c42e67d86ed164ec0cfdc59cf3d2610f2 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sun, 30 Aug 2026 12:36:37 +0900 Subject: [PATCH 2/6] test(server): cover the /v1/catalog data-plane contract and plane separation --- tests/api-catalog-route.test.ts | 134 ++++++++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) diff --git a/tests/api-catalog-route.test.ts b/tests/api-catalog-route.test.ts index f70c0c6514..80b5ec7ddf 100644 --- a/tests/api-catalog-route.test.ts +++ b/tests/api-catalog-route.test.ts @@ -75,3 +75,137 @@ describe("GET /api/catalog route (#709)", () => { expect(await response!.json()).toEqual({ error: "catalog not found" }); }); }); + +describe("GET|HEAD /v1/catalog least-privilege data-plane route (#809)", () => { + const DATA_KEY = "ocx_data_catalogreadonly"; + + function dataPlaneConfig(): OcxConfig { + return { + port: 0, + hostname: "127.0.0.1", + defaultProvider: "mock", + providers: { + mock: { adapter: "openai-chat", baseUrl: "http://127.0.0.1:1/v1", apiKey: "k", allowPrivateNetwork: true, models: ["test-model"] }, + }, + apiKeys: [{ id: "catalog-reader", name: "catalog reader", key: DATA_KEY, createdAt: "2026-08-30T00:00:00.000Z" }], + } as OcxConfig; + } + + const catalogFixture = { + models: [{ + slug: "mock/test-model", + display_name: "Mock Test", + description: "fixture", + priority: 1, + visibility: "list", + base_instructions: "You are a helpful coding assistant.", + input_modalities: ["text"], + }], + }; + + test("serves the catalog to a data credential and byte-matches the management route", async () => { + isolatedCodexHome = installIsolatedCodexHome("ocx-v1-catalog-"); + writeFileSync(join(isolatedCodexHome.path, "opencodex-catalog.json"), JSON.stringify(catalogFixture)); + saveConfig(dataPlaneConfig()); + + const { startServer } = await import("../src/server"); + const server = startServer(0); + try { + const res = await fetch(new URL("/v1/catalog", server.url), { + headers: { "x-opencodex-api-key": DATA_KEY }, + }); + expect(res.status).toBe(200); + const body = await res.text(); + expect(JSON.parse(body)).toEqual(catalogFixture); + expect(res.headers.get("cache-control")).toBe("private, no-cache"); + const etag = res.headers.get("etag"); + expect(etag).toBeTruthy(); + + // The whole point of the shared serializer: the two planes must not drift. + const mgmtUrl = new URL("http://localhost/api/catalog"); + const mgmt = await handleManagementAPI( + new ManagementRequest(mgmtUrl, { headers: managementHeaders() }), + mgmtUrl, + loadConfig(), + ); + expect(mgmt?.status).toBe(200); + expect(await mgmt!.text()).toBe(body); + + // Conditional GET re-validates without resending the payload. + const revalidated = await fetch(new URL("/v1/catalog", server.url), { + headers: { "x-opencodex-api-key": DATA_KEY, "if-none-match": etag! }, + }); + expect(revalidated.status).toBe(304); + expect(await revalidated.text()).toBe(""); + + // HEAD is the same status and headers with no body. + const head = await fetch(new URL("/v1/catalog", server.url), { + method: "HEAD", + headers: { "x-opencodex-api-key": DATA_KEY }, + }); + expect(head.status).toBe(200); + expect(head.headers.get("etag")).toBe(etag); + expect(await head.text()).toBe(""); + } finally { + await server.stop(true); + } + }); + + test("rejects a missing credential and never widens /api/* for a data credential", async () => { + isolatedCodexHome = installIsolatedCodexHome("ocx-v1-catalog-auth-"); + writeFileSync(join(isolatedCodexHome.path, "opencodex-catalog.json"), JSON.stringify(catalogFixture)); + saveConfig(dataPlaneConfig()); + + const { startServer } = await import("../src/server"); + const server = startServer(0); + try { + const anonymous = await fetch(new URL("/v1/catalog", server.url)); + expect(anonymous.status).toBe(401); + + const wrong = await fetch(new URL("/v1/catalog", server.url), { + headers: { "x-opencodex-api-key": "ocx_data_not_a_real_key" }, + }); + expect(wrong.status).toBe(401); + + // The point of #809: the data credential reads the catalog but must gain NOTHING on the + // management plane. If this ever passes, the fix became the vulnerability. + for (const path of ["/api/catalog", "/api/config", "/api/providers"]) { + const escalation = await fetch(new URL(path, server.url), { + headers: { "x-opencodex-api-key": DATA_KEY }, + }); + expect(escalation.status).toBe(401); + } + + // Mutations stay out of /v1 entirely. + const post = await fetch(new URL("/v1/catalog", server.url), { + method: "POST", + headers: { "x-opencodex-api-key": DATA_KEY, "content-type": "application/json" }, + body: "{}", + }); + expect(post.status).not.toBe(200); + } finally { + await server.stop(true); + } + }); + + test("reports a distinguishable code when no catalog is materialized", async () => { + isolatedCodexHome = installIsolatedCodexHome("ocx-v1-catalog-missing-"); + saveConfig(dataPlaneConfig()); + + const { startServer } = await import("../src/server"); + const server = startServer(0); + try { + const res = await fetch(new URL("/v1/catalog", server.url), { + headers: { "x-opencodex-api-key": DATA_KEY }, + }); + expect(res.status).toBe(404); + // catalog_not_found rather than the generic not_found: this is what distinguishes + // "route exists, no catalog" from "route is gone", so a deleted route cannot pass + // the AUTH_MATRIX check in tests/api-key-attribution.test.ts vacuously. + const body = await res.json() as { error?: { code?: string } }; + expect(body.error?.code).toBe("catalog_not_found"); + } finally { + await server.stop(true); + } + }); +}); From 9addd529f786fc8eab4c112231fd7d67bc18211c Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sun, 30 Aug 2026 12:37:31 +0900 Subject: [PATCH 3/6] test(server): bind non-loopback so the /v1/catalog auth assertions are real --- tests/api-catalog-route.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/api-catalog-route.test.ts b/tests/api-catalog-route.test.ts index 80b5ec7ddf..cc9dc8eff6 100644 --- a/tests/api-catalog-route.test.ts +++ b/tests/api-catalog-route.test.ts @@ -79,10 +79,15 @@ describe("GET /api/catalog route (#709)", () => { describe("GET|HEAD /v1/catalog least-privilege data-plane route (#809)", () => { const DATA_KEY = "ocx_data_catalogreadonly"; + /** + * Binds 0.0.0.0 deliberately. `isApiAuthRequired` returns false for a loopback bind, so a + * 127.0.0.1 server admits every data-plane request as `kind: "loopback"` and an auth test + * against it would pass while asserting nothing. + */ function dataPlaneConfig(): OcxConfig { return { port: 0, - hostname: "127.0.0.1", + hostname: "0.0.0.0", defaultProvider: "mock", providers: { mock: { adapter: "openai-chat", baseUrl: "http://127.0.0.1:1/v1", apiKey: "k", allowPrivateNetwork: true, models: ["test-model"] }, From 51fb439c401c2cebb7364c863b67c8f729ba0b0a Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sun, 30 Aug 2026 12:39:43 +0900 Subject: [PATCH 4/6] docs: point remote catalog fetch at the data-plane /v1/catalog route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Codex integration guide told operators to fetch the catalog with OPENCODEX_ADMIN_AUTH_TOKEN against /api/catalog — which is exactly the least-privilege problem #809 describes. English and all seven locales now use an ordinary data-plane key against /v1/catalog, and state that a data key admitted there gains nothing on the management plane. --- .../docs/fr/guides/codex-integration.md | 6 ++++-- .../content/docs/guides/codex-integration.md | 20 +++++++++++++++---- .../docs/ja/guides/codex-integration.md | 6 ++++-- .../docs/ko/guides/codex-integration.md | 6 ++++-- .../content/docs/reference/management-api.md | 2 +- .../docs/ru/guides/codex-integration.md | 6 ++++-- .../docs/tr/guides/codex-integration.md | 6 ++++-- .../docs/zh-cn/guides/codex-integration.md | 6 ++++-- .../docs/zh-tw/guides/codex-integration.md | 6 ++++-- 9 files changed, 45 insertions(+), 19 deletions(-) diff --git a/docs-site/src/content/docs/fr/guides/codex-integration.md b/docs-site/src/content/docs/fr/guides/codex-integration.md index 6b81c25608..bf1d40fcb4 100644 --- a/docs-site/src/content/docs/fr/guides/codex-integration.md +++ b/docs-site/src/content/docs/fr/guides/codex-integration.md @@ -260,8 +260,8 @@ d'admission que pour les autres routes `/api/*` : ```bash dest="${CODEX_HOME:-$HOME/.codex}/opencodex-catalog.json" tmp="$(mktemp "${dest}.XXXXXX")" -curl -fsS -H "x-opencodex-api-key: $OPENCODEX_ADMIN_AUTH_TOKEN" \ - "https://proxy.example.com/api/catalog" > "$tmp" \ +curl -fsS -H "x-opencodex-api-key: $OPENCODEX_API_AUTH_TOKEN" \ + "https://proxy.example.com/v1/catalog" > "$tmp" \ && mv "$tmp" "$dest" ocx sync-cache ``` @@ -274,6 +274,8 @@ Vous pouvez également définir ou modifier ce nom dans l'API de gestion — `PO `PUT /api/custom-models/` avec une chaîne `displayName` — et dans le tableau de bord web. Le caractère `/` est refusé, car il entrerait en collision avec le séparateur des identifiants de routage. +`GET /v1/catalog` existe pour que la lecture d'une liste de modèles ne coûte pas un jeton d'administration. La route est en lecture seule (`GET` et `HEAD`), accepte `x-opencodex-api-key`, un jeton bearer ou `x-api-key`, et renvoie exactement les mêmes octets que la route de gestion. Les réponses portent un `ETag` fort — renvoyez-le dans `If-None-Match` pour revalider et obtenir un `304` — et `Cache-Control: private, no-cache`. Une clé du plan de données admise ici n'obtient **rien** sur le plan de gestion : `/api/catalog` et toutes les routes `/api/*` exigent toujours le jeton d'administration ou une session du tableau de bord. + Le nom d'affichage sert **uniquement à l'affichage et reste stable entre les régénérations**. À chaque `ocx sync` et à chaque actualisation du catalogue, opencodex reconstruit les entrées routées depuis `config.json`, y compris `customModels` ; le nom configuré est donc réappliqué au lieu de revenir à l'identifiant de routage. Un service diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index 1209f96b44..3caec27f29 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -265,14 +265,14 @@ Add a display name from the CLI (the proxy syncs the catalog right away when liv ocx models add deepseek deepseek-v4 --display-name "DeepSeek V4" --context-window 128000 ``` -Remote Codex clients can fetch the same generated catalog over the management API (same -admission token as other `/api/*` routes): +Remote Codex clients can fetch the same generated catalog with an ordinary **data-plane** key +— the same credential they already use for `/v1/responses`, not an admin token: ```bash dest="${CODEX_HOME:-$HOME/.codex}/opencodex-catalog.json" tmp="$(mktemp "${dest}.XXXXXX")" -curl -fsS -H "x-opencodex-api-key: $OPENCODEX_ADMIN_AUTH_TOKEN" \ - "https://proxy.example.com/api/catalog" > "$tmp" \ +curl -fsS -H "x-opencodex-api-key: $OPENCODEX_API_AUTH_TOKEN" \ + "https://proxy.example.com/v1/catalog" > "$tmp" \ && mv "$tmp" "$dest" ocx sync-cache ``` @@ -281,6 +281,18 @@ The response is the raw `opencodex-catalog.json` document (no provider credentia available, the `x-opencodex-codex-version` header reports the Codex runtime version on the server so clients can spot version skew. +`GET /v1/catalog` exists so that reading a list of models does not cost an admin token. It is +read-only (`GET` and `HEAD`), accepts `x-opencodex-api-key`, a bearer token, or +`x-api-key`, and serves exactly the same bytes as the management route. Responses carry a +strong `ETag` — pass it back as `If-None-Match` to re-validate and get a `304` instead of the +full document — and `Cache-Control: private, no-cache`, since the body sits behind a +credential. + +A data-plane key admitted here gains **nothing** on the management plane: `/api/catalog` and +every other `/api/*` route still require the admin token or a dashboard session. The older +`/api/catalog` route keeps working unchanged for the dashboard and for scripts that already +hold an admin token. + You can also set or edit it through the management API (`POST /api/custom-models`, `PUT /api/custom-models/` with a `displayName` string) and the web dashboard. A `/` is rejected because it would collide with the routed-slug separator. diff --git a/docs-site/src/content/docs/ja/guides/codex-integration.md b/docs-site/src/content/docs/ja/guides/codex-integration.md index a4e7816b27..040d05cebc 100644 --- a/docs-site/src/content/docs/ja/guides/codex-integration.md +++ b/docs-site/src/content/docs/ja/guides/codex-integration.md @@ -168,8 +168,8 @@ ocx models add deepseek deepseek-v4 --display-name "DeepSeek V4" --context-windo ```bash dest="${CODEX_HOME:-$HOME/.codex}/opencodex-catalog.json" tmp="$(mktemp "${dest}.XXXXXX")" -curl -fsS -H "x-opencodex-api-key: $OPENCODEX_ADMIN_AUTH_TOKEN" \ - "https://proxy.example.com/api/catalog" > "$tmp" \ +curl -fsS -H "x-opencodex-api-key: $OPENCODEX_API_AUTH_TOKEN" \ + "https://proxy.example.com/v1/catalog" > "$tmp" \ && mv "$tmp" "$dest" ocx sync-cache ``` @@ -178,6 +178,8 @@ ocx sync-cache 管理 API (`POST /api/custom-models`、`PUT /api/custom-models/` と `displayName` 文字列) および Web ダッシュボードを通じて設定または編集することもできます。 `/` は、配線済みスラグ セパレータと衝突する可能性があるため拒否されます。 +`GET /v1/catalog` は、モデル一覧の読み取りに管理トークンを必要としないために存在します。読み取り専用(`GET` と `HEAD`)で、`x-opencodex-api-key`、bearer トークン、`x-api-key` を受け付け、管理ルートとまったく同じバイト列を返します。レスポンスには強い `ETag` が付き、`If-None-Match` で送り返すと全文ではなく `304` が返ります。また `Cache-Control: private, no-cache` が設定されます。ここで許可されたデータプレーンキーは、管理プレーンでは**何も**得られません。`/api/catalog` を含むすべての `/api/*` ルートは、引き続き管理トークンまたはダッシュボードセッションを要求します。 + 表示名は **表示専用であり、再生成しても安定しています**。 `ocx sync` およびカタログが更新されるたびに、`config.json` (`customModels` を含む) からルーティングされたエントリが再取得されるため、設定された名前はルーティングされたスラッグに戻るのではなく、再適用されます。管理対象サービスの再起動でも、プロキシのバインド直後にこの同期が試行されます。オフライン ログイン中など、ベストエフォート型ブート同期が失敗した場合、以前に永続化されたカタログが保持され、次に成功した `ocx sync` が構成された名前を再適用します。本物のアップストリーム ネイティブ名 (例: `gpt-5.6-sol` → "GPT-5.6-Sol") は、固定されたアップストリーム スナップショットから取得され、カスタム表示名によって上書きされることはありません。 ### 外部プロバイダーマネージャー diff --git a/docs-site/src/content/docs/ko/guides/codex-integration.md b/docs-site/src/content/docs/ko/guides/codex-integration.md index 5c008ff1a8..957b347643 100644 --- a/docs-site/src/content/docs/ko/guides/codex-integration.md +++ b/docs-site/src/content/docs/ko/guides/codex-integration.md @@ -158,8 +158,8 @@ ocx models add deepseek deepseek-v4 --display-name "DeepSeek V4" --context-windo ```bash dest="${CODEX_HOME:-$HOME/.codex}/opencodex-catalog.json" tmp="$(mktemp "${dest}.XXXXXX")" -curl -fsS -H "x-opencodex-api-key: $OPENCODEX_ADMIN_AUTH_TOKEN" \ - "https://proxy.example.com/api/catalog" > "$tmp" \ +curl -fsS -H "x-opencodex-api-key: $OPENCODEX_API_AUTH_TOKEN" \ + "https://proxy.example.com/v1/catalog" > "$tmp" \ && mv "$tmp" "$dest" ocx sync-cache ``` @@ -168,6 +168,8 @@ ocx sync-cache 또한 management API(`POST /api/custom-models`, `PUT /api/custom-models/`의 `displayName` string)와 웹 대시보드에서도 설정하거나 수정할 수 있습니다. `/`는 routed-slug separator와 충돌하므로 거부됩니다. +`GET /v1/catalog`은 모델 목록을 읽는 데 관리자 토큰이 필요하지 않도록 존재합니다. 읽기 전용(`GET`, `HEAD`)이며 `x-opencodex-api-key`, bearer 토큰, `x-api-key`를 허용하고 관리 라우트와 완전히 동일한 바이트를 제공합니다. 응답에는 강한 `ETag`가 포함되므로 `If-None-Match`로 다시 보내면 전체 문서 대신 `304`를 받고, `Cache-Control: private, no-cache`가 함께 설정됩니다. 여기서 허용된 데이터 플레인 키는 관리 플레인에서 **아무 권한도** 얻지 못합니다. `/api/catalog`을 비롯한 모든 `/api/*` 라우트는 여전히 관리자 토큰이나 대시보드 세션을 요구합니다. + 표시 이름은 **표시 전용이며 재생성 사이에서도 안정적**입니다. 모든 `ocx sync`와 catalog refresh는 `config.json`(`customModels` 포함)에서 routed entry를 다시 계산하므로, 설정된 이름이 라우팅 slug로 되돌아가지 않고 다시 적용됩니다. 관리형 service restart도 proxy가 bind된 직후 이 sync를 다시 시도합니다. 예를 들어 offline login 중이라 이 best-effort boot sync가 실패하면, 이전에 저장된 catalog는 유지되고 다음에 성공한 `ocx sync`가 설정된 이름을 다시 적용합니다. 진짜 upstream native name(예: `gpt-5.6-sol` → "GPT-5.6-Sol")은 고정된 upstream snapshot에서 오며, custom display name으로 덮어쓰지 않습니다. ### 외부 provider manager diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index bef307c3d5..a3fde8170f 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -152,7 +152,7 @@ first and submit the returned digest. Prefer quarantine when recovery may be nee | Method and path | Purpose | Notable errors | | --- | --- | --- | -| `GET /api/catalog` | Return the installed Codex catalog document | 404 catalog not found | +| `GET /api/catalog` | Return the installed Codex catalog document. Remote clients should prefer the data-plane `GET /v1/catalog`, which needs no admin token. | 404 catalog not found | | `GET /api/models` | Return the dashboard/CLI model rows | `catalog_busy` when gathering is saturated | | `GET /api/client-config?client=...` | Build a read-only client config for any supported file integration | 400 unsupported client; 503 catalog unavailable | | `PUT /api/disabled-models` | Replace the shared disabled-model list | 400 invalid JSON | diff --git a/docs-site/src/content/docs/ru/guides/codex-integration.md b/docs-site/src/content/docs/ru/guides/codex-integration.md index 395413fd84..1469d655d7 100644 --- a/docs-site/src/content/docs/ru/guides/codex-integration.md +++ b/docs-site/src/content/docs/ru/guides/codex-integration.md @@ -250,8 +250,8 @@ ocx models add deepseek deepseek-v4 --display-name "DeepSeek V4" --context-windo ```bash dest="${CODEX_HOME:-$HOME/.codex}/opencodex-catalog.json" tmp="$(mktemp "${dest}.XXXXXX")" -curl -fsS -H "x-opencodex-api-key: $OPENCODEX_ADMIN_AUTH_TOKEN" \ - "https://proxy.example.com/api/catalog" > "$tmp" \ +curl -fsS -H "x-opencodex-api-key: $OPENCODEX_API_AUTH_TOKEN" \ + "https://proxy.example.com/v1/catalog" > "$tmp" \ && mv "$tmp" "$dest" ocx sync-cache ``` @@ -264,6 +264,8 @@ Display name можно задать или отредактировать и ч (`POST /api/custom-models`, `PUT /api/custom-models/` с полем `displayName`) и через веб-дашборд. Символ `/` запрещён, потому что он столкнулся бы с разделителем routed-slug. +`GET /v1/catalog` существует для того, чтобы чтение списка моделей не требовало админского токена. Маршрут только для чтения (`GET` и `HEAD`), принимает `x-opencodex-api-key`, bearer-токен или `x-api-key` и отдаёт в точности те же байты, что и управляющий маршрут. Ответы содержат строгий `ETag` — верните его в `If-None-Match`, чтобы повторно проверить и получить `304` вместо полного документа — и `Cache-Control: private, no-cache`. Ключ плоскости данных, допущенный здесь, **не получает ничего** на плоскости управления: `/api/catalog` и все маршруты `/api/*` по-прежнему требуют админский токен или сессию панели. + Display name — это **только отображение, и оно устойчиво к перегенерации**. Каждый `ocx sync` и каждое обновление каталога заново выводят маршрутизируемые записи из `config.json` (включая `customModels`), поэтому настроенное имя накладывается снова и не «дрейфует» обратно к diff --git a/docs-site/src/content/docs/tr/guides/codex-integration.md b/docs-site/src/content/docs/tr/guides/codex-integration.md index ebd67f9af2..7692980e91 100644 --- a/docs-site/src/content/docs/tr/guides/codex-integration.md +++ b/docs-site/src/content/docs/tr/guides/codex-integration.md @@ -291,8 +291,8 @@ getirebilir (diğer `/api/*` rotalarıyla aynı kabul belirteci): ```bash dest="${CODEX_HOME:-$HOME/.codex}/opencodex-catalog.json" tmp="$(mktemp "${dest}.XXXXXX")" -curl -fsS -H "x-opencodex-api-key: $OPENCODEX_ADMIN_AUTH_TOKEN" \ - "https://proxy.example.com/api/catalog" > "$tmp" \ +curl -fsS -H "x-opencodex-api-key: $OPENCODEX_API_AUTH_TOKEN" \ + "https://proxy.example.com/v1/catalog" > "$tmp" \ && mv "$tmp" "$dest" ocx sync-cache ``` @@ -307,6 +307,8 @@ dizesiyle `PUT /api/custom-models/`) ve web kontrol paneli aracılığıyla ayarlayabilir veya düzenleyebilirsiniz. Yönlendirilen slug ayırıcısıyla çakışacağı için `/` işareti reddedilir. +`GET /v1/catalog`, bir model listesini okumanın yönetici belirtecine mal olmaması için vardır. Rota salt okunurdur (`GET` ve `HEAD`), `x-opencodex-api-key`, bearer belirteci veya `x-api-key` kabul eder ve yönetim rotasıyla tamamen aynı baytları sunar. Yanıtlar güçlü bir `ETag` taşır — tam belge yerine `304` almak için `If-None-Match` ile geri gönderin — ve `Cache-Control: private, no-cache` içerir. Burada kabul edilen bir veri düzlemi anahtarı yönetim düzleminde **hiçbir şey** kazanmaz: `/api/catalog` ve tüm `/api/*` rotaları hâlâ yönetici belirteci veya pano oturumu gerektirir. + Görünen ad **yalnızca görüntüleme amaçlıdır ve yeniden oluşturma boyunca kararlıdır**. Her `ocx sync` ve katalog yenilemesi yönlendirilen girdileri `config.json`'dan (`customModels` dahil) yeniden türetir, böylece yapılandırılan diff --git a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md index 69e829046f..069b3c0587 100644 --- a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md +++ b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md @@ -218,8 +218,8 @@ ocx models add deepseek deepseek-v4 --display-name "DeepSeek V4" --context-windo ```bash dest="${CODEX_HOME:-$HOME/.codex}/opencodex-catalog.json" tmp="$(mktemp "${dest}.XXXXXX")" -curl -fsS -H "x-opencodex-api-key: $OPENCODEX_ADMIN_AUTH_TOKEN" \ - "https://proxy.example.com/api/catalog" > "$tmp" \ +curl -fsS -H "x-opencodex-api-key: $OPENCODEX_API_AUTH_TOKEN" \ + "https://proxy.example.com/v1/catalog" > "$tmp" \ && mv "$tmp" "$dest" ocx sync-cache ``` @@ -230,6 +230,8 @@ ocx sync-cache 你也可以通过管理 API(`POST /api/custom-models`、带 `displayName` 字符串的 `PUT /api/custom-models/`) 以及 web dashboard 来设置或编辑它。因为会与路由 slug 分隔符冲突,所以 `/` 会被拒绝。 +`GET /v1/catalog` 的存在是为了让读取模型列表不再需要管理员令牌。该路由为只读(`GET` 与 `HEAD`),接受 `x-opencodex-api-key`、bearer 令牌或 `x-api-key`,并返回与管理路由完全相同的字节。响应携带强 `ETag`——通过 `If-None-Match` 回传即可重新验证并获得 `304` 而非完整文档——同时设置 `Cache-Control: private, no-cache`。在此被接纳的数据面密钥在管理面上**不会**获得任何权限:`/api/catalog` 以及所有 `/api/*` 路由仍然要求管理员令牌或仪表板会话。 + display name 是 **仅用于显示且在重新生成时保持稳定的**。每一次 `ocx sync` 和 catalog refresh 都会从 `config.json`(包括 `customModels`)重新派生路由条目,因此配置过的名称会重新应用,而不是漂回路由 slug。 受管服务重启后也会在 proxy 绑定完成后不久尝试做这次 sync。如果这个尽力而为的启动 sync 失败了,比如在离线登录时, diff --git a/docs-site/src/content/docs/zh-tw/guides/codex-integration.md b/docs-site/src/content/docs/zh-tw/guides/codex-integration.md index 24767de948..50c548ee74 100644 --- a/docs-site/src/content/docs/zh-tw/guides/codex-integration.md +++ b/docs-site/src/content/docs/zh-tw/guides/codex-integration.md @@ -225,8 +225,8 @@ token: ```bash dest="${CODEX_HOME:-$HOME/.codex}/opencodex-catalog.json" tmp="$(mktemp "${dest}.XXXXXX")" -curl -fsS -H "x-opencodex-api-key: $OPENCODEX_ADMIN_AUTH_TOKEN" \ - "https://proxy.example.com/api/catalog" > "$tmp" \ +curl -fsS -H "x-opencodex-api-key: $OPENCODEX_API_AUTH_TOKEN" \ + "https://proxy.example.com/v1/catalog" > "$tmp" \ && mv "$tmp" "$dest" ocx sync-cache ``` @@ -237,6 +237,8 @@ ocx sync-cache 也可以透過管理 API(`POST /api/custom-models`、`PUT /api/custom-models/`,搭配 `displayName` 字串)與 web 儀表板設定或編輯。`/` 會被拒絕,因為它會與路由 slug 的分隔符衝突。 +`GET /v1/catalog` 的存在是為了讓讀取模型清單不再需要管理員權杖。該路由為唯讀(`GET` 與 `HEAD`),接受 `x-opencodex-api-key`、bearer 權杖或 `x-api-key`,並回傳與管理路由完全相同的位元組。回應帶有強 `ETag`——以 `If-None-Match` 回傳即可重新驗證並取得 `304` 而非完整文件——同時設定 `Cache-Control: private, no-cache`。在此被接納的資料平面金鑰在管理平面上**不會**取得任何權限:`/api/catalog` 以及所有 `/api/*` 路由仍要求管理員權杖或儀表板工作階段。 + 顯示名稱**只用於顯示,且在重新產生時保持穩定**。每次 `ocx sync` 與目錄 refresh 都會從 `config.json`(包含 `customModels`)重新推導路由條目,因此會重新套用已設定名稱,而不會漂移回路由 slug。受管服務重啟後,也會在 proxy bind 後盡力同步一次。若這次啟動時的 best-effort 同步失敗,例如 From 2afd769b5377ed02a3b63aba85c84100c9549ec0 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sun, 30 Aug 2026 13:11:29 +0900 Subject: [PATCH 5/6] fix(server): scope the catalog size ceiling to the remote route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding on 63edd4b1e: the 32 MiB ceiling lived in the shared serializer, so it applied to /api/catalog too. The repository supports up to 2,000 discovered models and a 2,000-row catalog serializes to roughly 92 MB — the ceiling therefore rejected a valid supported catalog AND regressed the pre-existing management route to 507 for those operators. A size policy belongs to the route that serves the bytes, not to the shared serializer both planes depend on. serializePersistedCatalog no longer caps anything; /v1/catalog applies MAX_REMOTE_CATALOG_BYTES itself, raised to 256 MiB so the supported bound clears with room while a corrupt or hostile file is still refused. /api/catalog keeps its original behavior exactly. Regression builds a valid 2,000-model catalog above 32 MiB and asserts the serializer returns it and the management route still answers 200. Docs: locale integration intros still described a management-API fetch with an /api/* admission token while the command below used /v1/catalog, and the management reference said 'needs no admin token', which reads as anonymous. Both now say an ordinary data-plane credential is required and distinguish it from admin privilege. --- .../docs/fr/guides/codex-integration.md | 2 +- .../docs/ja/guides/codex-integration.md | 2 +- .../docs/ko/guides/codex-integration.md | 2 +- .../content/docs/reference/management-api.md | 2 +- .../docs/zh-cn/guides/codex-integration.md | 2 +- src/server/catalog-download.ts | 24 ++++++++---- src/server/index.ts | 26 +++++++++---- src/server/management/model-routes.ts | 8 ++-- tests/api-catalog-route.test.ts | 37 +++++++++++++++++++ 9 files changed, 80 insertions(+), 25 deletions(-) diff --git a/docs-site/src/content/docs/fr/guides/codex-integration.md b/docs-site/src/content/docs/fr/guides/codex-integration.md index bf1d40fcb4..08406249f8 100644 --- a/docs-site/src/content/docs/fr/guides/codex-integration.md +++ b/docs-site/src/content/docs/fr/guides/codex-integration.md @@ -254,7 +254,7 @@ Ajoutez un nom d'affichage depuis la CLI ; si le proxy est actif, il synchronise ocx models add deepseek deepseek-v4 --display-name "DeepSeek V4" --context-window 128000 ``` -Les clients Codex distants peuvent récupérer le même catalogue généré par l'API de gestion, avec le même jeton +Les clients Codex distants peuvent récupérer le même catalogue généré avec une clé ordinaire du plan de données (pas un jeton d'administration) — le même identifiant d'admission que pour les autres routes `/api/*` : ```bash diff --git a/docs-site/src/content/docs/ja/guides/codex-integration.md b/docs-site/src/content/docs/ja/guides/codex-integration.md index 040d05cebc..ac584e53d7 100644 --- a/docs-site/src/content/docs/ja/guides/codex-integration.md +++ b/docs-site/src/content/docs/ja/guides/codex-integration.md @@ -163,7 +163,7 @@ CLI から表示名を追加します (プロキシは、ライブ時にカタ ocx models add deepseek deepseek-v4 --display-name "DeepSeek V4" --context-window 128000 ``` -リモート Codex クライアントは、管理 API 経由で同じ生成されたカタログをフェッチできます (他の `/api/*` ルートと同じアドミッション トークン)。 +リモート Codex クライアントは、通常のデータプレーン キー(管理者トークンではなく、`/v1/responses` で既に使用しているものと同じ資格情報)で同じ生成済みカタログを取得できます。 ```bash dest="${CODEX_HOME:-$HOME/.codex}/opencodex-catalog.json" diff --git a/docs-site/src/content/docs/ko/guides/codex-integration.md b/docs-site/src/content/docs/ko/guides/codex-integration.md index 957b347643..fd37a48fe9 100644 --- a/docs-site/src/content/docs/ko/guides/codex-integration.md +++ b/docs-site/src/content/docs/ko/guides/codex-integration.md @@ -153,7 +153,7 @@ CLI에서 표시 이름을 추가할 수 있습니다(proxy가 live 상태면 ca ocx models add deepseek deepseek-v4 --display-name "DeepSeek V4" --context-window 128000 ``` -원격 Codex client는 management API로 같은 생성된 catalog를 가져올 수 있습니다(다른 `/api/*` 경로와 같은 admission token을 사용합니다): +원격 Codex client는 관리자 토큰이 아니라 일반 데이터 플레인 키(`/v1/responses`에 이미 사용하는 것과 같은 자격 증명)로 같은 생성된 catalog를 가져올 수 있습니다: ```bash dest="${CODEX_HOME:-$HOME/.codex}/opencodex-catalog.json" diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index a3fde8170f..25fedb6020 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -152,7 +152,7 @@ first and submit the returned digest. Prefer quarantine when recovery may be nee | Method and path | Purpose | Notable errors | | --- | --- | --- | -| `GET /api/catalog` | Return the installed Codex catalog document. Remote clients should prefer the data-plane `GET /v1/catalog`, which needs no admin token. | 404 catalog not found | +| `GET /api/catalog` | Return the installed Codex catalog document. Remote clients should prefer the data-plane `GET /v1/catalog`, which still requires an ordinary data-plane credential but not an admin token. | 404 catalog not found | | `GET /api/models` | Return the dashboard/CLI model rows | `catalog_busy` when gathering is saturated | | `GET /api/client-config?client=...` | Build a read-only client config for any supported file integration | 400 unsupported client; 503 catalog unavailable | | `PUT /api/disabled-models` | Replace the shared disabled-model list | 400 invalid JSON | diff --git a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md index 069b3c0587..f11f8d9c5c 100644 --- a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md +++ b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md @@ -213,7 +213,7 @@ Browser 或 Computer Use。原生 OpenAI 条目会保持其上游 tool mode 不 ocx models add deepseek deepseek-v4 --display-name "DeepSeek V4" --context-window 128000 ``` -远程 Codex 客户端也可以通过管理 API 拉取同一个生成好的 catalog(与其他 `/api/*` 路由使用相同的 admission token): +远程 Codex 客户端可以使用普通的数据面密钥(与 `/v1/responses` 所用凭据相同,而非管理员令牌)拉取同一个生成好的 catalog: ```bash dest="${CODEX_HOME:-$HOME/.codex}/opencodex-catalog.json" diff --git a/src/server/catalog-download.ts b/src/server/catalog-download.ts index 42c4cc7649..984b22217c 100644 --- a/src/server/catalog-download.ts +++ b/src/server/catalog-download.ts @@ -13,12 +13,20 @@ import { createHash } from "node:crypto"; /** - * Upper bound on a serialized catalog we are willing to hold in memory and hand - * to a remote client. A materialized catalog is a few hundred KiB; anything past - * this is a corrupt or hostile file, and streaming it would be worse than - * refusing it. + * Upper bound for the REMOTE route only. + * + * The first version of this used 32 MiB and applied it to both routes, which was + * wrong twice over. The repository supports up to 2,000 discovered models, and a + * 2,000-row catalog serializes to roughly 92 MB — so 32 MiB rejected a valid + * supported catalog, and applying it to `/api/catalog` turned a working + * management response into a 507 for those operators. + * + * 256 MiB clears the supported bound with room to spare while still refusing a + * file that could only be corrupt or hostile. The management route is not + * subject to it at all: it is a local dashboard read whose behavior predates + * this module and must not change. */ -export const MAX_REMOTE_CATALOG_BYTES = 32 * 1024 * 1024; +export const MAX_REMOTE_CATALOG_BYTES = 256 * 1024 * 1024; export interface SerializedCatalog { /** Serialized catalog JSON, or null when no catalog could be materialized. */ @@ -27,8 +35,6 @@ export interface SerializedCatalog { etag?: string; /** Byte length of `body`, present only when `body` is. */ bytes?: number; - /** True when the catalog materialized but exceeds `MAX_REMOTE_CATALOG_BYTES`. */ - tooLarge?: boolean; } export function catalogEtag(body: string): string { @@ -42,6 +48,9 @@ export function catalogEtag(body: string): string { * file, malformed JSON — because `readCatalog` already collapses those into * `null` and the routes render them identically as a 404. Distinguishing them * here would invite one route to leak a filesystem path in an error message. + * + * Deliberately does NOT apply a size ceiling: a size policy belongs to the route + * that serves the bytes, not to the shared serializer both planes depend on. */ export async function serializePersistedCatalog(): Promise { const { readCatalog, readCodexCatalogPath } = await import("../codex/catalog"); @@ -49,7 +58,6 @@ export async function serializePersistedCatalog(): Promise { if (!catalog) return { body: null }; const body = JSON.stringify(catalog); const bytes = Buffer.byteLength(body, "utf8"); - if (bytes > MAX_REMOTE_CATALOG_BYTES) return { body: null, tooLarge: true, bytes }; return { body, etag: catalogEtag(body), bytes }; } diff --git a/src/server/index.ts b/src/server/index.ts index bbedaad003..6c7e53f062 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1084,7 +1084,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server MAX_REMOTE_CATALOG_BYTES) { + return withCors( + new Response(JSON.stringify({ + error: { type: "server_error", code: "catalog_too_large", message: "catalog exceeds the maximum served size" }, + }), { + status: 507, headers: { "content-type": "application/json" }, }), req, diff --git a/src/server/management/model-routes.ts b/src/server/management/model-routes.ts index 9b821884ff..b798b44c18 100644 --- a/src/server/management/model-routes.ts +++ b/src/server/management/model-routes.ts @@ -336,11 +336,9 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise = { "Content-Type": "application/json", ...corsHeaders(req, config), diff --git a/tests/api-catalog-route.test.ts b/tests/api-catalog-route.test.ts index cc9dc8eff6..634ce3e7e1 100644 --- a/tests/api-catalog-route.test.ts +++ b/tests/api-catalog-route.test.ts @@ -193,6 +193,43 @@ describe("GET|HEAD /v1/catalog least-privilege data-plane route (#809)", () => { } }); + test("serves a supported large catalog on both planes", async () => { + // The repo supports up to 2,000 discovered models. A 2,000-row catalog serializes to + // roughly 92 MB, so an earlier 32 MiB ceiling in the shared serializer rejected a VALID + // catalog — and, because both routes shared it, turned the pre-existing /api/catalog + // response into a 507 for those operators. The ceiling now belongs to the remote route + // alone and clears the supported bound. + isolatedCodexHome = installIsolatedCodexHome("ocx-v1-catalog-large-"); + const template = catalogFixture.models[0]!; + const big = { + models: Array.from({ length: 2000 }, (_, i) => ({ + ...template, + slug: `mock/test-model-${i}`, + display_name: `Mock Test ${i}`, + // Pad so the serialized document clears 32 MiB, matching a real large catalog's + // per-row instruction text rather than a synthetic blob. + base_instructions: template.base_instructions + " ".repeat(20000), + })), + }; + writeFileSync(join(isolatedCodexHome.path, "opencodex-catalog.json"), JSON.stringify(big)); + saveConfig(dataPlaneConfig()); + + const { serializePersistedCatalog, MAX_REMOTE_CATALOG_BYTES } = await import("../src/server/catalog-download"); + const serialized = await serializePersistedCatalog(); + expect(serialized.body).not.toBeNull(); + expect(serialized.bytes!).toBeGreaterThan(32 * 1024 * 1024); + expect(serialized.bytes!).toBeLessThan(MAX_REMOTE_CATALOG_BYTES); + + // The management route must still answer 200 for it. + const mgmtUrl = new URL("http://localhost/api/catalog"); + const mgmt = await handleManagementAPI( + new ManagementRequest(mgmtUrl, { headers: managementHeaders() }), + mgmtUrl, + loadConfig(), + ); + expect(mgmt?.status).toBe(200); + }); + test("reports a distinguishable code when no catalog is materialized", async () => { isolatedCodexHome = installIsolatedCodexHome("ocx-v1-catalog-missing-"); saveConfig(dataPlaneConfig()); From 6cc261d17c70312a60fcc296a475ef2c13053c68 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sun, 30 Aug 2026 17:11:46 +0900 Subject: [PATCH 6/6] docs: state the real catalog credential in the fr and zh-tw guides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding: the French sentence contradicted itself mid-clause — it said an ordinary data-plane key and then called it the same admission credential as other /api/* routes. The Traditional Chinese one still described a management-API fetch with an /api/* admission token. Both now use the same explicit contract as the Japanese, Korean, and Simplified Chinese pages: an ordinary data-plane credential, the same one used for /v1/responses, not a management or admin token. --- docs-site/src/content/docs/fr/guides/codex-integration.md | 3 +-- docs-site/src/content/docs/zh-tw/guides/codex-integration.md | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/docs-site/src/content/docs/fr/guides/codex-integration.md b/docs-site/src/content/docs/fr/guides/codex-integration.md index 08406249f8..091a63a787 100644 --- a/docs-site/src/content/docs/fr/guides/codex-integration.md +++ b/docs-site/src/content/docs/fr/guides/codex-integration.md @@ -254,8 +254,7 @@ Ajoutez un nom d'affichage depuis la CLI ; si le proxy est actif, il synchronise ocx models add deepseek deepseek-v4 --display-name "DeepSeek V4" --context-window 128000 ``` -Les clients Codex distants peuvent récupérer le même catalogue généré avec une clé ordinaire du plan de données (pas un jeton d'administration) — le même identifiant -d'admission que pour les autres routes `/api/*` : +Les clients Codex distants peuvent récupérer le même catalogue généré avec une clé ordinaire du plan de données — le même identifiant que celui utilisé pour `/v1/responses`, et non un jeton de gestion ou d'administration : ```bash dest="${CODEX_HOME:-$HOME/.codex}/opencodex-catalog.json" diff --git a/docs-site/src/content/docs/zh-tw/guides/codex-integration.md b/docs-site/src/content/docs/zh-tw/guides/codex-integration.md index 50c548ee74..4166276fbc 100644 --- a/docs-site/src/content/docs/zh-tw/guides/codex-integration.md +++ b/docs-site/src/content/docs/zh-tw/guides/codex-integration.md @@ -219,8 +219,7 @@ provider 與原生 OpenAI 行銷名稱都維持不動。 ocx models add deepseek deepseek-v4 --display-name "DeepSeek V4" --context-window 128000 ``` -遠端 Codex client 也能透過管理 API 取得相同的產生目錄,使用與其他 `/api/*` route 相同的 admission -token: +遠端 Codex client 可以使用一般的資料平面金鑰取得相同的產生目錄——與 `/v1/responses` 所用的憑證相同,而非管理或管理員權杖: ```bash dest="${CODEX_HOME:-$HOME/.codex}/opencodex-catalog.json"