Public: anyone can read this; the crew can change it.
`ssh <forge> tell <words>` tells the crew in one line from any shell, and prints where it went #6120
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6110-ssh-forge-tell-words-tells-the-crew-in-o → main 3 commits fork 2 threads · 2 unresolved
4 files
changed +271
−1
README.md +3
| @@ -305,8 +305,11 @@ tokens and keys — as a keyboard-driven terminal app. The same port serves git. | ||
| 305 | 305 | sh |
| 306 | 306 | ssh -p 2222 localhost # the terminal UI |
| 307 | 307 | git clone ssh://localhost:2222/you/hello.git # git, same port |
| 308 | ssh -p 2222 localhost tell make the header blue # tell the crew in one line | |
| 308 | 309 | |
| 309 | 310 | |
| 311 | `tell <words>` takes the rest of the command line as given (no shell expansion; control characters are refused) and tells the crew in the forge's own repository, as the account the key belongs to, through the same action as the web composer. It prints the told line and the full address of the issue, and exits 0; an unknown key, nothing said, too much, or an unverified email prints the reason on stderr, records nothing and exits 1. (Quote the words if your local shell would expand them.) | |
| 312 | ||
| 310 | 313 | An app's open pulls, newest five first, one line each (`#n title · green|red|running|none`), need no tty: run `pulls` on the app's login — `ssh <app>@<forge> pulls`, with the app's page printing the exact command as `FORGE_APP_SSH` (the terminal is off when that is unset). A private app answers only a key its gate admits; sealed pulls show to their author, writers and the crew (`src/apps/door_pulls.ts`). |
| 311 | 314 | |
| 312 | 315 | A big repository clones over either transport. To skip its large files until a |
src/tui/ssh-tell.ts added +64
| @@ -0,0 +1,64 @@ | ||
| 1 | /** | |
| 2 | * `ssh <forge> tell <words>`: the crew told in one line from any shell. An | |
| 3 | * exec of `tell` on a non-app login calls the same `tell()` the web composer | |
| 4 | * and the terminal use, as the account the presented key belongs to, with | |
| 5 | * `from` null, and prints the told line and the full address of the issue. | |
| 6 | * The words are the rest of the command line as given: no shell, no | |
| 7 | * expansion, no `eval`; a control character is refused before anything runs. | |
| 8 | */ | |
| 9 | ||
| 10 | import { tell } from "../actions/tell.ts"; | |
| 11 | import { SAY_SOMETHING_WORDS } from "../actions/tell_words.ts"; | |
| 12 | import type { App } from "../app.ts"; | |
| 13 | import { getRepo } from "../core/repos.ts"; | |
| 14 | import type { User } from "../core/users.ts"; | |
| 15 | import type { SshSession } from "../ssh/server.ts"; | |
| 16 | import { tellAnswer } from "./screens/tell.ts"; | |
| 17 | ||
| 18 | const CRLF = "\r\n"; | |
| 19 | ||
| 20 | /** What an unknown key is told: it is a guest, and a guest tells nothing. */ | |
| 21 | export const ACCOUNT_NEEDED_WORDS = | |
| 22 | "telling the crew needs an account — sign in on the web and add this key under Settings → Keys"; | |
| 23 | ||
| 24 | /** What a command with a control character in its words is refused with. */ | |
| 25 | export const CONTROL_WORDS = "the words may not hold control characters"; | |
| 26 | ||
| 27 | /** What a forge with no code repository to tell is refused with. */ | |
| 28 | export const NO_CREW_WORDS = "this forge has no repository to tell the crew in"; | |
| 29 | ||
| 30 | /** The words after `tell`, as given; undefined when the command is not a tell. */ | |
| 31 | export function parseTellCommand(command: string | undefined): string | undefined { | |
| 32 | if (command === undefined) return undefined; | |
| 33 | const m = /^tell(?:[ \t]+([\s\S]*))?$/.exec(command.trim()); | |
| 34 | return m === null ? undefined : (m[1] ?? ""); | |
| 35 | } | |
| 36 | ||
| 37 | const refuse = (session: SshSession, words: string): void => { | |
| 38 | session.writeStderr(`forge: ${words}${CRLF}`); | |
| 39 | session.exit(1); | |
| 40 | }; | |
| 41 | ||
| 42 | /** Run one `tell` exec for `user` (undefined: an unknown key). */ | |
| 43 | export async function serveTell( | |
| 44 | app: App, | |
| 45 | session: SshSession, | |
| 46 | user: User | undefined, | |
| 47 | words: string, | |
| 48 | ): Promise<void> { | |
| 49 | if (user === undefined) return refuse(session, ACCOUNT_NEEDED_WORDS); | |
| 50 | if (/[\x00-\x1f\x7f-\x9f\u2028\u2029]/.test(words)) return refuse(session, CONTROL_WORDS); | |
| 51 | const said = words.trim(); | |
| 52 | if (said === "") return refuse(session, SAY_SOMETHING_WORDS); | |
| 53 | const [owner = "", name = ""] = (app.config.forgeRepo ?? "").split("/"); | |
| 54 | const repo = getRepo(app.db, owner, name); | |
| 55 | if (repo === undefined) return refuse(session, NO_CREW_WORDS); | |
| 56 | const told = await tell(app, user, repo, said); | |
| 57 | const answer = tellAnswer(told); | |
| 58 | if (!told.ok) return refuse(session, answer.text); | |
| 59 | const base = app.config.url ?? `http://localhost:${app.config.port ?? 3000}`; | |
| 60 | session.write( | |
| 61 | `${answer.text}${CRLF}${base}/${repo.ownerName}/${repo.name}/issues/${told.value.issue.number}${CRLF}`, | |
| 62 | ); | |
| 63 | session.exit(0); | |
| 64 | } |
src/tui/ssh.ts +14 −1
| @@ -29,6 +29,7 @@ import { appForLogin, attachAppTty } from "../apps/tty.ts"; | ||
| 29 | 29 | import { servePulls } from "../apps/tty_pulls.ts"; |
| 30 | 30 | import type { Terminal } from "./term/terminal.ts"; |
| 31 | 31 | import type { TuiIdentity } from "./session.ts"; |
| 32 | import { parseTellCommand, serveTell } from "./ssh-tell.ts"; | |
| 32 | 33 | |
| 33 | 34 | const CRLF = "\r\n"; |
| 34 | 35 | |
| @@ -199,7 +200,7 @@ const appExecs: Record<string, typeof servePulls> = Object.assign( | ||
| 199 | 200 | |
| 200 | 201 | /** |
| 201 | 202 | * What a forge does with an inbound SSH session: a shell is the TUI, an exec |
| 202 | * is git or nothing. Never throws — a fault here would take the listener with | |
| 203 | * is git, `tell`, or nothing. Never throws — a fault here would take the listener with | |
| 203 | 204 | * it, and one bad client must not. |
| 204 | 205 | */ |
| 205 | 206 | export function forgeSshSession(forge: Forge): (session: SshSession) => void { |
| @@ -224,6 +225,18 @@ export function forgeSshSession(forge: Forge): (session: SshSession) => void { | ||
| 224 | 225 | verb(forge, session, identity.user, door); |
| 225 | 226 | return; |
| 226 | 227 | } |
| 228 | // `tell <words>` on a login that names no app: the crew, in one line. | |
| 229 | const said = parseTellCommand(session.command); | |
| 230 | if (said !== undefined && door === undefined) { | |
| 231 | void serveTell(forge, session, identity.user, said).catch( | |
| 232 | (error: unknown) => { | |
| 233 | forge.log.error("tell over ssh failed", { error: String(error) }); | |
| 234 | session.writeStderr(`forge: internal error${CRLF}`); | |
| 235 | session.exit(1); | |
| 236 | }, | |
| 237 | ); | |
| 238 | return; | |
| 239 | } | |
| 227 | 240 | void serveGitCommand(forge, session, identity.user).catch( |
| 228 | 241 | (error: unknown) => { |
| 229 | 242 | forge.log.error("git over ssh failed", { error: String(error) }); |
test/ssh-tell.test.ts added +190
| @@ -0,0 +1,190 @@ | ||
| 1 | /** | |
| 2 | * `ssh <forge> tell <words>` (#6110): an exec of `tell` drives the same | |
| 3 | * `tell()` as the web composer, as the key's account, and says where it went. | |
| 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 { createRepo } from "../src/actions/repos.ts"; | |
| 11 | import { SAY_SOMETHING_WORDS } from "../src/actions/tell_words.ts"; | |
| 12 | import { MAX_BODY } from "../src/core/issues.ts"; | |
| 13 | import { TOO_LONG_WORDS, UNVERIFIED_WORDS } from "../src/actions/tell.ts"; | |
| 14 | import { addKey, fingerprint, parseAuthorizedKey } from "../src/core/keys.ts"; | |
| 15 | import { getRepo } from "../src/core/repos.ts"; | |
| 16 | import { appForLogin } from "../src/apps/tty.ts"; | |
| 17 | import { createUser } from "../src/core/users.ts"; | |
| 18 | import { forgeSshSession } from "../src/tui/ssh.ts"; | |
| 19 | import { DIRECTION_NOTE } from "../src/crew/direct.ts"; | |
| 20 | import { ACCOUNT_NEEDED_WORDS, CONTROL_WORDS, NO_CREW_WORDS } from "../src/tui/ssh-tell.ts"; | |
| 21 | import { promote } from "./helpers/members.ts"; | |
| 22 | import { quietLogs } from "./helpers/globals.ts"; | |
| 23 | ||
| 24 | quietLogs(); | |
| 25 | ||
| 26 | const KEY_A = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIB2S7bF0R4NmuTUvVJXPPjdI1AF8XHXeYSNfr8DJ5eDo alice"; | |
| 27 | const KEY_B = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGZ0w2ZbWQyPqkYtQmXkQ3q6e5rTn1b3G9m1x2yVd7pH bob"; | |
| 28 | const KEY_STRANGER = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SzdnnnBlJkdPJ9K0iwGlfqsvLiLD3LKh nobody"; | |
| 29 | ||
| 30 | describe("ssh forge tell", () => { | |
| 31 | let dir = ""; | |
| 32 | let app: App; | |
| 33 | const issues = () => | |
| 34 | app.db.query<{ title: string; body: string }, []>("SELECT title, body FROM issue ORDER BY number").all(); | |
| 35 | ||
| 36 | const exec = async (key: string, command: string, username = "whoever") => { | |
| 37 | const parsed = parseAuthorizedKey(key); | |
| 38 | if (!parsed.ok) throw new Error("key"); | |
| 39 | let out = ""; | |
| 40 | let err = ""; | |
| 41 | let code: number | undefined; | |
| 42 | const done = new Promise<void>((resolve) => { | |
| 43 | const session = { | |
| 44 | key: { fingerprint: fingerprint(parsed.value.blob), line: key }, | |
| 45 | username, | |
| 46 | remoteAddress: "203.0.113.9", | |
| 47 | kind: "exec" as const, | |
| 48 | command, | |
| 49 | term: undefined, | |
| 50 | env: {}, | |
| 51 | cols: 0, | |
| 52 | rows: 0, | |
| 53 | widthPx: 0, | |
| 54 | heightPx: 0, | |
| 55 | ended: false, | |
| 56 | pending: 0, | |
| 57 | write(d: Uint8Array | string) { | |
| 58 | out += typeof d === "string" ? d : Buffer.from(d).toString(); | |
| 59 | }, | |
| 60 | writeStderr(d: string) { | |
| 61 | err += d; | |
| 62 | }, | |
| 63 | onData() {}, | |
| 64 | onResize() {}, | |
| 65 | onEof() {}, | |
| 66 | onClose() {}, | |
| 67 | exit(c: number) { | |
| 68 | if (code !== undefined) return; | |
| 69 | code = c; | |
| 70 | resolve(); | |
| 71 | }, | |
| 72 | drained: async () => {}, | |
| 73 | }; | |
| 74 | forgeSshSession(app)(session as never); | |
| 75 | }); | |
| 76 | await done; | |
| 77 | return { out, err, code }; | |
| 78 | }; | |
| 79 | ||
| 80 | beforeAll(async () => { | |
| 81 | dir = await mkdtemp(join(tmpdir(), "forge-ssh-tell-")); | |
| 82 | app = createApp({ dataDir: dir, port: 0, forgeRepo: "alice/forge", url: "https://forge.test" }); | |
| 83 | const mk = async (username: string, trusted: boolean, key: string) => { | |
| 84 | const u = await createUser(app.db, { | |
| 85 | origin: "seed", | |
| 86 | trusted, | |
| 87 | username, | |
| 88 | email: `${username}@forge.local`, | |
| 89 | password: "a-good-passphrase", | |
| 90 | }); | |
| 91 | if (!u.ok) throw new Error(u.error); | |
| 92 | const parsed = parseAuthorizedKey(key); | |
| 93 | if (!parsed.ok) throw new Error("key"); | |
| 94 | addKey(app.db, u.value.id, "laptop", parsed.value); | |
| 95 | return u.value; | |
| 96 | }; | |
| 97 | const alice = await mk("alice", true, KEY_A); | |
| 98 | promote(app.db, await mk("bob", false, KEY_B)); | |
| 99 | promote(app.db, alice); | |
| 100 | const made = await createRepo(app, alice, { owner: "alice", name: "forge" }); | |
| 101 | if (!made.ok) throw new Error(`repo ${made.error}`); | |
| 102 | // Bob may direct the crew here, so only his unproven email stops him. | |
| 103 | app.db.query("UPDATE repo SET crew_outsiders = 1, visibility = 'public' WHERE id = ?1").run(made.value.id); | |
| 104 | }); | |
| 105 | ||
| 106 | afterAll(async () => { | |
| 107 | app.close(); | |
| 108 | await rm(dir, { recursive: true, force: true }); | |
| 109 | }); | |
| 110 | ||
| 111 | test("a known key tells the crew, with the words as given, and is told where it went", async () => { | |
| 112 | const r = await exec(KEY_A, "tell make $HOME `id` blue; rm -rf /"); | |
| 113 | expect(r.code).toBe(0); | |
| 114 | expect(r.err).toBe(""); | |
| 115 | expect(r.out).toContain("https://forge.test/alice/forge/issues/1"); | |
| 116 | expect(r.out.split("\r\n")[0]!.length).toBeGreaterThan(0); | |
| 117 | expect(issues()).toEqual([{ title: "make $HOME `id` blue; rm -rf /", body: "" }]); | |
| 118 | }); | |
| 119 | ||
| 120 | test("an unknown key is refused and records nothing", async () => { | |
| 121 | const r = await exec(KEY_STRANGER, "tell hello"); | |
| 122 | expect(r.code).toBe(1); | |
| 123 | expect(r.err).toContain(ACCOUNT_NEEDED_WORDS); | |
| 124 | expect(r.out).toBe(""); | |
| 125 | expect(issues().length).toBe(1); | |
| 126 | }); | |
| 127 | ||
| 128 | test("empty words, too many words, control characters and an unverified account record nothing", async () => { | |
| 129 | const empty = await exec(KEY_A, "tell "); | |
| 130 | expect([empty.code, empty.err]).toEqual([1, `forge: ${SAY_SOMETHING_WORDS}\r\n`]); | |
| 131 | const bare = await exec(KEY_A, "tell"); | |
| 132 | expect(bare.err).toContain(SAY_SOMETHING_WORDS); | |
| 133 | const long = await exec(KEY_A, `tell ${"x".repeat(MAX_BODY + 1)}`); | |
| 134 | expect([long.code, long.err]).toEqual([1, `forge: ${TOO_LONG_WORDS}\r\n`]); | |
| 135 | for (const bad of ["tell a\x1b[2Jb", "tell a\nb", "tell a\x00b"]) { | |
| 136 | const r = await exec(KEY_A, bad); | |
| 137 | expect([r.code, r.err]).toEqual([1, `forge: ${CONTROL_WORDS}\r\n`]); | |
| 138 | } | |
| 139 | const unverified = await exec(KEY_B, "tell hello"); | |
| 140 | expect([unverified.code, unverified.err]).toEqual([1, `forge: ${UNVERIFIED_WORDS}\r\n`]); | |
| 141 | expect(issues().length).toBe(1); | |
| 142 | }); | |
| 143 | ||
| 144 | test("git commands and other words are still git or nothing", async () => { | |
| 145 | const r = await exec(KEY_A, "telling hello"); | |
| 146 | expect(r.code).toBe(1); | |
| 147 | expect(r.err).toContain("only git"); | |
| 148 | expect(issues().length).toBe(1); | |
| 149 | }); | |
| 150 | ||
| 151 | test("a forge with no code repository refuses, and records nothing", async () => { | |
| 152 | const named = app.config.forgeRepo; | |
| 153 | try { | |
| 154 | for (const gone of [undefined, "alice/missing"]) { | |
| 155 | if (gone === undefined) delete app.config.forgeRepo; | |
| 156 | else app.config.forgeRepo = gone; | |
| 157 | const r = await exec(KEY_A, "tell hello"); | |
| 158 | expect([r.code, r.err]).toEqual([1, `forge: ${NO_CREW_WORDS}\r\n`]); | |
| 159 | } | |
| 160 | } finally { | |
| 161 | if (named !== undefined) app.config.forgeRepo = named; | |
| 162 | } | |
| 163 | expect(issues().length).toBe(1); | |
| 164 | }); | |
| 165 | ||
| 166 | test("a verified account that may not direct the crew is told so, and records nothing", async () => { | |
| 167 | app.db.query("UPDATE repo SET crew_outsiders = 0").run(); | |
| 168 | app.db.query("UPDATE email_check SET verified_at = ?1 WHERE verified_at IS NULL").run(new Date().toISOString()); | |
| 169 | try { | |
| 170 | const r = await exec(KEY_B, "tell hello"); | |
| 171 | expect([r.code, r.err]).toEqual([1, `forge: ${DIRECTION_NOTE}\r\n`]); | |
| 172 | } finally { | |
| 173 | app.db.query("UPDATE repo SET crew_outsiders = 1").run(); | |
| 174 | app.db.query("UPDATE email_check SET verified_at = NULL WHERE user_id = (SELECT id FROM user WHERE username = 'bob')").run(); | |
| 175 | } | |
| 176 | expect(issues().length).toBe(1); | |
| 177 | }); | |
| 178 | ||
| 179 | test("an app's login is never a tell: it goes the git way and records nothing", async () => { | |
| 180 | const repo = getRepo(app.db, "alice", "forge")!; | |
| 181 | app.db | |
| 182 | .query("INSERT INTO app (id, repo_id, slug, access, preview_data) VALUES ('app-tell-1', ?1, 'notes', 'public', 'copy')") | |
| 183 | .run(repo.id); | |
| 184 | expect(appForLogin(app, "notes")).toBeDefined(); | |
| 185 | const r = await exec(KEY_A, "tell hello", "notes"); | |
| 186 | expect(r.code).toBe(1); | |
| 187 | expect(r.err).toContain("only git"); | |
| 188 | expect(issues().length).toBe(1); | |
| 189 | }); | |
| 190 | }); |
new above
offline
The
NO_CREW_WORDSrefusal is untested. Add one test withforgeRepounset (or naming a missing repo) that asserts exit 1 and nothing recorded.