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

The terminal sets, reads or turns off your phone notify URL, showing only its host, as MCP `notify` does #7464

Merged opened this

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

Merged

scout/forge:scout/7258-the-terminal-sets-reads-or-turns-off-you → main 3 commits fork 2 threads · 2 unresolved

  • Merged. scout/7258-the-terminal-sets-reads-or-turns-off-you was merged into main as f0f4cac.

Review

Sign in to review this change.
4 files changed +241 −0
docs/APPS.md +4
@@ -1459,6 +1459,10 @@ a link to the milestone (`docs/PLANNING.md`); its progress never does.
14591459 link-local, `.local`, `.internal` and bare hosts. The name is resolved and
14601460 judged again at send time and redirects are not followed. It is kept in the
14611461 user's settings, never logged, and the account page shows only its host.
1462 - *Doors.* Settings, MCP `notify` and the terminal's "Phone notifications"
1463 screen (`src/tui/screens/notify.ts`: `s` sets, `x` turns off after a confirm)
1464 all go through `setNotifyUrl`. The terminal draws the typed URL as dots and
1465 shows only the host; an agent is not offered the screen.
14621466 - Not here yet: a health problem for an admin has no event to ride.
14631467
14641468 ## Configuration
src/tui/forge.ts +9
@@ -58,6 +58,7 @@ import { usageScreen } from "./screens/usage.ts";
5858 import { canScreen } from "./screens/can.ts";
5959 import { peopleScreen } from "./screens/people.ts";
6060 import { modelCeilingPrompt, modelGrantPrompt, modelScreen } from "./screens/model.ts";
61 import { notifyScreen } from "./screens/notify.ts";
6162 import { sshKeysScreen } from "./screens/sshkeys.ts";
6263 import { proposalTokensPrompt } from "./screens/proposal_tokens.ts";
6364 import { invitesScreen } from "./screens/invites.ts";
@@ -252,6 +253,14 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
252253 hint: "what it closed, deferred and asked you about today",
253254 run: () => ctx.push(deciderScreen(session, ctx)),
254255 });
256 // Where a decision finds the owner; an agent is never offered it.
257 if (session.signedIn && session.user?.kind !== "agent")
258 commands.push({
259 id: "notify",
260 title: "Phone notifications",
261 hint: "where the crew reaches your phone; s sets, x turns off",
262 run: () => ctx.push(notifyScreen(session, ctx)),
263 });
255264 if (session.signedIn)
256265 commands.push({
257266 id: "ssh-keys",
src/tui/screens/notify.ts added +85
@@ -0,0 +1,85 @@
1 /**
2 * Your phone notify URL in the terminal: the host notifications go to, or
3 * that they are off. `s` asks for a URL and saves it, `x` turns it off after
4 * a confirm. The URL may carry a secret in its path (an ntfy topic is a
5 * password), so only its host is ever drawn; the rules and the words are
6 * `crew/notify.ts`'s, as Settings and MCP `notify` use them.
7 */
8
9 import { NOTIFY_ERRORS, notifyHost, notifyUrlOf, setNotifyUrl } from "../../crew/notify.ts";
10 import type { Ctx, Screen } from "../app/app.ts";
11 import { binding } from "../app/keys.ts";
12 import { style } from "../term/style.ts";
13 import { truncate } from "../term/width.ts";
14 import type { Session } from "../session.ts";
15 import { confirmScreen, promptScreen } from "./common.ts";
16
17 export function notifyScreen(f: Session, ctx: Ctx): Screen {
18 let note = "";
19 const host = () =>
20 f.user === undefined ? undefined : notifyHost(notifyUrlOf(f.app.db, f.user.id));
21 const save = (raw: string) => {
22 if (f.user === undefined) return undefined;
23 const done = setNotifyUrl(f.app.db, f.user.id, raw);
24 if (!done.ok) return NOTIFY_ERRORS[done.error];
25 f.app.bus.append({ kind: "account.notify_set", actorId: f.user.id });
26 note = done.value === "set" ? "Saved." : "Turned off.";
27 return undefined;
28 };
29
30 const screen: Screen = {
31 title: "Phone notifications",
32 keys: [binding(["s"], "set the URL"), binding(["x"], "turn off")],
33 render(area, s) {
34 const c = ctx.theme.c;
35 const h = host();
36 const lines = [
37 h === undefined ? "Off. s sets a URL (an ntfy topic, https)." : `On: notifications go to ${h}.`,
38 "The address is a secret and is never shown past its host.",
39 ...(note === "" ? [] : ["", note]),
40 ];
41 lines.forEach((l, i) => {
42 if (i < area.h) s.text(area.x, area.y + i, truncate(l, area.w), style(c.mutedFg, c.bg), area.x + area.w);
43 });
44 },
45 key(e) {
46 if (e.ctrl || e.alt) return false;
47 if (e.key === "s") {
48 ctx.push(
49 promptScreen(ctx, screen, {
50 title: "Phone notifications",

The URL is a secret (an ntfy topic is a password) and the terminal is where people are read over the shoulder. promptScreen has mask: true for exactly this (see its doc in common.ts). Add mask: true so the typed URL is drawn as dots. Note masked values are kept verbatim (not trimmed), so save should still go through setNotifyUrl, which validates.

51 label: "an https URL that takes a POST",
52 mask: true,
53 onSubmit: (raw) => {
54 if (raw.trim() === "") return "Type a URL, or x to turn it off.";
55 return save(raw);
56 },
57 }),
58 );
59 return true;
60 }
61 if (e.key === "x") {
62 if (host() === undefined) {
63 note = "Already off.";
64 ctx.invalidate();
65 return true;
66 }
67 ctx.push(
68 confirmScreen(ctx, screen, {
69 title: "Turn off phone notifications",
70 body: [`Stop sending to ${host()}?`],
71 actions: [{ label: "Cancel", variant: "ghost" }, { label: "Turn off", variant: "danger" }],
72 onChoose: (i) => {
73 if (i !== 1) return;
74 save("");
75 ctx.invalidate();
76 },
77 }),
78 );
79 return true;
80 }
81 return false;
82 },
83 };
84 return screen;
85 }
test/tui-notify.test.ts added +143
@@ -0,0 +1,143 @@
1 /** Phone notifications in the terminal (#7258): host only, never the path. */
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 } from "../src/app.ts";
7 import { NOTIFY_ERRORS, notifyUrlOf } from "../src/crew/notify.ts";
8 import { createUser, getUserByName } from "../src/core/users.ts";
9 import { createForgeApp } from "../src/tui/forge.ts";
10 import { headlessTerminal } from "../src/tui/term/terminal.ts";
11 import { quietLogs } from "./helpers/globals.ts";
12 import { settleApp } from "./helpers/tui.ts";
13
14 quietLogs();
15
16 const URL_ = "https://ntfy.example.com/secret-topic-123?auth=hunter2";
17
18 describe("phone notifications in the terminal", () => {
19 let dataDir: string;
20 let app: App;
21 const frames: string[] = [];
22
23 function boot(username: string) {
24 const terminal = headlessTerminal(140, 32);
25 const user = getUserByName(app.db, username)!;
26 const { app: tui } = createForgeApp(
27 app,
28 { sshUsername: "t", remoteAddress: "127.0.0.1", user },
29 { terminal, exitHooks: false },
30 );
31 tui.start();
32 const key = (k: string, ctrl = false) =>
33 tui.dispatch({ type: "key", key: k, ctrl, alt: false, shift: false });
34 const h = {
35 text: () => {
36 const t = tui.screen.toText();
37 frames.push(t);
38 return t;
39 },
40 press: async (...keys: string[]) => {
41 keys.forEach((k) => key(k));
42 await settleApp(tui);
43 },
44 type: async (t: string) => {
45 for (const ch of t) key(ch);
46 await settleApp(tui);
47 },
48 palette: async (q: string) => {
49 key("k", true);
50 await settleApp(tui);
51 await h.type(q);
52 },
53 open: async () => {
54 await h.palette("Phone notifications");
55 await h.press("enter");
56 },
57 stop: () => tui.stop(),
58 };
59 return h;
60 }
61 const url = () => notifyUrlOf(app.db, getUserByName(app.db, "alice")!.id);
62
63 beforeAll(async () => {
64 dataDir = await mkdtemp(join(tmpdir(), "forge-tui-notify-"));
65 app = createApp({ dataDir, port: 0, host: "forge.test" });
66 const made = await createUser(app.db, {
67 origin: "seed",
68 trusted: true,
69 username: "alice",
70 email: "[email protected]",
71 password: "a-good-passphrase",
72 adminIfFirst: true,
73 });
74 if (!made.ok) throw new Error(made.error);
75 const bot = await createUser(app.db, {
76 origin: "seed",
77 trusted: true,
78 username: "helper",
79 email: "[email protected]",
80 password: "a-good-passphrase",
81 });
82 if (!bot.ok) throw new Error(bot.error);
83 app.db.query("UPDATE user SET is_agent = 1 WHERE username = 'helper'").run();
84 });
85
86 afterAll(async () => {
87 await rm(dataDir, { recursive: true, force: true });
88 });
89
90 test("off, refuse a bad URL, set, read back as host only, turn off", async () => {
91 const h = boot("alice");
92 await h.open();
93 expect(h.text()).toContain("Off.");
94
95 await h.press("s");
96 await h.type("http://ntfy.example.com/t");
97 await h.press("enter");
98 expect(h.text()).toContain(NOTIFY_ERRORS.not_https);
99 expect(url()).toBeUndefined();
100 await h.press("esc");
101
102 await h.press("s");
103 await h.type(URL_);
104 const typing = h.text();
105 expect(typing).not.toContain("secret-topic");
106 expect(typing).not.toContain("hunter2");
107 await h.press("enter");
108 expect(url()).toBe(URL_);
109 expect(h.text()).toContain("notifications go to ntfy.example.com");
110
111 await h.press("x");
112 expect(h.text()).toContain("Stop sending to ntfy.example.com?");
113 await h.press("esc");
114 expect(url()).toBe(URL_);
115 await h.press("x", "right", "enter");
116 expect(url()).toBeUndefined();
117 expect(h.text()).toContain("Off.");
118 h.stop();
119 });
outdated

This guard is weak: frames only gets a frame when h.text() is called, and no call happens while the URL is being typed (after h.type(URL_)), which is the one frame that would show the secret today. Call h.text() after h.type(URL_) and assert it contains neither secret-topic nor hunter2 (it fails now, passes with mask). Also add a case for pressing x when already off ("Already off."), which is untested.

120
121 test("x when already off says so", async () => {
122 const h = boot("alice");
123 await h.open();
124 await h.press("x");
125 expect(h.text()).toContain("Already off.");
126 h.stop();
127 });
128
129 test("an agent is never offered the screen", async () => {
130 const h = boot("helper");
131 await h.palette("Phone notifications");
132 expect(h.text()).not.toContain("where the crew reaches your phone");
133 h.stop();
134 });
135
136 test("no frame ever shows the path or query", () => {
137 expect(frames.length).toBeGreaterThan(0);
138 for (const f of frames) {
139 expect(f).not.toContain("secret-topic");
140 expect(f).not.toContain("hunter2");
141 }
142 });
143 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs