Files
AgentHub/server/routes/run.js
T
nntrivi2001 f77049c238 feat(run,ui): make the lane console usable — terminal focus, live-exit recovery, auto permission mode, model aliases
Terminal (issue #33):
- `convertEol` is gone from xterm. It rewrote every bare LF as CR+LF, so a
  tmux pane that scrolled (tmux emits a bare LF and keeps drawing at the
  column it was on) had the rest of that draw land at column 0, stamping a
  line's first characters into the left margin of the rows below — the torn
  left edge that only showed up once content scrolled.
- The attach carries the client's real size (`?cols=&rows=`, `sizeFromQuery`
  in `server/websocket.js`, bounded because it reaches a TIOCSWINSZ ioctl).
  `attach()` spawned `tmux attach-session` at a hardcoded 80x24 on every
  attach, and tmux sizes the window to the newest client, so `claude` redrew
  its whole UI at 80 columns until the first `resize` message landed.
- The terminal takes the keyboard on mount, so typing reaches tmux without
  clicking the pane first.
- A pane that ends (double Ctrl+C, `exit`, a crash) reports itself through a
  new `onExit` prop and `LaneConsolePane` runs `newRun()`, so the console
  returns to the setup form instead of leaving a dead pane behind a
  "New run" button.

Pane environment:
- `server/lib/desktop-env.js` forwards the graphical session's DISPLAY /
  WAYLAND_DISPLAY / XAUTHORITY / XDG_RUNTIME_DIR (from
  `systemctl --user show-environment`, this process's own values winning)
  into the pane. The server normally runs as a systemd user service with none
  of them, so `claude` there could not read the clipboard and answered "no
  image found in clipboard" on every image paste.

Run setup:
- `auto` is a permission mode and is now the default everywhere — allowlists,
  the `--permission-mode` fallback, the select, and the pane's initial state.
- The model picker sends the CLI's aliases (`fable`/`opus`/`sonnet`/`haiku`)
  instead of pinned ids. `claude --model` resolves an alias to the latest
  model itself, so a release like Fable 5.1 needs no edit here; pricing is
  unaffected because it keys off the resolved id the transcript reports.
- **Create/update readme file** moved from the Add-lane modal into the lane's
  Tools menu: creating a lane and writing its docs are separate decisions,
  and the second is worth re-running long after the first.
- The Workspace's no-lane state is a real banner; the lane strip (add tile
  included) renders only once a lane exists.

Refs #32, #33.

Tests: npm run test:server (1828 pass), client vitest (558 pass).
2026-09-21 17:03:27 +07:00

305 lines
10 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.
*
* 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 { 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 lanesLib = require("../lib/lanes");
const { sameOriginGuard } = require("../lib/same-origin");
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 ALLOWED_PERMISSION_MODES = new Set([
"auto",
"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=<n> caps results (default 50, max 500). The most recent
* runs are first. Optional ?laneId=<n> 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=<substring> 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 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 name is
// taken from a fixed allowlist, never from the query string directly — this
// string reaches `which`.
const tool = req.query.tool === undefined ? "claude" : String(req.query.tool);
if (tool !== "claude" && tool !== "codex") {
return res.status(400).json({ error: { code: "EBADTOOL", message: "unknown tool" } });
}
const which = require("node:child_process").spawnSync(
process.platform === "win32" ? "where" : "which",
[tool],
{ 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 lane = lanesLib.getLane(laneId);
const handle = runs.spawnRun({
laneId,
cwd,
tool: lane?.tool || "claude",
// The lane's own router, exactly like its tool — never a body field.
routerProfile: lane?.router_profile || 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,
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 = 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;