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.

326 lines · 21.2 KBRaw History
1// End-to-end web terminals, kill switches on them, and tasks run in them (phase 4b), on the same setup as tasks.test.mjs
2// (agents-harness.mjs). In a file of their own so each file stays well under the test runner's per-file limit.
3//
4// pnpm --filter @diffinery/app e2e
5import { spawn, execFileSync } from "node:child_process";
6import { mkdtempSync, writeFileSync, mkdirSync, rmSync, readdirSync, statSync, readFileSync, existsSync } from "node:fs";
7import { tmpdir } from "node:os";
8import { createHash, randomBytes } from "node:crypto";
9import { createRequire } from "node:module";
10import { join, dirname, resolve } from "node:path";
11import { fileURLToPath } from "node:url";
12import { createServer as netServer } from "node:net";
13import { createServer as httpServer } from "node:http";
14import { test } from "node:test";
15import assert from "node:assert/strict";
16import {
17 APP,
18 AGENT,
19 work,
20 poolRoot,
21 poolHome,
22 WS,
23 RIVAL_SESSION,
24 ANTHROPIC_KEY,
25 OPENROUTER_KEY,
26 base,
27 dev,
28 pool,
29 upstream,
30 devLog,
31 poolLog,
32 token,
33 modelRequests,
34 freePort,
35 api,
36 ok,
37 asPerson,
38 sleep,
39 until,
40 git,
41 mcp,
42 poolSandboxes,
43} from "./agents-harness.mjs";
44
45/** Opens a terminal's WebSocket; resolves with the HTTP status when the upgrade is refused. */
46function termSocket(url, headers) {
47 return new Promise((resolve) => {
48 const ws = new WS(url, { headers });
49 let out = "";
50 ws.on("message", (d, binary) => {
51 if (binary) out += String(d);
52 });
53 let closedWith = null;
54 const closed = new Promise((r) => ws.on("close", (code, reason) => r((closedWith = { code, reason: String(reason) }))));
55 ws.once("unexpected-response", (_req, res) => resolve({ status: res.statusCode }));
56 ws.once("error", (e) => resolve({ status: 0, error: e.message }));
57 ws.once("open", () =>
58 resolve({
59 status: 101,
60 ws,
61 closed,
62 out: () => out,
63 type: (text) => ws.send(Buffer.from(text)),
64 until: (re, ms = 30_000) => until(`${re} in the terminal (got ${JSON.stringify(out.slice(-300))}, closed ${JSON.stringify(closedWith)})`, async () => re.test(out), ms),
65 }),
66 );
67 });
68}
69
70test("a web terminal: only its opener attaches, the sandbox API can't reach it, and End takes it away", async () => {
71 const me = await ok("GET", "/user");
72 const owner = me.login;
73 await ok("POST", "/user/repos", { name: "term-demo" });
74 await ok("PUT", `/repos/${owner}/term-demo/contents/README.md`, { message: "init", content: Buffer.from("# term\n").toString("base64") });
75 assert.equal((await asPerson("POST", "/diffinery/agents", { login: "term-bot", name: "Term Bot" })).status, 201);
76 const page = (path) => fetch(`${base}/${owner}/term-demo/${path}`).then(async (r) => ({ status: r.status, text: await r.text(), headers: r.headers }));
77 async function form(path, fields, headers = {}) {
78 const res = await fetch(`${base}/${owner}/term-demo/terminals`, { headers });
79 const csrf = /name="_csrf" value="([^"]+)"/.exec(await res.text())?.[1];
80 const cookie = /dfy_csrf=([^;]+)/.exec(res.headers.get("set-cookie") ?? "")?.[1];
81 return fetch(`${base}/${owner}/term-demo/${path}`, { method: "POST", redirect: "manual", headers: { ...headers, cookie: [headers.cookie, `dfy_csrf=${cookie}`].filter(Boolean).join("; "), "sec-fetch-site": "same-origin" }, body: new URLSearchParams({ _csrf: csrf ?? "", ...fields }) });
82 }
83
84 // The notice comes first, and it is a Kumo checkbox, not a native one.
85 const list = await page("terminals");
86 assert.equal(list.status, 200);
87 assert.match(list.text, /Open a terminal/);
88 assert.match(list.text, /my own Anthropic or OpenAI account/);
89 assert.ok(!/<input[^>]+type="checkbox"/.test(list.text.replace(/<input[^>]+data-k[^>]*>/g, "")), "no native checkbox");
90 const refused = await form("terminals", { agent: "term-bot", ref: "main" });
91 assert.equal(refused.status, 422);
92 assert.match(await refused.text(), /accept the notice first/);
93 const opened = await form("terminals", { agent: "term-bot", ref: "main", accept: "1" });
94 assert.equal(opened.status, 303, await opened.text());
95 const path = new URL(opened.headers.get("location"), base).pathname;
96 const id = path.split("/").pop();
97 const running = await until("the terminal to start", async () => {
98 const p = await page(`terminals/${id}`);
99 return p.text.includes("data-socket") ? p : null;
100 });
101 assert.match(running.text, /Start Claude Code/);
102 assert.match(running.text, /Start Codex/, "a private repository offers Codex");
103 assert.match(running.text, /\/-\/terminal\/[a-z0-9]+\.js/);
104 const sandboxId = poolSandboxes()[0];
105
106 // The socket: this site's Origin, a browser session, and only the opener.
107 const url = `${base.replace("http", "ws")}/${owner}/term-demo/terminals/${id}/socket?cols=100&rows=30`;
108 const origin = new URL(base).origin;
109 assert.equal((await termSocket(url, {})).status, 403, "no Origin");
110 assert.equal((await termSocket(url, { origin: "https://evil.example" })).status, 403, "another Origin");
111 assert.equal((await termSocket(url, { origin, authorization: `Bearer ${token}` })).status, 403, "a token, even the opener's own admin token");
112 assert.equal((await termSocket(url, { origin, cookie: `__Host-dfy_session=${RIVAL_SESSION}` })).status, 404, "another person");
113 const t = await termSocket(url, { origin });
114 assert.equal(t.status, 101, JSON.stringify(t));
115 t.type("stty size; echo \"s=[$DIFFINERY_SANDBOX_SECRET] t=[$DIFFINERY_JOB_TOKEN]\"; git rev-parse --abbrev-ref HEAD; echo hi-$((6*7))\n");
116 await t.until(/hi-42/);
117 assert.match(t.out(), /30 100/);
118 assert.match(t.out(), /s=\[\] t=\[\]/);
119 assert.match(t.out(), /\bmain\b/);
120
121 // Another person, even one the owner made an admin of the repository, sees nothing of it.
122 const invite = await ok("PUT", `/repos/${owner}/term-demo/collaborators/rival`, { permission: "admin" });
123 if (invite?.id) await fetch(`${base}/api/v3/user/repository_invitations/${invite.id}`, { method: "PATCH", headers: { cookie: `__Host-dfy_session=${RIVAL_SESSION}`, "sec-fetch-site": "same-origin" } });
124 const rival = { cookie: `__Host-dfy_session=${RIVAL_SESSION}` };
125 assert.equal((await fetch(`${base}/${owner}/term-demo/terminals/${id}`, { headers: rival })).status, 404);
126 assert.ok(!(await (await fetch(`${base}/${owner}/term-demo/terminals`, { headers: rival })).text()).includes(id));
127 assert.equal((await form(`terminals/${id}/end`, {}, rival)).status, 404);
128 assert.equal((await termSocket(url, { origin, ...rival })).status, 404);
129
130 // The sandbox API and MCP never reach a terminal's sandbox, not even for a site admin's admin token.
131 assert.equal((await api("POST", `/repos/${owner}/term-demo/diffinery/sandboxes/${sandboxId}/exec`, { cmd: "cat ~/.claude/.credentials.json" })).status, 404);
132 assert.equal((await api("POST", `/repos/${owner}/term-demo/diffinery/sandboxes/${sandboxId}/read`, { path: "/root/.codex/auth.json" })).status, 404);
133 assert.equal((await api("POST", `/repos/${owner}/term-demo/diffinery/sandboxes/${sandboxId}/close`)).status, 404);
134 const viaMcp = await mcp("sandbox_exec", { owner, repo: "term-demo", sandbox_id: sandboxId, command: "id" });
135 assert.ok(viaMcp?.isError, JSON.stringify(viaMcp));
136
137 // Limits: two open per repository.
138 const second = await form("terminals", { agent: "term-bot", ref: "main" });
139 assert.equal(second.status, 303);
140 const third = await form("terminals", { agent: "term-bot", ref: "main" });
141 assert.equal(third.status, 422);
142 assert.match(await third.text(), /2 terminals open in this repository/);
143 const secondId = new URL(second.headers.get("location"), base).pathname.split("/").pop();
144
145 // End: the socket closes, the sandbox and its git credential go, and it can't be attached again.
146 assert.equal((await form(`terminals/${id}/end`, {})).status, 303);
147 const closed = await t.closed;
148 assert.ok([1000, 1006, 1011].includes(closed.code), JSON.stringify(closed));
149 assert.equal((await termSocket(url, { origin })).status, 409);
150 assert.match((await page(`terminals/${id}`)).text, /You ended it/);
151 await until("the pool to stop the sandbox", async () => !poolSandboxes().includes(sandboxId) || (await fetch(`${base}/${owner}/term-demo/terminals/${id}`)).ok);
152
153 // Losing access ends a terminal too: the agent's write access goes, and the next check ends it.
154 await until("the second terminal to start", async () => (await page(`terminals/${secondId}`)).text.includes("data-socket"));
155 assert.equal((await api("DELETE", "/diffinery/agents/term-bot")).status, 204);
156 await until("the second terminal to end", async () => /no longer have write access|ended/i.test((await page(`terminals/${secondId}`)).text) && !(await page(`terminals/${secondId}`)).text.includes("data-socket"), 150_000);
157});
158
159test("a kill switch ends web terminals and refuses new ones until it's lifted", async () => {
160 const owner = (await ok("GET", "/user")).login;
161 assert.equal((await asPerson("POST", "/diffinery/agents", { login: "term-bot-2", name: "Term Bot 2" })).status, 201);
162 const page = (path) => fetch(`${base}/${owner}/term-demo/${path}`).then(async (r) => ({ status: r.status, text: await r.text() }));
163 async function form(path, fields) {
164 const res = await fetch(`${base}/${owner}/term-demo/terminals`);
165 const csrf = /name="_csrf" value="([^"]+)"/.exec(await res.text())?.[1];
166 const cookie = /dfy_csrf=([^;]+)/.exec(res.headers.get("set-cookie") ?? "")?.[1];
167 return fetch(`${base}/${owner}/term-demo/${path}`, { method: "POST", redirect: "manual", headers: { cookie: `dfy_csrf=${cookie}`, "sec-fetch-site": "same-origin" }, body: new URLSearchParams({ _csrf: csrf ?? "", ...fields }) });
168 }
169 const opened = await form("terminals", { agent: "term-bot-2", ref: "main" });
170 assert.equal(opened.status, 303, await opened.text());
171 const id = new URL(opened.headers.get("location"), base).pathname.split("/").pop();
172 await until("the terminal to start", async () => (await page(`terminals/${id}`)).text.includes("data-socket"));
173 const url = `${base.replace("http", "ws")}/${owner}/term-demo/terminals/${id}/socket`;
174 const origin = new URL(base).origin;
175
176 // Suspending the repository's Actions ends the terminal, refuses attaching and opening another.
177 const S = `/admin/repos/${owner}/term-demo/actions-suspension`;
178 assert.equal((await api("PUT", S, { reason: "abuse report" })).status, 200);
179 // Nor does it start a task in the terminal (phase 4b).
180 const before = (await ok("GET", `/repos/${owner}/term-demo/diffinery/tasks`)).length;
181 assert.equal((await form(`terminals/${id}/task`, { goal: "Do it while suspended", runtime: "claude-code" })).status, 303);
182 assert.equal((await ok("GET", `/repos/${owner}/term-demo/diffinery/tasks`)).length, before, "a suspended repository's terminal starts no task");
183 const refused = await termSocket(url, { origin });
184 assert.ok([403, 409].includes(refused.status), JSON.stringify(refused));
185 const again = await form("terminals", { agent: "term-bot-2", ref: "main" });
186 assert.equal(again.status, 422);
187 assert.match(await again.text(), /Actions are suspended for this repository by a site administrator: abuse report/);
188 await until("the terminal to end", async () => /suspended Actions and sandboxes here|Its sandbox stopped/.test((await page(`terminals/${id}`)).text), 150_000);
189
190 // Lifted, a terminal opens again.
191 assert.equal((await api("DELETE", S)).status, 200);
192 const after = await form("terminals", { agent: "term-bot-2", ref: "main" });
193 assert.equal(after.status, 303, await after.text());
194 assert.equal((await form(`${new URL(after.headers.get("location"), base).pathname.split("/").slice(3).join("/")}/end`, {})).status, 303);
195});
196
197test("a task in my terminal: only on my click, with the CLI signed in there, in a checkout of its own", async () => {
198 const owner = (await ok("GET", "/user")).login;
199 await ok("POST", "/user/repos", { name: "term-tasks" });
200 await ok("PUT", `/repos/${owner}/term-tasks/contents/README.md`, { message: "init", content: Buffer.from("# term tasks\n").toString("base64") });
201 assert.equal((await asPerson("POST", "/diffinery/agents", { login: "term-hand", name: "Term Hand" })).status, 201);
202 const R = `/repos/${owner}/term-tasks/diffinery/tasks`;
203 const page = (path, headers = {}) => fetch(`${base}/${owner}/term-tasks/${path}`, { headers }).then(async (r) => ({ status: r.status, text: await r.text() }));
204 async function form(path, fields, headers = {}) {
205 const res = await fetch(`${base}/${owner}/term-tasks/terminals`, { headers });
206 const csrf = /name="_csrf" value="([^"]+)"/.exec(await res.text())?.[1];
207 const cookie = /dfy_csrf=([^;]+)/.exec(res.headers.get("set-cookie") ?? "")?.[1];
208 return fetch(`${base}/${owner}/term-tasks/${path}`, { method: "POST", redirect: "manual", headers: { ...headers, cookie: [headers.cookie, `dfy_csrf=${cookie}`].filter(Boolean).join("; "), "sec-fetch-site": "same-origin" }, body: new URLSearchParams({ _csrf: csrf ?? "", ...fields }) });
209 }
210 // The terminal opens on a branch whose name would run a command wherever it reached a shell unquoted.
211 const EVIL = "t$(touch${IFS}injected)";
212 const mainSha = (await ok("GET", `/repos/${owner}/term-tasks/git/ref/heads/main`)).object.sha;
213 await ok("POST", `/repos/${owner}/term-tasks/git/refs`, { ref: `refs/heads/${EVIL}`, sha: mainSha });
214 const opened = await form("terminals", { agent: "term-hand", ref: EVIL, accept: "1", tasks: "1" });
215 assert.equal(opened.status, 303, await opened.text());
216 const id = new URL(opened.headers.get("location"), base).pathname.split("/").pop();
217 const running = await until("the terminal to start", async () => {
218 const p = await page(`terminals/${id}`);
219 return p.text.includes("data-socket") ? p : null;
220 });
221 assert.match(running.text, /Give it a task/);
222 assert.match(running.text, /Run with my subscription/);
223 assert.match(running.text, /on your own subscription/);
224
225 // Someone else, even an admin of the repository, can't send a task to it; nor can a token (there is no API for it).
226 const invite = await ok("PUT", `/repos/${owner}/term-tasks/collaborators/rival`, { permission: "admin" });
227 if (invite?.id) await fetch(`${base}/api/v3/user/repository_invitations/${invite.id}`, { method: "PATCH", headers: { cookie: `__Host-dfy_session=${RIVAL_SESSION}`, "sec-fetch-site": "same-origin" } });
228 const rival = { cookie: `__Host-dfy_session=${RIVAL_SESSION}` };
229 assert.equal((await form(`terminals/${id}/task`, { goal: "Do it", runtime: "claude-code" }, rival)).status, 303);
230 assert.deepEqual(await ok("GET", R), [], "someone else's click starts nothing");
231 const viaToken = await api("POST", R, { goal: "Do it", runtime: "claude-code", agent: "term-hand", terminal: id });
232 assert.equal(viaToken.status, 403, "a token starts no task, in a terminal or not");
233 // A terminal opened without "Will run agent tasks" never runs one.
234 const plainOpened = await form("terminals", { agent: "term-hand", ref: "main" });
235 assert.equal(plainOpened.status, 303, await plainOpened.text());
236 const plainId = new URL(plainOpened.headers.get("location"), base).pathname.split("/").pop();
237 const plain = await until("the plain terminal to start", async () => {
238 const p = await page(`terminals/${plainId}`);
239 return p.text.includes("data-socket") ? p : null;
240 });
241 assert.match(plain.text, /opened without agent tasks/);
242 assert.doesNotMatch(plain.text, /Run with my subscription/);
243 assert.equal((await form(`terminals/${plainId}/task`, { goal: "Do it", runtime: "claude-code" })).status, 303);
244 assert.deepEqual(await ok("GET", R), [], "a terminal opened without tasks runs none");
245 assert.equal((await form(`terminals/${plainId}/end`, {})).status, 303);
246 // Only Claude Code or Codex, as signed in there.
247 const custom = await form(`terminals/${id}/task`, { goal: "Do it", runtime: "custom" });
248 assert.equal(custom.status, 303);
249 assert.deepEqual(await ok("GET", R), [], "a custom command doesn't run in a terminal");
250
251 // My click: the task runs in my terminal with the CLI there, with no model key or proxy, in its own checkout.
252 const started = await form(`terminals/${id}/task`, { goal: "Add a terminal file", runtime: "claude-code" });
253 assert.equal(started.status, 303);
254 const n = Number(new URL(started.headers.get("location"), base).pathname.split("/").pop());
255 assert.ok(n > 0, started.headers.get("location"));
256 const done = await until("the terminal task to finish", async () => {
257 const t = await ok("GET", `${R}/${n}`);
258 return t.state !== "running" && t.state !== "queued" ? t : null;
259 });
260 const text = (await ok("GET", `${R}/${n}/transcript`)).map((l) => `[${l.stream}] ${l.text}`).join("\n");
261 assert.equal(done.state, "ready", text);
262 assert.ok(done.pull_request, text);
263 assert.match(text, /Running in .*'s terminal/);
264 assert.match(text, /signed-in claude: key=none/, "no model key or token reaches it");
265 assert.ok(!/base=[^ ]*\/-\/model\//.test(text), "nor the model proxy");
266 assert.match(text, new RegExp(`dir=task-${n}-1`), "in a checkout of its own");
267 assert.match(text, /\[out\] Add a terminal file/, "the goal reaches it");
268 assert.match(text, /--permission-mode bypassPermissions --disallowedTools WebSearch,WebFetch --settings \{/, "no web tools");
269 assert.match(text, /"Read\(~\/\.claude\/\*\*\)"/, "and deny rules on the sign-ins");
270 // Its jail: the CLI's own sign-in, the proxy as its only way out, and nothing else of the terminal's.
271 assert.match(text, /jail: proxy=http:\/\/127\.0\.0\.1:\d+/);
272 assert.match(text, /jail: signin=\{"fake":"signin"\}/, "the CLI signs itself in");
273 assert.match(text, /jail: history=hidden/, "the rest of the home directory isn't there");
274 assert.match(text, /jail: credential=hidden/, "nor the checkout's git config, with the sandbox's credential");
275 assert.match(text, /jail: direct=(ENETUNREACH|EHOSTUNREACH|ECONNREFUSED)/, "and there is no route out but the proxy");
276 const file = await ok("GET", `/repos/${owner}/term-tasks/contents/terminal.txt?ref=${encodeURIComponent(done.branch)}`);
277 assert.equal(Buffer.from(file.content, "base64").toString(), "made in the terminal\n");
278 assert.equal(done.runs[0].in_terminal, true);
279 assert.equal(done.base, EVIL, "checked out from the terminal's branch, its name passed as data");
280
281 // The terminal's own checkout is untouched, and the terminal is still mine to use.
282 const t = await termSocket(`${base.replace("http", "ws")}/${owner}/term-tasks/terminals/${id}/socket?cols=100&rows=30`, { origin: new URL(base).origin });
283 assert.equal(t.status, 101);
284 t.type("cat ~/.claude/.credentials.json; echo; ls terminal.txt 2>&1 | head -1; ls injected ../injected ../pwned-fsmonitor 2>&1 | grep -c 'No such file'; git status --short | wc -l; ls .. | grep -c task- ; echo check-$((6*7))\n");
285 await t.until(/check-42/);
286 assert.match(t.out(), /No such file/);
287 assert.match(t.out(), /\{"fake":"signin"\}/, "the terminal's own sign-in is untouched by what the task wrote");
288 assert.doesNotMatch(t.out(), /attacker/);
289 assert.equal(readFileSync(join(poolHome, ".claude", ".credentials.json"), "utf8"), '{"fake":"signin"}', "byte for byte");
290 assert.match(t.out(), /\n3\r?\n0\r?\n0\r?\n/, "the branch name ran nothing, nor the clone's config outside its jail");
291 assert.match(t.out(), /\n0\r?\n0\r?\n/, "a clean checkout, and the task's checkout removed");
292
293 // Its page offers to run it again there; doing so is another run in the terminal.
294 const taskPage = await page(`tasks/${n}`);
295 assert.match(taskPage.text, /Run in my terminal/);
296 assert.match(taskPage.text, /in [a-z0-9-]+(&#39;|')s terminal/);
297 assert.doesNotMatch((await page(`tasks/${n}`, rival)).text, /Run in my terminal/, "not for anyone else");
298 assert.equal((await form(`tasks/${n}/terminal`, { terminal: id }, rival)).status, 303);
299 assert.equal((await ok("GET", `${R}/${n}`)).runs.length, 1, "someone else's click starts nothing");
300 const again = await form(`tasks/${n}/terminal`, { terminal: id, body: "Say it again" });
301 assert.equal(again.status, 303);
302 const twice = await until("the second terminal run", async () => {
303 const x = await ok("GET", `${R}/${n}`);
304 return x.runs.length === 2 && x.state === "ready" ? x : null;
305 });
306 assert.equal(twice.runs[1].state, "done");
307 assert.equal(twice.runs[1].in_terminal, true);
308 assert.equal(twice.runs[1].kind, "message");
309 // Usage (phase 5a): no request passed Diffinery, so each run shows what the signed-in CLI reported, with its cost.
310 assert.deepEqual(twice.runs[0].usage, { source: "runtime", requests: 0, input_tokens: 40, output_tokens: 7, cache_read_tokens: 1200, cache_write_tokens: 90, cost_usd: 0.0312 });
311 assert.deepEqual(twice.usage, { requests: 0, input_tokens: 80, output_tokens: 14, cache_read_tokens: 2400, cache_write_tokens: 180, cost_usd: 0.0624 });
312 assert.match((await page(`tasks/${n}`)).text, /Claude Code in the terminal/);
313 const usage = await until("the terminal runs in the usage report", async () => {
314 const r = await ok("GET", "/diffinery/model-usage");
315 return r.repositories.find((x) => x.repository === `${owner}/term-tasks` && x.runs === 2) ?? null;
316 });
317 assert.equal(usage.reported_by_cli_runs, 2);
318 assert.equal(usage.cost_usd, 0.0624);
319
320 // Ending the terminal ends what it can run: the page no longer offers it, and a click is refused.
321 assert.equal((await form(`terminals/${id}/end`, {})).status, 303);
322 assert.doesNotMatch((await page(`tasks/${n}`)).text, /Run in my terminal/);
323 assert.equal((await form(`tasks/${n}/terminal`, { terminal: id })).status, 303);
324 assert.equal((await ok("GET", `${R}/${n}`)).runs.length, 2);
325 t.close?.();
326});