Public: anyone can read this; the crew can change it.
A test pins MCP `propose_to_parent_forge` when the parent cannot be reached: `parent_unreachable`, and nothing is proposed #7192
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7176-a-test-pins-mcp-propose-to-parent-forge → main 1 commit fork
1 file
changed +179
−0
test/mcp-parent-unreachable.test.ts added +179
| @@ -0,0 +1,179 @@ | ||
| 1 | /** | |
| 2 | * MCP `propose_to_parent_forge` when the parent does not answer (#7176): a | |
| 3 | * writer is told `parent_unreachable`, nothing is recorded as proposed, and | |
| 4 | * once the parent answers a retry proposes exactly once. A reader is refused | |
| 5 | * for write access before the parent is ever asked. | |
| 6 | */ | |
| 7 | ||
| 8 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 9 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 10 | import { tmpdir } from "node:os"; | |
| 11 | import { join } from "node:path"; | |
| 12 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 13 | import { mergePull, openPull } from "../src/actions/pulls.ts"; | |
| 14 | import { createApp, type App } from "../src/app.ts"; | |
| 15 | import { mintProposalToken } from "../src/core/proposal_token.ts"; | |
| 16 | import { listPulls } from "../src/core/pulls.ts"; | |
| 17 | import { getRepo, type Repo } from "../src/core/repos.ts"; | |
| 18 | import { createToken } from "../src/core/tokens.ts"; | |
| 19 | import { createUser, type User } from "../src/core/users.ts"; | |
| 20 | import { runGit } from "../src/git/env.ts"; | |
| 21 | import { repoDir } from "../src/git/repos.ts"; | |
| 22 | import { proposalLimiter } from "../src/http/proposal_gate.ts"; | |
| 23 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 24 | import { keepParentCredential } from "../src/spawn/parent_credential.ts"; | |
| 25 | import { quietLogs } from "./helpers/globals.ts"; | |
| 26 | ||
| 27 | quietLogs(); | |
| 28 | ||
| 29 | const PASSWORD = "a-long-unreachable-passphrase"; | |
| 30 | ||
| 31 | describe("MCP proposes to a parent forge that does not answer", () => { | |
| 32 | let dir = ""; | |
| 33 | let parent: App; | |
| 34 | let child: App; | |
| 35 | let parentServer: Servers | undefined; | |
| 36 | let childServer: Servers; | |
| 37 | let kid: User; | |
| 38 | let lib: Repo; | |
| 39 | let forge: Repo; | |
| 40 | let writer = ""; | |
| 41 | ||
| 42 | const person = async (app: App, username: string) => { | |
| 43 | const made = await createUser(app.db, { | |
| 44 | origin: "seed", | |
| 45 | trusted: true, | |
| 46 | username, | |
| 47 | email: `${username}@forge.local`, | |
| 48 | password: PASSWORD, | |
| 49 | adminIfFirst: true, | |
| 50 | }); | |
| 51 | if (!made.ok) throw new Error(made.error); | |
| 52 | return made.value; | |
| 53 | }; | |
| 54 | ||
| 55 | const proposals = () => | |
| 56 | child.db.query<{ n: number }, []>("SELECT count(*) AS n FROM upstream_proposal").get()!.n; | |
| 57 | ||
| 58 | const call = async (token: string) => { | |
| 59 | const res = await fetch(`${childServer.url}/mcp`, { | |
| 60 | method: "POST", | |
| 61 | headers: { | |
| 62 | "content-type": "application/json", | |
| 63 | accept: "application/json, text/event-stream", | |
| 64 | authorization: `Bearer ${token}`, | |
| 65 | }, | |
| 66 | body: JSON.stringify({ | |
| 67 | jsonrpc: "2.0", | |
| 68 | id: 1, | |
| 69 | method: "tools/call", | |
| 70 | params: { name: "propose_to_parent_forge", arguments: { repo: "kid/forge" } }, | |
| 71 | }), | |
| 72 | }); | |
| 73 | const body = (await res.json()) as any; | |
| 74 | return { error: body.result.isError === true, text: body.result.content[0].text as string }; | |
| 75 | }; | |
| 76 | ||
| 77 | beforeAll(async () => { | |
| 78 | dir = await mkdtemp(join(tmpdir(), "forge-unreachable-")); | |
| 79 | // A port that was free a moment ago and refuses connections until the | |
| 80 | // parent is started on it. | |
| 81 | const probe = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => new Response("") }); | |
| 82 | const port = probe.port as number; | |
| 83 | await probe.stop(true); | |
| 84 | ||
| 85 | parent = createApp({ dataDir: join(dir, "parent"), port, host: "127.0.0.1" }); | |
| 86 | const alice = await person(parent, "alice"); | |
| 87 | const made = await createRepo(parent, alice, { name: "lib", visibility: "public" }); | |
| 88 | if (!made.ok) throw new Error(String(made.error)); | |
| 89 | lib = made.value; | |
| 90 | const seeded = await commitToBranch(parent, alice, lib, { | |
| 91 | branch: "main", | |
| 92 | message: "Start", | |
| 93 | files: [{ path: "README.md", content: "hi\n" }], | |
| 94 | }); | |
| 95 | if (!seeded.ok) throw new Error(JSON.stringify(seeded.error)); | |
| 96 | const minted = mintProposalToken(parent.db, alice.id, lib.id, { | |
| 97 | child: "kid", | |
| 98 | origin: "https://kid.example.com/", | |
| 99 | }); | |
| 100 | if (!minted.ok) throw new Error(minted.error); | |
| 101 | proposalLimiter(parent).setLimits({ perMinute: 1000, adminPerMinute: 1000, enabled: true }); | |
| 102 | ||
| 103 | child = createApp({ | |
| 104 | dataDir: join(dir, "child"), | |
| 105 | port: 0, | |
| 106 | host: "kid.test", | |
| 107 | url: "https://kid.example.com", | |
| 108 | }); | |
| 109 | childServer = await serve(child); | |
| 110 | kid = await person(child, "kid"); | |
| 111 | const f = await createRepo(child, kid, { name: "forge", visibility: "public" }); | |
| 112 | if (!f.ok) throw new Error(String(f.error)); | |
| 113 | const there = repoDir(child.reposRoot, "kid", "forge"); | |
| 114 | await rm(there, { recursive: true, force: true }); | |
| 115 | const cloned = await runGit(["clone", "--bare", repoDir(parent.reposRoot, "alice", "lib"), there], {}); | |
| 116 | if (!cloned.ok) throw new Error(String(cloned.error)); | |
| 117 | forge = getRepo(child.db, "kid", "forge")!; | |
| 118 | const tip = await runGit(["rev-parse", "main"], { cwd: there }); | |
| 119 | if (!tip.ok) throw new Error(String(tip.error)); | |
| 120 | await Bun.write( | |
| 121 | join(child.config.dataDir, "parent.json"), | |
| 122 | JSON.stringify({ | |
| 123 | upstream: `http://127.0.0.1:${port}`, | |
| 124 | name: "kid", | |
| 125 | forgeCode: { repo: "kid/forge", upstream: "alice/lib", mode: "tell-me", tip: tip.value.trim(), chain: [] }, | |
| 126 | }), | |
| 127 | ); | |
| 128 | const kept = await keepParentCredential(child, { child: "kid", token: minted.value.token }); | |
| 129 | if (!kept.ok) throw new Error(kept.error); | |
| 130 | ||
| 131 | // A change landed on the child, worth proposing. | |
| 132 | const c = await commitToBranch(child, kid, forge, { | |
| 133 | branch: "feat", | |
| 134 | base: "main", | |
| 135 | message: "Add feat", | |
| 136 | files: [{ path: "feat.md", content: "feat\n" }], | |
| 137 | }); | |
| 138 | if (!c.ok) throw new Error(JSON.stringify(c.error)); | |
| 139 | const opened = await openPull(child, kid, forge, { title: "Add feat", baseBranch: "main", headBranch: "feat" }); | |
| 140 | if (!opened.ok) throw new Error(JSON.stringify(opened.error)); | |
| 141 | const merged = await mergePull(child, kid, forge, opened.value, "merge"); | |
| 142 | if (!merged.ok) throw new Error(JSON.stringify(merged.error)); | |
| 143 | writer = createToken(child.db, kid.id, "rw", "rw").token; | |
| 144 | }); | |
| 145 | ||
| 146 | afterAll(async () => { | |
| 147 | await childServer.stop(); | |
| 148 | await parentServer?.stop(); | |
| 149 | await rm(dir, { recursive: true, force: true }); | |
| 150 | }); | |
| 151 | ||
| 152 | test("a reader is refused for write access, not told the parent is unreachable", async () => { | |
| 153 | const made = await person(child, "reader"); | |
| 154 | const reader = createToken(child.db, made.id, "rw", "rw").token; | |
| 155 | const refused = await call(reader); | |
| 156 | expect(refused.error).toBe(true); | |
| 157 | expect(refused.text).toMatch(/^forbidden: /); | |
| 158 | expect(refused.text).not.toContain("parent_unreachable"); | |
| 159 | expect(proposals()).toBe(0); | |
| 160 | }); | |
| 161 | ||
| 162 | test("a writer is told the parent is unreachable and nothing is recorded", async () => { | |
| 163 | const r = await call(writer); | |
| 164 | expect(r.error).toBe(true); | |
| 165 | expect(r.text).toBe("parent_unreachable: parent unreachable: nothing was proposed"); | |
| 166 | expect(proposals()).toBe(0); | |
| 167 | }); | |
| 168 | ||
| 169 | test("once the parent answers, a retry proposes exactly once", async () => { | |
| 170 | parentServer = await serve(parent); | |
| 171 | const first = await call(writer); | |
| 172 | expect(first.error).toBe(false); | |
| 173 | expect(JSON.parse(first.text).opened).toBe(true); | |
| 174 | const again = await call(writer); | |
| 175 | expect(JSON.parse(again.text).opened).toBe(false); | |
| 176 | expect(proposals()).toBe(1); | |
| 177 | expect(listPulls(parent.db, lib.id, { state: "open", limit: 50 })).toHaveLength(1); | |
| 178 | }); | |
| 179 | }); |
new above
offline