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.

590 lines · 28.9 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. So is /run
252 // (and /var/run where it isn't a link to it): it holds the Docker daemon's and BuildKit's sockets, and a socket
253 // file can be reached from the jail's separate network namespace, through a read-only bind, as root.
254 const varRun = existsSync("/var/run") && realpathSync("/var/run") !== "/run" ? ["/var/run"] : [];
255 const hidden = [...new Set(["/tmp", "/run", ...varRun, o.home, a.root, ...(existsSync("/root") ? ["/root"] : [])])].sort((x, y) => x.length - y.length);
256 const args = ["--unshare-all", "--die-with-parent", "--new-session", "--cap-drop", "ALL", "--ro-bind", "/", "/", "--dev", "/dev", "--proc", "/proc"];
257 for (const h of hidden) args.push("--tmpfs", h);
258 const isHidden = (p: string) => hidden.some((h) => under(p, h));
259 // A test pool's shared directories, with anything hidden inside them hidden again.
260 const shared = sharedDirs(o.env[SHARED_ENV], hidden, hidden.filter((h) => h !== "/tmp"));
261 for (const d of shared) args.push("--bind", d, d);
262 // What the jail does see: the task's clone and its output directory, the checkout's objects read-only.
263 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"));
264 // The relay, the proxy's socket and the CLI's link: read-only (connecting to a socket needs no write).
265 args.push("--ro-bind", o.tools, o.tools);
266 const reveal = (p: string) => {
267 if (isHidden(p)) args.push("--ro-bind", p, p);
268 };
269 // Node (for the relay) and the CLI, wherever they are installed.
270 reveal(path.dirname(path.dirname(o.nodePath)));
271 if (o.cliPath) reveal(path.dirname(o.cliPath));
272 // CA bundles the sandbox trusts (the egress CA under web-only egress), wherever they are.
273 for (const k of ["NODE_EXTRA_CA_CERTS", "REQUESTS_CA_BUNDLE", "SSL_CERT_FILE"]) {
274 const f = o.env[k];
275 if (f && path.isAbsolute(f) && existsSync(f)) reveal(f);
276 }
277 // The CLI's own sign-in and settings, at their usual paths so it signs itself in: private copies, never
278 // written back, so nothing the agent writes there (another account's credentials) reaches the terminal.
279 if (a.mode === "agent" && a.cli) {
280 for (const f of CLI_FILES[a.cli]) {
281 const copy = path.join(o.tools, "home", f.replace(/\//g, "_"));
282 if (!existsSync(copy)) continue;
283 args.push("--dir", path.dirname(path.join(o.home, f)), "--bind", copy, path.join(o.home, f));
284 }
285 }
286 const PATH = [path.join(o.tools, "bin"), ...(o.env.PATH ?? "/usr/local/bin:/usr/bin:/bin").split(":").filter((d) => d && !isHidden(d))].join(":");
287 args.push("--chdir", a.dir, "--clearenv", "--setenv", "PATH", PATH, "--setenv", "HOME", o.home, "--setenv", "TMPDIR", "/tmp", "--setenv", "DIFFINERY_TASK_OUT", a.out);
288 for (const k of [...KEEP_ENV, ...(a.egress === "model" ? MODEL_ENV : [])]) if (o.env[k] !== undefined) args.push("--setenv", k, o.env[k]!);
289 if (a.mode === "agent") {
290 // Claude Code skips its permission prompts when told it's in a sandbox (it runs as root here).
291 args.push("--setenv", "IS_SANDBOX", "1", "--setenv", "DISABLE_AUTOUPDATER", "1", "--setenv", "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "1");
292 const proxy = `http://127.0.0.1:${RELAY_PORT}`;
293 for (const k of ["HTTPS_PROXY", "HTTP_PROXY", "https_proxy", "http_proxy"]) args.push("--setenv", k, proxy);
294 // Model requests go straight to the relay's MODEL_URL, not through the CONNECT proxy.
295 if (a.egress === "model") for (const k of ["NO_PROXY", "no_proxy"]) args.push("--setenv", k, "127.0.0.1,localhost");
296 }
297 return args;
298}
299
300const MODEL_IDLE_MS = 10 * 60_000;
301
302/** Request headers the model proxy never forwards: hop-by-hop, the agent's own credentials and cookies, proxy headers. */
303const 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"]);
304
305/**
306 * The model proxy outside a headless run's jail: plain HTTP on a Unix socket in, Diffinery's model endpoints out.
307 * It forwards only paths under `/-/model/` (as sent: one that a URL parser would rewrite, with `..` or encoded
308 * dots, is refused), to `origin`, with the run's token as its only credential.
309 */
310export function startModelProxy(listen: string | number, origin: string, token: string, log: (line: string) => void = (l) => process.stderr.write(`${l}\n`)): Promise<Server> {
311 const base = new URL(origin);
312 if ((base.protocol !== "https:" && base.protocol !== "http:") || base.username || base.password) throw new Error(`${MODEL_ORIGIN_ENV} must be an http(s) origin`);
313 const server = createServer((req, res) => {
314 const raw = req.url ?? "";
315 let target: URL | null = null;
316 try {
317 target = raw.startsWith("/") && !raw.startsWith("//") ? new URL(raw, base.origin) : null;
318 } catch {
319 target = null;
320 }
321 const asSent = raw.split("?")[0]!;
322 if (!target || target.origin !== base.origin || target.pathname !== asSent || !target.pathname.startsWith("/-/model/") || /%2e|%2f|%5c|\\/i.test(asSent)) {
323 log(`diffinery: refused ${req.method} ${raw.slice(0, 200)} from the run (only Diffinery's model endpoints, /-/model/...)`);
324 res.writeHead(403, { "content-type": "text/plain" }).end("Only Diffinery's model endpoints (/-/model/...) are reachable here.\n");
325 req.resume();
326 return;
327 }
328 const headers: IncomingHttpHeaders = {};
329 for (const [k, v] of Object.entries(req.headers)) if (!MODEL_DROP.has(k) && v !== undefined) headers[k] = v;
330 headers.authorization = `Bearer ${token}`;
331 const send = base.protocol === "https:" ? httpsRequest : httpRequest;
332 const up = send(target, { method: req.method, headers }, (r) => {
333 const out = { ...r.headers };
334 delete out["set-cookie"];
335 res.writeHead(r.statusCode ?? 502, out);
336 r.pipe(res);
337 });
338 // The agent hung up: cancel the request to Diffinery, which stops the provider's answer (and what it costs).
339 res.on("close", () => {
340 if (!res.writableFinished) up.destroy();
341 });
342 // Nor does an answer that has stalled for 10 minutes hold on (Diffinery frees its slot after 15).
343 up.setTimeout(MODEL_IDLE_MS, () => up.destroy(new Error("no data for 10 minutes")));
344 up.on("error", (e) => {
345 if (res.destroyed) return;
346 log(`diffinery: the model proxy couldn't reach ${base.host}: ${e.message}`);
347 if (!res.headersSent) res.writeHead(502, { "content-type": "text/plain" }).end("Diffinery's model proxy couldn't be reached.\n");
348 else res.destroy();
349 });
350 req.pipe(up);
351 });
352 return new Promise((resolve, reject) => {
353 server.once("error", reject);
354 server.listen(listen, () => resolve(server));
355 });
356}
357
358const CLIENT_HELLO_TIMEOUT_MS = 10_000;
359/** A ClientHello spread over more than this is refused. */
360const MAX_CLIENT_HELLO_BYTES = 64 * 1024;
361
362/**
363 * The server name a TLS client's first bytes ask for (SNI): the name, lower case; `null` when they aren't a
364 * ClientHello, name no single host name, or carry Encrypted Client Hello (whose real name is hidden); or
365 * `undefined` while more bytes are needed. Handles a ClientHello split over several records.
366 */
367export function clientHelloServerName(data: Uint8Array): string | null | undefined {
368 const fragments: Uint8Array[] = [];
369 let have = 0;
370 let pos = 0;
371 for (;;) {
372 if (data.length < pos + 5) return data.length > MAX_CLIENT_HELLO_BYTES ? null : undefined;
373 if (data[pos] !== 0x16) return null; // not a handshake record
374 const len = (data[pos + 3]! << 8) | data[pos + 4]!;
375 if (len === 0 || len > 16384) return null;
376 if (data.length < pos + 5 + len) return pos + 5 + len > MAX_CLIENT_HELLO_BYTES ? null : undefined;
377 fragments.push(data.subarray(pos + 5, pos + 5 + len));
378 have += len;
379 pos += 5 + len;
380 const hs = Buffer.concat(fragments, have);
381 if (hs[0] !== 0x01) return null; // not a ClientHello
382 if (hs.length < 4) continue;
383 const size = (hs[1]! << 16) | (hs[2]! << 8) | hs[3]!;
384 if (size + 4 > MAX_CLIENT_HELLO_BYTES) return null;
385 if (hs.length < size + 4) continue;
386 try {
387 return serverNameOf(hs.subarray(4, 4 + size));
388 } catch {
389 return null;
390 }
391 }
392}
393
394function serverNameOf(b: Buffer): string | null {
395 const at = (n: number) => {
396 if (n >= b.length) throw new RangeError("short ClientHello");
397 return b[n]!;
398 };
399 const u16 = (n: number) => (at(n) << 8) | at(n + 1);
400 let p = 2 + 32; // version, random
401 p += 1 + at(p); // session id
402 p += 2 + u16(p); // cipher suites
403 p += 1 + at(p); // compression methods
404 if (p + 2 > b.length) return null; // no extensions, so no server name
405 const end = p + 2 + u16(p);
406 if (end > b.length) return null;
407 p += 2;
408 const names: string[] = [];
409 while (p < end) {
410 const type = u16(p);
411 const len = u16(p + 2);
412 const body = p + 4;
413 if (body + len > end) return null;
414 if (type === 0xfe0d) return null; // encrypted_client_hello
415 if (type === 0x0000) {
416 const listEnd = body + 2 + u16(body);
417 if (listEnd > body + len) return null;
418 for (let q = body + 2; q < listEnd; ) {
419 const nameType = at(q);
420 const nameLen = u16(q + 1);
421 if (q + 3 + nameLen > listEnd) return null;
422 if (nameType === 0) names.push(b.subarray(q + 3, q + 3 + nameLen).toString("latin1"));
423 q += 3 + nameLen;
424 }
425 }
426 p = body + len;
427 }
428 if (names.length !== 1 || !/^[A-Za-z0-9.-]+$/.test(names[0]!)) return null;
429 return names[0]!.toLowerCase().replace(/\.$/, "");
430}
431
432/** The CONNECT proxy outside the jail: tunnels to JAIL_HOSTS by name, plain-HTTP downloads from package mirrors. */
433export function startJailProxy(
434 listen: string | number,
435 log: (line: string) => void = (l) => process.stderr.write(`${l}\n`),
436 connectUpstream: (port: number, host: string, onConnect: () => void) => net.Socket = (port, host, onConnect) => net.connect(port, host, onConnect),
437 egress: JailEgress = "cli",
438): Promise<Server> {
439 const hostsDoc = egress === "model" ? "/docs/agents/tasks#what-a-run-can-reach" : "/docs/agents/terminal#what-a-task-can-reach";
440 const who = egress === "model" ? "an agent run" : "a terminal task";
441 const server = createServer((req, res) => {
442 let url: URL;
443 try {
444 url = new URL(req.url ?? "");
445 } catch {
446 res.writeHead(400).end();
447 return;
448 }
449 const port = Number(url.port || 80);
450 if (url.protocol !== "http:" || !jailAllows(url.hostname, port, egress) || (req.method !== "GET" && req.method !== "HEAD")) {
451 log(`diffinery: refused ${req.method} ${url.protocol}//${url.host} from the task (only the hosts in ${hostsDoc})`);
452 res.writeHead(403, { "content-type": "text/plain" }).end(`${url.host} isn't reachable from ${who}.\n`);
453 return;
454 }
455 const headers = { ...req.headers };
456 delete headers["proxy-connection"];
457 delete headers["proxy-authorization"];
458 // The host the proxy allowed, never another one the tool names in Host.
459 headers.host = url.host;
460 const up = httpRequest({ host: url.hostname, port, path: `${url.pathname}${url.search}`, method: req.method, headers }, (r) => {
461 res.writeHead(r.statusCode ?? 502, r.headers);
462 r.pipe(res);
463 });
464 up.on("error", () => res.writeHead(502).end());
465 up.end();
466 });
467 server.on("connect", (req, socket, head) => {
468 const m = /^\[?([A-Za-z0-9.:-]+?)\]?:(\d+)$/.exec(req.url ?? "");
469 const host = m?.[1] ?? "";
470 const port = Number(m?.[2] ?? 0);
471 socket.on("error", () => socket.destroy());
472 if (!m || port !== 443 || !jailAllows(host, port, egress) || net.isIP(host)) {
473 log(`diffinery: refused a connection to ${req.url} from the task (only the hosts in ${hostsDoc})`);
474 socket.end(`HTTP/1.1 403 Forbidden\r\ncontent-type: text/plain\r\n\r\nnot reachable from ${who}\n`);
475 return;
476 }
477 // The tunnel opens only once the client's ClientHello asks for this same host: CONNECT to an allowed
478 // host behind a CDN, then TLS naming another site on that CDN, would otherwise reach that site.
479 socket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
480 const want = host.toLowerCase().replace(/\.$/, "");
481 let buf = Buffer.from(head);
482 const refuse = (why: string) => {
483 log(`diffinery: refused a connection to ${req.url} from the task: ${why}`);
484 socket.destroy();
485 };
486 const timer = setTimeout(() => refuse("no TLS ClientHello"), CLIENT_HELLO_TIMEOUT_MS);
487 const check = () => {
488 const name = clientHelloServerName(buf);
489 if (name === undefined) return;
490 socket.off("data", onData);
491 socket.pause();
492 clearTimeout(timer);
493 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}`);
494 const up = connectUpstream(port, host, () => {
495 up.write(buf);
496 up.pipe(socket);
497 socket.pipe(up);
498 socket.resume();
499 });
500 up.on("error", () => socket.destroy());
501 };
502 const onData = (d: Buffer) => {
503 buf = Buffer.concat([buf, d]);
504 check();
505 };
506 socket.on("data", onData);
507 socket.on("close", () => clearTimeout(timer));
508 if (buf.length) check();
509 });
510 return new Promise((resolve, reject) => {
511 server.once("error", reject);
512 server.listen(listen, () => resolve(server));
513 });
514}
515
516/** `agent.mjs --jail ...`: runs the command in the jail and exits with its code (JAIL_FAILED if it couldn't). */
517export async function runJail(argv: string[]): Promise<number> {
518 const fail = (m: string) => {
519 process.stderr.write(`diffinery: the task's jail couldn't start: ${m}\n`);
520 return JAIL_FAILED;
521 };
522 let a: JailArgs;
523 try {
524 a = parseJailArgs(argv);
525 } catch (e) {
526 return fail((e as Error).message);
527 }
528 const bwrap = findBwrap();
529 if (!bwrap) return fail("bubblewrap (bwrap) isn't installed in this sandbox");
530 for (const p of [a.dir, a.out]) if (!existsSync(p)) return fail(`${p} doesn't exist`);
531 // bwrap's own setup errors exit 1 like any command would: try the namespaces first, so a jail that can't
532 // exist is never mistaken for an agent that failed.
533 const probe = spawnSync(bwrap, ["--unshare-all", "--die-with-parent", "--cap-drop", "ALL", "--ro-bind", "/", "/", "--dev", "/dev", "--proc", "/proc", "--tmpfs", "/tmp", "--", "true"], { encoding: "utf8" });
534 if (probe.status !== 0) return fail(`bubblewrap can't create its namespaces here: ${(probe.stderr || probe.error?.message || `exit ${probe.status}`).trim()}`);
535 // A headless run's model token stays out here, with its model proxy: nothing kept in the jail may carry it.
536 const token = process.env[MODEL_TOKEN_ENV] ?? "";
537 const origin = process.env[MODEL_ORIGIN_ENV] ?? "";
538 if (a.egress === "model") {
539 if (!token || !origin) return fail(`--egress model needs ${MODEL_TOKEN_ENV} and ${MODEL_ORIGIN_ENV}`);
540 if (!/^https?:\/\/[^/@]+$/.test(origin.replace(/\/$/, ""))) return fail(`${MODEL_ORIGIN_ENV} must be an http(s) origin`);
541 const leak = [...KEEP_ENV, ...MODEL_ENV].find((k) => process.env[k]?.includes(token));
542 if (leak) return fail(`${leak} holds the run's model token, which must stay outside the jail`);
543 }
544 const home = os.homedir();
545 let tools: string;
546 try {
547 tools = mkdtempSync(path.join(a.root, ".jail-"));
548 } catch (e) {
549 return fail((e as Error).message);
550 }
551 const servers: Server[] = [];
552 try {
553 // The CLI, found as the terminal finds it, linked into the jail's PATH.
554 const cliPath = which(a.cmd[0]!, process.env.PATH ?? "");
555 const real = cliPath ? realpathSync(cliPath) : null;
556 const bin = path.join(tools, "bin");
557 mkdirSync(bin);
558 if (real) symlinkSync(real, path.join(bin, path.basename(a.cmd[0]!)));
559 mkdirSync(path.join(tools, "home"));
560 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, "_")));
561 let cmd = a.cmd;
562 if (a.mode === "agent") {
563 const sock = path.join(tools, "proxy.sock");
564 servers.push(await startJailProxy(sock, undefined, undefined, a.egress));
565 const relays = [`${RELAY_PORT}=${sock}`];
566 if (a.egress === "model") {
567 const modelSock = path.join(tools, "model.sock");
568 servers.push(await startModelProxy(modelSock, origin, token));
569 relays.push(`${MODEL_PORT}=${modelSock}`);
570 }
571 writeFileSync(path.join(tools, "relay.mjs"), RELAY);
572 cmd = [process.execPath, path.join(tools, "relay.mjs"), ...relays, "--", ...a.cmd];
573 }
574 const args = [...bwrapArgs(a, { home, tools, env: process.env, cliPath: real, nodePath: process.execPath }), "--", ...cmd];
575 const code = await new Promise<number>((resolve) => {
576 const child = spawn(bwrap, args, { stdio: "inherit" });
577 const stop = (sig: NodeJS.Signals) => child.kill(sig);
578 process.on("SIGTERM", stop);
579 process.on("SIGINT", stop);
580 child.on("error", (e) => resolve(fail(e.message)));
581 child.on("exit", (c, sig) => resolve(c ?? 128 + (os.constants.signals[sig as NodeJS.Signals] ?? 1)));
582 });
583 return code;
584 } catch (e) {
585 return fail(e instanceof Error ? e.message : String(e));
586 } finally {
587 for (const s of servers) s.close();
588 rmSync(tools, { recursive: true, force: true });
589 }
590}