Public: anyone can read this; the crew can change it.
A writer's shell reaches the repository's own production and previews when its machine says `apps`, and nothing else #9943
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9934-a-writer-s-shell-reaches-the-repository → main 3 commits fork 1 thread · 1 unresolved
4 files
changed +88
−12
docs/features/machines.md +7 −3
| @@ -62,7 +62,12 @@ repository's machines: | ||
| 62 | 62 | it does not declare, answers 404. |
| 63 | 63 | - **Where.** tmux (or `/bin/sh` where tmux is not installed) on a real pty, |
| 64 | 64 | in a cell (`apps/contain.ts`) under the machine's memory and CPU ceilings, |
| 65 | with the forge's data hidden and no reach beyond the forge. Each writer has | |
| 65 | with the forge's data hidden. It reaches the forge and, when the machine | |
| 66 | says `apps` (as `standard` does), the repository's own production and each | |
| 67 | open pull's preview, each at its exact host and port through the cell's | |
| 68 | egress proxy (`shellReach`, #9934); a machine with `reach: []` reaches the | |
| 69 | forge alone. Another repository's app and every other host stay refused, | |
| 70 | and nothing a machine says un-hides the forge's data. Each writer has | |
| 66 | 71 | a directory and home of their own on each machine, |
| 67 | 72 | `<data>/machines/<repo id>/<machine>/<user id>`: one writer's files and |
| 68 | 73 | shell history are never the next's. A forge that cannot hold a cell closes |
| @@ -134,5 +139,4 @@ forge that cannot hold a cell says so in the status line and closes. | ||
| 134 | 139 | ## Not done yet (split from #9418) |
| 135 | 140 | |
| 136 | 141 | Disk ceilings are not declared (#9460); the tools are declared, not yet |
| 137 | installed in an image (the brief reports which are on PATH); the shell's cell does not reach the repository's | |
| 138 | apps even when its machine says `apps`. | |
| 142 | installed in an image (the brief reports which are on PATH). |
src/machines/shell.ts +38 −6
| @@ -5,7 +5,8 @@ | ||
| 5 | 5 | * (the Origin must be the forge's). It runs tmux on a real pty |
| 6 | 6 | * (`Bun.spawn({terminal})`), held in a cell (`apps/contain.ts`) — the same |
| 7 | 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 | |
| 8 | * and CPU ceilings and no reach beyond the forge, save the repository's own | |
| 9 | * production and previews when its machine says `apps` (`shellReach`). A forge that cannot hold a | |
| 9 | 10 | * cell refuses the shell rather than hand out one on the host. |
| 10 | 11 | * |
| 11 | 12 | * One session per person, repository and machine: a second connection (a |
| @@ -33,7 +34,8 @@ import { getUserById, isActive } from "../core/users.ts"; | ||
| 33 | 34 | import { isSafeName } from "../git/guard.ts"; |
| 34 | 35 | import type { User } from "../core/users.ts"; |
| 35 | 36 | import { repoMachines } from "../crew/machines_read.ts"; |
| 36 | import type { Machine } from "../crew/machines.ts"; | |
| 37 | import { reachableApps, type Machine } from "../crew/machines.ts"; | |
| 38 | import { ownApp, ownAppUrls } from "../crew/app_hosts.ts"; | |
| 37 | 39 | import { id as newId } from "../kernel/id.ts"; |
| 38 | 40 | |
| 39 | 41 | // ---- the route ---- |
| @@ -333,8 +335,30 @@ export function workspace(app: App, repo: Repo, machine: Machine, userId: string | ||
| 333 | 335 | return join(app.config.dataDir, "machines", repo.id, machine.name, userId); |
| 334 | 336 | } |
| 335 | 337 | |
| 336 | /** The cell a shell runs in: its own directory, the forge's data hidden, the machine's ceilings. */ | |
| 337 | export function shellCell(app: App, dir: string, machine: Machine, label: string): CellSpec { | |
| 338 | /** | |
| 339 | * The origins a shell's cell may open (#9934): the forge always, and the | |
| 340 | * repository's own production and previews only when the machine says | |
| 341 | * `apps`. `appUrls` are made from this repository's slug and pulls | |
| 342 | * (`crew/app_hosts.ts`), so another repository's app is never among them; | |
| 343 | * each is an exact host and port, never a pattern. | |
| 344 | */ | |
| 345 | export function shellReach(forgeUrl: string | undefined, machine: Machine, appUrls: readonly string[]): string[] { | |
| 346 | const forge = forgeUrl === undefined || forgeUrl === "" ? [] : [forgeUrl]; | |
| 347 | return [...forge, ...reachableApps(machine, appUrls)]; | |
| 348 | } | |
| 349 | ||
| 350 | /** | |
| 351 | * The cell a shell runs in: its own directory, the forge's data hidden, the | |
| 352 | * machine's ceilings, and the reach `shellReach` decides. Nothing the machine | |
| 353 | * says un-hides the forge's data. | |
| 354 | */ | |
| 355 | export function shellCell( | |
| 356 | app: App, | |
| 357 | dir: string, | |
| 358 | machine: Machine, | |
| 359 | label: string, | |
| 360 | appUrls: readonly string[] = [], | |
| 361 | ): CellSpec { | |
| 338 | 362 | const step = resolveLimits("step", undefined); |
| 339 | 363 | return { |
| 340 | 364 | dir, |
| @@ -342,7 +366,7 @@ export function shellCell(app: App, dir: string, machine: Machine, label: string | ||
| 342 | 366 | home: join(dir, "home"), |
| 343 | 367 | hide: [app.config.dataDir, process.env["HOME"] ?? "", tmpdir()], |
| 344 | 368 | egress: [], |
| 345 | reach: [app.config.url ?? ""].filter((u) => u !== ""), | |
| 369 | reach: shellReach(app.config.url, machine, appUrls), | |
| 346 | 370 | limits: { |
| 347 | 371 | ...step, |
| 348 | 372 | memoryMiB: machine.memoryMiB ?? step.memoryMiB, |
| @@ -393,7 +417,15 @@ function start(app: App, grant: ShellGrant, opts: ShellOptions, cols: number, ro | ||
| 393 | 417 | }, |
| 394 | 418 | }); |
| 395 | 419 | if (opts.contain === "cell") |
| 396 | cell = openCell(shellCell(app, dir, grant.machine, `machine:${grant.repo.id}/${grant.machine.name}`)); | |
| 420 | cell = openCell( | |
| 421 | shellCell( | |
| 422 | app, | |
| 423 | dir, | |
| 424 | grant.machine, | |
| 425 | `machine:${grant.repo.id}/${grant.machine.name}`, | |
| 426 | ownAppUrls(ownApp(app, grant.repo)), | |
| 427 | ), | |
| 428 | ); | |
| 397 | 429 | const env: Record<string, string> = { |
| 398 | 430 | PATH: process.env["PATH"] ?? "/usr/bin:/bin", |
| 399 | 431 | HOME: join(dir, "home"), |
test/crew-app-hosts.test.ts +35
| @@ -19,6 +19,8 @@ import { createUser, type User } from "../src/core/users.ts"; | ||
| 19 | 19 | import { ownApp, ownAppUrls } from "../src/crew/app_hosts.ts"; |
| 20 | 20 | import { ownAppLines } from "../src/crew/brief.ts"; |
| 21 | 21 | import { sandboxPolicy } from "../src/crew/sandbox.ts"; |
| 22 | import { STANDARD, type Machine } from "../src/crew/machines.ts"; | |
| 23 | import { shellCell, shellReach } from "../src/machines/shell.ts"; | |
| 22 | 24 | |
| 23 | 25 | const DOMAIN = "--forge-trevato-apps.open-platform.sh"; |
| 24 | 26 | |
| @@ -143,4 +145,37 @@ describe("a crew run's own app hosts", () => { | ||
| 143 | 145 | ownAppLines({ production: "https://x.example", previews: [] })[0], |
| 144 | 146 | ).toContain("no pull has a preview yet"); |
| 145 | 147 | }); |
| 148 | ||
| 149 | describe("a writer's shell (#9934)", () => { | |
| 150 | const machine = (reach: Machine["reach"]): Machine => ({ | |
| 151 | name: "box", | |
| 152 | label: "box", | |
| 153 | tools: [], | |
| 154 | reach, | |
| 155 | }); | |
| 156 | const FORGE = "https://forge.open-platform.sh"; | |
| 157 | ||
| 158 | test("a machine with `apps` reach opens R's production and previews, exactly, and no other repository's", () => { | |
| 159 | const spec = shellCell(app, join(dataDir, "w"), machine(["apps"]), "machine:x/box", ownAppUrls(ownApp(app, arm))); | |
| 160 | const forge = app.config.url === undefined ? [] : [app.config.url]; | |
| 161 | expect(spec.reach).toEqual([...forge, `https://arm${DOMAIN}`, `https://arm--${pullNumber}${DOMAIN}`]); | |
| 162 | expect(spec.reach.some((u) => u.includes("armory"))).toBe(false); | |
| 163 | expect(spec.egress).toEqual([]); | |
| 164 | // The forge's own data stays hidden whatever the machine says. | |
| 165 | expect(spec.hide).toContain(app.config.dataDir); | |
| 166 | }); | |
| 167 | ||
| 168 | test("a machine with no reach opens the forge alone", () => { | |
| 169 | const urls = ownAppUrls(ownApp(app, arm)); | |
| 170 | expect(shellReach(FORGE, machine([]), urls)).toEqual([FORGE]); | |
| 171 | const spec = shellCell(app, join(dataDir, "w"), machine([]), "machine:x/box", urls); | |
| 172 | expect(spec.reach.some((u) => u.includes(DOMAIN))).toBe(false); | |
| 173 | }); | |
| 174 | ||
| 175 | test("the reach follows what the machine declares, `standard` included", () => { | |
| 176 | const urls = ownAppUrls(ownApp(app, arm)); | |
| 177 | expect(shellReach(FORGE, STANDARD, urls)).toEqual([FORGE, ...urls]); | |
| 178 | expect(shellReach(undefined, machine(["apps"]), [])).toEqual([]); | |
| 179 | }); | |
| 180 | }); | |
| 146 | 181 | }); |
test/web-commits-check.test.ts +8 −3
| @@ -78,8 +78,13 @@ afterAll(async () => { | ||
| 78 | 78 | await rm(dir, { recursive: true, force: true }); |
| 79 | 79 | }); |
| 80 | 80 | |
| 81 | const get = async (path: string, cookie?: string) => | |
| 82 | (await fetch(`${servers.url}${path}`, { headers: cookie ? { cookie } : {} })).text(); | |
| 81 | /** | |
| 82 | * Signed in as the owner unless `null` asks for a stranger: a stranger's deep | |
| 83 | * pages are budgeted per address (`http/crawl.ts`), a quarter of it while a | |
| 84 | * busy shard runs the loop late, and these tests are about markup, not that. | |
| 85 | */ | |
| 86 | const get = async (path: string, cookie: string | null = ownerCookie) => | |
| 87 | (await fetch(`${servers.url}${path}`, { headers: cookie !== null ? { cookie } : {} })).text(); | |
| 83 | 88 | |
| 84 | 89 | const rowOf = (page: string, subject: string): string => |
| 85 | 90 | (page.split("<li>").find((l) => l.includes(subject)) ?? "").split("</li>")[0]!; |
| @@ -118,7 +123,7 @@ test("every row of the list is reachable by keyboard, checked or not", async () | ||
| 118 | 123 | test("a private repository's verdict is the owner's alone", async () => { |
| 119 | 124 | const list = "/alice/priv/commits"; |
| 120 | 125 | const one = `/alice/priv/commit/${oids["priv/red"]}`; |
| 121 | for (const cookie of [undefined, bobCookie]) { | |
| 126 | for (const cookie of [null, bobCookie]) { | |
| 122 | 127 | expect(await get(list, cookie)).not.toContain(`/runs/${runs.priv}`); |
| 123 | 128 | const seen = await get(one, cookie); |
| 124 | 129 | expect(seen).not.toContain(`/runs/${runs.priv}`); |
Rewrap: this comment line runs far past the others.