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:
+3
-2
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}`} />
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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)."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
@@ -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 */
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) }),
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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=")));
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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 };
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -243,6 +243,7 @@ module.exports = {
|
||||
profileUsage,
|
||||
fetchUsage,
|
||||
fetchOwner,
|
||||
liveOwner,
|
||||
refreshProfile,
|
||||
CLIENT_ID,
|
||||
USAGE_URL,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
@@ -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
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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
@@ -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
Reference in New Issue
Block a user