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.

Agent e2e: terminal tests in their own file, and test-only polls so the task tests take half the time (#140)

tasks.test.mjs reached its 600 s per-file limit on CI (0858d86). The web terminal and kill switch
tests move to terminals.test.mjs; both files import the shared setup (agents-harness.mjs).
AGENT_POLL_MS and TERMINAL_POLL_MS shorten the task and terminal polls, only on a Worker on this
machine (localTimer, the same guard as WAIT_TIMER_MINUTE_MS); unit-tested. Locally both files
together now take about 280 s, from about 600 s.


Claude-Session: https://claude.ai/code/session_014VDQqT8oL18uLJ9fpsNxW2

Co-authored-by: Claude <noreply@anthropic.com>
Canberk Demirci authored · GitHub committed signed1 parent d10062fcommit 2152c8235f523ac662aafea42cdd9b9269632d4eBrowse files
9 files changed+434−310
Files (9)
modifiedapps/diffinery/docs/self-hosting/configuration.md+2−0View file
@@ -37,6 +37,8 @@
3737 | `IMPORT_GIT_AGENT` | Not set in production; `external` in the `dev` environment | Never in production | With `external`, an import's Git transfer doesn't start a container: it waits for an import agent started by hand (see [The dev environment](#the-dev-environment)). |
3838 | `SCHEDULE_TICK_MINUTES` | Not set | No | Minutes between the workflow scheduler's checks, a whole number from 1 to 60. Default `5`, GitHub's shortest schedule interval. Anything else counts as not set. The end-to-end tests use `1`. |
3939 | `WAIT_TIMER_MINUTE_MS` | Not set | Never in production | Local development and the end-to-end tests only: how long one minute of a deployment environment's wait timer lasts, in milliseconds, from 1 to `60000` (the default). Anything else counts as not set. It counts only when `PUBLIC_URL` is on this machine (`localhost`, `127.x` or `[::1]`) and `ACCESS_AUD` is empty, as for `DEV_AUTH`; a deployed Worker ignores it. The end-to-end tests use `10000` so a wait timer passes in seconds. |
40+| `AGENT_POLL_MS` | Not set | Never in production | The end-to-end tests only: how often a running agent task is checked, in milliseconds, from `200` to `3000` (the default). Like `WAIT_TIMER_MINUTE_MS`, it counts only on a Worker on this machine; a deployed Worker ignores it. |
41+| `TERMINAL_POLL_MS` | Not set | Never in production | The end-to-end tests only: how often open web terminals are checked for their limits and their agent's access, in milliseconds, from `2000` to `60000` (the default). It counts only on a Worker on this machine; a deployed Worker ignores it. |
4042 | `EGRESS` | Not set | No | `web` limits hosted Actions jobs and agent sandboxes (including tasks, reviews and terminals) to HTTP and HTTPS (ports 80 and 443) and DNS. Any other value, or none, leaves them open to the whole internet. Accounts run by a site administrator stay open. See [EGRESS](#egress). |
4143 | `TERMINALS` | Not set | No | `off` switches [terminals](/docs/agents/terminal) off: nobody can open one, and open ones end at their next check, within a minute. Anything else, or not set, leaves them on. |
4244
modifiedapps/diffinery/package.json+1−1View file
@@ -9,7 +9,7 @@
99 "typecheck": "tsc --noEmit && tsc --noEmit -p islands",
1010 "test": "vitest run --passWithNoTests --exclude 'test/e2e/**'",
1111 "cf-typegen": "wrangler types",
12- "e2e": "node --test --test-concurrency=1 --test-timeout=600000 test/e2e/e2e.test.mjs test/e2e/passkeys.test.mjs test/e2e/registry.test.mjs test/e2e/email.test.mjs test/e2e/security.test.mjs test/e2e/agentic.test.mjs test/e2e/tasks.test.mjs test/e2e/review.test.mjs",
12+ "e2e": "node --test --test-concurrency=1 --test-timeout=600000 test/e2e/e2e.test.mjs test/e2e/passkeys.test.mjs test/e2e/registry.test.mjs test/e2e/email.test.mjs test/e2e/security.test.mjs test/e2e/agentic.test.mjs test/e2e/tasks.test.mjs test/e2e/terminals.test.mjs test/e2e/review.test.mjs",
1313 "db:migrate:local": "wrangler d1 migrations apply diffinery-dev --local -e dev",
1414 "build": "node islands/build.mjs && node scripts/build-docs.mjs",
1515 "predev": "node islands/build.mjs && node scripts/build-docs.mjs",
modifiedapps/diffinery/src/agentic/ProjectDO.ts+12−3View file
@@ -1,5 +1,6 @@
11 import { DurableObject } from "cloudflare:workers";
22 import { blockedBy, killSwitches } from "../lib/killswitch.js";
3+import { localTimer } from "../auth/dev.js";
34 import type { Env } from "../env.js";
45 import { Directory } from "../lib/directory.js";
56 import { HttpError, now, randomToken, sha256 } from "../lib/util.js";
@@ -1123,8 +1124,8 @@
11231124 console.error("terminal check failed", e);
11241125 return true;
11251126 });
1126- if (this.sql.exec<{ n: number }>(`SELECT count(*) AS n FROM runs WHERE step IN (${ACTIVE.map(() => "?").join(", ")})`, ...ACTIVE).one().n) this.arm(POLL_MS);
1127- else if (terminals) this.arm(TERMINAL_POLL_MS);
1127+ if (this.sql.exec<{ n: number }>(`SELECT count(*) AS n FROM runs WHERE step IN (${ACTIVE.map(() => "?").join(", ")})`, ...ACTIVE).one().n) this.arm(this.pollMs);
1128+ else if (terminals) this.arm(this.terminalPollMs);
11281129 }
11291130
11301131 // ---------------------------------------------------------------- web terminals (agentic/terminals.ts)
@@ -1132,6 +1133,14 @@
11321133 /** When the open terminals were last checked (they don't need the task runs' pace). */
11331134 private terminalsCheckedAt = 0;
11341135
1136+ /** POLL_MS and TERMINAL_POLL_MS, which an end-to-end test on this machine may shorten (auth/dev.ts localTimer). */
1137+ private get pollMs(): number {
1138+ return localTimer(this.env, this.env.AGENT_POLL_MS, POLL_MS, 200);
1139+ }
1140+ private get terminalPollMs(): number {
1141+ return localTimer(this.env, this.env.TERMINAL_POLL_MS, TERMINAL_POLL_MS, 2000);
1142+ }
1143+
11351144 /** A terminal was opened in this repository: start it now. */
11361145 async wakeTerminals(repoId: string): Promise<void> {
11371146 this.sql.exec("INSERT INTO project_settings (k, v) VALUES ('repo_id', ?) ON CONFLICT (k) DO UPDATE SET v = excluded.v", repoId);
@@ -1143,7 +1152,7 @@
11431152 private async tickTerminals(): Promise<boolean> {
11441153 const repoId = this.sql.exec<{ v: string }>("SELECT v FROM project_settings WHERE k = 'repo_id'").toArray()[0]?.v;
11451154 if (!repoId) return false;
1146- if (Date.now() - this.terminalsCheckedAt < TERMINAL_POLL_MS - 1000) return true;
1155+ if (Date.now() - this.terminalsCheckedAt < this.terminalPollMs - 1000) return true;
11471156 this.terminalsCheckedAt = Date.now();
11481157 const terminals = new Terminals(this.env);
11491158 const dir = new Directory(this.env);
modifiedapps/diffinery/src/auth/dev.ts+10−0View file
@@ -28,3 +28,13 @@
2828 export function localOnly(env: Pick<Env, "ACCESS_AUD" | "PUBLIC_URL">): boolean {
2929 return !env.ACCESS_AUD && loopback(env.PUBLIC_URL);
3030 }
31+
32+/**
33+ * A timer an end-to-end test may shorten: `value` (ms, from a test-only variable) counts only on a Worker on this
34+ * machine (`localOnly`), and only between `min` and `fallback`. Anything else gets `fallback`.
35+ */
36+export function localTimer(env: Pick<Env, "ACCESS_AUD" | "PUBLIC_URL">, value: string | undefined, fallback: number, min: number): number {
37+ if (!value || !localOnly(env)) return fallback;
38+ const ms = Number(value);
39+ return Number.isInteger(ms) && ms >= min && ms <= fallback ? ms : fallback;
40+}
modifiedapps/diffinery/src/env.ts+4−0View file
@@ -17,6 +17,10 @@
1717 SCHEDULE_TICK_MINUTES?: string;
1818 /** End-to-end tests only: how long one minute of an environment's wait timer lasts, in ms (default 60000). */
1919 WAIT_TIMER_MINUTE_MS?: string;
20+ /** End-to-end tests only: how often a running agent task is polled, in ms (default 3000, at least 200). */
21+ AGENT_POLL_MS?: string;
22+ /** End-to-end tests only: how often open web terminals are checked, in ms (default 60000, at least 2000). */
23+ TERMINAL_POLL_MS?: string;
2024 RUNNER: DurableObjectNamespace<RunnerContainer>;
2125 SANDBOX: DurableObjectNamespace<SandboxContainer>;
2226 /** One per repository: its agent tasks (agentic/ProjectDO.ts). */
addedapps/diffinery/test/e2e/agents-harness.mjs+171−0View file
@@ -1,0 +1,171 @@
1+// Shared setup for the agent e2e files (tasks.test.mjs, terminals.test.mjs): a real `wrangler dev`
2+// Worker, sandboxes in an `agent.mjs --sandbox-pool` process, a fake model provider, and helpers.
3+// Importing it registers the before/after hooks that start and stop them.
4+import { spawn, execFileSync } from "node:child_process";
5+import { mkdtempSync, writeFileSync, mkdirSync, rmSync, readdirSync, statSync, readFileSync, existsSync } from "node:fs";
6+import { tmpdir } from "node:os";
7+import { createHash, randomBytes } from "node:crypto";
8+import { createRequire } from "node:module";
9+import { join, dirname, resolve } from "node:path";
10+import { fileURLToPath } from "node:url";
11+import { createServer as netServer } from "node:net";
12+import { createServer as httpServer } from "node:http";
13+import { before, after } from "node:test";
14+import assert from "node:assert/strict";
15+
16+export const APP = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
17+export const AGENT = resolve(APP, "../../containers/runner/dist/agent.mjs");
18+export const work = mkdtempSync(join(tmpdir(), "diffinery-tasks-e2e-"));
19+export const poolRoot = join(work, "pool");
20+// The terminal test speaks WebSocket with headers (Origin, cookies), which Node's built-in client can't set.
21+export const { WebSocket: WS } = createRequire(resolve(APP, "../../containers/runner/package.json"))("ws");
22+export const RIVAL_SESSION = `rival-${randomBytes(12).toString("hex")}`;
23+export const ANTHROPIC_KEY = `sk-ant-api03-${randomBytes(30).toString("hex")}`;
24+export const OPENROUTER_KEY = `sk-or-v1-${randomBytes(32).toString("hex")}`;
25+export let base;
26+export let dev;
27+export let pool;
28+export let upstream;
29+export let devLog = "";
30+export let poolLog = "";
31+export let token;
32+export const modelRequests = [];
33+
34+export const freePort = () =>
35+ new Promise((res) => {
36+ const s = netServer();
37+ s.listen(0, "127.0.0.1", () => {
38+ const { port } = s.address();
39+ s.close(() => res(port));
40+ });
41+ });
42+
43+export async function api(method, path, body, tok = token) {
44+ const res = await fetch(`${base}/api/v3${path}`, {
45+ method,
46+ headers: { ...(tok === null ? {} : { authorization: `Bearer ${tok}` }), "content-type": "application/json" },
47+ body: body === undefined ? undefined : JSON.stringify(body),
48+ });
49+ const text = await res.text();
50+ let json = null;
51+ try {
52+ json = text ? JSON.parse(text) : null;
53+ } catch {
54+ json = text;
55+ }
56+ return { status: res.status, json };
57+}
58+export async function ok(method, path, body, tok) {
59+ const r = await api(method, path, body, tok);
60+ assert.ok(r.status >= 200 && r.status < 300, `${method} ${path} -> ${r.status} ${JSON.stringify(r.json)}`);
61+ return r.json;
62+}
63+/** As the signed-in person in a browser (the dev sign-in: no credentials). */
64+export const asPerson = (method, path, body) => api(method, path, body, null);
65+
66+export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
67+export async function until(what, fn, timeoutMs = 90_000) {
68+ const end = Date.now() + timeoutMs;
69+ let last;
70+ while (Date.now() < end) {
71+ last = await fn();
72+ if (last) return last;
73+ await sleep(500);
74+ }
75+ assert.fail(`timed out waiting for ${what}\n--- wrangler log tail ---\n${devLog.slice(-4000)}\n--- pool log tail ---\n${poolLog.slice(-3000)}`);
76+}
77+
78+export function git(cwd, args, tok = token) {
79+ const auth = Buffer.from(`x-token:${tok}`).toString("base64");
80+ return execFileSync("git", ["-c", `http.extraHeader=Authorization: Basic ${auth}`, "-c", "protocol.version=2", ...args], {
81+ cwd,
82+ encoding: "utf8",
83+ stdio: ["ignore", "pipe", "pipe"],
84+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_AUTHOR_NAME: "E2E", GIT_AUTHOR_EMAIL: "e2e@example.com", GIT_COMMITTER_NAME: "E2E", GIT_COMMITTER_EMAIL: "e2e@example.com" },
85+ });
86+}
87+
88+export async function mcp(name, args, tok = token) {
89+ const r = await fetch(`${base}/mcp`, { method: "POST", headers: { authorization: `Bearer ${tok}`, "content-type": "application/json", accept: "application/json, text/event-stream" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }) });
90+ const { result } = await r.json();
91+ return result;
92+}
93+
94+/** The sandboxes the pool has started, newest first. */
95+export const poolSandboxes = () =>
96+ existsSync(poolRoot)
97+ ? readdirSync(poolRoot)
98+ .map((id) => ({ id, at: statSync(join(poolRoot, id)).mtimeMs }))
99+ .sort((a, b) => b.at - a.at)
100+ .map((x) => x.id)
101+ : [];
102+
103+before(async () => {
104+ if (!existsSync(AGENT)) execFileSync("pnpm", ["--filter", "@diffinery/runner", "build"], { cwd: APP, stdio: "pipe" });
105+ // A stand-in for the provider: records what reaches it and answers like the Messages API.
106+ upstream = httpServer((req, res) => {
107+ let body = "";
108+ req.on("data", (d) => (body += d));
109+ req.on("end", () => {
110+ modelRequests.push({ method: req.method, url: req.url, headers: req.headers, body });
111+ res.writeHead(200, { "content-type": "application/json", "set-cookie": "upstream=1" });
112+ res.end(JSON.stringify({ type: "message", content: [{ type: "text", text: "fake model says hi" }] }));
113+ });
114+ });
115+ await new Promise((r) => upstream.listen(0, "127.0.0.1", r));
116+ const upstreamUrl = `http://127.0.0.1:${upstream.address().port}`;
117+
118+ const poolPort = await freePort();
119+ const poolSecret = randomBytes(16).toString("hex");
120+ pool = spawn("node", [AGENT, "--sandbox-pool", "--work-root", poolRoot], { env: { ...process.env, DIFFINERY_SANDBOX_POOL_SECRET: poolSecret, DIFFINERY_SANDBOX_PORT: String(poolPort) }, stdio: ["ignore", "pipe", "pipe"], detached: true });
121+ pool.stdout.on("data", (d) => (poolLog += d));
122+ pool.stderr.on("data", (d) => (poolLog += d));
123+ await until("sandbox pool", async () => poolLog.includes("listening"), 30_000);
124+
125+ const port = await freePort();
126+ base = `http://127.0.0.1:${port}`;
127+ const persist = join(work, "state");
128+ execFileSync("pnpm", ["exec", "wrangler", "d1", "migrations", "apply", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist], { cwd: APP, stdio: "pipe", env: { ...process.env, CI: "1" } });
129+ // A second person, signed in with a session: not the terminal's opener, whatever their role.
130+ {
131+ const at = new Date().toISOString();
132+ const later = new Date(Date.now() + 30 * 86400_000).toISOString();
133+ const hash = createHash("sha256").update(RIVAL_SESSION).digest("hex");
134+ execFileSync("pnpm", ["exec", "wrangler", "d1", "execute", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist, "--command", `INSERT INTO accounts (id, login, kind, name, status, created_at) VALUES ('u-rival', 'rival', 'user', 'Rival', 'active', '${at}'); INSERT INTO sessions (id, account_id, method, created_at, last_seen_at, expires_at) VALUES ('${hash}', 'u-rival', 'passkey', '${at}', '${at}', '${later}')`], { cwd: APP, stdio: "pipe", env: { ...process.env, CI: "1" } });
135+ }
136+ const vars = { PUBLIC_URL: base, MASTER_KEY: randomBytes(32).toString("base64"), SANDBOX_POOL_URL: `http://127.0.0.1:${poolPort}`, SANDBOX_POOL_SECRET: poolSecret, MODEL_UPSTREAM_ANTHROPIC: upstreamUrl, MODEL_UPSTREAM_OPENAI: upstreamUrl, MODEL_UPSTREAM_OPENROUTER: upstreamUrl, AGENT_POLL_MS: "500", TERMINAL_POLL_MS: "3000" };
137+ dev = spawn("pnpm", ["exec", "wrangler", "dev", "-e", "dev", "--ip", "127.0.0.1", "--port", String(port), "--inspector-port", String(await freePort()), "--persist-to", persist, ...Object.entries(vars).flatMap(([k, v]) => ["--var", `${k}:${v}`]), "--show-interactive-dev-session=false"], {
138+ cwd: APP,
139+ env: { ...process.env, CI: "1", WRANGLER_SEND_METRICS: "false" },
140+ stdio: ["ignore", "pipe", "pipe"],
141+ detached: true,
142+ });
143+ dev.stdout.on("data", (d) => (devLog += d));
144+ dev.stderr.on("data", (d) => (devLog += d));
145+ await until(
146+ "wrangler dev",
147+ async () => {
148+ try {
149+ return (await fetch(`${base}/healthz`)).ok;
150+ } catch {
151+ return false;
152+ }
153+ },
154+ 120_000,
155+ );
156+ const res = await fetch(`${base}/api/v3/diffinery/tokens`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: "e2e", scopes: ["admin"] }) });
157+ assert.equal(res.status, 201, await res.clone().text());
158+ token = (await res.json()).token;
159+});
160+
161+after(() => {
162+ for (const p of [dev, pool]) {
163+ if (p?.pid) {
164+ try {
165+ process.kill(-p.pid, "SIGTERM");
166+ } catch {}
167+ }
168+ }
169+ upstream?.close();
170+ if (process.env.E2E_KEEP !== "1") rmSync(work, { recursive: true, force: true });
171+});
modifiedapps/diffinery/test/e2e/tasks.test.mjs+28−306View file
@@ -19,166 +19,36 @@
1919 import { fileURLToPath } from "node:url";
2020 import { createServer as netServer } from "node:net";
2121 import { createServer as httpServer } from "node:http";
22-import { test, before, after } from "node:test";
22+import { test } from "node:test";
2323 import assert from "node:assert/strict";
24+import {
25+ APP,
26+ AGENT,
27+ work,
28+ poolRoot,
29+ WS,
30+ RIVAL_SESSION,
31+ ANTHROPIC_KEY,
32+ OPENROUTER_KEY,
33+ base,
34+ dev,
35+ pool,
36+ upstream,
37+ devLog,
38+ poolLog,
39+ token,
40+ modelRequests,
41+ freePort,
42+ api,
43+ ok,
44+ asPerson,
45+ sleep,
46+ until,
47+ git,
48+ mcp,
49+ poolSandboxes,
50+} from "./agents-harness.mjs";
2451
25-const APP = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
26-const AGENT = resolve(APP, "../../containers/runner/dist/agent.mjs");
27-const work = mkdtempSync(join(tmpdir(), "diffinery-tasks-e2e-"));
28-const poolRoot = join(work, "pool");
29-// The terminal test speaks WebSocket with headers (Origin, cookies), which Node's built-in client can't set.
30-const { WebSocket: WS } = createRequire(resolve(APP, "../../containers/runner/package.json"))("ws");
31-const RIVAL_SESSION = `rival-${randomBytes(12).toString("hex")}`;
32-const ANTHROPIC_KEY = `sk-ant-api03-${randomBytes(30).toString("hex")}`;
33-const OPENROUTER_KEY = `sk-or-v1-${randomBytes(32).toString("hex")}`;
34-let base;
35-let dev;
36-let pool;
37-let upstream;
38-let devLog = "";
39-let poolLog = "";
40-let token;
41-const modelRequests = [];
42-
43-const freePort = () =>
44- new Promise((res) => {
45- const s = netServer();
46- s.listen(0, "127.0.0.1", () => {
47- const { port } = s.address();
48- s.close(() => res(port));
49- });
50- });
51-
52-async function api(method, path, body, tok = token) {
53- const res = await fetch(`${base}/api/v3${path}`, {
54- method,
55- headers: { ...(tok === null ? {} : { authorization: `Bearer ${tok}` }), "content-type": "application/json" },
56- body: body === undefined ? undefined : JSON.stringify(body),
57- });
58- const text = await res.text();
59- let json = null;
60- try {
61- json = text ? JSON.parse(text) : null;
62- } catch {
63- json = text;
64- }
65- return { status: res.status, json };
66-}
67-async function ok(method, path, body, tok) {
68- const r = await api(method, path, body, tok);
69- assert.ok(r.status >= 200 && r.status < 300, `${method} ${path} -> ${r.status} ${JSON.stringify(r.json)}`);
70- return r.json;
71-}
72-/** As the signed-in person in a browser (the dev sign-in: no credentials). */
73-const asPerson = (method, path, body) => api(method, path, body, null);
74-
75-const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
76-async function until(what, fn, timeoutMs = 90_000) {
77- const end = Date.now() + timeoutMs;
78- let last;
79- while (Date.now() < end) {
80- last = await fn();
81- if (last) return last;
82- await sleep(500);
83- }
84- assert.fail(`timed out waiting for ${what}\n--- wrangler log tail ---\n${devLog.slice(-4000)}\n--- pool log tail ---\n${poolLog.slice(-3000)}`);
85-}
86-
87-function git(cwd, args, tok = token) {
88- const auth = Buffer.from(`x-token:${tok}`).toString("base64");
89- return execFileSync("git", ["-c", `http.extraHeader=Authorization: Basic ${auth}`, "-c", "protocol.version=2", ...args], {
90- cwd,
91- encoding: "utf8",
92- stdio: ["ignore", "pipe", "pipe"],
93- env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_AUTHOR_NAME: "E2E", GIT_AUTHOR_EMAIL: "e2e@example.com", GIT_COMMITTER_NAME: "E2E", GIT_COMMITTER_EMAIL: "e2e@example.com" },
94- });
95-}
96-
97-async function mcp(name, args, tok = token) {
98- const r = await fetch(`${base}/mcp`, { method: "POST", headers: { authorization: `Bearer ${tok}`, "content-type": "application/json", accept: "application/json, text/event-stream" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }) });
99- const { result } = await r.json();
100- return result;
101-}
102-
103-/** The sandboxes the pool has started, newest first. */
104-const poolSandboxes = () =>
105- existsSync(poolRoot)
106- ? readdirSync(poolRoot)
107- .map((id) => ({ id, at: statSync(join(poolRoot, id)).mtimeMs }))
108- .sort((a, b) => b.at - a.at)
109- .map((x) => x.id)
110- : [];
111-
112-before(async () => {
113- if (!existsSync(AGENT)) execFileSync("pnpm", ["--filter", "@diffinery/runner", "build"], { cwd: APP, stdio: "pipe" });
114- // A stand-in for the provider: records what reaches it and answers like the Messages API.
115- upstream = httpServer((req, res) => {
116- let body = "";
117- req.on("data", (d) => (body += d));
118- req.on("end", () => {
119- modelRequests.push({ method: req.method, url: req.url, headers: req.headers, body });
120- res.writeHead(200, { "content-type": "application/json", "set-cookie": "upstream=1" });
121- res.end(JSON.stringify({ type: "message", content: [{ type: "text", text: "fake model says hi" }] }));
122- });
123- });
124- await new Promise((r) => upstream.listen(0, "127.0.0.1", r));
125- const upstreamUrl = `http://127.0.0.1:${upstream.address().port}`;
126-
127- const poolPort = await freePort();
128- const poolSecret = randomBytes(16).toString("hex");
129- pool = spawn("node", [AGENT, "--sandbox-pool", "--work-root", poolRoot], { env: { ...process.env, DIFFINERY_SANDBOX_POOL_SECRET: poolSecret, DIFFINERY_SANDBOX_PORT: String(poolPort) }, stdio: ["ignore", "pipe", "pipe"], detached: true });
130- pool.stdout.on("data", (d) => (poolLog += d));
131- pool.stderr.on("data", (d) => (poolLog += d));
132- await until("sandbox pool", async () => poolLog.includes("listening"), 30_000);
133-
134- const port = await freePort();
135- base = `http://127.0.0.1:${port}`;
136- const persist = join(work, "state");
137- execFileSync("pnpm", ["exec", "wrangler", "d1", "migrations", "apply", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist], { cwd: APP, stdio: "pipe", env: { ...process.env, CI: "1" } });
138- // A second person, signed in with a session: not the terminal's opener, whatever their role.
139- {
140- const at = new Date().toISOString();
141- const later = new Date(Date.now() + 30 * 86400_000).toISOString();
142- const hash = createHash("sha256").update(RIVAL_SESSION).digest("hex");
143- execFileSync("pnpm", ["exec", "wrangler", "d1", "execute", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist, "--command", `INSERT INTO accounts (id, login, kind, name, status, created_at) VALUES ('u-rival', 'rival', 'user', 'Rival', 'active', '${at}'); INSERT INTO sessions (id, account_id, method, created_at, last_seen_at, expires_at) VALUES ('${hash}', 'u-rival', 'passkey', '${at}', '${at}', '${later}')`], { cwd: APP, stdio: "pipe", env: { ...process.env, CI: "1" } });
144- }
145- const vars = { PUBLIC_URL: base, MASTER_KEY: randomBytes(32).toString("base64"), SANDBOX_POOL_URL: `http://127.0.0.1:${poolPort}`, SANDBOX_POOL_SECRET: poolSecret, MODEL_UPSTREAM_ANTHROPIC: upstreamUrl, MODEL_UPSTREAM_OPENAI: upstreamUrl, MODEL_UPSTREAM_OPENROUTER: upstreamUrl };
146- dev = spawn("pnpm", ["exec", "wrangler", "dev", "-e", "dev", "--ip", "127.0.0.1", "--port", String(port), "--inspector-port", String(await freePort()), "--persist-to", persist, ...Object.entries(vars).flatMap(([k, v]) => ["--var", `${k}:${v}`]), "--show-interactive-dev-session=false"], {
147- cwd: APP,
148- env: { ...process.env, CI: "1", WRANGLER_SEND_METRICS: "false" },
149- stdio: ["ignore", "pipe", "pipe"],
150- detached: true,
151- });
152- dev.stdout.on("data", (d) => (devLog += d));
153- dev.stderr.on("data", (d) => (devLog += d));
154- await until(
155- "wrangler dev",
156- async () => {
157- try {
158- return (await fetch(`${base}/healthz`)).ok;
159- } catch {
160- return false;
161- }
162- },
163- 120_000,
164- );
165- const res = await fetch(`${base}/api/v3/diffinery/tokens`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: "e2e", scopes: ["admin"] }) });
166- assert.equal(res.status, 201, await res.clone().text());
167- token = (await res.json()).token;
168-});
169-
170-after(() => {
171- for (const p of [dev, pool]) {
172- if (p?.pid) {
173- try {
174- process.kill(-p.pid, "SIGTERM");
175- } catch {}
176- }
177- }
178- upstream?.close();
179- if (process.env.E2E_KEEP !== "1") rmSync(work, { recursive: true, force: true });
180-});
181-
18252 test("model keys: write-only, browser session only, with an opt-in", async () => {
18353 assert.deepEqual(await ok("GET", "/diffinery/model-keys"), []);
18454 // No token can set one, however wide.
@@ -1098,151 +968,3 @@
1098968 assert.equal(t.state, "stopped");
1099969 assert.equal((await fetch(`${base}/-/model/anthropic/v1/messages`, { method: "POST", headers: { "x-api-key": runToken }, body: "{}" })).status, 401);
1100970 });
1101-
1102-/** Opens a terminal's WebSocket; resolves with the HTTP status when the upgrade is refused. */
1103-function termSocket(url, headers) {
1104- return new Promise((resolve) => {
1105- const ws = new WS(url, { headers });
1106- let out = "";
1107- ws.on("message", (d, binary) => {
1108- if (binary) out += String(d);
1109- });
1110- let closedWith = null;
1111- const closed = new Promise((r) => ws.on("close", (code, reason) => r((closedWith = { code, reason: String(reason) }))));
1112- ws.once("unexpected-response", (_req, res) => resolve({ status: res.statusCode }));
1113- ws.once("error", (e) => resolve({ status: 0, error: e.message }));
1114- ws.once("open", () =>
1115- resolve({
1116- status: 101,
1117- ws,
1118- closed,
1119- out: () => out,
1120- type: (text) => ws.send(Buffer.from(text)),
1121- 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),
1122- }),
1123- );
1124- });
1125-}
1126-
1127-test("a web terminal: only its opener attaches, the sandbox API can't reach it, and End takes it away", async () => {
1128- const me = await ok("GET", "/user");
1129- const owner = me.login;
1130- await ok("POST", "/user/repos", { name: "term-demo" });
1131- await ok("PUT", `/repos/${owner}/term-demo/contents/README.md`, { message: "init", content: Buffer.from("# term\n").toString("base64") });
1132- assert.equal((await asPerson("POST", "/diffinery/agents", { login: "term-bot", name: "Term Bot" })).status, 201);
1133- const page = (path) => fetch(`${base}/${owner}/term-demo/${path}`).then(async (r) => ({ status: r.status, text: await r.text(), headers: r.headers }));
1134- async function form(path, fields, headers = {}) {
1135- const res = await fetch(`${base}/${owner}/term-demo/terminals`, { headers });
1136- const csrf = /name="_csrf" value="([^"]+)"/.exec(await res.text())?.[1];
1137- const cookie = /dfy_csrf=([^;]+)/.exec(res.headers.get("set-cookie") ?? "")?.[1];
1138- 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 }) });
1139- }
1140-
1141- // The notice comes first, and it is a Kumo checkbox, not a native one.
1142- const list = await page("terminals");
1143- assert.equal(list.status, 200);
1144- assert.match(list.text, /Open a terminal/);
1145- assert.match(list.text, /my own Anthropic or OpenAI account/);
1146- assert.ok(!/<input[^>]+type="checkbox"/.test(list.text.replace(/<input[^>]+data-k[^>]*>/g, "")), "no native checkbox");
1147- const refused = await form("terminals", { agent: "term-bot", ref: "main" });
1148- assert.equal(refused.status, 422);
1149- assert.match(await refused.text(), /accept the notice first/);
1150- const opened = await form("terminals", { agent: "term-bot", ref: "main", accept: "1" });
1151- assert.equal(opened.status, 303, await opened.text());
1152- const path = new URL(opened.headers.get("location"), base).pathname;
1153- const id = path.split("/").pop();
1154- const running = await until("the terminal to start", async () => {
1155- const p = await page(`terminals/${id}`);
1156- return p.text.includes("data-socket") ? p : null;
1157- });
1158- assert.match(running.text, /Start Claude Code/);
1159- assert.match(running.text, /Start Codex/, "a private repository offers Codex");
1160- assert.match(running.text, /\/-\/terminal\/[a-z0-9]+\.js/);
1161- const sandboxId = poolSandboxes()[0];
1162-
1163- // The socket: this site's Origin, a browser session, and only the opener.
1164- const url = `${base.replace("http", "ws")}/${owner}/term-demo/terminals/${id}/socket?cols=100&rows=30`;
1165- const origin = new URL(base).origin;
1166- assert.equal((await termSocket(url, {})).status, 403, "no Origin");
1167- assert.equal((await termSocket(url, { origin: "https://evil.example" })).status, 403, "another Origin");
1168- assert.equal((await termSocket(url, { origin, authorization: `Bearer ${token}` })).status, 403, "a token, even the opener's own admin token");
1169- assert.equal((await termSocket(url, { origin, cookie: `__Host-dfy_session=${RIVAL_SESSION}` })).status, 404, "another person");
1170- const t = await termSocket(url, { origin });
1171- assert.equal(t.status, 101, JSON.stringify(t));
1172- t.type("stty size; echo \"s=[$DIFFINERY_SANDBOX_SECRET] t=[$DIFFINERY_JOB_TOKEN]\"; git rev-parse --abbrev-ref HEAD; echo hi-$((6*7))\n");
1173- await t.until(/hi-42/);
1174- assert.match(t.out(), /30 100/);
1175- assert.match(t.out(), /s=\[\] t=\[\]/);
1176- assert.match(t.out(), /\bmain\b/);
1177-
1178- // Another person, even one the owner made an admin of the repository, sees nothing of it.
1179- const invite = await ok("PUT", `/repos/${owner}/term-demo/collaborators/rival`, { permission: "admin" });
1180- 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" } });
1181- const rival = { cookie: `__Host-dfy_session=${RIVAL_SESSION}` };
1182- assert.equal((await fetch(`${base}/${owner}/term-demo/terminals/${id}`, { headers: rival })).status, 404);
1183- assert.ok(!(await (await fetch(`${base}/${owner}/term-demo/terminals`, { headers: rival })).text()).includes(id));
1184- assert.equal((await form(`terminals/${id}/end`, {}, rival)).status, 404);
1185- assert.equal((await termSocket(url, { origin, ...rival })).status, 404);
1186-
1187- // The sandbox API and MCP never reach a terminal's sandbox, not even for a site admin's admin token.
1188- assert.equal((await api("POST", `/repos/${owner}/term-demo/diffinery/sandboxes/${sandboxId}/exec`, { cmd: "cat ~/.claude/.credentials.json" })).status, 404);
1189- assert.equal((await api("POST", `/repos/${owner}/term-demo/diffinery/sandboxes/${sandboxId}/read`, { path: "/root/.codex/auth.json" })).status, 404);
1190- assert.equal((await api("POST", `/repos/${owner}/term-demo/diffinery/sandboxes/${sandboxId}/close`)).status, 404);
1191- const viaMcp = await mcp("sandbox_exec", { owner, repo: "term-demo", sandbox_id: sandboxId, command: "id" });
1192- assert.ok(viaMcp?.isError, JSON.stringify(viaMcp));
1193-
1194- // Limits: two open per repository.
1195- const second = await form("terminals", { agent: "term-bot", ref: "main" });
1196- assert.equal(second.status, 303);
1197- const third = await form("terminals", { agent: "term-bot", ref: "main" });
1198- assert.equal(third.status, 422);
1199- assert.match(await third.text(), /2 terminals open in this repository/);
1200- const secondId = new URL(second.headers.get("location"), base).pathname.split("/").pop();
1201-
1202- // End: the socket closes, the sandbox and its git credential go, and it can't be attached again.
1203- assert.equal((await form(`terminals/${id}/end`, {})).status, 303);
1204- const closed = await t.closed;
1205- assert.ok([1000, 1006, 1011].includes(closed.code), JSON.stringify(closed));
1206- assert.equal((await termSocket(url, { origin })).status, 409);
1207- assert.match((await page(`terminals/${id}`)).text, /You ended it/);
1208- await until("the pool to stop the sandbox", async () => !poolSandboxes().includes(sandboxId) || (await fetch(`${base}/${owner}/term-demo/terminals/${id}`)).ok);
1209-
1210- // Losing access ends a terminal too: the agent's write access goes, and the next check ends it.
1211- await until("the second terminal to start", async () => (await page(`terminals/${secondId}`)).text.includes("data-socket"));
1212- assert.equal((await api("DELETE", "/diffinery/agents/term-bot")).status, 204);
1213- 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);
1214-});
1215-
1216-test("a kill switch ends web terminals and refuses new ones until it's lifted", async () => {
1217- const owner = (await ok("GET", "/user")).login;
1218- assert.equal((await asPerson("POST", "/diffinery/agents", { login: "term-bot-2", name: "Term Bot 2" })).status, 201);
1219- const page = (path) => fetch(`${base}/${owner}/term-demo/${path}`).then(async (r) => ({ status: r.status, text: await r.text() }));
1220- async function form(path, fields) {
1221- const res = await fetch(`${base}/${owner}/term-demo/terminals`);
1222- const csrf = /name="_csrf" value="([^"]+)"/.exec(await res.text())?.[1];
1223- const cookie = /dfy_csrf=([^;]+)/.exec(res.headers.get("set-cookie") ?? "")?.[1];
1224- 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 }) });
1225- }
1226- const opened = await form("terminals", { agent: "term-bot-2", ref: "main" });
1227- assert.equal(opened.status, 303, await opened.text());
1228- const id = new URL(opened.headers.get("location"), base).pathname.split("/").pop();
1229- await until("the terminal to start", async () => (await page(`terminals/${id}`)).text.includes("data-socket"));
1230- const url = `${base.replace("http", "ws")}/${owner}/term-demo/terminals/${id}/socket`;
1231- const origin = new URL(base).origin;
1232-
1233- // Suspending the repository's Actions ends the terminal, refuses attaching and opening another.
1234- const S = `/admin/repos/${owner}/term-demo/actions-suspension`;
1235- assert.equal((await api("PUT", S, { reason: "abuse report" })).status, 200);
1236- const refused = await termSocket(url, { origin });
1237- assert.ok([403, 409].includes(refused.status), JSON.stringify(refused));
1238- const again = await form("terminals", { agent: "term-bot-2", ref: "main" });
1239- assert.equal(again.status, 422);
1240- assert.match(await again.text(), /Actions are suspended for this repository by a site administrator: abuse report/);
1241- await until("the terminal to end", async () => /suspended Actions and sandboxes here|Its sandbox stopped/.test((await page(`terminals/${id}`)).text), 150_000);
1242-
1243- // Lifted, a terminal opens again.
1244- assert.equal((await api("DELETE", S)).status, 200);
1245- const after = await form("terminals", { agent: "term-bot-2", ref: "main" });
1246- assert.equal(after.status, 303, await after.text());
1247- assert.equal((await form(`${new URL(after.headers.get("location"), base).pathname.split("/").slice(3).join("/")}/end`, {})).status, 303);
1248-});
addedapps/diffinery/test/e2e/terminals.test.mjs+190−0View file
@@ -1,0 +1,190 @@
1+// End-to-end web terminals and kill switches on them, 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
5+import { spawn, execFileSync } from "node:child_process";
6+import { mkdtempSync, writeFileSync, mkdirSync, rmSync, readdirSync, statSync, readFileSync, existsSync } from "node:fs";
7+import { tmpdir } from "node:os";
8+import { createHash, randomBytes } from "node:crypto";
9+import { createRequire } from "node:module";
10+import { join, dirname, resolve } from "node:path";
11+import { fileURLToPath } from "node:url";
12+import { createServer as netServer } from "node:net";
13+import { createServer as httpServer } from "node:http";
14+import { test } from "node:test";
15+import assert from "node:assert/strict";
16+import {
17+ APP,
18+ AGENT,
19+ work,
20+ poolRoot,
21+ WS,
22+ RIVAL_SESSION,
23+ ANTHROPIC_KEY,
24+ OPENROUTER_KEY,
25+ base,
26+ dev,
27+ pool,
28+ upstream,
29+ devLog,
30+ poolLog,
31+ token,
32+ modelRequests,
33+ freePort,
34+ api,
35+ ok,
36+ asPerson,
37+ sleep,
38+ until,
39+ git,
40+ mcp,
41+ poolSandboxes,
42+} from "./agents-harness.mjs";
43+
44+/** Opens a terminal's WebSocket; resolves with the HTTP status when the upgrade is refused. */
45+function termSocket(url, headers) {
46+ return new Promise((resolve) => {
47+ const ws = new WS(url, { headers });
48+ let out = "";
49+ ws.on("message", (d, binary) => {
50+ if (binary) out += String(d);
51+ });
52+ let closedWith = null;
53+ const closed = new Promise((r) => ws.on("close", (code, reason) => r((closedWith = { code, reason: String(reason) }))));
54+ ws.once("unexpected-response", (_req, res) => resolve({ status: res.statusCode }));
55+ ws.once("error", (e) => resolve({ status: 0, error: e.message }));
56+ ws.once("open", () =>
57+ resolve({
58+ status: 101,
59+ ws,
60+ closed,
61+ out: () => out,
62+ type: (text) => ws.send(Buffer.from(text)),
63+ 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),
64+ }),
65+ );
66+ });
67+}
68+
69+test("a web terminal: only its opener attaches, the sandbox API can't reach it, and End takes it away", async () => {
70+ const me = await ok("GET", "/user");
71+ const owner = me.login;
72+ await ok("POST", "/user/repos", { name: "term-demo" });
73+ await ok("PUT", `/repos/${owner}/term-demo/contents/README.md`, { message: "init", content: Buffer.from("# term\n").toString("base64") });
74+ assert.equal((await asPerson("POST", "/diffinery/agents", { login: "term-bot", name: "Term Bot" })).status, 201);
75+ const page = (path) => fetch(`${base}/${owner}/term-demo/${path}`).then(async (r) => ({ status: r.status, text: await r.text(), headers: r.headers }));
76+ async function form(path, fields, headers = {}) {
77+ const res = await fetch(`${base}/${owner}/term-demo/terminals`, { headers });
78+ const csrf = /name="_csrf" value="([^"]+)"/.exec(await res.text())?.[1];
79+ const cookie = /dfy_csrf=([^;]+)/.exec(res.headers.get("set-cookie") ?? "")?.[1];
80+ 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 }) });
81+ }
82+
83+ // The notice comes first, and it is a Kumo checkbox, not a native one.
84+ const list = await page("terminals");
85+ assert.equal(list.status, 200);
86+ assert.match(list.text, /Open a terminal/);
87+ assert.match(list.text, /my own Anthropic or OpenAI account/);
88+ assert.ok(!/<input[^>]+type="checkbox"/.test(list.text.replace(/<input[^>]+data-k[^>]*>/g, "")), "no native checkbox");
89+ const refused = await form("terminals", { agent: "term-bot", ref: "main" });
90+ assert.equal(refused.status, 422);
91+ assert.match(await refused.text(), /accept the notice first/);
92+ const opened = await form("terminals", { agent: "term-bot", ref: "main", accept: "1" });
93+ assert.equal(opened.status, 303, await opened.text());
94+ const path = new URL(opened.headers.get("location"), base).pathname;
95+ const id = path.split("/").pop();
96+ const running = await until("the terminal to start", async () => {
97+ const p = await page(`terminals/${id}`);
98+ return p.text.includes("data-socket") ? p : null;
99+ });
100+ assert.match(running.text, /Start Claude Code/);
101+ assert.match(running.text, /Start Codex/, "a private repository offers Codex");
102+ assert.match(running.text, /\/-\/terminal\/[a-z0-9]+\.js/);
103+ const sandboxId = poolSandboxes()[0];
104+
105+ // The socket: this site's Origin, a browser session, and only the opener.
106+ const url = `${base.replace("http", "ws")}/${owner}/term-demo/terminals/${id}/socket?cols=100&rows=30`;
107+ const origin = new URL(base).origin;
108+ assert.equal((await termSocket(url, {})).status, 403, "no Origin");
109+ assert.equal((await termSocket(url, { origin: "https://evil.example" })).status, 403, "another Origin");
110+ assert.equal((await termSocket(url, { origin, authorization: `Bearer ${token}` })).status, 403, "a token, even the opener's own admin token");
111+ assert.equal((await termSocket(url, { origin, cookie: `__Host-dfy_session=${RIVAL_SESSION}` })).status, 404, "another person");
112+ const t = await termSocket(url, { origin });
113+ assert.equal(t.status, 101, JSON.stringify(t));
114+ t.type("stty size; echo \"s=[$DIFFINERY_SANDBOX_SECRET] t=[$DIFFINERY_JOB_TOKEN]\"; git rev-parse --abbrev-ref HEAD; echo hi-$((6*7))\n");
115+ await t.until(/hi-42/);
116+ assert.match(t.out(), /30 100/);
117+ assert.match(t.out(), /s=\[\] t=\[\]/);
118+ assert.match(t.out(), /\bmain\b/);
119+
120+ // Another person, even one the owner made an admin of the repository, sees nothing of it.
121+ const invite = await ok("PUT", `/repos/${owner}/term-demo/collaborators/rival`, { permission: "admin" });
122+ 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" } });
123+ const rival = { cookie: `__Host-dfy_session=${RIVAL_SESSION}` };
124+ assert.equal((await fetch(`${base}/${owner}/term-demo/terminals/${id}`, { headers: rival })).status, 404);
125+ assert.ok(!(await (await fetch(`${base}/${owner}/term-demo/terminals`, { headers: rival })).text()).includes(id));
126+ assert.equal((await form(`terminals/${id}/end`, {}, rival)).status, 404);
127+ assert.equal((await termSocket(url, { origin, ...rival })).status, 404);
128+
129+ // The sandbox API and MCP never reach a terminal's sandbox, not even for a site admin's admin token.
130+ assert.equal((await api("POST", `/repos/${owner}/term-demo/diffinery/sandboxes/${sandboxId}/exec`, { cmd: "cat ~/.claude/.credentials.json" })).status, 404);
131+ assert.equal((await api("POST", `/repos/${owner}/term-demo/diffinery/sandboxes/${sandboxId}/read`, { path: "/root/.codex/auth.json" })).status, 404);
132+ assert.equal((await api("POST", `/repos/${owner}/term-demo/diffinery/sandboxes/${sandboxId}/close`)).status, 404);
133+ const viaMcp = await mcp("sandbox_exec", { owner, repo: "term-demo", sandbox_id: sandboxId, command: "id" });
134+ assert.ok(viaMcp?.isError, JSON.stringify(viaMcp));
135+
136+ // Limits: two open per repository.
137+ const second = await form("terminals", { agent: "term-bot", ref: "main" });
138+ assert.equal(second.status, 303);
139+ const third = await form("terminals", { agent: "term-bot", ref: "main" });
140+ assert.equal(third.status, 422);
141+ assert.match(await third.text(), /2 terminals open in this repository/);
142+ const secondId = new URL(second.headers.get("location"), base).pathname.split("/").pop();
143+
144+ // End: the socket closes, the sandbox and its git credential go, and it can't be attached again.
145+ assert.equal((await form(`terminals/${id}/end`, {})).status, 303);
146+ const closed = await t.closed;
147+ assert.ok([1000, 1006, 1011].includes(closed.code), JSON.stringify(closed));
148+ assert.equal((await termSocket(url, { origin })).status, 409);
149+ assert.match((await page(`terminals/${id}`)).text, /You ended it/);
150+ await until("the pool to stop the sandbox", async () => !poolSandboxes().includes(sandboxId) || (await fetch(`${base}/${owner}/term-demo/terminals/${id}`)).ok);
151+
152+ // Losing access ends a terminal too: the agent's write access goes, and the next check ends it.
153+ await until("the second terminal to start", async () => (await page(`terminals/${secondId}`)).text.includes("data-socket"));
154+ assert.equal((await api("DELETE", "/diffinery/agents/term-bot")).status, 204);
155+ 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);
156+});
157+
158+test("a kill switch ends web terminals and refuses new ones until it's lifted", async () => {
159+ const owner = (await ok("GET", "/user")).login;
160+ assert.equal((await asPerson("POST", "/diffinery/agents", { login: "term-bot-2", name: "Term Bot 2" })).status, 201);
161+ const page = (path) => fetch(`${base}/${owner}/term-demo/${path}`).then(async (r) => ({ status: r.status, text: await r.text() }));
162+ async function form(path, fields) {
163+ const res = await fetch(`${base}/${owner}/term-demo/terminals`);
164+ const csrf = /name="_csrf" value="([^"]+)"/.exec(await res.text())?.[1];
165+ const cookie = /dfy_csrf=([^;]+)/.exec(res.headers.get("set-cookie") ?? "")?.[1];
166+ 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 }) });
167+ }
168+ const opened = await form("terminals", { agent: "term-bot-2", ref: "main" });
169+ assert.equal(opened.status, 303, await opened.text());
170+ const id = new URL(opened.headers.get("location"), base).pathname.split("/").pop();
171+ await until("the terminal to start", async () => (await page(`terminals/${id}`)).text.includes("data-socket"));
172+ const url = `${base.replace("http", "ws")}/${owner}/term-demo/terminals/${id}/socket`;
173+ const origin = new URL(base).origin;
174+
175+ // Suspending the repository's Actions ends the terminal, refuses attaching and opening another.
176+ const S = `/admin/repos/${owner}/term-demo/actions-suspension`;
177+ assert.equal((await api("PUT", S, { reason: "abuse report" })).status, 200);
178+ const refused = await termSocket(url, { origin });
179+ assert.ok([403, 409].includes(refused.status), JSON.stringify(refused));
180+ const again = await form("terminals", { agent: "term-bot-2", ref: "main" });
181+ assert.equal(again.status, 422);
182+ assert.match(await again.text(), /Actions are suspended for this repository by a site administrator: abuse report/);
183+ await until("the terminal to end", async () => /suspended Actions and sandboxes here|Its sandbox stopped/.test((await page(`terminals/${id}`)).text), 150_000);
184+
185+ // Lifted, a terminal opens again.
186+ assert.equal((await api("DELETE", S)).status, 200);
187+ const after = await form("terminals", { agent: "term-bot-2", ref: "main" });
188+ assert.equal(after.status, 303, await after.text());
189+ assert.equal((await form(`${new URL(after.headers.get("location"), base).pathname.split("/").slice(3).join("/")}/end`, {})).status, 303);
190+});
modifiedapps/diffinery/test/unit/wait-timer.test.ts+16−0View file
@@ -1,5 +1,6 @@
11 import { describe, expect, it } from "vitest";
22 import { timerMinute } from "../../src/repo/actions.js";
3+import { localTimer } from "../../src/auth/dev.js";
34
45 const local = { WAIT_TIMER_MINUTE_MS: "10000", ACCESS_AUD: "", PUBLIC_URL: "http://127.0.0.1:41234" };
56
@@ -21,3 +22,18 @@
2122 for (const v of ["0", "-5", "1.5", "60001", "soon", ""]) expect(timerMinute({ ...local, WAIT_TIMER_MINUTE_MS: v })).toBe(60_000);
2223 });
2324 });
25+
26+describe("test-only polls (agent tasks, web terminals)", () => {
27+ const env = { ACCESS_AUD: "", PUBLIC_URL: "http://127.0.0.1:41234" };
28+ it("shortens a poll only on a Worker on this machine", () => {
29+ expect(localTimer(env, "500", 3000, 200)).toBe(500);
30+ expect(localTimer(env, undefined, 3000, 200)).toBe(3000);
31+ expect(localTimer({ ...env, PUBLIC_URL: "https://diffinery.com" }, "500", 3000, 200)).toBe(3000);
32+ expect(localTimer({ ...env, PUBLIC_URL: "https://localhost.evil.example" }, "500", 3000, 200)).toBe(3000);
33+ expect(localTimer({ ...env, ACCESS_AUD: "aud-tag" }, "500", 3000, 200)).toBe(3000);
34+ });
35+
36+ it("ignores values below the floor, above the default, or not whole milliseconds", () => {
37+ for (const v of ["0", "199", "3001", "1.5", "soon", ""]) expect(localTimer(env, v, 3000, 200)).toBe(3000);
38+ });
39+});