@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
JavaScript
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