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

MCP `backups`: where snapshots go and the last ones taken, for a forge admin #5764

Merged opened this

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

Merged

scout/forge:scout/5448-mcp-backups-where-snapshots-go-and-the-l → main 13 commits fork 6 threads · 6 unresolved

  • Merged. scout/5448-mcp-backups-where-snapshots-go-and-the-l was merged into main as 53860d0.

Review

Sign in to review this change.
6 files changed +197 −4
docs/MCP.md +1
@@ -54,6 +54,7 @@ for a tunnel and a forge that sleeps.
5454 | `assigned` | `{ limit? }`: the open issues and pulls assigned to you in every repository you can still read (repository, number, title, kind, state), as `GET /api/v1/user/assigned` | |
5555 | `attach_look` | `{ app, pull, width, says, image, dryRun? }`: attach a look to an open pull: a PNG or JPEG (at most 5 MB, base64 in `image`), `width` phone or desktop, one line in `says`; planned first (`dryRun` is true unless `false`) | yes |
5656 | `audit` | who can do what, per app, read from its contract.json in git: yes, own or no per door, and whether outsiders may direct its crew (`crewOutsiders`) | |
57 | `backups` | where snapshots go (`destination`: `kind` sealed, dir or env, and `place`, never a credential; `null` with the Backups page's sentence when none), the last fourteen snapshots (`key`, `at`, `bytes`) and the off-box `mirror`'s state; a forge admin's, as /settings/backups | |
5758 | `backups_destination` | (admin scope) `{ action, endpoint?, bucket?, region?, accessKey?, secretKey? }`: `set` or `forget` where snapshots go, validated as the Backups page does; answers `{ kind, place, says }`, never a key; a person admin's, with an admin token | yes |
5859 | `backups_test` | (admin scope) `{ endpoint?, bucket?, region?, accessKey?, secretKey? }`: list the snapshot bucket as the Backups page's Test button does (blank fields test the destination in effect); a forge admin's, with an admin token; answers `{ reached, says }`, never a key | yes |
5960 | `board_delete` | `{ slug }` (person admins): delete a board by slug; its history keeps it | yes |
src/http/mcp/tools/backups.ts added +53
@@ -0,0 +1,53 @@
1 import { snapshotsOf } from "../../../snapshot/service.ts";
2 import { resolveDestination, scrubReason, sealedTarget } from "../../../snapshot/destination.ts";
3 import { mirrorNeed } from "../../../snapshot/mirror.ts";
4 import { s3FromEnv } from "../../../snapshot/store.ts";
5 import { listSnapshots } from "../../../snapshot/snapshot.ts";
6 import { good, bad, type Tool } from "../shared.ts";
7
8 export const tool: Tool = {
9 name: "backups",
10 doc: "where snapshots go (`destination`: `kind` sealed, dir or env, and `place`, never a credential; `null` with the Backups page's sentence when none), the last fourteen snapshots (`key`, `at`, `bytes`) and the off-box `mirror`'s state; a forge admin's, as /settings/backups",
outdated

Blocking, together with the size budget: this description and its entry are what push tools/list 154 bytes over 37,000. doc (in docs/MCP.md) already carries the detail, so description can be as short as "Admin: snapshot destination, recent, mirror." or shorter. Either way, the remaining bytes have to come from other tools.

11 description: "Admin: snapshot destination, recent, mirror.",
12 inputSchema: { type: "object", properties: {} },
13 writes: false,
14 adminOnly: true,
15 async run(ctx, user) {
16 if (!user.isAdmin) return bad("not_found", "not found");
17 const dest = resolveDestination({ db: ctx.app.db, dataDir: ctx.app.config.dataDir, bus: ctx.app.bus });
18 const svc = snapshotsOf(ctx.app);
19 const state = svc?.mirrored();
20 const need = mirrorNeed(state, Date.now());
21 const mirror = {
22 place: svc?.mirror?.where ?? null,
23 reachable: state?.reachable ?? null,
24 newest: state?.newest ?? null,
25 syncedAt: state?.syncedAt ?? null,
26 says: need?.sentence ?? null,
27 };
28 if (!dest)
29 return good({
30 destination: null,
31 says: "nothing — no snapshots are taken",
32 snapshots: [],
33 mirror,
34 });
35 let snapshots: { key: string; at: string; bytes: number }[] = [];
36 let listing: string | undefined;
37 try {
38 snapshots = (await listSnapshots(dest.store))
39 .slice(0, 14)
40 .map((m) => ({ key: m.name, at: m.finishedAt, bytes: m.bytes }));
outdated

Non-blocking: on the Backups page, key already means a snapshot's key state (keyOf → wrapped/none/raw). Here key is the snapshot's name. Calling it name (the manifest's own field) would avoid an agent reading it as the key state. Separately, String(error) from an S3 listing goes out uncapped. destination.ts caps its own with reason.slice(0, 200), and the same cap would do here.

41 } catch (error) {
outdated

Fine as is, because it matches the page. If S3 errors ever echo a presigned URL, this and the page should both trim it the way destination.ts does (reason.slice(0, 200)). That's not blocking.

outdated

Low, non-blocking. This returns the raw store error, the same way the Backups page does (backups.ts:325). If an S3 client error ever echoes a presigned URL or a long response body, both doors would show it to the admin in full. destination.ts:101-104 already has the safe shape: use error.message, then .slice(0, 200). A follow-up that applies it on the page and here would close this.

outdated

Non-blocking: String(error) returns the store's raw error to the agent. destination.ts:101-104 already strips the secret and caps the reason at 200 characters. Both this tool and the Backups page should share that helper, so an S3 error that echoes a signed URL never leaves the box. That belongs in a follow-up that covers both doors.

42 const sources = { db: ctx.app.db, dataDir: ctx.app.config.dataDir, bus: ctx.app.bus };
outdated

This only scrubs the sealed target's keys. When the destination is env (s3FromEnv), its access key and secret key are not removed from reason. Scrub the credentials of dest itself: share one helper with testTarget in destination.ts, which does exactly this split/join plus slice(0, 200).

43 const target = dest.source === "sealed" ? sealedTarget(sources) : dest.source === "env" ? s3FromEnv() : undefined;
44 listing = scrubReason(target, error);
45 }
outdated

Nothing tests this branch. Add a case in test/mcp-backups.test.ts that makes listSnapshots throw, then assert that listing is set, is at most 200 characters, and contains no credential.

46 return good({
47 destination: { kind: dest.source, place: dest.store.where },
48 snapshots,
49 ...(listing === undefined ? {} : { listing }),
50 mirror,
51 });
52 },
53 };
src/http/mcp/tools/index.ts +1
@@ -24,6 +24,7 @@ export { tool as assign } from "./assign.ts";
2424 export { tool as assigned } from "./assigned.ts";
2525 export { tool as attach_look } from "./attach_look.ts";
2626 export { tool as audit } from "./audit.ts";
27 export { tool as backups } from "./backups.ts";
2728 export { tool as backups_destination } from "./backups_destination.ts";
2829 export { tool as backups_test } from "./backups_test.ts";
2930 export { tool as board_delete } from "./board_delete.ts";
src/snapshot/destination.ts +9 −4
@@ -91,6 +91,14 @@ export function clearSealed(db: Database): void {
9191 for (const name of SEALED_NAMES) deleteSecret(db, "forge", "", name);
9292 }
9393
94 /** An error as one short sentence: the target's keys struck out, at most 200 characters. */
95 export function scrubReason(target: S3Target | undefined, error: unknown): string {
96 let reason = error instanceof Error ? error.message : String(error);
97 for (const secret of target ? [target.accessKeyId, target.secretAccessKey] : [])
98 if (secret) reason = reason.split(secret).join("…");
99 return reason.slice(0, 200);
100 }
101
94102 /** Does this target answer? One sentence either way, never a key in it. */
95103 export async function testTarget(target: S3Target): Promise<string> {
96104 const store = s3Store(target);
@@ -98,9 +106,6 @@ export async function testTarget(target: S3Target): Promise<string> {
98106 const keys = await store.list("");
99107 return `Reached ${store.where}: ${keys.length === 1 ? "1 object" : `${keys.length} objects`} listed.`;
100108 } catch (error) {
101 let reason = error instanceof Error ? error.message : String(error);
102 for (const secret of [target.accessKeyId, target.secretAccessKey])
103 reason = reason.split(secret).join("…");
104 return `Could not list ${store.where}: ${reason.slice(0, 200)}`;
109 return `Could not list ${store.where}: ${scrubReason(target, error)}`;
105110 }
106111 }
test/mcp-backups.test.ts added +132
@@ -0,0 +1,132 @@
1 /** MCP `backups`: the Backups page's destination, snapshots and mirror, for an admin. */
2
3 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
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 { createToken } from "../src/core/tokens.ts";
9 import { createUser } from "../src/core/users.ts";
10 import { serve, type Servers } from "../src/http/server.ts";
11 import { scrubReason } from "../src/snapshot/destination.ts";
12 import { dirStore } from "../src/snapshot/store.ts";
13 import { takeSnapshot } from "../src/snapshot/snapshot.ts";
14 import { quietLogs } from "./helpers/globals.ts";
15
16 quietLogs();
17
18 const PASS = "a-long-backups-passphrase";
19
20 describe("MCP backups", () => {
21 let dataDir = "";
22 let snapDir = "";
23 let app: App;
24 let servers: Servers;
25 let admin = "";
26 let plain = "";
27 const saved = process.env["FORGE_SNAPSHOT_DIR"];
28
29 const call = async (token: string) => {
30 const res = await fetch(`${servers.url}/mcp`, {
31 method: "POST",
32 headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` },
33 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "backups", arguments: {} } }),
34 });
35 const body = (await res.json()) as any;
36 if (body.error) return { error: true, text: body.error.message as string };
37 return { error: body.result.isError === true, text: body.result.content[0].text as string };
38 };
39
40 beforeAll(async () => {
41 dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-backups-"));
42 snapDir = await mkdtemp(join(tmpdir(), "forge-mcp-backups-snap-"));
43 delete process.env["FORGE_SNAPSHOT_DIR"];
44 app = createApp({ dataDir, port: 0 });
45 servers = await serve(app);
46 const made: Record<string, any> = {};
47 for (const [username, isAdmin] of [["alice", true], ["bob", false]] as const) {
48 made[username] = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@x.test`, password: PASS, isAdmin });
49 if (!made[username].ok) throw new Error(username);
50 }
51 admin = createToken(app.db, made["alice"].value.id, "t", "ro").token;
52 plain = createToken(app.db, made["bob"].value.id, "t", "ro").token;
53 }, 30_000);
54
55 afterAll(async () => {
56 if (saved === undefined) delete process.env["FORGE_SNAPSHOT_DIR"];
57 else process.env["FORGE_SNAPSHOT_DIR"] = saved;
58 servers.stop();
59 app.close();
60 await rm(dataDir, { recursive: true, force: true });
61 await rm(snapDir, { recursive: true, force: true });
62 });
63
64 test("an admin with no destination gets the page's sentence", async () => {
65 const r = await call(admin);
66 expect(r.error).toBe(false);
67 const out = JSON.parse(r.text);
68 expect(out.destination).toBeNull();
69 expect(out.says).toBe("nothing — no snapshots are taken");
70 expect(out.snapshots).toEqual([]);
71 });
72
73 test("an admin with a local destination and one snapshot sees it", async () => {
74 process.env["FORGE_SNAPSHOT_DIR"] = snapDir;
75 const m = await takeSnapshot({ db: app.db, dataDir: app.config.dataDir, reposRoot: app.reposRoot, version: "t" }, dirStore(snapDir));
76 const out = JSON.parse((await call(admin)).text);
77 expect(out.destination).toEqual({ kind: "dir", place: snapDir });
78 expect(out.snapshots).toHaveLength(1);
79 expect(out.snapshots[0].key).toBe(m.name);
80 expect(out.snapshots[0].bytes).toBe(m.bytes);
81 expect(out.mirror.place).toBeNull();
82 });
83
outdated

This test passes even without the tool, because an unknown tool also errors. Check that bob's tools/list does not include backups so the test actually exercises the adminOnly gate.

84 test("a listing that fails is a short sentence with no key in it", async () => {
85 const secret = "SECRETKEY-do-not-leak";
86 // An endpoint that is not a URL: the store throws at once and, as it is
87 // told, echoes what it was given.
88 const env = { S3_ENDPOINT: `not a url ${secret}`, S3_BUCKET: "b", S3_ACCESS_KEY: "ACCESSKEY-do-not-leak", S3_SECRET_KEY: secret };
89 delete process.env["FORGE_SNAPSHOT_DIR"];
90 Object.assign(process.env, env);
91 try {
92 const out = JSON.parse((await call(admin)).text);
93 expect(out.destination.kind).toBe("env");
94 expect(out.snapshots).toEqual([]);
95 expect(typeof out.listing).toBe("string");
96 expect(out.listing.length).toBeLessThanOrEqual(200);
97 expect(out.listing).not.toContain(secret);
98 expect(out.listing).not.toContain("ACCESSKEY-do-not-leak");
99 } finally {
100 for (const k of Object.keys(env)) delete process.env[k];
101 process.env["FORGE_SNAPSHOT_DIR"] = snapDir;
102 }
103 }, 20_000);
104
105 test("scrubReason strikes both keys and caps the length", () => {
106 const t = { endpoint: "e", bucket: "b", accessKeyId: "AKID", secretAccessKey: "SKEY" };
107 const out = scrubReason(t, new Error(`bad AKID and SKEY ${"x".repeat(300)}`));
108 expect(out).not.toContain("AKID");
109 expect(out).not.toContain("SKEY");
110 expect(out.length).toBe(200);
111 });
112
113 test("a non-admin is refused", async () => {
114 const r = await call(plain);
115 expect(r.error).toBe(true);
116 expect(r.text).not.toContain(snapDir);
117 expect(r.text).toContain("no tool named backups");
118 });
119
120 test("tools/list shows backups to an admin and hides it from a non-admin", async () => {
121 const names = async (token: string) => {
122 const res = await fetch(`${servers.url}/mcp`, {
123 method: "POST",
124 headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` },
125 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
126 });
127 return ((await res.json()) as any).result.tools.map((t: { name: string }) => t.name) as string[];
128 };
129 expect(await names(admin)).toContain("backups");
130 expect(await names(plain)).not.toContain("backups");
131 });
132 });
test/mcp.test.ts +1
@@ -310,6 +310,7 @@ describe("the forge over MCP", () => {
310310 "flags",
311311 "flow",
312312 "system",
313 "backups",
313314 "telemetry_history",
314315 "contribute",
315316 "limits",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs