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
+109 -35
View File
@@ -31,6 +31,7 @@ const lanesLib = require("../lib/lanes");
const { sameOriginGuard } = require("../lib/same-origin");
const aiTools = require("../lib/ai-tools");
const { resumeProxy } = require("../lib/proxy-apply");
const scope = require("../lib/scope");
const router = Router();
@@ -65,6 +66,28 @@ function sanitiseCwd(input) {
return resolved;
}
/**
* Server mode: whether a member may work in `dir` — only inside one of their
* own workspaces' folders, compared by real path so a symlink in their folder
* cannot point the check elsewhere. Always true for everyone else.
*/
function insideOwnWorkspace(req, dir) {
const visible = scope.visibleLaneIds(req);
if (visible === null) return true;
const real = lanesLib.canonicalCwd(dir);
return visible.some((id) => {
const cwd = lanesLib.getLane(id)?.cwd;
if (!cwd) return false;
const rel = path.relative(cwd, real);
return rel === "" || (rel.split(path.sep)[0] !== ".." && !path.isAbsolute(rel));
});
}
const refuseFolder = (res) =>
res.status(403).json({
error: { code: "EFOLDER", message: "that folder is not one of your workspaces" },
});
const ALLOWED_PERMISSION_MODES = new Set([
"auto",
"acceptEdits",
@@ -73,8 +96,11 @@ const ALLOWED_PERMISSION_MODES = new Set([
"bypassPermissions",
]);
router.get("/", (_req, res) => {
res.json({ items: runs.listRuns() });
router.get("/", (req, res) => {
// Server mode: a member sees only the panes of their own workspaces.
const visible = scope.visibleLaneIds(req);
const items = runs.listRuns();
res.json({ items: visible === null ? items : items.filter((r) => visible.includes(r.laneId)) });
});
/**
@@ -91,8 +117,9 @@ router.get("/", (_req, res) => {
// A local terminal answers a dropped image by typing its PATH, which is how
// `claude` attaches it. A browser has no path to give, so the image is saved
// here — on the machine the pane runs on — and the client pastes this path.
// Only the bytes come from the request: the name is random and the type picks
// the extension from a fixed map, so a request never names a file.
// drop-image: only the bytes come from the request — the name is random and the
// type picks the extension from a fixed map. drop-file (any other file) keeps a
// sanitized basename of the file's own name behind a random prefix.
const DROP_TYPES = {
"image/png": "png",
"image/jpeg": "jpg",
@@ -103,6 +130,30 @@ const DROP_MAX_BYTES = 20 * 1024 * 1024;
const DROP_TTL_MS = 24 * 60 * 60_000;
const dropDir = () => path.join(os.tmpdir(), `agenthub-drops-${os.userInfo().uid}`);
// Saves one dropped body under the drop dir and answers its path. Pruned on
// write: files older than a day go.
function saveDrop(res, name, body) {
try {
const dir = dropDir();
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
// ponytail: prune-on-write, a day old; a sweep job only if drops pile up.
const cutoff = Date.now() - DROP_TTL_MS;
for (const entry of fs.readdirSync(dir)) {
const f = path.join(dir, entry);
try {
if (fs.statSync(f).mtimeMs < cutoff) fs.unlinkSync(f);
} catch {
/* raced with another prune — fine */
}
}
const file = path.join(dir, name);
fs.writeFileSync(file, body, { mode: 0o600, flag: "wx" });
res.json({ path: file });
} catch (err) {
res.status(500).json({ error: err.message });
}
}
router.post(
"/drop-image",
express.raw({ type: Object.keys(DROP_TYPES), limit: DROP_MAX_BYTES }),
@@ -116,28 +167,26 @@ router.post(
if (!ext || !Buffer.isBuffer(req.body) || req.body.length === 0) {
return res.status(400).json({ error: "png, jpeg, gif or webp image body required" });
}
try {
const dir = dropDir();
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
// ponytail: prune-on-write, a day old; a sweep job only if drops pile up.
const cutoff = Date.now() - DROP_TTL_MS;
for (const name of fs.readdirSync(dir)) {
const f = path.join(dir, name);
try {
if (fs.statSync(f).mtimeMs < cutoff) fs.unlinkSync(f);
} catch {
/* raced with another prune — fine */
}
}
const file = path.join(dir, `${crypto.randomBytes(8).toString("hex")}.${ext}`);
fs.writeFileSync(file, req.body, { mode: 0o600, flag: "wx" });
res.json({ path: file });
} catch (err) {
res.status(500).json({ error: err.message });
}
saveDrop(res, `${crypto.randomBytes(8).toString("hex")}.${ext}`, req.body);
}
);
// Any other dropped file (a PDF, a log, a CSV…): the agent reads it by path,
// so its own name is kept — reduced to a basename of [A-Za-z0-9._-] behind a
// random prefix, so it can neither leave the drop dir nor collide, and the
// space-joined paste of several paths stays parseable.
router.post("/drop-file", express.raw({ type: () => true, limit: DROP_MAX_BYTES }), (req, res) => {
const safe = path
.basename(String(req.query.name || ""))
.replace(/[^A-Za-z0-9._-]/g, "_")
.replace(/^\.+/, "")
.slice(-100);
if (!safe || !Buffer.isBuffer(req.body) || req.body.length === 0) {
return res.status(400).json({ error: "?name= and a non-empty file body required" });
}
saveDrop(res, `${crypto.randomBytes(8).toString("hex")}-${safe}`, req.body);
});
router.get("/history", (req, res) => {
let dr = null;
try {
@@ -147,11 +196,22 @@ router.get("/history", (req, res) => {
}
const limit = Number.parseInt(String(req.query.limit || "50"), 10);
const laneId = Number.parseInt(String(req.query.laneId ?? ""), 10);
const items = dr.listRuns({
const opts = {
limit: Number.isFinite(limit) ? limit : 50,
laneId: Number.isFinite(laneId) ? laneId : null,
tool: typeof req.query.tool === "string" ? req.query.tool : null,
});
};
// Server mode: a member with no ?laneId (a named one is scopeGuard's) gets
// the newest runs of their own workspaces only.
const visible = opts.laneId === null ? scope.visibleLaneIds(req) : null;
// ponytail: one query per workspace; one IN (...) query if members own hundreds.
const items =
visible === null
? dr.listRuns(opts)
: visible
.flatMap((id) => dr.listRuns({ ...opts, laneId: id, tool: null }))
.sort((a, b) => String(b.started_at).localeCompare(String(a.started_at)))
.slice(0, Math.max(1, Math.min(500, Math.floor(opts.limit) || 50)));
const liveIds = new Set(runs.listRuns().map((h) => h.id));
res.json({
items: items.map((it) => ({ ...it, isLive: liveIds.has(it.id) })),
@@ -188,7 +248,12 @@ router.delete("/history/:id", (req, res) => {
*
* Optional ?q=<substring> filter applied client-side as well.
*/
router.get("/cwds", (_req, res) => {
router.get("/cwds", (req, res) => {
// Server mode: a member's suggestions come from their own workspaces and
// sessions only — not the server's own folder or home.
const member = scope.memberId(req) != null;
const ownLanes = scope.laneWhere(req, "id");
const ownSessions = scope.sessionWhere(req);
const out = [];
const seen = new Set();
const push = (kind, p, label) => {
@@ -200,8 +265,10 @@ router.get("/cwds", (_req, res) => {
out.push({ kind, path: abs, label: label || path.basename(abs) || abs });
};
push("dashboard", process.cwd(), "Dashboard server");
push("home", require("node:os").homedir(), "Home");
if (!member) {
push("dashboard", process.cwd(), "Dashboard server");
push("home", require("node:os").homedir(), "Home");
}
// Source repos lanes have already been provisioned from, newest lane first.
// The Add-lane form's repo field used to offer only "recent" session cwds,
@@ -214,10 +281,10 @@ router.get("/cwds", (_req, res) => {
const rows = db
.prepare(
`SELECT source_repo AS p, MAX(id) AS last_id FROM lanes
WHERE source_repo IS NOT NULL AND source_repo <> ''
WHERE source_repo IS NOT NULL AND source_repo <> '' AND ${ownLanes.where}
GROUP BY source_repo ORDER BY last_id DESC LIMIT 20`
)
.all();
.all(...ownLanes.params);
for (const row of rows) push("repo", row.p, path.basename(row.p));
} catch {
/* ignore — same best-effort contract as the recents below */
@@ -230,10 +297,10 @@ router.get("/cwds", (_req, res) => {
const rows = db
.prepare(
`SELECT cwd, MAX(started_at) AS last_at FROM sessions
WHERE cwd IS NOT NULL AND cwd <> ''
WHERE cwd IS NOT NULL AND cwd <> '' AND ${ownSessions.where}
GROUP BY cwd ORDER BY last_at DESC LIMIT 30`
)
.all();
.all(...ownSessions.params);
for (const row of rows) {
push("recent", row.cwd, path.basename(row.cwd));
}
@@ -257,6 +324,7 @@ router.get("/files", (req, res) => {
} catch (err) {
return res.status(400).json({ error: { code: err.code, message: err.message } });
}
if (!insideOwnWorkspace(req, cwd)) return refuseFolder(res);
const q = typeof req.query.q === "string" ? req.query.q.toLowerCase() : "";
const SKIP_DIRS = new Set([
"node_modules",
@@ -331,10 +399,15 @@ router.post("/", (req, res) => {
}
let cwd;
try {
cwd = sanitiseCwd(body.cwd);
// A member's run with no cwd starts in its workspace, not the server's folder.
const empty = body.cwd == null || body.cwd === "";
cwd = sanitiseCwd(
empty && scope.memberId(req) != null ? lanesLib.getLane(laneId)?.cwd : body.cwd
);
} catch (err) {
return res.status(400).json({ error: { code: err.code, message: err.message } });
}
if (!insideOwnWorkspace(req, cwd)) return refuseFolder(res);
try {
const lane = lanesLib.getLane(laneId);
const handle = runs.spawnRun({
@@ -386,7 +459,7 @@ router.post("/", (req, res) => {
});
router.get("/:id", (req, res) => {
const handle = runs.getRun(req.params.id);
const handle = scope.canSeeRun(req, req.params.id) ? runs.getRun(req.params.id) : null;
if (!handle || handle.status === "gone") {
return res.status(404).json({ error: { code: "ENOTFOUND", message: "run not found" } });
}
@@ -394,7 +467,8 @@ router.get("/:id", (req, res) => {
});
router.delete("/:id", (req, res) => {
const ok = runs.killRun(req.params.id);
// scopeGuard checks the raw path; this checks the decoded id the kill uses.
const ok = scope.canSeeRun(req, req.params.id) && runs.killRun(req.params.id);
if (!ok) {
return res.status(404).json({ error: { code: "ENOTFOUND", message: "run not found" } });
}