Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

587 lines · 28.5 KBRaw History
1import { accessSync, constants, copyFileSync, existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
2import { spawn, spawnSync } from "node:child_process";
3import { createServer, request as httpRequest, type IncomingHttpHeaders, type Server } from "node:http";
4import { request as httpsRequest } from "node:https";
5import net from "node:net";
6import os from "node:os";
7import path from "node:path";
8
9/**
10 * The jail an agent task runs in when it runs in someone's web terminal (phase 4b; Security's design).
11 *
12 * The terminal's sandbox holds the person's own Claude or ChatGPT sign-in, so the agent, which content in the
13 * repository can steer, runs under bubblewrap: no network of its own (a new network namespace with only
14 * loopback), a new PID namespace, no capabilities, an empty $HOME except the CLI's own sign-in, and nothing of
15 * the sandbox's work directory but the task's own clone. The person's checkout, its .git (with the sandbox's
16 * git credential) and the runner's state are not there; the checkout's objects are, read-only, because the
17 * task's clone borrows them (`git clone --shared`).
18 *
19 * The jail's only way out is a CONNECT proxy, outside it, that opens tunnels to the hosts in JAIL_HOSTS and
20 * nothing else. It never terminates TLS and handles no tokens: the CLI signs itself in, as in the terminal.
21 * Several of those hosts sit behind CDNs that serve every site they host on the same addresses, so a tunnel
22 * opens only once the client's TLS ClientHello names the same host (see `clientHelloServerName`).
23 * Inside, a relay on 127.0.0.1 forwards to the proxy's Unix socket and HTTPS_PROXY points at it.
24 *
25 * `finish` jails have no way out at all: they commit what the agent left and write a git bundle, which the
26 * runner, outside, pushes with the sandbox's credential.
27 *
28 * Headless runs (phase 5d: tasks, plans, reviews and coordinators in a sandbox of their own) use `--egress model`.
29 * They have no sign-in and reach no vendor: their CONNECT proxy opens tunnels to the package registries only, and
30 * their model requests go, as plain HTTP on 127.0.0.1, to a second proxy outside the jail. That one forwards only
31 * Diffinery's model endpoints (`/-/model/...`) to PUBLIC_URL over TLS, with the run's model token, which it holds
32 * and the jail never sees: whatever authorization the agent sends is dropped.
33 *
34 * agent.mjs --jail --root /workspace agent --dir /workspace/task-1-1 --out /workspace/task-1-1.out --cli claude-code -- claude -p ...
35 * agent.mjs --jail --root /workspace agent --dir /workspace/task-1-1 --out /workspace/task-1-1.out --egress model -- claude -p ...
36 */
37
38/** The CLI vendors' hosts a terminal task reaches with the person's sign-in (port 443). */
39const CLI_HOSTS = [
40 // Claude Code: the API, and its sign-in's token refresh.
41 "api.anthropic.com",
42 "console.anthropic.com",
43 "platform.claude.com",
44 // Codex on a ChatGPT plan: its backend and token refresh.
45 "chatgpt.com",
46 "auth.openai.com",
47];
48
49/** Package registries, download hosts where they have their own (uploads go to other hosts). */
50export const REGISTRY_HOSTS = [
51 "registry.npmjs.org",
52 "registry.yarnpkg.com",
53 "pypi.org",
54 "files.pythonhosted.org",
55 "index.crates.io",
56 "static.crates.io",
57 "rubygems.org",
58 "index.rubygems.org",
59 "repo1.maven.org",
60 "repo.maven.apache.org",
61 "deb.debian.org",
62 "security.debian.org",
63 "archive.ubuntu.com",
64 "security.ubuntu.com",
65];
66
67/** Where a terminal task may connect, by name (port 443, or 80 for package mirrors' plain HTTP). */
68export const JAIL_HOSTS = [...CLI_HOSTS, ...REGISTRY_HOSTS];
69
70/** How a jail reaches out: `cli`, a terminal task's (its CLI's vendor and the registries); `model`, a headless run's. */
71export type JailEgress = "cli" | "model";
72
73/** Hosts the plain-HTTP side of the proxy serves (apt's default sources), for GET and HEAD only. */
74const PLAIN_HTTP_HOSTS = new Set(["deb.debian.org", "security.debian.org", "archive.ubuntu.com", "security.ubuntu.com"]);
75
76export function jailAllows(host: string, port: number, egress: JailEgress = "cli"): boolean {
77 const h = host.toLowerCase().replace(/\.$/, "");
78 return (port === 443 && (egress === "model" ? REGISTRY_HOSTS : JAIL_HOSTS).includes(h)) || (port === 80 && PLAIN_HTTP_HOSTS.has(h));
79}
80
81/** Exit code when the jail itself can't start: nothing ran. */
82export const JAIL_FAILED = 125;
83
84const RELAY_PORT = 3128;
85/** Where a headless run's model requests go inside its jail: `*_BASE_URL` points here (MODEL_URL). */
86const MODEL_PORT = 3129;
87export const MODEL_URL = `http://127.0.0.1:${MODEL_PORT}`;
88
89/**
90 * Runs inside the jail: listens on 127.0.0.1, a port per proxy outside (`port=socket`, before the command),
91 * forwarding each connection to that proxy's Unix socket, then runs the command and exits with it.
92 */
93const RELAY = String.raw`
94import net from "node:net";
95import os from "node:os";
96import { spawn } from "node:child_process";
97const args = process.argv.slice(2);
98const sep = args.indexOf("--");
99const pairs = args.slice(0, sep).map((a) => [Number(a.slice(0, a.indexOf("="))), a.slice(a.indexOf("=") + 1)]);
100const cmd = args.slice(sep + 1);
101let listening = 0;
102for (const [port, sock] of pairs) {
103 const server = net.createServer((c) => {
104 const u = net.connect(sock);
105 c.pipe(u);
106 u.pipe(c);
107 // Either side hanging up closes the other, so a request the agent gave up on is cancelled outside too.
108 c.on("close", () => u.destroy());
109 u.on("close", () => c.destroy());
110 c.on("error", () => u.destroy());
111 u.on("error", () => c.destroy());
112 });
113 server.on("error", (e) => {
114 process.stderr.write("diffinery: the jail's proxy relay failed: " + e.message + "\n");
115 process.exit(${JAIL_FAILED});
116 });
117 server.listen(port, "127.0.0.1", () => {
118 if (++listening < pairs.length) return;
119 const child = spawn(cmd[0], cmd.slice(1), { stdio: "inherit" });
120 child.on("error", (e) => {
121 process.stderr.write("diffinery: " + cmd[0] + ": " + e.message + "\n");
122 process.exit(127);
123 });
124 child.on("exit", (code, signal) => process.exit(code ?? 128 + (os.constants.signals[signal] ?? 1)));
125 });
126}
127`;
128
129export interface JailArgs {
130 mode: "agent" | "finish";
131 root: string;
132 dir: string;
133 out: string;
134 cli?: "claude-code" | "codex";
135 egress: JailEgress;
136 cmd: string[];
137}
138
139export function parseJailArgs(argv: string[], cwd = process.cwd()): JailArgs {
140 const sep = argv.indexOf("--");
141 if (sep < 0 || sep === argv.length - 1) throw new Error("usage: --jail --root <dir> agent|finish --dir <dir> --out <dir> [--cli claude-code|codex | --egress model] -- <command...>");
142 const opts = argv.slice(0, sep);
143 const cmd = argv.slice(sep + 1);
144 const get = (name: string) => {
145 const i = opts.indexOf(name);
146 return i >= 0 ? opts[i + 1] : undefined;
147 };
148 const mode = opts.find((o) => o === "agent" || o === "finish") as JailArgs["mode"] | undefined;
149 const root = get("--root");
150 const dir = get("--dir");
151 const out = get("--out");
152 const cli = get("--cli");
153 const egress = get("--egress") ?? "cli";
154 if (!mode || !root || !dir || !out) throw new Error("--jail needs agent or finish, --root, --dir and --out");
155 if (cli !== undefined && cli !== "claude-code" && cli !== "codex") throw new Error(`unknown --cli ${cli}`);
156 if (egress !== "cli" && egress !== "model") throw new Error(`unknown --egress ${egress}`);
157 // A headless run has no sign-in to bring.
158 if (egress === "model" && (cli || mode !== "agent")) throw new Error("--egress model is for agent jails, without --cli");
159 const abs = (p: string) => path.resolve(cwd, p);
160 const a: JailArgs = { mode, root: abs(root), dir: abs(dir), out: abs(out), cli, egress, cmd };
161 // The task's own directories: inside the work root, beside the person's checkout and never in it.
162 const repo = path.join(a.root, "repo");
163 for (const p of [a.dir, a.out]) {
164 if (path.dirname(p) !== a.root || p === repo || path.basename(p).startsWith(".")) throw new Error(`${p} isn't a task directory of ${a.root}`);
165 }
166 if (a.dir === a.out) throw new Error("--dir and --out must differ");
167 return a;
168}
169
170const under = (p: string, root: string) => p === root || p.startsWith(root.endsWith("/") ? root : `${root}/`);
171
172function which(name: string, PATH: string): string | null {
173 if (name.includes("/")) return existsSync(name) ? name : null;
174 for (const d of PATH.split(":").filter(Boolean)) {
175 const p = path.join(d, name);
176 try {
177 accessSync(p, constants.X_OK);
178 if (statSync(p).isFile()) return p;
179 } catch {
180 // not here
181 }
182 }
183 return null;
184}
185
186function findBwrap(): string | null {
187 return which("bwrap", process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin");
188}
189
190/** The files of its home directory each CLI gets in the jail, as copies (paths relative to $HOME). */
191export const CLI_FILES: Record<"claude-code" | "codex", string[]> = {
192 "claude-code": [".claude/.credentials.json", ".claude.json"],
193 codex: [".codex/auth.json", ".codex/config.toml"],
194};
195
196/** Variables the jail keeps from the sandbox's environment; everything else is cleared. */
197const KEEP_ENV = ["LANG", "LC_ALL", "GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL", "GIT_COMMITTER_NAME", "GIT_COMMITTER_EMAIL", "NODE_EXTRA_CA_CERTS", "REQUESTS_CA_BUNDLE", "SSL_CERT_FILE", "PIP_BREAK_SYSTEM_PACKAGES", "COREPACK_ENABLE_DOWNLOAD_PROMPT", "DIFFINERY_TASK_MESSAGE", "DIFFINERY_TASK_START", "DIFFINERY_TASK_PROMPT"];
198
199/**
200 * What a headless run keeps besides (Diffinery's agentEnv): where each provider's model endpoints are, all on
201 * MODEL_URL, a stand-in for each API key (the real token stays outside), and the model a route pins.
202 */
203export const MODEL_ENV = [
204 "ANTHROPIC_BASE_URL",
205 "ANTHROPIC_API_KEY",
206 "ANTHROPIC_AUTH_TOKEN",
207 "ANTHROPIC_MODEL",
208 "ANTHROPIC_DEFAULT_OPUS_MODEL",
209 "ANTHROPIC_DEFAULT_SONNET_MODEL",
210 "ANTHROPIC_DEFAULT_HAIKU_MODEL",
211 "ANTHROPIC_SMALL_FAST_MODEL",
212 "CLAUDE_CODE_SUBAGENT_MODEL",
213 "OPENAI_BASE_URL",
214 "OPENAI_API_KEY",
215 "CODEX_API_KEY",
216 "OPENROUTER_BASE_URL",
217 "OPENROUTER_API_KEY",
218 "DIFFINERY_MODEL",
219];
220
221/** The run's model token and where Diffinery is: read by the jail's model proxy, outside, never kept inside. */
222export const MODEL_TOKEN_ENV = "DIFFINERY_MODEL_TOKEN";
223export const MODEL_ORIGIN_ENV = "DIFFINERY_MODEL_ORIGIN";
224
225/**
226 * Directories a test pool shares with every jail, read-write (colon-separated). Only the runner's own environment
227 * sets it, and only in end-to-end tests, whose commands keep their scripts and results in a temporary directory.
228 */
229export const SHARED_ENV = "DIFFINERY_JAIL_SHARED_FOR_TESTS";
230
231/**
232 * A test pool's shared directories, refused (so the jail doesn't start) when one is `/`, holds a hidden path (the
233 * work root, the home directory, /tmp, /root), or is inside the work root (the checkout and its git credential) or
234 * the home directory. Shared that way, it would undo the jail's filesystem isolation.
235 */
236export function sharedDirs(value: string | undefined, hidden: string[], private_: string[]): string[] {
237 const dirs = (value ?? "").split(":").filter(Boolean);
238 for (const d of dirs) {
239 const real = path.isAbsolute(d) && existsSync(d) ? realpathSync(d) : null;
240 if (!real) throw new Error(`${SHARED_ENV}: ${d} isn't an existing absolute directory`);
241 const holds = hidden.some((h) => under(h, real) || under(h, d));
242 const inside = private_.some((p) => under(real, p) || under(d, p) || (existsSync(p) && under(real, realpathSync(p))));
243 if (real === "/" || holds || inside) throw new Error(`${SHARED_ENV}: ${d} is, holds or is inside a directory the jail hides`);
244 }
245 return dirs;
246}
247
248/** The bubblewrap command line for a jail (exported for tests). */
249export function bwrapArgs(a: JailArgs, o: { home: string; tools: string; env: NodeJS.ProcessEnv; cliPath?: string | null; nodePath: string }): string[] {
250 // The home directory (shell history, other sign-ins, other checkouts), the work root (the person's checkout and
251 // the runner's state) and /tmp are empty in the jail; /root too, where it isn't the home directory.
252 const hidden = [...new Set(["/tmp", o.home, a.root, ...(existsSync("/root") ? ["/root"] : [])])].sort((x, y) => x.length - y.length);
253 const args = ["--unshare-all", "--die-with-parent", "--new-session", "--cap-drop", "ALL", "--ro-bind", "/", "/", "--dev", "/dev", "--proc", "/proc"];
254 for (const h of hidden) args.push("--tmpfs", h);
255 const isHidden = (p: string) => hidden.some((h) => under(p, h));
256 // A test pool's shared directories, with anything hidden inside them hidden again.
257 const shared = sharedDirs(o.env[SHARED_ENV], hidden, hidden.filter((h) => h !== "/tmp"));
258 for (const d of shared) args.push("--bind", d, d);
259 // What the jail does see: the task's clone and its output directory, the checkout's objects read-only.
260 args.push("--bind", a.dir, a.dir, "--bind", a.out, a.out, "--ro-bind", path.join(a.root, "repo", ".git", "objects"), path.join(a.root, "repo", ".git", "objects"));
261 // The relay, the proxy's socket and the CLI's link: read-only (connecting to a socket needs no write).
262 args.push("--ro-bind", o.tools, o.tools);
263 const reveal = (p: string) => {
264 if (isHidden(p)) args.push("--ro-bind", p, p);
265 };
266 // Node (for the relay) and the CLI, wherever they are installed.
267 reveal(path.dirname(path.dirname(o.nodePath)));
268 if (o.cliPath) reveal(path.dirname(o.cliPath));
269 // CA bundles the sandbox trusts (the egress CA under web-only egress), wherever they are.
270 for (const k of ["NODE_EXTRA_CA_CERTS", "REQUESTS_CA_BUNDLE", "SSL_CERT_FILE"]) {
271 const f = o.env[k];
272 if (f && path.isAbsolute(f) && existsSync(f)) reveal(f);
273 }
274 // The CLI's own sign-in and settings, at their usual paths so it signs itself in: private copies, never
275 // written back, so nothing the agent writes there (another account's credentials) reaches the terminal.
276 if (a.mode === "agent" && a.cli) {
277 for (const f of CLI_FILES[a.cli]) {
278 const copy = path.join(o.tools, "home", f.replace(/\//g, "_"));
279 if (!existsSync(copy)) continue;
280 args.push("--dir", path.dirname(path.join(o.home, f)), "--bind", copy, path.join(o.home, f));
281 }
282 }
283 const PATH = [path.join(o.tools, "bin"), ...(o.env.PATH ?? "/usr/local/bin:/usr/bin:/bin").split(":").filter((d) => d && !isHidden(d))].join(":");
284 args.push("--chdir", a.dir, "--clearenv", "--setenv", "PATH", PATH, "--setenv", "HOME", o.home, "--setenv", "TMPDIR", "/tmp", "--setenv", "DIFFINERY_TASK_OUT", a.out);
285 for (const k of [...KEEP_ENV, ...(a.egress === "model" ? MODEL_ENV : [])]) if (o.env[k] !== undefined) args.push("--setenv", k, o.env[k]!);
286 if (a.mode === "agent") {
287 // Claude Code skips its permission prompts when told it's in a sandbox (it runs as root here).
288 args.push("--setenv", "IS_SANDBOX", "1", "--setenv", "DISABLE_AUTOUPDATER", "1", "--setenv", "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "1");
289 const proxy = `http://127.0.0.1:${RELAY_PORT}`;
290 for (const k of ["HTTPS_PROXY", "HTTP_PROXY", "https_proxy", "http_proxy"]) args.push("--setenv", k, proxy);
291 // Model requests go straight to the relay's MODEL_URL, not through the CONNECT proxy.
292 if (a.egress === "model") for (const k of ["NO_PROXY", "no_proxy"]) args.push("--setenv", k, "127.0.0.1,localhost");
293 }
294 return args;
295}
296
297const MODEL_IDLE_MS = 10 * 60_000;
298
299/** Request headers the model proxy never forwards: hop-by-hop, the agent's own credentials and cookies, proxy headers. */
300const MODEL_DROP = new Set(["host", "connection", "keep-alive", "proxy-connection", "proxy-authorization", "transfer-encoding", "te", "trailer", "upgrade", "authorization", "x-api-key", "cookie", "forwarded", "x-forwarded-for", "x-forwarded-host", "x-forwarded-proto"]);
301
302/**
303 * The model proxy outside a headless run's jail: plain HTTP on a Unix socket in, Diffinery's model endpoints out.
304 * It forwards only paths under `/-/model/` (as sent: one that a URL parser would rewrite, with `..` or encoded
305 * dots, is refused), to `origin`, with the run's token as its only credential.
306 */
307export function startModelProxy(listen: string | number, origin: string, token: string, log: (line: string) => void = (l) => process.stderr.write(`${l}\n`)): Promise<Server> {
308 const base = new URL(origin);
309 if ((base.protocol !== "https:" && base.protocol !== "http:") || base.username || base.password) throw new Error(`${MODEL_ORIGIN_ENV} must be an http(s) origin`);
310 const server = createServer((req, res) => {
311 const raw = req.url ?? "";
312 let target: URL | null = null;
313 try {
314 target = raw.startsWith("/") && !raw.startsWith("//") ? new URL(raw, base.origin) : null;
315 } catch {
316 target = null;
317 }
318 const asSent = raw.split("?")[0]!;
319 if (!target || target.origin !== base.origin || target.pathname !== asSent || !target.pathname.startsWith("/-/model/") || /%2e|%2f|%5c|\\/i.test(asSent)) {
320 log(`diffinery: refused ${req.method} ${raw.slice(0, 200)} from the run (only Diffinery's model endpoints, /-/model/...)`);
321 res.writeHead(403, { "content-type": "text/plain" }).end("Only Diffinery's model endpoints (/-/model/...) are reachable here.\n");
322 req.resume();
323 return;
324 }
325 const headers: IncomingHttpHeaders = {};
326 for (const [k, v] of Object.entries(req.headers)) if (!MODEL_DROP.has(k) && v !== undefined) headers[k] = v;
327 headers.authorization = `Bearer ${token}`;
328 const send = base.protocol === "https:" ? httpsRequest : httpRequest;
329 const up = send(target, { method: req.method, headers }, (r) => {
330 const out = { ...r.headers };
331 delete out["set-cookie"];
332 res.writeHead(r.statusCode ?? 502, out);
333 r.pipe(res);
334 });
335 // The agent hung up: cancel the request to Diffinery, which stops the provider's answer (and what it costs).
336 res.on("close", () => {
337 if (!res.writableFinished) up.destroy();
338 });
339 // Nor does an answer that has stalled for 10 minutes hold on (Diffinery frees its slot after 15).
340 up.setTimeout(MODEL_IDLE_MS, () => up.destroy(new Error("no data for 10 minutes")));
341 up.on("error", (e) => {
342 if (res.destroyed) return;
343 log(`diffinery: the model proxy couldn't reach ${base.host}: ${e.message}`);
344 if (!res.headersSent) res.writeHead(502, { "content-type": "text/plain" }).end("Diffinery's model proxy couldn't be reached.\n");
345 else res.destroy();
346 });
347 req.pipe(up);
348 });
349 return new Promise((resolve, reject) => {
350 server.once("error", reject);
351 server.listen(listen, () => resolve(server));
352 });
353}
354
355const CLIENT_HELLO_TIMEOUT_MS = 10_000;
356/** A ClientHello spread over more than this is refused. */
357const MAX_CLIENT_HELLO_BYTES = 64 * 1024;
358
359/**
360 * The server name a TLS client's first bytes ask for (SNI): the name, lower case; `null` when they aren't a
361 * ClientHello, name no single host name, or carry Encrypted Client Hello (whose real name is hidden); or
362 * `undefined` while more bytes are needed. Handles a ClientHello split over several records.
363 */
364export function clientHelloServerName(data: Uint8Array): string | null | undefined {
365 const fragments: Uint8Array[] = [];
366 let have = 0;
367 let pos = 0;
368 for (;;) {
369 if (data.length < pos + 5) return data.length > MAX_CLIENT_HELLO_BYTES ? null : undefined;
370 if (data[pos] !== 0x16) return null; // not a handshake record
371 const len = (data[pos + 3]! << 8) | data[pos + 4]!;
372 if (len === 0 || len > 16384) return null;
373 if (data.length < pos + 5 + len) return pos + 5 + len > MAX_CLIENT_HELLO_BYTES ? null : undefined;
374 fragments.push(data.subarray(pos + 5, pos + 5 + len));
375 have += len;
376 pos += 5 + len;
377 const hs = Buffer.concat(fragments, have);
378 if (hs[0] !== 0x01) return null; // not a ClientHello
379 if (hs.length < 4) continue;
380 const size = (hs[1]! << 16) | (hs[2]! << 8) | hs[3]!;
381 if (size + 4 > MAX_CLIENT_HELLO_BYTES) return null;
382 if (hs.length < size + 4) continue;
383 try {
384 return serverNameOf(hs.subarray(4, 4 + size));
385 } catch {
386 return null;
387 }
388 }
389}
390
391function serverNameOf(b: Buffer): string | null {
392 const at = (n: number) => {
393 if (n >= b.length) throw new RangeError("short ClientHello");
394 return b[n]!;
395 };
396 const u16 = (n: number) => (at(n) << 8) | at(n + 1);
397 let p = 2 + 32; // version, random
398 p += 1 + at(p); // session id
399 p += 2 + u16(p); // cipher suites
400 p += 1 + at(p); // compression methods
401 if (p + 2 > b.length) return null; // no extensions, so no server name
402 const end = p + 2 + u16(p);
403 if (end > b.length) return null;
404 p += 2;
405 const names: string[] = [];
406 while (p < end) {
407 const type = u16(p);
408 const len = u16(p + 2);
409 const body = p + 4;
410 if (body + len > end) return null;
411 if (type === 0xfe0d) return null; // encrypted_client_hello
412 if (type === 0x0000) {
413 const listEnd = body + 2 + u16(body);
414 if (listEnd > body + len) return null;
415 for (let q = body + 2; q < listEnd; ) {
416 const nameType = at(q);
417 const nameLen = u16(q + 1);
418 if (q + 3 + nameLen > listEnd) return null;
419 if (nameType === 0) names.push(b.subarray(q + 3, q + 3 + nameLen).toString("latin1"));
420 q += 3 + nameLen;
421 }
422 }
423 p = body + len;
424 }
425 if (names.length !== 1 || !/^[A-Za-z0-9.-]+$/.test(names[0]!)) return null;
426 return names[0]!.toLowerCase().replace(/\.$/, "");
427}
428
429/** The CONNECT proxy outside the jail: tunnels to JAIL_HOSTS by name, plain-HTTP downloads from package mirrors. */
430export function startJailProxy(
431 listen: string | number,
432 log: (line: string) => void = (l) => process.stderr.write(`${l}\n`),
433 connectUpstream: (port: number, host: string, onConnect: () => void) => net.Socket = (port, host, onConnect) => net.connect(port, host, onConnect),
434 egress: JailEgress = "cli",
435): Promise<Server> {
436 const hostsDoc = egress === "model" ? "/docs/agents/tasks#what-a-run-can-reach" : "/docs/agents/terminal#what-a-task-can-reach";
437 const who = egress === "model" ? "an agent run" : "a terminal task";
438 const server = createServer((req, res) => {
439 let url: URL;
440 try {
441 url = new URL(req.url ?? "");
442 } catch {
443 res.writeHead(400).end();
444 return;
445 }
446 const port = Number(url.port || 80);
447 if (url.protocol !== "http:" || !jailAllows(url.hostname, port, egress) || (req.method !== "GET" && req.method !== "HEAD")) {
448 log(`diffinery: refused ${req.method} ${url.protocol}//${url.host} from the task (only the hosts in ${hostsDoc})`);
449 res.writeHead(403, { "content-type": "text/plain" }).end(`${url.host} isn't reachable from ${who}.\n`);
450 return;
451 }
452 const headers = { ...req.headers };
453 delete headers["proxy-connection"];
454 delete headers["proxy-authorization"];
455 // The host the proxy allowed, never another one the tool names in Host.
456 headers.host = url.host;
457 const up = httpRequest({ host: url.hostname, port, path: `${url.pathname}${url.search}`, method: req.method, headers }, (r) => {
458 res.writeHead(r.statusCode ?? 502, r.headers);
459 r.pipe(res);
460 });
461 up.on("error", () => res.writeHead(502).end());
462 up.end();
463 });
464 server.on("connect", (req, socket, head) => {
465 const m = /^\[?([A-Za-z0-9.:-]+?)\]?:(\d+)$/.exec(req.url ?? "");
466 const host = m?.[1] ?? "";
467 const port = Number(m?.[2] ?? 0);
468 socket.on("error", () => socket.destroy());
469 if (!m || port !== 443 || !jailAllows(host, port, egress) || net.isIP(host)) {
470 log(`diffinery: refused a connection to ${req.url} from the task (only the hosts in ${hostsDoc})`);
471 socket.end(`HTTP/1.1 403 Forbidden\r\ncontent-type: text/plain\r\n\r\nnot reachable from ${who}\n`);
472 return;
473 }
474 // The tunnel opens only once the client's ClientHello asks for this same host: CONNECT to an allowed
475 // host behind a CDN, then TLS naming another site on that CDN, would otherwise reach that site.
476 socket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
477 const want = host.toLowerCase().replace(/\.$/, "");
478 let buf = Buffer.from(head);
479 const refuse = (why: string) => {
480 log(`diffinery: refused a connection to ${req.url} from the task: ${why}`);
481 socket.destroy();
482 };
483 const timer = setTimeout(() => refuse("no TLS ClientHello"), CLIENT_HELLO_TIMEOUT_MS);
484 const check = () => {
485 const name = clientHelloServerName(buf);
486 if (name === undefined) return;
487 socket.off("data", onData);
488 socket.pause();
489 clearTimeout(timer);
490 if (name !== want) return refuse(name === null ? "its TLS ClientHello names no single server, or hides it (Encrypted Client Hello)" : `its TLS ClientHello asks for ${name}`);
491 const up = connectUpstream(port, host, () => {
492 up.write(buf);
493 up.pipe(socket);
494 socket.pipe(up);
495 socket.resume();
496 });
497 up.on("error", () => socket.destroy());
498 };
499 const onData = (d: Buffer) => {
500 buf = Buffer.concat([buf, d]);
501 check();
502 };
503 socket.on("data", onData);
504 socket.on("close", () => clearTimeout(timer));
505 if (buf.length) check();
506 });
507 return new Promise((resolve, reject) => {
508 server.once("error", reject);
509 server.listen(listen, () => resolve(server));
510 });
511}
512
513/** `agent.mjs --jail ...`: runs the command in the jail and exits with its code (JAIL_FAILED if it couldn't). */
514export async function runJail(argv: string[]): Promise<number> {
515 const fail = (m: string) => {
516 process.stderr.write(`diffinery: the task's jail couldn't start: ${m}\n`);
517 return JAIL_FAILED;
518 };
519 let a: JailArgs;
520 try {
521 a = parseJailArgs(argv);
522 } catch (e) {
523 return fail((e as Error).message);
524 }
525 const bwrap = findBwrap();
526 if (!bwrap) return fail("bubblewrap (bwrap) isn't installed in this sandbox");
527 for (const p of [a.dir, a.out]) if (!existsSync(p)) return fail(`${p} doesn't exist`);
528 // bwrap's own setup errors exit 1 like any command would: try the namespaces first, so a jail that can't
529 // exist is never mistaken for an agent that failed.
530 const probe = spawnSync(bwrap, ["--unshare-all", "--die-with-parent", "--cap-drop", "ALL", "--ro-bind", "/", "/", "--dev", "/dev", "--proc", "/proc", "--tmpfs", "/tmp", "--", "true"], { encoding: "utf8" });
531 if (probe.status !== 0) return fail(`bubblewrap can't create its namespaces here: ${(probe.stderr || probe.error?.message || `exit ${probe.status}`).trim()}`);
532 // A headless run's model token stays out here, with its model proxy: nothing kept in the jail may carry it.
533 const token = process.env[MODEL_TOKEN_ENV] ?? "";
534 const origin = process.env[MODEL_ORIGIN_ENV] ?? "";
535 if (a.egress === "model") {
536 if (!token || !origin) return fail(`--egress model needs ${MODEL_TOKEN_ENV} and ${MODEL_ORIGIN_ENV}`);
537 if (!/^https?:\/\/[^/@]+$/.test(origin.replace(/\/$/, ""))) return fail(`${MODEL_ORIGIN_ENV} must be an http(s) origin`);
538 const leak = [...KEEP_ENV, ...MODEL_ENV].find((k) => process.env[k]?.includes(token));
539 if (leak) return fail(`${leak} holds the run's model token, which must stay outside the jail`);
540 }
541 const home = os.homedir();
542 let tools: string;
543 try {
544 tools = mkdtempSync(path.join(a.root, ".jail-"));
545 } catch (e) {
546 return fail((e as Error).message);
547 }
548 const servers: Server[] = [];
549 try {
550 // The CLI, found as the terminal finds it, linked into the jail's PATH.
551 const cliPath = which(a.cmd[0]!, process.env.PATH ?? "");
552 const real = cliPath ? realpathSync(cliPath) : null;
553 const bin = path.join(tools, "bin");
554 mkdirSync(bin);
555 if (real) symlinkSync(real, path.join(bin, path.basename(a.cmd[0]!)));
556 mkdirSync(path.join(tools, "home"));
557 if (a.mode === "agent" && a.cli) for (const f of CLI_FILES[a.cli]) if (existsSync(path.join(home, f))) copyFileSync(path.join(home, f), path.join(tools, "home", f.replace(/\//g, "_")));
558 let cmd = a.cmd;
559 if (a.mode === "agent") {
560 const sock = path.join(tools, "proxy.sock");
561 servers.push(await startJailProxy(sock, undefined, undefined, a.egress));
562 const relays = [`${RELAY_PORT}=${sock}`];
563 if (a.egress === "model") {
564 const modelSock = path.join(tools, "model.sock");
565 servers.push(await startModelProxy(modelSock, origin, token));
566 relays.push(`${MODEL_PORT}=${modelSock}`);
567 }
568 writeFileSync(path.join(tools, "relay.mjs"), RELAY);
569 cmd = [process.execPath, path.join(tools, "relay.mjs"), ...relays, "--", ...a.cmd];
570 }
571 const args = [...bwrapArgs(a, { home, tools, env: process.env, cliPath: real, nodePath: process.execPath }), "--", ...cmd];
572 const code = await new Promise<number>((resolve) => {
573 const child = spawn(bwrap, args, { stdio: "inherit" });
574 const stop = (sig: NodeJS.Signals) => child.kill(sig);
575 process.on("SIGTERM", stop);
576 process.on("SIGINT", stop);
577 child.on("error", (e) => resolve(fail(e.message)));
578 child.on("exit", (c, sig) => resolve(c ?? 128 + (os.constants.signals[sig as NodeJS.Signals] ?? 1)));
579 });
580 return code;
581 } catch (e) {
582 return fail(e instanceof Error ? e.message : String(e));
583 } finally {
584 for (const s of servers) s.close();
585 rmSync(tools, { recursive: true, force: true });
586 }
587}