@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.
405 lines • 18.3 kB
JavaScript
/**
* HTTP server for the local goat-flow dashboard.
* It serves the frontend shell, exposes audit, quality, setup, and terminal endpoints.
*/
import { createServer, } from "node:http";
import { randomBytes, timingSafeEqual } from "node:crypto";
import { watch } from "node:fs";
import { dirname, resolve } from "node:path";
import { getPackageVersion, getTemplatePath } from "../paths.js";
import { getKnownAgentIds } from "../agents/registry.js";
import { assembleDashboardHtml, loadDashboardPresets, } from "./dashboard-assets.js";
import { createDashboardRouteHandlers } from "./dashboard-routes.js";
import { createDashboardTerminalHandlers } from "./dashboard-terminal.js";
import { loadConfig } from "../config/reader.js";
const KNOWN_AGENT_IDS = getKnownAgentIds();
/** Recognized runner identifiers for terminal session creation. */
const VALID_RUNNERS = new Set(KNOWN_AGENT_IDS);
const DEFAULT_RUNNER = KNOWN_AGENT_IDS[0] ?? "claude";
/** Maximum request body size accepted by POST endpoints */
const MAX_BODY_BYTES = 64 * 1024; // 64 KB
/** Current goat-flow package version for dashboard UI */
const PACKAGE_VERSION = getPackageVersion();
const DASHBOARD_TOKEN_HEADER = "x-goat-flow-dashboard-token";
/** Read the request body as a string, capped at the configured byte limit. */
function readBody(req, options = {}) {
return new Promise((resolve, reject) => {
const maxBytes = options.maxBytes ?? MAX_BODY_BYTES;
const tooLargeMessage = options.tooLargeMessage ?? "Request body too large";
const chunks = [];
let size = 0;
let hasRejectedBody = false;
req.on("data", (chunk) => {
if (hasRejectedBody)
return;
size += chunk.length;
if (size > maxBytes) {
hasRejectedBody = true;
chunks.length = 0;
req.resume();
reject(new Error(tooLargeMessage));
return;
}
chunks.push(chunk);
});
req.on("end", () => {
if (hasRejectedBody)
return;
resolve(Buffer.concat(chunks).toString("utf-8"));
});
req.on("error", (err) => {
if (!hasRejectedBody)
reject(err);
});
});
}
/** Send a JSON response. */
function jsonResponse(res, status, body) {
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify(body));
}
/**
* Side-effectful API route registry.
*
* Every POST/DELETE handler that mutates local state, executes a command, or
* could be CSRF-bait MUST appear in this set. The Origin/CSRF check fires via
* `isSideEffectfulApiRoute → SIDE_EFFECTFUL_EXACT_API_ROUTES.has(routeKey)`.
*
* Convention: register the exact route key `"<METHOD> <path>"` here whenever
* you add a side-effectful endpoint.
*/
const SIDE_EFFECTFUL_EXACT_API_ROUTES = new Set([
"POST /api/projects/list",
"POST /api/plans",
"POST /api/tasks",
"POST /api/index/regenerate",
"POST /api/quality/evaluate",
"POST /api/quality/analyse",
"POST /api/terminal/create",
]);
const HOOK_TOGGLE_API_ROUTE = /^\/api\/hooks\/[^/]+\/toggle$/u;
const TERMINAL_UPLOAD_IMAGE_API_ROUTE = /^\/api\/terminal\/[^/]+\/upload-image$/u;
/** Read the dashboard authorization token supplied by a browser/API client. */
function readDashboardToken(req, url) {
const header = req.headers[DASHBOARD_TOKEN_HEADER];
if (typeof header === "string" && header.length > 0)
return header;
if (Array.isArray(header) && typeof header[0] === "string")
return header[0];
return url.searchParams.get("token");
}
/** Compare dashboard tokens without leaking length-matched timing. */
function tokenMatches(expected, actual) {
if (!actual)
return false;
const expectedBuffer = Buffer.from(expected);
const actualBuffer = Buffer.from(actual);
if (expectedBuffer.length !== actualBuffer.length)
return false;
return timingSafeEqual(expectedBuffer, actualBuffer);
}
/**
* Start the local dashboard server and expose its API endpoints.
*
* @param options - selected project path plus optional dev-mode/dashboard configuration
* @returns running dashboard handle with URL, token, and close method
*/
export function serveDashboard(options) {
return new Promise((resolveStart) => {
const shellPath = getTemplatePath("dist/dashboard/index.html");
const dashboardPresets = loadDashboardPresets();
const devMode = options.isDevMode === true;
const dashboardToken = randomBytes(32).toString("base64url");
// In dev mode, re-read on every request. In prod, cache once.
let cachedTemplate = devMode
? null
: assembleDashboardHtml(shellPath);
/** Read the current dashboard HTML shell, using the cache when possible. */
function getTemplate() {
if (devMode)
return assembleDashboardHtml(shellPath);
if (!cachedTemplate)
cachedTemplate = assembleDashboardHtml(shellPath);
return cachedTemplate;
}
const absDefault = resolve(options.projectPath);
const loadedConfig = loadConfig(absDefault);
const idleTimeoutMinutes = loadedConfig.config.terminal.idleTimeoutMinutes;
const { handleHtmlRequest, handleAssetRequest, handleAuditRequest, handleSetupDetectRequest, handleSetupRequest, handleQualityRequest, handleQualityHistoryRequest, handleSkillQualityRequest, handleSkillQualityInventoryRequest, handleQualityEvaluateRequest, handleBrowseRequest, handleTasksRequest, handleHooksRequest, handleAgentDetectRequest, handleProjectsListRequest, handleProjectsStatusRequest, handleIndexRegenerateRequest, } = createDashboardRouteHandlers({
absDefault,
devMode,
getTemplate,
packageVersion: PACKAGE_VERSION,
dashboardToken,
dashboardPresets,
jsonResponse,
readBody,
});
const { handleTerminalCreateRequest, handleTerminalListRequest, handleTerminalDeleteRequest, handleTerminalUploadRequest, handleHealthRequest, handleTerminalSessionsRequest, handleTerminalUpgrade, logStartupNotice, close: closeTerminalResources, } = createDashboardTerminalHandlers({
absDefault,
validRunners: VALID_RUNNERS,
defaultRunner: DEFAULT_RUNNER,
jsonResponse,
readBody,
idleTimeoutMinutes,
});
// Live reload state (dev mode only)
const liveReloadClients = new Set();
let liveReloadWssPromise = null;
/** Lazy-load the live-reload WebSocket server for dev-mode browser refreshes. */
async function getLiveReloadWSS() {
if (!liveReloadWssPromise) {
liveReloadWssPromise = import("ws").then(({ WebSocketServer: WSS }) => new WSS({ noServer: true }));
}
return liveReloadWssPromise;
}
/** DNS rebinding protection: reject API requests with unexpected Host header. */
function rejectBadHostOrOrigin(req, url, res) {
if (!url.pathname.startsWith("/api/"))
return false;
const host = req.headers.host;
const addr = server.address();
if (addr && typeof addr !== "string") {
const allowed = [`127.0.0.1:${addr.port}`, `localhost:${addr.port}`];
if (!host || !allowed.includes(host)) {
console.warn(`[dashboard] Blocked ${req.method} ${url.pathname} - Host: ${host || "(none)"}`);
res.writeHead(403);
res.end("Forbidden");
return true;
}
}
return false;
}
/** Return whether a request targets a route that can mutate local state. */
function isSideEffectfulApiRoute(req, url) {
const method = req.method ?? "GET";
const routeKey = `${method} ${url.pathname}`;
if (SIDE_EFFECTFUL_EXACT_API_ROUTES.has(routeKey))
return true;
if (method === "POST" && HOOK_TOGGLE_API_ROUTE.test(url.pathname)) {
return true;
}
if (method === "POST" &&
TERMINAL_UPLOAD_IMAGE_API_ROUTE.test(url.pathname))
return true;
return method === "DELETE" && url.pathname.startsWith("/api/terminal/");
}
/** Check browser Origin headers for side-effectful dashboard routes. */
function originAllowed(req) {
const origin = req.headers.origin;
if (!origin)
return true;
const addr = server.address();
if (!addr || typeof addr === "string")
return false;
return (origin === `http://127.0.0.1:${addr.port}` ||
origin === `http://localhost:${addr.port}`);
}
/** Enforce process-local dashboard authorization for all API requests. */
function rejectUnauthorizedApi(req, url, res) {
if (!url.pathname.startsWith("/api/"))
return false;
if (!tokenMatches(dashboardToken, readDashboardToken(req, url))) {
jsonResponse(res, 403, { error: "Forbidden" });
return true;
}
if (isSideEffectfulApiRoute(req, url) && !originAllowed(req)) {
jsonResponse(res, 403, { error: "Forbidden" });
return true;
}
return false;
}
/** Enforce Host + token + Origin checks for terminal WebSocket upgrades. */
function rejectUnauthorizedTerminalUpgrade(req, url) {
if (!url.pathname.startsWith("/ws/terminal/"))
return false;
const host = req.headers.host;
const addr = server.address();
if (addr && typeof addr !== "string") {
const allowed = [`127.0.0.1:${addr.port}`, `localhost:${addr.port}`];
if (!host || !allowed.includes(host))
return true;
}
if (!tokenMatches(dashboardToken, readDashboardToken(req, url))) {
return true;
}
return !originAllowed(req);
}
/** Dispatch one HTTP request across the dashboard routes in priority order. */
async function handleRequest(req, res) {
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "127.0.0.1"}`);
if (rejectBadHostOrOrigin(req, url, res))
return;
if (rejectUnauthorizedApi(req, url, res))
return;
// Log API requests in dev mode
if (devMode && url.pathname.startsWith("/api/")) {
console.log(`[dashboard] ${req.method} ${url.pathname}${url.search}`);
}
const routeHandlers = [
() => Promise.resolve(handleHtmlRequest(url, res)),
() => Promise.resolve(handleAssetRequest(req, url, res)),
() => Promise.resolve(handleAuditRequest(url, res)),
() => Promise.resolve(handleSetupDetectRequest(url, res)),
() => handleSetupRequest(url, res),
() => Promise.resolve(handleQualityRequest(url, res)),
() => handleQualityHistoryRequest(url, res),
() => Promise.resolve(handleSkillQualityRequest(url, res)),
() => Promise.resolve(handleSkillQualityInventoryRequest(url, res)),
() => handleQualityEvaluateRequest(req, url, res),
() => Promise.resolve(handleBrowseRequest(url, res)),
() => handleTasksRequest(req, url, res),
() => handleHooksRequest(req, url, res),
() => Promise.resolve(handleAgentDetectRequest(url, res)),
() => handleProjectsListRequest(req, url, res),
() => Promise.resolve(handleProjectsStatusRequest(url, res)),
() => handleIndexRegenerateRequest(req, url, res),
() => handleTerminalCreateRequest(req, url, res),
() => handleTerminalListRequest(req, url, res),
() => handleTerminalSessionsRequest(req, url, res),
() => handleTerminalUploadRequest(req, url, res),
() => handleTerminalDeleteRequest(req, url, res),
() => handleHealthRequest(req, url, res),
];
for (const route of routeHandlers) {
if (await route())
return;
}
res.writeHead(404);
res.end("Not found");
}
const server = createServer((req, res) => {
handleRequest(req, res).catch((err) => {
const msg = err instanceof Error ? err.message : "Internal error";
const stack = err instanceof Error ? err.stack : "";
console.error(`[dashboard] ${req.method} ${req.url} → 500: ${msg}`);
if (stack)
console.error(stack);
if (!res.headersSent) {
jsonResponse(res, 500, { error: msg });
}
});
});
// Dev mode: watch dashboard files and notify connected browsers
let closeDevWatcher = null;
if (devMode) {
const dashDir = dirname(shellPath);
/** Notify live-reload clients that dashboard assets changed. */
const notifyReload = () => {
for (const client of liveReloadClients) {
try {
client.send("reload");
}
catch {
/* ignore */
}
}
};
let debounce = null;
const watcher = watch(dashDir, { recursive: true }, () => {
if (debounce)
clearTimeout(debounce);
debounce = setTimeout(notifyReload, 100);
});
/** Close the dev-mode dashboard file watcher and release its process hook. */
const closeWatcher = () => {
watcher.close();
};
process.on("exit", closeWatcher);
/** Release the dev watcher and its exit hook. */
closeDevWatcher = () => {
process.off("exit", closeWatcher);
closeWatcher();
};
console.log("Dev mode: watching dist/dashboard/ for changes");
}
// WebSocket upgrade for terminal and live-reload sessions
server.on("upgrade", (req, socket, head) => {
const url = new URL(req.url ?? "/", `http://127.0.0.1`);
// Live reload WebSocket (dev mode)
if (url.pathname === "/ws/livereload" && devMode) {
void (async () => {
try {
const wss = await getLiveReloadWSS();
wss.handleUpgrade(req, socket, head, (ws) => {
liveReloadClients.add(ws);
ws.on("close", () => {
liveReloadClients.delete(ws);
});
});
}
catch {
socket.destroy();
}
})();
return;
}
if (rejectUnauthorizedTerminalUpgrade(req, url)) {
socket.destroy();
return;
}
if (handleTerminalUpgrade(req, socket, head, server)) {
return;
}
if (!url.pathname.startsWith("/ws/terminal/")) {
socket.destroy();
return;
}
});
// Shutdown joins HTTP, WebSocket, watcher, and terminal cleanup so callers
// can await one idempotent close even when signals and tests race.
let closePromise = null;
/** Close the dashboard server, watchers, and terminal sessions through one promise because signals can race. */
async function closeServer() {
if (closePromise)
return closePromise;
closePromise = (async () => {
process.off("SIGTERM", doShutdown);
process.off("SIGINT", doShutdown);
closeDevWatcher?.();
if (liveReloadWssPromise) {
const liveReloadWss = await liveReloadWssPromise;
await new Promise((resolve) => {
liveReloadWss.close(() => {
resolve();
});
});
}
await closeTerminalResources();
await new Promise((resolveClose, rejectClose) => {
server.close((err) => {
if (err)
rejectClose(err);
else
resolveClose();
});
server.closeIdleConnections();
server.closeAllConnections();
});
})();
return closePromise;
}
/** Shut down the dashboard server's live terminal state before exiting the process. */
const doShutdown = () => {
void closeServer().finally(() => {
process.exit(0);
});
};
process.on("SIGTERM", doShutdown);
process.on("SIGINT", doShutdown);
server.listen(0, "127.0.0.1", () => {
const addr = server.address();
if (!addr || typeof addr === "string")
return;
const url = `http://127.0.0.1:${addr.port}/?token=${encodeURIComponent(dashboardToken)}`;
console.log(`Dashboard: ${url}`);
logStartupNotice();
resolveStart({
port: addr.port,
url,
close: closeServer,
});
});
}); // end Promise
}
//# sourceMappingURL=dashboard.js.map