import * as fs from "node:fs"; import { createRequire } from "node:module"; import * as path from "node:path"; import { defineEventHandler } from "h3"; import { getH3App, markDefaultPluginProvided, } from "../server/framework-request-handler.js"; /** * Nitro Plugin — Agent Terminal * * Starts a PTY WebSocket server alongside the app so the * component can connect to a real CLI. Mounts a discovery endpoint at * /_agent-native/agent-terminal-info for the client component. * * Skips activation when running inside a frame (FRAME_PORT is set). */ import { isNodeRuntime } from "../shared/runtime.js"; // ─── module-load self-heal: chmod node-pty's spawn-helper ───────────────── // pnpm can extract node-pty's prebuilds tarball without running the // post-install that chmods spawn-helper, leaving it as `-rw-r--r--` instead // of `-rwxr-xr-x`. Every PTY spawn then fails with `posix_spawnp failed`. // Run the fix synchronously at module load (static imports, sync fs calls) // so by the time ANY plugin worker starts spawning PTYs, the helper is // already executable. (function fixSpawnHelperPermissions() { if (!isNodeRuntime()) return; try { const req = createRequire(import.meta.url); const ptyPkg = req.resolve("node-pty/package.json"); const ptyDir = path.dirname(ptyPkg); const helper = path.join( ptyDir, "prebuilds", `${process.platform}-${process.arch}`, "spawn-helper", ); if (fs.existsSync(helper)) { const mode = fs.statSync(helper).mode; if (!(mode & 0o100)) { fs.chmodSync(helper, 0o755); console.log( `[terminal] Fixed non-executable node-pty spawn-helper at ${helper}`, ); } } } catch (err) { // node-pty not installed → stay silent here; createTerminalPlugin emits // the "install node-pty" message when the PTY server actually fails to // start. Logging twice for the same root cause just adds noise. const code = (err as NodeJS.ErrnoException)?.code; if (code === "MODULE_NOT_FOUND" || code === "ERR_MODULE_NOT_FOUND") return; console.warn( "[terminal] Could not verify node-pty spawn-helper permissions:", (err as Error).message, ); } })(); export interface TerminalPluginOptions { /** CLI command to run. Defaults to AGENT_CLI_COMMAND env or 'builder' */ command?: string; /** Port for the WebSocket server. Defaults to AGENT_TERMINAL_PORT env or auto-assigned */ port?: number; /** Enable in production. Defaults to AGENT_TERMINAL_ENABLED env or false in prod */ enabledInProduction?: boolean; /** Auth check for WebSocket connections in production */ authCheck?: (req: any) => boolean | Promise; } // Vite's dev server can initialize Nitro plugins more than once during boot. // Module-scope flags ensure the "node-pty not installed" / "Disabled in // production" / "Frame detected" notices each fire at most once per process. let _ptyMissingLogged = false; let _disabledLogged = false; let _frameDetectedLogged = false; export function createTerminalPlugin(options: TerminalPluginOptions = {}) { return async (nitroApp: any) => { markDefaultPluginProvided(nitroApp, "terminal"); // Terminal requires Node.js (PTY, child_process) — skip on edge runtimes if (!isNodeRuntime()) return; // Always mount /_agent-native/available-clis so the client doesn't get 404s getH3App(nitroApp).use( "/_agent-native/available-clis", defineEventHandler(async () => { try { const { CLI_REGISTRY, commandExists } = await import("./cli-registry.js"); const results = []; for (const [cmd, entry] of Object.entries(CLI_REGISTRY)) { results.push({ command: cmd, label: entry.label, available: await commandExists(cmd), }); } return results; } catch { return []; } }), ); // Skip if running inside a frame if (process.env.FRAME_PORT) { if (!_frameDetectedLogged) { console.log("[terminal] Frame detected, skipping embedded terminal"); _frameDetectedLogged = true; } return; } const isProd = process.env.NODE_ENV === "production"; const enabled = options.enabledInProduction ?? (process.env.AGENT_TERMINAL_ENABLED === "true" || !isProd); if (!enabled) { if (!_disabledLogged) { console.log( "[terminal] Disabled in production (set AGENT_TERMINAL_ENABLED=true to enable)", ); _disabledLogged = true; } // Mount a disabled info endpoint getH3App(nitroApp).use( "/_agent-native/agent-terminal-info", defineEventHandler(() => ({ available: false })), ); return; } // Require authCheck in production to prevent unauthenticated shell access if (isProd && !options.authCheck) { console.error( "[terminal] FATAL: authCheck is required when enabling the terminal in production. " + "Pass an authCheck function to createTerminalPlugin().", ); getH3App(nitroApp).use( "/_agent-native/agent-terminal-info", defineEventHandler(() => ({ available: false, error: "Terminal requires authCheck in production", })), ); return; } // Skip if a PTY server is already running (prevents leak on HMR rebuild) if (process.env.__AGENT_TERMINAL_RUNNING === "true") { const existingPort = process.env.AGENT_TERMINAL_PORT; console.log( `[terminal] PTY server already running on port ${existingPort}, skipping`, ); getH3App(nitroApp).use( "/_agent-native/agent-terminal-info", defineEventHandler(() => ({ available: true, wsPort: existingPort ? parseInt(existingPort, 10) : 0, command: options.command || process.env.AGENT_CLI_COMMAND || "builder", })), ); return; } const command = options.command || process.env.AGENT_CLI_COMMAND || "builder"; const port = options.port ?? (process.env.AGENT_TERMINAL_PORT ? parseInt(process.env.AGENT_TERMINAL_PORT, 10) : 0); // Mark as running BEFORE the async server start. The previous code only // set this AFTER `await createPtyWebSocketServer(...)`, which left a // TOCTOU window where two concurrent plugin invocations would both pass // the running-check, both spawn a server, and end up fighting for the // CLI's PTY pool — leading to `posix_spawnp failed` floods. process.env.__AGENT_TERMINAL_RUNNING = "true"; // guard:allow-env-mutation — process-wide running flag set once at boot, before any HTTP request handling, to coordinate concurrent plugin invocations try { const { createPtyWebSocketServer } = await import("./pty-server.js"); const result = await createPtyWebSocketServer({ appDir: process.cwd(), command, port, authCheck: isProd ? options.authCheck : undefined, logPrefix: "[terminal]", }); // Store port for other consumers process.env.AGENT_TERMINAL_PORT = String(result.port); // guard:allow-env-mutation — terminal subprocess port published once at boot, not per-request // Mount discovery endpoint getH3App(nitroApp).use( "/_agent-native/agent-terminal-info", defineEventHandler(() => ({ available: true, wsPort: result.port, command, })), ); // Cleanup on shutdown (use once to avoid listener leak on hot-reload) const cleanup = () => result.close(); process.once("SIGTERM", cleanup); process.once("SIGINT", cleanup); process.once("exit", cleanup); if (process.env.DEBUG) console.log( `[terminal] Agent terminal ready (command: ${command}, port: ${result.port})`, ); } catch (err) { // Clear the running flag so a retry can spawn a fresh server delete process.env.__AGENT_TERMINAL_RUNNING; // guard:allow-env-mutation — terminal subprocess boot failed, clearing boot-time sentinel so a later plugin retry can start cleanly // Distinguish "node-pty not installed" (expected when the user opts // out of the terminal feature) from real failures (port conflict, // native binding mismatch). Native deps are optional, so keep the // default dev console quiet unless terminal debugging is enabled. const code = (err as NodeJS.ErrnoException)?.code; const missingPty = code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND"; if (missingPty) { if ( !_ptyMissingLogged && (process.env.DEBUG || process.env.AGENT_TERMINAL_DEBUG === "1") ) { console.log( "[terminal] node-pty not installed — embedded terminal disabled. " + "Install with `pnpm add node-pty` to enable.", ); _ptyMissingLogged = true; } } else { console.error("[terminal] Failed to start PTY server:", err); console.error( "[terminal] If node-pty is installed but PTY fails to spawn, " + "try `pnpm rebuild node-pty` (common after switching Node " + "versions via fnm/nvm).", ); } // Mount a fallback info endpoint getH3App(nitroApp).use( "/_agent-native/agent-terminal-info", defineEventHandler(() => ({ available: false, error: missingPty ? "node-pty not installed" : "PTY server failed", })), ); } }; } /** Pre-configured terminal plugin with defaults */ export const defaultTerminalPlugin = createTerminalPlugin();