Skip to content
Merged
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
129 changes: 129 additions & 0 deletions tests/web/web-host.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<typeof httpRequest> | 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<never>((_, 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<string> };
}
).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);
Expand Down
67 changes: 55 additions & 12 deletions web/host/web-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>) => void;
Expand Down Expand Up @@ -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",
});
Expand Down Expand Up @@ -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<string, unknown>;
} 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<string, unknown>;
}

private authorized(request: IncomingMessage) {
Expand Down
Loading