18e574a765
A pane's tab shows the other people who have it open (pane-presence.js, in memory, fed by the /ws-pty attach; GET /api/lanes/:id/presence and the pane_presence broadcast to the owner and full collaborators) and says "Billed to <name>" for a pane a collaborator opened (startedBy on the run). The first real keystroke of each attach into a shared workspace's pane by anyone but its owner is audited as `type into <run id>` (mouse, focus and terminal replies to tmux do not count). The pty socket now pings, so a vanished browser is ended at the next re-check and leaves the presence list.
355 lines
14 KiB
JavaScript
355 lines
14 KiB
JavaScript
/**
|
||
* @file pty-attach.js
|
||
* @description Bridges one WebSocket connection to an `@lydell/node-pty`-backed
|
||
* `tmux attach-session` process, run with the live tmux server's own binary
|
||
* (`attachBinary`). Binary WS frames carry raw PTY bytes in
|
||
* both directions; text WS frames carry small JSON control messages
|
||
* (`resize`, and an outbound `exit` sent once when the pane process/tmux
|
||
* session ends). Multiple browser tabs each get their own PTY attach
|
||
* process, and ONE of them sizes the window: the DRIVER — the first to attach,
|
||
* then whichever sent `{type:"resize", drive:true}` last (the client does on a
|
||
* click or a keystroke). Every other attach is a follower: its PTY is held at
|
||
* the driver's size and told so (`{type:"size", cols, rows}`) so its xterm
|
||
* renders the same grid, scaled to fit, instead of each browser resizing tmux
|
||
* under the others. A driver that leaves sends `{type:"lead"}` to the rest.
|
||
* A keystroke that follows a mouse report first takes the pane out of tmux's
|
||
* copy-mode (the wheel's scrollback), so typing never lands in it.
|
||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||
*/
|
||
|
||
const fs = require("fs");
|
||
const tmux = require("./tmux");
|
||
|
||
const RUN_ID_RE = /^agenthub-lane-\d+(?:-[1-9])?$/;
|
||
|
||
/**
|
||
* Reject anything that isn't exactly `agenthub-lane-<digits>[-<1-9>]` before it can ever
|
||
* reach a tmux/PTY command — the trust boundary for this WS path, since a
|
||
* validated runId is the only thing standing between an authenticated WS
|
||
* client and naming an arbitrary session on the host.
|
||
*/
|
||
function validateRunId(runId) {
|
||
if (typeof runId !== "string" || !RUN_ID_RE.test(runId)) {
|
||
const err = new Error(`EBADRUNID: invalid runId: ${runId}`);
|
||
err.code = "EBADRUNID";
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
// Test seam — real implementation set in Step 4.
|
||
let spawnImpl = null;
|
||
function __setSpawnImpl(fn) {
|
||
spawnImpl = fn;
|
||
}
|
||
|
||
/**
|
||
* The binary to run `tmux attach` with: the RUNNING server's own executable
|
||
* (`/proc/<pid>/exe`, which still works after a package upgrade replaced the
|
||
* file), else `tmux` on PATH. A client must match its server — tmux installed
|
||
* in a newer version while the server runs makes PATH's client fail every
|
||
* attach with "open terminal failed: not a terminal", though every other tmux
|
||
* command still works. Linux only; elsewhere PATH's tmux as before — on
|
||
* Windows by its full file name: node-pty there looks the file up on PATH as
|
||
* given, never adding `.exe`, so a bare `tmux` failed every attach with
|
||
* "File not found" (the browser saw a socket that opened and closed, again
|
||
* every 1.5 s).
|
||
*/
|
||
function attachBinary(platform = process.platform) {
|
||
if (platform === "win32") return require("./ai-tools").which("tmux") || "tmux.exe";
|
||
const pid = tmux.serverPid();
|
||
if (!pid) return "tmux";
|
||
const exe = `/proc/${pid}/exe`;
|
||
try {
|
||
fs.accessSync(exe, fs.constants.X_OK);
|
||
return exe;
|
||
} catch {
|
||
return "tmux";
|
||
}
|
||
}
|
||
|
||
// psmux (Windows) holds a bracketed paste ~350 ms before the pane sees it,
|
||
// and input written meanwhile lands INSIDE it, before ESC[201~ (psmux#766):
|
||
// the Chat view's Enter, 100 ms after its paste, became a pasted newline and
|
||
// the prompt sat in the composer as "[Pasted text #1]". Input that follows a
|
||
// paste therefore waits until this long after it; 500 ms already arrived after
|
||
// the paste in that issue's measurements.
|
||
const PASTE_HOLD_MS = 700;
|
||
|
||
// Input that is not typing: a mouse report (SGR `ESC[<b;x;yM`, X10 `ESC[M` +
|
||
// three bytes) or a focus in/out. A wheel report is what puts a pane into
|
||
// tmux's copy-mode, where every typed key is taken as a scrollback command —
|
||
// the pane looked frozen. So once a mouse report has passed, the next real
|
||
// keystroke first leaves copy-mode (`tmux.leaveCopyMode`), then is written.
|
||
const NOT_TYPING_RE = /\x1b\[<\d+;\d+;\d+[Mm]|\x1b\[M[\s\S]{3}|\x1b\[[IO]/g;
|
||
|
||
// What the browser's terminal sends back on its own when tmux asks it
|
||
// something on attach (device attributes, cursor/status reports, mode
|
||
// reports, colour queries): no one typed it.
|
||
const TERMINAL_REPLY_RE =
|
||
/\x1b\[[?>=]?[\d;]*[cRn]|\x1b\[\?[\d;]*\$y|\x1bP[\s\S]*?\x1b\\|\x1b\][\s\S]*?(?:\x07|\x1b\\)/g;
|
||
|
||
/** Whether `text` from a browser is someone typing (not a mouse, focus or terminal reply). */
|
||
function isTyping(text) {
|
||
return text.replace(NOT_TYPING_RE, "").replace(TERMINAL_REPLY_RE, "") !== "";
|
||
}
|
||
|
||
// runId -> { driver, viewers: Set<{ ws, pty, cols, rows }>, scrolled }
|
||
// (`scrolled`: a mouse report reached the pane since the last keystroke — per
|
||
// pane, since one browser may scroll and another type)
|
||
const groups = new Map();
|
||
|
||
const tell = (v, msg) => {
|
||
try {
|
||
v.ws.send(JSON.stringify(msg), { binary: false });
|
||
} catch {
|
||
/* client gone — its close handler cleans up */
|
||
}
|
||
};
|
||
|
||
// node-pty throws from resize() once its process has exited ("Cannot resize a
|
||
// pty that has already exited" on Windows), and a viewer's attach can exit
|
||
// before its socket's close event takes it out of the group — a throw here,
|
||
// inside a WebSocket message handler, would take the whole server down.
|
||
const resize = (v, cols, rows) => {
|
||
if (v.exited) return;
|
||
try {
|
||
v.pty.resize(cols, rows);
|
||
} catch {
|
||
/* attach already gone — its exit/close handlers clean up */
|
||
}
|
||
};
|
||
|
||
/** `v` sizes the window now: every other attach is held at its size and told. */
|
||
function drive(group, v) {
|
||
group.driver = v;
|
||
resize(v, v.cols, v.rows);
|
||
for (const o of group.viewers) {
|
||
if (o === v) continue;
|
||
resize(o, v.cols, v.rows);
|
||
tell(o, { type: "size", cols: v.cols, rows: v.rows });
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Windows node-pty re-throws any read-pipe error that is not EIO unless a
|
||
* second `error` listener exists, and its write pipe has no listener at all —
|
||
* so the EPIPE/ECONNRESET a pane's attach raises when the session is stopped
|
||
* (Stop, kill-session) became an uncaught exception that killed the dashboard.
|
||
* The attach's exit is reported by `onExit`; the error itself carries nothing.
|
||
*/
|
||
function swallowPipeErrors(pty) {
|
||
const ignore = () => {};
|
||
try {
|
||
pty.on?.("error", ignore);
|
||
} catch {
|
||
/* no event emitter on this pty — nothing to guard */
|
||
}
|
||
// Private on node-pty's Windows agent; absent elsewhere and in tests.
|
||
const input = pty._agent?.inSocket;
|
||
if (input && typeof input.on === "function") input.on("error", ignore);
|
||
}
|
||
|
||
/**
|
||
* Attach `ws` to the tmux session `runId`. Spawns one PTY-backed
|
||
* `tmux attach-session -t <runId>` per call.
|
||
*/
|
||
function attach(ws, runId, { cols, rows, platform = process.platform }) {
|
||
validateRunId(runId);
|
||
// Retroactively covers sessions started before mouse mode was wired into
|
||
// newSession (or before a server restart picked that change up) — see
|
||
// tmux.js's enableMouse.
|
||
try {
|
||
tmux.enableMouse(runId);
|
||
} catch {
|
||
/* session gone or tmux unavailable — attach below will surface that */
|
||
}
|
||
let group = groups.get(runId);
|
||
if (!group) groups.set(runId, (group = { driver: null, viewers: new Set() }));
|
||
const lead = group.driver;
|
||
// A developer's pane is on that account's own tmux server, attached AS that
|
||
// account (S3: never a host client on a socket the account controls).
|
||
// node-pty forks natively, so drop-caps' child_process patch never sees it:
|
||
// the client must not keep the unit's CAP_DAC_READ_SEARCH (S3).
|
||
const [file, args] = require("./drop-caps").ptyArgv(
|
||
...(tmux.memberAttach(runId) || [attachBinary(), ["attach-session", "-t", tmux.session(runId)]])
|
||
);
|
||
// An attach with no size of its own (a terminal on a hidden tab, 0×0) takes
|
||
// the window's current size, so attaching it never shrinks the pane.
|
||
const own =
|
||
cols && rows ? { cols, rows } : (!lead && tmux.windowSize(runId)) || { cols: 80, rows: 24 };
|
||
// A follower attaches at the driver's size: tmux sizes the window to the
|
||
// newest client, so its own size would resize the driver's pane first.
|
||
const pty = spawnImpl(file, args, {
|
||
name: "xterm-256color",
|
||
cols: lead ? lead.cols : own.cols,
|
||
rows: lead ? lead.rows : own.rows,
|
||
// Windows' inbox ConPTY re-renders psmux's output instead of passing it
|
||
// through, and wherever its glyph widths disagree with xterm.js's (⏺, ✻,
|
||
// Vietnamese) it leaves stray cells: a torn left edge, bars mid-screen.
|
||
// The conpty.dll + OpenConsole.exe in @lydell/node-pty-win32-* is the
|
||
// newer one VS Code uses for the same reason. Ignored elsewhere.
|
||
...(platform === "win32" && { useConptyDll: true }),
|
||
});
|
||
swallowPipeErrors(pty);
|
||
const me = { ws, pty, cols: own.cols, rows: own.rows, exited: false };
|
||
group.viewers.add(me);
|
||
if (lead) tell(me, { type: "size", cols: lead.cols, rows: lead.rows });
|
||
else group.driver = me;
|
||
// psmux never turns mouse tracking on for its client (not even for a pane
|
||
// app that asked), so xterm.js would never report the wheel and the pane
|
||
// could not scroll. psmux does act on a wheel report it receives: the
|
||
// browser sends one itself (TerminalView).
|
||
if (platform === "win32") tell(me, { type: "wheel" });
|
||
|
||
// Backpressure: a slow viewer (a remote browser on a thin link) must not
|
||
// queue a chatty pane's output in this process without bound. Past 1 MB
|
||
// unsent the pty is paused — tmux holds the pane and redraws — and resumed
|
||
// once the socket drains below 256 KB.
|
||
let paused = false;
|
||
const drained = () => {
|
||
if (paused && ws.bufferedAmount < 256 * 1024) {
|
||
paused = false;
|
||
pty.resume?.();
|
||
}
|
||
};
|
||
pty.onData((data) => {
|
||
try {
|
||
ws.send(data, { binary: true }, drained);
|
||
if (!paused && ws.bufferedAmount > 1024 * 1024) {
|
||
paused = true;
|
||
pty.pause?.();
|
||
}
|
||
} catch {
|
||
/* client gone between data event and send — safe to ignore */
|
||
}
|
||
});
|
||
|
||
// Every keystroke goes through here, in order; see PASTE_HOLD_MS.
|
||
const holdsPaste = platform === "win32";
|
||
let holdUntil = 0;
|
||
let held = [];
|
||
const flush = () => {
|
||
const q = held;
|
||
held = [];
|
||
try {
|
||
q.forEach(write);
|
||
} catch {
|
||
/* the attach exited while its input was held — nothing to write to */
|
||
}
|
||
};
|
||
// Keystrokes after a scroll leave copy-mode first; see NOT_TYPING_RE.
|
||
function input(text) {
|
||
if (text.replace(NOT_TYPING_RE, "") === "") {
|
||
if (text.includes("\x1b[M") || text.includes("\x1b[<")) group.scrolled = true;
|
||
} else if (group.scrolled) {
|
||
group.scrolled = false;
|
||
tmux.leaveCopyMode(runId);
|
||
}
|
||
write(text);
|
||
}
|
||
function write(text) {
|
||
if (me.exited) return; // the attach is gone: nothing reads this pipe
|
||
const wait = holdUntil - Date.now();
|
||
if (held.length || wait > 0) {
|
||
if (held.push(text) === 1) setTimeout(flush, Math.max(0, wait));
|
||
return;
|
||
}
|
||
try {
|
||
pty.write(text);
|
||
} catch {
|
||
return; // exited between the check and the write — onExit follows
|
||
}
|
||
if (holdsPaste && text.includes("\x1b[201~")) holdUntil = Date.now() + PASTE_HOLD_MS;
|
||
}
|
||
|
||
pty.onExit(({ exitCode }) => {
|
||
me.exited = true;
|
||
try {
|
||
ws.send(JSON.stringify({ type: "exit", code: exitCode }), { binary: false });
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
try {
|
||
ws.close();
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
});
|
||
|
||
ws.on("message", (data, isBinary) => {
|
||
// node-pty attach processes for `tmux attach` are already tolerant of
|
||
// resize mid-stream; the `ws` lib passes isBinary either as the second
|
||
// callback arg (newer) or via `data.binary` on some transports — this
|
||
// helper's own tests exercise the `{binary}` option shape used above.
|
||
const binary = typeof isBinary === "boolean" ? isBinary : !!(isBinary && isBinary.binary);
|
||
const text = data.toString("utf8");
|
||
if (binary) {
|
||
input(text);
|
||
return;
|
||
}
|
||
// The browser's WebSocket API sends a JS string as a text frame, and
|
||
// xterm.js's onData callback hands over plain strings — so every
|
||
// keystroke arrives here as text, not binary. Only a JSON control frame
|
||
// (matched by this same prefix check the client uses for output) is
|
||
// NOT keystroke input; everything else must reach the pty or typing
|
||
// does nothing.
|
||
if (text.startsWith('{"type"')) {
|
||
let msg;
|
||
try {
|
||
msg = JSON.parse(text);
|
||
} catch {
|
||
input(text);
|
||
return;
|
||
}
|
||
if (msg && msg.type === "resize" && Number.isFinite(msg.cols) && Number.isFinite(msg.rows)) {
|
||
me.cols = msg.cols;
|
||
me.rows = msg.rows;
|
||
// A follower's own size is remembered, not applied: its PTY stays at
|
||
// the driver's until it takes the lead.
|
||
if (msg.drive || group.driver === me || !group.driver) drive(group, me);
|
||
return;
|
||
}
|
||
}
|
||
input(text);
|
||
});
|
||
|
||
ws.on("close", () => {
|
||
group.viewers.delete(me);
|
||
if (!group.viewers.size) groups.delete(runId);
|
||
else if (group.driver === me) {
|
||
group.driver = null;
|
||
for (const o of group.viewers) tell(o, { type: "lead" });
|
||
}
|
||
try {
|
||
pty.kill();
|
||
} catch {
|
||
/* already gone */
|
||
}
|
||
});
|
||
|
||
return pty;
|
||
}
|
||
|
||
// Real spawn implementation — lazy-required so unit tests never load the
|
||
// native PTY addon unless they explicitly opt in. Uses @lydell/node-pty (a
|
||
// drop-in-API-compatible fork of node-pty) rather than node-pty itself:
|
||
// node-pty ships prebuilt binaries for darwin/win32 only, so on Linux it
|
||
// needs a native build via its install script — but the plugin install path
|
||
// runs `npm install --ignore-scripts` deliberately (see plugin-bootstrap.js)
|
||
// to avoid requiring a build toolchain on the user's machine. @lydell/node-pty
|
||
// instead ships the platform binary as a regular optionalDependency
|
||
// (@lydell/node-pty-linux-x64 etc.), so a plain --ignore-scripts install still
|
||
// resolves a working native binding with no compiler needed.
|
||
__setSpawnImpl((...args) => require("@lydell/node-pty").spawn(...args));
|
||
|
||
const __resetGroups = () => groups.clear();
|
||
|
||
module.exports = {
|
||
attach,
|
||
attachBinary,
|
||
validateRunId,
|
||
isTyping,
|
||
__setSpawnImpl,
|
||
__resetGroups,
|
||
};
|