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.

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