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.

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