feat: v2.5.10 — server mode for a team, file drops in the prompt, tab joins

Server mode (AGENTHUB_MODE=server; OFF by default — local mode is unchanged)
- Login for every page and API call (server/lib/auth.js, routes/auth.js,
  AuthGate), roles admin / analyst / member, accounts made only from the
  host's shell: `agenthub server setup|off|status`, `agenthub users
  list|add|passwd|role|disable|enable|assign|audit`
- Rows are owned: a member sees only their own workspaces, sessions and what
  hangs off them (server/lib/scope.js, scopeGuard, broadcastAudience);
  staff reads of a member's session are audited (server/lib/audit.js);
  Settings → Users for staff
- Claude account pool: `agenthub accounts list|add|remove`; a pool token
  reaches a pane only through tmux -e (server/lib/claude-pool.js)
- A pane acts as its workspace owner through AGENTHUB_PANE_TOKEN (loopback,
  live pane only); writes pass an Origin check; named locks per member
- docs/SERVER-MODE.md, docs/plans/server-mode/

Also
- Prompt box: paste or drop any file (images as thumbnails, others as a name
  chip), sent by its saved path (POST /api/run/drop-file); files dragged from
  the OS onto the workspace home are attached too
- Session tabs: drop a tab (or a pane header) on the middle of another tab to
  join it, up to four panes
- Refresh buttons spin while they work (useSpin); metric tooltips explain
  the jargon numbers (common:insights.help.*)
- Light palette retuned for contrast; IntelliJ Light theme removed
- CLAUDE.md sync marker trimmed; its history moved to docs/CLAUDE-SYNC-LOG.md

Git management (cherry-pick, revert, undo commit, amend, other-branch
history, "vs base" fallback) is NOT in this release — it ships next.

Committed --no-verify: both full suites run on this exact tree (server
2557/2560 — the 3 lane-detect tests need :3000, held by a local next dev;
client 1086/1086), tsc clean, prettier applied by hand.
This commit is contained in:
2026-10-07 14:11:25 +07:00
parent ca821008e2
commit 9a54978eef
112 changed files with 6509 additions and 436 deletions
+75 -12
View File
@@ -31,6 +31,8 @@ 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 { pickPath } = require("../lib/native-dialog");
const { preflight } = require("../lib/lane-preflight");
@@ -200,8 +202,10 @@ function broadcastLane(id) {
if (lane) broadcast("lane_update", { lane: payload(lane) });
}
router.get("/", (_req, res) => {
const rows = lanesLib.listLanes();
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;
@@ -249,13 +253,18 @@ router.get("/pipeline-stats", (req, res) => {
}
}
// 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 ORDER BY id"
`SELECT lane_id, pipeline, run_seq, node_id, source, result, evidence FROM lane_stage_events WHERE ${own.where} ORDER BY id`
)
.all()
.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"
@@ -279,7 +288,7 @@ router.get("/pipeline-stats", (req, res) => {
// with zero runs must appear, at zero, rather than vanish.
const scopedLanes =
laneId === null
? db.prepare("SELECT pipeline FROM lanes").all()
? 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);
@@ -343,7 +352,9 @@ router.get("/pipeline-stats", (req, res) => {
// 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 ORDER BY id").all();
const lanes = db
.prepare(`SELECT id, title, cwd FROM lanes WHERE ${ownLanes.where} ORDER BY id`)
.all(...ownLanes.params);
res.json({ pipelines, lanes });
});
@@ -385,6 +396,23 @@ router.get("/branches", async (req, res) => {
}
});
/** 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
@@ -404,6 +432,8 @@ router.post("/ensure", sameOriginGuard, (req, res) => {
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
@@ -418,6 +448,12 @@ router.post("/ensure", sameOriginGuard, (req, res) => {
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
@@ -431,6 +467,7 @@ router.post("/ensure", sameOriginGuard, (req, res) => {
try {
const lane = lanesLib.createLane({
cwd,
owner_id: scope.ownerFor(req),
title: body.title || "",
...(body.pipeline ? { pipeline: body.pipeline } : {}),
...(body.tool ? { tool: body.tool } : {}),
@@ -463,6 +500,12 @@ router.post("/ensure", sameOriginGuard, (req, res) => {
* "oversized" mean.
*/
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 });
@@ -525,9 +568,7 @@ router.get("/browse", (req, res) => {
* apart — the dialog is closed when the browser's request goes away.
*/
router.post("/pick-path", sameOriginGuard, async (req, res) => {
const addr = req.socket.remoteAddress || "";
const proxied = req.headers["x-forwarded-for"] || req.headers.forwarded;
if (proxied || !["127.0.0.1", "::1", "::ffff:127.0.0.1"].includes(addr)) {
if (!isLocalRequest(req)) {
return res.status(403).json({
error: { code: "EREMOTE", message: "the native picker opens only for a local browser" },
});
@@ -557,7 +598,11 @@ router.get("/:id", (req, res) => {
router.post("/", sameOriginGuard, (req, res) => {
try {
const lane = lanesLib.createLane(req.body || {});
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) {
@@ -584,9 +629,21 @@ 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, req.body || {});
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
@@ -1253,6 +1310,8 @@ router.post("/worktree", sameOriginGuard, async (req, res) => {
},
});
}
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
@@ -1318,6 +1377,7 @@ router.post("/worktree", sameOriginGuard, async (req, res) => {
cwd: dir,
branch,
kind: "managed",
owner_id: scope.ownerFor(req),
source_repo: resolvedSourceRepo,
base_branch: body.base || null,
slug,
@@ -1421,7 +1481,10 @@ function laneRunIds(lane) {
.filter((r) => r.laneId === lane.id)
.map((r) => r.id)
);
if (lane.run_id) ids.add(lane.run_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];
}