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
+3 -2
View File
@@ -487,7 +487,7 @@ graph TD
### Splash & loading UX
- **`SplashScreen.tsx`** — rendered by `App.tsx` as a fixed full-screen overlay alongside the router. Shows once per browser session (`sessionStorage` gate, read synchronously so a repeat mount never flashes). Time-aware greeting + localized tagline/subtexts (`splash` i18n namespace, en/zh/vi/ko) + an animated node-graph brand mark on a dark backdrop (radial glow, drifting constellation, grain). The backdrop is **opaque from the first paint** (no entrance fade on the root) so the app rendered behind it never flashes through; only the inner content cascades in. Holds ~2.5 s, then fades out and unmounts; click-to-skip; honors `prefers-reduced-motion`. CSS-only keyframes, no added dependencies.
- **Colour tokens (`client/src/index.css`, `tailwind.config.js`)** — one CSS-variable set per mode, plus named-theme override blocks keyed by `data-theme` on `<html>` (Darcula, Apple Dark, Apple Light, IntelliJ Light, Canvas — picked in Settings → Appearance, `components/settings/AppearanceSettings.tsx`, state in `hooks/useTheme.ts`), consumed as `bg-surface-N` / `text-fg-*` / `border-border*` / `text-status-*` / `text-syntax-*`. The surface ramp encodes ELEVATION and rises in lightness with its index in BOTH themes: light mode's page is `surface-0` `#e8e5da` and a `.card` is the brighter `surface-3` `#f5f3ec` (Claude's warm ivory family rather than Radix slate — a cool near-white page reads as glare at any lightness, while dark mode stays slate + blue), so hovers lift instead of dimming. (Light mode previously used Radix slate in its native direction — index up meant darker — which put a near-white page under grey, recessed-looking cards: the "glaring and muddy at once" report.) Light `--accent` is `#924224` — Claude's terracotta three steps down, since the brand `#cc785c` is 3.1:1 on ivory — not dark's blue; `--accent` is a TEXT colour (dark Default `#70b8ff`), so white text sits on `--accent-solid` (`bg-accent-solid`, `.btn-primary`, Switch, Checkbox — dark Default `#0076d1`, light = `--accent`), never on `--accent`; `--fg-muted` is held at ≥4.5:1 because it carries timestamps and counts; `--shadow-card`, not the deliberately quiet `--border`, carries "raised" in light mode. Syntax colouring is tokenised too (`--syn-*`), dark keeping its 300-level pastels and light taking the same hues four steps darker. A hardcoded near-black panel, a `hover:text-white`, or `filter: invert(1)` on a form glyph is by definition a light-mode bug — token it, or scope it under `.dark`. Status and accent **fills** are pre-mixed rather than translucent: `tint()` in `tailwind.config.js` compiles `bg-status-success/10` and `border-status-danger/40` to `color-mix(…, rgb(var(--tint-base)))`, because an alpha tint composites with what is behind it and made one chip render as five different colours over the five surface steps. It is deliberately scoped to `backgroundColor`/`borderColor` — `text-*/NN` and `ring-*/NN` must stay translucent, and a translucent `surface` is what every scrim is made of.
- **Colour tokens (`client/src/index.css`, `tailwind.config.js`)** — one CSS-variable set per mode, plus named-theme override blocks keyed by `data-theme` on `<html>` (Darcula, Apple Dark, Apple Light, Canvas — picked in Settings → Appearance, `components/settings/AppearanceSettings.tsx`, state in `hooks/useTheme.ts`), consumed as `bg-surface-N` / `text-fg-*` / `border-border*` / `text-status-*` / `text-syntax-*`. The surface ramp encodes ELEVATION and rises in lightness with its index in BOTH themes: light mode's page is `surface-0` `#eceef2` and a `.card` is the brighter `surface-3` `#fdfdfe` (the light Default is IntelliJ Light's palette), so hovers lift instead of dimming. (Light mode previously used Radix slate in its native direction — index up meant darker — which put a near-white page under grey, recessed-looking cards: the "glaring and muddy at once" report.) Light `--accent` is IntelliJ's `#285ac0`, darker than dark's blue; `--accent` is a TEXT colour (dark Default `#70b8ff`), so white text sits on `--accent-solid` (`bg-accent-solid`, `.btn-primary`, Switch, Checkbox — dark Default `#0076d1`, light = `--accent`), never on `--accent`; `--fg-muted` is held at ≥4.5:1 because it carries timestamps and counts; `--shadow-card`, not the deliberately quiet `--border`, carries "raised" in light mode. Syntax colouring is tokenised too (`--syn-*`), dark keeping its 300-level pastels and light taking IntelliJ Light's editor scheme. A hardcoded near-black panel, a `hover:text-white`, or `filter: invert(1)` on a form glyph is by definition a light-mode bug — token it, or scope it under `.dark`. Status and accent **fills** are pre-mixed rather than translucent: `tint()` in `tailwind.config.js` compiles `bg-status-success/10` and `border-status-danger/40` to `color-mix(…, rgb(var(--tint-base)))`, because an alpha tint composites with what is behind it and made one chip render as five different colours over the five surface steps. It is deliberately scoped to `backgroundColor`/`borderColor` — `text-*/NN` and `ring-*/NN` must stay translucent, and a translucent `surface` is what every scrim is made of.
- **`Tooltip.tsx` (`GlobalTooltip`)** — mounted once by `App.tsx` beside the router. Every control in the app keeps writing the native `title="…"`; this listener upgrades it in place — on a 350 ms hover rest or immediately on keyboard focus it parks the attribute in `data-tip`, renders an anchored bubble (portal, arrow, flip above/below, viewport clamp, theme tokens so light mode is legible), and restores the attribute when it closes on pointer-out / pointer-down / scroll / resize / `Escape`. No wrapper at the call site, nothing to remember for the next `title=`, and the native tooltip plus the control's accessible name survive with JS off. `data-tip-off` opts an element out; `Tip.tsx` (cursor-following, for expanding a truncated number mid-sentence) draws the same `TOOLTIP_SURFACE`.
- **Loading skeletons** — the shared `Skeleton` primitive (`components/Skeleton.tsx`) uses Tailwind `animate-pulse`. `Analytics.tsx` now renders a pulsing `AnalyticsChartsSkeleton` for the whole chart region while `data` is null (previously it fell back to empty/zero charts).
- **`Workflows.tsx` `Section`** — the right-aligned section subtitle is clamped to a single line (`truncate` + `max-w` + hover `title`) so a long translation never wraps and unbalances the header; the full text stays in the section's `i` popover.
@@ -968,7 +968,8 @@ All messages are JSON with this envelope:
Frames a hook produces are sent only after its transaction committed and its
pane binding (`bindRunSession`) ran — in order, in the same tick — so no frame
describes a rolled-back row. `new_event` carries the event's row `id` (additive).
describes a rolled-back row, and a server-mode member's first frames of a new
session reach them. `new_event` carries the event's row `id` (additive).
The `remote_source.status` message (emitted by the **Remote Data Sources**
sync poller and the `/api/remote-sources` routes) carries
+4 -2
View File
File diff suppressed because one or more lines are too long
+9 -3
View File
@@ -61,8 +61,10 @@ Internal build — all rights reserved.
other its conversation with a prompt box — sending a message resumes it,
opening never does; **Analyst** in the right panel opens its analysis). A workspace opens on its home: a
prompt box that starts a new session, Recents, and the workspace's own
Instructions, Memory, Context files and extra Folders, which Claude and Codex
sessions start with.
Instructions, Memory, Context files (picked with + or dragged in from the OS)
and extra Folders, which Claude and Codex sessions start with. Files and
images dragged onto a terminal or a prompt box are uploaded and attached by
their saved path.
- **Settings as one page per section** (`/settings/<section>`): a left nav
grouped General (General / Data / About / Appearance / Pet / Notifications) /
Agents / Routing / Cost / Git, a search box (Ctrl+F) over every section whose
@@ -78,7 +80,7 @@ Internal build — all rights reserved.
(marketplaces → every plugin → AgentHub last → one session reload).
- **Appearance.** Settings → Appearance picks Dark / Light / System (follows the
OS live) and a theme per mode — dark: Default, Darcula, Apple Dark, Orca Dark;
light: Default, Apple Light, IntelliJ Light, Canvas, Orca Light. Each mode
light: Default, Apple Light, Canvas, Orca Light. Each mode
remembers its own theme, so the sidebar's quick toggle swaps between the two.
Per-browser only.
- **Notification sounds.** Settings → Notifications picks a sound (None, Chime,
@@ -92,6 +94,10 @@ Internal build — all rights reserved.
(`agenthub statusline install`, [docs/STATUSLINE.md](docs/STATUSLINE.md)), and
Prometheus rules + Grafana boards for `/api/metrics`
(`docker-compose.monitoring.yml`, [docs/MONITORING.md](docs/MONITORING.md)).
- **Server mode for a shared dashboard** (`agenthub server setup`): a login on
every page and API call, with admin / member / analyst roles. It is off by
default, and a local install does not change. It is login only, without
per-user isolation yet ([docs/SERVER-MODE.md](docs/SERVER-MODE.md)).
## Prerequisites
+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":
+9 -3
View File
@@ -54,11 +54,11 @@ The client is a single-page application (SPA) built with modern web technologies
- **Vite 6.1** - Lightning-fast HMR during development, optimized production builds
- **Tailwind CSS 3.4** - Utility-first CSS framework; colors are CSS-variable-backed tokens (`darkMode: "class"`) so a Dark/Light/System switch (Settings → Appearance; `useTheme` hook — the sidebar has no theme or language controls, the language lives in Settings → Appearance) re-themes the whole app by flipping one class. A **named theme** per mode is one more attribute: `data-theme` on `<html>` (absent for `default`) selects a CSS-variable block in `index.css` — `.dark[data-theme="darcula|apple-dark|orca-dark"]` and `[data-mode="light"][data-theme="apple-light|intellij-light|canvas|orca-light"]` — that overrides only the tokens it changes. `--tint-base` is `var(--surface-3)` and light `--syn-plain` is `var(--fg-primary)`, so a theme never restates them. A new theme is a block there plus an id in `THEMES` (`hooks/useTheme.ts`) and a label in `components/settings/AppearanceSettings.tsx`; its Settings preview needs no hex, because the swatch re-scopes the same variables on its own element. Storage: `theme` = `dark|light|system`, `agenthub.theme.dark` / `agenthub.theme.light` = theme ids (unknown ⇒ `default`); state is read through `useSyncExternalStore`, never cached in the module, so every consumer and every tab agree. An inline `<script>` at the top of `client/index.html` applies the same keys before first paint (no flash of the stock dark palette); it does not validate ids — the hook does, on mount
**The surface ramp means elevation, and it runs the same direction in both themes: a higher index is LIGHTER in dark and LIGHTER in light.** So in light mode the page is the *darkest* step (`surface-0`, `#e8e5da`) and a `.card` is brighter than the page it sits on (`surface-3`, `#f5f3ec`), which is also what makes every `hover:bg-surface-N` lift rather than dim. Light mode originally used Radix slate in its native direction — index up meant darker — which inverted the whole language: a near-white page (the glare complaint) under grey cards that read as recessed. Never "fix" a light-mode colour by swapping which step a component uses; fix the value of the step.
**The surface ramp means elevation, and it runs the same direction in both themes: a higher index is LIGHTER in dark and LIGHTER in light.** So in light mode the page is the *darkest* step (`surface-0`, `#eceef2`) and a `.card` is brighter than the page it sits on (`surface-3`, `#fdfdfe`), which is also what makes every `hover:bg-surface-N` lift rather than dim. Light mode originally used Radix slate in its native direction — index up meant darker — which inverted the whole language: a near-white page (the glare complaint) under grey cards that read as recessed. Never "fix" a light-mode colour by swapping which step a component uses; fix the value of the step.
Other rules that fall out of this:
- **Light mode's hue family is warm, dark mode's is cool.** The light ramp is Claude's own ivory/terracotta (`#e8e5da` → `#fcfbf7`, warm neutrals, warm `--shadow-card`), not Radix slate: a cool near-white page reads as glare at any lightness, because the brightest thing on screen is also the bluest. Dark mode stays slate + blue — a warm accent on a near-black page is a different problem with a different answer.
- Light `--accent` is **not** the dark one, in hue or in step. Dark's `#0090ff` is 3.3:1 on white; Claude's brand terracotta `#cc785c` is 3.1:1 on ivory and 3.3:1 under white text, so it works as a large fill and fails as a link or a button label. Light mode uses it three steps down, `#924224` (it was `#a34a28`, which missed 4.5:1 as text on its own selected chip).
- **The light Default is IntelliJ Light's palette** (`#eceef2` → `#ffffff` neutrals, blue accent, IntelliJ's editor syntax colours); the separate IntelliJ Light theme was folded into it.
- Light `--accent` is **not** the dark one: dark's `#0090ff` is 3.3:1 on white. Light mode uses IntelliJ's `#285ac0`, dark enough to clear 4.5:1 as text and under white.
- **White text sits on `accent-solid`, never on `accent`.** `--accent` is a TEXT colour; in the dark themes it is the light step (Default `#70b8ff`, Orca Dark `#6aa0f8`) that reads on a near-black card and is far too light to carry white. Every theme also defines `--accent-solid`/`--accent-solid-hover` (Tailwind `bg-accent-solid`) for the fill behind white text — `.btn-primary`, a Switch's on state, a Checkbox tick — darkened until white clears 4.5:1 (Default `#0076d1`). In the light themes the text accent is already dark enough, so `--accent-solid` is simply `var(--accent)`.
- `--fg-muted` carries real content (timestamps, counts), so it is held at ≥4.5:1 (`#686359`, 5.4:1 on a card), not at the decorative value the Radix step suggests.
- Code colouring goes through the `syntax.*` tokens (`--syn-*`), never fixed Tailwind steps: the dark set is pastel (300-level), the light set is the same hues four steps darker, because a pastel keyword on a light code block is unreadable.
@@ -793,6 +793,12 @@ in the file is shown as text, never run (no `rehype-raw`), links open in a new
tab, and a leading YAML frontmatter block is shown as a code block. Styles are
the `.md-preview` rules in `index.css`, built from theme tokens.
#### Metric tooltips (`common:insights.help.*`)
The dotted-underline tooltips on jargon metrics (`StatCard`'s and
`SessionOverview`'s `help` prop) are worded in `common:insights.help.*`, `en` +
`vi`.
---
## Utilities
@@ -92,6 +92,7 @@ import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "./ContextM
import { fmtCost, timeAgo } from "../lib/format";
import { toast } from "../lib/toast";
import { useAiTools } from "../hooks/useAiTools";
import { useSpin } from "../hooks/useSpin";
import type { AlertEvent, AlertRule, AlertRuleType, RulePreview, WSMessage } from "../lib/types";
const PAGE_SIZE = 25;
@@ -342,6 +343,7 @@ export function AlertsNotifications() {
setLoadingAlerts(false);
}
}, [unackedOnly, t]);
const [spinning, refreshAlerts] = useSpin(loadAlerts);
const loadMore = useCallback(async () => {
try {
@@ -1017,11 +1019,13 @@ export function AlertsNotifications() {
label={t("feed.unackedOnly")}
/>
<button
onClick={() => loadAlerts()}
onClick={refreshAlerts}
disabled={spinning}
aria-busy={spinning}
className="btn-ghost border border-border inline-flex items-center gap-1.5 text-xs"
title={t("refresh")}
>
<RefreshCw className="w-3.5 h-3.5" />
<RefreshCw className={`w-3.5 h-3.5 ${spinning ? "animate-spin" : ""}`} />
</button>
{unacked > 0 && (
<button
+163
View File
@@ -0,0 +1,163 @@
/**
* @file AuthGate.tsx
* @description Server mode's login gate around the whole app (docs/SERVER-MODE.md).
* Asks `GET /api/auth/me` (again every 2 s while it gets no answer): in local
* mode — the default — it renders the app and nothing else changes, at once
* when this browser last saw local mode (`agenthub.authMode`); in server mode it renders the login page until
* someone logs in, and again whenever a request answers 401 `EAUTH`
* (`AUTH_REQUIRED_EVENT`). Logging out unmounts the app, so no page keeps the
* previous user's data. `useAuth()` gives the sidebar the user and `logout`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import {
createContext,
useContext,
useEffect,
useState,
type FormEvent,
type ReactNode,
} from "react";
import { useTranslation } from "react-i18next";
import { api, AUTH_REQUIRED_EVENT, type AuthState, type AuthUser } from "../lib/api";
type AuthValue = { state: AuthState; logout: () => Promise<void> };
const AuthContext = createContext<AuthValue>({ state: { mode: "local" }, logout: async () => {} });
/** The current login; `{mode: "local"}` outside server mode (and in tests). */
export const useAuth = () => useContext(AuthContext);
/** The mode this browser last saw, so a local dashboard paints at once. */
const MODE_KEY = "agenthub.authMode";
const RETRY_MS = 2000;
function cachedState(): AuthState | null {
try {
return localStorage.getItem(MODE_KEY) === "local" ? { mode: "local" } : null;
} catch {
return null;
}
}
export function AuthGate({ children }: { children: ReactNode }) {
// null = still asking the server. A browser that last saw local mode starts
// there and is corrected by the answer (or by a 401) if that changed.
const [state, setState] = useState<AuthState | null>(cachedState);
useEffect(() => {
let timer: number | undefined;
let alive = true;
const ask = () => {
api.auth
.me()
.then((next) => {
if (!alive) return;
try {
localStorage.setItem(MODE_KEY, next.mode);
} catch {
/* storage blocked */
}
setState(next);
})
.catch((err: { status?: number; code?: string }) => {
if (!alive) return;
// An answer that is not this build's: an older server still running
// under a freshly built UI (404, or its index.html → EBADRESPONSE), or
// DASHBOARD_TOKEN's own 401 — neither is server mode, so render the
// app as before and let it show its own state.
if (
err?.status === 404 ||
err?.code === "EBADRESPONSE" ||
(err?.status === 401 && err?.code !== "EAUTH")
) {
setState({ mode: "local" });
return;
}
// No answer at all (a restart, a proxy's 502): ask again rather than
// guess — "local" would hide a server-mode dashboard's login page.
timer = window.setTimeout(ask, RETRY_MS);
});
};
ask();
// Any request answering 401 EAUTH means server mode with no valid login.
const onRequired = () => setState({ mode: "server", user: null });
window.addEventListener(AUTH_REQUIRED_EVENT, onRequired);
return () => {
alive = false;
window.clearTimeout(timer);
window.removeEventListener(AUTH_REQUIRED_EVENT, onRequired);
};
}, []);
if (!state) return null;
if (state.mode === "server" && !state.user) {
return <LoginPage onLoggedIn={(user) => setState({ mode: "server", user })} />;
}
const logout = async () => {
await api.auth.logout().catch(() => {});
setState({ mode: "server", user: null });
};
return <AuthContext.Provider value={{ state, logout }}>{children}</AuthContext.Provider>;
}
function LoginPage({ onLoggedIn }: { onLoggedIn: (user: AuthUser) => void }) {
const { t } = useTranslation();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const submit = async (e: FormEvent) => {
e.preventDefault();
setBusy(true);
setError(null);
try {
onLoggedIn((await api.auth.login(username, password)).user);
} catch (err) {
const code = (err as { code?: string }).code;
setError(code === "ETOOMANY" ? t("auth.tooMany") : t("auth.failed"));
setPassword("");
} finally {
setBusy(false);
}
};
return (
<main className="flex min-h-screen items-center justify-center bg-surface-0 p-4">
<form onSubmit={submit} className="card w-full max-w-sm space-y-4 p-6">
<h1 className="text-lg font-semibold text-fg-primary">{t("auth.title")}</h1>
<label className="block space-y-1 text-sm text-fg-secondary">
<span>{t("auth.username")}</span>
<input
className="input w-full"
autoComplete="username"
autoFocus
required
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</label>
<label className="block space-y-1 text-sm text-fg-secondary">
<span>{t("auth.password")}</span>
<input
className="input w-full"
type="password"
autoComplete="current-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</label>
{error && (
<p role="alert" className="text-sm text-status-danger">
{error}
</p>
)}
<button type="submit" className="btn-primary w-full" disabled={busy}>
{busy ? t("auth.submitting") : t("auth.submit")}
</button>
</form>
</main>
);
}
+19 -3
View File
@@ -88,12 +88,15 @@ function StatTile({
label,
value,
hint,
help,
icon,
tone = "default",
}: {
label: string;
value: React.ReactNode;
hint?: string;
/** Plain-language explanation, shown as the label's tooltip. */
help?: string;
icon: React.ReactNode;
tone?: "default" | "violet" | "emerald" | "amber" | "rose" | "cyan" | "blue";
}) {
@@ -120,9 +123,14 @@ function StatTile({
return (
<div className={`rounded-lg border px-3 py-2.5 ${palette}`}>
<div className="flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-fg-muted">
<div
className={`flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-fg-muted${help ? " cursor-help" : ""}`}
title={help}
>
<span className={iconTone}>{icon}</span>
{label}
<span className={help ? "underline decoration-dotted underline-offset-2" : undefined}>
{label}
</span>
</div>
<div className="mt-1 font-mono text-lg font-semibold text-fg-primary leading-tight">
{value}
@@ -310,6 +318,7 @@ export function SessionOverview({ session, agents, stats: frozen }: SessionOverv
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-2.5">
<StatTile
label={t("overview.events")}
help={t("common:insights.help.events")}
value={stats.total_events.toLocaleString()}
hint={
eventRate !== null && eventRate > 0
@@ -322,6 +331,7 @@ export function SessionOverview({ session, agents, stats: frozen }: SessionOverv
/>
<StatTile
label={t("overview.toolCalls")}
help={t("common:insights.help.toolCalls")}
value={toolCallCount.toLocaleString()}
hint={distinctTools > 0 ? t("overview.unique", { count: distinctTools }) : undefined}
icon={<Wrench className="w-3 h-3" />}
@@ -329,6 +339,7 @@ export function SessionOverview({ session, agents, stats: frozen }: SessionOverv
/>
<StatTile
label={t("overview.subagents")}
help={t("common:insights.help.subagents")}
value={stats.agents.subagent.toLocaleString()}
hint={
stats.agents.main > 0 ? t("overview.plusMain", { count: stats.agents.main }) : undefined
@@ -338,12 +349,14 @@ export function SessionOverview({ session, agents, stats: frozen }: SessionOverv
/>
<StatTile
label={t("overview.compactions")}
help={t("common:insights.help.compactions")}
value={stats.agents.compaction.toLocaleString()}
icon={<Layers className="w-3 h-3" />}
tone="blue"
/>
<StatTile
label={t("overview.errors")}
help={t("common:insights.help.errors")}
value={stats.error_count.toLocaleString()}
icon={<AlertTriangle className="w-3 h-3" />}
tone={stats.error_count > 0 ? "rose" : "default"}
@@ -478,7 +491,10 @@ export function SessionOverview({ session, agents, stats: frozen }: SessionOverv
{totalTokens > 0 && (
<div className="rounded-lg border border-surface-3 bg-surface-2/60 p-3.5">
<div className="flex items-center justify-between mb-2.5">
<h3 className="text-xs font-semibold text-fg-secondary uppercase tracking-wider flex items-center gap-1.5">
<h3
className="text-xs font-semibold text-fg-secondary uppercase tracking-wider flex items-center gap-1.5 cursor-help"
title={t("common:insights.help.tokenFlow")}
>
<Coins className="w-3.5 h-3.5 text-status-warning" />
{t("overview.tokenFlow")}
</h3>
+19
View File
@@ -69,9 +69,11 @@ import {
ChevronUp,
ChevronDown,
FolderTree,
LogOut,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { api } from "../lib/api";
import { useAuth } from "./AuthGate";
import { eventBus } from "../lib/eventBus";
import { WorkspaceTree } from "./WorkspaceTree";
import type { UpdateStatusPayload, WSMessage } from "../lib/types";
@@ -188,6 +190,7 @@ export function Sidebar({
resizing = false,
}: SidebarProps) {
const { t } = useTranslation();
const auth = useAuth();
const asideRef = useRef<HTMLElement>(null);
const drawerOpen = Boolean(drawer?.open);
useDialog(drawerOpen, asideRef, drawer?.onClose);
@@ -531,6 +534,22 @@ export function Sidebar({
<Settings className="w-4 h-4 flex-shrink-0" />
{!collapsed && <span>{t("nav:settings")}</span>}
</NavLink>
{auth.state.mode === "server" && auth.state.user && (
<button
type="button"
onClick={() => void auth.logout()}
title={`${t("common:auth.signedInAs", {
name: auth.state.user.username,
role: auth.state.user.role,
})} — ${t("common:auth.logout")}`}
aria-label={t("common:auth.logout")}
className={`inline-flex items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary ${
collapsed ? "py-2" : "h-8 w-8 flex-shrink-0"
}`}
>
<LogOut className="h-4 w-4" />
</button>
)}
<button
type="button"
onClick={onToggle}
+7 -1
View File
@@ -77,6 +77,8 @@ interface StatCardProps {
loading?: boolean;
/** Drill-in: the whole card links here (e.g. the list the number counts). */
to?: string;
/** Plain-language explanation of the metric, shown as the label's tooltip. */
help?: string;
}
/**
@@ -92,11 +94,15 @@ export function StatCard({
raw,
loading = false,
to,
help,
}: StatCardProps) {
const body = (
<>
<div className="flex items-center justify-between gap-3 mb-3">
<span className="text-xs font-medium text-fg-muted uppercase tracking-wider truncate">
<span
className={`text-xs font-medium text-fg-muted uppercase tracking-wider truncate${help ? " cursor-help underline decoration-dotted underline-offset-2" : ""}`}
title={help}
>
{label}
</span>
<Icon className={`w-5 h-5 flex-shrink-0 ${accentColor}`} />
+12 -11
View File
@@ -30,6 +30,7 @@ import type { BudgetStatus, CostResult, ToolQuota } from "../lib/types";
import { fmt, fmtCostFull, formatModelName, timeAgo } from "../lib/format";
import { AiToolIcon } from "./AiToolIcon";
import { useLiveRefresh } from "../hooks/useLiveRefresh";
import { useSpin } from "../hooks/useSpin";
import { LIVE_RUNS_EVENT, selectedFrom } from "./WorkspaceTree";
const COST_POLL_MS = 30_000;
@@ -265,15 +266,15 @@ function QuotaChips() {
closeTimer.current = window.setTimeout(() => setOpen(false), 200);
};
useEffect(() => () => window.clearTimeout(closeTimer.current), []);
const [refreshing, setRefreshing] = useState(false);
const load = useCallback((force = false) => {
if (force) setRefreshing(true);
api.pricing
.quota(force)
.then((r) => setTools(r.tools))
.catch(() => undefined)
.finally(() => force && setRefreshing(false));
}, []);
const load = useCallback(
(force = false) =>
api.pricing
.quota(force)
.then((r) => setTools(r.tools))
.catch(() => undefined),
[]
);
const [refreshing, refresh] = useSpin(() => load(true));
useVisiblePoll(load, QUOTA_POLL_MS, [load]);
if (!tools.length) return null;
return (
@@ -282,7 +283,7 @@ function QuotaChips() {
type="button"
data-testid="status-bar-quota"
data-tip-off
onClick={() => load(true)}
onClick={refresh}
onMouseEnter={show}
onMouseLeave={hide}
onFocus={show}
@@ -314,7 +315,7 @@ function QuotaChips() {
tools={tools}
onEnter={show}
onLeave={hide}
onRefresh={() => load(true)}
onRefresh={refresh}
refreshing={refreshing}
/>
)}
@@ -0,0 +1,100 @@
/**
* @file AuthGate.test.tsx
* @description The login gate: local mode renders the app untouched, server
* mode shows the login page until a login succeeds, a wrong password says so,
* and a later 401 `EAUTH` brings the login page back.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { act, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "../../i18n";
const me = vi.fn();
const login = vi.fn();
vi.mock("../../lib/api", () => ({
AUTH_REQUIRED_EVENT: "agenthub:auth-required",
api: { auth: { me: () => me(), login: (u: string, p: string) => login(u, p), logout: vi.fn() } },
}));
import { AuthGate } from "../AuthGate";
const user = { id: 1, username: "mia", role: "member" };
beforeEach(() => {
me.mockReset();
login.mockReset();
localStorage.clear();
});
describe("AuthGate", () => {
it("renders the app in local mode", async () => {
me.mockResolvedValue({ mode: "local" });
render(<AuthGate>app</AuthGate>);
expect(await screen.findByText("app")).toBeInTheDocument();
});
it("paints at once in a browser that last saw local mode", () => {
localStorage.setItem("agenthub.authMode", "local");
me.mockReturnValue(new Promise(() => {}));
render(<AuthGate>app</AuthGate>);
expect(screen.getByText("app")).toBeInTheDocument();
});
it("asks again instead of guessing when the server does not answer", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
me.mockRejectedValueOnce(new Error("502"));
me.mockResolvedValue({ mode: "server", user: null });
render(<AuthGate>app</AuthGate>);
expect(screen.queryByText("app")).toBeNull();
await act(async () => {
await vi.advanceTimersByTimeAsync(2100);
});
expect(await screen.findByLabelText(/username/i)).toBeInTheDocument();
expect(screen.queryByText("app")).toBeNull();
} finally {
vi.useRealTimers();
}
});
it("renders the app when an older server or a dashboard token answers instead", async () => {
for (const err of [
{ status: 404 },
{ status: 200, code: "EBADRESPONSE" },
{ status: 401, code: "EUNAUTHORIZED" },
]) {
me.mockReset();
me.mockRejectedValue(Object.assign(new Error("x"), err));
const { unmount } = render(<AuthGate>app</AuthGate>);
expect(await screen.findByText("app")).toBeInTheDocument();
unmount();
}
});
it("asks for a login in server mode and opens the app after one", async () => {
me.mockResolvedValue({ mode: "server", user: null });
login.mockRejectedValueOnce(Object.assign(new Error("x"), { code: "EBADLOGIN" }));
login.mockResolvedValueOnce({ user });
render(<AuthGate>app</AuthGate>);
const name = await screen.findByLabelText(/username/i);
const pass = screen.getByLabelText(/password/i);
await userEvent.type(name, "mia");
await userEvent.type(pass, "nope");
await userEvent.click(screen.getByRole("button"));
expect(await screen.findByRole("alert")).toBeInTheDocument();
expect(screen.queryByText("app")).toBeNull();
await userEvent.type(pass, "member-pass-1");
await userEvent.click(screen.getByRole("button"));
expect(await screen.findByText("app")).toBeInTheDocument();
expect(login).toHaveBeenLastCalledWith("mia", "member-pass-1");
act(() => {
window.dispatchEvent(new Event("agenthub:auth-required"));
});
expect(await screen.findByLabelText(/username/i)).toBeInTheDocument();
});
});
@@ -244,7 +244,11 @@ describe("StatusBar", () => {
await user.click(refresh);
expect(api.pricing.quota).toHaveBeenCalledTimes(1);
await act(async () => answer(same));
expect(screen.getByTestId("status-bar-quota-refresh")).toHaveTextContent("Refresh");
// The spin outlasts an instant answer (a throttled refresh is served from
// cache), so the label returns after the minimum spin.
await waitFor(() =>
expect(screen.getByTestId("status-bar-quota-refresh")).toHaveTextContent(/^Refresh$/)
);
expect(screen.getByTestId("status-bar-quota-panel")).toBeInTheDocument();
await user.unhover(panel);
await waitFor(() => expect(screen.queryByTestId("status-bar-quota-panel")).toBeNull());
@@ -59,6 +59,7 @@ import { api } from "../../lib/api";
import { eventBus } from "../../lib/eventBus";
import { isRemoteDataRefreshMessage } from "../../lib/remoteDataEvents";
import { MessageList } from "./MessageList";
import { useSpin } from "../../hooks/useSpin";
import { useTranslation } from "react-i18next";
import type { TranscriptMessage, TranscriptInfo, WSMessage } from "../../lib/types";
@@ -106,7 +107,6 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
const genRef = useRef(0);
// Refresh-button spinner state - separate from initial `loading` so the
// existing skeleton doesn't blink during a manual refresh.
const [refreshing, setRefreshing] = useState(false);
// Load available transcript list (also rescanned on a short interval so
// newly-spawned subagents appear in the dropdown without a page reload).
@@ -305,14 +305,7 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
// Manual refresh - surfaces a control in the toolbar so users can force
// a sync without reloading the page.
const refresh = useCallback(async () => {
setRefreshing(true);
try {
await fetchNewMessages();
} finally {
setRefreshing(false);
}
}, [fetchNewMessages]);
const [refreshing, refresh] = useSpin(fetchNewMessages);
// Scroll-up to load history
const loadHistory = useCallback(async () => {
@@ -81,6 +81,7 @@ import { copyText } from "../../lib/clipboard";
import { isSubmitEnter } from "../../lib/keys";
import { toast } from "../../lib/toast";
import { useArmed } from "../../hooks/useArmed";
import { useSpin } from "../../hooks/useSpin";
import { useUnsavedGuard } from "../../hooks/useUnsavedGuard";
import {
LaneDiffView,
@@ -1421,8 +1422,9 @@ export function LaneChangesPanel({
setTreeChildren({});
setExpandedDirs(new Set());
setReread((n) => n + 1);
void load();
return load();
};
const [rereading, runReread] = useSpin(rereadAll);
const toggleCollapsed = (path: string) =>
setCollapsed((prev) => {
@@ -1609,12 +1611,12 @@ export function LaneChangesPanel({
<button
type="button"
onClick={rereadAll}
onClick={runReread}
aria-label={t("changes.refresh")}
title={t("changes.refresh")}
className="order-last ml-auto shrink-0 text-fg-muted hover:text-fg-primary"
>
<RefreshCw className={`h-3.5 w-3.5 ${loading ? "animate-spin" : ""}`} />
<RefreshCw className={`h-3.5 w-3.5 ${loading || rereading ? "animate-spin" : ""}`} />
</button>
</div>
@@ -108,7 +108,7 @@ export function SessionReplay({
? { runId: holder.id, laneId: lane.id, held: true }
: await startInFreeTabDetailed(lane.id, { resumeSessionId: sessionId, prompt: text });
setPrompt("");
images.forEach((i) => URL.revokeObjectURL(i.url));
images.forEach((i) => i.url && URL.revokeObjectURL(i.url));
setImages([]);
onResumed(r.runId, r.laneId);
// Held already: the prompt reached nothing yet — paste it there.
+29 -6
View File
@@ -202,6 +202,12 @@ function HomeBody({
const [menuAt, setMenuAt] = useState<{ x: number; y: number } | null>(null);
const [title, setTitle] = useState<string | null>(null);
const fileInput = useRef<HTMLInputElement>(null);
const [ctxDrag, setCtxDrag] = useState(false);
const addFiles = async (picked: File[]) => {
for (const f of picked) {
if (!(await run(() => api.lanes.addFile(lane.id, f)))) break;
}
};
const memoryPanel = useRef<HTMLDivElement>(null);
useDialog(memoryOpen && !!memory, memoryPanel, () => setMemoryOpen(false));
const recentCtx = useContextMenu<Session>();
@@ -328,7 +334,7 @@ function HomeBody({
...(claude && inWorktree && project?.root ? { worktree: true } : {}),
});
setPrompt("");
images.forEach((i) => URL.revokeObjectURL(i.url));
images.forEach((i) => i.url && URL.revokeObjectURL(i.url));
setImages([]);
navigate(`/workspace?lane=${lane.id}&run=${encodeURIComponent(runId)}`);
} catch (err) {
@@ -839,7 +845,26 @@ function HomeBody({
</p>
</section>
<section className={card}>
<section
data-testid="workspace-context-files"
className={`${card} ${ctxDrag ? "ring-2 ring-accent/50" : ""}`}
// Files dragged in from the OS are added like the + button's pick.
onDragOver={(e) => {
if (!view || !e.dataTransfer.types.includes("Files")) return;
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
setCtxDrag(true);
}}
onDragLeave={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setCtxDrag(false);
}}
onDrop={(e) => {
setCtxDrag(false);
if (!view || e.dataTransfer.files.length === 0) return;
e.preventDefault();
void addFiles([...e.dataTransfer.files]);
}}
>
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-fg-primary">{t("home.context")}</h3>
<button
@@ -858,12 +883,10 @@ function HomeBody({
type="file"
multiple
hidden
onChange={async (e) => {
onChange={(e) => {
const picked = [...(e.target.files ?? [])];
e.target.value = "";
for (const f of picked) {
if (!(await run(() => api.lanes.addFile(lane.id, f)))) break;
}
void addFiles(picked);
}}
/>
</div>
@@ -12,7 +12,7 @@
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor, within } from "@testing-library/react";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
import type { Lane, Session } from "../../../lib/types";
@@ -454,6 +454,13 @@ describe("WorkspaceHome", () => {
const file = new File(["# notes"], "notes.md", { type: "text/markdown" });
await user.upload(screen.getByTestId("workspace-file-input"), file);
expect(api.lanes.addFile).toHaveBeenCalledWith(1, file);
const a = new File(["a"], "a.csv", { type: "text/csv" });
const b = new File(["b"], "b.log", { type: "" });
fireEvent.drop(screen.getByTestId("workspace-context-files"), {
dataTransfer: { types: ["Files"], files: [a, b] },
});
await waitFor(() => expect(api.lanes.addFile).toHaveBeenCalledWith(1, b));
expect(api.lanes.addFile).toHaveBeenCalledWith(1, a);
await user.click(screen.getByRole("button", { name: "Remove spec.pdf" }));
expect(api.lanes.removeSource).toHaveBeenCalledWith(1, 7);
});
+65 -10
View File
@@ -22,7 +22,10 @@
* a `+` item) brings it into the tab on screen, split to the right, and a
* one-pane tab drags onto the panes (`PaneGrid.tsx`) to join that tab. Any
* tab drags along the bar to reorder it, and a pane header dragged onto the
* bar takes that pane out into a tab of its own there. A right-click on a tab
* bar takes that pane out into a tab of its own there. Dropped on the MIDDLE
* of another tab, a tab (or a pane header) joins that tab instead
* (`joinGroup`: side by side, then under the right pane, then under the left
* one, up to four panes). A right-click on a tab
* opens the same actions as a menu (`ContextMenu`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -51,7 +54,15 @@ import { AiToolIcon } from "../AiToolIcon";
import { freeSlot, startInFreeTab } from "../lanes/startSession";
import { PANE_DRAG_TYPE, RunIcon, startPaneDrag, type GroupsUpdate } from "./PaneGrid";
import { insertionIndex } from "../../lib/laneOrderStorage";
import { type PaneGroups, detachSlot, groupOf, leaves, reorderGroups } from "../../lib/paneLayout";
import {
type PaneGroups,
MAX_PANES,
detachSlot,
groupOf,
joinGroup,
leaves,
reorderGroups,
} from "../../lib/paneLayout";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
type AiToolsState = ReturnType<typeof useAiTools>;
@@ -201,6 +212,10 @@ export function ConsoleTabs({
// point among the drawn tabs, from the pointer against their middles.
const listRef = useRef<HTMLDivElement>(null);
const [dropAt, setDropAt] = useState<number | null>(null);
// The drawn tab a drop would join (the pointer on its middle half), and the
// slots a drag from this bar carries — unknown for a pane header (one slot).
const [joinAt, setJoinAt] = useState<number | null>(null);
const dragged = useRef<number[]>([]);
const isBarDrag = (e: DragEvent) => {
const types = e.dataTransfer?.types;
return !!types && (types.includes(PANE_DRAG_TYPE) || types.includes(TAB_DRAG_TYPE));
@@ -215,31 +230,70 @@ export function ConsoleTabs({
x
);
};
const joinIndexAt = (x: number) => {
const els = [...(listRef.current?.querySelectorAll<HTMLElement>("[data-tab-index]") ?? [])];
const i = els.findIndex((el) => {
const b = el.getBoundingClientRect();
return x >= b.left + b.width / 4 && x <= b.right - b.width / 4;
});
const tab = tabs[i];
if (!tab || dragged.current.some((s) => tab.slots.includes(s))) return null;
return tab.slots.length + Math.max(dragged.current.length, 1) <= MAX_PANES ? i : null;
};
const endDrag = () => {
dragged.current = [];
setDropAt(null);
setJoinAt(null);
};
const dragHandlers = {
onDragOver: (e: DragEvent) => {
if (!isBarDrag(e)) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
const at = dropIndexAt(e.clientX);
const join = joinIndexAt(e.clientX);
const at = join === null ? dropIndexAt(e.clientX) : null;
setJoinAt((d) => (d === join ? d : join));
setDropAt((d) => (d === at ? d : at));
},
onDragLeave: (e: DragEvent) => {
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setDropAt(null);
},
onDragEnd: () => setDropAt(null),
onDrop: (e: DragEvent) => {
if (e.currentTarget.contains(e.relatedTarget as Node | null)) return;
setDropAt(null);
setJoinAt(null);
},
onDragEnd: endDrag,
onDrop: (e: DragEvent) => {
const join = joinIndexAt(e.clientX);
endDrag();
if (!isBarDrag(e)) return;
e.preventDefault();
const tab = e.dataTransfer.getData(TAB_DRAG_TYPE);
const raw = e.dataTransfer.getData(PANE_DRAG_TYPE);
const target = join === null ? undefined : tabs[join];
// A pane header dropped on its OWN tab is a detach, not a join: a pane
// drag carries no slots during dragover, so joinIndexAt cannot skip it.
const ownTab =
tab === "" && raw !== "" && target && groupOf(groups, Number(raw)) === target.index;
if (target && !ownTab) {
onGroupsChange((g) =>
joinGroup(
g,
tab !== ""
? leaves(g.groups[Number(tab)]?.root ?? null)
: raw !== ""
? [Number(raw)]
: [],
target.index
)
);
return;
}
// The drawn position, as an index into the groups.
const drawn = dropIndexAt(e.clientX);
const at = tabs[drawn]?.index ?? groups.groups.length;
const tab = e.dataTransfer.getData(TAB_DRAG_TYPE);
if (tab !== "") {
onGroupsChange((g) => reorderGroups(g, Number(tab), at));
return;
}
const raw = e.dataTransfer.getData(PANE_DRAG_TYPE);
if (raw === "") return;
const slot = Number(raw);
onGroupsChange((g) => {
@@ -426,6 +480,7 @@ export function ConsoleTabs({
data-slots={tab.key}
draggable
onDragStart={(e) => {
dragged.current = tab.slots;
if (single) startPaneDrag(e, single.slot ?? 0);
else {
e.dataTransfer?.setData(TAB_DRAG_TYPE, String(tab.index));
@@ -439,7 +494,7 @@ export function ConsoleTabs({
active
? "bg-surface-0 text-fg-primary"
: "text-fg-muted hover:bg-surface-4 hover:text-fg-secondary"
}`}
} ${joinAt === i ? "ring-2 ring-inset ring-accent/60 bg-accent/15" : ""}`}
>
{(dropAt === i || (dropAt === tabs.length && i === tabs.length - 1)) && (
<span
+38 -26
View File
@@ -6,8 +6,8 @@
* box (`WorkspaceHome.tsx`):
* - `PromptEditor` — the prompt textarea with `/` (commands and skills,
* Claude only) and `@` (`GET /api/run/files`) autocomplete, Shift/Alt+Enter
* newlines, and a pasted or dropped image attached by its saved path
* (`POST /api/run/drop-image`);
* newlines, and pasted or dropped files (images as thumbnails, any other
* as a name chip) attached by their saved paths (`POST /api/run/drop-file`);
* - `CwdAutocomplete` — the folder field the Add-workspace modal uses;
* - `ModelPicker` — a tool's own model list (`GET /api/ai-tools/:id/models`)
* or a fixed one (an AI Proxy's models), plus Custom…;
@@ -25,6 +25,7 @@ import {
Check,
ChevronDown,
ChevronRight,
FileText,
FolderOpen,
GitBranch,
Home,
@@ -153,8 +154,8 @@ interface PromptEditorProps {
slash: boolean;
/** Where `@` looks for files and project commands/skills are read from. */
fileCwd: string;
/** Images pasted or dropped so far, shown as thumbnails; the caller sends
* them with the text through `promptWithImages`. */
/** Files pasted or dropped so far (images shown as thumbnails); the caller
* sends them with the text through `promptWithImages`. */
images: PromptImage[];
onImagesChange: Dispatch<SetStateAction<PromptImage[]>>;
/** Borderless, for a composer box that draws its own frame. */
@@ -164,11 +165,12 @@ interface PromptEditorProps {
onBusyChange?: (busy: boolean) => void;
}
/** An attached image: its path on the server's machine and a local preview. */
export type PromptImage = { path: string; url: string; name: string };
/** An attached file: its path on the server's machine, and a local preview
* `url` when it is an image. */
export type PromptImage = { path: string; url?: string; name: string };
/** The prompt as sent: the text, then each image's saved path — which is how
* the CLI is handed an image. */
/** The prompt as sent: the text, then each file's saved path — which is how
* the CLI is handed an image or a file. */
export function promptWithImages(text: string, images: PromptImage[]): string {
return [text.trim(), ...images.map((i) => i.path)].filter(Boolean).join(" ");
}
@@ -309,9 +311,9 @@ export function PromptEditor({
});
};
// A pasted or dropped image is saved on the server's machine (the same
// route the terminal's drop uses) and shown as a thumbnail; its path is
// added to the prompt only when it is sent (promptWithImages).
// A pasted or dropped file is saved on the server's machine (the same
// route the terminal's drop uses) and shown as a thumbnail or a name chip;
// its path is added to the prompt only when it is sent (promptWithImages).
const [upload, setUpload] = useState<"uploading" | "failed" | null>(null);
const [viewing, setViewing] = useState<PromptImage | null>(null);
const [imageMenu, setImageMenu] = useState<(MenuPoint & { img: PromptImage }) | null>(null);
@@ -320,15 +322,15 @@ export function PromptEditor({
busyRef.current = onBusyChange;
useEffect(() => busyRef.current?.(uploading), [uploading]);
const attachImages = (list: FileList | null | undefined) => {
const picked = Array.from(list ?? []).filter((f) => f.type.startsWith("image/"));
const picked = Array.from(list ?? []);
if (picked.length === 0) return false;
setUpload("uploading");
Promise.all(picked.map((f) => api.run.dropImage(f)))
Promise.all(picked.map((f) => api.run.dropFile(f)))
.then((saved) => {
setUpload(null);
const added = picked.map((f, i) => ({
path: saved[i]!.path,
url: URL.createObjectURL(f),
url: f.type.startsWith("image/") ? URL.createObjectURL(f) : undefined,
name: f.name,
}));
onImagesChange((prev) => [...prev, ...added]);
@@ -337,7 +339,7 @@ export function PromptEditor({
return true;
};
const removeImage = (img: PromptImage) => {
URL.revokeObjectURL(img.url);
if (img.url) URL.revokeObjectURL(img.url);
onImagesChange((prev) => prev.filter((x) => x !== img));
};
useEffect(() => {
@@ -394,17 +396,27 @@ export function PromptEditor({
{images.length > 0 && (
<div data-testid="prompt-images" className="mb-2 flex flex-wrap gap-2 px-1 pt-1">
{images.map((img) => (
<div key={img.url} className="group relative">
<button
type="button"
aria-label={img.name}
title={img.name}
onClick={() => setViewing(img)}
onContextMenu={(e) => setImageMenu({ ...menuPoint(e), img })}
className="block h-14 w-14 overflow-hidden rounded-lg border border-border bg-surface-3 hover:border-accent/50"
>
<img src={img.url} alt="" className="h-full w-full object-cover" />
</button>
<div key={img.path} className="group relative">
{img.url ? (
<button
type="button"
aria-label={img.name}
title={img.name}
onClick={() => setViewing(img)}
onContextMenu={(e) => setImageMenu({ ...menuPoint(e), img })}
className="block h-14 w-14 overflow-hidden rounded-lg border border-border bg-surface-3 hover:border-accent/50"
>
<img src={img.url} alt="" className="h-full w-full object-cover" />
</button>
) : (
<span
title={img.path}
className="flex h-14 max-w-48 items-center gap-1.5 rounded-lg border border-border bg-surface-3 px-2 text-xs text-fg-secondary"
>
<FileText className="h-4 w-4 shrink-0 text-fg-muted" />
<span className="truncate">{img.name}</span>
</span>
)}
<button
type="button"
aria-label={t("drop.remove")}
+10 -11
View File
@@ -14,9 +14,10 @@
* (`loadXterm`), not bundled with the Workspace chunk.
* Always painted dark, in the palette of the user's chosen DARK theme
* (`lib/terminalTheme.ts`), recoloured live without re-attaching.
* An image dropped on it is saved on the server (`POST /api/run/drop-image`)
* and its PATH pasted into the pane — what a local terminal does on a drop,
* and how `claude` attaches an image. The pane also registers with
* Files (images or any other) dropped on it are saved on the server
* (`POST /api/run/drop-file`) and their PATHS pasted into the pane — what a
* local terminal does on a drop, and how `claude` attaches an image or reads
* a file. The pane also registers with
* `lib/panePaste.ts`, so a diff-line comment from the Changes panel can be
* pasted into whichever of the workspace's panes was focused last.
* Keyboard: the terminal keeps Tab and Shift+Tab (claude cycles its permission
@@ -46,7 +47,7 @@ import { terminalThemeFor } from "../../lib/terminalTheme";
import { api } from "../../lib/api";
import { notePaneFocus, registerPane } from "../../lib/panePaste";
type DropState = "hint" | "uploading" | "failed" | "imagesOnly";
type DropState = "hint" | "uploading" | "failed";
/** localStorage flag for xterm's screen-reader mode (a Settings toggle can set it later). */
export const SCREEN_READER_KEY = "agenthub.terminal.screenReader";
@@ -183,13 +184,13 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
if (!hasFiles(e)) return; // dragged text keeps xterm's own handling
e.preventDefault();
dragDepth.current = 0;
const images = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith("image/"));
if (images.length === 0) return flash("imagesOnly");
const files = Array.from(e.dataTransfer.files);
if (files.length === 0) return;
setDrop("uploading");
try {
const saved = await Promise.all(images.map((f) => api.run.dropImage(f)));
const saved = await Promise.all(files.map((f) => api.run.dropFile(f)));
// paste(), not a raw send: it honours bracketed-paste mode, so claude
// sees one pasted path rather than typed keystrokes.
// sees pasted paths rather than typed keystrokes.
termRef.current?.paste(saved.map((r) => r.path).join(" ") + " ");
termRef.current?.focus();
setDrop(null);
@@ -650,9 +651,7 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
<div
data-testid="terminal-drop"
className={`pointer-events-none absolute inset-2 flex items-center justify-center rounded-lg border-2 border-dashed bg-black/50 text-sm font-medium ${
drop === "failed" || drop === "imagesOnly"
? "border-red-400 text-red-300"
: "border-sky-400 text-sky-200"
drop === "failed" ? "border-red-400 text-red-300" : "border-sky-400 text-sky-200"
}`}
>
{t(`drop.${drop}`)}
@@ -44,7 +44,7 @@ vi.mock("../../../lib/api", async (importOriginal) => {
return { flag: id in lists, models: lists[id] ?? [], defaultModel: null };
}),
},
run: { files: r({ items: [] }), dropImage: r({ path: "/tmp/agenthub-drops-1/a.png" }) },
run: { files: r({ items: [] }), dropFile: r({ path: "/tmp/agenthub-drops-1/a.png" }) },
ccConfig: {
commands: r({
items: [
@@ -195,7 +195,7 @@ describe("PromptEditor — newlines and images", () => {
const img = new File(["x"], "shot.png", { type: "image/png" });
fireEvent.paste(box(), { clipboardData: { files: [img] } });
const thumb = await screen.findByRole("button", { name: "shot.png" });
expect(api.run.dropImage).toHaveBeenCalledWith(img);
expect(api.run.dropFile).toHaveBeenCalledWith(img);
// The path is not typed into the prompt; it goes with it on send.
expect(screen.getByTestId("sent").textContent).toBe("see /tmp/agenthub-drops-1/a.png");
@@ -204,16 +204,43 @@ describe("PromptEditor — newlines and images", () => {
fireEvent.keyDown(document, { key: "Escape" });
expect(screen.queryByRole("dialog")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Remove image" }));
fireEvent.click(screen.getByRole("button", { name: "Remove attachment" }));
expect(screen.queryByRole("button", { name: "shot.png" })).toBeNull();
expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:shot");
expect(screen.getByTestId("sent").textContent).toBe("see");
});
it("attaches a dropped non-image file as a name chip sent by its path", async () => {
vi.mocked(api.run.dropFile).mockResolvedValueOnce({ path: "/tmp/agenthub-drops-1/b-spec.pdf" });
function Harness() {
const [images, setImages] = useState<PromptImage[]>([]);
return (
<>
<PromptEditor
value="read"
onChange={() => {}}
onSubmit={() => {}}
placeholder="Ask anything…"
slash
fileCwd="/Users/tester"
images={images}
onImagesChange={setImages}
/>
<output data-testid="sent">{promptWithImages("read", images)}</output>
</>
);
}
render(<Harness />);
const pdf = new File(["%PDF"], "spec.pdf", { type: "application/pdf" });
fireEvent.drop(box(), { dataTransfer: { files: [pdf] } });
expect(await screen.findByText("spec.pdf")).toBeTruthy();
expect(screen.getByTestId("sent").textContent).toBe("read /tmp/agenthub-drops-1/b-spec.pdf");
});
it("leaves a text paste to the textarea", () => {
renderEditor("");
fireEvent.paste(box(), { clipboardData: { files: [] } });
expect(api.run.dropImage).not.toHaveBeenCalled();
expect(api.run.dropFile).not.toHaveBeenCalled();
});
});
@@ -21,9 +21,9 @@ const wheelHandlerMock = vi.fn();
const keyHandlerMock = vi.fn();
const focusMock = vi.fn();
const pasteMock = vi.fn();
const dropImageMock = vi.fn();
const dropFileMock = vi.fn();
vi.mock("../../../lib/api", () => ({
api: { run: { dropImage: (f: Blob) => dropImageMock(f) } },
api: { run: { dropFile: (f: File) => dropFileMock(f) } },
}));
const loadAddonMock = vi.fn();
// Mutable stand-ins for the two live terminal facts the wheel handler reads.
@@ -421,29 +421,36 @@ describe("TerminalView", () => {
});
it("pastes the server-side path of a dropped image into the pane", async () => {
dropImageMock.mockReset().mockResolvedValue({ path: "/tmp/agenthub-drops-1/ab.png" });
dropFileMock.mockReset().mockResolvedValue({ path: "/tmp/agenthub-drops-1/ab-shot.png" });
pasteMock.mockClear();
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
const img = new File([new Uint8Array([137, 80])], "shot.png", { type: "image/png" });
const target = screen.getByTestId("terminal-view");
fireEvent.dragEnter(target, { dataTransfer: { types: ["Files"], files: [img] } });
expect(screen.getByTestId("terminal-drop")).toHaveTextContent("Drop image to attach");
expect(screen.getByTestId("terminal-drop")).toHaveTextContent("Drop files or images to attach");
fireEvent.drop(target, { dataTransfer: { types: ["Files"], files: [img] } });
await waitFor(() => expect(pasteMock).toHaveBeenCalledWith("/tmp/agenthub-drops-1/ab.png "));
expect(dropImageMock).toHaveBeenCalledWith(img);
await waitFor(() =>
expect(pasteMock).toHaveBeenCalledWith("/tmp/agenthub-drops-1/ab-shot.png ")
);
expect(dropFileMock).toHaveBeenCalledWith(img);
await waitFor(() => expect(screen.queryByTestId("terminal-drop")).toBeNull());
});
it("uploads nothing for a dropped non-image and says why", async () => {
dropImageMock.mockReset();
it("pastes every path of several dropped files, images or not", async () => {
dropFileMock
.mockReset()
.mockImplementation(async (f: File) => ({ path: `/tmp/agenthub-drops-1/x-${f.name}` }));
pasteMock.mockClear();
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
const txt = new File(["hi"], "a.txt", { type: "text/plain" });
const img = new File(["x"], "b.png", { type: "image/png" });
fireEvent.drop(screen.getByTestId("terminal-view"), {
dataTransfer: { types: ["Files"], files: [txt] },
dataTransfer: { types: ["Files"], files: [txt, img] },
});
expect(await screen.findByText("Only images can be dropped here")).toBeInTheDocument();
expect(dropImageMock).not.toHaveBeenCalled();
expect(pasteMock).not.toHaveBeenCalled();
await waitFor(() =>
expect(pasteMock).toHaveBeenCalledWith(
"/tmp/agenthub-drops-1/x-a.txt /tmp/agenthub-drops-1/x-b.png "
)
);
});
});
@@ -23,7 +23,6 @@ const THEME_LABELS: Record<string, string> = {
darcula: "Darcula",
"apple-dark": "Apple Dark",
"apple-light": "Apple Light",
"intellij-light": "IntelliJ Light",
canvas: "Canvas",
"orca-dark": "Orca Dark",
"orca-light": "Orca Light",
@@ -0,0 +1,108 @@
/**
* @file UsersSettings.tsx
* @description Settings → Users, server mode only and only for an admin or
* analyst (docs/SERVER-MODE.md): every account with its role, state,
* workspaces, sessions and the tokens/$ it used in the last 7 and 30 days
* (`GET /api/users`), and the audit log of staff reads of members' sessions
* (`GET /api/users/audit`). Read-only — accounts are managed with
* `agenthub users …` on the host.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { api, type ServerUserUsage, type AuditEntry } from "../../lib/api";
import { fmt, fmtCost, formatDateTime } from "../../lib/format";
import { SettingsSection } from "./SettingsRow";
export function UsersSettings() {
const { t } = useTranslation("settings");
const [users, setUsers] = useState<ServerUserUsage[] | null>(null);
const [audit, setAudit] = useState<AuditEntry[] | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
api.users
.list()
.then((r) => setUsers(r.users))
.catch((e: Error) => setError(e.message));
api.users
.audit()
.then((r) => setAudit(r.entries))
.catch(() => setAudit([]));
}, []);
return (
<>
<SettingsSection title={t("users.title")} description={t("users.description")}>
{error && (
<p role="alert" className="py-2 text-xs text-status-danger">
{error}
</p>
)}
{users && (
<div className="overflow-x-auto">
<table className="w-full text-sm" data-testid="users-table">
<thead className="text-left text-[11px] uppercase text-fg-muted">
<tr>
<th className="py-2 pr-3 font-medium">{t("users.name")}</th>
<th className="py-2 pr-3 font-medium">{t("users.role")}</th>
<th className="py-2 pr-3 text-right font-medium">{t("users.workspaces")}</th>
<th className="py-2 pr-3 text-right font-medium">{t("users.sessions")}</th>
<th className="py-2 pr-3 text-right font-medium">{t("users.last7d")}</th>
<th className="py-2 text-right font-medium">{t("users.last30d")}</th>
</tr>
</thead>
<tbody className="divide-y divide-border/60">
{users.map((u) => (
<tr key={u.id} className={u.disabled ? "text-fg-muted" : "text-fg-secondary"}>
<td className="py-2 pr-3">
{u.username}
{u.disabled && (
<span className="ml-2 text-[11px]">({t("users.disabled")})</span>
)}
</td>
<td className="py-2 pr-3">{u.role}</td>
<td className="py-2 pr-3 text-right tabular-nums">{u.workspaces}</td>
<td className="py-2 pr-3 text-right tabular-nums">{u.sessions}</td>
<td
className="py-2 pr-3 text-right tabular-nums"
title={t("users.tokens", { n: fmt(u.last7d.tokens) })}
>
{fmtCost(u.last7d.cost)}
</td>
<td
className="py-2 text-right tabular-nums"
title={t("users.tokens", { n: fmt(u.last30d.tokens) })}
>
{fmtCost(u.last30d.cost)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<p className="pt-2 text-[11px] text-fg-muted">{t("users.manageHint")}</p>
</SettingsSection>
<SettingsSection title={t("users.auditTitle")} description={t("users.auditDescription")}>
{audit?.length === 0 && (
<p className="py-2 text-sm text-fg-muted">{t("users.auditEmpty")}</p>
)}
{audit && audit.length > 0 && (
<ul className="divide-y divide-border/60 text-xs" data-testid="audit-list">
{audit.map((a) => (
<li key={a.id} className="flex gap-3 py-1.5">
<span className="w-36 flex-shrink-0 text-fg-muted">{formatDateTime(a.at)}</span>
<span className="w-40 flex-shrink-0 text-fg-secondary">
{a.reader} → {a.owner}
</span>
<span className="min-w-0 truncate font-mono text-fg-muted">{a.path}</span>
</li>
))}
</ul>
)}
</SettingsSection>
</>
);
}
@@ -50,7 +50,6 @@ describe("AppearanceSettings", () => {
"Apple Dark",
"Orca Dark",
"Apple Light",
"IntelliJ Light",
"Canvas",
"Orca Light",
]) {
@@ -0,0 +1,57 @@
/**
* @file useSpin.test.ts
* @description Tests the Refresh-button hook: the reload runs with no
* arguments, a click while it is in flight is dropped, and the spin lasts at
* least the floor even when the reload answers at once — also under StrictMode's
* mount → unmount → mount, which once left it spinning for good.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, afterEach } from "vitest";
import { StrictMode } from "react";
import { act, renderHook } from "@testing-library/react";
import { useSpin } from "../useSpin";
afterEach(() => vi.useRealTimers());
describe("useSpin", () => {
it("drops a click while the reload is in flight, and spins for the floor after it answers", async () => {
vi.useFakeTimers();
let answer!: () => void;
const load = vi.fn(() => new Promise<void>((r) => (answer = r)));
const { result } = renderHook(() => useSpin(load, 600));
act(() => {
void result.current[1]();
});
expect(result.current[0]).toBe(true);
expect(load).toHaveBeenCalledWith();
act(() => {
void result.current[1]();
});
expect(load).toHaveBeenCalledTimes(1);
// Answered at once: still spinning until the floor, but a new click reloads.
await act(async () => answer());
expect(result.current[0]).toBe(true);
act(() => {
void result.current[1]();
});
expect(load).toHaveBeenCalledTimes(2);
await act(async () => {
answer();
await vi.advanceTimersByTimeAsync(600);
});
expect(result.current[0]).toBe(false);
});
it("stops spinning under StrictMode's double mount", async () => {
vi.useFakeTimers();
const { result } = renderHook(() => useSpin(() => undefined, 600), { wrapper: StrictMode });
await act(async () => {
void result.current[1]();
await vi.advanceTimersByTimeAsync(600);
});
expect(result.current[0]).toBe(false);
});
});
+53
View File
@@ -0,0 +1,53 @@
/**
* @file useSpin.ts
* @description A Refresh button's busy state. `run` calls the reload with no
* arguments (an `onClick={load}` handed the click event to it) and keeps
* `spinning` true for at least `minMs`, so a reload answered from a cache or
* a throttle in a few milliseconds still shows the user that it ran.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useRef, useState } from "react";
/**
* @returns `[spinning, run]`. A click while the reload is still in flight is
* dropped; once it has answered, a click reloads again even if the icon is
* still finishing its minimum spin.
*/
export function useSpin(fn: () => unknown, minMs = 600): [boolean, () => Promise<void>] {
const [spinning, setSpinning] = useState(false);
const inFlight = useRef(false);
const lastRun = useRef(0);
const fnRef = useRef(fn);
fnRef.current = fn;
const alive = useRef(true);
// Set on mount too: StrictMode mounts, unmounts and mounts again, and a
// cleanup-only effect left `alive` false — the spin never stopped and the
// button stayed busy for good.
useEffect(() => {
alive.current = true;
return () => {
alive.current = false;
};
}, []);
const run = useCallback(async () => {
if (inFlight.current) return;
inFlight.current = true;
const id = ++lastRun.current;
const started = Date.now();
setSpinning(true);
try {
await fnRef.current();
} finally {
inFlight.current = false;
const rest = minMs - (Date.now() - started);
if (rest > 0) await new Promise((r) => setTimeout(r, rest));
// Only the newest run stops the spin: an older one's floor ending must
// not cut short a reload clicked after it.
if (alive.current && id === lastRun.current) setSpinning(false);
}
}, [minMs]);
return [spinning, run];
}
+1 -1
View File
@@ -19,7 +19,7 @@ export type ThemeMode = Theme | "system";
/** Named themes per mode, in picker order. `default` is the stock palette. */
export const THEMES = {
dark: ["default", "darcula", "apple-dark", "orca-dark"],
light: ["default", "apple-light", "intellij-light", "canvas", "orca-light"],
light: ["default", "apple-light", "canvas", "orca-light"],
} as const satisfies Record<Theme, readonly string[]>;
const MODE_KEY = "theme";
+27
View File
@@ -202,5 +202,32 @@
"message": "Your edits have not been saved and will be lost.",
"discard": "Discard",
"keep": "Keep editing"
},
"auth": {
"title": "Sign in to AgentHub",
"username": "Username",
"password": "Password",
"submit": "Sign in",
"submitting": "Signing in…",
"failed": "Wrong username or password",
"tooMany": "Too many failed attempts — try again in a few minutes",
"logout": "Sign out",
"signedInAs": "Signed in as {{name}} ({{role}})"
},
"insights": {
"help": {
"sessions": "A session is one conversation with an AI agent, from start to finish.",
"agents": "Agents are the AI workers: the main one in each session plus any helpers it started.",
"tokens": "Tokens are the pieces of text the AI reads and writes. Roughly 1 token ≈ 4 characters. More tokens = more cost.",
"cost": "Estimated money spent, calculated from token counts and each model's price.",
"events": "Every step the AI takes (running a tool, finishing a reply…) is recorded as one event.",
"tokenMix": "Input = what you and the files sent the AI. Output = what the AI wrote. Cache read = text reused from memory (about 10× cheaper). Cache write = text saved to memory for reuse.",
"spendPace": "How fast money is being spent, and where this month will end up if that speed continues.",
"toolCalls": "How many actions the AI took: reading a file, editing, running a command…",
"subagents": "Helper agents the main AI started to work on part of the task in parallel.",
"compactions": "When a conversation gets too long, it is summarized to make room. Early details can be lost.",
"errors": "Steps that failed. A few are normal; many may mean the AI got stuck.",
"tokenFlow": "Input = sent to the AI, Output = written by the AI, Cache = reused from memory (cheaper)."
}
}
}
+4 -5
View File
@@ -142,11 +142,10 @@
"output": "Output"
},
"drop": {
"hint": "Drop image to attach",
"uploading": "Uploading image…",
"failed": "Could not attach the image",
"imagesOnly": "Only images can be dropped here",
"remove": "Remove image",
"hint": "Drop files or images to attach",
"uploading": "Uploading…",
"failed": "Could not attach the file",
"remove": "Remove attachment",
"view": "View image"
},
"terminal": {
+20
View File
@@ -823,6 +823,10 @@
"reports": {
"title": "Reports",
"description": "Analytics summaries written on a schedule, as HTML or JSON."
},
"users": {
"title": "Users",
"description": "Accounts, their usage and the audit log."
}
},
"backPrevious": "Back",
@@ -942,5 +946,21 @@
"importBody": "The file's preferences replace this browser's, then the page reloads.",
"imported": "Imported {{count}} preference(s). Reloading…",
"badFile": "That file is not an AgentHub preferences file."
},
"users": {
"title": "Users",
"description": "Every account on this server, with its workspaces, sessions and recent usage.",
"name": "User",
"role": "Role",
"workspaces": "Workspaces",
"sessions": "Sessions",
"last7d": "7 days",
"last30d": "30 days",
"disabled": "disabled",
"tokens": "{{n}} tokens",
"manageHint": "Add, disable or change a user on the host: agenthub users …",
"auditTitle": "Audit log",
"auditDescription": "Each time an admin or analyst opened a session a member owns.",
"auditEmpty": "No reads recorded yet."
}
}
+27
View File
@@ -202,5 +202,32 @@
"message": "Các chỉnh sửa của bạn chưa được lưu và sẽ bị mất.",
"discard": "Bỏ thay đổi",
"keep": "Tiếp tục sửa"
},
"auth": {
"title": "Đăng nhập AgentHub",
"username": "Tên đăng nhập",
"password": "Mật khẩu",
"submit": "Đăng nhập",
"submitting": "Đang đăng nhập…",
"failed": "Sai tên đăng nhập hoặc mật khẩu",
"tooMany": "Sai quá nhiều lần — hãy thử lại sau vài phút",
"logout": "Đăng xuất",
"signedInAs": "Đang đăng nhập: {{name}} ({{role}})"
},
"insights": {
"help": {
"sessions": "Một phiên là một cuộc trò chuyện với agent AI, từ lúc bắt đầu đến khi kết thúc.",
"agents": "Agent là các 'nhân công' AI: agent chính trong mỗi phiên cộng với các agent phụ nó tạo ra.",
"tokens": "Token là các mẩu chữ AI đọc và viết, khoảng 1 token ≈ 4 ký tự. Càng nhiều token càng tốn tiền.",
"cost": "Số tiền ước tính, tính từ số token và giá của từng mô hình.",
"events": "Mỗi bước AI làm (chạy công cụ, trả lời xong…) được ghi lại thành một sự kiện.",
"tokenMix": "Input = nội dung bạn và các file gửi cho AI. Output = nội dung AI viết ra. Cache read = nội dung dùng lại từ bộ nhớ đệm (rẻ hơn khoảng 10 lần). Cache write = nội dung lưu vào bộ nhớ đệm để dùng lại.",
"spendPace": "Tốc độ chi tiền, và cuối tháng sẽ tốn bao nhiêu nếu giữ tốc độ này.",
"toolCalls": "Số thao tác AI đã làm: đọc file, sửa file, chạy lệnh…",
"subagents": "Các agent phụ mà AI chính tạo ra để làm song song một phần công việc.",
"compactions": "Khi cuộc trò chuyện quá dài, nó được tóm tắt lại để có chỗ trống. Chi tiết ở đoạn đầu có thể bị mất.",
"errors": "Các bước bị lỗi. Vài lỗi là bình thường; nhiều lỗi có thể nghĩa là AI bị kẹt.",
"tokenFlow": "Input = gửi cho AI, Output = AI viết ra, Cache = dùng lại từ bộ nhớ đệm (rẻ hơn)."
}
}
}
+4 -5
View File
@@ -141,11 +141,10 @@
"output": "Output"
},
"drop": {
"hint": "Thả ảnh để đính kèm",
"uploading": "Đang tải ảnh lên…",
"failed": "Không đính kèm được ảnh",
"imagesOnly": "Chỉ thả được ảnh vào đây",
"remove": "Bỏ ảnh",
"hint": "Thả file hoặc ảnh để đính kèm",
"uploading": "Đang tải lên…",
"failed": "Không đính kèm được file",
"remove": "Bỏ đính kèm",
"view": "Xem ảnh"
},
"terminal": {
+20
View File
@@ -823,6 +823,10 @@
"reports": {
"title": "Báo cáo",
"description": "Bản tóm tắt phân tích ghi ra theo lịch, dạng HTML hoặc JSON."
},
"users": {
"title": "Người dùng",
"description": "Tài khoản, mức dùng và nhật ký truy cập."
}
},
"backPrevious": "Quay lại",
@@ -942,5 +946,21 @@
"importBody": "Tùy chọn trong tệp sẽ thay thế tùy chọn của trình duyệt này, sau đó trang tải lại.",
"imported": "Đã nhập {{count}} tùy chọn. Đang tải lại…",
"badFile": "Tệp này không phải tệp tùy chọn của AgentHub."
},
"users": {
"title": "Người dùng",
"description": "Mọi tài khoản trên server này, cùng workspace, session và mức dùng gần đây.",
"name": "Người dùng",
"role": "Vai trò",
"workspaces": "Workspace",
"sessions": "Session",
"last7d": "7 ngày",
"last30d": "30 ngày",
"disabled": "đã khoá",
"tokens": "{{n}} token",
"manageHint": "Thêm, khoá hoặc sửa người dùng trên máy chủ: agenthub users …",
"auditTitle": "Nhật ký truy cập",
"auditDescription": "Mỗi lần admin hoặc analyst mở một session của member.",
"auditEmpty": "Chưa có lượt đọc nào."
}
}
+38 -95
View File
@@ -17,8 +17,7 @@
* 11-12 text (11 secondary, 12 primary)
* The DARK theme follows it literally: `slate` supplies surface/border/fg and
* `blue` supplies accent. The LIGHT theme keeps the structure (same roles, same
* rising-lightness ramp) but swaps the hue family for Claude's own warm ivory
* and terracotta, because a cool near-white page reads as glare. `green`/
* rising-lightness ramp) with IntelliJ Light's neutrals and blue accent. `green`/
* `red`/`amber` step 11 supplies the status tokens — the same step used for
* fg-secondary, since 11 is Radix's guaranteed-readable-text step. Values are
* the literal Radix hex constants (steps 1/2/3/4/5/6/7/9/11/12), converted to
@@ -29,37 +28,19 @@
/* Light theme — the CSS default, so it applies with no class on <html>.
`[data-mode="light"]` lets a light theme preview re-scope inside a dark
page (the Settings appearance cards).
Radix `slate` light, RE-ORDERED so lightness rises with the index exactly
as it does in dark: the page is the DARKEST step and a card is brighter
than the page it sits on. The previous light block used the scale in its
native direction, which inverted the whole language — a near-white page
(glare) under grey cards that read as recessed, and every `hover:bg-*`
darkening instead of lifting.
The HUE is warm — Claude's own ivory/terracotta family, not a cool slate.
A cool near-white ramp kept reading as glare no matter how far the page
step was pulled down, because the brightest thing on screen was also the
bluest. Cream lowers the perceived glare at the same measured lightness
and gives every neutral a warm cast to sit against. The whole ramp sits
one notch below Claude's own ivory (#faf9f5 card) and no step reaches pure
white: at full brightness a large light panel still tires the eye. */
--surface-0: 232 229 218; /* #e8e5da — page background */
--surface-1: 236 233 223; /* #ece9df — sub-headers inside a card */
--surface-2: 240 237 228; /* #f0ede4 — inputs, chips, toolbars */
--surface-3: 245 243 236; /* #f5f3ec — `.card`, Claude's ivory a step down */
--surface-4: 249 247 241; /* #f9f7f1 — hover / active lift */
--surface-5: 252 251 247; /* #fcfbf7 — topmost, rare */
--border: 207 201 184; /* #cfc9b8 */
--border-light: 222 217 204; /* #ded9cc */
/* Claude's terracotta (#cc785c), taken down two steps: the brand value is
3.1:1 on ivory and 3.3:1 under white text, so it works as a large fill and
fails as a link or a button label. This is 5.3:1 as text on a card and
5.9:1 white-on-accent. Dark mode keeps its blue — inverting a warm accent
onto a near-black page is a different problem with a different answer.
One more step down (#a34a28 → #924224) so `text-accent` also clears 4.5:1
on its own selected chip (`bg-accent/15–20`), not just on the card. */
--accent: 146 66 36; /* #924224 */
--accent-hover: 122 55 30; /* #7a371e */
IntelliJ Light's cool neutrals, ordered so lightness rises with the index
exactly as it does in dark: the page is the DARKEST step and a card is
brighter than the page it sits on, so every `hover:bg-*` lifts. */
--surface-0: 236 238 242; /* #eceef2 — page background */
--surface-1: 241 242 245; /* #f1f2f5 — sub-headers inside a card */
--surface-2: 247 248 250; /* #f7f8fa — inputs, chips, toolbars */
--surface-3: 253 253 254; /* #fdfdfe — `.card` */
--surface-4: 255 255 255; /* #ffffff — hover / active lift */
--surface-5: 255 255 255; /* #ffffff — topmost, rare */
--border: 211 213 219; /* #d3d5db */
--border-light: 228 230 235; /* #e4e6eb */
--accent: 40 90 192; /* #285ac0 */
--accent-hover: 33 74 158; /* #214a9e */
/* The FILL behind white text (`.btn-primary`, a Switch's on-track, a ticked
Checkbox, `bg-accent-solid`). In light themes the text accent is already
dark enough to carry white, so it simply IS the accent — resolved per
@@ -68,43 +49,37 @@
white text cannot sit on (3.3:1). */
--accent-solid: var(--accent);
--accent-solid-hover: var(--accent-hover);
--fg-primary: 31 30 27; /* #1f1e1b — 15.0:1 on a card */
--fg-secondary: 96 91 83; /* #605b53 — 6.1:1 */
/* Measured, not eyeballed: this carries real content (timestamps, counts),
so it is held above 4.5:1 — 5.4:1 on a card, 4.7:1 on the page — and still
sits a clear step below fg-secondary. */
--fg-muted: 104 99 89; /* #686359 */
/* Warm and soft, to match the surfaces: a neutral-grey shadow goes muddy
over cream. The SHADOW carries "raised" here, not the quiet border. */
--shadow-card: 0 1px 2px rgb(60 50 30 / 0.05), 0 2px 6px rgb(60 50 30 / 0.07);
/* Status: darkened two steps from Radix 11. One step cleared 4.5:1 against
the card; these also clear it against the status's OWN densest chip (a
20% tint of itself — 4.5:1), which is where the label actually sits. */
--status-success: 22 104 66; /* #166842 */
--status-danger: 170 36 41; /* #aa2429 */
--status-warning: 133 79 0; /* #854f00 */
--fg-primary: 30 31 34; /* #1e1f22 */
--fg-secondary: 73 75 87; /* #494b57 */
/* Carries real content (timestamps, counts), so it is held above 4.5:1 on a
card and still sits a clear step below fg-secondary. */
--fg-muted: 103 106 120; /* #676a78 */
/* The SHADOW carries "raised" here, not the quiet border. */
--shadow-card: 0 1px 2px rgb(30 31 34 / 0.05), 0 2px 6px rgb(30 31 34 / 0.06);
/* Status labels clear 4.5:1 on the card AND on their own 20% chip. */
--status-success: 23 106 45; /* #176a2d */
--status-danger: 173 28 38; /* #ad1c26 */
--status-warning: 124 82 0; /* #7c5200 */
/* What every status/accent FILL is pre-mixed against (see `tint()` in
tailwind.config.js) — the card surface, because that is where chips and
alert strips overwhelmingly sit. */
/* Re-declared by every scope that sets a theme (this block, `.dark`), so it
resolves against THAT scope's surface-3; a new scope must re-declare it too. */
--tint-base: var(--surface-3);
/* Syntax colours for `lib/highlight.ts`. Same hues as the dark set, taken
four steps down the Tailwind ramp (300 → 700): a pastel keyword is legible
on a near-black code block and invisible on a light one. */
--syn-comment: 107 114 128; /* gray-500 #6b7280 */
--syn-string: 4 120 87; /* emerald-700 #047857 */
--syn-number: 194 65 12; /* orange-700 #c2410c */
--syn-keyword: 109 40 217; /* violet-700 #6d28d9 */
--syn-builtin: 3 105 161; /* sky-700 #0369a1 */
--syn-function: 180 83 9; /* amber-700 #b45309 */
--syn-operator: 190 24 93; /* pink-700 #be185d */
--syn-punctuation: 107 114 128; /* gray-500 #6b7280 */
--syn-property: 14 116 144; /* cyan-700 #0e7490 */
--syn-tag: 190 18 60; /* rose-700 #be123c */
/* Syntax colours for `lib/highlight.ts` — IntelliJ Light's editor scheme. */
--syn-comment: 112 112 112; /* #707070 */
--syn-string: 6 125 23; /* #067d17 */
--syn-number: 23 80 235; /* #1750eb */
--syn-keyword: 0 51 179; /* #0033b3 */
--syn-builtin: 0 0 128; /* #000080 */
--syn-function: 0 98 122; /* #00627a */
--syn-operator: 75 75 75; /* #4b4b4b */
--syn-punctuation: 75 75 75; /* #4b4b4b */
--syn-property: 135 16 148; /* #871094 */
--syn-tag: 0 51 179; /* #0033b3 */
--syn-plain: var(--fg-primary);
--syn-add: 4 120 87; /* emerald-700 */
--syn-del: 185 28 28; /* red-700 #b91c1c */
--syn-add: 6 125 23; /* #067d17 */
--syn-del: 192 31 42; /* #c01f2a */
/* A diff line edited in place (not wholly added or removed). */
--diff-mod: 29 78 216; /* blue-700 #1d4ed8 */
/* A file on the Changes panel's never-stage list (orange, apart from amber M). */
@@ -309,38 +284,6 @@
--syn-del: 197 0 15; /* #c5000f */
}
[data-mode="light"][data-theme="intellij-light"] {
--surface-0: 236 238 242; /* #eceef2 */
--surface-1: 241 242 245; /* #f1f2f5 */
--surface-2: 247 248 250; /* #f7f8fa */
--surface-3: 253 253 254; /* #fdfdfe */
--surface-4: 255 255 255; /* #ffffff */
--surface-5: 255 255 255; /* #ffffff */
--border: 211 213 219; /* #d3d5db */
--border-light: 228 230 235; /* #e4e6eb */
--accent: 40 90 192; /* #285ac0 */
--accent-hover: 33 74 158; /* #214a9e */
--fg-primary: 30 31 34; /* #1e1f22 */
--fg-secondary: 73 75 87; /* #494b57 */
--fg-muted: 103 106 120; /* #676a78 */
--shadow-card: 0 1px 2px rgb(30 31 34 / 0.05), 0 2px 6px rgb(30 31 34 / 0.06);
--status-success: 23 106 45; /* #176a2d */
--status-danger: 173 28 38; /* #ad1c26 */
--status-warning: 124 82 0; /* #7c5200 */
--syn-comment: 112 112 112; /* #707070 */
--syn-string: 6 125 23; /* #067d17 */
--syn-number: 23 80 235; /* #1750eb */
--syn-keyword: 0 51 179; /* #0033b3 */
--syn-builtin: 0 0 128; /* #000080 */
--syn-function: 0 98 122; /* #00627a */
--syn-operator: 75 75 75; /* #4b4b4b */
--syn-punctuation: 75 75 75; /* #4b4b4b */
--syn-property: 135 16 148; /* #871094 */
--syn-tag: 0 51 179; /* #0033b3 */
--syn-add: 6 125 23; /* #067d17 */
--syn-del: 192 31 42; /* #c01f2a */
}
[data-mode="light"][data-theme="canvas"] {
--surface-0: 227 220 205; /* #e3dccd */
--surface-1: 232 226 212; /* #e8e2d4 */
+84 -9
View File
@@ -5,7 +5,8 @@
* the pane that takes its place, swapping/moving panes, resizing a split,
* pruning panes whose run is gone, the rectangles the grid draws, the TABS
* that group up to four panes each (a session in exactly one), and the
* per-workspace storage that must survive bad or stale data.
* dropping one tab onto another (joined in a fixed order: right, under the
* right pane, under the left pane), and the per-workspace storage that must survive bad or stale data.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, beforeEach } from "vitest";
@@ -17,6 +18,7 @@ import {
MAX_PANES,
closeInLayout,
detachSlot,
joinGroup,
layoutRects,
leaves,
loadGroups,
@@ -203,7 +205,10 @@ describe("pane groups (tabs of up to four panes)", () => {
});
it("activates the tab already holding a session, focusing it", () => {
const g: PaneGroups = { groups: [single(0), { root: split("row", pane(1), pane(2)), focus: 1 }], active: 0 };
const g: PaneGroups = {
groups: [single(0), { root: split("row", pane(1), pane(2)), focus: 1 }],
active: 0,
};
const out = openInGroups(g, 2, false);
expect(out.active).toBe(1);
expect(out.groups[1]!.focus).toBe(2);
@@ -245,12 +250,18 @@ describe("pane groups (tabs of up to four panes)", () => {
it("an ended session leaves its tab; an emptied active tab hands over to a neighbour", () => {
const g: PaneGroups = { groups: [single(0), single(1), single(2)], active: 2 };
expect(removeSlot(g, 2)).toEqual({ groups: [single(0), single(1)], active: 1 });
expect(removeSlot({ ...g, active: 0 }, 0)).toEqual({ groups: [single(1), single(2)], active: 0 });
expect(removeSlot({ ...g, active: 0 }, 0)).toEqual({
groups: [single(1), single(2)],
active: 0,
});
expect(removeSlot(g, 7)).toBe(g);
});
it("prunes dead panes and gives every live session without a tab one", () => {
const g: PaneGroups = { groups: [{ root: split("row", pane(0), pane(1)), focus: 1 }, single(2)], active: 1 };
const g: PaneGroups = {
groups: [{ root: split("row", pane(0), pane(1)), focus: 1 }, single(2)],
active: 1,
};
const out = pruneGroups(g, new Set([0, 4]), new Set());
expect(out.groups.map((x) => leaves(x.root))).toEqual([[0], [4]]);
expect(out.active).toBe(0);
@@ -258,7 +269,10 @@ describe("pane groups (tabs of up to four panes)", () => {
});
it("detaches a pane into a tab of its own at a given place, and reorders tabs", () => {
const g: PaneGroups = { groups: [{ root: split("row", pane(0), pane(1)), focus: 0 }, single(2)], active: 1 };
const g: PaneGroups = {
groups: [{ root: split("row", pane(0), pane(1)), focus: 0 }, single(2)],
active: 1,
};
const out = detachSlot(g, 1, 0);
expect(out.groups.map((x) => leaves(x.root))).toEqual([[1], [0], [2]]);
expect(out.active).toBe(0);
@@ -272,19 +286,36 @@ describe("parseLayout / storage", () => {
beforeEach(() => window.localStorage.clear());
it("stores tabs per workspace and reads a pre-tab single layout as one tab", () => {
const g: PaneGroups = { groups: [{ root: split("row", pane(0), pane(9)), focus: 9 }, { root: pane(4), focus: 4 }], active: 1 };
const g: PaneGroups = {
groups: [
{ root: split("row", pane(0), pane(9)), focus: 9 },
{ root: pane(4), focus: 4 },
],
active: 1,
};
saveGroups(1, g);
expect(loadGroups(1)).toEqual(g);
window.localStorage.setItem(
"agenthub.workspace.paneLayout",
JSON.stringify({ 2: { root: split("row", pane(0), pane(1)), focus: 1 } })
);
expect(loadGroups(2)).toEqual({ groups: [{ root: split("row", pane(0), pane(1)), focus: 1 }], active: 0 });
expect(loadGroups(2)).toEqual({
groups: [{ root: split("row", pane(0), pane(1)), focus: 1 }],
active: 0,
});
});
it("drops a slot past nine and a session stored in two tabs", () => {
expect(parseGroups({ groups: [{ root: pane(10), focus: 10 }], active: 0 })).toEqual(EMPTY_GROUPS);
const out = parseGroups({ groups: [{ root: pane(1), focus: 1 }, { root: pane(1), focus: 1 }], active: 1 });
expect(parseGroups({ groups: [{ root: pane(10), focus: 10 }], active: 0 })).toEqual(
EMPTY_GROUPS
);
const out = parseGroups({
groups: [
{ root: pane(1), focus: 1 },
{ root: pane(1), focus: 1 },
],
active: 1,
});
expect(out).toEqual({ groups: [{ root: pane(1), focus: 1 }], active: 0 });
});
@@ -334,3 +365,47 @@ describe("parseLayout / storage", () => {
expect(loadGroups(1)).toEqual(EMPTY_GROUPS);
});
});
describe("joinGroup", () => {
const solo = (s: number): PaneLayout => ({ root: pane(s), focus: s });
const tabs = (...slots: number[]): PaneGroups => ({ groups: slots.map(solo), active: 0 });
it("joins tabs in order: side by side, under the right pane, under the left one", () => {
let g = tabs(0, 1, 2, 3);
g = joinGroup(g, [1], 0);
expect(g.groups[0]?.root).toEqual(split("row", pane(0), pane(1)));
g = joinGroup(g, [2], 0);
expect(g.groups[0]?.root).toEqual(split("row", pane(0), split("col", pane(1), pane(2))));
g = joinGroup(g, [3], 0);
expect(g.groups[0]?.root).toEqual(
split("row", split("col", pane(0), pane(3)), split("col", pane(1), pane(2)))
);
expect(g).toEqual({ groups: [g.groups[0]], active: 0 });
expect(g.groups[0]?.focus).toBe(3);
});
it("activates the target tab and takes a pane out of a combo", () => {
const combo: PaneLayout = { root: split("row", pane(1), pane(2)), focus: 1 };
const g = joinGroup({ groups: [solo(0), combo], active: 0 }, [2], 1);
expect(g.groups.map((l) => leaves(l.root))).toEqual([[0], [1, 2]]);
const h = joinGroup({ groups: [solo(0), combo], active: 1 }, [2], 0);
expect(h).toEqual({
groups: [
{ root: split("row", pane(0), pane(2)), focus: 2 },
{ root: pane(1), focus: 1 },
],
active: 0,
});
});
it("joins a whole combo, and refuses a fifth pane", () => {
const combo: PaneLayout = { root: split("row", pane(1), pane(2)), focus: 1 };
const g = joinGroup({ groups: [solo(0), combo], active: 1 }, [1, 2], 0);
expect(g.groups).toHaveLength(1);
expect(leaves(g.groups[0]?.root ?? null)).toEqual([0, 1, 2]);
const full = joinGroup(g, [], 0);
expect(full).toBe(g);
const four = joinGroup({ ...g, groups: [...g.groups, solo(3), solo(4)] }, [3], 0);
expect(joinGroup(four, [4], 0)).toBe(four);
});
});
+59
View File
@@ -529,6 +529,32 @@ function ccQuery(scope?: CcScope, cwd?: string): string {
* that needs to branch on which error this was should check `.code`, never
* guess at substrings of the human-readable `.message`.
*/
/** Fired on `window` when a request answers 401 `EAUTH` (server mode). */
export const AUTH_REQUIRED_EVENT = "agenthub:auth-required";
/** One account in Settings → Users (`GET /api/users`). */
export type ServerUserUsage = AuthUser & {
disabled: boolean;
workspaces: number;
sessions: number;
last7d: { cost: number; tokens: number };
last30d: { cost: number; tokens: number };
};
/** A staff read of a member's session (`GET /api/users/audit`). */
export type AuditEntry = {
id: number;
at: string;
method: string;
path: string;
session_id: string | null;
reader: string | null;
owner: string | null;
};
/** Who is using the dashboard: local mode has no login at all. */
export type AuthUser = { id: number; username: string; role: "admin" | "analyst" | "member" };
export type AuthState = { mode: "local" } | { mode: "server"; user: AuthUser | null };
/** GETs in flight, by path: concurrent callers of the same read (several
* components mounting, a WS event fanning out to many listeners) share one
* fetch and one parsed result. Deleted as soon as it settles, so this is
@@ -564,6 +590,11 @@ async function send<T>(path: string, options?: RequestInit): Promise<T> {
// isn't JSON (or json() throws), fall back to an empty object so the `?.`
// chain below cleanly degrades to the generic `HTTP <status>` message.
const body = await res.json().catch(() => ({}));
// Server mode: the login ended (expired, logged out elsewhere, disabled) —
// AuthGate listens and shows the login page again.
if (res.status === 401 && body?.error?.code === "EAUTH") {
window.dispatchEvent(new Event(AUTH_REQUIRED_EVENT));
}
throw Object.assign(new Error(body?.error?.message || `HTTP ${res.status}`), {
code: body?.error?.code,
status: res.status,
@@ -625,6 +656,26 @@ export function gitTarget(laneId: number, session?: string | null): GitTarget {
}
export const api = {
// ───────────────────────── Server mode login (routes/auth.js) ─────────────────────────
auth: {
/** GET /api/auth/me — `{mode:"local"}`, or server mode with the user (null = logged out). */
me: () => request<AuthState>("/auth/me"),
/** POST /api/auth/login — sets the httpOnly login cookie. */
login: (username: string, password: string) =>
request<{ user: AuthUser }>("/auth/login", {
method: "POST",
body: JSON.stringify({ username, password }),
}),
/** POST /api/auth/logout — ends this login. */
logout: () => request<{ ok: true }>("/auth/logout", { method: "POST" }),
},
// ───────────────────────── Server mode staff views (routes/users.js) ─────────────────────────
users: {
/** GET /api/users — every account with its recent usage (admin/analyst). */
list: () => request<{ users: ServerUserUsage[] }>("/users"),
/** GET /api/users/audit — staff reads of members' sessions, newest first. */
audit: () => request<{ entries: AuditEntry[] }>("/users/audit"),
},
// ───────────────────────── Updates / self-update API ─────────────────────────
/** Self-update status: whether this install is a git clone and, if so,
* whether the tracked upstream/origin remote is ahead. Backs the "update
@@ -2318,6 +2369,14 @@ export const api = {
body: file,
headers: { "Content-Type": file.type },
}),
/** POST /api/run/drop-file - the same for any file, keeping its (sanitized)
* name so the agent sees what it is. */
dropFile: (file: File) =>
request<{ path: string }>(`/run/drop-file?name=${encodeURIComponent(file.name)}`, {
method: "POST",
body: file,
headers: { "Content-Type": "application/octet-stream" },
}),
/** POST /api/run - start (or adopt, if already live) a lane's terminal run. */
start: (args: RunStartArgs) =>
request<RunHandle>("/run", { method: "POST", body: JSON.stringify(args) }),
+29
View File
@@ -327,6 +327,35 @@ export function detachSlot(g: PaneGroups, slot: number, at: number): PaneGroups
return compact(groups, at);
}
/** Where a pane joining a tab lands: right of a lone pane, then under the
* right pane, then under the left one (2×2 at four). */
function joinLayout(layout: PaneLayout, slot: number): PaneLayout {
const shown = leaves(layout.root);
if (!layout.root) return solo(slot);
const [target, edge]: [number, Edge] =
shown.length === 1
? [shown[0] as number, "right"]
: [(shown.length === 2 ? shown[1] : shown[0]) as number, "bottom"];
return { root: splitLeaf(layout.root, target, slot, edge), focus: slot };
}
/** Moves `slots` (a dropped tab, or one pane of a combo) out of their tabs
* into the tab at `to`, which becomes active. Same object back when nothing
* would move or the tab would pass `MAX_PANES`. */
export function joinGroup(g: PaneGroups, slots: number[], to: number): PaneGroups {
const dst = g.groups[to];
if (!dst) return g;
const moving = slots.filter((s) => {
const from = groupOf(g, s);
return from >= 0 && from !== to;
});
if (!moving.length || leaves(dst.root).length + moving.length > MAX_PANES) return g;
const groups = g.groups.map((l, i) =>
i === to ? moving.reduce(joinLayout, l) : moving.reduce(closeInLayout, l)
);
return compact(groups, to);
}
/** Moves the tab at `from` before the one at `at`; the active tab stays active. */
export function reorderGroups(g: PaneGroups, from: number, at: number): PaneGroups {
if (from < 0 || from === at || from + 1 === at) return g;
+6 -2
View File
@@ -63,11 +63,13 @@ import "@fontsource/jetbrains-mono/latin-500.css";
import "@fontsource/jetbrains-mono/vietnamese-400.css";
import "@fontsource/jetbrains-mono/vietnamese-500.css";
import App from "./App";
import { AuthGate } from "./components/AuthGate";
import { i18nReady } from "./i18n";
import "./index.css";
// Deep link into the Workspace: start its (lazy) chunk now, in parallel with the
// locale fetch below, instead of after the first render asks for it.
if (location.pathname.startsWith("/workspace")) void import("./pages/Workspace");
import "./index.css";
if ("serviceWorker" in navigator) {
// Detect whether the page is already controlled by an SW *before* we
@@ -103,7 +105,9 @@ void i18nReady
.then(() =>
createRoot(root).render(
<StrictMode>
<App />
<AuthGate>
<App />
</AuthGate>
</StrictMode>
)
);
+6 -2
View File
@@ -77,6 +77,7 @@ import { api } from "../lib/api";
import { eventBus } from "../lib/eventBus";
import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
import { useLiveRefresh } from "../hooks/useLiveRefresh";
import { useSpin } from "../hooks/useSpin";
import { downloadText, exportName, toCsv } from "../lib/exportData";
import { toast } from "../lib/toast";
import { useDataScope } from "../lib/dataScope";
@@ -234,6 +235,7 @@ export function ActivityFeed({ embedded = false }: { embedded?: boolean } = {})
useEffect(() => {
load();
}, [load]);
const [spinning, refresh] = useSpin(() => load());
// Agent labels for agents on screen not asked about yet — keyed by agent, so
// a subagent spawned later in a live session gets its label too.
@@ -401,12 +403,14 @@ export function ActivityFeed({ embedded = false }: { embedded?: boolean } = {})
<Download className="w-4 h-4" /> {t("exportJson")}
</button>
<button
onClick={() => load()}
onClick={refresh}
disabled={spinning}
aria-busy={spinning}
className="btn-ghost"
aria-label={t("common:refresh")}
title={t("common:refresh")}
>
<RefreshCw className="w-4 h-4" />
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />
</button>
</div>
</div>
+41 -6
View File
@@ -95,6 +95,7 @@ import {
import { api } from "../lib/api";
import { eventBus } from "../lib/eventBus";
import { useLiveRefresh } from "../hooks/useLiveRefresh";
import { useSpin } from "../hooks/useSpin";
import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
import { useDataScope } from "../lib/dataScope";
import { keepIfSame } from "../lib/keepIfSame";
@@ -779,6 +780,7 @@ function AnalyticsTab() {
if (seq === loadSeq.current) setLoading(false);
}
}, [scope, tool, range]);
const [spinning, refresh] = useSpin(load);
useEffect(() => {
load();
@@ -1067,8 +1069,13 @@ function AnalyticsTab() {
]}
/>
</div>
<button onClick={load} className="btn-ghost" disabled={loading}>
<RefreshCw className={`w-4 h-4 ${loading ? "animate-spin" : ""}`} />
<button
onClick={refresh}
className="btn-ghost"
disabled={loading || spinning}
aria-busy={loading || spinning}
>
<RefreshCw className={`w-4 h-4 ${loading || spinning ? "animate-spin" : ""}`} />
{t("common:refresh")}
</button>
<button onClick={handleExport} className="btn-ghost" disabled={!data}>
@@ -1095,6 +1102,7 @@ function AnalyticsTab() {
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
<StatCard
label={t("totalSessions")}
help={t("common:insights.help.sessions")}
to="/sessions"
value={data ? fmt(data.overview.total_sessions) : ""}
raw={data ? data.overview.total_sessions.toLocaleString() : undefined}
@@ -1105,6 +1113,7 @@ function AnalyticsTab() {
/>
<StatCard
label={t("totalAgents")}
help={t("common:insights.help.agents")}
value={data ? fmt(data.overview.total_agents) : ""}
raw={data ? data.overview.total_agents.toLocaleString() : undefined}
trend={data ? `${data.overview.active_agents} ${t("common:active")}` : undefined}
@@ -1114,6 +1123,7 @@ function AnalyticsTab() {
/>
<StatCard
label={t("tokensExCacheReads")}
help={t("common:insights.help.tokens")}
value={data ? fmt(nonCacheReadTokens) : ""}
raw={data ? nonCacheReadTokens.toLocaleString() : undefined}
trend={
@@ -1130,6 +1140,7 @@ function AnalyticsTab() {
/>
<StatCard
label={t("totalCost")}
help={t("common:insights.help.cost")}
value={costData ? fmtCost(costData.total_cost) : ""}
raw={costData ? fmtCostFull(costData.total_cost) : undefined}
trend={
@@ -1143,6 +1154,7 @@ function AnalyticsTab() {
/>
<StatCard
label={t("totalEvents")}
help={t("common:insights.help.events")}
to="/sessions?view=events"
value={data ? fmt(data.overview.total_events) : ""}
raw={data ? data.overview.total_events.toLocaleString() : undefined}
@@ -1329,7 +1341,12 @@ function AnalyticsTab() {
{/* Token mix donut */}
<div className="card p-5">
<h3 className="text-sm font-medium text-fg-secondary mb-5">{t("tokenMix")}</h3>
<h3
className="text-sm font-medium text-fg-secondary mb-5 cursor-help underline decoration-dotted underline-offset-2 w-fit"
title={t("common:insights.help.tokenMix")}
>
{t("tokenMix")}
</h3>
{tokenMixSegments.length === 0 ? (
<p className="text-sm text-fg-muted">{t("common:noData")}</p>
) : (
@@ -1437,7 +1454,12 @@ function AnalyticsTab() {
{/* Spend pace: burn rate, month to date, month-end forecast */}
<div className="card p-5">
<h3 className="text-sm font-medium text-fg-secondary mb-5">{t("spendPace")}</h3>
<h3
className="text-sm font-medium text-fg-secondary mb-5 cursor-help underline decoration-dotted underline-offset-2 w-fit"
title={t("common:insights.help.spendPace")}
>
{t("spendPace")}
</h3>
<div className="space-y-3">
{[
{ label: t("burnRate"), value: pace.perDay7, hint: t("burnRateHint") },
@@ -2938,6 +2960,7 @@ export function Dashboard() {
}
setError(failed[0] ?? null);
}, [scope]);
const [spinning, refresh] = useSpin(load);
const monitorOn = activeTab === "monitor";
useEffect(() => {
@@ -3059,8 +3082,14 @@ export function Dashboard() {
Analytics/Monitor/Health aside every time Monitor opened.
Analytics and Health carry their own Refresh; one per view. */}
{monitorOn && (
<button onClick={load} className="btn-ghost flex-shrink-0">
<RefreshCw className="w-4 h-4" /> {t("common:refresh")}
<button
onClick={refresh}
disabled={spinning}
aria-busy={spinning}
className="btn-ghost flex-shrink-0"
>
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />{" "}
{t("common:refresh")}
</button>
)}
{/* Tabs */}
@@ -3110,6 +3139,7 @@ export function Dashboard() {
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
<StatCard
label={t("totalSessions")}
help={t("common:insights.help.sessions")}
to="/sessions"
value={stats ? fmt(stats.total_sessions) : ""}
raw={stats ? stats.total_sessions.toLocaleString() : undefined}
@@ -3119,6 +3149,7 @@ export function Dashboard() {
/>
<StatCard
label={t("activeAgents")}
help={t("common:insights.help.agents")}
value={stats?.active_agents ?? ""}
icon={Bot}
accentColor="text-status-success"
@@ -3126,6 +3157,7 @@ export function Dashboard() {
/>
<StatCard
label={t("activeSubagents")}
help={t("common:insights.help.subagents")}
value={stats ? allSubagents.filter((a) => a.status === "working").length : ""}
icon={GitBranch}
accentColor="text-syntax-keyword"
@@ -3134,6 +3166,7 @@ export function Dashboard() {
/>
<StatCard
label={t("eventsToday")}
help={t("common:insights.help.events")}
to="/sessions?view=events"
value={stats ? fmt(stats.events_today) : ""}
raw={stats ? stats.events_today.toLocaleString() : undefined}
@@ -3143,6 +3176,7 @@ export function Dashboard() {
/>
<StatCard
label={t("totalEvents")}
help={t("common:insights.help.events")}
to="/sessions?view=events"
value={stats ? fmt(stats.total_events) : ""}
raw={stats ? stats.total_events.toLocaleString() : undefined}
@@ -3152,6 +3186,7 @@ export function Dashboard() {
/>
<StatCard
label={t("totalCost")}
help={t("common:insights.help.cost")}
value={totalCost !== null ? fmtCost(totalCost) : ""}
raw={
totalCost !== null
+6 -2
View File
@@ -84,6 +84,7 @@ import {
import { api, type RunHandle } from "../lib/api";
import { useLiveRefresh } from "../hooks/useLiveRefresh";
import { laneChangeMatcher, runKeyOf } from "../lib/laneChange";
import { useSpin } from "../hooks/useSpin";
import { workspaceHref } from "../components/SessionRowMenu";
import { downloadText, exportName, toCsv } from "../lib/exportData";
import { toast } from "../lib/toast";
@@ -275,6 +276,7 @@ export function SessionDetail() {
setLoading(false);
}
}, [id, t]);
const [spinning, refresh] = useSpin(load);
useEffect(() => {
load();
@@ -675,12 +677,14 @@ export function SessionDetail() {
<SessionResumeButton session={session} />
<SnapshotButton sessionId={session.id} />
<button
onClick={load}
onClick={refresh}
disabled={spinning}
aria-busy={spinning}
className="btn-ghost"
aria-label={t("common:refresh")}
title={t("common:refresh")}
>
<RefreshCw className="w-4 h-4" />
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />
</button>
</div>
+5 -2
View File
@@ -71,6 +71,7 @@ import { api } from "../lib/api";
import type { RemoteSource, RunHandle } from "../lib/api";
import { useLiveRefresh } from "../hooks/useLiveRefresh";
import { laneChangeMatcher, runKeyOf } from "../lib/laneChange";
import { useSpin } from "../hooks/useSpin";
import { useAiTools } from "../hooks/useAiTools";
import { useLanes } from "../hooks/useLanes";
import { toast } from "../lib/toast";
@@ -238,6 +239,7 @@ export function Sessions({ embedded = false }: { embedded?: boolean } = {}) {
// `scope` is a dep so a data-scope change re-fetches; the api layer injects
// the matching `sources` param.
}, [query, page, scope, t]);
const [spinning, refresh] = useSpin(load);
useEffect(() => {
load();
@@ -390,8 +392,9 @@ export function Sessions({ embedded = false }: { embedded?: boolean } = {}) {
>
<Download className="w-4 h-4" /> {t("exportJson")}
</button>
<button onClick={load} className="btn-ghost">
<RefreshCw className="w-4 h-4" /> {t("common:refresh")}
<button onClick={refresh} disabled={spinning} aria-busy={spinning} className="btn-ghost">
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />{" "}
{t("common:refresh")}
</button>
</div>
</div>
+10 -2
View File
@@ -120,6 +120,7 @@ import type { WSMessage } from "../lib/types";
import { confirmDialog } from "../lib/confirm";
import { toast } from "../lib/toast";
import { useDialog } from "../hooks/useDialog";
import { useSpin } from "../hooks/useSpin";
import { exportPrefs, importPrefs } from "../lib/prefsBackup";
/** The fewest milliseconds between two activity-driven `/settings/info` reads:
@@ -249,6 +250,7 @@ export function Settings({
setLoading(false);
}
}, [t, needsInfo, section]);
const [spinning, refresh] = useSpin(load);
useEffect(() => {
load();
@@ -814,8 +816,14 @@ export function Settings({
<FileDown className="w-3.5 h-3.5" />
{t("exportData")}
</a>
<button onClick={load} className="btn-ghost">
<RefreshCw className="w-4 h-4" /> {t("common:refresh")}
<button
onClick={refresh}
disabled={spinning}
aria-busy={spinning}
className="btn-ghost"
>
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />{" "}
{t("common:refresh")}
</button>
</div>
</div>
+30 -1
View File
@@ -41,6 +41,7 @@ import {
Palette,
Router,
Search,
UsersRound,
} from "lucide-react";
import { Settings } from "./Settings";
import { CcConfig } from "./CcConfig";
@@ -64,6 +65,8 @@ import {
import { Skeleton } from "../components/Skeleton";
import { AiToolIcon } from "../components/AiToolIcon";
import { toast } from "../lib/toast";
import { useAuth } from "../components/AuthGate";
import { UsersSettings } from "../components/settings/UsersSettings";
const GROUPS = ["general", "agents", "routing", "cost", "git"] as const;
type Group = (typeof GROUPS)[number];
@@ -124,6 +127,15 @@ const MOVED: Record<string, string> = {
"ai-router": "ai-proxy",
};
/** Sections whose API a server-mode member may not reach (STAFF_ONLY_PREFIXES in server/lib/auth.js). */
const STAFF_SECTIONS = new Set([
"data",
"reports",
"agents/claude-code",
"agents/codex",
"git-credentials",
]);
/** An unknown section: say so, then show Appearance. */
function UnknownSection({ name }: { name: string }) {
const { t } = useTranslation("settings");
@@ -205,12 +217,15 @@ export function SettingsHub() {
};
}, [location.key, location.hash]);
const auth = useAuth();
const isMember = auth.state.mode === "server" && auth.state.user?.role === "member";
const isStaff = auth.state.mode === "server" && !isMember && Boolean(auth.state.user);
const enabled = (id: string) => aiTools.byId(id)?.enabled !== false;
const s = (key: string) => ({
title: t(`settingsNav.section.${key}.title`),
description: t(`settingsNav.section.${key}.description`),
});
const sections: Section[] = [
const allSections: Section[] = [
{
id: "data",
group: "general",
@@ -246,6 +261,17 @@ export function SettingsHub() {
...s("reports"),
render: () => <ReportSettings />,
},
...(isStaff
? [
{
id: "users",
group: "general" as const,
Icon: UsersRound,
...s("users"),
render: () => <UsersSettings />,
},
]
: []),
{
id: "notifications",
group: "general",
@@ -342,6 +368,9 @@ export function SettingsHub() {
render: () => <WorktreeVisibilitySettings />,
},
];
// Server mode: a member cannot open the host's own configuration or the
// all-users views (the server answers 403 EADMIN), so those pages are hidden.
const sections = isMember ? allSections.filter((x) => !STAFF_SECTIONS.has(x.id)) : allSections;
const entries: SettingsEntry[] = sections.flatMap((sec) => [
{ section: sec.id, title: sec.title, description: sec.description, sectionTitle: true },
+10 -5
View File
@@ -78,6 +78,7 @@ import { api } from "../lib/api";
import type { DateRange } from "../lib/dateRange";
import { eventBus } from "../lib/eventBus";
import { useLiveRefresh } from "../hooks/useLiveRefresh";
import { useSpin } from "../hooks/useSpin";
import type { WorkflowData, WSMessage } from "../lib/types";
import { WorkflowStats } from "../components/workflows/WorkflowStats";
@@ -150,10 +151,7 @@ export function Workflows({
msg.type === "workflow_upserted",
});
const handleRefresh = () => {
setLoading(true);
fetchData();
};
const [refreshing, handleRefresh] = useSpin(fetchData);
const handleExport = () => {
if (!data) return;
@@ -174,6 +172,7 @@ export function Workflows({
statusFilter={statusFilter}
onStatusFilterChange={setStatusFilter}
onRefresh={handleRefresh}
refreshing={refreshing}
onExport={handleExport}
lastUpdated={null}
/>
@@ -197,6 +196,7 @@ export function Workflows({
statusFilter={statusFilter}
onStatusFilterChange={setStatusFilter}
onRefresh={handleRefresh}
refreshing={refreshing}
onExport={handleExport}
lastUpdated={null}
/>
@@ -221,6 +221,7 @@ export function Workflows({
statusFilter={statusFilter}
onStatusFilterChange={setStatusFilter}
onRefresh={handleRefresh}
refreshing={refreshing}
onExport={handleExport}
lastUpdated={lastUpdated}
/>
@@ -499,6 +500,7 @@ function PageHeader({
statusFilter,
onStatusFilterChange,
onRefresh,
refreshing = false,
onExport,
lastUpdated,
}: {
@@ -507,6 +509,7 @@ function PageHeader({
statusFilter: StatusFilter;
onStatusFilterChange: (f: StatusFilter) => void;
onRefresh: () => void;
refreshing?: boolean;
onExport: () => void;
lastUpdated: Date | null;
}) {
@@ -566,10 +569,12 @@ function PageHeader({
{/* Actions */}
<button
onClick={onRefresh}
disabled={refreshing}
aria-busy={refreshing}
className="p-2 rounded-lg text-fg-muted hover:text-fg-secondary hover:bg-surface-3 transition-colors"
title={t("refreshData")}
>
<RefreshCw className="w-4 h-4" />
<RefreshCw className={`w-4 h-4 ${refreshing ? "animate-spin" : ""}`} />
</button>
<button
onClick={onExport}
@@ -157,6 +157,7 @@ exports[`screen snapshots > Activity feed 1`] = `
JSON
</button>
<button
aria-busy="false"
aria-label="Refresh"
class="btn-ghost"
title="Refresh"
@@ -3220,6 +3221,7 @@ exports[`screen snapshots > Dashboard 1`] = `
</div>
</div>
<button
aria-busy="false"
class="btn-ghost"
>
<svg
@@ -3292,7 +3294,8 @@ exports[`screen snapshots > Dashboard 1`] = `
class="flex items-center justify-between gap-3 mb-3"
>
<span
class="text-xs font-medium text-fg-muted uppercase tracking-wider truncate"
class="text-xs font-medium text-fg-muted uppercase tracking-wider truncate cursor-help underline decoration-dotted underline-offset-2"
title="A session is one conversation with an AI agent, from start to finish."
>
Total Sessions
</span>
@@ -3339,7 +3342,8 @@ exports[`screen snapshots > Dashboard 1`] = `
class="flex items-center justify-between gap-3 mb-3"
>
<span
class="text-xs font-medium text-fg-muted uppercase tracking-wider truncate"
class="text-xs font-medium text-fg-muted uppercase tracking-wider truncate cursor-help underline decoration-dotted underline-offset-2"
title="Agents are the AI workers: the main one in each session plus any helpers it started."
>
Total Agents
</span>
@@ -3405,7 +3409,8 @@ exports[`screen snapshots > Dashboard 1`] = `
class="flex items-center justify-between gap-3 mb-3"
>
<span
class="text-xs font-medium text-fg-muted uppercase tracking-wider truncate"
class="text-xs font-medium text-fg-muted uppercase tracking-wider truncate cursor-help underline decoration-dotted underline-offset-2"
title="Tokens are the pieces of text the AI reads and writes. Roughly 1 token ≈ 4 characters. More tokens = more cost."
>
Tokens (excl. cache reads)
</span>
@@ -3487,7 +3492,8 @@ exports[`screen snapshots > Dashboard 1`] = `
class="flex items-center justify-between gap-3 mb-3"
>
<span
class="text-xs font-medium text-fg-muted uppercase tracking-wider truncate"
class="text-xs font-medium text-fg-muted uppercase tracking-wider truncate cursor-help underline decoration-dotted underline-offset-2"
title="Estimated money spent, calculated from token counts and each model's price."
>
Total Cost
</span>
@@ -3541,7 +3547,8 @@ exports[`screen snapshots > Dashboard 1`] = `
class="flex items-center justify-between gap-3 mb-3"
>
<span
class="text-xs font-medium text-fg-muted uppercase tracking-wider truncate"
class="text-xs font-medium text-fg-muted uppercase tracking-wider truncate cursor-help underline decoration-dotted underline-offset-2"
title="Every step the AI takes (running a tool, finishing a reply…) is recorded as one event."
>
Total Events
</span>
@@ -5386,7 +5393,8 @@ exports[`screen snapshots > Dashboard 1`] = `
class="card p-5"
>
<h3
class="text-sm font-medium text-fg-secondary mb-5"
class="text-sm font-medium text-fg-secondary mb-5 cursor-help underline decoration-dotted underline-offset-2 w-fit"
title="How fast money is being spent, and where this month will end up if that speed continues."
>
Spend pace
</h3>
@@ -6018,6 +6026,7 @@ exports[`screen snapshots > Session detail 1`] = `
</button>
</div>
<button
aria-busy="false"
aria-label="Refresh"
class="btn-ghost"
title="Refresh"
@@ -6210,7 +6219,8 @@ exports[`screen snapshots > Session detail 1`] = `
class="rounded-lg border px-3 py-2.5 border-surface-3 bg-surface-2 text-fg-secondary"
>
<div
class="flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-fg-muted"
class="flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-fg-muted cursor-help"
title="Every step the AI takes (running a tool, finishing a reply…) is recorded as one event."
>
<span
class="text-fg-muted"
@@ -6232,7 +6242,11 @@ exports[`screen snapshots > Session detail 1`] = `
/>
</svg>
</span>
Events
<span
class="underline decoration-dotted underline-offset-2"
>
Events
</span>
</div>
<div
class="mt-1 font-mono text-lg font-semibold text-fg-primary leading-tight"
@@ -6244,7 +6258,8 @@ exports[`screen snapshots > Session detail 1`] = `
class="rounded-lg border px-3 py-2.5 border-syntax-keyword/20 bg-syntax-keyword/5 text-syntax-keyword"
>
<div
class="flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-fg-muted"
class="flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-fg-muted cursor-help"
title="How many actions the AI took: reading a file, editing, running a command…"
>
<span
class="text-syntax-keyword"
@@ -6266,7 +6281,11 @@ exports[`screen snapshots > Session detail 1`] = `
/>
</svg>
</span>
Tool calls
<span
class="underline decoration-dotted underline-offset-2"
>
Tool calls
</span>
</div>
<div
class="mt-1 font-mono text-lg font-semibold text-fg-primary leading-tight"
@@ -6278,7 +6297,8 @@ exports[`screen snapshots > Session detail 1`] = `
class="rounded-lg border px-3 py-2.5 border-syntax-property/20 bg-syntax-property/5 text-syntax-property"
>
<div
class="flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-fg-muted"
class="flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-fg-muted cursor-help"
title="Helper agents the main AI started to work on part of the task in parallel."
>
<span
class="text-syntax-property"
@@ -6316,7 +6336,11 @@ exports[`screen snapshots > Session detail 1`] = `
/>
</svg>
</span>
Subagents
<span
class="underline decoration-dotted underline-offset-2"
>
Subagents
</span>
</div>
<div
class="mt-1 font-mono text-lg font-semibold text-fg-primary leading-tight"
@@ -6328,7 +6352,8 @@ exports[`screen snapshots > Session detail 1`] = `
class="rounded-lg border px-3 py-2.5 border-syntax-builtin/20 bg-syntax-builtin/5 text-syntax-builtin"
>
<div
class="flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-fg-muted"
class="flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-fg-muted cursor-help"
title="When a conversation gets too long, it is summarized to make room. Early details can be lost."
>
<span
class="text-syntax-builtin"
@@ -6356,7 +6381,11 @@ exports[`screen snapshots > Session detail 1`] = `
/>
</svg>
</span>
Compactions
<span
class="underline decoration-dotted underline-offset-2"
>
Compactions
</span>
</div>
<div
class="mt-1 font-mono text-lg font-semibold text-fg-primary leading-tight"
@@ -6368,7 +6397,8 @@ exports[`screen snapshots > Session detail 1`] = `
class="rounded-lg border px-3 py-2.5 border-surface-3 bg-surface-2 text-fg-secondary"
>
<div
class="flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-fg-muted"
class="flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-fg-muted cursor-help"
title="Steps that failed. A few are normal; many may mean the AI got stuck."
>
<span
class="text-fg-muted"
@@ -6396,7 +6426,11 @@ exports[`screen snapshots > Session detail 1`] = `
/>
</svg>
</span>
Errors
<span
class="underline decoration-dotted underline-offset-2"
>
Errors
</span>
</div>
<div
class="mt-1 font-mono text-lg font-semibold text-fg-primary leading-tight"
@@ -6435,7 +6469,9 @@ exports[`screen snapshots > Session detail 1`] = `
/>
</svg>
</span>
Duration
<span>
Duration
</span>
</div>
<div
class="mt-1 font-mono text-lg font-semibold text-fg-primary leading-tight"
@@ -6830,6 +6866,7 @@ exports[`screen snapshots > Sessions 1`] = `
JSON
</button>
<button
aria-busy="false"
class="btn-ghost"
>
<svg
@@ -7521,6 +7558,7 @@ exports[`screen snapshots > Settings — data 1`] = `
Export Data
</a>
<button
aria-busy="false"
class="btn-ghost"
>
<svg
@@ -9857,6 +9895,7 @@ exports[`screen snapshots > Workflows 1`] = `
</button>
</div>
<button
aria-busy="false"
class="p-2 rounded-lg text-fg-muted hover:text-fg-secondary hover:bg-surface-3 transition-colors"
title="Refresh data"
>
+17
View File
@@ -63,6 +63,8 @@ The server is **local-first** and is hardened to keep the dashboard off the netw
For deliberate LAN exposure, set `DASHBOARD_HOST` to a non-loopback address and list the names clients use in `DASHBOARD_ALLOWED_HOSTS`.
The routes that act on the machine (starting a session, creating a workspace, git writes, workspace sources, locks…) check the browser's `Origin` (`sameOriginGuard`). They accept a loopback origin, or an origin that **is** the request's own Host when that Host is one of `DASHBOARD_ALLOWED_HOSTS`. Before server mode they accepted loopback only, so a dashboard opened at a LAN name answered those writes with `403 EBADORIGIN`. Since then, the writes work from that LAN name too. Set `DASHBOARD_TOKEN` if the LAN is not trusted.
### Optional token (`DASHBOARD_TOKEN`)
Authentication is **off by default** (the loopback bind is the trust boundary). When `DASHBOARD_TOKEN` is set, every `/api/*` request **and** the WebSocket must present the token. It is strongly recommended whenever you bind beyond loopback. Pass it any of these ways:
@@ -73,6 +75,20 @@ Authentication is **off by default** (the loopback bind is the trust boundary).
These paths stay exempt even when a token is configured: `/api/health`, `/api/openapi.json`, `/api/docs`, and `/api/hooks` (local Claude Code hook ingestion). Requests that fail the check get `401` with error code `EUNAUTHORIZED`.
### Server mode login (`AGENTHUB_MODE=server`)
A dashboard shared by several people runs in **server mode** ([SERVER-MODE.md](SERVER-MODE.md)). Every `/api/*` call and WebSocket then needs the login cookie `agenthub_sid` (httpOnly, `SameSite=Strict`), except `/api/health` and `/api/auth/*`. A call without one answers `401` with code `EAUTH`. Hooks are accepted only from the host itself (`403 ELOCALONLY` otherwise). An `analyst` may only read (`403 EREADONLY`), and only an `admin` may write the host's configuration (`403 EADMIN`).
| Route | Answer |
| --- | --- |
| `GET /api/auth/me` | `{mode: "local"}`, or `{mode: "server", user: {id, username, role} \| null}` |
| `POST /api/auth/login` `{username, password}` | `{user}` + the cookie; `401 EBADLOGIN`, `429 ETOOMANY`, `404 ENOTSERVER` in local mode |
| `POST /api/auth/logout` | `{ok: true}`, cookie cleared |
In server mode a **member** sees only rows of the workspaces they own (`lanes.owner_id`): every list, count, chart and cost answer is limited to them, and a request naming another user's session, agent, workspace or run (in the path, query or body) answers `404 ENOTFOUND`. Admin and analyst see everything; `GET /api/users` lists every account with its workspaces, sessions and 7/30-day `{cost, tokens}`, and `GET /api/users/audit[?limit=]` the staff reads of members' sessions (both `403 EADMIN` for a member). See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees).
`GET /api/accounts[?force=1]` lists the Claude account pool ([SERVER-MODE.md](SERVER-MODE.md#claude-account-pool)): `{accounts: [{name, addedAt, expiresAt, expired, livePanes, spend5h, spend7d, quota: {fiveHour, sevenDay, checkedAt, error} | null, quotaPercent, full}], blockPercent}`. It never includes a token. `force=1` re-reads every quota first. Run rows (`/api/run/history`) carry `account`, the pool account their pane ran on (`null` = the machine's own login).
`GET /api/settings/info` includes `server.version` (the running dashboard release). Pair with `agenthub version` or the Settings About panel to confirm client and server builds match after deploy.
```mermaid
@@ -2429,6 +2445,7 @@ GET /api/run/cwds Suggested cwds (dashboard, home, recent)
GET /api/run/history?limit=&laneId=&tool= Persisted run history; laneId narrows to one lane's runs, tool (with laneId) to one tool's — `shell` = Terminal tabs
GET /api/run/files?cwd=&q= Fuzzy file search inside cwd for the @-file autocomplete
POST /api/run/drop-image Raw image body (png/jpeg/gif/webp, ≤20 MB) → {path}: saved 0600 under <tmpdir>/agenthub-drops-<uid>/ with a random name, pruned after a day; the terminal pastes the path
POST /api/run/drop-file?name= Any raw file body (≤20 MB) → {path}: same dir, 0600, named <random>-<name reduced to a [A-Za-z0-9._-] basename>; what the terminal, the prompt box and its paste use for every dropped file or image
(skips node_modules, .git, dist, build, .next, .cache, coverage, vendor)
POST /api/run Start/attach run — Body: { laneId, cwd?, model?, permissionMode?, effort?, resumeSessionId?, initialPrompt?, sandbox?, approval?, resumeLast?, bypass? }
GET /api/run/:id Run handle (returns live run state)
File diff suppressed because one or more lines are too long
+4
View File
@@ -101,6 +101,10 @@ The CLI talks to the local dashboard server — **API-backed commands require it
| `agenthub status` | At-a-glance up/down indicator (`●` running / `○` not running); exits `1` when down |
| `agenthub start [--port N]` | Start the production server **in the background** (detached; survives closing the terminal), wait up to 30 s for `/api/health`, print the URL + PID and the `kill <pid>` stop command. Logs append to `data/agenthub-server.log`. No-ops with a pointer when a server is already up. Requires a built client (`npm run build` once) |
| `agenthub repl` (aliases `shell`, `i`) | Open the **interactive shell** — see [Interactive REPL](#interactive-repl) |
| `agenthub server setup [--hostname name] [--admin name]` | Turn **server mode** on (logins for a shared dashboard): writes `AGENTHUB_MODE=server` (and `DASHBOARD_ALLOWED_HOSTS`) to the settings file and creates the first admin, asking for its password. Works with the server down; restart it afterwards. See [SERVER-MODE.md](SERVER-MODE.md) |
| `agenthub server off` / `agenthub server status` | Back to local mode (accounts kept) / show the mode and account count |
| `agenthub accounts list\|add <name>\|remove <name>` | The **Claude account pool**: new Claude sessions spread over these accounts (each a `claude setup-token` token, typed hidden or read from stdin). `list` shows live panes, $ in the last 5 h / 7 d, and quota. See [SERVER-MODE.md](SERVER-MODE.md#claude-account-pool) |
| `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]` | Server mode's accounts, written straight to the database; `assign` gives a workspace made before server mode an owner; `audit` lists admin/analyst reads of members' sessions. Inside a dashboard pane in server mode, every command acts as the workspace's owner through the pane's `AGENTHUB_PANE_TOKEN`. A password is typed hidden twice, or read from the first line of stdin |
### Interactive REPL
+17
View File
@@ -609,6 +609,23 @@ CREATE TABLE report_runs (
CREATE INDEX idx_report_runs_report ON report_runs(report_id, id);
```
### users / auth_sessions
Server mode's accounts ([SERVER-MODE.md](SERVER-MODE.md), `server/lib/auth.js`). They are created on every install and stay empty in local mode.
| Table | Columns |
| --- | --- |
| `users` | `id`, `username` (unique, case-insensitive), `role` (`admin`\|`analyst`\|`member`), `password_hash` (`scrypt$<salt>$<hash>`), `disabled`, `created_at` |
| `auth_sessions` | `token_hash` (SHA-256 of the cookie token, PK), `user_id` → `users.id` (cascade), `created_at`, `expires_at` (30 days) |
`dashboard_runs.account` names the Claude pool account a run's pane was started on (`server/lib/claude-pool.js`; `NULL` = the machine's own login). The balancer sums each account's spend from the `token_usage_hourly` rows of those runs' sessions. The pool's tokens live in files under `<CLAUDE_HOME>/agenthub-accounts/`, never in the database.
`lanes.owner_id` (server mode) is the user who created a workspace; `NULL` for one made in local mode or by the host account. A member sees only sessions of their own workspaces, and everything hanging off them (`server/lib/scope.js`). `audit_log` (`user_id`, `owner_id`, `session_id`, `method`, `path`, `at`) records each admin/analyst read of a session a member owns.
`dashboard_runs.pane_token_hash` is the SHA-256 of the pane credential (`AGENTHUB_PANE_TOKEN`) a server-mode pane was started with; it is never returned by any route. `push_subscriptions.user_id` is the user whose browser a subscription is (`NULL` in local mode).
Users are never deleted, only disabled. Disabling a user or changing their password deletes their `auth_sessions` rows.
### lane_sources (and `lanes.instructions`)
A workspace's own context, as on its home page (`server/lib/lane-sources.js`). `lanes.instructions` (TEXT, nullable, ≤ 16 000 characters) is appended to every new pane's system prompt; `lane_sources` holds its extra **folders** (the user's directories, never deleted by AgentHub) and uploaded context **files** (AgentHub's own copies under `<data dir>/workspace-context/<lane id>/`, deleted with the row and with the workspace). Both are read at spawn from these rows only (`launchExtras`), never from a request. Both are additive migrations; the rows cascade when the lane row is deleted.
+1 -1
View File
@@ -407,7 +407,7 @@ The dashboard web UI merges workspaces and runs into a single **Workspace** page
Keyboard: the terminal keeps Tab and Shift+Tab (`claude` cycles its permission mode on Shift+Tab), so **Ctrl+M** toggles *Tab moves focus* — VS Code's way out of its terminal — and Tab then leaves the pane; **Cmd+F** (macOS) / **Ctrl+Shift+F** opens a find bar. URLs open in a new tab; a selection tmux copies (OSC 52) lands in the browser clipboard, and that path is write-only — nothing in the pane can read your clipboard. `agenthub.terminal.screenReader` = `"1"` in `localStorage` turns on xterm's screen-reader mode. While the socket is down a **Reconnecting…** overlay says keystrokes are not reaching the pane; it re-attaches by itself. In the session view, outside a terminal or a text field, `Alt+1`–`Alt+4` focuses pane 1–4, `Alt+\` splits the focused pane right with a new terminal, and `Alt+W` closes the focused pane (its session stays a tab).
- **Clipboard in a pane** — the workspace's tmux session is started with the graphical session's own `DISPLAY`/`WAYLAND_DISPLAY`/`XAUTHORITY`/`XDG_RUNTIME_DIR` (read from `systemctl --user show-environment`, this process's own values winning; `server/lib/desktop-env.js`). The dashboard normally runs as a systemd *user* service, which has none of them, so a pane it started had no clipboard at all and `claude` answered "no image found in clipboard" on every image paste while the same CLI pasted fine in a terminal. Only those four names are forwarded, and only into panes this dashboard spawns — an already-running pane keeps the environment it was started with, so it needs a restart to gain one.
- **Scrollbar in a pane** — every pane gets tmux's `mouse on` and `pane-scrollbars on` with a visible style (`pane-scrollbars-style fg=brightwhite,bg=black,width=1`, `enableMouse` in `server/lib/tmux.js`; tmux 3.7+ — one column, since tmux 3.7c reserves `width` columns but paints only the first and a wider bar left a column of stale cells that read as a second scrollbar). tmux keeps no history and draws no scrollbar on the alternate screen, so a `claude` pane under `"tui": "fullscreen"` (`~/.claude/settings.json`) scrolls inside Claude's own UI instead. AgentHub no longer forces it inline (`CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1`): inline, every time the pane narrowed tmux re-wrapped Claude's full-width rules and pushed lines into history that its redraw could not clear, duplicating the banner and transcript. A pane started before this change keeps inline mode until it is restarted. Every pane's CLI (and a Terminal tab's shell) also starts only once a client is attached — 2 s at most — so nothing is drawn at tmux's 80×24 birth size and re-wrapped on the attach (`WAIT_FOR_CLIENT` in `server/lib/pty-run.js`, issue #55).
- **Tabs** — the session view's console is a tab bar (`client/src/components/run/ConsoleTabs.tsx`) over the workspace's live tmux sessions — at most ten, its slots 0–9: slot 0 is the workspace's `agenthub-lane-<id>` (its `run_id`), slots 1–9 are `agenthub-lane-<id>-<slot>`. Every tab is a **combo**: a group of one to four split panes, each session in exactly one tab (`PaneGroups` in `client/src/lib/paneLayout.ts`). A one-pane tab shows its AI tool's icon and name (or **Terminal**); a combo tab shows every pane's icon and name, and the most urgent state dot. The active tab is the one on screen and its focused pane is the URL's `?run=`, so the Changes panel and Analyst follow it. A click shows a tab; **Ctrl/Cmd+click** on a one-pane tab or a `+` item brings it into the tab on screen, split to the right (a full tab — four panes — opens it as a tab of its own instead), and a one-pane tab dragged onto a pane joins that tab there. Any tab drags along the bar to reorder it; a pane's header dragged onto the bar takes that pane out into a tab of its own at that place. Every live session stays mounted and those of other tabs are hidden with CSS, so switching tabs never drops a terminal. A tab's **×** stops every session in it (`DELETE /api/run/<runId>`, after a confirm when one is an AI tool); with no tab left it moves to the home. The **+** menu opens a new tab in the first free slot, which the server picks (`slot: "auto"`, under the workspace lock): **New terminal** (your own default shell in the workspace folder — `POST /api/lanes/:id/start {slot, shell: true}`, recorded as tool `shell`; it is not an agent, so the workspace's `run_id`/`status` are left alone), each installed, enabled AI tool (started bare, on its default model, no prompt), and **Agent settings…** (Settings → Agents). With ten sessions open **+** is disabled. EVERY tab is bound to the sidebar's selection. A tab has no header of its own (no title, no **Active runs** list, no **New run**): other sessions are opened from the sidebar or the home's Recents. (The old fixed 1/2/4 split layout and its `agenthub.workspace.splitView` key are gone; **Split panes** replaces them.)
- **Tabs** — the session view's console is a tab bar (`client/src/components/run/ConsoleTabs.tsx`) over the workspace's live tmux sessions — at most ten, its slots 0–9: slot 0 is the workspace's `agenthub-lane-<id>` (its `run_id`), slots 1–9 are `agenthub-lane-<id>-<slot>`. Every tab is a **combo**: a group of one to four split panes, each session in exactly one tab (`PaneGroups` in `client/src/lib/paneLayout.ts`). A one-pane tab shows its AI tool's icon and name (or **Terminal**); a combo tab shows every pane's icon and name, and the most urgent state dot. The active tab is the one on screen and its focused pane is the URL's `?run=`, so the Changes panel and Analyst follow it. A click shows a tab; **Ctrl/Cmd+click** on a one-pane tab or a `+` item brings it into the tab on screen, split to the right (a full tab — four panes — opens it as a tab of its own instead), and a one-pane tab dragged onto a pane joins that tab there. Any tab drags along the bar to reorder it; a pane's header dragged onto the bar takes that pane out into a tab of its own at that place. Dropped on the MIDDLE of another tab (it highlights), a tab — or a pane's header — joins that tab instead, in a fixed order: the second pane goes to the right (left | right), the third under the right pane, the fourth under the left pane (2×2); a drop that would pass four panes reorders instead (`joinGroup`). Every live session stays mounted and those of other tabs are hidden with CSS, so switching tabs never drops a terminal. A tab's **×** stops every session in it (`DELETE /api/run/<runId>`, after a confirm when one is an AI tool); with no tab left it moves to the home. The **+** menu opens a new tab in the first free slot, which the server picks (`slot: "auto"`, under the workspace lock): **New terminal** (your own default shell in the workspace folder — `POST /api/lanes/:id/start {slot, shell: true}`, recorded as tool `shell`; it is not an agent, so the workspace's `run_id`/`status` are left alone), each installed, enabled AI tool (started bare, on its default model, no prompt), and **Agent settings…** (Settings → Agents). With ten sessions open **+** is disabled. EVERY tab is bound to the sidebar's selection. A tab has no header of its own (no title, no **Active runs** list, no **New run**): other sessions are opened from the sidebar or the home's Recents. (The old fixed 1/2/4 split layout and its `agenthub.workspace.splitView` key are gone; **Split panes** replaces them.)
- **Split panes** — the console splits like VS Code (`client/src/components/run/PaneGrid.tsx`, model in `client/src/lib/paneLayout.ts`): one binary tree of splits PER TAB, each side by side or stacked at a ratio, whose leaves are the workspace's slots — at most four panes in a tab, a session never shown twice. **Ctrl/Cmd+click** a one-pane tab, a `+` item or a sidebar session row brings it into the tab on screen, to the right of the focused pane (the intent rides on the navigation's state, never in the URL); with more than one pane each gets a slim header: **drag** it (or a tab) onto another pane — its edge splits there, its middle swaps the two —, its **▾** shows another live session in it (swapping with the pane that showed it), **Split right / Split down** puts a not-shown session or a **New terminal** beside it, and **×** takes the pane out of this tab into a tab of its own (its session keeps running). A session dropped or swapped in from another tab leaves that tab. Dividers drag (or move by arrow keys) between 10% and 90%. Clicking in (or tabbing into) a pane focuses it, `?run=` follows — so the right panel, Analyst and the sidebar read the focused session — and the keyboard goes with it: only the focused pane's terminal takes it when it appears. A pane closes when its terminal reports that its session ended (exited, closed from another browser); the run list itself only prunes a remembered layout on a workspace's first read, and a pane opened here whose session never got listed — one empty read, which the server returns on any tmux error, never wipes a split. A session ending while the home or a replay is shown only closes its pane; the URL stays. The tabs and their layouts are a viewer preference kept per workspace in `localStorage` (`agenthub.workspace.paneLayout`, `{groups, active}`; a stored pre-tab single layout reads as one tab), never on the server. Every pane is an absolutely placed sibling keyed by its slot, so splitting, swapping or resizing never re-parents — and so never remounts — a live terminal; the session view itself fills the page edge to edge, its tab bar and the right panel's activity bar one header strip.
- **Workspace grid** — one card per workspace, 1 column, 2 at `md`, 3 at `xl`. Each card carries the workspace id, liveness dot and status, title, declared stage and time-on-stage, the `auto:` chip when detection leads, the kind and CI tags, the working-copy facts from `GET /api/lanes/:id/git`, and the action row. The needs-you message (`needs_action`) is on the workspace list tile only — the detail card no longer repeats it as a banner.
+1
View File
@@ -45,6 +45,7 @@ graph TB
| [client/README.md](../client/README.md) | React frontend architecture, components, state management | Frontend developers |
| [server/README.md](../server/README.md) | Express backend, database, WebSocket, API | Backend developers |
| [API.md](./API.md) | REST API endpoints (sessions, agents, events, stats, analytics, hooks, pricing, workflows, settings, import history, **cc-config**, **run**), WebSocket protocol (including `run_stream` / `run_status` / `run_input_ack` for the Run page) | Integration developers |
| [SERVER-MODE.md](./SERVER-MODE.md) | Server mode: logins and roles for a dashboard shared over the web (`agenthub server setup`), reverse proxy setup | Operators |
| [DATABASE.md](./DATABASE.md) | SQLite schema, queries, performance | Database administrators |
| [HOOKS.md](./HOOKS.md) | Claude Code hook system integration | Hook developers |
| [MCP.md](./MCP.md) | The read-only MCP server (`agenthub mcp`): setup and tool reference | MCP integrators |
+248
View File
@@ -0,0 +1,248 @@
# Server mode
AgentHub has one codebase and two ways to run it:
| | Local mode (default) | Server mode |
| --- | --- | --- |
| Who uses it | You, on your own machine | Several people over the web |
| Login | None (the loopback bind is the trust boundary) | Every page, API call and WebSocket needs one |
| Turn on | Nothing to do: every install starts here | `agenthub server setup` |
| Extra code loaded | None: `/api/auth/me` answers `{mode: "local"}` and nothing else changes | `server/lib/auth.js`, the login page |
Local mode does not change at all. The login code stays dormant until
`AGENTHUB_MODE=server` is set.
> **What server mode separates, and what it does not.** In the dashboard and
> its API, each member sees only their own workspaces and the sessions,
> prompts, events, tokens and cost under them (see [What each user sees](#what-each-user-sees)).
> Every terminal pane, though, still runs as the one Linux account that runs
> the dashboard. An agent in a pane can therefore read every user's folders,
> transcripts and the database straight from disk. Use server mode only with
> people you trust until phase S3 (one Linux user per account) lands. S3 and S4
> (shared tools) need `sudo` on the host and are planned in the AgentHub-Server
> repo.
## Set it up
On the machine that will host the dashboard, logged in as the account that
runs it:
```bash
# 1. Turn server mode on, allow the public name, and create the first admin
agenthub server setup --hostname agenthub.example.com --admin boss
# (asks for the admin's password twice; from a script: pipe it on stdin)
# 2. Restart the dashboard so it picks the mode up
systemctl --user restart agenthub # or: agenthub start
# 3. Add the other people
agenthub users add alice # member
agenthub users add carol --role analyst # read-only
```
`setup` writes `AGENTHUB_MODE=server` and `DASHBOARD_ALLOWED_HOSTS` into
AgentHub's settings file (`~/.config/agenthub/settings.env`). The real
environment and a checkout's `.env` still take priority over that file. Run
setup again whenever you need to: it creates an admin only when none exists.
## Put HTTPS in front of it
Keep the dashboard on its default loopback bind (do **not** set
`DASHBOARD_HOST`) and serve it through a reverse proxy on the same machine.
The proxy has to:
- pass the original `Host` (the dashboard checks it against
`DASHBOARD_ALLOWED_HOSTS` and compares it with the browser's `Origin`);
- forward WebSocket upgrades (`/ws` for live updates, `/ws-pty/*` for terminals);
- set `X-Forwarded-For` and `X-Forwarded-Proto`.
nginx:
```nginx
server {
listen 443 ssl;
server_name agenthub.example.com;
ssl_certificate /etc/ssl/agenthub.crt;
ssl_certificate_key /etc/ssl/agenthub.key;
location / {
proxy_pass http://127.0.0.1:4820;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 1d;
}
}
```
The login cookie gets `Secure` when the request came over HTTPS
(`X-Forwarded-Proto: https`).
## Roles
| Role | Can |
| --- | --- |
| `admin` | Everything, including the host's own configuration and every user's data |
| `member` | Their own workspaces: start sessions, terminals, git, files. Sees nothing of other users, and cannot open or change the host's configuration |
| `analyst` | Reads everything (usage, prompts, every user's sessions); no terminal, no write |
"The host's configuration" means a write (anything but GET/HEAD) under
`/api/settings`, `/updates`, `/cc-config`, `/codex-config`, `/ai-proxies`,
`/ai-routers`, `/git-credentials`, `/ai-tools`, `/import`, `/pricing`,
`/budgets`, `/webhooks`, `/remote-sources` and `/reports`
(`ADMIN_WRITE_PREFIXES` in `server/lib/auth.js`). A member who tries one gets
`403 EADMIN`. An analyst who tries any write gets `403 EREADONLY`.
## What each user sees
Every workspace has an owner: the user who created it (`lanes.owner_id`).
Workspaces made before server mode, or by the host account, have none.
- A session belongs to the workspace that owns it: `sessions.lane_id`, or the
workspace whose pane holds it. Its agents, events, tokens, cost, alerts,
snapshots and workflow runs belong to that session.
- A **member** sees only rows of their own workspaces, everywhere: lists,
counts, charts, cost, the Sessions/Events/Agents tables, exports, live
WebSocket updates and terminals. A request naming someone else's row (in the
path, the query or the body) gets `404 ENOTFOUND`, the same answer as a row
that does not exist (`scopeGuard`, `server/lib/scope.js`). Sessions with no
workspace, such as ones started in a terminal outside AgentHub, are not shown
to members.
- An **admin** or **analyst** sees everything. Each time one of them opens a
session a member owns (its page, transcript, events or cost), the read is
written to the audit log: `agenthub users audit`, or `GET /api/users/audit`.
- `GET /api/users` (admin/analyst) lists every account with its workspaces,
sessions, and the tokens and $ it used in the last 7 and 30 days.
A member also cannot open the host's own configuration, or any view across all
users: Claude Code / Codex config (except the command and skill lists the
prompt box uses), git credentials, remote sources, webhooks, reports, metrics,
imports, data export, alert rules and `/api/users`. Those return
`403 EADMIN`, and Settings hides them for members.
### Keep members' folders apart
```bash
# in ~/.config/agenthub/settings.env
AGENTHUB_USERS_ROOT=/srv/agenthub/users
```
With this set, a member can create a workspace only inside
`/srv/agenthub/users/<username>`; any other folder gets `403 EFOLDER`. A folder
that already belongs to another user's workspace gets `409 EOWNED`. Admins are
not limited. Create each user's folder yourself. Until S3 this keeps workspaces
apart, not agents.
## What the gate does
- `/api/health` and `/api/auth/*` answer without a login. Everything else
under `/api` answers `401 EAUTH` until someone logs in, and the UI shows the
login page. Paths are checked the way Express routes them: decoded, with
repeated slashes collapsed, and case-insensitive.
- Hooks (`/api/hooks/*`) are accepted only from this machine: a loopback socket
with no forwarding header. A request through the proxy gets
`403 ELOCALONLY`. `POST /api/hooks/ingest-batch` keeps its own bearer token.
- Every write (any method but GET/HEAD) that carries an `Origin` or `Referer`
must name the dashboard's own allowed host, or loopback. Anything else gets
`403 EBADORIGIN`. This is on top of the `SameSite=Strict` cookie, which a
sibling subdomain would still satisfy.
- WebSockets need a login, and an `Origin` that is the request's own allowed
Host. A terminal (`/ws-pty/*`) is refused to an analyst and to anyone but the
owner of the pane's workspace. Open sockets are re-checked: `/ws` every 30 s,
terminals every 10 s (`AGENTHUB_PTY_RECHECK_MS`). A logout, a disable, a
password change, or a demotion to analyst closes them.
- A login lasts 30 days in the httpOnly, `SameSite=Strict` cookie
`agenthub_sid`. Only the token's SHA-256 is stored. Logging out, a password
change and disabling the user each end it.
- Passwords are stored as scrypt hashes and must be at least 8 characters.
Every login attempt counts before its password is checked, so attempts sent
in parallel count too. After 10 failed logins from one address, or 30 for one
username, in 15 minutes, further logins answer `429 ETOOMANY` until the
window passes. The counters are kept in memory, so a restart resets them.
- **Inside a pane**, the `agenthub` CLI, the MCP server and the statusline act
as the owner of the pane's workspace. Each pane the dashboard starts gets its
own `AGENTHUB_PANE_TOKEN` through tmux `-e`, and the database stores only its
hash (`dashboard_runs.pane_token_hash`). The token is accepted only from this
machine and only while that pane is alive, as header `x-agenthub-pane-token`.
A workspace with no owner has no such user. Give old workspaces an owner with
`agenthub users assign <workspace id> <user>`.
- Named locks (`agenthub lock …`) are per user: a member's lock names live in
their own namespace, so one user's agents never see or take another's.
- Browser push subscriptions belong to the user who made them. A
notification goes only to that user's own browsers, never to the host's
desktop.
- A member cannot create a session by hand (`POST /api/sessions` → `403`), and
cannot change a workspace's pane or session columns: `PATCH` accepts only
`title`, `pipeline`, `notes`, `links` and `branch` (`403 EFIELD`).
Pet uploads are admin-only.
- The native file picker stays local-only (`EREMOTE`) and every page falls back
to its folder browser.
## Accounts
Accounts are managed from the host's shell, never from a web route:
```bash
agenthub users list
agenthub users add <name> [--role admin|analyst|member]
agenthub users passwd <name> # ends that user's logins
agenthub users role <name> <role>
agenthub users disable <name> # ends that user's logins; users are never deleted
agenthub users enable <name>
agenthub users assign <workspace id> <name> # give a pre-server-mode workspace an owner
agenthub users audit [--limit N] # who read which member's session
agenthub server status
```
## Claude account pool
An admin can sign every Claude account the company has into AgentHub. Each new
Claude session then runs on the account with the most room left, and nobody has
to log in or switch accounts by hand. The pool works in local mode too, and it
stays empty (changing nothing) until you add an account.
```bash
# Once per account: sign in as that account and make a one-year token
claude setup-token
# Add it to the pool (the token is typed hidden, or piped on stdin)
agenthub accounts add team-1
agenthub accounts list # live panes, $ in the last 5 h / 7 d, quota, state
agenthub accounts remove team-1
```
How a new session picks its account (`server/lib/claude-pool.js`):
1. An account whose last quota reading is at or above 90% in the 5-hour or
weekly window goes last. It is still used if every account is that full.
2. Next, the account with the fewest live panes, so concurrent sessions spread out.
3. Then the account with the least $ spent in the last 5 hours, then the last
7 days. AgentHub sums this from the usage of the sessions it started on each
account (`dashboard_runs.account`).
- The pane gets the token through tmux `-e CLAUDE_CODE_OAUTH_TOKEN=…`. That
token outranks the machine's own `/login` for that process only.
- A session on an AI Proxy keeps the proxy's key and does not use the pool.
- A token more than a year old is skipped.
- A resumed session picks an account again, like a new one.
- Quota is read with each account's own token, in the background, at most every
5 minutes. If the usage endpoint refuses a setup-token, the state column shows
`no quota (…)` and ranking uses spend alone.
- `GET /api/accounts` returns the same table, without tokens.
> **Ceiling:** anyone with a terminal in AgentHub can read the token of the
> account their pane runs on (`echo $CLAUDE_CODE_OAUTH_TOKEN`), and, as one
> Linux user, every file of the pool. Hiding tokens from panes needs one Linux
> user per account (phase S3), or AgentHub acting as the pane's API endpoint.
## Turn it off
```bash
agenthub server off
systemctl --user restart agenthub
```
Accounts are kept, so `agenthub server setup` turns server mode back on with
the same users.
+14
View File
@@ -0,0 +1,14 @@
# Server mode — working checklist
Goal (2026-10-07): finish the server part — S2 ownership as proposed, review it.
- [x] S1 login, roles, `agenthub server|users` (uncommitted)
- [x] Claude account pool + balancer (uncommitted)
- [x] S2 core: `lanes.owner_id`, `server/lib/scope.js` (predicates, `scopeGuard`, broadcast audience, folder root), STAFF_ONLY_PREFIXES, pty attach check, heartbeat re-check
- [x] S2 routes: aggregates, rows, workflows (+ read worker), workspaces/runs — 4-agent workflow, each with its own test file
- [x] Bypass fix: `apiPath()` canonical path + case-insensitive rules (found by the workspaces agent)
- [x] S6 basics: `GET /api/users`, audit log (`audit_log`, `agenthub users audit`), Settings → Users, members' Settings hides staff pages
- [x] Adversarial review (5 lenses, 21 findings, each verified) and fixes: lane id coercion (1e1), PATCH run_id, array ids, POST agents/sessions, transcript path traversal (all modes), push per user, pool/quota leaks, pty re-check, CSRF origin on writes, parallel login throttle, overlapping folders, pane credential for CLI/MCP/statusline, lock namespace + lock-name path traversal (all modes), AuthGate retry/cache
- [x] Full suites: server 2532/2535 (3 lane-detect fail on a foreign process holding port 3000, same on clean HEAD), client 1074/1074; browser e2e member vs admin; docs synced
- [x] Upgrade-impact review (2026-10-07): old vs new API diff on a copy of the real DB (local mode identical but for noise), old+new builds sharing one migrated DB, 5-lens review + verify. Fixed: lock names (any single dir name ≤255, holders with spaces normalised, CLI fails fast on non-409), AuthGate rendering on an older server / DASHBOARD_TOKEN 401, pane_token_hash kept out of exports; documented the LAN origin change. Suites: server 2536/2536, client 1078/1078
- Blocked on sudo / a real host: S0 spike, S3 (one Linux user per account), S4 (shared /opt tools)
+3
View File
@@ -164,6 +164,9 @@ async function callApi(pathname) {
const headers = process.env.DASHBOARD_TOKEN
? { Authorization: `Bearer ${process.env.DASHBOARD_TOKEN}` }
: {};
// Server mode: inside a dashboard pane, act as the workspace's owner.
if (process.env.AGENTHUB_PANE_TOKEN)
headers["x-agenthub-pane-token"] = process.env.AGENTHUB_PANE_TOKEN;
let res;
try {
res = await fetch(url, { headers, signal: AbortSignal.timeout(30_000) });
+263
View File
@@ -62,6 +62,8 @@ tags:
description: Web Push notification subscriptions and broadcast (VAPID); also fires native Electron notifications when hosted in the desktop app
- name: AI Tools
description: The catalog of AI coding CLIs a lane can run (server/data/ai-tools/*.json + DASHBOARD_AI_TOOLS_DIR), what is installed on PATH, and the user's enable/default choice
- name: Auth
description: 'Server mode''s login (AGENTHUB_MODE=server). In local mode — the default — only GET /api/auth/me answers, with {mode: "local"}.'
components:
parameters:
SessionIdPath:
@@ -4741,6 +4743,40 @@ components:
description: Events attributed to this run's inner agents, ordered by created_at then id. Capped at 5000 rows.
items:
$ref: '#/components/schemas/DashboardEvent'
AuthUser:
type: object
required:
- id
- username
- role
properties:
id:
type: integer
username:
type: string
role:
type: string
enum:
- admin
- analyst
- member
disabled:
type: boolean
AuthMe:
type: object
required:
- mode
properties:
mode:
type: string
enum:
- local
- server
user:
description: 'Server mode only: the logged-in user, or null.'
nullable: true
allOf:
- $ref: '#/components/schemas/AuthUser'
paths:
/api/health:
get:
@@ -13334,6 +13370,233 @@ paths:
error:
code: WORKFLOW_DETAIL_FAILED
message: database is locked
/api/users:
get:
tags:
- Auth
operationId: listServerUsers
summary: Every account with its workspaces, sessions and 7/30-day usage (admin/analyst; 403 EADMIN for a member)
responses:
'200':
description: Accounts
content:
application/json:
schema:
type: object
properties:
users:
type: array
items:
allOf:
- $ref: '#/components/schemas/AuthUser'
- type: object
properties:
workspaces:
type: integer
sessions:
type: integer
last7d:
type: object
properties:
cost:
type: number
tokens:
type: integer
last30d:
type: object
properties:
cost:
type: number
tokens:
type: integer
'403':
description: EADMIN — a member
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/users/audit:
get:
tags:
- Auth
operationId: listAuditLog
summary: Admin/analyst reads of sessions members own, newest first
parameters:
- name: limit
in: query
schema:
type: integer
maximum: 1000
responses:
'200':
description: Entries
content:
application/json:
schema:
type: object
properties:
entries:
type: array
items:
type: object
properties:
id:
type: integer
at:
type: string
method:
type: string
path:
type: string
session_id:
type: string
nullable: true
reader:
type: string
nullable: true
owner:
type: string
nullable: true
'403':
description: EADMIN — a member
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/accounts:
get:
tags:
- Auth
operationId: listPoolAccounts
summary: The Claude account pool as the balancer sees it (never a token)
parameters:
- name: force
in: query
schema:
type: string
enum:
- '1'
description: Re-read every account's quota first
responses:
'200':
description: Accounts with live panes, recent spend and quota
content:
application/json:
schema:
type: object
properties:
blockPercent:
type: number
accounts:
type: array
items:
type: object
properties:
name:
type: string
addedAt:
type: string
expiresAt:
type: string
expired:
type: boolean
livePanes:
type: integer
spend5h:
type: number
spend7d:
type: number
quota:
type: object
nullable: true
quotaPercent:
type: number
nullable: true
full:
type: boolean
/api/auth/me:
get:
tags:
- Auth
operationId: authMe
summary: The dashboard's mode and, in server mode, who is logged in
responses:
'200':
description: Mode and user
content:
application/json:
schema:
$ref: '#/components/schemas/AuthMe'
/api/auth/login:
post:
tags:
- Auth
operationId: authLogin
summary: Log in; sets the httpOnly, SameSite=Strict cookie agenthub_sid
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- username
- password
properties:
username:
type: string
password:
type: string
responses:
'200':
description: Logged in
content:
application/json:
schema:
type: object
properties:
user:
$ref: '#/components/schemas/AuthUser'
'401':
description: EBADLOGIN — wrong username or password, or a disabled user
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'403':
description: EBADORIGIN — a cross-origin request
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'404':
description: ENOTSERVER — the dashboard is in local mode
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'429':
description: ETOOMANY — too many failed logins from this address (15 minutes)
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/auth/logout:
post:
tags:
- Auth
operationId: authLogout
summary: End this login and clear its cookie
responses:
'200':
description: Logged out
content:
application/json:
schema:
type: object
properties:
ok:
type: boolean
/api/settings/import:
post:
tags:
+1
View File
@@ -760,6 +760,7 @@ HTTP surface for the tmux-backed terminal run: one session per workspace, named
| `GET` | `/api/run/tmux` | `{ available }` — whether the `tmux` binary exists |
| `GET` | `/api/run/cwds` | Suggested cwds (dashboard, home, recent from sessions) |
| `POST` | `/api/run/drop-image` | An image dropped on the Workspace terminal. Raw body, `Content-Type` one of `image/png\|jpeg\|gif\|webp`, ≤20 MB. Saved `0600` under `<tmpdir>/agenthub-drops-<uid>/` with a RANDOM name (nothing from the request names the file); files older than a day are pruned on each write. Answers `{ path }`, which `TerminalView` pastes into the pane — what a local terminal does on a drop, and how `claude` attaches the image. 400 for any other type |
| `POST` | `/api/run/drop-file?name=…` | Any file (or image) dropped on the Workspace terminal or pasted/dropped into a prompt box. Raw body of any type, ≤20 MB, same directory, `0600` and day-old pruning as `drop-image`. The name is the file's own, reduced to a `[A-Za-z0-9._-]` basename behind a random 16-hex prefix (so it can neither leave the directory nor collide, and space-joined paths stay parseable). Answers `{ path }`; 400 for an empty body or a name that sanitizes to nothing. The client uses this for every drop; `drop-image` is kept for API callers |
| `GET` | `/api/run/files?cwd=…&q=…` | Fuzzy file search inside `cwd` for the run panel's `@`-file autocomplete. Skips `node_modules`, `.git`, `dist`, `build`, `.next`, `.cache`, `coverage`, `vendor`, etc. Cwd is required and must exist; results are capped and ranked by basename match |
| `POST` | `/api/run` | Create-or-adopt the workspace's tmux session. Body: `{ laneId, cwd, initialPrompt?, model?, permissionMode?, effort?, resumeSessionId? }`. The CLI comes from the workspace row, never from the body. An existing session whose pane sits at a shell prompt gets the argv typed into it; a pane already running a program is adopted unchanged |
| `GET` | `/api/run/:id` | One run handle, or 404 once its tmux session is gone |
+9
View File
@@ -265,3 +265,12 @@ describe("profileUsage", () => {
);
});
});
describe("liveOwner", () => {
// The profiles routes call it; v2.5.9 defined it but never exported it, so
// GET /api/cc-config/profiles answered "ccUsage.liveOwner is not a function".
it("is exported, and is null with no live login", async () => {
assert.equal(typeof usage.liveOwner, "function");
assert.equal(await usage.liveOwner(), null);
});
});
+153
View File
@@ -0,0 +1,153 @@
/**
* @file claude-pool.test.js
* @description The Claude account pool (server/lib/claude-pool.js): token
* validation and file modes, the balancer's order (full quota last, then fewest
* live panes, then least recent spend, expired accounts never), and spawnRun
* handing the pick to a Claude pane through tmux `-e CLAUDE_CODE_OAUTH_TOKEN`
* and recording it — but not for a Codex pane, and not with an empty pool.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after, beforeEach } = require("node:test");
const assert = require("node:assert/strict");
const path = require("node:path");
const fs = require("node:fs");
const os = require("node:os");
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "claude-pool-test-"));
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
process.env.CLAUDE_HOME = path.join(TMP, "claude");
const { db } = require("../db");
const ccUsage = require("../lib/cc-usage");
const pool = require("../lib/claude-pool");
const tmux = require("../lib/tmux");
const pty = require("../lib/pty-run");
const TOKEN = (c) => `sk-ant-oat01-${c.repeat(40)}`;
const realFetchUsage = ccUsage.fetchUsage;
function run(id, account, { status = "completed", session = null, started } = {}) {
db.prepare(
`INSERT INTO dashboard_runs (id, session_id, mode, cwd, status, started_at, account)
VALUES (?, ?, 'interactive', '/tmp', ?, ?, ?)`
).run(id, session, status, started || new Date().toISOString(), account);
}
function spend(session, tokens) {
db.prepare(
"INSERT OR IGNORE INTO sessions (id, name, status, cwd) VALUES (?, ?, 'completed', '/tmp')"
).run(session, session);
db.prepare(
`INSERT INTO token_usage_hourly (session_id, hour, model, output_tokens)
VALUES (?, ?, 'claude-sonnet-4-5-20250929', ?)`
).run(session, new Date().toISOString().slice(0, 13), tokens);
}
before(() => {
// No network: every quota read answers "no reading" unless a test says otherwise.
ccUsage.fetchUsage = async () => {
throw Object.assign(new Error("offline"), { code: "EUNAUTHORIZED" });
};
});
after(() => {
ccUsage.fetchUsage = realFetchUsage;
tmux.__setExecImpl(null);
fs.rmSync(TMP, { recursive: true, force: true });
});
describe("claude pool", () => {
beforeEach(() => {
fs.rmSync(pool.poolDir(), { recursive: true, force: true });
db.prepare("DELETE FROM dashboard_runs").run();
db.prepare("DELETE FROM token_usage_hourly").run();
pool.__quotaCache.clear();
});
it("is empty until an admin adds an account, and stores it 0600", () => {
assert.equal(pool.pickAccount(), null);
assert.throws(() => pool.addAccount("a", "not-a-token"), { code: "EBADTOKEN" });
assert.throws(() => pool.addAccount("../x", TOKEN("a")), { code: "EBADNAME" });
const added = pool.addAccount("a", TOKEN("a"));
assert.ok(Date.parse(added.expiresAt) > Date.now() + 360 * 864e5);
assert.throws(() => pool.addAccount("a", TOKEN("b")), { code: "EEXISTS" });
const mode = fs.statSync(path.join(pool.poolDir(), "a.json")).mode & 0o777;
assert.equal(mode, 0o600);
assert.equal(fs.statSync(pool.poolDir()).mode & 0o777, 0o700);
assert.ok(
!JSON.stringify(pool.poolStatus()).includes(TOKEN("a")),
"status never shows a token"
);
pool.removeAccount("a");
assert.equal(pool.pickAccount(), null);
});
it("prefers fewer live panes, then less recent spend, and skips expired tokens", () => {
for (const n of ["a", "b", "c"]) pool.addAccount(n, TOKEN(n));
assert.equal(pool.pickAccount().name, "a");
run("r1", "a", { status: "running" });
assert.equal(pool.pickAccount().name, "b");
run("r2", "b", { session: "s-b" });
spend("s-b", 100000);
const picked = pool.pickAccount();
assert.equal(picked.name, "c");
assert.equal(picked.token, TOKEN("c"));
const status = Object.fromEntries(pool.poolStatus().map((a) => [a.name, a]));
assert.equal(status.a.livePanes, 1);
assert.ok(status.b.spend5h > 0 && status.b.spend7d >= status.b.spend5h);
assert.equal(status.c.spend5h, 0);
// c's token is a year old: never handed out.
const file = path.join(pool.poolDir(), "c.json");
const rec = JSON.parse(fs.readFileSync(file, "utf8"));
rec.addedAt = new Date(Date.now() - 366 * 864e5).toISOString();
fs.writeFileSync(file, JSON.stringify(rec));
assert.equal(pool.pickAccount().name, "b");
});
it("puts an account whose quota is nearly used up last", async () => {
for (const n of ["a", "b"]) pool.addAccount(n, TOKEN(n));
ccUsage.fetchUsage = async (token) => ({
fiveHour: { utilization: token === TOKEN("a") ? 95 : 10, resetsAt: null },
sevenDay: { utilization: 20, resetsAt: null },
});
try {
await pool.refreshQuotas({ force: true });
run("r1", "b", { status: "running" }); // b busier, but a is full
assert.equal(pool.pickAccount().name, "b");
assert.equal(pool.poolStatus().find((x) => x.name === "a").full, true);
} finally {
ccUsage.fetchUsage = async () => {
throw Object.assign(new Error("offline"), { code: "EUNAUTHORIZED" });
};
}
});
it("gives a Claude pane the picked account's token and records it", () => {
pool.addAccount("a", TOKEN("a"));
let argv = null;
tmux.__setExecImpl((args) => {
if (args[0] === "has-session") throw Object.assign(new Error("gone"), { status: 1 });
if (args[0] === "new-session") argv = args;
return "";
});
pty.spawnRun({ laneId: 7, cwd: "/tmp", tool: "claude" });
assert.ok(argv.includes(`CLAUDE_CODE_OAUTH_TOKEN=${TOKEN("a")}`));
assert.ok(argv.indexOf(`CLAUDE_CODE_OAUTH_TOKEN=${TOKEN("a")}`) < argv.indexOf("--"));
const row = db.prepare("SELECT account FROM dashboard_runs WHERE id = 'agenthub-lane-7'").get();
assert.equal(row.account, "a");
argv = null;
pty.spawnRun({ laneId: 8, cwd: "/tmp", tool: "codex" });
assert.ok(!argv.some((x) => String(x).startsWith("CLAUDE_CODE_OAUTH_TOKEN=")));
pool.removeAccount("a");
argv = null;
pty.spawnRun({ laneId: 9, cwd: "/tmp", tool: "claude" });
assert.ok(!argv.some((x) => String(x).startsWith("CLAUDE_CODE_OAUTH_TOKEN=")));
});
});
+16
View File
@@ -1319,6 +1319,22 @@ describe("lane ensure, start mode, lane_id and releasing a finished run", () =>
assert.equal(after.status, "idle");
});
it("healAllRunIds judges by the live-session set it is handed", async () => {
// The server's beat lists tmux once and passes the set, instead of one
// synchronous has-session per lane.
const lanesLib = require("../lib/lanes");
const { healAllRunIds } = require("../routes/lanes");
const lane = await adoptedLane("heal-all-live-set");
const runId = `agenthub-lane-${lane.id}`;
lanesLib.updateLane(lane.id, { run_id: runId, status: "running" });
healAllRunIds(new Set([runId]));
assert.equal(lanesLib.getLane(lane.id).run_id, runId);
healAllRunIds(new Set());
assert.equal(lanesLib.getLane(lane.id).run_id, null);
});
it("starts a session on the AI tool the request names, not the workspace's", async () => {
const lane = await adoptedLane("session-tool");
assert.equal(lane.tool, "claude");
+16
View File
@@ -442,6 +442,22 @@ describe("pty-run", () => {
);
});
it("listRuns asks tmux once, not once more per listed session", () => {
// Every tmux call is synchronous; the Workspace polls this every 5 s.
const calls = [];
tmux.__setExecImpl((args) => {
calls.push(args[0]);
if (args[0] === "list-sessions") return "agenthub-lane-1\nagenthub-lane-2\n";
return "";
});
const items = pty.listRuns();
assert.deepEqual(
items.map((r) => r.status),
["running", "running"]
);
assert.deepEqual(calls, ["list-sessions"]);
});
it("laneIdFromRunId parses the numeric lane id back out", () => {
assert.equal(pty.laneIdFromRunId("agenthub-lane-42"), 42);
assert.equal(pty.laneIdFromRunId("not-a-run-id"), null);
+25
View File
@@ -248,4 +248,29 @@ describe("POST /api/run/drop-image", () => {
});
assert.equal(r.status, 403);
});
it("drop-file saves any file under a random prefix and its sanitized name", async () => {
const name = encodeURIComponent("../../báo cáo Q3.pdf");
const r = await fetchJson(`/api/run/drop-file?name=${name}`, {
method: "POST",
raw: { type: "application/pdf", buf: Buffer.from("%PDF") },
});
assert.equal(r.status, 200);
assert.equal(
path.dirname(r.body.path),
path.join(os.tmpdir(), `agenthub-drops-${os.userInfo().uid}`)
);
assert.match(path.basename(r.body.path), /^[0-9a-f]{16}-b_o_c_o_Q3\.pdf$/);
assert.equal(fs.readFileSync(r.body.path, "utf8"), "%PDF");
assert.equal(fs.statSync(r.body.path).mode & 0o777, 0o600);
fs.unlinkSync(r.body.path);
});
it("drop-file without a name or a body is refused", async () => {
const r = await fetchJson("/api/run/drop-file", {
method: "POST",
raw: { type: "text/plain", buf: Buffer.alloc(0) },
});
assert.equal(r.status, 400);
});
});
+262
View File
@@ -0,0 +1,262 @@
/**
* @file scope.test.js
* @description Server mode's row ownership helpers (server/lib/scope.js): a
* member sees only sessions/agents/events/runs of the workspaces they own
* (stamped `sessions.lane_id`, or a `dashboard_runs` row of theirs holding the
* session), an admin/analyst/local request sees everything, and a member's
* workspace folder stays inside `AGENTHUB_USERS_ROOT/<username>`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const path = require("node:path");
const fs = require("node:fs");
const os = require("node:os");
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "scope-test-"));
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
const { db } = require("../db");
const scope = require("../lib/scope");
const member = (id, username = `u${id}`) => ({ user: { id, username, role: "member" } });
const admin = { user: { id: 99, username: "boss", role: "admin" } };
const analyst = { user: { id: 98, username: "ana", role: "analyst" } };
const local = {};
before(() => {
const lane = db.prepare("INSERT INTO lanes (cwd, tool, owner_id) VALUES (?, 'claude', ?)");
lane.run("/w/one", 1); // id 1, user 1
lane.run("/w/two", 2); // id 2, user 2
lane.run("/w/host", null); // id 3, nobody's
const s = db.prepare(
"INSERT INTO sessions (id, name, status, cwd, lane_id) VALUES (?, ?, 'active', '/w', ?)"
);
s.run("s1", "one", 1);
s.run("s2", "two", 2);
s.run("s3", "host", 3);
s.run("s-unstamped", "pending", null);
s.run("s-free", "outside", null);
db.prepare(
"INSERT INTO dashboard_runs (id, session_id, mode, cwd, status, lane_id) VALUES ('agenthub-lane-1-2', 's-unstamped', 'interactive', '/w', 'running', 1)"
).run();
db.prepare(
"INSERT INTO agents (id, session_id, name, type) VALUES ('a2', 's2', 'Main', 'main')"
).run();
});
after(() => fs.rmSync(TMP, { recursive: true, force: true }));
function visibleSessions(req) {
const w = scope.sessionWhere(req);
return db
.prepare(`SELECT id FROM sessions WHERE ${w.where} ORDER BY id`)
.all(...w.params)
.map((r) => r.id);
}
describe("scope", () => {
it("shows a member only their own workspaces' sessions, unstamped ones included", () => {
assert.deepEqual(visibleSessions(member(1)), ["s-unstamped", "s1"]);
assert.deepEqual(visibleSessions(member(2)), ["s2"]);
assert.deepEqual(visibleSessions(member(3)), []);
});
it("shows admin, analyst and local mode everything", () => {
for (const req of [admin, analyst, local]) {
assert.equal(visibleSessions(req).length, 5);
assert.equal(scope.canSeeLane(req, 2), true);
assert.equal(scope.visibleLaneIds(req), null);
}
});
it("answers per-id checks for lanes, sessions, agents and runs", () => {
const m = member(1);
assert.equal(scope.canSeeLane(m, 1), true);
assert.equal(scope.canSeeLane(m, 2), false);
assert.equal(scope.canSeeLane(m, 3), false);
assert.equal(scope.canSeeLane(m, 404), false);
assert.equal(scope.canSeeSession(m, "s1"), true);
assert.equal(scope.canSeeSession(m, "s-unstamped"), true);
assert.equal(scope.canSeeSession(m, "s2"), false);
assert.equal(scope.canSeeSession(m, "nope"), false);
assert.equal(scope.canSeeAgent(m, "a2"), false);
assert.equal(scope.canSeeAgent(member(2), "a2"), true);
assert.equal(scope.canSeeRun(m, "agenthub-lane-1"), true);
assert.equal(scope.canSeeRun(m, "agenthub-lane-1-2"), true);
assert.equal(scope.canSeeRun(m, "agenthub-lane-2"), false);
assert.equal(scope.canSeeRun(m, "agenthub-lane-12"), false);
assert.deepEqual(scope.visibleLaneIds(m), [1]);
});
it("scopes tables keyed by session id", () => {
const w = scope.bySessionWhere(member(2));
const rows = db.prepare(`SELECT id FROM agents WHERE ${w.where}`).all(...w.params);
assert.deepEqual(
rows.map((r) => r.id),
["a2"]
);
const none = scope.bySessionWhere(member(1));
assert.equal(
db.prepare(`SELECT id FROM agents WHERE ${none.where}`).all(...none.params).length,
0
);
});
it("keeps a member's workspace folder inside their own root", () => {
process.env.AGENTHUB_USERS_ROOT = "/srv/agenthub/users";
try {
const m = member(1, "alice");
assert.equal(scope.folderAllowed(m, "/srv/agenthub/users/alice"), true);
assert.equal(scope.folderAllowed(m, "/srv/agenthub/users/alice/repo"), true);
assert.equal(scope.folderAllowed(m, "/srv/agenthub/users/alice/..x"), true);
assert.equal(scope.folderAllowed(m, "/srv/agenthub/users/bob"), false);
assert.equal(scope.folderAllowed(m, "/srv/agenthub/users/alice/../bob"), false);
assert.equal(scope.folderAllowed(m, "/srv/agenthub/users/alicex"), false);
assert.equal(scope.folderAllowed(admin, "/etc"), true);
} finally {
delete process.env.AGENTHUB_USERS_ROOT;
}
assert.equal(scope.folderAllowed(member(1), "/anywhere"), true);
});
it("never lets a request choose the owner", () => {
assert.equal(scope.ownerFor(member(7)), 7);
assert.equal(scope.ownerFor(local), null);
});
});
describe("scopeGuard", () => {
const run = (req) => {
let status = 200;
let passed = false;
const res = {
status(s) {
status = s;
return this;
},
json() {
return this;
},
};
scope.scopeGuard({ method: "GET", query: {}, body: {}, ...req }, res, () => (passed = true));
return passed ? 200 : status;
};
const m1 = member(1).user;
it("refuses a member another user's row named in the path", () => {
assert.equal(run({ user: m1, path: "/sessions/s1" }), 200);
assert.equal(run({ user: m1, path: "/sessions/s2" }), 404);
assert.equal(run({ user: m1, path: "/sessions/s2/transcript" }), 404);
assert.equal(run({ user: m1, path: "/sessions/facets" }), 200);
assert.equal(run({ user: m1, path: "/agents/a2" }), 404);
assert.equal(run({ user: m1, path: "/lanes/1/git/changes" }), 200);
assert.equal(run({ user: m1, path: "/workspaces/2/start", method: "POST" }), 404);
assert.equal(run({ user: m1, path: "/lanes/3" }), 404);
assert.equal(run({ user: m1, path: "/lanes/ensure", method: "POST" }), 200);
assert.equal(run({ user: m1, path: "/run/agenthub-lane-2-3" }), 404);
assert.equal(run({ user: m1, path: "/run/agenthub-lane-1" }), 200);
assert.equal(run({ user: m1, path: "/pricing/cost/s2" }), 404);
assert.equal(run({ user: m1, path: "/sessions/s%32" }), 404); // encoded "s2"
});
it("refuses a member another user's row named in the query or body", () => {
assert.equal(run({ user: m1, path: "/sessions", query: { lane: "2" } }), 404);
assert.equal(run({ user: m1, path: "/sessions", query: { lane: "1" } }), 200);
assert.equal(run({ user: m1, path: "/agents", query: { session_ids: "s1,s2" } }), 404);
assert.equal(run({ user: m1, path: "/run", method: "POST", body: { laneId: 2 } }), 404);
assert.equal(
run({ user: m1, path: "/lanes/1/start", method: "POST", body: { resumeSessionId: "s2" } }),
404
);
assert.equal(run({ user: m1, path: "/lanes/1/git/changes", query: { session: "s2" } }), 404);
assert.equal(run({ user: m1, path: "/sessions", query: { lane: "1 OR 1" } }), 404);
// `?session_id[]=…` / `?lane[]=…` arrive as arrays, and a JSON body may
// carry objects: a member's request with one is refused outright.
assert.equal(run({ user: m1, path: "/agents", query: { session_id: ["s2"] } }), 404);
assert.equal(run({ user: m1, path: "/agents", query: { session_ids: ["s1", "s2"] } }), 404);
assert.equal(run({ user: m1, path: "/sessions", query: { lane: ["1", "2"] } }), 404);
assert.equal(
run({ user: m1, path: "/agents", method: "POST", body: { session_id: { $ne: 1 } } }),
404
);
});
it("lets admin, analyst and local requests through", () => {
for (const user of [admin.user, analyst.user, undefined]) {
assert.equal(run({ user, path: "/sessions/s2" }), 200);
assert.equal(run({ user, path: "/run", method: "POST", body: { laneId: 2 } }), 200);
}
});
});
describe("broadcastAudience", () => {
const m1 = member(1).user;
const m2 = member(2).user;
const reaches = (type, data, user) =>
scope.audienceIncludes(scope.broadcastAudience(type, data), user);
it("sends a session's updates only to its owner (and staff)", () => {
assert.equal(reaches("session_updated", { id: "s1" }, m1), true);
assert.equal(reaches("session_updated", { id: "s1" }, m2), false);
assert.equal(reaches("session_updated", { id: "s-unstamped" }, m1), true);
assert.equal(reaches("new_event", { session_id: "s2" }, m2), true);
assert.equal(reaches("new_event", { session_id: "s2" }, m1), false);
assert.equal(reaches("agent_updated", { session_id: "s3" }, m1), false);
assert.equal(reaches("new_event", { session_id: "s2" }, admin.user), true);
assert.equal(reaches("new_event", { session_id: "s2" }, undefined), true);
});
it("routes workspace messages by owner and keeps unknown types from members", () => {
assert.equal(reaches("lane_update", { lane: { id: 1 } }, m1), true);
assert.equal(reaches("lane_update", { lane: { id: 1 } }, m2), false);
assert.equal(reaches("lane_update", { removed: 2 }, m1), true);
assert.equal(reaches("lane_runtime", { laneId: 2 }, m1), false);
assert.equal(reaches("alert_triggered", { session_id: null }, m1), false);
assert.equal(reaches("alert_triggered", { session_id: null }, analyst.user), true);
assert.equal(reaches("update_status", {}, m1), true);
assert.equal(reaches("import.progress", {}, m1), false);
assert.equal(reaches("some_future_type", {}, m1), false);
});
});
describe("scopeGuard against the router's own leniency", () => {
const run = (req) => {
let status = 200;
let passed = false;
const res = {
status(s) {
status = s;
return this;
},
json() {
return this;
},
};
scope.scopeGuard({ method: "GET", query: {}, body: {}, ...req }, res, () => (passed = true));
return passed ? 200 : status;
};
const m1 = member(1).user;
it("matches case-insensitively and on decoded segments, like Express routes", () => {
assert.equal(run({ user: m1, path: "/Lanes/2" }), 404);
assert.equal(run({ user: m1, path: "/WORKSPACES/2/start" }), 404);
assert.equal(run({ user: m1, path: "/lanes/%32" }), 404);
assert.equal(run({ user: m1, path: "/Sessions/s2" }), 404);
assert.equal(run({ user: m1, path: "//sessions/s2" }), 404);
assert.equal(run({ user: m1, path: "/run/%61genthub-lane-2" }), 404);
assert.equal(run({ user: m1, path: "/Pricing/Cost/s2" }), 404);
});
it("reads a lane id the way SQLite will, and fails closed on odd ones", () => {
assert.equal(run({ user: m1, path: "/lanes/2.0" }), 404);
assert.equal(run({ user: m1, path: "/lanes/02" }), 404);
assert.equal(run({ user: m1, path: "/lanes/01" }), 200);
assert.equal(run({ user: m1, path: "/lanes/1.0" }), 404);
assert.equal(run({ user: m1, path: "/lanes/1e1/sources" }), 404);
assert.equal(run({ user: m1, path: "/lanes/abc" }), 404);
assert.equal(run({ user: m1, path: "/lanes/Ensure", method: "POST" }), 200);
assert.equal(run({ user: m1, path: "/lanes/pipeline-stats" }), 200);
});
});
@@ -0,0 +1,209 @@
/**
* @file server-mode-aggregates.test.js
* @description Server mode's row ownership on the aggregate endpoints: GET
* /api/stats, /api/analytics, /api/query (pages and CSV export),
* /api/pricing/cost, /api/budgets/status and /api/ai-tools/:id/usage count,
* total and price only a member's own sessions, while an admin, an analyst and
* local mode keep seeing every session.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const path = require("node:path");
const fs = require("node:fs");
const os = require("node:os");
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "server-mode-aggregates-"));
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
process.env.DASHBOARD_LIVENESS_PROBE = "0";
process.env.AGENTHUB_PRICING_SYNC = "0";
const { createApp, startServer } = require("../index");
const { db, stmts } = require("../db");
const auth = require("../lib/auth");
let server;
let BASE;
const cookies = {};
const lanes = {};
async function get(p, cookie) {
const res = await fetch(BASE + p, { headers: cookie ? { cookie } : {} });
const text = await res.text();
assert.equal(res.status, 200, `${p}: ${text}`);
return res.headers.get("content-type").includes("json") ? JSON.parse(text) : text;
}
async function login(username, password) {
const res = await fetch(BASE + "/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
assert.equal(res.status, 200);
return res.headers.get("set-cookie").split(";")[0];
}
// Each session spends a distinct number of MTok at $1 each, so every total
// says exactly which sessions it counted.
const SESSIONS = [
// id, tool, stamped lane, MTok
["agg-a", "claude", "mia", 1],
["agg-b", "codex", "max", 2],
["agg-host", "claude", null, 4],
["agg-run", "claude", null, 8], // unstamped, held by a run of mia's workspace
];
before(async () => {
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
const mia = await auth.createUser("mia", "member-pass-1", "member");
const max = await auth.createUser("max", "member-pass-2", "member");
await auth.createUser("boss", "admin-pass-1", "admin");
await auth.createUser("ana", "analyst-pass-1", "analyst");
const lane = db.prepare("INSERT INTO lanes (cwd, tool, owner_id) VALUES (?, 'claude', ?)");
lanes.mia = Number(lane.run("/agg/mia", mia.id).lastInsertRowid);
lanes.max = Number(lane.run("/agg/max", max.id).lastInsertRowid);
db.prepare(
"INSERT INTO model_pricing (model_pattern, display_name, input_per_mtok, output_per_mtok, cache_read_per_mtok, cache_write_per_mtok) VALUES ('agg-model%', 'Agg', 1, 0, 0, 0)"
).run();
for (const [id, tool, owner, mtok] of SESSIONS) {
db.prepare(
"INSERT INTO sessions (id, name, status, cwd, tool, lane_id, updated_at) VALUES (?, ?, 'active', '/agg', ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))"
).run(id, id, tool, owner ? lanes[owner] : null);
db.prepare(
"INSERT INTO token_usage (session_id, model, input_tokens) VALUES (?, 'agg-model', ?)"
).run(id, mtok * 1e6);
db.prepare("INSERT INTO agents (id, session_id, name, type) VALUES (?, ?, 'Main', 'main')").run(
`${id}-main`,
id
);
db.prepare(
"INSERT INTO events (session_id, event_type, tool_name, summary) VALUES (?, 'PreToolUse', 'Bash', 'x')"
).run(id);
}
db.prepare(
"INSERT INTO dashboard_runs (id, session_id, mode, cwd, status, lane_id) VALUES (?, 'agg-run', 'interactive', '/agg', 'exited', ?)"
).run(`agenthub-lane-${lanes.mia}@old`, lanes.mia);
stmts.insertAlertRule.run(
"agg-budget",
"Org budget",
"budget",
JSON.stringify({ period: "month", limit_usd: 100, thresholds: [50] }),
1,
0
);
process.env.AGENTHUB_MODE = "server";
cookies.mia = await login("mia", "member-pass-1");
cookies.max = await login("max", "member-pass-2");
cookies.boss = await login("boss", "admin-pass-1");
cookies.ana = await login("ana", "analyst-pass-1");
});
after(() => {
delete process.env.AGENTHUB_MODE;
server?.close();
fs.rmSync(TMP, { recursive: true, force: true });
});
/** Every aggregate one viewer gets, reduced to the numbers that tell rows apart. */
async function numbers(cookie) {
const stats = await get("/api/stats", cookie);
const analytics = await get("/api/analytics", cookie);
const ranged = await get("/api/analytics?from=2000-01-01&to=2999-12-31", cookie);
const sessions = await get("/api/query?entity=sessions", cookie);
const events = await get("/api/query?entity=events", cookie);
const agents = await get("/api/query?entity=agents", cookie);
const csv = await get("/api/query?entity=sessions&format=csv", cookie);
const cost = await get("/api/pricing/cost", cookie);
const claudeCost = await get("/api/pricing/cost?tool=claude", cookie);
const budgets = await get("/api/budgets/status", cookie);
const usage = await get("/api/ai-tools/claude/usage", cookie);
return {
stats: [stats.total_sessions, stats.total_agents, stats.total_events],
analytics: [analytics.overview.total_sessions, analytics.tokens.total_input / 1e6],
analyticsCost: analytics.total_cost,
rangedCost: ranged.total_cost,
query: [sessions.total, events.total, agents.total],
queryIds: sessions.rows.map((r) => r.id).sort(),
csvRows: csv.trim().split("\r\n").length - 1,
cost: cost.total_cost,
claudeCost: claudeCost.total_cost,
budget: budgets.budgets.find((b) => b.id === "agg-budget").spend,
usage: [usage.sessions, usage.cost],
};
}
const EVERYONE = {
stats: [4, 4, 4],
analytics: [4, 15],
analyticsCost: 15,
rangedCost: 15,
query: [4, 4, 4],
queryIds: ["agg-a", "agg-b", "agg-host", "agg-run"],
csvRows: 4,
cost: 15,
claudeCost: 13,
budget: 15,
usage: [3, 13],
};
describe("aggregates in server mode", () => {
it("count a member's own sessions only, unstamped ones their runs hold included", async () => {
assert.deepEqual(await numbers(cookies.mia), {
stats: [2, 2, 2],
analytics: [2, 9],
analyticsCost: 9,
rangedCost: 9,
query: [2, 2, 2],
queryIds: ["agg-a", "agg-run"],
csvRows: 2,
cost: 9,
claudeCost: 9,
budget: 9,
usage: [2, 9],
});
});
it("give another member theirs", async () => {
assert.deepEqual(await numbers(cookies.max), {
stats: [1, 1, 1],
analytics: [1, 2],
analyticsCost: 2,
rangedCost: 2,
query: [1, 1, 1],
queryIds: ["agg-b"],
csvRows: 1,
cost: 2,
claudeCost: 0,
budget: 2,
usage: [0, 0],
});
});
it("show an admin and an analyst everything", async () => {
assert.deepEqual(await numbers(cookies.boss), EVERYONE);
assert.deepEqual(await numbers(cookies.ana), EVERYONE);
});
it("refuse a member who names another member's workspace", async () => {
const res = await fetch(`${BASE}/api/pricing/cost?lane=${lanes.max}`, {
headers: { cookie: cookies.mia },
});
assert.equal(res.status, 404);
});
});
describe("aggregates in local mode", () => {
it("are unchanged: every session, no login", async () => {
delete process.env.AGENTHUB_MODE;
try {
assert.deepEqual(await numbers(), EVERYONE);
} finally {
process.env.AGENTHUB_MODE = "server";
}
});
});
+231
View File
@@ -0,0 +1,231 @@
/**
* @file server-mode-auth.test.js
* @description Server mode's login gate (server/lib/auth.js, routes/auth.js):
* local mode untouched, every API call behind a login, roles (analyst
* read-only, admin-only config writes), hooks from this machine only, logout
* and disable ending a login, the failed-login throttle, the WebSocket check,
* and the same-origin guard accepting the dashboard's own allowed Host.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const path = require("path");
const fs = require("fs");
const os = require("os");
const TEST_DB = path.join(os.tmpdir(), `server-mode-test-${Date.now()}-${process.pid}.db`);
process.env.DASHBOARD_DB_PATH = TEST_DB;
process.env.DASHBOARD_LIVENESS_PROBE = "0";
const { createApp, startServer } = require("../index");
const auth = require("../lib/auth");
const { sameOriginGuard } = require("../lib/same-origin");
let server;
let BASE;
async function call(method, p, { cookie, body, headers = {} } = {}) {
const res = await fetch(BASE + p, {
method,
headers: {
"Content-Type": "application/json",
...(cookie ? { cookie } : {}),
...headers,
},
body: body ? JSON.stringify(body) : undefined,
});
const text = await res.text();
let json = null;
try {
json = JSON.parse(text);
} catch {
/* not JSON */
}
return { status: res.status, body: json, setCookie: res.headers.get("set-cookie") };
}
async function loginAs(username, password) {
const r = await call("POST", "/api/auth/login", { body: { username, password } });
assert.equal(r.status, 200, JSON.stringify(r.body));
return r.setCookie.split(";")[0];
}
before(async () => {
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
await auth.createUser("boss", "admin-pass-1", "admin");
await auth.createUser("mia", "member-pass-1", "member");
await auth.createUser("ana", "analyst-pass-1", "analyst");
});
after(() => {
delete process.env.AGENTHUB_MODE;
server?.close();
for (const f of [TEST_DB, `${TEST_DB}-wal`, `${TEST_DB}-shm`, `${TEST_DB}-migrate.lock`]) {
fs.rmSync(f, { force: true });
}
});
describe("local mode (the default)", () => {
it("needs no login and says it is local", async () => {
delete process.env.AGENTHUB_MODE;
assert.equal((await call("GET", "/api/sessions")).status, 200);
assert.deepEqual((await call("GET", "/api/auth/me")).body, { mode: "local" });
assert.equal((await call("POST", "/api/auth/login", { body: {} })).status, 404);
});
});
describe("server mode", () => {
before(() => {
process.env.AGENTHUB_MODE = "server";
});
it("refuses the API without a login, but answers health and /auth/me", async () => {
const r = await call("GET", "/api/sessions");
assert.equal(r.status, 401);
assert.equal(r.body.error.code, "EAUTH");
assert.equal((await call("GET", "/api/health")).status, 200);
assert.deepEqual((await call("GET", "/api/auth/me")).body, { mode: "server", user: null });
});
it("rejects a wrong password and sets an httpOnly strict cookie on a right one", async () => {
const bad = await call("POST", "/api/auth/login", { body: { username: "mia", password: "x" } });
assert.equal(bad.status, 401);
assert.equal(bad.body.error.code, "EBADLOGIN");
const ok = await call("POST", "/api/auth/login", {
body: { username: "MIA", password: "member-pass-1" },
});
assert.equal(ok.status, 200);
assert.equal(ok.body.user.username, "mia");
assert.match(ok.setCookie, /agenthub_sid=[^;]+; Path=\/; HttpOnly; SameSite=Strict/);
const cookie = ok.setCookie.split(";")[0];
assert.equal((await call("GET", "/api/sessions", { cookie })).status, 200);
assert.equal((await call("GET", "/api/auth/me", { cookie })).body.user.role, "member");
});
it("keeps an analyst read-only and the host's config writes an admin's", async () => {
const analyst = await loginAs("ana", "analyst-pass-1");
assert.equal((await call("GET", "/api/sessions", { cookie: analyst })).status, 200);
const w = await call("POST", "/api/lanes/ensure", { cookie: analyst, body: {} });
assert.equal(w.status, 403);
assert.equal(w.body.error.code, "EREADONLY");
const member = await loginAs("mia", "member-pass-1");
// A path no router answers: the guard decides before any handler could
// write the real settings file.
const m = await call("PUT", "/api/settings/no-such-route", { cookie: member, body: {} });
assert.equal(m.status, 403);
assert.equal(m.body.error.code, "EADMIN");
const admin = await loginAs("boss", "admin-pass-1");
const a = await call("PUT", "/api/settings/no-such-route", { cookie: admin, body: {} });
assert.equal(a.status, 404);
});
it("takes hooks from this machine only", async () => {
const remote = await call("POST", "/api/hooks/event", {
body: {},
headers: { "x-forwarded-for": "203.0.113.9" },
});
assert.equal(remote.status, 403);
assert.equal(remote.body.error.code, "ELOCALONLY");
const local = await call("POST", "/api/hooks/event", { body: {} });
assert.ok(![401, 403].includes(local.status), `got ${local.status}`);
});
it("ends a login on logout and on disable", async () => {
const one = await loginAs("mia", "member-pass-1");
await call("POST", "/api/auth/logout", { cookie: one });
assert.equal((await call("GET", "/api/sessions", { cookie: one })).status, 401);
const two = await loginAs("mia", "member-pass-1");
auth.setDisabled("mia", true);
assert.equal((await call("GET", "/api/sessions", { cookie: two })).status, 401);
const again = await call("POST", "/api/auth/login", {
body: { username: "mia", password: "member-pass-1" },
});
assert.equal(again.status, 401);
auth.setDisabled("mia", false);
});
it("checks a WebSocket upgrade's login and Origin", async () => {
const cookie = await loginAs("boss", "admin-pass-1");
const req = (headers, url = "/ws") => ({ url, headers: { host: "127.0.0.1:1", ...headers } });
assert.equal(auth.isWebSocketLoggedIn(req({})), false);
assert.equal(auth.isWebSocketLoggedIn(req({ cookie })), true);
assert.equal(auth.isWebSocketLoggedIn(req({ cookie, origin: "https://evil.example" })), false);
const analyst = await loginAs("ana", "analyst-pass-1");
assert.equal(auth.isWebSocketLoggedIn(req({ cookie: analyst })), true);
assert.equal(
auth.isWebSocketLoggedIn(req({ cookie: analyst }, "/ws-pty/agenthub-lane-1")),
false
);
});
// Last: it locks this test's address out of logging in.
it("throttles repeated failed logins", async () => {
let last;
for (let i = 0; i < 11; i++) {
last = await call("POST", "/api/auth/login", { body: { username: "boss", password: "no" } });
}
assert.equal(last.status, 429);
assert.equal(last.body.error.code, "ETOOMANY");
});
});
describe("sameOriginGuard", () => {
const run = (headers) => {
let status = 200;
let passed = false;
const res = {
status(s) {
status = s;
return this;
},
json() {
return this;
},
};
sameOriginGuard({ headers }, res, () => (passed = true));
return passed ? 200 : status;
};
it("accepts the dashboard's own allowed Host as an Origin", () => {
process.env.DASHBOARD_ALLOWED_HOSTS = "agenthub.example.com";
try {
assert.equal(
run({ origin: "https://agenthub.example.com", host: "agenthub.example.com" }),
200
);
assert.equal(run({ origin: "https://evil.example", host: "agenthub.example.com" }), 403);
assert.equal(run({ origin: "https://evil.example", host: "evil.example" }), 403);
assert.equal(run({ origin: "http://localhost:4820", host: "localhost:4820" }), 200);
assert.equal(run({}), 200);
} finally {
delete process.env.DASHBOARD_ALLOWED_HOSTS;
}
});
});
describe("server mode prefix checks follow Express routing", () => {
before(() => {
process.env.AGENTHUB_MODE = "server";
});
it("refuses a member a staff-only router reached by case or encoding", async () => {
const r = await call("POST", "/api/auth/login", {
body: { username: "mia", password: "member-pass-1" },
headers: { "x-forwarded-for": "198.51.100.7" },
});
const cookie = r.setCookie.split(";")[0];
for (const p of ["/api/webhooks", "/api/Webhooks", "/api/%77ebhooks", "/api//webhooks"]) {
const res = await call("GET", p, { cookie });
assert.ok([403, 404].includes(res.status), `${p} answered ${res.status}`);
}
const hook = await call("POST", "/api/Hooks/event", {
body: {},
headers: { "x-forwarded-for": "203.0.113.9" },
});
assert.equal(hook.status, 403);
});
});
@@ -0,0 +1,352 @@
/**
* @file server-mode-review-fixes.test.js
* @description Regressions for the server-mode review's confirmed defects: a
* member cannot read another user's agent through `POST /api/agents`, cannot
* PATCH the pane/session columns of a workspace, cannot open the account pool;
* a transcript `agent_id`/`run_id` cannot walk out of its folder (any mode);
* push subscriptions belong to the user who made them, and a member's quota
* view names no account.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const path = require("node:path");
const fs = require("node:fs");
const os = require("node:os");
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "server-mode-fixes-"));
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
process.env.DASHBOARD_LIVENESS_PROBE = "0";
// Named locks live under LANES_ROOT/.locks — never the real one.
process.env.LANES_ROOT = path.join(TMP, "lanes");
const { createApp, startServer } = require("../index");
const { db } = require("../db");
const auth = require("../lib/auth");
let server;
let BASE;
let laneMia;
let laneMax;
async function call(method, p, { cookie, body } = {}) {
const res = await fetch(BASE + p, {
method,
headers: { "Content-Type": "application/json", ...(cookie ? { cookie } : {}) },
body: body ? JSON.stringify(body) : undefined,
});
return { status: res.status, body: await res.json().catch(() => null) };
}
async function login(username, password) {
const res = await fetch(BASE + "/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
assert.equal(res.status, 200);
return res.headers.get("set-cookie").split(";")[0];
}
before(async () => {
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
const mia = await auth.createUser("mia", "member-pass-1", "member");
const max = await auth.createUser("max", "member-pass-2", "member");
await auth.createUser("boss", "admin-pass-1", "admin");
const lane = db.prepare("INSERT INTO lanes (cwd, tool, owner_id) VALUES (?, 'claude', ?)");
laneMia = lane.run("/w/mia", mia.id).lastInsertRowid;
laneMax = lane.run("/w/max", max.id).lastInsertRowid;
db.prepare(
"INSERT INTO sessions (id, name, status, cwd, lane_id) VALUES ('s-max', 'max', 'active', '/w/max', ?)"
).run(laneMax);
db.prepare(
"INSERT INTO agents (id, session_id, name, type) VALUES ('a-max', 's-max', 'Main', 'main')"
).run();
});
after(() => {
delete process.env.AGENTHUB_MODE;
server?.close();
fs.rmSync(TMP, { recursive: true, force: true });
});
describe("server mode review fixes", () => {
before(() => {
process.env.AGENTHUB_MODE = "server";
});
it("does not hand back another user's agent from POST /api/agents", async () => {
const mia = await login("mia", "member-pass-1");
const r = await call("POST", "/api/agents", {
cookie: mia,
body: { id: "a-max", session_id: `s-mia-${laneMia}`, name: "x" },
});
assert.equal(r.status, 404);
assert.ok(!JSON.stringify(r.body).includes("s-max"));
});
it("lets a member PATCH only a workspace's typed fields", async () => {
const mia = await login("mia", "member-pass-1");
const bad = await call("PATCH", `/api/lanes/${laneMia}`, {
cookie: mia,
body: { run_id: `agenthub-lane-${laneMax}` },
});
assert.equal(bad.status, 403);
assert.equal(db.prepare("SELECT run_id FROM lanes WHERE id = ?").get(laneMia).run_id, null);
const ok = await call("PATCH", `/api/lanes/${laneMia}`, {
cookie: mia,
body: { title: "Mine" },
});
assert.equal(ok.status, 200);
const admin = await login("boss", "admin-pass-1");
const staff = await call("PATCH", `/api/lanes/${laneMia}`, {
cookie: admin,
body: { notes: "x", status: "idle" },
});
assert.equal(staff.status, 200);
});
it("keeps the account pool and account names from members", async () => {
const mia = await login("mia", "member-pass-1");
assert.equal((await call("GET", "/api/accounts", { cookie: mia })).status, 403);
const q = await call("GET", "/api/pricing/quota", { cookie: mia });
assert.equal(q.status, 200);
for (const t of q.body.tools || []) assert.equal(t.account, null);
});
it("scopes push subscriptions to their user", async () => {
const mia = await login("mia", "member-pass-1");
const max = await login("max", "member-pass-2");
const sub = (endpoint, cookie) =>
call("POST", "/api/push/subscribe", {
cookie,
body: { endpoint, keys: { p256dh: "p", auth: "a" } },
});
await sub("https://push.example/mia", mia);
await sub("https://push.example/max", max);
const owners = db
.prepare("SELECT endpoint, user_id FROM push_subscriptions ORDER BY endpoint")
.all()
.map((r) => [r.endpoint.split("/").pop(), r.user_id]);
assert.deepEqual(
owners.map(([e]) => e),
["max", "mia"]
);
assert.notEqual(owners[0][1], owners[1][1]);
// max cannot unsubscribe mia's browser.
await call("DELETE", "/api/push/subscribe", {
cookie: max,
body: { endpoint: "https://push.example/mia" },
});
assert.equal(db.prepare("SELECT COUNT(*) AS n FROM push_subscriptions").get().n, 2);
});
});
describe("transcript ids (every mode)", () => {
it("refuses an agent_id or run_id that is a path", async () => {
delete process.env.AGENTHUB_MODE;
for (const q of ["agent_id=../../x", "run_id=..%2F..%2Fx", "agent_id=a%2Fb"]) {
const r = await call("GET", `/api/sessions/s-max/transcript?${q}`);
assert.equal(r.status, 400, q);
assert.equal(r.body.error.code, "EBADID");
}
const ok = await call("GET", "/api/sessions/s-max/transcript?agent_id=a-max");
assert.equal(ok.status, 200);
});
});
describe("server mode review fixes, second round", () => {
before(() => {
process.env.AGENTHUB_MODE = "server";
});
it("refuses a write whose Origin is not the dashboard's own", async () => {
const mia = await login("mia", "member-pass-1");
const res = await fetch(`${BASE}/api/lanes/${laneMia}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
cookie: mia,
origin: "https://wiki.example.com",
},
body: JSON.stringify({ title: "csrf" }),
});
assert.equal(res.status, 403);
assert.equal((await res.json()).error.code, "EBADORIGIN");
const own = await fetch(`${BASE}/api/lanes/${laneMia}`, {
method: "PATCH",
headers: { "Content-Type": "application/json", cookie: mia, origin: BASE },
body: JSON.stringify({ title: "fine" }),
});
assert.equal(own.status, 200);
});
it("counts parallel failed logins against the limit", async () => {
const tries = await Promise.all(
Array.from({ length: 25 }, () =>
fetch(`${BASE}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json", "x-forwarded-for": "192.0.2.55" },
body: JSON.stringify({ username: "max", password: "wrong" }),
}).then((r) => r.status)
)
);
assert.ok(tries.filter((s) => s === 401).length <= 10, JSON.stringify(tries));
assert.ok(tries.includes(429));
});
it("refuses a member a workspace that overlaps another user's, for any tool", async () => {
const mia = await login("mia", "member-pass-1");
for (const body of [{ cwd: "/w/max", tool: "codex" }, { cwd: "/w/max/sub" }, { cwd: "/w" }]) {
const r = await call("POST", "/api/lanes", { cookie: mia, body });
assert.equal(r.status, 409, JSON.stringify(body));
assert.equal(r.body.error.code, "EOWNED");
}
});
it("keeps members from planting sessions and from the shared pet store", async () => {
const mia = await login("mia", "member-pass-1");
const s = await call("POST", "/api/sessions", {
cookie: mia,
body: { id: "fake-1", name: "x" },
});
assert.equal(s.status, 403);
assert.equal(db.prepare("SELECT COUNT(*) AS n FROM sessions WHERE id = 'fake-1'").get().n, 0);
const p = await call("DELETE", "/api/pets/some-pet", { cookie: mia });
assert.equal(p.status, 403);
});
it("lets a live pane's CLI act as its workspace owner, and nothing else", async () => {
const { execFileSync } = require("node:child_process");
const crypto = require("node:crypto");
const run = `agenthub-lane-${laneMia}`;
const token = "ahpane_test-token-value-0123456789";
db.prepare(
`INSERT OR REPLACE INTO dashboard_runs (id, mode, cwd, status, lane_id, pane_token_hash)
VALUES (?, 'interactive', '/w/mia', 'running', ?, ?)`
).run(run, laneMia, crypto.createHash("sha256").update(token).digest("hex"));
const asPane = (p, extra = {}) =>
fetch(BASE + p, { headers: { "x-agenthub-pane-token": token, ...extra } }).then(
(r) => r.status
);
// No live tmux session yet: the credential is dead.
assert.equal(await asPane("/api/sessions"), 401);
execFileSync("tmux", ["new-session", "-d", "-s", run, "sleep", "120"]);
try {
assert.equal(await asPane("/api/sessions"), 200);
assert.equal(await asPane(`/api/lanes/${laneMax}`), 404); // still a member
assert.equal(await asPane("/api/sessions", { "x-forwarded-for": "203.0.113.1" }), 401);
assert.equal(
await fetch(BASE + "/api/sessions", {
headers: { "x-agenthub-pane-token": "ahpane_wrong" },
}).then((r) => r.status),
401
);
} finally {
execFileSync("tmux", ["kill-session", "-t", `=${run}`]);
}
});
it("sends a member the first frames of a session their own pane starts", async () => {
const WebSocket = require("ws");
const cookie = await login("mia", "member-pass-1");
const run = `agenthub-lane-${laneMia}-3`;
db.prepare(
`INSERT OR REPLACE INTO dashboard_runs (id, mode, cwd, status, lane_id)
VALUES (?, 'interactive', '/w/mia', 'running', ?)`
).run(run, laneMia);
const ws = new WebSocket(BASE.replace("http", "ws") + "/ws", { headers: { cookie } });
const seen = [];
ws.on("message", (m) => seen.push(JSON.parse(String(m))));
await new Promise((resolve, reject) => ws.on("open", resolve).on("error", reject));
try {
// The session is new: it has no workspace until bindRunSession stamps
// it, so frames sent before that stamp reached no member.
const res = await call("POST", "/api/hooks/event", {
body: {
hook_type: "SessionStart",
run_id: run,
data: { session_id: "s-new-mia", cwd: "/w/mia", source: "startup" },
},
});
assert.equal(res.status, 200);
await new Promise((r) => setTimeout(r, 300));
const mine = seen.filter((m) => JSON.stringify(m.data || {}).includes("s-new-mia"));
assert.ok(
mine.some((m) => m.type === "new_event"),
`got ${seen.map((m) => m.type).join(", ")}`
);
} finally {
ws.close();
}
});
it("sends members the config-change signal without its payload", () => {
const scope = require("../lib/scope");
const a = scope.broadcastAudience("cc_config_changed", { type: "profile", name: "boss@x" });
assert.equal(a.strip, true);
assert.equal(scope.audienceIncludes(a, { id: 1, role: "member" }), true);
});
});
describe("named locks", () => {
it("never takes a path for a lock name (any mode)", () => {
const namedLock = require("../lib/named-lock");
for (const bad of ["../x", "..", ".", "a/b", "a\\b", "", "x".repeat(256)]) {
assert.throws(() => namedLock.tryAcquire(bad, "me"), { code: "EBADNAME" }, bad);
assert.throws(() => namedLock.release(bad, "me"), { code: "EBADNAME" }, bad);
}
assert.throws(() => namedLock.tryAcquire("ok", " "), { code: "EBADHOLDER" });
// A holder with spaces keeps working — and now really holds the lock.
assert.deepEqual(namedLock.tryAcquire("ci", "ci bot"), { acquired: true });
assert.equal(namedLock.tryAcquire("ci", "other").acquired, false);
namedLock.release("ci", "ci bot");
// Names that were always valid directory names keep working.
for (const good of ["db:migrate", "build web", "-x"]) {
assert.deepEqual(namedLock.tryAcquire(good, "me"), { acquired: true }, good);
namedLock.release(good, "me");
}
});
it("gives each member their own lock namespace", async () => {
process.env.AGENTHUB_MODE = "server";
const mia = await login("mia", "member-pass-1");
const max = await login("max", "member-pass-2");
const take = (cookie, holder) =>
call("POST", "/api/locks/deploy/acquire", { cookie, body: { holder } });
assert.equal((await take(mia, "mia-agent")).status, 200);
assert.equal((await take(max, "max-agent")).status, 200); // not mia's lock
const seen = (await call("GET", "/api/locks", { cookie: max })).body.locks;
assert.deepEqual(
seen.map((l) => [l.name, l.holder]),
[["deploy", "max-agent"]]
);
const bad = await call("POST", "/api/locks/..%2F..%2Fx/acquire", {
cookie: mia,
body: { holder: "h" },
});
assert.ok([400, 404].includes(bad.status));
await call("POST", "/api/locks/deploy/release", { cookie: mia, body: { holder: "mia-agent" } });
await call("POST", "/api/locks/deploy/release", { cookie: max, body: { holder: "max-agent" } });
});
});
describe("data export", () => {
it("never writes a pane credential's hash into a bundle", async () => {
delete process.env.AGENTHUB_MODE;
db.prepare(
`INSERT OR REPLACE INTO dashboard_runs (id, mode, cwd, status, pane_token_hash)
VALUES ('agenthub-lane-999@x', 'interactive', '/w', 'killed', 'deadbeefhash')`
).run();
const text = await (await fetch(`${BASE}/api/settings/export`)).text();
assert.ok(text.includes("agenthub-lane-999@x"));
assert.ok(!text.includes("deadbeefhash"));
const { buildExportBundle } = require("../lib/data-transfer");
const bundle = buildExportBundle(db, require("../db").stmts);
assert.ok(bundle.dashboard_runs.some((r) => r.id === "agenthub-lane-999@x"));
assert.ok(!JSON.stringify(bundle).includes("deadbeefhash"));
});
});
+253
View File
@@ -0,0 +1,253 @@
/**
* @file server-mode-rows.test.js
* @description Server mode's per-user row ownership on the list and aggregate
* routes (phase S2): sessions (+ facets, POST), agents, events (+ facets),
* snapshots, the alert feed and ack-all, and the settings info counts. A
* member gets only their own workspaces' rows, another member theirs, an admin
* and an analyst everything, and local mode is unchanged.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const path = require("path");
const fs = require("fs");
const os = require("os");
const TEST_DB = path.join(os.tmpdir(), `server-mode-rows-${Date.now()}-${process.pid}.db`);
process.env.DASHBOARD_DB_PATH = TEST_DB;
process.env.DASHBOARD_LIVENESS_PROBE = "0";
const { createApp, startServer } = require("../index");
const { db } = require("../db");
const auth = require("../lib/auth");
let server;
let BASE;
const cookies = {};
async function call(method, p, { as, body } = {}) {
const res = await fetch(BASE + p, {
method,
headers: {
"Content-Type": "application/json",
...(as ? { cookie: cookies[as] } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
return { status: res.status, body: await res.json().catch(() => null) };
}
const get = async (p, as) => {
const r = await call("GET", p, { as });
assert.equal(r.status, 200, `${p}: ${JSON.stringify(r.body)}`);
return r.body;
};
const ids = (rows, key = "id") => rows.map((r) => r[key]).sort();
const ALL_SESSIONS = ["sA", "sA2", "sB", "sHost"];
before(async () => {
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
const alice = await auth.createUser("alice", "alice-pass-1", "member");
const bob = await auth.createUser("bob", "bob-pass-1", "member");
await auth.createUser("boss", "admin-pass-1", "admin");
await auth.createUser("ana", "analyst-pass-1", "analyst");
const lane = db.prepare("INSERT INTO lanes (cwd, tool, owner_id) VALUES (?, 'claude', ?)");
const laneA = lane.run("/w/alice", alice.id).lastInsertRowid;
const laneB = lane.run("/w/bob", bob.id).lastInsertRowid;
const session = db.prepare(
"INSERT INTO sessions (id, name, status, cwd, lane_id, source) VALUES (?, ?, 'active', ?, ?, ?)"
);
session.run("sA", "alice one", "/w/alice", laneA, "local");
session.run("sA2", "alice unstamped", "/w/alice", null, "local");
session.run("sB", "bob one", "/w/bob", laneB, "remote-b");
session.run("sHost", "host", "/w/host", null, "local");
// sA2 is alice's through a run of her workspace, not a stamp.
db.prepare(
"INSERT INTO dashboard_runs (id, session_id, mode, cwd, status, lane_id) VALUES ('agenthub-lane-1-2', 'sA2', 'interactive', '/w/alice', 'running', ?)"
).run(laneA);
const rule = db.prepare(
"INSERT INTO alert_rules (id, name, rule_type, config) VALUES ('r1', 'rule', 'error_rate', '{}')"
);
rule.run();
for (const sid of ALL_SESSIONS) {
db.prepare(
"INSERT INTO agents (id, session_id, name, type, status) VALUES (?, ?, 'Main', 'main', 'working')"
).run(`${sid}-main`, sid);
db.prepare(
"INSERT INTO events (session_id, agent_id, event_type, tool_name, summary) VALUES (?, ?, ?, ?, 'x')"
).run(sid, `${sid}-main`, `Type_${sid}`, `Tool_${sid}`);
db.prepare("INSERT INTO token_usage (session_id, model, input_tokens) VALUES (?, 'm', 10)").run(
sid
);
db.prepare(
"INSERT INTO alert_events (rule_id, rule_name, rule_type, session_id, message) VALUES ('r1', 'rule', 'error_rate', ?, 'boom')"
).run(sid);
db.prepare(
"INSERT INTO session_snapshots (token, session_id, payload) VALUES (?, ?, '{\"events\":[]}')"
).run(`tok-${sid}`.padEnd(32, "x"), sid);
}
// An alert with no session (a budget) is staff-only.
db.prepare(
"INSERT INTO alert_events (rule_id, rule_name, rule_type, session_id, message) VALUES ('r1', 'rule', 'budget', NULL, 'spend')"
).run();
process.env.AGENTHUB_MODE = "server";
for (const [name, pass] of [
["alice", "alice-pass-1"],
["bob", "bob-pass-1"],
["boss", "admin-pass-1"],
["ana", "analyst-pass-1"],
]) {
const r = await fetch(BASE + "/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: name, password: pass }),
});
assert.equal(r.status, 200);
cookies[name] = r.headers.get("set-cookie").split(";")[0];
}
});
after(() => {
delete process.env.AGENTHUB_MODE;
server?.close();
for (const f of [TEST_DB, `${TEST_DB}-wal`, `${TEST_DB}-shm`, `${TEST_DB}-migrate.lock`]) {
fs.rmSync(f, { force: true });
}
});
/** What `as` sees on every scoped list/aggregate route. */
async function view(as) {
const sessions = await get("/api/sessions", as);
const priced = await get("/api/sessions?sort_by=price", as);
const waiting = await get("/api/sessions?status=active&archived=0&q=alice", as);
const sFacets = await get("/api/sessions/facets", as);
const agents = await get("/api/agents", as);
const working = await get("/api/agents?status=working", as);
const events = await get("/api/events", as);
const eFacets = await get("/api/events/facets", as);
const snaps = await get("/api/snapshots", as);
const alerts = await get("/api/alerts", as);
const info = await get("/api/settings/info", as);
return {
sessions: ids(sessions.sessions),
total: sessions.total,
priced: ids(priced.sessions),
waiting: ids(waiting.sessions),
cwds: sFacets.cwds,
sources: sFacets.sources,
agents: ids(agents.agents, "session_id"),
working: ids(working.agents, "session_id"),
events: ids(events.events, "session_id"),
eventTotal: events.total,
tools: eFacets.tool_names,
snapshots: ids(snaps.snapshots, "session_id"),
alerts: alerts.alerts.map((a) => a.session_id).sort(),
alertTotal: alerts.total,
unacked: alerts.unacked,
counts: info.db.counts,
h1: info.db.load_stats.h1,
};
}
function assertSees(v, sids) {
assert.deepEqual(v.sessions, sids);
assert.equal(v.total, sids.length);
assert.deepEqual(v.priced, sids);
assert.deepEqual(v.agents, sids);
assert.deepEqual(v.working, sids);
assert.deepEqual(v.events, sids);
assert.equal(v.eventTotal, sids.length);
assert.deepEqual(
v.tools,
sids.map((s) => `Tool_${s}`)
);
assert.deepEqual(v.snapshots, sids);
assert.equal(v.counts.sessions, sids.length);
assert.equal(v.counts.agents, sids.length);
assert.equal(v.counts.events, sids.length);
assert.equal(v.counts.token_usage, sids.length);
assert.equal(v.h1, sids.length);
}
describe("server mode: list and aggregate routes", () => {
it("shows a member only their own workspaces' rows, unstamped ones included", async () => {
const v = await view("alice");
assertSees(v, ["sA", "sA2"]);
assert.deepEqual(v.waiting, ["sA", "sA2"]);
assert.deepEqual(v.cwds, ["/w/alice"]);
assert.deepEqual(v.sources, ["local"]);
assert.deepEqual(v.alerts, ["sA", "sA2"]);
assert.equal(v.alertTotal, 2);
assert.equal(v.unacked, 2);
});
it("shows another member theirs", async () => {
const v = await view("bob");
assertSees(v, ["sB"]);
assert.deepEqual(v.waiting, []);
assert.deepEqual(v.cwds, ["/w/bob"]);
assert.deepEqual(v.sources, ["remote-b"]);
assert.deepEqual(v.alerts, ["sB"]);
assert.equal(v.unacked, 1);
});
it("shows an admin and an analyst everything", async () => {
for (const as of ["boss", "ana"]) {
const v = await view(as);
assertSees(v, ALL_SESSIONS);
assert.deepEqual(v.waiting, ["sA", "sA2"]);
assert.deepEqual(v.cwds, ["/w/alice", "/w/bob", "/w/host"]);
assert.deepEqual(v.sources, ["local", "remote-b"]);
assert.equal(v.alerts.length, 5); // the budget alert included
assert.equal(v.unacked, 5);
}
});
it("keeps a member out of another's rows through filters and POST", async () => {
// A filter naming another's session is refused, not answered empty.
assert.equal((await call("GET", "/api/events?session_id=sB", { as: "alice" })).status, 404);
assert.equal((await call("GET", "/api/agents?session_id=sB", { as: "alice" })).status, 404);
// An agent_id filter cannot reach past the ownership predicate.
const ev = await get("/api/events?agent_id=sB-main", "alice");
assert.equal(ev.total, 0);
// A member cannot create (or probe) sessions through POST /api/sessions at
// all — sessions come from hooks and the dashboard's own panes.
const r = await call("POST", "/api/sessions", { as: "alice", body: { id: "sB" } });
assert.equal(r.status, 403);
assert.ok(!JSON.stringify(r.body).includes("sB"));
const own = await call("POST", "/api/sessions", { as: "alice", body: { id: "sA" } });
assert.equal(own.status, 403);
// A snapshot of another's session cannot be captured.
const snap = await call("POST", "/api/snapshots", { as: "alice", body: { session_id: "sB" } });
assert.equal(snap.status, 404);
});
it("lets a member acknowledge only their own alerts", async () => {
const r = await call("POST", "/api/alerts/ack-all", { as: "alice" });
assert.equal(r.status, 200);
assert.equal(r.body.acknowledged, 2);
assert.equal((await get("/api/alerts", "alice")).unacked, 0);
assert.equal((await get("/api/alerts", "bob")).unacked, 1);
assert.equal((await get("/api/alerts?unacked=true", "boss")).total, 3);
});
});
describe("local mode", () => {
before(() => delete process.env.AGENTHUB_MODE);
after(() => (process.env.AGENTHUB_MODE = "server"));
it("needs no login and lists every row", async () => {
const v = await view();
assertSees(v, ALL_SESSIONS);
assert.equal(v.alerts.length, 5);
assert.equal(v.unacked, 3);
const ack = await call("POST", "/api/alerts/ack-all");
assert.equal(ack.body.acknowledged, 3);
});
});
+107
View File
@@ -0,0 +1,107 @@
/**
* @file server-mode-users.test.js
* @description Server mode's staff views: `GET /api/users` lists each account's
* workspaces, sessions and recent spend to an admin/analyst and refuses a
* member; an admin/analyst reading a member's session is written to the audit
* log (`GET /api/users/audit`), while a member reading their own, or a read of
* an ownerless session, is not.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const path = require("node:path");
const fs = require("node:fs");
const os = require("node:os");
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "server-mode-users-"));
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
process.env.DASHBOARD_LIVENESS_PROBE = "0";
const { createApp, startServer } = require("../index");
const { db } = require("../db");
const auth = require("../lib/auth");
let server;
let BASE;
const ids = {};
async function call(p, cookie) {
const res = await fetch(BASE + p, { headers: cookie ? { cookie } : {} });
return { status: res.status, body: await res.json().catch(() => null) };
}
async function login(username, password) {
const res = await fetch(BASE + "/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
assert.equal(res.status, 200);
return res.headers.get("set-cookie").split(";")[0];
}
before(async () => {
process.env.AGENTHUB_MODE = "server";
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
ids.boss = (await auth.createUser("boss", "admin-pass-1", "admin")).id;
ids.ana = (await auth.createUser("ana", "analyst-pass-1", "analyst")).id;
ids.mia = (await auth.createUser("mia", "member-pass-1", "member")).id;
const lane = db
.prepare("INSERT INTO lanes (cwd, tool, owner_id) VALUES ('/w/mia', 'claude', ?)")
.run(ids.mia).lastInsertRowid;
db.prepare(
"INSERT INTO sessions (id, name, status, cwd, lane_id) VALUES ('sm', 'mia work', 'active', '/w/mia', ?)"
).run(lane);
db.prepare(
"INSERT INTO sessions (id, name, status, cwd) VALUES ('sx', 'host work', 'active', '/w/host')"
).run();
db.prepare(
`INSERT INTO token_usage_hourly (session_id, hour, model, output_tokens)
VALUES ('sm', ?, 'claude-sonnet-4-5-20250929', 1000000)`
).run(new Date().toISOString().slice(0, 13));
});
after(() => {
delete process.env.AGENTHUB_MODE;
server?.close();
fs.rmSync(TMP, { recursive: true, force: true });
});
describe("staff views", () => {
it("lists every account's usage to staff and refuses a member", async () => {
const admin = await login("boss", "admin-pass-1");
const r = await call("/api/users", admin);
assert.equal(r.status, 200);
const mia = r.body.users.find((u) => u.username === "mia");
assert.equal(mia.workspaces, 1);
assert.equal(mia.sessions, 1);
assert.ok(mia.last7d.cost > 0 && mia.last30d.cost >= mia.last7d.cost);
assert.ok(!JSON.stringify(r.body).includes("scrypt"), "no password hash");
assert.equal((await call("/api/users", await login("ana", "analyst-pass-1"))).status, 200);
const member = await call("/api/users", await login("mia", "member-pass-1"));
assert.equal(member.status, 403);
});
it("audits a staff read of a member's session, and nothing else", async () => {
const admin = await login("boss", "admin-pass-1");
const mia = await login("mia", "member-pass-1");
const before = (await call("/api/users/audit", admin)).body.entries.length;
assert.equal((await call("/api/sessions/sm", mia)).status, 200); // own read
assert.equal((await call("/api/sessions/sx", admin)).status, 200); // ownerless
assert.equal((await call("/api/sessions/sm/transcript", admin)).status < 500, true);
await call("/api/sessions/sm", await login("ana", "analyst-pass-1"));
const entries = (await call("/api/users/audit", admin)).body.entries;
assert.equal(entries.length - before, 2);
assert.deepEqual(
entries.slice(0, 2).map((e) => [e.reader, e.owner, e.session_id]),
[
["ana", "mia", "sm"],
["boss", "mia", "sm"],
]
);
});
});
@@ -0,0 +1,175 @@
/**
* @file server-mode-workflows.test.js
* @description Server mode's row ownership on the workflow endpoints
* (server/routes/workflows.js, server/lib/workflow-stats.js run through the
* read worker): GET /api/workflows, /api/workflows/skill-usage and
* /api/workflows/runs count and list only a member's own sessions, another
* member sees theirs, an admin and an analyst see every row, and local mode
* answers exactly what an admin does.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const path = require("node:path");
const fs = require("node:fs");
const os = require("node:os");
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "server-mode-workflows-"));
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
process.env.DASHBOARD_LIVENESS_PROBE = "0";
const { createApp, startServer } = require("../index");
const { db } = require("../db");
const auth = require("../lib/auth");
let server;
let BASE;
const cookies = {};
async function get(p, cookie) {
const res = await fetch(BASE + p, { headers: cookie ? { cookie } : {} });
return { status: res.status, body: await res.json() };
}
async function loginAs(username, password) {
const res = await fetch(BASE + "/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
assert.equal(res.status, 200);
return res.headers.get("set-cookie").split(";")[0];
}
/** One session with a main agent, a Skill call, a Bash call and a workflow run. */
function seedSession(id, laneId, skill, wfStatus) {
db.prepare(
`INSERT INTO sessions (id, name, status, cwd, lane_id, started_at, ended_at)
VALUES (?, ?, 'completed', '/w', ?, '2026-10-01T00:00:00.000Z', '2026-10-01T00:10:00.000Z')`
).run(id, id, laneId);
db.prepare(
"INSERT INTO agents (id, session_id, name, type, status) VALUES (?, ?, 'Main', 'main', 'completed')"
).run(`${id}-main`, id);
const ev = db.prepare(
"INSERT INTO events (session_id, event_type, tool_name, data) VALUES (?, 'PreToolUse', ?, ?)"
);
ev.run(id, "Skill", JSON.stringify({ tool_input: { skill } }));
ev.run(id, "Bash", null);
db.prepare(
"INSERT INTO workflows (run_id, session_id, name, status, started_at) VALUES (?, ?, ?, ?, '2026-10-01T00:00:00.000Z')"
).run(`wf-${id}`, id, id, wfStatus);
}
before(async () => {
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
const mia = await auth.createUser("mia", "member-pass-1", "member");
const max = await auth.createUser("max", "member-pass-2", "member");
await auth.createUser("boss", "admin-pass-1", "admin");
await auth.createUser("ana", "analyst-pass-1", "analyst");
const lane = db.prepare("INSERT INTO lanes (cwd, tool, owner_id) VALUES (?, 'claude', ?)");
const miaLane = lane.run("/w/mia", mia.id).lastInsertRowid;
const maxLane = lane.run("/w/max", max.id).lastInsertRowid;
seedSession("m1", miaLane, "mia:one", "completed");
seedSession("m2", null, "mia:two", "running"); // not stamped: held by mia's run
db.prepare(
"INSERT INTO dashboard_runs (id, session_id, mode, cwd, status, lane_id) VALUES (?, 'm2', 'interactive', '/w', 'running', ?)"
).run(`agenthub-lane-${miaLane}-1`, miaLane);
seedSession("x1", maxLane, "max:one", "failed");
seedSession("h1", null, "host:one", "completed"); // nobody's workspace
});
after(() => {
delete process.env.AGENTHUB_MODE;
server?.close();
fs.rmSync(TMP, { recursive: true, force: true });
});
const ALL = ["h1", "m1", "m2", "x1"];
async function view(cookie) {
const wf = await get("/api/workflows?sections=stats,complexity,modelDelegation", cookie);
const skills = await get("/api/workflows/skill-usage", cookie);
const runs = await get("/api/workflows/runs", cookie);
const byStatus = await get("/api/workflows/runs?status=completed", cookie);
for (const r of [wf, skills, runs, byStatus]) assert.equal(r.status, 200, JSON.stringify(r));
return {
wf: wf.body,
sessions: wf.body.complexity.map((s) => s.id).sort(),
totalSessions: wf.body.stats.totalSessions,
totalAgents: wf.body.stats.totalAgents,
skills: skills.body.skills.map((s) => s.name).sort(),
plugins: skills.body.plugins.map((p) => p.name).sort(),
runs: runs.body.runs.map((r) => r.session_id).sort(),
total: runs.body.total,
counts: runs.body.counts,
completed: byStatus.body.runs.map((r) => r.session_id).sort(),
completedTotal: byStatus.body.total,
};
}
let localView;
describe("local mode", () => {
it("reads every row", async () => {
localView = await view();
assert.deepEqual(localView.sessions, ALL);
assert.equal(localView.totalSessions, 4);
assert.equal(localView.totalAgents, 4);
assert.deepEqual(localView.skills, ["host:one", "max:one", "mia:one", "mia:two"]);
assert.deepEqual(localView.runs, ALL);
assert.equal(localView.total, 4);
assert.deepEqual(localView.counts, { completed: 2, running: 1, failed: 1 });
assert.deepEqual(localView.completed, ["h1", "m1"]);
});
});
describe("server mode", () => {
before(async () => {
process.env.AGENTHUB_MODE = "server";
cookies.mia = await loginAs("mia", "member-pass-1");
cookies.max = await loginAs("max", "member-pass-2");
cookies.boss = await loginAs("boss", "admin-pass-1");
cookies.ana = await loginAs("ana", "analyst-pass-1");
});
it("shows a member only their own sessions in every number", async () => {
const v = await view(cookies.mia);
assert.deepEqual(v.sessions, ["m1", "m2"]);
assert.equal(v.totalSessions, 2);
assert.equal(v.totalAgents, 2);
assert.deepEqual(v.skills, ["mia:one", "mia:two"]);
assert.deepEqual(v.plugins, ["mia"]);
assert.deepEqual(v.runs, ["m1", "m2"]);
assert.equal(v.total, 2);
assert.deepEqual(v.counts, { completed: 1, running: 1 });
assert.deepEqual(v.completed, ["m1"]);
assert.equal(v.completedTotal, 1);
});
it("shows another member theirs", async () => {
const v = await view(cookies.max);
assert.deepEqual(v.sessions, ["x1"]);
assert.equal(v.totalSessions, 1);
assert.deepEqual(v.skills, ["max:one"]);
assert.deepEqual(v.runs, ["x1"]);
assert.deepEqual(v.counts, { failed: 1 });
assert.deepEqual(v.completed, []);
});
it("refuses a member another member's session filter", async () => {
const r = await get("/api/workflows/runs?session_id=x1", cookies.mia);
assert.equal(r.status, 404);
});
it("shows an admin and an analyst exactly what local mode shows", async () => {
for (const who of ["boss", "ana"]) {
const v = await view(cookies[who]);
assert.deepEqual({ ...v, wf: null }, { ...localView, wf: null });
assert.deepEqual(v.wf.stats, localView.wf.stats);
assert.deepEqual(v.wf.modelDelegation, localView.wf.modelDelegation);
}
});
});
@@ -0,0 +1,245 @@
/**
* @file server-mode-workspaces.test.js
* @description Server mode's per-user row ownership on the workspace routes
* (phase S2): the workspace list and its counts, pipeline stats, housekeeping
* (`/lanes/gc`), projects, the live and past run lists, folder suggestions,
* the `@` file walk, starting and killing a run, and a resume of another
* user's session. A member gets only their own rows, another member theirs,
* an admin and an analyst everything, and local mode is unchanged.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const path = require("path");
const fs = require("fs");
const os = require("os");
const TEST_DB = path.join(os.tmpdir(), `server-mode-ws-${Date.now()}-${process.pid}.db`);
process.env.DASHBOARD_DB_PATH = TEST_DB;
process.env.DASHBOARD_LIVENESS_PROBE = "0";
// A private tmux server: nothing here may list or kill the user's real panes.
const TMUX_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "server-mode-ws-tmux-"));
process.env.TMUX_TMPDIR = TMUX_DIR;
delete process.env.TMUX;
const { createApp, startServer } = require("../index");
const { db } = require("../db");
const auth = require("../lib/auth");
let server;
let BASE;
const cookies = {};
const ROOT = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "server-mode-ws-")));
const DIR_A = path.join(ROOT, "alice");
const DIR_B = path.join(ROOT, "bob");
const LANE_A = 9001;
const LANE_B = 9002;
async function call(method, p, { as, body } = {}) {
const res = await fetch(BASE + p, {
method,
headers: {
"Content-Type": "application/json",
...(as ? { cookie: cookies[as] } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
return { status: res.status, body: await res.json().catch(() => null) };
}
const get = async (p, as) => {
const r = await call("GET", p, { as });
assert.equal(r.status, 200, `${p}: ${JSON.stringify(r.body)}`);
return r.body;
};
const laneIds = async (as) => (await get("/api/lanes", as)).lanes.map((l) => l.id).sort();
before(async () => {
for (const d of [DIR_A, DIR_B]) {
fs.mkdirSync(d);
fs.writeFileSync(path.join(d, `${path.basename(d)}.txt`), "x");
}
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
const alice = await auth.createUser("alice", "alice-pass-1", "member");
const bob = await auth.createUser("bob", "bob-pass-1", "member");
await auth.createUser("boss", "admin-pass-1", "admin");
await auth.createUser("ana", "analyst-pass-1", "analyst");
const lane = db.prepare(
"INSERT INTO lanes (id, cwd, tool, owner_id, source_repo) VALUES (?, ?, 'claude', ?, ?)"
);
lane.run(LANE_A, DIR_A, alice.id, DIR_A);
lane.run(LANE_B, DIR_B, bob.id, DIR_B);
const session = db.prepare(
"INSERT INTO sessions (id, name, status, cwd, lane_id) VALUES (?, 'x', 'completed', ?, ?)"
);
session.run("sA-0000001", DIR_A, LANE_A);
session.run("sB-0000001", DIR_B, LANE_B);
const stage = db.prepare(
"INSERT INTO lane_stage_events (lane_id, pipeline, node_id) VALUES (?, 'default', 'x')"
);
stage.run(LANE_A);
stage.run(LANE_B);
const run = db.prepare(
"INSERT INTO dashboard_runs (id, mode, cwd, status, lane_id, started_at) VALUES (?, 'interactive', ?, 'exited', ?, ?)"
);
run.run(`agenthub-lane-${LANE_A}@1`, DIR_A, LANE_A, "2026-01-01T00:00:00Z");
run.run(`agenthub-lane-${LANE_B}@1`, DIR_B, LANE_B, "2026-01-02T00:00:00Z");
for (const [name, pass] of [
["alice", "alice-pass-1"],
["bob", "bob-pass-1"],
["boss", "admin-pass-1"],
["ana", "analyst-pass-1"],
]) {
process.env.AGENTHUB_MODE = "server";
const res = await fetch(BASE + "/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: name, password: pass }),
});
assert.equal(res.status, 200);
cookies[name] = res.headers.get("set-cookie").split(";")[0];
}
delete process.env.AGENTHUB_MODE;
});
after(() => {
delete process.env.AGENTHUB_MODE;
server?.close();
for (const f of [TEST_DB, `${TEST_DB}-wal`, `${TEST_DB}-shm`, `${TEST_DB}-migrate.lock`]) {
fs.rmSync(f, { force: true });
}
fs.rmSync(ROOT, { recursive: true, force: true });
fs.rmSync(TMUX_DIR, { recursive: true, force: true });
});
describe("local mode", () => {
it("lists every workspace, run and folder as before", async () => {
assert.deepEqual(await laneIds(), [LANE_A, LANE_B]);
assert.equal((await get("/api/lanes/pipeline-stats")).lanes.length, 2);
const hist = (await get("/api/run/history")).items.map((r) => r.lane_id).sort();
assert.deepEqual(hist, [LANE_A, LANE_B]);
const kinds = (await get("/api/run/cwds")).items.map((i) => i.kind);
assert.ok(kinds.includes("dashboard") && kinds.includes("home"));
assert.equal((await get(`/api/run/files?cwd=${encodeURIComponent(DIR_B)}`)).items.length, 1);
});
});
describe("server mode", () => {
before(() => {
process.env.AGENTHUB_MODE = "server";
});
after(() => {
delete process.env.AGENTHUB_MODE;
});
it("GET /api/lanes lists and counts only a member's own workspaces", async () => {
assert.deepEqual(await laneIds("alice"), [LANE_A]);
assert.deepEqual(await laneIds("bob"), [LANE_B]);
assert.equal((await get("/api/lanes", "alice")).counts.total, 1);
assert.deepEqual(await laneIds("boss"), [LANE_A, LANE_B]);
assert.deepEqual(await laneIds("ana"), [LANE_A, LANE_B]);
assert.deepEqual(
(await get("/api/workspaces", "bob")).lanes.map((l) => l.id),
[LANE_B]
);
});
it("pipeline stats cover only a member's own workspaces", async () => {
const mine = await get("/api/lanes/pipeline-stats", "alice");
assert.deepEqual(
mine.lanes.map((l) => l.id),
[LANE_A]
);
assert.equal(mine.pipelines[0].runs, 1);
const all = await get("/api/lanes/pipeline-stats", "ana");
assert.equal(all.lanes.length, 2);
assert.equal(all.pipelines[0].runs, 2);
assert.equal(
(await call("GET", `/api/lanes/pipeline-stats?lane=${LANE_B}`, { as: "alice" })).status,
404
);
});
it("housekeeping is refused to a member", async () => {
const r = await call("POST", "/api/lanes/gc", { as: "alice", body: { dryRun: true } });
assert.equal(r.status, 403);
assert.equal(r.body.error.code, "EADMIN");
assert.equal(
(await call("POST", "/api/lanes/gc", { as: "boss", body: { dryRun: true } })).status,
200
);
});
it("projects are built from a member's own workspaces", async () => {
const ws = (b) => b.projects.flatMap((p) => p.workspaces).sort();
assert.deepEqual(ws(await get("/api/projects", "alice")), [LANE_A]);
assert.deepEqual(ws(await get("/api/projects", "bob")), [LANE_B]);
assert.deepEqual(ws(await get("/api/projects", "ana")), [LANE_A, LANE_B]);
const r = await call("POST", "/api/projects/refresh", { as: "alice" });
assert.deepEqual(ws(r.body), [LANE_A]);
});
it("run lists and history show only a member's own runs", async () => {
const hist = async (as, q = "") =>
(await get(`/api/run/history${q}`, as)).items.map((r) => r.lane_id).sort();
assert.deepEqual(await hist("alice"), [LANE_A]);
assert.deepEqual(await hist("bob"), [LANE_B]);
assert.deepEqual(await hist("boss"), [LANE_A, LANE_B]);
assert.deepEqual(await hist("alice", "?limit=1"), [LANE_A]);
assert.equal(
(await call("GET", `/api/run/history?laneId=${LANE_B}`, { as: "alice" })).status,
404
);
const live = await get("/api/run", "alice");
assert.ok(live.items.every((r) => r.laneId === LANE_A));
});
it("folder suggestions come from a member's own workspaces and sessions", async () => {
const paths = async (as) => (await get("/api/run/cwds", as)).items.map((i) => i.path);
assert.deepEqual(await paths("alice"), [DIR_A]);
assert.deepEqual(await paths("bob"), [DIR_B]);
const all = await paths("ana");
assert.ok(all.includes(DIR_A) && all.includes(DIR_B) && all.includes(os.homedir()));
});
it("the file walk stays inside a member's own workspaces", async () => {
const files = (as, dir) => call("GET", `/api/run/files?cwd=${encodeURIComponent(dir)}`, { as });
assert.deepEqual((await files("alice", DIR_A)).body.items, ["alice.txt"]);
assert.equal((await files("alice", DIR_B)).status, 403);
assert.equal((await call("GET", "/api/run/files", { as: "alice" })).status, 403);
// A symlink inside her folder does not reach bob's.
fs.symlinkSync(DIR_B, path.join(DIR_A, "link"));
assert.equal((await files("alice", path.join(DIR_A, "link"))).status, 403);
assert.deepEqual((await files("boss", DIR_B)).body.items, ["bob.txt"]);
});
it("a member's run starts only in their own workspace and folder", async () => {
const start = (body) => call("POST", "/api/run", { as: "alice", body });
assert.equal((await start({})).status, 400);
assert.equal((await start({ laneId: LANE_B })).status, 404);
const r = await start({ laneId: LANE_A, cwd: DIR_B });
assert.equal(r.status, 403);
assert.equal(r.body.error.code, "EFOLDER");
});
it("another user's run cannot be read or killed, encoded or not", async () => {
const id = `agenthub-lane-${LANE_B}`;
assert.equal((await call("DELETE", `/api/run/${id}`, { as: "alice" })).status, 404);
const encoded = `%61genthub-lane-${LANE_B}`;
assert.equal((await call("DELETE", `/api/run/${encoded}`, { as: "alice" })).status, 404);
assert.equal((await call("GET", `/api/run/${encoded}`, { as: "alice" })).status, 404);
});
it("a member cannot resume another user's session in their own workspace", async () => {
const r = await call("POST", `/api/lanes/${LANE_A}/start`, {
as: "alice",
body: { resumeSessionId: "sB-0000001" },
});
assert.equal(r.status, 404);
});
});
+66
View File
@@ -715,6 +715,13 @@ try {
db.prepare("ALTER TABLE dashboard_runs ADD COLUMN proxy TEXT").run();
}
}
// Which Claude pool account a run's pane was started on (server/lib/claude-pool.js),
// by name; NULL = the machine's own login. The balancer sums spend per account.
try {
db.prepare("SELECT account FROM dashboard_runs LIMIT 1").get();
} catch {
db.prepare("ALTER TABLE dashboard_runs ADD COLUMN account TEXT").run();
}
// Migrate: add stage-detection columns to lanes. Inference is never evidence —
// these are additive columns separate from `stage` (the declared stage), so
@@ -1649,6 +1656,65 @@ db.prepare(
`
).run();
// Server mode: SHA-256 of the pane credential (`AGENTHUB_PANE_TOKEN`) a run's
// pane was started with — the CLI, MCP server and statusline inside the pane
// send it to act as the workspace's owner (server/lib/auth.js). Never listed.
try {
db.prepare("SELECT pane_token_hash FROM dashboard_runs LIMIT 1").get();
} catch {
db.prepare("ALTER TABLE dashboard_runs ADD COLUMN pane_token_hash TEXT").run();
}
// Server mode: whose browser a push subscription is (`users.id`); NULL in
// local mode. A user's notifications go only to their own subscriptions.
try {
db.prepare("SELECT user_id FROM push_subscriptions LIMIT 1").get();
} catch {
db.prepare("ALTER TABLE push_subscriptions ADD COLUMN user_id INTEGER").run();
}
// Server mode: the user who created a workspace (`users.id`); NULL = made in
// local mode or by the host account, which only an admin/analyst sees. Every
// session, agent, event and token row a member may see hangs off this
// (server/lib/scope.js).
try {
db.prepare("SELECT owner_id FROM lanes LIMIT 1").get();
} catch {
db.prepare("ALTER TABLE lanes ADD COLUMN owner_id INTEGER").run();
}
// Server mode's accounts (server/lib/auth.js). Created on every install so
// switching the mode on needs no migration; empty and unread in local mode.
// A password is stored only as an scrypt hash, a login session only as the
// SHA-256 of its cookie token.
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
role TEXT NOT NULL DEFAULT 'member' CHECK(role IN ('admin','analyst','member')),
password_hash TEXT NOT NULL,
disabled INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
-- Every read by an admin/analyst of a session a member owns (server/lib/audit.js).
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
owner_id INTEGER,
session_id TEXT,
method TEXT NOT NULL,
path TEXT NOT NULL,
at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE INDEX IF NOT EXISTS idx_audit_log_at ON audit_log(at);
CREATE TABLE IF NOT EXISTS auth_sessions (
token_hash TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
expires_at TEXT NOT NULL
);
`);
migrationLock.exec("COMMIT");
migrationLock.close();
+20 -1
View File
@@ -100,6 +100,12 @@ const laneSourcesRouter = require("./routes/lane-sources");
const locksRouter = require("./routes/locks");
const petsRouter = require("./routes/pets");
const snapshotsRouter = require("./routes/snapshots");
const authRouter = require("./routes/auth");
const accountsRouter = require("./routes/accounts");
const usersRouter = require("./routes/users");
const { auditGuard } = require("./lib/audit");
const { authGuard, isServerMode, hasAdmin } = require("./lib/auth");
const { scopeGuard } = require("./lib/scope");
const APP_VERSION = (() => {
try {
@@ -119,6 +125,14 @@ function createApp() {
app.use(hostGuard);
app.use(express.json({ limit: "1mb" }));
app.use("/api", tokenGuard);
// Server mode's login gate (server/lib/auth.js); a no-op in local mode.
app.use("/api", authGuard);
// Server mode, phase S2: a member reaches only rows of their own workspaces.
app.use("/api", scopeGuard);
app.use("/api", auditGuard);
app.use("/api/auth", authRouter);
app.use("/api/accounts", accountsRouter);
app.use("/api/users", usersRouter);
app.use("/api/sessions", sessionsRouter);
app.use("/api/agents", agentsRouter);
@@ -293,7 +307,12 @@ function startServer(app, port) {
const mode = isProduction ? "production" : "development";
const shown = boundLoopback ? "localhost" : host;
console.log(`Agent Dashboard server running on http://${shown}:${port} (${mode})`);
if (!boundLoopback) {
if (isServerMode()) {
console.log("Server mode: every page and API call needs a login.");
if (!hasAdmin()) {
console.warn("⚠️ Server mode has no admin yet — run: agenthub server setup");
}
} else if (!boundLoopback) {
console.warn(
`⚠️ Dashboard bound to ${host} — reachable from the network. ` +
(getDashboardToken()
+60
View File
@@ -0,0 +1,60 @@
/**
* @file audit.js
* @description Server mode's audit trail (docs/SERVER-MODE.md): every read by
* an admin or analyst of a session that a MEMBER owns — the session page, its
* transcript, its events, its cost — is written to `audit_log` with who read
* it, whose it was and which path. `auditGuard` runs on `/api` after the
* scope guard; it never blocks a request, and it is a no-op in local mode and
* for a member (who can only read their own).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const sql = (q) => require("../db").db.prepare(q);
// Paths that name one session's content.
const SESSION_PATHS = [
/^\/sessions\/(?!facets\/?$)([^/]+)/i,
/^\/pricing\/cost\/([^/]+)/i,
/^\/workflows\/session\/([^/]+)/i,
];
function auditGuard(req, res, next) {
const u = req.user;
if (!u || u.role === "member" || (req.method !== "GET" && req.method !== "HEAD")) return next();
const p = require("./auth").apiPath(req);
for (const re of SESSION_PATHS) {
const m = re.exec(p);
if (!m) continue;
try {
const { sessionOwners } = require("./scope");
const id = m[1];
for (const owner of sessionOwners(id)) {
if (owner === u.id) continue;
const ownerRole = sql("SELECT role FROM users WHERE id = ?").get(owner)?.role;
if (ownerRole !== "member") continue;
sql(
"INSERT INTO audit_log (user_id, owner_id, session_id, method, path) VALUES (?, ?, ?, ?, ?)"
).run(u.id, owner, id, req.method, req.originalUrl.slice(0, 500));
}
} catch (err) {
console.warn("[audit] could not record:", err.message);
}
break;
}
return next();
}
/** The newest entries, with user names. */
function listAudit(limit = 100) {
const n = Math.min(Math.max(Number(limit) || 100, 1), 1000);
return sql(
`SELECT a.id, a.at, a.method, a.path, a.session_id,
r.username AS reader, o.username AS owner
FROM audit_log a
LEFT JOIN users r ON r.id = a.user_id
LEFT JOIN users o ON o.id = a.owner_id
ORDER BY a.id DESC LIMIT ?`
).all(n);
}
module.exports = { auditGuard, listAudit };
+456
View File
@@ -0,0 +1,456 @@
/**
* @file auth.js
* @description Server mode's accounts and login (docs/SERVER-MODE.md). Off
* unless `AGENTHUB_MODE=server`: in local mode every function here answers
* "allowed" and nothing reads the `users`/`auth_sessions` tables.
*
* - A password is stored as `scrypt$<salt>$<hash>` (node:crypto, async so a
* login never blocks the event loop) and compared in constant time.
* - A login is a random 32-byte token in the httpOnly, SameSite=Strict cookie
* `agenthub_sid`; only its SHA-256 is stored, for `SESSION_DAYS` days. A
* disabled user's sessions stop working at once (the lookup joins `users`).
* - `authGuard` (mounted on `/api`) lets through `/health` and `/auth/*`, the
* hook routes only from this machine (`isLocalRequest`; `ingest-batch` keeps
* its own bearer token), and everything else only with a valid login. An
* `analyst` may only read; writes under `ADMIN_WRITE_PREFIXES` — the host
* account's own configuration — are an `admin`'s.
*
* This is login, not isolation: every member still sees every workspace and
* session, and every pane runs as the one host account (phases S2/S3 of the
* multi-user plan).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const crypto = require("node:crypto");
const { promisify } = require("node:util");
// Required on first use: a module that only loads this (websocket.js) must not
// open the database in local mode, where nothing here touches it.
const sql = (q) => require("../db").db.prepare(q);
const { isLocalRequest, isLoopback, isOwnHost } = require("./same-origin");
const { isHostAllowed } = require("./security");
const scrypt = promisify(crypto.scrypt);
const COOKIE = "agenthub_sid";
const SESSION_DAYS = 30;
const ROLES = ["admin", "analyst", "member"];
const USERNAME_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
const MIN_PASSWORD = 8;
/** API prefixes whose writes change the host account's own setup. */
const ADMIN_WRITE_PREFIXES = [
"/settings",
"/updates",
"/cc-config",
"/codex-config",
"/ai-proxies",
"/ai-routers",
"/git-credentials",
"/ai-tools",
"/import",
"/pricing",
"/budgets",
"/webhooks",
"/remote-sources",
"/reports",
"/pets",
];
/**
* API prefixes a member may not reach at all, reads included: the host's own
* configuration and logs, and every view across all users (export, metrics,
* reports, webhook deliveries, remote machines, imports). Admin and analyst
* pass. `MEMBER_READ_ALLOW` re-opens the few reads a member's own work needs.
*/
const STAFF_ONLY_PREFIXES = [
"/cc-config",
"/codex-config",
"/git-credentials",
"/remote-sources",
"/webhooks",
"/reports",
"/metrics",
"/import",
"/settings/export",
"/alerts/rules",
"/users",
"/accounts",
];
// The run form's `/` autocomplete lists commands and skills.
const MEMBER_READ_ALLOW = ["/cc-config/commands", "/cc-config/skills"];
const isServerMode = () => process.env.AGENTHUB_MODE === "server";
const sha256 = (s) => crypto.createHash("sha256").update(s).digest("hex");
const err = (code, message) => Object.assign(new Error(message), { code });
async function hashPassword(password) {
const salt = crypto.randomBytes(16);
const hash = await scrypt(password, salt, 64);
return `scrypt$${salt.toString("base64")}$${hash.toString("base64")}`;
}
async function verifyPassword(password, stored) {
const [kind, salt, hash] = String(stored || "").split("$");
if (kind !== "scrypt" || !salt || !hash) return false;
const want = Buffer.from(hash, "base64");
const got = await scrypt(String(password), Buffer.from(salt, "base64"), want.length);
return crypto.timingSafeEqual(got, want);
}
function validateUsername(name) {
const n = String(name || "").trim();
if (!USERNAME_RE.test(n)) {
throw err("EBADUSER", "username: 1–64 letters, digits, '.', '_' or '-'");
}
return n;
}
function validatePassword(password) {
if (typeof password !== "string" || password.length < MIN_PASSWORD) {
throw err("EBADPASSWORD", `password: at least ${MIN_PASSWORD} characters`);
}
if (password.length > 1024) throw err("EBADPASSWORD", "password too long");
}
// ── Users (the CLI's `agenthub users …` and `agenthub server setup`) ────────
const publicUser = (u) =>
u && { id: u.id, username: u.username, role: u.role, disabled: Boolean(u.disabled) };
function listUsers() {
return sql("SELECT * FROM users ORDER BY id").all().map(publicUser);
}
function findUser(username) {
return sql("SELECT * FROM users WHERE username = ?").get(String(username || ""));
}
async function createUser(username, password, role = "member") {
const name = validateUsername(username);
validatePassword(password);
if (!ROLES.includes(role)) throw err("EBADROLE", `role: one of ${ROLES.join(", ")}`);
if (findUser(name)) throw err("EEXISTS", `user ${name} already exists`);
const hash = await hashPassword(password);
const { lastInsertRowid } = sql(
"INSERT INTO users (username, role, password_hash) VALUES (?, ?, ?)"
).run(name, role, hash);
return publicUser(sql("SELECT * FROM users WHERE id = ?").get(lastInsertRowid));
}
function requireUser(username) {
const u = findUser(username);
if (!u) throw err("ENOUSER", `no user ${username}`);
return u;
}
/** New password; every login of that user ends. */
async function setPassword(username, password) {
const u = requireUser(username);
validatePassword(password);
sql("UPDATE users SET password_hash = ? WHERE id = ?").run(await hashPassword(password), u.id);
sql("DELETE FROM auth_sessions WHERE user_id = ?").run(u.id);
}
function setRole(username, role) {
if (!ROLES.includes(role)) throw err("EBADROLE", `role: one of ${ROLES.join(", ")}`);
sql("UPDATE users SET role = ? WHERE id = ?").run(role, requireUser(username).id);
}
/** Disable (ending every login) or re-enable a user. Users are never deleted:
* S2 will hang owned rows off their id. */
function setDisabled(username, disabled) {
const u = requireUser(username);
sql("UPDATE users SET disabled = ? WHERE id = ?").run(disabled ? 1 : 0, u.id);
if (disabled) sql("DELETE FROM auth_sessions WHERE user_id = ?").run(u.id);
}
/** Give a workspace (a lane id) to a user — for the ones made before server
* mode, which have no owner and so reach no member. */
function assignWorkspace(laneId, username) {
const u = requireUser(username);
const r = sql("UPDATE lanes SET owner_id = ? WHERE id = ?").run(u.id, Number(laneId));
if (r.changes === 0) throw err("ENOLANE", `no workspace ${laneId}`);
}
const hasAdmin = () =>
Boolean(sql("SELECT 1 FROM users WHERE role = 'admin' AND disabled = 0").get());
// ── Login sessions ──────────────────────────────────────────────────────────
/** Start a login: the cookie token, returned once, never stored. */
function startSession(userId) {
const token = crypto.randomBytes(32).toString("base64url");
const expires = new Date(Date.now() + SESSION_DAYS * 864e5).toISOString();
sql("DELETE FROM auth_sessions WHERE expires_at < ?").run(new Date().toISOString());
sql("INSERT INTO auth_sessions (token_hash, user_id, expires_at) VALUES (?, ?, ?)").run(
sha256(token),
userId,
expires
);
return token;
}
function endSession(token) {
if (token) sql("DELETE FROM auth_sessions WHERE token_hash = ?").run(sha256(token));
}
function cookieToken(req) {
for (const part of String(req.headers.cookie || "").split(";")) {
const eq = part.indexOf("=");
if (eq > 0 && part.slice(0, eq).trim() === COOKIE) return part.slice(eq + 1).trim() || null;
}
return null;
}
/** The logged-in user behind this request's cookie, or null. */
function userFromRequest(req) {
const token = cookieToken(req);
if (!token || token.length > 128) return null;
const row = sql(
`SELECT u.* FROM auth_sessions s JOIN users u ON u.id = s.user_id
WHERE s.token_hash = ? AND s.expires_at > ? AND u.disabled = 0`
).get(sha256(token), new Date().toISOString());
return publicUser(row) || null;
}
function cookieHeader(req, token, maxAgeSec) {
const secure = req.secure || req.headers["x-forwarded-proto"] === "https";
return [
`${COOKIE}=${token}`,
"Path=/",
"HttpOnly",
"SameSite=Strict",
`Max-Age=${maxAgeSec}`,
...(secure ? ["Secure"] : []),
].join("; ");
}
const setLoginCookie = (req, res, token) =>
res.setHeader("Set-Cookie", cookieHeader(req, token, SESSION_DAYS * 86400));
const clearLoginCookie = (req, res) => res.setHeader("Set-Cookie", cookieHeader(req, "", 0));
// ── Failed-login throttle ───────────────────────────────────────────────────
// ponytail: in-memory per-IP counter, reset on restart and not shared between
// two dashboards; move it into SQLite if the server ever runs as several processes.
const FAIL_WINDOW_MS = 15 * 60 * 1000;
const FAIL_LIMIT = 10;
const failures = new Map();
/** The client's address: the socket's, or — only behind a proxy on this
* machine — the LAST X-Forwarded-For entry, the one that proxy appended (the
* earlier ones are whatever the client sent). */
function clientKey(req) {
const addr = req.socket?.remoteAddress || "?";
if (!["127.0.0.1", "::1", "::ffff:127.0.0.1"].includes(addr)) return addr;
const fwd = String(req.headers["x-forwarded-for"] || "").split(",");
return fwd[fwd.length - 1].trim() || addr;
}
// Per username too, higher: many addresses guessing one account's password.
const USER_FAIL_LIMIT = 30;
const fresh = (f) => f && Date.now() - f.since <= FAIL_WINDOW_MS;
function blocked(key, limit) {
const f = failures.get(key);
return fresh(f) && f.count >= limit;
}
/** Count an attempt as failed BEFORE it is checked, so parallel guesses all count. */
function noteAttempt(key) {
if (failures.size > 10000) {
for (const [k, v] of failures) if (!fresh(v)) failures.delete(k);
}
const f = failures.get(key);
if (!fresh(f)) failures.set(key, { since: Date.now(), count: 1 });
else f.count++;
}
// A well-formed hash no password matches (64 zero bytes), for unknown users.
const DUMMY_HASH = `scrypt$${"A".repeat(22)}==$${"A".repeat(86)}==`;
/** `{user}` for a right username + password, else throws EBADLOGIN/ETOOMANY. */
async function login(req, username, password) {
const name = String(username || "").trim();
const ipKey = clientKey(req);
const userKey = `user:${name.toLowerCase()}`;
if (blocked(ipKey, FAIL_LIMIT) || blocked(userKey, USER_FAIL_LIMIT)) {
throw err("ETOOMANY", "too many failed logins, try again later");
}
noteAttempt(ipKey);
noteAttempt(userKey);
const u = findUser(name);
// Hash even for an unknown user, so the answer time does not tell which.
const ok = await verifyPassword(String(password || ""), u?.password_hash || DUMMY_HASH);
if (!u || !ok || u.disabled) throw err("EBADLOGIN", "wrong username or password");
failures.delete(ipKey);
failures.delete(userKey);
return { user: publicUser(u), token: startSession(u.id) };
}
// ── Pane credentials ────────────────────────────────────────────────────────
const PANE_HEADER = "x-agenthub-pane-token";
/**
* Server mode: a fresh credential for a pane the dashboard starts — the token
* goes into the pane's env (`AGENTHUB_PANE_TOKEN`, through tmux `-e`), its hash
* onto the run row. Null in local mode, where nothing needs one.
*/
function newPaneToken() {
if (!isServerMode()) return null;
const token = `ahpane_${crypto.randomBytes(24).toString("base64url")}`;
return { token, hash: sha256(token) };
}
/**
* The workspace owner a request from inside a live pane acts as: its
* `x-agenthub-pane-token` names a run that is still running in tmux, on a
* workspace someone owns, and the request came straight from this machine.
*/
function userFromPaneToken(req) {
const t = req.headers[PANE_HEADER];
if (typeof t !== "string" || !t.startsWith("ahpane_") || t.length > 200) return null;
if (!isLocalRequest(req)) return null;
const row = sql(
`SELECT r.id, l.owner_id FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id
WHERE r.pane_token_hash = ? AND r.status IN ('running', 'spawning') AND instr(r.id, '@') = 0`
).get(sha256(t));
if (!row || row.owner_id == null) return null;
if (!require("./tmux").hasSession(row.id)) return null;
return (
publicUser(sql("SELECT * FROM users WHERE id = ? AND disabled = 0").get(row.owner_id)) || null
);
}
// ── Gates ───────────────────────────────────────────────────────────────────
/**
* The path under `/api` as Express ROUTES it, for every prefix and id check:
* each segment percent-decoded, repeated slashes collapsed. Compare prefixes
* case-insensitively (`underPrefix`) — Express mounts and routes match
* regardless of case, so `/api/Webhooks` reaches the webhooks router.
*/
function apiPath(req) {
return String(req.path || "")
.split("/")
.map((seg) => {
try {
return decodeURIComponent(seg);
} catch {
return seg;
}
})
.join("/")
.replace(/\/{2,}/g, "/");
}
const underPrefix = (p, x) => {
const lp = p.toLowerCase();
return lp === x || lp.startsWith(x + "/");
};
const deny = (res, status, code, message) => res.status(status).json({ error: { code, message } });
/** Express middleware for `/api`; a no-op in local mode. */
function authGuard(req, res, next) {
if (!isServerMode()) return next();
const p = apiPath(req);
const lp = p.toLowerCase();
if (lp === "/health" || lp.startsWith("/auth/")) return next();
if (lp === "/hooks/ingest-batch") return next(); // its own bearer token
if (underPrefix(p, "/hooks")) {
return isLocalRequest(req)
? next()
: deny(res, 403, "ELOCALONLY", "hooks are accepted from this machine only");
}
const user = userFromRequest(req) || userFromPaneToken(req);
if (!user) return deny(res, 401, "EAUTH", "log in first");
req.user = user;
const under = (x) => underPrefix(p, x);
const isRead = req.method === "GET" || req.method === "HEAD";
if (
user.role === "member" &&
STAFF_ONLY_PREFIXES.some(under) &&
!(isRead && MEMBER_READ_ALLOW.some(under))
) {
return deny(res, 403, "EADMIN", "only an admin or analyst may open this");
}
if (isRead) return next();
// Every write in server mode passes the same-origin check — the cookie is
// SameSite=Strict, which a sibling subdomain or another port on the same
// host still satisfies. A browser always sends Origin on a write; a request
// with neither Origin nor Referer is not a browser's.
const raw = req.headers.origin || req.headers.referer;
if (raw && !isLoopback(raw) && !isOwnHost(raw, req)) {
return deny(res, 403, "EBADORIGIN", "cross-origin requests are not allowed");
}
if (user.role === "analyst")
return deny(res, 403, "EREADONLY", "an analyst account is read-only");
if (user.role !== "admin" && ADMIN_WRITE_PREFIXES.some(under)) {
return deny(res, 403, "EADMIN", "only an admin may change this");
}
return next();
}
/**
* WebSocket upgrade check (both `/ws` and `/ws-pty/*`); true in local mode.
* A login, and — the terminal is behind this — an Origin, when sent, that is
* the request's own allowed Host.
*/
function isWebSocketLoggedIn(req) {
if (!isServerMode()) return true;
const origin = req.headers.origin;
if (origin) {
let host = "";
try {
host = new URL(origin).host.toLowerCase();
} catch {
return false;
}
if (host !== String(req.headers.host || "").toLowerCase() || !isHostAllowed(host)) return false;
}
const user = userFromRequest(req);
if (
user &&
user.role === "analyst" &&
/^\/ws-pty\//.test(new URL(req.url, "http://x").pathname)
) {
return false; // a terminal is a write
}
return Boolean(user);
}
module.exports = {
COOKIE,
ROLES,
ADMIN_WRITE_PREFIXES,
STAFF_ONLY_PREFIXES,
apiPath,
newPaneToken,
userFromPaneToken,
PANE_HEADER,
isServerMode,
hashPassword,
verifyPassword,
listUsers,
findUser,
createUser,
setPassword,
setRole,
setDisabled,
assignWorkspace,
hasAdmin,
userFromRequest,
login,
endSession,
cookieToken,
setLoginCookie,
clearLoginCookie,
authGuard,
isWebSocketLoggedIn,
publicUser,
};
+13 -6
View File
@@ -6,11 +6,13 @@
* spend in its current period — priced exactly like GET /api/pricing/cost, by
* the day the tokens were spent — the pace-based projection to the period's
* end, and the status list behind GET /api/budgets/status. Firing lives in
* `alerts.js`, once per threshold per period.
* `alerts.js`, once per threshold per period. Budgets are org-wide; a server
* mode member's status shows each one's spend over their own sessions only.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { stmts } = require("../db");
const { memberId, sessionWhere } = require("./scope");
const { calculateCost, dailyTokenRows, costWhere } = require("../routes/pricing");
const PERIODS = ["day", "week", "month"];
@@ -58,16 +60,21 @@ function periodBounds(period, now, tzOffset = 0, back = 0) {
}
/**
* Spend of a budget config in one period (current by default).
* Spend of a budget config in one period (current by default); with a member's
* `req`, over that member's sessions only.
* @returns {{period_start: string, period_end: string, spend: number,
* projected: number, pct: number}}
*/
function budgetSpend(cfg, { now = Date.now(), back = 0, rules } = {}) {
function budgetSpend(cfg, { now = Date.now(), back = 0, rules, req } = {}) {
const tz = Number.isFinite(cfg.tz_offset) ? cfg.tz_offset : 0;
const b = periodBounds(cfg.period, now, tz, back);
// A session that spent in the period was touched in it, so older ones are
// skipped before their hours are read.
const extra = [{ clause: "s.updated_at >= ?", params: [b.startUtc] }];
if (memberId(req) != null) {
const own = sessionWhere(req, "s.id", "s.lane_id");
extra.push({ clause: own.where, params: own.params });
}
const scope = cfg.tool ? { where: "tool = ?", params: [cfg.tool] } : null;
const where = costWhere({ scope, lane: cfg.lane, extra });
const rows = dailyTokenRows(`${-tz} minutes`, where.whereClause, where.params).filter(
@@ -93,10 +100,10 @@ function crossed(cfg, pct) {
}
/**
* Every enabled budget rule with its current-period spend.
* Every enabled budget rule with its current-period spend (`req`: see budgetSpend).
* @returns {Array<object>} rule id/name/config + `budgetSpend` + `crossed`.
*/
function budgetStatus({ now = Date.now() } = {}) {
function budgetStatus({ now = Date.now(), req } = {}) {
const rules = stmts.listPricing.all();
return stmts.listEnabledAlertRules
.all()
@@ -109,7 +116,7 @@ function budgetStatus({ now = Date.now() } = {}) {
return null;
}
if (!PERIODS.includes(cfg.period) || !(cfg.limit_usd > 0)) return null;
const s = budgetSpend(cfg, { now, rules });
const s = budgetSpend(cfg, { now, rules, req });
return { id: r.id, name: r.name, config: cfg, ...s, crossed: crossed(cfg, s.pct) };
})
.filter(Boolean);
+1
View File
@@ -243,6 +243,7 @@ module.exports = {
profileUsage,
fetchUsage,
fetchOwner,
liveOwner,
refreshProfile,
CLIENT_ID,
USAGE_URL,
+262
View File
@@ -0,0 +1,262 @@
/**
* @file claude-pool.js
* @description The Claude account POOL: subscription accounts an admin added
* (`agenthub accounts add`, a `claude setup-token` token each), and the
* balancer that gives every new Claude pane one of them through
* `CLAUDE_CODE_OAUTH_TOKEN` (pty-run.js) — so sessions spread over the
* accounts without touching the machine's own login. An empty pool changes
* nothing: panes run on the machine's login as before.
*
* Tokens live in `<CLAUDE_HOME>/agenthub-accounts/<name>.json` (dir 0700, file
* 0600); no route returns one. A setup-token is valid for one year from when it
* was added; past that the account is skipped.
*
* `pickAccount` ranks the accounts by what AgentHub itself recorded:
* 1. an account whose last quota reading is ≥ `BLOCK_PERCENT` in either
* window goes last (still used when every account is that full);
* 2. fewest live panes (spreads concurrent sessions);
* 3. least $ spent in the last 5 hours, then in the last 7 days — summed from
* `token_usage_hourly` for the sessions its runs (`dashboard_runs.account`)
* started.
* The quota is read with the account's own token in the background, at most
* every `QUOTA_TTL_MS`; a token the usage endpoint refuses simply has no
* reading, and ranking falls back to spend.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const path = require("node:path");
const { getClaudeHome } = require("./claude-home");
const NAME_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
const TOKEN_RE = /^sk-ant-[A-Za-z0-9_-]{20,500}$/;
const TOKEN_LIFE_MS = 365 * 864e5;
const QUOTA_TTL_MS = 5 * 60 * 1000;
const QUOTA_FRESH_MS = 30 * 60 * 1000;
const BLOCK_PERCENT = 90;
const err = (code, message) => Object.assign(new Error(message), { code });
const poolDir = () => path.join(getClaudeHome(), "agenthub-accounts");
const fileOf = (name) => path.join(poolDir(), `${name}.json`);
function validName(name) {
const n = String(name || "").trim();
if (!NAME_RE.test(n))
throw err("EBADNAME", "account name: 1–64 letters, digits, '.', '_' or '-'");
return n;
}
function readAccount(name) {
try {
const a = JSON.parse(fs.readFileSync(fileOf(name), "utf8"));
return typeof a.token === "string" ? a : null;
} catch {
return null;
}
}
function allAccounts() {
let names = [];
try {
names = fs.readdirSync(poolDir()).filter((f) => f.endsWith(".json"));
} catch {
return [];
}
return names
.map((f) => f.slice(0, -5))
.filter((n) => NAME_RE.test(n))
.map(readAccount)
.filter(Boolean)
.sort((a, b) => a.name.localeCompare(b.name));
}
const expiresAt = (a) => new Date(Date.parse(a.addedAt) + TOKEN_LIFE_MS).toISOString();
const expired = (a) => Date.parse(expiresAt(a)) <= Date.now();
/** Add an account; refuses a name already in the pool. */
function addAccount(name, token) {
const n = validName(name);
const t = String(token || "").trim();
if (!TOKEN_RE.test(t)) {
throw err(
"EBADTOKEN",
"not a Claude token — paste what `claude setup-token` printed (sk-ant-…)"
);
}
if (readAccount(n)) throw err("EEXISTS", `account ${n} is already in the pool`);
fs.mkdirSync(poolDir(), { recursive: true, mode: 0o700 });
fs.chmodSync(poolDir(), 0o700);
const record = { name: n, token: t, addedAt: new Date().toISOString() };
fs.writeFileSync(fileOf(n), JSON.stringify(record, null, 2), { mode: 0o600, flag: "wx" });
quotaCache.delete(n);
return { name: n, addedAt: record.addedAt, expiresAt: expiresAt(record) };
}
function removeAccount(name) {
const n = validName(name);
if (!readAccount(n)) throw err("ENOACCOUNT", `no account ${n} in the pool`);
fs.rmSync(fileOf(n));
quotaCache.delete(n);
}
// ── Quota, read in the background ──────────────────────────────────────────
/** name → { at, fiveHour?, sevenDay?, error? } */
const quotaCache = new Map();
const inFlight = new Set();
/** Read every account's quota whose reading is older than QUOTA_TTL_MS. */
async function refreshQuotas({ force = false } = {}) {
const { fetchUsage } = require("./cc-usage");
await Promise.all(
allAccounts().map(async (a) => {
const cached = quotaCache.get(a.name);
if (inFlight.has(a.name) || (!force && cached && Date.now() - cached.at < QUOTA_TTL_MS)) {
return;
}
inFlight.add(a.name);
try {
const u = await fetchUsage(a.token);
quotaCache.set(a.name, { at: Date.now(), ...u });
} catch (e) {
// Keep the last good windows: a hiccup must not unblock a full account.
quotaCache.set(a.name, { ...cached, at: Date.now(), error: e.code || "EUPSTREAM" });
} finally {
inFlight.delete(a.name);
}
})
);
}
/** The highest known utilization (%) of a fresh reading, else null. */
function quotaPercent(name) {
const q = quotaCache.get(name);
if (!q || Date.now() - q.at > QUOTA_FRESH_MS) return null;
const now = Date.now();
// A window whose reset time has passed is empty again.
const used = [q.fiveHour, q.sevenDay]
.filter((w) => w && w.utilization != null)
.map((w) => (w.resetsAt && Date.parse(w.resetsAt) <= now ? 0 : w.utilization));
return used.length ? Math.max(...used) : null;
}
// ── What AgentHub recorded per account ─────────────────────────────────────
const USAGE_COLS = [
"input_tokens",
"output_tokens",
"cache_read_tokens",
"cache_write_tokens",
"cache_write_1h_tokens",
"web_search_requests",
"web_fetch_requests",
"code_execution_requests",
];
/**
* $ spent on `account` since `sinceHour` (`YYYY-MM-DDTHH`, UTC): the hourly
* usage of every session one of its runs started, from that run's start hour.
* ponytail: a session later resumed on ANOTHER account keeps counting here
* too; split by run end time if that ever skews a ranking.
*/
function spendSince(account, sinceHour) {
const { db, stmts } = require("../db");
const { calculateCost } = require("../routes/pricing");
const rows = db
.prepare(
`WITH s AS (
SELECT session_id, MIN(substr(started_at, 1, 13)) AS since
FROM dashboard_runs
WHERE account = ? AND session_id IS NOT NULL
GROUP BY session_id)
SELECT h.model, h.speed, h.inference_geo, h.service_tier,
${USAGE_COLS.map((c) => `SUM(h.${c}) AS ${c}`).join(", ")}
FROM s JOIN token_usage_hourly h
ON h.session_id = s.session_id AND h.hour >= s.since AND h.hour >= ?
GROUP BY h.model, h.speed, h.inference_geo, h.service_tier`
)
.all(account, sinceHour);
return rows.length ? calculateCost(rows, stmts.listPricing.all()).total_cost : 0;
}
function livePanes(account) {
const { db } = require("../db");
return db
.prepare(
"SELECT COUNT(*) AS n FROM dashboard_runs WHERE account = ? AND status IN ('running', 'spawning')"
)
.get(account).n;
}
const hourAgo = (hours) => new Date(Date.now() - hours * 3600e3).toISOString().slice(0, 13);
/** Every account with what the balancer sees — never a token. */
function poolStatus() {
return allAccounts().map((a) => {
const q = quotaCache.get(a.name);
const percent = quotaPercent(a.name);
return {
name: a.name,
addedAt: a.addedAt,
expiresAt: expiresAt(a),
expired: expired(a),
livePanes: livePanes(a.name),
spend5h: spendSince(a.name, hourAgo(5)),
spend7d: spendSince(a.name, hourAgo(24 * 7)),
quota: q
? {
fiveHour: q.fiveHour || null,
sevenDay: q.sevenDay || null,
checkedAt: new Date(q.at).toISOString(),
error: q.error || null,
}
: null,
quotaPercent: percent,
full: percent != null && percent >= BLOCK_PERCENT,
};
});
}
/**
* The account a new Claude pane should run on — `{name, token}` — or null when
* the pool has no usable account. Synchronous (a spawn cannot wait on the
* network): it ranks on the cached quota and starts a background re-read.
*/
function pickAccount() {
const usable = allAccounts().filter((a) => !expired(a));
if (usable.length === 0) return null;
refreshQuotas().catch(() => {});
const ranked = usable
.map((a) => {
const percent = quotaPercent(a.name);
return {
a,
key: [
percent != null && percent >= BLOCK_PERCENT ? 1 : 0,
livePanes(a.name),
spendSince(a.name, hourAgo(5)),
spendSince(a.name, hourAgo(24 * 7)),
],
};
})
.sort((x, y) => {
for (let i = 0; i < x.key.length; i++) {
if (x.key[i] !== y.key[i]) return x.key[i] - y.key[i];
}
return x.a.name.localeCompare(y.a.name);
});
const { name, token } = ranked[0].a;
return { name, token };
}
module.exports = {
BLOCK_PERCENT,
addAccount,
removeAccount,
poolStatus,
pickAccount,
refreshQuotas,
poolDir,
__quotaCache: quotaCache,
};
+5 -3
View File
@@ -32,11 +32,11 @@ const insertStmt = db.prepare(`
INSERT OR REPLACE INTO dashboard_runs (
id, session_id, mode, cwd, model, permission_mode, effort,
resume_session_id, prompt_preview, status, exit_code, started_at, ended_at,
lane_id, tool, worktree, proxy
lane_id, tool, worktree, proxy, account, pane_token_hash
) VALUES (
@id, @session_id, @mode, @cwd, @model, @permission_mode, @effort,
@resume_session_id, @prompt_preview, @status, @exit_code, @started_at, @ended_at,
@lane_id, @tool, @worktree, @proxy
@lane_id, @tool, @worktree, @proxy, @account, @pane_token_hash
)
`);
@@ -51,7 +51,7 @@ const updateStmt = db.prepare(`
const RUN_COLUMNS = `id, session_id, mode, cwd, model, permission_mode, effort,
resume_session_id, prompt_preview, status, exit_code,
started_at, ended_at, lane_id, tool, worktree, proxy`;
started_at, ended_at, lane_id, tool, worktree, proxy, account`;
const listStmt = db.prepare(`
SELECT ${RUN_COLUMNS}
@@ -124,6 +124,8 @@ function recordRun(handle) {
tool: typeof handle.tool === "string" && handle.tool ? handle.tool : "claude",
worktree: handle.worktree ? 1 : 0,
proxy: handle.proxy || null,
account: handle.account || null,
pane_token_hash: handle.paneTokenHash || null,
});
} catch (err) {
// Best-effort by design (a live run must never fail because sqlite did),
+12 -2
View File
@@ -58,7 +58,10 @@ function buildExportBundle(db, stmts) {
events: db.prepare("SELECT * FROM events ORDER BY created_at DESC").all(),
token_usage: db.prepare("SELECT * FROM token_usage").all(),
workflows: db.prepare("SELECT * FROM workflows").all(),
dashboard_runs: db.prepare("SELECT * FROM dashboard_runs ORDER BY started_at DESC").all(),
dashboard_runs: db
.prepare("SELECT * FROM dashboard_runs ORDER BY started_at DESC")
.all()
.map(exportableRun),
alert_rules: db.prepare("SELECT * FROM alert_rules ORDER BY created_at ASC").all(),
model_pricing: stmts.listPricing.all(),
};
@@ -89,6 +92,12 @@ function bindable(v) {
* whose value goes through a SQL function (events.data through
* `trim_event_data`, as every event insert must).
*/
/** A run row as it may leave this machine: never a pane credential's hash. */
function exportableRun(row) {
const { pane_token_hash: _secret, ...rest } = row;
return rest;
}
function makeInserter(db, table, { omit = [], wrap = {} } = {}) {
const cols = tableColumns(db, table).filter((c) => !omit.includes(c));
const quoted = cols.map((c) => `"${c}"`).join(", ");
@@ -180,7 +189,7 @@ function importExportBundle(db, bundle) {
events: makeInserter(db, "events", { omit: ["id"], wrap: { data: "trim_event_data" } }),
token_usage: makeInserter(db, "token_usage"),
workflows: makeInserter(db, "workflows"),
dashboard_runs: makeInserter(db, "dashboard_runs"),
dashboard_runs: makeInserter(db, "dashboard_runs", { omit: ["pane_token_hash"] }),
alert_rules: makeInserter(db, "alert_rules"),
model_pricing: makeInserter(db, "model_pricing"),
};
@@ -251,6 +260,7 @@ function importExportBundle(db, bundle) {
}
module.exports = {
exportableRun,
EXPORT_FORMAT,
EXPORT_VERSION,
SESSION_CHILD_TABLES,
+4 -2
View File
@@ -211,6 +211,7 @@ function createLane({
slug = null,
tool = "claude",
router_profile = null,
owner_id = null,
} = {}) {
if (!cwd || typeof cwd !== "string" || !path.isAbsolute(cwd)) {
throw Object.assign(new Error("cwd must be an absolute path"), { code: "EBADCWD" });
@@ -220,7 +221,7 @@ function createLane({
validateTool(tool);
const info = db
.prepare(
"INSERT INTO lanes (title, cwd, branch, pipeline, kind, source_repo, base_branch, slug, tool, router_profile, stage_since) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
"INSERT INTO lanes (title, cwd, branch, pipeline, kind, source_repo, base_branch, slug, tool, router_profile, stage_since, owner_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
)
.run(
normalizeTitle(title),
@@ -233,7 +234,8 @@ function createLane({
slug,
tool,
router_profile || null,
nowIso()
nowIso(),
Number.isInteger(owner_id) ? owner_id : null
);
return getLane(info.lastInsertRowid);
}
+35 -2
View File
@@ -37,12 +37,38 @@ function effectiveMaxHoldSec() {
return Math.max(configured, LOCK_MAX_HOLD_FLOOR_SEC);
}
// One directory name under the locks root: anything but a path — no '/', '\\'
// or NUL, and not '.' or '..' (names such as `db:migrate` keep working).
const NAME_RE = /^(?!\.{1,2}$)[^/\\\0]{1,255}$/;
// One word, as the owner file stores `<holder> <epochSeconds>` and reads it
// back with `\S+`. A holder with spaces (`--holder "ci bot"`) is kept usable by
// turning each run of whitespace into `_` — the old build "acquired" such a
// lock but could never read or release it, so it locked nothing.
const HOLDER_RE = /^\S{1,200}$/;
const normHolder = (holder) =>
typeof holder === "string" ? holder.trim().replace(/\s+/g, "_") : holder;
function checkName(name) {
if (typeof name !== "string" || !NAME_RE.test(name)) {
throw Object.assign(new Error("lock name: one name, not a path (no '/', '\\', '.' or '..')"), {
code: "EBADNAME",
});
}
}
function checkHolder(holder) {
if (typeof holder !== "string" || !HOLDER_RE.test(holder)) {
throw Object.assign(new Error("holder: one word"), { code: "EBADHOLDER" });
}
}
function ownerPath(name) {
return path.join(locksRoot(), name, "owner");
}
/** Parse an owner file's `"<holder> <epochSeconds>"` contents. */
function readOwner(name) {
checkName(name);
let text;
try {
text = fs.readFileSync(ownerPath(name), "utf8");
@@ -68,7 +94,10 @@ function readOwner(name) {
* @param {string} holder - Identity to record as the owner (e.g. `"lane3"`).
* @returns {{acquired: true} | {acquired: false, holder: string, since: number, ageSec: number}}
*/
function tryAcquire(name, holder) {
function tryAcquire(name, rawHolder) {
checkName(name);
const holder = normHolder(rawHolder);
checkHolder(holder);
const dir = path.join(locksRoot(), name);
fs.mkdirSync(locksRoot(), { recursive: true });
@@ -99,7 +128,10 @@ function tryAcquire(name, holder) {
* @param {string} name - Lock name.
* @param {string} holder - Must match the current owner.
*/
function release(name, holder) {
function release(name, rawHolder) {
checkName(name);
const holder = normHolder(rawHolder);
checkHolder(holder);
const owner = readOwner(name);
if (!owner) {
throw Object.assign(new Error(`no such lock: ${name}`), { code: "ENOLOCK" });
@@ -128,6 +160,7 @@ function listLocks() {
return [];
}
return entries
.filter((name) => NAME_RE.test(name))
.map((name) => ({ name, ...status(name) }))
.filter((lock) => lock.held)
.map(({ held, ...rest }) => rest);
+23 -4
View File
@@ -363,6 +363,12 @@ function spawnRun(args) {
throw makeErr("EUNSUPPORTED", `${tool} cannot start a session in its own worktree`);
}
const proxyProfile = resolveProxy(tool, proxy); // ENOTFOUND, EUNSUPPORTED
// A Claude session on the vendor's own API runs on the least-loaded pool
// account, if the admin added any (claude-pool.js); an AI Proxy brings its
// own key, and an empty pool leaves the machine's login in charge.
const poolAccount =
entry.launcher === "claude" && !proxyProfile ? require("./claude-pool").pickAccount() : null;
const paneCred = require("./auth").newPaneToken();
if (forkSession === true) {
if (!resumeSessionId) throw makeErr("EBADSESSION", "a fork needs resumeSessionId");
if (entry.launcher !== "claude") {
@@ -450,6 +456,8 @@ function spawnRun(args) {
resumeSessionId: resumeSessionId || null,
worktree: worktree === true || inWorktree === true,
proxy: proxyProfile ? proxyProfile.name : null,
account: poolAccount ? poolAccount.name : null,
paneTokenHash: paneCred ? paneCred.hash : null,
prompt: initialPrompt || "",
status: "running",
startedAt,
@@ -491,6 +499,12 @@ function spawnRun(args) {
// pushes lines into history its redraw cannot clear). The user's own
// `"tui"` setting decides; under fullscreen the pane scrolls in Claude.
...proxySpawn.env,
// The pool account's setup-token: outranks the machine's `/login` for
// this process only. Through tmux `-e`, never typed into the pane.
...(poolAccount ? { CLAUDE_CODE_OAUTH_TOKEN: poolAccount.token } : {}),
// Server mode: the CLI, MCP server and statusline in this pane act as the
// workspace's owner with it (server/lib/auth.js userFromPaneToken).
...(paneCred ? { AGENTHUB_PANE_TOKEN: paneCred.token } : {}),
...(bypass === true && entry.bypassEnv ? entry.bypassEnv : {}),
};
@@ -557,11 +571,13 @@ function spawnShell({ laneId, slot = 0, cwd }) {
}
if (!tmux.isTmuxAvailable()) throw makeErr("ETMUXMISSING", "tmux is not installed");
const id = runIdForLane(laneId, slot);
const paneCred = require("./auth").newPaneToken();
const env = {
AGENTHUB_LANE_ID: String(laneId),
AGENTHUB_RUN_ID: id,
...(process.env.PATH ? { PATH: process.env.PATH } : {}),
...desktopEnv(),
...(paneCred ? { AGENTHUB_PANE_TOKEN: paneCred.token } : {}),
};
// Started once the pane has its real size (WAIT_FOR_CLIENT, issue #55), as
// the login shell tmux would have run (tmux sets SHELL in the pane).
@@ -578,6 +594,7 @@ function spawnShell({ laneId, slot = 0, cwd }) {
resumeSessionId: null,
worktree: false,
proxy: null,
paneTokenHash: paneCred ? paneCred.hash : null,
prompt: "",
status: "running",
startedAt: Date.now(),
@@ -599,9 +616,8 @@ function killRun(id) {
return true;
}
function publicRun(id) {
function publicRun(id, live = tmux.hasSession(id)) {
const laneId = laneIdFromRunId(id);
const live = tmux.hasSession(id);
const row = dashboardRuns ? dashboardRuns.getRun(id) : null;
return {
id,
@@ -627,9 +643,12 @@ function getRun(id) {
return publicRun(id);
}
/** Computed fresh from tmux state every call — see file header. */
/**
* Computed fresh from tmux state every call — see file header. A listed
* session is live by definition, so no has-session per row.
*/
function listRuns() {
return tmux.listSessions("agenthub-lane-").map(publicRun);
return tmux.listSessions("agenthub-lane-").map((id) => publicRun(id, true));
}
module.exports = {
+8 -3
View File
@@ -87,10 +87,15 @@ function showNativeNotificationIfElectron(title, body) {
* service worker's `notificationclick` handler navigates to. Undefined for
* notifications with no specific target.
*/
async function sendPushToAll(db, title, body, url) {
const native = showNativeNotificationIfElectron(title, body);
async function sendPushToAll(db, title, body, url, userId = null) {
// Server mode (`userId` set): only that user's own browsers, and never the
// host's desktop — the text is about their sessions.
const native = userId == null ? showNativeNotificationIfElectron(title, body) : false;
const subscriptions = db.prepare("SELECT * FROM push_subscriptions").all();
const subscriptions =
userId == null
? db.prepare("SELECT * FROM push_subscriptions").all()
: db.prepare("SELECT * FROM push_subscriptions WHERE user_id = ?").all(userId);
if (subscriptions.length === 0) {
return { native, pushed: 0, failed: 0 };
}
+9 -2
View File
@@ -17,7 +17,14 @@ const JOBS = {
workflows: (conn, a) =>
require("./workflow-stats").computeWorkflowData(
conn,
{ status: a.status || null, tool: a.tool || null, range: a.range || null },
{
status: a.status || null,
tool: a.tool || null,
range: a.range || null,
// A server-mode member's user id: part of the args, so the in-flight
// share never hands one member another's numbers.
member: a.member ?? null,
},
a.sections || null
),
// Not a read: the worker runs it on its own writable connection (below), so
@@ -102,7 +109,7 @@ function dispatch(job, args) {
* Run a named read job off the event loop. Concurrent calls with the same job
* and args share one run; with `maxAgeMs`, a result that young is reused too,
* so N open dashboards cost one query per window instead of N. Args are part
* of the key, so a result is only reused for the very same query.
* of the key, so a member's scoped result is never handed to anyone else.
*
* @param {keyof JOBS} job
* @param {object} [args] structured-cloneable
+38 -21
View File
@@ -1,14 +1,18 @@
/**
* @file same-origin.js
* @description Loopback-Origin guard for the routes that act on the machine
* rather than on the dashboard's own database — spawning a process, or
* writing a file into a directory the request names.
* @description Origin guard for the routes that act on the machine rather than
* on the dashboard's own database — spawning a process, or writing a file into
* a directory the request names — plus `isLocalRequest`, the "a browser on this
* very machine, not through a proxy" test.
*
* The dashboard binds to localhost, so any page in the user's browser can
* reach it. Without this, a malicious site could POST to it and, say, drop a
* CLAUDE.md full of instructions into a project the user later opens.
*
* Browser requests carry `Origin`; it must be a loopback host. Requests with
* Browser requests carry `Origin`; it must be a loopback host, or — for a
* dashboard served under an operator-allowed name (server mode behind a
* reverse proxy, `DASHBOARD_ALLOWED_HOSTS`) — exactly the request's own Host,
* which `hostGuard` has already checked against that allowlist. Requests with
* no `Origin` (curl, the CLI) pass — they are not the drive-by case. `Referer`
* is the fallback for older browsers.
*
@@ -18,6 +22,8 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { isHostAllowed } = require("./security");
const ALLOWED_ORIGIN_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "0.0.0.0"]);
function isLoopback(raw) {
@@ -28,23 +34,34 @@ function isLoopback(raw) {
}
}
function sameOriginGuard(req, res, next) {
const origin = req.headers.origin;
if (origin) {
if (!isLoopback(origin)) {
return res.status(403).json({
error: { code: "EBADORIGIN", message: "cross-origin requests are not allowed" },
});
}
return next();
/** `raw`'s host:port is the request's own Host, and that Host is allowed. */
function isOwnHost(raw, req) {
const host = String(req.headers.host || "").toLowerCase();
try {
return Boolean(host) && new URL(raw).host.toLowerCase() === host && isHostAllowed(host);
} catch {
return false;
}
const referer = req.headers.referer;
if (referer && !isLoopback(referer)) {
return res.status(403).json({
error: { code: "EBADORIGIN", message: "cross-origin requests are not allowed" },
});
}
return next();
}
module.exports = { sameOriginGuard, ALLOWED_ORIGIN_HOSTS };
function sameOriginGuard(req, res, next) {
const raw = req.headers.origin || req.headers.referer;
if (!raw || isLoopback(raw) || isOwnHost(raw, req)) return next();
return res.status(403).json({
error: { code: "EBADORIGIN", message: "cross-origin requests are not allowed" },
});
}
/**
* The request came straight from this machine: a loopback socket and no
* forwarding header. A reverse proxy's requests arrive from loopback too, so a
* forwarding header counts as remote. An SSH tunnel carries none and cannot be
* told apart.
*/
function isLocalRequest(req) {
const addr = req.socket?.remoteAddress || "";
const proxied = req.headers["x-forwarded-for"] || req.headers.forwarded;
return !proxied && ["127.0.0.1", "::1", "::ffff:127.0.0.1"].includes(addr);
}
module.exports = { sameOriginGuard, isLocalRequest, ALLOWED_ORIGIN_HOSTS, isLoopback, isOwnHost };
+338
View File
@@ -0,0 +1,338 @@
/**
* @file scope.js
* @description Server mode's row ownership (phase S2, docs/SERVER-MODE.md) —
* the ONE place that decides which rows a request may see.
*
* A workspace (`lanes.owner_id`) belongs to the user who created it. A session
* belongs to its workspace: `sessions.lane_id`, or a `dashboard_runs` row of
* that workspace holding it (a session not stamped yet). Agents, events and
* token rows belong to their session. A member sees only what their own
* workspaces own; an admin and an analyst see everything, and so does every
* request in local mode (`req.user` is never set there).
*
* Every helper takes `req`, so no caller can pick whose rows it reads.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const sql = (q) => require("../db").db.prepare(q);
/** The member whose rows this request is limited to, or null for "all". */
function memberId(req) {
const u = req?.user;
return u && u.role === "member" ? u.id : null;
}
/** The owner a workspace created by this request gets (null outside server mode). */
const ownerFor = (req) => (req?.user ? req.user.id : null);
/**
* SQL predicate over a sessions alias: `{where, params}`, `where` = "1" when
* unrestricted. `col` is the session id expression, `laneCol` its lane id.
*/
function sessionWhere(req, col = "id", laneCol = "lane_id") {
const uid = memberId(req);
if (uid == null) return { where: "1", params: [] };
return {
where: `(${laneCol} IN (SELECT id FROM lanes WHERE owner_id = ?)
OR ${col} IN (SELECT r.session_id FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id
WHERE l.owner_id = ? AND r.session_id IS NOT NULL))`,
params: [uid, uid],
};
}
/** SQL predicate for any table carrying a session id column (events, agents, tokens). */
function bySessionWhere(req, col = "session_id") {
const uid = memberId(req);
if (uid == null) return { where: "1", params: [] };
const s = sessionWhere(req, "s.id", "s.lane_id");
return { where: `${col} IN (SELECT s.id FROM sessions s WHERE ${s.where})`, params: s.params };
}
/** SQL predicate over a lanes id column. */
function laneWhere(req, col = "id") {
const uid = memberId(req);
if (uid == null) return { where: "1", params: [] };
return { where: `${col} IN (SELECT id FROM lanes WHERE owner_id = ?)`, params: [uid] };
}
function canSeeLane(req, laneId) {
const uid = memberId(req);
if (uid == null) return true;
// Bound as given, like getLane binds a route param: SQLite's integer
// affinity reads '9002.0' or ' 9002' as 9002 for both, so the row checked
// here is the row the route would load.
const v = typeof laneId === "number" ? laneId : String(laneId);
const row = sql("SELECT owner_id FROM lanes WHERE id = ?").get(v);
return Boolean(row) && row.owner_id === uid;
}
function canSeeSession(req, sessionId) {
if (memberId(req) == null) return true;
const s = sessionWhere(req);
return Boolean(
sql(`SELECT 1 FROM sessions WHERE id = ? AND ${s.where}`).get(String(sessionId), ...s.params)
);
}
function canSeeAgent(req, agentId) {
if (memberId(req) == null) return true;
const row = sql("SELECT session_id FROM agents WHERE id = ?").get(String(agentId));
return Boolean(row) && canSeeSession(req, row.session_id);
}
/** A run id `agenthub-lane-<n>[-<slot>]` (or an archived `…@<time>`) → its lane. */
function canSeeRun(req, runId) {
if (memberId(req) == null) return true;
const m = /^agenthub-lane-(\d+)(?:-\d+)?(?:@.*)?$/.exec(String(runId || ""));
if (m) return canSeeLane(req, m[1]);
const row = sql("SELECT lane_id FROM dashboard_runs WHERE id = ?").get(String(runId));
return Boolean(row && row.lane_id != null) && canSeeLane(req, row.lane_id);
}
/** The ids of every workspace this request may see, or null for "all". */
function visibleLaneIds(req) {
const uid = memberId(req);
if (uid == null) return null;
return sql("SELECT id FROM lanes WHERE owner_id = ?")
.all(uid)
.map((r) => r.id);
}
/** A row of `table` (`id` = `rowId`) whose `session_id` this request may see. */
function canSeeRowSession(req, table, rowId, idCol = "id") {
if (memberId(req) == null) return true;
const row = sql(`SELECT session_id FROM ${table} WHERE ${idCol} = ?`).get(String(rowId));
return Boolean(row && row.session_id) && canSeeSession(req, row.session_id);
}
/** A `dashboard_runs` row (`/api/run/history/:id`) of one of this request's workspaces. */
function canSeeRunRow(req, id) {
if (memberId(req) == null) return true;
const row = sql("SELECT lane_id FROM dashboard_runs WHERE id = ?").get(String(id));
return Boolean(row && row.lane_id != null) && canSeeLane(req, row.lane_id);
}
/**
* Path rules for routes that name ONE row: `[pattern, check(req, match)]`,
* matched against the path under `/api`. Keep this in step with the routers —
* a new route that takes an id under one of these prefixes is covered by its
* prefix; one under a new prefix needs a rule here.
*/
// First segments under /lanes (and /workspaces) that are routes, not ids.
const LANE_WORDS = new Set([
"branches",
"browse",
"ensure",
"gc",
"pick-path",
"pipelines",
"pipeline-stats",
"worktree",
]);
const PATH_RULES = [
[/^\/sessions\/(?!facets\/?$)([^/]+)/i, (req, m) => canSeeSession(req, m[1])],
[/^\/agents\/([^/]+)/i, (req, m) => canSeeAgent(req, m[1])],
// A segment that is not one of the routes' own words is a lane id: it must
// be plain digits, which every reader (parseInt, Number, SQLite affinity)
// takes as the same lane — '1e1' is 10 to SQLite and 1 to parseInt.
[
/^\/(?:lanes|workspaces)\/([^/]+)/i,
(req, m) =>
LANE_WORDS.has(m[1].toLowerCase()) || (/^\d+$/.test(m[1]) && canSeeLane(req, Number(m[1]))),
],
[/^\/run\/history\/([^/]+)/i, (req, m) => canSeeRunRow(req, m[1])],
[/^\/run\/([^/]+)/i, (req, m) => !/^agenthub-lane-/i.test(m[1]) || canSeeRun(req, m[1])],
[/^\/pricing\/cost\/([^/]+)/i, (req, m) => canSeeSession(req, m[1])],
[/^\/workflows\/session\/([^/]+)/i, (req, m) => canSeeSession(req, m[1])],
[/^\/workflows\/runs\/([^/]+)/i, (req, m) => canSeeRowSession(req, "workflows", m[1], "run_id")],
[/^\/alerts\/([^/]+)\/ack/i, (req, m) => canSeeRowSession(req, "alert_events", m[1])],
[
/^\/snapshots\/([^/]+)$/i,
(req, m) => req.method !== "DELETE" || canSeeRowSession(req, "session_snapshots", m[1]),
],
];
const list = (v) =>
(Array.isArray(v) ? v : String(v).split(",")).map((x) => String(x).trim()).filter(Boolean);
/**
* Ids a request names in its query string or JSON body, by kind. A value
* that is not a plain string or number (an array from `?x[]=`, an object) is
* returned in `bad`: a member's request carrying one is refused outright.
*/
function namedIds(req) {
const q = req.query || {};
const b = req.body && typeof req.body === "object" && !Array.isArray(req.body) ? req.body : {};
const out = { lanes: [], sessions: [], bad: false };
const take = (into, v) => {
if (v === undefined || v === null || v === "") return;
if (typeof v === "string" || typeof v === "number") into.push(String(v));
else out.bad = true;
};
for (const v of [q.lane, q.laneId, q.lane_id, b.laneId, b.lane_id]) take(out.lanes, v);
for (const v of [
q.session_id,
q.sessionId,
q.session,
b.session_id,
b.sessionId,
b.resumeSessionId,
]) {
take(out.sessions, v);
}
if (q.session_ids !== undefined) {
if (typeof q.session_ids !== "string") out.bad = true;
else out.sessions.push(...list(q.session_ids));
}
return out;
}
/**
* Express middleware (on `/api`, after authGuard): a member naming a row that
* is not theirs — in the path, the query or the body — gets 404, the same
* answer as a row that does not exist. A no-op for admin, analyst and local.
*/
function scopeGuard(req, res, next) {
if (memberId(req) == null) return next();
const notFound = () =>
res.status(404).json({ error: { code: "ENOTFOUND", message: "not found" } });
const p = require("./auth").apiPath(req);
for (const [re, check] of PATH_RULES) {
const m = re.exec(p);
if (m && !check(req, m)) return notFound();
}
const { lanes, sessions, bad } = namedIds(req);
if (bad) return notFound();
if (lanes.some((id) => !/^\d+$/.test(id) || !canSeeLane(req, Number(id)))) return notFound();
if (sessions.some((id) => !canSeeSession(req, id))) return notFound();
return next();
}
// ── WebSocket broadcasts ───────────────────────────────────────────────────
const STAFF = { staffOnly: true };
/** Owner ids of the workspaces that own a session (stamped or held by a run). */
function sessionOwners(sessionId) {
if (!sessionId) return new Set();
const rows = sql(
`SELECT l.owner_id FROM sessions s JOIN lanes l ON l.id = s.lane_id WHERE s.id = ?
UNION
SELECT l.owner_id FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id WHERE r.session_id = ?`
).all(String(sessionId), String(sessionId));
return new Set(rows.map((r) => r.owner_id).filter((x) => x != null));
}
function laneOwners(laneId) {
const row =
laneId == null ? null : sql("SELECT owner_id FROM lanes WHERE id = ?").get(Number(laneId));
return new Set(row && row.owner_id != null ? [row.owner_id] : []);
}
/**
* Who a broadcast may reach: `null` = every client, `{owners: Set<userId>}` =
* admin/analyst plus those members, `{staffOnly: true}` = admin/analyst only.
* A message type not listed here reaches no member — add it when a member's
* page needs it.
*/
function broadcastAudience(type, data) {
const d = data && typeof data === "object" ? data : {};
switch (type) {
case "session_created":
case "session_updated":
return { owners: sessionOwners(d.id) };
case "agent_created":
case "agent_updated":
case "new_event":
case "workflow_upserted":
return { owners: sessionOwners(d.session_id) };
case "alert_triggered":
case "alert_updated":
if (d.acked_all) return null;
return d.session_id ? { owners: sessionOwners(d.session_id) } : STAFF;
case "lane_update":
// A removal names an id that no longer resolves; it reveals nothing.
if (d.removed != null && !d.lane) return null;
return { owners: laneOwners(d.lane?.id) };
case "lane_hook_output":
case "lane_hook_result":
case "lane_runtime":
return { owners: laneOwners(d.laneId) };
case "update_status":
return null;
// A member's prompt box refetches its command list on these, but the
// payload names the host's profiles and files: members get the type only.
case "cc_config_changed":
case "codex_config_changed":
return { strip: true };
default:
return STAFF;
}
}
/** Whether a client logged in as `user` (undefined in local mode) gets it. */
function audienceIncludes(audience, user) {
if (!user || user.role !== "member") return true;
if (audience === null || audience.strip) return true;
return Boolean(audience.owners && audience.owners.has(user.id));
}
/**
* The folder a member's workspace may use: anywhere outside server mode or for
* an admin; for a member, only inside `<AGENTHUB_USERS_ROOT>/<username>` when
* that root is set. `dir` must already be absolute and canonical. Until
* accounts are Linux users (S3) this keeps workspaces apart, not agents: a
* terminal can still reach any folder the host account can.
*/
function folderAllowed(req, dir) {
if (memberId(req) == null) return true;
const root = (process.env.AGENTHUB_USERS_ROOT || "").trim();
if (!root) return true;
const path = require("node:path");
const home = path.join(path.resolve(root), req.user.username);
const rel = path.relative(home, path.resolve(dir));
return rel === "" || (rel.split(path.sep)[0] !== ".." && !path.isAbsolute(rel));
}
/**
* Server mode: whether `dir` is, lies inside, or contains the folder of a
* workspace this member does not own (another user's, or the host's) — for any
* tool. A member's workspace may not overlap another's, or its git and file
* views would reach that user's files.
*/
function folderTakenByOther(req, dir) {
const uid = memberId(req);
if (uid == null) return false;
const path = require("node:path");
const mine = path.resolve(dir);
const related = (a, b) => {
const rel = path.relative(a, b);
return rel === "" || (rel.split(path.sep)[0] !== ".." && !path.isAbsolute(rel));
};
return sql("SELECT cwd FROM lanes WHERE owner_id IS NULL OR owner_id != ?")
.all(uid)
.some((r) => typeof r.cwd === "string" && (related(r.cwd, mine) || related(mine, r.cwd)));
}
module.exports = {
folderTakenByOther,
scopeGuard,
broadcastAudience,
audienceIncludes,
sessionOwners,
PATH_RULES,
canSeeRowSession,
canSeeRunRow,
folderAllowed,
memberId,
ownerFor,
sessionWhere,
bySessionWhere,
laneWhere,
canSeeLane,
canSeeSession,
canSeeAgent,
canSeeRun,
visibleLaneIds,
};
+13 -4
View File
@@ -12,7 +12,9 @@
* subquery so complex aggregate SQL (stats, analytics) needs only one extra AND.
*
* `?tool=<id>` (an AI Tools catalog id, matched against `sessions.tool`) narrows
* the same way; `parseScope` folds both into one predicate over `sessions`.
* the same way; `parseScope` folds both into one predicate over `sessions`,
* plus — in server mode, for a member — the member's own sessions
* (`sessionWhere`, server/lib/scope.js), so no request can widen past them.
*
* `?from=&to=` (local `YYYY-MM-DD`, inclusive, read with `tz_offset`) is a date
* range: `parseRange` turns it into the UTC instants a timestamp column is
@@ -21,6 +23,8 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { sessionWhere } = require("./scope");
/**
* Parse the `sources` query param into a de-duplicated list, or null for
* "all sources" (no filtering).
@@ -85,9 +89,9 @@ function parseTool(req) {
}
/**
* The data scope (sources and/or tool) as ONE predicate over `sessions` with
* unqualified columns, or null for "everything". Callers check `parseTool` for
* false first.
* The data scope (sources and/or tool, and a member's own sessions) as ONE
* predicate over `sessions` with unqualified columns, or null for "everything"
* — never null for a member. Callers check `parseTool` for false first.
* @param {import("express").Request} req
* @returns {{where:string, params:string[]}|null}
*/
@@ -104,6 +108,11 @@ function parseScope(req) {
where.push("tool = ?");
params.push(tool);
}
const own = sessionWhere(req);
if (own.where !== "1") {
where.push(own.where);
params.push(...own.params);
}
return where.length ? { where: where.join(" AND "), params } : null;
}
+16 -3
View File
@@ -23,14 +23,27 @@ function durationSec(s) {
// ═══════════════════════════════════════════════════
/**
* Build a SQL WHERE clause for the session filters `{status, tool, range}`
* (`range` = `parseRange`'s result: sessions started in it).
* Build a SQL WHERE clause for the session filters `{status, tool, range,
* member}` (`range` = `parseRange`'s result: sessions started in it; `member`
* = `scope.memberId(req)`, a server-mode member's user id, whose own sessions
* are the only ones read — null reads every session).
* Returns { clause, params } where clause is empty or " AND s.status = ? …".
* Use `alias` to match the sessions table alias used in your query (default "s").
*/
function statusClause(filter, alias = "s") {
const parts = [];
const params = [];
if (filter?.member != null) {
// A pure SQL builder: scope.js opens the db only for its canSee* checks,
// so this also runs in the read worker on its own connection.
const w = require("./scope").sessionWhere(
{ user: { id: filter.member, role: "member" } },
`${alias}.id`,
`${alias}.lane_id`
);
parts.push(w.where);
params.push(...w.params);
}
if (filter?.status && filter.status !== "all") {
parts.push(`${alias}.status = ?`);
params.push(filter.status);
@@ -710,7 +723,7 @@ const WORKFLOW_SECTIONS = [
* The GET /api/workflows bundle, or only the named sections of it.
*
* @param {object} conn SQLite connection (compat-sqlite Database)
* @param {{status?: string|null, tool?: string|null, range?: object|null}|null} statusFilter
* @param {{status?: string|null, tool?: string|null, range?: object|null, member?: number|null}|null} statusFilter
* the session filters (see `statusClause`); null = every session
* @param {string[]|null} sections subset of WORKFLOW_SECTIONS; null = all
* @returns {object}
+2
View File
@@ -17,6 +17,7 @@ const lanes = require("./openapi-extra/lanes");
const push = require("./openapi-extra/push");
const aiTools = require("./openapi-extra/ai-tools");
const misc = require("./openapi-extra/misc");
const auth = require("./openapi-extra/auth");
// Enriched overrides of already-documented endpoints — same operationId, tags,
// and request/response `$ref` schemas as the base, with added examples and
// richer descriptions. Listed last so they win the merge.
@@ -52,6 +53,7 @@ module.exports = combine(
push,
aiTools,
misc,
auth,
overrideSessionsAgents,
overrideCore,
overridePricingAlerts,
+253
View File
@@ -0,0 +1,253 @@
/**
* @file OpenAPI 3.0 fragment for server mode's login routes (server/routes/auth.js):
* - GET /api/auth/me (Auth)
* - POST /api/auth/login (Auth)
* - POST /api/auth/logout (Auth)
* - GET /api/accounts (Auth) — the Claude account pool, no tokens
* - GET /api/users (Auth) — every account's usage (admin/analyst)
* - GET /api/users/audit (Auth) — staff reads of members' sessions
* Exports `{ tags, schemas, paths }`, combined into the spec by
* server/openapi-extra.js. See docs/SERVER-MODE.md.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const tags = [
{
name: "Auth",
description:
'Server mode\'s login (AGENTHUB_MODE=server). In local mode — the default — only GET /api/auth/me answers, with {mode: "local"}.',
},
];
const schemas = {
AuthUser: {
type: "object",
required: ["id", "username", "role"],
properties: {
id: { type: "integer" },
username: { type: "string" },
role: { type: "string", enum: ["admin", "analyst", "member"] },
disabled: { type: "boolean" },
},
},
AuthMe: {
type: "object",
required: ["mode"],
properties: {
mode: { type: "string", enum: ["local", "server"] },
user: {
description: "Server mode only: the logged-in user, or null.",
nullable: true,
allOf: [{ $ref: "#/components/schemas/AuthUser" }],
},
},
},
};
const err = (description) => ({
description,
content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } },
});
const paths = {
"/api/users": {
get: {
tags: ["Auth"],
operationId: "listServerUsers",
summary:
"Every account with its workspaces, sessions and 7/30-day usage (admin/analyst; 403 EADMIN for a member)",
responses: {
200: {
description: "Accounts",
content: {
"application/json": {
schema: {
type: "object",
properties: {
users: {
type: "array",
items: {
allOf: [
{ $ref: "#/components/schemas/AuthUser" },
{
type: "object",
properties: {
workspaces: { type: "integer" },
sessions: { type: "integer" },
last7d: {
type: "object",
properties: { cost: { type: "number" }, tokens: { type: "integer" } },
},
last30d: {
type: "object",
properties: { cost: { type: "number" }, tokens: { type: "integer" } },
},
},
},
],
},
},
},
},
},
},
},
403: err("EADMIN — a member"),
},
},
},
"/api/users/audit": {
get: {
tags: ["Auth"],
operationId: "listAuditLog",
summary: "Admin/analyst reads of sessions members own, newest first",
parameters: [{ name: "limit", in: "query", schema: { type: "integer", maximum: 1000 } }],
responses: {
200: {
description: "Entries",
content: {
"application/json": {
schema: {
type: "object",
properties: {
entries: {
type: "array",
items: {
type: "object",
properties: {
id: { type: "integer" },
at: { type: "string" },
method: { type: "string" },
path: { type: "string" },
session_id: { type: "string", nullable: true },
reader: { type: "string", nullable: true },
owner: { type: "string", nullable: true },
},
},
},
},
},
},
},
},
403: err("EADMIN — a member"),
},
},
},
"/api/accounts": {
get: {
tags: ["Auth"],
operationId: "listPoolAccounts",
summary: "The Claude account pool as the balancer sees it (never a token)",
parameters: [
{
name: "force",
in: "query",
schema: { type: "string", enum: ["1"] },
description: "Re-read every account's quota first",
},
],
responses: {
200: {
description: "Accounts with live panes, recent spend and quota",
content: {
"application/json": {
schema: {
type: "object",
properties: {
blockPercent: { type: "number" },
accounts: {
type: "array",
items: {
type: "object",
properties: {
name: { type: "string" },
addedAt: { type: "string" },
expiresAt: { type: "string" },
expired: { type: "boolean" },
livePanes: { type: "integer" },
spend5h: { type: "number" },
spend7d: { type: "number" },
quota: { type: "object", nullable: true },
quotaPercent: { type: "number", nullable: true },
full: { type: "boolean" },
},
},
},
},
},
},
},
},
},
},
},
"/api/auth/me": {
get: {
tags: ["Auth"],
operationId: "authMe",
summary: "The dashboard's mode and, in server mode, who is logged in",
responses: {
200: {
description: "Mode and user",
content: { "application/json": { schema: { $ref: "#/components/schemas/AuthMe" } } },
},
},
},
},
"/api/auth/login": {
post: {
tags: ["Auth"],
operationId: "authLogin",
summary: "Log in; sets the httpOnly, SameSite=Strict cookie agenthub_sid",
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
required: ["username", "password"],
properties: { username: { type: "string" }, password: { type: "string" } },
},
},
},
},
responses: {
200: {
description: "Logged in",
content: {
"application/json": {
schema: {
type: "object",
properties: { user: { $ref: "#/components/schemas/AuthUser" } },
},
},
},
},
401: err("EBADLOGIN — wrong username or password, or a disabled user"),
403: err("EBADORIGIN — a cross-origin request"),
404: err("ENOTSERVER — the dashboard is in local mode"),
429: err("ETOOMANY — too many failed logins from this address (15 minutes)"),
},
},
},
"/api/auth/logout": {
post: {
tags: ["Auth"],
operationId: "authLogout",
summary: "End this login and clear its cookie",
responses: {
200: {
description: "Logged out",
content: {
"application/json": {
schema: { type: "object", properties: { ok: { type: "boolean" } } },
},
},
},
},
},
},
};
module.exports = { tags, schemas, paths };
+25
View File
@@ -0,0 +1,25 @@
/**
* @file accounts.js
* @description `GET /api/accounts` — the Claude account pool as the balancer
* sees it (server/lib/claude-pool.js): per account its live panes, $ spent in
* the last 5 h / 7 d, the last quota reading and whether it counts as full.
* Never a token. `?force=1` re-reads every quota first. Accounts are added and
* removed only from the host's shell (`agenthub accounts …`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { Router } = require("express");
const pool = require("../lib/claude-pool");
const router = Router();
router.get("/", async (req, res) => {
try {
await pool.refreshQuotas({ force: req.query.force === "1" });
res.json({ accounts: pool.poolStatus(), blockPercent: pool.BLOCK_PERCENT });
} catch (e) {
res.status(500).json({ error: { code: e.code || "EPOOL", message: e.message } });
}
});
module.exports = router;
+19 -4
View File
@@ -8,6 +8,7 @@ const { stmts, db } = require("../db");
const { broadcast } = require("../websocket");
const { attachAgentCosts } = require("./pricing");
const { parseSources, sessionIdInSourcesClause } = require("../lib/source-filter");
const { memberId, bySessionWhere } = require("../lib/scope");
const router = Router();
@@ -41,12 +42,22 @@ router.get("/", (req, res) => {
} else if (session_id) {
// A session belongs to exactly one source, so no extra source filter needed.
rows = stmts.listAgentsBySession.all(session_id);
} else if (sources) {
} else if (sources || memberId(req) != null) {
// Data-scope active: build a dynamic query restricting agents to sessions
// from the chosen machines. `agents` carries only session_id → subquery.
const scope = sessionIdInSourcesClause(sources, "session_id");
const clauses = [scope.clause];
const params = [...scope.params];
// A server-mode member gets only their own sessions' agents the same way.
const clauses = [];
const params = [];
const src = sessionIdInSourcesClause(sources, "session_id");
if (src.clause) {
clauses.push(src.clause);
params.push(...src.params);
}
if (memberId(req) != null) {
const own = bySessionWhere(req);
clauses.push(own.where);
params.push(...own.params);
}
if (status) {
clauses.push("status = ?");
params.push(status);
@@ -85,6 +96,10 @@ router.post("/", (req, res) => {
}
const existing = stmts.getAgent.get(id);
// Server mode: another user's agent is "not found", not handed back.
if (existing && !require("../lib/scope").canSeeAgent(req, id)) {
return res.status(404).json({ error: { code: "ENOTFOUND", message: "not found" } });
}
if (existing) {
return res.json({ agent: existing, created: false });
}
+9 -4
View File
@@ -21,6 +21,7 @@ const { listModels } = require("../lib/ai-tool-models");
const toolConfig = require("../lib/ai-tool-config");
const { calculateCost } = require("./pricing");
const { db, stmts } = require("../db");
const { sessionWhere } = require("../lib/scope");
const router = express.Router();
@@ -150,13 +151,17 @@ router.put(
)
);
// How much a tool has been used: its sessions and their priced tokens.
// How much a tool has been used: its sessions and their priced tokens (a
// server mode member: their own sessions only).
router.get("/:id/usage", (req, res) => {
if (!aiTools.has(req.params.id)) {
return res.status(404).json({ error: { code: "ENOTOOL", message: "unknown AI tool" } });
}
const id = req.params.id;
const { n } = db.prepare("SELECT COUNT(*) AS n FROM sessions WHERE tool = ?").get(id);
const own = sessionWhere(req, "s.id", "s.lane_id");
const { n } = db
.prepare(`SELECT COUNT(*) AS n FROM sessions s WHERE s.tool = ? AND ${own.where}`)
.get(id, ...own.params);
const rows = db
.prepare(
`SELECT tu.model AS model, tu.speed AS speed, tu.inference_geo AS inference_geo,
@@ -167,10 +172,10 @@ router.get("/:id/usage", (req, res) => {
SUM(tu.cache_write_tokens + tu.baseline_cache_write) AS cache_write_tokens,
SUM(tu.cache_write_1h_tokens + tu.baseline_cache_write_1h) AS cache_write_1h_tokens
FROM token_usage tu JOIN sessions s ON s.id = tu.session_id
WHERE s.tool = ?
WHERE s.tool = ? AND ${own.where}
GROUP BY tu.model, tu.speed, tu.inference_geo, tu.service_tier`
)
.all(id);
.all(id, ...own.params);
res.json({ sessions: n, cost: calculateCost(rows, stmts.listPricing.all()).total_cost });
});
+39 -3
View File
@@ -7,7 +7,7 @@
const { Router } = require("express");
const { v4: uuidv4 } = require("uuid");
const { stmts } = require("../db");
const { stmts, db } = require("../db");
const { broadcast } = require("../websocket");
const {
RULE_TYPES,
@@ -15,6 +15,7 @@ const {
invalidateRuleCache,
previewRule,
} = require("../lib/alerts");
const { memberId, bySessionWhere } = require("../lib/scope");
const router = Router();
@@ -150,6 +151,31 @@ router.get("/", (req, res) => {
const offset = Math.max(0, parseInt(req.query.offset, 10) || 0);
const unackedOnly = req.query.unacked === "true";
if (memberId(req) != null) {
// Server mode: a member's feed is their own sessions' alerts; one with no
// session (a budget, a global threshold) is staff-only.
const own = bySessionWhere(req);
const count = (extra) =>
db
.prepare(`SELECT COUNT(*) as count FROM alert_events WHERE ${own.where}${extra}`)
.get(...own.params).count;
const unackedSql = " AND acknowledged_at IS NULL";
const alerts = db
.prepare(
`SELECT * FROM alert_events WHERE ${own.where}${unackedOnly ? unackedSql : ""}
ORDER BY triggered_at DESC, id DESC LIMIT ? OFFSET ?`
)
.all(...own.params, limit, offset);
const unacked = count(unackedSql);
return res.json({
alerts,
total: unackedOnly ? unacked : count(""),
unacked,
limit,
offset,
});
}
const alerts = unackedOnly
? stmts.listUnackedAlertEvents.all(limit, offset)
: stmts.listAlertEvents.all(limit, offset);
@@ -174,8 +200,18 @@ router.post("/:id(\\d+)/ack", (req, res) => {
});
// POST /api/alerts/ack-all - Acknowledge every unacked alert
router.post("/ack-all", (_req, res) => {
const info = stmts.ackAllAlertEvents.run();
router.post("/ack-all", (req, res) => {
// A server-mode member acknowledges only their own sessions' alerts.
const own = bySessionWhere(req);
const info =
memberId(req) == null
? stmts.ackAllAlertEvents.run()
: db
.prepare(
`UPDATE alert_events SET acknowledged_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
WHERE acknowledged_at IS NULL AND ${own.where}`
)
.run(...own.params);
if (info.changes > 0) broadcast("alert_updated", { acked_all: true });
res.json({ ok: true, acknowledged: info.changes });
});
+42
View File
@@ -0,0 +1,42 @@
/**
* @file auth.js
* @description `/api/auth` — who is logged in, login and logout for server mode
* (server/lib/auth.js, docs/SERVER-MODE.md). `GET /me` answers in local mode
* too (`{mode: "local"}`) so the client knows whether to show a login page;
* login and logout exist only in server mode. Accounts are made with the CLI
* (`agenthub server setup`, `agenthub users …`), never by a route.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { Router } = require("express");
const auth = require("../lib/auth");
const { sameOriginGuard } = require("../lib/same-origin");
const router = Router();
router.get("/me", (req, res) => {
if (!auth.isServerMode()) return res.json({ mode: "local" });
res.json({ mode: "server", user: auth.userFromRequest(req) });
});
router.post("/login", sameOriginGuard, async (req, res) => {
if (!auth.isServerMode()) {
return res.status(404).json({ error: { code: "ENOTSERVER", message: "not in server mode" } });
}
try {
const { user, token } = await auth.login(req, req.body?.username, req.body?.password);
auth.setLoginCookie(req, res, token);
res.json({ user });
} catch (e) {
const status = e.code === "ETOOMANY" ? 429 : e.code === "EBADLOGIN" ? 401 : 500;
res.status(status).json({ error: { code: e.code || "EAUTH", message: e.message } });
}
});
router.post("/logout", sameOriginGuard, (req, res) => {
auth.endSession(auth.cookieToken(req));
auth.clearLoginCookie(req, res);
res.json({ ok: true });
});
module.exports = router;
+4 -3
View File
@@ -3,7 +3,8 @@
* `budget` (created, edited and deleted through /api/alerts/rules); this router
* only reads: `GET /api/budgets/status` lists every enabled budget with its
* current-period spend, percent of the limit, projected end-of-period spend and
* the thresholds reached (server/lib/budgets.js).
* the thresholds reached (server/lib/budgets.js) — a server mode member's spend
* over their own sessions only.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -13,8 +14,8 @@ const { budgetStatus } = require("../lib/budgets");
const router = Router();
// GET /api/budgets/status
router.get("/status", (_req, res) => {
res.json({ budgets: budgetStatus() });
router.get("/status", (req, res) => {
res.json({ budgets: budgetStatus({ req }) });
});
module.exports = router;
+17 -5
View File
@@ -10,6 +10,7 @@
const { Router } = require("express");
const { db } = require("../db");
const { parseSources, sessionIdInSourcesClause } = require("../lib/source-filter");
const { memberId, bySessionWhere } = require("../lib/scope");
const router = Router();
@@ -78,6 +79,12 @@ function buildWhere(filters) {
params.push(...scope.params);
}
// Server mode: a member's events are only those of their own sessions.
if (filters.own) {
clauses.push(filters.own.where);
params.push(...filters.own.params);
}
return {
sql: clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "",
params,
@@ -98,6 +105,7 @@ router.get("/", (req, res) => {
from: parseDate(req.query.from),
to: parseDate(req.query.to),
sources: parseSources(req),
own: memberId(req) != null ? bySessionWhere(req) : null,
};
const { sql: whereSql, params: whereParams } = buildWhere(filters);
@@ -120,17 +128,21 @@ router.get("/", (req, res) => {
});
// GET /api/events/facets — distinct event_type / tool_name values in the DB.
router.get("/facets", (_req, res) => {
router.get("/facets", (req, res) => {
// A server-mode member sees only the values in their own sessions' events.
const own = bySessionWhere(req);
const eventTypes = db
.prepare(
"SELECT DISTINCT event_type FROM events WHERE event_type IS NOT NULL ORDER BY event_type"
`SELECT DISTINCT event_type FROM events WHERE event_type IS NOT NULL AND ${own.where} ORDER BY event_type`
)
.all()
.all(...own.params)
.map((r) => r.event_type);
const toolNames = db
.prepare("SELECT DISTINCT tool_name FROM events WHERE tool_name IS NOT NULL ORDER BY tool_name")
.all()
.prepare(
`SELECT DISTINCT tool_name FROM events WHERE tool_name IS NOT NULL AND ${own.where} ORDER BY tool_name`
)
.all(...own.params)
.map((r) => r.tool_name);
res.json({ event_types: eventTypes, tool_names: toolNames });
+75 -12
View File
@@ -31,6 +31,8 @@ const runs = require("../lib/pty-run");
const tmux = require("../lib/tmux");
const { resumeProxy } = require("../lib/proxy-apply");
const { sameOriginGuard } = require("./run");
const { isLocalRequest } = require("../lib/same-origin");
const scope = require("../lib/scope");
const { writeLaneFile } = require("../lib/lane-file-write");
const { pickPath } = require("../lib/native-dialog");
const { preflight } = require("../lib/lane-preflight");
@@ -200,8 +202,10 @@ function broadcastLane(id) {
if (lane) broadcast("lane_update", { lane: payload(lane) });
}
router.get("/", (_req, res) => {
const rows = lanesLib.listLanes();
router.get("/", (req, res) => {
// Server mode: a member lists (and counts) only their own workspaces.
const visible = scope.visibleLaneIds(req);
const rows = lanesLib.listLanes().filter((l) => visible === null || visible.includes(l.id));
// ONE list-sessions answers every lane's run, instead of a synchronous
// has-session per lane (null when tmux could not be asked: per-lane then).
const live = rows.some((l) => l.run_id) ? tmux.liveSessions() : null;
@@ -249,13 +253,18 @@ router.get("/pipeline-stats", (req, res) => {
}
}
// Server mode: a member's aggregate, lanes and picker cover only their own
// workspaces ("1" for everyone else). A `?lane=` of another user's never
// gets here — scopeGuard answers 404 first.
const own = scope.laneWhere(req, "lane_id");
const ownLanes = scope.laneWhere(req, "id");
const events =
laneId === null
? db
.prepare(
"SELECT lane_id, pipeline, run_seq, node_id, source, result, evidence FROM lane_stage_events ORDER BY id"
`SELECT lane_id, pipeline, run_seq, node_id, source, result, evidence FROM lane_stage_events WHERE ${own.where} ORDER BY id`
)
.all()
.all(...own.params)
: db
.prepare(
"SELECT lane_id, pipeline, run_seq, node_id, source, result, evidence FROM lane_stage_events WHERE lane_id = ? ORDER BY id"
@@ -279,7 +288,7 @@ router.get("/pipeline-stats", (req, res) => {
// with zero runs must appear, at zero, rather than vanish.
const scopedLanes =
laneId === null
? db.prepare("SELECT pipeline FROM lanes").all()
? db.prepare(`SELECT pipeline FROM lanes WHERE ${ownLanes.where}`).all(...ownLanes.params)
: db.prepare("SELECT pipeline FROM lanes WHERE id = ?").all(laneId);
for (const row of scopedLanes) entryFor(row.pipeline);
@@ -343,7 +352,9 @@ router.get("/pipeline-stats", (req, res) => {
// the reader most likely came to look at.
.sort((a, b) => b.runs - a.runs);
const lanes = db.prepare("SELECT id, title, cwd FROM lanes ORDER BY id").all();
const lanes = db
.prepare(`SELECT id, title, cwd FROM lanes WHERE ${ownLanes.where} ORDER BY id`)
.all(...ownLanes.params);
res.json({ pipelines, lanes });
});
@@ -385,6 +396,23 @@ router.get("/branches", async (req, res) => {
}
});
/** The lane fields a server-mode member may PATCH (the UI sends title and pipeline). */
const MEMBER_PATCHABLE = new Set(["title", "pipeline", "notes", "links", "branch"]);
/** Server mode: the folder is, holds or lies in another user's workspace. */
function refuseOwned(res) {
return res.status(409).json({
error: { code: "EOWNED", message: "this folder belongs to another user's workspace" },
});
}
/** Server mode with AGENTHUB_USERS_ROOT: a member's folder lies outside their own. */
function refuseFolder(res) {
return res.status(403).json({
error: { code: "EFOLDER", message: "a workspace must be inside your own folder" },
});
}
/**
* Idempotent "which lane owns this directory?" — the Workspace page opens on a
* cwd, not on a lane id, so it needs one lane to exist for that cwd without
@@ -404,6 +432,8 @@ router.post("/ensure", sameOriginGuard, (req, res) => {
if (body.tool !== undefined && !aiTools.has(body.tool)) {
return res.status(400).json({ error: { code: "EBADTOOL", message: "unknown tool" } });
}
if (!scope.folderAllowed(req, cwd)) return refuseFolder(res);
if (scope.folderTakenByOther(req, cwd)) return refuseOwned(res);
// A cwd can now back one lane per tool (idx_lanes_cwd_tool) — "does a lane
// already own this (cwd, tool) pair" is the real question, so a request for
@@ -418,6 +448,12 @@ router.post("/ensure", sameOriginGuard, (req, res) => {
const found = lanesLib.resolveLaneByCwd(cwd, body.tool || "claude");
const strip = (p) => p.replace(/[\\/]+$/, "");
const owner = found && (body.exact !== true || strip(found.cwd) === strip(cwd)) ? found : null;
// Server mode: another user's workspace is not this one's to adopt.
if (owner && !scope.canSeeLane(req, owner.id)) {
return res.status(409).json({
error: { code: "EOWNED", message: "this folder belongs to another user's workspace" },
});
}
if (owner) {
// First-write wins on the title: an ensure-with-title on an existing lane
// is how callers like Sagi adopt the lane, so don't clobber a real title
@@ -431,6 +467,7 @@ router.post("/ensure", sameOriginGuard, (req, res) => {
try {
const lane = lanesLib.createLane({
cwd,
owner_id: scope.ownerFor(req),
title: body.title || "",
...(body.pipeline ? { pipeline: body.pipeline } : {}),
...(body.tool ? { tool: body.tool } : {}),
@@ -463,6 +500,12 @@ router.post("/ensure", sameOriginGuard, (req, res) => {
* "oversized" mean.
*/
router.post("/gc", sameOriginGuard, (req, res) => {
// Machine-wide, so it cannot be narrowed to one member's workspaces.
if (scope.memberId(req) != null) {
return res
.status(403)
.json({ error: { code: "EADMIN", message: "only an admin may run housekeeping" } });
}
const dryRun = req.body?.dryRun === true;
try {
const reaped = reapOrphanMcp({ dryRun });
@@ -525,9 +568,7 @@ router.get("/browse", (req, res) => {
* apart — the dialog is closed when the browser's request goes away.
*/
router.post("/pick-path", sameOriginGuard, async (req, res) => {
const addr = req.socket.remoteAddress || "";
const proxied = req.headers["x-forwarded-for"] || req.headers.forwarded;
if (proxied || !["127.0.0.1", "::1", "::ffff:127.0.0.1"].includes(addr)) {
if (!isLocalRequest(req)) {
return res.status(403).json({
error: { code: "EREMOTE", message: "the native picker opens only for a local browser" },
});
@@ -557,7 +598,11 @@ router.get("/:id", (req, res) => {
router.post("/", sameOriginGuard, (req, res) => {
try {
const lane = lanesLib.createLane(req.body || {});
const body = req.body || {};
const cwd = typeof body.cwd === "string" ? lanesLib.canonicalCwd(body.cwd) : body.cwd;
if (typeof cwd === "string" && !scope.folderAllowed(req, cwd)) return refuseFolder(res);
if (typeof cwd === "string" && scope.folderTakenByOther(req, cwd)) return refuseOwned(res);
const lane = lanesLib.createLane({ ...body, owner_id: scope.ownerFor(req) });
broadcastLane(lane.id);
res.status(201).json({ lane: payload(lane) });
} catch (err) {
@@ -584,9 +629,21 @@ router.patch("/:id", sameOriginGuard, (req, res) => {
if (!lanesLib.getLane(req.params.id)) {
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
}
// Server mode: a member edits only what a person types — never the columns
// that tie a workspace to a pane or a session (`run_id` pointed at another
// user's pane would let stop/reset kill it).
const body = req.body && typeof req.body === "object" ? req.body : {};
if (scope.memberId(req) != null) {
const off = Object.keys(body).filter((k) => !MEMBER_PATCHABLE.has(k));
if (off.length) {
return res.status(403).json({
error: { code: "EFIELD", message: `a member may not change ${off.join(", ")}` },
});
}
}
let lane;
try {
lane = lanesLib.updateLane(req.params.id, req.body || {});
lane = lanesLib.updateLane(req.params.id, body);
} catch (err) {
// A bad `kind`, `pipeline` or field value is invalid input, not a server
// fault — every sibling route answers 400 here, so this one must too
@@ -1253,6 +1310,8 @@ router.post("/worktree", sameOriginGuard, async (req, res) => {
},
});
}
if (!scope.folderAllowed(req, lanesLib.canonicalCwd(sourceRepo))) return refuseFolder(res);
if (scope.folderTakenByOther(req, lanesLib.canonicalCwd(sourceRepo))) return refuseOwned(res);
// A repo with no commit has nothing a worktree could branch from: git would
// fail later, in the background job, and leave a `failed` row behind. Refuse
@@ -1318,6 +1377,7 @@ router.post("/worktree", sameOriginGuard, async (req, res) => {
cwd: dir,
branch,
kind: "managed",
owner_id: scope.ownerFor(req),
source_repo: resolvedSourceRepo,
base_branch: body.base || null,
slug,
@@ -1421,7 +1481,10 @@ function laneRunIds(lane) {
.filter((r) => r.laneId === lane.id)
.map((r) => r.id)
);
if (lane.run_id) ids.add(lane.run_id);
// Only this lane's own panes: a run_id naming another lane's is ignored.
if (lane.run_id && new RegExp(`^agenthub-lane-${lane.id}(-\\d+)?$`).test(lane.run_id)) {
ids.add(lane.run_id);
}
return [...ids];
}

Some files were not shown because too many files have changed in this diff Show More