diff --git a/docs-site/src/content/docs/fr/reference/cli/lifecycle.md b/docs-site/src/content/docs/fr/reference/cli/lifecycle.md index 2624d6388e..aa2254733e 100644 --- a/docs-site/src/content/docs/fr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/fr/reference/cli/lifecycle.md @@ -155,6 +155,22 @@ Si des processus Codex `app-server` de longue durée sont encore actifs, `ocx sy Invalide le cache local du sélecteur de modèles de Codex afin qu’il soit reconstruit à partir du catalogue opencodex actif. Le même avertissement concernant un `app-server` obsolète et le même comportement facultatif `--restart-codex` que pour `ocx sync` s’appliquent. +### `ocx catalog pull [--auth-env ] [--json] [--restart-codex]` + +Installe un catalogue complet servi par le point de terminaison `/v1/catalog` d'une autre instance +OpenCodex, puis synchronise `models_cache.json`. L'URL doit être en HTTPS ; le HTTP est accepté +uniquement en loopback. Les identifiants intégrés à l'URL, les requêtes, les fragments, les +redirections, les réponses trop volumineuses et les catalogues invalides sont refusés avant toute +écriture locale. L'authentification est facultative et lue uniquement par référence à une variable +d'environnement (`--auth-env`), jamais depuis argv. + +Le catalogue et le cache sont écrits sous le verrou de catalogue Codex partagé ; un échec préserve +les derniers fichiers valides connus. Des octets identiques constituent une non-opération qui +préserve les mtimes. `--restart-codex` ne s'applique qu'après une écriture réelle. Les requêtes +conditionnelles `ETag` et le redémarrage de l'application Desktop ne font pas partie de cette +commande. Voir la [référence anglaise](/reference/cli/lifecycle/) pour l'enveloppe `--json` +complète et les codes de sortie. + ## Service d’arrière-plan ### `ocx service [install|repair|restart|start|stop|status|uninstall|remove]` diff --git a/docs-site/src/content/docs/guides/remote-hub.md b/docs-site/src/content/docs/guides/remote-hub.md index 1f2db4486c..dce98d2a86 100644 --- a/docs-site/src/content/docs/guides/remote-hub.md +++ b/docs-site/src/content/docs/guides/remote-hub.md @@ -92,6 +92,10 @@ local login state, and `ocx config show` on a client prints a `_remoteHub` note credentials and model availability live on the hub. The hub read uses the per-client data key only; no admin token and no provider secret ever reaches a client. +`ocx status` makes a live hub-state request only when the saved connection still matches the +status snapshot and the data-token file matches that connection. If either check fails, it skips +the request and shows matching cached hub state, or `unavailable` if no matching cache exists. + ## Linux systemd or macOS launchd Bind the data listener to the hub's Tailscale address, enable the loopback companion so the hub's diff --git a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md index 39cec05483..6fd2dc6333 100644 --- a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md @@ -158,6 +158,20 @@ ocx status --json Codex のローカル モデル ピッカー キャッシュを無効にし、アクティブな opencodex カタログから再構築されるようにします。 `ocx sync` と同じ、古い `app-server` 警告とオプションの `--restart-codex` 動作が適用されます。 +### `ocx catalog pull [--auth-env ] [--json] [--restart-codex]` + +別の OpenCodex インスタンスの `/v1/catalog` エンドポイントが提供する完全なカタログをインストール +し、続いて `models_cache.json` を同期します。URL は HTTPS が必須で、HTTP はループバックのみ許可 +されます。URL 埋め込み資格情報、クエリ、フラグメント、リダイレクト、サイズ超過の応答、不正な +カタログは、ローカル書き込みの前に拒否されます。認証は任意で、環境変数参照 (`--auth-env`) から +のみ読み取られ、argv からは読み取られません。 + +カタログとキャッシュは共有の Codex カタログロックの下で書き込まれ、失敗時は last-known-good の +ファイルが保持されます。バイトが同一の場合は mtime を保持する no-op です。`--restart-codex` は +実際の書き込みの後にのみ適用されます。`ETag` 条件付きリクエストと Desktop アプリの再起動は、この +コマンドには含まれません。`--json` エンベロープと終了コードの詳細は +[英語版リファレンス](/reference/cli/lifecycle/)を参照してください。 + ## バックグラウンドサービス ### `ocx service [install|repair|restart|start|stop|status|uninstall|remove]` diff --git a/docs-site/src/content/docs/ko/guides/remote-hub.md b/docs-site/src/content/docs/ko/guides/remote-hub.md index 72e96ae263..f47ae658fe 100644 --- a/docs-site/src/content/docs/ko/guides/remote-hub.md +++ b/docs-site/src/content/docs/ko/guides/remote-hub.md @@ -36,6 +36,13 @@ ocx sync 허브가 발급한 클라이언트별 키는 권한이 제한된 `service-api-token` 파일에 저장됩니다. `config.json`에는 저장되지 않습니다. 연결 중 사용량은 허브 기록에서 해당 `apiKeyId`만 조회하고, 연결을 끊은 뒤에는 로컬 기록을 봅니다. 두 기록은 서로 복제되지 않습니다. +### 연결된 클라이언트의 상태 표시 + +연결된 클라이언트의 `ocx status`는 허브 상태를 표시합니다. 상태 수집 중 연결 정보가 +바뀌거나 데이터 토큰 파일이 현재 연결과 일치하지 않으면 실시간 허브 조회를 건너뜁니다. +이때 상태 조회 대상 연결의 캐시를 표시하고, 일치하는 캐시가 없으면 `unavailable`로 보고합니다. +`ocx status --json`의 `remoteHub.stateSource`는 `hub`, `cache`, `unavailable` 중 하나입니다. + ## systemd 또는 launchd 데이터 리스너는 허브의 Tailscale 주소에 바인드하고, 허브 자신의 프로세스가 같은 포트를 자격 증명 없이 쓸 수 있도록 루프백 companion을 켜고, 관리 평면은 따로 공개합니다. 아래 값은 예시입니다. diff --git a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md index f8b5dc6579..e5b3e8a69b 100644 --- a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md @@ -236,6 +236,18 @@ single-flight/lock 파일을 만들 수 있는지, 건강하지 않은 OAuth 또 Codex의 로컬 모델 선택기 캐시를 무효화하여, 활성 opencodex 카탈로그에서 다시 빌드되게 합니다. `ocx sync`와 같은 오래된 `app-server` 경고와 선택적 `--restart-codex` 동작이 적용됩니다. +### `ocx catalog pull [--auth-env ] [--json] [--restart-codex]` + +다른 OpenCodex 인스턴스의 `/v1/catalog` 엔드포인트가 제공하는 완성된 카탈로그를 설치한 뒤 +`models_cache.json`을 맞춥니다. URL은 HTTPS여야 하고 HTTP는 루프백만 허용합니다. URL에 박힌 +자격증명, 쿼리, 프래그먼트, 리다이렉트, 크기를 넘는 응답, 잘못된 카탈로그는 로컬에 쓰기 전에 +거절합니다. 인증은 선택이며 환경변수 이름(`--auth-env`)으로만 읽고 argv로는 받지 않습니다. + +카탈로그와 캐시는 공유 Codex 카탈로그 잠금 아래에서 쓰고, 실패하면 직전까지 정상이던 파일을 +그대로 둡니다. 바이트가 같으면 mtime까지 건드리지 않는 no-op입니다. `--restart-codex`는 실제로 +쓴 뒤에만 적용됩니다. `ETag` 조건부 요청과 Desktop 앱 재시작은 이 명령에 없습니다. `--json` +envelope 필드와 종료 코드는 [영문 레퍼런스](/reference/cli/lifecycle/)를 보세요. + ## 백그라운드 서비스 ### `ocx service [install|repair|restart|start|stop|status|uninstall|remove]` diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index ea1f0295e9..e49dc08a08 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -282,6 +282,60 @@ were updated. Pass `--restart-codex` to send `SIGTERM` only to matching `codex Invalidate Codex's local model picker cache so it is rebuilt from the active opencodex catalog. The same stale-`app-server` warning and optional `--restart-codex` behavior as `ocx sync` apply. +### `ocx catalog pull [--auth-env ] [--json] [--restart-codex]` + +Install a complete catalog served by another OpenCodex instance's `/v1/catalog` endpoint, then +synchronize `models_cache.json`. Unlike `ocx sync`, this command does not discover configured +providers or inject Codex configuration. Unlike `ocx sync-cache`, it replaces the active catalog +before rebuilding the cache. It works even when the local Codex integration desired state is off. + +The URL must be HTTPS; loopback HTTP is accepted for local testing. Embedded URL credentials, +queries, fragments, redirects, oversized responses, malformed JSON, duplicate or unsafe slugs, and +unknown `input_modalities` are refused before any local write. Authentication is optional and is +read only by environment-variable reference: + +```bash +export OPENCODEX_CATALOG_AUTH_TOKEN='...' +ocx catalog pull https://proxy.example.com/v1/catalog \ + --auth-env OPENCODEX_CATALOG_AUTH_TOKEN +``` + +The value is sent as a Bearer token but is never accepted as an argv value. Redirects are refused, +so authorization cannot cross origins. Catalog and cache writes use the shared Codex catalog lock +and atomic writer. A failed fetch, validation, lock acquisition, catalog write, or cache rebuild +preserves the last-known-good files. Identical catalog bytes are a no-op that preserves mtimes and +never touches processes. `--restart-codex` applies only after a real write and remains explicit; +Desktop restart is not part of this command. + +The URL must name `/v1/catalog` at the host root. A reverse proxy that serves the endpoint under a +path prefix is not supported by this command. + +Two behaviors are deliberately out of scope in this first cut. The command downloads the full +catalog and compares bytes locally instead of issuing an `ETag` / `If-None-Match` conditional +request, and it has no Windows `--restart-desktop-app`. Identical bytes are treated as a complete +no-op, so a home whose catalog is correct but whose `models_cache.json` is missing or stale is not +repaired by this command; use `ocx sync-cache` for that. + +`--json` emits one stable envelope on stdout. `schemaVersion`, `ok`, `status`, `catalogWritten`, +`cacheSynced`, and `codexRestarted` are always present. `status` is `updated`, `unchanged`, or +`failed`. A successful pull adds `modelCount`; a failure adds `code`, which is the field a script +branches on: + +| `code` | Meaning | Exit | +| --- | --- | --- | +| `usage` | The arguments were not a valid `catalog pull` invocation | 2 | +| `auth_env_missing` | `--auth-env` named a variable that is not set | 1 | +| `url_invalid`, `insecure_http_refused` | The URL was refused before any request | 1 | +| `request_failed`, `redirect_refused`, `http_error` | The request did not produce a usable response | 1 | +| `body_too_large`, `body_invalid`, `catalog_invalid` | The response was refused before any local write | 1 | +| `write_failed`, `lock_database`, `unsafe_path` | The coordinated write did not complete; files are unchanged | 1 | +| `lock_busy` | Another writer holds the Codex catalog lock | 3 | +| `restart_incomplete` | The catalog and cache landed, but a Codex app-server survived `--restart-codex` | 1 | + +`restart_incomplete` is the one failure that reports real writes: `catalogWritten` and +`cacheSynced` stay true and `ok` is false, because a surviving app-server still serves the +previous catalog from memory. + ## Background service ### `ocx service [install|repair|restart|start|stop|status|uninstall|remove]` diff --git a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md index cbe330bf71..374e16202d 100644 --- a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md @@ -232,6 +232,20 @@ credential'ы и не выполняет repair. opencodex. Предупреждение о stale-`app-server` и optional `--restart-codex` работают так же, как и у `ocx sync`. +### `ocx catalog pull [--auth-env ] [--json] [--restart-codex]` + +Устанавливает полный каталог, который отдаёт эндпоинт `/v1/catalog` другого экземпляра OpenCodex, +и затем синхронизирует `models_cache.json`. URL должен быть HTTPS; HTTP допускается только на +loopback. Учётные данные в URL, query, фрагменты, редиректы, слишком большие ответы и невалидные +каталоги отклоняются до любой локальной записи. Аутентификация необязательна и читается только по +имени переменной окружения (`--auth-env`), но не из argv. + +Каталог и кэш пишутся под общей блокировкой каталога Codex; при сбое сохраняются last-known-good +файлы. Идентичные байты — это no-op, сохраняющий mtime. `--restart-codex` применяется только после +реальной записи. Условные запросы `ETag` и перезапуск Desktop-приложения в эту команду не входят. +Полная `--json`-обёртка и коды выхода описаны в +[английской справке](/reference/cli/lifecycle/). + ## Фоновая служба ### `ocx service [install|repair|restart|start|stop|status|uninstall|remove]` diff --git a/docs-site/src/content/docs/tr/reference/cli/lifecycle.md b/docs-site/src/content/docs/tr/reference/cli/lifecycle.md index 33d13c543b..dd6568b5ea 100644 --- a/docs-site/src/content/docs/tr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/tr/reference/cli/lifecycle.md @@ -242,6 +242,21 @@ Codex'in yerel model seçici önbelleğini geçersiz kılın, böylece aktif ope kataloğundan yeniden oluşturulur. `ocx sync` ile aynı eski `app-server` uyarısı ve isteğe bağlı `--restart-codex` davranışı geçerlidir. +### `ocx catalog pull [--auth-env ] [--json] [--restart-codex]` + +Başka bir OpenCodex örneğinin `/v1/catalog` uç noktasının sunduğu eksiksiz kataloğu kurar ve +ardından `models_cache.json` dosyasını eşitler. URL HTTPS olmalıdır; HTTP yalnızca loopback için +kabul edilir. URL içine gömülü kimlik bilgileri, sorgular, parçalar, yönlendirmeler, boyutu aşan +yanıtlar ve geçersiz kataloglar, herhangi bir yerel yazma işleminden önce reddedilir. Kimlik +doğrulama isteğe bağlıdır ve yalnızca ortam değişkeni adıyla (`--auth-env`) okunur, argv'den +alınmaz. + +Katalog ve önbellek, paylaşılan Codex katalog kilidi altında yazılır; bir hata durumunda +last-known-good dosyalar korunur. Aynı baytlar, mtime değerlerini koruyan bir no-op'tur. +`--restart-codex` yalnızca gerçek bir yazmadan sonra uygulanır. `ETag` koşullu istekleri ve Desktop +uygulamasının yeniden başlatılması bu komutun kapsamında değildir. Tam `--json` zarfı ve çıkış +kodları için [İngilizce referansa](/reference/cli/lifecycle/) bakın. + ## Arka plan servisi ### `ocx service [install|repair|restart|start|stop|status|uninstall|remove]` diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md b/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md index 1b1d98e178..98ff929156 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md @@ -154,6 +154,12 @@ ocx status --json 使 Codex 的本地模型选择器缓存失效,让它根据当前激活的 opencodex 目录重新生成。与 `ocx sync` 相同的陈旧 `app-server` 警告和可选 `--restart-codex` 行为同样适用。 +### `ocx catalog pull [--auth-env ] [--json] [--restart-codex]` + +安装由另一个 OpenCodex 实例的 `/v1/catalog` 端点提供的完整目录,然后同步 `models_cache.json`。URL 必须是 HTTPS;仅回环地址允许 HTTP。URL 内嵌凭据、查询、片段、重定向、超出大小的响应以及无效目录,都会在任何本地写入之前被拒绝。认证是可选的,并且只通过环境变量名(`--auth-env`)读取,不接受 argv 传入。 + +目录和缓存在共享的 Codex 目录锁下写入;失败时保留 last-known-good 文件。字节完全相同时是保留 mtime 的空操作。`--restart-codex` 仅在发生真实写入之后生效。`ETag` 条件请求和 Desktop 应用重启不属于此命令。完整的 `--json` 信封与退出码请参见[英文参考](/reference/cli/lifecycle/)。 + ## 后台服务 ### `ocx service [install|repair|restart|start|stop|status|uninstall|remove]` diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md b/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md index 727de9bb54..015590cd3c 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md @@ -148,6 +148,12 @@ ocx status --json 使 Codex 的本機模型選擇器快取失效,使其從現用的 opencodex 目錄重建。與 `ocx sync` 相同的過時 `app-server` 警告與可選的 `--restart-codex` 行為適用。 +### `ocx catalog pull [--auth-env ] [--json] [--restart-codex]` + +安裝由另一個 OpenCodex 執行個體的 `/v1/catalog` 端點提供的完整目錄,接著同步 `models_cache.json`。URL 必須是 HTTPS;僅回送位址允許 HTTP。URL 內嵌憑證、查詢、片段、重新導向、超出大小的回應以及無效目錄,都會在任何本機寫入之前遭拒。驗證為選用,且只透過環境變數名稱(`--auth-env`)讀取,不接受 argv 傳入。 + +目錄與快取在共用的 Codex 目錄鎖之下寫入;失敗時保留 last-known-good 檔案。位元組完全相同時是保留 mtime 的無操作。`--restart-codex` 僅在實際寫入之後生效。`ETag` 條件式請求與 Desktop 應用程式重新啟動不屬於此命令。完整的 `--json` 信封與結束碼請參見[英文參考](/reference/cli/lifecycle/)。 + ## 背景服務 ### `ocx service [install|repair|restart|start|stop|status|uninstall|remove]` diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index ed73c48686..3b2bd12533 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -282,6 +282,7 @@ "catalog-input-modality-enum.test.ts": "codex-integration", "catalog-llamacpp-capabilities.test.ts": "codex-integration", "catalog-oauth-observation.test.ts": "codex-integration", + "catalog-remote-pull.test.ts": "codex-integration", "catalog-retain-models.test.ts": "codex-integration", "catalog-verbosity-default.test.ts": "codex-integration", "catalog-vision-sidecar-modalities.test.ts": "codex-integration", diff --git a/src/cli/catalog.ts b/src/cli/catalog.ts new file mode 100644 index 0000000000..e3d9aa3883 --- /dev/null +++ b/src/cli/catalog.ts @@ -0,0 +1,109 @@ +import { afterCatalogWriteHandleAppServers } from "../codex/app-server-processes"; +import { pullRemoteCatalog, RemoteCatalogError } from "../codex/catalog/remote"; +import { hasHelpFlag, printSubcommandUsage } from "./help"; + +export interface CatalogPullEnvelope { + schemaVersion: 1; + ok: boolean; + status: "updated" | "unchanged" | "failed"; + catalogWritten: boolean; + cacheSynced: boolean; + codexRestarted: boolean; + modelCount?: number; + code?: string; +} + +function optionValue(args: string[], name: string): string | undefined { + const index = args.indexOf(name); + return index >= 0 ? args[index + 1] : undefined; +} + +export async function handleCatalogCommand(args: string[]): Promise { + if (hasHelpFlag(args)) { printSubcommandUsage("catalog"); return 0; } + const json = args.includes("--json"); + const restartCodex = args.includes("--restart-codex"); + const authEnv = optionValue(args, "--auth-env"); + const positionals = args.filter((arg, index) => { + if (arg === "--auth-env") return false; + if (index > 0 && args[index - 1] === "--auth-env") return false; + return !arg.startsWith("-"); + }); + const knownFlags = new Set(["--json", "--restart-codex", "--auth-env"]); + const unknown = args.find((arg, index) => arg.startsWith("-") && !knownFlags.has(arg) && args[index - 1] !== "--auth-env"); + const validEnvName = authEnv === undefined || /^[A-Za-z_][A-Za-z0-9_]*$/.test(authEnv); + if (positionals[0] !== "pull" || positionals.length !== 2 || unknown || !validEnvName + || args.includes("--auth-env") !== (authEnv !== undefined)) { + const envelope: CatalogPullEnvelope = { + schemaVersion: 1, ok: false, status: "failed", catalogWritten: false, + cacheSynced: false, codexRestarted: false, code: "usage", + }; + if (json) console.log(JSON.stringify(envelope)); + else console.error("Usage: ocx catalog pull [--auth-env ] [--json] [--restart-codex]"); + return 2; + } + let token: string | undefined; + if (authEnv !== undefined) { + token = process.env[authEnv]; + if (token === undefined) { + const envelope: CatalogPullEnvelope = { + schemaVersion: 1, ok: false, status: "failed", catalogWritten: false, + cacheSynced: false, codexRestarted: false, code: "auth_env_missing", + }; + if (json) console.log(JSON.stringify(envelope)); + else console.error(`Catalog authentication environment variable ${authEnv} is not set.`); + return 1; + } + } + try { + const result = await pullRemoteCatalog(positionals[1]!, { token }); + let codexRestarted = false; + let restartIncomplete = false; + if (result.catalogWritten) { + const processLog = json + ? { log: (...values: unknown[]) => console.error(...values), error: (...values: unknown[]) => console.error(...values) } + : console; + const processResult = afterCatalogWriteHandleAppServers({ restart: restartCodex, log: processLog }); + const restart = processResult.restart; + if (restart) { + // A partial stop is not a restart. `restartCodexAppServers` reports failures and + // survivors without throwing, so counting `stopped` alone reported success while a + // stale app-server was still serving the previous catalog from memory. + codexRestarted = restart.failed.length === 0 + && restart.surviving.length === 0 + && restart.stopped.length === processResult.processes.length; + restartIncomplete = !codexRestarted; + } + } + if (restartIncomplete) { + // The catalog and cache landed; only the restart did not finish. Saying the pull failed + // and wrote nothing would be a second false report, so the envelope keeps the real + // write state and `ok` carries the failure. + const envelope: CatalogPullEnvelope = { + schemaVersion: 1, ok: false, status: result.status, + catalogWritten: result.catalogWritten, cacheSynced: result.cacheSynced, + codexRestarted: false, modelCount: result.modelCount, code: "restart_incomplete", + }; + if (json) console.log(JSON.stringify(envelope)); + else console.error("Remote Codex catalog installed, but a Codex app-server is still running the previous catalog."); + return 1; + } + const envelope: CatalogPullEnvelope = { + schemaVersion: 1, ok: true, status: result.status, + catalogWritten: result.catalogWritten, cacheSynced: result.cacheSynced, + codexRestarted, modelCount: result.modelCount, + }; + if (json) console.log(JSON.stringify(envelope)); + else if (result.status === "unchanged") console.log("Remote Codex catalog is unchanged; no files or processes were touched."); + else console.log(`Remote Codex catalog installed (${result.modelCount} models) and models_cache.json synchronized.`); + return 0; + } catch (error) { + const code = error instanceof RemoteCatalogError ? error.code : "write_failed"; + const envelope: CatalogPullEnvelope = { + schemaVersion: 1, ok: false, status: "failed", catalogWritten: false, + cacheSynced: false, codexRestarted: false, code, + }; + if (json) console.log(JSON.stringify(envelope)); + else console.error(error instanceof RemoteCatalogError ? error.message : "Remote catalog installation failed"); + return code === "lock_busy" ? 3 : 1; + } +} diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 203d2d63d5..7ec21f9499 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -490,6 +490,10 @@ const commandRunners: Record = { const { handleDisconnectCommand } = await import("./connect"); return await handleDisconnectCommand(deps.args.slice(1)); }, + catalog: async deps => { + const { handleCatalogCommand } = await import("./catalog"); + return await handleCatalogCommand(deps.args.slice(1)); + }, "sync-cache": async deps => { const cacheArgs = deps.args.slice(1); const restartCodex = cacheArgs.includes("--restart-codex"); diff --git a/src/cli/help.ts b/src/cli/help.ts index 15f6ed711a..6765d27fcf 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -46,6 +46,7 @@ Usage: ocx sync [--restart-codex] Fetch models from providers and inject into Codex config ocx sync-cache [--restart-codex] Refresh Codex's model cache from the active catalog + ocx catalog pull Install a validated remote catalog and refresh the Codex cache ocx status Check proxy server status (on a hub: one block with its ports and token source) ocx doctor Diagnose environment/network issues (WSL, proxy, ChatGPT reachability) ocx doctor --reclaim-response-temps diff --git a/src/cli/registry.ts b/src/cli/registry.ts index 1883bb27f2..6a8a98478d 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -143,6 +143,16 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ "--restart-desktop-app (Windows only, opt-in) fully restarts the Codex desktop app so its model picker re-reads the catalog. Never implied by --restart-codex: it ends live conversations.", ], }, + { + name: "catalog", + usage: "ocx catalog pull [--auth-env ] [--json] [--restart-codex]", + summary: "Install a validated remote /v1/catalog snapshot into Codex.", + details: [ + "Authentication is read only from the named environment variable and sent as a Bearer token.", + "HTTPS is required except for loopback HTTP; redirects are refused.", + "The catalog and models_cache.json are coordinated under the Codex catalog write lock.", + ], + }, { name: "status", usage: "ocx status", summary: "Check proxy server status." }, { name: "doctor", diff --git a/src/cli/status.ts b/src/cli/status.ts index cc1f4506bf..e120b5de70 100644 --- a/src/cli/status.ts +++ b/src/cli/status.ts @@ -18,6 +18,7 @@ import { effectiveLoopbackListenerPort } from "../codex/loopback-target"; import { claudeDesktopIntegrationEnabled } from "../codex/desired-state"; import { claudeDesktopPolicyHealth, probeClaudeDesktopPolicy, type ClaudeDesktopPolicyHealth } from "../claude/desktop-policy"; import { collectClientConnectionStatus, type ClientConnectionStatus } from "./connect"; +import { readClientConnectionState, sameClientConnectionOwner } from "../client/state"; import type { HubStateOAuthEntry, HubStateProvider } from "../remote/hub-state"; import type { HubStateSource } from "../client/hub-state"; import { readServiceApiTokenState, serviceApiTokenFilePath } from "../lib/service-secrets"; @@ -324,6 +325,35 @@ export function disconnectedRemoteHubStatus(): CliRemoteHubStatus { }; } +/** + * The data key this status snapshot may spend, and the cause when it may not. + * + * A reconnect or a rotation can replace both files between the snapshot and this read, so a + * matching cache owner alone does not authorize sending the current token. Withholding is only + * half the job: reporting every withheld case as "no usable data-plane token" is false for a + * client that reconnected and holds a perfectly good token for a different hub, and a cause the + * operator acts on has to be the real one (#4169 is the same defect in the stop path). + */ +function boundHubStateToken( + current: ReturnType, + token: ReturnType, + owner: { serverUrl: string; apiKeyId: string; connectedAt: string }, +): { token: string | null; withheldReason?: string } { + if (current.kind !== "connected") { + return { token: null, withheldReason: "this client is no longer connected" }; + } + if (!sameClientConnectionOwner(current.value, owner)) { + return { token: null, withheldReason: "the saved connection no longer matches the one this status reports" }; + } + if (token.kind !== "present") { + return { token: null, withheldReason: "this client has no usable data-plane token" }; + } + if (token.fingerprint !== current.value.tokenFingerprint) { + return { token: null, withheldReason: "the data-plane token no longer belongs to the saved connection" }; + } + return { token: token.token }; +} + /** * Ask the hub what it can serve, with a bounded read and a cache fallback. * @@ -340,14 +370,16 @@ export async function collectRemoteHubStatus( return disconnectedRemoteHubStatus(); } const { resolveHubState } = await import("../client/hub-state"); - const token = readServiceApiTokenState(); + const owner = { + serverUrl: connection.serverUrl, + apiKeyId: connection.apiKeyId, + connectedAt: connection.connectedAt, + }; + const bound = boundHubStateToken(readClientConnectionState(), readServiceApiTokenState(), owner); const resolved = await resolveHubState({ - owner: { - serverUrl: connection.serverUrl, - apiKeyId: connection.apiKeyId, - connectedAt: connection.connectedAt, - }, - token: token.kind === "present" ? token.token : null, + owner, + token: bound.token, + ...(bound.withheldReason ? { withheldTokenReason: bound.withheldReason } : {}), ...(options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}), ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }), ...(options.now === undefined ? {} : { now: options.now }), diff --git a/src/client/hub-state.ts b/src/client/hub-state.ts index 655bcbdd9c..8b0916b514 100644 --- a/src/client/hub-state.ts +++ b/src/client/hub-state.ts @@ -161,6 +161,12 @@ export interface ResolveHubStateOptions { owner: HubStateOwner; /** The per-client data key. Null when the token file is missing or unsafe. */ token: string | null; + /** + * Why the caller withheld the token, when it withheld one it holds. A null token reads the + * same here whether the file is missing or the caller declined to send a perfectly good + * token for a different connection, and the operator acts on that difference. + */ + withheldTokenReason?: string; timeoutMs?: number; fetchImpl?: typeof fetch; now?: number; @@ -195,7 +201,9 @@ export async function resolveHubState(options: ResolveHubStateOptions): Promise< ? withAge("cache", cached.state, cached.fetchedAt, now, reason) : withAge("unavailable", null, undefined, now, reason); }; - if (!options.token) return fromCache("this client has no usable data-plane token"); + if (!options.token) { + return fromCache(options.withheldTokenReason ?? "this client has no usable data-plane token"); + } if (options.allowNetwork === false) return fromCache("a live hub read was not attempted"); let state: HubStateDTO; try { diff --git a/src/codex/catalog/remote.ts b/src/codex/catalog/remote.ts new file mode 100644 index 0000000000..46576b18e4 --- /dev/null +++ b/src/codex/catalog/remote.ts @@ -0,0 +1,233 @@ +import { existsSync, readFileSync, rmSync } from "node:fs"; + +import { MAX_REMOTE_CATALOG_BYTES } from "../../server/catalog-download"; +import { readBoundedResponseBytes } from "../../lib/bounded-body"; +import { withCatalogWriteSerialization, type CatalogSerializationOutcome, type CatalogWritePermit } from "../catalog-write-serialization"; +import { replaceActiveCodexCatalog } from "../internal/catalog-writer"; +import { resetCodexAppServerCatalogStateCache } from "../app-server-processes"; +import { getCodexHome } from "../paths"; +import { readCodexCatalogPathForHome } from "./parsing"; +import { invalidateCodexModelsCacheWithPermit } from "./sync"; + +const DEFAULT_TIMEOUT_MS = 15_000; +const MAX_MODELS = 2_000; +const MAX_SLUG_BYTES = 512; +const ALLOWED_MODALITIES = new Set(["text", "image", "audio"]); + +export type RemoteCatalogFailureCode = + | "url_invalid" | "insecure_http_refused" | "credential_invalid" | "request_failed" + | "redirect_refused" | "http_error" | "body_too_large" | "body_invalid" + | "catalog_invalid" | "write_failed" | "lock_busy" | "lock_database" | "unsafe_path"; + +export class RemoteCatalogError extends Error { + constructor(readonly code: RemoteCatalogFailureCode, message: string, readonly status?: number) { + super(message); + this.name = "RemoteCatalogError"; + } +} + +export interface RemoteCatalogDocument extends Record { + models: Record[]; +} + +export interface PullRemoteCatalogOptions { + token?: string; + timeoutMs?: number; + maxBytes?: number; + fetchImpl?: typeof fetch; + codexHome?: string; +} + +export interface PullRemoteCatalogResult { + status: "updated" | "unchanged"; + catalogWritten: boolean; + cacheSynced: boolean; + codexHome: string; + catalogPath: string; + modelCount: number; +} + +function isLoopback(hostname: string): boolean { + const host = hostname.toLowerCase(); + return host === "localhost" || host === "127.0.0.1" || host === "[::1]" || host === "::1"; +} + +export function validateRemoteCatalogUrl(input: string): URL { + let url: URL; + try { url = new URL(input); } catch { throw new RemoteCatalogError("url_invalid", "Catalog URL must be an absolute HTTPS URL"); } + if (url.username || url.password) throw new RemoteCatalogError("url_invalid", "Catalog URL must not contain credentials"); + if (url.protocol !== "https:" && !(url.protocol === "http:" && isLoopback(url.hostname))) { + throw new RemoteCatalogError("insecure_http_refused", "Catalog URL requires HTTPS (HTTP is allowed only on loopback)"); + } + if (url.pathname !== "/v1/catalog" || url.search || url.hash) { + throw new RemoteCatalogError("url_invalid", "Catalog URL must identify /v1/catalog without query or fragment"); + } + return url; +} + +function validateToken(token: string | undefined): string | undefined { + if (token === undefined) return undefined; + if (!token || token.length > 4096 || /[\r\n\0]/.test(token)) { + throw new RemoteCatalogError("credential_invalid", "Catalog authentication environment variable is invalid"); + } + return token; +} + +export function validateRemoteCatalogDocument(value: unknown): RemoteCatalogDocument { + const invalid = (message: string): never => { throw new RemoteCatalogError("catalog_invalid", message); }; + if (!value || typeof value !== "object" || Array.isArray(value)) invalid("Remote catalog must be a JSON object"); + const document = value as Record; + const rawModels = document.models; + if (!Array.isArray(rawModels) || rawModels.length === 0 || rawModels.length > MAX_MODELS) { + invalid("Remote catalog models must be a non-empty bounded array"); + } + const models = rawModels as unknown[]; + const slugs = new Set(); + for (const row of models) { + if (!row || typeof row !== "object" || Array.isArray(row) || Object.getPrototypeOf(row) !== Object.prototype) { + invalid("Remote catalog model rows must be plain objects"); + } + const model = row as Record; + const rawSlug = model.slug; + if (typeof rawSlug !== "string") invalid("Remote catalog contains an invalid model slug"); + const slug = rawSlug as string; + if (slug !== slug.trim() || !slug + || new TextEncoder().encode(slug).byteLength > MAX_SLUG_BYTES || /[\x00-\x1f\x7f]/.test(slug)) { + invalid("Remote catalog contains an invalid model slug"); + } + if (slugs.has(slug)) invalid("Remote catalog contains duplicate model slugs"); + slugs.add(slug); + if (Object.hasOwn(model, "input_modalities")) { + const modalities = model.input_modalities; + if (!Array.isArray(modalities) || modalities.length === 0 + || modalities.some(item => typeof item !== "string" || !ALLOWED_MODALITIES.has(item))) { + invalid("Remote catalog contains unsupported input modalities"); + } + } + } + return document as RemoteCatalogDocument; +} + +function safeTimeout(value: number | undefined): number { + return typeof value === "number" && Number.isFinite(value) && value > 0 + ? Math.min(Math.floor(value), 120_000) : DEFAULT_TIMEOUT_MS; +} + +export async function fetchRemoteCatalog( + input: string, + options: Pick = {}, +): Promise<{ document: RemoteCatalogDocument; content: string }> { + const url = validateRemoteCatalogUrl(input); + const token = validateToken(options.token); + const headers = new Headers({ Accept: "application/json" }); + if (token !== undefined) headers.set("Authorization", `Bearer ${token}`); + let response: Response; + try { + response = await (options.fetchImpl ?? fetch)(url, { + method: "GET", headers, redirect: "manual", signal: AbortSignal.timeout(safeTimeout(options.timeoutMs)), + }); + } catch { + throw new RemoteCatalogError("request_failed", "Remote catalog request did not complete"); + } + if (response.status >= 300 && response.status < 400) { + try { await response.body?.cancel(); } catch { /* best effort */ } + throw new RemoteCatalogError("redirect_refused", "Remote catalog redirect was refused", response.status); + } + if (!response.ok) { + try { await response.body?.cancel(); } catch { /* best effort */ } + throw new RemoteCatalogError("http_error", `Remote catalog request failed with HTTP ${response.status}`, response.status); + } + const contentType = response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase(); + if (contentType !== "application/json" && contentType?.endsWith("+json") !== true) { + try { await response.body?.cancel(); } catch { /* best effort */ } + throw new RemoteCatalogError("body_invalid", "Remote catalog response was not JSON"); + } + const maxBytes = options.maxBytes ?? MAX_REMOTE_CATALOG_BYTES; + const declaredRaw = response.headers.get("content-length"); + if (declaredRaw !== null) { + const declared = Number(declaredRaw); + if (!Number.isSafeInteger(declared) || declared < 0 || declared > maxBytes) { + try { await response.body?.cancel(); } catch { /* best effort */ } + throw new RemoteCatalogError("body_too_large", "Remote catalog exceeded the allowed size"); + } + } + let bytes: Uint8Array; + try { + const bounded = await readBoundedResponseBytes(response, { maxBytes, inactivityTimeoutMs: safeTimeout(options.timeoutMs) }); + if (bounded.oversized) throw new RemoteCatalogError("body_too_large", "Remote catalog exceeded the allowed size"); + bytes = bounded.bytes; + } catch (error) { + if (error instanceof RemoteCatalogError) throw error; + throw new RemoteCatalogError("request_failed", "Remote catalog download did not complete"); + } + let text: string; + try { text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); } + catch { throw new RemoteCatalogError("body_invalid", "Remote catalog was not valid UTF-8"); } + let parsed: unknown; + try { parsed = JSON.parse(text); } + catch { throw new RemoteCatalogError("body_invalid", "Remote catalog was not valid JSON"); } + const document = validateRemoteCatalogDocument(parsed); + return { document, content: `${JSON.stringify(document, null, 2)}\n` }; +} + +function mapSerializationFailure(outcome: CatalogSerializationOutcome): never { + if (outcome.kind === "completed") throw new RemoteCatalogError("write_failed", "Remote catalog installation failed"); + const code = outcome.reason === "busy" ? "lock_busy" : outcome.reason === "database" ? "lock_database" : "unsafe_path"; + throw new RemoteCatalogError(code, `Remote catalog installation unavailable (${outcome.reason})`); +} + +/** + * Put the catalog file back the way this pull found it. + * + * `replaceActiveCodexCatalog` is an atomic FILE write; the serialization permit rolls back the + * SQLite transaction and nothing on disk. Without this, a cache rebuild that fails after the + * catalog was replaced leaves a new catalog paired with a stale `models_cache.json` — the exact + * split the last-known-good guarantee exists to prevent — while the caller is told the pull + * failed and wrote nothing. + */ +function restorePreviousCatalog( + permit: CatalogWritePermit, + codexHome: string, + catalogPath: string, + previous: Buffer | null, +): void { + if (previous) { + replaceActiveCodexCatalog(permit, codexHome, { path: catalogPath, content: previous.toString("utf8") }); + return; + } + // There was no catalog before this pull, so last-known-good is its absence. + rmSync(catalogPath, { force: true }); + resetCodexAppServerCatalogStateCache(); +} + +export async function pullRemoteCatalog(input: string, options: PullRemoteCatalogOptions = {}): Promise { + // Network acquisition and fail-closed validation intentionally happen before K. + const fetched = await fetchRemoteCatalog(input, options); + const codexHome = options.codexHome ?? getCodexHome(); + const catalogPath = readCodexCatalogPathForHome(codexHome); + const current = existsSync(catalogPath) ? readFileSync(catalogPath) : null; + const candidate = Buffer.from(fetched.content, "utf8"); + if (current?.equals(candidate)) { + return { status: "unchanged", catalogWritten: false, cacheSynced: false, codexHome, catalogPath, modelCount: fetched.document.models.length }; + } + const outcome = withCatalogWriteSerialization(codexHome, permit => { + // Re-check under K: another writer may have installed these bytes while the request was in flight. + const lockedCurrent = existsSync(catalogPath) ? readFileSync(catalogPath) : null; + if (lockedCurrent?.equals(candidate)) return { catalogWritten: false, cacheSynced: false }; + replaceActiveCodexCatalog(permit, codexHome, { path: catalogPath, content: fetched.content }); + const cacheSynced = invalidateCodexModelsCacheWithPermit(permit, codexHome, { allowWhenDesiredDisabled: true }); + if (!cacheSynced) { + restorePreviousCatalog(permit, codexHome, catalogPath, lockedCurrent); + throw new RemoteCatalogError("write_failed", "Remote catalog cache synchronization failed"); + } + return { catalogWritten: true, cacheSynced: true }; + }); + if (outcome.kind !== "completed") return mapSerializationFailure(outcome); + return { + status: outcome.value.catalogWritten ? "updated" : "unchanged", + ...outcome.value, + codexHome, + catalogPath, + modelCount: fetched.document.models.length, + }; +} diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index 1412a498d1..c557b4ab2e 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -69,6 +69,9 @@ or profile-upload API. Thinking replay and prompt caching remain separate in #37 The shared Responses path follows the [bounded multipart recovery contract](../subagents.md#multipart-encrypted-task-recovery); credential admission and retry policy remain unchanged. +Connected `ocx status` diagnostics follow the shared +[status credential binding](../runtime.md#remote-hub-status-credential-binding). + ## Claude Desktop config-library resolution The Desktop profile writer and the management status probe share diff --git a/structure/config.md b/structure/config.md index 96f63e2a2e..1e30795cbe 100644 --- a/structure/config.md +++ b/structure/config.md @@ -209,6 +209,9 @@ the residual directory for manual review; there is no recursive-delete fallback. ## Remote client key files +The connection's `tokenFingerprint` participates in +[`ocx status` credential binding](runtime.md#remote-hub-status-credential-binding). + Client connection metadata stores a stable `apiKeyId` and a non-secret rotation `pendingOperation`. The current data secret remains only in `service-api-token`; a bounded rotation temporarily keeps the old secret in owner-only `service-api-token.prev`. Commit or recovery clears the marker before orphan cleanup. `ocx disconnect` is local-only and leaves remote revocation to the hub's **Integrations → API Keys** page. Hub and local usage stores are not mirrored. Codex display-cache expiry, retained main-policy evidence, and reset history follow the diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index 0aa5928944..c5f0aad889 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -308,6 +308,10 @@ The shared Responses path follows the [bounded multipart recovery contract](../s ## Remote Hub locale and release gate +The Remote Hub guide describes the +[status credential binding](../runtime.md#remote-hub-status-credential-binding) +and its matching-cache or `unavailable` result. + The Remote Hub guide and affected CLI, server-config, management-API, and dashboard references have eight sources: root English plus `fr`, `ko`, `zh-cn`, `zh-tw`, `ru`, `ja`, and `tr`. English is canonical; commands, defaults, endpoint auth, and warnings remain exact in translations. A release requires the remote-only focused/full gates, privacy scan, GUI/docs builds, protocol compatibility receipts, and the MAINTAINERS security review for the exact head. Codex display-cache expiry, retained main-policy evidence, and reset history follow the diff --git a/structure/runtime.md b/structure/runtime.md index 35b1e1f31a..48c3968db4 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -214,6 +214,17 @@ The shared Responses path follows the [bounded multipart recovery contract](suba `src/remote/protocol.ts` owns pure interval/feature negotiation. `src/remote/hub-state.ts` owns the `GET|HEAD /v1/hub-state` contract, its caps, and the parser both sides share. `src/client/hub-client.ts` owns bounded, schema-validated remote catalog consumption, hub-state reads, and key-id probes; `src/client/hub-state.ts` owns the resolution and the owner-stamped 0600 cache, and a failed read reports "unavailable" rather than degrading to the client's own local provider and login state. `src/client/hub-relay.ts` is a fixed-authority management relay with URL, header, body, redirect, and stream bounds. The public data listener remains the direct client→hub path; the loopback management ingress never serves data-plane routes. +### Remote Hub status credential binding + +`src/cli/status.ts` rereads the persisted client connection and `service-api-token` state before +requesting hub state. It passes a usable token only when the current connection matches the +status snapshot's `serverUrl`, `apiKeyId`, and `connectedAt`, and the token fingerprint matches +the current connection's `tokenFingerprint`. Otherwise it skips the live request and uses the +snapshot owner's matching cached hub state, or reports `unavailable`. +A withheld token carries its own cause into the reported `reason` through +`resolveHubState`'s `withheldTokenReason`, so a changed connection, a missing token file, and a +fingerprint mismatch are named separately rather than all reported as a missing data key. + Codex display-cache expiry, retained main-policy evidence, and reset history follow the [quota cache contract](providers/openai-tiers.md#quota-cache-and-short-window-history). diff --git a/tests/cli/cli-status-hub-state.test.ts b/tests/cli/cli-status-hub-state.test.ts index e396f023e1..d7f808b23b 100644 --- a/tests/cli/cli-status-hub-state.test.ts +++ b/tests/cli/cli-status-hub-state.test.ts @@ -27,6 +27,7 @@ import { type CliRemoteHubStatus, } from "../../src/cli/status"; import type { HubStateDTO } from "../../src/remote/hub-state"; +import { writeCachedHubState } from "../../src/client/hub-state"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { SPAWN_BUDGET_MS } from "../helpers/test-budget"; @@ -88,6 +89,27 @@ function jsonFetch(body: unknown): typeof fetch { })) as unknown as typeof fetch; } +function observedHubFetch() { + const requests: { url: string; token: string | null }[] = []; + const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => { + requests.push({ + url: String(input), + token: new Headers(init?.headers).get("x-opencodex-api-key"), + }); + return Response.json(hubState()); + }) as typeof fetch; + return { requests, fetchImpl }; +} + +function connectionSnapshot() { + return { + state: "connected" as const, + serverUrl: "https://hub.example.test:8443", + apiKeyId: "status-hub-state", + connectedAt: "2026-09-06T00:00:00.000Z", + }; +} + beforeEach(() => { testHome = mkdtempSync(join(tmpdir(), "ocx-status-hub-")); process.env.OPENCODEX_HOME = testHome; @@ -103,10 +125,15 @@ afterEach(() => { describe("collectRemoteHubStatus", () => { test("a connected client with a live hub reports the hub's facts", async () => { writeConnectedHome(testHome, "https://hub.example.test:8443"); + const probe = observedHubFetch(); const remoteHub = await collectRemoteHubStatus( { state: "connected", serverUrl: "https://hub.example.test:8443", apiKeyId: "status-hub-state", connectedAt: "2026-09-06T00:00:00.000Z" }, - { fetchImpl: jsonFetch(hubState()) }, + { fetchImpl: probe.fetchImpl }, ); + expect(probe.requests).toEqual([{ + url: "https://hub.example.test:8443/v1/hub-state", + token: FIXTURE_TOKEN, + }]); expect(remoteHub.connected).toBe(true); expect(remoteHub.stateSource).toBe("hub"); expect(remoteHub.hubVersion).toBe("2.51.0"); @@ -115,6 +142,78 @@ describe("collectRemoteHubStatus", () => { expect(remoteHub.claudeCodeEnabled).toBe(true); }); + test.each([ + ["origin", { serverUrl: "https://other-hub.example.test" }], + ["key", { apiKeyId: "another-client-key" }], + ["enrollment", { connectedAt: "2026-09-07T00:00:00.000Z" }], + ])("a changed %s never sends a credential for the old snapshot", async (_label, changed) => { + const snapshot = connectionSnapshot(); + const config = connectedConfig(snapshot.serverUrl); + Object.assign(config.client, changed); + writeFileSync(join(testHome, "config.json"), JSON.stringify(config)); + writeFileSync(join(testHome, "service-api-token"), FIXTURE_TOKEN, { mode: 0o600 }); + const probe = observedHubFetch(); + + const remoteHub = await collectRemoteHubStatus(snapshot, { fetchImpl: probe.fetchImpl }); + + expect(probe.requests).toEqual([]); + expect(remoteHub.stateSource).toBe("unavailable"); + expect(remoteHub.providers).toEqual([]); + // The token file is intact and usable — it simply belongs to a different connection now. + // Reporting "no usable data-plane token" here would send the operator to reconnect a + // credential that is not the problem. + expect(remoteHub.reason).toContain("no longer matches"); + expect(remoteHub.reason).not.toContain("no usable data-plane token"); + }); + + test("a persistent token fingerprint mismatch never sends the changed token", async () => { + const snapshot = connectionSnapshot(); + writeConnectedHome(testHome, snapshot.serverUrl); + writeFileSync(join(testHome, "service-api-token"), "unowned-status-token", { mode: 0o600 }); + const probe = observedHubFetch(); + + const remoteHub = await collectRemoteHubStatus(snapshot, { fetchImpl: probe.fetchImpl }); + + expect(probe.requests).toEqual([]); + expect(remoteHub.stateSource).toBe("unavailable"); + expect(remoteHub.reason).toContain("data-plane token"); + }); + + test("a token rotation during the asynchronous boundary cannot reach the old hub", async () => { + const snapshot = connectionSnapshot(); + writeConnectedHome(testHome, snapshot.serverUrl); + const probe = observedHubFetch(); + const pending = collectRemoteHubStatus(snapshot, { fetchImpl: probe.fetchImpl }); + // The collector has yielded before reading credentials. Rotate the actual files before + // its continuation runs, including a fingerprint that legitimately owns the NEW token. + const config = connectedConfig("https://other-hub.example.test"); + const rotatedToken = "rotated-status-token"; + config.client.apiKeyId = "rotated-client-key"; + config.client.tokenFingerprint = createHash("sha256").update(rotatedToken).digest("hex"); + writeFileSync(join(testHome, "config.json"), JSON.stringify(config)); + writeFileSync(join(testHome, "service-api-token"), rotatedToken, { mode: 0o600 }); + + const remoteHub = await pending; + + expect(probe.requests).toEqual([]); + expect(remoteHub.stateSource).toBe("unavailable"); + }); + + test.each([true, false])("a mismatched connection uses only the snapshot's cache (matching=%s)", async matching => { + const snapshot = connectionSnapshot(); + const current = connectedConfig("https://other-hub.example.test"); + writeFileSync(join(testHome, "config.json"), JSON.stringify(current)); + writeFileSync(join(testHome, "service-api-token"), FIXTURE_TOKEN, { mode: 0o600 }); + expect(writeCachedHubState(matching ? snapshot : current.client, hubState(), "2026-09-06T00:00:00.000Z")).toBe(true); + const probe = observedHubFetch(); + + const remoteHub = await collectRemoteHubStatus(snapshot, { fetchImpl: probe.fetchImpl }); + + expect(probe.requests).toEqual([]); + expect(remoteHub.stateSource).toBe(matching ? "cache" : "unavailable"); + expect(remoteHub.providers).toEqual(matching ? hubState().providers : []); + }); + test("a client whose token file is missing is unavailable, not locally sourced", async () => { writeFileSync(join(testHome, "config.json"), JSON.stringify(connectedConfig("https://hub.example.test:8443"))); const remoteHub = await collectRemoteHubStatus( diff --git a/tests/codex-integration/catalog-remote-pull.test.ts b/tests/codex-integration/catalog-remote-pull.test.ts new file mode 100644 index 0000000000..02a6768f22 --- /dev/null +++ b/tests/codex-integration/catalog-remote-pull.test.ts @@ -0,0 +1,242 @@ +import { afterEach, describe, expect, mock, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Database } from "bun:sqlite"; + +import { + fetchRemoteCatalog, + pullRemoteCatalog, + RemoteCatalogError, + validateRemoteCatalogDocument, + validateRemoteCatalogUrl, +} from "../../src/codex/catalog/remote"; +import { resolveCodexCatalogSerializationDatabasePath, resolveEffectiveUserIdentity } from "../../src/codex/user-identity"; +import { withCatalogWriteSerialization } from "../../src/codex/catalog-write-serialization"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const homes: string[] = []; +afterEach(() => { while (homes.length) removeTreeWithRetry(homes.pop()!); }); + +function home(): string { + const value = mkdtempSync(join(tmpdir(), "ocx-catalog-pull-")); + homes.push(value); + return value; +} + +const catalog = { version: 1, models: [{ slug: "provider/model", input_modalities: ["text", "image"], extension: { safe: true } }] }; +const response = (value: unknown, init: ResponseInit = {}) => new Response(JSON.stringify(value), { + headers: { "Content-Type": "application/json", ...init.headers }, status: init.status, +}); + +describe("remote catalog acquisition", () => { + test("accepts HTTPS and loopback HTTP but rejects credentials and insecure remote HTTP", () => { + expect(validateRemoteCatalogUrl("https://hub.example.com/v1/catalog").href).toBe("https://hub.example.com/v1/catalog"); + expect(validateRemoteCatalogUrl("http://127.0.0.1:10100/v1/catalog").protocol).toBe("http:"); + expect(() => validateRemoteCatalogUrl("http://hub.example.com/v1/catalog")).toThrow(RemoteCatalogError); + expect(() => validateRemoteCatalogUrl("https://user:secret@example.com/v1/catalog")).toThrow(RemoteCatalogError); + expect(() => validateRemoteCatalogUrl("https://hub.example.com/v1/catalog?q=secret")).toThrow(RemoteCatalogError); + }); + + test("sends optional bearer authentication from the caller without following redirects", async () => { + const fetchImpl = mock(async (_input: RequestInfo | URL, init?: RequestInit) => { + expect(new Headers(init?.headers).get("authorization")).toBe("Bearer env-token"); + expect(init?.redirect).toBe("manual"); + return response(catalog); + }) as typeof fetch; + const fetched = await fetchRemoteCatalog("https://hub.example/v1/catalog", { token: "env-token", fetchImpl }); + expect(fetched.document).toEqual(catalog); + expect(fetchImpl).toHaveBeenCalledTimes(1); + + await expect(fetchRemoteCatalog("https://hub.example/v1/catalog", { + token: "secret-marker", fetchImpl: async () => new Response("body-marker", { status: 302, headers: { Location: "https://other.example/secret" } }), + })).rejects.toMatchObject({ code: "redirect_refused", message: "Remote catalog redirect was refused" }); + }); + + test("never reflects credentials, remote bodies, URLs, or transport causes", async () => { + for (const fetchImpl of [ + async () => new Response("remote-body-marker", { status: 401 }), + async () => { throw new Error("secret-marker https://private.example/path"); }, + ]) { + let caught: unknown; + try { await fetchRemoteCatalog("https://hub.example/v1/catalog", { token: "secret-marker", fetchImpl: fetchImpl as typeof fetch }); } + catch (error) { caught = error; } + expect(String(caught)).not.toContain("secret-marker"); + expect(String(caught)).not.toContain("remote-body-marker"); + expect(String(caught)).not.toContain("private.example"); + expect((caught as Error).cause).toBeUndefined(); + } + }); + + test.each([401, 403, 404, 500])("rejects HTTP %s", async status => { + await expect(fetchRemoteCatalog("https://hub.example/v1/catalog", { + fetchImpl: async () => new Response(null, { status }), + })).rejects.toMatchObject({ code: "http_error", status }); + }); + + test("enforces declared and streamed byte limits", async () => { + await expect(fetchRemoteCatalog("https://hub.example/v1/catalog", { + maxBytes: 10, fetchImpl: async () => new Response("{}", { headers: { "Content-Type": "application/json", "Content-Length": "11" } }), + })).rejects.toMatchObject({ code: "body_too_large" }); + const stream = new ReadableStream({ start(controller) { + controller.enqueue(new TextEncoder().encode('{"models":[')); + controller.enqueue(new Uint8Array(128)); controller.close(); + } }); + await expect(fetchRemoteCatalog("https://hub.example/v1/catalog", { + maxBytes: 32, fetchImpl: async () => new Response(stream, { headers: { "Content-Type": "application/json", "Content-Length": "1" } }), + })).rejects.toMatchObject({ code: "body_too_large" }); + }); + + test("bounds headers and stalled streams", async () => { + await expect(fetchRemoteCatalog("https://hub.example/v1/catalog", { + timeoutMs: 10, + fetchImpl: async (_input, init) => await new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => reject(new Error("secret timeout cause")), { once: true }); + }), + })).rejects.toMatchObject({ code: "request_failed" }); + }); +}); + +describe("remote catalog validation", () => { + test.each([ + [null], [[]], [{}], [{ models: [] }], [{ models: [null] }], [{ models: [[]] }], + [{ models: [{}] }], [{ models: [{ slug: "" }] }], [{ models: [{ slug: " padded " }] }], + [{ models: [{ slug: "bad\u0000slug" }] }], [{ models: [{ slug: "a" }, { slug: "a" }] }], + [{ models: [{ slug: "a", input_modalities: [] }] }], + [{ models: [{ slug: "a", input_modalities: ["video"] }] }], + ])("rejects invalid document %#", value => { + expect(() => validateRemoteCatalogDocument(value)).toThrow(RemoteCatalogError); + }); + + test("preserves safe additive fields", () => { + expect(validateRemoteCatalogDocument(catalog)).toEqual(catalog); + }); +}); + +describe("remote catalog coordinated installation", () => { + test("updates catalog and cache under the shared writer even when desired integration is disabled", async () => { + const codexHome = home(); + const opencodexHome = home(); + const previous = process.env.OPENCODEX_HOME; + process.env.OPENCODEX_HOME = opencodexHome; + writeFileSync(join(opencodexHome, "config.json"), JSON.stringify({ providers: {}, defaultProvider: "openai", desiredIntegrations: { codex: false } })); + try { + const result = await pullRemoteCatalog("https://hub.example/v1/catalog", { + codexHome, fetchImpl: async () => response(catalog), + }); + expect(result).toMatchObject({ status: "updated", catalogWritten: true, cacheSynced: true, modelCount: 1 }); + expect(JSON.parse(readFileSync(result.catalogPath, "utf8"))).toEqual(catalog); + expect(JSON.parse(readFileSync(join(codexHome, "models_cache.json"), "utf8")).models).toEqual(catalog.models); + } finally { + if (previous === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previous; + } + }); + + test("an identical pull preserves catalog and cache mtimes", async () => { + const codexHome = home(); + const fetchImpl = async () => response(catalog); + const first = await pullRemoteCatalog("https://hub.example/v1/catalog", { codexHome, fetchImpl }); + const cachePath = join(codexHome, "models_cache.json"); + const before = [statSync(first.catalogPath).mtimeMs, statSync(cachePath).mtimeMs]; + await Bun.sleep(20); + const second = await pullRemoteCatalog("https://hub.example/v1/catalog", { codexHome, fetchImpl }); + expect(second).toMatchObject({ status: "unchanged", catalogWritten: false, cacheSynced: false }); + expect([statSync(first.catalogPath).mtimeMs, statSync(cachePath).mtimeMs]).toEqual(before); + }); + + test("lock contention is typed and preserves last-known-good files", async () => { + const codexHome = home(); + // Materialize K, then hold BEGIN IMMEDIATE from a separate connection while pull attempts it. + expect(withCatalogWriteSerialization(codexHome, () => null).kind).toBe("completed"); + const lockPath = resolveCodexCatalogSerializationDatabasePath(resolveEffectiveUserIdentity(), codexHome); + const holder = new Database(lockPath); + holder.exec("PRAGMA busy_timeout=0; BEGIN IMMEDIATE"); + try { + await expect(pullRemoteCatalog("https://hub.example/v1/catalog", { + codexHome, fetchImpl: async () => response(catalog), + })).rejects.toMatchObject({ code: "lock_busy" }); + expect(existsSync(join(codexHome, "opencodex-catalog.json"))).toBe(false); + expect(existsSync(join(codexHome, "models_cache.json"))).toBe(false); + } finally { + holder.exec("ROLLBACK"); holder.close(); + } + }); + + test("fetch and validation failures preserve last-known-good files", async () => { + const codexHome = home(); + const catalogPath = join(codexHome, "opencodex-catalog.json"); + const cachePath = join(codexHome, "models_cache.json"); + writeFileSync(catalogPath, "catalog-before"); writeFileSync(cachePath, "cache-before"); + for (const fetchImpl of [async () => new Response(null, { status: 500 }), async () => response({ models: [] })]) { + await expect(pullRemoteCatalog("https://hub.example/v1/catalog", { codexHome, fetchImpl: fetchImpl as typeof fetch })).rejects.toBeInstanceOf(RemoteCatalogError); + expect(readFileSync(catalogPath, "utf8")).toBe("catalog-before"); + expect(readFileSync(cachePath, "utf8")).toBe("cache-before"); + } + expect(existsSync(join(codexHome, "models_cache.json"))).toBe(true); + }); + + test("a cache rebuild that fails after the catalog write puts the previous catalog back", async () => { + const codexHome = home(); + const first = await pullRemoteCatalog("https://hub.example/v1/catalog", { + codexHome, fetchImpl: async () => response(catalog), + }); + const before = readFileSync(first.catalogPath, "utf8"); + // Make the cache write fail AFTER the catalog has already been replaced. The permit rolls + // back SQLite; the catalog is an atomic file write that nothing else undoes. + const cachePath = join(codexHome, "models_cache.json"); + rmSync(cachePath, { force: true }); + mkdirSync(cachePath); + const next = { version: 1, models: [{ slug: "provider/second-model" }] }; + + await expect(pullRemoteCatalog("https://hub.example/v1/catalog", { + codexHome, fetchImpl: async () => response(next), + })).rejects.toMatchObject({ code: "write_failed" }); + + // Without the restore this reads the SECOND catalog while the cache is stale, and the + // caller was told the pull wrote nothing. + expect(readFileSync(first.catalogPath, "utf8")).toBe(before); + }); + + test("a cache rebuild that fails on a first pull leaves no catalog behind", async () => { + const codexHome = home(); + const catalogPath = join(codexHome, "opencodex-catalog.json"); + const cachePath = join(codexHome, "models_cache.json"); + mkdirSync(cachePath); + + await expect(pullRemoteCatalog("https://hub.example/v1/catalog", { + codexHome, fetchImpl: async () => response(catalog), + })).rejects.toMatchObject({ code: "write_failed" }); + + // Last-known-good for a home that had no catalog is its absence, not a catalog whose + // cache was never built. + expect(existsSync(catalogPath)).toBe(false); + }); +}); + +describe("catalog pull CLI envelope", () => { + test("emits a stable JSON failure without reflecting the secret environment value", async () => { + const { handleCatalogCommand } = await import("../../src/cli/catalog"); + const old = process.env.OCX_CATALOG_TEST_TOKEN; + process.env.OCX_CATALOG_TEST_TOKEN = "secret-cli-marker"; + const output: string[] = []; + const errors: string[] = []; + const log = console.log; + const error = console.error; + console.log = (...values) => { output.push(values.map(String).join(" ")); }; + console.error = (...values) => { errors.push(values.map(String).join(" ")); }; + try { + expect(await handleCatalogCommand([ + "pull", "http://remote.example/v1/catalog", "--auth-env", "OCX_CATALOG_TEST_TOKEN", "--json", + ])).toBe(1); + expect(output).toHaveLength(1); + expect(JSON.parse(output[0]!)).toEqual({ + schemaVersion: 1, ok: false, status: "failed", catalogWritten: false, + cacheSynced: false, codexRestarted: false, code: "insecure_http_refused", + }); + expect(output.join("\n") + errors.join("\n")).not.toContain("secret-cli-marker"); + } finally { + console.log = log; console.error = error; + if (old === undefined) delete process.env.OCX_CATALOG_TEST_TOKEN; else process.env.OCX_CATALOG_TEST_TOKEN = old; + } + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 21bf29eb78..0dbf1402a2 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -115,6 +115,7 @@ "catalog-input-modality-enum.test.ts": "codex-integration", "catalog-llamacpp-capabilities.test.ts": "codex-integration", "catalog-oauth-observation.test.ts": "codex-integration", + "catalog-remote-pull.test.ts": "codex-integration", "catalog-retain-models.test.ts": "codex-integration", "catalog-verbosity-default.test.ts": "codex-integration", "catalog-vision-sidecar-modalities.test.ts": "codex-integration",