/** * @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. Also * saves files dropped on a pane or a prompt box (and serves a dropped image * back for the chat), and reads/steps a live claude pane's permission mode. * * 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 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 paneMode = require("../lib/pane-mode"); 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; } const insideOwnWorkspace = scope.insideOwnWorkspace; 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; /** Where a drop goes: the handoff dir of the account the PANE runs as — the * workspace `?laneId=` names (scopeGuard already refused one this user may * not see), else the requester's own — written AS that member under S3, so * their pane can read it; else this account's data dir, never a predictable * name in the shared tmp, which anyone on the machine could create first and * then swap the dropped files in. */ function dropDir(req) { const runAs = require("../lib/run-as"); const laneId = Number(req.query?.laneId); const member = Number.isInteger(laneId) && laneId > 0 && lanesLib.getLane(laneId) ? runAs.accountForLane(laneId) : runAs.accountForUser(req?.user?.username); return member ? { dir: path.join(require("../lib/run-as").handoffDir(member), "drops"), member } : { dir: path.join(require("../lib/claude-home").getDataDir(), "drops"), member: null }; } // Saves one dropped body under the drop dir and answers its path. Pruned on // write: files older than a day go. function saveDrop(req, res, name, body) { try { const { dir, member } = dropDir(req); const runAs = require("../lib/run-as"); if (member) runAs.mkdirAs(member, dir); else 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.lstatSync(f).mtimeMs >= cutoff) continue; if (member) runAs.removeAs(member, f); else fs.unlinkSync(f); } catch { /* raced with another prune — fine */ } } const file = path.join(dir, name); if (member) runAs.handoffWrite(member, path.join("drops", name), body); else 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(req, 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(req, 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) })), }); }); /** * Sessions whose pane went away without the user ending them (a reboot, a * killed tmux, a crash), for the "resume interrupted sessions" prompt. Only * the caller's own workspaces in server mode: an admin is not offered a * developer's sessions. */ router.get("/interrupted", (req, res) => { const dr = require("../lib/dashboard-runs"); res.json({ items: dr.listInterrupted(scope.ownerFor(req)) }); }); /** The user answered the prompt: stop offering these sessions. Resumes nothing. */ router.post("/interrupted/dismiss", (req, res) => { const ids = req.body?.sessionIds; if (!Array.isArray(ids) || ids.length > 100 || !ids.every((id) => typeof id === "string")) { return res.status(400).json({ error: { code: "EBADFIELD", message: "sessionIds must be an array of session ids" }, }); } const dr = require("../lib/dashboard-runs"); res.json({ dismissed: dr.dismissInterrupted(ids, scope.ownerFor(req)) }); }); /** * 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() : ""; // The composer's "Add from project" also attaches folders (`dir/`). const dirs = req.query.dirs === "1"; 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; const relDir = rel ? `${rel}/${ent.name}` : ent.name; if (dirs && (!q || relDir.toLowerCase().includes(q))) results.push(`${relDir}/`); walk(path.join(dir, ent.name), relDir); } 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) => { // `upgrade`: this platform's advice when the binary is older than AgentHub // asks for (tmux.upgradeAdvice), else null. The Workspace shows it. const version = tmux.clientVersion(); res.json({ available: version !== null, version, upgrade: version ? tmux.upgradeAdvice(version) : null, }); }); 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, lane?.owner_id ?? 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 ( ["ERUNLIVE", "ENOTSHARED", "EMEMBERGONE", "EOWNERFOLDER", "ECWDACCOUNT"].includes(err.code) ) { 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 } }); } }); // A dropped image, shown back in the session's conversation, or a dropped text // file (a long paste, #68), previewed in its Uploads/Files read list. Only a name // drop-image/drop-file made (random hex prefix, no separator) and only those // types; raster images, text as text/plain, under a sandbox CSP, like `/git/raw`. const DROP_IMAGE_TYPES = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp", }; const DROP_TEXT_TYPES = new Set([ "txt", "md", "markdown", "csv", "tsv", "log", "json", "xml", "yaml", "yml", ]); router.get("/drop/:name", (req, res) => { const name = String(req.params.name); const m = /^[0-9a-f]{16}(?:-[A-Za-z0-9._-]+)?\.([A-Za-z]+)$/.exec(name); const ext = m && m[1].toLowerCase(); const type = ext && (DROP_IMAGE_TYPES[ext] || (DROP_TEXT_TYPES.has(ext) && "text/plain; charset=utf-8")); // Opened first, then checked on the descriptor (no window to swap the name // for a link after the check): a regular file this account wrote. NOFOLLOW // refuses a link outright; NONBLOCK keeps a FIFO from hanging the loop. // S3: the requester's own drop dir, owned by their member account — so an // admin viewing a member's session gets the name chip, not the image. An // admin's host-folder workspace drops into the HOST's dir (only an admin // may own one), and the chat's GET names no workspace: that dir too. const dirs = type ? [dropDir(req)] : []; if (dirs[0]?.member && req.user?.role === "admin" && !req.query?.laneId) { dirs.push({ dir: path.join(require("../lib/claude-home").getDataDir(), "drops"), member: null, }); } let fd = null; for (const { dir, member } of dirs) { try { fd = fs.openSync( path.join(dir, name), fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0) | (fs.constants.O_NONBLOCK || 0) ); const st = fs.fstatSync(fd); const uid = member ? member.uid : typeof process.getuid === "function" ? process.getuid() : st.uid; if (st.isFile() && st.uid === uid) break; fs.closeSync(fd); fd = null; } catch { /* pruned, never dropped, or a link */ } } if (fd === null) { return res.status(404).json({ error: { code: "ENOTFOUND", message: "no such image" } }); } res.set({ "Content-Type": type, "Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; sandbox", "X-Content-Type-Options": "nosniff", "Cache-Control": "private, max-age=86400", }); fs.createReadStream(null, { fd }).pipe(res); }); // A live claude pane, or the 404/400 already sent. function claudePane(req, res) { const handle = scope.canSeeRun(req, req.params.id) ? runs.getRun(req.params.id) : null; if (!handle || handle.status === "gone") { res.status(404).json({ error: { code: "ENOTFOUND", message: "run not found" } }); return null; } if (handle.tool !== "claude") { res.status(400).json({ error: { code: "EUNSUPPORTED", message: "only a claude pane has a permission mode" }, }); return null; } return handle; } router.get("/:id/permission-mode", (req, res) => { const handle = claudePane(req, res); if (handle) res.json({ mode: paneMode.readMode(handle.id) }); }); router.post("/:id/permission-mode", async (req, res) => { const handle = claudePane(req, res); if (!handle) return; try { res.json({ mode: await paneMode.setMode(handle.id, String(req.body?.mode ?? "")) }); } catch (err) { const status = err.code === "EBADMODE" ? 400 : err.code ? 409 : 500; res.status(status).json({ error: { code: err.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" } }); } // `?archive=1` is the UI's Stop: the session the pane held leaves its // workspace's lists as well. Sleep (and the CLI) omit it, so the session // stays listed to be resumed later. A pane with no known session (a shell, // a Codex pane whose session was never learned) archives nothing. if (req.query.archive === "1") { return res.json({ ok: true, archived: archiveRunSession(req, req.params.id) }); } return res.json({ ok: true }); }); /** Archive the session a run held; returns its id, or null when none. */ function archiveRunSession(req, runId) { const sessionId = runs.getRun(runId)?.sessionId; if (!sessionId || !scope.canSeeSession(req, sessionId)) return null; const { db, stmts } = require("../db"); const changed = db .prepare( "UPDATE sessions SET archived_at = COALESCE(archived_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) WHERE id = ?" ) .run(sessionId).changes; if (!changed) return null; require("../websocket").broadcast("session_updated", stmts.getSession.get(sessionId)); return sessionId; } module.exports = router; // 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;