UNPKG

@blundergoat/goat-flow

Version:

AI coding agent harness and local dashboard for Claude Code, OpenAI Codex, Google Antigravity, and GitHub Copilot - setup audits, guardrails, structured skills, deny hooks, and persistent learning loops.

391 lines 16.1 kB
import { decodeTerminalCreateBody, decodeTerminalUploadBody, } from "./decoders.js"; import { MAX_SESSIONS } from "./terminal.js"; import { buildAttachmentNote, persistUploads, TERMINAL_UPLOAD_MAX_BODY_BYTES, TERMINAL_UPLOAD_MAX_FILES, uploadDirForSession, } from "./terminal-uploads.js"; import { recordEvidenceEvent, } from "../evidence/envelope.js"; import { redactEvidenceText } from "../evidence/redaction.js"; /** * Build the terminal-only dashboard handlers for one server instance. * * @param deps - server-local dependencies and limits shared by terminal routes * @returns terminal route handlers plus the shutdown hook for active sessions */ export function createDashboardTerminalHandlers(deps) { const { absDefault, validRunners, defaultRunner, jsonResponse, readBody } = deps; let managerPromise = null; let wssPromise = null; let closePromise = null; function recordTerminalEvent(projectPath, eventKind, payload) { recordEvidenceEvent({ producer: "dashboard-session-trace", actor: "server", eventType: eventKind, projectRoot: projectPath, payload, }); } /** Record terminal input trace events without forcing callers to know whether tracing is enabled. */ function recordTerminalTraceInput(event) { recordTerminalEvent(event.projectPath, event.eventKind, { session_id: event.sessionId, runner: event.runner, cwd: event.cwd, target_path: event.targetPath, bytes: event.bytes, input: redactEvidenceText("terminal input", event.input), }); } async function createTerminalSession(manager, decoded) { const { prompt, projectPath, targetPath, runner } = decoded; const result = await manager.create(prompt, projectPath || absDefault, runner, { targetPath: targetPath || projectPath || absDefault }); const session = manager.get(result.id); return { result, session, resolvedTargetPath: session?.targetPath || targetPath || projectPath || absDefault, }; } function recordTerminalLaunchEvents(decoded, sessionId, session, resolvedTargetPath) { const { prompt, projectPath, runner } = decoded; recordTerminalEvent(resolvedTargetPath, "terminal.create", { session_id: sessionId, runner, cwd: session?.cwd || projectPath || absDefault, target_path: resolvedTargetPath, }); if (prompt.trim().length > 0) { recordTerminalEvent(resolvedTargetPath, "prompt.launch", { session_id: sessionId, runner, prompt: redactEvidenceText("terminal launch prompt", prompt), }); } } /** Lazy-load the terminal manager the first time a terminal route is used. */ async function getManager() { if (!managerPromise) { managerPromise = import("./terminal.js").then(({ TerminalManager: TM }) => new TM(deps.idleTimeoutMinutes, recordTerminalTraceInput)); } return managerPromise; } /** Lazy-load the WebSocket server that bridges browser terminals to PTY sessions. */ async function getWSS() { if (!wssPromise) { wssPromise = import("ws").then(({ WebSocketServer: WSS }) => new WSS({ noServer: true })); } return wssPromise; } /** Map terminal-launch failures to the client-facing HTTP status codes we expose. */ function terminalCreateStatus(message) { return message.includes("Local path validation failed") || message.includes("Maximum") || message.includes("not found") || message.includes("not available") || message.includes("not a directory") || message.includes("does not exist") || message.includes("too large") ? 400 : 500; } /** Emit the terminal-unavailable startup warning once; swallows health probe failures after logging. */ function logStartupNotice() { void getManager() .then((manager) => manager.health()) .then((health) => { if (!health.nodePtyAvailable) { console.log("Note: Terminal feature unavailable (node-pty failed to load)"); console.log(" Fix: npm rebuild node-pty (requires C++ build tools)"); console.log(" pnpm: pnpm approve-builds"); console.log(" See: https://github.com/blundergoat/goat-flow#troubleshooting"); } }) .catch(() => { console.log("Note: Terminal feature unavailable (node-pty failed to load)"); console.log(" Fix: npm rebuild node-pty (requires C++ build tools)"); console.log(" pnpm: pnpm approve-builds"); console.log(" See: https://github.com/blundergoat/goat-flow#troubleshooting"); }); } /** Start a terminal session for the requested runner and workspace. */ async function handleTerminalCreateRequest(req, url, res) { if (url.pathname !== "/api/terminal/create" || req.method !== "POST") return false; try { const manager = await getManager(); const decoded = decodeTerminalCreateBody(await readBody(req), { validRunners, defaultRunner, }); if (!decoded.ok) { jsonResponse(res, 400, { error: decoded.error, path: decoded.path }); return true; } const { result, session, resolvedTargetPath } = await createTerminalSession(manager, decoded.value); recordTerminalLaunchEvents(decoded.value, result.id, session, resolvedTargetPath); jsonResponse(res, 200, result); } catch (err) { const message = err instanceof Error ? err.message : String(err); jsonResponse(res, terminalCreateStatus(message), { error: message }); } return true; } /** Return the set of currently live terminal sessions. */ async function handleTerminalListRequest(req, url, res) { if (url.pathname !== "/api/terminal/list" || req.method !== "GET") return false; try { const manager = await getManager(); jsonResponse(res, 200, manager.list()); } catch (err) { jsonResponse(res, 500, { error: err instanceof Error ? err.message : String(err), }); } return true; } /** Kill one terminal session and report whether it existed. */ async function handleTerminalDeleteRequest(req, url, res) { if (!url.pathname.startsWith("/api/terminal/") || req.method !== "DELETE") return false; const id = url.pathname.slice("/api/terminal/".length); try { const manager = await getManager(); const session = manager.get(id); const killed = manager.kill(id); if (killed && session) { recordTerminalEvent(session.targetPath || session.projectPath, "terminal.delete", { session_id: id, runner: session.runner, status: session.status, }); jsonResponse(res, 200, { ok: true }); } else if (killed) { jsonResponse(res, 200, { ok: true }); } else { jsonResponse(res, 404, { error: "Session not found" }); } } catch (err) { jsonResponse(res, 500, { error: err instanceof Error ? err.message : String(err), }); } return true; } /** Read the raw upload body up to TERMINAL_UPLOAD_MAX_BODY_BYTES. * Separate from the dashboard's 64KB readBody so other endpoints stay * capped tightly while uploads can carry several MiB of base64 payload. */ function readUploadBody(req) { return new Promise((resolveBody, rejectBody) => { const chunks = []; let size = 0; let tooLarge = false; req.on("data", (chunk) => { size += chunk.length; if (tooLarge) { return; } if (size > TERMINAL_UPLOAD_MAX_BODY_BYTES) { tooLarge = true; chunks.length = 0; return; } chunks.push(chunk); }); req.on("end", () => { if (tooLarge) { rejectBody(new Error("Upload body too large")); return; } resolveBody(Buffer.concat(chunks).toString("utf-8")); }); req.on("error", rejectBody); }); } /** Accept dragged image files for the active terminal session. */ // eslint-disable-next-line complexity -- intentional ingress validation; each branch maps to one rejection class. async function handleTerminalUploadRequest(req, url, res) { const match = url.pathname.match(/^\/api\/terminal\/([^/]+)\/upload-image$/u); if (!match || req.method !== "POST") return false; const sessionId = match[1] ?? ""; if (!/^[a-zA-Z0-9_-]+$/u.test(sessionId)) { jsonResponse(res, 400, { error: "Invalid session id" }); return true; } let body; try { body = await readUploadBody(req); } catch (err) { const message = err instanceof Error ? err.message : String(err); jsonResponse(res, 413, { error: message }); return true; } const decoded = decodeTerminalUploadBody(body, { maxFiles: TERMINAL_UPLOAD_MAX_FILES, }); if (!decoded.ok) { jsonResponse(res, 400, { error: decoded.error, path: decoded.path }); return true; } try { const manager = await getManager(); const session = manager.get(sessionId); if (!session) { jsonResponse(res, 404, { error: "Session not found" }); return true; } if (session.status !== "active") { jsonResponse(res, 409, { error: `Session is ${session.status}; uploads require an active session`, }); return true; } if (!session.targetPath) { jsonResponse(res, 409, { error: "Session has no target path; cannot resolve upload directory", }); return true; } let uploadDir; try { uploadDir = uploadDirForSession(session.targetPath, sessionId); } catch (err) { const message = err instanceof Error ? err.message : String(err); jsonResponse(res, 400, { error: message }); return true; } const result = persistUploads(uploadDir, decoded.value.files); const note = buildAttachmentNote(result.accepted); recordTerminalEvent(session.targetPath, "terminal.upload", { session_id: sessionId, runner: session.runner, accepted_count: result.accepted.length, rejected_count: result.rejected.length, bytes: result.accepted.reduce((total, file) => total + file.bytes, 0), }); jsonResponse(res, 200, { accepted: result.accepted.map((file) => ({ originalName: file.originalName, savedName: file.savedName, savedRelPath: file.savedRelPath, bytes: file.bytes, })), rejected: result.rejected, note, }); } catch (err) { const message = err instanceof Error ? err.message : String(err); jsonResponse(res, 500, { error: message }); } return true; } /** Return terminal-backend health details for dashboard diagnostics. */ async function handleHealthRequest(req, url, res) { if (url.pathname !== "/api/health" || req.method !== "GET") return false; try { const manager = await getManager(); jsonResponse(res, 200, await manager.health()); } catch (err) { jsonResponse(res, 500, { error: err instanceof Error ? err.message : String(err), }); } return true; } /** Return enriched terminal session info with age and idle duration. */ async function handleTerminalSessionsRequest(req, url, res) { if (url.pathname !== "/api/terminal/sessions" || req.method !== "GET") return false; try { const manager = await getManager(); const sessions = manager.list(); const now = Date.now(); const enriched = sessions.map((session) => ({ ...session, age: Math.floor((now - new Date(session.createdAt).getTime()) / 1000), idleDuration: Math.floor((now - session.lastInputAt) / 1000), })); jsonResponse(res, 200, { sessions: enriched, maxSessions: MAX_SESSIONS, activeCount: sessions.length, }); } catch (err) { jsonResponse(res, 500, { error: err instanceof Error ? err.message : String(err), }); } return true; } /** Handle terminal WebSocket upgrades and reject bad origins. */ function handleTerminalUpgrade(req, socket, head, server) { const url = new URL(req.url ?? "/", "http://127.0.0.1"); if (!url.pathname.startsWith("/ws/terminal/")) return false; const origin = req.headers.origin; const addr = server.address(); if (origin && addr && typeof addr !== "string") { const expected = `http://127.0.0.1:${addr.port}`; if (origin !== expected && origin !== `http://localhost:${addr.port}`) { socket.destroy(); return true; } } const sessionId = url.pathname.slice("/ws/terminal/".length); void (async () => { try { const wss = await getWSS(); const manager = await getManager(); wss.handleUpgrade(req, socket, head, (ws) => { manager.attachWebSocket(sessionId, ws); }); } catch { socket.destroy(); } })(); return true; } /** Close terminal resources with one shared promise because shutdown can be triggered from tests and signals. */ async function close() { if (closePromise) return closePromise; closePromise = (async () => { if (managerPromise) { const manager = await managerPromise; manager.shutdown(); } if (wssPromise) { const wss = await wssPromise; await new Promise((resolve) => { wss.close(() => { resolve(); }); }); } })(); return closePromise; } return { handleTerminalCreateRequest, handleTerminalListRequest, handleTerminalDeleteRequest, handleTerminalUploadRequest, handleHealthRequest, handleTerminalSessionsRequest, handleTerminalUpgrade, logStartupNotice, close, }; } //# sourceMappingURL=dashboard-terminal.js.map