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

MCP `rollback`: a forge admin plans, then rolls the forge back to a kept release, with the releases page's rules #7225

Merged opened this

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

Merged

scout/forge:scout/7213-mcp-rollback-a-forge-admin-plans-then-ro → main 4 commits fork 4 threads · 4 unresolved

  • Merged. scout/7213-mcp-rollback-a-forge-admin-plans-then-ro was merged into main as 0831424.

Review

Sign in to review this change.
6 files changed +192 −0
docs/MCP.md +1
@@ -138,6 +138,7 @@ for a tunnel and a forge that sleeps.
138138 | `restore_drill` | no input: restore the newest snapshot as a drill and answer `{ verdict, failedStep?, says }`, worded as the Backups page's button; no store, a failure or a red drill is an answer, not an error; a forge admin's, with an admin token | yes |
139139 | `returns` | `{ repo?, days? }`: what the crew's tokens bought over the last `days` (1–90, default 7): dollars, runs, changes landed, cost per change; `repo` narrows it to one app of yours | |
140140 | `review` | `{ repo, number, verdict, reason? }`: approve a pull (`approve`) or request changes (`changes`, with a `reason`), as the page does; a writer's or the crew's, and never your own pull | yes |
141 | `rollback` | `{ sha, dryRun? }`: roll the forge back to a kept release, as /releases does; plans unless `dryRun` is false (from which release to which, and whether the live schema allows it); a person who may roll back, with an admin-scope token, never an agent | yes |
141142 | `run_log` | `{ repo, run, tail? }`: a run's log, or its last `tail` lines (1–2000), with the REST route's access: a hidden log or unreadable repository is not found | |
142143 | `runs` | `{ repo, limit? }`: the repository's recent runs, newest first (id, kind, state, issue or pull number, started, ended), 1–50, default 20; read as the runs page reads them | |
143144 | `say` | speak on an issue or a pull; a working builder hears it as its next step | yes |
src/http/mcp/tools/index.ts +1
@@ -111,6 +111,7 @@ export { tool as reports } from "./reports.ts";
111111 export { tool as restore_drill } from "./restore_drill.ts";
112112 export { tool as returns } from "./returns.ts";
113113 export { tool as review } from "./review.ts";
114 export { tool as rollback } from "./rollback.ts";
114115 export { tool as run_log } from "./run_log.ts";
115116 export { tool as runs } from "./runs.ts";
116117 export { tool as say } from "./say.ts";
src/http/mcp/tools/rollback.ts added +42
@@ -0,0 +1,42 @@
1 import { existsSync } from "node:fs";
2 import { running } from "../../../kernel/version.ts";
3 import { releaseDir } from "../../../release/checkout.ts";
4 import { canRollBackTo, mayRollBack, rollBackTo } from "../../../release/gate.ts";
5 import { releaseName, releasesByPrefix } from "../../../release/record.ts";
6 import { bad, good, type Tool } from "../shared.ts";
7
8 export const tool: Tool = {
9 name: "rollback",
10 doc: "`{ sha, dryRun? }`: roll the forge back to a kept release, as /releases does; plans unless `dryRun` is false (from which release to which, and whether the live schema allows it); a person who may roll back, with an admin-scope token, never an agent",
11 description: "Plan, then apply, a rollback to a kept release (admin).",
12 inputSchema: {
13 type: "object",
14 properties: { sha: { type: "string", maxLength: 40 }, dryRun: { type: "boolean" } },
15 required: ["sha"],
16 },
17 writes: true,
18 needsAdminScope: true,
19 async run(ctx, user, args) {
20 if (!mayRollBack(ctx.app, user))
21 return bad("forbidden", "a rollback is for a person who may write the forge's repository and administer it, not an agent");
22 const given = args["sha"];
23 if (typeof given !== "string" || !/^[0-9a-f]{7,40}$/.test(given.toLowerCase()))
24 return bad("invalid", "sha is a kept release's commit, 7 to 40 hex digits");
25 const prefix = given.toLowerCase();
26 const matches = releasesByPrefix(ctx.app.db, prefix);
outdated

.find on a prefix takes the first of possibly several matches, so dryRun:false with an ambiguous 7-character sha rolls the forge to an arbitrary release. Gather all matches and refuse when there is more than one. Also drop the 1000-row cap, for example with a SQL prefix lookup.

27 if (matches.length > 1) return bad("invalid", "that sha names more than one release; give more digits");
28 const found = matches[0];
29 if (found === undefined || !existsSync(releaseDir(ctx.app.config.dataDir, found.sha)))
30 return bad("not_found", "no kept release has that sha");
outdated

No test reaches this branch. Add call(admin, { sha: LIVE }) and expect that release is the one running.

31 const live = running().commit;
32 if (live !== null && found.sha === live) return bad("invalid", "that release is the one running");
33 if (!(await canRollBackTo(ctx.app, found)))
34 return bad("refused", `${releaseName(found)} cannot be rolled back to: it failed its preflight, or predates the live schema and would need a down-migration`);
35 const plan = { from: live === null ? null : live.slice(0, 12), to: releaseName(found), allowed: true };
36 if (args["dryRun"] !== false)
37 return good({ dryRun: true, ...plan, says: `Would roll back from ${plan.from ?? "this release"} to ${plan.to}; pass dryRun false to do it.` });
38 const done = await rollBackTo(ctx.app, user, found.sha);
39 if (!done.ok) return bad("failed", done.line);
40 return good({ dryRun: false, ...plan, says: `Rolled back to ${plan.to}; it reads healthy.` });
41 },
42 };
src/release/record.ts +11
@@ -53,6 +53,17 @@ export function listReleases(db: Database, limit = 20): Release[] {
5353 .map(toRelease);
5454 }
5555
56 /** Every kept release whose sha starts with these hex digits, newest first, one per sha. */
57 export function releasesByPrefix(db: Database, hex: string): Release[] {
58 if (!/^[0-9a-f]{1,40}$/.test(hex)) return [];
59 const seen = new Set<string>();
60 return db
61 .query<Row, [string]>(`SELECT * FROM release WHERE sha LIKE ?1 || '%' ORDER BY id DESC`)
62 .all(hex)
63 .map(toRelease)
64 .filter((r) => !seen.has(r.sha) && seen.add(r.sha));
65 }
66
5667 /** The newest healthy release a pull landed in; undefined while it is in none. */
5768 export function releaseOfPull(db: Database, repoId: string, pullNumber: number): Release | undefined {
5869 const row = db
test/mcp-rollback.test.ts added +136
@@ -0,0 +1,136 @@
1 /** `rollback` over MCP: planned first, an admin's with an admin-scope token; applying stops where the forge does not swap itself. */
2 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
3 import { mkdirSync, writeFileSync } from "node:fs";
4 import { mkdtemp, rm } from "node:fs/promises";
5 import { tmpdir } from "node:os";
6 import { join } from "node:path";
7 import { createApp, type App } from "../src/app.ts";
8 import { createRepo } from "../src/actions/repos.ts";
9 import { createToken } from "../src/core/tokens.ts";
10 import { createUser } from "../src/core/users.ts";
11 import { serve, type Servers } from "../src/http/server.ts";
12 import { releaseDir } from "../src/release/checkout.ts";
13 import { recordRelease } from "../src/release/record.ts";
14
15 const LIVE = "a".repeat(40);
16 const KEPT = "b".repeat(40);
17 const OLD = "c".repeat(40);
18 const GONE = "d".repeat(40);
19 const TWIN1 = "1234567" + "0".repeat(33);
20 const TWIN2 = "1234567" + "f".repeat(33);
21
22 describe("MCP rollback", () => {
23 let dataDir: string;
24 let app: App;
25 let servers: Servers;
26 let admin: string;
27 let rw: string;
28 let bob: string;
29 let robot: string;
30 let n = 1;
31 const saved = process.env["FORGE_COMMIT"];
32
33 const call = async (token: string, args: Record<string, unknown>) => {
34 const res = await fetch(`${servers.url}/mcp`, {
35 method: "POST",
36 headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` },
37 body: JSON.stringify({ jsonrpc: "2.0", id: n++, method: "tools/call", params: { name: "rollback", arguments: args } }),
38 });
39 const body: any = await res.json();
40 if (body.error !== undefined) return { error: String(body.error.message) };
41 const text: string = body.result.content[0].text;
42 return body.result.isError === true ? { error: text } : { value: JSON.parse(text) };
43 };
44
45 const keep = (sha: string, migrations: number) => {
46 const dir = releaseDir(dataDir, sha);
47 mkdirSync(join(dir, "src", "core"), { recursive: true });
48 writeFileSync(join(dir, "src", "core", "schema.ts"), `export const migrations = ${JSON.stringify(Array.from({ length: migrations }, () => "SELECT 1"))};\n`);
49 };
50
51 beforeAll(async () => {
52 process.env["FORGE_COMMIT"] = LIVE;
53 dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-rollback-"));
54 app = createApp({ dataDir, port: 0, forgeRepo: "alice/forge" });
55 servers = await serve(app);
56 const mk = async (username: string, first = false) => {
57 const u = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst: first });
58 if (!u.ok) throw new Error(u.error);
59 return u.value;
60 };
61 const alice = await mk("alice", true);
62 const b = await mk("bob");
63 admin = createToken(app.db, alice.id, "a", "admin").token;
64 rw = createToken(app.db, alice.id, "w", "rw").token;
65 bob = createToken(app.db, b.id, "b", "admin").token;
66 const r = await mk("robot");
67 app.db.query("UPDATE user SET is_agent = 1, is_admin = 1 WHERE id = ?").run(r.id);

The robot is an admin but not a writer on alice/forge. canWrite refuses it before the agent check matters, so the "agent refused" test would pass without user.kind === "agent". Give the robot write access to alice/forge, for example as a collaborator with the helper the other tests use. Then add a sanity check: the same user with is_agent = 0 gets a plan.

68 robot = createToken(app.db, r.id, "r", "admin").token;
69 const made = await createRepo(app, alice, { name: "forge", visibility: "public" });
70 if (!made.ok) throw new Error(String(made.error));
71 for (const sha of [OLD, KEPT, LIVE]) recordRelease(app.db, { sha, repoId: made.value.id, pullNumber: null, ok: true, line: "ok" });
72 recordRelease(app.db, { sha: GONE, repoId: made.value.id, pullNumber: null, ok: true, line: "ok" });
73 for (const sha of [TWIN1, TWIN2]) {
74 recordRelease(app.db, { sha, repoId: made.value.id, pullNumber: null, ok: true, line: "ok" });
75 keep(sha, 10_000);
76 }
77 keep(KEPT, 10_000);
78 keep(OLD, 1);
79 keep(LIVE, 10_000);
80 }, 30_000);
81
82 afterAll(async () => {
83 if (saved === undefined) delete process.env["FORGE_COMMIT"];
84 else process.env["FORGE_COMMIT"] = saved;
85 servers.stop();
86 app.close();
outdated

The issue says an agent is refused, and that is never tested here. Add an agent user (is_agent = 1) with an admin-scope token and write access to alice/forge, and expect forbidden. Also add an ambiguous-prefix case that expects invalid.

87 await rm(dataDir, { recursive: true, force: true });
88 });
89
90 test("the plan says from which to which and changes nothing", async () => {
91 const r = await call(admin, { sha: KEPT.slice(0, 12) });
92 expect(r.value).toMatchObject({ dryRun: true, from: "a".repeat(12), to: "b".repeat(12), allowed: true });
93 expect((await call(admin, { sha: KEPT, dryRun: true })).value.dryRun).toBe(true);
94 });
95
96 test("a non-admin and an rw token are refused", async () => {
97 expect((await call(bob, { sha: KEPT })).error).toContain("rollback");
98 expect((await call(rw, { sha: KEPT })).error).toBeDefined();
99 });
100
101 test("an unknown or not-kept sha is not_found", async () => {
102 expect((await call(admin, { sha: "e".repeat(40) })).error).toContain("no kept release");
103 expect((await call(admin, { sha: GONE })).error).toContain("no kept release");
104 });
105
106 test("an agent holding an admin-scope token is refused", async () => {
107 expect((await call(robot, { sha: KEPT })).error).toContain("not an agent");
108 expect((await call(robot, { sha: KEPT, dryRun: false })).error).toContain("not an agent");
109 // The robot is a site admin, so it can write alice/forge: only its kind refuses it.
110 app.db.query("UPDATE user SET is_agent = 0 WHERE username = 'robot'").run();
111 try {
112 expect((await call(robot, { sha: KEPT })).value).toMatchObject({ dryRun: true, to: "b".repeat(12) });
113 } finally {
114 app.db.query("UPDATE user SET is_agent = 1 WHERE username = 'robot'").run();
115 }
116 });
117
118 test("the running release is refused", async () => {
119 expect((await call(admin, { sha: LIVE })).error).toContain("the one running");
120 });
121
122 test("a prefix two releases share is invalid, and nothing is applied", async () => {
123 expect((await call(admin, { sha: "1234567", dryRun: false })).error).toContain("more than one release");
124 expect((await call(admin, { sha: "12345670" })).value.to).toBe(TWIN1.slice(0, 12));
125 });
126
127 test("a malformed sha is invalid, a schema-behind release is refused", async () => {
128 expect((await call(admin, { sha: "xyz" })).error).toContain("hex");
129 expect((await call(admin, { sha: OLD })).error).toContain("down-migration");
130 });
131
132 test("applying on a forge that does not swap itself says so", async () => {
133 const r = await call(admin, { sha: KEPT, dryRun: false });
134 expect(r.error).toContain("FORGE_SELF_SWAP");
135 });
136 });
test/mcp-write-scope.test.ts +1
@@ -35,6 +35,7 @@ const ADMIN_ARGS: Record<string, Record<string, unknown>> = {
3535 app_domains: { repo: "alice/game", action: "add", host: "game.example.com" },
3636 backups_destination: { action: "set", bucket: "forge-snaps", accessKey: "AKIA", secretKey: "s3cr3t-key-0123456789" },
3737 backups_test: { endpoint: "http://127.0.0.1:1", bucket: "b", accessKey: "AKIA", secretKey: "s3cr3t-key-0123456789" },
38 rollback: { sha: "a".repeat(40), dryRun: false },
3839 };
3940
4041 /** What `visible()` answers for a tool the token's scope does not open. */

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs