Files
AgentHub/server/routes/run.js
T
nntrivi2001 0a86dfb8c5 feat(settings): add AI Router profiles and attach one per lane
A new **AI Router** tab in Settings, next to Codex, stores named router
endpoints (9Router, OpenRouter, a self-hosted gateway) as profiles: a base
URL, an API key, a default model and an optional per-tier override. The tab
only stores them; a lane CHOOSES one in the Add-lane modal, via a Native /
AI Router radio, and that choice is written into the lane's own config.

The two CLIs are configured differently, and the difference is theirs:

  - a claude lane gets eight `env` keys merged into its own
    `.claude/settings.local.json` (`.local` because the value holds a key);
  - a codex lane gets `$CODEX_HOME/<name>.config.toml` — the layer
    `codex -p <name>` loads — because Codex has NO project-level config.
    Its base `config.toml` is never touched.

Native is a built-in profile rather than a code branch, so "go back to
native" is the same write path. It blanks the two connection variables
instead of removing them (an absent key at project scope lets a USER-level
router apply) and removes the model names instead of writing them (writing
them would override the user's own global model choice).

Key material never leaves the server: profiles live one file each under
`<CLAUDE_HOME>/agenthub-routers/` (0700/0600), `GET /api/ai-routers`
reports only `hasKey`, and there is deliberately no route returning one
profile in full.

Three things this had to get right, each found by review and verified
against the real tools rather than assumed:

  - `wire_api = "chat"` is rejected outright by codex-cli 0.149 ("no longer
    supported"), so every codex lane on a router would have failed to start.
    The layer writes "responses", and writes `env_key` only when the profile
    actually has a key — naming an unset variable is itself a startup error.
  - The key must never be TYPED into a pane. `tmux.sendCommand` used
    `send-keys` with a `KEY='…' cmd` prefix, which the dashboard streams to
    every attached browser and bash appends to history; it now takes over the
    idle pane with `respawn-pane -k -e`, passing env through argv the way
    `newSession` always has. Verified on tmux 3.4: multi-element argv arrives
    intact and the pane renders no trace of the value.
  - Attaching COPIES a profile, so editing or deleting one in Settings has to
    reach the lanes already using it. An edit re-applies it (a rotated key
    would otherwise never reach the lane still holding the revoked one); a
    delete resets those lanes to native and removes the Codex layer file.
    Both report per-lane failures, which the tab surfaces.

`.claude/settings.local.json` is added to the lane's git exclude, for both
native and router lanes: `agenthub lanes reset` runs `git clean -fd` without
`-x`, so an un-excluded settings file is deleted on reset — and untracked
meant the key was committable.

DB: `lanes.router_profile`, nullable, additive. Written only by
`setRouterProfile` beside the disk write, never through `PATCHABLE`.
2026-09-14 16:04:57 +07:00

299 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(["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
: "acceptEdits",
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;