diff --git a/src/server/management/agent-settings-routes.ts b/src/server/management/agent-settings-routes.ts index d06d25630..3abeb3230 100644 --- a/src/server/management/agent-settings-routes.ts +++ b/src/server/management/agent-settings-routes.ts @@ -59,7 +59,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; @@ -703,21 +703,19 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise // #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 @@ -760,6 +758,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..e318557b1 100644 --- a/src/server/request-decompress.ts +++ b/src/server/request-decompress.ts @@ -90,6 +90,7 @@ 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); @@ -116,6 +117,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..283b0325b 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,6 +691,14 @@ 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 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" }); + const bad = await fetch(new URL("/api/claude-desktop/apply", server.url), { method: "POST", headers: { "Content-Type": "application/json" }, @@ -711,6 +720,22 @@ test("Claude Desktop apply validates the mode body", async () => { } }); +test("Claude Desktop apply rejects a decompressed body over the management limit", async () => { + const server = startServer(0); + try { + 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" }); + } finally { + await server.stop(true); + } +}); + test("Claude Desktop PUT rejects invalid JSON profile without mutating saved config", async () => { const server = startServer(0); try {