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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9471-a-writer-opens-a-shell-into-a-machine-ov → main 5 commits fork
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): | ||
| 44 | 44 | `FORGE_MACHINE` and `FORGE_MACHINE_TOOLS` (names only) in its environment, a |
| 45 | 45 | line in its brief naming the tools its machine carries, and its `apps` reach. |
| 46 | 46 | |
| 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 | ||
| 47 | 88 | ## Not done yet (split from #9418) |
| 48 | 89 | |
| 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 { | ||
| 534 | 534 | } from "../spawn/host.ts"; |
| 535 | 535 | import { getVerifiedDomain } from "../core/domains.ts"; |
| 536 | 536 | 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"; | |
| 537 | 551 | |
| 538 | 552 | // ---- auth ---- |
| 539 | 553 | |
| 554 | /** A machine's shell with no keystroke for this long ends (`machines/shell.ts`). */ | |
| 555 | const SHELL_IDLE_MS = 30 * 60_000; | |
| 556 | ||
| 540 | 557 | const NO_AUTH: Auth = { user: undefined, scope: undefined, via: "none" }; |
| 541 | 558 | |
| 542 | 559 | async function resolveAuth(app: App, request: Request): Promise<Auth> { |
| @@ -1353,6 +1370,7 @@ export function buildRouter(): Router<Handler> { | ||
| 1353 | 1370 | proposalTokenRevoke, |
| 1354 | 1371 | ); |
| 1355 | 1372 | router.add("GET", "/:owner/:name/settings/app", appSettingsPage); |
| 1373 | router.add("GET", "/:owner/:name/settings/machines", machinesSettingsPage); | |
| 1356 | 1374 | router.add("POST", "/:owner/:name/settings/app", appSettingsUpdate); |
| 1357 | 1375 | router.add("POST", "/:owner/:name/settings/app/disable", appDisable); |
| 1358 | 1376 | router.add("POST", "/:owner/:name/settings/app/members", appMembersUpdate); |
| @@ -2143,6 +2161,8 @@ export function forgeHttp(app: App): ForgeHttp { | ||
| 2143 | 2161 | |
| 2144 | 2162 | export async function serve(app: App): Promise<Servers> { |
| 2145 | 2163 | const router = buildRouter(); |
| 2164 | // No shell outlives its forge: a row a stop or crash left open is closed. | |
| 2165 | closeStaleSessions(app.db); | |
| 2146 | 2166 | const apiRouter = buildApiRouter(); |
| 2147 | 2167 | await clearStaleSockets(app); |
| 2148 | 2168 | // 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> { | ||
| 2338 | 2358 | |
| 2339 | 2359 | const handle = async ( |
| 2340 | 2360 | request: Request, |
| 2341 | server: Bun.Server<AppSocket>, | |
| 2361 | server: Bun.Server<ForgeSocket<AppSocket>>, | |
| 2342 | 2362 | ): Promise<Response | undefined> => { |
| 2343 | 2363 | const started = performance.now(); |
| 2344 | 2364 | noteStart(request, started); |
| @@ -2432,6 +2452,34 @@ export async function serve(app: App): Promise<Servers> { | ||
| 2432 | 2452 | if (ownUnknown) |
| 2433 | 2453 | return securityFloor(new Response("not found", { status: 404 })); |
| 2434 | 2454 | 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 | } | |
| 2435 | 2483 | // A body refused part-way, or one left unread with no declared length, |
| 2436 | 2484 | // ends its connection once answered (`http/limits.ts`). |
| 2437 | 2485 | armClose(request, () => server.timeout(request, 1)); |
| @@ -2460,7 +2508,7 @@ export async function serve(app: App): Promise<Servers> { | ||
| 2460 | 2508 | maxRequestBodySize: REQUEST_MAX, |
| 2461 | 2509 | // The only sockets the forge holds are an app's, carried through the |
| 2462 | 2510 | // proxy; its own live surfaces are SSE (`sse.ts`) and stay so. |
| 2463 | websocket: appWebSocket, | |
| 2511 | websocket: withShells(appWebSocket), | |
| 2464 | 2512 | fetch: handle, |
| 2465 | 2513 | // A last resort: `handle` answers a throw itself, so it is counted. |
| 2466 | 2514 | error: (error) => failure(app, error), |
| @@ -2484,6 +2532,7 @@ export async function serve(app: App): Promise<Servers> { | ||
| 2484 | 2532 | }, |
| 2485 | 2533 | stop() { |
| 2486 | 2534 | endOpen(); |
| 2535 | stopShells(app); | |
| 2487 | 2536 | prerender(); |
| 2488 | 2537 | clearInterval(sweep); |
| 2489 | 2538 | // 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"; | ||
| 81 | 81 | import { limbsScreen } from "./screens/limbs.ts"; |
| 82 | 82 | import { nextScreen } from "./screens/next.ts"; |
| 83 | 83 | import { weekScreen } from "./screens/week.ts"; |
| 84 | import { machinesScreen } from "./screens/machines.ts"; | |
| 84 | 85 | import { newPullScreen } from "./screens/newpull.ts"; |
| 85 | 86 | import { newForgeScreen } from "./screens/newforge.ts"; |
| 86 | 87 | import { newOrgScreen } from "./screens/neworg.ts"; |
| @@ -143,6 +144,12 @@ const REPO_VERBS: readonly { | ||
| 143 | 144 | title: "The week in a repository", |
| 144 | 145 | open: (f, ctx, repo) => weekScreen(f, ctx, repo), |
| 145 | 146 | }, |
| 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 | }, | |
| 146 | 153 | { |
| 147 | 154 | // What the crew's tokens bought on one app, as MCP `returns` with `repo` (#7271). |
| 148 | 155 | 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 = | ||
| 116 | 116 | | "prompts" |
| 117 | 117 | | "labels" |
| 118 | 118 | | "hooks" |
| 119 | | "machines" | |
| 119 | 120 | | "danger"; |
| 120 | 121 | |
| 121 | 122 | /** The repo, but only for someone who administers it. A non-admin is told |
| @@ -165,6 +166,12 @@ function sections(base: string, active: Section) { | ||
| 165 | 166 | icon: "webhook" as const, |
| 166 | 167 | current: active === "hooks", |
| 167 | 168 | }, |
| 169 | { | |
| 170 | label: "Machines", | |
| 171 | href: `${base}/settings/machines`, | |
| 172 | icon: "terminal" as const, | |
| 173 | current: active === "machines", | |
| 174 | }, | |
| 168 | 175 | { |
| 169 | 176 | label: "Danger zone", |
| 170 | 177 | 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", () => { | ||
| 57 | 57 | ["milestones", "Milestones in a repository", "owner/name"], |
| 58 | 58 | ["next-up", "Next up in a repository", "owner/name"], |
| 59 | 59 | ["week", "The week in a repository", "owner/name"], |
| 60 | ["machines", "Machines of a repository", "owner/name"], | |
| 60 | 61 | ["repo-usage", "Usage of a repository", "owner/name"], |
| 61 | 62 | ["new-issue", "New issue", "owner/name"], |
| 62 | 63 | ["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", | ||
| 157 | 158 | ["milestones", "Milestones in a repository", "owner/name"], |
| 158 | 159 | ["next-up", "Next up in a repository", "owner/name"], |
| 159 | 160 | ["week", "The week in a repository", "owner/name"], |
| 161 | ["machines", "Machines of a repository", "owner/name"], | |
| 160 | 162 | ["repo-usage", "Usage of a repository", "owner/name"], |
| 161 | 163 | ["new-issue", "New issue", "owner/name"], |
| 162 | 164 | ["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" | ||
| 221 | 223 | ["milestones", "Milestones in a repository", "owner/name"], |
| 222 | 224 | ["next-up", "Next up in a repository", "owner/name"], |
| 223 | 225 | ["week", "The week in a repository", "owner/name"], |
| 226 | ["machines", "Machines of a repository", "owner/name"], | |
| 224 | 227 | ["repo-usage", "Usage of a repository", "owner/name"], |
| 225 | 228 | ["new-issue", "New issue", "owner/name"], |
| 226 | 229 | ["new-pull", "New pull request", "owner/name"], |
| @@ -280,6 +283,7 @@ test("signed out, the palette offers sign-in and the public entries", () => { | ||
| 280 | 283 | ["milestones", "Milestones in a repository", "owner/name"], |
| 281 | 284 | ["next-up", "Next up in a repository", "owner/name"], |
| 282 | 285 | ["week", "The week in a repository", "owner/name"], |
| 286 | ["machines", "Machines of a repository", "owner/name"], | |
| 283 | 287 | ["repo-usage", "Usage of a repository", "owner/name"], |
| 284 | 288 | ["new-issue", "New issue", "owner/name"], |
| 285 | 289 | ["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 | }); |