Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 27 additions & 16 deletions src/claude/intercept/cli-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { join } from "node:path";
import type { ClaudeFirstPartyDesired } from "../first-party-settings";
import { classifyInterceptClient } from "./client-class";
import { BOOTSTRAP_MAX_DECODED_BYTES, injectPickerModels, type PickerModelEntry } from "./picker-bootstrap";
import { PickerRewriteBudget } from "./picker-budget";

export type CliCatalogKind = "model_selector" | "bootstrap";

Expand Down Expand Up @@ -60,26 +61,36 @@ export function injectCliBootstrapOptions(bootstrap: unknown, models: readonly P
if (current !== undefined && current !== null && !Array.isArray(current)) return 0;
const options = Array.isArray(current) ? current : [];
const existing = new Set(options.map(option => record(option)?.model));
let added = 0;
for (const model of models) {
if (existing.has(model.id)) continue;
options.push({ model: model.id, name: model.name, description: model.description ?? "" });
existing.add(model.id);
added++;
}
if (added > 0) body.additional_model_options = options;
return added;
const additions: Record<string, unknown>[] = [];
try {
const budget = new PickerRewriteBudget(body);
// A missing/null property needs a key and array, plus a possible separator (conservative).
if (!Array.isArray(current)) budget.reserveBytes(32);
for (const model of models) {
if (existing.has(model.id)) continue;
const row = { model: model.id, name: model.name, description: model.description ?? "" };
budget.reserveRow(row);
additions.push(row);
existing.add(model.id);
}
} catch { return 0; } // Fail open without mutating the original options.
for (const row of additions) options.push(row);
if (additions.length > 0) body.additional_model_options = options;
return additions.length;
}

/** Rewrite a decoded catalog body; `null` when nothing was added or the body is not the expected shape. */
export function rewriteCliCatalogBody(kind: CliCatalogKind, text: string, models: readonly PickerModelEntry[]): string | null {
if (models.length === 0) return null;
let parsed: unknown;
try { parsed = JSON.parse(text); } catch { return null; }
const added = kind === "bootstrap"
? injectCliBootstrapOptions(parsed, models)
: injectPickerModels(parsed, models, undefined, { surfaces: CLI_PICKER_SURFACE_IDS, extraStrippedKeys: CLI_EXTRA_STRIPPED_KEYS });
return added > 0 ? JSON.stringify(parsed) : null;
if (models.length === 0 || Buffer.byteLength(text) > BOOTSTRAP_MAX_DECODED_BYTES) return null;
try {
const parsed: unknown = JSON.parse(text);
const added = kind === "bootstrap"
? injectCliBootstrapOptions(parsed, models)
: injectPickerModels(parsed, models, undefined, { surfaces: CLI_PICKER_SURFACE_IDS, extraStrippedKeys: CLI_EXTRA_STRIPPED_KEYS });
if (added === 0) return null;
const rewritten = JSON.stringify(parsed);
return Buffer.byteLength(rewritten) <= BOOTSTRAP_MAX_DECODED_BYTES ? rewritten : null;
} catch { return null; }
}

/** Read at most `cap` bytes; null (and the stream cancelled) once the body would exceed it. */
Expand Down
53 changes: 37 additions & 16 deletions src/claude/intercept/picker-bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
* A failed or inapplicable transform leaves the upstream bytes untouched.
*/
import { brotliDecompressSync, gunzipSync, inflateSync } from "node:zlib";
import { PICKER_MAX_OUTPUT_BYTES, PickerRewriteBudget, pickerRowBytes } from "./picker-budget";

/** One opencodex model offered in Desktop's Code-tab picker. */
export interface PickerModelEntry {
Expand All @@ -16,7 +17,7 @@ export interface PickerModelEntry {
}

export const BOOTSTRAP_MAX_ENCODED_BYTES = 4 * 1024 * 1024;
export const BOOTSTRAP_MAX_DECODED_BYTES = 16 * 1024 * 1024;
export const BOOTSTRAP_MAX_DECODED_BYTES = PICKER_MAX_OUTPUT_BYTES;
const BOOTSTRAP_PATH = /^\/(?:edge-api|api)\/bootstrap(?:\/[A-Za-z0-9-]+\/app_start)?\/?$/;
const REWRITE_REMOVED_HEADERS = new Set([
"content-encoding", "content-length", "etag", "digest", "content-md5", "transfer-encoding",
Expand Down Expand Up @@ -56,36 +57,43 @@ export interface PickerInjectionOptions {
extraStrippedKeys?: readonly string[];
}

function injectIntoSurface(
interface SurfaceInjection { entries: unknown[]; additions: Record<string, unknown>[] }

function planSurfaceInjection(
surface: Record<string, unknown>,
models: readonly PickerModelEntry[],
stripped: readonly string[],
): number | string {
budget: PickerRewriteBudget,
): SurfaceInjection | string {
if (!Array.isArray(surface.models)) return "no_models";
const entries = surface.models as unknown[];
const template = entries.map(record).find(entry =>
typeof entry?.id === "string" && entry.id.startsWith("claude-")
&& !entry.disabled && !entry.disabled_reason && entry.section !== "deprecated");
if (!template) return `no_template(models=${entries.length})`;
const existing = new Set(entries.map(record).map(entry => entry?.id));
let added = 0;
// Strip by shallow projection before measuring or cloning retained metadata. Native rows stay intact.
const retained = { ...template };
for (const key of Object.keys(retained)) {
if (stripped.includes(key) || /version/i.test(key)
|| ["id", "name", "section", "context_window"].includes(key)) delete retained[key];
}
pickerRowBytes(retained);
const additions: Record<string, unknown>[] = [];
for (const model of models) {
if (existing.has(model.id)) continue;
const copy = structuredClone(template);
const copy = { ...retained };
copy.id = model.id;
copy.name = model.name;
copy.section = "main";
if (model.contextWindow === undefined) delete copy.context_window;
else copy.context_window = model.contextWindow;
for (const key of Object.keys(copy)) {
if (stripped.includes(key) || /version/i.test(key)) delete copy[key];
}
if (model.description !== undefined) copy.description = model.description;
entries.push(copy);
budget.reserveRow(copy);
additions.push(copy);
existing.add(model.id);
added++;
}
return added;
return { entries, additions };
}

export function injectPickerModels(
Expand All @@ -110,10 +118,21 @@ export function injectPickerModels(
}
let added = 0;
const skipped: string[] = [];
for (const surface of targets) {
const result = injectIntoSurface(surface, models, stripped);
if (typeof result === "number") added += result;
else skipped.push(`${String(surface.id)}:${result}`);
try {
const budget = new PickerRewriteBudget(bootstrap);
const plans: SurfaceInjection[] = [];
for (const surface of targets) {
const result = planSurfaceInjection(surface, models, stripped, budget);
if (typeof result === "string") skipped.push(`${String(surface.id)}:${result}`);
else { plans.push(result); added += result.additions.length; }
}
// All surfaces must fit before any deep clone or mutation; a refusal cannot publish a partial list.
const copies = plans.map(plan => plan.additions.map(row => structuredClone(row)));
for (let i = 0; i < plans.length; i++) {
for (const row of copies[i]!) plans[i]!.entries.push(row);
}
} catch {
return unchanged("rewrite_limit");
}
if (added === 0) return unchanged(skipped.length > 0 ? skipped.join(";") : models.length === 0 ? "no_routes" : "all_present");
explain?.({ kind: "rewritten", added });
Expand Down Expand Up @@ -142,7 +161,9 @@ export function rewriteBootstrapBody(
if (decoded.length > BOOTSTRAP_MAX_DECODED_BYTES) return unchanged("decoded_cap");
const parsed: unknown = JSON.parse(decoded.toString("utf8"));
if (injectPickerModels(parsed, models, explain) === 0) return null;
return Buffer.from(JSON.stringify(parsed), "utf8");
const text = JSON.stringify(parsed);
if (Buffer.byteLength(text) > BOOTSTRAP_MAX_DECODED_BYTES) return unchanged("rewrite_limit");
return Buffer.from(text, "utf8");
} catch {
return unchanged("decode_or_parse_failed");
}
Expand Down
41 changes: 41 additions & 0 deletions src/claude/intercept/picker-budget.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { jsonUtf8Bytes } from "../../lib/json-byte-size";

export const PICKER_MAX_OUTPUT_BYTES = 16 * 1024 * 1024;
const MAX_FIELD_BYTES = 64 * 1024;
const MAX_ROW_BYTES = 256 * 1024;
const MAX_ADDED_BYTES = 2 * 1024 * 1024;
const MAX_ADDED_ROWS = 4096;

/** Inspect references only: no deep copy or serialized string is allocated by preflight. */
export function pickerRowBytes(row: Record<string, unknown>): number {
for (const key of Object.keys(row)) {
jsonUtf8Bytes(key, MAX_FIELD_BYTES);
if (row[key] !== undefined) jsonUtf8Bytes(row[key], MAX_FIELD_BYTES);
}
return jsonUtf8Bytes(row, MAX_ROW_BYTES);
}

/** One budget for the entire response, including duplicate surfaces and the CLI fallback. */
export class PickerRewriteBudget {
private outputBytes: number;
private addedBytes = 0;
private addedRows = 0;

constructor(body: unknown) {
this.outputBytes = jsonUtf8Bytes(body, PICKER_MAX_OUTPUT_BYTES);
}

reserveBytes(bytes: number): void {
if (bytes > MAX_ADDED_BYTES - this.addedBytes || bytes > PICKER_MAX_OUTPUT_BYTES - this.outputBytes) {
throw new RangeError("Picker rewrite byte limit");
}
this.addedBytes += bytes;
this.outputBytes += bytes;
}

reserveRow(row: Record<string, unknown>): void {
if (++this.addedRows > MAX_ADDED_ROWS) throw new RangeError("Picker rewrite row limit");
// Include a separator even for an empty list, conservatively reserving one extra byte.
this.reserveBytes(pickerRowBytes(row) + 1);
}
}
14 changes: 14 additions & 0 deletions structure/clients/claude-desktop.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,20 @@ picker proxy bind failure only disables picker mode; a picker construction or st
every socket the start had bound before rethrowing. Nothing is logged but method, bootstrap or
other, and status.

### Picker catalog rewrite bounds

`src/claude/intercept/picker-budget.ts` preflights plain JSON before copying injected rows.
Each retained field value and key is limited to 64 KiB of serialized UTF-8, each added row to
256 KiB, and the whole response to 4096 added rows and 2 MiB of added JSON (including separators).
All selected surfaces, including duplicate surface ids, share that budget. The original body plus
reserved additions must fit 16 MiB before deep clones or final serialization. The CLI's explicit
bootstrap fallback uses the same budget, including space for a newly created options property.
A refused rewrite leaves every original row and the upstream response unchanged; it never publishes
a partially extended picker. Small nested capabilities/thinking metadata retain their shape,
while presentation/version stripping, descriptions, context windows, and surface eligibility keep
their existing rules. Regression coverage is in `tests/claude-integration/claude-picker-bootstrap.test.ts`
and `tests/claude-integration/claude-cli-picker.test.ts`.

`src/claude/desktop-picker.ts` owns every mutation while a server is running. One controller lock
serializes `enable`, `disable`, and `transition`; the latter wraps a whole Desktop mode change so
cleanup, mode/profile commit, and the optional picker enable cannot race. `runDesktopTransition` uses
Expand Down
2 changes: 1 addition & 1 deletion structure/runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,7 @@ Claude Code reaches the pair through an authenticated `HTTPS_PROXY` URL plus `NO
(`src/claude/intercept/settings.ts`), so no `ANTHROPIC_BASE_URL` rewrite is involved and the client
still believes it talks to Anthropic. The proxy splices `CONNECT api.anthropic.com:443` onto the TLS
listener, relays every other CONNECT target blind, and refuses unauthenticated clients, plain proxied HTTP, and loopback targets. The per-install proxy token is stored owner-only under `<OPENCODEX_HOME>/claude-intercept/` (0600 plus a real per-user NTFS ACL on Windows, via `src/lib/windows-secret-acl.ts`, and re-pinned on every read-through `ensure`), and the settings file carrying it is written through the same hardened atomic writer. Every start runs `migrateClaudeInterceptSettings` (`src/claude/intercept/settings.ts`), which rewrites an owned env that no longer matches — e.g. a pre-auth URL left by an upgrade — while never creating an absent env or touching a foreign one, so a service restart cannot strand clients on 407s. Status/inspection reads the token without minting it; only apply and intercept startup create it.
The TLS listener rewrites `POST /v1/messages` and `POST /v1/messages/count_tokens` to a loopback origin and dispatches them under the `claude-intercept` ingress; other paths relay to the configured upstream. One relayed path is extended: for the standalone CLI's picker catalog, the listener's `cliCatalog` hook (`src/claude/intercept/cli-catalog.ts`) merges opencodex rows into `GET /api/organizations/<org>/model_selector/cc` (or `/api/model_selector/cc`), touching only the `cc` surface and leaving Desktop's `ccd`/`code` surfaces to picker mode, and into the fallback `additional_model_options` of `GET /api/claude_cli/bootstrap`. Eligibility: the `cc` catalog is rewritten only for a CLI-classified `User-Agent` with `claudeCode.cliFirstParty` on, because Claude Code caches it on disk for every process and a Desktop-spawned process must not seed rows a plain `claude` cannot route; the bootstrap request carries no entrypoint and follows CLI intent alone. Rows come from `buildCliPickerModels` (`src/claude/intercept/picker-models.ts`), which uses Desktop 3P registry aliases because the CLI offers only Claude-shaped ids (an `ocx-claude-*` row renders "Update Claude Code"), and advertises only an alias the registry decodes back to the same route. `src/claude/intercept/cli-picker.ts` supplies them lazily on the first eligible request, persists the snapshot at `<OPENCODEX_HOME>/claude-intercept/cli-picker-models.json`, re-checks each persisted row against the live registry (its alias must still decode to its recorded route; a cold registry after a restart is awaited first, and a snapshot whose rows still do not decode, for example after a provider change, is rebuilt within the same bound), and bounds a cold build at 2.5 s while awaiting the shared registry build in `src/claude/desktop-3p-startup.ts` that startup also uses (a failed or empty on-demand build is not retried for 30 s). The rewrite fails open: a provider error, non-2xx status, unparsable body, or no added row relays the upstream bytes unchanged; a body over the 16 MiB cap is cut off while streaming and answered 502 rather than buffered. Claude Code trusts its cached `<claudeConfigDir>/cache/model-catalog/*-cc.json` for about an hour, so `writeSettings` in `src/claude/intercept/settings.ts` and every successful `reconcileClaudeFirstPartySettings` drop those files (leaving Desktop's `*-ccd.json`); running sessions keep their list until restarted.
The TLS listener rewrites `POST /v1/messages` and `POST /v1/messages/count_tokens` to a loopback origin and dispatches them under the `claude-intercept` ingress; other paths relay to the configured upstream. One relayed path is extended: for the standalone CLI's picker catalog, the listener's `cliCatalog` hook (`src/claude/intercept/cli-catalog.ts`) merges opencodex rows into `GET /api/organizations/<org>/model_selector/cc` (or `/api/model_selector/cc`), touching only the `cc` surface and leaving Desktop's `ccd`/`code` surfaces to picker mode, and into the fallback `additional_model_options` of `GET /api/claude_cli/bootstrap`. Eligibility: the `cc` catalog is rewritten only for a CLI-classified `User-Agent` with `claudeCode.cliFirstParty` on, because Claude Code caches it on disk for every process and a Desktop-spawned process must not seed rows a plain `claude` cannot route; the bootstrap request carries no entrypoint and follows CLI intent alone. Rows come from `buildCliPickerModels` (`src/claude/intercept/picker-models.ts`), which uses Desktop 3P registry aliases because the CLI offers only Claude-shaped ids (an `ocx-claude-*` row renders "Update Claude Code"), and advertises only an alias the registry decodes back to the same route. `src/claude/intercept/cli-picker.ts` supplies them lazily on the first eligible request, persists the snapshot at `<OPENCODEX_HOME>/claude-intercept/cli-picker-models.json`, re-checks each persisted row against the live registry (its alias must still decode to its recorded route; a cold registry after a restart is awaited first, and a snapshot whose rows still do not decode, for example after a provider change, is rebuilt within the same bound), and bounds a cold build at 2.5 s while awaiting the shared registry build in `src/claude/desktop-3p-startup.ts` that startup also uses (a failed or empty on-demand build is not retried for 30 s). Both CLI catalogs obey the shared [picker rewrite bounds](clients/claude-desktop.md#picker-catalog-rewrite-bounds), preflighting row growth before copying and keeping the final JSON within 16 MiB. The rewrite fails open: a provider error, non-2xx status, unparsable body, or no added row relays the upstream bytes unchanged; a body over the 16 MiB cap is cut off while streaming and answered 502 rather than buffered. Claude Code trusts its cached `<claudeConfigDir>/cache/model-catalog/*-cc.json` for about an hour, so `writeSettings` in `src/claude/intercept/settings.ts` and every successful `reconcileClaudeFirstPartySettings` drop those files (leaving Desktop's `*-ccd.json`); running sessions keep their list until restarted.
The pair is on by default on a hub (`claudeCode.intercept.enabled`); its proxy port defaults to public port + 100 (`claudeCode.intercept.port`). The lifecycle records bind failure, and stop joins both sockets. Its serialized `ensure()` joins pending startup, reuses the live config and dispatch wrapper, and can start a previously unbound pair. `stop()` refuses future ensure calls and awaits an in-flight start. Local management applies and `ocx claude intercept start` (`POST /api/claude-intercept/start`) use this seam through `src/server/index/optional-listeners.ts`; hub-management ingress and data-plane credentials cannot start interception. Status reports `interceptReason` (with `interceptFailurePort` for a CONNECT bind failure) and optional `pickerReason` (with `pickerFailurePort` when the Desktop picker relay port is occupied), distinguishing disabled routing, client role, ephemeral public ports, occupied CONNECT ports and other failures. A changed configured port never rebinds a running pair: apply reports `port_mismatch` with both ports. With an ephemeral public port (`startServer(0)`), an explicit intercept port is required.
This ingress honours first-party model bindings (`claudeCode.intercept.modelMap`); see [Claude Desktop](clients/claude-desktop.md#first-party-model-bindings). Picker mode and the primitive's optional `allowedTargets` restriction are described under [Desktop egress proxy](clients/claude-desktop.md#picker-mode-the-desktop-egress-proxy).

Expand Down
Loading
Loading