Public: anyone can read this; the crew can change it.
The terminal's resilience screen seals or forgets the off-box snapshot mirror, never showing a key, as MCP `mirror_set` does #7281
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7276-the-terminal-s-resilience-screen-seals-o → main 2 commits fork 5 threads · 5 unresolved
README.md +1
| @@ -376,6 +376,7 @@ This page is the front door. Everything else is a file of its own under | ||
| 376 | 376 | - The flow panel on `/boards` also says the time to land (median and slowest of the last 24 h, `src/flow/lead_time.ts`) and who opened the last hour's pulls (names linked, `src/flow/openers.ts`); non-admins read public repositories alone. The terminal Flow dialog carries the same two facts in its own words. |
| 377 | 377 | - The flow panel's Hot files card and the terminal Flow dialog also name files that gained open pulls over the last hour ("warming: src/a.ts 2 → 4 pulls", at most 5; `src/flow/hot_trend.ts`). The hour-old counts are samples kept in memory by `src/crew/hot_history.ts`, so after a restart nothing is named until a half-hour-old sample exists; an agent sees public repositories only. The MCP `flow` tool does not carry it yet. |
| 378 | 378 | - The flow panel on `/boards` and the terminal Flow dialog say how the open crew pulls sort into piles (red, conflicted, changes asked, approved and waiting, awaiting review), each pull counted once in the first pile it fits (`src/flow/pile.ts`). |
| 379 | - The off-box snapshot mirror is set from three doors with one set of rules (`src/actions/mirror.ts`): Settings → Backups, MCP `mirror_set` (`set` or `forget`), and the terminal's Resilience screen (a forge admin who is a person; an admin agent only reads). The screen shows `Mirror: <place>` or `Mirror: none.`, `M` seals it with five prompts (endpoint, bucket, region, access key, secret key; the keys are masked as typed) where a blank keeps what was sealed, and `F` forgets it after a confirm. A key is never read back or drawn. | |
| 379 | 380 | - [](docs/PLANNING.md) — how issues depend on issues: the edge, the map, and what "ready now" means to a person and to the crew. |
| 380 | 381 | - [](docs/FLEET.md) — one change across many repositories, runs or issues: a query, a dry-run plan of every row before → after, a receipt, and undo. |
| 381 | 382 | - [](docs/HOME-EVAL.md) — scoring the owner's home with a reader who has no context. |
src/tui/forge.ts +2 −2
| @@ -63,7 +63,7 @@ import { quotasScreen } from "./screens/quotas.ts"; | ||
| 63 | 63 | import { teamsScreen } from "./screens/teams.ts"; |
| 64 | 64 | import { domainsScreen } from "./screens/domains.ts"; |
| 65 | 65 | import { ownerScreen } from "./screens/owner.ts"; |
| 66 | import { resilienceLines, resilienceScreen } from "./screens/resilience.ts"; | |
| 66 | import { resilienceScreen } from "./screens/resilience.ts"; | |
| 67 | 67 | import { forgeCeiling } from "../flow/ceiling.ts"; |
| 68 | 68 | import { flowLines, flowScreen } from "./screens/flow.ts"; |
| 69 | 69 | import { readHeld } from "../flow/held.ts"; |
| @@ -344,7 +344,7 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 344 | 344 | id: "resilience", |
| 345 | 345 | title: "Resilience", |
| 346 | 346 | hint: "each protection green, red or untested", |
| 347 | run: () => ctx.push(resilienceScreen(ctx, resilienceLines(session.app.config.dataDir))), | |
| 347 | run: () => ctx.push(resilienceScreen(session, ctx)), | |
| 348 | 348 | }); |
| 349 | 349 | // The last releases with their pulls, as /changelog shows them: for anyone. |
| 350 | 350 | commands.push({ |
src/tui/screens/resilience.ts +79 −5
| @@ -1,9 +1,18 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * The Resilience command's dialog, a forge admin's: one line per THREATS.md |
| 3 | 3 | * protection with its standing in the last gate, as /resilience says it. |
| 4 | * Read-only; Enter, Esc or q closes it. | |
| 4 | * Enter, Esc or q closes it. A forge admin who is a person also sees where the | |
| 5 | * off-box snapshot mirror is, `M` seals it (five prompts, the secret masked, | |
| 6 | * a blank keeps what was sealed) and `F` forgets it after a confirm; the | |
| 7 | * rules are `actions/mirror.ts`, the Backups page's and MCP `mirror_set`'s. | |
| 8 | * No key is ever drawn or read back. | |
| 5 | 9 | */ |
| 6 | 10 | |
| 11 | import { forgetMirror, saveMirror } from "../../actions/mirror.ts"; | |
| 12 | import type { DestinationInput } from "../../actions/destination.ts"; | |
| 13 | import { snapshotsOf } from "../../snapshot/service.ts"; | |
| 14 | import type { Session } from "../session.ts"; | |
| 15 | import { confirmScreen, later, promptScreen } from "./common.ts"; | |
| 7 | 16 | import { readResilience } from "../../checks/resilience.ts"; |
| 8 | 17 | import type { Ctx, Screen } from "../app/app.ts"; |
| 9 | 18 | import { binding } from "../app/keys.ts"; |
| @@ -18,14 +27,39 @@ export function resilienceLines(dataDir: string): string[] { | ||
| 18 | 27 | return rows.flatMap(({ p, s }) => wrap(`${pad(s, 11)} ${p.text}`, inner)); |
| 19 | 28 | } |
| 20 | 29 | |
| 21 | export function resilienceScreen(ctx: Ctx, lines: readonly string[]): Screen { | |
| 22 | return { | |
| 30 | /** Where the mirror is, or that there is none; never a key. */ | |
| 31 | export function mirrorLine(f: Session): string { | |
| 32 | const where = snapshotsOf(f.app)?.mirror?.where; | |
| 33 | return where === undefined ? "Mirror: none." : `Mirror: ${where}`; | |
| 34 | } | |
| 35 | ||
| 36 | const FIELDS = [ | |
| 37 | ["endpoint", "endpoint (https://…)", false], | |
| 38 | ["bucket", "bucket", false], | |
| 39 | ["region", "region", false], | |
| 40 | ["accessKey", "access key", true], | |
| 41 | ["secretKey", "secret key", true], | |
| 42 | ] as const; | |
| 43 | ||
| 44 | export function resilienceScreen(f: Session, ctx: Ctx): Screen { | |
| 45 | const mayMirror = f.user?.isAdmin === true && f.user.kind === "user"; | |
| 46 | let note = ""; | |
| 47 | const screen: Screen = { | |
| 23 | 48 | title: "Resilience", |
| 24 | keys: [binding(["enter", "esc", "q"], "close")], | |
| 49 | keys: [ | |
| 50 | ...(mayMirror ? [binding(["m"], "set the mirror"), binding(["f"], "forget the mirror")] : []), | |
| 51 | binding(["enter", "esc", "q"], "close"), | |
| 52 | ], | |
| 25 | 53 | render(area, s) { |
| 26 | 54 | const buttons = dialog(s, area, ctx.theme.c, { |
| 27 | 55 | title: "Resilience", |
| 28 | body: lines, | |
| 56 | body: [ | |
| 57 | ...wrap(mirrorLine(f), WIDTH - 6), | |
| 58 | ...(mayMirror ? ["M sets the mirror, F forgets it."] : []), | |
| 59 | ...(note === "" ? [] : wrap(note, WIDTH - 6)), | |
| 60 | "", | |
| 61 | ...resilienceLines(f.app.config.dataDir), | |
| 62 | ], | |
| 29 | 63 | actions: [{ label: "Close" }], |
| 30 | 64 | selected: 0, |
| 31 | 65 | width: WIDTH, |
| @@ -33,8 +67,48 @@ export function resilienceScreen(ctx: Ctx, lines: readonly string[]): Screen { | ||
| 33 | 67 | buttons.forEach((rect) => ctx.hit(rect, () => ctx.pop())); |
| 34 | 68 | }, |
| 35 | 69 | key(e) { |
| 70 | if (e.ctrl || e.alt) return true; | |
| 36 | 71 | if (e.key === "enter" || e.key === "esc" || e.key === "q") ctx.pop(); |
| 72 | else if (mayMirror && e.key === "m") ctx.push(ask(0, {})); | |
| 73 | else if (mayMirror && e.key === "f") | |
| 74 | ctx.push( | |
| 75 | confirmScreen(ctx, screen, { | |
| 76 | title: "Forget the mirror?", | |
| 77 | body: ["The sealed mirror settings are cleared;", "snapshots stay only on this machine."], | |
| 78 | actions: [{ label: "Cancel", variant: "ghost" }, { label: "Forget", variant: "danger" }], | |
| 79 | onChoose: (i) => { | |
| 80 | if (i !== 1) return; | |
| 81 | note = forgetMirror(f.app, f.user).ok ? "The sealed mirror is cleared." : "Not found."; | |
| 82 | }, | |
| 83 | }), | |
| 84 | ); | |
| 37 | 85 | return true; |
| 38 | 86 | }, |
| 39 | 87 | }; |
| 88 | // One field at a time; the last submit seals, and a refusal keeps that prompt open. | |
| 89 | const ask = (n: number, got: Record<string, string>): Screen => { | |
| 90 | const [name, label, mask] = FIELDS[n]!; | |
| 91 | return promptScreen(ctx, screen, { | |
| 92 | title: `Mirror ${n + 1}/${FIELDS.length}`, | |
| 93 | label, | |
| 94 | hint: "blank keeps what was sealed", | |
| 95 | mask, | |
| 96 | onSubmit: (value) => { | |
| 97 | const next = { ...got, [name]: value }; | |
| 98 | if (n + 1 < FIELDS.length) return later(ctx, () => ask(n + 1, next)); | |
outdated
Low: |
||
| 99 | const input: DestinationInput = { | |
| 100 | endpoint: next["endpoint"] ?? "", | |
| 101 | bucket: next["bucket"] ?? "", | |
| 102 | region: next["region"] ?? "", | |
| 103 | accessKey: next["accessKey"] ?? "", | |
| 104 | secretKey: next["secretKey"] ?? "", | |
| 105 | }; | |
| 106 | const saved = saveMirror(f.app, f.user, input); | |
| 107 | if (!saved.ok) return saved.error === "not_found" ? "Not found." : saved.error; | |
| 108 | note = saved.value; | |
| 109 | return undefined; | |
| 110 | }, | |
| 111 | }); | |
| 112 | }; | |
| 113 | return screen; | |
| 40 | 114 | } |
test/tui-mirror-set.test.ts added +175
| @@ -0,0 +1,175 @@ | ||
| 1 | /** The terminal's resilience screen seals and forgets the off-box mirror, as MCP `mirror_set` does, and never draws 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 as Forge } from "../src/app.ts"; | |
| 7 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 8 | import { mirrorSealedSet } from "../src/snapshot/mirror.ts"; | |
| 9 | import { startSnapshots, type SnapshotsHandle } from "../src/snapshot/service.ts"; | |
| 10 | import { dirStore } from "../src/snapshot/store.ts"; | |
| 11 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 12 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 13 | import type { TuiIdentity } from "../src/tui/session.ts"; | |
| 14 | import { quietLogs } from "./helpers/globals.ts"; | |
| 15 | import { settleApp, settleUntil } from "./helpers/tui.ts"; | |
| 16 | ||
| 17 | quietLogs(); | |
| 18 | ||
| 19 | describe("mirror in the terminal", () => { | |
| 20 | let dataDir: string; | |
| 21 | let forge: Forge; | |
| 22 | let svc: SnapshotsHandle; | |
| 23 | const ACCESS = "AKIAEXAMPLEKEY"; | |
| 24 | const SECRET = "s3cr3t-key-that-must-not-leak"; | |
| 25 | const frames: string[] = []; | |
| 26 | ||
| 27 | function boot(username: string) { | |
| 28 | const terminal = headlessTerminal(140, 32); | |
| 29 | const user = getUserByName(forge.db, username)!; | |
| 30 | const identity: TuiIdentity = { sshUsername: "t", remoteAddress: "127.0.0.1", user }; | |
| 31 | const { app } = createForgeApp(forge, identity, { terminal, exitHooks: false }); | |
| 32 | app.start(); | |
| 33 | const key = (k: string) => app.dispatch({ type: "key", key: k, ctrl: false, alt: false, shift: false }); | |
| 34 | const snap = () => frames.push(app.screen.toText()); | |
| 35 | return { | |
| 36 | text: () => app.screen.toText(), | |
| 37 | press: async (...keys: string[]) => { | |
| 38 | keys.forEach(key); | |
| 39 | await settleApp(app); | |
| 40 | snap(); | |
| 41 | }, | |
| 42 | ctrl: async (k: string) => { | |
| 43 | app.dispatch({ type: "key", key: k, ctrl: true, alt: false, shift: false }); | |
| 44 | await settleApp(app); | |
| 45 | }, | |
| 46 | type: async (t: string) => { | |
| 47 | for (const ch of t) key(ch); | |
| 48 | await settleApp(app); | |
| 49 | snap(); | |
| 50 | }, | |
| 51 | until: (what: string) => settleUntil(app, what, { timeoutMs: 4_000 }), | |
| 52 | stop: () => app.stop(), | |
| 53 | }; | |
| 54 | } | |
| 55 | ||
| 56 | const open = async (h: ReturnType<typeof boot>) => { | |
| 57 | await h.ctrl("k"); | |
| 58 | await h.type("Resilience"); | |
| 59 | await h.until("each protection"); | |
| 60 | await h.press("enter"); | |
| 61 | await h.until("Mirror:"); | |
| 62 | }; | |
| 63 | const field = async (h: ReturnType<typeof boot>, value: string) => { | |
| 64 | await h.type(value); | |
| 65 | await h.press("enter"); | |
| 66 | }; | |
| 67 | ||
| 68 | beforeAll(async () => { | |
| 69 | dataDir = await mkdtemp(join(tmpdir(), "forge-tui-mirror-")); | |
| 70 | forge = createApp({ dataDir, port: 0 }); | |
| 71 | // A local directory stands in for the bucket: sealing starts a pass, and it never leaves the machine. | |
| 72 | const mirror = dirStore(join(dataDir, "mirror")); | |
| 73 | svc = startSnapshots(forge, dirStore(join(dataDir, "store")), { | |
| 74 | version: "t", | |
outdated
High: this resolves a real S3 store at |
||
| 75 | nightly: false, | |
| 76 | mirror: () => (mirrorSealedSet(forge.db).length >= 4 ? mirror : undefined), | |
| 77 | }); | |
| 78 | for (const [username, adminIfFirst] of [["alice", true], ["bob", false]] as const) { | |
| 79 | const made = await createUser(forge.db, { | |
| 80 | origin: "seed", | |
| 81 | trusted: true, | |
| 82 | username, | |
| 83 | email: `${username}@forge.local`, | |
| 84 | password: "a-good-passphrase", | |
| 85 | adminIfFirst, | |
| 86 | }); | |
| 87 | if (!made.ok) throw new Error(made.error); | |
| 88 | } | |
| 89 | }); | |
outdated
Await |
||
| 90 | afterAll(async () => { | |
| 91 | await svc.stop(); | |
| 92 | forge.close(); | |
| 93 | await rm(dataDir, { recursive: true, force: true }); | |
| 94 | }); | |
| 95 | ||
| 96 | test("seal, read back without a key, keep a sealed key on a blank field, forget", async () => { | |
| 97 | const h = boot("alice"); | |
| 98 | await open(h); | |
| 99 | expect(h.text()).toContain("Mirror: none."); | |
| 100 | await h.press("m"); | |
| 101 | await field(h, "http://s3.example.test"); | |
| 102 | await field(h, "offbox-copy"); | |
| 103 | await field(h, ""); | |
| 104 | await field(h, ACCESS); | |
| 105 | await h.type(SECRET); | |
| 106 | expect(h.text()).not.toContain(SECRET); | |
| 107 | await h.press("enter"); | |
| 108 | expect(mirrorSealedSet(forge.db).length).toBe(4); | |
| 109 | expect(h.text()).toContain("Mirror sealed"); | |
| 110 | expect(h.text()).toContain("mirror"); // the place: the local directory | |
| 111 | expect(h.text()).not.toContain("Mirror: none."); | |
| 112 | ||
| 113 | // Blank everywhere but the bucket: the sealed keys stay. | |
| 114 | await h.press("m"); | |
| 115 | await field(h, ""); | |
| 116 | await field(h, "offbox-other"); | |
| 117 | await field(h, ""); | |
| 118 | await field(h, ""); | |
| 119 | await field(h, ""); | |
| 120 | expect(mirrorSealedSet(forge.db).length).toBe(4); | |
| 121 | expect(h.text()).toContain("Mirror sealed"); | |
| 122 | ||
| 123 | // A refused field is named in the page's words, and nothing is sealed over it. | |
| 124 | await h.press("m"); | |
| 125 | await field(h, "ftp://nope"); | |
| 126 | await field(h, ""); | |
| 127 | await field(h, ""); | |
| 128 | await field(h, ""); | |
| 129 | await field(h, ""); | |
| 130 | expect(h.text()).toContain("The endpoint must be an http(s) URL"); | |
| 131 | await h.press("esc"); | |
| 132 | ||
| 133 | await h.press("f"); | |
| 134 | await h.press("enter"); // the default is Cancel | |
| 135 | expect(mirrorSealedSet(forge.db).length).toBe(4); | |
| 136 | await h.press("f"); | |
| 137 | await h.press("right", "enter"); | |
| 138 | expect(mirrorSealedSet(forge.db)).toEqual([]); | |
| 139 | expect(h.text()).toContain("Mirror: none."); | |
| 140 | h.stop(); | |
| 141 | ||
| 142 | for (const frame of frames) { | |
| 143 | expect(frame).not.toContain(SECRET); | |
| 144 | expect(frame).not.toContain(ACCESS); | |
| 145 | } | |
| 146 | }); | |
outdated
Medium: this passes whether or not the gate exists, because bob is never offered Resilience at all, so the screen never opens. Test the rule this pull adds: an admin whose |
||
| 147 | ||
| 148 | test("an admin who is not a person opens Resilience but is offered no M or F", async () => { | |
| 149 | const made = await createUser(forge.db, { | |
| 150 | origin: "seed", trusted: true, username: "robo", email: "[email protected]", password: "a-good-passphrase", | |
| 151 | }); | |
| 152 | if (!made.ok) throw new Error(made.error); | |
| 153 | forge.db.query("UPDATE user SET is_agent = 1, is_admin = 1 WHERE username = 'robo'").run(); | |
| 154 | expect(getUserByName(forge.db, "robo")).toMatchObject({ kind: "agent", isAdmin: true }); | |
| 155 | const before = mirrorSealedSet(forge.db); | |
| 156 | const h = boot("robo"); | |
| 157 | await open(h); | |
| 158 | expect(h.text()).not.toContain("M sets the mirror"); | |
| 159 | await h.press("m"); | |
| 160 | expect(h.text()).not.toContain("Mirror 1/5"); | |
| 161 | await h.press("f"); | |
| 162 | expect(h.text()).not.toContain("Forget the mirror?"); | |
| 163 | expect(mirrorSealedSet(forge.db)).toEqual(before); | |
| 164 | h.stop(); | |
| 165 | }); | |
| 166 | ||
| 167 | test("a person who is not an admin is not offered Resilience", async () => { | |
| 168 | const h = boot("bob"); | |
| 169 | await h.ctrl("k"); | |
| 170 | await h.type("Resilience"); | |
| 171 | await h.until("Resilience"); | |
| 172 | expect(h.text()).not.toContain("each protection"); | |
| 173 | h.stop(); | |
| 174 | }); | |
| 175 | }); |
This person-only gate is the only thing between an admin agent session and
saveMirror(which checksisAdminalone). That is fine, but it needs the test described on the bob case.