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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7258-the-terminal-sets-reads-or-turns-off-you → main 3 commits fork 2 threads · 2 unresolved
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. | ||
| 1459 | 1459 | link-local, `.local`, `.internal` and bare hosts. The name is resolved and |
| 1460 | 1460 | judged again at send time and redirects are not followed. It is kept in the |
| 1461 | 1461 | 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. | |
| 1462 | 1466 | - Not here yet: a health problem for an admin has no event to ride. |
| 1463 | 1467 | |
| 1464 | 1468 | ## Configuration |
src/tui/forge.ts +9
| @@ -58,6 +58,7 @@ import { usageScreen } from "./screens/usage.ts"; | ||
| 58 | 58 | import { canScreen } from "./screens/can.ts"; |
| 59 | 59 | import { peopleScreen } from "./screens/people.ts"; |
| 60 | 60 | import { modelCeilingPrompt, modelGrantPrompt, modelScreen } from "./screens/model.ts"; |
| 61 | import { notifyScreen } from "./screens/notify.ts"; | |
| 61 | 62 | import { sshKeysScreen } from "./screens/sshkeys.ts"; |
| 62 | 63 | import { proposalTokensPrompt } from "./screens/proposal_tokens.ts"; |
| 63 | 64 | import { invitesScreen } from "./screens/invites.ts"; |
| @@ -252,6 +253,14 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 252 | 253 | hint: "what it closed, deferred and asked you about today", |
| 253 | 254 | run: () => ctx.push(deciderScreen(session, ctx)), |
| 254 | 255 | }); |
| 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 | }); | |
| 255 | 264 | if (session.signedIn) |
| 256 | 265 | commands.push({ |
| 257 | 266 | 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", | |
| 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: |
||
| 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 | }); |
new above
offline
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: truefor exactly this (see its doc in common.ts). Addmask: trueso the typed URL is drawn as dots. Note masked values are kept verbatim (not trimmed), sosaveshould still go through setNotifyUrl, which validates.