UNPKG

@openguardrails/moltguard

Version:

AI agent security plugin for OpenClaw: prompt injection detection, PII sanitization, and monitoring dashboard

361 lines 12 kB
/** * Dashboard Launcher for MoltGuard * * Starts the local Dashboard in-process for monitoring agent activity. * All components (MoltGuard, Gateway, Dashboard) run in the same process. */ import crypto from "node:crypto"; import path from "node:path"; import fs from "node:fs"; import os from "node:os"; import { fileURLToPath } from "node:url"; import { setDashboardPort } from "./agent/gateway-manager.js"; import { openclawHome } from "./agent/env.js"; import { loadJsonSync } from "./agent/fs-utils.js"; // Dashboard state let dashboardRunning = false; let currentToken = null; let currentLocalUrl = null; let dashboardCloseFn = null; let startupInProgress = false; let startupPromise = null; export const DASHBOARD_PORT = 53667; const TOKEN_FILE = path.join(os.homedir(), ".openclaw", "credentials", "moltguard", "dashboard-session-token"); /** * Get the package root directory */ function getPackageRoot() { if (typeof import.meta !== 'undefined' && import.meta.url) { const currentFile = fileURLToPath(import.meta.url); const currentDir = path.dirname(currentFile); if (currentDir.endsWith('dist')) { return path.dirname(currentDir); } return currentDir; } if (__dirname.endsWith('dist')) { return path.dirname(__dirname); } return __dirname; } /** * Get the plugin's data directory */ export function getPluginDataDir() { return path.join(openclawHome, "extensions", "moltguard", "data"); } /** * Check if a port is responding to HTTP health check */ async function isPortResponding(port) { try { const res = await fetch(`http://localhost:${port}/health`, { signal: AbortSignal.timeout(1000), }); return res.ok; } catch { return false; } } /** * Check if a port is in use (TCP level check) */ async function isPortInUse(port) { const net = await import("node:net"); return new Promise((resolve) => { const server = net.createServer(); server.once("error", (err) => { if (err.code === "EADDRINUSE") { resolve(true); } else { resolve(false); } }); server.once("listening", () => { server.close(); resolve(false); }); server.listen(port, "127.0.0.1"); }); } /** * Wait for a port to become available */ async function waitForPortAvailable(port, timeoutMs = 10000) { const startTime = Date.now(); while (Date.now() - startTime < timeoutMs) { const inUse = await isPortInUse(port); if (!inUse) { return true; } // Wait 500ms before checking again await new Promise(r => setTimeout(r, 500)); } return false; } /** * Read saved token from file */ function readSavedToken() { if (fs.existsSync(TOKEN_FILE)) { try { const data = loadJsonSync(TOKEN_FILE); if (data.token && typeof data.token === "string") { return data.token; } } catch { // Ignore } } return null; } /** * Save token to file */ function saveToken(token, port) { try { const tokenDir = path.dirname(TOKEN_FILE); fs.mkdirSync(tokenDir, { recursive: true }); fs.writeFileSync(TOKEN_FILE, JSON.stringify({ token, port })); } catch { // Ignore } } /** * Find the Dashboard directory */ function findDashboardDir() { const packageRoot = getPackageRoot(); const candidates = [ // 1. Bundled in moltguard package (production) { dir: path.join(packageRoot, "dashboard-dist"), bundled: true }, // 2. Relative to moltguard (monorepo development) { dir: path.join(packageRoot, "..", "dashboard"), bundled: false }, ]; for (const candidate of candidates) { const checkFile = candidate.bundled ? path.join(candidate.dir, "api", "package.json") : path.join(candidate.dir, "package.json"); if (fs.existsSync(checkFile)) { return candidate; } } return null; } /** * Start the local Dashboard (in-process) */ export async function startLocalDashboard(options) { // If already running, return existing URL if (dashboardRunning && currentToken && currentLocalUrl) { return { localUrl: currentLocalUrl, token: currentToken, }; } // If startup is already in progress, wait for it if (startupInProgress && startupPromise) { return startupPromise; } // Check if Dashboard is already running (e.g., dev mode with pnpm dev, or previous instance) const isAlreadyRunning = await isPortResponding(DASHBOARD_PORT); if (isAlreadyRunning) { const existingToken = readSavedToken(); if (existingToken) { currentToken = existingToken; currentLocalUrl = `http://localhost:${DASHBOARD_PORT}/dashboard/?token=${existingToken}`; dashboardRunning = true; return { localUrl: currentLocalUrl, token: existingToken, }; } // Port is responding but no token - another process is using it // Don't try to start, just throw throw new Error(`Port ${DASHBOARD_PORT} is already in use by another process`); } // Check if port is in use but not responding (e.g., server shutting down) // Wait for it to become available const portInUse = await isPortInUse(DASHBOARD_PORT); if (portInUse) { // Port is held but not responding - likely shutting down, wait for it const portAvailable = await waitForPortAvailable(DASHBOARD_PORT, 15000); if (!portAvailable) { throw new Error(`Port ${DASHBOARD_PORT} is still in use after waiting. Please try again.`); } } // Mark startup in progress and create promise startupInProgress = true; const doStartup = async () => { try { // Find dashboard directory const dashboard = findDashboardDir(); if (!dashboard) { throw new Error("Dashboard directory not found."); } // Generate token const token = crypto.randomBytes(16).toString("hex"); currentToken = token; // Determine data and web directories const dataDir = options.dataDir || getPluginDataDir(); fs.mkdirSync(dataDir, { recursive: true }); // CRITICAL: Set environment variables BEFORE importing dashboard modules // This ensures the database client uses the correct path // Uses setEnv() helper to keep env access centralised (avoids scanner false-positive) const { setEnv } = await import("./agent/env.js"); setEnv("DASHBOARD_DATA_DIR", dataDir); setEnv("LOCAL_MODE", "true"); if (options.coreUrl) { setEnv("OG_CORE_URL", options.coreUrl); } // Save token before starting saveToken(token, DASHBOARD_PORT); // Start Dashboard in-process with retry on EADDRINUSE const startWithRetry = async (startFn, config, maxRetries = 3) => { for (let attempt = 1; attempt <= maxRetries; attempt++) { try { return await startFn(config); } catch (err) { if (err?.code === "EADDRINUSE" || err?.message?.includes("EADDRINUSE")) { if (attempt < maxRetries) { // Wait and retry await new Promise(r => setTimeout(r, 1000 * attempt)); continue; } } throw err; } } throw new Error("Failed to start dashboard after retries"); }; let result; if (dashboard.bundled) { // Production: import from bundled dist const apiIndexPath = path.join(dashboard.dir, "api", "index.js"); const webOutDir = path.join(dashboard.dir, "web"); const { startDashboard } = await import(apiIndexPath); result = await startWithRetry(startDashboard, { port: DASHBOARD_PORT, localMode: true, localToken: token, webOutDir, dataDir, coreUrl: options.coreUrl, }); } else { // Development: import from source (requires build) const apiIndexPath = path.join(dashboard.dir, "apps", "api", "dist", "index.js"); const webOutDir = path.join(dashboard.dir, "apps", "web", "out"); if (!fs.existsSync(apiIndexPath)) { throw new DevModeError(dashboard.dir); } const { startDashboard } = await import(apiIndexPath); result = await startWithRetry(startDashboard, { port: DASHBOARD_PORT, localMode: true, localToken: token, webOutDir, dataDir, coreUrl: options.coreUrl, }); } // Save close function for cleanup dashboardCloseFn = result.close; dashboardRunning = true; currentLocalUrl = `http://localhost:${DASHBOARD_PORT}/dashboard/?token=${token}`; // Notify gateway manager of dashboard port for activity reporting setDashboardPort(DASHBOARD_PORT); return { localUrl: currentLocalUrl, token, }; } finally { // Always reset startup state when done (success or failure) startupInProgress = false; startupPromise = null; } }; // Assign the promise so concurrent calls can wait on it startupPromise = doStartup(); return startupPromise; } /** * Check if Dashboard is running */ export function isDashboardRunning() { return dashboardRunning; } /** * Get current Dashboard URL */ export function getDashboardUrl() { return currentLocalUrl; } /** * Get current token */ export function getDashboardToken() { return currentToken; } /** * Error for development mode (when build is required) */ export class DevModeError extends Error { dashboardDir; constructor(dashboardDir) { super("Development mode requires dashboard build"); this.dashboardDir = dashboardDir; this.name = "DevModeError"; } getInstructions() { return [ "**Dashboard Not Built**", "", "Build the Dashboard first:", "", "```bash", `cd ${this.dashboardDir}`, `pnpm build`, "```", "", "Then run `/og_dashboard` again.", ].join("\n"); } } /** * Stop Dashboard server */ export async function stopLocalDashboard() { // Wait for any in-progress startup to complete first if (startupInProgress && startupPromise) { try { await startupPromise; } catch { // Ignore startup errors - we're stopping anyway } } if (dashboardCloseFn) { try { await dashboardCloseFn(); } catch { // Ignore errors during shutdown } dashboardCloseFn = null; } // Reset all state dashboardRunning = false; currentLocalUrl = null; currentToken = null; startupInProgress = false; startupPromise = null; } //# sourceMappingURL=dashboard-launcher.js.map