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

Merged opened this

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

Merged

scout/forge:scout/7276-the-terminal-s-resilience-screen-seals-o → main 2 commits fork 5 threads · 5 unresolved

  • Merged. scout/7276-the-terminal-s-resilience-screen-seals-o was merged into main as 64ac8a0.

Review

Sign in to review this change.
4 files changed +257 −7
README.md +1
@@ -376,6 +376,7 @@ This page is the front door. Everything else is a file of its own under
376376 - 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.
377377 - 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.
378378 - 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.
379380 - [`docs/PLANNING.md`](docs/PLANNING.md) — how issues depend on issues: the edge, the map, and what "ready now" means to a person and to the crew.
380381 - [`docs/FLEET.md`](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.
381382 - [`docs/HOME-EVAL.md`](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";
6363 import { teamsScreen } from "./screens/teams.ts";
6464 import { domainsScreen } from "./screens/domains.ts";
6565 import { ownerScreen } from "./screens/owner.ts";
66 import { resilienceLines, resilienceScreen } from "./screens/resilience.ts";
66 import { resilienceScreen } from "./screens/resilience.ts";
6767 import { forgeCeiling } from "../flow/ceiling.ts";
6868 import { flowLines, flowScreen } from "./screens/flow.ts";
6969 import { readHeld } from "../flow/held.ts";
@@ -344,7 +344,7 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
344344 id: "resilience",
345345 title: "Resilience",
346346 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)),
348348 });
349349 // The last releases with their pulls, as /changelog shows them: for anyone.
350350 commands.push({
src/tui/screens/resilience.ts +79 −5
@@ -1,9 +1,18 @@
11 /**
22 * The Resilience command's dialog, a forge admin's: one line per THREATS.md
33 * 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.
59 */
610
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";
716 import { readResilience } from "../../checks/resilience.ts";
817 import type { Ctx, Screen } from "../app/app.ts";
918 import { binding } from "../app/keys.ts";
@@ -18,14 +27,39 @@ export function resilienceLines(dataDir: string): string[] {
1827 return rows.flatMap(({ p, s }) => wrap(`${pad(s, 11)} ${p.text}`, inner));
1928 }
2029
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 {
outdated

This person-only gate is the only thing between an admin agent session and saveMirror (which checks isAdmin alone). That is fine, but it needs the test described on the bob case.

45 const mayMirror = f.user?.isAdmin === true && f.user.kind === "user";
46 let note = "";
47 const screen: Screen = {
2348 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 ],
2553 render(area, s) {
2654 const buttons = dialog(s, area, ctx.theme.c, {
2755 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 ],
2963 actions: [{ label: "Close" }],
3064 selected: 0,
3165 width: WIDTH,
@@ -33,8 +67,48 @@ export function resilienceScreen(ctx: Ctx, lines: readonly string[]): Screen {
3367 buttons.forEach((rect) => ctx.hit(rect, () => ctx.pop()));
3468 },
3569 key(e) {
70 if (e.ctrl || e.alt) return true;
3671 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 );
3785 return true;
3886 },
3987 };
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: as unknown as gives up the type check. Build the input from the five fields explicitly ({ endpoint: next.endpoint ?? "", … } typed as DestinationInput), so a renamed field is a compile error rather than a silent undefined.

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;
40114 }
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 http://mirror.invalid, so each seal starts a network pass, and afterAll cannot await it. Use a local dirStore mirror once the four keys are sealed, and await svc.stop(), so the suite never touches the network and never leaves work in flight.

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 svc.stop() once the mirror is local. A fire-and-forget stop means the pass can outlive rm(dataDir) and write into a deleted directory.

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 kind is not user opens Resilience, m/f open nothing, there is no hint, and mirrorSealedSet is unchanged.

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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs