import { join } from 'node:path'; import * as pty from 'node-pty'; import { homedir } from 'node:os'; // The shell store. Everything about running a terminal lives here: what shell, where it opens, how much // scrollback is kept, who is watching. The platform holds none of it — it authenticates a browser and // relays bytes to this process, and that is the whole of its involvement. // What a re-attaching client gets back. 50KB was about one long agent turn, so reconnecting mid-task // showed you the tail and nothing else. Per session, so ten live shells is ~5MB — cheap next to node-pty. const BUFFER_MAX = 512 * 1024; // HOME_DIR mirrors `data-path.ts:getOwnerHomeDir` — on a host where the owner's real login home differs // from this process's HOME, the shell should open in the former, like every other host-executing surface. const HOME_DIR = homedir(); const SHELL = { command: process.env.SHELL ?? '/bin/zsh', args: ['-i'] }; // A terminal is always a plain host shell: the owner is the only account and it is their own machine // (`platform/CLAUDE.md` — do not add a jail without being asked). /** * @typedef {{ * term: import('node-pty').IPty, buffer: string, cols: number, rows: number, * createdAt: number, lastActivityAt: number, title: string, pid: number|undefined, * clients: Set<{ send: (msg: object) => void }>, * }} Session */ /** @type {Map} */ const sessions = new Map(); /** * Where a shell opens. `base` is the requesting account's home; without one this is the owner's HOME_DIR, as * it always was. * * An absolute `cwd` is honoured as given, for the owner and for a member alike. That is not a hole and not an * oversight: the member's shell runs as their uid, so the kernel decides what they can see once they get * there. A path check here would be theatre — `cd /` is one keystroke away in any shell. */ const resolveCwd = (cwd, base) => { const home = base || HOME_DIR; if (!cwd || cwd === '~') return home; if (cwd.startsWith('~/')) return join(home, cwd.slice(2)); if (cwd.startsWith('/')) return cwd; // Relative paths have no meaning here — this process's cwd is the repo, not the user's folder. return home; }; /** * Sequences that make a terminal ANSWER, stripped before anything is stored. * * The scrollback is replayed verbatim to a re-attaching client. Anything in it that asks the terminal a * question gets asked AGAIN on every reconnect — and xterm answers, correctly, by writing the reply to its * input. That input is a keystroke as far as the pty is concerned, so a reconnect injects text into the * shell that nobody typed: `^[[?62;c` and friends landing on the command line, or being eaten by whatever * TUI is running. It is the "terminal goes weird after reconnecting" symptom, and it is not the shell's * fault. * * Stripping on the way IN rather than on the way out: the buffer is the thing that gets replayed, and a * live client has already answered these once, at the moment they were legitimately asked. * * What is removed is only ever a QUESTION. Colour, cursor movement, screen clears — everything that draws — * is untouched, so a replay still reproduces the screen exactly. */ // Each pattern is the QUERY form only. Where a control shares its final byte with a command that DRAWS, // the numeric parameter is enumerated rather than wildcarded — `CSI 18 t` asks the window size, but // `CSI 22 t` pushes the title, and stripping the second would silently change what a replay renders. const QUERY_SEQUENCES = [ /\x1b\[\??[56]n/g, // DSR — cursor position (6n), status (5n), and the DEC `?` variants /\x1b\[[0-9;?>=]*c/g, // DA1/DA2/DA3 — device attributes. `c` is only ever a query. /\x1b\[\?[0-9;]*\$p/g, // DECRQM — mode query /\x1b\[(?:1[1345689]|2[01])(?:;[0-9]+)*t/g, // XTWINOPS reports only — NOT 22/23 (title push/pop) /\x1b\[>[0-9;]*q/g, // XTVERSION /\x1bP\+q[0-9a-fA-F;]*(?:\x1b\\|\x07)/g, // DCS XTGETTCAP — terminfo capability query /\x1b\](?:10|11|12|4;[0-9]+);\?(?:\x07|\x1b\\)/g, // OSC colour queries (fg/bg/cursor/palette) ]; /** Exported for the test: this is the one function here whose mistakes are invisible until a replay. */ export const stripQueries = (data) => QUERY_SEQUENCES.reduce((out, re) => out.replace(re, ''), data); const appendBuffer = (session, data) => { session.buffer += stripQueries(data); if (session.buffer.length > BUFFER_MAX) { // Cut on a line boundary, not a byte offset. A blind slice can land inside an escape sequence, and the // replay then opens with the tail of a colour or cursor-move code — which xterm renders as garbage, or // worse, applies as a real instruction. Fall back to the raw cut when a single line is enormous. const cut = session.buffer.length - BUFFER_MAX; const nl = session.buffer.indexOf('\n', cut); session.buffer = nl !== -1 && nl - cut < 4096 ? session.buffer.slice(nl + 1) : session.buffer.slice(cut); } }; const broadcast = (session, msg) => { for (const client of session.clients) { try { client.send(msg); } catch { // client went away between the check and the write } } }; /** * The argv that opens a shell as `osUser`, or as this process when there is none. * * Mirrors `runAs` in src/servers/os-user.ts, and cannot import it: this sidecar runs under node (node-pty * binds a native addon against node's ABI) while that file is Bun/TypeScript. If you change one, change both * — the flags are load-bearing and the reasoning for each is documented there. * * node-pty does support `uid`/`gid` options, unlike Bun.spawn. They are deliberately NOT used: they set the * ids without applying the account's supplementary groups or resetting the environment, so the shell would * keep the OWNER's groups and the platform's entire env — including everything Bun loaded from `.env`. * * `--reset-env` keeps TERM (per setpriv(1)) and sets HOME/SHELL/USER/LOGNAME/PATH from the target's passwd * entry. COLORTERM does not survive it, so it is re-stated through `env` — it is how programs decide they may * emit 24-bit colour. */ function shellArgv(osUser) { if (!osUser) return { command: SHELL.command, args: SHELL.args }; return { command: 'sudo', args: [ '-n', 'setpriv', `--reuid=${osUser}`, `--regid=${osUser}`, '--init-groups', '--reset-env', '--', // THEIR login shell, from their passwd entry — not this sidecar's `$SHELL`, which is whatever PM2 was // started with. `--reset-env` has already set SHELL from passwd, so the indirection through `sh -c` is // what reads it; there is no shell in an argv to expand a variable otherwise. // // `-i` rather than `-l`: interactive is what makes zsh read ~/.zshrc, which is the file the shell // template writes. COLORTERM does not survive --reset-env and is restated here — it is how programs // decide they may emit 24-bit colour. 'sh', '-c', 'COLORTERM=truecolor exec "$SHELL" -i', ], }; } /** * Attach a client to a session, spawning the shell if this is the first time we've seen the id. * * `osUser` and `home` come from the PLATFORM, which resolves them from the authenticated account — never from * anything the browser sent. This socket binds loopback and only the platform's bridge reaches it, which is * the same trust model as the `X-Officer-User` header on the HTTP sidecars. */ export function attach(sessionId, client, { cwd, cols, rows, osUser, home } = {}) { let session = sessions.get(sessionId); if (session) { // A session belongs to whoever started it. Re-attaching is only allowed for the same account, or this is // how one member resumes another's shell by guessing a session id — and the id is in a query string. if ((session.osUser ?? null) !== (osUser ?? null)) { client.send({ type: 'output', data: '\r\n[Terminal error] that session belongs to another account\r\n' }); client.send({ type: 'exit', exitCode: 1 }); return null; } session.clients.add(client); // Scrollback goes out as `replay`, not as ordinary output: the client may already be showing some of // it, so it resets and rebuilds from this rather than appending a second copy. if (session.buffer.length > 0) client.send({ type: 'replay', data: session.buffer }); if (cols > 0 && rows > 0 && (cols !== session.cols || rows !== session.rows)) resize(sessionId, cols, rows); return session; } const spawnCols = cols > 0 ? cols : 80; const spawnRows = rows > 0 ? rows : 24; let term; try { const { command, args } = shellArgv(osUser); term = pty.spawn(command, args, { name: 'xterm-256color', cols: spawnCols, rows: spawnRows, // A member's shell starts in THEIR home, not the owner's. `resolveCwd` resolves `~` and relative paths // against HOME_DIR, which is the owner's — so the base has to be passed in for anyone else. cwd: resolveCwd(cwd, home), // For a member this env reaches `sudo` and `setpriv`, and `--reset-env` clears it before the shell: // see shellArgv. For the owner it is the shell's environment as before. env: { ...process.env, TERM: 'xterm-256color', COLORTERM: 'truecolor' }, }); } catch (err) { const message = err instanceof Error ? err.message : 'Failed to start terminal'; client.send({ type: 'output', data: `\r\n[Terminal error] ${message}\r\n` }); client.send({ type: 'exit', exitCode: 1 }); return null; } const now = Date.now(); session = { term, buffer: '', cols: spawnCols, rows: spawnRows, createdAt: now, lastActivityAt: now, title: '', pid: term.pid, /** Whose shell this is, or null for the owner. Read by `list` and `kill` so one account cannot see or * end another's — the gap TODO.md called the pty sidecar's identity blindness. */ osUser: osUser ?? null, clients: new Set([client]), }; sessions.set(sessionId, session); term.onData((output) => { appendBuffer(session, output); session.lastActivityAt = Date.now(); // The title the shell sets for itself (OSC 0/2 — usually the running command). It is what turns "some // uuid" into "the one running claude" in the session list. const titleMatch = /\x1b\][02];([^\x07\x1b]*)(?:\x07|\x1b\\)/.exec(output); if (titleMatch) session.title = titleMatch[1]; broadcast(session, { type: 'output', data: output }); }); term.onExit(({ exitCode, signal }) => { console.log(`[pty-sidecar] session ${sessionId} exited code=${exitCode} signal=${signal}`); broadcast(session, { type: 'exit', exitCode, signal }); sessions.delete(sessionId); }); return session; } /** A client went away. The shell stays: it is re-attachable, and killing it is an explicit act. */ export function detach(sessionId, client) { sessions.get(sessionId)?.clients.delete(client); } export function write(sessionId, data) { sessions.get(sessionId)?.term.write(data ?? ''); } export function resize(sessionId, cols, rows) { const session = sessions.get(sessionId); if (!session || !(cols > 0) || !(rows > 0)) return; session.cols = cols; session.rows = rows; try { session.term.resize(cols, rows); } catch { // the shell died between the lookup and the resize } } /** * End a session. `osUser` scopes it: pass an account name and only that account's sessions can be killed; * pass nothing and it is the owner, who may kill any. * * Undefined and null mean different things here, which is why the check is explicit. `undefined` is "the * caller did not scope this" (the owner). `null` is "the caller is the owner's own shell", which is a real * value a member must not match. */ export function kill(sessionId, osUser) { const session = sessions.get(sessionId); if (!session) return false; if (osUser !== undefined && (session.osUser ?? null) !== osUser) return false; try { session.term.kill(); } catch { // already gone } sessions.delete(sessionId); return true; } /** Sessions, scoped the same way as `kill`: an account name sees only its own, nothing sees everything. */ export function list(osUser) { return [...sessions.entries()] .filter(([, s]) => osUser === undefined || (s.osUser ?? null) === osUser) .map(([sessionId, s]) => ({ sessionId, cols: s.cols, rows: s.rows, createdAt: s.createdAt, lastActivityAt: s.lastActivityAt, title: s.title || undefined, pid: s.pid, clients: s.clients.size, osUser: s.osUser ?? undefined, })); } export function killAll() { for (const session of sessions.values()) { try { session.term.kill(); } catch { // ignore } } sessions.clear(); }