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

`ssh <app>@<forge> tell <words>` tells that app's crew, and the tell records the app as where it was said #6172

Merged opened this

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

Merged

scout/forge:scout/6113-ssh-app-forge-tell-words-tells-that-app → main 1 commit fork 2 threads · 2 unresolved

  • Merged. scout/6113-ssh-app-forge-tell-words-tells-that-app was merged into main as a0474f6.

Review

Sign in to review this change.
5 files changed +94 −18
README.md +2
@@ -328,6 +328,8 @@ ssh -p 2222 localhost tell make the header blue # tell the crew in one line
328328
329329 `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.)
330330
331 On an app's login, `ssh -p 2222 notes@localhost tell make the header blue` goes through the app terminal's gate (an app you may not enter answers `no app named notes`, and nothing is recorded) and tells the crew of the app's repository, with the app's production address as where it was said: the issue body and `/asked` show `Told from <address>`.
332
331333 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`).
332334
333335 A big repository clones over either transport. To skip its large files until a
src/actions/tell.ts +10 −1
@@ -60,6 +60,13 @@ export function parseFrom(raw: unknown): string | null | undefined {
6060 return raw;
6161 }
6262
63 /**
64 * An app's own address as a place a tell was said: scheme and host (and
65 * port) only. Only the app terminal's door builds one and says `fromApp`; every other
66 * caller takes `parseFrom`'s paths alone.
67 */
68 const APP_ADDRESS = /^https?:\/\/[a-z0-9][a-z0-9.-]*(?::\d{1,5})?$/i;
69
6370 /** What a bad `from` is refused with, at every door. */
6471 export const FROM_WORDS =
6572 "from is a path on this forge, like /alice/notes/pulls/3, at most 512 characters";
@@ -87,6 +94,7 @@ export async function tell(
8794 said: string,
8895 variants = 1,
8996 from: string | null = null,
97 fromApp = false,
9098 ): Promise<Result<Told, TellError>> {
9199 // A tell is a run: only someone who may direct the crew here says one,
92100 // and a refused tell opens nothing (`crew/direct.ts`).
@@ -100,7 +108,8 @@ export async function tell(
100108 if (text === "") return err("empty");
101109 // One long line becomes only a title, so the body cap never sees it.
102110 if (text.length > MAX_BODY) return err("body_too_long");
103 if (from !== null && parseFrom(from) !== from) return err("bad_from");
111 if (from !== null && parseFrom(from) !== from && !(fromApp && APP_ADDRESS.test(from)))
112 return err("bad_from");
104113 const firstLine = text.split(/\n/)[0]!.trim();
105114 const title =
106115 firstLine.length <= 80 ? firstLine : `${firstLine.slice(0, 77).trimEnd()}…`;
src/tui/ssh-tell.ts +36 −8
@@ -1,17 +1,23 @@
11 /**
22 * `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
3 * exec of `tell` calls the same `tell()` the web composer
44 * 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.
5 * `from` null (on an app's login: through the app terminal's gate, to the
6 * app's repository, `from` the app's production address), and prints the told line and the full address of the issue.
67 * The words are the rest of the command line as given: no shell, no
78 * expansion, no `eval`; a control character is refused before anything runs.
89 */
910
1011 import { tell } from "../actions/tell.ts";
1112 import { SAY_SOMETHING_WORDS } from "../actions/tell_words.ts";
13 import { appGate, ttyRefusalText } from "../apps/tty.ts";
14 import { appsRuntime } from "../apps/runtime.ts";
1215 import type { App } from "../app.ts";
13 import { getRepo } from "../core/repos.ts";
16 import type { AppRecord } from "../core/apps.ts";
17 import { getRepo, getRepoById } from "../core/repos.ts";
18 import type { Repo } from "../core/repos.ts";
1419 import type { User } from "../core/users.ts";
20 import type { EnvName } from "../apps/data.ts";
1521 import type { SshSession } from "../ssh/server.ts";
1622 import { tellAnswer } from "./screens/tell.ts";
1723
@@ -45,20 +51,42 @@ export async function serveTell(
4551 session: SshSession,
4652 user: User | undefined,
4753 words: string,
54 door?: { app: AppRecord; env: EnvName },
55 ): Promise<void> {
56 // On an app's login the app terminal's gate decides first, in its words:
57 // an app the viewer cannot enter is not there, and nothing is recorded.
58 if (door !== undefined) {
59 const refusal = appGate(app, user, door);
60 const crew = getRepoById(app.db, door.app.repoId);
61 const runtime = appsRuntime(app);
62 if (refusal !== undefined || crew === undefined || runtime === undefined)
63 return refuse(session, ttyRefusalText(refusal ?? "not_found", session.username));
64 return told(app, session, user, words, crew, runtime.urlFor(door.app, "production"));
65 }
66 return told(app, session, user, words, undefined, null);
67 }
68
69 async function told(
70 app: App,
71 session: SshSession,
72 user: User | undefined,
73 words: string,
74 appRepo: Repo | undefined,
75 from: string | null,
4876 ): Promise<void> {
4977 if (user === undefined) return refuse(session, ACCOUNT_NEEDED_WORDS);
5078 if (/[\x00-\x1f\x7f-\x9f\u2028\u2029]/.test(words)) return refuse(session, CONTROL_WORDS);
5179 const said = words.trim();
5280 if (said === "") return refuse(session, SAY_SOMETHING_WORDS);
5381 const [owner = "", name = ""] = (app.config.forgeRepo ?? "").split("/");
54 const repo = getRepo(app.db, owner, name);
82 const repo = appRepo ?? getRepo(app.db, owner, name);
5583 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);
84 const result = await tell(app, user, repo, said, 1, from, from !== null);
85 const answer = tellAnswer(result);
86 if (!result.ok) return refuse(session, answer.text);
5987 const base = app.config.url ?? `http://localhost:${app.config.port ?? 3000}`;

fromApp is derived here from from !== null. Pass it explicitly (or pass the door) so a later caller that sets from cannot silently gain the app-address allowance.

6088 session.write(
61 `${answer.text}${CRLF}${base}/${repo.ownerName}/${repo.name}/issues/${told.value.issue.number}${CRLF}`,
89 `${answer.text}${CRLF}${base}/${repo.ownerName}/${repo.name}/issues/${result.value.issue.number}${CRLF}`,
6290 );
6391 session.exit(0);
6492 }
src/tui/ssh.ts +4 −3
@@ -225,10 +225,11 @@ export function forgeSshSession(forge: Forge): (session: SshSession) => void {
225225 verb(forge, session, identity.user, door);
226226 return;
227227 }
228 // `tell <words>` on a login that names no app: the crew, in one line.
228 // `tell <words>`: the crew, in one line. On an app's login it is the
229 // app's crew, said from the app (through the terminal's own gate).
229230 const said = parseTellCommand(session.command);
230 if (said !== undefined && door === undefined) {
231 void serveTell(forge, session, identity.user, said).catch(
231 if (said !== undefined) {
232 void serveTell(forge, session, identity.user, said, door).catch(
232233 (error: unknown) => {
233234 forge.log.error("tell over ssh failed", { error: String(error) });
234235 session.writeStderr(`forge: internal error${CRLF}`);
test/ssh-tell.test.ts +42 −6
@@ -13,6 +13,7 @@ import { MAX_BODY } from "../src/core/issues.ts";
1313 import { TOO_LONG_WORDS, UNVERIFIED_WORDS } from "../src/actions/tell.ts";
1414 import { addKey, fingerprint, parseAuthorizedKey } from "../src/core/keys.ts";
1515 import { getRepo } from "../src/core/repos.ts";
16 import { AppsRuntime } from "../src/apps/runtime.ts";
1617 import { appForLogin } from "../src/apps/tty.ts";
1718 import { createUser } from "../src/core/users.ts";
1819 import { forgeSshSession } from "../src/tui/ssh.ts";
@@ -176,15 +177,50 @@ describe("ssh forge tell", () => {
176177 expect(issues().length).toBe(1);
177178 });
178179
179 test("an app's login is never a tell: it goes the git way and records nothing", async () => {
180 test("on an app's login a tell goes through the app gate to the app's crew, said from the app", async () => {
180181 const repo = getRepo(app.db, "alice", "forge")!;
181182 app.db
182183 .query("INSERT INTO app (id, repo_id, slug, access, preview_data) VALUES ('app-tell-1', ?1, 'notes', 'public', 'copy')")
183184 .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);
185 const record = appForLogin(app, "notes")!.app;
186 const address = new AppsRuntime(app, { domain: "forge.test", scheme: "https", publicPort: 443, forgeUrl: "https://forge.test" }).urlFor(record, "production");
187 expect(address).toBe("https://notes.forge.test");
188 const before = issues().length;
189 // A writer.
190 const w = await exec(KEY_A, "tell make the header blue", "notes");
191 expect([w.code, w.err]).toEqual([0, ""]);
192 expect(w.out).toContain("https://forge.test/alice/forge/issues/");
193 const row = app.db.query<{ body: string }, []>("SELECT body FROM issue ORDER BY number DESC LIMIT 1").get()!;
194 expect(row.body).toContain(`Told from [${address}](${address})`);
195 expect(issues().length).toBe(before + 1);
196 // A reader who may tell: verified, and the crew takes outsiders.
197 app.db.query("UPDATE email_check SET verified_at = ?1 WHERE user_id = (SELECT id FROM user WHERE username = 'bob')").run(new Date().toISOString());
198 const r = await exec(KEY_B, "tell and the footer", "notes");
199 expect([r.code, r.err]).toEqual([0, ""]);
200 expect(issues().length).toBe(before + 2);
201 // /asked shows it.
202 const asked = app.db.query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM issue WHERE body LIKE ?1").get(`%${address}%`)!;
203 expect(asked.n).toBe(2);
204 // Words still refused on the app's login are refused as before.
205 const empty = await exec(KEY_A, "tell", "notes");
206 expect([empty.code, empty.err]).toEqual([1, `forge: ${SAY_SOMETHING_WORDS}\r\n`]);
207 expect(issues().length).toBe(before + 2);
208 });
209
210 test("a viewer who cannot enter the app gets `no app named`, and nothing is recorded", async () => {
211 const alice = getRepo(app.db, "alice", "forge")!;
212 app.db.query("UPDATE repo SET visibility = 'private' WHERE id = ?1").run(alice.id);
213 app.db.query("UPDATE repo SET crew_outsiders = 0 WHERE id = ?1").run(alice.id);
214 const before = issues().length;
215 try {
216 const bob = await exec(KEY_B, "tell hello", "notes");
217 expect([bob.code, bob.err, bob.out]).toEqual([1, "forge: no app named notes\r\n", ""]);
218 const stranger = await exec(KEY_STRANGER, "tell hello", "notes");
219 expect(stranger.code).toBe(1);
220 expect(stranger.err).toContain("notes");
221 expect(issues().length).toBe(before);
222 } finally {

expect(stranger.err).toContain("notes") is weak. Assert the exact gate refusal line so a wording regression fails.

223 app.db.query("UPDATE repo SET visibility = 'public', crew_outsiders = 1 WHERE id = ?1").run(alice.id);
224 }
189225 });
190226 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs