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

The terminal lists your SSH keys, adds one and removes one with a confirm, as MCP `ssh_keys` does #7247

Merged opened this

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

Merged

scout/forge:scout/7242-the-terminal-lists-your-ssh-keys-adds-on → main 3 commits fork 2 threads · 2 unresolved

  • Merged. scout/7242-the-terminal-lists-your-ssh-keys-adds-on was merged into main as e1fff44.

Review

Sign in to review this change.
6 files changed +286 −8
docs/TOUR.md +2
@@ -175,6 +175,8 @@ door (`s`) cannot let that person in. They add an SSH key under Settings → Key
175175 once signed in on the web; after that the terminal signs them straight in as
176176 that account, as for anyone with a key.
177177
178 In the terminal the palette's `SSH keys` (ctrl+k) lists your own keys; `a` adds one (a title, then the authorized_keys line) and `d` removes the selected key after a confirm that warns when it is this session's key. Refusals read in MCP `ssh_keys`'s words (`keyWords`).
179
178180 ## Page-view beacon
179181
180182 Every page links one static, immutable script (`src/web/beacon.ts`, under 2 KB, no dependency) that sends, once per page view with `sendBeacon`, the route's path, TTFB, load time, LCP, INP (the worst interaction) and CLS to `POST /_measure`. The route is same-origin only, validates every field, caps the body at 512 bytes and the rate per client, and answers 204. Reports land in `page_vital` (bun:sqlite) keyed by route *pattern*; no IP and no user agent string is stored. A unique visitor is a hash under a salt that rotates daily (yesterday's salt is deleted), rows are kept 90 days. Do-Not-Track and the `forge_measure=off` cookie send nothing, and the server stores nothing for them either. The CSP is unchanged: the script is nonce'd like the shell's.
src/http/mcp/shared.ts +8
@@ -26,6 +26,14 @@ export const attachWords = (error: string): string =>
2626 too_large: "the picture is over 5 MB",
2727 })[error] ?? "refused";
2828
29 /** What an SSH key refusal says, on MCP and in the terminal. */
30 export const keyWords = (error: string): string =>
31 ({
32 invalid_key: "not a public key in authorized_keys form",
33 key_exists: "that key is already registered",
34 not_found: "no such key of yours",
35 })[error] ?? error;
36
2937 export const bad = (error: string, message: string): Answer => ({
3038 ok: false,
3139 error,
src/http/mcp/tools/ssh_keys.ts +1 −8
@@ -1,13 +1,6 @@
11 import { addSshKey, deleteSshKey } from "../../../actions/users.ts";
22 import { listKeys, publicKey } from "../../../core/keys.ts";
3 import { good, bad, type Tool } from "../shared.ts";
4
5 const keyWords = (error: string): string =>
6 ({
7 invalid_key: "not a public key in authorized_keys form",
8 key_exists: "that key is already registered",
9 not_found: "no such key of yours",
10 })[error] ?? error;
3 import { good, bad, keyWords, type Tool } from "../shared.ts";
114
125 export const tool: Tool = {
136 name: "ssh_keys",
src/tui/forge.ts +8
@@ -57,6 +57,7 @@ import { usageScreen } from "./screens/usage.ts";
5757 import { canScreen } from "./screens/can.ts";
5858 import { peopleScreen } from "./screens/people.ts";
5959 import { modelCeilingPrompt, modelGrantPrompt, modelScreen } from "./screens/model.ts";
60 import { sshKeysScreen } from "./screens/sshkeys.ts";
6061 import { proposalTokensPrompt } from "./screens/proposal_tokens.ts";
6162 import { invitesScreen } from "./screens/invites.ts";
6263 import { quotasScreen } from "./screens/quotas.ts";
@@ -241,6 +242,13 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
241242 hint: "what it closed, deferred and asked you about today",
242243 run: () => ctx.push(deciderScreen(session, ctx)),
243244 });
245 if (session.signedIn)
246 commands.push({
247 id: "ssh-keys",
248 title: "SSH keys",
249 hint: "list, add and remove the keys that sign in as you",
250 run: () => ctx.push(sshKeysScreen(session, ctx)),
251 });
244252 if (session.signedIn)
245253 commands.push(
246254 {
src/tui/screens/sshkeys.ts added +94
@@ -0,0 +1,94 @@
1 /**
2 * Your SSH keys in the terminal: the list, `a` to add one (a title, then an
3 * authorized_keys line) and `d` to remove the one under the cursor after a
4 * confirm. Every rule is the action's (`actions/users.ts`) and the words are
5 * MCP `ssh_keys`'s own (`keyWords`); the list is your own keys, never another
6 * account's.
7 */
8
9 import { addSshKey, deleteSshKey } from "../../actions/users.ts";
10 import { listKeys } from "../../core/keys.ts";
11 import { keyWords } from "../../http/mcp/shared.ts";
12 import type { Ctx, Screen } from "../app/app.ts";
13 import { binding } from "../app/keys.ts";
14 import { style } from "../term/style.ts";
15 import { truncate } from "../term/width.ts";
16 import type { Session } from "../session.ts";
17 import { fill, len, split } from "../ui/layout.ts";
18 import { ListState, confirmScreen, drawList, later, promptScreen, type ListRow } from "./common.ts";
19
20 export function sshKeysScreen(f: Session, ctx: Ctx): Screen {
21 const state = new ListState();
22 let note = "";
23 const items = () => (f.user === undefined ? [] : listKeys(f.app.db, f.user.id));
24
25 const rows = (): ListRow[] =>
26 items().map((k) => ({
27 title: f.isCurrentKey(k) ? `${k.name} (this session)` : k.name,
28 secondary: k.fingerprint,
29 meta: `added ${k.createdAt.slice(0, 10)}, used ${k.lastUsedAt?.slice(0, 10) ?? "never"}`,
30 }));
31
32 const screen: Screen = {
33 title: "SSH keys",
34 keys: [binding(["j", "k"], "move cursor"), binding(["a"], "add"), binding(["d"], "remove")],
35 render(area, s) {
36 const c = ctx.theme.c;
37 const [head, body] = split(area, "v", [len(1), fill(1)]);
38 const text =
39 note !== "" ? note : items().length === 0 ? "None yet. a adds one." : "Keys that sign in as you.";
40 s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
41 drawList(s, body!, c, state, rows());
42 },
43 key(e) {
44 if (state.key(e, items().length)) return true;
45 if (e.ctrl || e.alt) return false;
46 if (e.key === "d") {
47 const k = items()[state.index];
48 if (k === undefined) return false;
49 ctx.push(
50 confirmScreen(ctx, screen, {
51 title: "Remove SSH key",
52 body: [
53 `Remove ${k.name}?`,
54 k.fingerprint,
55 ...(f.isCurrentKey(k) ? ["", "This terminal session signed in with it."] : []),

No test covers this line or the (this session) title on line 27. Pass the signing key into the session that boot() builds, and assert both texts. The issue lists this as a done item.

56 ],
57 actions: [{ label: "Cancel", variant: "ghost" }, { label: "Remove", variant: "danger" }],
58 onChoose: (i) => {
59 if (i !== 1) return;
60 const done = deleteSshKey(f.app, f.user, k.id);
61 note = done.ok ? `${k.name}: removed.` : keyWords(done.error);
62 ctx.invalidate();
63 },
64 }),
65 );
66 return true;
67 }
68 if (e.key === "a") {
69 ctx.push(
70 promptScreen(ctx, screen, {
71 title: "Add an SSH key",
72 label: "a title (optional)",
73 onSubmit: (title) =>
74 later(ctx, () =>
75 promptScreen(ctx, screen, {
76 title: "Add an SSH key",
77 label: "the authorized_keys line",
78 onSubmit: (key) => {
79 const added = addSshKey(f.app, f.user, { name: title, key });
80 if (!added.ok) return keyWords(added.error);
81 note = `${added.value.name}: added.`;
82 return undefined;
83 },
84 }),
85 ),
86 }),
87 );
88 return true;
89 }
90 return false;
91 },
92 };
93 return screen;
94 }
test/tui-sshkeys.test.ts added +173
@@ -0,0 +1,173 @@
1 /**
2 * SSH keys in the terminal (#7242): your own keys listed, one added, a bad
3 * line refused in MCP's words, one removed after a confirm.
4 */
5 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
6 import { mkdtemp, rm } from "node:fs/promises";
7 import { tmpdir } from "node:os";
8 import { join } from "node:path";
9 import { createApp, type App } from "../src/app.ts";
10 import { addKey, listKeys, parseAuthorizedKey } from "../src/core/keys.ts";
11 import { createUser, getUserByName } from "../src/core/users.ts";
12 import { createForgeApp } from "../src/tui/forge.ts";
13 import { headlessTerminal } from "../src/tui/term/terminal.ts";
14 import { quietLogs } from "./helpers/globals.ts";
15 import { settleApp } from "./helpers/tui.ts";
16
17 quietLogs();
18
19 /** A well-formed ed25519 authorized_keys line whose 32 key bytes are all `fill`. */
20 const line = (fill: number, comment = "") => {
21 const blob = Buffer.concat([
22 Buffer.from([0, 0, 0, 11]),
23 Buffer.from("ssh-ed25519"),
24 Buffer.from([0, 0, 0, 32]),
25 Buffer.alloc(32, fill),
26 ]);
27 return `ssh-ed25519 ${blob.toString("base64")} ${comment}`.trim();
28 };
29
30 describe("SSH keys in the terminal", () => {
31 let dataDir: string;
32 let app: App;
33
34 const seed = (owner: string, name: string, fill: number) => {
35 const parsed = parseAuthorizedKey(line(fill));
36 if (!parsed.ok) throw new Error("bad fixture key");
37 const added = addKey(app.db, getUserByName(app.db, owner)!.id, name, parsed.value);
38 if (!added.ok) throw new Error(added.error);
39 };
40
41 function boot(username: string, keyId?: string) {
42 const terminal = headlessTerminal(140, 32);
43 const user = getUserByName(app.db, username)!;
44 const { app: tui } = createForgeApp(
45 app,
46 { sshUsername: "t", remoteAddress: "127.0.0.1", user, keyId },
47 { terminal, exitHooks: false },
48 );
49 tui.start();
50 const key = (k: string, ctrl = false) =>
51 tui.dispatch({ type: "key", key: k, ctrl, alt: false, shift: false });
52 return {
53 text: () => tui.screen.toText(),
54 press: async (...keys: string[]) => {
55 keys.forEach((k) => key(k));
56 await settleApp(tui);
57 },
58 type: async (t: string) => {
59 for (const ch of t) key(ch);
60 await settleApp(tui);
61 },
62 open: async function () {
63 key("k", true);
64 await settleApp(tui);
65 await this.type("SSH keys");
66 await this.press("enter");
67 },
68 stop: () => tui.stop(),
69 };
70 }
71
72 const names = (owner: string) => listKeys(app.db, getUserByName(app.db, owner)!.id).map((k) => k.name);
73
74 beforeAll(async () => {
75 dataDir = await mkdtemp(join(tmpdir(), "forge-tui-sshkeys-"));
76 app = createApp({ dataDir, port: 0, host: "forge.test" });
77 for (const [username, first] of [["alice", true], ["bob", false]] as const) {
78 const made = await createUser(app.db, {
79 origin: "seed",
80 trusted: true,
81 username,
82 email: `${username}@forge.local`,
83 password: "a-good-passphrase",
84 adminIfFirst: first,
85 });
86 if (!made.ok) throw new Error(made.error);
87 }
88 seed("alice", "laptop", 1);
89 seed("alice", "desktop", 2);
90 seed("bob", "bobs-secret-box", 3);
91 });
92
93 afterAll(async () => {
94 await rm(dataDir, { recursive: true, force: true });
95 });
96
97 test("lists your two keys, never another account's", async () => {
98 const h = boot("alice");
99 await h.open();
100 expect(h.text()).toContain("laptop");
101 expect(h.text()).toContain("desktop");
102 expect(h.text()).toContain("SHA256:");
103 expect(h.text()).toContain("used never");
104 expect(h.text()).not.toContain("bobs-secret-box");
105 h.stop();
106 });
107
108 test("a adds a key; a malformed line and a duplicate add nothing", async () => {
109 const h = boot("alice");
110 await h.open();
111 await h.press("a");
112 await h.type("phone");
113 await h.press("enter");
114 await h.type("not a key");
115 await h.press("enter");
116 expect(h.text()).toContain("not a public key in authorized_keys form");
117 expect(names("alice")).toEqual(["laptop", "desktop"]);
118 await h.press("esc");
119
120 await h.press("a");
121 await h.press("enter");
122 await h.type(line(1));
123 await h.press("enter");
124 expect(h.text()).toContain("that key is already registered");
125 expect(names("alice")).toEqual(["laptop", "desktop"]);
126 await h.press("esc");
127
128 await h.press("a");
129 await h.type("phone");
130 await h.press("enter");
131 await h.type(line(4));
132 await h.press("enter");
133 expect(h.text()).toContain("phone: added.");
134 expect(names("alice")).toContain("phone");
135 h.stop();
136 });
137
138 test("the key this session signed in with is marked and its confirm warns; another key's does not", async () => {
outdated

Add a case here: open the confirm on the key this session signed in with and expect "This terminal session signed in with it.". Open it on a different key and expect the line to be absent.

139 const uid = getUserByName(app.db, "alice")!.id;
140 const laptop = listKeys(app.db, uid).find((k) => k.name === "laptop")!;
141 const h = boot("alice", laptop.id);
142 await h.open();
143 expect(h.text()).toContain("laptop (this session)");
144 expect(h.text()).not.toContain("desktop (this session)");
145 const row = (name: string) => h.text().split("\n").findIndex((l) => l.includes(name));
146 // Walk the cursor to the laptop row, confirm opens with the warning.
147 if (row("laptop") > row("desktop")) await h.press("j");
148 await h.press("d");
149 expect(h.text()).toContain("This terminal session signed in with it.");
150 await h.press("esc");
151 await h.press(row("laptop") > row("desktop") ? "k" : "j");
152 await h.press("d");
153 expect(h.text()).toContain("Remove desktop?");
154 expect(h.text()).not.toContain("This terminal session signed in with it.");
155 h.stop();
156 });
157
158 test("d removes the selected key only after a confirm", async () => {
159 const h = boot("alice");
160 await h.open();
161 const before = names("alice").length;
162 await h.press("d");
163 expect(h.text()).toContain("Remove SSH key");
164 await h.press("esc");
165 expect(names("alice").length).toBe(before);
166
167 await h.press("d", "right", "enter");
168 expect(h.text()).toContain(": removed.");
169 expect(names("alice").length).toBe(before - 1);
170 expect(names("bob")).toEqual(["bobs-secret-box"]);
171 h.stop();
172 });
173 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs