Files
AgentHub/server/lib/pty-attach.js
T
nntrivi2001 a0cd808456 fix(terminal): a pane on a hidden tab never shrinks its tmux window
A terminal mounted on a hidden tab sits in a 0x0 box; its first fit shrank
xterm to 12x6 and the attach carried that size to tmux, so every background
pane was squeezed (Claude's TUI redrawn at 12 columns). It now neither fits
nor reports a size until shown, attaches with no size, and the server spawns
such an attach at the window's own size (tmux.windowSize). A shown tab
reports its size even when the fit did not change xterm's.

Settings > Agents shows the host's login profiles to staff only: a developer
got two 403s from reads that were never theirs.
2026-10-26 14:53:29 +07:00

337 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @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;
// 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, __setSpawnImpl, __resetGroups };