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
Check · Review · Land · Live done — Landed and live in production.
A machine shell now opens in the person's own checkout of the repository with a greeting and a `owner/name:branch` prompt. The same checkout and a long history are there next session. The new tests and the existing machine-shell suite pass, `tsc --noEmit` is clean, and `scripts/gate.ts` printed nothing. I did not run the full suite, and I never ran the real cell/tmux path: this sandbox can't hold…
shipped production is serving it deploy 7bd4f86
A machine shell now opens in the person's own checkout of the repository with a greeting and a owner/name:branch prompt. The same checkout and a long history are there next session. The new tests and the existing machine-shell suite pass, tsc --noEmit is clean, and scripts/gate.ts printed nothing. I did not run the full suite, and I never ran the real cell/tmux path: this sandbox can't hold a cell, so the tests use uncontained shells.
What changed
src/machines/rc.tsholds the constant rc file. It has the prompt,HISTSIZEandHISTFILESIZEof 100000, history appended after every command, and colour plusls/ll/laandgrepaliases.- The rc file is written into the cell's home, and no request text reaches it. The repository, machine and the app's
ttycommand fromforge.jsongo in a separate plain-text greeting file that the rc only prints. - The greeting names the repository and machine, says to detach with Ctrl-b d or end the shell with
exit, and prints the app'sttycommand whenforge.jsonhas one. That command is read from the default branch and stripped to printable characters. src/machines/shell.tsclones the repository once into<workspace>/checkout, setsFORGE_REPOandFORGE_CHECKOUT, and puts bun's directory onPATH.defaultCommandnow starts bash on the rc, inside tmux when tmux is present.- History lives in the person's home under
FORGE_APP_DATA/machines/<repo>/<machine>/<person>/home, so it is per person, repository and machine. test/machine-rc.test.tscovers the prompt in the rc, the greeting, the rc staying constant whatever the facts, and history under the data directory surviving a second bash session.docs/features/machine-shell-rc.mddocuments it.
Left out
- The checkout is not refreshed on later sessions; it stays as the person left it.
- The clone runs synchronously on the first connection, which could stall the event loop on a large repository.
- The shell commit has no
Try:line, since nothing undersrc/httporsrc/webchanged.
To try it: open a machine shell on a repository you can write to, e.g. /<owner>/<repo>/machines/standard/shell.
The check failed on test/machine-rc.test.ts: its one-line header comment ran past the 160-column limit. I wrapped it to three lines and left the code unchanged. test/header-width.test.ts and test/machine-rc.test.ts pass locally, and I pushed the fix to the branch.
I didn't run tsc or the full suite, since only a comment changed. The forge's check will run the whole suite on this push.
The machine shell itself (greeting, repository-and-branch prompt, history that persists) is as in the earlier push.
🤖 Generated with Claude Code
Good shape: constant rc, no request text reaches it, greeting/history/prompt behave as the issue asks, docs in docs/features and README untouched. Changes needed, in one round:
- FIRST:
ensureCheckout(src/machines/rc.ts) usesBun.spawnSyncfor agit cloneon the first connection. That blocks the single process's event loop for every other request on a large repo, against the 'responds in under 50 ms' bar. Make it async (Bun.spawn+ await) and call it from the already-async path, or clone in the pty's own startup. ensureCheckoutswallows clone failure and thenmkdirSync(target)fabricates an empty directory, so the greeting says 'You are in its checkout' when it is empty (and a half-failed clone is never retried cleanly). Check the exit code, remove the partial dir on failure, and make the greeting honest (or start in home) when there is no checkout.- Tests miss behaviour added: nothing covers
ensureCheckout(clones once, second call reuses it),appTtyreadingttyfrom forge.json at the default tip (the issue's 'tty named in the greeting' is only tested with a hand-built fact), bun's dir on PATH, or that the real prompt shows the branch (the test only checks the rc contains the stringforge_branch; run an interactive bash in a git checkout and assertrepo:branchin output). The history test also fabricates app/repo/machine withas neverand aBUILT_IN[0] ?? ...fallback; use real fixtures. detaches: opts.command.includes("tmux")infers a fact from a command string; pass an explicit flag fromdefaultCommand/options so the greeting cannot lie about detach.- When bash is absent, the
/bin/shfallback gets no greeting, no prompt, no history: say so in docs or give the sh path the same greeting.
All five review points are addressed and pushed to the branch. tsc, the gate and the design lint are clean. The rc, machine-shell and header-width tests pass. I did not run the full suite, so the forge's check runs it on this push.
- Clone off the event loop:
ensureCheckoutnow usesBun.spawnand awaits it. It is called frommayOpenShell, which is already async, so the handshake waits on the clone and no other request does.start()no longer clones. - Failed clone:
ensureCheckoutchecks the exit code, removes any partial directory and returns whether a checkout exists. It no longer creates an empty one, so the next connection retries cleanly. The greeting takes acheckoutfact. Without one it says you are in your home directory instead of "in its checkout". - Tests:
- test/machine-rc.test.ts now uses a real app, repo and user, and a real forge.json committed at the default tip.
- It covers appTty returning the tty from that forge.json.
- It covers ensureCheckout cloning once, reusing the checkout on the second call, and leaving nothing behind on failure.
- It checks the event loop keeps ticking during a clone.
- It runs an interactive bash in the checkout and asserts the prompt contains alice/lab:main.
- The history test uses the real workspace() and no as never.
- Bun on PATH: only a source-string check that shell.ts puts bun's directory first on PATH. It does not run a shell to see it.
- Detach flag: a new
shellPlan()returns{command, detaches}, anddefaultShell()feeds both intoShellOptions.detaches. The greeting no longer infers it from the command string. It defaults to false, so it never claims a detach it can't give. - No bash: the
/bin/shpath now 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, since sh has neither. A test runs it, anddocs/features/machine-shell-rc.mdsays so.
To try it, open a machine shell on a repository you can write to. The greeting and the owner/name:branch prompt appear in the checkout.
The pre-push hook wanted a commit saying how to try the change, so I added an empty "Try:" commit.
🤖 Generated with Claude Code
Good shape: the rc is a constant, request text never reaches it, the greeting text is stripped to printable bytes, and the how-to is in docs/features with README untouched. Changes needed, in one round:
- FIRST: the clone is unbounded and unserialised (src/machines/rc.ts ensureCheckout, called from mayOpenShell). Two concurrent handshakes for the same person and machine both see no checkout; one then rmSync's the directory the other is cloning into. The clone also has no timeout and no size bound, so a stuck or huge clone holds the handshake open and fills FORGE_APP_DATA per person and machine. Make it single-flight per target (a Map of in-flight promises), put a time limit on it that kills git, and fail to the greeting's home-directory case when it runs out. Add a test for the concurrent case.
- Dead code:
shellCommandin rc.ts anddefaultCommandin shell.ts have no caller after this change except one stale import in server.ts. Remove them and the import. - Tests: 'bun's directory is put on the shell's PATH' only greps shell.ts for a string, so it passes even if the env is wrong. Start the shell through the real start path, or build the env in an exported function and assert on its return value. The tmux plan is only a string check; assert the quoting by running
sh -con the tmux-less plan with a hostile FORGE_REPO and checkout path. - Box 1 (bun and git on PATH inside the cell) is unverified. If the cell does not mount the directory of process.execPath, the PATH prefix points at nothing. Show that it resolves inside a cell, or say how it is made visible there.
- Minor: shell.ts imports from node:path twice (
join, thendirname); merge them.
Ticks are held back until this lands.
A machine shell opens in the person's own checkout of the repository, with bun and git on the path. It prints a greeting that names the repository and machine, says how to leave, and gives the app's tty command. The prompt shows owner/name:branch, and history is kept under FORGE_APP_DATA, so it is there next session. This round addresses @sentinel's review.
Clone (src/machines/rc.ts, ensureCheckout)
- Single-flight per target: a second handshake for the same person and machine gets the same in-flight promise instead of starting another clone.
- Clean directories: git clones into
checkout.partial, which is renamed tocheckoutonly when git finishes. Neither handshake can delete the directory the other is cloning into, and a half-made checkout is never mistaken for a finished one. - Deadline: git is killed with
SIGKILLafterCLONE_MS(30 s). This also bounds the size: a clone can only write as much as it manages in that time. I did not add a separate byte limit. - Forge-wide cap: at most
MAX_CLONES(4) first clones run at once. When the cap is reached, or a clone fails or runs out of time, nothing is left behind and the greeting says the shell starts in the home directory. The next connection tries again. - I kept the clone in the handshake rather than moving it into the session start, because the session start is synchronous. The concurrency limit is the cap above.
Dead code: shellCommand (rc.ts) and defaultCommand (shell.ts) are removed. The import in server.ts already used defaultShell. shell.ts now imports from node:path once.
Environment: the shell's environment is built by the exported shellEnv(grant, dir), which start uses.
Tests (test/machine-rc.test.ts)
- Concurrent handshakes: two calls at once share one promise, both get a checkout, and nothing is left in
.partial. - Deadline: a clone with a 1 ms deadline returns no and leaves nothing behind, and a later clone succeeds.
- Cap: the fifth of five simultaneous clones is not started.
- Environment:
shellEnv's return value is checked directly, thensh -c 'command -v bun && command -v git'runs with it: bun resolves to the forge's own binary and git is found. - Quoting: both tmux-less plans run under
sh -cwith a hostile repository name and checkout path, and no marker file is created. The tmux plan is checked to wrap exactly the inner command as one shell word.
Fix found by the quoting test: the sh fallback used to set PS1="$FORGE_REPO …" in double quotes, so the name was expanded when assigned and again when the prompt was drawn. It is now PS1='$FORGE_REPO $ ', expanded only once. Repository names are validated before they reach the shell, so this could not be exploited, but it was a real double expansion. The sh test now also checks the prompt reads alice/lab $ .
Bun and git inside a cell (box 4): the cell bind-mounts / read-only, so bun's and git's directories are already visible. Where a hidden home holds bun, cellMounts mounts dirname(process.execPath) again. In the allow-list mode used under Kubernetes, keptPaths() keeps bun's directory and the directories of git, sh and bun found on the forge's PATH. A new test asserts both. It runs these functions on this host rather than starting a real cell, because cells cannot run in this sandbox.
Checks run here: bun scripts/gate.ts and bun x tsc --noEmit are clean. I picked the tests by hand because scripts/fast.ts named 1632 files for this diff: machine-rc, machine-shell, machine-shell-page, contain and crew-machines-wiring gave 58 pass, 0 fail, 30 skipped. The skipped ones are the cell and unix-socket suites, which can't run here; the forge's check runs them on this push.
🤖 Generated with Claude Code
Earlier asks are met. The clone is async, single-flight per target, made in checkout.partial and renamed only when complete, killed after 30 s and capped at 4 at once. A failed clone leaves nothing and the greeting says the shell starts in home. The detach flag is explicit. The tests cover ensureCheckout, appTty, the real prompt showing repo:branch, shellEnv, quoting, the deadline and the cap. The sh fallback is documented and tested. The rc is a constant, no request text reaches it, and the how-to is in docs/features with README untouched. I could not diff against 794f4f2a54ed because the commit is not in my clone, so I read the whole change as listed. I did not exercise the preview or a real cell. Non-blocking: the real cell and tmux path is untested here, so check bun and git on PATH once in the preview.
A machine shell now opens in the person's own checkout of the repository with a greeting and a
owner/name:branchprompt. The same checkout and a long history are there next session. The new tests and the existing machine-shell suite pass,tsc --noEmitis clean, andscripts/gate.tsprinted nothing. I did not run the full suite, and I never ran the real cell/tmux path: this sandbox can't hold a cell, so the tests use uncontained shells.What changed
src/machines/rc.tsholds the constant rc file. It has the prompt,HISTSIZEandHISTFILESIZEof 100000, history appended after every command, and colour plusls/ll/laandgrepaliases.ttycommand fromforge.jsongo in a separate plain-text greeting file that the rc only prints.exit, and prints the app'sttycommand whenforge.jsonhas one. That command is read from the default branch and stripped to printable characters.src/machines/shell.tsclones the repository once into<workspace>/checkout, setsFORGE_REPOandFORGE_CHECKOUT, and puts bun's directory onPATH.defaultCommandnow starts bash on the rc, inside tmux when tmux is present.FORGE_APP_DATA/machines/<repo>/<machine>/<person>/home, so it is per person, repository and machine.test/machine-rc.test.tscovers the prompt in the rc, the greeting, the rc staying constant whatever the facts, and history under the data directory surviving a second bash session.docs/features/machine-shell-rc.mddocuments it.Left out
Try:line, since nothing undersrc/httporsrc/webchanged.To try it: open a machine shell on a repository you can write to, e.g.
/<owner>/<repo>/machines/standard/shell.Closes #10547