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

A writer opens a shell into a machine over an authenticated WebSocket, and each session is recorded #9481

Merged opened this

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

Merged

scout/forge:scout/9471-a-writer-opens-a-shell-into-a-machine-ov → main 5 commits fork

  • Merged. scout/9471-a-writer-opens-a-shell-into-a-machine-ov was merged into main as 6d94644.

Review

Sign in to review this change.
12 files changed +1166 −4
docs/features/machines.md +45 −2
@@ -44,7 +44,50 @@ cannot widen its own machine by editing the file on its branch):
4444 `FORGE_MACHINE` and `FORGE_MACHINE_TOOLS` (names only) in its environment, a
4545 line in its brief naming the tools its machine carries, and its `apps` reach.
4646
47 ## A shell into a machine (#9471)
48
49 `src/machines/shell.ts`. A WebSocket at
50 `/<owner>/<repo>/machines/<machine>/shell` opens a shell into one of the
51 repository's machines:
52
53 - **Who.** A signed-in person (a session cookie or the platform edge, never a
54 token) with write permission on the repository, from this forge's own
55 pages: the `Origin` must be the forge's, and a missing one is refused. A
56 reader is refused 403 on a public repository; a private one, or a machine
57 it does not declare, answers 404.
58 - **Where.** tmux (or `/bin/sh` where tmux is not installed) on a real pty,
59 in a cell (`apps/contain.ts`) under the machine's memory and CPU ceilings,
60 with the forge's data hidden and no reach beyond the forge. Each writer has
61 a directory and home of their own on each machine,
62 `<data>/machines/<repo id>/<machine>/<user id>`: one writer's files and
63 shell history are never the next's. A forge that cannot hold a cell closes
64 the socket (1011, "this forge cannot hold a machine's shell") rather than
65 hand out a shell on its host. The command is a constant: nothing a person
66 sends reaches a command line. A shell that fails to start leaves no pty,
67 cell or open row behind (its row ends `failed`).
68 - **Frames.** Binary frames are keystrokes in (at most 64 KiB each; a bigger
69 one closes with 1009) and screen bytes out; a peer that stops reading
70 (over 1 MiB waiting) is closed. A text frame is
71 `{"type":"resize","cols":n,"rows":n}` (cols 1–500, rows 1–300); any other
72 text frame closes the connection (1003).
73 - **Limits.** 16 sessions on the forge at once and 3 per person; 20
74 handshakes per person a minute. Past either the handshake answers 429.
75 - **One session per person, repository and machine.** A second connection
76 (another tab, a reload) joins the running session and sees the same
77 output. The session ends when the shell exits, after 30 minutes with no
78 keystroke, or — checked every 5 seconds and when its last connection
79 closes — once its owner is suspended or can no longer write here.
80 - **Records.** Each session is a row in `machine_sessions` (machine, owner,
81 start, end and why: `exit`, `idle`, `stopped`, `revoked`, `failed`). No
82 shell outlives its forge, so a row a stop or crash left open is closed as
83 `stopped` when the forge boots. Writers see the last 20 at
84 `/<owner>/<repo>/settings/machines` (in the settings frame for an admin,
85 on its own for a writer who is not one), and in the terminal: the palette's
86 **Machines of a repository** (`src/tui/screens/machines.ts`), writers only.
87
4788 ## Not done yet (split from #9418)
4889
49 Disk ceilings are not declared (#9460); the tools are declared, not yet installed in an image; the browser
50 shell (ghostty-web, tmux, WebSocket, session records) is not started.
90 Disk ceilings are not declared (#9460); the tools are declared, not yet
91 installed in an image; the browser terminal (ghostty-web) that speaks to the
92 shell socket is not built; the shell's cell does not reach the repository's
93 apps even when its machine says `apps`.
src/core/migration-pins/20261108120000-machine-sessions.pin added +1
@@ -0,0 +1,1 @@
1 d58a32c5ba794e7b185e71f64424001caae46e3df64cc84f3954f8a2a43f8b11
src/core/migrations/20261108120000-machine-sessions.ts added +17
@@ -0,0 +1,17 @@
1 // A writer's shell into a machine (#9471, `machines/shell.ts`): one row per
2 // session — the repository, the machine's name, whose it is, when it started
3 // and when and why it ended. The last sessions are read newest first per
4 // repository, so the index leads with the repository and the start. New
5 // table only. Additive.
6 export default `
7 CREATE TABLE machine_sessions (
8 id TEXT PRIMARY KEY,
9 repo_id TEXT NOT NULL,
10 machine TEXT NOT NULL,
11 owner_id TEXT NOT NULL,
12 started_at TEXT NOT NULL,
13 ended_at TEXT,
14 end_reason TEXT CHECK (end_reason IN ('exit', 'idle', 'stopped', 'revoked', 'failed'))
15 );
16 CREATE INDEX machine_sessions_repo_started ON machine_sessions (repo_id, started_at DESC);
17 `;
src/http/server.ts +51 −2
@@ -534,9 +534,26 @@ import {
534534 } from "../spawn/host.ts";
535535 import { getVerifiedDomain } from "../core/domains.ts";
536536 import { previewOwner } from "../spawn/preview-owner.ts";
537 import { cellsAvailable } from "../apps/contain.ts";
538 import { machinesSettingsPage } from "../web/pages/machines.ts";
539 import {
540 defaultCommand,
541 mayOpenShell,
542 setShellOptions,
543 shellOptions,
544 shellTarget,
545 stopShells,
546 withShells,
547 closeStaleSessions,
548 type ForgeSocket,
549 type ShellSocket,
550 } from "../machines/shell.ts";
537551
538552 // ---- auth ----
539553
554 /** A machine's shell with no keystroke for this long ends (`machines/shell.ts`). */
555 const SHELL_IDLE_MS = 30 * 60_000;
556
540557 const NO_AUTH: Auth = { user: undefined, scope: undefined, via: "none" };
541558
542559 async function resolveAuth(app: App, request: Request): Promise<Auth> {
@@ -1353,6 +1370,7 @@ export function buildRouter(): Router<Handler> {
13531370 proposalTokenRevoke,
13541371 );
13551372 router.add("GET", "/:owner/:name/settings/app", appSettingsPage);
1373 router.add("GET", "/:owner/:name/settings/machines", machinesSettingsPage);
13561374 router.add("POST", "/:owner/:name/settings/app", appSettingsUpdate);
13571375 router.add("POST", "/:owner/:name/settings/app/disable", appDisable);
13581376 router.add("POST", "/:owner/:name/settings/app/members", appMembersUpdate);
@@ -2143,6 +2161,8 @@ export function forgeHttp(app: App): ForgeHttp {
21432161
21442162 export async function serve(app: App): Promise<Servers> {
21452163 const router = buildRouter();
2164 // No shell outlives its forge: a row a stop or crash left open is closed.
2165 closeStaleSessions(app.db);
21462166 const apiRouter = buildApiRouter();
21472167 await clearStaleSockets(app);
21482168 // One limiter per listener: its state is a minute of timestamps, and two
@@ -2338,7 +2358,7 @@ export async function serve(app: App): Promise<Servers> {
23382358
23392359 const handle = async (
23402360 request: Request,
2341 server: Bun.Server<AppSocket>,
2361 server: Bun.Server<ForgeSocket<AppSocket>>,
23422362 ): Promise<Response | undefined> => {
23432363 const started = performance.now();
23442364 noteStart(request, started);
@@ -2432,6 +2452,34 @@ export async function serve(app: App): Promise<Servers> {
24322452 if (ownUnknown)
24332453 return securityFloor(new Response("not found", { status: 404 }));
24342454 notePeer(request, server.requestIP(request)?.address, trust);
2455 // A writer's shell into a machine: decided here, before any route,
2456 // because an upgrade is answered by the socket, not a response.
2457 if (isWebSocketHandshake(request)) {
2458 const target = shellTarget(new URL(request.url).pathname);
2459 if (target !== undefined) {
2460 const auth = await resolveAuth(app, request);
2461 const may = await mayOpenShell(app, request, publicUrl(request, app.config.url), target, auth);
2462 if (!may.ok) {
2463 const [status, text] =
2464 may.refusal === "not_found" ? [404, "not found"] : may.refusal === "busy" ? [429, "too many shells"] : [403, "forbidden"];
2465 return securityFloor(new Response(text, { status }));
2466 }
2467 // A machine's shell runs only where a cell holds it, asked at the
2468 // first shell rather than at boot; a test that set its own keeps them.
2469 if (shellOptions(app) === undefined) {
2470 const cells = await cellsAvailable();
2471 if (shellOptions(app) === undefined)
2472 setShellOptions(app, {
2473 idleMs: SHELL_IDLE_MS,
2474 contain: cells.ok ? "cell" : "refuse",
2475 command: defaultCommand(),
2476 });
2477 }
2478 const data: ShellSocket = { shell: { ...may.grant, app } };
2479 if (server.upgrade(request, { data })) return undefined;
2480 return securityFloor(new Response("upgrade refused", { status: 400 }));
2481 }
2482 }
24352483 // A body refused part-way, or one left unread with no declared length,
24362484 // ends its connection once answered (`http/limits.ts`).
24372485 armClose(request, () => server.timeout(request, 1));
@@ -2460,7 +2508,7 @@ export async function serve(app: App): Promise<Servers> {
24602508 maxRequestBodySize: REQUEST_MAX,
24612509 // The only sockets the forge holds are an app's, carried through the
24622510 // proxy; its own live surfaces are SSE (`sse.ts`) and stay so.
2463 websocket: appWebSocket,
2511 websocket: withShells(appWebSocket),
24642512 fetch: handle,
24652513 // A last resort: `handle` answers a throw itself, so it is counted.
24662514 error: (error) => failure(app, error),
@@ -2484,6 +2532,7 @@ export async function serve(app: App): Promise<Servers> {
24842532 },
24852533 stop() {
24862534 endOpen();
2535 stopShells(app);
24872536 prerender();
24882537 clearInterval(sweep);
24892538 // Counts first: a hosted child's database closes with its app.
src/machines/shell.ts added +520
@@ -0,0 +1,520 @@
1 /**
2 * A writer's shell into a machine (#9471): a WebSocket at
3 * `/:owner/:name/machines/:machine/shell` that a signed-in person with write
4 * permission on the repository may open, from this forge's own pages only
5 * (the Origin must be the forge's). It runs tmux on a real pty
6 * (`Bun.spawn({terminal})`), held in a cell (`apps/contain.ts`) — the same
7 * bubblewrap the forge holds repository code in — with the machine's memory
8 * and CPU ceilings and no reach beyond the forge. A forge that cannot hold a
9 * cell refuses the shell rather than hand out one on the host.
10 *
11 * One session per person, repository and machine: a second connection (a
12 * second tab, a reload) joins the session that is running and sees the same
13 * screen. Binary frames are keystrokes in and screen bytes out; a text frame
14 * is JSON and says only `{"type":"resize","cols":n,"rows":n}`. The session
15 * ends when its program exits or after `idleMs` with no keystroke, and each
16 * one is a row in `machine_sessions`: machine, owner, start, end and why.
17 *
18 * No text a person sends ever reaches a command line: the command is a
19 * constant, and the names in the path only pick a row and a directory after
20 * they have been validated.
21 */
22
23 import { mkdirSync } from "node:fs";
24 import { tmpdir } from "node:os";
25 import { join } from "node:path";
26 import type { Database } from "bun:sqlite";
27 import type { App } from "../app.ts";
28 import { openCell, resolveLimits, type Cell, type CellSpec } from "../apps/contain.ts";
29 import { startTerminal, type TerminalProgram } from "../apps/tty.ts";
30 import { holdOpen } from "../http/caller.ts";
31 import { canWrite, getRepo, getRepoById, type Repo } from "../core/repos.ts";
32 import { getUserById, isActive } from "../core/users.ts";
33 import { isSafeName } from "../git/guard.ts";
34 import type { User } from "../core/users.ts";
35 import { repoMachines } from "../crew/machines_read.ts";
36 import type { Machine } from "../crew/machines.ts";
37 import { id as newId } from "../kernel/id.ts";
38
39 // ---- the route ----
40
41 export interface ShellTarget {
42 readonly owner: string;
43 readonly name: string;
44 readonly machine: string;
45 }
46
47 const MACHINE_NAME = /^[a-z][a-z0-9-]{0,31}$/;
48
49 /** The repository and machine a shell path names, or undefined when it names none. */
50 export function shellTarget(pathname: string): ShellTarget | undefined {
51 const parts = pathname.split("/");
52 if (parts.length !== 6 || parts[0] !== "" || parts[3] !== "machines" || parts[5] !== "shell")
53 return undefined;
54 const [, owner, name, , machine] = parts as [string, string, string, string, string];
55 if (!isSafeName(owner) || !isSafeName(name) || !MACHINE_NAME.test(machine)) return undefined;
56 return { owner, name, machine };
57 }
58
59 /** Same origin, strictly: a browser always sends Origin on a WebSocket, so none is a refusal. */
60 function sameOrigin(request: Request, forgeUrl: URL): boolean {
61 const origin = request.headers.get("origin");
62 if (origin === null) return false;
63 try {
64 return new URL(origin).origin === forgeUrl.origin;
65 } catch {
66 return false;
67 }
68 }
69
70 export type ShellRefusal = "not_found" | "forbidden" | "busy";
71
72 export interface ShellGrant {
73 readonly repo: Repo;
74 readonly machine: Machine;
75 readonly user: User;
76 readonly sessionId: string | undefined;
77 }
78
79 /**
80 * Who may open this shell, decided before the upgrade. A repository the
81 * caller cannot see, or a machine it does not declare, is `not_found`; a
82 * reader, a token, a cross-origin page is `forbidden`.
83 */
84 export async function mayOpenShell(
85 app: App,
86 request: Request,
87 forgeUrl: URL,
88 target: ShellTarget,
89 auth: { user: User | undefined; via: string; sessionId?: string | undefined },
90 ): Promise<{ ok: true; grant: ShellGrant } | { ok: false; refusal: ShellRefusal }> {
91 const repo = getRepo(app.db, target.owner, target.name);
92 const user = auth.user;
93 if (repo === undefined) return { ok: false, refusal: "not_found" };
94 // A browser identity only: a token cannot open a shell.
95 if (user === undefined || (auth.via !== "session" && auth.via !== "edge"))
96 return { ok: false, refusal: repo.visibility === "public" ? "forbidden" : "not_found" };
97 if (!sameOrigin(request, forgeUrl)) return { ok: false, refusal: "forbidden" };
98 if (!canWrite(app.db, repo, user))
99 return { ok: false, refusal: repo.visibility === "public" ? "forbidden" : "not_found" };
100 const config = await repoMachines(app, repo);
101 const machine = config.machines.find((m) => m.name === target.machine);
102 if (machine === undefined) return { ok: false, refusal: "not_found" };
103 const grant = { repo, machine, user, sessionId: auth.sessionId };
104 if (!roomFor(app, grant)) return { ok: false, refusal: "busy" };
105 return { ok: true, grant };
106 }
107
108 // ---- frames ----
109
110 export const RESIZE = { cols: { min: 1, max: 500 }, rows: { min: 1, max: 300 } } as const;
111
112 /** A text frame's resize, or undefined for anything else. */
113 export function parseControl(text: string): { cols: number; rows: number } | undefined {
114 if (text.length > 200) return undefined;
115 let value: unknown;
116 try {
117 value = JSON.parse(text);
118 } catch {
119 return undefined;
120 }
121 if (typeof value !== "object" || value === null) return undefined;
122 const { type, cols, rows } = value as Record<string, unknown>;
123 if (type !== "resize") return undefined;
124 const fits = (n: unknown, r: { min: number; max: number }): n is number =>
125 Number.isInteger(n) && (n as number) >= r.min && (n as number) <= r.max;
126 if (!fits(cols, RESIZE.cols) || !fits(rows, RESIZE.rows)) return undefined;
127 return { cols, rows };
128 }
129
130 // ---- the records ----
131
132 export type EndReason = "exit" | "idle" | "stopped" | "revoked" | "failed";
133
134 export interface SessionRecord {
135 readonly id: string;
136 readonly machine: string;
137 readonly ownerId: string;
138 readonly ownerName: string;
139 readonly startedAt: string;
140 readonly endedAt: string | undefined;
141 readonly endReason: EndReason | undefined;
142 }
143
144 export function recordStart(db: Database, repoId: string, machine: string, ownerId: string): string {
145 const row = newId();
146 db.query(
147 "INSERT INTO machine_sessions (id, repo_id, machine, owner_id, started_at) VALUES (?, ?, ?, ?, ?)",
148 ).run(row, repoId, machine, ownerId, new Date().toISOString());
149 return row;
150 }
151
152 export function recordEnd(db: Database, row: string, reason: EndReason): void {
153 db.query(
154 "UPDATE machine_sessions SET ended_at = ?, end_reason = ? WHERE id = ? AND ended_at IS NULL",
155 ).run(new Date().toISOString(), reason, row);
156 }
157
158 /**
159 * At boot, every row still open belongs to a forge that stopped or crashed
160 * without ending it: no shell survives its forge, so each is closed as
161 * `stopped`. Returns how many.
162 */
163 export function closeStaleSessions(db: Database): number {
164 return db
165 .query("UPDATE machine_sessions SET ended_at = ?, end_reason = 'stopped' WHERE ended_at IS NULL")
166 .run(new Date().toISOString()).changes;
167 }
168
169 /** The repository's last sessions, newest first (index `machine_sessions_repo_started`). */
170 export function lastSessions(db: Database, repoId: string, limit = 20): SessionRecord[] {
171 return db
172 .query<
173 { id: string; machine: string; owner_id: string; username: string | null; started_at: string; ended_at: string | null; end_reason: EndReason | null },
174 [string, number]
175 >(
176 `SELECT s.id, s.machine, s.owner_id, u.username, s.started_at, s.ended_at, s.end_reason
177 FROM machine_sessions s LEFT JOIN user u ON u.id = s.owner_id
178 WHERE s.repo_id = ? ORDER BY s.started_at DESC LIMIT ?`,
179 )
180 .all(repoId, limit)
181 .map((r) => ({
182 id: r.id,
183 machine: r.machine,
184 ownerId: r.owner_id,
185 ownerName: r.username ?? "someone",
186 startedAt: r.started_at,
187 endedAt: r.ended_at ?? undefined,
188 endReason: r.end_reason ?? undefined,
189 }));
190 }
191
192 /** How each end reads, on the page and in the terminal. */
193 export const ENDED: Record<EndReason, string> = {
194 exit: "the shell exited",
195 idle: "idle too long",
196 stopped: "the forge stopped",
197 revoked: "its owner could no longer write here",
198 failed: "the shell could not start",
199 };
200
201 // ---- the sessions ----
202
203 /** What a socket carries: its grant, and the session it joined once open. */
204 export interface ShellSocket {
205 readonly shell: ShellGrant & { readonly app: App };
206 joined?: Session;
207 release?: () => void;
208 }
209
210 type Peer = Bun.ServerWebSocket<ShellSocket>;
211
212 interface Session {
213 readonly key: string;
214 readonly userId: string;
215 readonly repoId: string;
216 readonly row: string;
217 readonly terminal: Bun.Terminal;
218 readonly program: TerminalProgram;
219 readonly peers: Set<Peer>;
220 lastInput: number;
221 ended: boolean;
222 readonly timer: ReturnType<typeof setInterval>;
223 }
224
225 export interface ShellOptions {
226 /** No keystroke for this long ends the session. */
227 readonly idleMs: number;
228 /**
229 * Whether this forge can hold a cell. Without one the shell is refused —
230 * except where a test says `uncontained`, on purpose, to run the bridge.
231 */
232 readonly contain: "cell" | "uncontained" | "refuse";
233 /** The command the pty runs: constant, never built from a request. */
234 readonly command: string;
235 /** How often a session asks again whether its owner may still write; defaults to `CHECK_MS`. */
236 readonly checkMs?: number;
237 /** Handshakes one person may make in a minute; defaults to `HANDSHAKES_PER_MINUTE`. */
238 readonly handshakesPerMinute?: number;
239 }
240
241 /** Sessions the whole forge runs at once, and one person runs at once. */
242 export const MAX_SESSIONS = { forge: 16, person: 3 } as const;
243 /** Handshakes one person may make in a minute. */
244 export const HANDSHAKES_PER_MINUTE = 20;
245 /** The largest keystroke frame: a paste, not a file. */
246 export const MAX_INPUT_BYTES = 64 * 1024;
247 /** Output waiting on a peer that is not reading, past which it is closed. */
248 export const MAX_BUFFERED_BYTES = 1024 * 1024;
249 export const CHECK_MS = 5_000;
250
251 /** tmux where the machine has it, so a person's windows survive a closed tab; else a login shell. */
252 export function defaultCommand(): string {
253 return Bun.which("tmux") !== null
254 ? "exec tmux -L forge new-session -A -s shell"
255 : "exec /bin/sh -i";
256 }
257
258 const sessions = new WeakMap<App, Map<string, Session>>();
259 const options = new WeakMap<App, ShellOptions>();
260 const handshakes = new WeakMap<App, Map<string, number[]>>();
261
262 export function setShellOptions(app: App, next: ShellOptions): void {
263 options.set(app, next);
264 }
265
266 export function shellOptions(app: App): ShellOptions | undefined {
267 return options.get(app);
268 }
269
270 function sessionsOf(app: App): Map<string, Session> {
271 let map = sessions.get(app);
272 if (map === undefined) {
273 map = new Map();
274 sessions.set(app, map);
275 }
276 return map;
277 }
278
279 /** The sessions running now. */
280 export function liveSessions(app: App): number {
281 return sessionsOf(app).size;
282 }
283
284 const keyOf = (g: ShellGrant): string => `${g.repo.id}\0${g.machine.name}\0${g.user.id}`;
285
286 /**
287 * Whether this handshake may go on: within the person's handshakes this
288 * minute, and either joining their running session or within the caps.
289 */
290 function roomFor(app: App, grant: ShellGrant, now = Date.now()): boolean {
291 let byUser = handshakes.get(app);
292 if (byUser === undefined) handshakes.set(app, (byUser = new Map()));
293 const recent = (byUser.get(grant.user.id) ?? []).filter((t) => now - t < 60_000);
294 if (recent.length >= (options.get(app)?.handshakesPerMinute ?? HANDSHAKES_PER_MINUTE)) return false;
295 recent.push(now);
296 byUser.set(grant.user.id, recent);
297 const live = sessionsOf(app);
298 if (live.has(keyOf(grant))) return true;
299 if (live.size >= MAX_SESSIONS.forge) return false;
300 let mine = 0;
301 for (const s of live.values()) if (s.userId === grant.user.id) mine++;
302 return mine < MAX_SESSIONS.person;
303 }
304
305 /** Each person's own directory on each machine: one writer's files and history are never the next's. */
306 export function workspace(app: App, repo: Repo, machine: Machine, userId: string): string {
307 return join(app.config.dataDir, "machines", repo.id, machine.name, userId);
308 }
309
310 /** The cell a shell runs in: its own directory, the forge's data hidden, the machine's ceilings. */
311 export function shellCell(app: App, dir: string, machine: Machine, label: string): CellSpec {
312 const step = resolveLimits("step", undefined);
313 return {
314 dir,
315 writable: [],
316 home: join(dir, "home"),
317 hide: [app.config.dataDir, process.env["HOME"] ?? "", tmpdir()],
318 egress: [],
319 reach: [app.config.url ?? ""].filter((u) => u !== ""),
320 limits: {
321 ...step,
322 memoryMiB: machine.memoryMiB ?? step.memoryMiB,
323 cpus: machine.cpus ?? step.cpus,
324 },
325 terminal: true,
326 label,
327 };
328 }
329
330 function end(app: App, session: Session, reason: EndReason): void {
331 if (session.ended) return;
332 session.ended = true;
333 clearInterval(session.timer);
334 sessionsOf(app).delete(session.key);
335 recordEnd(app.db, session.row, reason);
336 if (reason !== "exit") session.program.hangup();
337 for (const peer of session.peers) if (peer.readyState === WebSocket.OPEN) peer.close(1000, ENDED[reason]);
338 session.peers.clear();
339 }
340
341 /** Whether the session's owner may still hold it: active, and still a writer of the repository. */
342 function stillMay(app: App, session: Session): boolean {
343 const user = getUserById(app.db, session.userId);
344 const repo = getRepoById(app.db, session.repoId);
345 return user !== undefined && repo !== undefined && isActive(user) && canWrite(app.db, repo, user);
346 }
347
348 function start(app: App, grant: ShellGrant, opts: ShellOptions, cols: number, rows: number): Session | undefined {
349 const dir = workspace(app, grant.repo, grant.machine, grant.user.id);
350 const peers = new Set<Peer>();
351 let terminal: Bun.Terminal | undefined;
352 let cell: Cell | undefined;
353 let row: string | undefined;
354 try {
355 mkdirSync(join(dir, "home"), { recursive: true });
356 row = recordStart(app.db, grant.repo.id, grant.machine.name, grant.user.id);
357 terminal = new Bun.Terminal({
358 cols,
359 rows,
360 data: (_t, chunk) => {
361 for (const peer of peers) {
362 if (peer.readyState !== WebSocket.OPEN) continue;
363 // A peer that is not reading is closed, never buffered without end.
364 if (peer.sendBinary(chunk) === 0 || peer.getBufferedAmount() > MAX_BUFFERED_BYTES)
365 peer.close(1008, "not reading");
366 }
367 },
368 });
369 if (opts.contain === "cell")
370 cell = openCell(shellCell(app, dir, grant.machine, `machine:${grant.repo.id}/${grant.machine.name}`));
371 const env: Record<string, string> = {
372 PATH: process.env["PATH"] ?? "/usr/bin:/bin",
373 HOME: join(dir, "home"),
374 TERM: "xterm-256color",
375 COLORTERM: "truecolor",
376 LANG: "C.UTF-8",
377 FORGE_MACHINE: grant.machine.name,
378 FORGE_MACHINE_TOOLS: grant.machine.tools.join(" "),
379 };
380 const program = startTerminal(opts.command, { terminal, env, cwd: dir, cell });
381 const term = terminal;
382 const session: Session = {
383 key: keyOf(grant),
384 userId: grant.user.id,
385 repoId: grant.repo.id,
386 row,
387 terminal: term,
388 program,
389 peers,
390 lastInput: Date.now(),
391 ended: false,
392 timer: setInterval(
393 () => {
394 if (Date.now() - session.lastInput >= opts.idleMs) end(app, session, "idle");
395 else if (!stillMay(app, session)) end(app, session, "revoked");
396 },
397 Math.max(10, Math.min(opts.idleMs, opts.checkMs ?? CHECK_MS)),
398 ),
399 };
400 void program.exited.then(() => {
401 term.close();
402 end(app, session, "exit");
403 });
404 sessionsOf(app).set(session.key, session);
405 app.log.info("machine shell started", {
406 repo: `${grant.repo.ownerName}/${grant.repo.name}`,
407 machine: grant.machine.name,
408 user: grant.user.username,
409 });
410 return session;
411 } catch (error) {
412 // Nothing half-made is left behind: the pty, the cell, and an open row.
413 try {
414 terminal?.close();
415 } catch {
416 /* already closed */
417 }
418 cell?.close();
419 if (row !== undefined) recordEnd(app.db, row, "failed");
420 app.log.warn("machine shell did not start", { machine: grant.machine.name, error: String(error) });
421 return undefined;
422 }
423 }
424
425 /** End every session (the forge is stopping). */
426 export function stopShells(app: App): void {
427 for (const session of [...sessionsOf(app).values()]) end(app, session, "stopped");
428 }
429
430 /** Whether a socket's data is a shell's, so the forge's one handler can tell. */
431 export function isShellSocket(data: unknown): data is ShellSocket {
432 return typeof data === "object" && data !== null && "shell" in data;
433 }
434
435 export const shellWebSocket = {
436 open(ws: Peer): void {
437 const { app, user, sessionId } = ws.data.shell;
438 const opts = options.get(app);
439 if (opts === undefined || opts.contain === "refuse") {
440 ws.close(1011, "this forge cannot hold a machine's shell");
441 return;
442 }
443 const session = sessionsOf(app).get(keyOf(ws.data.shell)) ?? start(app, ws.data.shell, opts, 80, 24);
444 if (session === undefined) {
445 ws.close(1011, ENDED.failed);
446 return;
447 }
448 session.peers.add(ws);
449 ws.data.joined = session;
450 ws.data.release = holdOpen(app.db, user.id, sessionId, () => {
451 if (ws.readyState === WebSocket.OPEN) ws.close(1008, "this account was suspended");
452 });
453 },
454 message(ws: Peer, message: string | Buffer): void {
455 const session = ws.data.joined;
456 if (session === undefined || session.ended) return;
457 if (typeof message === "string") {
458 const resize = parseControl(message);
459 if (resize === undefined) {
460 ws.close(1003, "a text frame is a resize");
461 return;
462 }
463 if (!session.terminal.closed) session.terminal.resize(resize.cols, resize.rows);
464 return;
465 }
466 if (message.byteLength > MAX_INPUT_BYTES) {
467 ws.close(1009, "a keystroke frame is at most 64 KiB");
468 return;
469 }
470 session.lastInput = Date.now();
471 if (session.terminal.closed) return;
472 try {
473 session.terminal.write(message);
474 } catch {
475 /* the program is gone; the keystroke has nowhere to go */
476 }
477 },
478 close(ws: Peer): void {
479 ws.data.release?.();
480 const session = ws.data.joined;
481 if (session === undefined) return;
482 session.peers.delete(ws);
483 // The session stays for the next connection until it is idle or exits —
484 // unless its owner was suspended or may no longer write here.
485 if (session.peers.size === 0 && !stillMay(ws.data.shell.app, session))
486 end(ws.data.shell.app, session, "revoked");
487 },
488 };
489
490 /** What the forge's one `websocket` handler carries: an app's proxied socket, or a shell's. */
491 export type ForgeSocket<A> = A | ShellSocket;
492
493 /** The forge's one `websocket` handler, with a shell's sockets taken first. */
494 export function withShells<A extends object>(inner: Bun.WebSocketHandler<A>): Bun.WebSocketHandler<ForgeSocket<A>> {
495 type W = Bun.ServerWebSocket<ForgeSocket<A>>;
496 const asApp = (ws: W) => ws as Bun.ServerWebSocket<A>;
497 const asShell = (ws: W) => ws as Peer;
498 return {
499 ...(inner.maxPayloadLength !== undefined ? { maxPayloadLength: inner.maxPayloadLength } : {}),
500 ...(inner.backpressureLimit !== undefined ? { backpressureLimit: inner.backpressureLimit } : {}),
501 ...(inner.closeOnBackpressureLimit !== undefined ? { closeOnBackpressureLimit: inner.closeOnBackpressureLimit } : {}),
502 ...(inner.idleTimeout !== undefined ? { idleTimeout: inner.idleTimeout } : {}),
503 open(ws) {
504 if (isShellSocket(ws.data)) return shellWebSocket.open(asShell(ws));
505 return inner.open?.(asApp(ws));
506 },
507 message(ws, message) {
508 if (isShellSocket(ws.data)) return shellWebSocket.message(asShell(ws), message);
509 return inner.message(asApp(ws), message);
510 },
511 close(ws, code, reason) {
512 if (isShellSocket(ws.data)) return shellWebSocket.close(asShell(ws));
513 return inner.close?.(asApp(ws), code, reason);
514 },
515 drain(ws) {
516 if (isShellSocket(ws.data)) return;
517 return inner.drain?.(asApp(ws));
518 },
519 };
520 }
src/tui/forge.ts +7
@@ -81,6 +81,7 @@ import { issuesScreen, newIssueScreen } from "./screens/issues.ts";
8181 import { limbsScreen } from "./screens/limbs.ts";
8282 import { nextScreen } from "./screens/next.ts";
8383 import { weekScreen } from "./screens/week.ts";
84 import { machinesScreen } from "./screens/machines.ts";
8485 import { newPullScreen } from "./screens/newpull.ts";
8586 import { newForgeScreen } from "./screens/newforge.ts";
8687 import { newOrgScreen } from "./screens/neworg.ts";
@@ -143,6 +144,12 @@ const REPO_VERBS: readonly {
143144 title: "The week in a repository",
144145 open: (f, ctx, repo) => weekScreen(f, ctx, repo),
145146 },
147 {
148 // Settings → Machines' other door: the machines and last shell sessions, writers only (#9471).
149 id: "machines",
150 title: "Machines of a repository",
151 open: (f, ctx, repo) => machinesScreen(f, ctx, repo),
152 },
146153 {
147154 // What the crew's tokens bought on one app, as MCP `returns` with `repo` (#7271).
148155 id: "repo-usage",
src/tui/screens/machines.ts added +66
@@ -0,0 +1,66 @@
1 /**
2 * A repository's machines in the terminal (#9471), the door beside
3 * Settings → Machines: the machines it declares (`crew/machines.ts`) and its
4 * last shell sessions (`machines/shell.ts`) — who opened which, when, and
5 * when and why it ended. Writers only, as the page: access is read at every
6 * draw, so a writer who loses it sees nothing on the next frame.
7 */
8
9 import { canWrite, type Repo } from "../../core/repos.ts";
10 import { repoMachines } from "../../crew/machines_read.ts";
11 import type { Machine } from "../../crew/machines.ts";
12 import { ENDED, lastSessions, type SessionRecord } from "../../machines/shell.ts";
13 import type { Ctx, Screen } from "../app/app.ts";
14 import { binding } from "../app/keys.ts";
15 import { style } from "../term/style.ts";
16 import { truncate } from "../term/width.ts";
17 import type { Session } from "../session.ts";
18 import { fill, len, split } from "../ui/layout.ts";
19 import { ListState, drawList, type ListRow } from "./common.ts";
20
21 /** One line per session, as the page's table reads. */
22 export function sessionRow(s: SessionRecord): ListRow {
23 return {
24 title: `${s.machine} · ${s.ownerName}`,
25 secondary: s.endedAt === undefined ? "running" : ENDED[s.endReason ?? "stopped"],
26 meta: `started ${s.startedAt.slice(0, 16).replace("T", " ")}`,
27 };
28 }
29
30 /** The machines, one line: name and tools. */
31 export function machinesLine(list: readonly Machine[]): string {
32 return `Machines: ${list.map((m) => (m.tools.length === 0 ? m.name : `${m.name} (${m.tools.join(", ")})`)).join("; ")}`;
33 }
34
35 export function machinesScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
36 const state = new ListState();
37 let machines: readonly Machine[] = [];
38 const may = (): boolean => f.user !== undefined && canWrite(f.db, repo, f.user);
39 const rows = (): SessionRecord[] => (may() ? lastSessions(f.db, repo.id) : []);
40 return {
41 title: "Machines",
42 keys: [binding(["j", "k"], "move cursor"), binding(["esc"], "back")],
43 async load() {
44 if (may()) machines = (await repoMachines(f.app, repo)).machines;
45 ctx.invalidate();
46 },
47 render(area, s) {
48 const c = ctx.theme.c;
49 const [head, list] = split(area, "v", [len(3), fill(1)]);
50 const listed = rows();
51 const lines = !may()
52 ? ["A repository's machines and shell sessions are its writers'."]
53 : [
54 machinesLine(machines),
55 listed.length === 0 ? "No shell has been opened here yet." : "Last sessions, newest first.",
56 ];
57 lines.forEach((line, i) =>
58 s.text(head!.x, head!.y + i, truncate(line, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w),
59 );
60 drawList(s, list!, c, state, listed.map(sessionRow));
61 },
62 key(e) {
63 return state.key(e, rows().length);
64 },
65 };
66 }
src/web/pages/machines.ts added +89
@@ -0,0 +1,89 @@
1 /**
2 * GET /:owner/:name/settings/machines — the machines a repository declares
3 * (`crew/machines.ts`) and its last shell sessions (`machines/shell.ts`):
4 * who opened which machine, when, and when and why it ended. Writers only:
5 * a session is a shell on the repository's machine, so a reader sees none.
6 */
7
8 import type { Machine } from "../../crew/machines.ts";
9 import { canAdmin, canWrite, type Repo } from "../../core/repos.ts";
10 import { nameFrame, notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts";
11 import { repoMachines } from "../../crew/machines_read.ts";
12 import type { Ctx } from "../../http/context.ts";
13 import { ENDED, lastSessions, type SessionRecord } from "../../machines/shell.ts";
14 import { crumbsFor, forbiddenPage, loadRepo, repoHeader, repoNotFound, repoPalette, NOT_YOURS_TO_DO } from "../chrome.ts";
15 import { page } from "../layout.ts";
16 import { html } from "../html.ts";
17 import { card, icon, time } from "../ui.ts";
18 import { settingsPageResponse } from "./settings.ts";
19
20
21 export async function machinesSettingsPage(ctx: Ctx): Promise<Response> {
22 nameFrame(ctx.request);
23 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
24 const repo = timePhase(ctx.request, "repo", () => loadRepo(ctx));
25 if (!repo) return repoNotFound(ctx);
26 if (!canWrite(ctx.app.db, repo, ctx.auth.user)) return forbiddenPage(ctx, NOT_YOURS_TO_DO);
27 const config = await timePhaseAsync(ctx.request, "machines", () => repoMachines(ctx.app, repo));
28 const sessions = timePhase(ctx.request, "sessions", () => lastSessions(ctx.app.db, repo.id));
29 return timePhase(ctx.request, "render", () => render(ctx, repo, config.machines, sessions));
30 }
31
32 function render(ctx: Ctx, repo: Repo, list: readonly Machine[], sessions: readonly SessionRecord[]): Response {
33 const machines = card(
34 html`<p class="muted">
35 A machine is where the crew's shell runs. A writer can open a shell into one; each
36 writer has a directory and home of their own on each machine, never shared with the
37 next. The browser terminal that opens it is not built yet.
38 </p>
39 <div class="table-scroll">
40 <table class="table">
41 <thead><tr><th>Machine</th><th>Tools</th><th>Reach</th></tr></thead>
42 <tbody>
43 ${list.map(
44 (m) => html`<tr>
45 <td><strong>${m.name}</strong></td>
46 <td class="muted">${m.tools.length === 0 ? "none" : m.tools.join(", ")}</td>
47 <td class="muted">${m.reach.length === 0 ? "the forge only" : "the forge and its apps"}</td>
48 </tr>`,
49 )}
50 </tbody>
51 </table>
52 </div>`,
53 { head: html`${icon("terminal")}<h2>Machines</h2>` },
54 );
55 const recent = card(
56 sessions.length === 0
57 ? html`<p class="muted">No shell has been opened here yet.</p>`
58 : html`<div class="table-scroll">
59 <table class="table">
60 <thead><tr><th>Machine</th><th>Who</th><th>Started</th><th>Ended</th></tr></thead>
61 <tbody>
62 ${sessions.map(
63 (s) => html`<tr>
64 <td><strong>${s.machine}</strong></td>
65 <td>${s.ownerName}</td>
66 <td class="muted">${time(s.startedAt)}</td>
67 <td class="muted">${s.endedAt === undefined
68 ? "running"
69 : html`${time(s.endedAt)} · ${ENDED[s.endReason ?? "stopped"]}`}</td>
70 </tr>`,
71 )}
72 </tbody>
73 </table>
74 </div>`,
75 { head: html`${icon("history")}<h2>Last sessions</h2>` },
76 );
77 const body = html`${machines}<div class="mt">${recent}</div>`;
78 // The settings frame and its sections are an admin's; a writer who is not
79 // one sees this pane alone, so no link offers them a page that refuses.
80 if (canAdmin(ctx.app.db, repo, ctx.auth.user))
81 return settingsPageResponse(ctx, repo, "machines", "Machines", body);
82 return page({
83 ctx,
84 title: `Machines · ${repo.name}`,
85 crumbs: crumbsFor(repo),
86 palette: repoPalette(ctx, repo),
87 body: html`${repoHeader(ctx, repo, "settings", { compact: true })}${body}`,
88 });
89 }
src/web/pages/settings.ts +7
@@ -116,6 +116,7 @@ type Section =
116116 | "prompts"
117117 | "labels"
118118 | "hooks"
119 | "machines"
119120 | "danger";
120121
121122 /** The repo, but only for someone who administers it. A non-admin is told
@@ -165,6 +166,12 @@ function sections(base: string, active: Section) {
165166 icon: "webhook" as const,
166167 current: active === "hooks",
167168 },
169 {
170 label: "Machines",
171 href: `${base}/settings/machines`,
172 icon: "terminal" as const,
173 current: active === "machines",
174 },
168175 {
169176 label: "Danger zone",
170177 href: `${base}/settings/danger`,
test/machine-shell.test.ts added +278
@@ -0,0 +1,278 @@
1 /**
2 * A writer's shell into a machine (#9471): the upgrade is refused to a
3 * reader, a token and a page from elsewhere; a writer's keystrokes come back
4 * on the pty; a resize frame reaches it; a second connection joins the same
5 * session; and each session's start and end is a row in `machine_sessions`.
6 *
7 * This sandbox cannot hold a cell, so the bridge runs uncontained here on
8 * purpose (`contain: "uncontained"`); a forge that cannot hold one refuses.
9 */
10 import { afterAll, beforeAll, expect, test } from "bun:test";
11 import { mkdtempSync } from "node:fs";
12 import { tmpdir } from "node:os";
13 import { join } from "node:path";
14 import { createApp, type App } from "../src/app.ts";
15 import { createRepo, type Repo } from "../src/core/repos.ts";
16 import { createSession } from "../src/core/sessions.ts";
17 import { createUser, type User } from "../src/core/users.ts";
18 import { serve, type Servers } from "../src/http/server.ts";
19 import { tokenFor } from "./helpers/tokens.ts";
20 import { existsSync } from "node:fs";
21 import { closeStaleSessions, recordStart, shellCell, workspace, MAX_SESSIONS } from "../src/machines/shell.ts";
22 import { BUILT_IN } from "../src/crew/machines.ts";
23 import {
24 lastSessions,
25 liveSessions,
26 parseControl,
27 setShellOptions,
28 shellTarget,
29 stopShells,
30 } from "../src/machines/shell.ts";
31 import { quietLogs } from "./helpers/globals.ts";
32
33 quietLogs();
34
35 let app: App;
36 let servers: Servers;
37 let repo: Repo;
38 let base: string;
39 let staleRow: string;
40 const cookies: Record<string, string> = {};
41
42 async function person(name: string, isAdmin = false): Promise<User> {
43 const made = await createUser(app.db, { username: name, email: `${name}@x.test`, password: "correct horse battery", origin: "seed", isAdmin, trusted: true });
44 if (!made.ok) throw new Error(String(made.error));
45 cookies[name] = `forge_session=${createSession(app.db, made.value.id).token}`;
46 return made.value;
47 }
48
49 beforeAll(async () => {
50 const dataDir = mkdtempSync(join(tmpdir(), "machine-shell-"));
51 app = createApp({ dataDir, port: 0 });
52 setShellOptions(app, { idleMs: 60_000, contain: "uncontained", command: "exec /bin/sh", handshakesPerMinute: 1000 });
53 const alice = await person("alice");
54 await person("bob");
55 const made = await createRepo(app.db, app.reposRoot, { ownerId: alice.id, name: "lab", visibility: "public" });
56 if (!made.ok) throw new Error(String(made.error));
57 repo = made.value;
58 const shut = await createRepo(app.db, app.reposRoot, { ownerId: alice.id, name: "shut", visibility: "private" });
59 if (!shut.ok) throw new Error(String(shut.error));
60 // A row a crashed forge left open, closed when this one boots.
61 staleRow = recordStart(app.db, repo.id, "standard", alice.id);
62 servers = await serve(app);
63 base = servers.url.replace(/^http/, "ws");
64 });
65
66 afterAll(() => {
67 servers.stop();
68 app.close();
69 });
70
71 const ORIGIN = () => new URL(servers.url).origin;
72
73 /** The handshake's answer, as fetch sees it: 101 never comes back to fetch, a refusal does. */
74 async function handshake(who: string | undefined, origin: string | undefined, path = "/alice/lab/machines/standard/shell", extra: Record<string, string> = {}): Promise<number> {
75 const headers: Record<string, string> = {
76 ...extra,
77 connection: "Upgrade",
78 upgrade: "websocket",
79 "sec-websocket-version": "13",
80 "sec-websocket-key": "dGhlIHNhbXBsZSBub25jZQ==",
81 };
82 if (who !== undefined) headers["cookie"] = cookies[who]!;
83 if (origin !== undefined) headers["origin"] = origin;
84 const res = await fetch(`${ORIGIN()}${path}`, { headers });
85 await res.body?.cancel();
86 return res.status;
87 }
88
89 function open(who: string, machine = "standard"): Promise<{ ws: WebSocket; seen: () => string }> {
90 const ws = new WebSocket(`${base}/alice/lab/machines/${machine}/shell`, {
91 headers: { cookie: cookies[who]!, origin: ORIGIN() },
92 } as unknown as string[]);
93 ws.binaryType = "arraybuffer";
94 let text = "";
95 ws.addEventListener("message", (e) => {
96 text += typeof e.data === "string" ? e.data : new TextDecoder().decode(e.data as ArrayBuffer);
97 });
98 return new Promise((resolve, reject) => {
99 ws.addEventListener("open", () => resolve({ ws, seen: () => text }));
100 ws.addEventListener("error", () => reject(new Error("refused")));
101 });
102 }
103
104 async function until(what: () => boolean, ms = 5_000): Promise<void> {
105 const end = Date.now() + ms;
106 while (!what()) {
107 if (Date.now() > end) throw new Error("timed out");
108 await Bun.sleep(20);
109 }
110 }
111
112 test("the path names a repository and a machine, or nothing", () => {
113 expect(shellTarget("/alice/lab/machines/standard/shell")).toEqual({ owner: "alice", name: "lab", machine: "standard" });
114 expect(shellTarget("/alice/lab/machines/Bad;rm/shell")).toBeUndefined();
115 expect(shellTarget("/alice/lab/machines/standard/shell/x")).toBeUndefined();
116 });
117
118 test("a resize frame is validated", () => {
119 expect(parseControl('{"type":"resize","cols":120,"rows":40}')).toEqual({ cols: 120, rows: 40 });
120 expect(parseControl('{"type":"resize","cols":0,"rows":40}')).toBeUndefined();
121 expect(parseControl('{"type":"resize","cols":1.5,"rows":40}')).toBeUndefined();
122 expect(parseControl('{"type":"resize","cols":9999,"rows":40}')).toBeUndefined();
123 expect(parseControl('{"type":"exec","cols":80,"rows":24}')).toBeUndefined();
124 expect(parseControl("not json")).toBeUndefined();
125 });
126
127 test("a reader, a stranger, another origin and an unknown machine are refused", async () => {
128 expect(await handshake("bob", ORIGIN())).toBe(403);
129 expect(await handshake(undefined, ORIGIN())).toBe(403);
130 expect(await handshake("alice", "https://evil.test")).toBe(403);
131 expect(await handshake("alice", undefined)).toBe(403);
132 expect(await handshake("alice", ORIGIN(), "/alice/lab/machines/nope/shell")).toBe(404);
133 expect(await handshake("alice", ORIGIN(), "/alice/missing/machines/standard/shell")).toBe(404);
134 // A token, even one that may write, never opens a shell.
135 expect(await handshake(undefined, ORIGIN(), undefined, { authorization: `Bearer ${tokenFor(app, "alice", "rw")}` })).toBe(403);
136 // A reader of a private repository is not told it exists.
137 expect(await handshake("bob", ORIGIN(), "/alice/shut/machines/standard/shell")).toBe(404);
138 // Only the row a crashed forge left behind, closed at boot.
139 expect(lastSessions(app.db, repo.id).map((r) => [r.id, r.endReason])).toEqual([[staleRow, "stopped"]]);
140 });
141
142 test("a writer's session echoes input, applies a resize, reattaches, and is recorded", async () => {
143 const first = await open("alice");
144 first.ws.send(new TextEncoder().encode("echo hel''lo-shell\n"));
145 await until(() => first.seen().includes("hello-shell"));
146
147 first.ws.send(JSON.stringify({ type: "resize", cols: 111, rows: 33 }));
148 first.ws.send(new TextEncoder().encode("stty size\n"));
149 await until(() => first.seen().includes("33 111"));
150
151 // A second connection joins the same session, and sees its output.
152 const second = await open("alice");
153 expect(liveSessions(app)).toBe(1);
154 first.ws.send(new TextEncoder().encode("echo two''-tabs\n"));
155 await until(() => second.seen().includes("two-tabs"));
156
157 const started = lastSessions(app.db, repo.id);
158 expect(started).toHaveLength(2);
159 expect(started[0]).toMatchObject({ machine: "standard", ownerName: "alice", endedAt: undefined });
160
161 const closed = new Promise((r) => second.ws.addEventListener("close", r));
162 first.ws.send(new TextEncoder().encode("exit\n"));
163 await closed;
164 await until(() => lastSessions(app.db, repo.id)[0]?.endedAt !== undefined);
165 expect(lastSessions(app.db, repo.id)[0]).toMatchObject({ endReason: "exit" });
166 expect(liveSessions(app)).toBe(0);
167 });
168
169 test("a text frame that is not a resize ends the connection", async () => {
170 const { ws } = await open("alice");
171 const closed = new Promise<number>((r) => ws.addEventListener("close", (e) => r(e.code)));
172 ws.send("rm -rf /");
173 expect(await closed).toBe(1003);
174 });
175
176 test("a session with no keystroke past the idle limit ends, and says so", async () => {
177 stopShells(app);
178 setShellOptions(app, { idleMs: 150, contain: "uncontained", command: "exec /bin/sh", handshakesPerMinute: 1000 });
179 const { ws } = await open("alice");
180 await new Promise((r) => ws.addEventListener("close", r));
181 await until(() => lastSessions(app.db, repo.id)[0]?.endReason !== undefined);
182 expect(lastSessions(app.db, repo.id)[0]?.endReason).toBe("idle");
183 setShellOptions(app, { idleMs: 60_000, contain: "uncontained", command: "exec /bin/sh", handshakesPerMinute: 1000 });
184 });
185
186 test("a forge that cannot hold a cell refuses the shell", async () => {
187 setShellOptions(app, { idleMs: 60_000, contain: "refuse", command: "exec /bin/sh", handshakesPerMinute: 1000 });
188 const ws = new WebSocket(`${base}/alice/lab/machines/standard/shell`, {
189 headers: { cookie: cookies["alice"]!, origin: ORIGIN() },
190 } as unknown as string[]);
191 const code = await new Promise<number>((r) => ws.addEventListener("close", (e) => r(e.code)));
192 expect(code).toBe(1011);
193 setShellOptions(app, { idleMs: 60_000, contain: "uncontained", command: "exec /bin/sh", handshakesPerMinute: 1000 });
194 });
195
196 test("a row left open by a forge that stopped is closed as stopped at boot", () => {
197 const row = recordStart(app.db, repo.id, "standard", lastSessions(app.db, repo.id)[0]!.ownerId);
198 expect(closeStaleSessions(app.db)).toBe(1);
199 expect(lastSessions(app.db, repo.id).find((r) => r.id === row)?.endReason).toBe("stopped");
200 });
201
202 test("stopping the forge ends a session as stopped", async () => {
203 const { ws } = await open("alice");
204 const closed = new Promise((r) => ws.addEventListener("close", r));
205 stopShells(app);
206 await closed;
207 expect(lastSessions(app.db, repo.id)[0]?.endReason).toBe("stopped");
208 expect(liveSessions(app)).toBe(0);
209 });
210
211 test("a session ends when its owner can no longer write here", async () => {
212 setShellOptions(app, { idleMs: 60_000, contain: "uncontained", command: "exec /bin/sh", checkMs: 30, handshakesPerMinute: 1000 });
213 const { ws } = await open("alice");
214 const closed = new Promise((r) => ws.addEventListener("close", r));
215 app.db.run("UPDATE user SET suspended_at = ? WHERE username = 'alice'", [new Date().toISOString()]);
216 await closed;
217 await until(() => lastSessions(app.db, repo.id)[0]?.endReason !== undefined);
218 expect(lastSessions(app.db, repo.id)[0]?.endReason).toBe("revoked");
219 expect(liveSessions(app)).toBe(0);
220 app.db.run("UPDATE user SET suspended_at = NULL WHERE username = 'alice'");
221 setShellOptions(app, { idleMs: 60_000, contain: "uncontained", command: "exec /bin/sh", handshakesPerMinute: 1000 });
222 });
223
224 test("a keystroke frame past the cap closes the connection", async () => {
225 const { ws } = await open("alice");
226 const closed = new Promise<number>((r) => ws.addEventListener("close", (e) => r(e.code)));
227 ws.send(new Uint8Array(64 * 1024 + 1));
228 expect(await closed).toBe(1009);
229 stopShells(app);
230 });
231
232 test("one person holds at most a few sessions at once, and a handshake flood is refused", async () => {
233 expect(MAX_SESSIONS.person).toBe(3);
234 const held = [await open("alice"), await open("alice", "security")];
235 expect(liveSessions(app)).toBe(2);
236 setShellOptions(app, { idleMs: 60_000, contain: "uncontained", command: "exec /bin/sh", handshakesPerMinute: 0 });
237 expect(await handshake("alice", ORIGIN())).toBe(429);
238 setShellOptions(app, { idleMs: 60_000, contain: "uncontained", command: "exec /bin/sh", handshakesPerMinute: 1000 });
239 for (const h of held) h.ws.close();
240 stopShells(app);
241 });
242
243 test("each writer has their own directory, and the cell hides the forge and holds the machine's ceilings", () => {
244 const machine = { ...BUILT_IN[0]!, memoryMiB: 2048, cpus: 2 };
245 const a = workspace(app, repo, machine, "u1");
246 const b = workspace(app, repo, machine, "u2");
247 expect(a).not.toBe(b);
248 expect(existsSync(join(app.config.dataDir, "machines", repo.id, "standard"))).toBe(true);
249 const spec = shellCell(app, a, machine, "machine:x/standard");
250 expect(spec.hide).toContain(app.config.dataDir);
251 expect(spec.egress).toEqual([]);
252 expect(spec.limits.memoryMiB).toBe(2048);
253 expect(spec.limits.cpus).toBe(2);
254 expect(spec.home).toBe(join(a, "home"));
255 expect(spec.terminal).toBe(true);
256 });
257
258 test("the last sessions are read by their index", () => {
259 const plan = app.db
260 .query("EXPLAIN QUERY PLAN SELECT id FROM machine_sessions WHERE repo_id = ? ORDER BY started_at DESC LIMIT 20")
261 .all("r")
262 .map((r) => (r as { detail: string }).detail)
263 .join("\n");
264 expect(plan).toContain("machine_sessions_repo_started");
265 expect(plan).not.toContain("TEMP B-TREE");
266 });
267
268 test("the repository's settings show a writer the last sessions, and a reader none", async () => {
269 const page = await fetch(`${servers.url}/alice/lab/settings/machines`, { headers: { cookie: cookies["alice"]! } });
270 const body = await page.text();
271 expect(page.status).toBe(200);
272 expect(body).toContain("Last sessions");
273 expect(body).toContain("idle too long");
274 expect(body).toContain("the shell exited");
275 const reader = await fetch(`${servers.url}/alice/lab/settings/machines`, { headers: { cookie: cookies["bob"]! } });
276 await reader.body?.cancel();
277 expect(reader.status).toBe(403);
278 });
test/palette-roster.test.ts +4
@@ -57,6 +57,7 @@ test("a site-admin person is offered the whole roster", () => {
5757 ["milestones", "Milestones in a repository", "owner/name"],
5858 ["next-up", "Next up in a repository", "owner/name"],
5959 ["week", "The week in a repository", "owner/name"],
60 ["machines", "Machines of a repository", "owner/name"],
6061 ["repo-usage", "Usage of a repository", "owner/name"],
6162 ["new-issue", "New issue", "owner/name"],
6263 ["new-pull", "New pull request", "owner/name"],
@@ -157,6 +158,7 @@ test("a site-admin agent is offered the roster without the person-only entries",
157158 ["milestones", "Milestones in a repository", "owner/name"],
158159 ["next-up", "Next up in a repository", "owner/name"],
159160 ["week", "The week in a repository", "owner/name"],
161 ["machines", "Machines of a repository", "owner/name"],
160162 ["repo-usage", "Usage of a repository", "owner/name"],
161163 ["new-issue", "New issue", "owner/name"],
162164 ["new-pull", "New pull request", "owner/name"],
@@ -221,6 +223,7 @@ test("a member person is offered the signed-in roster without the admin entries"
221223 ["milestones", "Milestones in a repository", "owner/name"],
222224 ["next-up", "Next up in a repository", "owner/name"],
223225 ["week", "The week in a repository", "owner/name"],
226 ["machines", "Machines of a repository", "owner/name"],
224227 ["repo-usage", "Usage of a repository", "owner/name"],
225228 ["new-issue", "New issue", "owner/name"],
226229 ["new-pull", "New pull request", "owner/name"],
@@ -280,6 +283,7 @@ test("signed out, the palette offers sign-in and the public entries", () => {
280283 ["milestones", "Milestones in a repository", "owner/name"],
281284 ["next-up", "Next up in a repository", "owner/name"],
282285 ["week", "The week in a repository", "owner/name"],
286 ["machines", "Machines of a repository", "owner/name"],
283287 ["repo-usage", "Usage of a repository", "owner/name"],
284288 ["new-issue", "New issue", "owner/name"],
285289 ["new-pull", "New pull request", "owner/name"],
test/tui-machines.test.ts added +81
@@ -0,0 +1,81 @@
1 /**
2 * A repository's machines and last shell sessions in the terminal (#9471),
3 * the door beside Settings → Machines: a writer sees the machines and each
4 * session's machine, owner and end; a reader sees neither.
5 */
6 import { afterAll, beforeAll, 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 { createApp, type App as Forge } from "../src/app.ts";
11 import { createRepo, type Repo } from "../src/core/repos.ts";
12 import { createUser, type User } from "../src/core/users.ts";
13 import { recordEnd, recordStart } from "../src/machines/shell.ts";
14 import { createForgeApp } from "../src/tui/forge.ts";
15 import type { TuiIdentity } from "../src/tui/session.ts";
16 import { headlessTerminal } from "../src/tui/term/terminal.ts";
17 import { quietLogs } from "./helpers/globals.ts";
18 import { settleApp } from "./helpers/tui.ts";
19
20 quietLogs();
21
22 let dataDir: string;
23 let forge: Forge;
24 let repo: Repo;
25 const people: Record<string, User> = {};
26
27 beforeAll(async () => {
28 dataDir = await mkdtemp(join(tmpdir(), "forge-tui-machines-"));
29 forge = createApp({ dataDir, port: 0 });
30 for (const name of ["alice", "bob"]) {
31 const made = await createUser(forge.db, { origin: "seed", trusted: true, username: name, email: `${name}@forge.local`, password: "a-good-passphrase" });
32 if (!made.ok) throw new Error(made.error);
33 people[name] = made.value;
34 }
35 const made = await createRepo(forge.db, forge.reposRoot, { ownerId: people["alice"]!.id, name: "lab", visibility: "public" });
36 if (!made.ok) throw new Error(made.error);
37 repo = made.value;
38 recordEnd(forge.db, recordStart(forge.db, repo.id, "standard", people["alice"]!.id), "idle");
39 });
40
41 afterAll(async () => {
42 forge.close();
43 await rm(dataDir, { recursive: true, force: true });
44 });
45
46 async function machinesAs(user: User): Promise<string> {
47 const terminal = headlessTerminal(140, 32);
48 const identity: TuiIdentity = { sshUsername: "t", remoteAddress: "127.0.0.1", user };
49 const { app } = createForgeApp(forge, identity, { terminal, exitHooks: false });
50 app.start();
51 const key = (k: string, ctrl = false) => app.dispatch({ type: "key", key: k, ctrl, alt: false, shift: false });
52 await settleApp(app);
53 key("k", true);
54 await settleApp(app);
55 for (const ch of "Machines of a repository") key(ch);
56 await settleApp(app);
57 key("enter");
58 await settleApp(app);
59 for (const ch of "alice/lab") key(ch);
60 await settleApp(app);
61 key("enter");
62 await settleApp(app);
63 await Bun.sleep(50);
64 await settleApp(app);
65 const text = app.screen.toText();
66 app.stop();
67 return text;
68 }
69
70 test("a writer sees the machines and the last sessions", async () => {
71 const text = await machinesAs(people["alice"]!);
72 expect(text).toContain("Machines: standard; security");
73 expect(text).toContain("standard · alice");
74 expect(text).toContain("idle too long");
75 });
76
77 test("a reader sees neither", async () => {
78 const text = await machinesAs(people["bob"]!);
79 expect(text).toContain("writers'");
80 expect(text).not.toContain("standard · alice");
81 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs