/** * @file run.js * @description HTTP routes for the dashboard's terminal-run feature. Starts, * resumes, kills, and lists tmux-backed sessions for `claude` or `codex` (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 express = require("express"); const { Router } = express; const fs = require("node:fs"); const os = require("node:os"); const crypto = require("node:crypto"); const path = require("node:path"); const runs = require("../lib/pty-run"); const tmux = require("../lib/tmux"); const lanesLib = require("../lib/lanes"); const { sameOriginGuard } = require("../lib/same-origin"); const aiTools = require("../lib/ai-tools"); const { resumeProxy } = require("../lib/proxy-apply"); const scope = require("../lib/scope"); const router = Router(); 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; } /** * Server mode: whether a member may work in `dir` — only inside one of their * own workspaces' folders, compared by real path so a symlink in their folder * cannot point the check elsewhere. Always true for everyone else. */ function insideOwnWorkspace(req, dir) { const visible = scope.visibleLaneIds(req); if (visible === null) return true; const real = lanesLib.canonicalCwd(dir); return visible.some((id) => { const cwd = lanesLib.getLane(id)?.cwd; if (!cwd) return false; const rel = path.relative(cwd, real); return rel === "" || (rel.split(path.sep)[0] !== ".." && !path.isAbsolute(rel)); }); } const refuseFolder = (res) => res.status(403).json({ error: { code: "EFOLDER", message: "that folder is not one of your workspaces" }, }); const ALLOWED_PERMISSION_MODES = new Set([ "auto", "acceptEdits", "default", "plan", "bypassPermissions", ]); router.get("/", (req, res) => { // Server mode: a member sees only the panes of their own workspaces. const visible = scope.visibleLaneIds(req); const items = runs.listRuns(); res.json({ items: visible === null ? items : items.filter((r) => visible.includes(r.laneId)) }); }); /** * 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, and ?tool= (with a laneId) to one tool's (`shell` = Terminal tabs; * a closed one's row is dropped on the next run-heal beat). */ // ── Drag & drop into the terminal ───────────────────────────────────────── // A local terminal answers a dropped image by typing its PATH, which is how // `claude` attaches it. A browser has no path to give, so the image is saved // here — on the machine the pane runs on — and the client pastes this path. // drop-image: only the bytes come from the request — the name is random and the // type picks the extension from a fixed map. drop-file (any other file) keeps a // sanitized basename of the file's own name behind a random prefix. const DROP_TYPES = { "image/png": "png", "image/jpeg": "jpg", "image/gif": "gif", "image/webp": "webp", }; const DROP_MAX_BYTES = 20 * 1024 * 1024; const DROP_TTL_MS = 24 * 60 * 60_000; const dropDir = () => path.join(os.tmpdir(), `agenthub-drops-${os.userInfo().uid}`); // Saves one dropped body under the drop dir and answers its path. Pruned on // write: files older than a day go. function saveDrop(res, name, body) { try { const dir = dropDir(); fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); // ponytail: prune-on-write, a day old; a sweep job only if drops pile up. const cutoff = Date.now() - DROP_TTL_MS; for (const entry of fs.readdirSync(dir)) { const f = path.join(dir, entry); try { if (fs.statSync(f).mtimeMs < cutoff) fs.unlinkSync(f); } catch { /* raced with another prune — fine */ } } const file = path.join(dir, name); fs.writeFileSync(file, body, { mode: 0o600, flag: "wx" }); res.json({ path: file }); } catch (err) { res.status(500).json({ error: err.message }); } } router.post( "/drop-image", express.raw({ type: Object.keys(DROP_TYPES), limit: DROP_MAX_BYTES }), (req, res) => { const ext = DROP_TYPES[ String(req.headers["content-type"] || "") .split(";")[0] .trim() ]; if (!ext || !Buffer.isBuffer(req.body) || req.body.length === 0) { return res.status(400).json({ error: "png, jpeg, gif or webp image body required" }); } saveDrop(res, `${crypto.randomBytes(8).toString("hex")}.${ext}`, req.body); } ); // Any other dropped file (a PDF, a log, a CSV…): the agent reads it by path, // so its own name is kept — reduced to a basename of [A-Za-z0-9._-] behind a // random prefix, so it can neither leave the drop dir nor collide, and the // space-joined paste of several paths stays parseable. router.post("/drop-file", express.raw({ type: () => true, limit: DROP_MAX_BYTES }), (req, res) => { const safe = path .basename(String(req.query.name || "")) .replace(/[^A-Za-z0-9._-]/g, "_") .replace(/^\.+/, "") .slice(-100); if (!safe || !Buffer.isBuffer(req.body) || req.body.length === 0) { return res.status(400).json({ error: "?name= and a non-empty file body required" }); } saveDrop(res, `${crypto.randomBytes(8).toString("hex")}-${safe}`, req.body); }); 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 opts = { limit: Number.isFinite(limit) ? limit : 50, laneId: Number.isFinite(laneId) ? laneId : null, tool: typeof req.query.tool === "string" ? req.query.tool : null, }; // Server mode: a member with no ?laneId (a named one is scopeGuard's) gets // the newest runs of their own workspaces only. const visible = opts.laneId === null ? scope.visibleLaneIds(req) : null; // ponytail: one query per workspace; one IN (...) query if members own hundreds. const items = visible === null ? dr.listRuns(opts) : visible .flatMap((id) => dr.listRuns({ ...opts, laneId: id, tool: null })) .sort((a, b) => String(b.started_at).localeCompare(String(a.started_at))) .slice(0, Math.max(1, Math.min(500, Math.floor(opts.limit) || 50))); const liveIds = new Set(runs.listRuns().map((h) => h.id)); res.json({ items: items.map((it) => ({ ...it, isLive: liveIds.has(it.id) })), }); }); /** * Forget one closed Terminal tab (`tool = 'shell'`) from the history at once, * ahead of the run-heal beat. A live pane is refused; another tool's row is a 404. */ router.delete("/history/:id", (req, res) => { if (runs.listRuns().some((h) => h.id === req.params.id)) { return res.status(409).json({ error: { code: "ERUNLIVE", message: "terminal is still open" } }); } const dr = require("../lib/dashboard-runs"); if (!dr.forgetShellRun(req.params.id)) { return res .status(404) .json({ error: { code: "ENOTFOUND", message: "no closed terminal with that id" } }); } res.json({ removed: req.params.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) * - "repo": source repos existing lanes were provisioned from — the paths * the Add-lane form's repo field is actually asking for (issue #34) * - "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) => { // Server mode: a member's suggestions come from their own workspaces and // sessions only — not the server's own folder or home. const member = scope.memberId(req) != null; const ownLanes = scope.laneWhere(req, "id"); const ownSessions = scope.sessionWhere(req); 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 }); }; if (!member) { push("dashboard", process.cwd(), "Dashboard server"); push("home", require("node:os").homedir(), "Home"); } // Source repos lanes have already been provisioned from, newest lane first. // The Add-lane form's repo field used to offer only "recent" session cwds, // which for a worktree lane is the WORKTREE, never the repo it was cut from // — so the one path that form actually wants had to be retyped every time. // These sit above the recents below because a repo is a deliberate choice a // user made once, not a directory a session happened to run in. try { const { db } = require("../db"); const rows = db .prepare( `SELECT source_repo AS p, MAX(id) AS last_id FROM lanes WHERE source_repo IS NOT NULL AND source_repo <> '' AND ${ownLanes.where} GROUP BY source_repo ORDER BY last_id DESC LIMIT 20` ) .all(...ownLanes.params); for (const row of rows) push("repo", row.p, path.basename(row.p)); } catch { /* ignore — same best-effort contract as the recents below */ } // 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 <> '' AND ${ownSessions.where} GROUP BY cwd ORDER BY last_at DESC LIMIT 30` ) .all(...ownSessions.params); 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 } }); } if (!insideOwnWorkspace(req, cwd)) return refuseFolder(res); 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 the lane's CLI is on PATH so the UI can show a helpful // error before the user clicks Run. We don't actually invoke it. The tool id // is looked up in the AI Tools catalog; only a catalog file names a command. const tool = req.query.tool === undefined ? "claude" : String(req.query.tool); if (!aiTools.has(tool)) { return res.status(400).json({ error: { code: "EBADTOOL", message: "unknown tool" } }); } // Fresh, not the catalog's cache: a CLI installed while the dashboard is up // should clear the missing-binary banner on the next look. const d = aiTools.detect(tool, { fresh: true }); res.json({ found: d.installed, path: d.path }); }); 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 { // A member's run with no cwd starts in its workspace, not the server's folder. const empty = body.cwd == null || body.cwd === ""; cwd = sanitiseCwd( empty && scope.memberId(req) != null ? lanesLib.getLane(laneId)?.cwd : body.cwd ); } catch (err) { return res.status(400).json({ error: { code: err.code, message: err.message } }); } if (!insideOwnWorkspace(req, cwd)) return refuseFolder(res); try { const lane = lanesLib.getLane(laneId); const handle = runs.spawnRun({ laneId, cwd, tool: lane?.tool || "claude", // The session's AI Proxy, by name (key and endpoint from the store); a // resume that names none goes back to the one its session last ran on. // `router` is the pre-rename field, still taken from an older client. proxy: (body.proxy ?? body.router) !== undefined ? (body.proxy ?? body.router) : resumeProxy(typeof body.resumeSessionId === "string" ? body.resumeSessionId : null), model: typeof body.model === "string" && body.model ? body.model : null, permissionMode: typeof body.permissionMode === "string" && ALLOWED_PERMISSION_MODES.has(body.permissionMode) ? body.permissionMode : "auto", effort: typeof body.effort === "string" && body.effort ? body.effort : null, // Codex's own fields, checked against pty-run's allowlists there. sandbox: typeof body.sandbox === "string" && body.sandbox ? body.sandbox : null, approval: typeof body.approval === "string" && body.approval ? body.approval : null, resumeLast: body.resumeLast === true, // Only for this run, only when the user ticked it; the args come from // the lane tool's catalog file, never from here. bypass: body.bypass === true, 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) { // A busy pane is a conflict with live state, not a malformed request. if (err.code === "ERUNLIVE") { return res.status(409).json({ error: { code: err.code, message: err.message } }); } // Nor is a host missing tmux: the request was fine, the dependency is not // installed. 400 would tell the user to fix their input. if (err.code === "ETMUXMISSING") { return res.status(503).json({ error: { code: err.code, message: err.message } }); } 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 = scope.canSeeRun(req, req.params.id) ? runs.getRun(req.params.id) : null; 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) => { // scopeGuard checks the raw path; this checks the decoded id the kill uses. const ok = scope.canSeeRun(req, req.params.id) && 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;