165 lines
6.8 KiB
TypeScript
165 lines
6.8 KiB
TypeScript
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-<paneId>`. 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<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.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<boolean> {
|
|
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<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,
|
|
): Promise<boolean> {
|
|
const res = await runTmux(tmuxConfPath, ['kill-session', '-t', sessionName]);
|
|
return res.code === 0;
|
|
}
|
|
|
|
// 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,
|
|
lines: number = 2000,
|
|
): Promise<string> {
|
|
const res = await runTmux(tmuxConfPath, [
|
|
'capture-pane', '-t', sessionName, '-p', '-e', '-S', `-${lines}`,
|
|
]);
|
|
if (res.code !== 0) return '';
|
|
return res.stdout.replace(/(?:\r?\n)+$/, '');
|
|
}
|