-
Notifications
You must be signed in to change notification settings - Fork 1.1k
feat(codex): pull an authenticated remote catalog into local Codex state #4413
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -278,6 +278,34 @@ 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 <https-url> [--auth-env <NAME>] [--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. | ||||||||||||||||||
|
|
||||||||||||||||||
| `--json` emits one stable envelope on stdout. The `status` field is `updated`, `unchanged`, or | ||||||||||||||||||
| `failed`; `catalogWritten`, `cacheSynced`, and `codexRestarted` are always present. | ||||||||||||||||||
|
Comment on lines
+306
to
+307
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win Document the Line 306-307 lists 📝 Proposed documentation update-`--json` emits one stable envelope on stdout. The `status` field is `updated`, `unchanged`, or
-`failed`; `catalogWritten`, `cacheSynced`, and `codexRestarted` are always present.
+`--json` emits one stable envelope on stdout. `schemaVersion`, `ok`, `status`, `catalogWritten`,
+`cacheSynced`, and `codexRestarted` are always present. The `status` field is `updated`,
+`unchanged`, or `failed`. A success envelope adds `modelCount`; a failure envelope adds `code`
+(for example `usage`, `auth_env_missing`, `insecure_http_refused`, `body_too_large`,
+`catalog_invalid`, `lock_busy`, `write_failed`). Exit status is `0` on success, `2` for usage
+errors, `3` for `lock_busy`, and `1` for other failures.📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||
|
|
||||||||||||||||||
| ## Background service | ||||||||||||||||||
|
|
||||||||||||||||||
| ### `ocx service [install|repair|restart|start|stop|status|uninstall|remove]` | ||||||||||||||||||
|
|
||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,86 @@ | ||
| 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<number> { | ||
| 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 <https-url> [--auth-env <NAME>] [--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; | ||
| 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 }); | ||
| codexRestarted = (processResult.restart?.stopped.length ?? 0) > 0; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win Report an incomplete Codex restart as a failure. At Set 🤖 Prompt for AI Agents |
||
| } | ||
| 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; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,205 @@ | ||
| import { existsSync, readFileSync, statSync } from "node:fs"; | ||
|
|
||
| import { MAX_REMOTE_CATALOG_BYTES } from "../../server/catalog-download"; | ||
| import { readBoundedResponseBytes } from "../../lib/bounded-body"; | ||
| import { withCatalogWriteSerialization, type CatalogSerializationOutcome } from "../catalog-write-serialization"; | ||
| import { replaceActiveCodexCatalog } from "../internal/catalog-writer"; | ||
| 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<string, unknown> { | ||
| models: Record<string, unknown>[]; | ||
| } | ||
|
|
||
| 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<string, unknown>; | ||
| 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<string>(); | ||
| 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<string, unknown>; | ||
| 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<PullRemoteCatalogOptions, "token" | "timeoutMs" | "maxBytes" | "fetchImpl"> = {}, | ||
| ): 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<T>(outcome: CatalogSerializationOutcome<T>): 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})`); | ||
| } | ||
|
|
||
| export async function pullRemoteCatalog(input: string, options: PullRemoteCatalogOptions = {}): Promise<PullRemoteCatalogResult> { | ||
| // 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) throw new RemoteCatalogError("write_failed", "Remote catalog cache synchronization failed"); | ||
|
Comment on lines
+192
to
+194
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🔴 Critical | 🏗️ Heavy lift Catalog replacement is committed before the cache rebuild succeeds, so both the code and the documented guarantee are wrong.
📍 Affects 2 files
🤖 Prompt for AI Agents |
||
| 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, | ||
| }; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Add
ocx catalog pullto the seven localized lifecycle pages.The repository has one English lifecycle page and seven localized pages:
fr,ja,ko,ru,tr,zh-cn, andzh-tw. The command is documented only indocs-site/src/content/docs/reference/cli/lifecycle.md:281-307. Update the seven localized files to keep the CLI references synchronized.🤖 Prompt for AI Agents