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.

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