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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7242-the-terminal-lists-your-ssh-keys-adds-on → main 3 commits fork 2 threads · 2 unresolved
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 | ||
| 175 | 175 | once signed in on the web; after that the terminal signs them straight in as |
| 176 | 176 | that account, as for anyone with a key. |
| 177 | 177 | |
| 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 | ||
| 178 | 180 | ## Page-view beacon |
| 179 | 181 | |
| 180 | 182 | 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/tools/ssh_keys.ts +1 −8
| @@ -1,13 +1,6 @@ | ||
| 1 | 1 | import { addSshKey, deleteSshKey } from "../../../actions/users.ts"; |
| 2 | 2 | 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"; | |
| 11 | 4 | |
| 12 | 5 | export const tool: Tool = { |
| 13 | 6 | name: "ssh_keys", |
src/tui/forge.ts +8
| @@ -57,6 +57,7 @@ import { usageScreen } from "./screens/usage.ts"; | ||
| 57 | 57 | import { canScreen } from "./screens/can.ts"; |
| 58 | 58 | import { peopleScreen } from "./screens/people.ts"; |
| 59 | 59 | import { modelCeilingPrompt, modelGrantPrompt, modelScreen } from "./screens/model.ts"; |
| 60 | import { sshKeysScreen } from "./screens/sshkeys.ts"; | |
| 60 | 61 | import { proposalTokensPrompt } from "./screens/proposal_tokens.ts"; |
| 61 | 62 | import { invitesScreen } from "./screens/invites.ts"; |
| 62 | 63 | import { quotasScreen } from "./screens/quotas.ts"; |
| @@ -241,6 +242,13 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 241 | 242 | hint: "what it closed, deferred and asked you about today", |
| 242 | 243 | run: () => ctx.push(deciderScreen(session, ctx)), |
| 243 | 244 | }); |
| 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 | }); | |
| 244 | 252 | if (session.signedIn) |
| 245 | 253 | commands.push( |
| 246 | 254 | { |
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."] : []), | |
| 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 | }); |
new above
offline
No test covers this line or the
(this session)title on line 27. Pass the signing key into the session thatboot()builds, and assert both texts. The issue lists this as a done item.