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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7213-mcp-rollback-a-forge-admin-plans-then-ro → main 4 commits fork 4 threads · 4 unresolved
docs/MCP.md +1
| @@ -138,6 +138,7 @@ for a tunnel and a forge that sleeps. | ||
| 138 | 138 | | `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 | |
| 139 | 139 | | `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 | | |
| 140 | 140 | | `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 | | |
| 141 | 142 | | `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 | | |
| 142 | 143 | | `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 | | |
| 143 | 144 | | `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"; | ||
| 111 | 111 | export { tool as restore_drill } from "./restore_drill.ts"; |
| 112 | 112 | export { tool as returns } from "./returns.ts"; |
| 113 | 113 | export { tool as review } from "./review.ts"; |
| 114 | export { tool as rollback } from "./rollback.ts"; | |
| 114 | 115 | export { tool as run_log } from "./run_log.ts"; |
| 115 | 116 | export { tool as runs } from "./runs.ts"; |
| 116 | 117 | 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); | |
| 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 |
||
| 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[] { | ||
| 53 | 53 | .map(toRelease); |
| 54 | 54 | } |
| 55 | 55 | |
| 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 | ||
| 56 | 67 | /** The newest healthy release a pull landed in; undefined while it is in none. */ |
| 57 | 68 | export function releaseOfPull(db: Database, repoId: string, pullNumber: number): Release | undefined { |
| 58 | 69 | 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. |
||
| 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 ( |
||
| 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>> = { | ||
| 35 | 35 | app_domains: { repo: "alice/game", action: "add", host: "game.example.com" }, |
| 36 | 36 | backups_destination: { action: "set", bucket: "forge-snaps", accessKey: "AKIA", secretKey: "s3cr3t-key-0123456789" }, |
| 37 | 37 | 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 }, | |
| 38 | 39 | }; |
| 39 | 40 | |
| 40 | 41 | /** What `visible()` answers for a tool the token's scope does not open. */ |
.findon a prefix takes the first of possibly several matches, sodryRun:falsewith 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.