/** * @file run.js * @description HTTP routes for the dashboard's terminal-run feature. Starts, * resumes, kills, and lists tmux-backed `claude` sessions (one per lane), * streamed to the client over a dedicated WebSocket path (see * server/websocket.js `/ws-pty/:runId`) rather than this REST surface. * * Security model: * - Local-first dashboard. The dashboard server is expected to bind to * localhost (or the user's intranet at most). To prevent a malicious * webpage from drive-by spawning processes via CSRF, we enforce a * same-origin / loopback-Origin check on every route here. curl from the * terminal (no Origin header) is allowed; browser requests must come from * a localhost-ish origin. * - cwd is sanitised: must be absolute and exist as a directory at request * time. Anything else is rejected. * - Concurrency cap (RUN_MAX_CONCURRENT, default 10) prevents runaway spawn. * * @author Nguyễn Ngọc Trí Vĩ */ const { Router } = require("express"); const fs = require("node:fs"); const path = require("node:path"); const runs = require("../lib/pty-run"); const tmux = require("../lib/tmux"); const router = Router(); const ALLOWED_ORIGIN_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "0.0.0.0"]); /** * Loopback-Origin guard. Browser requests carry Origin; if it's not localhost, * we reject. Server/CLI requests (curl) typically don't carry Origin and pass. * * Referer is checked as a fallback for older browsers / fetch with credentials * disabled — the same loopback-host rule applies. */ function sameOriginGuard(req, res, next) { const checkHost = (raw) => { try { const u = new URL(raw); return ALLOWED_ORIGIN_HOSTS.has(u.hostname); } catch { return false; } }; const origin = req.headers.origin; if (origin) { if (!checkHost(origin)) { return res.status(403).json({ error: { code: "EBADORIGIN", message: "cross-origin requests are not allowed" }, }); } return next(); } const referer = req.headers.referer; if (referer && !checkHost(referer)) { return res.status(403).json({ error: { code: "EBADORIGIN", message: "cross-origin requests are not allowed" }, }); } return next(); } router.use(sameOriginGuard); function isExistingDir(p) { try { return fs.statSync(p).isDirectory(); } catch { return false; } } function sanitiseCwd(input) { if (input == null || input === "") return process.cwd(); if (typeof input !== "string") { const e = new Error("cwd must be a string"); e.code = "EBADCWD"; throw e; } if (!path.isAbsolute(input)) { const e = new Error("cwd must be an absolute path"); e.code = "EBADCWD"; throw e; } const resolved = path.resolve(input); if (!isExistingDir(resolved)) { const e = new Error(`cwd does not exist: ${resolved}`); e.code = "EBADCWD"; throw e; } return resolved; } const ALLOWED_PERMISSION_MODES = new Set(["acceptEdits", "default", "plan", "bypassPermissions"]); router.get("/", (_req, res) => { res.json({ items: runs.listRuns() }); }); /** * Persistent history of every run spawned via the dashboard. Survives the * 5-minute in-memory reap so the user can see (and resume) past runs days * after they finished. Backed by the `dashboard_runs` sqlite table. * * Optional ?limit= caps results (default 50, max 500). The most recent * runs are first. Optional ?laneId= narrows to the runs started through one * lane, which is what the Workspace page's per-lane history shows. */ router.get("/history", (req, res) => { let dr = null; try { dr = require("../lib/dashboard-runs"); } catch { return res.json({ items: [] }); } const limit = Number.parseInt(String(req.query.limit || "50"), 10); const laneId = Number.parseInt(String(req.query.laneId ?? ""), 10); const items = dr.listRuns({ limit: Number.isFinite(limit) ? limit : 50, laneId: Number.isFinite(laneId) ? laneId : null, }); const liveIds = new Set(runs.listRuns().map((h) => h.id)); res.json({ items: items.map((it) => ({ ...it, isLive: liveIds.has(it.id) })), }); }); /** * Suggest plausible working directories. Pulls from: * - "dashboard": the dashboard server's cwd * - "home": $HOME (the Run page's default cwd and first autocomplete group — * a neutral spawn location that doesn't inherit this repo's project * context, issue #202) * - "recent": distinct cwds Claude Code has been used in, sourced from the * dashboard's own sessions table. Filtered to dirs that still exist. * * Optional ?q= filter applied client-side as well. */ router.get("/cwds", (_req, res) => { const out = []; const seen = new Set(); const push = (kind, p, label) => { if (!p) return; const abs = path.resolve(p); if (seen.has(abs)) return; if (!isExistingDir(abs)) return; seen.add(abs); out.push({ kind, path: abs, label: label || path.basename(abs) || abs }); }; push("dashboard", process.cwd(), "Dashboard server"); push("home", require("node:os").homedir(), "Home"); // Pull recent cwds from the sessions DB (best-effort; if the DB isn't // ready or has a different schema, just return what we have). try { const { db } = require("../db"); const rows = db .prepare( `SELECT cwd, MAX(started_at) AS last_at FROM sessions WHERE cwd IS NOT NULL AND cwd <> '' GROUP BY cwd ORDER BY last_at DESC LIMIT 30` ) .all(); for (const row of rows) { push("recent", row.cwd, path.basename(row.cwd)); } } catch { /* ignore — DB may not be ready in tests */ } res.json({ items: out }); }); /** * File autocomplete for the prompt editor's `@` references. Walks `cwd` * (must be inside the cwd allowlist via sanitiseCwd), returns up to 40 * matching paths relative to that cwd. Skips dotdirs, node_modules, build, * dist, .git, etc. Substring-matches against `q` case-insensitively. */ router.get("/files", (req, res) => { let cwd; try { cwd = sanitiseCwd(req.query.cwd); } catch (err) { return res.status(400).json({ error: { code: err.code, message: err.message } }); } const q = typeof req.query.q === "string" ? req.query.q.toLowerCase() : ""; const SKIP_DIRS = new Set([ "node_modules", ".git", "dist", "build", "out", ".next", ".cache", ".vite", "coverage", ".turbo", "target", ".venv", "__pycache__", ]); const MAX_RESULTS = 40; const MAX_VISITED = 5000; const results = []; let visited = 0; const walk = (dir, rel) => { if (results.length >= MAX_RESULTS || visited >= MAX_VISITED) return; let entries; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; } for (const ent of entries) { if (results.length >= MAX_RESULTS || visited >= MAX_VISITED) return; visited++; if (ent.name.startsWith(".") && ent.name !== ".env" && ent.name !== ".gitignore") continue; if (ent.isDirectory()) { if (SKIP_DIRS.has(ent.name)) continue; walk(path.join(dir, ent.name), rel ? `${rel}/${ent.name}` : ent.name); } else if (ent.isFile()) { const relPath = rel ? `${rel}/${ent.name}` : ent.name; if (!q || relPath.toLowerCase().includes(q)) { results.push(relPath); } } } }; walk(cwd, ""); results.sort((a, b) => a.length - b.length || a.localeCompare(b)); res.json({ items: results.slice(0, MAX_RESULTS) }); }); router.get("/binary", (_req, res) => { // Surface whether `claude` is on PATH so the UI can show a helpful error // before the user clicks Run. We don't actually invoke it — just let the // user know the spawn will work. const which = require("node:child_process").spawnSync( process.platform === "win32" ? "where" : "which", ["claude"], { encoding: "utf8" } ); const stdout = (which.stdout || "").trim(); res.json({ found: which.status === 0 && stdout.length > 0, path: stdout || null, }); }); router.get("/tmux", (_req, res) => { res.json({ available: tmux.isTmuxAvailable() }); }); router.post("/", (req, res) => { const body = req.body || {}; const laneId = Number.parseInt(String(body.laneId ?? ""), 10); if (!Number.isInteger(laneId)) { return res.status(400).json({ error: { code: "EBADLANE", message: "laneId is required" } }); } let cwd; try { cwd = sanitiseCwd(body.cwd); } catch (err) { return res.status(400).json({ error: { code: err.code, message: err.message } }); } try { const handle = runs.spawnRun({ laneId, cwd, model: typeof body.model === "string" && body.model ? body.model : null, permissionMode: typeof body.permissionMode === "string" && ALLOWED_PERMISSION_MODES.has(body.permissionMode) ? body.permissionMode : "acceptEdits", effort: typeof body.effort === "string" && body.effort ? body.effort : null, resumeSessionId: typeof body.resumeSessionId === "string" && body.resumeSessionId ? body.resumeSessionId : null, initialPrompt: typeof body.initialPrompt === "string" ? body.initialPrompt : "", }); return res.status(201).json(handle); } catch (err) { if (err.code && err.code.startsWith("E")) { return res.status(400).json({ error: { code: err.code, message: err.message } }); } return res.status(500).json({ error: { code: "EINTERNAL", message: err.message } }); } }); router.get("/:id", (req, res) => { const handle = runs.getRun(req.params.id); if (!handle || handle.status === "gone") { return res.status(404).json({ error: { code: "ENOTFOUND", message: "run not found" } }); } return res.json(handle); }); router.delete("/:id", (req, res) => { const ok = runs.killRun(req.params.id); if (!ok) { return res.status(404).json({ error: { code: "ENOTFOUND", message: "run not found" } }); } return res.json({ ok: true }); }); module.exports = router; module.exports.__sameOriginGuard = sameOriginGuard; module.exports.__sanitiseCwd = sanitiseCwd; // Shared with routes/lanes.js: lane actions spawn processes through the same // run-spawner, so they must sit behind the same loopback/same-origin check. module.exports.sameOriginGuard = sameOriginGuard;