This commit is contained in:
2026-05-20 14:56:02 +00:00
parent 8cea4a899c
commit 2d841ee0b4
15 changed files with 1041 additions and 503 deletions

View File

@@ -1,7 +1,6 @@
import { spawn } from 'node:child_process';
import type { FastifyBaseLogger } from 'fastify';
// UUIDs already match [0-9a-f-]; allow uppercase and longer just in case.
const ID_RE = /^[a-zA-Z0-9_-]{1,64}$/;
export function sanitizeId(raw: string): string | null {
@@ -9,12 +8,15 @@ export function sanitizeId(raw: string): string | null {
return raw.toLowerCase();
}
export function tmuxSessionName(sessionId: string): string {
return `bc-${sessionId}`;
}
export function tmuxWindowName(paneId: string): string {
return `term-${paneId}`;
// v1.10.8c: per-pane tmux sessions (boolab pattern). Previously booterm used
// one tmux session per chat-session with one window per pane; that meant the
// session-level window-size policy was shared across panes, and
// `attach-session -d` (used to take over from a stale browser) would detach
// every other pane attached to the same session — the "[detached]" bug.
// Now each pane gets its own tmux session named `bc-<paneId>`. The bc- prefix
// namespaces booterm sessions on the shared tmux server.
export function tmuxSessionName(paneId: string): string {
return `bc-${paneId}`;
}
interface CmdResult {
@@ -23,15 +25,17 @@ interface CmdResult {
code: number;
}
// Wrap child_process.spawn with shell:false so each argv element is passed
// as a separate argument — no shell interpolation, no injection surface.
function runTmux(tmuxConfPath: string, args: string[]): Promise<CmdResult> {
return new Promise((resolve) => {
const child = spawn('tmux', ['-f', tmuxConfPath, ...args], { shell: false });
let stdout = '';
let stderr = '';
child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString('utf8'); });
child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8'); });
child.stdout.on('data', (chunk: Buffer) => {
stdout += chunk.toString('utf8');
});
child.stderr.on('data', (chunk: Buffer) => {
stderr += chunk.toString('utf8');
});
child.on('error', (err) => {
resolve({ stdout, stderr: stderr + String(err), code: 1 });
});
@@ -46,57 +50,115 @@ export async function hasSession(tmuxConfPath: string, sessionName: string): Pro
return res.code === 0;
}
export async function listWindows(tmuxConfPath: string, sessionName: string): Promise<string[]> {
const res = await runTmux(tmuxConfPath, ['list-windows', '-t', sessionName, '-F', '#{window_name}']);
if (res.code !== 0) return [];
return res.stdout.trim().split('\n').filter(Boolean);
// Default fallback size — wider than any real terminal would care about; the
// real client size lands via the WS resize frame within a few ms of attach.
const DEFAULT_COLS = 200;
const DEFAULT_ROWS = 50;
// v1.10.8d: per-pane shell is `ssh -t samkintop@SSH_HOST` (matches boolab's
// pattern). The container has no docker / claude / opencode binaries; SSH'ing
// to the host gives the user their full normal shell environment. Default is
// the host's Tailscale IP (100.114.205.53) — the hostname `ubuntu-homelab`
// only resolves on the host's local /etc/hosts, not from inside containers,
// so SSH'ing to the hostname fails with `Could not resolve hostname` even
// though the host machine is reachable. Boolab uses the same IP.
const SSH_HOST = process.env['BOOTERM_SSH_HOST']?.trim() || '100.114.205.53';
const SSH_USER = process.env['BOOTERM_SSH_USER']?.trim() || 'samkintop';
// POSIX shell single-quote escape: wrap in '…', escape embedded singles by
// closing-the-quote, inserting an escaped quote, and re-opening.
function shellEscape(s: string): string {
return `'${s.replace(/'/g, `'\\''`)}'`;
}
export async function killWindow(
// Idempotent. Creates the tmux session if it doesn't exist, sized via -x/-y
// from the client's measured xterm dimensions. With `window-size = largest`
// + `aggressive-resize on` in tmux.conf, the attached client's actual size
// wins once it reports in — but seeding at the right size avoids the brief
// window where bash/TUI inherits the default 80x24 from a stale fallback.
export async function ensureSession(
tmuxConfPath: string,
sessionName: string,
projectRoot: string,
log: FastifyBaseLogger,
cols?: number,
rows?: number,
): Promise<void> {
if (await hasSession(tmuxConfPath, sessionName)) return;
const sizeCols = cols && cols > 0 ? Math.floor(cols) : DEFAULT_COLS;
const sizeRows = rows && rows > 0 ? Math.floor(rows) : DEFAULT_ROWS;
// Bypass tmux.conf's default-command — build the per-pane argv explicitly
// so we can wrap ssh in the gosu privilege drop. The remote shell sequence
// (per boolab's invariants in services/tmux_session.py target_cmd_for):
// 1. ssh's argv must flatten into a single quoted bash -lc <script>
// 2. -l on the outer bash sources ~/.profile on the remote (PATH etc.)
// 3. cd to projectRoot, then exec bash -l so the user lands in the repo
// /opt is bind-mounted host↔container, so projectRoot resolves to the
// same files on both sides.
const remoteScript = `cd ${shellEscape(projectRoot)} && exec bash -l`;
const remoteCmd = `bash -lc ${shellEscape(remoteScript)}`;
const argv = [
'new-session', '-d',
'-s', sessionName,
'-c', projectRoot,
'-x', String(sizeCols),
'-y', String(sizeRows),
'--',
// gosu drops privs from the container's root (tmux server runs as root)
// to samkintop:samkintop. env restores HOME/USER/SHELL so ssh finds the
// right ~/.ssh/id_ed25519 (key is mode 0600 and ssh refuses keys whose
// UID doesn't match the running user — both are 1000 here).
'gosu', 'samkintop:samkintop',
'env', 'HOME=/home/samkintop', 'USER=samkintop', 'SHELL=/bin/bash',
'ssh', '-t',
'-o', 'StrictHostKeyChecking=yes',
'-o', 'ServerAliveInterval=30',
'-o', 'ServerAliveCountMax=3',
`${SSH_USER}@${SSH_HOST}`,
remoteCmd,
];
log.info(
{ sessionName, projectRoot, cols: sizeCols, rows: sizeRows, sshTarget: `${SSH_USER}@${SSH_HOST}` },
'creating tmux session (ssh to host)',
);
const res = await runTmux(tmuxConfPath, argv);
if (res.code !== 0) {
log.error({ res }, 'tmux new-session failed');
throw new Error(`tmux new-session failed: ${res.stderr}`);
}
}
export async function killSession(
tmuxConfPath: string,
sessionName: string,
windowName: string,
): Promise<boolean> {
const res = await runTmux(tmuxConfPath, ['kill-window', '-t', `${sessionName}:${windowName}`]);
const res = await runTmux(tmuxConfPath, ['kill-session', '-t', sessionName]);
return res.code === 0;
}
// Idempotent. Creates the tmux session if it doesn't exist, then ensures the
// named window is present. The session's initial window is created with the
// target name (via `-n`) so we don't need a separate rename step.
export async function ensureWindow(
// v1.10.8c: capture-pane on WS attach to replay the buffer state to the fresh
// xterm (boolab pattern). `-e` preserves ANSI escape sequences so colours and
// cursor position survive the replay. Returns empty string on failure — the
// client falls back to whatever tmux itself decides to repaint, which is
// non-fatal but visually noisier.
//
// v1.10.8d: strip trailing blank rows. tmux capture-pane emits one `\n` per
// pane row (including all the empty rows below the actual content), so on a
// fresh 35-row pane with just the bash prompt at row 0, the output is
// `<prompt>` followed by 35 `\n` bytes. When xterm.write()s those naively,
// the cursor advances row-by-row until it hits the bottom of the canvas and
// scrolls — pushing the prompt into the scrollback buffer where the user
// can't see it. Stripping the trailing newlines leaves xterm's cursor at the
// natural end of the rendered content (matching tmux's actual cursor
// position for the common single-line-prompt case).
export async function capturePane(
tmuxConfPath: string,
sessionName: string,
windowName: string,
projectRoot: string,
log: FastifyBaseLogger,
): Promise<void> {
if (!(await hasSession(tmuxConfPath, sessionName))) {
log.info({ sessionName, windowName, projectRoot }, 'creating tmux session');
const res = await runTmux(tmuxConfPath, [
'new-session', '-d',
'-s', sessionName,
'-n', windowName,
'-c', projectRoot,
]);
if (res.code !== 0) {
log.error({ res }, 'tmux new-session failed');
throw new Error(`tmux new-session failed: ${res.stderr}`);
}
return;
}
const windows = await listWindows(tmuxConfPath, sessionName);
if (windows.includes(windowName)) return;
lines: number = 2000,
): Promise<string> {
const res = await runTmux(tmuxConfPath, [
'new-window',
'-t', sessionName,
'-n', windowName,
'-c', projectRoot,
'capture-pane', '-t', sessionName, '-p', '-e', '-S', `-${lines}`,
]);
if (res.code !== 0) {
log.error({ res }, 'tmux new-window failed');
throw new Error(`tmux new-window failed: ${res.stderr}`);
}
if (res.code !== 0) return '';
return res.stdout.replace(/(?:\r?\n)+$/, '');
}

View File

@@ -3,7 +3,6 @@ import type { IPty } from 'node-pty';
export interface AttachPtyOptions {
sessionName: string;
windowName: string;
projectRoot: string;
cols: number;
rows: number;
@@ -19,16 +18,24 @@ function cleanEnv(): { [key: string]: string } {
return out;
}
// Spawns a tmux client attached to the given session+window. `-d` detaches any
// other client so a browser refresh takes over the same window without
// duplicate input. tmux server (and the window) persists across PTY exits.
// v1.10.8c: no `-d` (multi-attach friendly — boolab pattern). With per-pane
// tmux sessions, dropping `-d` means multiple browser tabs viewing the same
// pane share one tmux session as N clients; tmux fans I/O at the session
// layer just like boolab's backend. The earlier `-d` flag detached EVERY
// other client of the session — across windows — which caused the
// "[detached] from session" bug whenever a new pane attached to a chat
// session that already had another pane open.
//
// Tmux server + session persist across PTY exits, so a refresh resumes with
// full scrollback. Explicit destroy happens via the /kill route (called from
// the frontend when the user closes a pane).
export function attachPty(opts: AttachPtyOptions): IPty {
return pty.spawn(
'tmux',
[
'-f', opts.tmuxConfPath,
'attach-session', '-d',
'-t', `${opts.sessionName}:${opts.windowName}`,
'attach-session',
'-t', opts.sessionName,
],
{
name: 'xterm-256color',

View File

@@ -4,22 +4,33 @@ import { getSessionInfo } from '../db.js';
import {
sanitizeId,
tmuxSessionName,
tmuxWindowName,
ensureWindow,
killWindow,
ensureSession,
killSession,
hasSession,
listWindows,
} from '../pty/manager.js';
import { resizePane } from '../ws/attach.js';
const ParamsSchema = z.object({ sid: z.string(), pid: z.string() });
const ResizeBodySchema = z.object({
cols: z.coerce.number().int().min(1).max(2000),
rows: z.coerce.number().int().min(1).max(2000),
});
// v1.10.8c: optional cols/rows on /start so the per-pane tmux session is
// born at the right dimensions. Bodyless POSTs remain valid (Fastify's
// tolerant parser).
const StartBodySchema = z
.object({
cols: z.coerce.number().int().min(1).max(2000).optional(),
rows: z.coerce.number().int().min(1).max(2000).optional(),
})
.partial()
.optional();
export function registerTerminalRoutes(app: FastifyInstance, tmuxConfPath: string): void {
app.post<{ Params: { sid: string; pid: string } }>(
// v1.10.8c: /start creates the per-pane tmux session. Idempotent — a second
// /start on the same paneId is a no-op (hasSession returns true). The WS
// attach handler also calls ensureSession as belt-and-suspenders, so /start
// is technically optional, but having it as a separate step surfaces tmux
// errors as HTTP responses (vs WS 1011 close codes).
app.post<{
Params: { sid: string; pid: string };
Body: { cols?: number; rows?: number } | undefined;
}>(
'/api/term/sessions/:sid/panes/:pid/start',
async (req, reply) => {
const p = ParamsSchema.safeParse(req.params);
@@ -28,39 +39,35 @@ export function registerTerminalRoutes(app: FastifyInstance, tmuxConfPath: strin
const pid = sanitizeId(p.data.pid);
if (!sid || !pid) return reply.code(400).send({ error: 'bad_id_format' });
const b = StartBodySchema.safeParse(req.body ?? {});
const cols = b.success ? b.data?.cols : undefined;
const rows = b.success ? b.data?.rows : undefined;
const session = await getSessionInfo(sid);
if (!session) return reply.code(404).send({ error: 'unknown_session' });
const sessionName = tmuxSessionName(sid);
const windowName = tmuxWindowName(pid);
const sessionName = tmuxSessionName(pid);
try {
await ensureWindow(tmuxConfPath, sessionName, windowName, session.project_path, req.log);
await ensureSession(
tmuxConfPath,
sessionName,
session.project_path,
req.log,
cols,
rows,
);
} catch (err) {
req.log.error({ err }, 'ensureWindow failed');
req.log.error({ err }, 'ensureSession failed');
return reply.code(500).send({ error: 'tmux_failed' });
}
return reply.code(200).send({ tmux_window: windowName });
},
);
app.post<{ Params: { sid: string; pid: string }; Body: { cols: number; rows: number } }>(
'/api/term/sessions/:sid/panes/:pid/resize',
async (req, reply) => {
const p = ParamsSchema.safeParse(req.params);
if (!p.success) return reply.code(400).send({ error: 'bad_params' });
const b = ResizeBodySchema.safeParse(req.body);
if (!b.success) return reply.code(400).send({ error: 'bad_body' });
const sid = sanitizeId(p.data.sid);
const pid = sanitizeId(p.data.pid);
if (!sid || !pid) return reply.code(400).send({ error: 'bad_id_format' });
const ok = resizePane(pid, b.data.cols, b.data.rows);
if (!ok) return reply.code(404).send({ error: 'no_active_pty' });
return reply.code(200).send({ ok: true });
return reply.code(200).send({ tmux_session: sessionName });
},
);
// v1.10.8c: explicit pane teardown. Frontend calls this when the user
// intentionally closes a terminal pane (vs an implicit WS disconnect, which
// leaves the tmux session intact for refresh-driven resume).
app.post<{ Params: { sid: string; pid: string } }>(
'/api/term/sessions/:sid/panes/:pid/kill',
async (req, reply) => {
@@ -70,19 +77,17 @@ export function registerTerminalRoutes(app: FastifyInstance, tmuxConfPath: strin
const pid = sanitizeId(p.data.pid);
if (!sid || !pid) return reply.code(400).send({ error: 'bad_id_format' });
const sessionName = tmuxSessionName(sid);
const windowName = tmuxWindowName(pid);
const sessionName = tmuxSessionName(pid);
if (!(await hasSession(tmuxConfPath, sessionName))) {
return reply.code(404).send({ error: 'unknown_session' });
}
const windows = await listWindows(tmuxConfPath, sessionName);
if (!windows.includes(windowName)) {
return reply.code(404).send({ error: 'unknown_pane' });
}
const killed = await killWindow(tmuxConfPath, sessionName, windowName);
const killed = await killSession(tmuxConfPath, sessionName);
if (!killed) return reply.code(500).send({ error: 'tmux_kill_failed' });
return reply.code(200).send({ ok: true });
},
);
// Resize endpoint removed in v1.10.8c. Resize now flows in-band via the
// WebSocket as a `{type:"resize",cols,rows}` text frame — no more race
// between active-PTY-map registration and HTTP POST lookup. See ws/attach.ts.
}

View File

@@ -1,25 +1,15 @@
import type { FastifyInstance } from 'fastify';
import type { IPty } from 'node-pty';
import { getSessionInfo } from '../db.js';
import { sanitizeId, tmuxSessionName, tmuxWindowName, ensureWindow } from '../pty/manager.js';
import {
sanitizeId,
tmuxSessionName,
ensureSession,
capturePane,
} from '../pty/manager.js';
import { attachPty } from '../pty/pty.js';
import { getUser } from '../auth.js';
// Registry of currently-attached PTYs keyed by paneId. Used by the resize REST
// route to find the active node-pty handle so it can call pty.resize(cols, rows).
const active = new Map<string, IPty>();
export function resizePane(paneId: string, cols: number, rows: number): boolean {
const handle = active.get(paneId);
if (!handle) return false;
try {
handle.resize(cols, rows);
return true;
} catch {
return false;
}
}
export function registerWsAttachRoute(app: FastifyInstance, tmuxConfPath: string): void {
app.get<{
Params: { sid: string; pid: string };
@@ -44,24 +34,33 @@ export function registerWsAttachRoute(app: FastifyInstance, tmuxConfPath: string
return;
}
const sessionName = tmuxSessionName(sid);
const windowName = tmuxWindowName(pid);
const sessionName = tmuxSessionName(pid);
const cols = parseInt(req.query.cols ?? '', 10) || 80;
const rows = parseInt(req.query.rows ?? '', 10) || 24;
// Idempotent — /start typically created the session already, but cover
// the race where the client opens the WS before /start's response lands
// (or skips /start entirely). With per-pane tmux sessions there's no
// cross-pane interference, so creating-on-attach is safe.
try {
await ensureWindow(tmuxConfPath, sessionName, windowName, session.project_path, req.log);
await ensureSession(
tmuxConfPath,
sessionName,
session.project_path,
req.log,
cols,
rows,
);
} catch (err) {
req.log.error({ err }, 'ensureWindow failed in WS handler');
req.log.error({ err }, 'ensureSession failed in WS handler');
socket.close(1011, 'tmux_failed');
return;
}
const cols = parseInt(req.query.cols ?? '', 10) || 80;
const rows = parseInt(req.query.rows ?? '', 10) || 24;
let handle: IPty;
try {
handle = attachPty({
sessionName,
windowName,
projectRoot: session.project_path,
cols,
rows,
@@ -73,9 +72,31 @@ export function registerWsAttachRoute(app: FastifyInstance, tmuxConfPath: string
return;
}
active.set(pid, handle);
// Frame contract (boolab pattern):
// server → client text: JSON control — `init` on connect, `exit` on PTY death
// server → client binary: raw PTY bytes (first frame after init = capture-pane replay)
// client → server binary: user keystrokes
// client → server text: JSON control — `{type:"resize", cols, rows}`
//
// The init frame lets the client term.clear() before paint so a remount
// doesn't show stale buffer content. The capture-pane replay then
// paints the current tmux pane state into the fresh xterm.
try {
socket.send(JSON.stringify({ type: 'init', cols, rows, tmux_session: sessionName }));
} catch (err) {
req.log.warn({ err }, 'init frame send failed');
}
const onData = (data: string) => {
try {
const capture = await capturePane(tmuxConfPath, sessionName);
if (capture.length > 0) {
socket.send(Buffer.from(capture, 'utf8'), { binary: true });
}
} catch (err) {
req.log.warn({ err }, 'capture-pane failed');
}
const onData = (data: string): void => {
if (socket.readyState !== socket.OPEN) return;
try {
socket.send(Buffer.from(data, 'utf8'), { binary: true });
@@ -85,13 +106,32 @@ export function registerWsAttachRoute(app: FastifyInstance, tmuxConfPath: string
};
handle.onData(onData);
socket.on('message', (data: Buffer | string) => {
try {
if (typeof data === 'string') {
handle.write(data);
} else {
handle.write(data.toString('utf8'));
socket.on('message', (rawData: Buffer | string, isBinary?: boolean) => {
// ws v8 emits Buffer + isBinary boolean; older versions emit string
// for text frames. Either way: text path tries JSON parse for the
// resize control; binary path writes to the PTY.
const isTextFrame = typeof rawData === 'string' || isBinary === false;
if (isTextFrame) {
const text = typeof rawData === 'string' ? rawData : rawData.toString('utf8');
try {
const parsed = JSON.parse(text) as { type?: string; cols?: number; rows?: number };
if (parsed.type === 'resize') {
const newCols = Math.max(1, Math.min(2000, Math.floor(Number(parsed.cols) || 80)));
const newRows = Math.max(1, Math.min(2000, Math.floor(Number(parsed.rows) || 24)));
req.log.info({ pid, cols: newCols, rows: newRows }, 'resize');
try {
handle.resize(newCols, newRows);
} catch {
/* ignore — invalid winsize bubble */
}
}
} catch {
/* malformed text frame — drop silently */
}
return;
}
try {
handle.write((rawData as Buffer).toString('utf8'));
} catch (err) {
req.log.warn({ err }, 'pty write failed');
}
@@ -110,13 +150,13 @@ export function registerWsAttachRoute(app: FastifyInstance, tmuxConfPath: string
} catch {
/* ignore */
}
if (active.get(pid) === handle) active.delete(pid);
});
// WS close kills the local PTY (the tmux client). The tmux server and
// window persist so a refresh resumes with full scrollback.
// WS close kills the tmux client (the local PTY) but the tmux server +
// session persist so a refresh resumes with full scrollback. Permanent
// teardown happens via the /kill route called from the frontend when the
// user closes the pane.
socket.on('close', () => {
if (active.get(pid) === handle) active.delete(pid);
try {
handle.kill();
} catch {