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

The terminal's resilience screen sets or forgets where snapshots go, never showing a key, as MCP `backups_destination` does #7278

Merged opened this

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

Merged

torque/forge:torque/7269-the-terminal-s-resilience-screen-sets-or → main 6 commits fork 1 thread · 1 unresolved

  • Merged. torque/7269-the-terminal-s-resilience-screen-sets-or was merged into main as 6e48d8c.

Review

Sign in to review this change.
8 files changed +351 −81
README.md +1 −1
@@ -424,7 +424,7 @@ This page is the front door. Everything else is a file of its own under
424424 - 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.
425425 - Under each queued repository's merge-queue line, the terminal Flow dialog lists its last five gates, newest first: the pulls carried, `green`, `red` or `cut off`, how long ago, and for a red gate the first failing test with its file, `flaky` when two gates sharing no pull failed it (`src/checks/gate_result.ts`, as MCP `queue_gates` reads them); a repository with none kept says `no gates yet`, and an agent reader sees public repositories only.
426426 - 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`).
427 - 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.
427 - 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.
428428 - [`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.
429429 - [`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.
430430 - [`docs/HOME-EVAL.md`](docs/HOME-EVAL.md) — scoring the owner's home with a reader who has no context.
docs/CREW.md +1 −1
@@ -336,7 +336,7 @@ kill, which leave nothing in the log themselves).
336336 `docs/FLOW.md` is where every issue stands: `GET /api/v1/repos/:owner/:name/flow` returns each open issue's stage, since, stalled and unblock, plus what went live in the last 24 h.
337337 Where the whole forge waits is the owner's, on `/boards` (the old `/flow` redirects there, query kept; an agent granted `telemetry:read` reads the same from `/api/v1/telemetry/flow` and the MCP `flow` tool): the flow panel shows each stage from ready to live with what waits, the oldest wait and its capacity, under one sentence naming the bottleneck, and beside each what it let out in the last hour with a 24-hour sparkline, under a line of landings and the median ask→live — every count links to the items it counts. A stalled step is a card on the home (Studio mode) for anyone who can write to the repository: an open pull whose check was never asked for, or is red, past its stage's allowance, with its stage, how long it has been stuck, and one button — Run check or Re-run — that asks for a check at the head and returns to the home. The terminal's **Stalled steps** command (palette) lists the same steps, live, and Enter or `r` does the same re-run; both read `flow/stalled.ts` and act through `flow/unblock.ts`, and neither shows anything to someone who cannot write. A conflicted pull never sits: every tick the crew's open pulls that no longer merge get one bring-up-to-date run each (one in flight per pull), oldest pull first and only as many as the check lane has slots for, since each ends in a push and a check: check slots minus the checks queued and running minus the runs already in flight, with one always allowed (`crew/conflict_room.ts`, #4459); after 2 failed tries in the same conflict, or 24 h since the first, the pull is closed with the conflicting files named and its issue goes back to ready, unassigned, with no strike against the builder. Conflicted pulls are listed oldest first on `/boards`. The terminal's Flow command and the MCP `flow` tool read the same: the sentence, the landings line, and each stage's count out in the last hour and in 24 h.
338338
339 `/resilience` is a forge admin's too (a guest is sent to sign in, anyone else gets 404): each protection in `docs/THREATS.md` as one line, green, red, untested or "no test yet", judged by the test file it cites in the last full gate of the forge's own repository (`FORGE_REPO`; other repositories' gates never touch it). The terminal's **Resilience** command (palette, admins) lists the same lines.
339 `/resilience` is a forge admin's too (a guest is sent to sign in, anyone else gets 404): each protection in `docs/THREATS.md` as one line, green, red, untested or "no test yet", judged by the test file it cites in the last full gate of the forge's own repository (`FORGE_REPO`; other repositories' gates never touch it). The terminal's **Resilience** command (palette, admins) lists the same lines under where snapshots go now; for an admin who is a person, `d` seals a destination field by field (both keys typed masked, checked as the Backups page checks them) and `x` forgets it after a confirm; `m` and `f` do the same for the off-box mirror. No key is ever drawn.
340340
341341 The terminal also has an Access command (`:` → Access), a site admin's: it reads the same last-day non-browser requests as Settings → Access and the `access` MCP tool, newest first, and filters them with `s` (since), `u` (user agent) and `p` (path), `c` to clear.
342342
src/actions/destination.ts +6 −2
@@ -61,13 +61,17 @@ export function invalidField(i: DestinationInput): string | undefined {
6161
6262 const isAdmin = (actor: User | undefined): boolean => actor?.isAdmin === true;
6363
64 /** Who may set or forget where snapshots go, through every door — page, MCP, terminal: a site admin who is a person, never an agent. */
65 export const mayChangeDestination = (actor: Pick<User, "isAdmin" | "kind"> | null | undefined): boolean =>
66 actor?.isAdmin === true && actor.kind !== "agent";
67
6468 /** Seal what was given (an error is `not_found` or a sentence for the page); blank fields keep their sealed value. Says what is still missing. */
6569 export function saveDestination(
6670 app: App,
6771 actor: User | undefined,
6872 input: DestinationInput,
6973 ): Result<string, string> {
70 if (!isAdmin(actor)) return err("not_found");
74 if (!mayChangeDestination(actor)) return err("not_found");
7175 const bad = invalidField(input);
7276 if (bad) return err(bad);
7377 const fields: [string, string][] = [
@@ -103,7 +107,7 @@ export function forgetDestination(
103107 app: App,
104108 actor: User | undefined,
105109 ): Result<void, "not_found"> {
106 if (!isAdmin(actor)) return err("not_found");
110 if (!mayChangeDestination(actor)) return err("not_found");
107111 clearSealed(app.db);
108112 app.bus.append({
109113 kind: "secret.deleted",
src/tui/forge.ts +2 −2
@@ -376,12 +376,12 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
376376 },
377377 });
378378 }
379 // Each THREATS.md protection's standing, as /resilience says it: a site admin's.
379 // Where snapshots go and each THREATS.md protection's standing, as /resilience says it: a site admin's; a person admin sets or forgets the destination and the mirror here.
380380 if (session.user?.isAdmin === true)
381381 commands.push({
382382 id: "resilience",
383383 title: "Resilience",
384 hint: "each protection green, red or untested",
384 hint: "where snapshots go and the mirror; each protection green, red or untested",
385385 run: () => ctx.push(resilienceScreen(session, ctx)),
386386 });
387387 // The last releases with their pulls, as /changelog shows them: for anyone.
src/tui/screens/resilience.ts +139 −67
@@ -1,28 +1,35 @@
11 /**
2 * The Resilience command's dialog, a forge admin's: one line per THREATS.md
3 * protection with its standing in the last gate, as /resilience says it.
4 * Enter, Esc or q closes it.
2 * The Resilience command's dialog, a forge admin's: where snapshots go now and
3 * where the off-box mirror is, the newest snapshots, then one line per
4 * THREATS.md protection with its standing in the last gate, as /resilience
5 * says it. Enter, Esc or q closes it.
56 *
6 * Above the protections, the newest snapshots, newest first. A forge admin
7 * takes one now with `n` and brings the mirror level with `l`, each after a
8 * `y/n` asked on the dialog's first line, through `actions/backups_now.ts` —
9 * MCP `snapshot_now`'s and `mirror_now`'s actions — and the answer is said
10 * there in the page's words. While one runs, neither key is offered. The
11 * command is a forge admin's only (`tui/forge.ts`), and the actions refuse
12 * anyone else again.
7 * A forge admin takes a snapshot now with `n` and brings the mirror level with
8 * `l`, each after a `y/n` asked on the dialog's first line, through
9 * `actions/backups_now.ts` — MCP `snapshot_now`'s and `mirror_now`'s actions —
10 * and the answer is said there in the page's words. While one runs, neither
11 * key is offered.
1312 *
14 * A forge admin who is a person also sees where the off-box snapshot mirror
15 * is, `M` seals it (five prompts, the secret masked, a blank keeps what was
16 * sealed) and `F` forgets it after a confirm; the rules are
17 * `actions/mirror.ts`, the Backups page's and MCP `mirror_set`'s. No key is
18 * ever drawn or read back.
13 * For an admin who is a person, `d` sets the destination and `x` forgets it
14 * after a confirm, through `actions/destination.ts`; `m` seals the mirror and
15 * `f` forgets it, through `actions/mirror.ts` — the Backups page's and MCP's
16 * rules and words. Each asks five prompts (endpoint, bucket, region, access
17 * key, secret key — both keys typed masked; a blank keeps what was sealed).
18 * A key is never drawn, kept past the save, or put in a note.
1919 */
2020
2121 import { mirrorNow, recentSnapshots, snapshotNow } from "../../actions/backups_now.ts";
22 import { forgetMirror, saveMirror } from "../../actions/mirror.ts";
23 import type { DestinationInput } from "../../actions/destination.ts";
24 import { snapshotsOf } from "../../snapshot/service.ts";
2522 import { readResilience } from "../../checks/resilience.ts";
23 import {
24 forgetDestination,
25 invalidField,
26 mayChangeDestination,
27 saveDestination,
28 type DestinationInput,
29 } from "../../actions/destination.ts";
30 import { forgetMirror, saveMirror } from "../../actions/mirror.ts";
31 import { resolveDestination } from "../../snapshot/destination.ts";
32 import { snapshotsOf } from "../../snapshot/service.ts";
2633 import type { Ctx, Screen } from "../app/app.ts";
2734 import { binding } from "../app/keys.ts";
2835 import type { Session } from "../session.ts";
@@ -40,19 +47,42 @@ export function resilienceLines(dataDir: string): string[] {
4047 return rows.flatMap(({ p, s }) => wrap(`${pad(s, 11)} ${p.text}`, INNER));
4148 }
4249
50 /** The source of the destination in effect, in the Backups page's words. */
51 const SOURCE: Record<string, string> = {
52 sealed: "sealed in this forge",
53 dir: "the directory named by FORGE_SNAPSHOT_DIR",
54 env: "the host's S3_* environment variables",
55 };
56
57 /** Where snapshots go now — kind and place — or that they stay on this box. Never a key. */
58 export function destinationLine(f: Session): string {
59 const dest = resolveDestination({ db: f.app.db, dataDir: f.app.config.dataDir, bus: f.app.bus });
60 return dest === undefined
61 ? "Snapshots stay on this box: no destination is set."
62 : `Snapshots go to ${dest.store.where} (${SOURCE[dest.source] ?? dest.source}).`;
63 }
64
4365 /** Where the mirror is, or that there is none; never a key. */
4466 export function mirrorLine(f: Session): string {
4567 const where = snapshotsOf(f.app)?.mirror?.where;
4668 return where === undefined ? "Mirror: none." : `Mirror: ${where}`;
4769 }
4870
49 const FIELDS = [
50 ["endpoint", "endpoint (https://…)", false],
51 ["bucket", "bucket", false],
52 ["region", "region", false],
53 ["accessKey", "access key", true],
54 ["secretKey", "secret key", true],
55 ] as const;
71 const STEPS: readonly { key: keyof DestinationInput; label: string; placeholder?: string; mask?: true }[] = [
72 { key: "endpoint", label: "Endpoint, an http(s) URL", placeholder: "https://s3.example.com" },
73 { key: "bucket", label: "Bucket" },
74 { key: "region", label: "Region (optional)", placeholder: "us-east-1" },
75 { key: "accessKey", label: "Access key (sealed, never shown)", mask: true },
76 { key: "secretKey", label: "Secret key (sealed, never shown)", mask: true },
77 ];
78
79 const BLANK: DestinationInput = { endpoint: "", bucket: "", region: "", accessKey: "", secretKey: "" };
80
81 /** One of the two off-box places this screen sets: its prompt title and its save. */
82 type Target = {
83 readonly title: string;
84 readonly save: (input: DestinationInput) => string;
85 };
5686
5787 type Asking = "snapshot" | "mirror";
5888
@@ -62,7 +92,9 @@ const QUESTION: Record<Asking, string> = {
6292 };
6393
6494 export function resilienceScreen(f: Session, ctx: Ctx): Screen {
65 const mayMirror = f.user?.isAdmin === true && f.user.kind === "user";
95 // One rule for the destination and the mirror: a site admin who is a person.
96 const mayChange = (): boolean => mayChangeDestination(f.user);
97 const refusedAll = "Where snapshots go is a forge admin's.";
6698 /** The newest snapshots, `unreadable` when the store could not be listed; undefined until read. */
6799 let snapshots: string[] | "unreadable" | undefined;
68100 let asking: Asking | undefined;
@@ -71,6 +103,11 @@ export function resilienceScreen(f: Session, ctx: Ctx): Screen {
71103 let said = "";
72104 let note = "";
73105
106 const say = (text: string): void => {
107 note = text;
108 ctx.invalidate();
109 };
110
74111 const read = async (): Promise<void> => {
75112 const listed = await recentSnapshots(f.app, f.user, SNAPSHOTS);
76113 snapshots = listed.ok ? listed.value : "unreadable";
@@ -98,14 +135,75 @@ export function resilienceScreen(f: Session, ctx: Ctx): Screen {
98135 });
99136 };
100137
138 const destination: Target = {
139 title: "Snapshot destination",
140 save: (input) => {
141 const saved = saveDestination(f.app, f.user, input);
142 return saved.ok ? saved.value : saved.error === "not_found" ? refusedAll : saved.error;
143 },
144 };
145 const mirror: Target = {
146 title: "Mirror",
147 save: (input) => {
148 const saved = saveMirror(f.app, f.user, input);
149 return saved.ok ? saved.value : saved.error === "not_found" ? refusedAll : saved.error;
150 },
151 };
152
153 /** One prompt per field, each validated as typed; the last one seals them all. */
154 const step = (target: Target, i: number, given: DestinationInput): Screen => {
155 const s = STEPS[i]!;
156 return promptScreen(ctx, screen, {
157 title: `${target.title} ${i + 1}/${STEPS.length}`,
158 label: s.label,
159 hint: "Blank keeps it; a refusal is said whole behind.",
160 ...(s.placeholder === undefined ? {} : { placeholder: s.placeholder }),
161 ...(s.mask ? { mask: true } : {}),
162 onSubmit: (value) => {
163 const refused = invalidField({ ...BLANK, [s.key]: value });
164 // The prompt has room for the start of it; the screen behind says it whole.
165 if (refused) {
166 say(refused);
167 return refused;
168 }
169 const next = { ...given, [s.key]: value };
170 if (i + 1 < STEPS.length) return later(ctx, () => step(target, i + 1, next));
171 say(target.save(next));
172 return undefined;
173 },
174 });
175 };
176
177 const forgetDest = (): Screen =>
178 confirmScreen(ctx, screen, {
179 title: "Forget the destination?",
180 body: ["The sealed endpoint, bucket and keys are cleared;", "the environment's destination, if any, takes over."],
181 actions: [{ label: "Cancel", variant: "ghost" }, { label: "Forget", variant: "danger" }],
182 onChoose: (index) => {
183 if (index !== 1) return;
184 say(forgetDestination(f.app, f.user).ok ? "The sealed destination is cleared." : refusedAll);
185 },
186 });
187
188 const forgetMir = (): Screen =>
189 confirmScreen(ctx, screen, {
190 title: "Forget the mirror?",
191 body: ["The sealed mirror settings are cleared;", "snapshots stay only on this machine."],
192 actions: [{ label: "Cancel", variant: "ghost" }, { label: "Forget", variant: "danger" }],
193 onChoose: (index) => {
194 if (index !== 1) return;
195 say(forgetMirror(f.app, f.user).ok ? "The sealed mirror is cleared." : refusedAll);
196 },
197 });
198
101199 const body = (): string[] => {
102200 // The question and the answer come first: a long list must not clip them.
103201 const out: string[] = [];
104202 if (asking !== undefined) out.push(QUESTION[asking], "");
105203 else if (said !== "") out.push(...wrap(said, INNER), "");
106 out.push(...wrap(mirrorLine(f), INNER));
107 if (mayMirror) out.push("M sets the mirror, F forgets it.");
204 out.push(...wrap(destinationLine(f), INNER), ...wrap(mirrorLine(f), INNER));
108205 if (note !== "") out.push(...wrap(note, INNER));
206 if (mayChange()) out.push("d sets where snapshots go, x forgets it.", "m sets the mirror, f forgets it.");
109207 out.push("", "Snapshots, newest first:");
110208 if (snapshots === undefined) out.push(" reading…");
111209 else if (snapshots === "unreadable") out.push(" the snapshots could not be read");
@@ -121,7 +219,14 @@ export function resilienceScreen(f: Session, ctx: Ctx): Screen {
121219 if (asking !== undefined) return [binding(["y"], "yes"), binding(["n", "esc"], "no")];
122220 return [
123221 ...(busy ? [] : [binding(["n"], "take a snapshot now"), binding(["l"], "bring the mirror level now")]),
124 ...(mayMirror ? [binding(["m"], "set the mirror"), binding(["f"], "forget the mirror")] : []),
222 ...(mayChange()
223 ? [
224 binding(["d"], "set destination"),
225 binding(["x"], "forget destination"),
226 binding(["m"], "set the mirror"),
227 binding(["f"], "forget the mirror"),
228 ]
229 : []),
125230 binding(["enter", "esc", "q"], "close"),
126231 ];
127232 },
@@ -148,46 +253,13 @@ export function resilienceScreen(f: Session, ctx: Ctx): Screen {
148253 asking = e.key === "n" ? "snapshot" : "mirror";
149254 said = "";
150255 } else if (e.key === "enter" || e.key === "esc" || e.key === "q") ctx.pop();
151 else if (mayMirror && e.key === "m") ctx.push(ask(0, {}));
152 else if (mayMirror && e.key === "f")
153 ctx.push(
154 confirmScreen(ctx, screen, {
155 title: "Forget the mirror?",
156 body: ["The sealed mirror settings are cleared;", "snapshots stay only on this machine."],
157 actions: [{ label: "Cancel", variant: "ghost" }, { label: "Forget", variant: "danger" }],
158 onChoose: (i) => {
159 if (i !== 1) return;
160 note = forgetMirror(f.app, f.user).ok ? "The sealed mirror is cleared." : "Not found.";
161 },
162 }),
163 );
256 else if (!mayChange()) return true;
257 else if (e.key === "d") ctx.push(step(destination, 0, BLANK));
258 else if (e.key === "x") ctx.push(forgetDest());
259 else if (e.key === "m") ctx.push(step(mirror, 0, BLANK));
260 else if (e.key === "f") ctx.push(forgetMir());
164261 return true;
165262 },
166263 };
167 // One field at a time; the last submit seals, and a refusal keeps that prompt open.
168 const ask = (n: number, got: Record<string, string>): Screen => {
169 const [name, label, mask] = FIELDS[n]!;
170 return promptScreen(ctx, screen, {
171 title: `Mirror ${n + 1}/${FIELDS.length}`,
172 label,
173 hint: "blank keeps what was sealed",
174 mask,
175 onSubmit: (value) => {
176 const next = { ...got, [name]: value };
177 if (n + 1 < FIELDS.length) return later(ctx, () => ask(n + 1, next));
178 const input: DestinationInput = {
179 endpoint: next["endpoint"] ?? "",
180 bucket: next["bucket"] ?? "",
181 region: next["region"] ?? "",
182 accessKey: next["accessKey"] ?? "",
183 secretKey: next["secretKey"] ?? "",
184 };
185 const saved = saveMirror(f.app, f.user, input);
186 if (!saved.ok) return saved.error === "not_found" ? "Not found." : saved.error;
187 note = saved.value;
188 return undefined;
189 },
190 });
191 };
192264 return screen;
193265 }
test/merge-queue.test.ts +14 −7
@@ -456,16 +456,23 @@ describe("the merge queue", () => {
456456 test("a docs-only move voids the gate: tests read docs, so the pull is gated again on the new base (#6640)", async () => {
457457 const repo = await autonomousRepo("docs-mid-gate");
458458 const pull = await greenPull(repo, "a.ts");
459 held = (async () => {
460 const moved = await commitToBranch(app, alice, repo, {
461 message: "A doc",
462 files: [{ path: "docs/NOTE.md", content: "# note\n" }],
463 });
464 if (!moved.ok) throw new Error(JSON.stringify(moved.error));
465 })();
459 // The doc lands while the gate runs, not before it starts: on a loaded
460 // machine a move begun up front can beat the sweep, and the gate then
461 // rightly runs on the new base and lands.
462 atGate = () => {
463 atGate = undefined;
464 held = (async () => {
465 const moved = await commitToBranch(app, alice, repo, {
466 message: "A doc",
467 files: [{ path: "docs/NOTE.md", content: "# note\n" }],
468 });
469 if (!moved.ok) throw new Error(JSON.stringify(moved.error));
470 })();
471 };
466472 try {
467473 await sweepGreen(app, repo);
468474 } finally {
475 atGate = undefined;
469476 held = undefined;
470477 }
471478 // Voided: not landed on the gate that ran before the move.
test/tui-backups.test.ts added +186
@@ -0,0 +1,186 @@
1 /**
2 * The snapshot destination in the terminal (#7269): the Resilience screen
3 * says where snapshots go; `d` seals a destination field by field, `f`

Non-blocking: the header says d seals and f forgets the destination; it is x now (f is the mirror).

Non-blocking: the header comment says f forgets the destination; it is x now. Fix the wording.

4 * forgets it after a confirm. Every frame drawn is kept, and none may hold
5 * the access key or the secret key.
6 */
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { createApp, type App as Forge } from "../src/app.ts";
12 import { forgetDestination, mayChangeDestination, saveDestination } from "../src/actions/destination.ts";
13 import { createUser, getUserByName } from "../src/core/users.ts";
14 import { sealedSet } from "../src/snapshot/destination.ts";
15 import { createForgeApp } from "../src/tui/forge.ts";
16 import type { TuiIdentity } from "../src/tui/session.ts";
17 import { headlessTerminal } from "../src/tui/term/terminal.ts";
18 import { quietLogs } from "./helpers/globals.ts";
19 import { settleApp } from "./helpers/tui.ts";
20
21 quietLogs();
22
23 const ACCESS = "AKIAEXAMPLEACCESS7269";
24 const SECRET = "s3cr3t/Key+ThatMustNeverShow7269";
25
26 describe("the snapshot destination in the terminal", () => {
27 let dataDir: string;
28 let forge: Forge;
29 const frames: string[] = [];
30 const saved = { ...process.env };
31
32 function boot(username: string) {
33 const terminal = headlessTerminal(140, 40);
34 const user = getUserByName(forge.db, username)!;
35 const identity: TuiIdentity = { sshUsername: "t", remoteAddress: "127.0.0.1", user };
36 const { app } = createForgeApp(forge, identity, { terminal, exitHooks: false });
37 app.start();
38 const frame = () => {
39 const t = app.screen.toText();
40 frames.push(t);
41 return t;
42 };
43 const key = (k: string, ctrl = false) => {
44 app.dispatch({ type: "key", key: k, ctrl, alt: false, shift: false });
45 frame();
46 };
47 const press = async (...keys: string[]) => {
48 for (const k of keys) key(k);
49 await settleApp(app);
50 return frame();
51 };
52 const type = async (text: string) => {
53 for (const ch of text) key(ch);
54 await settleApp(app);
55 return frame();
56 };
57 const open = async () => {
58 key("k", true);
59 await settleApp(app);
60 await type("Resilience");
61 return press("enter");
62 };
63 return { text: frame, press, type, open, stop: () => app.stop() };
64 }
65
66 beforeAll(async () => {
67 for (const k of Object.keys(process.env))
68 if (k.startsWith("S3_") || k === "FORGE_SNAPSHOT_DIR") delete process.env[k];
69 dataDir = await mkdtemp(join(tmpdir(), "forge-tui-backups-"));
70 forge = createApp({ dataDir, port: 0 });
71 for (const [username, adminIfFirst] of [["alice", true], ["carol", false]] as const) {
72 const made = await createUser(forge.db, {
73 origin: "seed",
74 trusted: true,
75 username,
76 email: `${username}@forge.local`,
77 password: "a-good-passphrase",
78 adminIfFirst,
79 });
80 if (!made.ok) throw new Error(made.error);
81 }
82 forge.db.query("UPDATE user SET is_admin = 1, is_agent = 1 WHERE username = 'carol'").run();
83 });
84
85 afterAll(async () => {
86 forge.close();
87 await rm(dataDir, { recursive: true, force: true });
88 process.env = saved;
89 });
90
91 test("with nothing set, snapshots stay on this box", async () => {
92 const h = boot("alice");
93 const t = await h.open();
94 expect(t).toContain("Snapshots stay on this box");
95 expect(t).toContain("d sets where snapshots go, x forgets it.");
96 h.stop();
97 });
98
99 test("a bad endpoint is refused in the page's words, and nothing is sealed", async () => {
100 const h = boot("alice");
101 await h.open();
102 // The prompt says up front that a refusal is told in full on the screen behind it.
103 expect(await h.press("d")).toContain("a refusal is said whole behind");
104 await h.type("ftp://user:[email protected]");
105 const t = await h.press("enter");
106 expect(t).toContain("The endpoint must be an http(s) URL without credentials in it.");
107 expect(t).toContain("1/5");
108 await h.press("esc");
109 expect(sealedSet(forge.db)).toEqual([]);
110 h.stop();
111 });
112
113 test("d seals each field, and the screen reads back kind and place without a key", async () => {
114 const h = boot("alice");
115 await h.open();
116 await h.press("d");
117 await h.type("https://s3.example.com");
118 expect(await h.press("enter")).toContain("2/5");
119 await h.type("forge-snaps");
120 await h.press("enter");
121 await h.type("eu-west-1");
122 await h.press("enter");
123 await h.type(ACCESS);
124 expect(h.text()).toContain("•".repeat(ACCESS.length));
125 await h.press("enter");
126 await h.type(SECRET);
127 const t = await h.press("enter");
128 expect(t).toContain("Destination sealed; the next snapshot goes there.");
129 expect(t).toContain("Snapshots go to s3.example.com/forge-snaps (sealed in this forge).");
130 expect(sealedSet(forge.db).length).toBeGreaterThanOrEqual(4);
131 h.stop();
132 // Opened afresh, it still says where, and still no key.
133 const again = boot("alice");
134 expect(await again.open()).toContain("s3.example.com/forge-snaps");
135 again.stop();
136 });
137
138 test("an agent admin reads the destination but cannot change it", async () => {
139 const h = boot("carol");
140 const t = await h.open();
141 expect(t).toContain("s3.example.com/forge-snaps");
142 expect(t).not.toContain("d sets where snapshots go, x forgets it.");
143 expect(await h.press("x")).not.toContain("Forget the destination?");
144 expect(await h.press("d")).not.toContain("1/5");
145 h.stop();
146 // The action refuses it too, so no other door (page, MCP) lets an agent change it.
147 const carol = getUserByName(forge.db, "carol")!;
148 const before = sealedSet(forge.db);
149 expect(saveDestination(forge, carol, { endpoint: "https://x.example", bucket: "b", region: "", accessKey: "", secretKey: "" })).toEqual({ ok: false, error: "not_found" });
150 expect(forgetDestination(forge, carol).ok).toBe(false);
151 expect(sealedSet(forge.db)).toEqual(before);
152 });
153
154 test("x forgets only after the confirm; f is the mirror's", async () => {
155 const h = boot("alice");
156 await h.open();
157 // `f` forgets the mirror (#7281), never the destination.
158 expect(await h.press("f")).toContain("Forget the mirror?");
159 await h.press("esc");
160 expect(await h.press("x")).toContain("Forget the destination?");
161 await h.press("esc");
162 expect(sealedSet(forge.db).length).toBeGreaterThan(0);
163 await h.press("x");
164 const t = await h.press("right", "enter");
165 expect(t).toContain("The sealed destination is cleared.");
166 expect(t).toContain("Snapshots stay on this box");
167 expect(sealedSet(forge.db)).toEqual([]);
168 h.stop();
169 });
170
171 test("one rule offers set and forget: a site admin who is a person", () => {
172 expect(mayChangeDestination({ isAdmin: true, kind: "user" })).toBe(true);
173 expect(mayChangeDestination({ isAdmin: true, kind: "agent" })).toBe(false);
174 expect(mayChangeDestination({ isAdmin: false, kind: "user" })).toBe(false);
175 expect(mayChangeDestination(undefined)).toBe(false);
176 });
177
178 test("no frame ever held the access key or the secret key", () => {
179 expect(frames.length).toBeGreaterThan(50);
180 for (const f of frames) {
181 expect(f).not.toContain(ACCESS);
182 expect(f).not.toContain(SECRET);
183 expect(f).not.toContain(SECRET.slice(0, 10));
184 }
185 });
186 });
test/tui-mirror-set.test.ts +2 −1
@@ -97,6 +97,7 @@ describe("mirror in the terminal", () => {
9797 const h = boot("alice");
9898 await open(h);
9999 expect(h.text()).toContain("Mirror: none.");
100 expect(h.text()).toContain("m sets the mirror, f forgets it.");
100101 await h.press("m");
101102 await field(h, "http://s3.example.test");
102103 await field(h, "offbox-copy");
@@ -155,7 +156,7 @@ describe("mirror in the terminal", () => {
155156 const before = mirrorSealedSet(forge.db);
156157 const h = boot("robo");
157158 await open(h);
158 expect(h.text()).not.toContain("M sets the mirror");
159 expect(h.text()).not.toContain("m sets the mirror");
159160 await h.press("m");
160161 expect(h.text()).not.toContain("Mirror 1/5");
161162 await h.press("f");

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs