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.

303 lines · 13.1 KBRaw History
1import { timingSafeEqual } from "node:crypto";
2import { existsSync } from "node:fs";
3import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
4import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
5import type { AddressInfo } from "node:net";
6import path from "node:path";
7import { WebSocketServer } from "ws";
8import { basicAuthHeader } from "./builtins/checkout.js";
9import { Masker } from "./mask.js";
10import { SHARED_ENV } from "./jail.js";
11import { ProcTable } from "./procs.js";
12import { Terminal } from "./terminal.js";
13import { exec } from "./util.js";
14
15export interface SandboxOptions {
16 port: number;
17 host?: string;
18 secret: string;
19 workRoot: string;
20 cloneUrl?: string;
21 ref?: string;
22 token?: string;
23 /** Extra values to mask in /exec output. */
24 maskValues?: string[];
25 maxOutputBytes?: number;
26 baseEnv?: NodeJS.ProcessEnv;
27 /** The runner's own script, for `--jail` (default: the one running). */
28 agentPath?: string;
29}
30
31const shellQuote = (s: string) => `'${s.replace(/'/g, "'\\''")}'`;
32
33export interface SandboxHandle {
34 server: Server;
35 port: number;
36 repoDir: string;
37 ready: Promise<void>;
38 close(): Promise<void>;
39}
40
41const MAX_BODY = 64 * 1024 * 1024;
42const SENSITIVE = ["DIFFINERY_JOB_TOKEN", "DIFFINERY_SANDBOX_SECRET"];
43
44class HttpErr extends Error {
45 constructor(
46 public status: number,
47 message: string,
48 ) {
49 super(message);
50 }
51}
52
53async function readBody(req: IncomingMessage): Promise<unknown> {
54 const chunks: Buffer[] = [];
55 let size = 0;
56 for await (const c of req) {
57 size += (c as Buffer).length;
58 if (size > MAX_BODY) throw new HttpErr(413, "request body too large");
59 chunks.push(c as Buffer);
60 }
61 const raw = Buffer.concat(chunks).toString("utf8");
62 if (!raw) return {};
63 try {
64 return JSON.parse(raw);
65 } catch {
66 throw new HttpErr(400, "invalid JSON body");
67 }
68}
69
70function send(res: ServerResponse, status: number, body: unknown): void {
71 const data = JSON.stringify(body);
72 res.writeHead(status, { "content-type": "application/json", "content-length": Buffer.byteLength(data) });
73 res.end(data);
74}
75
76function authorized(req: IncomingMessage, secret: string): boolean {
77 const h = req.headers.authorization ?? "";
78 const m = /^Bearer\s+(.+)$/i.exec(h);
79 if (!m || !secret) return false;
80 const a = Buffer.from(m[1]!.trim());
81 const b = Buffer.from(secret);
82 return a.length === b.length && timingSafeEqual(a, b);
83}
84
85function truncate(s: string, max: number): { text: string; truncated: boolean } {
86 const buf = Buffer.from(s, "utf8");
87 if (buf.length <= max) return { text: s, truncated: false };
88 return { text: buf.subarray(0, max).toString("utf8"), truncated: true };
89}
90
91async function cloneRepo(o: SandboxOptions, repoDir: string, masker: Masker, log: (l: string) => void): Promise<void> {
92 if (!o.cloneUrl) {
93 await mkdir(repoDir, { recursive: true });
94 return;
95 }
96 if (existsSync(path.join(repoDir, ".git"))) return;
97 await mkdir(path.dirname(repoDir), { recursive: true });
98 const auth = o.token && /^https?:/i.test(o.cloneUrl) ? ["-c", `http.extraheader=${basicAuthHeader(o.token)}`] : [];
99 const env: NodeJS.ProcessEnv = { ...(o.baseEnv ?? process.env), GIT_TERMINAL_PROMPT: "0" };
100 for (const k of SENSITIVE) delete env[k];
101 const onLine = (l: string) => log(masker.mask(l));
102 const r = await exec("git", [...auth, "clone", "--no-tags", o.cloneUrl, repoDir], { env, onLine, capture: true });
103 if (r.exitCode !== 0) throw new Error(`git clone failed (exit ${r.exitCode}): ${masker.mask(r.stderr.trim().split("\n").slice(-3).join(" "))}`);
104 if (auth.length) {
105 await exec("git", ["config", "--local", `http.${new URL(o.cloneUrl).origin}/.extraheader`, basicAuthHeader(o.token!)], { cwd: repoDir, env });
106 }
107 if (o.ref) {
108 const ref = o.ref;
109 const short = ref.replace(/^refs\/heads\//, "");
110 let c = await exec("git", ["checkout", "-q", short], { cwd: repoDir, env, capture: true });
111 if (c.exitCode !== 0) {
112 const f = await exec("git", [...auth, "fetch", "-q", "origin", ref], { cwd: repoDir, env, capture: true });
113 if (f.exitCode === 0) c = await exec("git", ["checkout", "-q", "FETCH_HEAD"], { cwd: repoDir, env, capture: true });
114 if (c.exitCode !== 0) throw new Error(`could not check out ${ref}: ${masker.mask(c.stderr.trim())}`);
115 }
116 }
117}
118
119/** Starts the AI-agent task sandbox HTTP server. */
120export async function startSandbox(o: SandboxOptions): Promise<SandboxHandle> {
121 const masker = new Masker();
122 masker.add(o.secret);
123 if (o.token) masker.add(o.token);
124 if (o.token) masker.add(basicAuthHeader(o.token).slice("AUTHORIZATION: basic ".length));
125 for (const v of o.maskValues ?? []) masker.add(v);
126 const repoDir = path.join(o.workRoot, "repo");
127 const maxOut = o.maxOutputBytes ?? 1024 * 1024;
128 let cloneError: string | null = null;
129 let isReady = false;
130 const ready = cloneRepo(o, repoDir, masker, (l) => process.stdout.write(`[sandbox] ${l}\n`))
131 .then(() => {
132 isReady = true;
133 })
134 .catch((e: unknown) => {
135 cloneError = e instanceof Error ? e.message : String(e);
136 process.stderr.write(`[sandbox] ${cloneError}\n`);
137 });
138
139 const procs = new ProcTable(path.join(o.workRoot, ".procs"), masker);
140 // A terminal task's jail (jail.ts), as one command: "$DIFFINERY_JAIL" agent --dir ... -- claude -p ...
141 const jailBin = path.join(o.workRoot, ".jail");
142 await mkdir(o.workRoot, { recursive: true });
143 await writeFile(jailBin, `#!/bin/sh\nexec ${shellQuote(process.execPath)} ${shellQuote(o.agentPath ?? process.argv[1]!)} --jail --root ${shellQuote(o.workRoot)} "$@"\n`, { mode: 0o755 });
144 const baseEnv = (): NodeJS.ProcessEnv => {
145 const env: NodeJS.ProcessEnv = { ...(o.baseEnv ?? process.env), CI: "true", DIFFINERY: "true", DIFFINERY_SANDBOX: "true", DIFFINERY_JAIL: jailBin };
146 for (const k of SENSITIVE) delete env[k];
147 return env;
148 };
149 const strings = (v: unknown, field: string): Record<string, string> => {
150 if (v === undefined || v === null) return {};
151 if (typeof v !== "object" || Array.isArray(v)) throw new HttpErr(400, `\`${field}\` must be an object of strings`);
152 // Only the pool's own environment shares directories with jails (end-to-end tests), never a request.
153 return Object.fromEntries(Object.entries(v).filter(([k]) => k !== SHARED_ENV).map(([k, x]) => [k, String(x)]));
154 };
155
156 // The web terminal's shell: interactive, so not CI, and without the sandbox's own credentials.
157 const terminal = new Terminal(o.workRoot, () => (existsSync(repoDir) ? repoDir : o.workRoot), () => {
158 const env = baseEnv();
159 delete env.CI;
160 return { ...env, IS_SANDBOX: "1" };
161 });
162 const sockets = new WebSocketServer({ noServer: true, maxPayload: 1024 * 1024 });
163
164 const resolvePath = (p: unknown): string => {
165 if (typeof p !== "string" || !p) throw new HttpErr(400, "`path` is required");
166 return path.isAbsolute(p) ? p : path.join(repoDir, p);
167 };
168
169 const server = createServer((req, res) => {
170 void (async () => {
171 const url = new URL(req.url ?? "/", "http://sandbox");
172 try {
173 if (req.method === "GET" && url.pathname === "/health") {
174 send(res, 200, { ok: true, ready: isReady, ...(cloneError ? { error: cloneError } : {}) });
175 return;
176 }
177 if (!authorized(req, o.secret)) throw new HttpErr(401, "unauthorized");
178 if (req.method !== "POST") throw new HttpErr(405, "method not allowed");
179 const body = (await readBody(req)) as Record<string, unknown>;
180 await ready;
181 switch (url.pathname) {
182 case "/exec": {
183 const cmd = body.cmd;
184 if (typeof cmd !== "string" || !cmd) throw new HttpErr(400, "`cmd` is required");
185 const cwd = body.cwd ? resolvePath(body.cwd) : repoDir;
186 if (!existsSync(cwd)) throw new HttpErr(400, `cwd does not exist: ${cwd}`);
187 const env = baseEnv();
188 if (body.env && typeof body.env === "object") for (const [k, v] of Object.entries(body.env)) if (k !== SHARED_ENV) env[k] = String(v);
189 const timeoutMs = typeof body.timeoutMs === "number" && body.timeoutMs > 0 ? body.timeoutMs : 10 * 60_000;
190 const started = Date.now();
191 const r = await exec("bash", ["-c", cmd], { cwd, env, timeoutMs, capture: true, maxCapture: maxOut + 1 });
192 const so = truncate(masker.mask(r.stdout), maxOut);
193 const se = truncate(masker.mask(r.stderr), maxOut);
194 send(res, 200, {
195 exitCode: r.timedOut ? 124 : r.exitCode,
196 stdout: so.text,
197 stderr: se.text,
198 timedOut: r.timedOut,
199 truncated: so.truncated || se.truncated || r.stdoutTruncated || r.stderrTruncated,
200 durationMs: Date.now() - started,
201 });
202 return;
203 }
204 case "/files/read": {
205 const p = resolvePath(body.path);
206 let st;
207 try {
208 st = await stat(p);
209 } catch {
210 throw new HttpErr(404, `not found: ${body.path}`);
211 }
212 if (st.isDirectory()) throw new HttpErr(400, "path is a directory");
213 if (st.size > MAX_BODY) throw new HttpErr(413, "file too large");
214 const data = await readFile(p);
215 send(res, 200, { path: p, size: st.size, content: data.toString("base64") });
216 return;
217 }
218 case "/files/write": {
219 const p = resolvePath(body.path);
220 if (typeof body.content !== "string") throw new HttpErr(400, "`content` (base64) is required");
221 const data = Buffer.from(body.content, "base64");
222 await mkdir(path.dirname(p), { recursive: true });
223 await writeFile(p, data, typeof body.mode === "number" ? { mode: body.mode } : undefined);
224 send(res, 200, { ok: true, path: p, size: data.length });
225 return;
226 }
227 // Background processes (procs.ts): long agent runs that outlive one request.
228 case "/proc/start": {
229 if (typeof body.id !== "string" || typeof body.cmd !== "string" || !body.cmd) throw new HttpErr(400, "`id` and `cmd` are required");
230 const cwd = body.cwd ? resolvePath(body.cwd) : repoDir;
231 if (!existsSync(cwd)) throw new HttpErr(400, `cwd does not exist: ${cwd}`);
232 const env = { ...baseEnv(), ...strings(body.env, "env") };
233 const timeoutMs = typeof body.timeoutMs === "number" && body.timeoutMs > 0 ? body.timeoutMs : undefined;
234 try {
235 send(res, 200, await procs.start({ id: body.id, cmd: body.cmd, cwd, env, secretEnv: strings(body.secretEnv, "secretEnv"), timeoutMs }));
236 } catch (e) {
237 throw new HttpErr(409, masker.mask(e instanceof Error ? e.message : String(e)));
238 }
239 return;
240 }
241 case "/proc/read": {
242 if (typeof body.id !== "string") throw new HttpErr(400, "`id` is required");
243 const off = (v: unknown) => (typeof v === "number" && v >= 0 ? v : 0);
244 try {
245 send(res, 200, await procs.read(body.id, off(body.stdoutOffset), off(body.stderrOffset)));
246 } catch (e) {
247 throw new HttpErr(404, e instanceof Error ? e.message : String(e));
248 }
249 return;
250 }
251 case "/pty/status": {
252 send(res, 200, terminal.status());
253 return;
254 }
255 case "/pty/kill": {
256 terminal.kill();
257 send(res, 200, { ok: true });
258 return;
259 }
260 case "/proc/kill": {
261 if (typeof body.id !== "string") throw new HttpErr(400, "`id` is required");
262 const sig = body.signal === "SIGKILL" || body.signal === "SIGINT" ? body.signal : "SIGTERM";
263 send(res, 200, { killed: procs.kill(body.id, sig) });
264 return;
265 }
266 default:
267 throw new HttpErr(404, "not found");
268 }
269 } catch (e) {
270 if (e instanceof HttpErr) send(res, e.status, { error: e.message });
271 else send(res, 500, { error: masker.mask(e instanceof Error ? e.message : String(e)) });
272 }
273 })();
274 });
275
276 // GET /pty with Upgrade: websocket attaches to the terminal (?cols=&rows= for its first size).
277 server.on("upgrade", (req, socket, head) => {
278 const url = new URL(req.url ?? "/", "http://sandbox");
279 if (url.pathname !== "/pty" || !authorized(req, o.secret)) {
280 socket.end(`HTTP/1.1 ${url.pathname !== "/pty" ? "404 Not Found" : "401 Unauthorized"}\r\nConnection: close\r\n\r\n`);
281 return;
282 }
283 // Open only once the checkout is there, so nothing typed arrives before the shell listens.
284 void ready.then(() => sockets.handleUpgrade(req, socket, head, (ws) => terminal.attach(ws, { cols: Number(url.searchParams.get("cols")), rows: Number(url.searchParams.get("rows")) })));
285 });
286
287 await new Promise<void>((resolve, reject) => {
288 server.once("error", reject);
289 server.listen(o.port, o.host ?? "0.0.0.0", () => resolve());
290 });
291 const port = (server.address() as AddressInfo).port;
292 return {
293 server,
294 port,
295 repoDir,
296 ready,
297 close: () => {
298 procs.killAll();
299 terminal.kill();
300 return new Promise<void>((r) => server.close(() => r()));
301 },
302 };
303}