Files
AgentHub/server/routes/run.js
T
Nguyễn Ngọc Trí Vĩ cd427d218b fix(terminal): psmux wheel scrolling, per-platform tmux upgrade advice, Windows-safe server tests
- Server tests on Windows no longer reach the user's real psmux: psmux
  ignores TMUX_TMPDIR (and 3.3.x PSMUX_DATA_DIR), so quit.test.js's Quit and
  the runner's kill-server ended every live lane pane — the pre-commit hook
  included. scripts/test-server.js now takes tmux off the suites' PATH there.
- Wheel scrolling on Windows: psmux never turns mouse tracking on for its
  client, so xterm.js never reported the wheel and a pane could not scroll.
  A Windows attach is told {type:"wheel"} and the browser sends the SGR wheel
  report itself; psmux scrolls or forwards it to the pane's app.
- tmux.upgradeAdvice(): the oldest tmux AgentHub asks for and how each
  platform upgrades (psmux 3.4.0 -> winget upgrade marlocarlo.psmux; tmux 3.7
  -> brew upgrade tmux / build the release on Linux). Used by the doctor,
  GET /api/run/tmux ({available, version, upgrade}), `agenthub doctor`, and a
  sticky toast on the Workspace page. Advice only: nothing installs tmux.

Committed with --no-verify (user's call): the pre-commit hook runs the full
backend suite, which has ~378 pre-existing failures on Windows (S3/Linux-only
tests: sudo, /proc, systemd, members). Every test touching this change passes:
pty-attach, tmux, run, plugin-doctor-tmux, TerminalView, Workspace, and the
client tests related to the changed files; tsc and the header audit are clean.
2026-10-15 11:46:09 +07:00

636 lines
24 KiB
JavaScript

/**
* @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ĩ <vinnt@smartgift.vn>
*/
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=<n> caps results (default 50, max 500). The most recent
* runs are first. Optional ?laneId=<n> narrows to the runs started through one
* lane, and ?tool=<id> (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=<substring> 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" } });
}
return res.json({ ok: true });
});
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;