@gatebolt/agent-redroom 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/redroom.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { randomBytes, randomInt, randomUUID } from "node:crypto";
2
2
  import {} from "./agents/agent.js";
3
- import { dockerCli, MAX_OUTPUT_MB, tarOf, withDefaultTimeouts, } from "./docker/cli.js";
3
+ import { dockerCli, failureReason, keepEnd, mustSucceed, outputTooLarge, tarOf, withDefaultTimeouts, } from "./docker/cli.js";
4
4
  import { AGENT_HOST, agentArgs, CA_DIR, CA_FILE, DEFAULT_LIMITS, GIT_INIT, sessionPlan, SIDECAR_HOST, SIDECAR_IMAGE, sidecarArgs, SYSTEM_CA_DIR, SYSTEM_CA_FILE, WORKSPACE, } from "./docker/plan.js";
5
5
  import { createInternalNetwork, LEASE_MS, LEASE_RENEW_MS, networkIp, removeLeftovers, tearDownSession, } from "./docker/session.js";
6
6
  import { withDeadline } from "./deadline.js";
@@ -10,6 +10,7 @@ import { absoluteEntries, servedPages, workspaceEntries } from "./planting.js";
10
10
  import { redactDeep, redactSecrets } from "./redact.js";
11
11
  import { openRun } from "./run.js";
12
12
  import { CONFIG_ENV, PORTS } from "./sidecar/config.js";
13
+ import { count, firstLine } from "./text.js";
13
14
  import { DETAIL_LIMIT, messageOf, RedroomError, } from "./types.js";
14
15
  export const DEFAULT_DOMAIN = "redroom.test";
15
16
  export const CONTROL_TIMEOUT_MS = 30 * 1000;
@@ -18,8 +19,10 @@ const READY_DELAY_MS = 500;
18
19
  const START_FAILED = "start_failed";
19
20
  const RUN_START_FAILED = "run_start_failed";
20
21
  const MAX_PARALLEL = 245;
22
+ // Node timers fire at once above this.
23
+ export const MAX_TIMEOUT_MS = 2_147_483_647;
21
24
  // Docker 28 added the isolated gateway mode; older engines ignore or reject it.
22
- const MIN_ENGINE_MAJOR = 28;
25
+ export const MIN_ENGINE_MAJOR = 28;
23
26
  const DOMAIN = /^[a-z0-9-]+(\.[a-z0-9-]+)*$/;
24
27
  export const defaultDeps = () => ({
25
28
  docker: dockerCli(),
@@ -71,11 +74,29 @@ function validate(options) {
71
74
  if (!DOMAIN.test(domain)) {
72
75
  throw new RedroomError("invalid_option", `fakeDomain must be a lowercase DNS name, got ${domain}`, "redroom");
73
76
  }
74
- return {
75
- parallel,
76
- domain,
77
- limits: { ...DEFAULT_LIMITS, ...options.limits },
78
- };
77
+ const limits = { ...DEFAULT_LIMITS, ...options.limits };
78
+ const { turnTimeoutMs } = limits;
79
+ if (!Number.isInteger(turnTimeoutMs) ||
80
+ turnTimeoutMs < 1 ||
81
+ turnTimeoutMs > MAX_TIMEOUT_MS) {
82
+ throw new RedroomError("invalid_option", `limits.turnTimeoutMs must be a whole number from 1 to ${MAX_TIMEOUT_MS}, got ${turnTimeoutMs}`, "redroom");
83
+ }
84
+ return { parallel, domain, limits };
85
+ }
86
+ export async function checkEngine(docker, redact = keepEnd) {
87
+ const version = await docker(["version", "--format", "{{.Server.Version}}"]);
88
+ const call = { command: "docker version", exitCode: version.exitCode };
89
+ if (version.outputTooLarge)
90
+ throw outputTooLarge("docker version", call);
91
+ if (version.exitCode !== 0) {
92
+ throw new RedroomError("docker_unavailable", `the Docker engine did not answer: ${redact(failureReason(version))}`, "docker", call);
93
+ }
94
+ const engine = version.stdout.trim();
95
+ const major = Number.parseInt(engine, 10);
96
+ if (Number.isNaN(major) || major < MIN_ENGINE_MAJOR) {
97
+ throw new RedroomError("docker_unavailable", `Docker Engine ${MIN_ENGINE_MAJOR} or newer is required to keep agents off host services, found ${engine || "no version"}`, "docker", call);
98
+ }
99
+ return engine;
79
100
  }
80
101
  export async function startRedroom(options) {
81
102
  return createRedroom(options, defaultDeps());
@@ -95,18 +116,7 @@ export async function createRedroom(options, deps) {
95
116
  const conceal = (text) => redactSecrets(text, [...hidden]);
96
117
  const redact = (text) => conceal(text).slice(-DETAIL_LIMIT);
97
118
  async function must(code, args, dockerOptions) {
98
- const result = await docker(args, dockerOptions);
99
- const call = {
100
- command: `docker ${args.slice(0, 2).join(" ")}`,
101
- exitCode: result.exitCode,
102
- };
103
- if (result.outputTooLarge) {
104
- throw new RedroomError("output_too_large", `docker ${args[0]} wrote more than ${MAX_OUTPUT_MB} MB of output`, "docker", call);
105
- }
106
- if (result.exitCode !== 0) {
107
- throw new RedroomError(code, `docker ${args[0]} failed (${result.exitCode}): ${redact(result.stderr.trim())}`, "docker", call);
108
- }
109
- return result;
119
+ return mustSucceed(docker, args, code, { ...dockerOptions, redact });
110
120
  }
111
121
  function fetchControl(base, path, init) {
112
122
  const timeoutError = () => new RedroomError("sidecar_timeout", `${path} did not answer within ${CONTROL_TIMEOUT_MS} ms`, "sidecar", { url: path });
@@ -115,17 +125,15 @@ export async function createRedroom(options, deps) {
115
125
  return new Response(await response.arrayBuffer(), response);
116
126
  });
117
127
  }
118
- const version = await docker(["version", "--format", "{{.Server.Version}}"]);
119
- if (version.exitCode !== 0) {
120
- throw new RedroomError("docker_unavailable", `the Docker engine did not answer: ${redact(version.stderr.trim())}`, "docker", { command: "docker version", exitCode: version.exitCode });
121
- }
122
- const engine = version.stdout.trim();
123
- if (Number.parseInt(engine, 10) < MIN_ENGINE_MAJOR) {
124
- throw new RedroomError("docker_unavailable", `Docker Engine ${MIN_ENGINE_MAJOR} or newer is required to keep agents off host services, found ${engine}`, "docker", { command: "docker version", exitCode: version.exitCode });
128
+ await checkEngine(docker, redact);
129
+ try {
130
+ const leftovers = await removeLeftovers(docker, Date.now(), redact);
131
+ if (leftovers.containers.length + leftovers.networks.length > 0) {
132
+ log(`agent-redroom: removed ${count(leftovers.containers.length, "container")} and ${count(leftovers.networks.length, "network")} left by stopped sessions`);
133
+ }
125
134
  }
126
- const leftovers = await removeLeftovers(docker);
127
- if (leftovers.containers.length + leftovers.networks.length > 0) {
128
- log(`agent-redroom: removed ${leftovers.containers.length} containers and ${leftovers.networks.length} networks left by stopped sessions`);
135
+ catch (error) {
136
+ log(`agent-redroom: could not remove leftovers: ${redact(firstLine(messageOf(error)))}`);
129
137
  }
130
138
  await must("image_build_failed", [
131
139
  ...["build", "-t", agent.image.tag],
@@ -141,7 +149,7 @@ export async function createRedroom(options, deps) {
141
149
  packagePath("dist", "sidecar-bundle"),
142
150
  ]);
143
151
  if (sidecarBuild.exitCode !== 0) {
144
- throw new RedroomError("image_build_failed", `could not build ${SIDECAR_IMAGE}; build the package so dist/sidecar-bundle/sidecar.mjs exists (${redact(sidecarBuild.stderr.trim())})`, "docker", { command: "docker build", exitCode: sidecarBuild.exitCode });
152
+ throw new RedroomError("image_build_failed", `could not build ${SIDECAR_IMAGE}; build the package so dist/sidecar-bundle/sidecar.mjs exists (${redact(failureReason(sidecarBuild))})`, "docker", { command: "docker build", exitCode: sidecarBuild.exitCode });
145
153
  }
146
154
  const reported = await must("version_failed", [
147
155
  ...["run", "--rm", "--network", "none", agent.image.tag],
package/dist/run.js CHANGED
@@ -112,11 +112,12 @@ export function openRun(deps, run) {
112
112
  }
113
113
  const { complete, ...parsed } = agent.parseTurn(stdout);
114
114
  if (!complete) {
115
- return fail(new RedroomError("turn_failed", `${agent.name} exited with code ${result.exitCode} without a result: ${redact(result.stderr.trim()).slice(-DETAIL_LIMIT)}`, "agent", call));
115
+ const stderr = redact(result.stderr.trim()).slice(-DETAIL_LIMIT);
116
+ return fail(new RedroomError("turn_failed", `${agent.name} exited with code ${result.exitCode} without a result${stderr && `: ${stderr}`}`, "agent", call));
116
117
  }
117
118
  const turn = {
118
119
  index: turns.length,
119
- message,
120
+ message: redact(message),
120
121
  exitCode: result.exitCode,
121
122
  ...parsed,
122
123
  };
@@ -140,8 +141,26 @@ export function openRun(deps, run) {
140
141
  async function collect() {
141
142
  let transcript = "";
142
143
  let diff = "";
144
+ const attempt = async (label, piece) => {
145
+ try {
146
+ await piece();
147
+ return true;
148
+ }
149
+ catch (error) {
150
+ const info = errorInfo(error, "redroom");
151
+ errors.push({ ...info, detail: `${label}: ${redact(info.detail)}` });
152
+ return false;
153
+ }
154
+ };
155
+ const isolated = await attempt("agent stop", stopAgent);
156
+ if (!isolated) {
157
+ errors.push({
158
+ by: "redroom",
159
+ code: "collection_incomplete",
160
+ detail: "could not stop and isolate the agent; final evidence collection skipped",
161
+ });
162
+ }
143
163
  const pieces = [
144
- ["agent stop", stopAgent],
145
164
  [
146
165
  "transcript",
147
166
  async () => {
@@ -168,14 +187,9 @@ export function openRun(deps, run) {
168
187
  ["tool calls", () => drain("tool-calls")],
169
188
  ["model calls", () => drain("model-calls")],
170
189
  ];
171
- for (const [label, piece] of pieces) {
172
- try {
173
- await piece();
174
- }
175
- catch (error) {
176
- const info = errorInfo(error, "redroom");
177
- errors.push({ ...info, detail: `${label}: ${redact(info.detail)}` });
178
- }
190
+ if (isolated) {
191
+ for (const [label, piece] of pieces)
192
+ await attempt(label, piece);
179
193
  }
180
194
  try {
181
195
  await deps.remove();
@@ -0,0 +1,12 @@
1
+ import { type ClaudeCodeOptions } from "./agents/claude-code.js";
2
+ import { type RedroomOptions } from "./redroom.js";
3
+ import { type RunOptions } from "./types.js";
4
+ export declare class ScenarioError extends Error {
5
+ }
6
+ export type Scenario = {
7
+ agent: Pick<ClaudeCodeOptions, "version" | "model" | "maxTurns" | "maxBudgetUsd">;
8
+ turns: string[];
9
+ redroom: Pick<RedroomOptions, "modelProxy" | "toolCallCheck" | "limits">;
10
+ run: RunOptions;
11
+ };
12
+ export declare function parseScenario(text: string, file: string, env: NodeJS.ProcessEnv): Scenario;
@@ -0,0 +1,130 @@
1
+ import { z } from "zod";
2
+ import {} from "./agents/claude-code.js";
3
+ import { MAX_TIMEOUT_MS } from "./redroom.js";
4
+ import {} from "./types.js";
5
+ export class ScenarioError extends Error {
6
+ }
7
+ const headerValue = z.union([
8
+ z.string(),
9
+ z.object({ env: z.string().min(1) }).strict(),
10
+ ]);
11
+ const headers = z.record(z.string().min(1), headerValue);
12
+ const scenarioSchema = z
13
+ .object({
14
+ agent: z
15
+ .object({
16
+ version: z.string().min(1),
17
+ model: z.string().min(1),
18
+ maxTurns: z.number().int().positive().optional(),
19
+ maxBudgetUsd: z.number().positive().optional(),
20
+ })
21
+ .strict(),
22
+ turns: z.array(z.string().min(1)).min(1),
23
+ turnTimeoutMs: z.number().int().positive().max(MAX_TIMEOUT_MS).optional(),
24
+ workspace: z
25
+ .record(z.union([
26
+ z.string(),
27
+ z
28
+ .object({ body: z.string(), executable: z.boolean().optional() })
29
+ .strict(),
30
+ ]))
31
+ .optional(),
32
+ pages: z
33
+ .record(z.union([
34
+ z.string(),
35
+ z
36
+ .object({
37
+ body: z.string(),
38
+ contentType: z.string().optional(),
39
+ status: z.number().int().optional(),
40
+ })
41
+ .strict(),
42
+ ]))
43
+ .optional(),
44
+ secrets: z.record(z.string()).optional(),
45
+ modelProxy: z
46
+ .object({ baseUrl: z.string().min(1), headers: headers.optional() })
47
+ .strict()
48
+ .optional(),
49
+ toolCallCheck: z
50
+ .object({ url: z.string().min(1), headers: headers.optional() })
51
+ .strict()
52
+ .optional(),
53
+ })
54
+ .strict();
55
+ // A union reports "Invalid input" at its own path; report the branch that got furthest instead.
56
+ function innermostIssue(issue) {
57
+ if (issue.code !== "invalid_union")
58
+ return issue;
59
+ const [first = issue, ...rest] = issue.unionErrors.flatMap(({ issues: [branch] }) => (branch ? [innermostIssue(branch)] : []));
60
+ return rest.reduce((best, next) => (next.path.length > best.path.length ? next : best), first);
61
+ }
62
+ function formatPath(path) {
63
+ if (path.length === 0)
64
+ return "(root)";
65
+ return path
66
+ .map((key, i) => {
67
+ if (typeof key === "number")
68
+ return `[${key}]`;
69
+ if (/^[A-Za-z_$][\w$]*$/.test(key))
70
+ return i === 0 ? key : `.${key}`;
71
+ return `[${JSON.stringify(key)}]`;
72
+ })
73
+ .join("");
74
+ }
75
+ export function parseScenario(text, file, env) {
76
+ let json;
77
+ try {
78
+ json = JSON.parse(text);
79
+ }
80
+ catch {
81
+ throw new ScenarioError(`${file}: invalid JSON`);
82
+ }
83
+ const parsed = scenarioSchema.safeParse(json);
84
+ if (!parsed.success) {
85
+ const [first] = parsed.error.issues;
86
+ if (!first)
87
+ throw new ScenarioError(`${file}: invalid scenario`);
88
+ const issue = innermostIssue(first);
89
+ throw new ScenarioError(`${file}: ${formatPath(issue.path)}: ${issue.message}`);
90
+ }
91
+ const { agent, turns, turnTimeoutMs, workspace, pages, secrets, modelProxy, toolCallCheck, } = parsed.data;
92
+ const resolveHeaders = (headers, field) => Object.fromEntries(Object.entries(headers).map(([name, value]) => {
93
+ if (typeof value === "string")
94
+ return [name, value];
95
+ const found = env[value.env];
96
+ if (!found) {
97
+ const path = formatPath([field, "headers", name]);
98
+ throw new ScenarioError(`${file}: ${path}: environment variable ${value.env} is not set or empty`);
99
+ }
100
+ return [name, found];
101
+ }));
102
+ return {
103
+ agent,
104
+ turns,
105
+ redroom: {
106
+ ...(turnTimeoutMs !== undefined && { limits: { turnTimeoutMs } }),
107
+ ...(modelProxy && {
108
+ modelProxy: {
109
+ baseUrl: modelProxy.baseUrl,
110
+ ...(modelProxy.headers && {
111
+ headers: resolveHeaders(modelProxy.headers, "modelProxy"),
112
+ }),
113
+ },
114
+ }),
115
+ ...(toolCallCheck && {
116
+ toolCallCheck: {
117
+ url: toolCallCheck.url,
118
+ ...(toolCallCheck.headers && {
119
+ headers: resolveHeaders(toolCallCheck.headers, "toolCallCheck"),
120
+ }),
121
+ },
122
+ }),
123
+ },
124
+ run: {
125
+ ...(workspace && { workspace }),
126
+ ...(pages && { pages }),
127
+ ...(secrets && { secrets }),
128
+ },
129
+ };
130
+ }
package/dist/secrets.js CHANGED
@@ -16,13 +16,13 @@ function base64Fragments(value) {
16
16
  }
17
17
  return [...fragments];
18
18
  }
19
- function matchIn(text, lower, value) {
19
+ function matchIn(text, lower, urlText, value) {
20
20
  if (text.includes(value))
21
21
  return "plain";
22
22
  if (lower.includes(value.toLowerCase()))
23
23
  return "case_insensitive";
24
24
  const url = encodeURIComponent(value);
25
- if (url !== value && text.includes(url))
25
+ if (url !== value && urlText.includes(url))
26
26
  return "url";
27
27
  if (lower.includes(Buffer.from(value).toString("hex")))
28
28
  return "hex";
@@ -35,10 +35,11 @@ export function findSecrets(text, secrets) {
35
35
  if (text === "")
36
36
  return [];
37
37
  const lower = text.toLowerCase();
38
+ const urlText = text.replace(/%[\da-f]{2}/gi, (escape) => escape.toUpperCase());
38
39
  return Object.entries(secrets).flatMap(([secret, value]) => {
39
40
  if (value === "")
40
41
  return [];
41
- const encoding = matchIn(text, lower, value);
42
+ const encoding = matchIn(text, lower, urlText, value);
42
43
  return encoding ? [{ secret, encoding }] : [];
43
44
  });
44
45
  }
@@ -1,5 +1,6 @@
1
1
  import { createSocket } from "node:dgram";
2
2
  import { normalizeIp } from "./state.js";
3
+ const RECEIVE_BUFFER_BYTES = 1024 * 1024;
3
4
  export const isFakeHost = (host, domain) => host === domain || host.endsWith(`.${domain}`);
4
5
  function parseQuestion(msg) {
5
6
  const labels = [];
@@ -59,10 +60,31 @@ export function answerQuery(msg, ip, context, source) {
59
60
  const subnetOf = (ip) => ip.split(".").slice(0, 3).join(".");
60
61
  export function createDns(context, ownIps) {
61
62
  const socket = createSocket("udp4");
63
+ socket.once("listening", () => {
64
+ try {
65
+ if (socket.getRecvBufferSize() < RECEIVE_BUFFER_BYTES) {
66
+ socket.setRecvBufferSize(RECEIVE_BUFFER_BYTES);
67
+ }
68
+ }
69
+ catch (error) {
70
+ socket.emit("error", error);
71
+ }
72
+ });
73
+ let bySubnet = new Map();
62
74
  socket.on("message", (msg, remote) => {
63
75
  const source = normalizeIp(remote.address);
64
- const ips = ownIps();
65
- const ip = ips.find((own) => subnetOf(own) === subnetOf(source)) ?? ips[0];
76
+ const subnet = subnetOf(source);
77
+ let ip = bySubnet.get(subnet);
78
+ if (ip === undefined) {
79
+ const ips = ownIps();
80
+ bySubnet = new Map();
81
+ for (const own of ips) {
82
+ const ownSubnet = subnetOf(own);
83
+ if (!bySubnet.has(ownSubnet))
84
+ bySubnet.set(ownSubnet, own);
85
+ }
86
+ ip = bySubnet.get(subnet) ?? ips[0];
87
+ }
66
88
  const reply = answerQuery(msg, ip, context, source);
67
89
  if (reply)
68
90
  socket.send(reply, remote.port, remote.address);
@@ -10,6 +10,7 @@ export type FakeWebContext = {
10
10
  state: SidecarState;
11
11
  domain: string;
12
12
  trustTestSourceHeader: boolean;
13
+ requestTimeoutMs: number;
13
14
  };
14
15
  export declare function createFakeHttps(context: FakeWebContext, tls: Tls): Server;
15
16
  export declare function createFakeHttp(context: FakeWebContext): Server;
@@ -7,6 +7,7 @@ import { join } from "node:path";
7
7
  import { promisify } from "node:util";
8
8
  import {} from "../types.js";
9
9
  import { isFakeHost } from "./dns.js";
10
+ import { BodyBuffer, sendJson } from "./http.js";
10
11
  import { headersOf, SCAN_LIMIT, sourceIp } from "./state.js";
11
12
  const run = promisify(execFile);
12
13
  const HIT_BODY_LIMIT = 64 * 1024;
@@ -32,25 +33,26 @@ export async function generateTls(domain) {
32
33
  }
33
34
  function fakeWeb(context, protocol) {
34
35
  return (req, res) => {
35
- const chunks = [];
36
+ const host = (req.headers.host ?? "").replace(/:\d+$/, "").toLowerCase();
37
+ if (!isFakeHost(host, context.domain)) {
38
+ res.writeHead(404, { Connection: "close" }).end();
39
+ return;
40
+ }
41
+ const source = sourceIp(req, context.trustTestSourceHeader);
42
+ const target = req.url ?? "/";
43
+ const url = URL.parse(target, "http://fake-web");
44
+ const method = req.method ?? "GET";
45
+ const path = url ? url.pathname + url.search : target;
46
+ const lease = context.state.requests.take(source);
47
+ const buffered = new BodyBuffer(SCAN_LIMIT, (bytes) => lease?.reserve(bytes) ?? false);
36
48
  let size = 0;
37
- req.on("data", (chunk) => {
38
- if (size < SCAN_LIMIT)
39
- chunks.push(chunk);
40
- size += chunk.length;
41
- });
42
- req.on("end", () => {
43
- const host = (req.headers.host ?? "").replace(/:\d+$/, "").toLowerCase();
44
- if (!isFakeHost(host, context.domain)) {
45
- res.writeHead(404).end();
49
+ let recorded = false;
50
+ const record = (error, extra) => {
51
+ if (recorded)
46
52
  return;
47
- }
48
- const target = req.url ?? "/";
49
- const url = URL.parse(target, "http://fake-web");
50
- const source = sourceIp(req, context.trustTestSourceHeader);
51
- const method = req.method ?? "GET";
52
- const path = url ? url.pathname + url.search : target;
53
- const body = Buffer.concat(chunks);
53
+ recorded = true;
54
+ clearTimeout(timer);
55
+ const body = buffered.finish(extra);
54
56
  context.state.recordHit(source, {
55
57
  protocol,
56
58
  host,
@@ -59,7 +61,58 @@ function fakeWeb(context, protocol) {
59
61
  headers: headersOf(req),
60
62
  body: body.subarray(0, HIT_BODY_LIMIT).toString("utf8"),
61
63
  truncated: size > HIT_BODY_LIMIT,
64
+ ...(error ? { error } : {}),
62
65
  }, body.toString("utf8"));
66
+ };
67
+ const refuse = (status, code, detail, extra) => {
68
+ record({ by: "sidecar", code, detail }, extra);
69
+ req.pause();
70
+ sendJson(res, status, { error: detail }, { Connection: "close" });
71
+ };
72
+ const timer = setTimeout(() => refuse(408, "request_timeout", "request body timed out"), context.requestTimeoutMs);
73
+ timer.unref();
74
+ res.once("finish", () => lease?.release());
75
+ res.once("close", () => {
76
+ record({
77
+ by: "sidecar",
78
+ code: "request_aborted",
79
+ detail: "request closed before its body ended",
80
+ });
81
+ lease?.release();
82
+ });
83
+ req.once("aborted", () => {
84
+ record({
85
+ by: "sidecar",
86
+ code: "request_aborted",
87
+ detail: "request body aborted",
88
+ });
89
+ res.destroy();
90
+ });
91
+ req.once("error", () => {
92
+ record({
93
+ by: "sidecar",
94
+ code: "request_aborted",
95
+ detail: "request body failed",
96
+ });
97
+ res.destroy();
98
+ });
99
+ if (!lease) {
100
+ refuse(429, "request_limit", "too many in-flight requests");
101
+ return;
102
+ }
103
+ req.on("data", (chunk) => {
104
+ if (recorded)
105
+ return;
106
+ size += chunk.length;
107
+ if (!buffered.append(chunk)) {
108
+ refuse(429, "buffer_limit", "too many buffered request bytes", chunk);
109
+ return;
110
+ }
111
+ });
112
+ req.on("end", () => {
113
+ if (recorded)
114
+ return;
115
+ record();
63
116
  if (!url) {
64
117
  res.writeHead(400, { "Content-Type": "text/plain" }).end("bad request");
65
118
  return;
@@ -1,7 +1,36 @@
1
1
  import { type IncomingMessage, type Server, type ServerResponse } from "node:http";
2
2
  export declare const BODY_LIMIT_BYTES: number;
3
+ export declare const REQUEST_TIMEOUT_MS = 30000;
4
+ export type RequestLimits = {
5
+ requests: number;
6
+ requestsPerSource: number;
7
+ bytes: number;
8
+ bytesPerSource: number;
9
+ };
10
+ export type RequestLease = {
11
+ reserve(bytes: number): boolean;
12
+ release(): void;
13
+ };
14
+ export declare class RequestBudget {
15
+ private readonly limits;
16
+ private readonly sources;
17
+ private requests;
18
+ private bytes;
19
+ constructor(limits?: Partial<RequestLimits>);
20
+ take(source: string): RequestLease | undefined;
21
+ }
3
22
  export declare class BodyTooLargeError extends Error {
4
23
  }
24
+ export declare class BodyBuffer {
25
+ private readonly limit;
26
+ private readonly reserve;
27
+ private blocks;
28
+ private size;
29
+ private capacity;
30
+ constructor(limit: number, reserve?: (bytes: number) => boolean);
31
+ append(chunk: Uint8Array): boolean;
32
+ finish(extra?: Uint8Array): Buffer;
33
+ }
5
34
  export declare function readBody(body: AsyncIterable<Uint8Array>, limit?: number): Promise<Buffer>;
6
35
  export declare function readJson(req: IncomingMessage): Promise<unknown>;
7
36
  export declare function sendJson(res: ServerResponse, status: number, body: unknown, headers?: Record<string, string>): void;
@@ -1,18 +1,120 @@
1
1
  import { timingSafeEqual } from "node:crypto";
2
2
  import { createServer, } from "node:http";
3
3
  export const BODY_LIMIT_BYTES = 1024 * 1024;
4
+ export const REQUEST_TIMEOUT_MS = 30_000;
5
+ export class RequestBudget {
6
+ limits;
7
+ sources = new Map();
8
+ requests = 0;
9
+ bytes = 0;
10
+ constructor(limits = {}) {
11
+ this.limits = {
12
+ requests: 2048,
13
+ requestsPerSource: 8,
14
+ bytes: 512 * 1024 * 1024,
15
+ bytesPerSource: 32 * 1024 * 1024,
16
+ ...limits,
17
+ };
18
+ }
19
+ take(source) {
20
+ const usage = this.sources.get(source) ?? { requests: 0, bytes: 0 };
21
+ if (this.requests >= this.limits.requests ||
22
+ usage.requests >= this.limits.requestsPerSource)
23
+ return undefined;
24
+ this.sources.set(source, usage);
25
+ this.requests += 1;
26
+ usage.requests += 1;
27
+ let bytes = 0;
28
+ let released = false;
29
+ return {
30
+ reserve: (size) => {
31
+ if (released ||
32
+ this.bytes + size > this.limits.bytes ||
33
+ usage.bytes + size > this.limits.bytesPerSource)
34
+ return false;
35
+ this.bytes += size;
36
+ usage.bytes += size;
37
+ bytes += size;
38
+ return true;
39
+ },
40
+ release: () => {
41
+ if (released)
42
+ return;
43
+ released = true;
44
+ this.requests -= 1;
45
+ usage.requests -= 1;
46
+ this.bytes -= bytes;
47
+ usage.bytes -= bytes;
48
+ if (usage.requests === 0)
49
+ this.sources.delete(source);
50
+ },
51
+ };
52
+ }
53
+ }
4
54
  export class BodyTooLargeError extends Error {
5
55
  }
56
+ const BODY_BLOCK_BYTES = 64 * 1024;
57
+ export class BodyBuffer {
58
+ limit;
59
+ reserve;
60
+ blocks = [];
61
+ size = 0;
62
+ capacity = 0;
63
+ constructor(limit, reserve = () => true) {
64
+ this.limit = limit;
65
+ this.reserve = reserve;
66
+ }
67
+ append(chunk) {
68
+ const length = Math.min(chunk.length, this.limit - this.size);
69
+ if (length === 0)
70
+ return true;
71
+ const end = this.size + length;
72
+ const capacity = Math.min(this.limit, Math.ceil(end / BODY_BLOCK_BYTES) * BODY_BLOCK_BYTES);
73
+ const needed = capacity - this.capacity;
74
+ if (needed > 0 && !this.reserve(needed))
75
+ return false;
76
+ while (this.capacity < capacity) {
77
+ const block = Buffer.alloc(Math.min(BODY_BLOCK_BYTES, this.limit - this.capacity));
78
+ this.blocks.push(block);
79
+ this.capacity += block.length;
80
+ }
81
+ let offset = 0;
82
+ while (offset < length) {
83
+ const block = this.blocks[Math.floor(this.size / BODY_BLOCK_BYTES)];
84
+ const at = this.size % BODY_BLOCK_BYTES;
85
+ const copied = Math.min(length - offset, block.length - at);
86
+ block.set(chunk.subarray(offset, offset + copied), at);
87
+ offset += copied;
88
+ this.size += copied;
89
+ }
90
+ return true;
91
+ }
92
+ finish(extra) {
93
+ const blocks = this.blocks;
94
+ const size = this.size;
95
+ this.blocks = [];
96
+ this.size = 0;
97
+ this.capacity = 0;
98
+ const last = blocks.at(-1);
99
+ if (last && size % BODY_BLOCK_BYTES !== 0) {
100
+ blocks[blocks.length - 1] = last.subarray(0, size % BODY_BLOCK_BYTES);
101
+ }
102
+ const extraLength = Math.min(extra?.length ?? 0, this.limit - size);
103
+ if (extra && extraLength > 0)
104
+ blocks.push(extra.subarray(0, extraLength));
105
+ return Buffer.concat(blocks, size + extraLength);
106
+ }
107
+ }
6
108
  export async function readBody(body, limit = BODY_LIMIT_BYTES) {
7
- const chunks = [];
109
+ const buffered = new BodyBuffer(limit);
8
110
  let size = 0;
9
111
  for await (const chunk of body) {
10
112
  size += chunk.length;
11
113
  if (size > limit)
12
114
  throw new BodyTooLargeError("body too large");
13
- chunks.push(chunk);
115
+ buffered.append(chunk);
14
116
  }
15
- return Buffer.concat(chunks);
117
+ return buffered.finish();
16
118
  }
17
119
  export async function readJson(req) {
18
120
  return JSON.parse((await readBody(req)).toString("utf8"));