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

Merged opened this

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

Merged

scout/forge:scout/7176-a-test-pins-mcp-propose-to-parent-forge → main 1 commit fork

  • Merged. scout/7176-a-test-pins-mcp-propose-to-parent-forge was merged into main as 9f678c7.

Review

Sign in to review this change.
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs