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

Merged opened this

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

Merged

scout/forge:scout/6110-ssh-forge-tell-words-tells-the-crew-in-o → main 3 commits fork 2 threads · 2 unresolved

  • Merged. scout/6110-ssh-forge-tell-words-tells-the-crew-in-o was merged into main as 83fd5fb.

Review

Sign in to review this change.
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.
305305 ```sh
306306 ssh -p 2222 localhost # the terminal UI
307307 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
308309 ```
309310
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
310313 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`).
311314
312315 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);

The NO_CREW_WORDS refusal is untested. Add one test with forgeRepo unset (or naming a missing repo) that asserts exit 1 and nothing recorded.

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";
2929 import { servePulls } from "../apps/tty_pulls.ts";
3030 import type { Terminal } from "./term/terminal.ts";
3131 import type { TuiIdentity } from "./session.ts";
32 import { parseTellCommand, serveTell } from "./ssh-tell.ts";
3233
3334 const CRLF = "\r\n";
3435
@@ -199,7 +200,7 @@ const appExecs: Record<string, typeof servePulls> = Object.assign(
199200
200201 /**
201202 * 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
203204 * it, and one bad client must not.
204205 */
205206 export function forgeSshSession(forge: Forge): (session: SshSession) => void {
@@ -224,6 +225,18 @@ export function forgeSshSession(forge: Forge): (session: SshSession) => void {
224225 verb(forge, session, identity.user, door);
225226 return;
226227 }
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 }
227240 void serveGitCommand(forge, session, identity.user).catch(
228241 (error: unknown) => {
229242 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs