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
+284 -1
View File
@@ -160,7 +160,13 @@ async function api(method, pathname, body, options = {}) {
const form = body instanceof FormData;
res = await fetch(url, {
method,
headers: body && !form ? { "Content-Type": "application/json" } : undefined,
headers: {
...(body && !form ? { "Content-Type": "application/json" } : {}),
// Server mode: inside a dashboard pane, act as the workspace's owner.
...(process.env.AGENTHUB_PANE_TOKEN
? { "x-agenthub-pane-token": process.env.AGENTHUB_PANE_TOKEN }
: {}),
},
body: body ? (form ? body : JSON.stringify(body)) : undefined,
signal: AbortSignal.timeout(options.timeoutMs ?? 30_000),
});
@@ -2303,6 +2309,17 @@ async function cmdLockAcquire(args) {
console.log(`${c.green("✔")} acquired lock "${name}" as ${holder}`);
return;
}
// Only 409 means "held by someone else"; any other answer (a refused
// name, a server error) will not change by waiting.
if (result.status !== 409) {
console.error(
c.red(
`✖ ${result.data?.error?.message || `could not acquire lock (HTTP ${result.status})`}`
)
);
process.exitCode = 1;
return;
}
if (Date.now() - lastPrinted >= 60_000) {
const waited = Math.floor((Date.now() - startedAt) / 1000);
console.log(
@@ -2719,6 +2736,21 @@ const COMMAND_GROUPS = [
"install|uninstall|status [--port N]",
"Auto-start on boot via systemd --user (Linux)",
],
[
"server",
"setup|off|status [--hostname name] [--admin name]",
"Server mode: logins for a shared dashboard (docs/SERVER-MODE.md)",
],
[
"users",
"list|add|passwd|role|disable|enable|assign|audit [name]",
"Server mode's accounts (add: --role admin|analyst|member)",
],
[
"accounts",
"list|add|remove [name]",
"Claude account pool: new sessions spread over these accounts",
],
["repl", "", "Open the interactive shell (also: shell, i)"],
],
],
@@ -3668,6 +3700,9 @@ const COMMANDS = (() => {
const SUBCOMMANDS = {
alerts: ["ack", "ack-all"],
service: ["install", "uninstall", "status"],
server: ["setup", "off", "status"],
users: ["list", "add", "passwd", "role", "disable", "enable", "assign", "audit"],
accounts: ["list", "add", "remove"],
pricing: ["set", "delete", "reset"],
webhooks: ["test"],
import: ["rescan", "path"],
@@ -3708,6 +3743,248 @@ function cmdStatusline(sub, flags) {
}
}
// ── Server mode (docs/SERVER-MODE.md) ──────────────────────────────────────
/** The auth module over the SAME database and settings the server reads: the
* repo `.env`, then AgentHub's settings file (a pinned DASHBOARD_DATA_DIR). */
function serverAuth() {
const settingsEnv = require(path.join(REPO_ROOT, "server", "lib", "settings-env.js"));
settingsEnv.loadEnvFiles({ owned: [path.join(REPO_ROOT, ".env")] });
return { settingsEnv, auth: require(path.join(REPO_ROOT, "server", "lib", "auth.js")) };
}
/** One password: typed hidden on a terminal, else the first line of stdin. */
async function askPassword(prompt) {
const stdin = process.stdin;
if (!stdin.isTTY) {
const chunks = [];
for await (const chunk of stdin) chunks.push(chunk);
return Buffer.concat(chunks).toString("utf8").split(/\r?\n/)[0];
}
process.stdout.write(prompt);
return new Promise((resolve, reject) => {
let typed = "";
const finish = () => {
stdin.setRawMode(false);
stdin.pause();
stdin.off("data", onData);
process.stdout.write("\n");
};
const onData = (text) => {
for (const ch of text) {
if (ch === "\r" || ch === "\n") return (finish(), resolve(typed));
if (ch === "\u0003") return (finish(), reject(new Error("cancelled")));
typed = ch === "\u007f" || ch === "\b" ? typed.slice(0, -1) : typed + ch;
}
};
stdin.setRawMode(true);
stdin.setEncoding("utf8");
stdin.on("data", onData);
stdin.resume();
});
}
/** A new password, typed twice on a terminal. */
async function newPassword(label) {
const first = await askPassword(`${label}: `);
if (process.stdin.isTTY && (await askPassword("Again: ")) !== first) {
throw new Error("the two passwords differ");
}
return first;
}
/** `agenthub server setup|off|status` — turn server mode on or off. */
async function cmdServer(sub, flags) {
const { settingsEnv, auth } = serverAuth();
const file = settingsEnv.settingsFilePath();
const envNote = (key) => {
if (settingsEnv.envOwned.has(key)) {
console.log(
c.yellow(`! ${key} is also set in the environment or .env, which wins over ${file}`)
);
}
};
if (sub === "setup") {
if (flags.hostname) {
const hosts = String(flags.hostname);
if (!/^[a-z0-9.\-:[\],]+$/i.test(hosts)) throw new Error(`bad --hostname: ${hosts}`);
settingsEnv.writeSetting("DASHBOARD_ALLOWED_HOSTS", hosts);
envNote("DASHBOARD_ALLOWED_HOSTS");
}
if (!auth.hasAdmin()) {
const name = typeof flags.admin === "string" ? flags.admin : "admin";
const user = await auth.createUser(
name,
await newPassword(`Password for admin ${name}`),
"admin"
);
console.log(`${c.green("✔")} admin ${user.username} created`);
} else {
console.log(c.dim(" an admin already exists — add more with `agenthub users add`"));
}
settingsEnv.writeSetting("AGENTHUB_MODE", "server");
envNote("AGENTHUB_MODE");
console.log(`${c.green("✔")} server mode on (${file})`);
console.log(" Next:");
console.log(
" 1. Restart the dashboard: systemctl --user restart agenthub (or agenthub start)"
);
console.log(
" 2. Serve it over HTTPS through a reverse proxy that keeps the Host header,\n" +
" and run setup with --hostname <that name> — see docs/SERVER-MODE.md."
);
console.log(
c.dim(" Members see only their own workspaces, but every pane still runs as this")
);
console.log(c.dim(" one Linux account — use it with people you trust (docs/SERVER-MODE.md)."));
console.log(
c.dim(" Workspaces made before now have no owner: agenthub users assign <id> <user>")
);
return;
}
if (sub === "off") {
settingsEnv.writeSetting("AGENTHUB_MODE", "local");
envNote("AGENTHUB_MODE");
console.log(`${c.green("✔")} server mode off — restart the dashboard. Users are kept.`);
return;
}
if (sub === "status" || sub === undefined) {
const users = auth.listUsers();
console.log(`mode: ${auth.isServerMode() ? "server" : "local"}`);
console.log(`settings: ${file}`);
console.log(
`users: ${users.length} (${users.filter((u) => u.role === "admin").length} admin)`
);
console.log(c.dim(" a change takes effect when the dashboard restarts"));
return;
}
console.error(
c.red("✖ Usage: agenthub server setup [--hostname name] [--admin name] | off | status")
);
process.exitCode = 1;
}
/** `agenthub users …` — server mode's accounts, written straight to the DB. */
async function cmdUsers(sub, positional, flags) {
const { auth } = serverAuth();
const name = positional[0];
const need = () => {
if (!name) throw new Error(`usage: agenthub users ${sub} <username>`);
};
switch (sub) {
case undefined:
case "list":
table(
["id", "username", "role", "state"],
auth.listUsers().map((u) => [u.id, u.username, u.role, u.disabled ? "disabled" : "active"])
);
return;
case "add": {
need();
const role = typeof flags.role === "string" ? flags.role : "member";
const user = await auth.createUser(name, await newPassword(`Password for ${name}`), role);
console.log(`${c.green("✔")} ${user.username} added (${user.role})`);
return;
}
case "passwd":
need();
await auth.setPassword(name, await newPassword(`New password for ${name}`));
console.log(`${c.green("✔")} password changed — ${name}'s logins ended`);
return;
case "role":
need();
auth.setRole(name, positional[1]);
console.log(`${c.green("✔")} ${name} is now ${positional[1]}`);
return;
case "assign":
if (!name || !positional[1]) {
throw new Error("usage: agenthub users assign <workspace id> <username>");
}
auth.assignWorkspace(name, positional[1]);
console.log(`${c.green("✔")} workspace ${name} now belongs to ${positional[1]}`);
return;
case "audit": {
const { listAudit } = require(path.join(REPO_ROOT, "server", "lib", "audit.js"));
table(
["when", "reader", "owner", "path"],
listAudit(typeof flags.limit === "string" ? flags.limit : 50).map((a) => [
a.at.replace("T", " ").slice(0, 19),
a.reader,
a.owner,
a.path,
])
);
return;
}
case "disable":
case "enable":
need();
auth.setDisabled(name, sub === "disable");
console.log(`${c.green("✔")} ${name} ${sub}d`);
return;
default:
console.error(
c.red(
"✖ Usage: agenthub users list | add <name> [--role admin|analyst|member] | passwd <name> | role <name> <role> | disable <name> | enable <name> | assign <workspace id> <name> | audit [--limit N]"
)
);
process.exitCode = 1;
}
}
/** `agenthub accounts …` — the Claude account pool (server/lib/claude-pool.js). */
async function cmdAccounts(sub, positional) {
serverAuth(); // the same settings + database the server reads
const pool = require(path.join(REPO_ROOT, "server", "lib", "claude-pool.js"));
const name = positional[0];
if (sub === "add" || sub === "remove") {
if (!name) throw new Error(`usage: agenthub accounts ${sub} <name>`);
}
if (sub === "add") {
console.log(
c.dim(" Run `claude setup-token` signed in as that account, then paste the token.")
);
const added = pool.addAccount(name, await askPassword(`Token for ${name}: `));
console.log(
`${c.green("✔")} ${added.name} added — valid until ${added.expiresAt.slice(0, 10)}`
);
console.log(c.dim(" New Claude sessions now spread over the pool's accounts."));
return;
}
if (sub === "remove") {
pool.removeAccount(name);
console.log(`${c.green("✔")} ${name} removed — running panes keep it until they exit`);
return;
}
if (sub === undefined || sub === "list") {
await pool.refreshQuotas({ force: true });
const pct = (w) => (w?.utilization != null ? `${Math.round(w.utilization)}%` : "–");
table(
["account", "live", "$ 5h", "$ 7d", "quota 5h", "quota wk", "state"],
pool
.poolStatus()
.map((a) => [
a.name,
a.livePanes,
a.spend5h.toFixed(2),
a.spend7d.toFixed(2),
pct(a.quota?.fiveHour),
pct(a.quota?.sevenDay),
a.expired
? "expired"
: a.full
? "full"
: a.quota?.error
? `no quota (${a.quota.error})`
: "ok",
])
);
return;
}
console.error(c.red("✖ Usage: agenthub accounts list | add <name> | remove <name>"));
process.exitCode = 1;
}
/** Run one parsed command. Returns the handler's promise; may throw
* ServerDownError (for the caller's offline routing) or call process.exit
* for usage/validation failures. `cmd === "repl"` is handled by the entry
@@ -3725,6 +4002,12 @@ async function runCommand(argv) {
return cmdStart(flags);
case "service":
return cmdService(positional[0], flags);
case "server":
return cmdServer(positional[0], flags);
case "users":
return cmdUsers(positional[0], positional.slice(1), flags);
case "accounts":
return cmdAccounts(positional[0], positional.slice(1));
case "stats":
return cmdStats();
case "kanban":