77917203fd
server/lib/ai-tools.js loads server/data/ai-tools/*.json (38 tools, data from Orca, MIT) plus DASHBOARD_AI_TOOLS_DIR, detects each CLI by walking PATH, and keeps enable/default in <data dir>/ai-tools.json. shell-path.js merges the login shell's PATH at startup and on Refresh. /api/ai-tools lists, refreshes and toggles; /api/run/binary resolves through the catalog. Lanes accept any catalog id (EDISABLED for a disabled one on creation, ENOTOOL at start once a tool left the catalog). pty-run keeps the Claude/Codex builders and builds every other tool's argv from its catalog file, pasting an after-start prompt through a tmux buffer; every pane gets the hydrated PATH. A per-run bypass switch appends the catalog's bypass args (Codex included). POST /api/run now forwards sandbox/approval/resumeLast, which it dropped. agenthub lanes add gains --tool.
385 lines
14 KiB
JavaScript
385 lines
14 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 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 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.
|
|
*/
|
|
// ── 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.
|
|
// Only the bytes come from the request: the name is random and the type picks
|
|
// the extension from a fixed map, so a request never names a file.
|
|
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}`);
|
|
|
|
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" });
|
|
}
|
|
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 name of fs.readdirSync(dir)) {
|
|
const f = path.join(dir, name);
|
|
try {
|
|
if (fs.statSync(f).mtimeMs < cutoff) fs.unlinkSync(f);
|
|
} catch {
|
|
/* raced with another prune — fine */
|
|
}
|
|
}
|
|
const file = path.join(dir, `${crypto.randomBytes(8).toString("hex")}.${ext}`);
|
|
fs.writeFileSync(file, req.body, { mode: 0o600, flag: "wx" });
|
|
res.json({ path: file });
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
}
|
|
);
|
|
|
|
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)
|
|
* - "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) => {
|
|
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");
|
|
|
|
// 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 <> ''
|
|
GROUP BY source_repo ORDER BY last_id DESC LIMIT 20`
|
|
)
|
|
.all();
|
|
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 <> ''
|
|
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 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 {
|
|
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,
|
|
// 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 = 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;
|