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.

235 lines · 11.7 KBRaw History
1import { execFileSync } from "node:child_process";
2import { readFileSync, rmSync, writeFileSync } from "node:fs";
3import path from "node:path";
4import { afterAll, beforeAll, describe, expect, it } from "vitest";
5import { WebSocket } from "ws";
6import { startSandbox, type SandboxHandle } from "../src/sandbox.js";
7import { startSandboxPool } from "../src/sandbox-pool.js";
8import { tmp } from "./helpers.js";
9
10const SECRET = "sandbox-secret-xyz";
11const TOKEN = "job-token-abc-123";
12let sb: SandboxHandle;
13let workRoot: string;
14let origin: string;
15let base: string;
16
17async function call(p: string, body?: unknown, auth = SECRET) {
18 const res = await fetch(`${base}${p}`, {
19 method: body === undefined ? "GET" : "POST",
20 headers: { authorization: `Bearer ${auth}`, "content-type": "application/json" },
21 body: body === undefined ? undefined : JSON.stringify(body),
22 });
23 return { status: res.status, json: (await res.json()) as Record<string, any> };
24}
25
26beforeAll(async () => {
27 workRoot = tmp("sandbox-");
28 origin = tmp("sandbox-origin-");
29 const env = { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t" };
30 execFileSync("git", ["init", "-q", "-b", "main"], { cwd: origin, env });
31 writeFileSync(path.join(origin, "hello.txt"), "hello sandbox\n");
32 execFileSync("git", ["add", "."], { cwd: origin, env });
33 execFileSync("git", ["commit", "-q", "-m", "init"], { cwd: origin, env });
34 execFileSync("git", ["checkout", "-q", "-b", "feature"], { cwd: origin, env });
35 writeFileSync(path.join(origin, "feature.txt"), "on feature\n");
36 execFileSync("git", ["add", "."], { cwd: origin, env });
37 execFileSync("git", ["commit", "-q", "-m", "feat"], { cwd: origin, env });
38 execFileSync("git", ["checkout", "-q", "main"], { cwd: origin, env });
39 sb = await startSandbox({ port: 0, host: "127.0.0.1", secret: SECRET, workRoot, cloneUrl: `file://${origin}`, ref: "feature", token: TOKEN, maxOutputBytes: 1000 });
40 base = `http://127.0.0.1:${sb.port}`;
41 await sb.ready;
42});
43
44afterAll(async () => {
45 await sb?.close();
46 rmSync(workRoot, { recursive: true, force: true });
47 rmSync(origin, { recursive: true, force: true });
48});
49
50describe("sandbox server", () => {
51 it("reports health without auth and rejects unauthenticated calls", async () => {
52 const h = await fetch(`${base}/health`);
53 expect(await h.json()).toEqual({ ok: true, ready: true });
54 expect((await call("/exec", { cmd: "true" }, "wrong")).status).toBe(401);
55 const noAuth = await fetch(`${base}/exec`, { method: "POST", body: "{}" });
56 expect(noAuth.status).toBe(401);
57 });
58
59 it("cloned the repo at the requested ref", async () => {
60 const r = await call("/exec", { cmd: "git rev-parse --abbrev-ref HEAD && cat hello.txt feature.txt" });
61 expect(r.status).toBe(200);
62 expect(r.json).toMatchObject({ exitCode: 0, stdout: "feature\nhello sandbox\non feature\n", stderr: "" });
63 });
64
65 it("executes commands with cwd/env, masks secrets and truncates output", async () => {
66 await call("/exec", { cmd: "mkdir -p sub" });
67 const r = await call("/exec", { cmd: 'pwd; echo "v=$MYVAR"; echo "s=$DIFFINERY_SANDBOX_SECRET"; echo err >&2; echo "tok=' + TOKEN + '"; exit 3', cwd: "sub", env: { MYVAR: "x1" } });
68 expect(r.json.exitCode).toBe(3);
69 expect(r.json.stdout).toBe(`${path.join(sb.repoDir, "sub")}\nv=x1\ns=\ntok=***\n`);
70 expect(r.json.stderr).toBe("err\n");
71 const big = await call("/exec", { cmd: "head -c 5000 /dev/zero | tr '\\0' a" });
72 expect(big.json.stdout.length).toBe(1000);
73 expect(big.json.truncated).toBe(true);
74 });
75
76 it("times out long commands", async () => {
77 const r = await call("/exec", { cmd: "sleep 20", timeoutMs: 300 });
78 expect(r.json.timedOut).toBe(true);
79 expect(r.json.exitCode).toBe(124);
80 });
81
82 it("reads and writes files (base64)", async () => {
83 const content = Buffer.from("bin\0ary ✓").toString("base64");
84 const w = await call("/files/write", { path: "new/dir/file.bin", content });
85 expect(w.json).toMatchObject({ ok: true, size: Buffer.from("bin\0ary ✓").length });
86 expect(readFileSync(path.join(sb.repoDir, "new/dir/file.bin"), "utf8")).toBe("bin\0ary ✓");
87 const r = await call("/files/read", { path: "new/dir/file.bin" });
88 expect(Buffer.from(r.json.content, "base64").toString()).toBe("bin\0ary ✓");
89 expect((await call("/files/read", { path: "missing.txt" })).status).toBe(404);
90 expect((await call("/files/read", {})).status).toBe(400);
91 expect((await call("/nope", {})).status).toBe(404);
92 });
93
94 it("runs background processes: env, secret env masked, output from offsets, exit codes", async () => {
95 // Built at run time so push protection never sees a key-shaped literal in this file.
96 const KEY = ["sk", "ant", "api03", "secret-key-value"].join("-");
97 const start = await call("/proc/start", { id: "run1", cmd: 'echo "a=$A"; echo "key=$MODEL_KEY"; echo oops >&2; sleep 0.3; echo done; exit 4', env: { A: "1" }, secretEnv: { MODEL_KEY: KEY } });
98 expect(start.status).toBe(200);
99 expect((await call("/proc/start", { id: "run1", cmd: "true" })).status).toBe(409);
100 let out = "";
101 let err = "";
102 let so = 0;
103 let se = 0;
104 let r;
105 for (;;) {
106 r = await call("/proc/read", { id: "run1", stdoutOffset: so, stderrOffset: se });
107 out += r.json.stdout;
108 err += r.json.stderr;
109 so = r.json.stdoutOffset;
110 se = r.json.stderrOffset;
111 if (!r.json.running) break;
112 await new Promise((x) => setTimeout(x, 50));
113 }
114 expect(out).toBe("a=1\nkey=***\ndone\n");
115 expect(err).toBe("oops\n");
116 expect(r.json.exitCode).toBe(4);
117 // The secret is masked everywhere from now on, and other commands don't get it in their environment.
118 const ex = await call("/exec", { cmd: `echo ${KEY}; echo "env=$MODEL_KEY"` });
119 expect(ex.json.stdout).toBe("***\nenv=\n");
120 // A finished process can start again under the same id.
121 expect((await call("/proc/start", { id: "run1", cmd: "echo again" })).status).toBe(200);
122 expect((await call("/proc/read", { id: "missing" })).status).toBe(404);
123 expect((await call("/proc/start", { id: "bad id!", cmd: "true" })).status).toBe(409);
124 });
125
126 it("kills a background process tree and times one out", async () => {
127 await call("/proc/start", { id: "long", cmd: "sleep 30 & sleep 30; echo never" });
128 expect((await call("/proc/kill", { id: "long" })).json.killed).toBe(true);
129 let r;
130 do r = await call("/proc/read", { id: "long" });
131 while (r.json.running);
132 expect(r.json.stdout).toBe("");
133 await call("/proc/start", { id: "slow", cmd: "sleep 30", timeoutMs: 200 });
134 do r = await call("/proc/read", { id: "slow" });
135 while (r.json.running);
136 expect(r.json).toMatchObject({ exitCode: 124, timedOut: true });
137 });
138});
139
140describe("sandbox pool", () => {
141 it("boots sandboxes on demand and routes calls to each by id", async () => {
142 const pool = await startSandboxPool({ port: 0, secret: "pool-secret", workRoot: tmp("pool-") });
143 const p = `http://127.0.0.1:${pool.port}`;
144 const boot = (auth: string) => fetch(`${p}/boot`, { method: "POST", headers: { authorization: `Bearer ${auth}` }, body: JSON.stringify({ id: "sb1", secret: "s1", env: { DIFFINERY_CLONE_URL: `file://${origin}`, DIFFINERY_REF: "main", FOO: "bar" } }) });
145 expect((await boot("wrong")).status).toBe(401);
146 expect((await boot("pool-secret")).status).toBe(200);
147 const exec = (auth: string) => fetch(`${p}/s/sb1/exec`, { method: "POST", headers: { authorization: `Bearer ${auth}`, "content-type": "application/json" }, body: JSON.stringify({ cmd: 'cat hello.txt; echo "$FOO"' }) });
148 expect((await exec("wrong")).status).toBe(401);
149 expect(await (await exec("s1")).json()).toMatchObject({ exitCode: 0, stdout: "hello sandbox\nbar\n" });
150 expect((await fetch(`${p}/s/nope/exec`, { method: "POST" })).status).toBe(404);
151 await pool.close();
152 });
153});
154
155
156/** A terminal connection that collects the screen output. */
157async function term(url: string, auth = SECRET) {
158 const ws = new WebSocket(url, { headers: { authorization: `Bearer ${auth}` } });
159 let out = "";
160 const closed = new Promise<{ code: number; reason: string }>((r) => ws.on("close", (code, reason) => r({ code, reason: String(reason) })));
161 ws.on("message", (d, binary) => {
162 if (binary) out += String(d);
163 });
164 await new Promise<void>((resolve, reject) => {
165 ws.once("open", () => resolve());
166 ws.once("unexpected-response", (_req, res) => reject(new Error(`status ${res.statusCode}`)));
167 ws.once("error", reject);
168 });
169 const until = async (re: RegExp, ms = 10_000) => {
170 const end = Date.now() + ms;
171 while (!re.test(out)) {
172 if (Date.now() > end) throw new Error(`timed out waiting for ${re} in ${JSON.stringify(out.slice(-500))}`);
173 await new Promise((r) => setTimeout(r, 50));
174 }
175 };
176 return { ws, out: () => out, until, closed, type: (s: string) => ws.send(Buffer.from(s)), control: (m: unknown) => ws.send(JSON.stringify(m)) };
177}
178
179describe("sandbox terminal", () => {
180 it("runs one shell on a PTY in the checkout, resizes it, and redraws it for the next tab", async () => {
181 const ws = base.replace("http", "ws");
182 await expect(term(`${ws}/pty`, "wrong")).rejects.toThrow(/401/);
183 expect((await call("/pty/status", {})).json).toMatchObject({ attached: false, running: false });
184 const a = await term(`${ws}/pty?cols=100&rows=30`);
185 a.type("tty; stty size; pwd; echo \"s=[$DIFFINERY_SANDBOX_SECRET] t=[$DIFFINERY_JOB_TOKEN] ci=[$CI]\"; echo mark-$((40+2))\n");
186 await a.until(/mark-42/);
187 expect(a.out()).toMatch(/\/dev\/pts\/\d+/);
188 expect(a.out()).toContain("30 100");
189 expect(a.out()).toContain(sb.repoDir);
190 expect(a.out()).toContain("s=[] t=[] ci=[]");
191 a.control({ type: "resize", cols: 132, rows: 43 });
192 a.type("stty size\n");
193 await a.until(/43 132/);
194 expect((await call("/pty/status", {})).json).toMatchObject({ attached: true, running: true, idleSince: null });
195
196 // A second tab takes over the same shell and gets the recent output; the first is told why it closed.
197 a.type("export KEPT=yes\n");
198 const b = await term(`${ws}/pty?cols=80&rows=24`);
199 expect((await a.closed).code).toBe(4000);
200 await b.until(/mark-42/);
201 b.type("echo kept=$KEPT\n");
202 await b.until(/kept=yes/);
203 b.ws.close();
204 await b.closed;
205 await new Promise((r) => setTimeout(r, 100));
206 const idle = (await call("/pty/status", {})).json;
207 expect(idle).toMatchObject({ attached: false, running: true });
208 expect(Date.parse(idle.idleSince)).toBeGreaterThan(Date.now() - 5000);
209
210 // Exiting the shell closes the connection; the next attach starts a fresh one.
211 const c = await term(`${ws}/pty`);
212 c.type("exit\n");
213 expect((await c.closed).reason).toBe("the shell exited");
214 const d = await term(`${ws}/pty`);
215 d.type("echo fresh=${KEPT:-none}\n");
216 await d.until(/fresh=none/);
217 await call("/pty/kill", {});
218 expect((await d.closed).reason).toBe("terminal ended");
219 });
220
221 it("reaches a pooled sandbox's terminal through the pool", { timeout: 20_000 }, async () => {
222 const pool = await startSandboxPool({ port: 0, secret: "pool-secret", workRoot: tmp("pool-") });
223 const p = `127.0.0.1:${pool.port}`;
224 await fetch(`http://${p}/boot`, { method: "POST", headers: { authorization: "Bearer pool-secret" }, body: JSON.stringify({ id: "sb2", secret: "s2", env: { DIFFINERY_CLONE_URL: `file://${origin}`, DIFFINERY_REF: "main" } }) });
225 await expect(term(`ws://${p}/s/sb2/pty`, "wrong")).rejects.toThrow(/401/);
226 await expect(term(`ws://${p}/s/nope/pty`, "s2")).rejects.toThrow(/404/);
227 const t = await term(`ws://${p}/s/sb2/pty?cols=90&rows=20`, "s2");
228 t.type("cat hello.txt; stty size\n");
229 await t.until(/20 90/);
230 expect(t.out()).toContain("hello sandbox");
231 t.ws.close();
232 await t.closed;
233 await pool.close();
234 });
235});