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
+83 -6
View File
@@ -5,8 +5,12 @@
const { WebSocketServer } = require("ws");
const { isHostAllowed, isWebSocketAuthorized } = require("./lib/security");
const { isWebSocketLoggedIn, isServerMode, userFromRequest } = require("./lib/auth");
const { broadcastAudience, audienceIncludes, canSeeRun } = require("./lib/scope");
let wss = null;
// How often an open terminal's login is re-checked in server mode.
const PTY_RECHECK_MS = Number(process.env.AGENTHUB_PTY_RECHECK_MS) || 10000;
function initWebSocket(server) {
// `noServer: true` + a manual, path-checked `server.on("upgrade", ...)`
@@ -22,6 +26,7 @@ function initWebSocket(server) {
verifyClient(info, done) {
if (!isHostAllowed(info.req.headers.host)) return done(false, 403, "host not allowed");
if (!isWebSocketAuthorized(info.req)) return done(false, 401, "unauthorized");
if (!isWebSocketLoggedIn(info.req)) return done(false, 401, "log in first");
return done(true);
},
});
@@ -42,7 +47,13 @@ function initWebSocket(server) {
});
});
wss.on("connection", (ws) => {
wss.on("connection", (ws, req) => {
// Server mode: who this socket belongs to, for broadcast filtering, and
// the cookie to re-check it by (a logout or a disabled user ends it).
if (isServerMode()) {
ws.user = userFromRequest(req);
ws.cookie = req.headers.cookie || "";
}
ws.isAlive = true;
ws.on("pong", () => {
ws.isAlive = true;
@@ -66,6 +77,13 @@ function initWebSocket(server) {
ws.terminate();
return;
}
if (ws.cookie !== undefined) {
ws.user = userFromRequest({ headers: { cookie: ws.cookie } });
if (!ws.user) {
ws.terminate();
return;
}
}
ws.isAlive = false;
ws.ping();
});
@@ -117,7 +135,12 @@ function initPtyWebSocket(server) {
const { attach } = require("./lib/pty-attach");
ptyWss = new WebSocketServer({ noServer: true, maxPayload: 1024 * 1024 });
ptyWss.on("connection", (ws, runId, size) => {
ptyWss.on("connection", (ws, runId, size, cookie) => {
// Server mode: who attached, re-checked on the beat below.
if (cookie !== undefined) {
ws.cookie = cookie;
ws.runId = runId;
}
try {
attach(ws, runId, size || { cols: 80, rows: 24 });
} catch (err) {
@@ -142,7 +165,12 @@ function initPtyWebSocket(server) {
socket.destroy();
return;
}
if (!isWebSocketAuthorized(req)) {
if (!isWebSocketAuthorized(req) || !isWebSocketLoggedIn(req)) {
socket.destroy();
return;
}
// Server mode: a member attaches only to a pane of their own workspace.
if (isServerMode() && !canSeeRun({ user: userFromRequest(req) }, match[1])) {
socket.destroy();
return;
}
@@ -153,19 +181,68 @@ function initPtyWebSocket(server) {
return;
}
ptyWss.handleUpgrade(req, socket, head, (ws) => {
ptyWss.emit("connection", ws, match[1], sizeFromQuery(url.searchParams));
ptyWss.emit(
"connection",
ws,
match[1],
sizeFromQuery(url.searchParams),
isServerMode() ? req.headers.cookie || "" : undefined
);
});
});
// Server mode: a terminal stays open only while its user may still use it —
// logged in, not disabled, not an analyst, still owning the pane's workspace.
const recheck = setInterval(() => {
if (!ptyWss) {
clearInterval(recheck);
return;
}
ptyWss.clients.forEach((ws) => {
if (ws.cookie === undefined) return;
const user = userFromRequest({ headers: { cookie: ws.cookie } });
if (!user || user.role === "analyst" || !canSeeRun({ user }, ws.runId)) {
try {
ws.close(1008, "log in again");
} catch {
/* already gone */
}
ws.terminate();
}
});
}, PTY_RECHECK_MS);
recheck.unref();
return ptyWss;
}
function broadcast(type, data) {
if (!wss) return;
const message = JSON.stringify({ type, data, timestamp: new Date().toISOString() });
wss.clients.forEach((client) => {
if (client.readyState === 1) {
// Server mode: worked out once, and only when a member is connected.
let audience;
const reaches = (client) => {
if (!client.user || client.user.role !== "member")
return !isServerMode() || Boolean(client.user);
if (audience === undefined) {
try {
audience = broadcastAudience(type, data);
} catch {
audience = { staffOnly: true };
}
}
return audienceIncludes(audience, client.user);
};
let stripped;
wss.clients.forEach((client) => {
if (client.readyState === 1 && reaches(client)) {
try {
const member = client.user && client.user.role === "member";
if (member && audience && audience.strip) {
stripped ??= JSON.stringify({ type, data: {}, timestamp: new Date().toISOString() });
client.send(stripped);
return;
}
client.send(message);
} catch {
// Client closed between readyState check and send — safe to ignore