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

Merged opened this

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

Merged

scout/forge:scout/9934-a-writer-s-shell-reaches-the-repository → main 3 commits fork 1 thread · 1 unresolved

  • Merged. scout/9934-a-writer-s-shell-reaches-the-repository was merged into main as f801636.

Review

Sign in to review this change.
4 files changed +88 −12
docs/features/machines.md +7 −3
@@ -62,7 +62,12 @@ repository's machines:
6262 it does not declare, answers 404.
6363 - **Where.** tmux (or `/bin/sh` where tmux is not installed) on a real pty,
6464 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
6671 a directory and home of their own on each machine,
6772 `<data>/machines/<repo id>/<machine>/<user id>`: one writer's files and
6873 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.
134139 ## Not done yet (split from #9418)
135140
136141 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 @@
55 * (the Origin must be the forge's). It runs tmux on a real pty
66 * (`Bun.spawn({terminal})`), held in a cell (`apps/contain.ts`) — the same
77 * 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

Rewrap: this comment line runs far past the others.

910 * cell refuses the shell rather than hand out one on the host.
1011 *
1112 * One session per person, repository and machine: a second connection (a
@@ -33,7 +34,8 @@ import { getUserById, isActive } from "../core/users.ts";
3334 import { isSafeName } from "../git/guard.ts";
3435 import type { User } from "../core/users.ts";
3536 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";
3739 import { id as newId } from "../kernel/id.ts";
3840
3941 // ---- the route ----
@@ -333,8 +335,30 @@ export function workspace(app: App, repo: Repo, machine: Machine, userId: string
333335 return join(app.config.dataDir, "machines", repo.id, machine.name, userId);
334336 }
335337
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 {
338362 const step = resolveLimits("step", undefined);
339363 return {
340364 dir,
@@ -342,7 +366,7 @@ export function shellCell(app: App, dir: string, machine: Machine, label: string
342366 home: join(dir, "home"),
343367 hide: [app.config.dataDir, process.env["HOME"] ?? "", tmpdir()],
344368 egress: [],
345 reach: [app.config.url ?? ""].filter((u) => u !== ""),
369 reach: shellReach(app.config.url, machine, appUrls),
346370 limits: {
347371 ...step,
348372 memoryMiB: machine.memoryMiB ?? step.memoryMiB,
@@ -393,7 +417,15 @@ function start(app: App, grant: ShellGrant, opts: ShellOptions, cols: number, ro
393417 },
394418 });
395419 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 );
397429 const env: Record<string, string> = {
398430 PATH: process.env["PATH"] ?? "/usr/bin:/bin",
399431 HOME: join(dir, "home"),
test/crew-app-hosts.test.ts +35
@@ -19,6 +19,8 @@ import { createUser, type User } from "../src/core/users.ts";
1919 import { ownApp, ownAppUrls } from "../src/crew/app_hosts.ts";
2020 import { ownAppLines } from "../src/crew/brief.ts";
2121 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";
2224
2325 const DOMAIN = "--forge-trevato-apps.open-platform.sh";
2426
@@ -143,4 +145,37 @@ describe("a crew run's own app hosts", () => {
143145 ownAppLines({ production: "https://x.example", previews: [] })[0],
144146 ).toContain("no pull has a preview yet");
145147 });
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 });
146181 });
test/web-commits-check.test.ts +8 −3
@@ -78,8 +78,13 @@ afterAll(async () => {
7878 await rm(dir, { recursive: true, force: true });
7979 });
8080
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();
8388
8489 const rowOf = (page: string, subject: string): string =>
8590 (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 ()
118123 test("a private repository's verdict is the owner's alone", async () => {
119124 const list = "/alice/priv/commits";
120125 const one = `/alice/priv/commit/${oids["priv/red"]}`;
121 for (const cookie of [undefined, bobCookie]) {
126 for (const cookie of [null, bobCookie]) {
122127 expect(await get(list, cookie)).not.toContain(`/runs/${runs.priv}`);
123128 const seen = await get(one, cookie);
124129 expect(seen).not.toContain(`/runs/${runs.priv}`);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs