From 619b7e1fba0a11fd5a66c4243e2363ae73322bd6 Mon Sep 17 00:00:00 2001 From: onychen <2752845347@qq.com> Date: Sat, 5 Sep 2026 13:05:57 +0800 Subject: [PATCH 1/2] fix(web): classify invalid request bodies --- tests/web/web-host.test.ts | 70 ++++++++++++++++++++++++++++++++++++++ web/host/web-host.ts | 67 +++++++++++++++++++++++++++++------- 2 files changed, 125 insertions(+), 12 deletions(-) diff --git a/tests/web/web-host.test.ts b/tests/web/web-host.test.ts index d80df4fa..77c6d427 100644 --- a/tests/web/web-host.test.ts +++ b/tests/web/web-host.test.ts @@ -697,6 +697,76 @@ async function startTestHost(runtime: WebRuntimeController) { return { host, launched, headers }; } +test("classifies invalid and oversized JSON bodies as client errors", async () => { + const cwd = await mkdtemp(join(tmpdir(), "openpi-web-request-body-")); + const { host, launched, headers } = await startTestHost(testRuntime(cwd)); + try { + const invalidJson = await fetch(`${launched.origin}/api/workspaces`, { + method: "POST", + headers: { ...headers, "Content-Type": "application/json" }, + body: '{"path":', + }); + assert.equal(invalidJson.status, 400); + assert.deepEqual(await invalidJson.json(), { + code: "INVALID_REQUEST_BODY", + error: "request body is invalid JSON", + }); + + const nonObjectJson = await fetch(`${launched.origin}/api/workspaces`, { + method: "POST", + headers: { ...headers, "Content-Type": "application/json" }, + body: "[]", + }); + assert.equal(nonObjectJson.status, 400); + assert.deepEqual(await nonObjectJson.json(), { + code: "INVALID_REQUEST_BODY", + error: "request body must be an object", + }); + + const oversizedBody = await fetch(`${launched.origin}/api/workspaces`, { + method: "POST", + headers: { ...headers, "Content-Type": "application/json" }, + body: JSON.stringify({ path: "x".repeat(16 * 1024) }), + }); + assert.equal(oversizedBody.status, 413); + assert.deepEqual(await oversizedBody.json(), { + code: "REQUEST_BODY_TOO_LARGE", + error: "request body is too large", + maxBytes: 16 * 1024, + }); + } finally { + await host.stop(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("keeps unexpected Web Host failures classified as server errors", async () => { + const cwd = await mkdtemp(join(tmpdir(), "openpi-web-server-error-")); + const { host, launched, headers } = await startTestHost(testRuntime(cwd)); + const adapter = ( + host as unknown as { + adapter: { importWorkspace(path: string): Promise }; + } + ).adapter; + adapter.importWorkspace = async () => { + throw new Error("unexpected adapter failure"); + }; + try { + const response = await fetch(`${launched.origin}/api/workspaces`, { + method: "POST", + headers: { ...headers, "Content-Type": "application/json" }, + body: JSON.stringify({ path: cwd }), + }); + assert.equal(response.status, 500); + assert.deepEqual(await response.json(), { + error: "unexpected adapter failure", + }); + } finally { + await host.stop(); + await rm(cwd, { recursive: true, force: true }); + } +}); + test("adapter initialization fails before the Host starts listening", async () => { const cwd = await mkdtemp(join(tmpdir(), "openpi-web-startup-failure-")); const runtime = testRuntime(cwd); diff --git a/web/host/web-host.ts b/web/host/web-host.ts index 6203e647..3abd2c72 100644 --- a/web/host/web-host.ts +++ b/web/host/web-host.ts @@ -37,6 +37,29 @@ const SERVER_CLOSE_DRAIN_MS = 500; const DEFAULT_SHUTDOWN_TIMEOUT_MS = 5_000; const execFileAsync = promisify(execFile); +type WebRequestErrorCode = + | "INVALID_REQUEST_BODY" + | "REQUEST_BODY_TOO_LARGE"; + +class WebRequestError extends Error { + readonly code: WebRequestErrorCode; + readonly statusCode: 400 | 413; + readonly maxBytes?: number; + + constructor( + message: string, + code: WebRequestErrorCode, + statusCode: 400 | 413, + maxBytes?: number, + ) { + super(message); + this.name = "WebRequestError"; + this.code = code; + this.statusCode = statusCode; + this.maxBytes = maxBytes; + } +} + export interface WebHostOptions { runtime: WebRuntimeController; onEvent?: (type: string, detail?: Record) => void; @@ -266,6 +289,15 @@ export class WebHost { await this.handle(request, response); } catch (error) { if (response.destroyed || response.writableEnded) return; + if (error instanceof WebRequestError) { + return this.json(response, error.statusCode, { + code: error.code, + error: error.message, + ...(error.maxBytes === undefined + ? {} + : { maxBytes: error.maxBytes }), + }); + } this.json(response, 500, { error: error instanceof Error ? error.message : "request failed", }); @@ -633,23 +665,34 @@ export class WebHost { for await (const chunk of request) { const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); bytes += buffer.length; - if (bytes > MAX_COMMAND_BYTES) - throw new Error("request body is too large"); + if (bytes > MAX_COMMAND_BYTES) { + throw new WebRequestError( + "request body is too large", + "REQUEST_BODY_TOO_LARGE", + 413, + MAX_COMMAND_BYTES, + ); + } chunks.push(buffer); } + let value: unknown; try { - const value: unknown = JSON.parse(Buffer.concat(chunks).toString("utf8")); - if (!value || typeof value !== "object" || Array.isArray(value)) { - throw new Error("request body must be an object"); - } - return value as Record; - } catch (error) { - throw new Error( - error instanceof SyntaxError - ? "request body is invalid JSON" - : String(error), + value = JSON.parse(Buffer.concat(chunks).toString("utf8")); + } catch { + throw new WebRequestError( + "request body is invalid JSON", + "INVALID_REQUEST_BODY", + 400, + ); + } + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new WebRequestError( + "request body must be an object", + "INVALID_REQUEST_BODY", + 400, ); } + return value as Record; } private authorized(request: IncomingMessage) { From 37ac204c04c238ea8a1c4c51b9355f9e08b071ff Mon Sep 17 00:00:00 2001 From: tt-a1i Date: Sun, 6 Sep 2026 00:11:15 +0800 Subject: [PATCH 2/2] test(web): cover chunked oversized request bodies --- tests/web/web-host.test.ts | 59 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/tests/web/web-host.test.ts b/tests/web/web-host.test.ts index 9f3f2fcc..7ac7b137 100644 --- a/tests/web/web-host.test.ts +++ b/tests/web/web-host.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import { once } from "node:events"; import { mkdtemp, rm } from "node:fs/promises"; +import { request as httpRequest } from "node:http"; import { createConnection } from "node:net"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -826,6 +827,64 @@ test("classifies invalid and oversized JSON bodies as client errors", async () = } }); +test("classifies an oversized body sent in multiple chunks as a client error", async () => { + const cwd = await mkdtemp(join(tmpdir(), "openpi-web-request-chunks-")); + const { host, launched, headers } = await startTestHost(testRuntime(cwd)); + const bodyLength = 64 * 1024; + let request: ReturnType | undefined; + const responsePromise = new Promise<{ + statusCode: number | undefined; + body: string; + }>((resolve, reject) => { + request = httpRequest( + { + hostname: launched.hostname, + port: Number(launched.port), + path: "/api/workspaces", + method: "POST", + headers: { + ...headers, + "Content-Type": "application/json", + "Content-Length": bodyLength, + }, + }, + (response) => { + let body = ""; + response.setEncoding("utf8"); + response.on("data", (chunk: string) => { + body += chunk; + }); + response.on("end", () => + resolve({ statusCode: response.statusCode, body }), + ); + }, + ); + request.once("error", reject); + request.write(Buffer.alloc(20 * 1024, "a")); + }); + + try { + const timeout = new Promise((_, reject) => { + const timer = setTimeout( + () => reject(new Error("timed out waiting for early 413 response")), + 2_000, + ); + timer.unref(); + }); + const response = await Promise.race([responsePromise, timeout]); + assert.equal(response.statusCode, 413); + assert.deepEqual(JSON.parse(response.body), { + code: "REQUEST_BODY_TOO_LARGE", + error: "request body is too large", + maxBytes: 16 * 1024, + }); + } finally { + request?.destroy(); + await host.stop(); + await rm(cwd, { recursive: true, force: true }); + } +}); + test("keeps unexpected Web Host failures classified as server errors", async () => { const cwd = await mkdtemp(join(tmpdir(), "openpi-web-server-error-")); const { host, launched, headers } = await startTestHost(testRuntime(cwd));