Public: anyone can read this; the crew can change it.

spawnForge has callers: `POST /api/v1/forges` and an MCP `spawn` tool, both dry-run first #581

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/577-spawnforge-has-callers-post-api-v1-forge → main 1 commit fork

  • Merged. scout/577-spawnforge-has-callers-post-api-v1-forge was merged into main as a7d1d7f.

Review

Sign in to review this change.
9 files changed +395 −6
docs/MCP.md +1
@@ -35,6 +35,7 @@ for a tunnel and a forge that sleeps.
3535 | `audit` | who can do what, per app, read from its contract.json in git: yes, own or no per door | |
3636 | `probe` | knock on a running app's doors as nobody, a user and an admin; the ones that disobey its contract | |
3737 | `following` | where a fork stands against what it follows, and what the crew is doing about it | |
38 | `spawn` | `{ name, apps?, crew?, dryRun? }`: a child forge, planned first (`dryRun` is true unless `false`); an admin's, and an `ro` token may plan but not make | yes |
3839 | `take_updates` | a fork takes its parent's updates when git can do it alone; a conflict is refused | yes |
3940 | `converge` | the fork's crew merges where both sides changed the same files; comes back as a pull | yes |
4041 | `map` | an app's plan: ready now, waiting on what, the critical path, where the crew is (`app`) | |
docs/SPAWN.md +4
@@ -36,3 +36,7 @@ as a second implementation of the placement seam (#427), in their own issue.
3636 - The parent never reaches into a child; children report in with a signed
3737 heartbeat carrying counts and version only.
3838 - A proposal can only create branches under `child/<name>/` and open pulls.
39
40 ## Callers
41
42 `POST /api/v1/forges {name, apps[], crew[], dryRun}` (rw token, admin only) and the MCP `spawn` tool share `src/spawn/door.ts`. `dryRun` is true unless it is `false`: the answer is the plan and what is left behind, and nothing is made. `GET /api/v1/forges` lists the ledger's children. The child's org is its name; `crew` names the agents whose briefs travel. Errors: 401 unauthorized, 403 forbidden / cross_origin, 422 invalid, 409 refused / exists, 500 failed — each with one sentence. The MCP lets an `ro` token plan, never make.
scripts/gate.ts +1
@@ -74,6 +74,7 @@ export const GATE: readonly string[] = [
7474 "test/board.test.ts",
7575 "test/spawn-plan.test.ts",
7676 "test/spawn.test.ts",
77 "test/spawn-api.test.ts",
7778 "test/web-apps.test.ts",
7879 "test/fleet-ops.test.ts",
7980 "test/meter.test.ts",
src/actions/spawn.ts +15 −1
@@ -12,7 +12,7 @@
1212 * idempotent — and a finished one is answered, not made twice.
1313 */
1414
15 import { mkdir, rename } from "node:fs/promises";
15 import { mkdir, readdir, rename } from "node:fs/promises";
1616 import { join } from "node:path";
1717 import { createApp, type App } from "../app.ts";
1818 import { getAgentByName, createAgentRow, updateAgent, BRIEF_MAX } from "../core/agents.ts";
@@ -252,3 +252,17 @@ async function copyInto(
252252 child.close();
253253 }
254254 }
255
256 /** The children this forge has made or begun, from its ledger, by name. */
257 export async function listChildren(
258 app: App,
259 ): Promise<{ name: string; url: string; bodyId: string; done: boolean }[]> {
260 const dir = join(app.config.dataDir, "spawn");
261 const files = await readdir(dir).catch(() => [] as string[]);
262 const out = [];
263 for (const f of files.filter((f) => f.endsWith(".json")).sort()) {
264 const ledger = await readLedger(app, f.slice(0, -".json".length));
265 if (ledger) out.push({ name: ledger.name, url: ledger.url, bodyId: ledger.bodyId, done: ledger.done });
266 }
267 return out;
268 }
src/http/api.ts +21
@@ -9,6 +9,8 @@
99 */
1010
1111 import { forgeHealth } from "../kernel/guard.ts";
12 import { listChildren } from "../actions/spawn.ts";
13 import { spawnDoor } from "../spawn/door.ts";
1214 import { listSecrets, putSecret, removeSecret } from "../actions/secrets.ts";
1315 import { proposeUpstream } from "../actions/upstream.ts";
1416 import { relationOf, syncFork, takeUpdates } from "../actions/forks.ts";
@@ -2892,6 +2894,25 @@ export function apiRoutes(router: Router<Handler>): void {
28922894 return json({ ...v, ...forge, ok: forge.ok && v.ok });
28932895 });
28942896
2897 // A forge's children (docs/SPAWN.md): an admin's, planned before made.
2898 router.add("GET", "/api/v1/forges", async (ctx) => {
2899 const denied = requireScope(ctx, "ro");
2900 if (denied) return denied;
2901 if (!ctx.auth.user?.isAdmin) return forbidden();
2902 return json({ items: await listChildren(ctx.app), next_cursor: null });
2903 });
2904
2905 // `dryRun` is true unless it is `false`: the plan, and nothing is made.
2906 router.add("POST", "/api/v1/forges", async (ctx) => {
2907 const input = await body(ctx.request);
2908 const denied = requireScope(ctx, "rw");
2909 if (denied) return denied;
2910 if (typeof input !== "object" || input === null || Array.isArray(input))
2911 return apiError("invalid", 422, "send a JSON object: name, apps, crew, dryRun");
2912 const door = await spawnDoor(ctx, ctx.auth.user!, input as Record<string, unknown>);
2913 return json(door.body, door.status);
2914 });
2915
28952916 router.add("GET", "/api/v1/apps", (ctx) => {
28962917 const records = listApps(ctx.app.db);
28972918 const repos = getReposByIds(
src/http/mcp.ts +52 −5
@@ -26,6 +26,7 @@ import {
2626 } from "../actions/dependencies.ts";
2727 import { openPlan, parsePlan, PLAN_MAX } from "../actions/plan.ts";
2828 import { cancelRepoRun } from "../actions/runs.ts";
29 import { spawnDoor } from "../spawn/door.ts";
2930 import { tell, toldWords } from "../actions/tell.ts";
3031 import {
3132 readUsage,
@@ -79,6 +80,8 @@ interface Tool {
7980 readonly inputSchema: Record<string, unknown>;
8081 /** Whether it changes anything: `rw` scope, and said so to the client. */
8182 readonly writes: boolean;
83 /** A write that only plans (`dryRun` not false) is a read: `ro` may ask. */
84 readonly dryByDefault?: true;
8285 run(ctx: Ctx, user: User, args: Args): Promise<Answer> | Answer;
8386 }
8487
@@ -413,6 +416,49 @@ const TOOLS: readonly Tool[] = [
413416 });
414417 },
415418 },
419 {
420 name: "spawn",
421 description:
422 "Spawn a child forge: a new forge with the apps you name and the crew's briefs. It plans first — `dryRun` is true unless you say false — and shows what would be copied and what is left behind (secrets, tokens, keys, accounts). Only an admin spawns; a read-only token may plan, not make.",
423 inputSchema: {
424 type: "object",
425 properties: {
426 name: {
427 type: "string",
428 description: "The child's name: a-z, 0-9 and -, at most 39 characters.",
429 },
430 apps: {
431 type: "array",
432 items: APP_ARG,
433 description: "The apps to copy, each as owner/name, with their history.",
434 },
435 crew: {
436 type: "array",
437 items: { type: "string" },
438 description: "The crew members whose briefs go along, by name.",
439 },
440 dryRun: {
441 type: "boolean",
442 description: "Plan only (the default). Send false to make the child.",
443 },
444 },
445 required: ["name"],
446 },
447 writes: true,
448 dryByDefault: true,
449 async run(ctx, user, args) {
450 const door = await spawnDoor(ctx, user, args);
451 if (door.status >= 400)
452 return bad(String(door.body["error"]), String(door.body["message"]));
453 const made = door.body["dryRun"] === false;
454 return good({
455 says: made
456 ? `Spawned ${String(door.body["name"])} at ${String(door.body["url"])}.`
457 : "This is the plan; nothing was made. Send dryRun: false to spawn.",
458 ...door.body,
459 });
460 },
461 },
416462 {
417463 name: "take_updates",
418464 description:
@@ -706,17 +752,18 @@ function rpcOne(
706752 const tool = TOOLS.find((t) => t.name === params["name"]);
707753 if (tool === undefined)
708754 return refuse(-32602, `no tool named ${String(params["name"])}`);
709 if (tool.writes && !scopeAllows(ctx.auth.scope ?? "ro", "rw"))
755 const args =
756 typeof params["arguments"] === "object" && params["arguments"] !== null
757 ? (params["arguments"] as Args)
758 : {};
759 const plansOnly = tool.dryByDefault === true && args["dryRun"] !== false;
760 if (tool.writes && !plansOnly && !scopeAllows(ctx.auth.scope ?? "ro", "rw"))
710761 return reply({
711762 content: [
712763 { type: "text", text: "forbidden: this token may read, not act" },
713764 ],
714765 isError: true,
715766 });
716 const args =
717 typeof params["arguments"] === "object" && params["arguments"] !== null
718 ? (params["arguments"] as Args)
719 : {};
720767 return Promise.resolve(tool.run(ctx, user, args)).then((answer) =>
721768 reply(
722769 answer.ok
src/spawn/door.ts added +138
@@ -0,0 +1,138 @@
1 /**
2 * The spawn door: what `POST /api/v1/forges` and the MCP `spawn` tool share.
3 * Plain input in, one status and one JSON body out. The owner's request is
4 * turned into the plan's inputs here (the catalogue is only what they may
5 * read, the briefs only the crew they named), and `dryRun` — true unless
6 * the caller says `false` — returns the plan and changes nothing.
7 */
8
9 import { listChildren, spawnForge, type SpawnError } from "../actions/spawn.ts";
10 import type { App } from "../app.ts";
11 import { getAgentByName } from "../core/agents.ts";
12 import { canRead, getRepo } from "../core/repos.ts";
13 import type { User } from "../core/users.ts";
14 import { repoDir } from "../git/repos.ts";
15 import type { Ctx } from "../http/context.ts";
16 import { hostedPlacement, type Placement } from "./placement.ts";
17 import { dryRun, planSpawn, MAX_APPS, type CatalogApp, type CrewBrief, type SpawnSource } from "./plan.ts";
18 import { readdir, stat } from "node:fs/promises";
19 import { join } from "node:path";
20
21 export interface Door {
22 readonly status: number;
23 readonly body: Record<string, unknown>;
24 }
25
26 const MAX_CREW = 100;
27 const placements = new WeakMap<App, Placement>();
28
29 /** Tests (and an external placement, one day) swap where children live. */
30 export function usePlacement(app: App, placement: Placement): void {
31 placements.set(app, placement);
32 }
33
34 function placementFor(app: App): Placement {
35 let p = placements.get(app);
36 if (!p) {
37 const host = app.config.url ? new URL(app.config.url).host : (app.config.host ?? "localhost");
38 p = hostedPlacement({ host });
39 placements.set(app, p);
40 }
41 return p;
42 }
43
44 /** One status and one sentence per kind of failure. */
45 export function spawnWords(e: SpawnError): Door {
46 const say = (status: number, error: string, message: string): Door => ({
47 status,
48 body: { error, message },
49 });
50 switch (e.kind) {
51 case "unauthorized":
52 return say(401, "unauthorized", "sign in with a token to spawn a forge");
53 case "forbidden":
54 return say(403, "forbidden", "only an admin of this forge may spawn a child");
55 case "cross_origin":
56 return say(403, "cross_origin", "this request came from another origin");
57 case "invalid":
58 return say(422, "invalid", `${e.field}: ${e.reason}`);
59 case "refused":
60 return say(409, "refused", e.message);
61 case "exists":
62 return say(409, "exists", e.message);
63 case "failed":
64 return say(500, "failed", `the child could not be made: ${e.message}`);
65 }
66 }
67
68 const invalid = (field: string, reason: string): Door =>
69 spawnWords({ kind: "invalid", field, reason });
70
71 const names = (v: unknown, field: string, max: number): string[] | Door => {
72 if (v === undefined) return [];
73 if (!Array.isArray(v) || v.length > max || v.some((x) => typeof x !== "string"))
74 return invalid(field, `must be a list of at most ${max} names`);
75 return v as string[];
76 };
77
78 async function bytesOf(dir: string): Promise<number> {
79 let n = 0;
80 for (const e of await readdir(dir, { recursive: true, withFileTypes: true }).catch(() => [])) {
81 if (e.isFile()) n += (await stat(join(e.parentPath, e.name)).catch(() => undefined))?.size ?? 0;
82 }
83 return n;
84 }
85
86 /** Plan a child (and make it unless `dryRun` is true, the default). */
87 export async function spawnDoor(ctx: Ctx, user: User, raw: Record<string, unknown>): Promise<Door> {
88 if (!user.isAdmin) return spawnWords({ kind: "forbidden" });
89 if (raw["dryRun"] !== undefined && typeof raw["dryRun"] !== "boolean")
90 return invalid("dryRun", "must be true or false");
91 const dry = raw["dryRun"] !== false;
92 const apps = names(raw["apps"], "apps", MAX_APPS);
93 if (!Array.isArray(apps)) return apps;
94 const crew = names(raw["crew"], "crew", MAX_CREW);
95 if (!Array.isArray(crew)) return crew;
96
97 const db = ctx.app.db;
98 // Only what the caller named and may read: an unreadable app is "not a known app".
99 const catalog: CatalogApp[] = [];
100 for (const ref of new Set(apps)) {
101 const [o, n, ...rest] = ref.split("/");
102 const repo = o && n && rest.length === 0 ? getRepo(db, o, n) : undefined;
103 if (!repo || !canRead(db, repo, user)) continue;
104 catalog.push({
105 ref,
106 readable: true,
107 bytes: await bytesOf(repoDir(ctx.app.reposRoot, o!, n!)),
108 forgeJsonBytes: 0,
109 });
110 }
111 const briefs: CrewBrief[] = [];
112 for (const name of new Set(crew)) {
113 const agent = getAgentByName(db, name);
114 if (!agent) return invalid("crew", `${name} is not on this forge's crew`);
115 briefs.push({ agent: agent.user.username, text: agent.brief });
116 }
117 const source: SpawnSource = { catalog, briefs, subscriptions: [] };
118 const input = {
119 name: raw["name"],
120 org: raw["name"],
121 apps,
122 carryBriefs: briefs.length > 0,
123 carrySubscriptions: false,
124 };
125
126 if (dry) {
127 const planned = planSpawn(input, source);
128 if (!planned.ok) return spawnWords({ kind: "invalid", ...planned.error });
129 return { status: 200, body: { dryRun: true, plan: planned.value, ...dryRun(planned.value, source) } };
130 }
131 const made = await spawnForge(ctx.app, user, input, source, {
132 placement: placementFor(ctx.app),
133 request: ctx.request,
134 url: ctx.url,
135 });
136 return made.ok ? { status: made.value.existed ? 200 : 201, body: { dryRun: false, ...made.value } } : spawnWords(made.error);
137 }
138
test/mcp.test.ts +1
@@ -163,6 +163,7 @@ describe("the forge over MCP", () => {
163163 "audit",
164164 "probe",
165165 "following",
166 "spawn",
166167 "take_updates",
167168 "converge",
168169 "map",
test/spawn-api.test.ts added +162
@@ -0,0 +1,162 @@
1 /**
2 * The callers of spawnForge: POST/GET /api/v1/forges and the MCP `spawn`
3 * tool. Dry-run is the default, an ro token plans and never makes, and a
4 * fake placement stands in for where the child lives.
5 */
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { mkdtemp, readdir, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createApp, type App } from "../src/app.ts";
11 import { createAgent } from "../src/actions/crew.ts";
12 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
13 import { spawnWords, usePlacement } from "../src/spawn/door.ts";
14 import { createToken } from "../src/core/tokens.ts";
15 import { createUser } from "../src/core/users.ts";
16 import { serve, type Servers } from "../src/http/server.ts";
17 import { ok } from "../src/kernel/result.ts";
18 import type { SpawnError } from "../src/actions/spawn.ts";
19
20 describe("spawn over the API and MCP", () => {
21 let dataDir: string;
22 let app: App;
23 let servers: Servers;
24 let base: string;
25 let rw: string;
26 let ro: string;
27 let bobRw: string;
28 let placed = 0;
29
30 const post = (token: string | null, payload: unknown, extra: Record<string, string> = {}) =>
31 fetch(`${base}/api/v1/forges`, {
32 method: "POST",
33 headers: { "content-type": "application/json", ...(token ? { authorization: `Bearer ${token}` } : {}), ...extra },
34 body: JSON.stringify(payload),
35 });
36 const get = (token: string) =>
37 fetch(`${base}/api/v1/forges`, { headers: { authorization: `Bearer ${token}` } });
38 const mcp = async (token: string, args: unknown) => {
39 const res = await fetch(`${base}/mcp`, {
40 method: "POST",
41 headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
42 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "spawn", arguments: args } }),
43 });
44 const r = ((await res.json()) as any).result;
45 return { error: r.isError === true, text: r.content[0].text as string };
46 };
47
48 beforeAll(async () => {
49 dataDir = await mkdtemp(join(tmpdir(), "forge-spawn-api-"));
50 app = createApp({ dataDir, port: 0 });
51 servers = await serve(app);
52 base = servers.url;
53 const a = await createUser(app.db, { username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
54 const b = await createUser(app.db, { username: "bob", email: "[email protected]", password: "a-good-passphrase" });
55 if (!a.ok || !b.ok) throw new Error("users");
56 rw = createToken(app.db, a.value.id, "rw", "rw").token;
57 ro = createToken(app.db, a.value.id, "ro", "ro").token;
58 bobRw = createToken(app.db, b.value.id, "rw", "rw").token;
59 const repo = await createRepo(app, a.value, { name: "notes" });
60 if (!repo.ok) throw new Error("repo");
61 await commitToBranch(app, a.value, repo.value, { message: "seed", files: [{ path: "a.txt", content: "a" }] });
62 const agent = await createAgent(app, a.value, { username: "scout", brief: "ship small" });
63 if (!agent.ok) throw new Error(`agent ${JSON.stringify(agent.error)}`);
64 usePlacement(app, {
65 place: async (plan) => ok({ url: `https://${plan.name}.fake.test`, bodyId: `fake-${++placed}` }),
66 retire: async () => ok(),
67 });
68 }, 30_000);
69
70 afterAll(async () => {
71 servers.stop();
72 app.close();
73 await rm(dataDir, { recursive: true, force: true });
74 });
75
76 test("dry-run is the default: the plan comes back and nothing is made", async () => {
77 const res = await post(rw, { name: "kid", apps: ["alice/notes"], crew: ["scout"] });
78 expect(res.status).toBe(200);
79 const body = (await res.json()) as any;
80 expect(body.dryRun).toBe(true);
81 expect(body.plan.apps.map((a: any) => a.ref)).toEqual(["alice/notes"]);
82 expect(body.plan.briefs).toEqual([{ agent: "scout", text: "ship small" }]);
83 expect(body.leftBehind).toContain("secrets");
84 expect(placed).toBe(0);
85 expect(await readdir(join(dataDir, "children")).catch(() => [])).toEqual([]);
86 expect(await (await get(rw)).json()).toEqual({ items: [], next_cursor: null });
87 });
88
89 test("the boundary refuses a bad name, an unknown app, an unknown crew member", async () => {
90 for (const bad of [
91 { name: "Bad Name", apps: [] },
92 { name: "kid", apps: ["alice/nope"] },
93 { name: "kid", apps: ["alice/notes"], crew: ["ghost"] },
94 { name: "kid", apps: "alice/notes" },
95 { name: "kid", dryRun: "no" },
96 ]) {
97 const res = await post(rw, bad);
98 expect(res.status).toBe(422);
99 expect(((await res.json()) as any).error).toBe("invalid");
100 }
101 expect((await post(rw, [1])).status).toBe(422);
102 });
103
104 test("a non-admin, an ro token and no token are refused, even for a dry run", async () => {
105 expect((await post(bobRw, { name: "kid" })).status).toBe(403);
106 expect((await post(bobRw, { name: "kid", dryRun: false })).status).toBe(403);
107 expect((await post(ro, { name: "kid" })).status).toBe(403);
108 expect((await post(null, { name: "kid" })).status).toBe(401);
109 expect((await get(bobRw)).status).toBe(403);
110 expect(placed).toBe(0);
111 });
112
113 test("a cross-origin spawn is refused", async () => {
114 const res = await post(rw, { name: "kid", apps: ["alice/notes"], dryRun: false }, { origin: "https://evil.test" });
115 expect(res.status).toBe(403);
116 expect(placed).toBe(0);
117 });
118
119 test("dryRun: false makes the child through the placement, and the ledger lists it", async () => {
120 const res = await post(rw, { name: "kid", apps: ["alice/notes"], crew: ["scout"], dryRun: false });
121 expect(res.status).toBe(201);
122 const made = (await res.json()) as any;
123 expect(made).toMatchObject({ dryRun: false, name: "kid", url: "https://kid.fake.test", existed: false });
124 expect(placed).toBe(1);
125 const list = (await (await get(ro)).json()) as any;
126 expect(list.items).toEqual([{ name: "kid", url: "https://kid.fake.test", bodyId: "fake-1", done: true }]);
127 // The same request again is answered, not made twice.
128 const again = await post(rw, { name: "kid", apps: ["alice/notes"], crew: ["scout"], dryRun: false });
129 expect(again.status).toBe(200);
130 expect(((await again.json()) as any).existed).toBe(true);
131 expect(placed).toBe(1);
132 });
133
134 test("every SpawnError kind has one status and one sentence", () => {
135 const kinds: SpawnError[] = [
136 { kind: "unauthorized" },
137 { kind: "forbidden" },
138 { kind: "cross_origin" },
139 { kind: "invalid", field: "name", reason: "x" },
140 { kind: "refused", reason: "children", message: "a forge holds at most 5 hosted children" },
141 { kind: "exists", message: "that child is being made" },
142 { kind: "failed", message: "git failed" },
143 ];
144 expect(kinds.map((k) => spawnWords(k).status)).toEqual([401, 403, 403, 422, 409, 409, 500]);
145 for (const k of kinds) expect(String(spawnWords(k).body["message"]).length).toBeGreaterThan(5);
146 });
147
148 test("MCP: it lists as a tool, plans by default for ro, and only rw makes", async () => {
149 const plan = await mcp(ro, { name: "kid2", apps: ["alice/notes"] });
150 expect(plan.error).toBe(false);
151 expect(JSON.parse(plan.text)).toMatchObject({ dryRun: true, says: expect.stringContaining("nothing was made") });
152 const denied = await mcp(ro, { name: "kid2", apps: ["alice/notes"], dryRun: false });
153 expect(denied).toEqual({ error: true, text: "forbidden: this token may read, not act" });
154 const notAdmin = await mcp(bobRw, { name: "kid2" });
155 expect(notAdmin.error).toBe(true);
156 expect(notAdmin.text).toStartWith("forbidden:");
157 const made = await mcp(rw, { name: "kid2", apps: ["alice/notes"], dryRun: false });
158 expect(made.error).toBe(false);
159 expect(JSON.parse(made.text)).toMatchObject({ dryRun: false, name: "kid2", url: "https://kid2.fake.test" });
160 expect(placed).toBe(2);
161 });
162 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs