@gatebolt/agent-redroom 0.1.0 → 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 +10 -6
- package/dist/run.js +23 -10
- package/dist/secrets.js +4 -3
- package/dist/sidecar/dns.js +24 -2
- package/dist/sidecar/fake-web.d.ts +1 -0
- package/dist/sidecar/fake-web.js +70 -17
- package/dist/sidecar/http.d.ts +29 -0
- package/dist/sidecar/http.js +105 -3
- package/dist/sidecar/relay.d.ts +1 -0
- package/dist/sidecar/relay.js +70 -22
- package/dist/sidecar/sidecar.d.ts +3 -0
- package/dist/sidecar/sidecar.js +6 -2
- package/dist/sidecar/state.d.ts +3 -0
- package/dist/sidecar/state.js +5 -0
- package/dist/sidecar/tool-hook.d.ts +1 -0
- package/dist/sidecar/tool-hook.js +78 -29
- package/dist/sidecar-bundle/sidecar.mjs +410 -78
- package/dist/types.d.ts +1 -0
- package/package.json +2 -2
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
|
|
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
|
-
- **
|
|
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
|
|
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
|
-
|
|
172
|
-
|
|
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 &&
|
|
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
|
}
|
package/dist/sidecar/dns.js
CHANGED
|
@@ -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
|
|
65
|
-
|
|
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;
|
package/dist/sidecar/fake-web.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
49
|
-
const
|
|
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;
|
package/dist/sidecar/http.d.ts
CHANGED
|
@@ -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;
|
package/dist/sidecar/http.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
115
|
+
buffered.append(chunk);
|
|
14
116
|
}
|
|
15
|
-
return
|
|
117
|
+
return buffered.finish();
|
|
16
118
|
}
|
|
17
119
|
export async function readJson(req) {
|
|
18
120
|
return JSON.parse((await readBody(req)).toString("utf8"));
|
package/dist/sidecar/relay.d.ts
CHANGED
package/dist/sidecar/relay.js
CHANGED
|
@@ -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
|
|
41
|
+
function refuseRequest(run, method, path, res, status, code, detail) {
|
|
41
42
|
push(run.modelCalls, {
|
|
42
43
|
method,
|
|
43
44
|
path,
|
|
44
|
-
status
|
|
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
|
|
52
|
-
detail
|
|
52
|
+
code,
|
|
53
|
+
detail,
|
|
53
54
|
},
|
|
54
55
|
});
|
|
55
|
-
res.writeHead(
|
|
56
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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 =
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
164
|
+
req.unpipe(out);
|
|
165
|
+
req.pause();
|
|
166
|
+
out.destroy();
|
|
167
|
+
record(extra);
|
|
141
168
|
};
|
|
142
169
|
req.on("data", (chunk) => {
|
|
143
|
-
if (
|
|
144
|
-
|
|
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
|
-
|
|
170
|
-
|
|
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
|
}
|