From 9c0e4817b264325e669244fc5f9adae17a842d42 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Sat, 8 Aug 2026 17:00:28 +0900 Subject: [PATCH 1/2] fix(server): bound Claude Desktop apply bodies --- .../management/agent-settings-routes.ts | 43 +++++++++---------- src/server/management/body.ts | 6 +++ src/server/request-decompress.ts | 9 +++- tests/claude-management-api.test.ts | 40 +++++++++++++++++ tests/request-decompress.test.ts | 39 +++++++++++++++++ 5 files changed, 112 insertions(+), 25 deletions(-) diff --git a/src/server/management/agent-settings-routes.ts b/src/server/management/agent-settings-routes.ts index 17f7c00b3..11ea0c136 100644 --- a/src/server/management/agent-settings-routes.ts +++ b/src/server/management/agent-settings-routes.ts @@ -60,7 +60,7 @@ import { applySystemEnvToggle } from "../system-env"; import { isPlainRecord, parseDebugLogQuery, tokPerSecondResult, unavailableCostReason, costResult, requestLogDto, stripRegistryOnlyStaticHeaders, fetchAllModels, fetchGrokCandidateModels, buildClaudeDesktopState } from "./shared"; import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, CostResult, MetricSource } from "./shared"; -import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; +import { readManagementJsonBody, readOptionalManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; const GROK_APPLY_JOIN_MS = 120_000; export const GROK_APPLY_TERMINAL_MS = 10 * 60_000; @@ -759,32 +759,22 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise } if (url.pathname === "/api/claude-desktop/apply" && req.method === "POST") { try { - const { setIntegrationEnabled, claudeDesktopIntegrationEnabled } = await import("../../codex/desired-state"); - const desired = setIntegrationEnabled("claude-desktop", true); - if (!desired.ok) return jsonResponse({ error: desired.message }, desired.retryable ? 409 : 500); - // Disk now says ON; the reused server snapshot must agree, or the native - // GET reports OFF and a later whole-snapshot save undoes this transition. - mirrorDesiredEnabledOntoSnapshot(config, "claude-desktop", true); - // Disk now says ON; the reused server snapshot must agree, or the native - // GET reports OFF and a later whole-snapshot save undoes this transition. // #859: the CLI delegates here so the registry is built in the serving // process. Accept an optional mode; default stays static for back-compat. let mode: "static" | "hybrid" | "discovery" = "static"; - const rawBody = await req.text(); let parsed: unknown; - if (rawBody.trim()) { - try { - parsed = JSON.parse(rawBody); - } catch { - return jsonResponse({ error: "invalid JSON body" }, 400); - } - const requested = (parsed as { mode?: unknown } | null)?.mode; - if (requested !== undefined) { - if (requested === "static" || requested === "hybrid" || requested === "discovery") { - mode = requested; - } else { - return jsonResponse({ error: "mode must be static, hybrid, or discovery" }, 400); - } + try { + parsed = await readOptionalManagementJsonBody(req); + } catch (error) { + rethrowManagementBodyTooLarge(error); + return jsonResponse({ error: "invalid JSON body" }, 400); + } + const requested = (parsed as { mode?: unknown } | null)?.mode; + if (requested !== undefined) { + if (requested === "static" || requested === "hybrid" || requested === "discovery") { + mode = requested; + } else { + return jsonResponse({ error: "mode must be static, hybrid, or discovery" }, 400); } } // #859: a delegated CLI apply carries the profile it just saved — the @@ -800,6 +790,12 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise return jsonResponse({ error: error instanceof Error ? error.message : String(error) }, 400); } } + const { setIntegrationEnabled, claudeDesktopIntegrationEnabled } = await import("../../codex/desired-state"); + const desired = setIntegrationEnabled("claude-desktop", true); + if (!desired.ok) return jsonResponse({ error: desired.message }, desired.retryable ? 409 : 500); + // Disk now says ON; the reused server snapshot must agree, or the native + // GET reports OFF and a later whole-snapshot save undoes this transition. + mirrorDesiredEnabledOntoSnapshot(config, "claude-desktop", true); const state = await buildClaudeDesktopState(config, profileOverride); // `setIntegrationEnabled` above wrote desired ON to DISK; it does not touch // this long-lived server snapshot. Saving the snapshot wholesale would carry @@ -866,6 +862,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise } return jsonResponse({ ok: true, saved: true, applied: true, path: result.path, fingerprint: result.fingerprint }); } catch (error) { + rethrowManagementBodyTooLarge(error); return jsonResponse({ error: error instanceof Error ? error.message : String(error) }, 400); } } diff --git a/src/server/management/body.ts b/src/server/management/body.ts index e2f4f0d17..5d506c26c 100644 --- a/src/server/management/body.ts +++ b/src/server/management/body.ts @@ -11,6 +11,12 @@ export function readManagementJsonBody(req: Request): Promise { return readBoundedJsonRequestBody(req, MANAGEMENT_JSON_BODY_MAX_BYTES) as Promise; } +export function readOptionalManagementJsonBody(req: Request): Promise { + return readBoundedJsonRequestBody(req, MANAGEMENT_JSON_BODY_MAX_BYTES, undefined, { + emptyBodyFallback: {}, + }) as Promise; +} + export function managementBodyTooLargeResponse( error: unknown, req: Request, diff --git a/src/server/request-decompress.ts b/src/server/request-decompress.ts index c3e5bca0a..efb8b862d 100644 --- a/src/server/request-decompress.ts +++ b/src/server/request-decompress.ts @@ -90,12 +90,13 @@ export async function readBoundedJsonRequestBody( req: Request, maxBytes: number, budget?: TranslatorBudget, + options?: { emptyBodyFallback?: unknown }, ): Promise { const encoding = req.headers.get("content-encoding"); const declaredLength = declaredBodyLength(req); // Reject an honest oversized declaration before req.arrayBuffer() can allocate it. - // Missing, malformed, or dishonest declarations remain covered by decodeRequestBody's - // post-read cap below. + // Missing, malformed, or dishonest declarations remain covered by the raw and decoded + // post-read caps below. if (declaredLength !== null && declaredLength > maxBytes) { throw new DecompressedBodyTooLargeError(declaredLength, maxBytes); } @@ -108,6 +109,7 @@ export async function readBoundedJsonRequestBody( } finally { releaseReservation?.(); } + assertBodySizeWithinLimit(raw, maxBytes); const releaseRaw = budget?.observeAcceptedRequestCopy(raw.byteLength); let releaseDecoded: (() => void) | undefined; let releaseText: (() => void) | undefined; @@ -116,6 +118,9 @@ export async function readBoundedJsonRequestBody( releaseDecoded = decoded === raw ? undefined : budget?.observeAcceptedRequestCopy(decoded.byteLength); const text = new TextDecoder().decode(decoded); releaseText = budget?.observeAcceptedRequestCopy(new TextEncoder().encode(text).byteLength); + if (text.trim() === "" && options && "emptyBodyFallback" in options) { + return options.emptyBodyFallback; + } const parsed = JSON.parse(text); budget?.observeAcceptedRequestCopy(new TextEncoder().encode(JSON.stringify(parsed)).byteLength); return parsed; diff --git a/tests/claude-management-api.test.ts b/tests/claude-management-api.test.ts index 4286deeac..4545c8f7d 100644 --- a/tests/claude-management-api.test.ts +++ b/tests/claude-management-api.test.ts @@ -8,6 +8,7 @@ import { startServer } from "../src/server"; import * as systemEnv from "../src/server/system-env"; import type { OcxConfig } from "../src/types"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "./helpers/isolated-codex-home"; +import { MANAGEMENT_JSON_BODY_MAX_BYTES } from "../src/server/management/body"; // Full-suite Windows load: startServer + multi-PUT management flows often exceed bun's // default 5s per-test budget (same flake class as 810fa115 / kiro-oauth). @@ -690,12 +691,33 @@ test("Claude Desktop apply honors the profile in the request body over daemon-st test("Claude Desktop apply validates the mode body", async () => { const server = startServer(0); try { + const beforeMalformed = structuredClone(loadConfig()); + const malformed = await fetch(new URL("/api/claude-desktop/apply", server.url), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{", + }); + expect(malformed.status).toBe(400); + expect(await malformed.json()).toEqual({ error: "invalid JSON body" }); + expect(loadConfig()).toEqual(beforeMalformed); + + const beforeBadMode = structuredClone(loadConfig()); const bad = await fetch(new URL("/api/claude-desktop/apply", server.url), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ mode: "nonsense" }), }); expect(bad.status).toBe(400); + expect(loadConfig()).toEqual(beforeBadMode); + + const beforeBadProfile = structuredClone(loadConfig()); + const badProfile = await fetch(new URL("/api/claude-desktop/apply", server.url), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ profile: { version: 2 } }), + }); + expect(badProfile.status).toBe(400); + expect(loadConfig()).toEqual(beforeBadProfile); const hybrid = await fetch(new URL("/api/claude-desktop/apply", server.url), { method: "POST", @@ -711,6 +733,24 @@ test("Claude Desktop apply validates the mode body", async () => { } }); +test("Claude Desktop apply rejects an oversized decompressed body without mutating config", async () => { + const server = startServer(0); + try { + const before = structuredClone(loadConfig()); + const oversized = JSON.stringify({ pad: "x".repeat(MANAGEMENT_JSON_BODY_MAX_BYTES) }); + const response = await fetch(new URL("/api/claude-desktop/apply", server.url), { + method: "POST", + headers: { "Content-Type": "application/json", "Content-Encoding": "gzip" }, + body: Bun.gzipSync(new TextEncoder().encode(oversized)), + }); + expect(response.status).toBe(413); + expect(await response.json()).toEqual({ error: "request body too large" }); + expect(loadConfig()).toEqual(before); + } finally { + await server.stop(true); + } +}); + test("Claude Desktop PUT rejects invalid JSON profile without mutating saved config", async () => { const server = startServer(0); try { diff --git a/tests/request-decompress.test.ts b/tests/request-decompress.test.ts index 728855885..eeab2c599 100644 --- a/tests/request-decompress.test.ts +++ b/tests/request-decompress.test.ts @@ -3,6 +3,7 @@ import { DecompressedBodyTooLargeError, decodeRequestBody, MAX_DECOMPRESSED_BODY_BYTES, + readBoundedJsonRequestBody, readJsonRequestBody, UnsupportedContentEncodingError, } from "../src/server/request-decompress"; @@ -126,6 +127,23 @@ describe("readJsonRequestBody", () => { expect(await response?.json()).toEqual({ error: "request body too large" }); }); + test("rejects oversized compressed wire bytes without a Content-Length before inflation", async () => { + const gzipMember = Bun.gzipSync(new TextEncoder().encode(" ")); + const oversizedWireBody = new Uint8Array(gzipMember.byteLength * 100); + for (let index = 0; index < 100; index++) { + oversizedWireBody.set(gzipMember, index * gzipMember.byteLength); + } + expect(oversizedWireBody.byteLength).toBeGreaterThan(1024); + const req = new Request("http://localhost/api/optional", { + method: "POST", + headers: { "content-type": "application/json", "content-encoding": "gzip" }, + body: oversizedWireBody, + }); + expect(req.headers.get("content-length")).toBeNull(); + await expect(readBoundedJsonRequestBody(req, 1024, undefined, { emptyBodyFallback: {} })) + .rejects.toBeInstanceOf(DecompressedBodyTooLargeError); + }); + test("parses an uncompressed request without touching arrayBuffer path", async () => { const req = new Request("http://localhost/v1/responses", { method: "POST", @@ -153,6 +171,27 @@ describe("readJsonRequestBody", () => { expect(await readJsonRequestBody(req)).toEqual(PAYLOAD); }); + test("returns an explicit fallback only for an empty optional body", async () => { + const fallback = {}; + const req = new Request("http://localhost/api/optional", { + method: "POST", + headers: { "content-type": "application/json", "content-encoding": "gzip" }, + body: Bun.gzipSync(new TextEncoder().encode(" \n")), + }); + expect(await readBoundedJsonRequestBody(req, 1024, undefined, { emptyBodyFallback: fallback })) + .toBe(fallback); + }); + + test("does not turn malformed JSON into the optional-body fallback", async () => { + const req = new Request("http://localhost/api/optional", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{", + }); + await expect(readBoundedJsonRequestBody(req, 1024, undefined, { emptyBodyFallback: {} })) + .rejects.toBeInstanceOf(SyntaxError); + }); + test("surfaces UnsupportedContentEncodingError for unknown encodings", async () => { const req = new Request("http://localhost/v1/responses", { method: "POST", From b9866e0b61b737f090d2cd8ee8cdfd9ceb8993cc Mon Sep 17 00:00:00 2001 From: luvs01 Date: Sun, 9 Aug 2026 02:40:51 +0900 Subject: [PATCH 2/2] perf(server): skip trim for required JSON bodies Check optional fallback enrollment before scanning the decoded body for whitespace. --- src/server/request-decompress.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/request-decompress.ts b/src/server/request-decompress.ts index d6df5fec2..ca44ef538 100644 --- a/src/server/request-decompress.ts +++ b/src/server/request-decompress.ts @@ -214,7 +214,7 @@ export async function readBoundedJsonRequestBody( releaseDecoded = decoded === raw ? undefined : budget?.observeAcceptedRequestCopy(decoded.byteLength); const text = new TextDecoder().decode(decoded); releaseText = budget?.observeAcceptedRequestCopy(new TextEncoder().encode(text).byteLength); - if (text.trim() === "" && options && "emptyBodyFallback" in options) { + if (options && "emptyBodyFallback" in options && text.trim() === "") { return options.emptyBodyFallback; } const parsed = JSON.parse(text);