agent-orchestrator/packages/web/server/terminal-websocket.ts

337 lines
11 KiB
TypeScript

/**
* Terminal server that manages ttyd instances for tmux sessions.
*
* Runs alongside Next.js. Spawns a ttyd process per session on demand,
* each on a unique port. The dashboard embeds ttyd via iframe.
*
* ttyd handles all the hard parts: xterm.js, WebSocket, ANSI rendering,
* cursor positioning, resize, input — battle-tested and correct.
*
* TODO: Add authentication middleware to verify:
* - User is authenticated
* - User owns the requested session
* - Rate limiting for terminal access
*/
import { spawn, type ChildProcess } from "node:child_process";
import { createServer, request } from "node:http";
import { findTmux, resolveTmuxSession, validateSessionId } from "./tmux-utils.js";
/** Cached full path to tmux binary */
const TMUX = findTmux();
console.log(`[Terminal] Using tmux: ${TMUX}`);
interface TtydInstance {
sessionId: string;
port: number;
process: ChildProcess;
}
const instances = new Map<string, TtydInstance>();
const availablePorts = new Set<number>(); // Pool of recycled ports
let nextPort = 7800; // Start ttyd instances from port 7800
const MAX_PORT = 7900; // Prevent unbounded port allocation
/**
* Check if ttyd is ready to accept connections by making a test request.
* Returns a promise that resolves when ttyd is ready or rejects after timeout.
* Properly cancels pending timeouts and requests to prevent memory leaks.
*/
function waitForTtyd(port: number, sessionId: string, timeoutMs = 3000): Promise<void> {
const startTime = Date.now();
let timeoutId: NodeJS.Timeout | null = null;
let pendingReq: ReturnType<typeof request> | null = null;
let settled = false;
return new Promise((resolve, reject) => {
const cleanup = () => {
settled = true;
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
if (pendingReq) {
pendingReq.destroy();
pendingReq = null;
}
};
const checkReady = () => {
if (settled) return;
if (Date.now() - startTime > timeoutMs) {
cleanup();
reject(new Error(`ttyd did not become ready within ${timeoutMs}ms`));
return;
}
const req = request(
{
hostname: "localhost",
port,
path: `/${sessionId}/`,
method: "GET",
timeout: 500,
},
(_res) => {
// Any response (even 404) means ttyd is listening
cleanup();
resolve();
},
);
pendingReq = req;
req.on("timeout", () => {
if (settled) return;
req.destroy();
pendingReq = null;
// Schedule retry but track the timeout ID
timeoutId = setTimeout(checkReady, 100);
});
req.on("error", () => {
if (settled) return;
pendingReq = null;
// Connection refused or other error - ttyd not ready yet, retry
timeoutId = setTimeout(checkReady, 100);
});
req.end();
};
checkReady();
});
}
/**
* Spawn or reuse a ttyd instance for a tmux session.
*
* @param sessionId - User-facing session ID (used for base-path and URL)
* @param tmuxSessionName - Actual tmux session name (may be hash-prefixed)
*/
function getOrSpawnTtyd(sessionId: string, tmuxSessionName: string): TtydInstance {
const existing = instances.get(sessionId);
if (existing) return existing;
// Allocate port: reuse from pool if available, otherwise increment
let port: number;
if (availablePorts.size > 0) {
// Reuse a recycled port
port = availablePorts.values().next().value as number;
availablePorts.delete(port);
} else {
// Allocate new port
if (nextPort >= MAX_PORT) {
throw new Error(`Port exhaustion: reached maximum of ${MAX_PORT - 7800} terminal instances`);
}
port = nextPort++;
}
console.log(`[Terminal] Spawning ttyd for ${tmuxSessionName} on port ${port}`);
// Enable mouse mode for scrollback support
const mouseProc = spawn(TMUX, ["set-option", "-t", tmuxSessionName, "mouse", "on"]);
mouseProc.on("error", (err) => {
console.error(`[Terminal] Failed to set mouse mode for ${tmuxSessionName}:`, err.message);
});
// Hide the green status bar for cleaner appearance
const statusProc = spawn(TMUX, ["set-option", "-t", tmuxSessionName, "status", "off"]);
statusProc.on("error", (err) => {
console.error(`[Terminal] Failed to hide status bar for ${tmuxSessionName}:`, err.message);
});
// Use user-facing sessionId for base-path (matches URL the dashboard uses)
// Use tmuxSessionName for tmux attach (may be hash-prefixed)
const proc = spawn(
"ttyd",
[
"--writable",
"--port",
String(port),
"--base-path",
`/${sessionId}`,
TMUX,
"attach-session",
"-t",
tmuxSessionName,
],
{
stdio: ["ignore", "pipe", "pipe"],
},
);
proc.stdout?.on("data", (data: Buffer) => {
console.log(`[Terminal] ttyd ${sessionId}: ${data.toString().trim()}`);
});
proc.stderr?.on("data", (data: Buffer) => {
console.log(`[Terminal] ttyd ${sessionId}: ${data.toString().trim()}`);
});
// Use once() for cleanup handlers to prevent race condition when both exit and error fire
proc.once("exit", (code) => {
console.log(`[Terminal] ttyd ${sessionId} exited with code ${code}`);
// Only delete if this is still the current instance (prevents race with error handler)
const current = instances.get(sessionId);
if (current?.process === proc) {
instances.delete(sessionId);
// Only recycle port on clean exit (code 0), not on errors
// Failed ttyd processes may leave ports in TIME_WAIT state
if (code === 0) {
availablePorts.add(port);
}
}
});
proc.once("error", (err) => {
console.error(`[Terminal] ttyd ${sessionId} error:`, err.message);
// Only delete if this is still the current instance (prevents race with exit handler)
const current = instances.get(sessionId);
if (current?.process === proc) {
instances.delete(sessionId);
// Don't recycle port on error - may still be in use or TIME_WAIT
}
// Kill any running process
try {
proc.kill();
} catch {
// Ignore kill errors if process already dead
}
});
const instance: TtydInstance = { sessionId, port, process: proc };
instances.set(sessionId, instance);
return instance;
}
// Simple HTTP API for the dashboard to request terminal URLs
const server = createServer(async (req, res) => {
const url = new URL(req.url ?? "/", "http://localhost");
// CORS for dashboard - allow requests from the same host as the dashboard
// TODO: Replace with proper session-based authentication
const origin = req.headers.origin;
if (origin && origin !== "null") {
// Extract hostname from origin and compare with request host
try {
const originUrl = new URL(origin);
const requestHost = req.headers.host;
// Allow if origin hostname matches request host (supports remote deployments)
if (requestHost && originUrl.hostname === requestHost.split(":")[0]) {
res.setHeader("Access-Control-Allow-Origin", origin);
}
} catch {
// Invalid origin URL, don't set CORS header
}
} else {
// Allow null origin (file:// or local HTML files)
res.setHeader("Access-Control-Allow-Origin", "*");
}
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
if (req.method === "OPTIONS") {
res.writeHead(204);
res.end();
return;
}
// GET /terminal?session=ao-1 → returns { url, port }
if (url.pathname === "/terminal") {
const sessionId = url.searchParams.get("session");
if (!sessionId) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Missing session parameter" }));
return;
}
// Validate session ID to prevent path traversal and injection
if (!validateSessionId(sessionId)) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Invalid session ID" }));
return;
}
// Resolve tmux session name: try exact match first, then suffix match
// (hash-prefixed sessions like "8474d6f29887-ao-15" are accessed by user-facing ID "ao-15")
const tmuxSessionId = resolveTmuxSession(sessionId, TMUX);
if (!tmuxSessionId) {
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Session not found" }));
return;
}
// Spawn ttyd and wait for it to be ready (catch port exhaustion and startup failures)
try {
const instance = getOrSpawnTtyd(sessionId, tmuxSessionId);
await waitForTtyd(instance.port, sessionId);
// Use the request host to construct the terminal URL (supports remote access)
const host = req.headers.host ?? "localhost";
const protocol = req.headers["x-forwarded-proto"] ?? "http";
res.writeHead(200, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
url: `${protocol}://${host.split(":")[0]}:${instance.port}/${sessionId}/`,
port: instance.port,
sessionId,
}),
);
} catch (err) {
const errorMsg = err instanceof Error ? err.message : String(err);
console.error(`[Terminal] Failed to start terminal for ${sessionId}:`, errorMsg);
res.writeHead(503, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Failed to start terminal" }));
}
return;
}
// GET /health
if (url.pathname === "/health") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
instances: Object.fromEntries(
[...instances.entries()].map(([id, inst]) => [id, { port: inst.port }]),
),
}),
);
return;
}
res.writeHead(404);
res.end("Not found");
});
const PORT = parseInt(process.env.TERMINAL_PORT ?? "14800", 10);
server.listen(PORT, () => {
console.log(`[Terminal] Server listening on port ${PORT}`);
});
// Graceful shutdown — kill all ttyd instances
function shutdown(signal: string) {
console.log(`[Terminal] Received ${signal}, shutting down...`);
for (const [, instance] of instances) {
instance.process.kill();
}
server.close(() => {
console.log("[Terminal] Server closed");
process.exit(0);
});
// Force exit after 5s if graceful shutdown hangs
// Use unref() so this timer doesn't prevent process exit if server closes quickly
const forceExitTimer = setTimeout(() => {
console.error("[Terminal] Forced shutdown after timeout");
process.exit(1);
}, 5000);
forceExitTimer.unref();
}
process.on("SIGINT", () => shutdown("SIGINT"));
process.on("SIGTERM", () => shutdown("SIGTERM"));