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

The terminal lists your passkeys, with name, added and last used, beside SSH keys #8051

Merged opened this

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

Merged

scout/forge:scout/8044-the-terminal-lists-your-passkeys-with-na → main 3 commits fork 1 thread · 1 unresolved

  • Merged. scout/8044-the-terminal-lists-your-passkeys-with-na was merged into main as 9ae258b.

Review

Sign in to review this change.
6 files changed +176 −1
docs/CREW.md +1 −1

Diff not shown — a line 2,406 characters wide (limit 2,000). +1 −1

View file
docs/TOUR.md +2
@@ -197,6 +197,8 @@ that account, as for anyone with a key.
197197
198198 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`).
199199
200 Beside it the palette's `Passkeys` (ctrl+k, people only, not agents or guests) lists your own passkeys, read only: each row is the name, then `added YYYY-MM-DD, used YYYY-MM-DD` (or `used never`). The head line says it is read only and points at Settings › Passkeys to add or remove one; with none it reads "None yet. Add one on the web: Settings › Passkeys." (`src/tui/screens/passkeys.ts`, the same rows as MCP `whoami_can kind: passkeys`).
201
200202 ## Page-view beacon
201203
202204 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/tui/forge.ts +9
@@ -49,6 +49,7 @@ import { canScreen } from "./screens/can.ts";
4949 import { modelScreen } from "./screens/model.ts";
5050 import { notifyScreen } from "./screens/notify.ts";
5151 import { sshKeysScreen } from "./screens/sshkeys.ts";
52 import { passkeysScreen } from "./screens/passkeys.ts";
5253 import { confirmsScreen } from "./screens/confirms.ts";
5354 import { proposalTokensPrompt } from "./screens/proposal_tokens.ts";
5455 import { signInsScreen } from "./screens/signins.ts";
@@ -272,6 +273,14 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
272273 hint: "list, add and remove the keys that sign in as you",
273274 run: () => ctx.push(sshKeysScreen(session, ctx)),
274275 });
276 // Settings › Passkeys and MCP `whoami_can kind: passkeys`, read only (#8044).
277 if (session.signedIn && session.user?.kind !== "agent")
278 commands.push({
279 id: "passkeys",
280 title: "Passkeys",
281 hint: "the passkeys that sign in as you",
282 run: () => ctx.push(passkeysScreen(session, ctx)),
283 });
275284 if (session.signedIn)
276285 commands.push(
277286 {
src/tui/screens/passkeys.ts added +44
@@ -0,0 +1,44 @@
1 /**
2 * Your passkeys in the terminal (#8044): the list, read only. A passkey is
3 * made by a browser ceremony, so this screen never offers to add one; it
4 * names where to, Settings › Passkeys. The list is your own passkeys, never
5 * another account's, as MCP `whoami_can kind: passkeys` shows them.
6 */
7
8 import { listPasskeys } from "../../core/passkeys.ts";
9 import type { Ctx, Screen } from "../app/app.ts";
10 import { binding } from "../app/keys.ts";
11 import { style } from "../term/style.ts";
12 import { truncate } from "../term/width.ts";
13 import type { Session } from "../session.ts";
14 import { fill, len, split } from "../ui/layout.ts";
15 import { ListState, drawList, type ListRow } from "./common.ts";
16
17 export function passkeysScreen(f: Session, ctx: Ctx): Screen {
18 const state = new ListState();
19 const items = () => (f.user === undefined ? [] : listPasskeys(f.app.db, f.user.id));
20
21 const rows = (): ListRow[] =>
22 items().map((p) => ({
23 title: p.name,
24 meta: `added ${p.createdAt.slice(0, 10)}, used ${p.lastUsedAt?.slice(0, 10) ?? "never"}`,
25 }));
26
27 return {
28 title: "Passkeys",

Only j/k are bound, so a and d do nothing and the screen never says why. Consider a one-line note in the keys list or head line that this screen is read only.

29 keys: [binding(["j", "k"], "move cursor")],
30 render(area, s) {
31 const c = ctx.theme.c;
32 const [head, body] = split(area, "v", [len(1), fill(1)]);
33 const text =
34 items().length === 0
35 ? "None yet. Add one on the web: Settings › Passkeys."
36 : "Passkeys that sign in as you. Read only: add or remove one on the web, Settings › Passkeys.";
37 s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
38 drawList(s, body!, c, state, rows());
39 },
40 key(e) {
41 return state.key(e, items().length);
42 },
43 };
44 }
test/palette-roster.test.ts +2
@@ -65,6 +65,7 @@ test("a site-admin person is offered the whole roster", () => {
6565 ["notify", "Phone notifications", "where the crew reaches your phone; s sets, x turns off"],
6666 ["confirms", "Confirms", "what the forge's agent asked to do; y does it once"],
6767 ["ssh-keys", "SSH keys", "list, add and remove the keys that sign in as you"],
68 ["passkeys", "Passkeys", "the passkeys that sign in as you"],
6869 ["account", "Account settings", undefined],
6970 ["signins", "Signed in", "your browsers and tokens; d signs a browser out"],
7071 ["sentinel", "Sentinel", "the forge's open faults"],
@@ -188,6 +189,7 @@ test("a member person is offered the signed-in roster without the admin entries"
188189 ["notify", "Phone notifications", "where the crew reaches your phone; s sets, x turns off"],
189190 ["confirms", "Confirms", "what the forge's agent asked to do; y does it once"],
190191 ["ssh-keys", "SSH keys", "list, add and remove the keys that sign in as you"],
192 ["passkeys", "Passkeys", "the passkeys that sign in as you"],
191193 ["account", "Account settings", undefined],
192194 ["signins", "Signed in", "your browsers and tokens; d signs a browser out"],
193195 ["origin-apps", "Origin apps", "your OAuth apps for connecting origins; a adds, d removes"],
test/tui-passkeys.test.ts added +118
@@ -0,0 +1,118 @@
1 /**
2 * Passkeys in the terminal (#8044): your own passkeys listed with added and
3 * last used, never another account's; none yet points at the web; a person's
4 * command, never an agent's.
5 */
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createApp, type App } from "../src/app.ts";
11 import type { Credential } from "../src/auth/webauthn.ts";
12 import { addPasskey, notePasskeyUsed } from "../src/core/passkeys.ts";
13 import { createUser, getUserByName, type User } from "../src/core/users.ts";
14 import { createForgeApp, forgeCommands, Session } from "../src/tui/forge.ts";
15 import { headlessTerminal } from "../src/tui/term/terminal.ts";
16 import { quietLogs } from "./helpers/globals.ts";
17 import { settleApp } from "./helpers/tui.ts";
18
19 quietLogs();
20
21 const cred = (id: string): Credential => ({
22 id,
23 alg: "ES256",
24 key: { kty: "EC", crv: "P-256", x: "x", y: "y" },
25 signCount: 0,
26 });
27
28 describe("passkeys in the terminal", () => {
29 let dataDir: string;
30 let app: App;
31
32 const seed = (owner: string, name: string) => {
33 const added = addPasskey(app.db, getUserByName(app.db, owner)!.id, name, cred(`${owner}-${name}`));
34 if (!added.ok) throw new Error(added.error);
35 return added.value;
36 };
37
38 function boot(username: string) {
39 const terminal = headlessTerminal(140, 32);
40 const user = getUserByName(app.db, username)!;
41 const { app: tui } = createForgeApp(
42 app,
43 { sshUsername: "t", remoteAddress: "127.0.0.1", user },
44 { terminal, exitHooks: false },
45 );
46 tui.start();
47 const key = (k: string, ctrl = false) =>
48 tui.dispatch({ type: "key", key: k, ctrl, alt: false, shift: false });
49 return {
50 text: () => tui.screen.toText(),
51 open: async () => {
52 key("k", true);
53 await settleApp(tui);
54 for (const ch of "Passkeys") key(ch);
55 await settleApp(tui);
56 key("enter");
57 await settleApp(tui);
58 },
59 stop: () => tui.stop(),
60 };
61 }
62
63 beforeAll(async () => {
64 dataDir = await mkdtemp(join(tmpdir(), "forge-tui-passkeys-"));
65 app = createApp({ dataDir, port: 0, host: "forge.test" });
66 for (const [username, first] of [["alice", true], ["bob", false], ["carol", false]] as const) {
67 const made = await createUser(app.db, {
68 origin: "seed",
69 trusted: true,
70 username,
71 email: `${username}@forge.local`,
72 password: "a-good-passphrase",
73 adminIfFirst: first,
74 });
75 if (!made.ok) throw new Error(made.error);
76 }
77 notePasskeyUsed(app.db, seed("alice", "work laptop"), 1);
78 seed("alice", "phone");
79 seed("bob", "bobs-yubikey");
80 });
81
82 afterAll(async () => {
83 await rm(dataDir, { recursive: true, force: true });
84 });
85
86 test("lists your two passkeys with added and used, never another account's", async () => {
87 const h = boot("alice");
88 await h.open();
89 const text = h.text();
90 expect(text).toContain("Passkeys that sign in as you. Read only: add or remove one on the web, Settings › Passkeys.");
91 expect(text).toContain("work laptop");
92 expect(text).toContain("phone");
93 const today = new Date().toISOString().slice(0, 10);
94 expect(text).toContain(`added ${today}, used ${today}`);
95 expect(text).toContain(`added ${today}, used never`);
96 expect(text).not.toContain("bobs-yubikey");
97 h.stop();
98 });
99
100 test("with none, the head line points at Settings › Passkeys", async () => {
101 const h = boot("carol");
102 await h.open();
103 expect(h.text()).toContain("None yet. Add one on the web: Settings › Passkeys.");
104 expect(h.text()).not.toContain("bobs-yubikey");
105 h.stop();
106 });
107
108 test("the palette offers it to a signed-in person, never to an agent or a guest", () => {
109 const alice = getUserByName(app.db, "alice")!;
110 const ids = (u?: User) =>
111 forgeCommands(new Session(app, { sshUsername: u?.username ?? "git", remoteAddress: "127.0.0.1", user: u }))(
112 {} as never,
113 ).map((c) => c.id);
114 expect(ids(alice)).toContain("passkeys");
115 expect(ids({ ...alice, kind: "agent" })).not.toContain("passkeys");
116 expect(ids()).not.toContain("passkeys");
117 });
118 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs