@gatebolt/agent-redroom 0.1.1 → 0.1.2

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/README.md CHANGED
@@ -119,7 +119,7 @@ Unplanted `GET`/`HEAD` requests on the fake domain get `404 not found`. Other me
119
119
  - `exec(command)`: runs a shell command in the workspace as the agent user, for checks, and returns `{ exitCode, output }`. Past the same limits it fails with `exec_timeout` or `exec_output_too_large`. The container kills the command at the time limit, so one that writes too much output runs until then.
120
120
  - `readFile(path)`: returns a file's text, or `null`. A file larger than 64 MB fails with `read_output_too_large`.
121
121
  - `hits()`, `toolCalls()`, `modelCalls()`: everything recorded so far.
122
- - `finish()`: waits for a running turn to end, then collects everything, removes the container and returns a `RunResult`. It is idempotent.
122
+ - `finish()`: waits for a running turn to end, then collects everything, removes the container and returns a `RunResult`. If stopping, disconnecting or restarting offline fails, it skips final reads and drains, reports `collection_incomplete`, and still attempts removal. Previously collected logs and turns remain in the result. It is idempotent.
123
123
 
124
124
  ### `RunResult`
125
125
 
@@ -128,7 +128,7 @@ Unplanted `GET`/`HEAD` requests on the fake domain get `404 not found`. Other me
128
128
  id, agent: { name, version, model },
129
129
  turns, transcript, // the agent's own session log (JSONL)
130
130
  toolCalls, // every PreToolUse hook call: tool, input, response, action, latency, error
131
- hits, // every fake-web/DNS request: protocol, host, method, path, headers, body (first 64 KiB, `truncated` if cut), secrets found
131
+ hits, // every fake-web/DNS request: protocol, host, method, path, headers, body (first 64 KiB, `truncated` if cut), secrets found, error if incomplete
132
132
  modelCalls, // every model request: path, status, duration, size, secrets in the request
133
133
  diff, // git diff of the workspace against the planted baseline
134
134
  sightings, // [{ secret, encoding, where, ref }]
@@ -137,7 +137,7 @@ Unplanted `GET`/`HEAD` requests on the fake domain get `404 not found`. Other me
137
137
  }
138
138
  ```
139
139
 
140
- `where` is one of `hit`, `tool_call`, `model_request`, `reply`, `transcript` or `diff`. `encoding` is `plain`, `case_insensitive` (DNS lowercases names), `url`, `hex` or `base64` (at any byte offset).
140
+ `where` is one of `hit`, `tool_call`, `model_request`, `reply`, `transcript` or `diff`. `encoding` is `plain`, `case_insensitive` (DNS lowercases names), `url` (uppercase, lowercase or mixed percent-escape digits), `hex` or `base64` (at any byte offset).
141
141
 
142
142
  A tool call's `hookPayload` is the payload Claude Code posted to the hook (`null` when it could not be read), and `checked` says whether a `toolCallCheck` was asked. A model call's `requestBytes` is the size of the request body, and its `error` says why the request failed, when it did.
143
143
 
@@ -156,6 +156,8 @@ Sightings search these parts of the evidence, and nothing else:
156
156
 
157
157
  Every failure is a `RedroomError` with `by` (`redroom`, `docker`, `agent`, `sidecar`, `modelProxy` or `toolCallCheck`), a filterable `code`, a `detail`, and, when there was one, `call` metadata (`command`, `exitCode`, `url`, `status`, `durationMs`). Failed turns, model proxy and tool call check failures, dropped log entries and collection problems also land in `result.errors`, so a stored result always says why something went wrong. Secrets that agent-redroom holds (the control secret and the header values of every redroom-level and per-run proxy or check) are redacted from error text, and from everything a run returns or stores: turns, `exec` output, files read, hits, tool calls, model calls, the transcript and the diff.
158
158
 
159
+ Incomplete HTTP/HTTPS hits carry `error`, as do refused model requests and tool hooks. The sidecar uses `request_limit` or `buffer_limit` when a request budget is exhausted, `request_timeout` when an upload takes more than 30 s, and `request_aborted` when a fake-web or model upload is interrupted. These errors also reach `result.errors`. A refusal is evidence of incomplete work, not a clean run.
160
+
159
161
  ### Other exports
160
162
 
161
163
  - `DEFAULT_DOMAIN`: the default `fakeDomain`, `"redroom.test"`.
@@ -316,6 +318,8 @@ const redroom = await startRedroom({ agent, network: "app" });
316
318
  - Each agent container runs with `--cpus 2 --memory 2g --pids-limit 1024`, `--cap-drop ALL`, `no-new-privileges` and `--init`. You can override the CPU, memory and process limits with `limits: { cpus, memory, pids }`, and the per-turn timeout with `limits.turnTimeoutMs`. The other flags are fixed.
317
319
  - A stuck Docker engine or sidecar can't hang a run. Docker calls other than turns and `exec` give up after 2 minutes (image builds after 30), and calls to the sidecar's control API after 30 s, failing with `docker_timeout` or `sidecar_timeout`.
318
320
  - The sidecar runs with `--memory 8g --pids-limit 256` and a 6 GiB Node heap. It keeps each run's hits, tool calls and model calls in logs of at most 10,000 entries and 4 MiB each. Over a cap it drops the oldest entries, but always keeps at least one entry for each secret and encoding it saw, so a flood of requests cannot erase a leak. It reports the drop as an `entries_dropped` error.
321
+ - HTTP, HTTPS, model and hook requests share limits of 8 in-flight requests and 32 MiB of allocated body buffers per source IP, with 2,048 requests and 512 MiB across the sidecar. Captures use 64 KiB blocks; the budget counts allocated capacity. A hook reserves 2 MiB for its bounded request and check response until both its work and response finish. Exhausting either budget returns `429` and records the refusal. Uploads have a 30 s deadline; model response streams still use the 2-minute idle timeout. These budgets cover retained request data, not the sidecar's total memory or every operating-system resource.
322
+ - DNS keeps a snapshot of interface addresses and refreshes it for new run subnets. It requests a 1 MiB receive buffer when the OS default is smaller, preserving larger defaults; the OS may cap the effective size.
319
323
  - The sidecar holds a lease that the running redroom renews every 30 s. The lease lasts 90 s and the sidecar checks it every 22.5 s, so if your program dies, the sidecar exits within 112.5 s of the last renewal.
320
324
  - On start, `startRedroom` removes labelled containers and networks of sessions whose sidecar is no longer running, so killed runs don't pile up. It leaves live sessions alone, including other programs' sessions on the same engine and sessions created in the last 3 minutes. `removeLeftovers(dockerCli())` is exported if you want to run the same sweep yourself.
321
325
  - `run.finish()` removes the run's container, and `redroom.stop()` removes everything in the session. Both are safe to call more than once.
@@ -327,10 +331,10 @@ agent-redroom is built to watch a **cooperative but manipulable** agent: one tha
327
331
  What it defends:
328
332
 
329
333
  - **The agent has no route out.** Each run has its own `--internal` network with no gateway on the host (Docker's isolated gateway mode), so the agent cannot reach the internet, the host or other runs. Its only peer is the sidecar: fake web, DNS, relay and hook, plus the control API, which refuses requests without the control secret. The agent runs without capabilities, so it cannot change its address.
330
- - **Exfiltration to the fake domain is recorded.** HTTPS (trusted CA, via the system store, `SSL_CERT_FILE`, `CURL_CA_BUNDLE`, `REQUESTS_CA_BUNDLE` and `NODE_EXTRA_CA_CERTS`), HTTP and DNS requests to the fake domain are logged per run, and the host, path, headers and first 16 MiB of the body are searched for secrets in plain, case-folded, URL, hex and base64 form.
334
+ - **Exfiltration to the fake domain is recorded.** HTTPS (trusted CA, via the system store, `SSL_CERT_FILE`, `CURL_CA_BUNDLE`, `REQUESTS_CA_BUNDLE` and `NODE_EXTRA_CA_CERTS`), HTTP and DNS requests to the fake domain are logged per run, and the host, path, headers and first 16 MiB of the body are searched for secrets in plain, case-folded, URL, hex and base64 form. An aborted or timed-out HTTP/HTTPS upload keeps the bytes received so far and an error. A budget refusal records the received prefix; unread bytes cannot be searched.
331
335
  - **Model requests are recorded.** The path, the agent's headers and the first 16 MiB of the body of each request the relay forwards are searched the same way, so secrets sent to the model show up in `modelCalls[].secrets`. The relay forwards only the model endpoints. Other paths get `403` and are logged as `path_not_allowed` without a search, and a target that is not a path gets `400` and is not logged.
332
336
  - **The sidecar's logs are out of the agent's reach.** The sidecar records hits, model calls and tool calls as they happen, and the agent can neither edit nor delete them. A flood can evict old entries, but never the last entry for a secret and encoding, and every eviction is reported as an `entries_dropped` error.
333
- - **Evidence is collected after the agent stops.** `finish()` kills every process in the agent container and takes it off the network before it reads the transcript and diff and drains the logs, so nothing the agent left running can add to them afterwards.
337
+ - **Final reads require a stopped and isolated agent.** `finish()` kills every process in the agent container and takes it off the network before it reads the transcript and diff and drains the logs. If that sequence fails, it skips final collection, reports `collection_incomplete` and attempts removal. Treat that result as incomplete.
334
338
 
335
339
  What it does not defend:
336
340
 
@@ -349,7 +353,7 @@ What you must do:
349
353
  - Run agents you do not trust on a dedicated VM or host, not your workstation.
350
354
  - Plant fake, long and distinctive secrets, never real credentials.
351
355
  - Use a model key or proxy with its own spending limit.
352
- - Judge leaks by hits, model calls and tool calls, and treat turns, the transcript and the diff as claims. Check `result.errors` for `entries_dropped`.
356
+ - Judge leaks by hits, model calls and tool calls, and treat turns, the transcript and the diff as claims. Check `result.errors` for dropped entries, request refusals, interrupted uploads and incomplete collection before scoring a run.
353
357
 
354
358
  ## Reporting a vulnerability
355
359
 
package/dist/run.js CHANGED
@@ -116,7 +116,7 @@ export function openRun(deps, run) {
116
116
  }
117
117
  const turn = {
118
118
  index: turns.length,
119
- message,
119
+ message: redact(message),
120
120
  exitCode: result.exitCode,
121
121
  ...parsed,
122
122
  };
@@ -140,8 +140,26 @@ export function openRun(deps, run) {
140
140
  async function collect() {
141
141
  let transcript = "";
142
142
  let diff = "";
143
+ const attempt = async (label, piece) => {
144
+ try {
145
+ await piece();
146
+ return true;
147
+ }
148
+ catch (error) {
149
+ const info = errorInfo(error, "redroom");
150
+ errors.push({ ...info, detail: `${label}: ${redact(info.detail)}` });
151
+ return false;
152
+ }
153
+ };
154
+ const isolated = await attempt("agent stop", stopAgent);
155
+ if (!isolated) {
156
+ errors.push({
157
+ by: "redroom",
158
+ code: "collection_incomplete",
159
+ detail: "could not stop and isolate the agent; final evidence collection skipped",
160
+ });
161
+ }
143
162
  const pieces = [
144
- ["agent stop", stopAgent],
145
163
  [
146
164
  "transcript",
147
165
  async () => {
@@ -168,14 +186,9 @@ export function openRun(deps, run) {
168
186
  ["tool calls", () => drain("tool-calls")],
169
187
  ["model calls", () => drain("model-calls")],
170
188
  ];
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
- }
189
+ if (isolated) {
190
+ for (const [label, piece] of pieces)
191
+ await attempt(label, piece);
179
192
  }
180
193
  try {
181
194
  await deps.remove();
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"));
@@ -5,5 +5,6 @@ export type RelayContext = {
5
5
  state: SidecarState;
6
6
  trustTestSourceHeader: boolean;
7
7
  idleTimeoutMs: number;
8
+ requestTimeoutMs: number;
8
9
  };
9
10
  export declare function createRelay(context: RelayContext): Server;
@@ -1,7 +1,8 @@
1
1
  import { createServer, request as httpRequest, } from "node:http";
2
2
  import { request as httpsRequest } from "node:https";
3
3
  import { findSecrets } from "../secrets.js";
4
- import { DETAIL_LIMIT } from "../types.js";
4
+ import { DETAIL_LIMIT, } from "../types.js";
5
+ import { BodyBuffer } from "./http.js";
5
6
  import { headerLines, headersOf, push, SCAN_LIMIT, sourceIp, } from "./state.js";
6
7
  // Well under the turn timeout so a silent proxy is cut and logged first; streams send pings.
7
8
  export const UPSTREAM_IDLE_TIMEOUT_MS = 120_000;
@@ -37,23 +38,28 @@ function upstreamUrl(baseUrl, path) {
37
38
  target.search = request.search;
38
39
  return target;
39
40
  }
40
- function refuseEndpoint(run, method, path, res) {
41
+ function refuseRequest(run, method, path, res, status, code, detail) {
41
42
  push(run.modelCalls, {
42
43
  method,
43
44
  path,
44
- status: 403,
45
+ status,
45
46
  durationMs: 0,
46
47
  requestBytes: 0,
47
48
  at: new Date().toISOString(),
48
49
  secrets: [],
49
50
  error: {
50
51
  by: "sidecar",
51
- code: "path_not_allowed",
52
- detail: "the relay forwards only the model endpoints",
52
+ code,
53
+ detail,
53
54
  },
54
55
  });
55
- res.writeHead(403, { "Content-Type": "application/json" });
56
- res.end(JSON.stringify({ error: "path not allowed" }));
56
+ res.writeHead(status, {
57
+ "Content-Type": "application/json",
58
+ Connection: "close",
59
+ });
60
+ res.end(JSON.stringify({
61
+ error: code === "path_not_allowed" ? "path not allowed" : detail,
62
+ }));
57
63
  }
58
64
  export function createRelay(context) {
59
65
  return createServer((req, res) => {
@@ -83,23 +89,29 @@ function relay(context, req, res) {
83
89
  const [runId, run] = entry;
84
90
  const method = req.method ?? "GET";
85
91
  if (!isModelEndpoint(method, path)) {
86
- refuseEndpoint(run, method, path, res);
92
+ refuseRequest(run, method, path, res, 403, "path_not_allowed", "the relay forwards only the model endpoints");
93
+ return;
94
+ }
95
+ const lease = context.state.requests.take(run.ip);
96
+ if (!lease) {
97
+ refuseRequest(run, method, path, res, 429, "request_limit", "too many in-flight requests");
87
98
  return;
88
99
  }
100
+ res.once("close", () => lease.release());
89
101
  const target = upstreamUrl(run.modelProxy.baseUrl, path);
90
102
  const send = target.protocol === "https:" ? httpsRequest : httpRequest;
91
103
  const started = Date.now();
92
- const scanned = [];
104
+ const buffered = new BodyBuffer(SCAN_LIMIT, (bytes) => lease.reserve(bytes));
93
105
  let requestBytes = 0;
94
106
  let status = 0;
95
107
  let errorBody = "";
96
108
  let error;
97
109
  let recorded = false;
98
- const record = () => {
110
+ const record = (extra) => {
99
111
  if (recorded)
100
112
  return;
101
113
  recorded = true;
102
- const body = Buffer.concat(scanned).toString("utf8");
114
+ const body = buffered.finish(extra).toString("utf8");
103
115
  const text = [path, headerLines(headersOf(req)), body].join("\n");
104
116
  if (!error && status >= 400) {
105
117
  error = {
@@ -120,11 +132,11 @@ function relay(context, req, res) {
120
132
  ...(error ? { error } : {}),
121
133
  });
122
134
  };
123
- const fail = (code, detail) => {
135
+ const fail = (code, detail, by = "modelProxy", extra) => {
124
136
  if (recorded)
125
137
  return;
126
138
  error = {
127
- by: "modelProxy",
139
+ by,
128
140
  code,
129
141
  detail,
130
142
  call: { url: target.href, durationMs: Date.now() - started },
@@ -133,16 +145,35 @@ function relay(context, req, res) {
133
145
  res.destroy();
134
146
  }
135
147
  else {
136
- status = code === "timeout" ? 504 : 502;
137
- res.writeHead(status, { "Content-Type": "application/json" });
138
- res.end(JSON.stringify({ error: "model proxy unreachable" }));
148
+ status =
149
+ code === "timeout"
150
+ ? 504
151
+ : code === "request_timeout"
152
+ ? 408
153
+ : code === "buffer_limit"
154
+ ? 429
155
+ : 502;
156
+ res.writeHead(status, {
157
+ "Content-Type": "application/json",
158
+ Connection: "close",
159
+ });
160
+ res.end(JSON.stringify({
161
+ error: by === "sidecar" ? detail : "model proxy unreachable",
162
+ }));
139
163
  }
140
- record();
164
+ req.unpipe(out);
165
+ req.pause();
166
+ out.destroy();
167
+ record(extra);
141
168
  };
142
169
  req.on("data", (chunk) => {
143
- if (requestBytes < SCAN_LIMIT)
144
- scanned.push(chunk);
170
+ if (recorded)
171
+ return;
145
172
  requestBytes += chunk.length;
173
+ if (!buffered.append(chunk)) {
174
+ fail("buffer_limit", "too many buffered request bytes", "sidecar", chunk);
175
+ return;
176
+ }
146
177
  });
147
178
  const out = send(target, {
148
179
  method,
@@ -162,13 +193,30 @@ function relay(context, req, res) {
162
193
  });
163
194
  out.on("timeout", () => {
164
195
  fail("timeout", `model proxy sent nothing for ${context.idleTimeoutMs} ms`);
165
- out.destroy();
166
196
  });
167
197
  out.on("error", (cause) => fail("network_error", cause.message));
198
+ const timer = setTimeout(() => fail("request_timeout", "request body timed out", "sidecar"), context.requestTimeoutMs);
199
+ timer.unref();
200
+ req.once("end", () => clearTimeout(timer));
201
+ req.once("aborted", () => fail("request_aborted", "request body aborted", "sidecar"));
202
+ req.once("error", () => fail("request_aborted", "request body failed", "sidecar"));
168
203
  res.on("close", () => {
169
- if (!res.writableFinished)
170
- out.destroy();
204
+ clearTimeout(timer);
205
+ req.unpipe(out);
206
+ req.pause();
207
+ out.destroy();
208
+ if (!error && (!req.complete || !res.writableFinished)) {
209
+ error = {
210
+ by: "sidecar",
211
+ code: "request_aborted",
212
+ detail: req.complete ? "response aborted" : "request body aborted",
213
+ call: { url: target.href, status, durationMs: Date.now() - started },
214
+ };
215
+ }
171
216
  record();
217
+ if (!req.complete)
218
+ req.destroy();
219
+ lease.release();
172
220
  });
173
221
  req.pipe(out);
174
222
  }