three separate faults behind "reconnecting gets weird and the keyboard is not natural". ── the replay typed into the shell ── the pty buffer was stored raw and replayed verbatim on every re-attach. anything in it that ASKS the terminal a question — DSR, DA, DECRQM, XTVERSION, XTGETTCAP, the OSC colour queries — got asked again, and xterm answered correctly by writing the reply to its input. the pty receives that as a keystroke nobody typed. stripped on the way IN, since the buffer is the thing that gets replayed and a live client already answered them once when they were legitimately asked. only questions are removed; everything that draws is untouched. where a control shares its final byte with one that draws, the parameter is enumerated rather than wildcarded — CSI 18 t asks the window size, CSI 22 t pushes the title, and stripping the second would change what a replay renders. 36 tests, both directions, because both fail silently. ── two sockets on one session ── handleClose armed a reconnect timer; handleVisibilityChange fired on tab focus whenever readyState was CLOSED — which is exactly what a pending timer leaves. both ran. every keystroke went twice, two replay frames fought over the screen, and only one socket was ever cleaned up because __terminalCleanup is overwritten by whichever connect ran last. connect() is now the single guard, and a stale socket's close no longer speaks for the session. ── the keyboard ── alt-arrow was dead for everyone: xterm.js 5 rewrote it into the ctrl-arrow sequence, xterm.js 6 removed that rewrite and emits the honest ^[[1;3C/D (verified — the string 1;3D does not appear anywhere in the 6.0 bundle). nothing bound it. so it broke on a dependency bump, with no shell config changed. bound in zsh rather than translated in the browser, deliberately: tmux.conf claims M-Left/M-Right for pane switching, and a client-side rewrite would send ^[b to tmux and break it. the real sequence lets tmux handle it inside a session and zsh outside. ctrl-arrow was worse and more embarrassing: it worked for MEMBERS and not for the OWNER. shell-skel/zshrc has had the bindings all along; the owner's .zshrc is assembled in machine-setup and never got them. the owner had a strictly worse shell than the accounts they provision. confirmed with `zsh -i -c bindkey` before and after. also: escape-time 10 in tmux.conf. the 500ms default delays every Alt chord and every Escape, which is most of what "not natural" felt like. applied to this host by hand — setup only runs at install. cmd+arrow is left alone: xterm emits nothing for it, so there is no sequence to bind. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
299 lines
13 KiB
JavaScript
299 lines
13 KiB
JavaScript
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<string, Session>} */
|
|
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();
|
|
}
|