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.

1256 lines · 80.0 KBRaw History
1// End-to-end agent tasks: a real `wrangler dev` Worker, sandboxes in an
2// `agent.mjs --sandbox-pool` process (containers don't run under wrangler dev),
3// a fake model provider behind the model proxy, and the real git CLI.
4//
5// pnpm --filter @diffinery/app e2e
6//
7// Covers model keys (write-only, browser session only, opt-in), a task run end
8// to end (sandbox as the agent, the model proxy adding the person's key, the
9// push and the pull request as the agent), follow-ups, stopping, and the
10// security rules: no token, agent or sandbox can start or steer a task, task
11// sandboxes are closed to the sandbox API, and a run's model token stops
12// working when the run ends.
13import "./fresh-connections.mjs";
14import { spawn, execFileSync } from "node:child_process";
15import { mkdtempSync, writeFileSync, mkdirSync, rmSync, readdirSync, statSync, readFileSync, existsSync } from "node:fs";
16import { tmpdir } from "node:os";
17import { createHash, randomBytes } from "node:crypto";
18import { createRequire } from "node:module";
19import { join, dirname, resolve } from "node:path";
20import { fileURLToPath } from "node:url";
21import { createServer as netServer } from "node:net";
22import { createServer as httpServer } from "node:http";
23import { test } from "node:test";
24import assert from "node:assert/strict";
25import {
26 APP,
27 AGENT,
28 work,
29 poolRoot,
30 WS,
31 RIVAL_SESSION,
32 ANTHROPIC_KEY,
33 OPENROUTER_KEY,
34 base,
35 dev,
36 pool,
37 upstream,
38 devLog,
39 poolLog,
40 token,
41 modelRequests,
42 jailToken,
43 api,
44 ok,
45 asPerson,
46 sleep,
47 until,
48 git,
49 mcp,
50 poolSandboxes,
51} from "./agents-harness.mjs";
52import { freePort } from "./ports.mjs";
53
54test("model keys: write-only, browser session only, with an opt-in", async () => {
55 assert.deepEqual(await ok("GET", "/diffinery/model-keys"), []);
56 // No token can set one, however wide.
57 const viaToken = await api("PUT", "/diffinery/model-keys/anthropic", { key: ANTHROPIC_KEY, accept_terms: true });
58 assert.equal(viaToken.status, 403, JSON.stringify(viaToken.json));
59 assert.match(viaToken.json.message, /browser session/);
60 // The opt-in is required, and the key's shape is checked.
61 assert.equal((await asPerson("PUT", "/diffinery/model-keys/anthropic", { key: ANTHROPIC_KEY })).status, 422);
62 assert.equal((await asPerson("PUT", "/diffinery/model-keys/anthropic", { key: "sk-proj-not-anthropic-1234567890abcdef", accept_terms: true })).status, 422);
63 assert.equal((await asPerson("PUT", "/diffinery/model-keys/gemini", { key: ANTHROPIC_KEY, accept_terms: true })).status, 404);
64 const saved = await asPerson("PUT", "/diffinery/model-keys/anthropic", { key: ANTHROPIC_KEY, accept_terms: true });
65 assert.equal(saved.status, 200, JSON.stringify(saved.json));
66 assert.equal(saved.json.hint, ANTHROPIC_KEY.slice(-4));
67 assert.ok(saved.json.opted_in_at);
68 // Never returned: not by the API, not on the page.
69 const listed = await ok("GET", "/diffinery/model-keys");
70 assert.deepEqual(listed.map((k) => k.provider), ["anthropic"]);
71 assert.ok(!JSON.stringify(listed).includes(ANTHROPIC_KEY));
72 const page = await (await fetch(`${base}/settings/model-access`)).text();
73 assert.match(page, /Model access/);
74 assert.match(page, /Commercial Terms/);
75 assert.ok(page.includes(`…${ANTHROPIC_KEY.slice(-4)}`));
76 assert.ok(!page.includes(ANTHROPIC_KEY));
77 assert.equal((await api("DELETE", "/diffinery/model-keys/anthropic")).status, 403);
78});
79
80test("an agent task runs in a sandbox as the agent, through the model proxy, and opens a pull request", async () => {
81 const me = await ok("GET", "/user");
82 const owner = me.login;
83 await ok("POST", "/user/repos", { name: "tasks-demo" });
84 const src = join(work, "src");
85 mkdirSync(src, { recursive: true });
86 writeFileSync(join(src, "README.md"), "# tasks\n");
87 git(src, ["init", "-q", "-b", "main"]);
88 git(src, ["add", "-A"]);
89 git(src, ["commit", "-q", "-m", "initial"]);
90 git(src, ["remote", "add", "origin", `${base}/${owner}/tasks-demo.git`]);
91 git(src, ["push", "-q", "origin", "main"]);
92 const R = `/repos/${owner}/tasks-demo/diffinery/tasks`;
93 const bot = (await asPerson("POST", "/diffinery/agents", { login: "task-bot", name: "Task Bot" })).json.token.token;
94
95 const tokFile = join(work, "model-token");
96 // The custom runtime: call the model through the proxy, show the key variable, save it for later, change a file.
97 // It runs in a jail (phase 5d): what it can see and reach is shown too.
98 const command = [
99 `curl -sS -X POST "$ANTHROPIC_BASE_URL/v1/messages" -H "x-api-key: $ANTHROPIC_API_KEY" -H "content-type: application/json" -d '{"model":"m","messages":[]}'`,
100 "echo",
101 'echo "key is $ANTHROPIC_API_KEY"',
102 `printf %s "$ANTHROPIC_API_KEY" > ${tokFile}`,
103 'echo "goal: $DIFFINERY_TASK_PROMPT" | tail -1',
104 'echo "jail: credential=$(cat ../repo/.git/config 2>/dev/null || echo hidden)"',
105 'echo "jail: token=$(env | grep -c dmt_)"',
106 // Only the model endpoints through the jail's model proxy, however the path is spelled; Diffinery itself is out of reach.
107 `echo "jail: api=$(curl -s -o /dev/null -w '%{http_code}' --path-as-is "$ANTHROPIC_BASE_URL/../../api/v3/user")"`,
108 // (The test's Diffinery is on 127.0.0.1, which NO_PROXY sends direct; --noproxy '' sends it through the proxy.)
109 `echo "jail: via-proxy=$(curl -s -o /dev/null -w '%{http_code}' --noproxy '' ${base}/api/v3/user)"`,
110 `echo "jail: direct=$(curl -s -o /dev/null -w '%{http_code}' --noproxy '*' --max-time 5 ${base}/api/v3/user)"`,
111 "echo hello > hello.txt",
112 ].join("; ");
113 const goal = "Add a hello file";
114
115 // Nobody but a person in a browser starts a task: not their token, not the agent's.
116 for (const tok of [token, bot]) {
117 const r = await api("POST", R, { goal, runtime: "custom", command, agent: "task-bot" }, tok);
118 assert.equal(r.status, 403, JSON.stringify(r.json));
119 assert.match(r.json.message, /browser session/);
120 }
121 // Codex needs an OpenAI key the person doesn't have.
122 const codex = await asPerson("POST", R, { goal, runtime: "codex", agent: "task-bot" });
123 assert.equal(codex.status, 422);
124 assert.match(codex.json.message, /OpenAI API key/);
125 assert.equal((await asPerson("POST", R, { goal, runtime: "custom", command, agent: owner })).status, 422, "a person isn't an agent");
126
127 const started = await asPerson("POST", R, { goal, runtime: "custom", command, agent: "task-bot" });
128 assert.equal(started.status, 201, JSON.stringify(started.json));
129 const n = started.json.number;
130 assert.equal(started.json.state, "running");
131 assert.equal(started.json.branch, "agent/1-add-a-hello-file");
132 assert.equal(started.json.agent.login, "task-bot");
133 assert.equal(started.json.started_by.login, owner);
134
135 const done = await until("the task to finish", async () => {
136 const t = await ok("GET", `${R}/${n}`);
137 return t.state !== "running" ? t : null;
138 });
139 const lines = await ok("GET", `${R}/${n}/transcript`);
140 const text = lines.map((l) => `[${l.stream}] ${l.text}`).join("\n");
141 assert.equal(done.state, "ready", text);
142 assert.ok(done.pull_request, text);
143
144 // The provider got the person's key, never the run's token; the sandbox never saw the key.
145 assert.equal(modelRequests.length, 1, text);
146 assert.equal(modelRequests[0].headers["x-api-key"], ANTHROPIC_KEY);
147 assert.equal(modelRequests[0].url, "/v1/messages");
148 assert.ok(!JSON.stringify(modelRequests[0].headers).includes("dmt_"));
149 assert.match(text, /fake model says hi/);
150 // The agent had a stand-in for a key: the run's token stayed outside its jail, with the jail's model proxy.
151 assert.match(text, /key is diffinery-jail/);
152 assert.equal(readFileSync(tokFile, "utf8"), "diffinery-jail");
153 assert.match(text, /\[out\] Add a hello file/, "the prompt reaches the runtime");
154 assert.ok(!text.includes(ANTHROPIC_KEY));
155 assert.ok(!text.includes("dmt_"));
156 // Its jail (phase 5d): no sandbox credential, no token, Diffinery reachable only at its model endpoints, no network of its own.
157 assert.match(text, /jail: credential=hidden/);
158 assert.match(text, /jail: token=0/);
159 assert.match(text, /jail: api=403/);
160 assert.match(text, /jail: via-proxy=403/);
161 assert.match(text, /jail: direct=000/);
162 assert.equal(modelRequests.length, 1);
163 // The proxy doesn't take an account token, or anything outside /v1/.
164 assert.equal((await fetch(`${base}/-/model/anthropic/v1/messages`, { method: "POST", headers: { authorization: `Bearer ${token}` } })).status, 401);
165 assert.equal((await fetch(`${base}/-/model/anthropic/admin`, { headers: { "x-api-key": `dmt_${"x".repeat(12)}_y` } })).status, 403);
166
167 // The pull request is the agent's, from the task's branch, with the work.
168 const pr = await ok("GET", `/repos/${owner}/tasks-demo/pulls/${done.pull_request.number}`);
169 assert.equal(pr.user.login, "task-bot");
170 assert.equal(pr.head.ref, "agent/1-add-a-hello-file");
171 assert.equal(pr.intent, goal);
172 const files = await ok("GET", `/repos/${owner}/tasks-demo/pulls/${pr.number}/files`);
173 assert.deepEqual(files.map((f) => f.filename), ["hello.txt"]);
174 const commit = await ok("GET", `/repos/${owner}/tasks-demo/commits/${pr.head.sha}`);
175 assert.equal(commit.commit.author.name, "task-bot");
176
177 // Read over MCP and on Today; steering is not on MCP.
178 const viaMcp = await mcp("agent_task", { owner, repo: "tasks-demo", number: n });
179 assert.equal(viaMcp.isError, false, viaMcp.content[0].text);
180 assert.equal(viaMcp.structuredContent.state, "ready");
181 assert.ok(viaMcp.structuredContent.transcript.length > 0);
182 const mine = await mcp("agent_tasks", {});
183 assert.deepEqual(mine.structuredContent.result.map((t) => t.number), [n]);
184 const today = await ok("GET", "/diffinery/today");
185 assert.equal(today.tasks[0].number, n);
186 assert.equal(today.tasks[0].state, "ready");
187
188 // Pages.
189 for (const path of ["tasks", `tasks/${n}`, "tasks/new"]) {
190 const res = await fetch(`${base}/${owner}/tasks-demo/${path}`);
191 assert.equal(res.status, 200, path);
192 }
193 const taskPage = await (await fetch(`${base}/${owner}/tasks-demo/tasks/${n}`)).text();
194 assert.match(taskPage, /Add a hello file/);
195 assert.match(taskPage, /fake model says hi/);
196
197 // Usage (phase 5a): counted by the model proxy from the provider's answer, on the task, its page and the person's 30 days.
198 const counted = await until("the run's usage", async () => {
199 const t = await ok("GET", `${R}/${n}`);
200 return t.runs[0].usage?.input_tokens === 21 ? t : null;
201 });
202 assert.deepEqual(counted.runs[0].usage, { source: "proxy", requests: 1, input_tokens: 21, output_tokens: 4, cache_read_tokens: 300, cache_write_tokens: 0, cost_usd: null });
203 assert.deepEqual(counted.usage, { requests: 1, input_tokens: 21, output_tokens: 4, cache_read_tokens: 300, cache_write_tokens: 0, cost_usd: null });
204 assert.equal((await mcp("agent_task", { owner, repo: "tasks-demo", number: n })).structuredContent.usage.input_tokens, 21);
205 const usagePage = await (await fetch(`${base}/${owner}/tasks-demo/tasks/${n}`)).text();
206 assert.match(usagePage, /<h2>Usage<\/h2>/);
207 assert.match(usagePage, /Diffinery<\/td>/);
208 const report = await until("the usage report", async () => {
209 const r = await ok("GET", "/diffinery/model-usage");
210 return r.repositories.find((x) => x.repository === `${owner}/tasks-demo` && x.kind === "task" && x.input_tokens >= 21) ? r : null;
211 });
212 assert.equal(report.days, 30);
213 assert.deepEqual(report.repositories[0], { repository: `${owner}/tasks-demo`, kind: "task", runs: 1, reported_by_cli_runs: 0, requests: 1, input_tokens: 21, output_tokens: 4, cache_read_tokens: 300, cache_write_tokens: 0, cost_usd: null });
214 assert.equal((await ok("GET", "/diffinery/model-usage?days=500")).days, 90);
215 // A repository is named as it was when the run ended, not by a later name (Security, 5a).
216 await ok("PATCH", `/repos/${owner}/tasks-demo`, { name: "tasks-demo-renamed" });
217 assert.deepEqual((await ok("GET", "/diffinery/model-usage")).repositories.map((x) => x.repository), [`${owner}/tasks-demo`]);
218 await ok("PATCH", `/repos/${owner}/tasks-demo-renamed`, { name: "tasks-demo" });
219 const settingsPage = await (await fetch(`${base}/settings/model-access`)).text();
220 assert.match(settingsPage, /Usage in the last 30 days/);
221 assert.ok(settingsPage.includes(`>${owner}/tasks-demo</a>`));
222 // Someone else's report is their own: the rival pays for nothing here.
223 const rivalUsage = await (await fetch(`${base}/api/v3/diffinery/model-usage`, { headers: { cookie: `__Host-dfy_session=${RIVAL_SESSION}` } })).json();
224 assert.deepEqual(rivalUsage.repositories, []);
225
226 // Follow-ups and retries: the person, in a browser; never a token.
227 assert.equal((await api("POST", `${R}/${n}/messages`, { body: "again" })).status, 403);
228 assert.equal((await api("POST", `${R}/${n}/retry`, {})).status, 403);
229 assert.equal((await api("POST", `${R}/${n}/messages`, { body: "again" }, bot)).status, 403);
230 const followUp = await asPerson("POST", `${R}/${n}/messages`, { body: "Say hello again" });
231 assert.equal(followUp.status, 201, JSON.stringify(followUp.json));
232 const second = await until("the follow-up run", async () => {
233 const t = await ok("GET", `${R}/${n}`);
234 return t.state !== "running" ? t : null;
235 });
236 assert.equal(second.state, "ready");
237 assert.deepEqual(second.runs.map((r) => [r.kind, r.state]), [
238 ["start", "done"],
239 ["message", "done"],
240 ]);
241 const text2 = (await ok("GET", `${R}/${n}/transcript?after=${lines.at(-1).seq}`)).map((l) => l.text).join("\n");
242 assert.match(text2, /Say hello again/);
243 assert.match(text2, /No changes to push/);
244 assert.match(text2, /Starting a sandbox on agent\/1-add-a-hello-file/);
245});
246
247test("task sandboxes are closed to the sandbox API, and stopping ends the run's access", async () => {
248 const me = await ok("GET", "/user");
249 const owner = me.login;
250 const R = `/repos/${owner}/tasks-demo/diffinery/tasks`;
251 const bot = (await asPerson("POST", "/diffinery/agents/task-bot/tokens", { name: "second" })).json.token;
252 const tokFile = join(work, "model-token-2");
253 // An OpenRouter key too: its own shape, its own opt-in.
254 assert.equal((await asPerson("PUT", "/diffinery/model-keys/openrouter", { key: ANTHROPIC_KEY, accept_terms: true })).status, 422);
255 assert.equal((await asPerson("PUT", "/diffinery/model-keys/openrouter", { key: OPENROUTER_KEY })).status, 422);
256 assert.equal((await asPerson("PUT", "/diffinery/model-keys/openrouter", { key: OPENROUTER_KEY, accept_terms: true })).status, 200);
257 const before_ = new Set(poolSandboxes());
258 const started = await asPerson("POST", R, { goal: "Wait a while", runtime: "custom", command: `printf %s "$ANTHROPIC_API_KEY" > ${tokFile}; sleep 120`, agent: "task-bot" });
259 assert.equal(started.status, 201, JSON.stringify(started.json));
260 const n = started.json.number;
261 const id = await until("the task's sandbox", async () => poolSandboxes().find((x) => !before_.has(x)) ?? null);
262 const runToken = await jailToken(tokFile);
263
264 // While it runs, its token works through the proxy.
265 const live = await fetch(`${base}/-/model/anthropic/v1/messages`, { method: "POST", headers: { "x-api-key": runToken, "content-type": "application/json" }, body: "{}" });
266 assert.equal(live.status, 200);
267
268 // Only the model endpoints: not the rest of the person's provider account.
269 for (const [method, path] of [
270 ["GET", "/v1/files"],
271 ["POST", "/v1/messages/batches"],
272 ["DELETE", "/v1/files/file_1"],
273 ]) {
274 const seen = modelRequests.length;
275 const r = await fetch(`${base}/-/model/anthropic${path}`, { method, headers: { "x-api-key": runToken, "content-type": "application/json" }, body: method === "POST" ? "{}" : undefined });
276 assert.equal(r.status, 403, `${method} ${path}`);
277 assert.match((await r.json()).error.message, new RegExp(path.replace(/\//g, "\\/")));
278 assert.equal(modelRequests.length, seen, `${method} ${path} must not reach the provider`);
279 }
280
281 // OpenRouter: the run's token works there too (the custom runtime gets every provider the person has a key for),
282 // with the person's OpenRouter key and Diffinery's attribution added, and only its model endpoints.
283 const seenOr = modelRequests.length;
284 const orLive = await fetch(`${base}/-/model/openrouter/v1/chat/completions`, { method: "POST", headers: { authorization: `Bearer ${runToken}`, "content-type": "application/json", "x-title": "spoofed", "http-referer": "https://spoofed.example", referer: "https://spoofed.example", "x-openrouter-categories": "spoofed" }, body: '{"model":"anthropic/claude-sonnet-4.5","messages":[]}' });
285 assert.equal(orLive.status, 200);
286 const orReq = modelRequests[seenOr];
287 assert.equal(orReq.url, "/v1/chat/completions");
288 assert.equal(orReq.headers.authorization, `Bearer ${OPENROUTER_KEY}`);
289 assert.equal(orReq.headers["x-api-key"], undefined);
290 assert.equal(orReq.headers["x-title"], "Diffinery");
291 assert.equal(orReq.headers["http-referer"], base);
292 // Only Diffinery names itself to OpenRouter: the sandbox's attribution headers never reach it.
293 assert.equal(orReq.headers.referer, undefined);
294 assert.equal(orReq.headers["x-openrouter-categories"], undefined);
295 // Claude Code's way in: the Messages endpoint, with the token as a bearer token.
296 assert.equal((await fetch(`${base}/-/model/openrouter/v1/messages`, { method: "POST", headers: { authorization: `Bearer ${runToken}`, "content-type": "application/json" }, body: "{}" })).status, 200);
297 for (const [method, path] of [
298 ["GET", "/v1/key"],
299 ["GET", "/v1/credits"],
300 ["GET", "/v1/keys"],
301 ["POST", "/v1/keys"],
302 ["GET", "/v1/generation"],
303 ["GET", "/v1/activity"],
304 ["GET", "/v1/models/user"],
305 ]) {
306 const seen = modelRequests.length;
307 const r = await fetch(`${base}/-/model/openrouter${path}`, { method, headers: { authorization: `Bearer ${runToken}`, "content-type": "application/json" }, body: method === "POST" ? "{}" : undefined });
308 assert.equal(r.status, 403, `openrouter ${method} ${path}`);
309 assert.equal(modelRequests.length, seen, `openrouter ${method} ${path} must not reach the provider`);
310 }
311
312 // No public sandbox call reaches it: the person (a repository admin here), their token, the agent.
313 for (const [who, tok] of [
314 ["the person in a browser", null],
315 ["the person's admin token", token],
316 ["the agent's token", bot],
317 ]) {
318 for (const action of ["exec", "read", "write", "close"]) {
319 const r = await api("POST", `/repos/${owner}/tasks-demo/diffinery/sandboxes/${id}/${action}`, { cmd: "env", path: "README.md", content: "" }, tok);
320 assert.equal(r.status, 404, `${who} ${action}: ${JSON.stringify(r.json)}`);
321 }
322 }
323 for (const tool of ["sandbox_exec", "sandbox_read_file", "sandbox_write_file", "sandbox_close"]) {
324 const r = await mcp(tool, { owner, repo: "tasks-demo", sandbox_id: id, command: "env", path: "README.md", content: "" }, bot);
325 assert.equal(r.isError, true, tool);
326 assert.match(r.content[0].text, /sandbox not found/, tool);
327 }
328
329 // A plain sandbox's own credential can't start or steer a task either.
330 const plain = await ok("POST", `/repos/${owner}/tasks-demo/diffinery/sandboxes`, {}, bot);
331 const cfgPath = join(poolRoot, plain.id, "repo", ".git", "config");
332 const config = await until("the plain sandbox's git credential", async () => (existsSync(cfgPath) && /extraheader/i.test(readFileSync(cfgPath, "utf8")) ? readFileSync(cfgPath, "utf8") : null));
333 const basic = /extraheader = Authorization: Basic (\S+)/i.exec(config);
334 assert.ok(basic, config);
335 const dst = Buffer.from(basic[1], "base64").toString().split(":").pop();
336 assert.match(dst, /^dst_/);
337 assert.equal((await api("POST", R, { goal: "x", runtime: "custom", command: "true", agent: "task-bot" }, dst)).status, 403);
338 assert.equal((await api("POST", `${R}/${n}/messages`, { body: "x" }, dst)).status, 403);
339 await ok("POST", `/repos/${owner}/tasks-demo/diffinery/sandboxes/${plain.id}/close`, {}, bot);
340
341 // One account keeps at most 5 sandboxes open in a repository; closing one makes room.
342 const open = [];
343 for (let i = 0; i < 5; i++) open.push((await ok("POST", `/repos/${owner}/tasks-demo/diffinery/sandboxes`, {}, bot)).id);
344 const sixth = await api("POST", `/repos/${owner}/tasks-demo/diffinery/sandboxes`, {}, bot);
345 assert.equal(sixth.status, 429);
346 assert.match(sixth.json.message, /5 open sandboxes in this repository/);
347 await ok("POST", `/repos/${owner}/tasks-demo/diffinery/sandboxes/${open.pop()}/close`, {}, bot);
348 open.push((await ok("POST", `/repos/${owner}/tasks-demo/diffinery/sandboxes`, {}, bot)).id);
349 for (const id of open) await ok("POST", `/repos/${owner}/tasks-demo/diffinery/sandboxes/${id}/close`, {}, bot);
350
351 // The agent can stop its own task; the run's token stops working.
352 const stopped = await ok("POST", `${R}/${n}/stop`, {}, bot);
353 assert.equal(stopped.state, "stopped");
354 const after_ = await fetch(`${base}/-/model/anthropic/v1/messages`, { method: "POST", headers: { "x-api-key": runToken, "content-type": "application/json" }, body: "{}" });
355 assert.equal(after_.status, 401);
356 const t = await ok("GET", `${R}/${n}`);
357 assert.equal(t.runs[0].state, "stopped");
358 assert.match((await ok("GET", `${R}/${n}/transcript`)).map((l) => l.text).join("\n"), /Stopped by task-bot/);
359
360 // Claude Code and Codex can run through OpenRouter with a model of the person's choice; only combinations that work.
361 const form = await (await fetch(`${base}/${owner}/tasks-demo/tasks/new`)).text();
362 assert.match(form, /claude-code\+openrouter&quot;,&quot;label&quot;:&quot;Claude Code via OpenRouter/);
363 assert.match(form, /codex\+openrouter&quot;,&quot;label&quot;:&quot;Codex via OpenRouter/);
364 for (const bad of [
365 { runtime: "codex", provider: "anthropic" },
366 { runtime: "custom", command: "true", provider: "openrouter" },
367 { runtime: "claude-code", provider: "openrouter", model: "not a model" },
368 { runtime: "claude-code", model: "anthropic/claude-sonnet-4.5" },
369 ]) assert.equal((await asPerson("POST", R, { goal: "x", agent: "task-bot", ...bad })).status, 422, JSON.stringify(bad));
370 const viaOr = await asPerson("POST", R, { goal: "Say hi", agent: "task-bot", runtime: "claude-code", provider: "openrouter", model: "anthropic/claude-sonnet-4.5" });
371 assert.equal(viaOr.status, 201, JSON.stringify(viaOr.json));
372 assert.equal(viaOr.json.runtime, "claude-code");
373 assert.equal(viaOr.json.provider, "openrouter");
374 assert.equal(viaOr.json.model, "anthropic/claude-sonnet-4.5");
375 assert.equal((await ok("GET", `${R}/${viaOr.json.number}`)).provider, "openrouter");
376 await ok("POST", `${R}/${viaOr.json.number}/stop`, {}, bot);
377});
378
379test("a repository is a project: settings, memory, conversation, and a queue of tasks", async () => {
380 const me = await ok("GET", "/user");
381 const owner = me.login;
382 const P = `/repos/${owner}/tasks-demo/diffinery/project`;
383 const R = `/repos/${owner}/tasks-demo/diffinery/tasks`;
384
385 // Settings: an admin in a browser; never a token.
386 assert.equal((await api("PUT", `${P}/settings`, { max_running: 1 })).status, 403);
387 for (const bad of [0, 11, 1.5, "x"]) assert.equal((await asPerson("PUT", `${P}/settings`, { max_running: bad })).status, 422, String(bad));
388 assert.deepEqual((await asPerson("PUT", `${P}/settings`, { max_running: 1, instructions: "Always say PROJECT-RULE-7." })).json, { max_running: 1, instructions: "Always say PROJECT-RULE-7.", plan_first: "off" });
389
390 // Memory: every save is a version, and a restore is one more.
391 assert.equal((await api("PUT", `${P}/memory`, { body: "x" })).status, 403);
392 assert.equal((await asPerson("PUT", `${P}/memory`, { body: "MEMORY-NOTE-1: docs live in docs/." })).json.version, 1);
393 assert.equal((await asPerson("PUT", `${P}/memory`, { body: "MEMORY-NOTE-2: something wrong." })).json.version, 2);
394 assert.equal((await asPerson("PUT", `${P}/memory`, { restore: 99 })).status, 404);
395 const restored = await asPerson("PUT", `${P}/memory`, { restore: 1 });
396 assert.equal(restored.json.version, 3);
397 assert.equal(restored.json.body, "MEMORY-NOTE-1: docs live in docs/.");
398 const memory = await ok("GET", `${P}/memory`);
399 assert.deepEqual(memory.versions.map((v) => v.version), [3, 2, 1]);
400 assert.equal(memory.updated_by.login, owner);
401
402 // The conversation: people post in a browser.
403 assert.equal((await api("POST", `${P}/posts`, { body: "hi" })).status, 403);
404 assert.equal((await asPerson("POST", `${P}/posts`, { body: " " })).status, 422);
405 const idea = await asPerson("POST", `${P}/posts`, { body: "Idea: write a queued file" });
406 assert.equal(idea.status, 201, JSON.stringify(idea.json));
407
408 // One at a time: the second task waits, then starts when the first ends. Both get the instructions and memory.
409 const command = (file, wait) => `printf '%s\\n' "$DIFFINERY_TASK_PROMPT" | grep -E 'PROJECT-RULE-7|MEMORY-NOTE|--- (instructions|memory) ---'; sleep ${wait}; echo ${file} > ${file}.txt`;
410 const a = await asPerson("POST", R, { goal: "Write the first file", runtime: "custom", command: command("first", 8), agent: "task-bot" });
411 assert.equal(a.status, 201, JSON.stringify(a.json));
412 const b = await asPerson("POST", R, { goal: "Write the queued file", runtime: "custom", command: command("queued", 0), agent: "task-bot" });
413 assert.equal(b.status, 201, JSON.stringify(b.json));
414 assert.equal(a.json.state, "running");
415 assert.equal(b.json.state, "queued");
416 const board = await ok("GET", P);
417 assert.deepEqual(board.board.working.map((t) => t.number), [a.json.number]);
418 assert.deepEqual(board.board.queued.map((t) => t.number), [b.json.number]);
419 assert.equal(board.settings.max_running, 1);
420 assert.equal(board.memory.version, 3);
421 const queuedLines = (await ok("GET", `${R}/${b.json.number}/transcript`)).map((l) => l.text).join("\n");
422 assert.match(queuedLines, /Queued: 1 task is already running/);
423 const listed = (await ok("GET", `${R}?state=queued`)).map((t) => t.number);
424 assert.deepEqual(listed, [b.json.number]);
425
426 const first = await until("the first task", async () => {
427 const t = await ok("GET", `${R}/${a.json.number}`);
428 return t.state !== "running" ? t : null;
429 });
430 assert.equal(first.state, "ready");
431 const second = await until("the queued task", async () => {
432 const t = await ok("GET", `${R}/${b.json.number}`);
433 return t.state === "ready" || t.state === "failed" ? t : null;
434 });
435 const text = (await ok("GET", `${R}/${b.json.number}/transcript`)).map((l) => l.text).join("\n");
436 assert.equal(second.state, "ready", text);
437 assert.ok(second.pull_request, text);
438 // The queued run started only after the first one ended.
439 assert.ok(Date.parse(second.runs[0].started_at) >= Date.parse(first.runs[0].ended_at), JSON.stringify([first.runs[0], second.runs[0]]));
440 for (const want of ["--- instructions ---", "PROJECT-RULE-7", "--- memory ---", "MEMORY-NOTE-1"]) assert.ok(text.includes(want), `${want}\n${text}`);
441 assert.ok(!text.includes("MEMORY-NOTE-2"), "the restored memory, not the replaced one");
442
443 // Tasks report to the conversation.
444 const posts = await ok("GET", `${P}/posts`);
445 const bodies = posts.map((p) => p.body);
446 assert.ok(bodies.includes("Idea: write a queued file"));
447 assert.ok(bodies.includes(`${owner} started task #${b.json.number}: Write the queued file`), bodies.join("\n"));
448 assert.ok(bodies.includes(`Task #${b.json.number} opened pull request #${second.pull_request.number}.`), bodies.join("\n"));
449 const later = await ok("GET", `${P}/posts?after=${idea.json.id}`);
450 assert.ok(later.every((p) => p.id > idea.json.id && p.kind === "event"));
451
452 // Over MCP.
453 const viaMcp = await mcp("project", { owner, repo: "tasks-demo" });
454 assert.equal(viaMcp.isError, false, viaMcp.content[0].text);
455 assert.equal(viaMcp.structuredContent.settings.max_running, 1);
456 const postsMcp = await mcp("project_posts", { owner, repo: "tasks-demo" });
457 assert.equal(postsMcp.isError, false, postsMcp.content[0].text);
458 assert.ok(JSON.stringify(postsMcp.structuredContent).includes("Idea: write a queued file"));
459
460 // Pages.
461 const projectPage = await (await fetch(`${base}/${owner}/tasks-demo/project`)).text();
462 assert.match(projectPage, /Ready for review/);
463 assert.match(projectPage, /Write the queued file/);
464 assert.match(projectPage, /Idea: write a queued file/);
465 assert.match(projectPage, /MEMORY-NOTE-1/);
466 assert.match(projectPage, /href="\/[^"]+\/tasks-demo\/project" class="on"/);
467 const fromPost = await (await fetch(`${base}/${owner}/tasks-demo/tasks/new?post=${idea.json.id}`)).text();
468 assert.match(fromPost, /Idea: write a queued file<\/textarea>/);
469 const memoryPage = await (await fetch(`${base}/${owner}/tasks-demo/project/memory?version=2`)).text();
470 assert.match(memoryPage, /Restore this version/);
471 assert.match(memoryPage, /<tr class="del"><td class="ln">-<\/td><td class="code">MEMORY-NOTE-1/);
472 assert.match(memoryPage, /<tr class="add"><td class="ln">\+<\/td><td class="code">MEMORY-NOTE-2/);
473 const settingsPage = await (await fetch(`${base}/${owner}/tasks-demo/settings/project`)).text();
474 assert.match(settingsPage, /name="max_running"[^>]*value="1"/);
475 assert.match(settingsPage, /Always say PROJECT-RULE-7\./);
476
477 // The web forms work too, and raising the limit is what later tests need.
478 const form = async (path, fields) => {
479 const res = await fetch(`${base}/${owner}/tasks-demo/settings/project`);
480 const csrf = /name="_csrf" value="([^"]+)"/.exec(await res.text())?.[1];
481 const cookie = /dfy_csrf=([^;]+)/.exec(res.headers.get("set-cookie") ?? "")?.[1];
482 return fetch(`${base}/${owner}/tasks-demo/${path}`, { method: "POST", redirect: "manual", headers: { cookie: `dfy_csrf=${cookie}`, "sec-fetch-site": "same-origin" }, body: new URLSearchParams({ _csrf: csrf ?? "", ...fields }) });
483 };
484 assert.equal((await form("settings/project", { max_running: "0", instructions: "" })).status, 422);
485 assert.equal((await form("settings/project", { max_running: "3", instructions: "", plan_first: "sometimes" })).status, 422);
486 assert.equal((await form("settings/project", { max_running: "3", instructions: "", plan_first: "default" })).status, 303);
487 assert.deepEqual((await ok("GET", P)).settings, { max_running: 3, instructions: "", plan_first: "default" });
488 assert.equal((await form("settings/project", { max_running: "3", instructions: "", plan_first: "off" })).status, 303);
489 assert.equal((await form("project/posts", { body: "Posted from the page" })).status, 303);
490 assert.ok((await ok("GET", `${P}/posts`)).some((p) => p.body === "Posted from the page"));
491 assert.equal((await form("project/memory/2/restore", {})).status, 303);
492 assert.equal((await ok("GET", `${P}/memory`)).body, "MEMORY-NOTE-2: something wrong.");
493});
494
495test("a coordinator turns posts into tasks, within its caps, and acts only through a validated block", async () => {
496 const me = await ok("GET", "/user");
497 const owner = me.login;
498 const P = `/repos/${owner}/tasks-demo/diffinery/project`;
499 const R = `/repos/${owner}/tasks-demo/diffinery/tasks`;
500 // The coordinator and the tasks it starts run this script (the custom runtime), from the default branch.
501 const src = join(work, "src");
502 writeFileSync(
503 join(src, "coord.sh"),
504 `p="$DIFFINERY_TASK_PROMPT"
505case "$p" in
506 *"You are the project coordinator"*)
507 printf '%s\\n' "$p" | grep -E '^--- (post #|memory ---|instructions ---)' || true
508 post=$(printf '%s\\n' "$p" | sed -n '/^--- post #/,/^--- end of post ---/p')
509 case "$post" in
510 *BAD-BATCH*) printf '%s\\n' 'Checking.' '\`\`\`json' '{"reply": "Stopping it.", "stop": [999]}' '\`\`\`' ;;
511 *MSG-HAND*) printf '%s\\n' '\`\`\`json' '{"reply": "Nudging it.", "messages": [{"task": 1, "text": "Spend more."}]}' '\`\`\`' ;;
512 *MSG-OWN*)
513 n=$(printf '%s\\n' "$p" | sed -n 's/^- #\\([0-9]*\\) \\[[a-z]*\\] Small part .*started by you: you may message.*/\\1/p' | head -1)
514 printf '%s\\n' '\`\`\`json' "{\\"reply\\": \\"Following up on #$n.\\", \\"messages\\": [{\\"task\\": $n, \\"text\\": \\"Check it again.\\"}]}" '\`\`\`' ;;
515 *PLAN-THREE*) printf '%s\\n' '\`\`\`json' '{"reply": "Splitting it in three.", "tasks": [{"title": "Small part", "goal": "COORD-SMALL: write the small part."}, {"title": "Big part", "goal": "COORD-BIG: write the big part.", "after": [0]}, {"title": "Never part", "goal": "COORD-NEVER: never runs.", "after": [1]}], "memory": "MEMORY-BY-COORD: parts are split in three."}' '\`\`\`' ;;
516 *) printf '%s\\n' '\`\`\`json' '{"reply": "Noted, nothing to do."}' '\`\`\`' ;;
517 esac ;;
518 *COORD-SMALL*)
519 curl -s -o /dev/null -w "model %{http_code}\\n" -X POST "$ANTHROPIC_BASE_URL/v1/messages" -H "x-api-key: $ANTHROPIC_API_KEY" -H "content-type: application/json" -d '{"model":"m","max_tokens":5,"messages":[]}'
520 echo small > small.txt ;;
521 *COORD-BIG*)
522 curl -s -o /dev/null -w "model %{http_code}\\n" -X POST "$ANTHROPIC_BASE_URL/v1/messages" -H "x-api-key: $ANTHROPIC_API_KEY" -H "content-type: application/json" -d '{"model":"m","max_tokens":200000,"messages":[]}'
523 echo big > big.txt ;;
524 *) echo other > other.txt ;;
525esac
526`,
527 );
528 git(src, ["pull", "-q", "--rebase", "origin", "main"]);
529 git(src, ["add", "coord.sh"]);
530 git(src, ["commit", "-q", "-m", "coordinator script"]);
531 git(src, ["push", "-q", "origin", "main"]);
532
533 // Your coordinator: yours alone, set in a browser, with an opt-in to your key.
534 assert.deepEqual(await ok("GET", `${P}/coordinator`), { enabled: false });
535 const settings = { agent: "task-bot", runtime: "custom", command: "sh coord.sh", tokens_per_task: 150000, accept: true };
536 assert.equal((await api("PUT", `${P}/coordinator`, settings)).status, 403);
537 assert.equal((await asPerson("PUT", `${P}/coordinator`, { ...settings, accept: false })).status, 422);
538 assert.equal((await asPerson("PUT", `${P}/coordinator`, { ...settings, tokens_per_task: 5 })).status, 422);
539 assert.equal((await asPerson("PUT", `${P}/coordinator`, { ...settings, agent: owner })).status, 422);
540 const saved = await asPerson("PUT", `${P}/coordinator`, settings);
541 assert.equal(saved.status, 200, JSON.stringify(saved.json));
542 assert.equal(saved.json.agent.login, "task-bot");
543 assert.equal(saved.json.tokens_per_task, 150000);
544 assert.equal(saved.json.tokens_per_run, 1000000);
545 // Everyone with write access sees whose coordinators are on, and with which agent and model; never their budgets or command.
546 const viewer = (await asPerson("POST", "/diffinery/agents/task-bot/tokens", { name: "coordinators" })).json.token;
547 const listed = await api("GET", `${P}/coordinators`, undefined, viewer);
548 assert.equal(listed.status, 200, JSON.stringify(listed.json));
549 assert.equal(listed.json.length, 1, JSON.stringify(listed.json));
550 assert.deepEqual(listed.json[0], { person: { login: owner }, agent: { login: "task-bot" }, runtime: "custom", provider: null, model: null, opted_in_at: listed.json[0].opted_in_at });
551 // Without write access (the rival, signed in in a browser), the list doesn't exist.
552 const rivalList = await fetch(`${base}/api/v3${P}/coordinators`, { headers: { cookie: `__Host-dfy_session=${RIVAL_SESSION}` } });
553 assert.equal(rivalList.status, 404, "people without write access see nothing");
554
555 const coordinatorRun = async (post) =>
556 until(`the coordinator to handle post #${post}`, async () => {
557 const t = await ok("GET", `${R}/${post.task}`);
558 return t.runs.length >= post.run && t.runs[post.run - 1].state !== "boot" && !["queued", "boot", "install", "agent", "finish"].includes(t.runs[post.run - 1].state) ? t : null;
559 });
560
561 // A post goes to the poster's coordinator, which plans three tasks: the second after the first, the third after the second.
562 const plan = await asPerson("POST", `${P}/posts`, { body: "PLAN-THREE: split the work." });
563 assert.equal(plan.status, 201, JSON.stringify(plan.json));
564 assert.equal(plan.json.coordinator.status, "started");
565 const coord = plan.json.coordinator.task;
566 const done = await coordinatorRun({ task: coord, run: 1 });
567 const coordText = (await ok("GET", `${R}/${coord}/transcript`)).map((l) => l.text).join("\n");
568 assert.equal(done.kind, "coordinator");
569 assert.equal(done.runs[0].state, "done", coordText);
570 assert.match(coordText, new RegExp(`^--- post #${plan.json.id} by ${owner} ---$`, "m"));
571 assert.match(coordText, /^--- memory ---$/m, "the coordinator reads the memory");
572 assert.match(coordText, /read-only sandbox/);
573 assert.match(coordText, /Applied: a reply, started #\d+, #\d+, #\d+, memory\./);
574
575 const posts = await ok("GET", `${P}/posts?after=${plan.json.id - 1}`);
576 const reply = posts.find((p) => p.kind === "coordinator");
577 assert.equal(reply.body, "Splitting it in three.");
578 assert.equal(reply.author.login, "task-bot");
579 assert.equal(posts.find((p) => p.id === plan.json.id).handled_by.number, coord);
580 const started = posts.filter((p) => p.kind === "event" && p.body.startsWith(`${owner}'s coordinator started task`));
581 assert.equal(started.length, 3, JSON.stringify(posts));
582 const [a, b, c] = started.map((p) => p.task.number);
583 const memEvent = posts.find((p) => p.memory_version);
584 assert.match(memEvent.body, /changed the project memory to version \d+: 1 line added, 1 removed\./);
585 assert.equal((await ok("GET", `${P}/memory`)).body, "MEMORY-BY-COORD: parts are split in three.");
586
587 const ta = await ok("GET", `${R}/${a}`);
588 assert.deepEqual(ta.started_via, { type: "coordinator", post: plan.json.id });
589 assert.equal(ta.token_budget, 150000);
590 assert.equal(ta.title, "Small part");
591 assert.equal(ta.goal, "COORD-SMALL: write the small part.");
592 assert.deepEqual((await ok("GET", `${R}/${b}`)).waits_for, [a]);
593
594 // The first runs; the second starts only when the first is ready, and its budget stops it; the third never starts.
595 const ra = await until("the first part", async () => {
596 const t = await ok("GET", `${R}/${a}`);
597 return ["ready", "failed", "stopped"].includes(t.state) ? t : null;
598 });
599 const textA = (await ok("GET", `${R}/${a}/transcript`)).map((l) => l.text).join("\n");
600 assert.equal(ra.state, "ready", textA);
601 assert.match(textA, /model 200/);
602 assert.match(textA, new RegExp(`${owner}'s coordinator started the task`));
603 const rb = await until("the second part", async () => {
604 const t = await ok("GET", `${R}/${b}`);
605 return ["ready", "failed", "stopped"].includes(t.state) ? t : null;
606 });
607 const textB = (await ok("GET", `${R}/${b}/transcript`)).map((l) => l.text).join("\n");
608 assert.equal(rb.state, "failed", textB);
609 assert.match(textB, /Queued: it starts when task #\d+ is ready\./);
610 assert.match(rb.error, /The run's next model request could take it past its budget of 150k tokens/);
611 assert.ok(Date.parse(rb.runs[0].started_at) >= Date.parse(ra.runs[0].ended_at));
612 const rc = await until("the third part to be stopped", async () => {
613 const t = await ok("GET", `${R}/${c}`);
614 return t.state === "stopped" ? t : null;
615 });
616 assert.equal(rc.error, `Task #${b}, which this task waits for, failed.`);
617 assert.deepEqual(rc.runs.map((r) => r.state), ["stopped"]);
618
619 // An invalid batch is rejected whole: nothing is applied.
620 const memoryBefore = (await ok("GET", `${P}/memory`)).version;
621 const bad = await asPerson("POST", `${P}/posts`, { body: "BAD-BATCH: stop someone else's task." });
622 assert.equal(bad.json.coordinator.task, coord);
623 const badRun = await coordinatorRun({ task: coord, run: 2 });
624 assert.equal(badRun.runs[1].state, "failed");
625 assert.match(badRun.runs[1].error, /Its actions were rejected: stop\[0\] must be one of the tasks you may stop\. Nothing was applied\./);
626 const afterBad = await ok("GET", `${P}/posts?after=${bad.json.id}`);
627 assert.ok(!afterBad.some((p) => p.kind === "coordinator"), "no reply was posted");
628 assert.ok(afterBad.some((p) => p.body === `${owner}'s coordinator didn't finish handling post #${bad.json.id}: ${badRun.runs[1].error}`), JSON.stringify(afterBad));
629 assert.equal((await ok("GET", `${P}/memory`)).version, memoryBefore);
630 // It may only message tasks it started (their runs have a budget), never the person's own hand-started ones.
631 const hand = await asPerson("POST", `${P}/posts`, { body: "MSG-HAND: nudge task one." });
632 const handRun = await coordinatorRun({ task: coord, run: 3 });
633 assert.match(handRun.runs[2].error, /messages\[0\]\.task must be one of the tasks you may message/);
634 assert.equal((await ok("GET", `${R}/1`)).runs.length, 2, "task #1 got no new run");
635 void hand;
636 const own = await asPerson("POST", `${P}/posts`, { body: "MSG-OWN: follow up on your own task." });
637 const ownRun = await coordinatorRun({ task: coord, run: 4 });
638 assert.equal(ownRun.runs[3].state, "done", JSON.stringify(ownRun.runs[3]));
639 void own;
640 const followed = await until("the follow-up on the first part", async () => {
641 const t = await ok("GET", `${R}/${a}`);
642 return t.runs.length === 2 && !["queued", "boot", "install", "agent", "finish"].includes(t.runs[1].state) ? t : null;
643 });
644 assert.equal(followed.runs[1].kind, "message");
645 assert.match(followed.runs[1].message, new RegExp(`^\\(From ${owner}'s coordinator, handling post #\\d+\\.\\)\\nCheck it again\\.$`));
646
647 // A post can skip the coordinator, and be handed to it later, once.
648 const quiet = await asPerson("POST", `${P}/posts`, { body: "Just a note.", coordinator: false });
649 assert.equal(quiet.json.coordinator, null);
650 assert.equal((await api("POST", `${P}/posts/${quiet.json.id}/handle`)).status, 403);
651 const handled = await asPerson("POST", `${P}/posts/${quiet.json.id}/handle`);
652 assert.equal(handled.status, 202, JSON.stringify(handled.json));
653 assert.equal((await asPerson("POST", `${P}/posts/${quiet.json.id}/handle`)).status, 409);
654 const third = await coordinatorRun({ task: coord, run: 5 });
655 assert.equal(third.runs[4].state, "done");
656 assert.ok((await ok("GET", `${P}/posts?after=${quiet.json.id}`)).some((p) => p.kind === "coordinator" && p.body === "Noted, nothing to do."));
657 // A coordinator takes no messages and isn't retried.
658 assert.equal((await asPerson("POST", `${R}/${coord}/messages`, { body: "hi" })).status, 409);
659
660 // The board leaves the coordinator out; the pages show its work.
661 const board = await ok("GET", P);
662 assert.ok(!Object.values(board.board).flat().some((t) => t.number === coord));
663 const page = await (await fetch(`${base}/${owner}/tasks-demo/project`)).text();
664 assert.match(page, /Splitting it in three\./);
665 assert.match(page, /badge purple">Coordinator/);
666 assert.match(page, /The change to the memory/);
667 assert.match(page, /<tr class="add"><td class="ln">\+<\/td><td class="code">MEMORY-BY-COORD/);
668 assert.match(page, /Hand this to my coordinator/);
669 assert.doesNotMatch(page, /Other coordinators here/, "your own coordinator isn't listed again");
670 assert.equal((await fetch(`${base}/${owner}/tasks-demo/project/coordinator`)).status, 200);
671 const taskPage = await (await fetch(`${base}/${owner}/tasks-demo/tasks/${a}`)).text();
672 assert.match(taskPage, /coordinator started this task while handling/);
673
674 // Turning it off: posts stay posts.
675 assert.equal((await asPerson("DELETE", `${P}/coordinator`)).status, 204);
676 assert.deepEqual(await ok("GET", `${P}/coordinator`), { enabled: false });
677 const after = await asPerson("POST", `${P}/posts`, { body: "PLAN-THREE again" });
678 assert.equal(after.json.coordinator, null);
679 assert.equal((await asPerson("POST", `${P}/posts/${after.json.id}/handle`)).status, 422);
680});
681
682test("plan first: a read-only plan run, then approve, change or split the plan", async () => {
683 const me = await ok("GET", "/user");
684 const owner = me.login;
685 const P = `/repos/${owner}/tasks-demo/diffinery/project`;
686 const R = `/repos/${owner}/tasks-demo/diffinery/tasks`;
687 const src = join(work, "src");
688 writeFileSync(
689 join(src, "plan.sh"),
690 `p="$DIFFINERY_TASK_PROMPT"
691case "$p" in
692 *"You are the project coordinator"*)
693 post=$(printf '%s\\n' "$p" | sed -n '/^--- post #/,/^--- end of post ---/p')
694 case "$post" in
695 *"Split the plan of task #"*"PLAN-V1-STEP"*) printf '%s\\n' '\`\`\`json' '{"reply": "Split it.", "tasks": [{"title": "Plan part one", "goal": "PLAN-PART-ONE: build step one."}]}' '\`\`\`' ;;
696 *) printf '%s\\n' '\`\`\`json' '{"reply": "Noted."}' '\`\`\`' ;;
697 esac ;;
698 *"You are planning task"*)
699 echo planned > should-not-land.txt
700 git add -A && git commit -qm "plan run commit" && git push -q origin HEAD:refs/heads/plan-run-push 2>/dev/null && echo "PUSHED" >&2
701 case "$p" in
702 *"Your previous plan:"*"Use the KV store"*) printf '%s\\n' '## Plan' '1. PLAN-V2-STEP: write planned.txt, with the KV store.' ;;
703 *) printf '%s\\n' '## Plan' '1. PLAN-V1-STEP: write planned.txt.' ;;
704 esac ;;
705 *"approved. Follow it:"*)
706 printf '%s\\n' "$p" | sed -n '/^--- plan ---$/,/^--- end of plan ---$/p'
707 echo built > planned.txt ;;
708 *PLAN-PART-ONE*) echo part > part.txt ;;
709 *) echo other > other.txt ;;
710esac
711`,
712 );
713 git(src, ["pull", "-q", "--rebase", "origin", "main"]);
714 git(src, ["add", "plan.sh"]);
715 git(src, ["commit", "-q", "-m", "plan script"]);
716 git(src, ["push", "-q", "origin", "main"]);
717 const settled = (n, what, fn = () => true) =>
718 until(what, async () => {
719 const t = await ok("GET", `${R}/${n}`);
720 const last = t.runs.at(-1);
721 return last && !["queued", "boot", "install", "agent", "finish"].includes(last.state) && fn(t) ? t : null;
722 });
723 const transcript = async (n) => (await ok("GET", `${R}/${n}/transcript`)).map((l) => l.text).join("\n");
724
725 // A plan-first task's first run plans in a read-only sandbox: nothing it changes is pushed.
726 assert.equal((await asPerson("POST", R, { goal: "x", runtime: "custom", command: "sh plan.sh", agent: "task-bot", plan_first: "yes" })).status, 422);
727 const started = await asPerson("POST", R, { goal: "PLANNED-GOAL: write planned.txt", runtime: "custom", command: "sh plan.sh", agent: "task-bot", plan_first: true });
728 assert.equal(started.status, 201, JSON.stringify(started.json));
729 const n = started.json.number;
730 assert.equal(started.json.plan_first, true);
731 assert.equal(started.json.plan_state, "planning");
732 const planned = await settled(n, "the plan");
733 const text1 = await transcript(n);
734 assert.equal(planned.runs[0].kind, "plan");
735 assert.equal(planned.runs[0].state, "done", text1);
736 assert.equal(planned.state, "ready");
737 assert.equal(planned.plan_state, "waiting");
738 assert.match(planned.plan, /^## Plan\n1\. PLAN-V1-STEP: write planned\.txt\.$/);
739 assert.equal(planned.pull_request, null);
740 assert.match(text1, /Starting a read-only sandbox to plan on main as task-bot\./);
741 assert.match(text1, /It plans first\./);
742 assert.ok(!text1.includes("PUSHED"), "the plan run's push was refused");
743 assert.equal(git(src, ["ls-remote", "origin", "refs/heads/plan-run-push", `refs/heads/${planned.branch}`]).trim(), "", "nothing was pushed");
744 const board = await ok("GET", P);
745 assert.ok(board.board.waiting.some((t) => t.number === n), "a plan waiting for approval waits on you");
746 assert.ok((await ok("GET", `${P}/posts`)).some((p) => p.body === `Task #${n}'s plan needs approval.`));
747 const page = await (await fetch(`${base}/${owner}/tasks-demo/tasks/${n}`)).text();
748 assert.match(page, /PLAN-V1-STEP/);
749 assert.match(page, /Approve plan/);
750
751 // Asking for changes plans again, with the plan it replaces.
752 assert.equal((await asPerson("POST", `${R}/${n}/messages`, { body: "Use the KV store" })).status, 201);
753 const replanned = await settled(n, "the second plan", (t) => t.runs.length === 2);
754 assert.equal(replanned.runs[1].kind, "plan");
755 assert.equal(replanned.plan_state, "waiting");
756 assert.match(replanned.plan, /PLAN-V2-STEP/);
757 assert.match(await transcript(n), /Planning again with the changes asked for\./);
758
759 // Approving starts the build run, which carries the approved plan; only a person in a browser may approve.
760 assert.equal((await api("POST", `${R}/${n}/plan/approve`)).status, 403);
761 const approved = await asPerson("POST", `${R}/${n}/plan/approve`);
762 assert.equal(approved.status, 202, JSON.stringify(approved.json));
763 assert.equal(approved.json.plan_state, "approved");
764 assert.equal((await asPerson("POST", `${R}/${n}/plan/approve`)).status, 409);
765 const built = await settled(n, "the build", (t) => t.runs.length === 3);
766 const text3 = await transcript(n);
767 assert.equal(built.runs[2].kind, "build");
768 assert.equal(built.runs[2].state, "done", text3);
769 assert.match(text3, /The plan is approved: building it\./);
770 assert.match(text3, /^--- plan ---\n## Plan\n1\. PLAN-V2-STEP: write planned\.txt, with the KV store\.\n--- end of plan ---$/m);
771 assert.ok(built.pull_request, "the build opened a pull request");
772 assert.ok((await ok("GET", `${P}/posts`)).some((p) => p.body === `${owner} approved the plan of task #${n}.`));
773 // A follow-up after approval is an ordinary run, still with the plan.
774 assert.equal((await asPerson("POST", `${R}/${n}/messages`, { body: "One more thing" })).status, 201);
775 const after = await settled(n, "the follow-up", (t) => t.runs.length === 4);
776 assert.equal(after.runs[3].kind, "message");
777
778 // A project can require plans: every task plans first, ticked or not, including ones coordinators start.
779 assert.equal((await asPerson("PUT", `${P}/settings`, { plan_first: "always" })).status, 422);
780 assert.equal((await asPerson("PUT", `${P}/settings`, { plan_first: "required" })).json.plan_first, "required");
781 const forced = await asPerson("POST", R, { goal: "SPLIT-GOAL: a goal to split", runtime: "custom", command: "sh plan.sh", agent: "task-bot", plan_first: false });
782 assert.equal(forced.status, 201, JSON.stringify(forced.json));
783 const m = forced.json.number;
784 assert.equal(forced.json.plan_first, true);
785 await settled(m, "the plan to split", (t) => t.plan_state === "waiting");
786
787 // Splitting hands the plan to the person's coordinator; the tasks it starts link back, and the task is done with.
788 assert.equal((await asPerson("POST", `${R}/${m}/plan/split`)).status, 422, "no coordinator yet");
789 const coord = await asPerson("PUT", `${P}/coordinator`, { agent: "task-bot", runtime: "custom", command: "sh plan.sh", accept: true });
790 assert.equal(coord.status, 200, JSON.stringify(coord.json));
791 assert.equal((await asPerson("POST", `${R}/${n}/plan/split`)).status, 409, "an approved plan can't be split");
792 assert.equal((await api("POST", `${R}/${m}/plan/split`)).status, 403);
793 const split = await asPerson("POST", `${R}/${m}/plan/split`);
794 assert.equal(split.status, 202, JSON.stringify(split.json));
795 assert.equal((await asPerson("POST", `${R}/${m}/plan/split`)).status, 409);
796 const splitPost = (await ok("GET", `${P}/posts?after=${split.json.post - 1}`)).find((p) => p.id === split.json.post);
797 assert.match(splitPost.body, new RegExp(`^Split the plan of task #${m} \\(SPLIT-GOAL: a goal to split\\) into tasks\\.`));
798 assert.equal(splitPost.author.login, owner);
799 const child = await until("the coordinator to split the plan", async () => {
800 const posts = await ok("GET", `${P}/posts?after=${split.json.post}`);
801 const ev = posts.find((p) => p.kind === "event" && p.body.startsWith(`${owner}'s coordinator started task`));
802 return ev ? ev.task.number : null;
803 });
804 const c = await ok("GET", `${R}/${child}`);
805 assert.equal(c.parent_task, m);
806 assert.equal(c.title, "Plan part one");
807 assert.equal(c.plan_first, true, "a coordinator's task plans first when the project requires it");
808 const parent = await ok("GET", `${R}/${m}`);
809 assert.equal(parent.plan_state, "split");
810 assert.ok((await ok("GET", P)).board.idle.some((t) => t.number === m));
811 const childPage = await (await fetch(`${base}/${owner}/tasks-demo/tasks/${child}`)).text();
812 assert.match(childPage, new RegExp(`Split from the plan of <a href="[^"]*/tasks/${m}">task #${m}</a>`));
813 await settled(child, "the child's plan", (t) => t.plan_state === "waiting");
814
815 assert.equal((await asPerson("PUT", `${P}/settings`, { plan_first: "off" })).json.plan_first, "off");
816 assert.equal((await asPerson("DELETE", `${P}/coordinator`)).status, 204);
817});
818
819test("best of N: attempts run side by side on their own branches, then one is picked", async () => {
820 const me = await ok("GET", "/user");
821 const owner = me.login;
822 const P = `/repos/${owner}/tasks-demo/diffinery/project`;
823 const R = `/repos/${owner}/tasks-demo/diffinery/tasks`;
824 const src = join(work, "src");
825 // Attempts get the same prompt; each claims a slot (in the test's work directory, which the jails share) and does
826 // different work.
827 const slots = join(work, "best-slots");
828 mkdirSync(slots, { recursive: true });
829 writeFileSync(
830 join(src, "best.sh"),
831 `p="$DIFFINERY_TASK_PROMPT"
832case "$p" in
833 *"New instructions from"*) echo follow > follow.txt; echo FOLLOWED-UP; exit 0 ;;
834esac
835tag=$(printf '%s\\n' "$p" | grep -o 'BEST-[a-z0-9]*' | head -1)
836slot=0
837for i in 1 2 3 4; do if mkdir "${slots}/$tag-$i" 2>/dev/null; then slot=$i; break; fi; done
838case $slot in
839 1) echo one > one.txt; echo SUMMARY-ONE-FILE ;;
840 2) echo a > a.txt; echo b > b.txt; echo c > c.txt; echo SUMMARY-THREE-FILES ;;
841 *) echo SLOT-FAIL; exit 3 ;;
842esac
843`,
844 );
845 git(src, ["pull", "-q", "--rebase", "origin", "main"]);
846 git(src, ["add", "best.sh"]);
847 git(src, ["commit", "-q", "-m", "best of N script"]);
848 git(src, ["push", "-q", "origin", "main"]);
849 const tag = `BEST-${randomBytes(6).toString("hex")}`;
850 const start = { goal: `${tag}: try it three ways`, runtime: "custom", command: "sh best.sh", agent: "task-bot" };
851
852 for (const bad of [0, 5, 1.5, "x"]) assert.equal((await asPerson("POST", R, { ...start, attempts: bad })).status, 422, String(bad));
853 const started = await asPerson("POST", R, { ...start, attempts: 3 });
854 assert.equal(started.status, 201, JSON.stringify(started.json));
855 const n = started.json.number;
856 assert.equal(started.json.attempts, 3);
857 assert.equal(started.json.pick_state, "running");
858 assert.deepEqual(started.json.runs.map((r) => r.attempt), [1, 2, 3]);
859
860 // They run together, each on its own branch; the task waits on you when the last one ends.
861 const done = await until("the attempts", async () => {
862 const t = await ok("GET", `${R}/${n}`);
863 return !["running", "queued"].includes(t.state) ? t : null;
864 });
865 const text = (await ok("GET", `${R}/${n}/transcript`)).map((l) => l.text).join("\n");
866 assert.equal(done.state, "ready", text);
867 assert.equal(done.pick_state, "waiting");
868 assert.equal(done.pull_request, null, "no pull request until a pick");
869 assert.match(text, new RegExp(`Attempt 2 of 3, on ${done.branch}-a2\\.`));
870 assert.ok((await ok("GET", P)).board.waiting.some((t) => t.number === n), "attempts to pick from wait on you");
871 assert.ok((await ok("GET", `${P}/posts`)).some((p) => p.body === `Task #${n}'s 3 attempts are done: 2 of them pushed work to compare. Pick one.`));
872
873 const attempts = await ok("GET", `${R}/${n}/attempts`);
874 assert.equal(attempts.length, 3);
875 assert.deepEqual(attempts.map((a) => a.branch), [done.branch, `${done.branch}-a2`, `${done.branch}-a3`]);
876 const failed = attempts.find((a) => a.state === "failed");
877 const one = attempts.find((a) => a.changes.files === 1);
878 const three = attempts.find((a) => a.changes.files === 3);
879 assert.ok(failed && one && three, JSON.stringify(attempts));
880 assert.equal(failed.head_sha, null);
881 assert.match(one.summary, /SUMMARY-ONE-FILE/);
882 assert.match(three.summary, /SUMMARY-THREE-FILES/);
883 assert.deepEqual(three.changes, { files: 3, additions: 3, deletions: 0 });
884 for (const a of [one, three]) assert.equal(git(src, ["ls-remote", "origin", `refs/heads/${a.branch}`]).trim().split(/\s/)[0], a.head_sha);
885 const page = await (await fetch(`${base}/${owner}/tasks-demo/tasks/${n}`)).text();
886 assert.match(page, /<h2>Attempts<\/h2>/);
887 assert.match(page, new RegExp(`/tasks/${n}/attempts/${three.attempt}/pick`));
888
889 // Follow-ups wait for a pick; only the person who started it may pick, and only an attempt with work.
890 assert.equal((await asPerson("POST", `${R}/${n}/messages`, { body: "go on" })).status, 409);
891 assert.equal((await api("POST", `${R}/${n}/attempts/${three.attempt}/pick`)).status, 403);
892 assert.equal((await asPerson("POST", `${R}/${n}/attempts/${failed.attempt}/pick`)).status, 422);
893 assert.equal((await asPerson("POST", `${R}/${n}/attempts/9/pick`)).status, 404);
894 const picked = await asPerson("POST", `${R}/${n}/attempts/${three.attempt}/pick`);
895 assert.equal(picked.status, 202, JSON.stringify(picked.json));
896 assert.equal(picked.json.picked_attempt, three.attempt);
897 assert.equal(picked.json.pick_state, "picked");
898 assert.equal(picked.json.branch, three.branch);
899 assert.ok(picked.json.pull_request, "picking opens the pull request");
900 const pr = await ok("GET", `/repos/${owner}/tasks-demo/pulls/${picked.json.pull_request.number}`);
901 assert.equal(pr.head.ref, three.branch);
902 assert.equal(git(src, ["ls-remote", "origin", `refs/heads/${one.branch}`]).trim(), "", "the other attempt's branch is deleted");
903 assert.equal((await asPerson("POST", `${R}/${n}/attempts/${one.attempt}/pick`)).status, 409);
904 assert.ok((await ok("GET", `${P}/posts`)).some((p) => p.body === `${owner} picked attempt ${three.attempt} of task #${n}.`));
905
906 // Later runs build on the picked attempt.
907 assert.equal((await asPerson("POST", `${R}/${n}/messages`, { body: "Add a follow file" })).status, 201);
908 const next = await until("the follow-up", async () => {
909 const t = await ok("GET", `${R}/${n}`);
910 return t.runs.length === 4 && !["queued", "boot", "install", "agent", "finish"].includes(t.runs[3].state) ? t : null;
911 });
912 assert.equal(next.runs[3].state, "done");
913 assert.equal(next.runs[3].attempt, undefined);
914 assert.match((await ok("GET", `${R}/${n}/transcript`)).map((l) => l.text).join("\n"), new RegExp(`Starting a sandbox on ${three.branch} as task-bot\\.`));
915 const files = (await ok("GET", `/repos/${owner}/tasks-demo/pulls/${pr.number}/files`)).map((f) => f.filename).sort();
916 assert.deepEqual(files, ["a.txt", "b.txt", "c.txt", "follow.txt"]);
917 for (let i = 1; i <= 4; i++) rmSync(`/tmp/${tag}-${i}`, { recursive: true, force: true });
918});
919
920test("verify and land: failing checks start a fix run, and an approved pull request lands", async () => {
921 const me = await ok("GET", "/user");
922 const owner = me.login;
923 const P = `/repos/${owner}/tasks-demo/diffinery/project`;
924 const R = `/repos/${owner}/tasks-demo/diffinery/tasks`;
925 const REPO = `/repos/${owner}/tasks-demo`;
926 const src = join(work, "src");
927 writeFileSync(
928 join(src, "verify.sh"),
929 `p="$DIFFINERY_TASK_PROMPT"
930case "$p" in
931 *"Checks failed on pull request"*) printf '%s\\n' "$p" | grep -E '^(--- |New instructions from|CI-SAYS)' ; echo fixed > fixed.txt ;;
932 *) echo work > verify.txt ;;
933esac
934`,
935 );
936 git(src, ["pull", "-q", "--rebase", "origin", "main"]);
937 git(src, ["add", "verify.sh"]);
938 git(src, ["commit", "-q", "-m", "verify script"]);
939 git(src, ["push", "-q", "origin", "main"]);
940 // An unprotected branch to land on: agents may have pull requests merged into it automatically, once a person approves.
941 git(src, ["push", "-q", "origin", "main:refs/heads/release"]);
942
943 const start = { goal: "VERIFY-GOAL: write verify.txt", runtime: "custom", command: "sh verify.sh", agent: "task-bot", base: "release" };
944 assert.equal((await asPerson("POST", R, { ...start, land: "yes" })).status, 422);
945 const started = await asPerson("POST", R, { ...start, land: true });
946 assert.equal(started.status, 201, JSON.stringify(started.json));
947 const n = started.json.number;
948 assert.equal(started.json.fix_checks, true, "on by default");
949 assert.equal(started.json.land, true);
950 const ready = await until("the task's pull request", async () => {
951 const t = await ok("GET", `${R}/${n}`);
952 return t.state !== "running" && t.state !== "queued" ? t : null;
953 });
954 assert.equal(ready.state, "ready", JSON.stringify(ready));
955 const pr = ready.pull_request.number;
956 assert.match((await ok("GET", `${R}/${n}/transcript`)).map((l) => l.text).join("\n"), new RegExp(`Auto-merge is on for #${pr}: it lands once a person approves it and its checks pass\\.`));
957 const pull = await ok("GET", `${REPO}/pulls/${pr}`);
958 assert.ok(pull.auto_merge, "auto-merge is on");
959 assert.equal(pull.merged, false, "not without a person's approval");
960
961 // A failing check on the head starts one fix run, with what the check said.
962 const head = pull.head.sha;
963 await ok("POST", `${REPO}/statuses/${head}`, { state: "failure", context: "ci/verify", description: "CI-SAYS: verify.txt is wrong" });
964 const fixing = await until("the fix run", async () => {
965 const t = await ok("GET", `${R}/${n}`);
966 return t.runs.length === 2 && !["queued", "boot", "install", "agent", "finish"].includes(t.runs[1].state) ? t : null;
967 });
968 const text = (await ok("GET", `${R}/${n}/transcript`)).map((l) => l.text).join("\n");
969 assert.equal(fixing.runs[1].kind, "fix");
970 assert.equal(fixing.runs[1].state, "done", text);
971 assert.equal(fixing.check_fixes, 1);
972 assert.match(text, /^--- ci\/verify: failure ---$/m);
973 assert.match(text, /^CI-SAYS: verify\.txt is wrong$/m);
974 assert.match(text, /^New instructions from Diffinery \(the pull request's checks failed\):$/m);
975 assert.ok((await ok("GET", `${P}/posts`)).some((p) => p.body === `Task #${n}'s pull request #${pr} failed checks; starting fix run 1 of 3.`));
976 const fixedHead = (await ok("GET", `${REPO}/pulls/${pr}`)).head.sha;
977 assert.notEqual(fixedHead, head);
978 // The same failure reported again on the old head starts nothing more.
979 await ok("POST", `${REPO}/statuses/${head}`, { state: "failure", context: "ci/other" });
980 await ok("POST", `${REPO}/statuses/${fixedHead}`, { state: "success", context: "ci/verify" });
981 await sleep(3000);
982 assert.equal((await ok("GET", `${R}/${n}`)).runs.length, 2);
983
984 // Options: only the person who started it, in a browser.
985 assert.equal((await api("PATCH", `${R}/${n}`, { land: false })).status, 403);
986 assert.equal((await asPerson("PATCH", `${R}/${n}`, { fix_checks: "no" })).status, 422);
987 const off = await asPerson("PATCH", `${R}/${n}`, { fix_checks: false });
988 assert.equal(off.status, 200, JSON.stringify(off.json));
989 assert.equal(off.json.fix_checks, false);
990 assert.match(await (await fetch(`${base}/${owner}/tasks-demo/tasks/${n}`)).text(), /<h2>Verify and land<\/h2>/);
991
992 // A person approves: it lands by itself.
993 const review = await api("POST", `${REPO}/pulls/${pr}/reviews`, { event: "APPROVE", commit_id: fixedHead });
994 assert.ok(review.status < 300, JSON.stringify(review.json));
995 const merged = await until("the pull request to land", async () => {
996 const p = await ok("GET", `${REPO}/pulls/${pr}`);
997 return p.merged ? p : null;
998 });
999 assert.equal(merged.merged, true);
1000 assert.ok((await ok("GET", P)).board.done.some((t) => t.number === n), "a landed task is done");
1001});
1002
1003test("organizations: an agent policy, model keys only owners hold, and whose key pays (phase 5b)", async () => {
1004 const me = await ok("GET", "/user");
1005 const owner = me.login;
1006 const ORG = "acme-agents";
1007 const O = `/orgs/${ORG}/diffinery`;
1008 const ORG_KEY = `sk-ant-api03-${randomBytes(30).toString("hex")}`;
1009 const RIVAL_KEY = `sk-ant-api03-${randomBytes(30).toString("hex")}`;
1010 const rivalCookie = `__Host-dfy_session=${RIVAL_SESSION}`;
1011 const asRival = async (method, path, body) => {
1012 const res = await fetch(`${base}/api/v3${path}`, { method, headers: { cookie: rivalCookie, "content-type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body) });
1013 const text = await res.text();
1014 let json = null;
1015 try {
1016 json = text ? JSON.parse(text) : null;
1017 } catch {
1018 json = text;
1019 }
1020 return { status: res.status, json };
1021 };
1022 // A run's request through the model proxy, with its token, as the agent's CLI would send it.
1023 const proxyBody = (tok, body) => fetch(`${base}/-/model/anthropic/v1/messages`, { method: "POST", headers: { "x-api-key": tok, "content-type": "application/json" }, body });
1024 const proxy = (tok, max = 100) => proxyBody(tok, JSON.stringify({ model: "m", max_tokens: max, messages: [] }));
1025 // A web form, as the owner in their browser.
1026 const form = async (page, action, fields) => {
1027 const res = await fetch(`${base}${page}`);
1028 const csrf = /name="_csrf" value="([^"]+)"/.exec(await res.text())?.[1];
1029 const cookie = /dfy_csrf=([^;]+)/.exec(res.headers.get("set-cookie") ?? "")?.[1];
1030 return fetch(`${base}${action}`, { method: "POST", redirect: "manual", headers: { cookie: `dfy_csrf=${cookie}`, "sec-fetch-site": "same-origin" }, body: new URLSearchParams([["_csrf", csrf ?? ""], ...fields]) });
1031 };
1032 const runToken = (file) => jailToken(join(work, file));
1033 const stopped = (R, n) =>
1034 until(`task #${n} to stop`, async () => {
1035 const t = await ok("GET", `${R}/${n}`);
1036 return t.state !== "running" && t.state !== "queued" ? t : null;
1037 });
1038
1039 await ok("POST", "/user/orgs", { login: ORG });
1040 await ok("POST", `/orgs/${ORG}/repos`, { name: "org-tasks" });
1041 const src = join(work, "org-src");
1042 mkdirSync(src, { recursive: true });
1043 writeFileSync(join(src, "README.md"), "# org tasks\n");
1044 git(src, ["init", "-q", "-b", "main"]);
1045 git(src, ["add", "-A"]);
1046 git(src, ["commit", "-q", "-m", "initial"]);
1047 git(src, ["remote", "add", "origin", `${base}/${ORG}/org-tasks.git`]);
1048 git(src, ["push", "-q", "origin", "main"]);
1049 assert.equal((await asPerson("PUT", "/diffinery/model-keys/anthropic", { key: ANTHROPIC_KEY, accept_terms: true })).status, 200);
1050 assert.equal((await asPerson("POST", "/diffinery/agents", { login: "org-bot" })).status, 201);
1051
1052 // Nothing changes until an owner sets something; only owners see it, and never with a repository-limited token.
1053 assert.deepEqual(await ok("GET", `${O}/agent-policy`), { keys: "personal", runtimes: ["claude-code", "codex", "custom"], providers: ["anthropic", "openai", "openrouter"], max_running: 10, terminals: true, terminal_tasks: true, review: true, member_monthly_tokens: null, updated_by: null, updated_at: null });
1054 assert.equal((await asRival("GET", `${O}/agent-policy`)).status, 404, "not a member");
1055 const limited = (await asPerson("POST", "/diffinery/tokens", { name: "org-limited", scopes: ["admin"], repositories: [`${ORG}/org-tasks`] })).json.token;
1056 for (const path of ["agent-policy", "model-keys", "model-usage"]) assert.equal((await api("GET", `${O}/${path}`, undefined, limited)).status, 403, path);
1057
1058 // The organization's key: an owner in a browser, accepting the terms for it; never shown again.
1059 const viaToken = await api("PUT", `${O}/model-keys/anthropic`, { key: ORG_KEY, accept_terms: true });
1060 assert.equal(viaToken.status, 403, JSON.stringify(viaToken.json));
1061 assert.equal((await asPerson("PUT", `${O}/model-keys/anthropic`, { key: ORG_KEY })).status, 422);
1062 const saved = await asPerson("PUT", `${O}/model-keys/anthropic`, { key: ORG_KEY, accept_terms: true });
1063 assert.equal(saved.status, 200, JSON.stringify(saved.json));
1064 assert.equal(saved.json.hint, ORG_KEY.slice(-4));
1065 assert.equal(saved.json.opted_in_by, owner);
1066 const keys = await ok("GET", `${O}/model-keys`);
1067 assert.deepEqual(keys.map((k) => k.provider), ["anthropic"]);
1068 assert.ok(!JSON.stringify(keys).includes(ORG_KEY));
1069 assert.ok(!(await ok("GET", "/diffinery/model-keys")).some((k) => k.hint === ORG_KEY.slice(-4)), "a person's keys are their own");
1070
1071 // The policy: owners in a browser.
1072 assert.equal((await api("PUT", `${O}/agent-policy`, { keys: "organization" })).status, 403, "a token can't change it");
1073 assert.equal((await asPerson("PUT", `${O}/agent-policy`, { max_running: 11 })).status, 422);
1074 const policy = await asPerson("PUT", `${O}/agent-policy`, { keys: "organization", runtimes: ["claude-code", "custom"], max_running: 2, member_monthly_tokens: 10000 });
1075 assert.equal(policy.status, 200, JSON.stringify(policy.json));
1076 assert.equal(policy.json.updated_by, owner);
1077 // Its repositories' "tasks at once" is held to it.
1078 await until("the organization's limit to reach the project", async () => (await ok("GET", `/repos/${ORG}/org-tasks/diffinery/project`)).settings.org_max_running === 2);
1079
1080 const R = `/repos/${ORG}/org-tasks/diffinery/tasks`;
1081 const codex = await asPerson("POST", R, { goal: "Not allowed", runtime: "codex", agent: "org-bot" });
1082 assert.equal(codex.status, 403, JSON.stringify(codex.json));
1083 assert.match(codex.json.message, /acme-agents's agent policy doesn't allow Codex/);
1084
1085 // An owner's task runs on the organization's key, which the provider gets and the sandbox never sees.
1086 const started = await asPerson("POST", R, { goal: "On the organization's key", runtime: "custom", command: `printf %s "$ANTHROPIC_API_KEY" > ${join(work, "org-token")}; echo "key is $ANTHROPIC_API_KEY"; sleep 120`, agent: "org-bot" });
1087 assert.equal(started.status, 201, JSON.stringify(started.json));
1088 assert.equal(started.json.paid_by, "organization");
1089 const n1 = started.json.number;
1090 const t1 = await runToken("org-token");
1091 assert.match(t1, /^dmt_/);
1092 const sent = modelRequests.length;
1093 const first = await proxy(t1);
1094 assert.equal(first.status, 200, await first.clone().text());
1095 assert.equal(modelRequests.length, sent + 1);
1096 assert.equal(modelRequests.at(-1).headers["x-api-key"], ORG_KEY);
1097 // The month's count settles to what the answer used: 21 + 4 + 300 cache reads at a tenth.
1098 const month = await until("the month's count", async () => {
1099 const u = await ok("GET", `${O}/model-usage`);
1100 return u.month.people.find((p) => p.person === owner && p.tokens === 55) ? u : null;
1101 });
1102 assert.equal(month.month.member_monthly_tokens, 10000);
1103 // A request that could take the person past the allowance never reaches the provider, and the task stops.
1104 const over = await proxy(t1, 20000);
1105 assert.equal(over.status, 403);
1106 assert.match((await over.json()).error.message, /allowance of 10,000 tokens per person on acme-agents's keys is used up/);
1107 assert.equal(modelRequests.length, sent + 1);
1108 assert.equal((await stopped(R, n1)).state, "stopped");
1109 const lines = (await ok("GET", `${R}/${n1}/transcript`)).map((l) => l.text).join("\n");
1110 assert.match(lines, /Stopped: this month's allowance/);
1111 assert.ok(!lines.includes(ORG_KEY));
1112 // The run is the organization's to see, not in the owner's own 30 days.
1113 const orgReport = await until("the organization's report", async () => {
1114 const u = await ok("GET", `${O}/model-usage`);
1115 return u.repositories.find((x) => x.repository === `${ORG}/org-tasks`) ? u : null;
1116 });
1117 assert.deepEqual(orgReport.repositories[0], { repository: `${ORG}/org-tasks`, kind: "task", runs: 1, reported_by_cli_runs: 0, requests: 1, input_tokens: 21, output_tokens: 4, cache_read_tokens: 300, cache_write_tokens: 0, cost_usd: null, person: owner });
1118 assert.ok(!(await ok("GET", "/diffinery/model-usage")).repositories.some((x) => x.repository === `${ORG}/org-tasks`));
1119
1120 // Someone outside the organization, with write access, their own agent and their own key.
1121 await ok("PUT", `/repos/${ORG}/org-tasks/collaborators/rival`, { permission: "push" });
1122 const inv = (await asRival("GET", "/user/repository_invitations")).json.find((i) => i.repository.full_name === `${ORG}/org-tasks`);
1123 assert.equal((await asRival("PATCH", `/user/repository_invitations/${inv.id}`)).status, 204);
1124 assert.equal((await asRival("PUT", "/diffinery/model-keys/anthropic", { key: RIVAL_KEY, accept_terms: true })).status, 200);
1125 assert.equal((await asRival("POST", "/diffinery/agents", { login: "rival-bot" })).status, 201);
1126 const outside = await asRival("POST", R, { goal: "Outside", runtime: "custom", command: "true", agent: "rival-bot" });
1127 assert.equal(outside.status, 403, JSON.stringify(outside.json));
1128 assert.match(outside.json.message, /only its members can use/);
1129 // Under `either`, they pay with their own key.
1130 assert.equal((await asPerson("PUT", `${O}/agent-policy`, { keys: "either", member_monthly_tokens: null })).status, 200);
1131 const own = await asRival("POST", R, { goal: "Own key", runtime: "custom", command: `printf %s "$ANTHROPIC_API_KEY" > ${join(work, "rival-token")}; sleep 120`, agent: "rival-bot" });
1132 assert.equal(own.status, 201, JSON.stringify(own.json));
1133 assert.equal(own.json.paid_by, "person");
1134 const t2 = await runToken("rival-token");
1135 assert.equal((await proxy(t2)).status, 200);
1136 assert.equal(modelRequests.at(-1).headers["x-api-key"], RIVAL_KEY);
1137 // On their own key, a request goes as they sent it.
1138 assert.equal((await proxyBody(t2, '{"model":"m","background":true,"messages":[]}')).status, 200);
1139 assert.deepEqual(JSON.parse(modelRequests.at(-1).body), { model: "m", background: true, messages: [] });
1140 assert.equal((await asRival("POST", `${R}/${own.json.number}/stop`)).status, 200);
1141
1142 // Once a member, their task runs on the organization's key, until they're removed: the next request is refused.
1143 const invited = await form(`/${ORG}?tab=people`, `/${ORG}/-/org/people`, [["who", "rival"], ["role", "member"]]);
1144 assert.equal(invited.status, 303);
1145 assert.equal((await asRival("PATCH", `/user/memberships/orgs/${ORG}`, { state: "active" })).status, 200);
1146 const member = await asRival("POST", R, { goal: "As a member", runtime: "custom", command: `printf %s "$ANTHROPIC_API_KEY" > ${join(work, "member-token")}; sleep 120`, agent: "rival-bot" });
1147 assert.equal(member.status, 201, JSON.stringify(member.json));
1148 assert.equal(member.json.paid_by, "organization");
1149 const t3 = await runToken("member-token");
1150 assert.equal((await proxy(t3)).status, 200);
1151 assert.equal(modelRequests.at(-1).headers["x-api-key"], ORG_KEY);
1152 // On the organization's key, a run sends only what its booking bounds (Security M1): no background responses,
1153 // stored context or several answers, and its output capped as booked.
1154 const sentBefore = modelRequests.length;
1155 const background = await proxyBody(t3, '{"model":"m","max_tokens":100,"background":true,"messages":[]}');
1156 assert.equal(background.status, 403);
1157 assert.match((await background.json()).error.message, /background responses aren't available to agent reviews, runs with a token budget and runs on an organization's key/);
1158 assert.equal((await proxyBody(t3, '{"model":"m","max_tokens":100,"n":3,"messages":[]}')).status, 403);
1159 // Nor input the request doesn't carry (Security's re-check): a PDF by URL, a stored item, a body that isn't JSON.
1160 const byUrl = await proxyBody(t3, JSON.stringify({ model: "m", max_tokens: 16, messages: [{ role: "user", content: [{ type: "document", source: { type: "url", url: "https://example.com/big.pdf" } }] }] }));
1161 assert.equal(byUrl.status, 403);
1162 assert.match((await byUrl.json()).error.message, /^inputs given by URL aren't available to agent reviews, runs with a token budget and runs on an organization's key/);
1163 assert.equal((await proxyBody(t3, '{"model":"m","max_tokens":16,"background":"true","messages":[]}')).status, 403);
1164 assert.equal((await proxyBody(t3, '{"model":"m","max_tokens":16,"temperature":NaN,"messages":[]}')).status, 403);
1165 assert.equal(modelRequests.length, sentBefore);
1166 // Its tools that run on the provider are stripped; those the agent runs stay.
1167 assert.equal((await proxyBody(t3, JSON.stringify({ model: "m", messages: [], tools: [{ name: "Read", input_schema: {} }, { type: "web_fetch_20250910", name: "web_fetch" }] }))).status, 200);
1168 assert.deepEqual(JSON.parse(modelRequests.at(-1).body), { model: "m", messages: [], tools: [{ name: "Read", input_schema: {} }], max_tokens: 32000 });
1169 // An absurd cap books a bounded estimate (Security S2): the month's count stays exact, here 3 answers of 55.
1170 assert.equal((await proxyBody(t3, '{"model":"m","max_tokens":1e300,"messages":[]}')).status, 200);
1171 assert.equal(JSON.parse(modelRequests.at(-1).body).max_tokens, 1000000);
1172 await until("the member's exact count", async () => (await ok("GET", `${O}/model-usage`)).month.people.find((p) => p.person === "rival" && p.tokens === 165));
1173 // Under an allowance, a person has at most 4 requests on the organization's keys on their way at once (Security's third
1174 // look: images and PDFs bill past their booking, so this bounds the overshoot); a fifth is told to try again.
1175 assert.equal((await asPerson("PUT", `${O}/agent-policy`, { member_monthly_tokens: 1_000_000 })).status, 200);
1176 const slow = await Promise.all([1, 2, 3, 4, 5].map((i) => proxyBody(t3, JSON.stringify({ model: "m", max_tokens: 10, metadata: { user_id: "slow" }, messages: [{ role: "user", content: `slow ${i}` }] }))));
1177 assert.deepEqual(slow.map((r) => r.status).sort(), [200, 200, 200, 200, 429]);
1178 const fifth = slow.find((r) => r.status === 429);
1179 assert.equal(fifth.headers.get("retry-after"), "5");
1180 assert.match((await fifth.json()).error.message, /4 of your model requests on acme-agents's keys are already on their way/);
1181 // Once they are answered, the slots are free again.
1182 await until("the slots to free up", async () => (await proxy(t3)).status === 200);
1183 // A request whose sandbox hangs up while the provider is still answering is settled anyway, and frees its slot
1184 // (Security's fourth look): the next 4 at once all go through.
1185 const hangUp = new AbortController();
1186 const dropped = fetch(`${base}/-/model/anthropic/v1/messages`, { method: "POST", signal: hangUp.signal, headers: { "x-api-key": t3, "content-type": "application/json" }, body: JSON.stringify({ model: "m", max_tokens: 10, metadata: { user_id: "slow" }, messages: [] }) }).catch(() => null);
1187 await sleep(1000);
1188 hangUp.abort();
1189 await dropped;
1190 await sleep(4000);
1191 const after4 = await Promise.all([1, 2, 3, 4].map(() => proxyBody(t3, JSON.stringify({ model: "m", max_tokens: 10, metadata: { user_id: "slow" }, messages: [] }))));
1192 assert.deepEqual(after4.map((r) => r.status), [200, 200, 200, 200]);
1193 assert.equal((await asPerson("PUT", `${O}/agent-policy`, { member_monthly_tokens: null })).status, 200);
1194 const removed = await form(`/${ORG}?tab=people`, `/${ORG}/-/org/members`, [["login", "rival"], ["role", "remove"]]);
1195 assert.equal(removed.status, 303);
1196 // Removal also takes away their grants on its repositories; with write access given back as an outside
1197 // collaborator, they can still run the task, but not on the organization's key.
1198 await ok("PUT", `/repos/${ORG}/org-tasks/collaborators/rival`, { permission: "push" });
1199 const again = (await asRival("GET", "/user/repository_invitations")).json.find((i) => i.repository.full_name === `${ORG}/org-tasks`);
1200 if (again) assert.equal((await asRival("PATCH", `/user/repository_invitations/${again.id}`)).status, 204);
1201 const before = modelRequests.length;
1202 const refused = await proxy(t3);
1203 assert.equal(refused.status, 403);
1204 assert.match((await refused.json()).error.message, /rival is no longer a member of acme-agents, whose key this task uses/);
1205 assert.equal(modelRequests.length, before);
1206 assert.equal((await stopped(R, member.json.number)).state, "stopped");
1207 // Retrying it isn't theirs to pay with the organization's key any more.
1208 const retry = await asRival("POST", `${R}/${member.json.number}/retry`);
1209 assert.equal(retry.status, 403, JSON.stringify(retry.json));
1210 assert.match(retry.json.message, /only members of acme-agents can run agents on its keys/);
1211
1212 // The Settings tab: owners see the policy, the key's last four characters and the usage; nobody sees the key.
1213 const page = await (await fetch(`${base}/${ORG}?tab=settings`)).text();
1214 assert.match(page, /Agent policy/);
1215 assert.ok(page.includes(`…${ORG_KEY.slice(-4)}`));
1216 assert.ok(!page.includes(ORG_KEY));
1217 assert.match(page, /Paid with the organization/);
1218 assert.equal((await fetch(`${base}/${ORG}?tab=settings`, { headers: { cookie: rivalCookie } })).status, 403);
1219 // Its form saves the policy (Kumo checkboxes send only what's ticked).
1220 const savedForm = await form(`/${ORG}?tab=settings`, `/${ORG}/-/org/agent-policy`, [
1221 ["keys", "personal"],
1222 ["runtimes", "claude-code"],
1223 ["runtimes", "custom"],
1224 ["providers", "anthropic"],
1225 ["max_running", "3"],
1226 ["terminal_tasks", "1"],
1227 ["review", "1"],
1228 ["member_monthly_tokens", ""],
1229 ]);
1230 assert.equal(savedForm.status, 303);
1231 assert.deepEqual(await ok("GET", `${O}/agent-policy`), { ...(await ok("GET", `${O}/agent-policy`)), keys: "personal", runtimes: ["claude-code", "custom"], providers: ["anthropic"], max_running: 3, terminals: false, terminal_tasks: true, review: true, member_monthly_tokens: null });
1232 // Deleting the key.
1233 assert.equal((await asPerson("DELETE", `${O}/model-keys/anthropic`)).status, 204);
1234 assert.deepEqual(await ok("GET", `${O}/model-keys`), []);
1235});
1236
1237test("losing access to the agent stops the task", async () => {
1238 const me = await ok("GET", "/user");
1239 const owner = me.login;
1240 const R = `/repos/${owner}/tasks-demo/diffinery/tasks`;
1241 const tokFile = join(work, "model-token-3");
1242 const started = await asPerson("POST", R, { goal: "Lose access", runtime: "custom", command: `printf %s "$ANTHROPIC_API_KEY" > ${tokFile}; sleep 120`, agent: "task-bot" });
1243 assert.equal(started.status, 201, JSON.stringify(started.json));
1244 const n = started.json.number;
1245 const runToken = await jailToken(tokFile);
1246 // Disabling the agent ends its access, and with it the task's.
1247 assert.equal((await api("DELETE", "/diffinery/agents/task-bot")).status, 204);
1248 const refused = await fetch(`${base}/-/model/anthropic/v1/messages`, { method: "POST", headers: { "x-api-key": runToken, "content-type": "application/json" }, body: "{}" });
1249 assert.equal(refused.status, 403);
1250 const t = await until("the task to stop", async () => {
1251 const x = await ok("GET", `${R}/${n}`);
1252 return x.state !== "running" ? x : null;
1253 });
1254 assert.equal(t.state, "stopped");
1255 assert.equal((await fetch(`${base}/-/model/anthropic/v1/messages`, { method: "POST", headers: { "x-api-key": runToken }, body: "{}" })).status, 401);
1256});