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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6113-ssh-app-forge-tell-words-tells-that-app → main 1 commit fork 2 threads · 2 unresolved
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 | ||
| 328 | 328 | |
| 329 | 329 | `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.) |
| 330 | 330 | |
| 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 | ||
| 331 | 333 | 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`). |
| 332 | 334 | |
| 333 | 335 | 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 { | ||
| 60 | 60 | return raw; |
| 61 | 61 | } |
| 62 | 62 | |
| 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 | ||
| 63 | 70 | /** What a bad `from` is refused with, at every door. */ |
| 64 | 71 | export const FROM_WORDS = |
| 65 | 72 | "from is a path on this forge, like /alice/notes/pulls/3, at most 512 characters"; |
| @@ -87,6 +94,7 @@ export async function tell( | ||
| 87 | 94 | said: string, |
| 88 | 95 | variants = 1, |
| 89 | 96 | from: string | null = null, |
| 97 | fromApp = false, | |
| 90 | 98 | ): Promise<Result<Told, TellError>> { |
| 91 | 99 | // A tell is a run: only someone who may direct the crew here says one, |
| 92 | 100 | // and a refused tell opens nothing (`crew/direct.ts`). |
| @@ -100,7 +108,8 @@ export async function tell( | ||
| 100 | 108 | if (text === "") return err("empty"); |
| 101 | 109 | // One long line becomes only a title, so the body cap never sees it. |
| 102 | 110 | 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"); | |
| 104 | 113 | const firstLine = text.split(/\n/)[0]!.trim(); |
| 105 | 114 | const title = |
| 106 | 115 | firstLine.length <= 80 ? firstLine : `${firstLine.slice(0, 77).trimEnd()}…`; |
src/tui/ssh-tell.ts +36 −8
| @@ -1,17 +1,23 @@ | ||
| 1 | 1 | /** |
| 2 | 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 | |
| 3 | * exec of `tell` calls the same `tell()` the web composer | |
| 4 | 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. | |
| 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. | |
| 6 | 7 | * The words are the rest of the command line as given: no shell, no |
| 7 | 8 | * expansion, no `eval`; a control character is refused before anything runs. |
| 8 | 9 | */ |
| 9 | 10 | |
| 10 | 11 | import { tell } from "../actions/tell.ts"; |
| 11 | 12 | import { SAY_SOMETHING_WORDS } from "../actions/tell_words.ts"; |
| 13 | import { appGate, ttyRefusalText } from "../apps/tty.ts"; | |
| 14 | import { appsRuntime } from "../apps/runtime.ts"; | |
| 12 | 15 | 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"; | |
| 14 | 19 | import type { User } from "../core/users.ts"; |
| 20 | import type { EnvName } from "../apps/data.ts"; | |
| 15 | 21 | import type { SshSession } from "../ssh/server.ts"; |
| 16 | 22 | import { tellAnswer } from "./screens/tell.ts"; |
| 17 | 23 | |
| @@ -45,20 +51,42 @@ export async function serveTell( | ||
| 45 | 51 | session: SshSession, |
| 46 | 52 | user: User | undefined, |
| 47 | 53 | 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, | |
| 48 | 76 | ): Promise<void> { |
| 49 | 77 | if (user === undefined) return refuse(session, ACCOUNT_NEEDED_WORDS); |
| 50 | 78 | if (/[\x00-\x1f\x7f-\x9f\u2028\u2029]/.test(words)) return refuse(session, CONTROL_WORDS); |
| 51 | 79 | const said = words.trim(); |
| 52 | 80 | if (said === "") return refuse(session, SAY_SOMETHING_WORDS); |
| 53 | 81 | const [owner = "", name = ""] = (app.config.forgeRepo ?? "").split("/"); |
| 54 | const repo = getRepo(app.db, owner, name); | |
| 82 | const repo = appRepo ?? getRepo(app.db, owner, name); | |
| 55 | 83 | 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); | |
| 59 | 87 | const base = app.config.url ?? `http://localhost:${app.config.port ?? 3000}`; |
| 60 | 88 | 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}`, | |
| 62 | 90 | ); |
| 63 | 91 | session.exit(0); |
| 64 | 92 | } |
src/tui/ssh.ts +4 −3
| @@ -225,10 +225,11 @@ export function forgeSshSession(forge: Forge): (session: SshSession) => void { | ||
| 225 | 225 | verb(forge, session, identity.user, door); |
| 226 | 226 | return; |
| 227 | 227 | } |
| 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). | |
| 229 | 230 | 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( | |
| 232 | 233 | (error: unknown) => { |
| 233 | 234 | forge.log.error("tell over ssh failed", { error: String(error) }); |
| 234 | 235 | 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"; | ||
| 13 | 13 | import { TOO_LONG_WORDS, UNVERIFIED_WORDS } from "../src/actions/tell.ts"; |
| 14 | 14 | import { addKey, fingerprint, parseAuthorizedKey } from "../src/core/keys.ts"; |
| 15 | 15 | import { getRepo } from "../src/core/repos.ts"; |
| 16 | import { AppsRuntime } from "../src/apps/runtime.ts"; | |
| 16 | 17 | import { appForLogin } from "../src/apps/tty.ts"; |
| 17 | 18 | import { createUser } from "../src/core/users.ts"; |
| 18 | 19 | import { forgeSshSession } from "../src/tui/ssh.ts"; |
| @@ -176,15 +177,50 @@ describe("ssh forge tell", () => { | ||
| 176 | 177 | expect(issues().length).toBe(1); |
| 177 | 178 | }); |
| 178 | 179 | |
| 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 () => { | |
| 180 | 181 | const repo = getRepo(app.db, "alice", "forge")!; |
| 181 | 182 | app.db |
| 182 | 183 | .query("INSERT INTO app (id, repo_id, slug, access, preview_data) VALUES ('app-tell-1', ?1, 'notes', 'public', 'copy')") |
| 183 | 184 | .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 | } | |
| 189 | 225 | }); |
| 190 | 226 | }); |
new above
offline
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.