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
|
### 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.
|
- **`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`.
|
- **`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).
|
- **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.
|
- **`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
|
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
|
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**
|
The `remote_source.status` message (emitted by the **Remote Data Sources**
|
||||||
sync poller and the `/api/remote-sources` routes) carries
|
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,
|
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
|
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
|
prompt box that starts a new session, Recents, and the workspace's own
|
||||||
Instructions, Memory, Context files and extra Folders, which Claude and Codex
|
Instructions, Memory, Context files (picked with + or dragged in from the OS)
|
||||||
sessions start with.
|
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
|
- **Settings as one page per section** (`/settings/<section>`): a left nav
|
||||||
grouped General (General / Data / About / Appearance / Pet / Notifications) /
|
grouped General (General / Data / About / Appearance / Pet / Notifications) /
|
||||||
Agents / Routing / Cost / Git, a search box (Ctrl+F) over every section whose
|
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).
|
(marketplaces → every plugin → AgentHub last → one session reload).
|
||||||
- **Appearance.** Settings → Appearance picks Dark / Light / System (follows the
|
- **Appearance.** Settings → Appearance picks Dark / Light / System (follows the
|
||||||
OS live) and a theme per mode — dark: Default, Darcula, Apple Dark, Orca Dark;
|
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.
|
remembers its own theme, so the sidebar's quick toggle swaps between the two.
|
||||||
Per-browser only.
|
Per-browser only.
|
||||||
- **Notification sounds.** Settings → Notifications picks a sound (None, Chime,
|
- **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
|
(`agenthub statusline install`, [docs/STATUSLINE.md](docs/STATUSLINE.md)), and
|
||||||
Prometheus rules + Grafana boards for `/api/metrics`
|
Prometheus rules + Grafana boards for `/api/metrics`
|
||||||
(`docker-compose.monitoring.yml`, [docs/MONITORING.md](docs/MONITORING.md)).
|
(`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
|
## Prerequisites
|
||||||
|
|
||||||
|
|||||||
+284
-1
@@ -160,7 +160,13 @@ async function api(method, pathname, body, options = {}) {
|
|||||||
const form = body instanceof FormData;
|
const form = body instanceof FormData;
|
||||||
res = await fetch(url, {
|
res = await fetch(url, {
|
||||||
method,
|
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,
|
body: body ? (form ? body : JSON.stringify(body)) : undefined,
|
||||||
signal: AbortSignal.timeout(options.timeoutMs ?? 30_000),
|
signal: AbortSignal.timeout(options.timeoutMs ?? 30_000),
|
||||||
});
|
});
|
||||||
@@ -2303,6 +2309,17 @@ async function cmdLockAcquire(args) {
|
|||||||
console.log(`${c.green("✔")} acquired lock "${name}" as ${holder}`);
|
console.log(`${c.green("✔")} acquired lock "${name}" as ${holder}`);
|
||||||
return;
|
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) {
|
if (Date.now() - lastPrinted >= 60_000) {
|
||||||
const waited = Math.floor((Date.now() - startedAt) / 1000);
|
const waited = Math.floor((Date.now() - startedAt) / 1000);
|
||||||
console.log(
|
console.log(
|
||||||
@@ -2719,6 +2736,21 @@ const COMMAND_GROUPS = [
|
|||||||
"install|uninstall|status [--port N]",
|
"install|uninstall|status [--port N]",
|
||||||
"Auto-start on boot via systemd --user (Linux)",
|
"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)"],
|
["repl", "", "Open the interactive shell (also: shell, i)"],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@@ -3668,6 +3700,9 @@ const COMMANDS = (() => {
|
|||||||
const SUBCOMMANDS = {
|
const SUBCOMMANDS = {
|
||||||
alerts: ["ack", "ack-all"],
|
alerts: ["ack", "ack-all"],
|
||||||
service: ["install", "uninstall", "status"],
|
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"],
|
pricing: ["set", "delete", "reset"],
|
||||||
webhooks: ["test"],
|
webhooks: ["test"],
|
||||||
import: ["rescan", "path"],
|
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
|
/** Run one parsed command. Returns the handler's promise; may throw
|
||||||
* ServerDownError (for the caller's offline routing) or call process.exit
|
* ServerDownError (for the caller's offline routing) or call process.exit
|
||||||
* for usage/validation failures. `cmd === "repl"` is handled by the entry
|
* for usage/validation failures. `cmd === "repl"` is handled by the entry
|
||||||
@@ -3725,6 +4002,12 @@ async function runCommand(argv) {
|
|||||||
return cmdStart(flags);
|
return cmdStart(flags);
|
||||||
case "service":
|
case "service":
|
||||||
return cmdService(positional[0], flags);
|
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":
|
case "stats":
|
||||||
return cmdStats();
|
return cmdStats();
|
||||||
case "kanban":
|
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
|
- **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
|
- **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:
|
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.
|
- **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, 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).
|
- 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)`.
|
- **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.
|
- `--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.
|
- 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
|
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.
|
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
|
## Utilities
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "./ContextM
|
|||||||
import { fmtCost, timeAgo } from "../lib/format";
|
import { fmtCost, timeAgo } from "../lib/format";
|
||||||
import { toast } from "../lib/toast";
|
import { toast } from "../lib/toast";
|
||||||
import { useAiTools } from "../hooks/useAiTools";
|
import { useAiTools } from "../hooks/useAiTools";
|
||||||
|
import { useSpin } from "../hooks/useSpin";
|
||||||
import type { AlertEvent, AlertRule, AlertRuleType, RulePreview, WSMessage } from "../lib/types";
|
import type { AlertEvent, AlertRule, AlertRuleType, RulePreview, WSMessage } from "../lib/types";
|
||||||
|
|
||||||
const PAGE_SIZE = 25;
|
const PAGE_SIZE = 25;
|
||||||
@@ -342,6 +343,7 @@ export function AlertsNotifications() {
|
|||||||
setLoadingAlerts(false);
|
setLoadingAlerts(false);
|
||||||
}
|
}
|
||||||
}, [unackedOnly, t]);
|
}, [unackedOnly, t]);
|
||||||
|
const [spinning, refreshAlerts] = useSpin(loadAlerts);
|
||||||
|
|
||||||
const loadMore = useCallback(async () => {
|
const loadMore = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -1017,11 +1019,13 @@ export function AlertsNotifications() {
|
|||||||
label={t("feed.unackedOnly")}
|
label={t("feed.unackedOnly")}
|
||||||
/>
|
/>
|
||||||
<button
|
<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"
|
className="btn-ghost border border-border inline-flex items-center gap-1.5 text-xs"
|
||||||
title={t("refresh")}
|
title={t("refresh")}
|
||||||
>
|
>
|
||||||
<RefreshCw className="w-3.5 h-3.5" />
|
<RefreshCw className={`w-3.5 h-3.5 ${spinning ? "animate-spin" : ""}`} />
|
||||||
</button>
|
</button>
|
||||||
{unacked > 0 && (
|
{unacked > 0 && (
|
||||||
<button
|
<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,
|
label,
|
||||||
value,
|
value,
|
||||||
hint,
|
hint,
|
||||||
|
help,
|
||||||
icon,
|
icon,
|
||||||
tone = "default",
|
tone = "default",
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
value: React.ReactNode;
|
value: React.ReactNode;
|
||||||
hint?: string;
|
hint?: string;
|
||||||
|
/** Plain-language explanation, shown as the label's tooltip. */
|
||||||
|
help?: string;
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
tone?: "default" | "violet" | "emerald" | "amber" | "rose" | "cyan" | "blue";
|
tone?: "default" | "violet" | "emerald" | "amber" | "rose" | "cyan" | "blue";
|
||||||
}) {
|
}) {
|
||||||
@@ -120,9 +123,14 @@ function StatTile({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`rounded-lg border px-3 py-2.5 ${palette}`}>
|
<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>
|
<span className={iconTone}>{icon}</span>
|
||||||
{label}
|
<span className={help ? "underline decoration-dotted underline-offset-2" : undefined}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 font-mono text-lg font-semibold text-fg-primary leading-tight">
|
<div className="mt-1 font-mono text-lg font-semibold text-fg-primary leading-tight">
|
||||||
{value}
|
{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">
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-2.5">
|
||||||
<StatTile
|
<StatTile
|
||||||
label={t("overview.events")}
|
label={t("overview.events")}
|
||||||
|
help={t("common:insights.help.events")}
|
||||||
value={stats.total_events.toLocaleString()}
|
value={stats.total_events.toLocaleString()}
|
||||||
hint={
|
hint={
|
||||||
eventRate !== null && eventRate > 0
|
eventRate !== null && eventRate > 0
|
||||||
@@ -322,6 +331,7 @@ export function SessionOverview({ session, agents, stats: frozen }: SessionOverv
|
|||||||
/>
|
/>
|
||||||
<StatTile
|
<StatTile
|
||||||
label={t("overview.toolCalls")}
|
label={t("overview.toolCalls")}
|
||||||
|
help={t("common:insights.help.toolCalls")}
|
||||||
value={toolCallCount.toLocaleString()}
|
value={toolCallCount.toLocaleString()}
|
||||||
hint={distinctTools > 0 ? t("overview.unique", { count: distinctTools }) : undefined}
|
hint={distinctTools > 0 ? t("overview.unique", { count: distinctTools }) : undefined}
|
||||||
icon={<Wrench className="w-3 h-3" />}
|
icon={<Wrench className="w-3 h-3" />}
|
||||||
@@ -329,6 +339,7 @@ export function SessionOverview({ session, agents, stats: frozen }: SessionOverv
|
|||||||
/>
|
/>
|
||||||
<StatTile
|
<StatTile
|
||||||
label={t("overview.subagents")}
|
label={t("overview.subagents")}
|
||||||
|
help={t("common:insights.help.subagents")}
|
||||||
value={stats.agents.subagent.toLocaleString()}
|
value={stats.agents.subagent.toLocaleString()}
|
||||||
hint={
|
hint={
|
||||||
stats.agents.main > 0 ? t("overview.plusMain", { count: stats.agents.main }) : undefined
|
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
|
<StatTile
|
||||||
label={t("overview.compactions")}
|
label={t("overview.compactions")}
|
||||||
|
help={t("common:insights.help.compactions")}
|
||||||
value={stats.agents.compaction.toLocaleString()}
|
value={stats.agents.compaction.toLocaleString()}
|
||||||
icon={<Layers className="w-3 h-3" />}
|
icon={<Layers className="w-3 h-3" />}
|
||||||
tone="blue"
|
tone="blue"
|
||||||
/>
|
/>
|
||||||
<StatTile
|
<StatTile
|
||||||
label={t("overview.errors")}
|
label={t("overview.errors")}
|
||||||
|
help={t("common:insights.help.errors")}
|
||||||
value={stats.error_count.toLocaleString()}
|
value={stats.error_count.toLocaleString()}
|
||||||
icon={<AlertTriangle className="w-3 h-3" />}
|
icon={<AlertTriangle className="w-3 h-3" />}
|
||||||
tone={stats.error_count > 0 ? "rose" : "default"}
|
tone={stats.error_count > 0 ? "rose" : "default"}
|
||||||
@@ -478,7 +491,10 @@ export function SessionOverview({ session, agents, stats: frozen }: SessionOverv
|
|||||||
{totalTokens > 0 && (
|
{totalTokens > 0 && (
|
||||||
<div className="rounded-lg border border-surface-3 bg-surface-2/60 p-3.5">
|
<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">
|
<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" />
|
<Coins className="w-3.5 h-3.5 text-status-warning" />
|
||||||
{t("overview.tokenFlow")}
|
{t("overview.tokenFlow")}
|
||||||
</h3>
|
</h3>
|
||||||
|
|||||||
@@ -69,9 +69,11 @@ import {
|
|||||||
ChevronUp,
|
ChevronUp,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
FolderTree,
|
FolderTree,
|
||||||
|
LogOut,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
import { api } from "../lib/api";
|
import { api } from "../lib/api";
|
||||||
|
import { useAuth } from "./AuthGate";
|
||||||
import { eventBus } from "../lib/eventBus";
|
import { eventBus } from "../lib/eventBus";
|
||||||
import { WorkspaceTree } from "./WorkspaceTree";
|
import { WorkspaceTree } from "./WorkspaceTree";
|
||||||
import type { UpdateStatusPayload, WSMessage } from "../lib/types";
|
import type { UpdateStatusPayload, WSMessage } from "../lib/types";
|
||||||
@@ -188,6 +190,7 @@ export function Sidebar({
|
|||||||
resizing = false,
|
resizing = false,
|
||||||
}: SidebarProps) {
|
}: SidebarProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const auth = useAuth();
|
||||||
const asideRef = useRef<HTMLElement>(null);
|
const asideRef = useRef<HTMLElement>(null);
|
||||||
const drawerOpen = Boolean(drawer?.open);
|
const drawerOpen = Boolean(drawer?.open);
|
||||||
useDialog(drawerOpen, asideRef, drawer?.onClose);
|
useDialog(drawerOpen, asideRef, drawer?.onClose);
|
||||||
@@ -531,6 +534,22 @@ export function Sidebar({
|
|||||||
<Settings className="w-4 h-4 flex-shrink-0" />
|
<Settings className="w-4 h-4 flex-shrink-0" />
|
||||||
{!collapsed && <span>{t("nav:settings")}</span>}
|
{!collapsed && <span>{t("nav:settings")}</span>}
|
||||||
</NavLink>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
|
|||||||
@@ -77,6 +77,8 @@ interface StatCardProps {
|
|||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
/** Drill-in: the whole card links here (e.g. the list the number counts). */
|
/** Drill-in: the whole card links here (e.g. the list the number counts). */
|
||||||
to?: string;
|
to?: string;
|
||||||
|
/** Plain-language explanation of the metric, shown as the label's tooltip. */
|
||||||
|
help?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -92,11 +94,15 @@ export function StatCard({
|
|||||||
raw,
|
raw,
|
||||||
loading = false,
|
loading = false,
|
||||||
to,
|
to,
|
||||||
|
help,
|
||||||
}: StatCardProps) {
|
}: StatCardProps) {
|
||||||
const body = (
|
const body = (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-between gap-3 mb-3">
|
<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}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
<Icon className={`w-5 h-5 flex-shrink-0 ${accentColor}`} />
|
<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 { fmt, fmtCostFull, formatModelName, timeAgo } from "../lib/format";
|
||||||
import { AiToolIcon } from "./AiToolIcon";
|
import { AiToolIcon } from "./AiToolIcon";
|
||||||
import { useLiveRefresh } from "../hooks/useLiveRefresh";
|
import { useLiveRefresh } from "../hooks/useLiveRefresh";
|
||||||
|
import { useSpin } from "../hooks/useSpin";
|
||||||
import { LIVE_RUNS_EVENT, selectedFrom } from "./WorkspaceTree";
|
import { LIVE_RUNS_EVENT, selectedFrom } from "./WorkspaceTree";
|
||||||
|
|
||||||
const COST_POLL_MS = 30_000;
|
const COST_POLL_MS = 30_000;
|
||||||
@@ -265,15 +266,15 @@ function QuotaChips() {
|
|||||||
closeTimer.current = window.setTimeout(() => setOpen(false), 200);
|
closeTimer.current = window.setTimeout(() => setOpen(false), 200);
|
||||||
};
|
};
|
||||||
useEffect(() => () => window.clearTimeout(closeTimer.current), []);
|
useEffect(() => () => window.clearTimeout(closeTimer.current), []);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const load = useCallback(
|
||||||
const load = useCallback((force = false) => {
|
(force = false) =>
|
||||||
if (force) setRefreshing(true);
|
api.pricing
|
||||||
api.pricing
|
.quota(force)
|
||||||
.quota(force)
|
.then((r) => setTools(r.tools))
|
||||||
.then((r) => setTools(r.tools))
|
.catch(() => undefined),
|
||||||
.catch(() => undefined)
|
[]
|
||||||
.finally(() => force && setRefreshing(false));
|
);
|
||||||
}, []);
|
const [refreshing, refresh] = useSpin(() => load(true));
|
||||||
useVisiblePoll(load, QUOTA_POLL_MS, [load]);
|
useVisiblePoll(load, QUOTA_POLL_MS, [load]);
|
||||||
if (!tools.length) return null;
|
if (!tools.length) return null;
|
||||||
return (
|
return (
|
||||||
@@ -282,7 +283,7 @@ function QuotaChips() {
|
|||||||
type="button"
|
type="button"
|
||||||
data-testid="status-bar-quota"
|
data-testid="status-bar-quota"
|
||||||
data-tip-off
|
data-tip-off
|
||||||
onClick={() => load(true)}
|
onClick={refresh}
|
||||||
onMouseEnter={show}
|
onMouseEnter={show}
|
||||||
onMouseLeave={hide}
|
onMouseLeave={hide}
|
||||||
onFocus={show}
|
onFocus={show}
|
||||||
@@ -314,7 +315,7 @@ function QuotaChips() {
|
|||||||
tools={tools}
|
tools={tools}
|
||||||
onEnter={show}
|
onEnter={show}
|
||||||
onLeave={hide}
|
onLeave={hide}
|
||||||
onRefresh={() => load(true)}
|
onRefresh={refresh}
|
||||||
refreshing={refreshing}
|
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);
|
await user.click(refresh);
|
||||||
expect(api.pricing.quota).toHaveBeenCalledTimes(1);
|
expect(api.pricing.quota).toHaveBeenCalledTimes(1);
|
||||||
await act(async () => answer(same));
|
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();
|
expect(screen.getByTestId("status-bar-quota-panel")).toBeInTheDocument();
|
||||||
await user.unhover(panel);
|
await user.unhover(panel);
|
||||||
await waitFor(() => expect(screen.queryByTestId("status-bar-quota-panel")).toBeNull());
|
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 { eventBus } from "../../lib/eventBus";
|
||||||
import { isRemoteDataRefreshMessage } from "../../lib/remoteDataEvents";
|
import { isRemoteDataRefreshMessage } from "../../lib/remoteDataEvents";
|
||||||
import { MessageList } from "./MessageList";
|
import { MessageList } from "./MessageList";
|
||||||
|
import { useSpin } from "../../hooks/useSpin";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { TranscriptMessage, TranscriptInfo, WSMessage } from "../../lib/types";
|
import type { TranscriptMessage, TranscriptInfo, WSMessage } from "../../lib/types";
|
||||||
|
|
||||||
@@ -106,7 +107,6 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
|
|||||||
const genRef = useRef(0);
|
const genRef = useRef(0);
|
||||||
// Refresh-button spinner state - separate from initial `loading` so the
|
// Refresh-button spinner state - separate from initial `loading` so the
|
||||||
// existing skeleton doesn't blink during a manual refresh.
|
// 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
|
// Load available transcript list (also rescanned on a short interval so
|
||||||
// newly-spawned subagents appear in the dropdown without a page reload).
|
// 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
|
// Manual refresh - surfaces a control in the toolbar so users can force
|
||||||
// a sync without reloading the page.
|
// a sync without reloading the page.
|
||||||
const refresh = useCallback(async () => {
|
const [refreshing, refresh] = useSpin(fetchNewMessages);
|
||||||
setRefreshing(true);
|
|
||||||
try {
|
|
||||||
await fetchNewMessages();
|
|
||||||
} finally {
|
|
||||||
setRefreshing(false);
|
|
||||||
}
|
|
||||||
}, [fetchNewMessages]);
|
|
||||||
|
|
||||||
// Scroll-up to load history
|
// Scroll-up to load history
|
||||||
const loadHistory = useCallback(async () => {
|
const loadHistory = useCallback(async () => {
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ import { copyText } from "../../lib/clipboard";
|
|||||||
import { isSubmitEnter } from "../../lib/keys";
|
import { isSubmitEnter } from "../../lib/keys";
|
||||||
import { toast } from "../../lib/toast";
|
import { toast } from "../../lib/toast";
|
||||||
import { useArmed } from "../../hooks/useArmed";
|
import { useArmed } from "../../hooks/useArmed";
|
||||||
|
import { useSpin } from "../../hooks/useSpin";
|
||||||
import { useUnsavedGuard } from "../../hooks/useUnsavedGuard";
|
import { useUnsavedGuard } from "../../hooks/useUnsavedGuard";
|
||||||
import {
|
import {
|
||||||
LaneDiffView,
|
LaneDiffView,
|
||||||
@@ -1421,8 +1422,9 @@ export function LaneChangesPanel({
|
|||||||
setTreeChildren({});
|
setTreeChildren({});
|
||||||
setExpandedDirs(new Set());
|
setExpandedDirs(new Set());
|
||||||
setReread((n) => n + 1);
|
setReread((n) => n + 1);
|
||||||
void load();
|
return load();
|
||||||
};
|
};
|
||||||
|
const [rereading, runReread] = useSpin(rereadAll);
|
||||||
|
|
||||||
const toggleCollapsed = (path: string) =>
|
const toggleCollapsed = (path: string) =>
|
||||||
setCollapsed((prev) => {
|
setCollapsed((prev) => {
|
||||||
@@ -1609,12 +1611,12 @@ export function LaneChangesPanel({
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={rereadAll}
|
onClick={runReread}
|
||||||
aria-label={t("changes.refresh")}
|
aria-label={t("changes.refresh")}
|
||||||
title={t("changes.refresh")}
|
title={t("changes.refresh")}
|
||||||
className="order-last ml-auto shrink-0 text-fg-muted hover:text-fg-primary"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ export function SessionReplay({
|
|||||||
? { runId: holder.id, laneId: lane.id, held: true }
|
? { runId: holder.id, laneId: lane.id, held: true }
|
||||||
: await startInFreeTabDetailed(lane.id, { resumeSessionId: sessionId, prompt: text });
|
: await startInFreeTabDetailed(lane.id, { resumeSessionId: sessionId, prompt: text });
|
||||||
setPrompt("");
|
setPrompt("");
|
||||||
images.forEach((i) => URL.revokeObjectURL(i.url));
|
images.forEach((i) => i.url && URL.revokeObjectURL(i.url));
|
||||||
setImages([]);
|
setImages([]);
|
||||||
onResumed(r.runId, r.laneId);
|
onResumed(r.runId, r.laneId);
|
||||||
// Held already: the prompt reached nothing yet — paste it there.
|
// 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 [menuAt, setMenuAt] = useState<{ x: number; y: number } | null>(null);
|
||||||
const [title, setTitle] = useState<string | null>(null);
|
const [title, setTitle] = useState<string | null>(null);
|
||||||
const fileInput = useRef<HTMLInputElement>(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);
|
const memoryPanel = useRef<HTMLDivElement>(null);
|
||||||
useDialog(memoryOpen && !!memory, memoryPanel, () => setMemoryOpen(false));
|
useDialog(memoryOpen && !!memory, memoryPanel, () => setMemoryOpen(false));
|
||||||
const recentCtx = useContextMenu<Session>();
|
const recentCtx = useContextMenu<Session>();
|
||||||
@@ -328,7 +334,7 @@ function HomeBody({
|
|||||||
...(claude && inWorktree && project?.root ? { worktree: true } : {}),
|
...(claude && inWorktree && project?.root ? { worktree: true } : {}),
|
||||||
});
|
});
|
||||||
setPrompt("");
|
setPrompt("");
|
||||||
images.forEach((i) => URL.revokeObjectURL(i.url));
|
images.forEach((i) => i.url && URL.revokeObjectURL(i.url));
|
||||||
setImages([]);
|
setImages([]);
|
||||||
navigate(`/workspace?lane=${lane.id}&run=${encodeURIComponent(runId)}`);
|
navigate(`/workspace?lane=${lane.id}&run=${encodeURIComponent(runId)}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -839,7 +845,26 @@ function HomeBody({
|
|||||||
</p>
|
</p>
|
||||||
</section>
|
</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">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-sm font-medium text-fg-primary">{t("home.context")}</h3>
|
<h3 className="text-sm font-medium text-fg-primary">{t("home.context")}</h3>
|
||||||
<button
|
<button
|
||||||
@@ -858,12 +883,10 @@ function HomeBody({
|
|||||||
type="file"
|
type="file"
|
||||||
multiple
|
multiple
|
||||||
hidden
|
hidden
|
||||||
onChange={async (e) => {
|
onChange={(e) => {
|
||||||
const picked = [...(e.target.files ?? [])];
|
const picked = [...(e.target.files ?? [])];
|
||||||
e.target.value = "";
|
e.target.value = "";
|
||||||
for (const f of picked) {
|
void addFiles(picked);
|
||||||
if (!(await run(() => api.lanes.addFile(lane.id, f)))) break;
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
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 userEvent from "@testing-library/user-event";
|
||||||
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
|
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
|
||||||
import type { Lane, Session } from "../../../lib/types";
|
import type { Lane, Session } from "../../../lib/types";
|
||||||
@@ -454,6 +454,13 @@ describe("WorkspaceHome", () => {
|
|||||||
const file = new File(["# notes"], "notes.md", { type: "text/markdown" });
|
const file = new File(["# notes"], "notes.md", { type: "text/markdown" });
|
||||||
await user.upload(screen.getByTestId("workspace-file-input"), file);
|
await user.upload(screen.getByTestId("workspace-file-input"), file);
|
||||||
expect(api.lanes.addFile).toHaveBeenCalledWith(1, 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" }));
|
await user.click(screen.getByRole("button", { name: "Remove spec.pdf" }));
|
||||||
expect(api.lanes.removeSource).toHaveBeenCalledWith(1, 7);
|
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
|
* 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
|
* 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
|
* 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`).
|
* opens the same actions as a menu (`ContextMenu`).
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @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 { freeSlot, startInFreeTab } from "../lanes/startSession";
|
||||||
import { PANE_DRAG_TYPE, RunIcon, startPaneDrag, type GroupsUpdate } from "./PaneGrid";
|
import { PANE_DRAG_TYPE, RunIcon, startPaneDrag, type GroupsUpdate } from "./PaneGrid";
|
||||||
import { insertionIndex } from "../../lib/laneOrderStorage";
|
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";
|
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||||
|
|
||||||
type AiToolsState = ReturnType<typeof useAiTools>;
|
type AiToolsState = ReturnType<typeof useAiTools>;
|
||||||
@@ -201,6 +212,10 @@ export function ConsoleTabs({
|
|||||||
// point among the drawn tabs, from the pointer against their middles.
|
// point among the drawn tabs, from the pointer against their middles.
|
||||||
const listRef = useRef<HTMLDivElement>(null);
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
const [dropAt, setDropAt] = useState<number | null>(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 isBarDrag = (e: DragEvent) => {
|
||||||
const types = e.dataTransfer?.types;
|
const types = e.dataTransfer?.types;
|
||||||
return !!types && (types.includes(PANE_DRAG_TYPE) || types.includes(TAB_DRAG_TYPE));
|
return !!types && (types.includes(PANE_DRAG_TYPE) || types.includes(TAB_DRAG_TYPE));
|
||||||
@@ -215,31 +230,70 @@ export function ConsoleTabs({
|
|||||||
x
|
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 = {
|
const dragHandlers = {
|
||||||
onDragOver: (e: DragEvent) => {
|
onDragOver: (e: DragEvent) => {
|
||||||
if (!isBarDrag(e)) return;
|
if (!isBarDrag(e)) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.dataTransfer.dropEffect = "move";
|
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));
|
setDropAt((d) => (d === at ? d : at));
|
||||||
},
|
},
|
||||||
onDragLeave: (e: DragEvent) => {
|
onDragLeave: (e: DragEvent) => {
|
||||||
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setDropAt(null);
|
if (e.currentTarget.contains(e.relatedTarget as Node | null)) return;
|
||||||
},
|
|
||||||
onDragEnd: () => setDropAt(null),
|
|
||||||
onDrop: (e: DragEvent) => {
|
|
||||||
setDropAt(null);
|
setDropAt(null);
|
||||||
|
setJoinAt(null);
|
||||||
|
},
|
||||||
|
onDragEnd: endDrag,
|
||||||
|
onDrop: (e: DragEvent) => {
|
||||||
|
const join = joinIndexAt(e.clientX);
|
||||||
|
endDrag();
|
||||||
if (!isBarDrag(e)) return;
|
if (!isBarDrag(e)) return;
|
||||||
e.preventDefault();
|
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.
|
// The drawn position, as an index into the groups.
|
||||||
const drawn = dropIndexAt(e.clientX);
|
const drawn = dropIndexAt(e.clientX);
|
||||||
const at = tabs[drawn]?.index ?? groups.groups.length;
|
const at = tabs[drawn]?.index ?? groups.groups.length;
|
||||||
const tab = e.dataTransfer.getData(TAB_DRAG_TYPE);
|
|
||||||
if (tab !== "") {
|
if (tab !== "") {
|
||||||
onGroupsChange((g) => reorderGroups(g, Number(tab), at));
|
onGroupsChange((g) => reorderGroups(g, Number(tab), at));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const raw = e.dataTransfer.getData(PANE_DRAG_TYPE);
|
|
||||||
if (raw === "") return;
|
if (raw === "") return;
|
||||||
const slot = Number(raw);
|
const slot = Number(raw);
|
||||||
onGroupsChange((g) => {
|
onGroupsChange((g) => {
|
||||||
@@ -426,6 +480,7 @@ export function ConsoleTabs({
|
|||||||
data-slots={tab.key}
|
data-slots={tab.key}
|
||||||
draggable
|
draggable
|
||||||
onDragStart={(e) => {
|
onDragStart={(e) => {
|
||||||
|
dragged.current = tab.slots;
|
||||||
if (single) startPaneDrag(e, single.slot ?? 0);
|
if (single) startPaneDrag(e, single.slot ?? 0);
|
||||||
else {
|
else {
|
||||||
e.dataTransfer?.setData(TAB_DRAG_TYPE, String(tab.index));
|
e.dataTransfer?.setData(TAB_DRAG_TYPE, String(tab.index));
|
||||||
@@ -439,7 +494,7 @@ export function ConsoleTabs({
|
|||||||
active
|
active
|
||||||
? "bg-surface-0 text-fg-primary"
|
? "bg-surface-0 text-fg-primary"
|
||||||
: "text-fg-muted hover:bg-surface-4 hover:text-fg-secondary"
|
: "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)) && (
|
{(dropAt === i || (dropAt === tabs.length && i === tabs.length - 1)) && (
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
* box (`WorkspaceHome.tsx`):
|
* box (`WorkspaceHome.tsx`):
|
||||||
* - `PromptEditor` — the prompt textarea with `/` (commands and skills,
|
* - `PromptEditor` — the prompt textarea with `/` (commands and skills,
|
||||||
* Claude only) and `@` (`GET /api/run/files`) autocomplete, Shift/Alt+Enter
|
* Claude only) and `@` (`GET /api/run/files`) autocomplete, Shift/Alt+Enter
|
||||||
* newlines, and a pasted or dropped image attached by its saved path
|
* newlines, and pasted or dropped files (images as thumbnails, any other
|
||||||
* (`POST /api/run/drop-image`);
|
* as a name chip) attached by their saved paths (`POST /api/run/drop-file`);
|
||||||
* - `CwdAutocomplete` — the folder field the Add-workspace modal uses;
|
* - `CwdAutocomplete` — the folder field the Add-workspace modal uses;
|
||||||
* - `ModelPicker` — a tool's own model list (`GET /api/ai-tools/:id/models`)
|
* - `ModelPicker` — a tool's own model list (`GET /api/ai-tools/:id/models`)
|
||||||
* or a fixed one (an AI Proxy's models), plus Custom…;
|
* or a fixed one (an AI Proxy's models), plus Custom…;
|
||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
Check,
|
Check,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
FileText,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
Home,
|
Home,
|
||||||
@@ -153,8 +154,8 @@ interface PromptEditorProps {
|
|||||||
slash: boolean;
|
slash: boolean;
|
||||||
/** Where `@` looks for files and project commands/skills are read from. */
|
/** Where `@` looks for files and project commands/skills are read from. */
|
||||||
fileCwd: string;
|
fileCwd: string;
|
||||||
/** Images pasted or dropped so far, shown as thumbnails; the caller sends
|
/** Files pasted or dropped so far (images shown as thumbnails); the caller
|
||||||
* them with the text through `promptWithImages`. */
|
* sends them with the text through `promptWithImages`. */
|
||||||
images: PromptImage[];
|
images: PromptImage[];
|
||||||
onImagesChange: Dispatch<SetStateAction<PromptImage[]>>;
|
onImagesChange: Dispatch<SetStateAction<PromptImage[]>>;
|
||||||
/** Borderless, for a composer box that draws its own frame. */
|
/** Borderless, for a composer box that draws its own frame. */
|
||||||
@@ -164,11 +165,12 @@ interface PromptEditorProps {
|
|||||||
onBusyChange?: (busy: boolean) => void;
|
onBusyChange?: (busy: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** An attached image: its path on the server's machine and a local preview. */
|
/** An attached file: its path on the server's machine, and a local preview
|
||||||
export type PromptImage = { path: string; url: string; name: string };
|
* `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 prompt as sent: the text, then each file's saved path — which is how
|
||||||
* the CLI is handed an image. */
|
* the CLI is handed an image or a file. */
|
||||||
export function promptWithImages(text: string, images: PromptImage[]): string {
|
export function promptWithImages(text: string, images: PromptImage[]): string {
|
||||||
return [text.trim(), ...images.map((i) => i.path)].filter(Boolean).join(" ");
|
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
|
// 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; its path is
|
// route the terminal's drop uses) and shown as a thumbnail or a name chip;
|
||||||
// added to the prompt only when it is sent (promptWithImages).
|
// its path is added to the prompt only when it is sent (promptWithImages).
|
||||||
const [upload, setUpload] = useState<"uploading" | "failed" | null>(null);
|
const [upload, setUpload] = useState<"uploading" | "failed" | null>(null);
|
||||||
const [viewing, setViewing] = useState<PromptImage | null>(null);
|
const [viewing, setViewing] = useState<PromptImage | null>(null);
|
||||||
const [imageMenu, setImageMenu] = useState<(MenuPoint & { img: PromptImage }) | null>(null);
|
const [imageMenu, setImageMenu] = useState<(MenuPoint & { img: PromptImage }) | null>(null);
|
||||||
@@ -320,15 +322,15 @@ export function PromptEditor({
|
|||||||
busyRef.current = onBusyChange;
|
busyRef.current = onBusyChange;
|
||||||
useEffect(() => busyRef.current?.(uploading), [uploading]);
|
useEffect(() => busyRef.current?.(uploading), [uploading]);
|
||||||
const attachImages = (list: FileList | null | undefined) => {
|
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;
|
if (picked.length === 0) return false;
|
||||||
setUpload("uploading");
|
setUpload("uploading");
|
||||||
Promise.all(picked.map((f) => api.run.dropImage(f)))
|
Promise.all(picked.map((f) => api.run.dropFile(f)))
|
||||||
.then((saved) => {
|
.then((saved) => {
|
||||||
setUpload(null);
|
setUpload(null);
|
||||||
const added = picked.map((f, i) => ({
|
const added = picked.map((f, i) => ({
|
||||||
path: saved[i]!.path,
|
path: saved[i]!.path,
|
||||||
url: URL.createObjectURL(f),
|
url: f.type.startsWith("image/") ? URL.createObjectURL(f) : undefined,
|
||||||
name: f.name,
|
name: f.name,
|
||||||
}));
|
}));
|
||||||
onImagesChange((prev) => [...prev, ...added]);
|
onImagesChange((prev) => [...prev, ...added]);
|
||||||
@@ -337,7 +339,7 @@ export function PromptEditor({
|
|||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
const removeImage = (img: PromptImage) => {
|
const removeImage = (img: PromptImage) => {
|
||||||
URL.revokeObjectURL(img.url);
|
if (img.url) URL.revokeObjectURL(img.url);
|
||||||
onImagesChange((prev) => prev.filter((x) => x !== img));
|
onImagesChange((prev) => prev.filter((x) => x !== img));
|
||||||
};
|
};
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -394,17 +396,27 @@ export function PromptEditor({
|
|||||||
{images.length > 0 && (
|
{images.length > 0 && (
|
||||||
<div data-testid="prompt-images" className="mb-2 flex flex-wrap gap-2 px-1 pt-1">
|
<div data-testid="prompt-images" className="mb-2 flex flex-wrap gap-2 px-1 pt-1">
|
||||||
{images.map((img) => (
|
{images.map((img) => (
|
||||||
<div key={img.url} className="group relative">
|
<div key={img.path} className="group relative">
|
||||||
<button
|
{img.url ? (
|
||||||
type="button"
|
<button
|
||||||
aria-label={img.name}
|
type="button"
|
||||||
title={img.name}
|
aria-label={img.name}
|
||||||
onClick={() => setViewing(img)}
|
title={img.name}
|
||||||
onContextMenu={(e) => setImageMenu({ ...menuPoint(e), img })}
|
onClick={() => setViewing(img)}
|
||||||
className="block h-14 w-14 overflow-hidden rounded-lg border border-border bg-surface-3 hover:border-accent/50"
|
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>
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={t("drop.remove")}
|
aria-label={t("drop.remove")}
|
||||||
|
|||||||
@@ -14,9 +14,10 @@
|
|||||||
* (`loadXterm`), not bundled with the Workspace chunk.
|
* (`loadXterm`), not bundled with the Workspace chunk.
|
||||||
* Always painted dark, in the palette of the user's chosen DARK theme
|
* Always painted dark, in the palette of the user's chosen DARK theme
|
||||||
* (`lib/terminalTheme.ts`), recoloured live without re-attaching.
|
* (`lib/terminalTheme.ts`), recoloured live without re-attaching.
|
||||||
* An image dropped on it is saved on the server (`POST /api/run/drop-image`)
|
* Files (images or any other) dropped on it are saved on the server
|
||||||
* and its PATH pasted into the pane — what a local terminal does on a drop,
|
* (`POST /api/run/drop-file`) and their PATHS pasted into the pane — what a
|
||||||
* and how `claude` attaches an image. The pane also registers with
|
* 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
|
* `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.
|
* pasted into whichever of the workspace's panes was focused last.
|
||||||
* Keyboard: the terminal keeps Tab and Shift+Tab (claude cycles its permission
|
* 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 { api } from "../../lib/api";
|
||||||
import { notePaneFocus, registerPane } from "../../lib/panePaste";
|
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). */
|
/** localStorage flag for xterm's screen-reader mode (a Settings toggle can set it later). */
|
||||||
export const SCREEN_READER_KEY = "agenthub.terminal.screenReader";
|
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
|
if (!hasFiles(e)) return; // dragged text keeps xterm's own handling
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
dragDepth.current = 0;
|
dragDepth.current = 0;
|
||||||
const images = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith("image/"));
|
const files = Array.from(e.dataTransfer.files);
|
||||||
if (images.length === 0) return flash("imagesOnly");
|
if (files.length === 0) return;
|
||||||
setDrop("uploading");
|
setDrop("uploading");
|
||||||
try {
|
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
|
// 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?.paste(saved.map((r) => r.path).join(" ") + " ");
|
||||||
termRef.current?.focus();
|
termRef.current?.focus();
|
||||||
setDrop(null);
|
setDrop(null);
|
||||||
@@ -650,9 +651,7 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
|||||||
<div
|
<div
|
||||||
data-testid="terminal-drop"
|
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 ${
|
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"
|
drop === "failed" ? "border-red-400 text-red-300" : "border-sky-400 text-sky-200"
|
||||||
? "border-red-400 text-red-300"
|
|
||||||
: "border-sky-400 text-sky-200"
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{t(`drop.${drop}`)}
|
{t(`drop.${drop}`)}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
|||||||
return { flag: id in lists, models: lists[id] ?? [], defaultModel: null };
|
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: {
|
ccConfig: {
|
||||||
commands: r({
|
commands: r({
|
||||||
items: [
|
items: [
|
||||||
@@ -195,7 +195,7 @@ describe("PromptEditor — newlines and images", () => {
|
|||||||
const img = new File(["x"], "shot.png", { type: "image/png" });
|
const img = new File(["x"], "shot.png", { type: "image/png" });
|
||||||
fireEvent.paste(box(), { clipboardData: { files: [img] } });
|
fireEvent.paste(box(), { clipboardData: { files: [img] } });
|
||||||
const thumb = await screen.findByRole("button", { name: "shot.png" });
|
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.
|
// 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");
|
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" });
|
fireEvent.keyDown(document, { key: "Escape" });
|
||||||
expect(screen.queryByRole("dialog")).toBeNull();
|
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(screen.queryByRole("button", { name: "shot.png" })).toBeNull();
|
||||||
expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:shot");
|
expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:shot");
|
||||||
expect(screen.getByTestId("sent").textContent).toBe("see");
|
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", () => {
|
it("leaves a text paste to the textarea", () => {
|
||||||
renderEditor("");
|
renderEditor("");
|
||||||
fireEvent.paste(box(), { clipboardData: { files: [] } });
|
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 keyHandlerMock = vi.fn();
|
||||||
const focusMock = vi.fn();
|
const focusMock = vi.fn();
|
||||||
const pasteMock = vi.fn();
|
const pasteMock = vi.fn();
|
||||||
const dropImageMock = vi.fn();
|
const dropFileMock = vi.fn();
|
||||||
vi.mock("../../../lib/api", () => ({
|
vi.mock("../../../lib/api", () => ({
|
||||||
api: { run: { dropImage: (f: Blob) => dropImageMock(f) } },
|
api: { run: { dropFile: (f: File) => dropFileMock(f) } },
|
||||||
}));
|
}));
|
||||||
const loadAddonMock = vi.fn();
|
const loadAddonMock = vi.fn();
|
||||||
// Mutable stand-ins for the two live terminal facts the wheel handler reads.
|
// 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 () => {
|
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();
|
pasteMock.mockClear();
|
||||||
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
|
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
|
||||||
const img = new File([new Uint8Array([137, 80])], "shot.png", { type: "image/png" });
|
const img = new File([new Uint8Array([137, 80])], "shot.png", { type: "image/png" });
|
||||||
const target = screen.getByTestId("terminal-view");
|
const target = screen.getByTestId("terminal-view");
|
||||||
fireEvent.dragEnter(target, { dataTransfer: { types: ["Files"], files: [img] } });
|
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] } });
|
fireEvent.drop(target, { dataTransfer: { types: ["Files"], files: [img] } });
|
||||||
await waitFor(() => expect(pasteMock).toHaveBeenCalledWith("/tmp/agenthub-drops-1/ab.png "));
|
await waitFor(() =>
|
||||||
expect(dropImageMock).toHaveBeenCalledWith(img);
|
expect(pasteMock).toHaveBeenCalledWith("/tmp/agenthub-drops-1/ab-shot.png ")
|
||||||
|
);
|
||||||
|
expect(dropFileMock).toHaveBeenCalledWith(img);
|
||||||
await waitFor(() => expect(screen.queryByTestId("terminal-drop")).toBeNull());
|
await waitFor(() => expect(screen.queryByTestId("terminal-drop")).toBeNull());
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uploads nothing for a dropped non-image and says why", async () => {
|
it("pastes every path of several dropped files, images or not", async () => {
|
||||||
dropImageMock.mockReset();
|
dropFileMock
|
||||||
|
.mockReset()
|
||||||
|
.mockImplementation(async (f: File) => ({ path: `/tmp/agenthub-drops-1/x-${f.name}` }));
|
||||||
pasteMock.mockClear();
|
pasteMock.mockClear();
|
||||||
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
|
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
|
||||||
const txt = new File(["hi"], "a.txt", { type: "text/plain" });
|
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"), {
|
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();
|
await waitFor(() =>
|
||||||
expect(dropImageMock).not.toHaveBeenCalled();
|
expect(pasteMock).toHaveBeenCalledWith(
|
||||||
expect(pasteMock).not.toHaveBeenCalled();
|
"/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",
|
darcula: "Darcula",
|
||||||
"apple-dark": "Apple Dark",
|
"apple-dark": "Apple Dark",
|
||||||
"apple-light": "Apple Light",
|
"apple-light": "Apple Light",
|
||||||
"intellij-light": "IntelliJ Light",
|
|
||||||
canvas: "Canvas",
|
canvas: "Canvas",
|
||||||
"orca-dark": "Orca Dark",
|
"orca-dark": "Orca Dark",
|
||||||
"orca-light": "Orca Light",
|
"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",
|
"Apple Dark",
|
||||||
"Orca Dark",
|
"Orca Dark",
|
||||||
"Apple Light",
|
"Apple Light",
|
||||||
"IntelliJ Light",
|
|
||||||
"Canvas",
|
"Canvas",
|
||||||
"Orca Light",
|
"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. */
|
/** Named themes per mode, in picker order. `default` is the stock palette. */
|
||||||
export const THEMES = {
|
export const THEMES = {
|
||||||
dark: ["default", "darcula", "apple-dark", "orca-dark"],
|
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[]>;
|
} as const satisfies Record<Theme, readonly string[]>;
|
||||||
|
|
||||||
const MODE_KEY = "theme";
|
const MODE_KEY = "theme";
|
||||||
|
|||||||
@@ -202,5 +202,32 @@
|
|||||||
"message": "Your edits have not been saved and will be lost.",
|
"message": "Your edits have not been saved and will be lost.",
|
||||||
"discard": "Discard",
|
"discard": "Discard",
|
||||||
"keep": "Keep editing"
|
"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"
|
"output": "Output"
|
||||||
},
|
},
|
||||||
"drop": {
|
"drop": {
|
||||||
"hint": "Drop image to attach",
|
"hint": "Drop files or images to attach",
|
||||||
"uploading": "Uploading image…",
|
"uploading": "Uploading…",
|
||||||
"failed": "Could not attach the image",
|
"failed": "Could not attach the file",
|
||||||
"imagesOnly": "Only images can be dropped here",
|
"remove": "Remove attachment",
|
||||||
"remove": "Remove image",
|
|
||||||
"view": "View image"
|
"view": "View image"
|
||||||
},
|
},
|
||||||
"terminal": {
|
"terminal": {
|
||||||
|
|||||||
@@ -823,6 +823,10 @@
|
|||||||
"reports": {
|
"reports": {
|
||||||
"title": "Reports",
|
"title": "Reports",
|
||||||
"description": "Analytics summaries written on a schedule, as HTML or JSON."
|
"description": "Analytics summaries written on a schedule, as HTML or JSON."
|
||||||
|
},
|
||||||
|
"users": {
|
||||||
|
"title": "Users",
|
||||||
|
"description": "Accounts, their usage and the audit log."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"backPrevious": "Back",
|
"backPrevious": "Back",
|
||||||
@@ -942,5 +946,21 @@
|
|||||||
"importBody": "The file's preferences replace this browser's, then the page reloads.",
|
"importBody": "The file's preferences replace this browser's, then the page reloads.",
|
||||||
"imported": "Imported {{count}} preference(s). Reloading…",
|
"imported": "Imported {{count}} preference(s). Reloading…",
|
||||||
"badFile": "That file is not an AgentHub preferences file."
|
"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.",
|
"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",
|
"discard": "Bỏ thay đổi",
|
||||||
"keep": "Tiếp tục sửa"
|
"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"
|
"output": "Output"
|
||||||
},
|
},
|
||||||
"drop": {
|
"drop": {
|
||||||
"hint": "Thả ảnh để đính kèm",
|
"hint": "Thả file hoặc ảnh để đính kèm",
|
||||||
"uploading": "Đang tải ảnh lên…",
|
"uploading": "Đang tải lên…",
|
||||||
"failed": "Không đính kèm được ảnh",
|
"failed": "Không đính kèm được file",
|
||||||
"imagesOnly": "Chỉ thả được ảnh vào đây",
|
"remove": "Bỏ đính kèm",
|
||||||
"remove": "Bỏ ảnh",
|
|
||||||
"view": "Xem ảnh"
|
"view": "Xem ảnh"
|
||||||
},
|
},
|
||||||
"terminal": {
|
"terminal": {
|
||||||
|
|||||||
@@ -823,6 +823,10 @@
|
|||||||
"reports": {
|
"reports": {
|
||||||
"title": "Báo cáo",
|
"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."
|
"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",
|
"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.",
|
"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…",
|
"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."
|
"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)
|
* 11-12 text (11 secondary, 12 primary)
|
||||||
* The DARK theme follows it literally: `slate` supplies surface/border/fg and
|
* The DARK theme follows it literally: `slate` supplies surface/border/fg and
|
||||||
* `blue` supplies accent. The LIGHT theme keeps the structure (same roles, same
|
* `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
|
* rising-lightness ramp) with IntelliJ Light's neutrals and blue accent. `green`/
|
||||||
* and terracotta, because a cool near-white page reads as glare. `green`/
|
|
||||||
* `red`/`amber` step 11 supplies the status tokens — the same step used for
|
* `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
|
* 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
|
* 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>.
|
/* 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
|
`[data-mode="light"]` lets a light theme preview re-scope inside a dark
|
||||||
page (the Settings appearance cards).
|
page (the Settings appearance cards).
|
||||||
Radix `slate` light, RE-ORDERED so lightness rises with the index exactly
|
IntelliJ Light's cool neutrals, ordered so lightness rises with the index
|
||||||
as it does in dark: the page is the DARKEST step and a card is brighter
|
exactly as it does in dark: the page is the DARKEST step and a card is
|
||||||
than the page it sits on. The previous light block used the scale in its
|
brighter than the page it sits on, so every `hover:bg-*` lifts. */
|
||||||
native direction, which inverted the whole language — a near-white page
|
--surface-0: 236 238 242; /* #eceef2 — page background */
|
||||||
(glare) under grey cards that read as recessed, and every `hover:bg-*`
|
--surface-1: 241 242 245; /* #f1f2f5 — sub-headers inside a card */
|
||||||
darkening instead of lifting.
|
--surface-2: 247 248 250; /* #f7f8fa — inputs, chips, toolbars */
|
||||||
|
--surface-3: 253 253 254; /* #fdfdfe — `.card` */
|
||||||
The HUE is warm — Claude's own ivory/terracotta family, not a cool slate.
|
--surface-4: 255 255 255; /* #ffffff — hover / active lift */
|
||||||
A cool near-white ramp kept reading as glare no matter how far the page
|
--surface-5: 255 255 255; /* #ffffff — topmost, rare */
|
||||||
step was pulled down, because the brightest thing on screen was also the
|
--border: 211 213 219; /* #d3d5db */
|
||||||
bluest. Cream lowers the perceived glare at the same measured lightness
|
--border-light: 228 230 235; /* #e4e6eb */
|
||||||
and gives every neutral a warm cast to sit against. The whole ramp sits
|
--accent: 40 90 192; /* #285ac0 */
|
||||||
one notch below Claude's own ivory (#faf9f5 card) and no step reaches pure
|
--accent-hover: 33 74 158; /* #214a9e */
|
||||||
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 */
|
|
||||||
/* The FILL behind white text (`.btn-primary`, a Switch's on-track, a ticked
|
/* 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
|
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
|
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). */
|
white text cannot sit on (3.3:1). */
|
||||||
--accent-solid: var(--accent);
|
--accent-solid: var(--accent);
|
||||||
--accent-solid-hover: var(--accent-hover);
|
--accent-solid-hover: var(--accent-hover);
|
||||||
--fg-primary: 31 30 27; /* #1f1e1b — 15.0:1 on a card */
|
--fg-primary: 30 31 34; /* #1e1f22 */
|
||||||
--fg-secondary: 96 91 83; /* #605b53 — 6.1:1 */
|
--fg-secondary: 73 75 87; /* #494b57 */
|
||||||
/* Measured, not eyeballed: this carries real content (timestamps, counts),
|
/* Carries real content (timestamps, counts), so it is held above 4.5:1 on a
|
||||||
so it is held above 4.5:1 — 5.4:1 on a card, 4.7:1 on the page — and still
|
card and still sits a clear step below fg-secondary. */
|
||||||
sits a clear step below fg-secondary. */
|
--fg-muted: 103 106 120; /* #676a78 */
|
||||||
--fg-muted: 104 99 89; /* #686359 */
|
/* The SHADOW carries "raised" here, not the quiet border. */
|
||||||
/* Warm and soft, to match the surfaces: a neutral-grey shadow goes muddy
|
--shadow-card: 0 1px 2px rgb(30 31 34 / 0.05), 0 2px 6px rgb(30 31 34 / 0.06);
|
||||||
over cream. The SHADOW carries "raised" here, not the quiet border. */
|
/* Status labels clear 4.5:1 on the card AND on their own 20% chip. */
|
||||||
--shadow-card: 0 1px 2px rgb(60 50 30 / 0.05), 0 2px 6px rgb(60 50 30 / 0.07);
|
--status-success: 23 106 45; /* #176a2d */
|
||||||
/* Status: darkened two steps from Radix 11. One step cleared 4.5:1 against
|
--status-danger: 173 28 38; /* #ad1c26 */
|
||||||
the card; these also clear it against the status's OWN densest chip (a
|
--status-warning: 124 82 0; /* #7c5200 */
|
||||||
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 */
|
|
||||||
/* What every status/accent FILL is pre-mixed against (see `tint()` in
|
/* What every status/accent FILL is pre-mixed against (see `tint()` in
|
||||||
tailwind.config.js) — the card surface, because that is where chips and
|
tailwind.config.js) — the card surface, because that is where chips and
|
||||||
alert strips overwhelmingly sit. */
|
alert strips overwhelmingly sit. */
|
||||||
/* Re-declared by every scope that sets a theme (this block, `.dark`), so it
|
/* 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. */
|
resolves against THAT scope's surface-3; a new scope must re-declare it too. */
|
||||||
--tint-base: var(--surface-3);
|
--tint-base: var(--surface-3);
|
||||||
/* Syntax colours for `lib/highlight.ts`. Same hues as the dark set, taken
|
/* Syntax colours for `lib/highlight.ts` — IntelliJ Light's editor scheme. */
|
||||||
four steps down the Tailwind ramp (300 → 700): a pastel keyword is legible
|
--syn-comment: 112 112 112; /* #707070 */
|
||||||
on a near-black code block and invisible on a light one. */
|
--syn-string: 6 125 23; /* #067d17 */
|
||||||
--syn-comment: 107 114 128; /* gray-500 #6b7280 */
|
--syn-number: 23 80 235; /* #1750eb */
|
||||||
--syn-string: 4 120 87; /* emerald-700 #047857 */
|
--syn-keyword: 0 51 179; /* #0033b3 */
|
||||||
--syn-number: 194 65 12; /* orange-700 #c2410c */
|
--syn-builtin: 0 0 128; /* #000080 */
|
||||||
--syn-keyword: 109 40 217; /* violet-700 #6d28d9 */
|
--syn-function: 0 98 122; /* #00627a */
|
||||||
--syn-builtin: 3 105 161; /* sky-700 #0369a1 */
|
--syn-operator: 75 75 75; /* #4b4b4b */
|
||||||
--syn-function: 180 83 9; /* amber-700 #b45309 */
|
--syn-punctuation: 75 75 75; /* #4b4b4b */
|
||||||
--syn-operator: 190 24 93; /* pink-700 #be185d */
|
--syn-property: 135 16 148; /* #871094 */
|
||||||
--syn-punctuation: 107 114 128; /* gray-500 #6b7280 */
|
--syn-tag: 0 51 179; /* #0033b3 */
|
||||||
--syn-property: 14 116 144; /* cyan-700 #0e7490 */
|
|
||||||
--syn-tag: 190 18 60; /* rose-700 #be123c */
|
|
||||||
--syn-plain: var(--fg-primary);
|
--syn-plain: var(--fg-primary);
|
||||||
--syn-add: 4 120 87; /* emerald-700 */
|
--syn-add: 6 125 23; /* #067d17 */
|
||||||
--syn-del: 185 28 28; /* red-700 #b91c1c */
|
--syn-del: 192 31 42; /* #c01f2a */
|
||||||
/* A diff line edited in place (not wholly added or removed). */
|
/* A diff line edited in place (not wholly added or removed). */
|
||||||
--diff-mod: 29 78 216; /* blue-700 #1d4ed8 */
|
--diff-mod: 29 78 216; /* blue-700 #1d4ed8 */
|
||||||
/* A file on the Changes panel's never-stage list (orange, apart from amber M). */
|
/* 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 */
|
--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"] {
|
[data-mode="light"][data-theme="canvas"] {
|
||||||
--surface-0: 227 220 205; /* #e3dccd */
|
--surface-0: 227 220 205; /* #e3dccd */
|
||||||
--surface-1: 232 226 212; /* #e8e2d4 */
|
--surface-1: 232 226 212; /* #e8e2d4 */
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
* the pane that takes its place, swapping/moving panes, resizing a split,
|
* 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
|
* 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
|
* 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>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect, beforeEach } from "vitest";
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
@@ -17,6 +18,7 @@ import {
|
|||||||
MAX_PANES,
|
MAX_PANES,
|
||||||
closeInLayout,
|
closeInLayout,
|
||||||
detachSlot,
|
detachSlot,
|
||||||
|
joinGroup,
|
||||||
layoutRects,
|
layoutRects,
|
||||||
leaves,
|
leaves,
|
||||||
loadGroups,
|
loadGroups,
|
||||||
@@ -203,7 +205,10 @@ describe("pane groups (tabs of up to four panes)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("activates the tab already holding a session, focusing it", () => {
|
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);
|
const out = openInGroups(g, 2, false);
|
||||||
expect(out.active).toBe(1);
|
expect(out.active).toBe(1);
|
||||||
expect(out.groups[1]!.focus).toBe(2);
|
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", () => {
|
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 };
|
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, 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);
|
expect(removeSlot(g, 7)).toBe(g);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("prunes dead panes and gives every live session without a tab one", () => {
|
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());
|
const out = pruneGroups(g, new Set([0, 4]), new Set());
|
||||||
expect(out.groups.map((x) => leaves(x.root))).toEqual([[0], [4]]);
|
expect(out.groups.map((x) => leaves(x.root))).toEqual([[0], [4]]);
|
||||||
expect(out.active).toBe(0);
|
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", () => {
|
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);
|
const out = detachSlot(g, 1, 0);
|
||||||
expect(out.groups.map((x) => leaves(x.root))).toEqual([[1], [0], [2]]);
|
expect(out.groups.map((x) => leaves(x.root))).toEqual([[1], [0], [2]]);
|
||||||
expect(out.active).toBe(0);
|
expect(out.active).toBe(0);
|
||||||
@@ -272,19 +286,36 @@ describe("parseLayout / storage", () => {
|
|||||||
beforeEach(() => window.localStorage.clear());
|
beforeEach(() => window.localStorage.clear());
|
||||||
|
|
||||||
it("stores tabs per workspace and reads a pre-tab single layout as one tab", () => {
|
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);
|
saveGroups(1, g);
|
||||||
expect(loadGroups(1)).toEqual(g);
|
expect(loadGroups(1)).toEqual(g);
|
||||||
window.localStorage.setItem(
|
window.localStorage.setItem(
|
||||||
"agenthub.workspace.paneLayout",
|
"agenthub.workspace.paneLayout",
|
||||||
JSON.stringify({ 2: { root: split("row", pane(0), pane(1)), focus: 1 } })
|
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", () => {
|
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);
|
expect(parseGroups({ groups: [{ root: pane(10), focus: 10 }], active: 0 })).toEqual(
|
||||||
const out = parseGroups({ groups: [{ root: pane(1), focus: 1 }, { root: pane(1), focus: 1 }], active: 1 });
|
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 });
|
expect(out).toEqual({ groups: [{ root: pane(1), focus: 1 }], active: 0 });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -334,3 +365,47 @@ describe("parseLayout / storage", () => {
|
|||||||
expect(loadGroups(1)).toEqual(EMPTY_GROUPS);
|
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
|
* that needs to branch on which error this was should check `.code`, never
|
||||||
* guess at substrings of the human-readable `.message`.
|
* 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
|
/** GETs in flight, by path: concurrent callers of the same read (several
|
||||||
* components mounting, a WS event fanning out to many listeners) share one
|
* 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
|
* 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 `?.`
|
// isn't JSON (or json() throws), fall back to an empty object so the `?.`
|
||||||
// chain below cleanly degrades to the generic `HTTP <status>` message.
|
// chain below cleanly degrades to the generic `HTTP <status>` message.
|
||||||
const body = await res.json().catch(() => ({}));
|
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}`), {
|
throw Object.assign(new Error(body?.error?.message || `HTTP ${res.status}`), {
|
||||||
code: body?.error?.code,
|
code: body?.error?.code,
|
||||||
status: res.status,
|
status: res.status,
|
||||||
@@ -625,6 +656,26 @@ export function gitTarget(laneId: number, session?: string | null): GitTarget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const api = {
|
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 ─────────────────────────
|
// ───────────────────────── Updates / self-update API ─────────────────────────
|
||||||
/** Self-update status: whether this install is a git clone and, if so,
|
/** Self-update status: whether this install is a git clone and, if so,
|
||||||
* whether the tracked upstream/origin remote is ahead. Backs the "update
|
* whether the tracked upstream/origin remote is ahead. Backs the "update
|
||||||
@@ -2318,6 +2369,14 @@ export const api = {
|
|||||||
body: file,
|
body: file,
|
||||||
headers: { "Content-Type": file.type },
|
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. */
|
/** POST /api/run - start (or adopt, if already live) a lane's terminal run. */
|
||||||
start: (args: RunStartArgs) =>
|
start: (args: RunStartArgs) =>
|
||||||
request<RunHandle>("/run", { method: "POST", body: JSON.stringify(args) }),
|
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);
|
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. */
|
/** 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 {
|
export function reorderGroups(g: PaneGroups, from: number, at: number): PaneGroups {
|
||||||
if (from < 0 || from === at || from + 1 === at) return g;
|
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-400.css";
|
||||||
import "@fontsource/jetbrains-mono/vietnamese-500.css";
|
import "@fontsource/jetbrains-mono/vietnamese-500.css";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
|
import { AuthGate } from "./components/AuthGate";
|
||||||
import { i18nReady } from "./i18n";
|
import { i18nReady } from "./i18n";
|
||||||
|
import "./index.css";
|
||||||
|
|
||||||
// Deep link into the Workspace: start its (lazy) chunk now, in parallel with the
|
// 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.
|
// locale fetch below, instead of after the first render asks for it.
|
||||||
if (location.pathname.startsWith("/workspace")) void import("./pages/Workspace");
|
if (location.pathname.startsWith("/workspace")) void import("./pages/Workspace");
|
||||||
import "./index.css";
|
|
||||||
|
|
||||||
if ("serviceWorker" in navigator) {
|
if ("serviceWorker" in navigator) {
|
||||||
// Detect whether the page is already controlled by an SW *before* we
|
// Detect whether the page is already controlled by an SW *before* we
|
||||||
@@ -103,7 +105,9 @@ void i18nReady
|
|||||||
.then(() =>
|
.then(() =>
|
||||||
createRoot(root).render(
|
createRoot(root).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<AuthGate>
|
||||||
|
<App />
|
||||||
|
</AuthGate>
|
||||||
</StrictMode>
|
</StrictMode>
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ import { api } from "../lib/api";
|
|||||||
import { eventBus } from "../lib/eventBus";
|
import { eventBus } from "../lib/eventBus";
|
||||||
import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
|
import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
|
||||||
import { useLiveRefresh } from "../hooks/useLiveRefresh";
|
import { useLiveRefresh } from "../hooks/useLiveRefresh";
|
||||||
|
import { useSpin } from "../hooks/useSpin";
|
||||||
import { downloadText, exportName, toCsv } from "../lib/exportData";
|
import { downloadText, exportName, toCsv } from "../lib/exportData";
|
||||||
import { toast } from "../lib/toast";
|
import { toast } from "../lib/toast";
|
||||||
import { useDataScope } from "../lib/dataScope";
|
import { useDataScope } from "../lib/dataScope";
|
||||||
@@ -234,6 +235,7 @@ export function ActivityFeed({ embedded = false }: { embedded?: boolean } = {})
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load();
|
load();
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
const [spinning, refresh] = useSpin(() => load());
|
||||||
|
|
||||||
// Agent labels for agents on screen not asked about yet — keyed by agent, so
|
// 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.
|
// 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")}
|
<Download className="w-4 h-4" /> {t("exportJson")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => load()}
|
onClick={refresh}
|
||||||
|
disabled={spinning}
|
||||||
|
aria-busy={spinning}
|
||||||
className="btn-ghost"
|
className="btn-ghost"
|
||||||
aria-label={t("common:refresh")}
|
aria-label={t("common:refresh")}
|
||||||
title={t("common:refresh")}
|
title={t("common:refresh")}
|
||||||
>
|
>
|
||||||
<RefreshCw className="w-4 h-4" />
|
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ import {
|
|||||||
import { api } from "../lib/api";
|
import { api } from "../lib/api";
|
||||||
import { eventBus } from "../lib/eventBus";
|
import { eventBus } from "../lib/eventBus";
|
||||||
import { useLiveRefresh } from "../hooks/useLiveRefresh";
|
import { useLiveRefresh } from "../hooks/useLiveRefresh";
|
||||||
|
import { useSpin } from "../hooks/useSpin";
|
||||||
import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
|
import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
|
||||||
import { useDataScope } from "../lib/dataScope";
|
import { useDataScope } from "../lib/dataScope";
|
||||||
import { keepIfSame } from "../lib/keepIfSame";
|
import { keepIfSame } from "../lib/keepIfSame";
|
||||||
@@ -779,6 +780,7 @@ function AnalyticsTab() {
|
|||||||
if (seq === loadSeq.current) setLoading(false);
|
if (seq === loadSeq.current) setLoading(false);
|
||||||
}
|
}
|
||||||
}, [scope, tool, range]);
|
}, [scope, tool, range]);
|
||||||
|
const [spinning, refresh] = useSpin(load);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load();
|
load();
|
||||||
@@ -1067,8 +1069,13 @@ function AnalyticsTab() {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={load} className="btn-ghost" disabled={loading}>
|
<button
|
||||||
<RefreshCw className={`w-4 h-4 ${loading ? "animate-spin" : ""}`} />
|
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")}
|
{t("common:refresh")}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={handleExport} className="btn-ghost" disabled={!data}>
|
<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">
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
|
||||||
<StatCard
|
<StatCard
|
||||||
label={t("totalSessions")}
|
label={t("totalSessions")}
|
||||||
|
help={t("common:insights.help.sessions")}
|
||||||
to="/sessions"
|
to="/sessions"
|
||||||
value={data ? fmt(data.overview.total_sessions) : ""}
|
value={data ? fmt(data.overview.total_sessions) : ""}
|
||||||
raw={data ? data.overview.total_sessions.toLocaleString() : undefined}
|
raw={data ? data.overview.total_sessions.toLocaleString() : undefined}
|
||||||
@@ -1105,6 +1113,7 @@ function AnalyticsTab() {
|
|||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label={t("totalAgents")}
|
label={t("totalAgents")}
|
||||||
|
help={t("common:insights.help.agents")}
|
||||||
value={data ? fmt(data.overview.total_agents) : ""}
|
value={data ? fmt(data.overview.total_agents) : ""}
|
||||||
raw={data ? data.overview.total_agents.toLocaleString() : undefined}
|
raw={data ? data.overview.total_agents.toLocaleString() : undefined}
|
||||||
trend={data ? `${data.overview.active_agents} ${t("common:active")}` : undefined}
|
trend={data ? `${data.overview.active_agents} ${t("common:active")}` : undefined}
|
||||||
@@ -1114,6 +1123,7 @@ function AnalyticsTab() {
|
|||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label={t("tokensExCacheReads")}
|
label={t("tokensExCacheReads")}
|
||||||
|
help={t("common:insights.help.tokens")}
|
||||||
value={data ? fmt(nonCacheReadTokens) : ""}
|
value={data ? fmt(nonCacheReadTokens) : ""}
|
||||||
raw={data ? nonCacheReadTokens.toLocaleString() : undefined}
|
raw={data ? nonCacheReadTokens.toLocaleString() : undefined}
|
||||||
trend={
|
trend={
|
||||||
@@ -1130,6 +1140,7 @@ function AnalyticsTab() {
|
|||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label={t("totalCost")}
|
label={t("totalCost")}
|
||||||
|
help={t("common:insights.help.cost")}
|
||||||
value={costData ? fmtCost(costData.total_cost) : ""}
|
value={costData ? fmtCost(costData.total_cost) : ""}
|
||||||
raw={costData ? fmtCostFull(costData.total_cost) : undefined}
|
raw={costData ? fmtCostFull(costData.total_cost) : undefined}
|
||||||
trend={
|
trend={
|
||||||
@@ -1143,6 +1154,7 @@ function AnalyticsTab() {
|
|||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label={t("totalEvents")}
|
label={t("totalEvents")}
|
||||||
|
help={t("common:insights.help.events")}
|
||||||
to="/sessions?view=events"
|
to="/sessions?view=events"
|
||||||
value={data ? fmt(data.overview.total_events) : ""}
|
value={data ? fmt(data.overview.total_events) : ""}
|
||||||
raw={data ? data.overview.total_events.toLocaleString() : undefined}
|
raw={data ? data.overview.total_events.toLocaleString() : undefined}
|
||||||
@@ -1329,7 +1341,12 @@ function AnalyticsTab() {
|
|||||||
|
|
||||||
{/* Token mix donut */}
|
{/* Token mix donut */}
|
||||||
<div className="card p-5">
|
<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 ? (
|
{tokenMixSegments.length === 0 ? (
|
||||||
<p className="text-sm text-fg-muted">{t("common:noData")}</p>
|
<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 */}
|
{/* Spend pace: burn rate, month to date, month-end forecast */}
|
||||||
<div className="card p-5">
|
<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">
|
<div className="space-y-3">
|
||||||
{[
|
{[
|
||||||
{ label: t("burnRate"), value: pace.perDay7, hint: t("burnRateHint") },
|
{ label: t("burnRate"), value: pace.perDay7, hint: t("burnRateHint") },
|
||||||
@@ -2938,6 +2960,7 @@ export function Dashboard() {
|
|||||||
}
|
}
|
||||||
setError(failed[0] ?? null);
|
setError(failed[0] ?? null);
|
||||||
}, [scope]);
|
}, [scope]);
|
||||||
|
const [spinning, refresh] = useSpin(load);
|
||||||
|
|
||||||
const monitorOn = activeTab === "monitor";
|
const monitorOn = activeTab === "monitor";
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -3059,8 +3082,14 @@ export function Dashboard() {
|
|||||||
Analytics/Monitor/Health aside every time Monitor opened.
|
Analytics/Monitor/Health aside every time Monitor opened.
|
||||||
Analytics and Health carry their own Refresh; one per view. */}
|
Analytics and Health carry their own Refresh; one per view. */}
|
||||||
{monitorOn && (
|
{monitorOn && (
|
||||||
<button onClick={load} className="btn-ghost flex-shrink-0">
|
<button
|
||||||
<RefreshCw className="w-4 h-4" /> {t("common:refresh")}
|
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>
|
</button>
|
||||||
)}
|
)}
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
@@ -3110,6 +3139,7 @@ export function Dashboard() {
|
|||||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||||
<StatCard
|
<StatCard
|
||||||
label={t("totalSessions")}
|
label={t("totalSessions")}
|
||||||
|
help={t("common:insights.help.sessions")}
|
||||||
to="/sessions"
|
to="/sessions"
|
||||||
value={stats ? fmt(stats.total_sessions) : ""}
|
value={stats ? fmt(stats.total_sessions) : ""}
|
||||||
raw={stats ? stats.total_sessions.toLocaleString() : undefined}
|
raw={stats ? stats.total_sessions.toLocaleString() : undefined}
|
||||||
@@ -3119,6 +3149,7 @@ export function Dashboard() {
|
|||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label={t("activeAgents")}
|
label={t("activeAgents")}
|
||||||
|
help={t("common:insights.help.agents")}
|
||||||
value={stats?.active_agents ?? ""}
|
value={stats?.active_agents ?? ""}
|
||||||
icon={Bot}
|
icon={Bot}
|
||||||
accentColor="text-status-success"
|
accentColor="text-status-success"
|
||||||
@@ -3126,6 +3157,7 @@ export function Dashboard() {
|
|||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label={t("activeSubagents")}
|
label={t("activeSubagents")}
|
||||||
|
help={t("common:insights.help.subagents")}
|
||||||
value={stats ? allSubagents.filter((a) => a.status === "working").length : ""}
|
value={stats ? allSubagents.filter((a) => a.status === "working").length : ""}
|
||||||
icon={GitBranch}
|
icon={GitBranch}
|
||||||
accentColor="text-syntax-keyword"
|
accentColor="text-syntax-keyword"
|
||||||
@@ -3134,6 +3166,7 @@ export function Dashboard() {
|
|||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label={t("eventsToday")}
|
label={t("eventsToday")}
|
||||||
|
help={t("common:insights.help.events")}
|
||||||
to="/sessions?view=events"
|
to="/sessions?view=events"
|
||||||
value={stats ? fmt(stats.events_today) : ""}
|
value={stats ? fmt(stats.events_today) : ""}
|
||||||
raw={stats ? stats.events_today.toLocaleString() : undefined}
|
raw={stats ? stats.events_today.toLocaleString() : undefined}
|
||||||
@@ -3143,6 +3176,7 @@ export function Dashboard() {
|
|||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label={t("totalEvents")}
|
label={t("totalEvents")}
|
||||||
|
help={t("common:insights.help.events")}
|
||||||
to="/sessions?view=events"
|
to="/sessions?view=events"
|
||||||
value={stats ? fmt(stats.total_events) : ""}
|
value={stats ? fmt(stats.total_events) : ""}
|
||||||
raw={stats ? stats.total_events.toLocaleString() : undefined}
|
raw={stats ? stats.total_events.toLocaleString() : undefined}
|
||||||
@@ -3152,6 +3186,7 @@ export function Dashboard() {
|
|||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label={t("totalCost")}
|
label={t("totalCost")}
|
||||||
|
help={t("common:insights.help.cost")}
|
||||||
value={totalCost !== null ? fmtCost(totalCost) : ""}
|
value={totalCost !== null ? fmtCost(totalCost) : ""}
|
||||||
raw={
|
raw={
|
||||||
totalCost !== null
|
totalCost !== null
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ import {
|
|||||||
import { api, type RunHandle } from "../lib/api";
|
import { api, type RunHandle } from "../lib/api";
|
||||||
import { useLiveRefresh } from "../hooks/useLiveRefresh";
|
import { useLiveRefresh } from "../hooks/useLiveRefresh";
|
||||||
import { laneChangeMatcher, runKeyOf } from "../lib/laneChange";
|
import { laneChangeMatcher, runKeyOf } from "../lib/laneChange";
|
||||||
|
import { useSpin } from "../hooks/useSpin";
|
||||||
import { workspaceHref } from "../components/SessionRowMenu";
|
import { workspaceHref } from "../components/SessionRowMenu";
|
||||||
import { downloadText, exportName, toCsv } from "../lib/exportData";
|
import { downloadText, exportName, toCsv } from "../lib/exportData";
|
||||||
import { toast } from "../lib/toast";
|
import { toast } from "../lib/toast";
|
||||||
@@ -275,6 +276,7 @@ export function SessionDetail() {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [id, t]);
|
}, [id, t]);
|
||||||
|
const [spinning, refresh] = useSpin(load);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load();
|
load();
|
||||||
@@ -675,12 +677,14 @@ export function SessionDetail() {
|
|||||||
<SessionResumeButton session={session} />
|
<SessionResumeButton session={session} />
|
||||||
<SnapshotButton sessionId={session.id} />
|
<SnapshotButton sessionId={session.id} />
|
||||||
<button
|
<button
|
||||||
onClick={load}
|
onClick={refresh}
|
||||||
|
disabled={spinning}
|
||||||
|
aria-busy={spinning}
|
||||||
className="btn-ghost"
|
className="btn-ghost"
|
||||||
aria-label={t("common:refresh")}
|
aria-label={t("common:refresh")}
|
||||||
title={t("common:refresh")}
|
title={t("common:refresh")}
|
||||||
>
|
>
|
||||||
<RefreshCw className="w-4 h-4" />
|
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ import { api } from "../lib/api";
|
|||||||
import type { RemoteSource, RunHandle } from "../lib/api";
|
import type { RemoteSource, RunHandle } from "../lib/api";
|
||||||
import { useLiveRefresh } from "../hooks/useLiveRefresh";
|
import { useLiveRefresh } from "../hooks/useLiveRefresh";
|
||||||
import { laneChangeMatcher, runKeyOf } from "../lib/laneChange";
|
import { laneChangeMatcher, runKeyOf } from "../lib/laneChange";
|
||||||
|
import { useSpin } from "../hooks/useSpin";
|
||||||
import { useAiTools } from "../hooks/useAiTools";
|
import { useAiTools } from "../hooks/useAiTools";
|
||||||
import { useLanes } from "../hooks/useLanes";
|
import { useLanes } from "../hooks/useLanes";
|
||||||
import { toast } from "../lib/toast";
|
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
|
// `scope` is a dep so a data-scope change re-fetches; the api layer injects
|
||||||
// the matching `sources` param.
|
// the matching `sources` param.
|
||||||
}, [query, page, scope, t]);
|
}, [query, page, scope, t]);
|
||||||
|
const [spinning, refresh] = useSpin(load);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load();
|
load();
|
||||||
@@ -390,8 +392,9 @@ export function Sessions({ embedded = false }: { embedded?: boolean } = {}) {
|
|||||||
>
|
>
|
||||||
<Download className="w-4 h-4" /> {t("exportJson")}
|
<Download className="w-4 h-4" /> {t("exportJson")}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={load} className="btn-ghost">
|
<button onClick={refresh} disabled={spinning} aria-busy={spinning} className="btn-ghost">
|
||||||
<RefreshCw className="w-4 h-4" /> {t("common:refresh")}
|
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />{" "}
|
||||||
|
{t("common:refresh")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ import type { WSMessage } from "../lib/types";
|
|||||||
import { confirmDialog } from "../lib/confirm";
|
import { confirmDialog } from "../lib/confirm";
|
||||||
import { toast } from "../lib/toast";
|
import { toast } from "../lib/toast";
|
||||||
import { useDialog } from "../hooks/useDialog";
|
import { useDialog } from "../hooks/useDialog";
|
||||||
|
import { useSpin } from "../hooks/useSpin";
|
||||||
import { exportPrefs, importPrefs } from "../lib/prefsBackup";
|
import { exportPrefs, importPrefs } from "../lib/prefsBackup";
|
||||||
|
|
||||||
/** The fewest milliseconds between two activity-driven `/settings/info` reads:
|
/** The fewest milliseconds between two activity-driven `/settings/info` reads:
|
||||||
@@ -249,6 +250,7 @@ export function Settings({
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [t, needsInfo, section]);
|
}, [t, needsInfo, section]);
|
||||||
|
const [spinning, refresh] = useSpin(load);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load();
|
load();
|
||||||
@@ -814,8 +816,14 @@ export function Settings({
|
|||||||
<FileDown className="w-3.5 h-3.5" />
|
<FileDown className="w-3.5 h-3.5" />
|
||||||
{t("exportData")}
|
{t("exportData")}
|
||||||
</a>
|
</a>
|
||||||
<button onClick={load} className="btn-ghost">
|
<button
|
||||||
<RefreshCw className="w-4 h-4" /> {t("common:refresh")}
|
onClick={refresh}
|
||||||
|
disabled={spinning}
|
||||||
|
aria-busy={spinning}
|
||||||
|
className="btn-ghost"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />{" "}
|
||||||
|
{t("common:refresh")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import {
|
|||||||
Palette,
|
Palette,
|
||||||
Router,
|
Router,
|
||||||
Search,
|
Search,
|
||||||
|
UsersRound,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Settings } from "./Settings";
|
import { Settings } from "./Settings";
|
||||||
import { CcConfig } from "./CcConfig";
|
import { CcConfig } from "./CcConfig";
|
||||||
@@ -64,6 +65,8 @@ import {
|
|||||||
import { Skeleton } from "../components/Skeleton";
|
import { Skeleton } from "../components/Skeleton";
|
||||||
import { AiToolIcon } from "../components/AiToolIcon";
|
import { AiToolIcon } from "../components/AiToolIcon";
|
||||||
import { toast } from "../lib/toast";
|
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;
|
const GROUPS = ["general", "agents", "routing", "cost", "git"] as const;
|
||||||
type Group = (typeof GROUPS)[number];
|
type Group = (typeof GROUPS)[number];
|
||||||
@@ -124,6 +127,15 @@ const MOVED: Record<string, string> = {
|
|||||||
"ai-router": "ai-proxy",
|
"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. */
|
/** An unknown section: say so, then show Appearance. */
|
||||||
function UnknownSection({ name }: { name: string }) {
|
function UnknownSection({ name }: { name: string }) {
|
||||||
const { t } = useTranslation("settings");
|
const { t } = useTranslation("settings");
|
||||||
@@ -205,12 +217,15 @@ export function SettingsHub() {
|
|||||||
};
|
};
|
||||||
}, [location.key, location.hash]);
|
}, [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 enabled = (id: string) => aiTools.byId(id)?.enabled !== false;
|
||||||
const s = (key: string) => ({
|
const s = (key: string) => ({
|
||||||
title: t(`settingsNav.section.${key}.title`),
|
title: t(`settingsNav.section.${key}.title`),
|
||||||
description: t(`settingsNav.section.${key}.description`),
|
description: t(`settingsNav.section.${key}.description`),
|
||||||
});
|
});
|
||||||
const sections: Section[] = [
|
const allSections: Section[] = [
|
||||||
{
|
{
|
||||||
id: "data",
|
id: "data",
|
||||||
group: "general",
|
group: "general",
|
||||||
@@ -246,6 +261,17 @@ export function SettingsHub() {
|
|||||||
...s("reports"),
|
...s("reports"),
|
||||||
render: () => <ReportSettings />,
|
render: () => <ReportSettings />,
|
||||||
},
|
},
|
||||||
|
...(isStaff
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: "users",
|
||||||
|
group: "general" as const,
|
||||||
|
Icon: UsersRound,
|
||||||
|
...s("users"),
|
||||||
|
render: () => <UsersSettings />,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
{
|
{
|
||||||
id: "notifications",
|
id: "notifications",
|
||||||
group: "general",
|
group: "general",
|
||||||
@@ -342,6 +368,9 @@ export function SettingsHub() {
|
|||||||
render: () => <WorktreeVisibilitySettings />,
|
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) => [
|
const entries: SettingsEntry[] = sections.flatMap((sec) => [
|
||||||
{ section: sec.id, title: sec.title, description: sec.description, sectionTitle: true },
|
{ 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 type { DateRange } from "../lib/dateRange";
|
||||||
import { eventBus } from "../lib/eventBus";
|
import { eventBus } from "../lib/eventBus";
|
||||||
import { useLiveRefresh } from "../hooks/useLiveRefresh";
|
import { useLiveRefresh } from "../hooks/useLiveRefresh";
|
||||||
|
import { useSpin } from "../hooks/useSpin";
|
||||||
import type { WorkflowData, WSMessage } from "../lib/types";
|
import type { WorkflowData, WSMessage } from "../lib/types";
|
||||||
|
|
||||||
import { WorkflowStats } from "../components/workflows/WorkflowStats";
|
import { WorkflowStats } from "../components/workflows/WorkflowStats";
|
||||||
@@ -150,10 +151,7 @@ export function Workflows({
|
|||||||
msg.type === "workflow_upserted",
|
msg.type === "workflow_upserted",
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleRefresh = () => {
|
const [refreshing, handleRefresh] = useSpin(fetchData);
|
||||||
setLoading(true);
|
|
||||||
fetchData();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleExport = () => {
|
const handleExport = () => {
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
@@ -174,6 +172,7 @@ export function Workflows({
|
|||||||
statusFilter={statusFilter}
|
statusFilter={statusFilter}
|
||||||
onStatusFilterChange={setStatusFilter}
|
onStatusFilterChange={setStatusFilter}
|
||||||
onRefresh={handleRefresh}
|
onRefresh={handleRefresh}
|
||||||
|
refreshing={refreshing}
|
||||||
onExport={handleExport}
|
onExport={handleExport}
|
||||||
lastUpdated={null}
|
lastUpdated={null}
|
||||||
/>
|
/>
|
||||||
@@ -197,6 +196,7 @@ export function Workflows({
|
|||||||
statusFilter={statusFilter}
|
statusFilter={statusFilter}
|
||||||
onStatusFilterChange={setStatusFilter}
|
onStatusFilterChange={setStatusFilter}
|
||||||
onRefresh={handleRefresh}
|
onRefresh={handleRefresh}
|
||||||
|
refreshing={refreshing}
|
||||||
onExport={handleExport}
|
onExport={handleExport}
|
||||||
lastUpdated={null}
|
lastUpdated={null}
|
||||||
/>
|
/>
|
||||||
@@ -221,6 +221,7 @@ export function Workflows({
|
|||||||
statusFilter={statusFilter}
|
statusFilter={statusFilter}
|
||||||
onStatusFilterChange={setStatusFilter}
|
onStatusFilterChange={setStatusFilter}
|
||||||
onRefresh={handleRefresh}
|
onRefresh={handleRefresh}
|
||||||
|
refreshing={refreshing}
|
||||||
onExport={handleExport}
|
onExport={handleExport}
|
||||||
lastUpdated={lastUpdated}
|
lastUpdated={lastUpdated}
|
||||||
/>
|
/>
|
||||||
@@ -499,6 +500,7 @@ function PageHeader({
|
|||||||
statusFilter,
|
statusFilter,
|
||||||
onStatusFilterChange,
|
onStatusFilterChange,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
|
refreshing = false,
|
||||||
onExport,
|
onExport,
|
||||||
lastUpdated,
|
lastUpdated,
|
||||||
}: {
|
}: {
|
||||||
@@ -507,6 +509,7 @@ function PageHeader({
|
|||||||
statusFilter: StatusFilter;
|
statusFilter: StatusFilter;
|
||||||
onStatusFilterChange: (f: StatusFilter) => void;
|
onStatusFilterChange: (f: StatusFilter) => void;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
|
refreshing?: boolean;
|
||||||
onExport: () => void;
|
onExport: () => void;
|
||||||
lastUpdated: Date | null;
|
lastUpdated: Date | null;
|
||||||
}) {
|
}) {
|
||||||
@@ -566,10 +569,12 @@ function PageHeader({
|
|||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<button
|
<button
|
||||||
onClick={onRefresh}
|
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"
|
className="p-2 rounded-lg text-fg-muted hover:text-fg-secondary hover:bg-surface-3 transition-colors"
|
||||||
title={t("refreshData")}
|
title={t("refreshData")}
|
||||||
>
|
>
|
||||||
<RefreshCw className="w-4 h-4" />
|
<RefreshCw className={`w-4 h-4 ${refreshing ? "animate-spin" : ""}`} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onExport}
|
onClick={onExport}
|
||||||
|
|||||||
@@ -157,6 +157,7 @@ exports[`screen snapshots > Activity feed 1`] = `
|
|||||||
JSON
|
JSON
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
aria-busy="false"
|
||||||
aria-label="Refresh"
|
aria-label="Refresh"
|
||||||
class="btn-ghost"
|
class="btn-ghost"
|
||||||
title="Refresh"
|
title="Refresh"
|
||||||
@@ -3220,6 +3221,7 @@ exports[`screen snapshots > Dashboard 1`] = `
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
aria-busy="false"
|
||||||
class="btn-ghost"
|
class="btn-ghost"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@@ -3292,7 +3294,8 @@ exports[`screen snapshots > Dashboard 1`] = `
|
|||||||
class="flex items-center justify-between gap-3 mb-3"
|
class="flex items-center justify-between gap-3 mb-3"
|
||||||
>
|
>
|
||||||
<span
|
<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
|
Total Sessions
|
||||||
</span>
|
</span>
|
||||||
@@ -3339,7 +3342,8 @@ exports[`screen snapshots > Dashboard 1`] = `
|
|||||||
class="flex items-center justify-between gap-3 mb-3"
|
class="flex items-center justify-between gap-3 mb-3"
|
||||||
>
|
>
|
||||||
<span
|
<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
|
Total Agents
|
||||||
</span>
|
</span>
|
||||||
@@ -3405,7 +3409,8 @@ exports[`screen snapshots > Dashboard 1`] = `
|
|||||||
class="flex items-center justify-between gap-3 mb-3"
|
class="flex items-center justify-between gap-3 mb-3"
|
||||||
>
|
>
|
||||||
<span
|
<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)
|
Tokens (excl. cache reads)
|
||||||
</span>
|
</span>
|
||||||
@@ -3487,7 +3492,8 @@ exports[`screen snapshots > Dashboard 1`] = `
|
|||||||
class="flex items-center justify-between gap-3 mb-3"
|
class="flex items-center justify-between gap-3 mb-3"
|
||||||
>
|
>
|
||||||
<span
|
<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
|
Total Cost
|
||||||
</span>
|
</span>
|
||||||
@@ -3541,7 +3547,8 @@ exports[`screen snapshots > Dashboard 1`] = `
|
|||||||
class="flex items-center justify-between gap-3 mb-3"
|
class="flex items-center justify-between gap-3 mb-3"
|
||||||
>
|
>
|
||||||
<span
|
<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
|
Total Events
|
||||||
</span>
|
</span>
|
||||||
@@ -5386,7 +5393,8 @@ exports[`screen snapshots > Dashboard 1`] = `
|
|||||||
class="card p-5"
|
class="card p-5"
|
||||||
>
|
>
|
||||||
<h3
|
<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
|
Spend pace
|
||||||
</h3>
|
</h3>
|
||||||
@@ -6018,6 +6026,7 @@ exports[`screen snapshots > Session detail 1`] = `
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
aria-busy="false"
|
||||||
aria-label="Refresh"
|
aria-label="Refresh"
|
||||||
class="btn-ghost"
|
class="btn-ghost"
|
||||||
title="Refresh"
|
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"
|
class="rounded-lg border px-3 py-2.5 border-surface-3 bg-surface-2 text-fg-secondary"
|
||||||
>
|
>
|
||||||
<div
|
<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
|
<span
|
||||||
class="text-fg-muted"
|
class="text-fg-muted"
|
||||||
@@ -6232,7 +6242,11 @@ exports[`screen snapshots > Session detail 1`] = `
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
Events
|
<span
|
||||||
|
class="underline decoration-dotted underline-offset-2"
|
||||||
|
>
|
||||||
|
Events
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="mt-1 font-mono text-lg font-semibold text-fg-primary leading-tight"
|
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"
|
class="rounded-lg border px-3 py-2.5 border-syntax-keyword/20 bg-syntax-keyword/5 text-syntax-keyword"
|
||||||
>
|
>
|
||||||
<div
|
<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
|
<span
|
||||||
class="text-syntax-keyword"
|
class="text-syntax-keyword"
|
||||||
@@ -6266,7 +6281,11 @@ exports[`screen snapshots > Session detail 1`] = `
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
Tool calls
|
<span
|
||||||
|
class="underline decoration-dotted underline-offset-2"
|
||||||
|
>
|
||||||
|
Tool calls
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="mt-1 font-mono text-lg font-semibold text-fg-primary leading-tight"
|
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"
|
class="rounded-lg border px-3 py-2.5 border-syntax-property/20 bg-syntax-property/5 text-syntax-property"
|
||||||
>
|
>
|
||||||
<div
|
<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
|
<span
|
||||||
class="text-syntax-property"
|
class="text-syntax-property"
|
||||||
@@ -6316,7 +6336,11 @@ exports[`screen snapshots > Session detail 1`] = `
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
Subagents
|
<span
|
||||||
|
class="underline decoration-dotted underline-offset-2"
|
||||||
|
>
|
||||||
|
Subagents
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="mt-1 font-mono text-lg font-semibold text-fg-primary leading-tight"
|
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"
|
class="rounded-lg border px-3 py-2.5 border-syntax-builtin/20 bg-syntax-builtin/5 text-syntax-builtin"
|
||||||
>
|
>
|
||||||
<div
|
<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
|
<span
|
||||||
class="text-syntax-builtin"
|
class="text-syntax-builtin"
|
||||||
@@ -6356,7 +6381,11 @@ exports[`screen snapshots > Session detail 1`] = `
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
Compactions
|
<span
|
||||||
|
class="underline decoration-dotted underline-offset-2"
|
||||||
|
>
|
||||||
|
Compactions
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="mt-1 font-mono text-lg font-semibold text-fg-primary leading-tight"
|
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"
|
class="rounded-lg border px-3 py-2.5 border-surface-3 bg-surface-2 text-fg-secondary"
|
||||||
>
|
>
|
||||||
<div
|
<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
|
<span
|
||||||
class="text-fg-muted"
|
class="text-fg-muted"
|
||||||
@@ -6396,7 +6426,11 @@ exports[`screen snapshots > Session detail 1`] = `
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
Errors
|
<span
|
||||||
|
class="underline decoration-dotted underline-offset-2"
|
||||||
|
>
|
||||||
|
Errors
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="mt-1 font-mono text-lg font-semibold text-fg-primary leading-tight"
|
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>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
Duration
|
<span>
|
||||||
|
Duration
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="mt-1 font-mono text-lg font-semibold text-fg-primary leading-tight"
|
class="mt-1 font-mono text-lg font-semibold text-fg-primary leading-tight"
|
||||||
@@ -6830,6 +6866,7 @@ exports[`screen snapshots > Sessions 1`] = `
|
|||||||
JSON
|
JSON
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
aria-busy="false"
|
||||||
class="btn-ghost"
|
class="btn-ghost"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@@ -7521,6 +7558,7 @@ exports[`screen snapshots > Settings — data 1`] = `
|
|||||||
Export Data
|
Export Data
|
||||||
</a>
|
</a>
|
||||||
<button
|
<button
|
||||||
|
aria-busy="false"
|
||||||
class="btn-ghost"
|
class="btn-ghost"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@@ -9857,6 +9895,7 @@ exports[`screen snapshots > Workflows 1`] = `
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
aria-busy="false"
|
||||||
class="p-2 rounded-lg text-fg-muted hover:text-fg-secondary hover:bg-surface-3 transition-colors"
|
class="p-2 rounded-lg text-fg-muted hover:text-fg-secondary hover:bg-surface-3 transition-colors"
|
||||||
title="Refresh data"
|
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`.
|
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`)
|
### 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:
|
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`.
|
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.
|
`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
|
```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/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
|
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-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)
|
(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? }
|
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)
|
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 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 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 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
|
### Interactive REPL
|
||||||
|
|
||||||
|
|||||||
@@ -609,6 +609,23 @@ CREATE TABLE report_runs (
|
|||||||
CREATE INDEX idx_report_runs_report ON report_runs(report_id, id);
|
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`)
|
### 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.
|
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).
|
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.
|
- **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).
|
- **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.
|
- **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.
|
- **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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [DATABASE.md](./DATABASE.md) | SQLite schema, queries, performance | Database administrators |
|
||||||
| [HOOKS.md](./HOOKS.md) | Claude Code hook system integration | Hook developers |
|
| [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 |
|
| [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
|
const headers = process.env.DASHBOARD_TOKEN
|
||||||
? { Authorization: `Bearer ${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;
|
let res;
|
||||||
try {
|
try {
|
||||||
res = await fetch(url, { headers, signal: AbortSignal.timeout(30_000) });
|
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
|
description: Web Push notification subscriptions and broadcast (VAPID); also fires native Electron notifications when hosted in the desktop app
|
||||||
- name: AI Tools
|
- 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
|
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:
|
components:
|
||||||
parameters:
|
parameters:
|
||||||
SessionIdPath:
|
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.
|
description: Events attributed to this run's inner agents, ordered by created_at then id. Capped at 5000 rows.
|
||||||
items:
|
items:
|
||||||
$ref: '#/components/schemas/DashboardEvent'
|
$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:
|
paths:
|
||||||
/api/health:
|
/api/health:
|
||||||
get:
|
get:
|
||||||
@@ -13334,6 +13370,233 @@ paths:
|
|||||||
error:
|
error:
|
||||||
code: WORKFLOW_DETAIL_FAILED
|
code: WORKFLOW_DETAIL_FAILED
|
||||||
message: database is locked
|
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:
|
/api/settings/import:
|
||||||
post:
|
post:
|
||||||
tags:
|
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/tmux` | `{ available }` — whether the `tmux` binary exists |
|
||||||
| `GET` | `/api/run/cwds` | Suggested cwds (dashboard, home, recent from sessions) |
|
| `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-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 |
|
| `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 |
|
| `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 |
|
| `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");
|
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 () => {
|
it("starts a session on the AI tool the request names, not the workspace's", async () => {
|
||||||
const lane = await adoptedLane("session-tool");
|
const lane = await adoptedLane("session-tool");
|
||||||
assert.equal(lane.tool, "claude");
|
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", () => {
|
it("laneIdFromRunId parses the numeric lane id back out", () => {
|
||||||
assert.equal(pty.laneIdFromRunId("agenthub-lane-42"), 42);
|
assert.equal(pty.laneIdFromRunId("agenthub-lane-42"), 42);
|
||||||
assert.equal(pty.laneIdFromRunId("not-a-run-id"), null);
|
assert.equal(pty.laneIdFromRunId("not-a-run-id"), null);
|
||||||
|
|||||||
@@ -248,4 +248,29 @@ describe("POST /api/run/drop-image", () => {
|
|||||||
});
|
});
|
||||||
assert.equal(r.status, 403);
|
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();
|
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 —
|
// Migrate: add stage-detection columns to lanes. Inference is never evidence —
|
||||||
// these are additive columns separate from `stage` (the declared stage), so
|
// these are additive columns separate from `stage` (the declared stage), so
|
||||||
@@ -1649,6 +1656,65 @@ db.prepare(
|
|||||||
`
|
`
|
||||||
).run();
|
).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.exec("COMMIT");
|
||||||
migrationLock.close();
|
migrationLock.close();
|
||||||
|
|
||||||
|
|||||||
+20
-1
@@ -100,6 +100,12 @@ const laneSourcesRouter = require("./routes/lane-sources");
|
|||||||
const locksRouter = require("./routes/locks");
|
const locksRouter = require("./routes/locks");
|
||||||
const petsRouter = require("./routes/pets");
|
const petsRouter = require("./routes/pets");
|
||||||
const snapshotsRouter = require("./routes/snapshots");
|
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 = (() => {
|
const APP_VERSION = (() => {
|
||||||
try {
|
try {
|
||||||
@@ -119,6 +125,14 @@ function createApp() {
|
|||||||
app.use(hostGuard);
|
app.use(hostGuard);
|
||||||
app.use(express.json({ limit: "1mb" }));
|
app.use(express.json({ limit: "1mb" }));
|
||||||
app.use("/api", tokenGuard);
|
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/sessions", sessionsRouter);
|
||||||
app.use("/api/agents", agentsRouter);
|
app.use("/api/agents", agentsRouter);
|
||||||
@@ -293,7 +307,12 @@ function startServer(app, port) {
|
|||||||
const mode = isProduction ? "production" : "development";
|
const mode = isProduction ? "production" : "development";
|
||||||
const shown = boundLoopback ? "localhost" : host;
|
const shown = boundLoopback ? "localhost" : host;
|
||||||
console.log(`Agent Dashboard server running on http://${shown}:${port} (${mode})`);
|
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(
|
console.warn(
|
||||||
`⚠️ Dashboard bound to ${host} — reachable from the network. ` +
|
`⚠️ Dashboard bound to ${host} — reachable from the network. ` +
|
||||||
(getDashboardToken()
|
(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
|
* 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
|
* 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
|
* 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>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const { stmts } = require("../db");
|
const { stmts } = require("../db");
|
||||||
|
const { memberId, sessionWhere } = require("./scope");
|
||||||
const { calculateCost, dailyTokenRows, costWhere } = require("../routes/pricing");
|
const { calculateCost, dailyTokenRows, costWhere } = require("../routes/pricing");
|
||||||
|
|
||||||
const PERIODS = ["day", "week", "month"];
|
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,
|
* @returns {{period_start: string, period_end: string, spend: number,
|
||||||
* projected: number, pct: 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 tz = Number.isFinite(cfg.tz_offset) ? cfg.tz_offset : 0;
|
||||||
const b = periodBounds(cfg.period, now, tz, back);
|
const b = periodBounds(cfg.period, now, tz, back);
|
||||||
// A session that spent in the period was touched in it, so older ones are
|
// A session that spent in the period was touched in it, so older ones are
|
||||||
// skipped before their hours are read.
|
// skipped before their hours are read.
|
||||||
const extra = [{ clause: "s.updated_at >= ?", params: [b.startUtc] }];
|
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 scope = cfg.tool ? { where: "tool = ?", params: [cfg.tool] } : null;
|
||||||
const where = costWhere({ scope, lane: cfg.lane, extra });
|
const where = costWhere({ scope, lane: cfg.lane, extra });
|
||||||
const rows = dailyTokenRows(`${-tz} minutes`, where.whereClause, where.params).filter(
|
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`.
|
* @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();
|
const rules = stmts.listPricing.all();
|
||||||
return stmts.listEnabledAlertRules
|
return stmts.listEnabledAlertRules
|
||||||
.all()
|
.all()
|
||||||
@@ -109,7 +116,7 @@ function budgetStatus({ now = Date.now() } = {}) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (!PERIODS.includes(cfg.period) || !(cfg.limit_usd > 0)) 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) };
|
return { id: r.id, name: r.name, config: cfg, ...s, crossed: crossed(cfg, s.pct) };
|
||||||
})
|
})
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|||||||
@@ -243,6 +243,7 @@ module.exports = {
|
|||||||
profileUsage,
|
profileUsage,
|
||||||
fetchUsage,
|
fetchUsage,
|
||||||
fetchOwner,
|
fetchOwner,
|
||||||
|
liveOwner,
|
||||||
refreshProfile,
|
refreshProfile,
|
||||||
CLIENT_ID,
|
CLIENT_ID,
|
||||||
USAGE_URL,
|
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 (
|
INSERT OR REPLACE INTO dashboard_runs (
|
||||||
id, session_id, mode, cwd, model, permission_mode, effort,
|
id, session_id, mode, cwd, model, permission_mode, effort,
|
||||||
resume_session_id, prompt_preview, status, exit_code, started_at, ended_at,
|
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 (
|
) VALUES (
|
||||||
@id, @session_id, @mode, @cwd, @model, @permission_mode, @effort,
|
@id, @session_id, @mode, @cwd, @model, @permission_mode, @effort,
|
||||||
@resume_session_id, @prompt_preview, @status, @exit_code, @started_at, @ended_at,
|
@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,
|
const RUN_COLUMNS = `id, session_id, mode, cwd, model, permission_mode, effort,
|
||||||
resume_session_id, prompt_preview, status, exit_code,
|
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(`
|
const listStmt = db.prepare(`
|
||||||
SELECT ${RUN_COLUMNS}
|
SELECT ${RUN_COLUMNS}
|
||||||
@@ -124,6 +124,8 @@ function recordRun(handle) {
|
|||||||
tool: typeof handle.tool === "string" && handle.tool ? handle.tool : "claude",
|
tool: typeof handle.tool === "string" && handle.tool ? handle.tool : "claude",
|
||||||
worktree: handle.worktree ? 1 : 0,
|
worktree: handle.worktree ? 1 : 0,
|
||||||
proxy: handle.proxy || null,
|
proxy: handle.proxy || null,
|
||||||
|
account: handle.account || null,
|
||||||
|
pane_token_hash: handle.paneTokenHash || null,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Best-effort by design (a live run must never fail because sqlite did),
|
// 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(),
|
events: db.prepare("SELECT * FROM events ORDER BY created_at DESC").all(),
|
||||||
token_usage: db.prepare("SELECT * FROM token_usage").all(),
|
token_usage: db.prepare("SELECT * FROM token_usage").all(),
|
||||||
workflows: db.prepare("SELECT * FROM workflows").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(),
|
alert_rules: db.prepare("SELECT * FROM alert_rules ORDER BY created_at ASC").all(),
|
||||||
model_pricing: stmts.listPricing.all(),
|
model_pricing: stmts.listPricing.all(),
|
||||||
};
|
};
|
||||||
@@ -89,6 +92,12 @@ function bindable(v) {
|
|||||||
* whose value goes through a SQL function (events.data through
|
* whose value goes through a SQL function (events.data through
|
||||||
* `trim_event_data`, as every event insert must).
|
* `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 = {} } = {}) {
|
function makeInserter(db, table, { omit = [], wrap = {} } = {}) {
|
||||||
const cols = tableColumns(db, table).filter((c) => !omit.includes(c));
|
const cols = tableColumns(db, table).filter((c) => !omit.includes(c));
|
||||||
const quoted = cols.map((c) => `"${c}"`).join(", ");
|
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" } }),
|
events: makeInserter(db, "events", { omit: ["id"], wrap: { data: "trim_event_data" } }),
|
||||||
token_usage: makeInserter(db, "token_usage"),
|
token_usage: makeInserter(db, "token_usage"),
|
||||||
workflows: makeInserter(db, "workflows"),
|
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"),
|
alert_rules: makeInserter(db, "alert_rules"),
|
||||||
model_pricing: makeInserter(db, "model_pricing"),
|
model_pricing: makeInserter(db, "model_pricing"),
|
||||||
};
|
};
|
||||||
@@ -251,6 +260,7 @@ function importExportBundle(db, bundle) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
exportableRun,
|
||||||
EXPORT_FORMAT,
|
EXPORT_FORMAT,
|
||||||
EXPORT_VERSION,
|
EXPORT_VERSION,
|
||||||
SESSION_CHILD_TABLES,
|
SESSION_CHILD_TABLES,
|
||||||
|
|||||||
+4
-2
@@ -211,6 +211,7 @@ function createLane({
|
|||||||
slug = null,
|
slug = null,
|
||||||
tool = "claude",
|
tool = "claude",
|
||||||
router_profile = null,
|
router_profile = null,
|
||||||
|
owner_id = null,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
if (!cwd || typeof cwd !== "string" || !path.isAbsolute(cwd)) {
|
if (!cwd || typeof cwd !== "string" || !path.isAbsolute(cwd)) {
|
||||||
throw Object.assign(new Error("cwd must be an absolute path"), { code: "EBADCWD" });
|
throw Object.assign(new Error("cwd must be an absolute path"), { code: "EBADCWD" });
|
||||||
@@ -220,7 +221,7 @@ function createLane({
|
|||||||
validateTool(tool);
|
validateTool(tool);
|
||||||
const info = db
|
const info = db
|
||||||
.prepare(
|
.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(
|
.run(
|
||||||
normalizeTitle(title),
|
normalizeTitle(title),
|
||||||
@@ -233,7 +234,8 @@ function createLane({
|
|||||||
slug,
|
slug,
|
||||||
tool,
|
tool,
|
||||||
router_profile || null,
|
router_profile || null,
|
||||||
nowIso()
|
nowIso(),
|
||||||
|
Number.isInteger(owner_id) ? owner_id : null
|
||||||
);
|
);
|
||||||
return getLane(info.lastInsertRowid);
|
return getLane(info.lastInsertRowid);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,12 +37,38 @@ function effectiveMaxHoldSec() {
|
|||||||
return Math.max(configured, LOCK_MAX_HOLD_FLOOR_SEC);
|
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) {
|
function ownerPath(name) {
|
||||||
return path.join(locksRoot(), name, "owner");
|
return path.join(locksRoot(), name, "owner");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Parse an owner file's `"<holder> <epochSeconds>"` contents. */
|
/** Parse an owner file's `"<holder> <epochSeconds>"` contents. */
|
||||||
function readOwner(name) {
|
function readOwner(name) {
|
||||||
|
checkName(name);
|
||||||
let text;
|
let text;
|
||||||
try {
|
try {
|
||||||
text = fs.readFileSync(ownerPath(name), "utf8");
|
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"`).
|
* @param {string} holder - Identity to record as the owner (e.g. `"lane3"`).
|
||||||
* @returns {{acquired: true} | {acquired: false, holder: string, since: number, ageSec: number}}
|
* @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);
|
const dir = path.join(locksRoot(), name);
|
||||||
fs.mkdirSync(locksRoot(), { recursive: true });
|
fs.mkdirSync(locksRoot(), { recursive: true });
|
||||||
|
|
||||||
@@ -99,7 +128,10 @@ function tryAcquire(name, holder) {
|
|||||||
* @param {string} name - Lock name.
|
* @param {string} name - Lock name.
|
||||||
* @param {string} holder - Must match the current owner.
|
* @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);
|
const owner = readOwner(name);
|
||||||
if (!owner) {
|
if (!owner) {
|
||||||
throw Object.assign(new Error(`no such lock: ${name}`), { code: "ENOLOCK" });
|
throw Object.assign(new Error(`no such lock: ${name}`), { code: "ENOLOCK" });
|
||||||
@@ -128,6 +160,7 @@ function listLocks() {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
return entries
|
return entries
|
||||||
|
.filter((name) => NAME_RE.test(name))
|
||||||
.map((name) => ({ name, ...status(name) }))
|
.map((name) => ({ name, ...status(name) }))
|
||||||
.filter((lock) => lock.held)
|
.filter((lock) => lock.held)
|
||||||
.map(({ held, ...rest }) => rest);
|
.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`);
|
throw makeErr("EUNSUPPORTED", `${tool} cannot start a session in its own worktree`);
|
||||||
}
|
}
|
||||||
const proxyProfile = resolveProxy(tool, proxy); // ENOTFOUND, EUNSUPPORTED
|
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 (forkSession === true) {
|
||||||
if (!resumeSessionId) throw makeErr("EBADSESSION", "a fork needs resumeSessionId");
|
if (!resumeSessionId) throw makeErr("EBADSESSION", "a fork needs resumeSessionId");
|
||||||
if (entry.launcher !== "claude") {
|
if (entry.launcher !== "claude") {
|
||||||
@@ -450,6 +456,8 @@ function spawnRun(args) {
|
|||||||
resumeSessionId: resumeSessionId || null,
|
resumeSessionId: resumeSessionId || null,
|
||||||
worktree: worktree === true || inWorktree === true,
|
worktree: worktree === true || inWorktree === true,
|
||||||
proxy: proxyProfile ? proxyProfile.name : null,
|
proxy: proxyProfile ? proxyProfile.name : null,
|
||||||
|
account: poolAccount ? poolAccount.name : null,
|
||||||
|
paneTokenHash: paneCred ? paneCred.hash : null,
|
||||||
prompt: initialPrompt || "",
|
prompt: initialPrompt || "",
|
||||||
status: "running",
|
status: "running",
|
||||||
startedAt,
|
startedAt,
|
||||||
@@ -491,6 +499,12 @@ function spawnRun(args) {
|
|||||||
// pushes lines into history its redraw cannot clear). The user's own
|
// pushes lines into history its redraw cannot clear). The user's own
|
||||||
// `"tui"` setting decides; under fullscreen the pane scrolls in Claude.
|
// `"tui"` setting decides; under fullscreen the pane scrolls in Claude.
|
||||||
...proxySpawn.env,
|
...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 : {}),
|
...(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");
|
if (!tmux.isTmuxAvailable()) throw makeErr("ETMUXMISSING", "tmux is not installed");
|
||||||
const id = runIdForLane(laneId, slot);
|
const id = runIdForLane(laneId, slot);
|
||||||
|
const paneCred = require("./auth").newPaneToken();
|
||||||
const env = {
|
const env = {
|
||||||
AGENTHUB_LANE_ID: String(laneId),
|
AGENTHUB_LANE_ID: String(laneId),
|
||||||
AGENTHUB_RUN_ID: id,
|
AGENTHUB_RUN_ID: id,
|
||||||
...(process.env.PATH ? { PATH: process.env.PATH } : {}),
|
...(process.env.PATH ? { PATH: process.env.PATH } : {}),
|
||||||
...desktopEnv(),
|
...desktopEnv(),
|
||||||
|
...(paneCred ? { AGENTHUB_PANE_TOKEN: paneCred.token } : {}),
|
||||||
};
|
};
|
||||||
// Started once the pane has its real size (WAIT_FOR_CLIENT, issue #55), as
|
// 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).
|
// 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,
|
resumeSessionId: null,
|
||||||
worktree: false,
|
worktree: false,
|
||||||
proxy: null,
|
proxy: null,
|
||||||
|
paneTokenHash: paneCred ? paneCred.hash : null,
|
||||||
prompt: "",
|
prompt: "",
|
||||||
status: "running",
|
status: "running",
|
||||||
startedAt: Date.now(),
|
startedAt: Date.now(),
|
||||||
@@ -599,9 +616,8 @@ function killRun(id) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function publicRun(id) {
|
function publicRun(id, live = tmux.hasSession(id)) {
|
||||||
const laneId = laneIdFromRunId(id);
|
const laneId = laneIdFromRunId(id);
|
||||||
const live = tmux.hasSession(id);
|
|
||||||
const row = dashboardRuns ? dashboardRuns.getRun(id) : null;
|
const row = dashboardRuns ? dashboardRuns.getRun(id) : null;
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
@@ -627,9 +643,12 @@ function getRun(id) {
|
|||||||
return publicRun(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() {
|
function listRuns() {
|
||||||
return tmux.listSessions("agenthub-lane-").map(publicRun);
|
return tmux.listSessions("agenthub-lane-").map((id) => publicRun(id, true));
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
|||||||
+8
-3
@@ -87,10 +87,15 @@ function showNativeNotificationIfElectron(title, body) {
|
|||||||
* service worker's `notificationclick` handler navigates to. Undefined for
|
* service worker's `notificationclick` handler navigates to. Undefined for
|
||||||
* notifications with no specific target.
|
* notifications with no specific target.
|
||||||
*/
|
*/
|
||||||
async function sendPushToAll(db, title, body, url) {
|
async function sendPushToAll(db, title, body, url, userId = null) {
|
||||||
const native = showNativeNotificationIfElectron(title, body);
|
// 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) {
|
if (subscriptions.length === 0) {
|
||||||
return { native, pushed: 0, failed: 0 };
|
return { native, pushed: 0, failed: 0 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,14 @@ const JOBS = {
|
|||||||
workflows: (conn, a) =>
|
workflows: (conn, a) =>
|
||||||
require("./workflow-stats").computeWorkflowData(
|
require("./workflow-stats").computeWorkflowData(
|
||||||
conn,
|
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
|
a.sections || null
|
||||||
),
|
),
|
||||||
// Not a read: the worker runs it on its own writable connection (below), so
|
// 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
|
* 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,
|
* 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
|
* 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 {keyof JOBS} job
|
||||||
* @param {object} [args] structured-cloneable
|
* @param {object} [args] structured-cloneable
|
||||||
|
|||||||
+38
-21
@@ -1,14 +1,18 @@
|
|||||||
/**
|
/**
|
||||||
* @file same-origin.js
|
* @file same-origin.js
|
||||||
* @description Loopback-Origin guard for the routes that act on the machine
|
* @description Origin guard for the routes that act on the machine rather than
|
||||||
* rather than on the dashboard's own database — spawning a process, or
|
* on the dashboard's own database — spawning a process, or writing a file into
|
||||||
* writing a file into a directory the request names.
|
* 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
|
* 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
|
* 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.
|
* 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`
|
* no `Origin` (curl, the CLI) pass — they are not the drive-by case. `Referer`
|
||||||
* is the fallback for older browsers.
|
* is the fallback for older browsers.
|
||||||
*
|
*
|
||||||
@@ -18,6 +22,8 @@
|
|||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @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"]);
|
const ALLOWED_ORIGIN_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "0.0.0.0"]);
|
||||||
|
|
||||||
function isLoopback(raw) {
|
function isLoopback(raw) {
|
||||||
@@ -28,23 +34,34 @@ function isLoopback(raw) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function sameOriginGuard(req, res, next) {
|
/** `raw`'s host:port is the request's own Host, and that Host is allowed. */
|
||||||
const origin = req.headers.origin;
|
function isOwnHost(raw, req) {
|
||||||
if (origin) {
|
const host = String(req.headers.host || "").toLowerCase();
|
||||||
if (!isLoopback(origin)) {
|
try {
|
||||||
return res.status(403).json({
|
return Boolean(host) && new URL(raw).host.toLowerCase() === host && isHostAllowed(host);
|
||||||
error: { code: "EBADORIGIN", message: "cross-origin requests are not allowed" },
|
} catch {
|
||||||
});
|
return false;
|
||||||
}
|
|
||||||
return next();
|
|
||||||
}
|
}
|
||||||
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.
|
* subquery so complex aggregate SQL (stats, analytics) needs only one extra AND.
|
||||||
*
|
*
|
||||||
* `?tool=<id>` (an AI Tools catalog id, matched against `sessions.tool`) narrows
|
* `?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
|
* `?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
|
* 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>
|
* @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
|
* Parse the `sources` query param into a de-duplicated list, or null for
|
||||||
* "all sources" (no filtering).
|
* "all sources" (no filtering).
|
||||||
@@ -85,9 +89,9 @@ function parseTool(req) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The data scope (sources and/or tool) as ONE predicate over `sessions` with
|
* The data scope (sources and/or tool, and a member's own sessions) as ONE
|
||||||
* unqualified columns, or null for "everything". Callers check `parseTool` for
|
* predicate over `sessions` with unqualified columns, or null for "everything"
|
||||||
* false first.
|
* — never null for a member. Callers check `parseTool` for false first.
|
||||||
* @param {import("express").Request} req
|
* @param {import("express").Request} req
|
||||||
* @returns {{where:string, params:string[]}|null}
|
* @returns {{where:string, params:string[]}|null}
|
||||||
*/
|
*/
|
||||||
@@ -104,6 +108,11 @@ function parseScope(req) {
|
|||||||
where.push("tool = ?");
|
where.push("tool = ?");
|
||||||
params.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;
|
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}`
|
* Build a SQL WHERE clause for the session filters `{status, tool, range,
|
||||||
* (`range` = `parseRange`'s result: sessions started in it).
|
* 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 = ? …".
|
* 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").
|
* Use `alias` to match the sessions table alias used in your query (default "s").
|
||||||
*/
|
*/
|
||||||
function statusClause(filter, alias = "s") {
|
function statusClause(filter, alias = "s") {
|
||||||
const parts = [];
|
const parts = [];
|
||||||
const params = [];
|
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") {
|
if (filter?.status && filter.status !== "all") {
|
||||||
parts.push(`${alias}.status = ?`);
|
parts.push(`${alias}.status = ?`);
|
||||||
params.push(filter.status);
|
params.push(filter.status);
|
||||||
@@ -710,7 +723,7 @@ const WORKFLOW_SECTIONS = [
|
|||||||
* The GET /api/workflows bundle, or only the named sections of it.
|
* The GET /api/workflows bundle, or only the named sections of it.
|
||||||
*
|
*
|
||||||
* @param {object} conn SQLite connection (compat-sqlite Database)
|
* @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
|
* the session filters (see `statusClause`); null = every session
|
||||||
* @param {string[]|null} sections subset of WORKFLOW_SECTIONS; null = all
|
* @param {string[]|null} sections subset of WORKFLOW_SECTIONS; null = all
|
||||||
* @returns {object}
|
* @returns {object}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const lanes = require("./openapi-extra/lanes");
|
|||||||
const push = require("./openapi-extra/push");
|
const push = require("./openapi-extra/push");
|
||||||
const aiTools = require("./openapi-extra/ai-tools");
|
const aiTools = require("./openapi-extra/ai-tools");
|
||||||
const misc = require("./openapi-extra/misc");
|
const misc = require("./openapi-extra/misc");
|
||||||
|
const auth = require("./openapi-extra/auth");
|
||||||
// Enriched overrides of already-documented endpoints — same operationId, tags,
|
// Enriched overrides of already-documented endpoints — same operationId, tags,
|
||||||
// and request/response `$ref` schemas as the base, with added examples and
|
// and request/response `$ref` schemas as the base, with added examples and
|
||||||
// richer descriptions. Listed last so they win the merge.
|
// richer descriptions. Listed last so they win the merge.
|
||||||
@@ -52,6 +53,7 @@ module.exports = combine(
|
|||||||
push,
|
push,
|
||||||
aiTools,
|
aiTools,
|
||||||
misc,
|
misc,
|
||||||
|
auth,
|
||||||
overrideSessionsAgents,
|
overrideSessionsAgents,
|
||||||
overrideCore,
|
overrideCore,
|
||||||
overridePricingAlerts,
|
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 { broadcast } = require("../websocket");
|
||||||
const { attachAgentCosts } = require("./pricing");
|
const { attachAgentCosts } = require("./pricing");
|
||||||
const { parseSources, sessionIdInSourcesClause } = require("../lib/source-filter");
|
const { parseSources, sessionIdInSourcesClause } = require("../lib/source-filter");
|
||||||
|
const { memberId, bySessionWhere } = require("../lib/scope");
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -41,12 +42,22 @@ router.get("/", (req, res) => {
|
|||||||
} else if (session_id) {
|
} else if (session_id) {
|
||||||
// A session belongs to exactly one source, so no extra source filter needed.
|
// A session belongs to exactly one source, so no extra source filter needed.
|
||||||
rows = stmts.listAgentsBySession.all(session_id);
|
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
|
// Data-scope active: build a dynamic query restricting agents to sessions
|
||||||
// from the chosen machines. `agents` carries only session_id → subquery.
|
// from the chosen machines. `agents` carries only session_id → subquery.
|
||||||
const scope = sessionIdInSourcesClause(sources, "session_id");
|
// A server-mode member gets only their own sessions' agents the same way.
|
||||||
const clauses = [scope.clause];
|
const clauses = [];
|
||||||
const params = [...scope.params];
|
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) {
|
if (status) {
|
||||||
clauses.push("status = ?");
|
clauses.push("status = ?");
|
||||||
params.push(status);
|
params.push(status);
|
||||||
@@ -85,6 +96,10 @@ router.post("/", (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const existing = stmts.getAgent.get(id);
|
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) {
|
if (existing) {
|
||||||
return res.json({ agent: existing, created: false });
|
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 toolConfig = require("../lib/ai-tool-config");
|
||||||
const { calculateCost } = require("./pricing");
|
const { calculateCost } = require("./pricing");
|
||||||
const { db, stmts } = require("../db");
|
const { db, stmts } = require("../db");
|
||||||
|
const { sessionWhere } = require("../lib/scope");
|
||||||
|
|
||||||
const router = express.Router();
|
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) => {
|
router.get("/:id/usage", (req, res) => {
|
||||||
if (!aiTools.has(req.params.id)) {
|
if (!aiTools.has(req.params.id)) {
|
||||||
return res.status(404).json({ error: { code: "ENOTOOL", message: "unknown AI tool" } });
|
return res.status(404).json({ error: { code: "ENOTOOL", message: "unknown AI tool" } });
|
||||||
}
|
}
|
||||||
const id = req.params.id;
|
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
|
const rows = db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT tu.model AS model, tu.speed AS speed, tu.inference_geo AS inference_geo,
|
`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_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
|
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
|
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`
|
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 });
|
res.json({ sessions: n, cost: calculateCost(rows, stmts.listPricing.all()).total_cost });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+39
-3
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
const { Router } = require("express");
|
const { Router } = require("express");
|
||||||
const { v4: uuidv4 } = require("uuid");
|
const { v4: uuidv4 } = require("uuid");
|
||||||
const { stmts } = require("../db");
|
const { stmts, db } = require("../db");
|
||||||
const { broadcast } = require("../websocket");
|
const { broadcast } = require("../websocket");
|
||||||
const {
|
const {
|
||||||
RULE_TYPES,
|
RULE_TYPES,
|
||||||
@@ -15,6 +15,7 @@ const {
|
|||||||
invalidateRuleCache,
|
invalidateRuleCache,
|
||||||
previewRule,
|
previewRule,
|
||||||
} = require("../lib/alerts");
|
} = require("../lib/alerts");
|
||||||
|
const { memberId, bySessionWhere } = require("../lib/scope");
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -150,6 +151,31 @@ router.get("/", (req, res) => {
|
|||||||
const offset = Math.max(0, parseInt(req.query.offset, 10) || 0);
|
const offset = Math.max(0, parseInt(req.query.offset, 10) || 0);
|
||||||
const unackedOnly = req.query.unacked === "true";
|
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
|
const alerts = unackedOnly
|
||||||
? stmts.listUnackedAlertEvents.all(limit, offset)
|
? stmts.listUnackedAlertEvents.all(limit, offset)
|
||||||
: stmts.listAlertEvents.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
|
// POST /api/alerts/ack-all - Acknowledge every unacked alert
|
||||||
router.post("/ack-all", (_req, res) => {
|
router.post("/ack-all", (req, res) => {
|
||||||
const info = stmts.ackAllAlertEvents.run();
|
// 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 });
|
if (info.changes > 0) broadcast("alert_updated", { acked_all: true });
|
||||||
res.json({ ok: true, acknowledged: info.changes });
|
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
|
* `budget` (created, edited and deleted through /api/alerts/rules); this router
|
||||||
* only reads: `GET /api/budgets/status` lists every enabled budget with its
|
* 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
|
* 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>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -13,8 +14,8 @@ const { budgetStatus } = require("../lib/budgets");
|
|||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
// GET /api/budgets/status
|
// GET /api/budgets/status
|
||||||
router.get("/status", (_req, res) => {
|
router.get("/status", (req, res) => {
|
||||||
res.json({ budgets: budgetStatus() });
|
res.json({ budgets: budgetStatus({ req }) });
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
+17
-5
@@ -10,6 +10,7 @@
|
|||||||
const { Router } = require("express");
|
const { Router } = require("express");
|
||||||
const { db } = require("../db");
|
const { db } = require("../db");
|
||||||
const { parseSources, sessionIdInSourcesClause } = require("../lib/source-filter");
|
const { parseSources, sessionIdInSourcesClause } = require("../lib/source-filter");
|
||||||
|
const { memberId, bySessionWhere } = require("../lib/scope");
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -78,6 +79,12 @@ function buildWhere(filters) {
|
|||||||
params.push(...scope.params);
|
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 {
|
return {
|
||||||
sql: clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "",
|
sql: clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "",
|
||||||
params,
|
params,
|
||||||
@@ -98,6 +105,7 @@ router.get("/", (req, res) => {
|
|||||||
from: parseDate(req.query.from),
|
from: parseDate(req.query.from),
|
||||||
to: parseDate(req.query.to),
|
to: parseDate(req.query.to),
|
||||||
sources: parseSources(req),
|
sources: parseSources(req),
|
||||||
|
own: memberId(req) != null ? bySessionWhere(req) : null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const { sql: whereSql, params: whereParams } = buildWhere(filters);
|
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.
|
// 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
|
const eventTypes = db
|
||||||
.prepare(
|
.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);
|
.map((r) => r.event_type);
|
||||||
|
|
||||||
const toolNames = db
|
const toolNames = db
|
||||||
.prepare("SELECT DISTINCT tool_name FROM events WHERE tool_name IS NOT NULL ORDER BY tool_name")
|
.prepare(
|
||||||
.all()
|
`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);
|
.map((r) => r.tool_name);
|
||||||
|
|
||||||
res.json({ event_types: eventTypes, tool_names: toolNames });
|
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 tmux = require("../lib/tmux");
|
||||||
const { resumeProxy } = require("../lib/proxy-apply");
|
const { resumeProxy } = require("../lib/proxy-apply");
|
||||||
const { sameOriginGuard } = require("./run");
|
const { sameOriginGuard } = require("./run");
|
||||||
|
const { isLocalRequest } = require("../lib/same-origin");
|
||||||
|
const scope = require("../lib/scope");
|
||||||
const { writeLaneFile } = require("../lib/lane-file-write");
|
const { writeLaneFile } = require("../lib/lane-file-write");
|
||||||
const { pickPath } = require("../lib/native-dialog");
|
const { pickPath } = require("../lib/native-dialog");
|
||||||
const { preflight } = require("../lib/lane-preflight");
|
const { preflight } = require("../lib/lane-preflight");
|
||||||
@@ -200,8 +202,10 @@ function broadcastLane(id) {
|
|||||||
if (lane) broadcast("lane_update", { lane: payload(lane) });
|
if (lane) broadcast("lane_update", { lane: payload(lane) });
|
||||||
}
|
}
|
||||||
|
|
||||||
router.get("/", (_req, res) => {
|
router.get("/", (req, res) => {
|
||||||
const rows = lanesLib.listLanes();
|
// 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
|
// 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).
|
// 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;
|
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 =
|
const events =
|
||||||
laneId === null
|
laneId === null
|
||||||
? db
|
? db
|
||||||
.prepare(
|
.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
|
: db
|
||||||
.prepare(
|
.prepare(
|
||||||
"SELECT lane_id, pipeline, run_seq, node_id, source, result, evidence FROM lane_stage_events WHERE lane_id = ? ORDER BY id"
|
"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.
|
// with zero runs must appear, at zero, rather than vanish.
|
||||||
const scopedLanes =
|
const scopedLanes =
|
||||||
laneId === null
|
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);
|
: db.prepare("SELECT pipeline FROM lanes WHERE id = ?").all(laneId);
|
||||||
for (const row of scopedLanes) entryFor(row.pipeline);
|
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.
|
// the reader most likely came to look at.
|
||||||
.sort((a, b) => b.runs - a.runs);
|
.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 });
|
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
|
* 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
|
* 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)) {
|
if (body.tool !== undefined && !aiTools.has(body.tool)) {
|
||||||
return res.status(400).json({ error: { code: "EBADTOOL", message: "unknown 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
|
// 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
|
// 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 found = lanesLib.resolveLaneByCwd(cwd, body.tool || "claude");
|
||||||
const strip = (p) => p.replace(/[\\/]+$/, "");
|
const strip = (p) => p.replace(/[\\/]+$/, "");
|
||||||
const owner = found && (body.exact !== true || strip(found.cwd) === strip(cwd)) ? found : null;
|
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) {
|
if (owner) {
|
||||||
// First-write wins on the title: an ensure-with-title on an existing lane
|
// 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
|
// 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 {
|
try {
|
||||||
const lane = lanesLib.createLane({
|
const lane = lanesLib.createLane({
|
||||||
cwd,
|
cwd,
|
||||||
|
owner_id: scope.ownerFor(req),
|
||||||
title: body.title || "",
|
title: body.title || "",
|
||||||
...(body.pipeline ? { pipeline: body.pipeline } : {}),
|
...(body.pipeline ? { pipeline: body.pipeline } : {}),
|
||||||
...(body.tool ? { tool: body.tool } : {}),
|
...(body.tool ? { tool: body.tool } : {}),
|
||||||
@@ -463,6 +500,12 @@ router.post("/ensure", sameOriginGuard, (req, res) => {
|
|||||||
* "oversized" mean.
|
* "oversized" mean.
|
||||||
*/
|
*/
|
||||||
router.post("/gc", sameOriginGuard, (req, res) => {
|
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;
|
const dryRun = req.body?.dryRun === true;
|
||||||
try {
|
try {
|
||||||
const reaped = reapOrphanMcp({ dryRun });
|
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.
|
* apart — the dialog is closed when the browser's request goes away.
|
||||||
*/
|
*/
|
||||||
router.post("/pick-path", sameOriginGuard, async (req, res) => {
|
router.post("/pick-path", sameOriginGuard, async (req, res) => {
|
||||||
const addr = req.socket.remoteAddress || "";
|
if (!isLocalRequest(req)) {
|
||||||
const proxied = req.headers["x-forwarded-for"] || req.headers.forwarded;
|
|
||||||
if (proxied || !["127.0.0.1", "::1", "::ffff:127.0.0.1"].includes(addr)) {
|
|
||||||
return res.status(403).json({
|
return res.status(403).json({
|
||||||
error: { code: "EREMOTE", message: "the native picker opens only for a local browser" },
|
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) => {
|
router.post("/", sameOriginGuard, (req, res) => {
|
||||||
try {
|
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);
|
broadcastLane(lane.id);
|
||||||
res.status(201).json({ lane: payload(lane) });
|
res.status(201).json({ lane: payload(lane) });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -584,9 +629,21 @@ router.patch("/:id", sameOriginGuard, (req, res) => {
|
|||||||
if (!lanesLib.getLane(req.params.id)) {
|
if (!lanesLib.getLane(req.params.id)) {
|
||||||
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
|
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;
|
let lane;
|
||||||
try {
|
try {
|
||||||
lane = lanesLib.updateLane(req.params.id, req.body || {});
|
lane = lanesLib.updateLane(req.params.id, body);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// A bad `kind`, `pipeline` or field value is invalid input, not a server
|
// 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
|
// 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
|
// 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
|
// 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,
|
cwd: dir,
|
||||||
branch,
|
branch,
|
||||||
kind: "managed",
|
kind: "managed",
|
||||||
|
owner_id: scope.ownerFor(req),
|
||||||
source_repo: resolvedSourceRepo,
|
source_repo: resolvedSourceRepo,
|
||||||
base_branch: body.base || null,
|
base_branch: body.base || null,
|
||||||
slug,
|
slug,
|
||||||
@@ -1421,7 +1481,10 @@ function laneRunIds(lane) {
|
|||||||
.filter((r) => r.laneId === lane.id)
|
.filter((r) => r.laneId === lane.id)
|
||||||
.map((r) => r.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];
|
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