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:
+109
-35
@@ -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" } });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user