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

A machine shell opens to a greeting, a prompt with the repository and branch, and history that persists #10611

Merged opened this

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

Merged

scout/forge:scout/10547-a-machine-shell-opens-to-a-greeting-a-pr → main 5 commits fork 5 threads · 5 unresolved

  • Merged. scout/10547-a-machine-shell-opens-to-a-greeting-a-pr was merged into main as 0db1115.

Review

Sign in to review this change.
5 files changed +468 −19
docs/features/machine-shell-rc.md added +9
@@ -0,0 +1,9 @@
1 # A machine shell's first screen
2
3 The shell opens in the person's own checkout of the repository (cloned once from the bare repo, kept between sessions), with bun and git on the path. It prints a greeting: which repository and machine, how to leave (detach with Ctrl-b d to keep it running, `exit` to end it) and the app's own `tty` command when forge.json has one. The prompt shows `owner/name:branch`. History is `.forge_history` in the person's home under `FORGE_APP_DATA/machines/<repo>/<machine>/<person>/home`, written after every command.
4
5 The rc file is the constant `RC` in `src/machines/rc.ts`; nothing from a request reaches it.
6
7 The checkout is cloned when the shell is first opened, off the event loop. Two connections at once share one clone. git gets 30 seconds (`CLONE_MS`), then it is killed; at most four first clones (`MAX_CLONES`) run across the forge at once. A clone that fails, runs out of time or finds no room leaves nothing behind (it is made in `checkout.partial` and renamed only when complete), the next connection tries again, and the greeting says the shell starts in home instead. Detach is only mentioned when tmux holds the session.
8
9 Without bash on the machine, `/bin/sh` still prints the greeting, starts in the checkout and shows the repository in the prompt; it has no branch in the prompt and no persistent history, because sh has neither.
src/http/server.ts +6 −3
@@ -579,7 +579,7 @@ import { cellsAvailable } from "../apps/contain.ts";
579579 import { machineShellPage, machinesSettingsPage } from "../web/pages/machines.ts";
580580 import { machineTermAsset, wasmResponse, ghosttyWasmAsset } from "../web/machine-term-asset.ts";
581581 import {
582 defaultCommand,
582 defaultShell,
583583 mayOpenShell,
584584 setShellOptions,
585585 shellOptions,
@@ -2612,12 +2612,15 @@ export async function serve(app: App): Promise<Servers> {
26122612 // first shell rather than at boot; a test that set its own keeps them.
26132613 if (shellOptions(app) === undefined) {
26142614 const cells = await cellsAvailable();
2615 if (shellOptions(app) === undefined)
2615 if (shellOptions(app) === undefined) {
2616 const plan = defaultShell();
26162617 setShellOptions(app, {
26172618 idleMs: () => shellIdleMs(app.db),
26182619 contain: cells.ok ? "cell" : "refuse",
2619 command: defaultCommand(),
2620 command: plan.command,
2621 detaches: plan.detaches,
26202622 });
2623 }
26212624 }
26222625 const data: ShellSocket = { shell: { ...may.grant, app } };
26232626 if (server.upgrade(request, { data })) return undefined;
src/machines/rc.ts added +166
@@ -0,0 +1,166 @@
1 /**
2 * What a machine's shell opens to (#10547): a greeting, a prompt with the
3 * repository and branch, and history that is there next session.
4 *
5 * The rc file is a constant in this source, written into the cell's home; no
6 * text from a request ever reaches it. What varies — which repository, which
7 * machine, the app's own `tty` command — travels as environment variables
8 * and a greeting file of plain text the rc only prints (`cat`), never
9 * evaluates. The names are validated before they get here; the tty command
10 * is stripped of control bytes.
11 *
12 * History lives in the person's own home, which is under
13 * `FORGE_APP_DATA/machines/<repo>/<machine>/<person>/home`: per person,
14 * repository and machine, and written after every command so a tab closed
15 * mid-session loses nothing.
16 */
17 import { mkdirSync, writeFileSync, existsSync, renameSync, rmSync } from "node:fs";
18 import { join } from "node:path";
19 import { gitEnv } from "../git/env.ts";
20
21 export const RC_FILE = ".forge_rc";
22 export const GREETING_FILE = ".forge_greeting";
23 export const HISTORY_FILE = ".forge_history";
24 /** The checkout's directory under the person's workspace. */
25 export const CHECKOUT_DIR = "checkout";
26
27 /** The rc file: constant. Reads FORGE_REPO, FORGE_MACHINE and the greeting file. */
28 export const RC = `# The forge's machine shell rc. Written by the forge; edits are replaced.
29 case $- in *i*) ;; *) return ;; esac
30 export HISTFILE="$HOME/${HISTORY_FILE}"
31 export HISTSIZE=100000
32 export HISTFILESIZE=100000
33 export HISTCONTROL=ignoredups
34 shopt -s histappend checkwinsize
35 PROMPT_COMMAND='history -a'
36 alias ls='ls --color=auto'
37 alias ll='ls -lA --color=auto'
38 alias la='ls -A --color=auto'
39 alias grep='grep --color=auto'
40 forge_branch() {
41 git symbolic-ref --short -q HEAD 2>/dev/null || git rev-parse --short HEAD 2>/dev/null
42 }
43 PS1='\\[\\e[1;36m\\]$FORGE_REPO\\[\\e[0m\\]:\\[\\e[1;33m\\]$(forge_branch)\\[\\e[0m\\] \\w \\$ '
44 if [ -d "$FORGE_CHECKOUT" ]; then cd "$FORGE_CHECKOUT"; fi
45 if [ -f "$HOME/${GREETING_FILE}" ]; then cat "$HOME/${GREETING_FILE}"; fi
46 `;
47
48 /** A one-line, printable form of text that may come from a repository's file. */
49 function plain(text: string): string {
50 return text.replace(/[^\x20-\x7e]/g, "").slice(0, 200);
51 }
52
53 export interface GreetingFacts {
54 readonly repo: string;
55 readonly machine: string;
56 /** Whether tmux holds the session, so detaching keeps it. */
57 readonly detaches: boolean;
58 /** Whether the checkout exists; without it the shell starts in home and says so. */
59 readonly checkout: boolean;
60 /** The app's own `tty` command from forge.json, when it has one. */
61 readonly tty: string | null;
62 }
63
64 /** The greeting, in plain words: where you are and how to leave. */
65 export function greeting(facts: GreetingFacts): string {
66 const leave = facts.detaches
67 ? "To leave and keep this shell running, detach: press Ctrl-b then d. To end it, type exit."
68 : "To leave, type exit (this shell ends with it).";
69 const lines = [
70 `This is the machine "${plain(facts.machine)}" of ${plain(facts.repo)}. ${facts.checkout ? "You are in its checkout, with bun and git on the path." : "Its checkout could not be made, so you are in your home directory; bun and git are on the path."}`,
71 leave,
72 ];
73 if (facts.tty !== null && plain(facts.tty) !== "")
74 lines.push(`The app's own terminal command: ${plain(facts.tty)}`);
75 return `${lines.join("\n")}\n\n`;
76 }
77
78 /** Where a person's history is kept, under the data directory. */
79 export function historyPath(home: string): string {
80 return join(home, HISTORY_FILE);
81 }
82
83 /** Write the rc and greeting into `home`, and make the history file exist. */
84 export function prepareHome(home: string, facts: GreetingFacts): void {
85 mkdirSync(home, { recursive: true });
86 writeFileSync(join(home, RC_FILE), RC);
87 writeFileSync(join(home, GREETING_FILE), greeting(facts));
88 if (!existsSync(historyPath(home))) writeFileSync(historyPath(home), "");
89 }
90
91 /** How long a first clone may take before git is killed and the shell starts in home. */
92 export const CLONE_MS = 30_000;
93 /** First clones running at once across the forge; past it a shell starts in home. */
94 export const MAX_CLONES = 4;
95
96 const inflight = new Map<string, Promise<boolean>>();
97
98 /**
outdated

Concurrent calls for the same dir race: the second does rmSync(target) on line 101 while the first is cloning. Share one in-flight promise per target, and kill git after a deadline (Bun.spawn accepts timeout/killSignal). Without a bound, one slow or huge repository holds the handshake and the disk.

99 * The person's checkout of the repository, cloned once from the bare
100 * repository on the default branch; later sessions find it as they left it.
101 *
102 * Single-flight per target: a second handshake for the same person and
103 * machine waits on the first clone rather than racing it. The clone goes to
104 * a sibling directory and is renamed into place only when git finished, so
105 * a half-made checkout is never mistaken for one. git is killed after
106 * `timeoutMs` (which also bounds how much a huge repository can write), and
107 * at most `MAX_CLONES` run at once; either way the answer is no, and the
108 * greeting says the shell is in home. The clone runs beside the event loop.
109 */
110 export function ensureCheckout(bareDir: string, dir: string, timeoutMs = CLONE_MS): Promise<boolean> {
111 const target = join(dir, CHECKOUT_DIR);
112 const running = inflight.get(target);
113 if (running !== undefined) return running;
114 if (existsSync(join(target, ".git"))) return Promise.resolve(true);
115 if (inflight.size >= MAX_CLONES) return Promise.resolve(false);
116 const flight = clone(bareDir, target, timeoutMs).finally(() => inflight.delete(target));
117 inflight.set(target, flight);
118 return flight;
119 }
120
121 async function clone(bareDir: string, target: string, timeoutMs: number): Promise<boolean> {
122 const partial = `${target}.partial`;
123 rmSync(partial, { recursive: true, force: true });
124 let code = 1;
125 try {
126 const proc = Bun.spawn(["git", "clone", "--quiet", "--", bareDir, partial], {
127 env: gitEnv(),
128 stdin: "ignore",
129 stdout: "ignore",
130 stderr: "ignore",
131 timeout: timeoutMs,
132 killSignal: "SIGKILL",
133 });
134 code = await proc.exited;
135 } catch {
136 code = 1;
137 }
138 if (code === 0 && existsSync(join(partial, ".git"))) {
139 rmSync(target, { recursive: true, force: true });
140 renameSync(partial, target);
outdated

Dead code: nothing calls shellCommand. Remove it.

141 return true;
142 }
143 rmSync(partial, { recursive: true, force: true });
144 return false;
145 }
146
147 /** What the pty runs, and whether leaving it keeps the session (tmux holds it). */
148 export interface ShellPlan {
149 readonly command: string;
150 readonly detaches: boolean;
151 }
152
153 /**
154 * The shell the pty runs: bash on the rc, in tmux where the machine has it.
155 * Without bash, `/bin/sh` still prints the greeting and starts in the
156 * checkout with a repository prompt, but has no branch in the prompt and no
157 * persistent history (sh has neither).
158 */
159 export function shellPlan(hasTmux: boolean, hasBash: boolean): ShellPlan {
160 const sh = `if [ -d "$FORGE_CHECKOUT" ]; then cd "$FORGE_CHECKOUT"; fi; ` +
161 `if [ -f "$HOME/${GREETING_FILE}" ]; then cat "$HOME/${GREETING_FILE}"; fi; PS1='$FORGE_REPO $ '; export PS1; exec /bin/sh -i`;
162 const inner = hasBash ? `bash --rcfile "$HOME/${RC_FILE}" -i` : `/bin/sh -c '${sh.replace(/'/g, "'\\''")}'`;
163 return hasTmux
164 ? { command: `exec tmux -L forge new-session -A -s shell '${inner.replace(/'/g, "'\\''")}'`, detaches: true }
165 : { command: `exec ${inner}`, detaches: false };
166 }
src/machines/shell.ts +55 −16
@@ -21,9 +21,9 @@
2121 * they have been validated.
2222 */
2323
24 import { mkdirSync } from "node:fs";
24 import { existsSync, mkdirSync } from "node:fs";
2525 import { cellHides } from "../spawn/hosted-mark.ts";
26 import { join } from "node:path";
26 import { dirname, join } from "node:path";
2727 import type { Database } from "bun:sqlite";
2828 import type { App } from "../app.ts";
2929 import { openCell, resolveLimits, type Cell, type CellSpec } from "../apps/contain.ts";
@@ -38,6 +38,11 @@ import { reachableApps, type Machine } from "../crew/machines.ts";
3838 import { ownApp, ownAppUrls } from "../crew/app_hosts.ts";
3939 import { limitValue } from "../settings/limits.ts";
4040 import { id as newId } from "../kernel/id.ts";
41 import { readFileAt } from "../apps/checkout.ts";
42 import { parseManifest } from "../apps/manifest.ts";
43 import { resolveOid } from "../git/merge.ts";
44 import { repoDir } from "../git/repos.ts";
45 import { ensureCheckout, prepareHome, shellPlan, CHECKOUT_DIR, type ShellPlan } from "./rc.ts";
4146
4247 // ---- the route ----
4348
@@ -77,6 +82,8 @@ export interface ShellGrant {
7782 readonly machine: Machine;
7883 readonly user: User;
7984 readonly sessionId: string | undefined;
85 /** The app's own `tty` command at the default branch's tip, for the greeting. */
86 readonly tty?: string | null;
8087 }
8188
8289 /**
@@ -103,11 +110,25 @@ export async function mayOpenShell(
103110 const config = await repoMachines(app, repo);
104111 const machine = config.machines.find((m) => m.name === target.machine);
105112 if (machine === undefined) return { ok: false, refusal: "not_found" };
106 const grant = { repo, machine, user, sessionId: auth.sessionId };
113 const grant = { repo, machine, user, sessionId: auth.sessionId, tty: await appTty(app, repo) };
107114 if (!roomFor(app, grant)) return { ok: false, refusal: "busy" };
115 // The first connection clones; async, so no other request waits on it.
116 await ensureCheckout(
117 repoDir(app.reposRoot, repo.ownerName, repo.name),
outdated

Clone runs on every handshake that finds no checkout, after the room check but with no limit on how many run at once across people. Bound the concurrency, or clone inside the session start where the existing caps apply.

118 workspace(app, repo, machine, user.id),
119 );
108120 return { ok: true, grant };
109121 }
110122
123 /** The `tty` command forge.json declares at the default branch's tip, or null. */
124 export async function appTty(app: App, repo: Repo): Promise<string | null> {
125 const dir = repoDir(app.reposRoot, repo.ownerName, repo.name);
126 const tip = await resolveOid(dir, `refs/heads/${repo.defaultBranch}`);
127 if (tip === undefined) return null;
128 const parsed = parseManifest(await readFileAt(dir, tip, "forge.json"), undefined);
129 return parsed.ok ? parsed.value.tty : null;
130 }
131
111132 // ---- frames ----
112133
113134 export const RESIZE = { cols: { min: 1, max: 500 }, rows: { min: 1, max: 300 } } as const;
@@ -266,6 +287,8 @@ export interface ShellOptions {
266287 readonly contain: "cell" | "uncontained" | "refuse";
267288 /** The command the pty runs: constant, never built from a request. */
268289 readonly command: string;
290 /** Whether leaving the command keeps the session (tmux holds it); said in the greeting. Defaults to no. */
291 readonly detaches?: boolean;
269292 /** How often a session asks again whether its owner may still write; defaults to `CHECK_MS`. */
270293 readonly checkMs?: number;
271294 /** Handshakes one person may make in a minute; defaults to `HANDSHAKES_PER_MINUTE`. */
@@ -283,10 +306,27 @@ export const MAX_BUFFERED_BYTES = 1024 * 1024;
283306 export const CHECK_MS = 5_000;
284307
285308 /** tmux where the machine has it, so a person's windows survive a closed tab; else a login shell. */
286 export function defaultCommand(): string {
287 return Bun.which("tmux") !== null
288 ? "exec tmux -L forge new-session -A -s shell"
289 : "exec /bin/sh -i";
309 export function defaultShell(): ShellPlan {
310 return shellPlan(Bun.which("tmux") !== null, Bun.which("bash") !== null);
311 }
312
outdated

defaultCommand is no longer used by anything but a stale import in server.ts. Remove it and the import.

313 /**
314 * The environment a machine shell starts with: the forge's own bun first on
315 * PATH (the cell binds `/` read-only and reveals bun's directory even under a
316 * hidden home, see `cellMounts`), then the forge's PATH, where git is.
317 */
318 export function shellEnv(grant: ShellGrant, dir: string): Record<string, string> {
319 return {
320 PATH: `${dirname(process.execPath)}:${process.env["PATH"] ?? "/usr/bin:/bin"}`,
321 FORGE_REPO: `${grant.repo.ownerName}/${grant.repo.name}`,
322 FORGE_CHECKOUT: join(dir, CHECKOUT_DIR),
323 HOME: join(dir, "home"),
324 TERM: "xterm-256color",
325 COLORTERM: "truecolor",
326 LANG: "C.UTF-8",
327 FORGE_MACHINE: grant.machine.name,
328 FORGE_MACHINE_TOOLS: grant.machine.tools.join(" "),
329 };
290330 }
291331
292332 const sessions = new WeakMap<App, Map<string, Session>>();
@@ -420,6 +460,13 @@ function start(app: App, grant: ShellGrant, opts: ShellOptions, cols: number, ro
420460 let live: Session | undefined;
421461 try {
422462 mkdirSync(join(dir, "home"), { recursive: true });
463 prepareHome(join(dir, "home"), {
464 repo: `${grant.repo.ownerName}/${grant.repo.name}`,
465 machine: grant.machine.name,
466 detaches: opts.detaches === true,
467 checkout: existsSync(join(dir, CHECKOUT_DIR, ".git")),
468 tty: grant.tty ?? null,
469 });
423470 row = recordStart(app.db, grant.repo.id, grant.machine.name, grant.user.id);
424471 terminal = new Bun.Terminal({
425472 cols,
@@ -444,15 +491,7 @@ function start(app: App, grant: ShellGrant, opts: ShellOptions, cols: number, ro
444491 ownAppUrls(ownApp(app, grant.repo)),
445492 ),
446493 );
447 const env: Record<string, string> = {
448 PATH: process.env["PATH"] ?? "/usr/bin:/bin",
449 HOME: join(dir, "home"),
450 TERM: "xterm-256color",
451 COLORTERM: "truecolor",
452 LANG: "C.UTF-8",
453 FORGE_MACHINE: grant.machine.name,
454 FORGE_MACHINE_TOOLS: grant.machine.tools.join(" "),
455 };
494 const env = shellEnv(grant, dir);
456495 const program = startTerminal(opts.command, { terminal, env, cwd: dir, cell });
457496 const term = terminal;
458497 const session: Session = {
test/machine-rc.test.ts added +232
@@ -0,0 +1,232 @@
1 /**
2 * A machine shell's first screen (#10547): the rc holds the repository-and-branch prompt, the greeting says where you are and how to leave,
3 * history lives under the data directory and is there in the next session.
4 */
5 import { afterAll, beforeAll, expect, test } from "bun:test";
6 import { existsSync, mkdirSync, mkdtempSync, readFileSync } from "node:fs";
7 import { tmpdir } from "node:os";
8 import { dirname, join } from "node:path";
9 import { createApp, type App } from "../src/app.ts";
10 import { createRepo, type Repo } from "../src/core/repos.ts";
11 import { createUser } from "../src/core/users.ts";
12 import { commitFiles } from "../src/git/write.ts";
13 import { repoDir } from "../src/git/repos.ts";
14 import { BUILT_IN } from "../src/crew/machines.ts";
15 import { RC, RC_FILE, CHECKOUT_DIR, MAX_CLONES, ensureCheckout, greeting, historyPath, prepareHome, shellPlan } from "../src/machines/rc.ts";
16 import { appTty, shellEnv, workspace } from "../src/machines/shell.ts";
17 import { cellMounts, keptPaths } from "../src/apps/contain.ts";
18
19 const facts = { repo: "alice/lab", machine: "standard", detaches: true, checkout: true, tty: "bun run tty.ts" };
20
21 let app: App;
22 let repo: Repo;
23 let userId: string;
24 let bare: string;
25
26 beforeAll(async () => {
27 app = createApp({ dataDir: mkdtempSync(join(tmpdir(), "rc-data-")), port: 0 });
28 const made = await createUser(app.db, { username: "alice", email: "[email protected]", password: "correct horse battery", origin: "seed", isAdmin: false, trusted: true });
29 if (!made.ok) throw new Error(String(made.error));
30 userId = made.value.id;
31 const r = await createRepo(app.db, app.reposRoot, { ownerId: userId, name: "lab", visibility: "public" });
32 if (!r.ok) throw new Error(String(r.error));
33 repo = r.value;
34 bare = repoDir(app.reposRoot, repo.ownerName, repo.name);
35 const c = await commitFiles(bare, {
36 branch: repo.defaultBranch,
37 message: "seed",
38 files: [
39 { path: "forge.json", content: JSON.stringify({ app: { run: "bun run index.ts", tty: "bun run tty.ts" } }) },
40 { path: "README.md", content: "hi\n" },
41 ],
42 author: { name: "T", email: "[email protected]" },
43 });
44 if (!c.ok) throw new Error(JSON.stringify(c.error));
45 });
46
47 afterAll(() => app.close());
48
49 const standard = () => BUILT_IN.find((m) => m.name === "standard")!;
50
51 test("the rc holds the repository and branch prompt, a long history and ls aliases", () => {
52 expect(RC).toContain("$FORGE_REPO");
53 expect(RC).toContain("HISTSIZE=100000");
54 expect(RC).toContain("alias ll=");
55 expect(RC).not.toContain("eval");
56 });
57
58 test("the real prompt shows repo:branch in a git checkout", async () => {
59 const dir = workspace(app, repo, standard(), userId);
60 const home = join(dir, "home");
61 expect(await ensureCheckout(bare, dir)).toBe(true);
62 prepareHome(home, facts);
63 const env = { PATH: process.env["PATH"] ?? "/usr/bin:/bin", HOME: home, FORGE_REPO: "alice/lab", FORGE_CHECKOUT: join(dir, CHECKOUT_DIR), TERM: "dumb" };
64 // PS1 is shown only by an interactive shell on a terminal; expand it the way bash does.
65 const run = Bun.spawnSync(["bash", "--rcfile", join(home, RC_FILE), "-i"], {
66 env,
67 cwd: home,
68 stdin: new TextEncoder().encode('echo "PROMPT=${PS1@P}"\nexit\n'),
69 });
70 const out = run.stdout.toString() + run.stderr.toString();
71 expect(out.replace(/[\x01\x02]|\x1b\[[0-9;]*m/g, "")).toContain(`PROMPT=alice/lab:${repo.defaultBranch} `);
72 });
73
74 test("the greeting names the repository, machine, how to leave, and the app's tty", () => {
75 const text = greeting(facts);
76 expect(text).toContain('machine "standard" of alice/lab');
77 expect(text).toContain("its checkout");
78 expect(text).toContain("detach");
79 expect(text).toContain("bun run tty.ts");
80 expect(greeting({ ...facts, detaches: false })).not.toContain("detach");
81 expect(greeting({ ...facts, tty: null })).not.toContain("terminal command");
82 expect(greeting({ ...facts, tty: "x\x1b[2Jy" })).not.toContain("\x1b");
83 const none = greeting({ ...facts, checkout: false });
84 expect(none).not.toContain("You are in its checkout");
85 expect(none).toContain("home directory");
86 });
87
88 test("the app's tty is read from forge.json at the default tip", async () => {
89 expect(await appTty(app, repo)).toBe("bun run tty.ts");
90 });
91
92 test("a checkout is cloned once and reused; a failed clone leaves nothing and says so", async () => {
93 const dir = mkdtempSync(join(tmpdir(), "rc-co-"));
94 expect(await ensureCheckout(bare, dir)).toBe(true);
95 const target = join(dir, CHECKOUT_DIR);
96 expect(readFileSync(join(target, "README.md"), "utf8")).toBe("hi\n");
97 Bun.spawnSync(["touch", join(target, "marker")]);
98 expect(await ensureCheckout(bare, dir)).toBe(true);
99 expect(existsSync(join(target, "marker"))).toBe(true);
100 const bad = mkdtempSync(join(tmpdir(), "rc-bad-"));
101 expect(await ensureCheckout(join(bad, "missing.git"), bad)).toBe(false);
102 expect(existsSync(join(bad, CHECKOUT_DIR))).toBe(false);
103 });
104
105 test("the clone does not block the event loop", async () => {
106 const dir = mkdtempSync(join(tmpdir(), "rc-loop-"));
107 let ticks = 0;
108 const timer = setInterval(() => ticks++, 1);
109 await ensureCheckout(bare, dir);
110 clearInterval(timer);
111 expect(ticks).toBeGreaterThan(0);
112 });
113
114 test("the rc is the same constant whatever the facts", () => {
115 const home = mkdtempSync(join(tmpdir(), "rc-"));
116 prepareHome(home, { ...facts, repo: "evil/$(touch pwned)" });
117 expect(readFileSync(join(home, RC_FILE), "utf8")).toBe(RC);
118 });
119
120 test("history is under the data directory and survives a second session", () => {
121 const dir = workspace(app, repo, standard(), userId);
122 const home = join(dir, "home");
123 mkdirSync(home, { recursive: true });
124 prepareHome(home, facts);
125 expect(historyPath(home).startsWith(app.config.dataDir)).toBe(true);
126 const env = { PATH: process.env["PATH"] ?? "/usr/bin:/bin", HOME: home, FORGE_REPO: "alice/lab" };
127 const run = (input: string) =>
128 Bun.spawnSync(["bash", "--rcfile", join(home, RC_FILE), "-i"], { env, stdin: new TextEncoder().encode(input), cwd: home });
129 run("echo remembered-command\nexit\n");
130 expect(readFileSync(historyPath(home), "utf8")).toContain("remembered-command");
131 const second = run("history\nexit\n");
132 expect(second.stdout.toString() + second.stderr.toString()).toContain("remembered-command");
133 });
134
135 test("the shell plan runs bash on the rc, in tmux when there is one, and says whether it detaches", () => {
136 expect(shellPlan(true, true).command).toContain("tmux");
137 expect(shellPlan(true, true).command).toContain(RC_FILE);
138 expect(shellPlan(true, true).detaches).toBe(true);
139 expect(shellPlan(false, true)).toEqual({ command: `exec bash --rcfile "$HOME/${RC_FILE}" -i`, detaches: false });
140 expect(shellPlan(false, false).detaches).toBe(false);
141 });
142
143 test("the shell's env puts bun first on PATH, and bun and git resolve with it", () => {
144 const dir = workspace(app, repo, standard(), userId);
145 const grant = { repo, machine: standard(), user: { id: userId } as never, sessionId: undefined };
146 const env = shellEnv(grant, dir);
147 expect(env["PATH"]!.split(":")[0]).toBe(dirname(process.execPath));
148 expect(env["FORGE_REPO"]).toBe("alice/lab");
149 expect(env["FORGE_CHECKOUT"]).toBe(join(dir, CHECKOUT_DIR));
150 expect(env["HOME"]).toBe(join(dir, "home"));
151 const run = Bun.spawnSync(["sh", "-c", "command -v bun && command -v git"], { env });
152 const found = run.stdout.toString().trim().split("\n");
153 expect(found[0]).toBe(join(dirname(process.execPath), "bun"));
154 expect(found[1]).toMatch(/\/git$/);
155 });
156
157 test("a cell keeps bun's and git's directories visible", () => {
158 // The cell binds / read-only; a hidden home that holds bun is revealed again.
159 const home = dirname(dirname(process.execPath));
160 expect(cellMounts({ hide: [home] }, []).reveal).toContain(dirname(process.execPath));
161 // Under the allow-list (Kubernetes), only kept mounts are seen: bun's and git's are kept.
162 const kept = keptPaths();
163 expect(kept).toContain(dirname(process.execPath));
164 expect(kept).toContain(dirname(Bun.which("git")!));
165 });
166
167 test("the plans quote a hostile repository and checkout path: sh runs them as text", () => {
168 const dir = mkdtempSync(join(tmpdir(), "rc-q-"));
169 const home = join(dir, "home");
170 const evil = join(dir, "co'; touch PWNED; '$(touch PWNED2)");
171 mkdirSync(evil, { recursive: true });
172 prepareHome(home, facts);
173 for (const plan of [shellPlan(false, false), shellPlan(false, true)]) {
174 const run = Bun.spawnSync(["sh", "-c", plan.command], {
175 env: { PATH: process.env["PATH"] ?? "/usr/bin:/bin", HOME: home, FORGE_REPO: "x/$(touch PWNED3)`touch PWNED4`", FORGE_CHECKOUT: evil, TERM: "dumb" },
176 cwd: dir,
177 stdin: new TextEncoder().encode("pwd\nexit\n"),
178 });
179 expect(run.stdout.toString() + run.stderr.toString()).toContain(evil);
180 }
181 for (const f of ["PWNED", "PWNED2", "PWNED3", "PWNED4"]) {
182 expect(existsSync(join(dir, f))).toBe(false);
183 expect(existsSync(join(evil, f))).toBe(false);
184 }
185 // tmux's plan wraps the same inner command in one single-quoted word.
186 const tmux = shellPlan(true, false).command;
187 const inner = shellPlan(false, false).command.slice("exec ".length);
188 expect(tmux).toBe(`exec tmux -L forge new-session -A -s shell '${inner.replace(/'/g, "'\\''")}'`);
189 const word = Bun.spawnSync(["sh", "-c", `printf %s ${tmux.slice(tmux.indexOf("'"))}`]);
190 expect(word.stdout.toString()).toBe(inner);
191 });
192
193 test("two handshakes at once share one clone; neither removes the other's checkout", async () => {
194 const dir = mkdtempSync(join(tmpdir(), "rc-race-"));
195 const a = ensureCheckout(bare, dir);
196 const b = ensureCheckout(bare, dir);
197 expect(b).toBe(a);
198 expect(await Promise.all([a, b])).toEqual([true, true]);
199 expect(readFileSync(join(dir, CHECKOUT_DIR, "README.md"), "utf8")).toBe("hi\n");
200 expect(existsSync(join(dir, `${CHECKOUT_DIR}.partial`))).toBe(false);
201 });
202
203 test("a clone past its deadline is killed and leaves nothing; the shell starts in home", async () => {
204 const dir = mkdtempSync(join(tmpdir(), "rc-slow-"));
205 expect(await ensureCheckout(bare, dir, 1)).toBe(false);
206 expect(existsSync(join(dir, CHECKOUT_DIR))).toBe(false);
207 expect(existsSync(join(dir, `${CHECKOUT_DIR}.partial`))).toBe(false);
208 expect(await ensureCheckout(bare, dir)).toBe(true);
209 });
210
211 test("past the forge-wide cap a clone is not started", async () => {
212 const dirs = Array.from({ length: MAX_CLONES + 1 }, () => mkdtempSync(join(tmpdir(), "rc-cap-")));
213 const results = await Promise.all(dirs.map((d) => ensureCheckout(bare, d)));
214 expect(results.slice(0, MAX_CLONES)).toEqual(Array(MAX_CLONES).fill(true));
215 expect(results[MAX_CLONES]).toBe(false);
216 expect(existsSync(join(dirs[MAX_CLONES]!, CHECKOUT_DIR))).toBe(false);
217 });
218
219 test("without bash, sh gets the greeting, the checkout and a repository prompt", () => {
220 const dir = mkdtempSync(join(tmpdir(), "rc-sh-"));
221 const home = join(dir, "home");
222 mkdirSync(join(dir, CHECKOUT_DIR), { recursive: true });
223 prepareHome(home, facts);
224 const run = Bun.spawnSync(["sh", "-c", shellPlan(false, false).command], {
225 env: { PATH: process.env["PATH"] ?? "/usr/bin:/bin", HOME: home, FORGE_REPO: "alice/lab", FORGE_CHECKOUT: join(dir, CHECKOUT_DIR) },
226 stdin: new TextEncoder().encode("pwd\nexit\n"),
227 });
228 const out = run.stdout.toString() + run.stderr.toString();
229 expect(out).toContain('machine "standard" of alice/lab');
230 expect(out).toContain(join(dir, CHECKOUT_DIR));
231 expect(out).toContain("alice/lab $ ");
232 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs