import { spawn } from 'node:child_process'; import type { ChildProcessWithoutNullStreams } from 'node:child_process'; import type { Server } from 'node:http'; import type { WebSocket } from 'ws'; import { WebSocketServer } from 'ws'; import type { BoxHandle } from './user-container'; import { env } from './env'; import { verifyTerminalToken } from './terminal-token'; import { acquireUserBox } from './user-container'; import { getTerminalWorkspace } from './worker'; const clampDimension = (value: unknown) => { const n = Math.trunc(Number(value)); if (!Number.isFinite(n)) return undefined; return Math.min(Math.max(n, 1), 1000); }; // Single-quote a string for a POSIX shell. const shellQuote = (value: string) => `'${value.replaceAll("'", `'\\''`)}'`; const bridge = async (ws: WebSocket, jobId: string) => { const workspace = getTerminalWorkspace(jobId); if (!workspace) { ws.close(1011, 'Workspace is not active.'); return; } // bun can't load node-pty (native ABI mismatch) and dockerode can't attach to // podman, so we drive the runtime CLI (` exec -i`) and allocate the PTY // *inside* the container with `script`, bridging the plain pipes to the socket. // // Register the message handler immediately and buffer input/size until the exec // is ready (acquiring the box can take seconds on first connect), so the initial // resize and early keystrokes aren't dropped. const procHolder: { current?: ChildProcessWithoutNullStreams } = {}; const pendingInput: Buffer[] = []; let cols = 80; let rows = 24; ws.on('message', (data: Buffer, isBinary: boolean) => { if (!isBinary) { // Text frames are control messages (resize); anything else is raw input. try { const message = JSON.parse(data.toString('utf8')) as { type?: string; cols?: number; rows?: number; }; if (message.type === 'resize') { const c = clampDimension(message.cols); const r = clampDimension(message.rows); if (c && r) { cols = c; rows = r; } return; } } catch { // fall through: treat as raw input } } if (procHolder.current) procHolder.current.stdin.write(data); else pendingInput.push(data); }); const handleHolder: { current?: BoxHandle } = {}; let released = false; // Read through a function so TS doesn't narrow `released` to a constant — the // cleanup handler flips it asynchronously when the socket closes. const isReleased = () => released; const cleanup = () => { if (released) return; released = true; procHolder.current?.kill(); handleHolder.current?.release(); }; ws.on('close', cleanup); ws.on('error', cleanup); // Hold the per-user box open while this terminal is connected; the agent and // the terminal share the exact same container (Phase 2). let boxName: string; try { const handle = await acquireUserBox({ username: workspace.username, workdir: workspace.workdir, containerHome: workspace.containerHome, }); handleHolder.current = handle; boxName = handle.boxName; } catch (error) { ws.close( 1011, `Failed to start terminal: ${error instanceof Error ? error.message : 'unknown error'}`, ); return; } // Cleanup may have run before the awaited handle existed. if (isReleased()) { handleHolder.current.release(); return; } // Reattach a persistent tmux session across reconnects when available, else a // plain login shell. `stty` sizes the PTY to the client's viewport up front. const launcher = `stty rows ${rows} cols ${cols} 2>/dev/null; ` + // Reattach a persistent tmux session when tmux is present; otherwise fall back // to an interactive login shell (`-i` so it prints a prompt and line-edits). // Check with `command -v` rather than `exec tmux || …`: a failed `exec` makes a // non-interactive shell exit before the `||`, so the fallback never runs. 'if command -v tmux >/dev/null 2>&1; then exec tmux new-session -A -s spoon; ' + 'else exec bash -il; fi'; const envFlags = [ '-e', 'TERM=xterm-256color', '-e', `HOME=${workspace.containerHome}`, ...workspace.secrets.flatMap((s) => ['-e', `${s.name}=${s.value}`]), ]; const proc = spawn( env.containerRuntime, [ 'exec', '-i', ...envFlags, '-w', workspace.containerRepo, boxName, '/bin/bash', '-lc', `exec script -qfc ${shellQuote(launcher)} /dev/null`, ], { stdio: ['pipe', 'pipe', 'pipe'] }, ); procHolder.current = proc; // Replay any keystrokes the client sent before the process was ready. for (const buffered of pendingInput) proc.stdin.write(buffered); pendingInput.length = 0; const forward = (chunk: Buffer) => { if (ws.readyState === ws.OPEN) ws.send(chunk, { binary: true }); }; proc.stdout.on('data', forward); proc.stderr.on('data', forward); proc.on('exit', () => { if (ws.readyState === ws.OPEN) ws.close(); }); proc.on('error', () => { if (ws.readyState === ws.OPEN) ws.close(); }); }; /** * Attaches the interactive-terminal WebSocket endpoint to the worker's HTTP * server. Browser connects to `/jobs/:jobId/terminal?token=…` with a short-lived * token minted by the Next app (which has already verified job ownership). */ export const attachTerminalServer = (server: Server) => { if (env.runtime !== 'docker') return; const wss = new WebSocketServer({ noServer: true }); server.on('upgrade', (request, socket, head) => { const url = new URL(request.url ?? '', `http://localhost:${env.httpPort}`); const match = /^\/jobs\/([^/]+)\/terminal$/.exec(url.pathname); if (!match?.[1]) { socket.destroy(); return; } const jobId = decodeURIComponent(match[1]); const token = url.searchParams.get('token') ?? ''; if (!verifyTerminalToken(token, jobId, env.terminalSecret)) { socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); socket.destroy(); return; } wss.handleUpgrade(request, socket, head, (ws) => { void bridge(ws, jobId); }); }); console.log('Spoon agent worker terminal WebSocket endpoint enabled.'); };