@parcha/agentrun-kernel 0.0.0-bootstrap.0 → 0.1.0-beta.29

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.
Files changed (82) hide show
  1. package/CHANGELOG.md +135 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +6 -0
  4. package/README.md +53 -0
  5. package/dist/delegate.d.ts +87 -0
  6. package/dist/delegate.js +143 -0
  7. package/dist/errors.d.ts +36 -0
  8. package/dist/errors.js +37 -0
  9. package/dist/host/alive.d.ts +2 -0
  10. package/dist/host/alive.js +28 -0
  11. package/dist/host/box-slot.d.ts +26 -0
  12. package/dist/host/box-slot.js +112 -0
  13. package/dist/host/box.d.ts +104 -0
  14. package/dist/host/box.js +16 -0
  15. package/dist/host/egress-proxy.d.ts +45 -0
  16. package/dist/host/egress-proxy.js +416 -0
  17. package/dist/host/egress-table.d.ts +24 -0
  18. package/dist/host/egress-table.js +165 -0
  19. package/dist/host/index.d.ts +16 -0
  20. package/dist/host/index.js +12 -0
  21. package/dist/host/overhead.d.ts +53 -0
  22. package/dist/host/overhead.js +75 -0
  23. package/dist/host/parent-death.d.ts +15 -0
  24. package/dist/host/parent-death.js +42 -0
  25. package/dist/host/parent-watch.d.ts +1 -0
  26. package/dist/host/parent-watch.js +38 -0
  27. package/dist/host/pinned-binary.d.ts +6 -0
  28. package/dist/host/pinned-binary.js +48 -0
  29. package/dist/host/proxy.d.ts +179 -0
  30. package/dist/host/proxy.js +629 -0
  31. package/dist/host/serial.d.ts +3 -0
  32. package/dist/host/serial.js +10 -0
  33. package/dist/index.d.ts +11 -0
  34. package/dist/index.js +6 -0
  35. package/dist/kernel/blobs.d.ts +16 -0
  36. package/dist/kernel/blobs.js +59 -0
  37. package/dist/kernel/drive.d.ts +35 -0
  38. package/dist/kernel/drive.js +202 -0
  39. package/dist/kernel/errors.d.ts +27 -0
  40. package/dist/kernel/errors.js +42 -0
  41. package/dist/kernel/follow-up.d.ts +67 -0
  42. package/dist/kernel/follow-up.js +146 -0
  43. package/dist/kernel/halt.d.ts +11 -0
  44. package/dist/kernel/halt.js +17 -0
  45. package/dist/kernel/index.d.ts +8 -0
  46. package/dist/kernel/index.js +5 -0
  47. package/dist/kernel/types.d.ts +120 -0
  48. package/dist/kernel/types.js +1 -0
  49. package/dist/process/builtins.d.ts +142 -0
  50. package/dist/process/builtins.js +207 -0
  51. package/dist/process/inbox.d.ts +29 -0
  52. package/dist/process/inbox.js +52 -0
  53. package/dist/process/index.d.ts +9 -0
  54. package/dist/process/index.js +11 -0
  55. package/dist/process/journal.d.ts +111 -0
  56. package/dist/process/journal.js +232 -0
  57. package/dist/process/records.d.ts +143 -0
  58. package/dist/process/records.js +142 -0
  59. package/dist/process/recovery.d.ts +76 -0
  60. package/dist/process/recovery.js +139 -0
  61. package/dist/process/settle.d.ts +11 -0
  62. package/dist/process/settle.js +20 -0
  63. package/dist/process/tail.d.ts +20 -0
  64. package/dist/process/tail.js +53 -0
  65. package/dist/process/tools.d.ts +105 -0
  66. package/dist/process/tools.js +120 -0
  67. package/dist/process/zod.d.ts +32 -0
  68. package/dist/process/zod.js +109 -0
  69. package/dist/runner.d.ts +154 -0
  70. package/dist/runner.js +547 -0
  71. package/dist/schema-check-cases.d.ts +7 -0
  72. package/dist/schema-check-cases.js +73 -0
  73. package/dist/schema-check.d.ts +3 -0
  74. package/dist/schema-check.js +88 -0
  75. package/dist/skill-files.d.ts +23 -0
  76. package/dist/skill-files.js +136 -0
  77. package/dist/testing.d.ts +91 -0
  78. package/dist/testing.js +196 -0
  79. package/dist/types.d.ts +237 -0
  80. package/dist/types.js +10 -0
  81. package/package.json +76 -1
  82. package/vendor/durable-harness/UPSTREAM.json +67 -0
@@ -0,0 +1,416 @@
1
+ // A box's egress proxy, written for AgentRun: the one way out of a box whose network namespace has only its own loopback.
2
+ // It listens on a Unix socket (0600) in the session's local runtime directory, bound into the box, and speaks HTTP CONNECT, plain HTTP
3
+ // forwarding and SOCKS5 CONNECT (told apart by the first byte), each to the public internet only (egress-table.ts):
4
+ // - a host is resolved once, every address in the answer is checked, and the connection goes to exactly the address
5
+ // that was checked, so a name cannot point elsewhere between the check and the dial; an IP literal is checked the
6
+ // same way, by its bytes, whatever form the client wrote it in;
7
+ // - a refusal is an answer, sent before any socket opens: 403 (or SOCKS reply 2) with the class in its reason;
8
+ // - plain HTTP goes to the request URI's host, and a Host header that names another host is refused (400);
9
+ // - a redirect is never followed here: it reaches the client as the origin sent it, and the client's next request is
10
+ // one more request through this proxy, checked the same way;
11
+ // - SOCKS5 BIND and UDP ASSOCIATE are refused by name, and so is an HTTP upgrade;
12
+ // - a run has a cap on connections open at once and a budget of connections in all, each refused by name;
13
+ // - a log line holds the host, the port, the address and the class, never a path, a query, a header or a body;
14
+ // - with a host list (`allow`), a host off it, an IP literal and any port but 80 and 443 are refused before any lookup,
15
+ // so a name off the list never leaves the host (no DNS channel out); a listed name then meets the address rule as
16
+ // any other does.
17
+ // It holds no key. `close()` ends every connection and removes the socket.
18
+ import { randomBytes } from "node:crypto";
19
+ import { lookup as dnsLookup } from "node:dns";
20
+ import { chmod, link, lstat, unlink } from "node:fs/promises";
21
+ import { createServer as createHttpServer, request as httpRequest } from "node:http";
22
+ import { connect as netConnect, createServer } from "node:net";
23
+ import { networkInterfaces } from "node:os";
24
+ import { dirname, join } from "node:path";
25
+ import { addressBytes, checkAddress } from "./egress-table.js";
26
+ /** A host list's entry: a name of two labels or more, or `*.` and such a name. */
27
+ const ENTRY = /^(\*\.)?([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$/;
28
+ /** A host list from its entries, or a comma list of them: each lowercased, its trailing dot dropped, the repeats gone.
29
+ * Throws for an entry that is not a name of two labels or more, or `*.` and one (an address, a port, a URL, a bare `*`). */
30
+ export function parseAllow(entries) {
31
+ const list = (typeof entries === "string" ? entries.split(/[\s,]+/) : entries).map((entry) => entry.trim()).filter((entry) => entry !== "");
32
+ const out = [];
33
+ for (const entry of list) {
34
+ const name = entry.toLowerCase().replace(/\.$/, "");
35
+ if (!ENTRY.test(name) || addressBytes(name.replace(/^\*\./, "")) !== undefined) {
36
+ throw new Error(`the network list's entry ${JSON.stringify(entry)} is not a name with at least two labels, or *. and one`);
37
+ }
38
+ if (!out.includes(name))
39
+ out.push(name);
40
+ }
41
+ return out;
42
+ }
43
+ /** Whether `host` is on `list`: an exact name, or below a `*.suffix`; in any case, and without a trailing dot. */
44
+ const listedHost = (list, host) => {
45
+ const name = host.toLowerCase().replace(/\.$/, "");
46
+ return list.some((entry) => (entry.startsWith("*.") ? name.endsWith(entry.slice(1)) && name.length > entry.length - 1 : name === entry));
47
+ };
48
+ /** The ports a host list opens: plain HTTP and HTTPS. */
49
+ const LISTED_PORTS = [80, 443];
50
+ const HOP = new Set(["connection", "proxy-connection", "keep-alive", "proxy-authorization", "proxy-authenticate", "te", "trailer", "upgrade", "transfer-encoding"]);
51
+ /** The headers that stay on this hop: the fixed hop-by-hop names, and every name a Connection header lists. */
52
+ function hopHeaders(headers) {
53
+ const listed = [headers.connection ?? []].flat().flatMap((value) => String(value).split(",")).map((name) => name.trim().toLowerCase()).filter(Boolean);
54
+ return new Set([...HOP, ...listed]);
55
+ }
56
+ /** The canonical text of an address's bytes: what is dialled. */
57
+ function addressText(bytes) {
58
+ if (bytes.length === 4)
59
+ return [...bytes].join(".");
60
+ const groups = [];
61
+ for (let i = 0; i < 16; i += 2)
62
+ groups.push(((bytes[i] << 8) | bytes[i + 1]).toString(16));
63
+ return groups.join(":");
64
+ }
65
+ const hostOf = (text) => JSON.stringify(text.slice(0, 255));
66
+ export async function startEgressProxy(options) {
67
+ const { socketPath, maxTunnels, maxConnections, log } = options;
68
+ const allow = options.allow === undefined ? undefined : parseAllow(options.allow);
69
+ // The hint goes into a status line, which carries printable ASCII only: Node refuses anything else there (an em dash
70
+ // included), and a control character would end the line.
71
+ if (options.allowHint !== undefined && !/^[\x20-\x7e]*$/.test(options.allowHint))
72
+ throw new Error("the egress proxy's allowHint holds a character a status line cannot carry: printable ASCII only");
73
+ const hinted = (words) => (options.allowHint ? `${words}: ${options.allowHint}` : words);
74
+ /** The refusal of a target off the host list, or undefined: checked before any lookup. */
75
+ const offList = (host, port) => {
76
+ if (allow === undefined)
77
+ return undefined;
78
+ // A phrase holds only a plain host: anything else is named as such.
79
+ const shown = /^[A-Za-z0-9._:-]+$/.test(host) ? host : "the host";
80
+ const words = addressBytes(host) !== undefined
81
+ ? hinted(`${shown} is an address, not a name on this box's network list`)
82
+ : !listedHost(allow, host)
83
+ ? hinted(`${shown} is not on this box's network list`)
84
+ : !LISTED_PORTS.includes(port)
85
+ ? `${shown}:${port} is not open on this box's network list, which takes ports 80 and 443 only`
86
+ : undefined;
87
+ return words === undefined ? undefined : { status: 403, reason: "not-listed", socks: 2, words };
88
+ };
89
+ const lookup = options.lookup ?? ((hostname, opts, callback) => dnsLookup(hostname, opts, callback));
90
+ const dial = options.dial ?? ((address, port) => netConnect({ host: address, port }));
91
+ const sockets = new Set();
92
+ let open = 0;
93
+ let used = 0;
94
+ let closed = false;
95
+ const track = (socket) => {
96
+ sockets.add(socket);
97
+ socket.once("close", () => sockets.delete(socket));
98
+ if (closed)
99
+ socket.destroy();
100
+ };
101
+ /** The host's addresses at this moment: every interface's, and the seam's. */
102
+ const local = () => [
103
+ ...Object.values(networkInterfaces()).flatMap((list) => (list ?? []).map((entry) => entry.address)),
104
+ ...(options.alsoLocal?.() ?? []),
105
+ ];
106
+ /** Resolves `host` once and checks every answer: the address to dial, or the refusal. */
107
+ const resolve = async (host) => {
108
+ const literal = addressBytes(host);
109
+ let answers;
110
+ if (literal)
111
+ answers = [literal];
112
+ else {
113
+ if (!/^[A-Za-z0-9._-]+$/.test(host) || host.length > 253)
114
+ return { refused: { status: 400, reason: "invalid host", socks: 4 } };
115
+ try {
116
+ const found = await new Promise((ok, fail) => lookup(host, { all: true, verbatim: true }, (error, list) => (error ? fail(error) : ok(list))));
117
+ answers = found.map((entry) => addressBytes(entry.address)).filter((bytes) => bytes !== undefined);
118
+ }
119
+ catch {
120
+ return { refused: { status: 502, reason: "name not resolved", socks: 4 } };
121
+ }
122
+ if (answers.length === 0)
123
+ return { refused: { status: 502, reason: "name not resolved", socks: 4 } };
124
+ }
125
+ const here = local();
126
+ for (const bytes of answers) {
127
+ const verdict = checkAddress(bytes, here);
128
+ if (!verdict.ok)
129
+ return { refused: { status: 403, reason: verdict.class, socks: 2 }, address: addressText(bytes) };
130
+ }
131
+ return { address: addressText(answers[0]) };
132
+ };
133
+ /** A connection may open now: the cap and the budget. Takes a slot; `release` gives it back. */
134
+ const admit = () => {
135
+ if (open >= maxTunnels)
136
+ return { status: 429, reason: "tunnel cap", socks: 2 };
137
+ if (used >= maxConnections)
138
+ return { status: 429, reason: "connection budget", socks: 2 };
139
+ open++;
140
+ used++;
141
+ return undefined;
142
+ };
143
+ /** Resolve, check, admit and dial: the upstream socket once it connects, or the refusal. */
144
+ const reach = async (kind, host, port) => {
145
+ const unlisted = offList(host, port);
146
+ if (unlisted) {
147
+ log(`egress ${kind} host=${hostOf(host)} port=${port} ip=- class=${unlisted.reason}`);
148
+ return { refused: unlisted };
149
+ }
150
+ const target = await resolve(host);
151
+ if ("refused" in target) {
152
+ log(`egress ${kind} host=${hostOf(host)} port=${port} ip=${target.address ?? "-"} class=${target.refused.reason}`);
153
+ return target;
154
+ }
155
+ const full = admit();
156
+ if (full) {
157
+ log(`egress ${kind} host=${hostOf(host)} port=${port} ip=${target.address} class=${full.reason}`);
158
+ return { refused: full };
159
+ }
160
+ log(`egress ${kind} host=${hostOf(host)} port=${port} ip=${target.address} class=ok`);
161
+ const upstream = dial(target.address, port);
162
+ track(upstream);
163
+ upstream.once("close", () => open--);
164
+ return await new Promise((ok) => {
165
+ upstream.once("connect", () => ok({ socket: upstream, address: target.address }));
166
+ upstream.once("error", () => ok({ refused: { status: 502, reason: "dial failed", socks: 5 } }));
167
+ });
168
+ };
169
+ // HTTP: CONNECT, and plain requests in absolute form.
170
+ const http = createHttpServer();
171
+ const refuseRaw = (socket, refusal) => {
172
+ const body = `${refusal.words ?? `egress refused: ${refusal.reason}`}\n`;
173
+ socket.end(`HTTP/1.1 ${refusal.status} ${refusal.words ?? `Egress refused: ${refusal.reason}`}\r\nx-egress-refused: ${refusal.reason}\r\ncontent-type: text/plain\r\ncontent-length: ${Buffer.byteLength(body)}\r\nconnection: close\r\n\r\n${body}`);
174
+ };
175
+ const refuse = (res, refusal) => {
176
+ const body = `${refusal.words ?? `egress refused: ${refusal.reason}`}\n`;
177
+ res.writeHead(refusal.status, refusal.words ?? `Egress refused: ${refusal.reason}`, { "x-egress-refused": refusal.reason, "content-type": "text/plain", "content-length": Buffer.byteLength(body), connection: "close" });
178
+ res.end(body);
179
+ };
180
+ http.on("connect", (req, client, head) => {
181
+ void (async () => {
182
+ const target = /^\[([0-9A-Fa-f:.%a-zA-Z]+)\]:(\d{1,5})$/.exec(req.url ?? "") ?? /^([^:[\]/@\s]+):(\d{1,5})$/.exec(req.url ?? "");
183
+ const port = Number(target?.[2]);
184
+ if (!target || !(port >= 1 && port <= 65535))
185
+ return refuseRaw(client, { status: 400, reason: "invalid target", socks: 1 });
186
+ const reached = await reach("connect", target[1], port);
187
+ if ("refused" in reached)
188
+ return refuseRaw(client, reached.refused);
189
+ // A client that left during the lookup or the dial gets no tunnel: its socket goes, and so does its slot.
190
+ if (client.destroyed)
191
+ return void reached.socket.destroy();
192
+ client.write("HTTP/1.1 200 Connection Established\r\n\r\n");
193
+ if (head.length)
194
+ reached.socket.write(head);
195
+ reached.socket.pipe(client).pipe(reached.socket);
196
+ reached.socket.on("error", () => client.destroy());
197
+ client.on("error", () => reached.socket.destroy());
198
+ reached.socket.on("close", () => client.destroy());
199
+ client.on("close", () => reached.socket.destroy());
200
+ })();
201
+ });
202
+ http.on("upgrade", (_req, client) => refuseRaw(client, { status: 400, reason: "upgrade not supported", socks: 7 }));
203
+ http.on("request", (req, res) => {
204
+ // Watched from the start: a client that leaves during the lookup or the dial is gone before any listener below exists.
205
+ let gone = false;
206
+ res.once("close", () => (gone = true));
207
+ void (async () => {
208
+ let url;
209
+ try {
210
+ url = new URL(req.url ?? "");
211
+ }
212
+ catch {
213
+ return refuse(res, { status: 400, reason: "not an absolute URI", socks: 1 });
214
+ }
215
+ if (url.protocol !== "http:")
216
+ return refuse(res, { status: 400, reason: "not an http URI", socks: 1 });
217
+ const host = url.hostname.replace(/^\[|\]$/g, "");
218
+ const port = url.port === "" ? 80 : Number(url.port);
219
+ if (req.headers.host !== undefined) {
220
+ let named;
221
+ try {
222
+ named = new URL(`http://${req.headers.host}`);
223
+ }
224
+ catch { }
225
+ if (!named || named.hostname !== url.hostname || (named.port === "" ? 80 : Number(named.port)) !== port) {
226
+ log(`egress http host=${hostOf(host)} port=${port} ip=- class=host mismatch`);
227
+ return refuse(res, { status: 400, reason: "host mismatch", socks: 1 });
228
+ }
229
+ }
230
+ const reached = await reach("http", host, port);
231
+ if ("refused" in reached)
232
+ return refuse(res, reached.refused);
233
+ if (gone)
234
+ return void reached.socket.destroy();
235
+ const headers = {};
236
+ const hop = hopHeaders(req.headers);
237
+ for (const [name, value] of Object.entries(req.headers))
238
+ if (!hop.has(name))
239
+ headers[name] = value;
240
+ headers.host = url.host;
241
+ headers.connection = "close";
242
+ // The request goes over the socket already dialled to the checked address. Its host and port name that address too,
243
+ // so nothing else is reached even if the socket were not used (with `agent: false`, Node ignores createConnection
244
+ // and dials host and port itself).
245
+ const upstream = httpRequest({ host: reached.address, port, method: req.method, path: `${url.pathname}${url.search}`, headers, createConnection: () => reached.socket });
246
+ upstream.on("response", (answer) => {
247
+ const out = {};
248
+ const back = hopHeaders(answer.headers);
249
+ for (const [name, value] of Object.entries(answer.headers))
250
+ if (!back.has(name) && value !== undefined)
251
+ out[name] = value;
252
+ res.writeHead(answer.statusCode ?? 502, answer.statusMessage, out);
253
+ answer.pipe(res);
254
+ });
255
+ upstream.on("error", () => (res.headersSent ? res.destroy() : refuse(res, { status: 502, reason: "upstream failed", socks: 1 })));
256
+ req.pipe(upstream);
257
+ res.on("close", () => reached.socket.destroy());
258
+ })();
259
+ });
260
+ // SOCKS5: a greeting offering "no authentication", then one CONNECT.
261
+ const socks = (client) => {
262
+ let buffer = Buffer.alloc(0);
263
+ let stage = "greet";
264
+ const reply = (code) => Buffer.from([5, code, 0, 1, 0, 0, 0, 0, 0, 0]);
265
+ const onData = (chunk) => {
266
+ buffer = Buffer.concat([buffer, chunk]);
267
+ if (buffer.length > 1024)
268
+ return client.destroy();
269
+ if (stage === "greet") {
270
+ if (buffer.length < 2 || buffer.length < 2 + buffer[1])
271
+ return;
272
+ const methods = buffer.subarray(2, 2 + buffer[1]);
273
+ buffer = buffer.subarray(2 + buffer[1]);
274
+ if (!methods.includes(0))
275
+ return client.end(Buffer.from([5, 0xff]));
276
+ client.write(Buffer.from([5, 0]));
277
+ stage = "request";
278
+ }
279
+ if (stage !== "request" || buffer.length < 5)
280
+ return;
281
+ const [version, command, , type] = buffer;
282
+ if (version !== 5)
283
+ return client.destroy();
284
+ const length = type === 1 ? 4 : type === 4 ? 16 : type === 3 ? 1 + buffer[4] : -1;
285
+ if (length < 0)
286
+ return client.end(reply(8));
287
+ if (buffer.length < 4 + length + 2)
288
+ return;
289
+ stage = "done";
290
+ client.off("data", onData);
291
+ client.setTimeout(0);
292
+ const raw = buffer.subarray(4, 4 + length);
293
+ const host = type === 3 ? raw.subarray(1).toString("latin1") : addressText(raw);
294
+ const port = buffer.readUInt16BE(4 + length);
295
+ const rest = buffer.subarray(4 + length + 2);
296
+ if (command !== 1) {
297
+ const name = command === 2 ? "socks bind" : command === 3 ? "socks udp associate" : "socks command";
298
+ log(`egress socks host=${hostOf(host)} port=${port} ip=- class=${name}`);
299
+ return client.end(reply(7));
300
+ }
301
+ void (async () => {
302
+ const reached = await reach("socks", host, port);
303
+ if ("refused" in reached)
304
+ return client.end(reply(reached.refused.socks));
305
+ if (client.destroyed)
306
+ return void reached.socket.destroy();
307
+ client.write(reply(0));
308
+ if (rest.length)
309
+ reached.socket.write(rest);
310
+ reached.socket.pipe(client).pipe(reached.socket);
311
+ reached.socket.on("error", () => client.destroy());
312
+ client.on("error", () => reached.socket.destroy());
313
+ reached.socket.on("close", () => client.destroy());
314
+ client.on("close", () => reached.socket.destroy());
315
+ })();
316
+ };
317
+ client.on("data", onData);
318
+ };
319
+ const server = createServer((client) => {
320
+ track(client);
321
+ client.on("error", () => client.destroy());
322
+ client.setTimeout(30_000, () => client.destroy());
323
+ client.once("data", (first) => {
324
+ client.pause();
325
+ client.unshift(first);
326
+ if (first[0] === 5) {
327
+ socks(client);
328
+ client.resume();
329
+ }
330
+ else {
331
+ client.setTimeout(0);
332
+ http.emit("connection", client);
333
+ client.resume();
334
+ }
335
+ });
336
+ });
337
+ // A socket already at the path belongs to another proxy while it answers, and is never taken from it. The socket's
338
+ // directory is local; a connect refused there means nothing listens. So only a socket that refuses a connection is
339
+ // removed; any other failure of the probe (EACCES, say) is thrown and the socket left, and anything that is not a socket
340
+ // is left where it is. (On a disk shared between hosts, a live proxy's socket on another host would refuse this host's
341
+ // connection: that is why the directory must be local.) Two starts that both find the same dead socket could each
342
+ // remove it in turn, the second after the first has linked its live socket there; that needs two starters in one
343
+ // session's runtime directory, which its single claim holder rules out, so nothing here guards it (moving the socket
344
+ // aside to check it would leave a live one briefly missing from its path instead).
345
+ const taken = (code, what) => Object.assign(new Error(`egress proxy: ${socketPath} ${what}`), { code });
346
+ let found;
347
+ try {
348
+ found = await lstat(socketPath);
349
+ }
350
+ catch (error) {
351
+ if (error.code !== "ENOENT")
352
+ throw error;
353
+ }
354
+ if (found) {
355
+ if (!found.isSocket())
356
+ throw taken("egress_socket_path_taken", "exists and is not a socket");
357
+ const answer = await new Promise((ok) => {
358
+ const probe = netConnect(socketPath);
359
+ probe.once("connect", () => {
360
+ probe.destroy();
361
+ ok(true);
362
+ });
363
+ probe.once("error", (error) => ok(error));
364
+ });
365
+ if (answer === true)
366
+ throw taken("egress_socket_in_use", "is a live egress proxy's socket");
367
+ if (answer.code !== "ECONNREFUSED")
368
+ throw answer;
369
+ await unlink(socketPath);
370
+ }
371
+ // Made under a name of its own and made 0600 before it gets its path, so it is never reachable there with a wider
372
+ // mode. The path is a hard link, never a rename. Between the check above and this link, another start can put a socket
373
+ // at the path: a link then fails (EEXIST, refused as egress_socket_in_use), where a rename would replace that socket
374
+ // silently. No test can hold that window open, so this comment is its record. The temporary name is removed once linked.
375
+ const making = join(dirname(socketPath), `.egress-${randomBytes(6).toString("hex")}.sock`);
376
+ await new Promise((ok, fail) => {
377
+ server.once("error", fail);
378
+ server.listen(making, () => {
379
+ server.off("error", fail);
380
+ ok();
381
+ });
382
+ });
383
+ try {
384
+ await chmod(making, 0o600);
385
+ await link(making, socketPath);
386
+ }
387
+ catch (error) {
388
+ server.close();
389
+ await unlink(making).catch(() => { });
390
+ if (error.code === "EEXIST")
391
+ throw taken("egress_socket_in_use", "was taken while this proxy started");
392
+ throw error;
393
+ }
394
+ // This socket's inode: close removes the path only while it still holds it, never a socket another start put there.
395
+ const mine = await lstat(making);
396
+ await unlink(making).catch(() => { });
397
+ return {
398
+ socketPath,
399
+ async close() {
400
+ if (closed)
401
+ return;
402
+ closed = true;
403
+ const done = new Promise((ok) => server.close(() => ok()));
404
+ for (const socket of sockets)
405
+ socket.destroy();
406
+ http.close();
407
+ await done;
408
+ const now = await lstat(socketPath).catch((error) => {
409
+ if (error.code !== "ENOENT")
410
+ throw error;
411
+ });
412
+ if (now && now.dev === mine.dev && now.ino === mine.ino)
413
+ await unlink(socketPath);
414
+ },
415
+ };
416
+ }
@@ -0,0 +1,24 @@
1
+ /** Why an address is refused. */
2
+ export type EgressClass = "loopback" | "unspecified" | "private" | "cgnat" | "link-local" | "metadata" | "multicast" | "broadcast" | "reserved" | "benchmarking" | "this-host" | "invalid";
3
+ export type EgressVerdict = {
4
+ ok: true;
5
+ } | {
6
+ ok: false;
7
+ class: EgressClass;
8
+ };
9
+ /** The table's ranges as written, in order: what the no-drift test pins. */
10
+ export declare const EGRESS_RANGES: readonly string[];
11
+ /**
12
+ * An IP address's bytes (4 or 16), or undefined when `text` is not one. Takes what a socket or a URL could carry for a
13
+ * host: a dotted IPv4 address, or the forms the classic resolver reads as one (a single number, 0x hex and 0 octal
14
+ * parts, fewer than four parts with the last filling the rest), and an IPv6 address, bracketed or not, with or without a
15
+ * zone. Never a name.
16
+ */
17
+ export declare function addressBytes(text: string): Uint8Array | undefined;
18
+ /**
19
+ * Whether a box may reach `address` (its bytes, or an address's text: never a name). `local` is the host's own interface
20
+ * addresses, read by the caller at the dial: an address equal to one of them is refused as "this-host". An IPv6 address
21
+ * that carries an IPv4 one is judged by the IPv4 inside, and is then still the host's own if the host holds that IPv6
22
+ * address itself (the dial goes to the IPv6 address, not to the IPv4 inside it).
23
+ */
24
+ export declare function checkAddress(address: Uint8Array | string, local: readonly string[]): EgressVerdict;
@@ -0,0 +1,165 @@
1
+ // The address rule of a box's egress, written for AgentRun: which addresses a box may reach through its egress proxy.
2
+ // Everything public; nothing private or local. It judges the binary address a socket would connect to, never the text a
3
+ // client wrote, so a single decimal number, octal or hex parts, or a bracketed IPv6 address is the address it parses to. An IPv6 address that carries
4
+ // an IPv4 one (mapped, NAT64, 6to4) is judged by the IPv4 inside. The host's own interface addresses are refused too: the
5
+ // caller reads them at each dial and passes them in, so the table itself stays pure. This is the one place the rule
6
+ // lives; the key gate's denylist marker accepts exactly these literals, and a test pins the list against drift.
7
+ import { isIP } from "node:net";
8
+ /** The refused ranges, specific ones first (the first match decides): a single metadata address before the range it sits
9
+ * in, the broadcast address before the reserved block. Past the table, an IPv6 address outside global unicast
10
+ * (2000::/3) is refused as reserved, in code: the IPv4-compatible form (::a.b.c.d) and every unassigned block among
11
+ * them. The mapped and NAT64 forms sit outside 2000::/3 too, and are judged by their IPv4 before that rule. */
12
+ const TABLE = [
13
+ { cidr: "0.0.0.0/8", class: "unspecified" }, // key-gate: denylist unspecified, refused by the egress proxy
14
+ { cidr: "127.0.0.0/8", class: "loopback" }, // key-gate: loopback-rule loopback, refused by the egress proxy
15
+ { cidr: "10.0.0.0/8", class: "private" }, // key-gate: denylist RFC 1918, refused by the egress proxy
16
+ { cidr: "172.16.0.0/12", class: "private" }, // key-gate: denylist RFC 1918, refused by the egress proxy
17
+ { cidr: "192.168.0.0/16", class: "private" }, // key-gate: denylist RFC 1918, refused by the egress proxy
18
+ { cidr: "192.0.0.0/24", class: "private" }, // key-gate: denylist IETF protocol assignments, refused by the egress proxy
19
+ { cidr: "100.100.100.200/32", class: "metadata" }, // key-gate: denylist a cloud metadata address, refused by the egress proxy
20
+ { cidr: "100.64.0.0/10", class: "cgnat" }, // key-gate: denylist shared address space, refused by the egress proxy
21
+ { cidr: "168.63.129.16/32", class: "metadata" }, // key-gate: denylist a cloud metadata address, refused by the egress proxy
22
+ { cidr: "169.254.0.0/16", class: "link-local" }, // key-gate: denylist link-local, refused by the egress proxy
23
+ { cidr: "198.18.0.0/15", class: "benchmarking" }, // key-gate: denylist benchmarking, refused by the egress proxy
24
+ { cidr: "192.0.2.0/24", class: "reserved" }, // key-gate: denylist documentation, refused by the egress proxy
25
+ { cidr: "198.51.100.0/24", class: "reserved" }, // key-gate: denylist documentation, refused by the egress proxy
26
+ { cidr: "203.0.113.0/24", class: "reserved" }, // key-gate: denylist documentation, refused by the egress proxy
27
+ { cidr: "192.88.99.0/24", class: "reserved" }, // key-gate: denylist the 6to4 relay anycast, refused by the egress proxy
28
+ { cidr: "224.0.0.0/4", class: "multicast" }, // key-gate: denylist multicast, refused by the egress proxy
29
+ { cidr: "255.255.255.255/32", class: "broadcast" }, // key-gate: denylist broadcast, refused by the egress proxy
30
+ { cidr: "240.0.0.0/4", class: "reserved" }, // key-gate: denylist reserved, refused by the egress proxy
31
+ { cidr: "::1/128", class: "loopback" }, // key-gate: loopback-rule loopback, refused by the egress proxy
32
+ { cidr: "::/128", class: "unspecified" }, // key-gate: denylist unspecified, refused by the egress proxy
33
+ { cidr: "::ffff:0:0/96", class: "last32" }, // key-gate: denylist IPv4-mapped, judged by the IPv4 inside
34
+ { cidr: "64:ff9b::/96", class: "last32" }, // key-gate: denylist NAT64, judged by the IPv4 inside
35
+ // Local-use NAT64 (RFC 8215): its operator picks a prefix of /48 to /96 inside it, so where an IPv4 sits cannot be read
36
+ // from the address alone. The whole block is refused.
37
+ { cidr: "64:ff9b:1::/48", class: "private" }, // key-gate: denylist local-use NAT64, refused by the egress proxy
38
+ { cidr: "2002::/16", class: "6to4" }, // key-gate: denylist 6to4, judged by the IPv4 inside
39
+ { cidr: "2001::/32", class: "reserved" }, // key-gate: denylist Teredo, refused by the egress proxy
40
+ { cidr: "2001:db8::/32", class: "reserved" }, // key-gate: denylist documentation, refused by the egress proxy
41
+ { cidr: "fd00:ec2::/32", class: "metadata" }, // key-gate: denylist a cloud metadata range, refused by the egress proxy
42
+ { cidr: "fc00::/7", class: "private" }, // key-gate: denylist unique local, refused by the egress proxy
43
+ { cidr: "fe80::/10", class: "link-local" }, // key-gate: denylist link-local, refused by the egress proxy
44
+ { cidr: "ff00::/8", class: "multicast" }, // key-gate: denylist multicast, refused by the egress proxy
45
+ ];
46
+ /** The table's ranges as written, in order: what the no-drift test pins. */
47
+ export const EGRESS_RANGES = TABLE.map((entry) => entry.cidr);
48
+ /**
49
+ * An IP address's bytes (4 or 16), or undefined when `text` is not one. Takes what a socket or a URL could carry for a
50
+ * host: a dotted IPv4 address, or the forms the classic resolver reads as one (a single number, 0x hex and 0 octal
51
+ * parts, fewer than four parts with the last filling the rest), and an IPv6 address, bracketed or not, with or without a
52
+ * zone. Never a name.
53
+ */
54
+ export function addressBytes(text) {
55
+ let host = text.trim();
56
+ if (host.startsWith("[") && host.endsWith("]"))
57
+ host = host.slice(1, -1);
58
+ const zone = host.indexOf("%");
59
+ if (zone >= 0 && host.includes(":"))
60
+ host = host.slice(0, zone);
61
+ if (isIP(host) === 6)
62
+ return v6Bytes(host);
63
+ return v4Bytes(host);
64
+ }
65
+ /** The classic resolver's reading of an IPv4 address (inet_aton): 1 to 4 parts, each decimal, 0x hex or 0 octal. */
66
+ function v4Bytes(text) {
67
+ const parts = text.split(".");
68
+ if (parts.length < 1 || parts.length > 4)
69
+ return undefined;
70
+ const values = [];
71
+ for (const part of parts) {
72
+ let value;
73
+ if (/^0[xX][0-9a-fA-F]*$/.test(part))
74
+ value = part.length === 2 ? 0 : parseInt(part.slice(2), 16);
75
+ else if (/^0[0-7]*$/.test(part))
76
+ value = parseInt(part, 8);
77
+ else if (/^[1-9][0-9]*$/.test(part))
78
+ value = parseInt(part, 10);
79
+ else
80
+ return undefined;
81
+ if (!Number.isSafeInteger(value))
82
+ return undefined;
83
+ values.push(value);
84
+ }
85
+ const last = values.pop();
86
+ if (values.some((v) => v > 255) || last >= 2 ** (8 * (4 - values.length)))
87
+ return undefined;
88
+ const out = new Uint8Array(4);
89
+ values.forEach((v, i) => (out[i] = v));
90
+ for (let i = 3, rest = last; i >= values.length; i--, rest = Math.floor(rest / 256))
91
+ out[i] = rest % 256;
92
+ return out;
93
+ }
94
+ /** An IPv6 address's 16 bytes; `text` is one `isIP` accepts. */
95
+ function v6Bytes(text) {
96
+ let head = text;
97
+ const dotted = /^(.*:)(\d+\.\d+\.\d+\.\d+)$/.exec(text);
98
+ if (dotted) {
99
+ const v4 = v4Bytes(dotted[2]);
100
+ if (!v4 || dotted[2].split(".").length !== 4)
101
+ return undefined;
102
+ head = `${dotted[1]}${((v4[0] << 8) | v4[1]).toString(16)}:${((v4[2] << 8) | v4[3]).toString(16)}`;
103
+ }
104
+ const [left, right] = head.split("::");
105
+ const groups = (s) => (s === "" ? [] : s.split(":").map((g) => parseInt(g, 16)));
106
+ const l = groups(left);
107
+ const r = right === undefined ? [] : groups(right);
108
+ const fill = right === undefined ? 0 : 8 - l.length - r.length;
109
+ const all = [...l, ...Array(Math.max(fill, 0)).fill(0), ...r];
110
+ if (all.length !== 8 || all.some((g) => !(g >= 0 && g <= 0xffff)))
111
+ return undefined;
112
+ const out = new Uint8Array(16);
113
+ all.forEach((g, i) => {
114
+ out[2 * i] = g >> 8;
115
+ out[2 * i + 1] = g & 0xff;
116
+ });
117
+ return out;
118
+ }
119
+ const RANGES = TABLE.map((entry) => {
120
+ const [base, bits] = entry.cidr.split("/");
121
+ return { bytes: addressBytes(base), bits: Number(bits), class: entry.class };
122
+ });
123
+ const within = (address, range) => {
124
+ if (address.length !== range.bytes.length)
125
+ return false;
126
+ for (let bit = 0; bit < range.bits; bit += 8) {
127
+ const mask = range.bits - bit >= 8 ? 0xff : (0xff << (8 - (range.bits - bit))) & 0xff;
128
+ if ((address[bit / 8] & mask) !== (range.bytes[bit / 8] & mask))
129
+ return false;
130
+ }
131
+ return true;
132
+ };
133
+ const same = (a, b) => a.length === b.length && a.every((byte, i) => byte === b[i]);
134
+ /**
135
+ * Whether a box may reach `address` (its bytes, or an address's text: never a name). `local` is the host's own interface
136
+ * addresses, read by the caller at the dial: an address equal to one of them is refused as "this-host". An IPv6 address
137
+ * that carries an IPv4 one is judged by the IPv4 inside, and is then still the host's own if the host holds that IPv6
138
+ * address itself (the dial goes to the IPv6 address, not to the IPv4 inside it).
139
+ */
140
+ export function checkAddress(address, local) {
141
+ const bytes = typeof address === "string" ? addressBytes(address) : address;
142
+ if (!bytes || (bytes.length !== 4 && bytes.length !== 16))
143
+ return { ok: false, class: "invalid" };
144
+ for (const range of RANGES) {
145
+ if (!within(bytes, range))
146
+ continue;
147
+ if (range.class === "last32" || range.class === "6to4") {
148
+ const inner = checkAddress(range.class === "last32" ? bytes.slice(12, 16) : bytes.slice(2, 6), local);
149
+ return inner.ok ? ownAddress(bytes, local) : inner;
150
+ }
151
+ return { ok: false, class: range.class };
152
+ }
153
+ if (bytes.length === 16 && (bytes[0] & 0xe0) !== 0x20)
154
+ return { ok: false, class: "reserved" };
155
+ return ownAddress(bytes, local);
156
+ }
157
+ /** Refused as "this-host" when `bytes` is one of the host's own addresses, as it is written there. */
158
+ function ownAddress(bytes, local) {
159
+ for (const text of local) {
160
+ const own = addressBytes(text);
161
+ if (own && same(own, bytes))
162
+ return { ok: false, class: "this-host" };
163
+ }
164
+ return { ok: true };
165
+ }
@@ -0,0 +1,16 @@
1
+ export { ANTHROPIC_IMAGE_TOKENS_MAX, ANTHROPIC_TOOL_USE_SYSTEM_PROMPT_TOKENS, ASSUMED_TOOL_PROMPT_TOKENS, INPUT_OVERHEAD_APIS, OPENAI_IMAGE_TOKENS_MAX, providerInputOverhead, type InputShape } from "./overhead.js";
2
+ export { hostHeaderProblems, inputTokensMaxOf, matchRoute, parseStrict, providerIdsOf, ProxyRefusal, reportedCostOf, sameKey, startModelProxy } from "./proxy.js";
3
+ export type { ModelProxy, ModelProxyHooks, ModelProxyOptions, ModelUsageReport, ProviderPolicy, ProxyRequest, ProxyRoute, ProxyUsage } from "./proxy.js";
4
+ export { processAlive } from "./alive.js";
5
+ export { serialWriter } from "./serial.js";
6
+ export { addressBytes, checkAddress, EGRESS_RANGES } from "./egress-table.js";
7
+ export type { EgressClass, EgressVerdict } from "./egress-table.js";
8
+ export { parseAllow, startEgressProxy } from "./egress-proxy.js";
9
+ export type { EgressLookup, EgressProxy, EgressProxyOptions } from "./egress-proxy.js";
10
+ export { PARENT_WATCH_SHIM, parentWatched, parentWatchHere, SETPRIV } from "./parent-death.js";
11
+ export type { LaunchCommand, ParentWatch } from "./parent-death.js";
12
+ export { BoxError } from "./box.js";
13
+ export type { Box, BoxedChild, BoxErrorCode, BoxRecord, BoxRecorder, BoxSpec, Isolation, Reaped } from "./box.js";
14
+ export { boxSlot } from "./box-slot.js";
15
+ export type { BoxSlot, BoxSlotState, BoxStart, SlotChild } from "./box-slot.js";
16
+ export { checkedOnce, sha256OfFile } from "./pinned-binary.js";
@@ -0,0 +1,12 @@
1
+ // What a harness adapter runs beside its harness on the host: the session's model proxy, provider-neutral, with each
2
+ // provider's rules in a `ProviderPolicy`. Written for AgentRun.
3
+ export { ANTHROPIC_IMAGE_TOKENS_MAX, ANTHROPIC_TOOL_USE_SYSTEM_PROMPT_TOKENS, ASSUMED_TOOL_PROMPT_TOKENS, INPUT_OVERHEAD_APIS, OPENAI_IMAGE_TOKENS_MAX, providerInputOverhead } from "./overhead.js";
4
+ export { hostHeaderProblems, inputTokensMaxOf, matchRoute, parseStrict, providerIdsOf, ProxyRefusal, reportedCostOf, sameKey, startModelProxy } from "./proxy.js";
5
+ export { processAlive } from "./alive.js";
6
+ export { serialWriter } from "./serial.js";
7
+ export { addressBytes, checkAddress, EGRESS_RANGES } from "./egress-table.js";
8
+ export { parseAllow, startEgressProxy } from "./egress-proxy.js";
9
+ export { PARENT_WATCH_SHIM, parentWatched, parentWatchHere, SETPRIV } from "./parent-death.js";
10
+ export { BoxError } from "./box.js";
11
+ export { boxSlot } from "./box-slot.js";
12
+ export { checkedOnce, sha256OfFile } from "./pinned-binary.js";