import { spawn } from 'node:child_process'; import type { FastifyBaseLogger } from 'fastify'; const ID_RE = /^[a-zA-Z0-9_-]{1,64}$/; export function sanitizeId(raw: string): string | null { if (!ID_RE.test(raw)) return null; return raw.toLowerCase(); } // 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-`. The bc- prefix // namespaces booterm sessions on the shared tmux server. export function tmuxSessionName(paneId: string): string { return `bc-${paneId}`; } interface CmdResult { stdout: string; stderr: string; code: number; } function runTmux(tmuxConfPath: string, args: string[]): Promise { 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.on('error', (err) => { resolve({ stdout, stderr: stderr + String(err), code: 1 }); }); child.on('close', (code) => { resolve({ stdout, stderr, code: code ?? 0 }); }); }); } export async function hasSession(tmuxConfPath: string, sessionName: string): Promise { const res = await runTmux(tmuxConfPath, ['has-session', '-t', `=${sessionName}`]); return res.code === 0; } // 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, `'\\''`)}'`; } // 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 { 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