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.

428 lines · 26.3 KBRaw History
1// End-to-end Agent setup (lib/agent-setup.ts, web/agent-setup.tsx) against a real `wrangler dev` Worker: a
2// person generates a token on Settings → Agent setup, and a fresh machine (its own HOME, nothing installed)
3// runs the hosted setup script with only the page's environment variables, as a Claude Code cloud
4// environment does.
5//
6// pnpm --filter @diffinery/app exec node --test test/e2e/agent-setup.test.mjs
7//
8// Covers: the page (people with write access in a browser only), the script (no secret in it, a 404 for a
9// name that can't be a repository), the CLI install, git and gh wired to the environment with no token on
10// disk, the clone, the self-test and the Check turning green, a second run skipping the download, failure
11// lines that say what to change, the token reaching only its repository (not even a public one), merging
12// only what a person approved and never a workflow change, turning merging off, and revoke, regenerate
13// and renew. gh only talks HTTPS, so a stand-in gh (below) does what gh does with GH_ENTERPRISE_TOKEN and
14// GH_HOST; the real gh runs against diffinery.com in the live check.
15import { spawn, execFileSync } from "node:child_process";
16import { createHash, randomBytes } from "node:crypto";
17import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
18import { tmpdir } from "node:os";
19import { join, dirname, resolve } from "node:path";
20import { fileURLToPath } from "node:url";
21import { test, before, after } from "node:test";
22import assert from "node:assert/strict";
23import { migratedState } from "./d1-state.mjs";
24import { freePort } from "./ports.mjs";
25
26const APP = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
27const work = mkdtempSync(join(tmpdir(), "diffinery-agent-setup-"));
28let base;
29let host;
30let dev;
31let devLog = "";
32
33const hash = (s) => createHash("sha256").update(s).digest("hex");
34const tok = (name) => `dfy_${name}${randomBytes(12).toString("hex")}`;
35const T = { ada: tok("ada"), bob: tok("bob"), wes: tok("wes") };
36const S = { ada: `ada-session-${randomBytes(12).toString("hex")}`, bob: `bob-session-${randomBytes(12).toString("hex")}`, wes: `wes-session-${randomBytes(12).toString("hex")}` };
37
38const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
39async function until(what, fn, timeoutMs = 120_000) {
40 const end = Date.now() + timeoutMs;
41 while (Date.now() < end) {
42 const v = await fn();
43 if (v) return v;
44 await sleep(500);
45 }
46 assert.fail(`timed out waiting for ${what}\n--- wrangler log tail ---\n${devLog.slice(-4000)}`);
47}
48
49async function api(method, path, body, as) {
50 const headers = { "content-type": "application/json" };
51 if (as) headers.authorization = `Bearer ${as}`;
52 const res = await fetch(`${base}/api/v3${path}`, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) });
53 const text = await res.text();
54 let json = null;
55 try {
56 json = text ? JSON.parse(text) : null;
57 } catch {
58 json = text;
59 }
60 return { status: res.status, json };
61}
62async function ok(method, path, body, as) {
63 const r = await api(method, path, body, as);
64 assert.ok(r.status >= 200 && r.status < 300, `${method} ${path} -> ${r.status} ${JSON.stringify(r.json)}`);
65 return r.json;
66}
67const b64 = (s) => Buffer.from(s).toString("base64");
68
69async function page(session, path) {
70 const res = await fetch(`${base}${path}`, { headers: session ? { cookie: `__Host-dfy_session=${session}` } : {}, redirect: "manual" });
71 return { status: res.status, location: res.headers.get("location"), text: await res.text() };
72}
73/** A form post as `session`, with the CSRF pair from the page at `from`. */
74async function submit(session, from, action, fields = {}) {
75 const cookie = `__Host-dfy_session=${session}`;
76 const res = await fetch(`${base}${from}`, { headers: { cookie } });
77 const csrf = /name="_csrf" value="([^"]+)"/.exec(await res.text())?.[1];
78 const pair = /dfy_csrf=([^;]+)/.exec(res.headers.get("set-cookie") ?? "")?.[1];
79 assert.ok(csrf && pair, `no CSRF pair on ${from} (${res.status})`);
80 const r = await fetch(`${base}${action}`, { method: "POST", body: new URLSearchParams({ _csrf: csrf, ...fields }), redirect: "manual", headers: { cookie: `${cookie}; dfy_csrf=${pair}`, "sec-fetch-site": "same-origin" } });
81 return { status: r.status, location: r.headers.get("location"), text: await r.text(), flash: decodeURIComponent(decodeURIComponent(r.headers.get("set-cookie") ?? "")) };
82}
83const unescape = (s) => s.replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&");
84/** The Environment variables box's text. */
85const envBox = (html) => unescape(/<pre[^>]*id="agent-env"[^>]*>([\s\S]*?)<\/pre>/.exec(html)?.[1] ?? "").trim();
86
87/**
88 * A stand-in for the GitHub CLI: what gh does with GH_ENTERPRISE_TOKEN and GH_HOST (it needs HTTPS, and this
89 * server is plain HTTP). `gh auth login` refuses while the token is in the environment, as gh does.
90 */
91function fakeGh(dir) {
92 mkdirSync(dir, { recursive: true });
93 writeFileSync(
94 join(dir, "gh"),
95 `#!/usr/bin/env node
96const { execFileSync } = require("node:child_process");
97const [cmd, sub, ...rest] = process.argv.slice(2);
98if (cmd === "--version") { console.log("gh version 2.0.0 (stand-in)"); process.exit(0); }
99const token = process.env.GH_ENTERPRISE_TOKEN;
100if (cmd === "auth") { if (token) { console.error("The value of the GH_ENTERPRISE_TOKEN environment variable is being used for authentication."); process.exit(1); } process.exit(0); }
101const arg = (n) => { const i = rest.indexOf(n); return i >= 0 ? rest[i + 1] : undefined; };
102let host = arg("--hostname") ?? process.env.GH_HOST;
103let path;
104if (cmd === "api" && sub === "user") path = "/api/v3/user";
105else if (cmd === "pr" && sub === "list") {
106 // In a clone gh finds the repository from its remote, on a host it has a token for (GH_HOST with an environment token).
107 const remote = execFileSync("git", ["remote", "get-url", "origin"], { encoding: "utf8" }).trim();
108 const u = new URL(remote);
109 if (u.host !== process.env.GH_HOST) { console.error("none of the git remotes configured for this repository point to a known GitHub host"); process.exit(1); }
110 path = "/api/v3/repos" + u.pathname.replace(/\\.git$/, "") + "/pulls?state=all";
111} else { console.error("stand-in gh: unsupported " + cmd + " " + sub); process.exit(2); }
112if (!token || !host) { console.error("To get started with GitHub CLI, please run: gh auth login"); process.exit(4); }
113fetch("http://" + host + path, { headers: { authorization: "token " + token } }).then(async (r) => {
114 const j = await r.json();
115 if (!r.ok) { console.error("HTTP " + r.status + ": " + j.message); process.exit(1); }
116 console.log(cmd === "api" ? j.login : JSON.stringify(j.map((p) => ({ number: p.number }))));
117});
118`,
119 );
120 chmodSync(join(dir, "gh"), 0o755);
121}
122
123/** A fresh machine: its own HOME, git with no global config, a bin directory on PATH to link diffinery from, and the stand-in gh. */
124function machine(name) {
125 const root = join(work, name);
126 const home = join(root, "home");
127 const bin = join(root, "bin");
128 const ghDir = join(root, "gh");
129 const where = join(root, "work");
130 for (const d of [home, bin, where]) mkdirSync(d, { recursive: true });
131 fakeGh(ghDir);
132 const env = {
133 HOME: home,
134 PATH: `${bin}:${ghDir}:${process.env.PATH}`,
135 DIFFINERY_LINK_DIR: bin,
136 DIFFINERY_CLONE_DIR: where,
137 GIT_CONFIG_NOSYSTEM: "1",
138 NO_COLOR: "1",
139 LANG: "C.UTF-8",
140 };
141 return { root, home, bin, where, env };
142}
143
144/**
145 * Runs a command without blocking the event loop (a blocked loop leaves fetch a keep-alive connection the
146 * server already closed). Resolves with its exit code and output; `check` rejects on a non-zero exit.
147 */
148function sh(cmd, args, opts = {}, check = false) {
149 return new Promise((res, rej) => {
150 const p = spawn(cmd, args, { ...opts, stdio: ["ignore", "pipe", "pipe"] });
151 let stdout = "";
152 let stderr = "";
153 p.stdout.on("data", (d) => (stdout += d));
154 p.stderr.on("data", (d) => (stderr += d));
155 const timer = setTimeout(() => p.kill("SIGKILL"), 180_000);
156 p.on("error", rej);
157 p.on("close", (code) => {
158 clearTimeout(timer);
159 if (check && code !== 0) rej(new Error(`${cmd} ${args.join(" ")} exited ${code}: ${stderr}`));
160 else res({ code, stdout, stderr, out: `${stdout}${stderr}` });
161 });
162 });
163}
164
165/** Runs the setup line from the page, as a setup script field would. */
166const runSetup = (m, extra) => sh("bash", ["-c", `curl -fsSL ${base}/ada/zutrix/agent-setup.sh | bash`], { cwd: m.root, env: { ...m.env, ...extra } });
167
168const auth = (token) => ["-c", `http.extraHeader=Authorization: Basic ${b64(`x-token:${token}`)}`];
169const git = async (cwd, args, token, env = {}) => (await sh("git", [...auth(token), ...args], { cwd, env: { ...process.env, GIT_TERMINAL_PROMPT: "0", ...env } }, true)).stdout;
170const gitFails = async (cwd, args, token) => {
171 const r = await sh("git", [...auth(token), ...args], { cwd, env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } });
172 assert.notEqual(r.code, 0, `git ${args.join(" ")} should fail`);
173 return r.stderr;
174};
175
176before(async () => {
177 const port = await freePort();
178 base = `http://127.0.0.1:${port}`;
179 host = `127.0.0.1:${port}`;
180 const persist = join(work, "state");
181 const wrangler = (...args) => execFileSync("pnpm", ["exec", "wrangler", ...args], { cwd: APP, stdio: "pipe", env: { ...process.env, CI: "1" } });
182 migratedState(APP, persist);
183 const at = new Date().toISOString();
184 const later = new Date(Date.now() + 30 * 86400_000).toISOString();
185 const account = (id, login) => `INSERT INTO accounts (id, login, kind, name, email, status, created_at) VALUES ('${id}', '${login}', 'user', '${login}', '${login}@example.com', 'active', '${at}')`;
186 const token = (id, acct, t) => `INSERT INTO tokens (id, account_id, name, token_hash, prefix, scopes, expires_at, created_at) VALUES ('${id}', '${acct}', 'e2e', '${hash(t)}', '${t.slice(0, 10)}', '["admin"]', '${later}', '${at}')`;
187 const session = (acct, cookie) => `INSERT INTO sessions (id, account_id, method, created_at, last_seen_at, expires_at) VALUES ('${hash(cookie)}', '${acct}', 'passkey', '${at}', '${at}', '${later}')`;
188 wrangler(
189 "d1", "execute", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist, "--command",
190 [account("u-ada", "ada"), account("u-bob", "bob"), account("u-wes", "wes"), token("t-ada", "u-ada", T.ada), token("t-bob", "u-bob", T.bob), token("t-wes", "u-wes", T.wes), session("u-ada", S.ada), session("u-bob", S.bob), session("u-wes", S.wes)].join("; "),
191 );
192 dev = spawn(
193 "pnpm",
194 ["exec", "wrangler", "dev", "-e", "dev", "--ip", "127.0.0.1", "--port", String(port), "--inspector-port", String(await freePort()), "--persist-to", persist, "--var", `PUBLIC_URL:${base}`, "--var", "DEV_AUTH:", "--var", `MASTER_KEY:${randomBytes(32).toString("base64")}`, "--show-interactive-dev-session=false"],
195 { cwd: APP, env: { ...process.env, CI: "1", WRANGLER_SEND_METRICS: "false" }, stdio: ["ignore", "pipe", "pipe"], detached: true },
196 );
197 dev.stdout.on("data", (d) => (devLog += d));
198 dev.stderr.on("data", (d) => (devLog += d));
199 await until("wrangler dev", async () => {
200 try {
201 return (await fetch(`${base}/healthz`)).ok;
202 } catch {
203 return false;
204 }
205 });
206 // ada's private repository zutrix (bob writes to it, wes reads it), another of hers, and bob's public one.
207 for (const [name, t, visibility] of [["zutrix", T.ada, "private"], ["other", T.ada, "private"], ["pub", T.bob, "public"]]) {
208 await ok("POST", "/user/repos", { name, visibility }, t);
209 await ok("PUT", `/repos/${t === T.ada ? "ada" : "bob"}/${name}/contents/README.md`, { message: "init", content: b64(`# ${name}\n`) }, t);
210 }
211 await ok("PUT", "/repos/ada/zutrix/collaborators/bob", { permission: "write" }, T.ada);
212 await ok("PUT", "/repos/ada/zutrix/collaborators/wes", { permission: "read" }, T.ada);
213 // Being added makes an invitation: bob and wes accept theirs.
214 for (const t of [T.bob, T.wes]) for (const inv of await ok("GET", "/user/repository_invitations", undefined, t)) await ok("PATCH", `/user/repository_invitations/${inv.id}`, undefined, t);
215});
216
217after(() => {
218 if (dev?.pid) {
219 try {
220 process.kill(-dev.pid, "SIGTERM");
221 } catch {}
222 }
223 if (process.env.E2E_KEEP !== "1") rmSync(work, { recursive: true, force: true });
224});
225
226const PAGE = "/ada/zutrix/settings/agent-setup";
227let token;
228
229test("the page: for people with write access, in a browser", async () => {
230 const anon = await page(null, PAGE);
231 assert.equal(anon.status, 302, "signed out: off to sign in");
232 assert.match(anon.location ?? "", /\/login/);
233 assert.equal((await page(S.wes, PAGE)).status, 403, "read access isn't enough");
234 const res = await fetch(`${base}${PAGE}`, { headers: { authorization: `Bearer ${T.ada}` } });
235 assert.equal(res.status, 403, "a token never sees the page");
236 const p = await page(S.ada, PAGE);
237 assert.equal(p.status, 200);
238 for (const s of ["Agent setup", "Generate agent token", "Not connected yet", `curl -fsSL ${base}/ada/zutrix/agent-setup.sh | bash`, `GH_HOST=${host}`, "DIFFINERY_REPO=ada/zutrix", "Network secrets"]) assert.ok(unescape(p.text).includes(s), s);
239 // bob has write access but isn't an admin: Settings opens Agent setup, and it's the only tab.
240 const b = await page(S.bob, PAGE);
241 assert.equal(b.status, 200);
242 assert.ok(b.text.includes('href="/ada/zutrix/settings/agent-setup"'));
243 assert.ok(!b.text.includes('href="/ada/zutrix/settings/branches"'));
244 assert.equal((await page(S.bob, "/ada/zutrix/settings")).status, 403);
245});
246
247test("generating a token makes ada's agent for this repository, and fills it in once", async () => {
248 const r = await submit(S.ada, PAGE, `${PAGE}/generate`, { name: "Claude Code cloud · e2e" });
249 assert.equal(r.status, 200, r.text.slice(0, 500));
250 const env = envBox(r.text);
251 token = /^DIFFINERY_TOKEN=(dfy_[A-Za-z0-9]+)$/m.exec(env)?.[1];
252 assert.ok(token, env);
253 assert.equal(env, [`DIFFINERY_TOKEN=${token}`, `GH_ENTERPRISE_TOKEN=${token}`, `GH_HOST=${host}`, "DIFFINERY_REPO=ada/zutrix", `DIFFINERY_URL=${base}`].join("\n"));
254 const me = await ok("GET", "/user", undefined, token);
255 assert.equal(me.login, "zutrix-agent");
256 assert.equal(me.type, "Bot");
257 // Shown once: the page masks it afterwards.
258 const again = await page(S.ada, PAGE);
259 assert.ok(!again.text.includes(token));
260 assert.match(envBox(again.text), new RegExp(`^DIFFINERY_TOKEN=${token.slice(0, 10)}…$`, "m"));
261 assert.ok(again.text.includes("Claude Code cloud · e2e"));
262});
263
264test("the hosted script holds no secret, and only answers for names a repository can have", async () => {
265 const res = await fetch(`${base}/ada/zutrix/agent-setup.sh`);
266 assert.equal(res.status, 200);
267 const s = await res.text();
268 assert.match(s, new RegExp(`^server=${base.replace(/[.]/g, "\\.")}$`, "m"));
269 assert.match(s, /^repo=ada\/zutrix$/m);
270 assert.match(s, /^cli_sha256=[0-9a-f]{64}$/m);
271 assert.doesNotMatch(s, /dfy_[A-Za-z0-9]{10}/);
272 assert.equal((await fetch(`${base}/no-such-owner/whatever/agent-setup.sh`)).status, 200, "says nothing about whether the repository exists");
273 assert.equal((await fetch(`${base}/ada/${encodeURIComponent("$(id)")}/agent-setup.sh`)).status, 404);
274});
275
276test("a fresh machine with only the page's three boxes: everything works and the Check turns green", async () => {
277 const m = machine("fresh");
278 const env = Object.fromEntries(envBox((await page(S.ada, PAGE)).text).split("\n").map((l) => l.split("=")));
279 Object.assign(env, { DIFFINERY_TOKEN: token, GH_ENTERPRISE_TOKEN: token });
280 const first = await runSetup(m, env);
281 assert.equal(first.code, 0, first.out);
282 assert.ok(!first.out.includes(token), "never prints the token");
283 assert.match(first.out, /✓ diffinery CLI: installed diffinery \d/);
284 assert.ok(!first.out.includes("sign in through your browser"), "no advice meant for people");
285 for (const s of ["✓ server:", "✓ diffinery on PATH:", "✓ DIFFINERY_TOKEN: acts as zutrix-agent (agent)", "✓ repository: ada/zutrix with write access", "✓ git:", "✓ clone: cloned into", "✓ gh: gh api acts as zutrix-agent", "✓ gh in the clone:", "Ready:"]) assert.ok(first.out.includes(s), `${s}\n${first.out}`);
286 const all = { ...m.env, ...env };
287 // The acceptance checks, as the session would run them: no path prefix, no prompt.
288 assert.match((await sh("bash", ["-c", "diffinery --version"], { env: all }, true)).stdout, /0\.\d/);
289 assert.match((await sh("bash", ["-c", "diffinery whoami"], { env: all }, true)).stdout, /zutrix-agent/);
290 const fresh = join(m.root, "second-clone");
291 await sh("git", ["clone", "-q", `${base}/ada/zutrix.git`, fresh], { env: { ...all, GIT_TERMINAL_PROMPT: "0" } }, true);
292 assert.ok(existsSync(join(fresh, "README.md")));
293 assert.equal((await sh("gh", ["api", "user", "--hostname", host, "--jq", ".login"], { env: all }, true)).stdout.trim(), "zutrix-agent");
294 await sh("gh", ["pr", "list", "--state", "all"], { cwd: join(m.where, "zutrix"), env: all }, true);
295 // No token anywhere on disk: git and gh read it from the environment.
296 const files = (dir) => readdirSync(dir, { recursive: true }).map((f) => join(dir, String(f))).filter((f) => statSync(f).isFile() && statSync(f).size < 5_000_000);
297 for (const f of files(m.home)) assert.ok(!readFileSync(f, "utf8").includes(token), `${f} holds the token`);
298 // The Check, on the page and in the API.
299 const check = await ok("GET", "/repos/ada/zutrix/diffinery/agent-setup", undefined, T.ada);
300 assert.equal(check.check.ok, true, JSON.stringify(check));
301 assert.equal(check.check.account, "zutrix-agent");
302 assert.equal(check.check.token.name, "Claude Code cloud · e2e");
303 const fragment = await page(S.ada, `${PAGE}/check`);
304 assert.match(fragment.text, /Connected\./);
305
306 // Every session start: the CLI isn't downloaded again, and the clone is brought up to date.
307 await ok("PUT", "/repos/ada/zutrix/contents/NEWS.md", { message: "news", content: b64("news\n") }, T.ada);
308 const started = Date.now();
309 const second = await runSetup(m, env);
310 assert.equal(second.code, 0, second.out);
311 assert.ok(second.out.includes("diffinery CLI: already the current build"), second.out);
312 assert.ok(second.out.includes("(up to date)"), second.out);
313 assert.ok(existsSync(join(m.where, "zutrix", "NEWS.md")));
314 assert.ok(Date.now() - started < 30_000, `second run took ${Date.now() - started} ms`);
315});
316
317test("failures say what to change, and a missing token doesn't fail the session", async () => {
318 const m = machine("broken");
319 const missing = await runSetup(m, {});
320 assert.equal(missing.code, 0, missing.out);
321 assert.match(missing.out, /DIFFINERY_TOKEN is not set: generate a token on .*\/ada\/zutrix\/settings\/agent-setup/);
322 const noHost = await runSetup(m, { DIFFINERY_TOKEN: token, GH_ENTERPRISE_TOKEN: token, DIFFINERY_NO_CLONE: "1" });
323 assert.equal(noHost.code, 1, noHost.out);
324 assert.ok(noHost.out.includes(`✗ GH_HOST: not set: add GH_HOST=${host} to the environment variables`), noHost.out);
325 const mismatch = await runSetup(m, { DIFFINERY_TOKEN: token, GH_ENTERPRISE_TOKEN: T.bob, GH_HOST: host, DIFFINERY_NO_CLONE: "1" });
326 assert.equal(mismatch.code, 1);
327 assert.ok(mismatch.out.includes("GH_ENTERPRISE_TOKEN isn't the same token as DIFFINERY_TOKEN"), mismatch.out);
328 assert.ok(!mismatch.out.includes(T.bob) && !mismatch.out.includes(token));
329 const refused = await runSetup(m, { DIFFINERY_TOKEN: "dfy_notarealtoken0000", DIFFINERY_NO_CLONE: "1" });
330 assert.equal(refused.code, 1);
331 assert.ok(refused.out.includes("refused (revoked or expired)"), refused.out);
332 // A failed run is the Check now, saying what failed.
333 const check = await ok("GET", "/repos/ada/zutrix/diffinery/agent-setup", undefined, T.ada);
334 assert.equal(check.check.ok, false);
335 assert.ok(check.check.checks.some((l) => l.name === "gh" && !l.ok));
336});
337
338test("the token reaches only its repository", async () => {
339 assert.equal((await api("GET", "/repos/ada/other", undefined, token)).status, 404);
340 assert.equal((await api("GET", "/repos/bob/pub", undefined, token)).status, 404, "not even a public one");
341 assert.deepEqual((await ok("GET", "/user/repos", undefined, token)).map((r) => r.full_name), ["ada/zutrix"]);
342 const dir = mkdtempSync(join(work, "iso-"));
343 assert.match(await gitFails(dir, ["clone", "-q", `${base}/ada/other.git`, join(dir, "o")], token), /not found|401|403|404|Authentication/i);
344 await git(dir, ["clone", "-q", `${base}/ada/zutrix.git`, join(dir, "z")], token);
345 await git(join(dir, "z"), ["checkout", "-q", "-b", "feature"], token);
346 writeFileSync(join(dir, "z", "x.txt"), "x\n");
347 await git(join(dir, "z"), ["add", "."], token, { GIT_AUTHOR_NAME: "a", GIT_AUTHOR_EMAIL: "a@x", GIT_COMMITTER_NAME: "a", GIT_COMMITTER_EMAIL: "a@x" });
348 await git(join(dir, "z"), ["commit", "-qm", "x"], token, { GIT_AUTHOR_NAME: "a", GIT_AUTHOR_EMAIL: "a@x", GIT_COMMITTER_NAME: "a", GIT_COMMITTER_EMAIL: "a@x" });
349 assert.match(await gitFails(join(dir, "z"), ["push", "-q", `${base}/ada/other.git`, "feature"], token), /not found|denied|403|404/i);
350 assert.match(await gitFails(join(dir, "z"), ["push", "-q", "origin", "HEAD:main"], token), /agents may not push/);
351 await git(join(dir, "z"), ["push", "-q", "origin", "feature"], token);
352 // Nor can it mint anything, or change account settings.
353 assert.equal((await api("POST", "/diffinery/tokens", { name: "x" }, token)).status, 403);
354 assert.equal((await api("PUT", "/repos/ada/zutrix/actions/secrets/X", { encrypted_value: "x" }, token)).status, 403);
355});
356
357test("merging: only what a person approved, never a workflow change, and off when ada says so", async () => {
358 const pr = await ok("POST", "/repos/ada/zutrix/pulls", { title: "Add x", head: "feature", base: "main" }, token);
359 const merge = () => api("PUT", `/repos/ada/zutrix/pulls/${pr.number}/merge`, { merge_method: "squash" }, token);
360 const before = await merge();
361 assert.equal(before.status, 405, JSON.stringify(before.json));
362 assert.match(before.json.message, /needs 1 more approving review\(s\) of its latest commit/);
363 await ok("POST", `/repos/ada/zutrix/pulls/${pr.number}/reviews`, { event: "APPROVE" }, T.ada);
364 const merged = await merge();
365 assert.equal(merged.status, 200, JSON.stringify(merged.json));
366
367 // A change to a workflow file needs a person, approved or not.
368 const wf = await ok("POST", "/repos/ada/zutrix/git/refs", { ref: "refs/heads/wf", sha: (await ok("GET", "/repos/ada/zutrix/git/ref/heads/main", undefined, T.ada)).object.sha }, T.ada);
369 assert.ok(wf);
370 await ok("PUT", "/repos/ada/zutrix/contents/.github/workflows/ci.yml", { message: "ci", content: b64("on: push\njobs: {}\n"), branch: "wf" }, T.ada);
371 const wfPr = await ok("POST", "/repos/ada/zutrix/pulls", { title: "CI", head: "wf", base: "main" }, T.ada);
372 await ok("POST", `/repos/ada/zutrix/pulls/${wfPr.number}/reviews`, { event: "APPROVE" }, T.bob);
373 const wfMerge = await api("PUT", `/repos/ada/zutrix/pulls/${wfPr.number}/merge`, {}, token);
374 assert.equal(wfMerge.status, 405);
375 assert.match(wfMerge.json.message, /changes workflow file `\.github\/workflows\/ci\.yml`; a person must merge it/);
376 const wfAuto = await api("PUT", `/repos/ada/zutrix/pulls/${wfPr.number}/auto-merge`, {}, token);
377 assert.equal(wfAuto.status, 422, JSON.stringify(wfAuto.json));
378
379 // Merging off: the agent rule is back.
380 await ok("PUT", "/repos/ada/zutrix/contents/y.txt", { message: "y", content: b64("y\n"), branch: "wf" }, T.ada);
381 const off = await submit(S.ada, PAGE, `${PAGE}/merge`, {});
382 assert.equal(off.status, 303);
383 assert.match(off.flash, /can no longer merge/);
384 const plain = await ok("POST", "/repos/ada/zutrix/git/refs", { ref: "refs/heads/plain", sha: (await ok("GET", "/repos/ada/zutrix/git/ref/heads/main", undefined, T.ada)).object.sha }, T.ada);
385 assert.ok(plain);
386 await ok("PUT", "/repos/ada/zutrix/contents/z.txt", { message: "z", content: b64("z\n"), branch: "plain" }, T.ada);
387 const plainPr = await ok("POST", "/repos/ada/zutrix/pulls", { title: "z", head: "plain", base: "main" }, T.ada);
388 await ok("POST", `/repos/ada/zutrix/pulls/${plainPr.number}/reviews`, { event: "APPROVE" }, T.bob);
389 const refused = await api("PUT", `/repos/ada/zutrix/pulls/${plainPr.number}/merge`, {}, token);
390 assert.equal(refused.status, 405);
391 assert.match(refused.json.message, /agents may not merge into this branch/);
392 const on = await submit(S.ada, PAGE, `${PAGE}/merge`, { mayMerge: "1" });
393 assert.match(on.flash, /can merge pull requests a person approved/);
394 assert.equal((await api("PUT", `/repos/ada/zutrix/pulls/${plainPr.number}/merge`, {}, token)).status, 200);
395});
396
397test("renew, revoke and regenerate; an admin turns off someone else's agent", async () => {
398 const html = (await page(S.ada, PAGE)).text;
399 const id = /\/settings\/agent-setup\/tokens\/([^/]+)\/renew/.exec(html)?.[1];
400 assert.ok(id);
401 const renewed = await submit(S.ada, PAGE, `${PAGE}/tokens/${id}/renew`);
402 assert.match(renewed.flash, /Renewed: the token works until/);
403 const regen = await submit(S.ada, PAGE, `${PAGE}/tokens/${id}/regenerate`);
404 assert.equal(regen.status, 200);
405 const next = /^DIFFINERY_TOKEN=(dfy_[A-Za-z0-9]+)$/m.exec(envBox(regen.text))?.[1];
406 assert.ok(next && next !== token);
407 assert.equal((await api("GET", "/user", undefined, token)).status, 401, "the old token stops at once");
408 assert.equal((await ok("GET", "/user", undefined, next)).login, "zutrix-agent", "the same agent");
409
410 // bob's own agent here; ada, an admin, turns it off.
411 const b = await submit(S.bob, PAGE, `${PAGE}/generate`, { name: "bob's" });
412 const bobToken = /^DIFFINERY_TOKEN=(dfy_[A-Za-z0-9]+)$/m.exec(envBox(b.text))?.[1];
413 const bobAgent = (await ok("GET", "/user", undefined, bobToken)).login;
414 assert.equal(bobAgent, "bob-zutrix-agent");
415 const adaView = (await page(S.ada, PAGE)).text;
416 const agentId = /\/settings\/agent-setup\/agents\/([^/]+)\/disable/.exec(adaView)?.[1];
417 assert.ok(agentId, "ada sees bob's agent");
418 const off = await submit(S.ada, PAGE, `${PAGE}/agents/${agentId}/disable`);
419 assert.equal(off.status, 303);
420 assert.equal((await api("GET", "/user", undefined, bobToken)).status, 401);
421 // bob still has write access, so he can generate again: the agent comes back, its old tokens don't.
422 const back = await submit(S.bob, PAGE, `${PAGE}/generate`, { name: "bob's again" });
423 const newer = /^DIFFINERY_TOKEN=(dfy_[A-Za-z0-9]+)$/m.exec(envBox(back.text))?.[1];
424 assert.equal((await ok("GET", "/user", undefined, newer)).login, "bob-zutrix-agent");
425 assert.equal((await api("GET", "/user", undefined, bobToken)).status, 401, "turned off for good");
426 // bob, not an admin, can't do the same to ada's.
427 assert.equal((await submit(S.bob, PAGE, `${PAGE}/agents/whatever/disable`)).status, 403);
428});