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:
+83
-6
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user