Public: anyone can read this; the crew can change it.
MCP `mirror_set`: set or forget the off-box mirror of snapshots, with the same rules as the Backups page #5457
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5442-mcp-mirror-set-set-or-forget-the-off-box → main 2 commits fork 6 threads · 6 unresolved
docs/MCP.md +1
| @@ -42,6 +42,7 @@ for a tunnel and a forge that sleeps. | ||
| 42 | 42 | | `read_file` | a file (utf-8, or base64 for binary) or a directory listing at a ref, as GET …/contents | no | |
| 43 | 43 | | `seal` | `{ repo, number, sealed }`: seal a security report (and the pull that fixes it) so only writers and the crew see it, or unseal it for every reader; a writer's, recorded | yes | |
| 44 | 44 | | `secrets` | `{ scope, ref?, action, name?, value? }` (forge admin): `list` the names and timestamps of the forge's sealed secrets, `set` or `remove` one; `scope` is forge, org or repo, `ref` the org or `owner/name`; a value is never read back | yes | |
| 45 | | `mirror_set` | `{ action, endpoint?, bucket?, region?, accessKey?, secretKey? }` (forge admin, admin-scope token): `set` seals the off-box snapshot mirror, `forget` clears it; the Backups page's rules and refusals, a blank field keeps what was sealed; answers `{ place, says }`, never a key | yes | | |
| 45 | 46 | | `org_teams` | `{ org, action, team, user?, permission? }` (admin-scope token, an org owner): `create` a team (`permission` read, write or admin; read by default), `add_member` a `user`, or `remove` the team; the same as POST/DELETE `/api/v1/orgs/:org/teams` | yes | |
| 46 | 47 | | `now` | what the crew is doing right now: who, on what, since when, at what cost; a planner that declined the direction and waits for you comes first (`run: null`, its words in `says`) | | |
| 47 | 48 | | `next_steps` | on a spawned forge, your first three steps and whether each is done; empty elsewhere or for anyone but its owner | | |
src/http/mcp.ts +45
| @@ -38,6 +38,8 @@ import { getOrg, listTeams, type Permission } from "../core/orgs.ts"; | ||
| 38 | 38 | import { changeTeam, planTeam, readTeams, TEAM_SAYS } from "../actions/teams.ts"; |
| 39 | 39 | import { readScorecards } from "../actions/scorecard.ts"; |
| 40 | 40 | import { createAgent, deleteAgent, updateAgent } from "../actions/crew.ts"; |
| 41 | import { forgetMirror, saveMirror } from "../actions/mirror.ts"; | |
| 42 | import { snapshotsOf } from "../snapshot/service.ts"; | |
| 41 | 43 | import { listSecrets, putSecret, removeSecret } from "../actions/secrets.ts"; |
| 42 | 44 | import { addAccount, allowWrite, auditAccount, removeAccount, renameAccount, reverify, rotateAccount } from "../actions/cloud.ts"; |
| 43 | 45 | import { usesByVendor, type VendorUse } from "../cloud/declared.ts"; |
| @@ -1337,6 +1339,49 @@ const TOOLS: readonly Tool[] = [ | ||
| 1337 | 1339 | return bad("invalid", "action is list, set or remove"); |
| 1338 | 1340 | }, |
| 1339 | 1341 | }, |
| 1342 | { | |
| 1343 | name: "mirror_set", | |
| 1344 | description: "Admin: set or forget the off-box snapshot mirror.", | |
| 1345 | inputSchema: { | |
| 1346 | type: "object", | |
| 1347 | properties: { | |
| 1348 | action: { type: "string" }, | |
| 1349 | endpoint: { type: "string" }, | |
| 1350 | bucket: { type: "string" }, | |
| 1351 | region: { type: "string" }, | |
| 1352 | accessKey: { type: "string" }, | |
| 1353 | secretKey: { type: "string" }, | |
| 1354 | }, | |
| 1355 | }, | |
| 1356 | writes: true, | |
| 1357 | adminOnly: true, | |
| 1358 | needsAdminScope: true, | |
| 1359 | run(ctx, user, args) { | |
| 1360 | const place = (): string | null => snapshotsOf(ctx.app)?.mirror?.where ?? null; | |
| 1361 | if (args["action"] === "forget") { | |
| 1362 | const gone = forgetMirror(ctx.app, user); | |
| 1363 | if (!gone.ok) return bad("not_found", "not found"); | |
| 1364 | return good({ place: place(), says: "The sealed mirror is cleared." }); | |
| 1365 | } | |
| 1366 | if (args["action"] !== "set") return bad("invalid", "action is set or forget"); | |
| 1367 | const field = (k: string): string | undefined => { | |
| 1368 | const v = args[k]; | |
| 1369 | return v === undefined ? "" : typeof v === "string" ? v.trim() : undefined; | |
| 1370 | }; | |
| 1371 | const input = { | |
| 1372 | endpoint: field("endpoint"), | |
| 1373 | bucket: field("bucket"), | |
| 1374 | region: field("region"), | |
| 1375 | accessKey: field("accessKey"), | |
| 1376 | secretKey: field("secretKey"), | |
| 1377 | }; | |
| 1378 | if (Object.values(input).some((v) => v === undefined)) | |
| 1379 | return bad("invalid", "the fields are strings"); | |
| 1380 | const saved = saveMirror(ctx.app, user, input as Record<keyof typeof input, string>); | |
| 1381 | if (!saved.ok) return bad(saved.error === "not_found" ? "not_found" : "invalid", saved.error); | |
| 1382 | return good({ place: place(), says: saved.value }); | |
| 1383 | }, | |
| 1384 | }, | |
| 1340 | 1385 | { |
| 1341 | 1386 | name: "issue_teams", |
| 1342 | 1387 | description: |
Right bar: same as |
test/mcp-mirror-set.test.ts added +137
| @@ -0,0 +1,137 @@ | ||
| 1 | /** The `mirror_set` tool is the Backups page's mirror form over MCP: admins only, and no answer carries a key. */ | |
| 2 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp, type App } from "../src/app.ts"; | |
| 7 | import { createToken } from "../src/core/tokens.ts"; | |
| 8 | import { createUser } from "../src/core/users.ts"; | |
| 9 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 10 | import { resolveMirror } from "../src/snapshot/mirror.ts"; | |
| 11 | import { startSnapshots, type SnapshotsHandle } from "../src/snapshot/service.ts"; | |
| 12 | import { dirStore } from "../src/snapshot/store.ts"; | |
| 13 | import { promote } from "./helpers/members.ts"; | |
| 14 | import { quietLogs } from "./helpers/globals.ts"; | |
| 15 | ||
| 16 | quietLogs(); | |
| 17 | ||
| 18 | describe("MCP mirror_set", () => { | |
| 19 | let dataDir: string; | |
| 20 | let app: App; | |
| 21 | let servers: Servers; | |
| 22 | let svc: SnapshotsHandle; | |
| 23 | let admin: string; | |
| 24 | let other: string; | |
| 25 | let adminRw: string; | |
| 26 | let cookie = ""; | |
| 27 | let id = 1; | |
| 28 | const SECRET = "s3cr3t-key-that-must-not-leak"; | |
| 29 | // The forge's own listener stands in for the bucket: it answers at once, and says no. | |
| 30 | let FIELDS: Record<string, string>; | |
| 31 | ||
| 32 | const tool = async (token: string, args: unknown) => { | |
| 33 | const res = await fetch(`${servers.url}/mcp`, { | |
| 34 | method: "POST", | |
| 35 | headers: { | |
| 36 | "content-type": "application/json", | |
| 37 | accept: "application/json, text/event-stream", | |
| 38 | authorization: `Bearer ${token}`, | |
| 39 | }, | |
| 40 | body: JSON.stringify({ | |
| 41 | jsonrpc: "2.0", | |
| 42 | id: id++, | |
| 43 | method: "tools/call", | |
| 44 | params: { name: "mirror_set", arguments: args }, | |
| 45 | }), | |
| 46 | }); | |
| 47 | const body = (await res.json()) as any; | |
| 48 | if (body.error) return { text: JSON.stringify(body.error), isError: true }; | |
| 49 | return { text: body.result.content[0].text as string, isError: body.result.isError === true }; | |
| 50 | }; | |
| 51 | const page = async () => | |
| 52 | (await fetch(`${servers.url}/settings/backups`, { headers: { cookie } })).text(); | |
| 53 | ||
| 54 | beforeAll(async () => { | |
| 55 | dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-mirror-")); | |
| 56 | app = createApp({ dataDir, port: 0 }); | |
| 57 | servers = await serve(app); | |
| 58 | FIELDS = { endpoint: servers.url, bucket: "offbox-copy", accessKey: "AKIAEXAMPLE", secretKey: SECRET }; | |
| 59 | svc = startSnapshots(app, dirStore(join(dataDir, "store")), { | |
| 60 | version: "t", | |
| 61 | nightly: false, | |
| 62 | mirror: () => resolveMirror({ db: app.db, dataDir, bus: app.bus, env: {} })?.store, | |
| 63 | }); | |
| 64 | const a = await createUser(app.db, { origin: "seed", trusted: true, | |
| 65 | username: "alice", | |
| 66 | email: "[email protected]", | |
| 67 | password: "a-good-passphrase", | |
| 68 | adminIfFirst: true, | |
| 69 | }); | |
| 70 | if (!a.ok) throw new Error(a.error); | |
| 71 | const b = await createUser(app.db, { origin: "seed", trusted: true, | |
| 72 | username: "bob", | |
| 73 | email: "[email protected]", | |
| 74 | password: "a-good-passphrase", | |
| 75 | }); | |
| 76 | if (!b.ok) throw new Error(b.error); | |
| 77 | promote(app.db, b.value); | |
| 78 | admin = createToken(app.db, a.value.id, "admin", "admin").token; | |
| 79 | adminRw = createToken(app.db, a.value.id, "rw", "rw").token; | |
| 80 | other = createToken(app.db, b.value.id, "rw", "rw").token; | |
| 81 | const login = await fetch(`${servers.url}/signin`, { | |
| 82 | method: "POST", | |
| 83 | headers: { origin: servers.url }, | |
| 84 | body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }), | |
| 85 | redirect: "manual", | |
| 86 | }); | |
| 87 | cookie = (login.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 88 | }, 30_000); | |
| 89 | ||
| 90 | afterAll(async () => { | |
| 91 | void svc.stop(); // a pass against the stand-in may still be in flight; the process ends it | |
| 92 | servers.stop(); | |
| 93 | app.close(); | |
| 94 | await rm(dataDir, { recursive: true, force: true }); | |
| 95 | }); | |
| 96 | ||
| 97 | test("an admin sets a mirror and the page shows it; forget clears it", async () => { | |
| 98 | const set = await tool(admin, { action: "set", ...FIELDS }); | |
| 99 | expect(set.isError).toBe(false); | |
| 100 | const said = JSON.parse(set.text); | |
| 101 | const place = `${servers.url.replace(/^https?:\/\//, "")}/offbox-copy`; | |
| 102 | expect(said.place).toBe(place); | |
| 103 | expect(said.says).toContain("sealed"); | |
| 104 | expect(set.text).not.toContain(SECRET); | |
| 105 | const shown = await page(); | |
| 106 | expect(shown).toContain(place); | |
| 107 | expect(shown).not.toContain(SECRET); | |
| 108 | ||
| 109 | const gone = await tool(admin, { action: "forget" }); | |
| 110 | expect(gone.isError).toBe(false); | |
| 111 | expect(JSON.parse(gone.text).place).toBeNull(); | |
| 112 | expect(await page()).toContain("No off-box mirror"); | |
| 113 | }); | |
| 114 | ||
| 115 | test("a bad field is refused with the action's own sentence, never echoing the key", async () => { | |
| 116 | const bad = await tool(admin, { action: "set", ...FIELDS, bucket: "X" }); | |
| 117 | expect(bad.isError).toBe(true); | |
| 118 | expect(bad.text).toContain("bucket name"); | |
| 119 | expect(bad.text).not.toContain(SECRET); | |
| 120 | expect((await tool(admin, { action: "peek" })).isError).toBe(true); | |
| 121 | }); | |
outdated
Add a case with alice's |
||
| 122 | ||
| 123 | test("an admin's rw token is refused: sealing the mirror needs an admin-scope token", async () => { | |
| 124 | const set = await tool(adminRw, { action: "set", ...FIELDS }); | |
| 125 | expect(set.isError).toBe(true); | |
| 126 | expect(set.text).not.toContain(SECRET); | |
| 127 | expect(svc.mirror).toBeUndefined(); | |
| 128 | expect((await tool(adminRw, { action: "forget" })).isError).toBe(true); | |
| 129 | }); | |
| 130 | ||
| 131 | test("a non-admin is refused", async () => { | |
Non-blocking: this case and the |
||
| 132 | const set = await tool(other, { action: "set", ...FIELDS }); | |
| 133 | expect(set.isError).toBe(true); | |
| 134 | expect(set.text).not.toContain(SECRET); | |
| 135 | expect(svc.mirror).toBeUndefined(); | |
| 136 | }); | |
| 137 | }); |
High: add
needsAdminScope: truehere. This writes forge-level sealed secrets, andsecretsrequires an admin-scope token for exactly that write. As written, an admin'srwtoken can redirect the snapshot mirror, and with it all forge data, to any bucket.