/** * @file Express router for lanes — the durable per-working-directory unit of * parallel agent work. Read endpoints join each lane with its most recent event * timestamp so liveness can be computed without a separate heartbeat, and every * mutation re-broadcasts the lane over the existing WebSocket as `lane_update`. * Orchestration is deliberately absent: the driving Claude session declares its * own stage (`POST /:id/stage`); the dashboard never guesses a transition. * @author Nguyễn Ngọc Trí Vĩ */ const express = require("express"); const { Router } = express; const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); const { db } = require("../db"); const lanesLib = require("../lib/lanes"); const { sessionWorktree, removeSessionWorktree } = require("../lib/session-worktree"); const { sourceFolder } = require("../lib/lane-sources"); const { suggestCommitMessage } = require("../lib/commit-message"); const aiTools = require("../lib/ai-tools"); const { listPipelines, getPipeline, nodeStates, progressPct, phaseIdx, } = require("../lib/pipelines"); const laneFeatures = require("../lib/lane-features"); const proofLib = require("../lib/proof"); const { broadcast } = require("../websocket"); const runs = require("../lib/pty-run"); const tmux = require("../lib/tmux"); const { resumeProxy } = require("../lib/proxy-apply"); const { sameOriginGuard } = require("./run"); const { isLocalRequest } = require("../lib/same-origin"); const scope = require("../lib/scope"); const { writeLaneFile } = require("../lib/lane-file-write"); const { openDownload, saveUpload, UPLOAD_MAX_BYTES } = require("../lib/lane-file-transfer"); const { pickPath, startDir } = require("../lib/native-dialog"); const { preflight } = require("../lib/lane-preflight"); const { LANES_ROOT, addWorktree, git, gitFacts, isGitRepo, isValidBranchName, listBranches, listWorktrees, resetWorktree, resolveBase, slugify, } = require("../lib/worktree"); const { collectChanges, listCommits, defaultBranch, listPaths, listTree, listTreeAll, readFileAt, blameFile, readImageAt, } = require("../lib/git-diff"); const { runGitAction } = require("../lib/git-ops"); const { withLaneLock } = require("../lib/lane-lock"); const { checkSync, mergeSync, continueSync } = require("../lib/lane-sync"); const { syncMcp } = require("../lib/lane-mcp"); const { HOOKS, runHook, resolveProfile, isIntegrationEnabled } = require("../lib/lane-profile"); const { slotDirs } = require("../lib/lane-slots"); const { upLane, downLane, runtimeFacts, requireProfile, provisionLane, resetLaneData, } = require("../lib/lane-runtime"); const { reapOrphanMcp, capOversizedLogs } = require("../lib/lane-gc"); const { detectNode, scaffoldProfile } = require("../lib/lane-detect"); const { ciStatus } = require("../lib/ci-status"); const router = Router(); const MAX_WORKTREE_DIRECTORY_ATTEMPTS = 50; /** Bytes of a hook log returned by default — enough to see a failure's tail. */ const LOG_TAIL_DEFAULT = 64 * 1024; const LOG_TAIL_MAX = 1024 * 1024; /** * Seconds since this lane last showed life: its session's newest event or the * start of its current run, whichever is newer; null if neither exists. The * run counts because a new run keeps the PREVIOUS session_id until the new * session's first hook rebinds it — judged by that old session alone, a run * started a second ago read "dead" and, with no lane field changing after * (a resume, or a hook ingested by another server on the same DB), stayed so * until a refresh. Only with a session bound: a lane no hook ever gives one * (a catalog tool, Codex without its hooks) has no signal, so stays null. */ const lastEventStmt = db.prepare("SELECT MAX(created_at) AS last FROM events WHERE session_id = ?"); const runStartStmt = db.prepare("SELECT started_at FROM dashboard_runs WHERE id = ?"); function lastEventAge(lane) { if (!lane.session_id) return null; const times = []; const row = lastEventStmt.get(lane.session_id); if (row?.last) times.push(Date.parse(row.last)); if (lane.run_id) { const run = runStartStmt.get(lane.run_id); if (run?.started_at) times.push(Date.parse(run.started_at)); } const t = Math.max(...times.filter((x) => !Number.isNaN(x))); return Number.isFinite(t) ? Math.max(0, Math.round((Date.now() - t) / 1000)) : null; } /** * Self-heals a stale `run_id`: a tmux-backed run has no exit event to push a * release notification, so liveness is re-checked here, on every read, * instead — the same "computed fact, never stored" principle this repo * already applies to lane runtime up/down. A lane whose run_id points at a * tmux session that's gone (the pane's process exited, or it was killed * outside the dashboard entirely) gets released the next time anything reads * it, exactly like the old push-based handler did, just pulled instead of * pushed. */ function healRunId(lane, live = null) { if (!lane.run_id) return lane; // A member's pane on a server without S3 (a dev dashboard on the same DB): // its tmux is not ours to ask, so "not listed" proves nothing. if (require("../lib/linux-accounts").unseenMemberFolder(lane.cwd)) return lane; if (live) { if (live.has(lane.run_id)) return lane; // Started a moment ago: a cached list may not show it yet (dashboard-runs.js). const dashboardRuns = require("../lib/dashboard-runs"); const row = dashboardRuns.getRun(lane.run_id); if (row && row.status === "running" && dashboardRuns.isYoungRun(row.started_at)) return lane; } else { const run = runs.getRun(lane.run_id); if (run && run.status === "running") return lane; } lanesLib.updateLane(lane.id, { run_id: null, status: "idle" }); broadcastLane(lane.id); return lanesLib.getLane(lane.id); } /** * Heal every lane at once. A claude pane that exits fires a hook, which * broadcasts the lane and so re-reads it; a codex or antigravity pane fires * nothing, so without this sweep its lane read "running" until something * else happened to fetch it. `live` (a `tmux.liveSessions()` set) answers * every lane from one tmux call instead of a has-session per lane. */ function healAllRunIds(live = null) { for (const lane of lanesLib.listLanes()) if (lane.run_id) healRunId(lane, live); } /** * Re-broadcast the lanes ANOTHER process changed. Two dashboards may share one * database, and hooks reach only one of them (the lowest port), so the other's * browsers never heard about a lane the hooks moved until they refreshed. * `PRAGMA data_version` moves only on another connection's commit, so the * common pass is one pragma; after one, each lane whose `updated_at` differs * from what this pass saw last is broadcast (a lane this server wrote itself * may go out twice — the client drops an identical copy), and a vanished * lane goes out as `removed`. * ponytail: two writes in the same millisecond share an updated_at; a counter * column would close that. */ let seenDataVersion = null; const seenLanes = new Map(); const laneStampsStmt = db.prepare("SELECT id, updated_at FROM lanes"); function broadcastForeignLaneChanges() { const out = { changed: [], removed: [] }; const version = db.pragma("data_version", { simple: true }); if (version === seenDataVersion) return out; const primed = seenDataVersion !== null; seenDataVersion = version; const live = new Set(); for (const { id, updated_at } of laneStampsStmt.all()) { live.add(id); if (primed && seenLanes.get(id) !== updated_at) out.changed.push(id); seenLanes.set(id, updated_at); } for (const id of [...seenLanes.keys()]) { if (live.has(id)) continue; seenLanes.delete(id); if (primed) out.removed.push(id); } for (const id of out.changed) broadcastLane(id); for (const id of out.removed) broadcast("lane_update", { removed: id }); return out; } function payload(lane, live = null) { const healed = healRunId(lane, live); return lanesLib.lanePayload(healed, lastEventAge(healed)); } /** A feature row's pipeline view, computed the same way payload() computes * it for a live lane — lets the client render an archived feature with the * exact same PipelineMap component, no special-casing on the frontend. */ function featurePayload(feature) { const pipeline = getPipeline(feature.pipeline); return { ...feature, pipeline_name: pipeline.name, pipeline_nodes: nodeStates(pipeline, feature), progress: progressPct(pipeline, feature), }; } /** Push the current state of one lane to every connected client. */ function broadcastLane(id) { const lane = lanesLib.getLane(id); if (lane) broadcast("lane_update", { lane: payload(lane) }); } router.get("/", (req, res) => { // Server mode: a member lists (and counts) only their own workspaces. const visible = scope.visibleLaneIds(req); const rows = lanesLib.listLanes().filter((l) => visible === null || visible.includes(l.id)); // ONE list-sessions answers every lane's run, instead of a synchronous // has-session per lane (null when tmux could not be asked: per-lane then). const live = rows.some((l) => l.run_id) ? tmux.liveSessions() : null; const lanes = rows.map((l) => payload(l, live)); res.json({ lanes, counts: { total: lanes.length, running: lanes.filter((l) => l.status === "running").length, needs_you: lanes.filter((l) => l.needs_action).length, dead: lanes.filter((l) => l.liveness === "dead").length, }, }); }); // Registered before "/:id" so the literal path is not swallowed by the param. router.get("/pipelines", (_req, res) => res.json({ pipelines: listPipelines() })); /** * How often each pipeline node has actually been passed, aggregated over every * lane AND every archived feature — an archived feature is a finished run of a * pipeline, so dropping it would throw away most of the history the moment a * lane starts its next feature. * * A node is counted `entered` when a stage record resolves onto it; `failed` * when that record says `result: "fail"`; `passed` otherwise; `withEvidence` * for the subset of passes that left an artifact behind. Evidence is tracked * separately rather than folded into the pass rate, because "claimed" and * "proved" are not the same fact — the same distinction the pipeline map draws * between `done` and `passed-no-evidence`. Also registered before "/:id". * * `?lane=` narrows the same aggregation to one lane and its own archived * features. The returned `lanes` list is never narrowed — it populates the * picker, so it has to keep offering the way back to every other lane. */ router.get("/pipeline-stats", (req, res) => { const laneFilter = req.query.lane; let laneId = null; if (laneFilter !== undefined && laneFilter !== "" && laneFilter !== "all") { laneId = Number(laneFilter); if (!Number.isInteger(laneId) || laneId < 1) { return res .status(400) .json({ error: { code: "EBADLANE", message: "workspace must be an id" } }); } } // Server mode: a member's aggregate, lanes and picker cover only their own // workspaces ("1" for everyone else). A `?lane=` of another user's never // gets here — scopeGuard answers 404 first. const own = scope.laneWhere(req, "lane_id"); const ownLanes = scope.laneWhere(req, "id"); const events = laneId === null ? db .prepare( `SELECT lane_id, pipeline, run_seq, node_id, source, result, evidence FROM lane_stage_events WHERE ${own.where} ORDER BY id` ) .all(...own.params) : db .prepare( "SELECT lane_id, pipeline, run_seq, node_id, source, result, evidence FROM lane_stage_events WHERE lane_id = ? ORDER BY id" ) .all(laneId); const acc = new Map(); const entryFor = (pipelineId) => { const pipeline = getPipeline(pipelineId); let entry = acc.get(pipeline.id); if (!entry) { entry = { id: pipeline.id, runs: new Set(), nodes: new Map(), template: pipeline }; acc.set(pipeline.id, entry); } return entry; }; // Seed the templates the lanes in scope are configured with, before folding // any events in. A lane that has recorded nothing still has to render its map // — the picker is how you find out a pipeline is going unused, so a pipeline // with zero runs must appear, at zero, rather than vanish. const scopedLanes = laneId === null ? db.prepare(`SELECT pipeline FROM lanes WHERE ${ownLanes.where}`).all(...ownLanes.params) : db.prepare("SELECT pipeline FROM lanes WHERE id = ?").all(laneId); for (const row of scopedLanes) entryFor(row.pipeline); // Fold the event stream down to one verdict per (run, node): a run that // entered a node twice is still one pass of it, and a declaration always // beats a detection of the same node — the agent's own claim is the stronger // statement, so a node it declared must never be reported as merely inferred. for (const ev of events) { const entry = entryFor(ev.pipeline); const pipeline = entry.template; entry.runs.add(`${ev.lane_id}#${ev.run_seq}`); const idx = phaseIdx(pipeline, ev.node_id); if (idx === -1) continue; // a stage nothing in this pipeline is named after const key = `${ev.lane_id}#${ev.run_seq}#${idx}`; const prev = entry.nodes.get(key); const declared = ev.source !== "detected"; if (prev && prev.declared && !declared) continue; entry.nodes.set(key, { idx, declared: declared || (prev ? prev.declared : false), failed: declared ? ev.result === "fail" : prev ? prev.failed : false, evidence: (declared && !!ev.evidence) || (prev ? prev.evidence : false), }); } const pipelines = [...acc.values()] .map((entry) => { const nodes = entry.template.nodes.map((n) => ({ id: n.id, label: n.label, icon: n.icon, gate: n.gate, command: n.command, entered: 0, passed: 0, withEvidence: 0, failed: 0, detected: 0, })); for (const v of entry.nodes.values()) { const node = nodes[v.idx]; if (!node) continue; if (!v.declared) { // Reached, but only inferred from the hook stream. Counted apart from // the pass rate: it says the work happened, not that a gate was met. node.detected += 1; continue; } node.entered += 1; if (v.failed) node.failed += 1; else { node.passed += 1; if (v.evidence) node.withEvidence += 1; } } const name = entry.template.name || entry.id; return { id: entry.id, name, runs: entry.runs.size, nodes }; }) // Busiest pipeline first: the one with the most recorded runs is the one // the reader most likely came to look at. .sort((a, b) => b.runs - a.runs); const lanes = db .prepare(`SELECT id, title, cwd FROM lanes WHERE ${ownLanes.where} ORDER BY id`) .all(...ownLanes.params); res.json({ pipelines, lanes }); }); /** * Local branches of a candidate source repo, for the "Add lane" picker: pick * a repo, then pick which branch to fork the new worktree from, instead of * typing a branch name and hoping it exists. Same validation as `/worktree` * (below), since a repo this can't resolve branches for can't be provisioned * from either. Read-only, so no same-origin guard. */ router.get("/branches", async (req, res) => { const sourceRepo = typeof req.query.repo === "string" ? req.query.repo : ""; if (!sourceRepo || !path.isAbsolute(sourceRepo) || !fs.existsSync(sourceRepo)) { return res.status(400).json({ error: { code: "EBADSOURCEREPO", message: "repo must be an existing absolute path" }, }); } // A developer names only a repo in their own folder: git would otherwise run // AS whichever member owns the path (S3) and hand back their branches. if (!scope.folderAllowed(req, lanesLib.canonicalCwd(sourceRepo))) return refuseFolder(res); if (!(await isGitRepo(sourceRepo))) { return res.status(400).json({ error: { code: "EBADSOURCEREPO", message: "repo is not a git repository" }, }); } try { const { branches, current } = await listBranches(sourceRepo); // The repo's worktrees ride along because every question the Add-lane // picker asks about a branch is really a question about them: which // branches can still be checked out (git allows one worktree per branch, // and `addWorktree` throws EBRANCHBUSY for the rest), and what this repo // already has provisioned. One extra subprocess on a form the user is // still typing into, versus a second round trip for facts that expire // together. A repo whose worktree list cannot be read still answers with // its branches — the picker just loses the busy markers. const worktrees = await listWorktrees(sourceRepo).catch(() => []); res.json({ branches, current, worktrees }); } catch (err) { res .status(500) .json({ error: { code: gitErrorCode(err), message: err.message, git: err.git } }); } }); /** The lane fields a server-mode member may PATCH (the UI sends title and pipeline). */ const MEMBER_PATCHABLE = new Set(["title", "pipeline", "notes", "links", "branch"]); /** Server mode: the folder is, holds or lies in another user's workspace. */ function refuseOwned(res) { return res.status(409).json({ error: { code: "EOWNED", message: "this folder belongs to another user's workspace" }, }); } /** Server mode with AGENTHUB_USERS_ROOT: a member's folder lies outside their own. */ function refuseFolder(res) { return res.status(403).json({ error: { code: "EFOLDER", message: "a workspace must be inside your own folder" }, }); } /** * Idempotent "which lane owns this directory?" — the Workspace page opens on a * cwd, not on a lane id, so it needs one lane to exist for that cwd without * ever creating a duplicate. Also registered before "/:id". */ router.post("/ensure", sameOriginGuard, (req, res) => { const body = req.body || {}; // Default the cwd to the user's home so callers that don't care about a // specific directory (e.g. Sagi's mascot companion) get a real, existing // path and lane uniqueness stays meaningful. Callers that do care pass one // through unchanged. // Canonical before the lookup, so the lookup and createLane's insert agree. const cwd = lanesLib.canonicalCwd( typeof body.cwd === "string" && body.cwd.trim() ? body.cwd.trim() : os.homedir() ); if (body.tool !== undefined && !aiTools.has(body.tool)) { return res.status(400).json({ error: { code: "EBADTOOL", message: "unknown tool" } }); } if (!scope.folderAllowed(req, cwd)) return refuseFolder(res); if (scope.folderTakenByOther(req, cwd)) return refuseOwned(res); // A cwd can now back one lane per tool (idx_lanes_cwd_tool) — "does a lane // already own this (cwd, tool) pair" is the real question, so a request for // a different tool than an existing lane at this cwd falls through to // creating that tool's own lane instead of adopting the wrong one. // // `exact: true` is the Add-lane modal's question — "is THIS directory a // lane" — so a lane on a PARENT directory does not answer it (#42: the // modal closed on the parent lane and no new card ever appeared). Without // it, a nested cwd still resolves to its owner, which is what the CLI and // other callers adopting "the lane I am inside" rely on. const found = lanesLib.resolveLaneByCwd(cwd, body.tool || "claude"); const strip = (p) => p.replace(/[\\/]+$/, ""); const owner = found && (body.exact !== true || strip(found.cwd) === strip(cwd)) ? found : null; // Server mode: another user's workspace is not this one's to adopt. if (owner && !scope.canSeeLane(req, owner.id)) { return res.status(409).json({ error: { code: "EOWNED", message: "this folder belongs to another user's workspace" }, }); } if (owner) { // First-write wins on the title: an ensure-with-title on an existing lane // is how callers like Sagi adopt the lane, so don't clobber a real title // with an empty one. if (body.title && !owner.title) { const updated = lanesLib.updateLane(owner.id, { title: body.title }); return res.json({ lane: payload(updated || owner), created: false }); } return res.json({ lane: payload(owner), created: false }); } try { const lane = lanesLib.createLane({ cwd, owner_id: scope.ownerFor(req), title: body.title || "", ...(body.pipeline ? { pipeline: body.pipeline } : {}), ...(body.tool ? { tool: body.tool } : {}), }); broadcastLane(lane.id); return res.status(201).json({ lane: payload(lane), created: true }); } catch (err) { if ( err.code === "EBADCWD" || err.code === "EBADPIPELINE" || err.code === "EBADTOOL" || err.code === "EDISABLED" ) { return res.status(400).json({ error: { code: err.code, message: err.message } }); } if (err.code === "EOWNERFOLDER" || err.code === "EMEMBERGONE") { return res.status(409).json({ error: { code: err.code, message: err.message } }); } // The cwd UNIQUE constraint is the arbiter: someone else won the race, so // re-read and return THEIR lane rather than reporting a conflict. if (err.code === "SQLITE_CONSTRAINT_UNIQUE" || String(err.message).includes("UNIQUE")) { const winner = lanesLib.resolveLaneByCwd(cwd, body.tool || "claude"); if (winner) return res.json({ lane: payload(winner), created: false }); } return res.status(500).json({ error: { code: err.code, message: err.message } }); } }); /** * Machine-wide housekeeping — not scoped to one lane. Reaps orphaned * Playwright MCP processes and caps oversized hook logs across every lane * on this machine. See server/lib/lane-gc.js for what "orphaned" and * "oversized" mean. */ /** * S3 (docs/plans/server-mode/S3-PLAN.md): the managed-worktree mode and the * runtime layer run as the dashboard's own account and write into the * workspace or LANES_ROOT, so they are not offered for a folder that belongs * to a Linux account — such a workspace uses Repo mode and the prompt box's * New worktree. True once it has answered 403 EMEMBERLANE. */ function refuseS3Folder(res, folder) { // ownerForRead: a folder of a gone member is still a member's (accountForPath throws). if (!require("../lib/run-as").ownerForRead(folder)) return false; res.status(403).json({ error: { code: "EMEMBERLANE", message: "not available for a workspace in a member's folder — use Repo mode and the prompt box's New worktree", }, }); return true; } /** Route guard: `refuseS3Folder` for the workspace `:id`. */ function s3Guard(req, res, next) { const lane = lanesLib.getLane(Number(req.params.id)); if (lane && refuseS3Folder(res, lane.cwd)) return; next(); } router.post("/gc", sameOriginGuard, (req, res) => { // Machine-wide, so it cannot be narrowed to one member's workspaces. if (scope.memberId(req) != null) { return res .status(403) .json({ error: { code: "EADMIN", message: "only an admin may run housekeeping" } }); } const dryRun = req.body?.dryRun === true; try { const reaped = reapOrphanMcp({ dryRun }); const capped = capOversizedLogs({ dryRun }); res.json({ reaped, capped }); } catch (err) { res.status(500).json({ error: { code: err.code || "ERUNTIME", message: err.message } }); } }); /** * Directory listing for the Add Lane modal's folder browser — browsers cannot * expose absolute filesystem paths from a native picker, so path selection is * done by browsing server-side instead. Read-only; registered ahead of * "/:id" so the literal "browse" segment is never captured as a lane id. * `files=1` (a file field's fallback) lists files too, and opens `path` the * way the native picker would (`startDir`: `~`, a file's folder, else home). * `hidden=1` lists dot entries too — never those at the top of a non-admin's * own folder, where a member's logins live. Each entry carries its `mtime` * (ms), and `home` is the folder the browser's Home place opens. * An admin with a Linux account of their own (S3) starts in THEIR home, like * any member, and the answer carries `hostHome` (the host account's home); * `host=1` opens that one instead — a workspace there runs as the shared * host account, so it is a deliberate tick in the dialog, never the default. */ router.get("/browse", (req, res) => { // A developer browses only their own folder (by real path: a link in it // cannot point the listing elsewhere) — the dashboard lists with its own // rights, CAP_DAC_READ_SEARCH under S3, not theirs. const own = scope.ownFolder(req); const files = req.query.files === "1"; const hidden = req.query.hidden === "1"; // An admin is not held to a folder, but starts in their own home (S3). let adminHome = null; if (!own && req.user?.role === "admin") { try { adminHome = require("../lib/run-as").accountForUser(req.user.username)?.home || null; } catch { // No Linux account for this admin (EMEMBERGONE): the host's home, as before. } } const startHome = adminHome && req.query.host !== "1" ? adminHome : own || os.homedir(); const raw = typeof req.query.path === "string" && req.query.path.trim() ? files ? startDir(req.query.path) : req.query.path : startHome; const resolved = own ? lanesLib.canonicalCwd(path.resolve(raw)) : path.resolve(raw); if (own && !scope.folderAllowed(req, resolved, { allowHome: true })) return refuseFolder(res); let stat; try { stat = fs.statSync(resolved); } catch { return res.status(400).json({ error: { code: "ENOTFOUND", message: "path does not exist" } }); } if (!stat.isDirectory()) { return res .status(400) .json({ error: { code: "ENOTADIR", message: "path is not a directory" } }); } const atOwnTop = Boolean(own) && resolved === lanesLib.canonicalCwd(own); const mtimeOf = (full) => { try { return fs.statSync(full).mtimeMs; } catch { return null; } }; let entries = []; try { entries = fs .readdirSync(resolved, { withFileTypes: true }) .filter( (e) => (e.isDirectory() || (files && e.isFile())) && (!e.name.startsWith(".") || (hidden && !atOwnTop)) ) .map((e) => { const full = path.join(resolved, e.name); const mtime = mtimeOf(full); if (e.isFile()) return { name: e.name, path: full, isGitRepo: false, file: true, mtime }; return { name: e.name, path: full, isGitRepo: fs.existsSync(path.join(full, ".git")), mtime, }; }) // Folders first, as every file picker lists them. .sort((a, b) => !!a.file - !!b.file || a.name.localeCompare(b.name)); } catch { // An unreadable entry mid-listing is skipped, not a request failure. } const top = path.dirname(resolved) === resolved || atOwnTop; const parent = top ? null : path.dirname(resolved); res.json({ path: resolved, parent, entries, home: startHome, ...(adminHome ? { hostHome: os.homedir() } : {}), }); }); /** * The folder browser's New folder: makes ONE folder `name` (a plain name — * no separator, not `.`/`..`) in the existing folder `parent`, never with * `-p` and never over an existing entry (`EEXISTS`). A non-admin makes one * only inside their own folder, judged by real path like `GET /browse`, and * in server mode a non-admin with no folder of their own makes none * (`EFOLDER`) — a read of the host's disk is not a write to it. A folder in a * member's home (S3) is made AS the member, never by the host. */ router.post("/browse/mkdir", sameOriginGuard, (req, res) => { const { parent, name } = req.body || {}; if ( typeof name !== "string" || !name || name === "." || name === ".." || /[/\\\0]/.test(name) || Buffer.byteLength(name) > 255 ) { return res .status(400) .json({ error: { code: "EBADNAME", message: "a folder name without '/' or '\\'" } }); } if (typeof parent !== "string" || !path.isAbsolute(parent)) { return res.status(400).json({ error: { code: "EBADPATH", message: "parent: absolute path" } }); } const own = scope.ownFolder(req); const limited = Boolean(req.user) && req.user.role !== "admin"; if (limited && !own) return refuseFolder(res); const dir = own ? lanesLib.canonicalCwd(path.resolve(parent)) : path.resolve(parent); if (own && !scope.folderAllowed(req, dir, { allowHome: true })) return refuseFolder(res); try { if (!fs.statSync(dir).isDirectory()) throw new Error("not a directory"); } catch { return res .status(400) .json({ error: { code: "ENOTFOUND", message: "parent folder does not exist" } }); } const target = path.join(dir, name); if (fs.existsSync(target) || isSymlink(target)) { return res.status(409).json({ error: { code: "EEXISTS", message: "that name is taken" } }); } try { const runAs = require("../lib/run-as"); const acct = runAs.accountForPath(target); if (acct) runAs.mkdirAs(acct, target); else fs.mkdirSync(target); } catch (err) { const status = err.code === "EMEMBERGONE" ? 409 : err.code === "EEXIST" ? 409 : 500; return res.status(status).json({ error: { code: err.code === "EEXIST" ? "EEXISTS" : err.code || "EMKDIR", message: err.message, }, }); } res.status(201).json({ path: target }); }); /** * Add workspace → Clone: `git clone ` into `/` and make * that folder a workspace (like `POST /ensure`). The URL passes the same * allowlist as `publish` (`validateRemoteUrl`: https with no userinfo, ssh://, * scp-like), `name` is one plain folder name that must not exist yet, and * `parent` is held to the user's own folder exactly like New folder above. * The clone runs AS the folder's account (S3: the member) with THAT * account's own identity (`server/lib/git-identity.js`) — the user's SSH key * or saved HTTPS login, or `credentials` typed for this one https host. * 409 `ECREDENTIALS` (with `auth: {protocol, host}`) asks the browser for a * login, 409 `ESSHAUTH` means the key was refused — 409 like the git bar's * `EGITAUTH`, never 401, which the client reads as "signed out". */ router.post("/clone", sameOriginGuard, async (req, res) => { const body = req.body || {}; const identity = require("../lib/git-identity"); let url; try { url = require("../lib/git-ops").validateRemoteUrl( typeof body.url === "string" ? body.url.trim() : body.url ); } catch (err) { return res.status(400).json({ error: { code: err.code, message: err.message } }); } const name = typeof body.name === "string" && body.name.trim() ? body.name.trim() : identity.defaultName(url); if ( !name || name === "." || name === ".." || /[/\\\0]/.test(name) || Buffer.byteLength(name) > 255 ) { return res .status(400) .json({ error: { code: "EBADNAME", message: "a folder name without '/' or '\\'" } }); } if (typeof body.parent !== "string" || !path.isAbsolute(body.parent)) { return res.status(400).json({ error: { code: "EBADPATH", message: "parent: absolute path" } }); } if (body.tool !== undefined && !aiTools.has(body.tool)) { return res.status(400).json({ error: { code: "EBADTOOL", message: "unknown tool" } }); } const once = body.credentials; if (once != null && (typeof once !== "object" || Array.isArray(once))) { return res.status(400).json({ error: { code: "EBADCRED", message: "credentials: object" } }); } const own = scope.ownFolder(req); const limited = Boolean(req.user) && req.user.role !== "admin"; if (limited && !own) return refuseFolder(res); const dir = own ? lanesLib.canonicalCwd(path.resolve(body.parent)) : path.resolve(body.parent); if (own && !scope.folderAllowed(req, dir, { allowHome: true })) return refuseFolder(res); try { if (!fs.statSync(dir).isDirectory()) throw new Error("not a directory"); } catch { return res .status(400) .json({ error: { code: "ENOTFOUND", message: "parent folder does not exist" } }); } const target = path.join(dir, name); if (fs.existsSync(target) || isSymlink(target)) { return res.status(409).json({ error: { code: "EEXISTS", message: "that name is taken" } }); } if (!scope.folderAllowed(req, target)) return refuseFolder(res); if (scope.folderTakenByOther(req, target)) return refuseOwned(res); const ownerId = scope.ownerFor(req); try { // Before the clone: a workspace this user may not own there, or a tool // that is disabled, is refused up front — not after minutes of cloning, // leaving a clone with no workspace on it. lanesLib.validateTool(body.tool || "claude"); require("../lib/run-as").assertOwnerFits(target, ownerId); await identity.cloneRepo({ url, parent: dir, name, once: once ? { username: once.username, password: once.password, remember: once.remember === true } : null, }); } catch (err) { const status = ["EBADCRED", "EBADHOST", "EBADTOOL", "EDISABLED"].includes(err.code) ? 400 : ["ECREDENTIALS", "ESSHAUTH", "ENOREPO", "EOWNERFOLDER", "EMEMBERGONE"].includes(err.code) ? 409 : 500; return res.status(status).json({ error: { code: err.code || "ECLONE", message: err.message, ...(err.code === "ECREDENTIALS" ? { auth: { protocol: err.protocol, host: err.host } } : {}), }, }); } try { const lane = lanesLib.createLane({ cwd: lanesLib.canonicalCwd(target), owner_id: ownerId, title: typeof body.title === "string" && body.title.trim() ? body.title.trim() : name, ...(body.tool ? { tool: body.tool } : {}), }); broadcastLane(lane.id); return res.status(201).json({ lane: payload(lane), created: true, path: target }); } catch (err) { // The clone stays: it is the user's now, and Repo mode can adopt it. const status = ["EBADCWD", "EBADTOOL", "EDISABLED"].includes(err.code) ? 400 : 500; return res .status(status) .json({ error: { code: err.code || "ELANE", message: err.message }, path: target }); } }); function isSymlink(p) { try { return fs.lstatSync(p).isSymbolicLink(); } catch { return false; } } /** * The OS's own folder/file picker, shown on the SERVER's desktop * (`server/lib/native-dialog.js`), answering `{path}` or `{cancelled: true}`. * Only a client on this machine may ask — anywhere else the dialog would pop * up on a screen nobody is looking at (`EREMOTE`); that client, and a server * with no desktop (`ENODIALOG`), falls back to `GET /browse` above. A request * a reverse proxy forwarded arrives from loopback too, so a forwarding header * counts as remote. An SSH tunnel carries no such header and cannot be told * apart — the dialog is closed when the browser's request goes away. */ router.post("/pick-path", sameOriginGuard, async (req, res) => { if (!isLocalRequest(req)) { return res.status(403).json({ error: { code: "EREMOTE", message: "the native picker opens only for a local browser" }, }); } const kind = req.body?.kind === "file" ? "file" : "dir"; const ac = new AbortController(); res.on("close", () => { if (!res.writableFinished) ac.abort(); }); try { const picked = await pickPath(kind, req.body?.start, ac.signal); if (ac.signal.aborted) return; res.json(picked ? { path: picked } : { cancelled: true }); } catch (err) { if (ac.signal.aborted) return; const status = err.code === "EBUSY" ? 409 : err.code === "ENODIALOG" ? 501 : 500; res.status(status).json({ error: { code: err.code || "EDIALOG", message: err.message } }); } }); router.get("/:id", (req, res) => { const lane = lanesLib.getLane(req.params.id); if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } }); res.json({ lane: payload(lane) }); }); router.post("/", sameOriginGuard, (req, res) => { try { const body = req.body || {}; const cwd = typeof body.cwd === "string" ? lanesLib.canonicalCwd(body.cwd) : body.cwd; if (typeof cwd === "string" && !scope.folderAllowed(req, cwd)) return refuseFolder(res); if (typeof cwd === "string" && scope.folderTakenByOther(req, cwd)) return refuseOwned(res); const lane = lanesLib.createLane({ ...body, owner_id: scope.ownerFor(req) }); broadcastLane(lane.id); res.status(201).json({ lane: payload(lane) }); } catch (err) { if ( err.code === "EBADCWD" || err.code === "EBADPIPELINE" || err.code === "EBADKIND" || err.code === "EBADFIELD" || err.code === "EBADTOOL" || err.code === "EDISABLED" ) { return res.status(400).json({ error: { code: err.code, message: err.message } }); } if (err.code === "EOWNERFOLDER" || err.code === "EMEMBERGONE") { return res.status(409).json({ error: { code: err.code, message: err.message } }); } if (err.code === "SQLITE_CONSTRAINT_UNIQUE" || String(err.message).includes("UNIQUE")) { return res .status(409) .json({ error: { code: "EDUPCWD", message: "a workspace already owns that cwd" } }); } res.status(500).json({ error: { message: err.message } }); } }); router.patch("/:id", sameOriginGuard, (req, res) => { if (!lanesLib.getLane(req.params.id)) { return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } }); } // Server mode: a member edits only what a person types — never the columns // that tie a workspace to a pane or a session (`run_id` pointed at another // user's pane would let stop/reset kill it). const body = req.body && typeof req.body === "object" ? req.body : {}; if (scope.memberId(req) != null) { const off = Object.keys(body).filter((k) => !MEMBER_PATCHABLE.has(k)); if (off.length) { return res.status(403).json({ error: { code: "EFIELD", message: `a member may not change ${off.join(", ")}` }, }); } } let lane; try { lane = lanesLib.updateLane(req.params.id, body); } catch (err) { // A bad `kind`, `pipeline` or field value is invalid input, not a server // fault — every sibling route answers 400 here, so this one must too // instead of throwing into Express. The SQLITE_CONSTRAINT family lands // here for the same reason: a NOT NULL column patched to null is the // caller's mistake, and enumerating which columns those are in JS would // just be the schema written twice. if (err.code === "EBADKIND" || err.code === "EBADPIPELINE" || err.code === "EBADFIELD") { return res.status(400).json({ error: { code: err.code, message: err.message } }); } if (String(err.code || "").startsWith("SQLITE_CONSTRAINT")) { return res.status(400).json({ error: { code: "EBADFIELD", message: err.message } }); } return res.status(500).json({ error: { code: err.code, message: err.message } }); } broadcastLane(lane.id); res.json({ lane: payload(lane) }); }); router.post("/:id/stage", sameOriginGuard, (req, res) => { const current = lanesLib.getLane(req.params.id); if (!current) { return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } }); } // A stage naming no node of this lane's pipeline used to be stored and then // silently ignored by every reader — `agenthub stage test` on a ship-feature // lane (whose node is `gates`) left the CI row reading "never reached" with // no hint anything was wrong. Aliases still resolve, so this rejects typos // and wrong-pipeline names, not shorthand. const requested = req.body && req.body.stage; if (requested !== undefined && requested !== null && requested !== "") { const pipeline = getPipeline(current.pipeline); // No template installed: the pipeline feature is dormant, so there is no // stage to declare — say so instead of listing zero nodes. if (!pipeline.nodes.length) { return res.status(400).json({ error: { code: "ENOPIPELINE", message: "no pipeline template is installed, so this workspace has no stages to declare " + "(drop a template into DASHBOARD_PIPELINES_DIR to turn stages on)", stages: [], }, }); } if (phaseIdx(pipeline, requested) === -1) { return res.status(400).json({ error: { code: "EBADSTAGE", message: `"${requested}" is not a stage of the "${pipeline.id}" pipeline. ` + `Nodes: ${pipeline.nodes.map((n) => n.id).join(", ")}`, stages: pipeline.nodes.map((n) => n.id), }, }); } } const lane = lanesLib.setStage(req.params.id, req.body || {}); broadcastLane(lane.id); res.json({ lane: payload(lane) }); }); router.get("/:id/preflight", async (req, res) => { const lane = lanesLib.getLane(req.params.id); if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } }); const action = String(req.query.action || ""); if (!["reset", "remove", "purge"].includes(action)) { return res .status(400) .json({ error: { code: "EBADACTION", message: `unknown action ${action}` } }); } try { res.json(await preflight(lane, action)); } catch (err) { res.status(500).json({ error: { code: err.code, message: err.message } }); } }); /** * Per-feature state and archive (B) — `lib/lane-features.js`. `GET * /:id/features` lists every feature the lane has ever activated (archived * or live); `GET /:id/features/:slug` shows one, including an archived * one's saved pipeline; `POST /:id/features/activate` switches the live * lane to a feature by slug, archiving whichever one was active first. */ router.get("/:id/features", (req, res) => { const lane = lanesLib.getLane(req.params.id); if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } }); res.json({ features: laneFeatures.listFeatures(lane.id).map(featurePayload) }); }); router.get("/:id/features/:slug", (req, res) => { const lane = lanesLib.getLane(req.params.id); if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } }); const feature = laneFeatures.getFeature(lane.id, req.params.slug); if (!feature) { return res.status(404).json({ error: { code: "ENOFEATURE", message: "no such feature" } }); } res.json({ feature: featurePayload(feature) }); }); router.post("/:id/features/activate", sameOriginGuard, (req, res) => { const lane = lanesLib.getLane(req.params.id); if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } }); const slug = typeof req.body?.slug === "string" ? req.body.slug : ""; if (!slug) { return res.status(400).json({ error: { code: "EBADSLUG", message: "slug is required" } }); } try { const { lane: updated, feature } = laneFeatures.activateFeature(lane.id, slug, { title: req.body?.title, }); broadcastLane(updated.id); res.json({ lane: payload(updated), feature: featurePayload(feature) }); } catch (err) { if (err.code === "EBADSLUG") { return res.status(400).json({ error: { code: err.code, message: err.message } }); } res.status(500).json({ error: { code: err.code, message: err.message } }); } }); router.get("/:id/proof", (req, res) => { const lane = lanesLib.getLane(req.params.id); if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } }); res.json({ features: proofLib.listProof(lane.id) }); }); router.get("/:id/proof/:slug/:group/:file", (req, res) => { const lane = lanesLib.getLane(req.params.id); if (!lane) return res.status(404).send("not found"); const p = proofLib.proofFile(lane.id, req.params.slug, req.params.group, req.params.file); if (!p) return res.status(404).send("not found"); const ext = path.extname(p).toLowerCase(); const contentType = ext === ".html" ? "text/html; charset=utf-8" : ext === ".png" ? "image/png" : "image/jpeg"; res.type(contentType).sendFile(p); }); router.delete("/:id/proof/:slug", sameOriginGuard, s3Guard, (req, res) => { const lane = lanesLib.getLane(req.params.id); if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } }); try { const result = proofLib.deleteProof(lane.id, { slug: req.params.slug, group: req.body?.group, images: req.body?.images, }); res.json(result); } catch (err) { if (err.code === "ENOFEATURE") { return res.status(404).json({ error: { code: err.code, message: err.message } }); } if (err.code === "EBADPATH") { return res.status(400).json({ error: { code: err.code, message: err.message } }); } res.status(500).json({ error: { code: err.code, message: err.message } }); } }); router.post("/:id/proof-link", sameOriginGuard, s3Guard, (req, res) => { const lane = lanesLib.getLane(req.params.id); if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } }); res.json(proofLib.ensureProofLink(lane)); }); /** * Give this lane the same MCP servers as its source repo — relocates the * source repo's already-configured mcpServers (from ~/.claude.json) into * /.mcp.json, pins Playwright's proof output dir, seeds Chromium * profiles. Never automatic, same as proof-link/agents-install: a session * calls this explicitly. Never touches permissions/settings.local.json — * see the F1 design spec for why. */ router.post("/:id/mcp/sync", sameOriginGuard, s3Guard, async (req, res) => { const lane = lanesLib.getLane(req.params.id); if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } }); try { res.json(await syncMcp(lane)); } catch (err) { if (err.code === "ENOMCPCONFIG") { return res.status(400).json({ error: { code: err.code, message: err.message } }); } res.status(500).json({ error: { code: err.code || "ERUNTIME", message: err.message } }); } }); /** * Detect a Node.js project at this lane's OWN directory and scaffold * .agenthub/profile/ if one is found — the HTTP equivalent of * `agenthub lanes profile init`, always targeting lane.cwd (never an arbitrary * path; the CLI's argument has no meaning here, this lane's own * directory is the only sensible target). "No Node.js project detected" is * a normal 200 outcome, not an error — most lanes won't be auto-detectable * and that's fine, same as every other optional profile declaration. */ router.post("/:id/profile/init", sameOriginGuard, s3Guard, (req, res) => { const lane = lanesLib.getLane(req.params.id); if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } }); const facts = detectNode(lane.cwd); if (!facts) { return res.json({ scaffolded: false, reason: "no detectable Node.js project" }); } try { const result = scaffoldProfile(lane.cwd, facts, { force: req.body?.force === true }); res.json({ scaffolded: true, written: result.written, todos: result.todos }); } catch (err) { if (err.code === "EPROFILEEXISTS") { return res.status(400).json({ error: { code: err.code, message: err.message } }); } res.status(500).json({ error: { code: err.code || "ERUNTIME", message: err.message } }); } }); /** * Whether a named integration (tracker, dev_qc, ci_wait, ...) is turned on * for this lane — reads .agenthub/profile/integrations.env. Read-only, no * sameOriginGuard needed (same reasoning GET /:id/git already documents: * that guard exists for destructive actions). */ router.get("/:id/integrations/:name", (req, res) => { const lane = lanesLib.getLane(req.params.id); if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } }); res.json({ enabled: isIntegrationEnabled(lane, req.params.name) }); }); /** * A lane's working-copy facts: branch, short HEAD, that commit's subject, and * the uncommitted counts. Read-only, so no same-origin guard — that guard * exists for the destructive actions. * * Deliberately NOT part of `GET /api/lanes`: this shells out to git five * times, and that payload is polled and re-broadcast on every hook-driven * lane_update. Any failure — no such directory, not a repo, git itself * erroring — is reported as `available: false` rather than a 500, because a * lane pointing at a plain directory is a normal state, not a fault. * * The `/:id/:action` catch-all below cannot shadow this one — that route is a * POST and Express matches on method as well as path. Verified by moving this * registration after it: the suite stayed green. */ router.get("/:id/git", async (req, res) => { const t = await gitTargetOr404(req, res); if (!t) return; try { res.json({ available: true, ...(await gitFacts(t.cwd)), // What the Changes panel's "vs base" compares against: the lane's own // base, else the repo's default branch (an extra folder is another // repo: always its own default branch). base: (!t.source && t.lane.base_branch) || (await defaultBranch(t.cwd)), ...(t.worktree ? { worktree: t.worktree } : {}), }); } catch { res.json({ available: false }); } }); /** * The lane's code changes, in the three shapes the Workspace's Changes panel * asks for — one route, because they are the same read at three depths: * * ?range=work|branch|commit — overview: every changed file + its counts * &full=1 — review: the same list WITH every hunk * &path= — one file's hunks (opened from the overview) * &context= — lines of context per hunk (expand) * * `work` (default) is HEAD vs the working tree, untracked files included; * `branch` is `...HEAD` — the lane's base, else the repo's default * branch — and answers 404-free with ENOBASE when neither exists; `commit` needs `&sha=` and shows that * one commit. Read-only, so no same-origin guard — same reasoning as * GET /:id/git. See server/lib/git-diff.js for the caps. */ router.get("/:id/git/changes", async (req, res) => { const t = await gitTargetOr404(req, res); if (!t) return; const { lane, cwd } = t; const range = ["branch", "commit"].includes(req.query.range) ? req.query.range : "work"; const file = typeof req.query.path === "string" && req.query.path ? req.query.path : null; const full = req.query.full === "1" || req.query.full === "true"; try { const changes = await collectChanges(cwd, { range, base: lane.base_branch, sha: typeof req.query.sha === "string" ? req.query.sha : null, file, full, context: req.query.context, }); res.json({ available: true, ...changes }); } catch (err) { if (["ENOBASE", "ENOFILECHANGE", "EBADRANGE", "EBADREV"].includes(err.code)) { return res.status(400).json({ error: { code: err.code, message: err.message } }); } // Not a git repo (an adopted lane pointed at a plain directory), or a repo // with no commits yet. Same contract as GET /:id/git: a state, not a fault. res.json({ available: false, range, files: [] }); } }); /** * The lane's recent commits, for the history list. `?file=` narrows it to the * commits touching one path — the "history of this file" the single-file view * offers. Reading a commit's own diff is `GET /:id/git/changes?range=commit`. */ router.get("/:id/git/log", async (req, res) => { const t = await gitTargetOr404(req, res); if (!t) return; const { lane, cwd } = t; try { const commits = await listCommits(cwd, { limit: req.query.limit, file: typeof req.query.file === "string" && req.query.file ? req.query.file : null, ref: typeof req.query.ref === "string" && req.query.ref ? req.query.ref : null, }); res.json({ available: true, commits }); } catch (err) { if (err.code === "EBADPATH" || err.code === "ENOBRANCH") { return res.status(400).json({ error: { code: err.code, message: err.message } }); } res.json({ available: false, commits: [] }); } }); /** * One file's whole content at a revision (`?rev=work|HEAD|`), for the * viewer's "show the entire file" mode. Path validation lives in * git-diff.js's `readFileAt` — the `work` revision is the one branch that * touches the filesystem, and it is realpath-confined to the lane (symlinks * included). `.claude/settings.local.json` is 403 ESECRET at every rev. */ router.get("/:id/git/file", async (req, res) => { const t = await gitTargetOr404(req, res); if (!t) return; const { lane, cwd } = t; try { const file = await readFileAt( cwd, typeof req.query.path === "string" ? req.query.path : "", typeof req.query.rev === "string" && req.query.rev ? req.query.rev : "work" ); res.json({ available: true, ...file }); } catch (err) { // A secret file (where a proxy's API key once lived) is refused at every rev. if (err.code === "ESECRET" || err.code === "EFOREIGN") { return res.status(403).json({ error: { code: err.code, message: err.message } }); } if (["EBADPATH", "EBADREV", "ENOFILE"].includes(err.code)) { return res.status(400).json({ error: { code: err.code, message: err.message } }); } res.json({ available: false }); } }); /** * Annotate (`?path=`): who last changed each line of the working-tree file — * git-diff.js's `blameFile`, with the whole-file read's guards (lexical lane * guard, 403 ESECRET, a size cap). A read like `/git/file`, so unguarded too. */ router.get("/:id/git/blame", async (req, res) => { const t = await gitTargetOr404(req, res); if (!t) return; try { const blame = await blameFile(t.cwd, typeof req.query.path === "string" ? req.query.path : ""); res.json({ available: true, ...blame }); } catch (err) { if (err.code === "ESECRET" || err.code === "EFOREIGN") { return res.status(403).json({ error: { code: err.code, message: err.message } }); } if (["EBADPATH", "ENOFILE", "ETOOBIG"].includes(err.code)) { return res.status(400).json({ error: { code: err.code, message: err.message } }); } // Not tracked (git blame has no history for it) or not a repo. res.json({ available: false }); } }); /** * One image's bytes (`?path=&rev=work|HEAD|`) for the viewer's ``: * `readImageAt`'s guards (the file read's, plus an image-extension allowlist * that picks the Content-Type). Served under a `sandbox` CSP with `nosniff`, * so an SVG opened straight from this URL runs no script on the dashboard's * origin. */ router.get("/:id/git/raw", async (req, res) => { const t = await gitTargetOr404(req, res); if (!t) return; try { const { type, buf } = await readImageAt( t.cwd, typeof req.query.path === "string" ? req.query.path : "", typeof req.query.rev === "string" && req.query.rev ? req.query.rev : "work" ); res.set({ "Content-Type": type, "Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; sandbox", "X-Content-Type-Options": "nosniff", "Cache-Control": "no-store", }); res.send(buf); } catch (err) { if (err.code === "ESECRET" || err.code === "EFOREIGN") { return res.status(403).json({ error: { code: err.code, message: err.message } }); } if (["EBADPATH", "EBADREV", "ENOFILE", "ENOTIMAGE", "ETOOBIG"].includes(err.code)) { return res.status(400).json({ error: { code: err.code, message: err.message } }); } res.status(500).json({ error: { code: "EREAD", message: "could not read the image" } }); } }); /** * Save one text file in the lane from the viewer's editor. Guards live in * lane-file-write.js (the read path's own, plus existing-textual-file-only and * a `baseSha` stale check). Answers the file re-read, fresh `sha` included. */ router.put("/:id/git/file", sameOriginGuard, async (req, res) => { const t = await gitTargetOr404(req, res); if (!t) return; const { cwd } = t; const body = req.body || {}; try { await writeLaneFile(cwd, body.path, body.content, body.baseSha); res.json({ available: true, ...(await readFileAt(cwd, body.path, "work")) }); } catch (err) { const status = { ESECRET: 403, EFOREIGN: 403, ESTALE: 409 }[err.code] ?? (["EBADPATH", "ENOFILE", "EBADCONTENT", "ETOOBIG", "EBINARY"].includes(err.code) ? 400 : 500); res.status(status).json({ error: { code: err.code || "EWRITE", message: err.message } }); } }); /** * Download one file of the workspace (`?path=`), any type — lane-file-transfer.js's * guards (the file read's own, `openSafe`, a size cap). Always an attachment * under a `sandbox` CSP with `nosniff`, so nothing in it renders or runs on the * dashboard's origin. A read, so unguarded like `/git/file`. */ router.get("/:id/git/download", async (req, res) => { const t = await gitTargetOr404(req, res); if (!t) return; let file; try { file = await openDownload(t.cwd, typeof req.query.path === "string" ? req.query.path : ""); } catch (err) { if (err.code === "ESECRET" || err.code === "EFOREIGN") { return res.status(403).json({ error: { code: err.code, message: err.message } }); } if (["EBADPATH", "ENOFILE", "ETOOBIG"].includes(err.code)) { return res.status(400).json({ error: { code: err.code, message: err.message } }); } return res.status(500).json({ error: { code: "EREAD", message: "could not read the file" } }); } // RFC 6266: an ASCII fallback plus the exact UTF-8 name. const ascii = file.name.replace(/[^\x20-\x7e]|["\\]/g, "_"); res.set({ "Content-Type": "application/octet-stream", "Content-Length": String(file.size), "Content-Disposition": `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(file.name)}`, "Content-Security-Policy": "default-src 'none'; sandbox", "X-Content-Type-Options": "nosniff", "Cache-Control": "no-store", }); file.stream.on("error", () => res.destroy()); file.stream.pipe(res); }); /** * Upload one file into a folder of the workspace (`?dir=` — "" is its root — * and the `X-File-Name` header, URI-encoded; the body is the raw bytes). It * only CREATES (409 EEXISTS for a taken name), writes into an existing folder * only, never under `.git`, as the folder's member under S3 — * lane-file-transfer.js. Same-origin, like every lane write. */ const uploadBody = (req, res, next) => express.raw({ type: () => true, limit: UPLOAD_MAX_BYTES })(req, res, (err) => { if (!err) return next(); const big = err.type === "entity.too.large"; res.status(big ? 413 : 400).json({ error: big ? { code: "ETOOBIG", message: "a file may be at most 25 MB" } : { code: "EBADBODY", message: "could not read the upload" }, }); }); router.post("/:id/git/upload", sameOriginGuard, uploadBody, async (req, res) => { const t = await gitTargetOr404(req, res); if (!t) return; let name = String(req.headers["x-file-name"] || ""); try { name = decodeURIComponent(name); } catch { /* a header that is not URI-encoded is used as sent */ } try { const saved = await saveUpload( t.cwd, typeof req.query.dir === "string" ? req.query.dir : "", name, Buffer.isBuffer(req.body) ? req.body : null ); res.status(201).json(saved); } catch (err) { const status = { ESECRET: 403, EFOREIGN: 403, EEXISTS: 409, ETOOBIG: 413, EMEMBERGONE: 403 }[err.code] ?? (["EBADPATH", "EBADNAME", "ENOFILE", "EBADCONTENT"].includes(err.code) ? 400 : 500); res.status(status).json({ error: { code: err.code || "EWRITE", message: err.message } }); } }); /** Tracked paths matching `?q=`, for quick-open. */ router.get("/:id/git/ls", async (req, res) => { const t = await gitTargetOr404(req, res); if (!t) return; const { cwd } = t; try { res.json({ available: true, ...(await listPaths(cwd, { q: req.query.q })) }); } catch { res.json({ available: false, paths: [], truncated: false, total: 0 }); } }); /** Every level of the tracked tree in one read, for "expand all folders". */ router.get("/:id/git/tree-all", async (req, res) => { const t = await gitTargetOr404(req, res); if (!t) return; const { cwd } = t; try { res.json({ available: true, ...(await listTreeAll(cwd)) }); } catch { res.json({ available: false, levels: {}, truncated: false }); } }); /** One directory level of the explorer tree (tracked + untracked-not-ignored), status-decorated. */ router.get("/:id/git/tree", async (req, res) => { const t = await gitTargetOr404(req, res); if (!t) return; const { lane, cwd } = t; try { const subdir = typeof req.query.path === "string" ? req.query.path : ""; res.json({ available: true, ...(await listTree(cwd, { subdir })) }); } catch (err) { if (err.code === "EBADPATH") { return res.status(400).json({ error: { code: err.code, message: err.message } }); } res.json({ available: false, path: "", entries: [], truncated: false }); } }); /** * The lane's own local branches, for the branch switcher. Separate from * `GET /branches` (which takes a candidate repo PATH from the query, for a * lane that does not exist yet); this one resolves the repo from the lane row, * so no path ever comes from the request. */ router.get("/:id/git/branches", async (req, res) => { const t = await gitTargetOr404(req, res); if (!t) return; const { lane, cwd } = t; try { const { branches, current } = await listBranches(cwd); const worktrees = await listWorktrees(cwd).catch(() => []); res.json({ available: true, branches, current, worktrees }); } catch { res.json({ available: false, branches: [], current: null, worktrees: [] }); } }); /** * The git WRITES the dashboard offers — the list is `ACTIONS` in * server/lib/git-ops.js (sync, stage/discard, commit/amend/undo-commit, * branches, merge, cherry-pick, revert). * Guarded (they mutate), serialized per git index (`withLaneLock` keyed on * the worktree's git dir, so a claude and a codex lane on one directory share * it) so a push and a commit fired from two tabs or two lanes cannot * interleave on one index, and thin — every * rule about what each action may do lives in server/lib/git-ops.js, not here. * `git pull` is --ff-only and `switch` refuses a dirty tree, by design there. */ /** * A commit message suggested by the user's `claude` CLI for what is STAGED * (server/lib/commit-message.js). Not a git write — it only reads the index — * but same-origin, because each call spends the user's Claude quota. * Registered before `/:id/git/:action`, which would answer EBADACTION. */ router.post("/:id/git/commit-message", sameOriginGuard, async (req, res) => { const t = await gitTargetOr404(req, res); if (!t) return; try { const paths = Array.isArray(req.body?.paths) ? req.body.paths : undefined; res.json({ message: await suggestCommitMessage(t.cwd, { paths }) }); } catch (err) { const status = ["ENOSTAGED", "ENOTOOL", "EBADPATH"].includes(err.code) ? 400 : err.code === "EAI" ? 502 : 500; res.status(status).json({ error: { code: gitErrorCode(err), message: err.message } }); } }); router.post("/:id/git/:action", sameOriginGuard, async (req, res) => { const t = await gitTargetOr404(req, res); if (!t) return; const { lane, cwd } = t; try { // Keyed on the INDEX, not the lane id: a claude and a codex lane on one // cwd — or a root lane and a subdirectory lane of one repo — share one // index, and their writes would otherwise race on index.lock. The git dir // is per worktree, so sibling worktree lanes still run in parallel; a // cwd that is not a repo falls back to its real path. const gitDir = await git(cwd, ["rev-parse", "--absolute-git-dir"]) .then((r) => r.stdout.trim()) .catch(() => fs.promises.realpath(cwd).catch(() => cwd)); const result = await withLaneLock(`git:${gitDir}`, () => runGitAction(cwd, req.params.action, req.body || {}) ); res.json(result); } catch (err) { const badRequest = [ "EBADACTION", "EBADMESSAGE", "EBADBRANCH", "ENOBRANCH", "EBRANCHEXISTS", "EBRANCHBUSY", "EDIRTY", "ENOCHANGES", "EBADPATH", "ECONFIRM", "EPROTECTED", "ESTAGEDOUTSIDE", "ENOREMOTE", "EUNTRACKED", "EBADHOST", "EBADCRED", "EMEMBERCRED", "ESUBDIR", "EBADSIDE", "EMARKERS", "EBADURL", "EREMOTEEXISTS", "ENOCOMMITS", "EBADSHA", "EMERGECOMMIT", "ENOPARENT", "ETRACKED", ]; // A numeric code is git's own exit status (execFile reports one that way): // git ran and refused — a rejected push, a failed credential, a diverged // pull. That is the repository's state answering a valid request, not a // server fault, so it is a 409 under the string code the client expects. const exited = typeof err.code === "number"; // EHUNKGONE is the same kind of answer: the tree moved under the click. const status = badRequest.includes(err.code) ? 400 : exited || [ "EHUNKGONE", "EGITAUTH", "EMERGING", "ENOMERGE", "ECONFLICTS", "EPUSHED", "EEMPTY", "ESTALE", "EMEMBERGONE", ].includes(err.code) ? 409 : 500; // git's own stderr is the useful half of a failed push or pull (a // rejection, a missing credential, a diverged branch), so it joins the // message rather than riding a side field the client would have to know // about — `message` is the one thing every caller already reads. const detail = err.git ? (err.git.stderr || "").trim() : ""; res.status(status).json({ error: { code: gitErrorCode(err), message: detail ? `${err.message}\n${detail}` : err.message, // EGITAUTH: which host wants a login, for the git bar's form. Never // a secret — just protocol, host, a username hint and `rejected`. ...(err.auth ? { auth: err.auth } : {}), }, }); } }); /** * Claude Desktop's "archive a worktree session": remove the git worktree a * session of this workspace ran in (`claude --worktree`), then its branch when * git agrees it is merged. The session is named, never a path; the tree comes * from `sessionWorktree`. Refused while a live pane holds the session * (`ELIVE`), and every refusal git itself makes — a dirty tree, an unmerged * branch (kept and reported) — stands: nothing here forces. */ router.post("/:id/session-worktree/remove", sameOriginGuard, async (req, res) => { const lane = laneOr404(req, res); if (!lane) return; const sessionId = typeof req.body?.session === "string" ? req.body.session : ""; const held = runs .listRuns() .some((r) => r.laneId === lane.id && r.status === "running" && r.sessionId === sessionId); if (held) { return res.status(409).json({ error: { code: "ELIVE", message: "a pane is still running that session — exit it first" }, }); } try { res.json(await removeSessionWorktree(lane, sessionId, { confirm: req.body?.confirm })); } catch (err) { // git ran and refused (a dirty tree): the repository answering, a 409. const refused = !!err.git; const status = ["ECONFIRM", "ENOWORKTREE"].includes(err.code) ? 400 : err.code === "ELOCKED" || refused ? 409 : 500; const detail = refused ? (err.git.stderr || "").trim() : ""; res.status(status).json({ error: { code: refused ? "EGIT" : err.code || "EGIT", message: detail ? `${err.message}\n${detail}` : err.message, }, }); } }); /** * A single GitHub Actions status snapshot for one ref (branch or commit sha) * in the lane's repo — NOT a poll loop. ship-feature-lane's Stage 13 owns * the wait/retry decision (docs/LANES.md "Orchestration: what AgentHub does NOT * do"); this route only reports what `gh` currently knows. Read-only, no * same-origin guard, same reasoning as GET /:id/git. */ router.get("/:id/ci/status", async (req, res) => { const lane = laneOr404(req, res); if (!lane) return; const ref = typeof req.query.ref === "string" ? req.query.ref.trim() : ""; if (!ref) { return res.status(400).json({ error: { code: "EBADREF", message: "ref is required" } }); } try { res.json(await ciStatus(lane.cwd, ref)); } catch (err) { sendRuntimeError(res, err); } }); /** * Create a dashboard-managed worktree lane. Git work happens after the 202 * response because provisioning a large repository can take seconds. */ router.post("/worktree", sameOriginGuard, async (req, res) => { const body = req.body || {}; // Checked before any git work: a worktree for a tool that cannot get a lane // would be left behind with no row pointing at it. try { lanesLib.validateTool(body.tool === undefined ? "claude" : body.tool); } catch (err) { return res.status(400).json({ error: { code: err.code, message: err.message } }); } const sourceRepo = body.sourceRepo; // Before any git work (which would already run as the member). if ( typeof sourceRepo === "string" && path.isAbsolute(sourceRepo) && refuseS3Folder(res, lanesLib.canonicalCwd(sourceRepo)) ) { return; } if ( typeof sourceRepo !== "string" || !path.isAbsolute(sourceRepo) || !fs.existsSync(sourceRepo) || !(await isGitRepo(sourceRepo)) ) { return res.status(400).json({ error: { code: "EBADSOURCEREPO", message: "sourceRepo must be an existing absolute git repository", }, }); } if (!scope.folderAllowed(req, lanesLib.canonicalCwd(sourceRepo))) return refuseFolder(res); if (scope.folderTakenByOther(req, lanesLib.canonicalCwd(sourceRepo))) return refuseOwned(res); // A repo with no commit has nothing a worktree could branch from: git would // fail later, in the background job, and leave a `failed` row behind. Refuse // it here instead — that repo is a Repo lane until its first commit. if (!(await listBranches(sourceRepo)).branches.length) { const unborn = await git(sourceRepo, ["rev-parse", "--verify", "HEAD"]).then( () => false, () => true ); if (unborn) { return res.status(400).json({ error: { code: "EEMPTYREPO", message: "sourceRepo has no commits yet — add it as a Repo workspace and commit first", }, }); } } let slug; try { slug = slugify(body.slug || body.title); } catch (err) { return res.status(400).json({ error: { code: err.code, message: err.message } }); } const resolvedSourceRepo = path.resolve(sourceRepo); const repoName = path.basename(resolvedSourceRepo); const originalSlug = slug; let dir = path.join(LANES_ROOT, `${repoName}__${slug}`); let attempts = 0; while (fs.existsSync(dir) && attempts < MAX_WORKTREE_DIRECTORY_ATTEMPTS) { attempts += 1; const suffix = attempts + 1; slug = `${originalSlug}-${suffix}`; dir = path.join(LANES_ROOT, `${repoName}__${slug}`); } if (fs.existsSync(dir)) { return res.status(409).json({ error: { code: "EWORKTREEDIRCOLLISION", message: "could not allocate a unique worktree directory after 50 attempts", }, }); } let branch; if (body.branch !== undefined) { if (!(await isValidBranchName(resolvedSourceRepo, body.branch))) { return res.status(400).json({ error: { code: "EBADBRANCH", message: "branch is not a valid git branch name" }, }); } branch = body.branch; } else { const branchPrefix = process.env.LANE_BRANCH_PREFIX || "feat/"; branch = `${branchPrefix}${slug}`; } let lane; try { lane = lanesLib.createLane({ title: body.title || "", cwd: dir, branch, kind: "managed", owner_id: scope.ownerFor(req), source_repo: resolvedSourceRepo, base_branch: body.base || null, slug, ...(body.pipeline ? { pipeline: body.pipeline } : {}), ...(body.tool ? { tool: body.tool } : {}), }); lane = lanesLib.updateLane(lane.id, { status: "provisioning" }); } catch (err) { if (err.code === "SQLITE_CONSTRAINT_UNIQUE" || String(err.message).includes("UNIQUE")) { return res .status(409) .json({ error: { code: "EDUPCWD", message: "a workspace already owns that cwd" } }); } return res.status(500).json({ error: { code: err.code, message: err.message } }); } res.status(202).json({ lane: payload(lane) }); void withLaneLock(lane.id, async () => { try { const baseBranch = await resolveBase( resolvedSourceRepo, body.base || process.env.LANE_BASE_BRANCH || "main" ); await addWorktree({ sourceRepo: resolvedSourceRepo, dir, branch, base: baseBranch }); // base_branch is a provisioning fact, not a patchable field — see PATCHABLE. lanesLib.setProvisioningFacts(lane.id, { base_branch: baseBranch }); // A2 data isolation: only when the repo actually declares a profile — a // worktree lane with none is a normal state (nothing about A1 required // one either), so this is a no-op rather than a provisioning failure. const worktreeLane = lanesLib.getLane(lane.id); const profile = resolveProfile(worktreeLane); if (profile) { const onLine = (line, stream) => broadcast("lane_hook_output", { laneId: lane.id, hook: "provision", stream, line }); await provisionLane(worktreeLane, { onLine }); } lanesLib.updateLane(lane.id, { status: "idle", notes: null }); } catch (err) { lanesLib.updateLane(lane.id, { status: "failed", notes: err.git?.stderr || err.message, }); } broadcastLane(lane.id); }); }); const ACTIONS = new Set(["start", "stop", "message", "clear", "reset", "remove", "purge"]); const DESTRUCTIVE_ACTIONS = new Set(["reset", "remove", "purge"]); const RUN_EXIT_POLL_MS = 50; // killRun escalates from SIGTERM to SIGKILL after five seconds. Leave enough // time for that escalation and for Node to receive the child's real exit. const RUN_EXIT_TIMEOUT_MS = 7500; function lifecycleError(code, message) { return Object.assign(new Error(message), { code }); } function expectedFields(action) { return action === "purge" ? ["sessions", "events", "tokenRows"] : ["head", "dirty", "untracked", "unpushed"]; } /** Refuse a destructive action when the facts shown in its preflight have moved. */ function assertExpectedPreflight(action, current, expected) { const fields = expectedFields(action); if ( !expected || typeof expected !== "object" || Array.isArray(expected) || fields.some((field) => !Object.hasOwn(expected, field)) ) { throw lifecycleError( "EEXPECT", `${action} requires a complete expect object with: ${fields.join(", ")}` ); } const changed = fields.some((field) => current[field] !== expected[field]); if (changed) { const err = lifecycleError("ESTALE", "workspace state changed since preflight"); err.expected = expected; err.current = current; throw err; } } function wait(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } /** Every tmux session a lane holds: its recorded `run_id` plus the extra * split-pane sessions (`agenthub-lane--`), which never touch it. */ function laneRunIds(lane) { const ids = new Set( runs .listRuns() .filter((r) => r.laneId === lane.id) .map((r) => r.id) ); // Only this lane's own panes: a run_id naming another lane's is ignored. if (lane.run_id && new RegExp(`^agenthub-lane-${lane.id}(-\\d+)?$`).test(lane.run_id)) { ids.add(lane.run_id); } return [...ids]; } /** Kill a lane's runs and wait for their tmux sessions to exit before touching its cwd. */ async function stopLaneRun(lane) { const ids = laneRunIds(lane); if (ids.length === 0) return; for (const id of ids) { try { runs.killRun(id); } catch { /* a concurrently completed run is already safe */ } } const deadline = Date.now() + RUN_EXIT_TIMEOUT_MS; for (const id of ids) { let run = runs.getRun(id); while (run && run.status !== "gone") { if (Date.now() >= deadline) { throw lifecycleError( "ERUNTIMEOUT", `workspace run ${id} did not exit within ${RUN_EXIT_TIMEOUT_MS / 1000} seconds` ); } await wait(RUN_EXIT_POLL_MS); run = runs.getRun(id); } } lanesLib.updateLane(lane.id, { run_id: null }); } function sendLifecycleError(res, err) { if (["ENOTMANAGED", "EOUTSIDEROOT", "ENOTWORKTREE", "EEXPECT"].includes(err.code)) { return res.status(400).json({ error: { code: err.code, message: err.message } }); } // ERUNLIVE: the lane's pane is busy with a live agent, so a resume/prompt // could not be delivered — a conflict, not a bad request or a server fault. if (["ESTALE", "EUNPUSHED", "ERUNLIVE"].includes(err.code)) { return res.status(409).json({ error: { code: err.code, message: err.message, ...(err.code === "ESTALE" ? { expected: err.expected, current: err.current } : {}), }, }); } if (err.git) { return res.status(500).json({ error: { code: gitErrorCode(err), message: err.message, stderr: err.git.stderr }, }); } return res.status(500).json({ error: { code: err.code, message: err.message } }); } /* --------------------------------------------------------------------------- * Runtime: a lane's own stack, isolated by slot-derived ports and directories. * * These routes are registered BEFORE the "/:id/:action" catch-all below, which * would otherwise swallow "up" and "down" as unknown actions. They are also * deliberately NOT folded into that catch-all: it drives a lane's Claude RUN, * while these drive the application the lane is working on — two different * lifecycles that happen to share a lane id. * * None of them writes `stage`, `status` or `notes`. A booted stack is not an * agent at work, and only `slot`/`ports` describe the runtime. * ------------------------------------------------------------------------ */ /** Map a runtime error onto its status code. */ function sendRuntimeError(res, err) { const badRequest = [ "ENOPROFILE", "ENOHOOK", "EBADLANEDIR", "EBADSVC", "EBADBRANCH", "EUNRESOLVED", "EMERGEUNCOMMITTED", "ENOMCPCONFIG", ]; if (badRequest.includes(err.code)) { return res.status(400).json({ error: { code: err.code, message: err.message, ...(err.searched ? { searched: err.searched } : {}), }, }); } if (err.code === "ESLOTS") { return res.status(409).json({ error: { code: err.code, message: err.message } }); } if (err.code === "EPORTBUSY") { return res.status(409).json({ error: { code: err.code, message: err.message, port: err.preferred, pids: err.pids }, }); } return res.status(500).json({ error: { code: err.code || "ERUNTIME", message: err.message, logPath: err.logPath }, }); } /** * The string `error.code` for an error that may have come out of `git()`. * When git exits non-zero, execFile sets `err.code` to the exit STATUS (a * number, e.g. 128); the client branches on string codes, so that becomes * `EGIT`. A missing code (a timeout kill) is `EGIT` too; string codes pass. */ function gitErrorCode(err) { return typeof err.code === "string" && err.code ? err.code : "EGIT"; } /** Resolve `:id` or answer 404. Returns null once the response has been sent. */ /** * Which tree a git route works in: the workspace's own folder, or — with * `?session=` — that session's worktree (`claude --worktree`), resolved by * `sessionWorktree` from the session ROW (owned by this workspace, registered * by `git worktree list`), or — with `?source=` — one of the workspace's * extra folders, resolved by `sourceFolder` from its `lane_sources` ROW; never * from a path in the request. An unresolvable session (400) or source (404, * like another workspace's row) is never a silent fall back to the main * folder: a commit or a discard must never land in the wrong tree. */ async function gitTargetOr404(req, res) { const lane = laneOr404(req, res); if (!lane) return null; if (req.query.source !== undefined) { try { const cwd = sourceFolder(lane.id, req.query.source); return { lane, cwd, worktree: null, source: true }; } catch (err) { res.status(404).json({ error: { code: err.code, message: err.message } }); return null; } } const session = req.query.session; if (session === undefined) return { lane, cwd: lane.cwd, worktree: null }; try { const wt = await sessionWorktree(lane, String(session)); return { lane, cwd: wt.path, worktree: { path: wt.path, branch: wt.branch } }; } catch (err) { res.status(400).json({ error: { code: err.code, message: err.message } }); return null; } } function laneOr404(req, res) { const lane = lanesLib.getLane(req.params.id); if (!lane) { res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } }); return null; } return lane; } /** * What is running for this lane, computed fresh. * * Follows `GET /:id/git`'s contract: a lane with no profile answers * `{available:false}` with HTTP 200, because that is a normal state and not a * fault. It probes ports and stats pid files, which is why it is its own endpoint * rather than a field on the polled lane list. */ router.get("/:id/runtime", async (req, res) => { const lane = laneOr404(req, res); if (!lane) return; try { res.json(await runtimeFacts(lane)); } catch (err) { sendRuntimeError(res, err); } }); /** * Tail one of the lane's hook or service logs. * * `:svc` is resolved against the log directory's real contents and the result is * confined to that directory after `realpath`, so a name from the request can * never escape it. */ router.get("/:id/logs/:svc", (req, res) => { const lane = laneOr404(req, res); if (!lane) return; if (!lane.slot) return res.json({ available: false }); const { logDir } = slotDirs(lane.slot); const file = path.resolve(logDir, `${req.params.svc}.log`); let real; try { real = fs.realpathSync(file); const relative = path.relative(fs.realpathSync(logDir), real); if (relative.startsWith("..") || path.isAbsolute(relative)) throw new Error("escapes log dir"); } catch { return res.status(404).json({ error: { code: "ENOLOG", message: "no such log" } }); } const requested = Number(req.query.tail); const tail = Math.min( Number.isInteger(requested) && requested > 0 ? requested : LOG_TAIL_DEFAULT, LOG_TAIL_MAX ); const { size } = fs.statSync(real); const start = Math.max(0, size - tail); const fd = fs.openSync(real, "r"); try { const buffer = Buffer.alloc(size - start); fs.readSync(fd, buffer, 0, buffer.length, start); res.json({ available: true, svc: req.params.svc, size, truncated: start > 0, text: buffer.toString("utf8"), }); } finally { fs.closeSync(fd); } }); /** * Boot the lane's stack. 202 + background, like `POST /worktree`: a build can run * for minutes and the caller should not hold a socket open for it. Progress * streams as `lane_hook_output`; completion re-broadcasts the lane, whose `ports` * the boot may have changed. */ router.post("/:id/up", sameOriginGuard, s3Guard, (req, res) => { const lane = laneOr404(req, res); if (!lane) return; try { requireProfile(lane); } catch (err) { return sendRuntimeError(res, err); } const build = req.body?.build !== false; const qc = req.body?.qc === true; res.status(202).json({ ok: true, laneId: lane.id }); void withLaneLock(lane.id, async () => { const onLine = (line, stream) => broadcast("lane_hook_output", { laneId: lane.id, hook: "up", stream, line }); try { const facts = await upLane(lanesLib.getLane(lane.id), { build, qc, onLine }); broadcast("lane_runtime", { laneId: lane.id, runtime: facts }); } catch (err) { broadcast("lane_runtime", { laneId: lane.id, error: { code: err.code || "ERUNTIME", message: err.message }, }); } broadcastLane(lane.id); }); }); /** Stop the lane's stack. Fast and idempotent, so it answers synchronously. */ router.post("/:id/down", sameOriginGuard, s3Guard, async (req, res) => { const lane = laneOr404(req, res); if (!lane) return; try { const result = await withLaneLock(lane.id, () => downLane(lanesLib.getLane(lane.id))); const facts = await runtimeFacts(lanesLib.getLane(lane.id)); broadcast("lane_runtime", { laneId: lane.id, runtime: facts }); res.json({ ok: true, killed: result.killed, runtime: facts }); } catch (err) { sendRuntimeError(res, err); } }); /** * Run one of the profile's hooks on the lane — the surface a driving session uses * for `ci-gate`, `e2e`, `migrate` and friends. * * `:name` is checked against the hook allowlist BEFORE anything is spawned, and * `args` travels as an array of strings straight into argv. Neither is ever * joined into a command string. */ router.post("/:id/hook/:name", sameOriginGuard, s3Guard, (req, res) => { const lane = laneOr404(req, res); if (!lane) return; const { name } = req.params; if (!HOOKS.includes(name)) { return res .status(400) .json({ error: { code: "ENOHOOK", message: `unknown hook ${name}`, allowed: HOOKS } }); } const args = Array.isArray(req.body?.args) ? req.body.args.map(String) : []; let profile; try { profile = requireProfile(lane); } catch (err) { return sendRuntimeError(res, err); } if (!lane.slot) { return res.status(409).json({ error: { code: "ENOSLOT", message: "workspace has no runtime yet — bring it up first" }, }); } res.status(202).json({ ok: true, laneId: lane.id, hook: name }); void withLaneLock(lane.id, async () => { const onLine = (line, stream) => broadcast("lane_hook_output", { laneId: lane.id, hook: name, stream, line }); try { const result = await runHook(lanesLib.getLane(lane.id), profile, name, args, { onLine }); broadcast("lane_hook_result", { laneId: lane.id, hook: name, code: result.code }); } catch (err) { broadcast("lane_hook_result", { laneId: lane.id, hook: name, code: null, error: { code: err.code || "ERUNTIME", message: err.message }, }); } }); }); /** * The ONE sanctioned merge in the ship-feature-lane pipeline: origin/development * INTO a feature branch, gated by a migration-number collision preflight. * Synchronous — a fetch + collision-check + merge is seconds of git work, not * the minutes a build/test hook can take, so this follows GET /:id/git's * pattern rather than the hook route's 202-and-broadcast. * * Returns 200 with {code: 0|4|5, ...} for every DOCUMENTED outcome — a * migration collision or a left-in-place conflict is an expected result, not * an HTTP error. A malformed request, a missing profile, or an out-of-order * --continue is the only case that answers with an `error` body. * * Never writes stage/status/notes — same boundary the hook and runtime * routes already keep; the caller decides what a collision or conflict means * for the lane's declared stage. */ router.post("/:id/sync-base", sameOriginGuard, s3Guard, async (req, res) => { const lane = laneOr404(req, res); if (!lane) return; let profile; try { profile = requireProfile(lane); } catch (err) { return sendRuntimeError(res, err); } const mode = ["check", "merge", "continue"].includes(req.body?.mode) ? req.body.mode : "merge"; const branch = typeof req.body?.branch === "string" && req.body.branch ? req.body.branch : undefined; try { const result = await withLaneLock(lane.id, () => { const current = lanesLib.getLane(lane.id); if (mode === "check") return checkSync(current, profile, branch); if (mode === "continue") return continueSync(current, profile, branch); return mergeSync(current, profile, branch); }); res.json(result); } catch (err) { sendRuntimeError(res, err); } }); /** * Lane control. Deliberately thin: every action maps onto one existing * lifecycle function. There is no queue, no chaining, no gate evaluation — the * dashboard drives a lane, it does not orchestrate a pipeline. */ router.post("/:id/:action", sameOriginGuard, async (req, res) => { const { action } = req.params; if (!ACTIONS.has(action)) { return res .status(400) .json({ error: { code: "EBADACTION", message: `unknown action ${action}` } }); } const lane = lanesLib.getLane(req.params.id); if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } }); const body = req.body || {}; if (DESTRUCTIVE_ACTIONS.has(action)) { if (body.confirm !== true) { return res.status(400).json({ error: { code: "ECONFIRM", message: `${action} requires confirm: true` }, }); } try { const result = await withLaneLock(lane.id, async () => { const lockedLane = lanesLib.getLane(lane.id); if (!lockedLane) throw lifecycleError("ENOLANE", "workspace not found"); await stopLaneRun(lockedLane); // A running stack holds files open in the very directory `reset` is about // to rewrite, and either way its processes would outlive the lane still // bound to its ports. Stop it before touching git or dropping the row. // Idempotent and a no-op for a lane that was never brought up. if (action === "reset" || action === "remove") { await downLane(lanesLib.getLane(lane.id)); } const current = lanesLib.getLane(lane.id); const facts = await preflight(current, action); assertExpectedPreflight(action, facts, body.expect); // Only `reset` can lose committed work now — `remove` forgets the row and // touches neither the worktree nor the branch, so unpushed commits are // nothing it can discard. if (action === "reset" && facts.unpushed > 0 && body.force !== true) { throw lifecycleError( "EUNPUSHED", `${action} requires force: true when commits are unpushed` ); } if (action === "reset") { await resetWorktree(current); const resetLane = lanesLib.clearLane(current.id); // A2 data isolation, only when the lane actually has both a profile // and an allocated slot — a lane that was never brought up has // nothing of this kind to reset. const profile = resolveProfile(resetLane); if (profile && resetLane.slot) { const onLine = (line, stream) => broadcast("lane_hook_output", { laneId: resetLane.id, hook: "reset", stream, line }); await resetLaneData(resetLane, profile, { keepDb: body.keepDb === true, onLine }); } return { lane: lanesLib.getLane(current.id) }; } if (action === "remove") { // `remove` forgets the lane, it does not destroy it. The worktree // directory, its branch and the lane's own database(s) are the USER's // data and outlive the dashboard's record of them — one mis-click here // used to cost a whole branch's unpushed code, and no dashboard row is // worth that. Deleting any of it is the user's own call, by hand // (`git worktree remove` / `git branch -d` / dropping the db). // // Runtime bookkeeping is the exception, because it is AgentHub's and // not the user's: pid files and hook // logs under .state/lane/ would be inherited by whichever lane // claims that slot next. Deleting the row is what frees the slot — // usedSlots() reads the table, so there is nothing else to release. if (current.slot) { fs.rmSync(slotDirs(current.slot).stateDir, { recursive: true, force: true }); } lanesLib.deleteLane(current.id); return { removed: current.id }; } return { purged: lanesLib.purgeLaneSessions(current.id) }; }); if (action === "remove") { broadcast("lane_update", { removed: result.removed }); return res.json({ ok: true }); } if (action === "purge") { broadcastLane(lane.id); return res.json({ ok: true, purged: result.purged }); } broadcastLane(lane.id); return res.json({ lane: payload(result.lane) }); } catch (err) { if (err.code === "ENOLANE") { return res.status(404).json({ error: { code: err.code, message: err.message } }); } return sendLifecycleError(res, err); } } // The pane a `start` targets: 0 is the lane's own session (`run_id`), 1–3 // are the Workspace split panes' extra sessions on the same lane. let startedRunId = null; try { switch (action) { case "start": { // A folder that is gone (or never was) would start the pane in the // account's home instead, under this workspace's name. EACCES is not // "gone": a member's folder may be unreadable to this process. try { if (!fs.statSync(lane.cwd).isDirectory()) throw Object.assign(new Error(), { code: "ENOTDIR" }); } catch (err) { // A member's new project folder is made for them, as them. const made = err.code === "ENOENT" && require("../lib/run-as").makeMemberFolder(lane.cwd); if (!made && (err.code === "ENOENT" || err.code === "ENOTDIR")) { return res.status(409).json({ error: { code: "ENOCWD", message: `the workspace folder does not exist: ${lane.cwd}`, }, }); } } // `"auto"`: the first free tab, picked under the lane lock below — a // client's own pick comes from a list that may already be stale. const auto = body.slot === "auto"; let slot = body.slot === undefined || auto ? 0 : body.slot; if (!Number.isInteger(slot) || slot < 0 || slot > runs.MAX_PANE_SLOT) { return res.status(400).json({ error: { code: "EBADSLOT", message: `slot must be an integer 0–${runs.MAX_PANE_SLOT}` }, }); } const isLive = (laneId, s) => runs.getRun(runs.runIdForLane(laneId, s))?.status === "running"; // Under the lock: the free slot, or null when every tab is busy. const pickSlot = (laneId) => { if (!auto) return slot; for (let s = 0; s <= runs.MAX_PANE_SLOT; s++) if (!isLive(laneId, s)) return s; return null; }; const allBusy = () => res.status(409).json({ error: { code: "ERUNLIVE", message: "every tab of this workspace is busy" }, }); // A Terminal tab: a plain shell in the slot. No agent is at work in // it, so the lane's run_id/status stay as they are. if (body.shell === true) { const shellOutcome = await withLaneLock(lane.id, async () => { const current = lanesLib.getLane(lane.id); if (!current) return { missing: true }; const picked = pickSlot(current.id); if (picked === null) return { allBusy: true }; slot = picked; if (isLive(current.id, slot)) return { conflict: true }; startedRunId = runs.spawnShell({ laneId: current.id, slot, cwd: current.cwd }).id; return {}; }); if (shellOutcome.missing) { return res .status(404) .json({ error: { code: "ENOLANE", message: "workspace not found" } }); } if (shellOutcome.allBusy) return allBusy(); if (shellOutcome.conflict) { return res.status(409).json({ error: { code: "ERUNLIVE", message: "that tab already has a live session" }, }); } break; } // A session that ran in a worktree of its own resumes (or forks) THERE: // Claude finds a session by the folder it ran in. Resolved from the // session row, before the lock; anything else starts as it always did. let resumeWorktree = null; if (typeof body.resumeSessionId === "string" && body.resumeSessionId) { resumeWorktree = await sessionWorktree(lane, body.resumeSessionId).catch(() => null); const row = resumeWorktree ? null : db .prepare("SELECT cwd, lane_id FROM sessions WHERE id = ?") .get(body.resumeSessionId); // One of this workspace's worktree sessions whose worktree is gone // (Claude removed it on exit): there is no folder to resume it in. if ( row && row.lane_id === lane.id && /\/\.claude\/worktrees\/[^/]+\/?$/.test(row.cwd || "") ) { return res.status(400).json({ error: { code: "ENOWORKTREE", message: "that session's worktree no longer exists" }, }); } } // The SESSION's AI tool: a resume keeps the CLI its session ran on // (`sessions.tool`); a new session takes the catalog id the request // names — known and enabled, only an id, never a command — and the // workspace's own tool otherwise. const resumeId = body.resumeSessionId || (body.resume ? lane.session_id : null); let sessionTool = null; if (resumeId) { sessionTool = db.prepare("SELECT tool FROM sessions WHERE id = ?").get(resumeId)?.tool || null; } else if (body.tool !== undefined) { try { if (typeof body.tool !== "string") { throw Object.assign(new Error("tool must be a string"), { code: "EBADTOOL" }); } lanesLib.validateTool(body.tool); // EBADTOOL, EDISABLED } catch (err) { return res.status(400).json({ error: { code: err.code, message: err.message } }); } sessionTool = body.tool; } // Overwriting run_id while its child is alive orphans that child: a later // reset would kill and await only the RECORDED run, then `git clean -fd` // the directory the orphan is still writing into. Stop the first run before starting a // second. The check and the spawn happen under the per-lane lock so that // atomicity is guaranteed rather than an accident of this code having no // `await` between them — a future edit that adds one must not reopen the // race. const outcome = await withLaneLock(lane.id, async () => { const current = lanesLib.getLane(lane.id); // A concurrent `remove` could have deleted the row while this request // waited for the lock — the lock makes that race visible instead of // spawning a run for a lane that no longer exists. if (!current) return { missing: true }; // A session holds ONE pane: a second `--resume` of it would run two // agents writing one transcript. Its pane is named instead. A fork // makes a new session, so it is no duplicate. const resumeTarget = body.resumeSessionId || (body.resume ? current.session_id : null); if (resumeTarget && body.forkSession !== true) { const holder = runs .listRuns() .find((r) => r.status === "running" && r.sessionId === resumeTarget); if (holder) return { sessionLive: holder.id }; } const picked = pickSlot(current.id); if (picked === null) return { allBusy: true }; slot = picked; // By the pane's own name, slot 0 too: a terminal tab in pane 0 is no // run, so `run_id` does not name it, and a start there would take it over. const live = runs.getRun(runs.runIdForLane(current.id, slot || 0)); if (live && live.status === "running") { return { conflict: true }; } const handle = runs.spawnRun({ laneId: current.id, slot, initialPrompt: body.prompt || "", cwd: resumeWorktree ? resumeWorktree.path : current.cwd, inWorktree: !!resumeWorktree, // A NEW session in a worktree of its own (`claude --worktree`). worktree: body.worktree === true && !body.resumeSessionId, tool: sessionTool || current.tool || "claude", // The SESSION's AI Proxy, by name; 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( body.resumeSessionId || (body.resume ? current.session_id : null), current.owner_id ?? null ), model: body.model, permissionMode: body.permissionMode, effort: body.effort, sandbox: body.sandbox, approval: body.approval, resumeLast: body.resumeLast === true, bypass: body.bypass === true, resumeSessionId: body.resumeSessionId || (body.resume ? current.session_id : undefined), forkSession: body.forkSession === true, }); startedRunId = handle.id; // An extra pane's session is not the lane's run: `run_id` stays the // pane-0 session every other path (reset, stop) names. if (!slot) lanesLib.updateLane(current.id, { run_id: handle.id, status: "running" }); return {}; }); if (outcome.missing) { return res .status(404) .json({ error: { code: "ENOLANE", message: "workspace not found" } }); } if (outcome.sessionLive) { return res.status(409).json({ error: { code: "ESESSIONLIVE", message: "that session is already open in a tab", run_id: outcome.sessionLive, }, }); } if (outcome.allBusy) return allBusy(); if (outcome.conflict) { return res .status(409) .json({ error: { code: "ERUNLIVE", message: "that tab already has a live session" } }); } break; } case "stop": { // A lane with no live run is already stopped — say so, don't 500. for (const id of laneRunIds(lane)) { try { runs.killRun(id); } catch { /* already gone */ } } lanesLib.updateLane(lane.id, { status: "idle", run_id: null }); break; } case "message": { return res.status(400).json({ error: { code: "EUNSUPPORTED", message: "sending input to a workspace's run is no longer supported via REST — open the workspace's terminal in Workspace and type directly (attaches over WebSocket to the same tmux session)", }, }); } case "clear": lanesLib.clearLane(lane.id); break; default: break; } } catch (err) { // A host with no tmux cannot start a run, but nothing is broken on this // side — 503 plus the install line, not a 500 the reader can only read as a // dashboard bug. if (err.code === "ETMUXMISSING") { return res.status(503).json({ error: { code: err.code, message: err.message } }); } // A refused field (EBADMODEL, EBADEFFORT, …) or a thing the lane's tool // cannot do (EUNSUPPORTED: a fork on a non-Claude lane) is the request's fault. // ENOTSHARED (S3): the tool is not under /opt/agenthub/tools yet — a setup state. const status = /^(EBAD|EUNSUPPORTED$)/.test(err.code || "") ? 400 : ["ENOTSHARED", "EMEMBERGONE", "EOWNERFOLDER", "ECWDACCOUNT"].includes(err.code) ? 409 : 500; return res.status(status).json({ error: { code: err.code, message: err.message } }); } broadcastLane(lane.id); res.json({ lane: payload(lanesLib.getLane(lane.id)), ...(startedRunId ? { run_id: startedRunId } : {}), }); }); router.delete("/:id", sameOriginGuard, (req, res) => { const ok = lanesLib.deleteLane(req.params.id); if (!ok) return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } }); broadcast("lane_update", { removed: Number(req.params.id) }); res.json({ ok: true }); }); module.exports = router; module.exports.broadcastLane = broadcastLane; module.exports.healAllRunIds = healAllRunIds; module.exports.broadcastForeignLaneChanges = broadcastForeignLaneChanges;