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..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,14 +254,13 @@ 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 -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" 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 +273,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..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,13 +163,13 @@ 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" 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..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,13 +153,13 @@ 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" 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..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 | 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/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..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,13 +213,13 @@ 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" 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..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,14 +219,13 @@ 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" 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 +236,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 同步失敗,例如 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..984b22217c --- /dev/null +++ b/src/server/catalog-download.ts @@ -0,0 +1,73 @@ +/** + * 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 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 = 256 * 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; +} + +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. + * + * 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"); + const catalog = readCatalog(readCodexCatalogPath()); + if (!catalog) return { body: null }; + const body = JSON.stringify(catalog); + const bytes = Buffer.byteLength(body, "utf8"); + 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..6c7e53f062 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1069,7 +1069,81 @@ 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, + policy, + ); + } + const headers: Record = { + "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..b798b44c18 100644 --- a/src/server/management/model-routes.ts +++ b/src/server/management/model-routes.ts @@ -333,17 +333,20 @@ 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-catalog-route.test.ts b/tests/api-catalog-route.test.ts index f70c0c6514..634ce3e7e1 100644 --- a/tests/api-catalog-route.test.ts +++ b/tests/api-catalog-route.test.ts @@ -75,3 +75,179 @@ 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"; + + /** + * 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: "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"] }, + }, + 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("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()); + + 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); + } + }); +}); 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 })