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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/577-spawnforge-has-callers-post-api-v1-forge → main 1 commit fork
9 files
changed +395
−6
docs/MCP.md +1
| @@ -35,6 +35,7 @@ for a tunnel and a forge that sleeps. | ||
| 35 | 35 | | `audit` | who can do what, per app, read from its contract.json in git: yes, own or no per door | | |
| 36 | 36 | | `probe` | knock on a running app's doors as nobody, a user and an admin; the ones that disobey its contract | | |
| 37 | 37 | | `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 | | |
| 38 | 39 | | `take_updates` | a fork takes its parent's updates when git can do it alone; a conflict is refused | yes | |
| 39 | 40 | | `converge` | the fork's crew merges where both sides changed the same files; comes back as a pull | yes | |
| 40 | 41 | | `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. | ||
| 36 | 36 | - The parent never reaches into a child; children report in with a signed |
| 37 | 37 | heartbeat carrying counts and version only. |
| 38 | 38 | - 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[] = [ | ||
| 74 | 74 | "test/board.test.ts", |
| 75 | 75 | "test/spawn-plan.test.ts", |
| 76 | 76 | "test/spawn.test.ts", |
| 77 | "test/spawn-api.test.ts", | |
| 77 | 78 | "test/web-apps.test.ts", |
| 78 | 79 | "test/fleet-ops.test.ts", |
| 79 | 80 | "test/meter.test.ts", |
src/actions/spawn.ts +15 −1
| @@ -12,7 +12,7 @@ | ||
| 12 | 12 | * idempotent — and a finished one is answered, not made twice. |
| 13 | 13 | */ |
| 14 | 14 | |
| 15 | import { mkdir, rename } from "node:fs/promises"; | |
| 15 | import { mkdir, readdir, rename } from "node:fs/promises"; | |
| 16 | 16 | import { join } from "node:path"; |
| 17 | 17 | import { createApp, type App } from "../app.ts"; |
| 18 | 18 | import { getAgentByName, createAgentRow, updateAgent, BRIEF_MAX } from "../core/agents.ts"; |
| @@ -252,3 +252,17 @@ async function copyInto( | ||
| 252 | 252 | child.close(); |
| 253 | 253 | } |
| 254 | 254 | } |
| 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 @@ | ||
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | 11 | import { forgeHealth } from "../kernel/guard.ts"; |
| 12 | import { listChildren } from "../actions/spawn.ts"; | |
| 13 | import { spawnDoor } from "../spawn/door.ts"; | |
| 12 | 14 | import { listSecrets, putSecret, removeSecret } from "../actions/secrets.ts"; |
| 13 | 15 | import { proposeUpstream } from "../actions/upstream.ts"; |
| 14 | 16 | import { relationOf, syncFork, takeUpdates } from "../actions/forks.ts"; |
| @@ -2892,6 +2894,25 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2892 | 2894 | return json({ ...v, ...forge, ok: forge.ok && v.ok }); |
| 2893 | 2895 | }); |
| 2894 | 2896 | |
| 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 | ||
| 2895 | 2916 | router.add("GET", "/api/v1/apps", (ctx) => { |
| 2896 | 2917 | const records = listApps(ctx.app.db); |
| 2897 | 2918 | const repos = getReposByIds( |
src/http/mcp.ts +52 −5
| @@ -26,6 +26,7 @@ import { | ||
| 26 | 26 | } from "../actions/dependencies.ts"; |
| 27 | 27 | import { openPlan, parsePlan, PLAN_MAX } from "../actions/plan.ts"; |
| 28 | 28 | import { cancelRepoRun } from "../actions/runs.ts"; |
| 29 | import { spawnDoor } from "../spawn/door.ts"; | |
| 29 | 30 | import { tell, toldWords } from "../actions/tell.ts"; |
| 30 | 31 | import { |
| 31 | 32 | readUsage, |
| @@ -79,6 +80,8 @@ interface Tool { | ||
| 79 | 80 | readonly inputSchema: Record<string, unknown>; |
| 80 | 81 | /** Whether it changes anything: `rw` scope, and said so to the client. */ |
| 81 | 82 | readonly writes: boolean; |
| 83 | /** A write that only plans (`dryRun` not false) is a read: `ro` may ask. */ | |
| 84 | readonly dryByDefault?: true; | |
| 82 | 85 | run(ctx: Ctx, user: User, args: Args): Promise<Answer> | Answer; |
| 83 | 86 | } |
| 84 | 87 | |
| @@ -413,6 +416,49 @@ const TOOLS: readonly Tool[] = [ | ||
| 413 | 416 | }); |
| 414 | 417 | }, |
| 415 | 418 | }, |
| 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 | }, | |
| 416 | 462 | { |
| 417 | 463 | name: "take_updates", |
| 418 | 464 | description: |
| @@ -706,17 +752,18 @@ function rpcOne( | ||
| 706 | 752 | const tool = TOOLS.find((t) => t.name === params["name"]); |
| 707 | 753 | if (tool === undefined) |
| 708 | 754 | 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")) | |
| 710 | 761 | return reply({ |
| 711 | 762 | content: [ |
| 712 | 763 | { type: "text", text: "forbidden: this token may read, not act" }, |
| 713 | 764 | ], |
| 714 | 765 | isError: true, |
| 715 | 766 | }); |
| 716 | const args = | |
| 717 | typeof params["arguments"] === "object" && params["arguments"] !== null | |
| 718 | ? (params["arguments"] as Args) | |
| 719 | : {}; | |
| 720 | 767 | return Promise.resolve(tool.run(ctx, user, args)).then((answer) => |
| 721 | 768 | reply( |
| 722 | 769 | 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", () => { | ||
| 163 | 163 | "audit", |
| 164 | 164 | "probe", |
| 165 | 165 | "following", |
| 166 | "spawn", | |
| 166 | 167 | "take_updates", |
| 167 | 168 | "converge", |
| 168 | 169 | "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 | }); |
new above
offline