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:
+75
-12
@@ -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];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user