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.

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