/** * @file pty-attach.js * @description Bridges one WebSocket connection to a `node-pty`-backed * `tmux attach-session` process. 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 — tmux itself is what keeps them all in sync, this module does no * cross-connection coordination. * @author Nguyễn Ngọc Trí Vĩ */ const RUN_ID_RE = /^ccam-lane-\d+$/; /** * Reject anything that isn't exactly `ccam-lane-` 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; } /** * Attach `ws` to the tmux session `runId`. Spawns one PTY-backed * `tmux attach-session -t ` per call. */ function attach(ws, runId, { cols, rows }) { validateRunId(runId); const pty = spawnImpl("tmux", ["attach-session", "-t", runId], { name: "xterm-256color", cols: cols || 80, rows: rows || 24, }); pty.onData((data) => { try { ws.send(data, { binary: true }); } catch { /* client gone between data event and send — safe to ignore */ } }); pty.onExit(({ exitCode }) => { 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); if (binary) { pty.write(data.toString("utf8")); return; } let msg; try { msg = JSON.parse(data.toString("utf8")); } catch { return; } if (msg && msg.type === "resize" && Number.isFinite(msg.cols) && Number.isFinite(msg.rows)) { pty.resize(msg.cols, msg.rows); } }); ws.on("close", () => { try { pty.kill(); } catch { /* already gone */ } }); return pty; } // Real spawn implementation — lazy-required so unit tests never load the // native node-pty addon unless they explicitly opt in. __setSpawnImpl((...args) => require("node-pty").spawn(...args)); module.exports = { attach, validateRunId, __setSpawnImpl };