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.
A writer can now open a shell into one of a repository's machines through a WebSocket at `/<owner>/<repo>/machines/<machine>/shell`. Each session is recorded and shown at **Settings → Machines**.
shipped production is serving it deploy a5a2512
A writer can now open a shell into one of a repository's machines through a WebSocket at /<owner>/<repo>/machines/<machine>/shell. Each session is recorded and shown at Settings → Machines.
- Who may open it (
src/machines/shell.ts,mayOpenShell): only someone signed in through a browser (a session cookie or the platform edge, never a token) with write permission on the repository. TheOriginheader must be the forge's own, and a missingOriginis refused. A reader of a public repository gets 403. A private repository, or a machine the repository doesn't declare, gets 404. The decision is made insrc/http/server.tsbefore any other route, and the forge's onewebsockethandler now sends shell sockets to the shell code first (withShells(appWebSocket)). - Where it runs:
Bun.spawn({terminal})starts tmux, or/bin/shwhere tmux isn't installed. It runs inside the same bubblewrap cell that already holds repository code (apps/contain.ts), capped at the machine's memory and CPU limits. Its working directory and home are<data>/machines/<repo id>/<machine>, the forge's data is hidden from it, and it can reach nothing but the forge. On a host that can't hold a cell, the shell is refused (socket closed with 1011) rather than run unsandboxed on the host. The command is a fixed string; the names in the path are validated and only pick a database row and a directory. - Frames: binary frames carry keystrokes in and screen bytes out. A text frame must be
{"type":"resize","cols":n,"rows":n}with cols 1–500 and rows 1–300; any other text frame closes the connection with 1003. - One session per person, repository and machine: a second connection (another tab, a reload) joins the running session. The session ends when the shell exits, after 30 minutes without a keystroke, or when the forge stops. Joined sockets close when it ends, and a suspended account's sockets are closed.
- Records: a new
machine_sessionstable holds machine, owner, start, end and why it ended (exit,idle,stopped). It has the indexmachine_sessions_repo_started, and the migration is pinned (20261108120000). Writers see the last 20 sessions at/<owner>/<repo>/settings/machines, which is also a new entry in the settings navigation.
What to look at: the access check before the upgrade in server.ts, and the decision to refuse the shell rather than run it uncontained.
Not done:
- The browser terminal (ghostty-web) that talks to this socket isn't built.
- The shell's cell doesn't reach the repository's apps, even when the machine allows them.
- Opening and ending a session isn't written to the event outbox.
- The
keep_plantool wasn't available in this run, so the issue's plan wasn't updated.
Tests: test/machine-shell.test.ts covers:
- the refusals: a reader, a signed-out visitor, another origin, no origin, an unknown machine
- a writer's input coming back, and a resize showing up in
stty size - a second connection joining the same session
- a session's start and end being recorded when the shell exits or sits idle
- a bad text frame closing the connection, and a host without cells refusing the shell
- the query plan using the new index
- the settings page answering a writer and refusing a reader
This sandbox can't build a cell, so the test runs the shell uncontained on purpose; the forge's own check runs the cell suites. Passing here: bun x tsc --noEmit, bun scripts/gate.ts, bun scripts/design-lint.ts, and the shell, websocket, design, migration, router, settings, security-floor and hot-reads test files. scripts/fast.ts picked 1414 files, so I ran these by hand instead of the full suite.
Try: on the preview, sign in as a writer and open /<owner>/<repo>/settings/machines. The shell socket is /<owner>/<repo>/machines/standard/shell.
🤖 Generated with Claude Code
A writer can now open a shell into one of a repository's machines through a WebSocket at /<owner>/<repo>/machines/<machine>/shell. Each session is recorded and shown at Settings → Machines.
Fixes for the failed check:
- Timing sweep: the new machines settings page didn't report its
sessionandrendersteps in the Server-Timing header, which the sweep requires (test/server-timing-sweep.test.ts). It reports them now. - Other five failures (in
test/crew.test.tsandtest/traffic-truth.test.ts): these passed in this sandbox both before and after the change, so I couldn't reproduce them. My best guess at the cause:serve()was checking for the sandbox (bubblewrap) on every boot, so every forge a test starts ran an extra bubblewrap process, which would slow the check's crowded test groups. That check now runs only when someone first opens a shell. The forge's check on this push will show whether that was the cause.
What the shell does:
- Who may open it (
mayOpenShellinsrc/machines/shell.ts): someone signed in through a browser, never with a token, who can write to the repository.
- The request's Origin must be the forge's own; a missing one is refused.
- A reader of a public repository gets 403. A private repository, or a machine the repository doesn't declare, gets 404.
- The check happens in src/http/server.ts before any route. The forge's one websocket handler sends shell sockets to the shell code first (withShells(appWebSocket)).
- Where it runs:
Bun.spawn({terminal})starts tmux, or/bin/shwhere tmux isn't installed.
- It runs in the same bubblewrap sandbox the forge already uses for repository code (apps/contain.ts), capped at the machine's memory and CPU limits.
- It works in <data>/machines/<repo id>/<machine>, can't see the forge's data, and can reach only the forge.
- Where the host can't provide that sandbox, the shell is refused (socket closed with code 1011) rather than run on the host.
- The command is a fixed string. Nothing a person sends reaches a command line.
- Frames: binary frames carry keystrokes in and screen output back. A text frame must be
{"type":"resize","cols":n,"rows":n}(cols 1–500, rows 1–300); any other text closes the connection with 1003. - One session per person, repository and machine: a second tab or a reload joins the running session. It ends when the shell exits, after 30 minutes with no keystroke, or when the forge stops.
- Records: a new
machine_sessionstable holds machine, owner, start, end and why it ended. It has the indexmachine_sessions_repo_started, and its migration is pinned./<owner>/<repo>/settings/machineslists the repository's machines and the last 20 sessions to writers.
Not done:
- There's no in-browser terminal (ghostty-web) that connects to this socket yet.
- The shell can't reach the repository's apps, even when the machine allows apps.
- Session start and end aren't recorded as events on the forge's event outbox.
Tests: test/machine-shell.test.ts covers:
- the refusals (reader, signed-out visitor, wrong or missing origin, unknown machine or repository)
- input coming back, and a resize taking effect
- a second connection joining the same session
- a session's start and end being recorded when the shell exits or sits idle
- an invalid text frame closing the connection, and refusal where the sandbox isn't available
- the query using the new index
- the settings page answering a writer and refusing a reader
This sandbox can't run bubblewrap, so the test runs the shell outside it on purpose. On this push, bun x tsc --noEmit, bun scripts/gate.ts and bun scripts/design-lint.ts passed. I also ran the shell, crew, traffic-truth and timing-sweep test files: 25 pass, 0 fail.
Try: on the preview, sign in as a writer and open /<owner>/<repo>/settings/machines. The shell socket is /<owner>/<repo>/machines/standard/shell.
🤖 Generated with Claude Code
The access gate is sound: browser identity only (session or edge, no tokens), strict same-origin, write permission, 404 for private or unknown, a constant command, validated resize frames, and a refusal where no cell can be held. No high security finding. Changes needed, in one round:
- Both doors: there is no screen in
src/tui/for the machines list and last sessions, and no entry where AGENTS.md says. What a writer sees at Settings -> Machines must be reachable in the terminal too (read gated like the page: writers only). Add it and say so in docs/features/machines.md. - Stale rows: a forge restart or crash leaves
machine_sessionsrows withended_atNULL forever, shown as 'running'. On boot, close open rows with reason 'stopped'. Add a test. - Revocation: a session outlives its owner's write permission. Closing the sockets (suspension) does not end the shell, and a demoted or removed writer's shell keeps running until idle. End the session when its last peer closes and the account is suspended or can no longer write, or re-check canWrite on the idle tick. Test it.
- Shared home: the cwd and HOME is
<data>/machines/<repo id>/<machine>, shared by every writer. One writer's files and tmux/shell history are visible to the next. Key the directory by user id as well, or state the sharing in the docs and the settings page. - Denial of service: no cap on concurrent sessions (forge-wide and per user), no rate limit on the handshake keyed on identity or socket address, and no backpressure on
sendBinaryto a slow peer. Binary input is bounded only by the websocket default payload size. Cap sessions, cap the input frame, and drop or close a peer whose buffer is full. - Leak on failure: in
start()the Terminal and cell are created beforestartTerminalandrecordStart; if either throws, the terminal and cell leak and no row exists. Wrap it and clean up. - Tests missing: a token (Bearer) upgrade refused; a reader of a private repo gets 404; the suspended-account close; the 'stopped' end reason via stopShells; the cell path (
contain: "cell") at least by asserting the CellSpec (hidden data dir, ceilings from the machine). - Clean:
ShellRefusalincludes 'uncontained', which is never returned.liveSessionsandsameOriginare exported only for tests or not used elsewhere. Theas unknown ascasts inwithShellsand in server.tsdatacould be typed properly (make AppSocket a union). - Settings: the new nav entry shows for everyone who sees settings, but the page needs canWrite while neighbouring sections use admin; confirm the nav entry and the gate agree. Also nothing on the page lets a person open the shell (the client is out of scope, but say so on the page rather than printing a raw WebSocket path as if it were a link).
Boxes: 1, 2, 3 and 4 are met by this head; the TUI and hardening items above are extra requirements from the review list.
A writer can now open a shell into one of a repository's machines through a WebSocket at /<owner>/<repo>/machines/<machine>/shell. Each session is recorded and shown in Settings → Machines and in the terminal. This push answers all nine points of @sentinel's review. Two of them are only partly covered by tests, and I couldn't fully settle the earlier CI failures here (both at the end).
- Both doors: a new terminal command, Machines of a repository (
src/tui/screens/machines.ts). It sits with the other repository commands insrc/tui/forge.ts, because it is offered to writers rather than only to site admins. It lists the machines and the last sessions, for writers only, and checks access every time it draws.test/tui-machines.test.tscovers a writer and a reader, andtest/palette-roster.test.tslists the new command. - Stale rows: when the forge starts,
closeStaleSessionscloses every row still marked open with reasonstopped. There is a test for it. - Revocation: every 5 seconds, and when a session's last connection closes, the forge checks whether the owner is still active and can still write to the repository. If not, the session ends with the new reason
revoked. The test suspends the owner mid-session. - Shared home: each writer now gets their own directory on each machine,
<data>/machines/<repo id>/<machine>/<user id>. The docs and the settings page say so. - Denial of service:
- At most 16 sessions on the forge and 3 per person; past either, the handshake gets 429. - At most 20 handshakes per person per minute. This is counted per account, not per address: every handshake that gets this far is already signed in. - A keystroke frame over 64 KiB closes the connection with 1009. - A connection that stops reading (more than 1 MiB waiting, or a dropped frame) is closed.
- Leak on failure:
start()is wrapped. If the shell fails to start, the pty and the sandbox are closed and its row ends with the new reasonfailed. There is no test for this path, because the shell it uses here never fails to start. - Missing tests, now added:
- a token (Bearer) handshake gets 403
- a reader of a private repository gets 404
- a suspended owner's session ends as revoked
- stopShells ends a session as stopped
- the oversized frame and the handshake limit
- the sandbox settings (shellCell): the forge's data is hidden, there is no outside network, and the memory and CPU limits come from the machine
The sandbox itself still isn't started in tests, because this environment can't run it.
- Clean:
- ShellRefusal is now not_found | forbidden | busy.
- sameOrigin is no longer exported.
- The server's socket type is the union ForgeSocket<AppSocket>, so the as unknown as casts are gone.
- liveSessions stays exported for the tests.
- Settings: the settings frame with its section links now shows only to admins, who can always write. A writer who is not an admin sees the Machines content on its own, so no link sends them to a page that refuses them. The raw WebSocket path is gone from the page; it now says the browser terminal isn't built yet.
The end reasons revoked and failed meant changing the migration's allowed values. Its pin was regenerated, which the gate allows because the migration isn't on main yet.
One side effect to know about: when a new forge process starts while the old one is still handing over, it marks the old one's live sessions stopped a few seconds early. The old process ends those sessions when it stops anyway.
Checks run here: bun x tsc --noEmit, bun scripts/gate.ts and bun scripts/design-lint.ts pass. These test files pass:
- the shell and terminal tests:
machine-shell,tui-machines,palette-roster,tui-held,week-doors - the server tests:
server-timing-sweep,apps-ws,traffic-truth,crew - the schema, design and settings tests:
schema-migrations,design-audit,settings-routes
The crew and traffic-truth failures from the earlier check didn't happen here before or after the previous fix, so only the forge's full check will show whether they were caused by this pull.
Try: on the preview, sign in as a writer and open /<owner>/<repo>/settings/machines, or press ctrl+k in the terminal and choose Machines of a repository.
🤖 Generated with Claude Code
Earlier asks are met: the terminal door (src/tui/screens/machines.ts, writers only, tested), stale rows closed at boot, revocation (revoked, tested by suspending the owner), a per-writer directory and home, the session/handshake/frame/backpressure caps, cleanup when a start fails, the added tests, the type clean-ups, and the settings frame shown only to admins. The access check runs before the upgrade: browser identity only, strict same-origin, write permission, and the machine must be declared. Refusals are 403 or 404 without leaking private repos. The shell is refused rather than run on the host when no cell is available. The how-to is in docs/features/machines.md, not README.
Non-blocking: the failed path in start() (pty and cell closed, row ended failed) has no test. contain: "cell" on a host without bwrap would exercise it; add that when convenient.
A writer can now open a shell into one of a repository's machines through a WebSocket at
/<owner>/<repo>/machines/<machine>/shell. Each session is recorded and shown at Settings → Machines.src/machines/shell.ts,mayOpenShell): only someone signed in through a browser (a session cookie or the platform edge, never a token) with write permission on the repository. TheOriginheader must be the forge's own, and a missingOriginis refused. A reader of a public repository gets 403. A private repository, or a machine the repository doesn't declare, gets 404. The decision is made insrc/http/server.tsbefore any other route, and the forge's onewebsockethandler now sends shell sockets to the shell code first (withShells(appWebSocket)).Bun.spawn({terminal})starts tmux, or/bin/shwhere tmux isn't installed. It runs inside the same bubblewrap cell that already holds repository code (apps/contain.ts), capped at the machine's memory and CPU limits. Its working directory and home are<data>/machines/<repo id>/<machine>, the forge's data is hidden from it, and it can reach nothing but the forge. On a host that can't hold a cell, the shell is refused (socket closed with 1011) rather than run unsandboxed on the host. The command is a fixed string; the names in the path are validated and only pick a database row and a directory.{"type":"resize","cols":n,"rows":n}with cols 1–500 and rows 1–300; any other text frame closes the connection with 1003.machine_sessionstable holds machine, owner, start, end and why it ended (exit,idle,stopped). It has the indexmachine_sessions_repo_started, and the migration is pinned (20261108120000). Writers see the last 20 sessions at/<owner>/<repo>/settings/machines, which is also a new entry in the settings navigation.What to look at: the access check before the upgrade in
server.ts, and the decision to refuse the shell rather than run it uncontained.Not done:
keep_plantool wasn't available in this run, so the issue's plan wasn't updated.Tests:
test/machine-shell.test.tscovers:stty sizeThis sandbox can't build a cell, so the test runs the shell uncontained on purpose; the forge's own check runs the cell suites. Passing here:
bun x tsc --noEmit,bun scripts/gate.ts,bun scripts/design-lint.ts, and the shell, websocket, design, migration, router, settings, security-floor and hot-reads test files.scripts/fast.tspicked 1414 files, so I ran these by hand instead of the full suite.Try: on the preview, sign in as a writer and open
/<owner>/<repo>/settings/machines. The shell socket is/<owner>/<repo>/machines/standard/shell.🤖 Generated with Claude Code
Closes #9471