diff --git a/tests/web/web-host.test.ts b/tests/web/web-host.test.ts index d02d2e36..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"; @@ -783,6 +784,134 @@ 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("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)); + 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 779d502a..c61c9612 100644 --- a/web/host/web-host.ts +++ b/web/host/web-host.ts @@ -40,6 +40,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; @@ -269,6 +292,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", }); @@ -649,23 +681,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) {