diff --git a/src/claude/intercept/cli-catalog.ts b/src/claude/intercept/cli-catalog.ts index 41ad614d437..1508f754f88 100644 --- a/src/claude/intercept/cli-catalog.ts +++ b/src/claude/intercept/cli-catalog.ts @@ -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"; @@ -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[] = []; + 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. */ diff --git a/src/claude/intercept/picker-bootstrap.ts b/src/claude/intercept/picker-bootstrap.ts index 2d1e721096a..d99f5cec905 100644 --- a/src/claude/intercept/picker-bootstrap.ts +++ b/src/claude/intercept/picker-bootstrap.ts @@ -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 { @@ -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", @@ -56,11 +57,14 @@ export interface PickerInjectionOptions { extraStrippedKeys?: readonly string[]; } -function injectIntoSurface( +interface SurfaceInjection { entries: unknown[]; additions: Record[] } + +function planSurfaceInjection( surface: Record, 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 => @@ -68,24 +72,28 @@ function injectIntoSurface( && !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[] = []; 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( @@ -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 }); @@ -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"); } diff --git a/src/claude/intercept/picker-budget.ts b/src/claude/intercept/picker-budget.ts new file mode 100644 index 00000000000..e8344af12ef --- /dev/null +++ b/src/claude/intercept/picker-budget.ts @@ -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): 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): 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); + } +} diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index 56b5a89deb0..5064d7038f7 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -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 diff --git a/structure/runtime.md b/structure/runtime.md index f82140ee9a1..7c24d562b94 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -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 `/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//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 `/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 `/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//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 `/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 `/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). diff --git a/tests/claude-integration/claude-cli-picker.test.ts b/tests/claude-integration/claude-cli-picker.test.ts index 120c1a8b0d2..68e6cdc1884 100644 --- a/tests/claude-integration/claude-cli-picker.test.ts +++ b/tests/claude-integration/claude-cli-picker.test.ts @@ -7,14 +7,14 @@ import { activeDesktop3pAlias, buildDesktop3pRegistry, resolveDesktop3pAlias } f import { DESKTOP_3P_REGISTRY_RETRY_MS, ensureDesktop3pRegistry } from "../../src/claude/desktop-3p-startup"; import { reconcileClaudeFirstPartySettings } from "../../src/claude/first-party-settings"; import { - cliCatalogEligible, cliCatalogKind, invalidateClaudeCodeServedCatalog, + cliCatalogEligible, cliCatalogKind, injectCliBootstrapOptions, invalidateClaudeCodeServedCatalog, rewriteCliCatalogBody, rewriteCliCatalogResponse, } from "../../src/claude/intercept/cli-catalog"; import { createCliCatalogProvider } from "../../src/claude/intercept/cli-picker"; import { CLAUDE_INTERCEPT_HOSTS, startConnectProxy } from "../../src/claude/intercept/connect-proxy"; import { CLAUDE_INTERCEPT_UPSTREAM, startClaudeInterceptListener } from "../../src/claude/intercept/listener"; import { createLocalInterceptCa, issueLocalInterceptLeaf } from "../../src/claude/intercept/local-ca"; -import { injectPickerModels, type PickerModelEntry } from "../../src/claude/intercept/picker-bootstrap"; +import { BOOTSTRAP_MAX_DECODED_BYTES, injectPickerModels, type PickerModelEntry } from "../../src/claude/intercept/picker-bootstrap"; import { buildCliPickerModels, routableCliPickerModels, type PickerRouteInput } from "../../src/claude/intercept/picker-models"; import { applyClaudeInterceptSettings, buildClaudeInterceptEnv } from "../../src/claude/intercept/settings"; @@ -489,3 +489,45 @@ test("writing the intercept settings and a successful first-party reconcile drop expect(failed).toMatchObject({ ok: false, reason: "unreadable" }); expect(existsSync(join(dir, "org-3-cc.json"))).toBe(true); }); + + +test("CLI retained metadata amplification fails open with original response bytes and headers", async () => { + const text = JSON.stringify({ model_selector_config: [{ id: "cc", models: [{ + id: "claude-native", metadata: { retained: "x".repeat(1024 * 1024) }, + }] }] }); + const aliases = Array.from({ length: 32 }, (_, i) => ({ id: `claude-alias-${i}`, name: `Route ${i}` })); + const response = await rewriteCliCatalogResponse(new Response(text, { + headers: { etag: '"native"', "x-request-id": "catalog-test" }, + }), "model_selector", aliases); + expect((await response.text()) === text).toBe(true); + expect(response.headers.get("etag")).toBe('"native"'); + expect(response.headers.get("x-request-id")).toBe("catalog-test"); +}); + +test("CLI selector and explicit bootstrap fallback share the final output cap", () => { + const aliases = Array.from({ length: 32 }, (_, i) => ({ id: `claude-alias-${i}`, name: `Route ${i}` })); + for (const [kind, body] of [["model_selector", modelSelector()], ["bootstrap", {}]] as const) { + const text = JSON.stringify({ ...body, padding: "x".repeat(BOOTSTRAP_MAX_DECODED_BYTES - 1000) }); + expect(Buffer.byteLength(text)).toBeLessThan(BOOTSTRAP_MAX_DECODED_BYTES); + expect(rewriteCliCatalogBody(kind, text, aliases) === null).toBe(true); + } +}); + +test("CLI fallback refuses oversized row fields without changing its parsed input", () => { + const row = { ...ROW, description: "x".repeat(1024 * 1024) }; + const body = { additional_model_options: [{ model: "native" }] }; + expect(injectCliBootstrapOptions(body, [ROW, row])).toBe(1); + const fresh = { additional_model_options: [{ model: "native" }] }; + expect(injectCliBootstrapOptions(fresh, [ROW, { ...row, id: "claude-oversized" }])).toBe(0); + expect(fresh.additional_model_options).toEqual([{ model: "native" }]); + expect(rewriteCliCatalogBody("bootstrap", "{}", [row]) === null).toBe(true); +}); + + +test("duplicate CLI surfaces share the row expansion limit", () => { + const body = { model_selector_config: Array.from({ length: 3 }, () => ({ id: "cc", models: [{ + id: "claude-native", metadata: "x".repeat(32 * 1024), + }] })) }; + const aliases = Array.from({ length: 32 }, (_, i) => ({ id: `claude-alias-${i}`, name: `Route ${i}` })); + expect(rewriteCliCatalogBody("model_selector", JSON.stringify(body), aliases) === null).toBe(true); +}); diff --git a/tests/claude-integration/claude-picker-bootstrap.test.ts b/tests/claude-integration/claude-picker-bootstrap.test.ts index 1e0d112b083..a9b962ac9bc 100644 --- a/tests/claude-integration/claude-picker-bootstrap.test.ts +++ b/tests/claude-integration/claude-picker-bootstrap.test.ts @@ -1,4 +1,4 @@ -import { expect, test } from "bun:test"; +import { expect, spyOn, test } from "bun:test"; import { brotliCompressSync, deflateSync, gzipSync } from "node:zlib"; import { BOOTSTRAP_MAX_DECODED_BYTES, injectPickerModels, isPickerBootstrapRequest, @@ -139,3 +139,74 @@ test("unknown surface ids from the body are counted in the outcome, never echoed expect(outcomes).toEqual(["no_code_surface(cowork,other:2)"]); expect(outcomes.join("")).not.toContain(secretish); }); + + +test("oversized retained fields are refused before cloning any picker rows", () => { + const body = { model_selector_config: [{ id: "code", models: [{ + id: "claude-native", metadata: { retained: "x".repeat(1024 * 1024) }, + }] }] }; + const aliases = Array.from({ length: 32 }, (_, i) => ({ id: `ocx-${i}`, name: `Route ${i}` })); + const clone = spyOn(globalThis, "structuredClone"); + try { + expect(injectPickerModels(body, aliases)).toBe(0); + expect(clone).not.toHaveBeenCalled(); + expect(body.model_selector_config[0]!.models).toHaveLength(1); + } finally { clone.mockRestore(); } +}); + +test("a wide retained row is refused even when its individual fields are small", () => { + const native = { id: "claude-native", ...Object.fromEntries( + Array.from({ length: 10 }, (_, i) => [`field${i}`, "x".repeat(32 * 1024)]), + ) }; + const body = { model_selector_config: [{ id: "code", models: [native] }] }; + expect(injectPickerModels(body, models)).toBe(0); + expect(body.model_selector_config[0]!.models).toEqual([native]); +}); + +test("all target surfaces share one expansion budget and refusal is atomic", () => { + const aliases = Array.from({ length: 32 }, (_, i) => ({ id: `ocx-${i}`, name: `Route ${i}` })); + for (const ids of [["ccd", "code"], ["code", "code"]]) { + const body = { model_selector_config: ids.map(id => ({ id, models: [{ + id: "claude-native", metadata: { retained: "x".repeat(40 * 1024) }, + }] })) }; + const clone = spyOn(globalThis, "structuredClone"); + try { + expect(injectPickerModels(body, aliases)).toBe(0); + expect(clone).not.toHaveBeenCalled(); + expect(body.model_selector_config.every(surface => surface.models.length === 1)).toBe(true); + } finally { clone.mockRestore(); } + } +}); + +test("compressed Desktop catalogs leave no rewritten body when the output would exceed its cap", () => { + const body = fixture(); + const text = JSON.stringify({ ...body, padding: "x".repeat(BOOTSTRAP_MAX_DECODED_BYTES - 700) }); + expect(Buffer.byteLength(text)).toBeLessThan(BOOTSTRAP_MAX_DECODED_BYTES); + const aliases = Array.from({ length: 32 }, (_, i) => ({ id: `ocx-${i}`, name: `Route ${i}` })); + expect(rewriteBootstrapBody(gzipSync(Buffer.from(text)), "gzip", aliases) === null).toBe(true); +}); + + +test("small nested metadata is independently cloned on both Desktop surfaces", () => { + const native = { id: "claude-native", thinking: { mode: ["adaptive", "fixed"] }, capabilities: { images: true }, + description: "x".repeat(1024 * 1024), context_window: 200_000 }; + const body = { model_selector_config: ["ccd", "code", "ccr"].map(id => ({ id, models: [native] })) }; + expect(injectPickerModels(body, models)).toBe(2); + for (const surface of body.model_selector_config.slice(0, 2)) { + expect(surface.models[0]).toBe(native); + expect(surface.models[1]!.thinking).toEqual(native.thinking); + expect(surface.models[1]!.thinking).not.toBe(native.thinking); + expect(surface.models[1]!.capabilities).toEqual(native.capabilities); + expect(surface.models[1]!).not.toHaveProperty("description"); + expect(surface.models[1]!.context_window).toBe(128_000); + } + expect(body.model_selector_config[2]!.models).toEqual([native]); +}); + +test("UTF-8 and JSON escapes count toward retained field limits", () => { + for (const value of ["界".repeat(24 * 1024), "\u0001".repeat(12 * 1024), "\ud800".repeat(12 * 1024)]) { + const body = { model_selector_config: [{ id: "code", models: [{ id: "claude-native", metadata: value }] }] }; + expect(injectPickerModels(body, models)).toBe(0); + expect(body.model_selector_config[0]!.models).toHaveLength(1); + } +});