Files
platform/src/servers/sidecar/pty/sessions.mjs
T
pastilhasandClaude Opus 5 02e049cae8 terminal: stop replaying questions, stop opening two sockets, bind the word keys
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>
2026-08-14 21:38:27 +00:00

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();
}