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.

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