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

`ssh <app>@<forge> pulls` prints the app's open pulls a viewer may see, newest first, with each head's check word #6116

Merged opened this

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

Merged

scout/forge:scout/6109-ssh-app-forge-pulls-prints-the-app-s-ope → main 1 commit fork

  • Merged. scout/6109-ssh-app-forge-pulls-prints-the-app-s-ope was merged into main as 454b8dd.

Review

Sign in to review this change.
6 files changed +367 −10
README.md +2
@@ -307,6 +307,8 @@ ssh -p 2222 localhost # the terminal UI
307307 git clone ssh://localhost:2222/you/hello.git # git, same port
308308 ```
309309
310 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
310312 A big repository clones over either transport. To skip its large files until a
311313 checkout needs them, run `git clone --filter=blob:none <url>`. See
312314 [`docs/GIT.md`](docs/GIT.md).
src/apps/door_pulls.ts added +70
@@ -0,0 +1,70 @@
1 /**
2 * An app's open pulls, for a door that cannot draw a page (#6109): number,
3 * title, head and the state of the newest check on it, newest first, in one
4 * indexed read. Who may see which is the pages' own rule: a repository you
5 * cannot read has none, a sealed pull is its author's, a writer's and the
6 * crew's, and a suspended account is a guest (`core/sealed.ts`).
7 */
8 import type { Database } from "bun:sqlite";
9 import { canRead, type Repo } from "../core/repos.ts";
10 import type { RunState } from "../core/runs.ts";
11 import { canSeeSealed } from "../core/sealed.ts";
12 import { isActive, type User } from "../core/users.ts";
13
14 export interface DoorPull {
15 readonly number: number;
16 readonly title: string;
17 readonly headOid: string;
18 /** The newest check or deploy run of this pull, whatever head it ran. */
19 readonly run: { state: RunState; commitOid: string } | undefined;
20 }
21
22 /**
23 * ?1 repo, ?2 whether sealed pulls are all visible, ?3 the viewer's id
24 * (empty for a guest), ?4 the limit. Walks `issue_repo_kind_number` newest
25 * first and stops at the limit; each run is one probe of `run_issue`.
26 */
27 export const OPEN_PULLS_SQL = `
28 SELECT i.number AS number, i.title AS title, p.head_oid AS head_oid,
29 r.state AS run_state, r.commit_oid AS run_commit
30 FROM issue i
31 JOIN pull p ON p.issue_id = i.id
32 LEFT JOIN run r ON r.id = (
33 SELECT id FROM run
34 WHERE issue_id = i.id AND kind IN ('deploy','check')
35 ORDER BY id DESC LIMIT 1)
36 WHERE i.repo_id = ?1 AND i.kind = 'pull' AND i.state = 'open'
37 AND (i.sealed = 0 OR ?2 = 1 OR i.author_id = ?3)
38 ORDER BY i.number DESC
39 LIMIT ?4`;
40
41 interface Row {
42 number: number;
43 title: string;
44 head_oid: string;
45 run_state: RunState | null;
46 run_commit: string | null;
47 }
48
49 export function openPulls(
50 db: Database,
51 repo: Repo,
52 viewer: User | undefined,
53 limit = 5,
54 ): DoorPull[] {
55 const who = viewer !== undefined && isActive(viewer) ? viewer : undefined;
56 if (!canRead(db, repo, who)) return [];
57 const n = Math.min(Math.max(Math.trunc(limit) || 0, 0), 50);
58 const rows = db
59 .query<Row, [string, number, string, number]>(OPEN_PULLS_SQL)
60 .all(repo.id, canSeeSealed(db, repo, who) ? 1 : 0, who?.id ?? "", n);
61 return rows.map((r) => ({
62 number: r.number,
63 title: r.title,
64 headOid: r.head_oid,
65 run:
66 r.run_state === null
67 ? undefined
68 : { state: r.run_state, commitOid: r.run_commit ?? "" },
69 }));
70 }
src/apps/tty.ts +23 −10
@@ -105,6 +105,26 @@ export function ttyRefusalText(
105105 }
106106 }
107107
108 /**
109 * Who may open an app's terminal or ask it a question over ssh: the one
110 * gate, so every door refuses in the same words. An app you may not see is
111 * not there.
112 */
113 export function appGate(
114 forge: Forge,
115 user: User | undefined,
116 target: { app: AppRecord },
117 ): TtyRefusal | undefined {
118 const repo = getRepoById(forge.db, target.app.repoId);
119 if (!repo || !canRead(forge.db, repo, user)) return "not_found";
120 if (target.app.access === "private" && user === undefined) return "private";
121 // A private app's terminal is its writers' and crew's: a person the app
122 // names may enter the app, not its terminal (#752).
123 if (!canEnterApp(forge.db, repo, target.app, user, { named: false }))
124 return "not_found";
125 return undefined;
126 }
127
108128 /**
109129 * Run the app's terminal on `host` until it exits. Every reason it cannot
110130 * is a refusal the caller says in words; a program that ran resolves with
@@ -116,16 +136,9 @@ export async function runAppTty(
116136 user: User | undefined,
117137 target: { app: AppRecord; env: EnvName },
118138 ): Promise<Result<number, { refusal: TtyRefusal; detail: string }>> {
119 const repo = getRepoById(forge.db, target.app.repoId);
120 // The same conflation as everywhere: an app you may not see is not there.
121 if (!repo || !canRead(forge.db, repo, user))
122 return err({ refusal: "not_found", detail: "" });
123 if (target.app.access === "private" && user === undefined)
124 return err({ refusal: "private", detail: "" });
125 // A private app's terminal is its writers' and crew's: a person the app
126 // names may enter the app, not its terminal (#752).
127 if (!canEnterApp(forge.db, repo, target.app, user, { named: false }))
128 return err({ refusal: "not_found", detail: "" });
139 const refused = appGate(forge, user, target);
140 if (refused !== undefined) return err({ refusal: refused, detail: "" });
141 const repo = getRepoById(forge.db, target.app.repoId)!;
129142 if (host.term === undefined) return err({ refusal: "no_pty", detail: "" });
130143 const runtime = appsRuntime(forge);
131144 // A terminal opened on a sleeping environment wakes it first.
src/apps/tty_pulls.ts added +46
@@ -0,0 +1,46 @@
1 /**
2 * `ssh <app>@<forge> pulls`: the app's open pulls, one plain line each —
3 * `#n title · <check word>` — for a viewer the key makes. No pty, and the
4 * same gate as the app's terminal (`appGate`), so a refusal is the same
5 * sentence.
6 */
7 import type { App as Forge } from "../app.ts";
8 import type { AppRecord } from "../core/apps.ts";
9 import { getRepoById } from "../core/repos.ts";
10 import type { User } from "../core/users.ts";
11 import type { SshSession } from "../ssh/server.ts";
12 import { checkWord } from "./check_word.ts";
13 import type { EnvName } from "./data.ts";
14 import { openPulls } from "./door_pulls.ts";
15 import { appGate, ttyRefusalText } from "./tty.ts";
16
17 /** A title as one safe line: control characters (escapes, newlines) gone, bounded. */
18 export function plainTitle(title: string): string {
19 return title
20 .replace(/[\p{Cc}\p{Cf}\u2028\u2029]/gu, "")
21 .trim()
22 .slice(0, 120);
23 }
24
25 export function servePulls(
26 forge: Forge,
27 session: SshSession,
28 user: User | undefined,
29 target: { app: AppRecord; env: EnvName },
30 ): void {
31 const refusal = appGate(forge, user, target);
32 const repo = getRepoById(forge.db, target.app.repoId);
33 if (refusal !== undefined || repo === undefined) {
34 session.writeStderr(
35 `forge: ${ttyRefusalText(refusal ?? "not_found", session.username)}\r\n`,
36 );
37 session.exit(1);
38 return;
39 }
40 const lines = openPulls(forge.db, repo, user).map(
41 (p) =>
42 `#${p.number} ${plainTitle(p.title)} · ${checkWord(p.run, p.headOid)}\n`,
43 );
44 session.write(new TextEncoder().encode(lines.join("")));
45 session.exit(0);
46 }
src/tui/ssh.ts +14
@@ -26,6 +26,7 @@ import { serveSsh, type SshServer, type SshSession } from "../ssh/server.ts";
2626 import { err, ok, type Result } from "../kernel/result.ts";
2727 import { createForgeApp } from "./forge.ts";
2828 import { appForLogin, attachAppTty } from "../apps/tty.ts";
29 import { servePulls } from "../apps/tty_pulls.ts";
2930 import type { Terminal } from "./term/terminal.ts";
3031 import type { TuiIdentity } from "./session.ts";
3132
@@ -190,6 +191,12 @@ export function endOnSuspension(
190191 });
191192 }
192193
194 /** The execs an app's login answers besides git, by command word. */
195 const appExecs: Record<string, typeof servePulls> = Object.assign(
196 Object.create(null) as Record<string, typeof servePulls>,
197 { pulls: servePulls },
198 );
199
193200 /**
194201 * What a forge does with an inbound SSH session: a shell is the TUI, an exec
195202 * is git or nothing. Never throws — a fault here would take the listener with
@@ -210,6 +217,13 @@ export function forgeSshSession(forge: Forge): (session: SshSession) => void {
210217 else attachTui(forge, session, identity);
211218 return;
212219 }
220 // An exec on an app's login that git does not speak: a small table.
221 const door = appForLogin(forge, session.username);
222 const verb = session.command === undefined ? undefined : appExecs[session.command.trim()];
223 if (door !== undefined && verb !== undefined) {
224 verb(forge, session, identity.user, door);
225 return;
226 }
213227 void serveGitCommand(forge, session, identity.user).catch(
214228 (error: unknown) => {
215229 forge.log.error("git over ssh failed", { error: String(error) });
test/door-pulls.test.ts added +212
@@ -0,0 +1,212 @@
1 /**
2 * `ssh <app>@<forge> pulls` (#6109): the module that reads an app's open
3 * pulls for a viewer, and the exec that prints them, driven through
4 * `forgeSshSession` with a fake session.
5 */
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { newApp } from "../src/actions/apps.ts";
11 import { createApp, type App } from "../src/app.ts";
12 import { OPEN_PULLS_SQL, openPulls } from "../src/apps/door_pulls.ts";
13 import { addKey, fingerprint, parseAuthorizedKey } from "../src/core/keys.ts";
14 import type { Repo } from "../src/core/repos.ts";
15 import { createUser, type User } from "../src/core/users.ts";
16 import { forgeSshSession } from "../src/tui/ssh.ts";
17 import { promote } from "./helpers/members.ts";
18
19 const KEYS = [
20 "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIB2S7bF0R4NmuTUvVJXPPjdI1AF8XHXeYSNfr8DJ5eDo bob",
21 "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGq5xkQ8iVtfZ3pQ5m9X1Jw0m1dZf0QYbPzv6H7eYv2k eve",
22 ];
23
24 describe("an app's open pulls over ssh", () => {
25 let dataDir: string;
26 let app: App;
27 let owner: User;
28 let author: User;
29 let stranger: User;
30 let repo: Repo;
31 let privateRepo: Repo;
32
33 const person = async (username: string, adminIfFirst = false) => {
34 const made = await createUser(app.db, {
35 origin: "seed",
36 trusted: true,
37 username,
38 email: `${username}@f.dev`,
39 password: "a-good-passphrase",
40 adminIfFirst,
41 });
42 if (!made.ok) throw new Error(made.error);
43 promote(app.db, made.value);
44 return made.value;
45 };
46
47 /** A pull row without a branch behind it: the module reads rows only. */
48 const pull = (r: Repo, n: number, by: User, title: string, sealed = 0) => {
49 const id = crypto.randomUUID();
50 app.db
51 .query(
52 "INSERT INTO issue (id, repo_id, number, kind, title, author_id, sealed) VALUES (?, ?, ?, 'pull', ?, ?, ?)",
53 )
54 .run(id, r.id, n, title, by.id, sealed);
55 app.db
56 .query(
57 "INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid) VALUES (?, 'main', ?, 'b', ?)",
58 )
59 .run(id, `h${n}`, `head${n}`);
60 return id;
61 };
62
63 const run = (issueId: string, r: Repo, state: string, commit: string) =>
64 app.db
65 .query(
66 "INSERT INTO run (id, repo_id, kind, issue_id, commit_oid, state) VALUES (?, ?, 'check', ?, ?, ?)",
67 )
68 .run(crypto.randomUUID(), r.id, issueId, commit, state);
69
70 /** Exec `pulls` on `login` as the holder of KEYS[i] (none: an unknown key). */
71 async function exec(login: string, key: number, command = "pulls") {
72 const parsed = parseAuthorizedKey(KEYS[key]!);
73 if (!parsed.ok) throw new Error("key");
74 let out = "";
75 let err = "";
76 let code: number | undefined;
77 const closers: (() => void)[] = [];
78 const session = {
79 key: { fingerprint: fingerprint(parsed.value.blob), line: KEYS[key]! },
80 username: login,
81 remoteAddress: "203.0.113.9",
82 kind: "exec" as const,
83 command,
84 term: undefined,
85 env: {},
86 cols: 0,
87 rows: 0,
88 widthPx: 0,
89 heightPx: 0,
90 ended: false,
91 pending: 0,
92 write: (b: Uint8Array) => void (out += new TextDecoder().decode(b)),
93 writeStderr: (s: string | Uint8Array) =>
94 void (err += typeof s === "string" ? s : new TextDecoder().decode(s)),
95 onData() {},
96 onResize() {},
97 onEof() {},
98 onClose: (cb: () => void) => void closers.push(cb),
99 exit(c: number) {
100 if (code !== undefined) return;
101 code = c;
102 for (const cb of closers) cb();
103 },
104 drained: async () => {},
105 };
106 forgeSshSession(app)(session as never);
107 for (let i = 0; i < 50 && code === undefined; i++) await Bun.sleep(10);
108 return { out, err, code };
109 }
110
111 beforeAll(async () => {
112 dataDir = await mkdtemp(join(tmpdir(), "forge-door-pulls-"));
113 app = createApp({ dataDir, port: 0 });
114 owner = await person("olive", true);
115 author = await person("bob");
116 stranger = await person("eve");
117 for (const [u, k] of [
118 [author, 0],
119 [stranger, 1],
120 ] as const) {
121 const parsed = parseAuthorizedKey(KEYS[k]!);
122 if (!parsed.ok) throw new Error("key");
123 expect(addKey(app.db, u.id, "k", parsed.value).ok).toBe(true);
124 }
125 const pub = await newApp(app, owner, { name: "hello", description: "", visibility: "public" });
126 if (!pub.ok) throw new Error(String(pub.error));
127 repo = pub.value.repo;
128 const priv = await newApp(app, owner, { name: "secret", description: "", visibility: "private" });
129 if (!priv.ok) throw new Error(String(priv.error));
130 privateRepo = priv.value.repo;
131 const ids: string[] = [];
132 for (let n = 1; n <= 7; n++) ids.push(pull(repo, n, owner, `Pull ${n}`));
133 pull(repo, 8, author, "Sealed fix", 1);
134 pull(privateRepo, 1, owner, "Hidden");
135 run(ids[6]!, repo, "ok", "head7");
136 run(ids[5]!, repo, "failed", "head6");
137 run(ids[4]!, repo, "ok", "old-head");
138 run(ids[3]!, repo, "running", "head4");
139 });
140 afterAll(async () => {
141 app.close();
142 await rm(dataDir, { recursive: true, force: true });
143 });
144
145 test("the query walks an index: no scan of run, pull or issue", () => {
146 const plan = app.db
147 .query<{ detail: string }, [string, number, string, number]>(`EXPLAIN QUERY PLAN ${OPEN_PULLS_SQL}`)
148 .all(repo.id, 0, "", 5)
149 .map((r) => r.detail);
150 for (const line of plan)
151 expect(line).not.toMatch(/\bSCAN (run|pull|issue)\b(?! USING)/);
152 expect(plan.join("\n")).toMatch(/USING (COVERING )?INDEX/);
153 });
154
155 test("a guest sees the five newest unsealed pulls with their check words", async () => {
156 const got = openPulls(app.db, repo, undefined);
157 expect(got.map((p) => p.number)).toEqual([7, 6, 5, 4, 3]);
158 const seen = await exec("hello", 1);
159 // eve is a named user with no standing on the repo, which is public.
160 expect(seen.out).toBe(
161 [
162 "#7 Pull 7 · green",
163 "#6 Pull 6 · red",
164 "#5 Pull 5 · none",
165 "#4 Pull 4 · running",
166 "#3 Pull 3 · none",
167 "",
168 ].join("\n"),
169 );
170 expect(seen.code).toBe(0);
171 });
172
173 test("a sealed pull is its author's, a writer's and an admin's alone", async () => {
174 expect(openPulls(app.db, repo, author, 10).map((p) => p.number)[0]).toBe(8);
175 expect(openPulls(app.db, repo, owner, 10).map((p) => p.number)[0]).toBe(8);
176 expect(openPulls(app.db, repo, stranger, 10).map((p) => p.number)[0]).toBe(7);
177 expect(openPulls(app.db, repo, undefined, 10).map((p) => p.number)[0]).toBe(7);
178 const mine = await exec("hello", 0);
179 expect(mine.out.split("\n")[0]).toBe("#8 Sealed fix · none");
180 });
181
182 test("a suspended account counts as a guest", () => {
183 const gone = { ...author, suspendedAt: "2026-01-01T00:00:00Z" };
184 expect(openPulls(app.db, repo, gone, 10).map((p) => p.number)[0]).toBe(7);
185 });
186
187 test("a viewer who cannot read the repository gets nothing", () => {
188 expect(openPulls(app.db, privateRepo, stranger)).toEqual([]);
189 expect(openPulls(app.db, privateRepo, undefined)).toEqual([]);
190 expect(openPulls(app.db, privateRepo, owner).map((p) => p.title)).toEqual(["Hidden"]);
191 });
192
193 test("a named user who cannot read a private app is told there is none", async () => {
194 const got = await exec("secret", 1);
195 expect(got.err).toContain("no app named secret");
196 expect(got.out).toBe("");
197 expect(got.code).toBe(1);
198 });
199
200 test("control characters in a title are stripped", async () => {
201 pull(repo, 9, owner, "Evil\x1b[31m red\x07\nnext\r\x00line");
202 const got = await exec("hello", 1);
203 expect(got.out.split("\n")[0]).toBe("#9 Evil[31m rednextline · none");
204 expect(got.out).not.toMatch(/[\x00-\x09\x0b-\x1f\x7f]/);
205 });
206
207 test("other execs on an app's login are still git's", async () => {
208 const got = await exec("hello", 1, "ls");
209 expect(got.err).toContain("only git is available");
210 expect(got.code).toBe(1);
211 });
212 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs