Skip to content
Draft
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
29 changes: 14 additions & 15 deletions src/server/management/agent-settings-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
}
}
Expand Down
6 changes: 6 additions & 0 deletions src/server/management/body.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ export function readManagementJsonBody<T = unknown>(req: Request): Promise<T> {
return readBoundedJsonRequestBody(req, MANAGEMENT_JSON_BODY_MAX_BYTES) as Promise<T>;
}

export function readOptionalManagementJsonBody<T = unknown>(req: Request): Promise<T> {
return readBoundedJsonRequestBody(req, MANAGEMENT_JSON_BODY_MAX_BYTES, undefined, {
emptyBodyFallback: {},
}) as Promise<T>;
}

export function managementBodyTooLargeResponse(
error: unknown,
req: Request,
Expand Down
4 changes: 4 additions & 0 deletions src/server/request-decompress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ export async function readBoundedJsonRequestBody(
req: Request,
maxBytes: number,
budget?: TranslatorBudget,
options?: { emptyBodyFallback?: unknown },
): Promise<unknown> {
const encoding = req.headers.get("content-encoding");
const declaredLength = declaredBodyLength(req);
Expand All @@ -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;
Expand Down
25 changes: 25 additions & 0 deletions tests/claude-management-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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" },
Expand All @@ -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 {
Expand Down
Loading